diff --git a/.threads-captions.sample.json b/.threads-captions.sample.json index 99e33e5..9c528ca 100644 --- a/.threads-captions.sample.json +++ b/.threads-captions.sample.json @@ -1,14 +1,14 @@ { - "template": ["{message}", "", "{url}"], + "template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}", "{hostname}"], "testPrefix": "[테스트] ", "thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 }, "messages": { - "clear": ["오늘은 하늘이 맑아. 여기서 같이 해 기다릴래? 🌅"], - "partlyCloudy": ["구름 사이로 아침이 오는 중이야. 조금만 더 기다려 볼까? 🐑"], - "cloudy": ["오늘은 구름이 잔뜩이네. 해가 숨어 있어도 난 여기 있어 ☁️"], - "fog": ["오늘은 멀리까지 잘 안 보여. 천천히 하늘을 기다리는 중이야 🐑"], - "rain": ["비 오는 관악산에서 해를 기다렸어. 오늘은 빗소리도 같이 듣자 ☔"], - "snow": ["눈 오는 관악산에 왔어. 오늘도 여기서 해를 기다릴게 ❄️"], - "unknown": ["오늘도 관악산에 자리 잡았어. 같이 하늘 보고 갈래? 🐑"] + "clear": ["맑음"], + "partlyCloudy": ["구름 조금"], + "cloudy": ["흐림"], + "fog": ["낮은 시정"], + "rain": ["비"], + "snow": ["눈"], + "unknown": ["정보 없음"] } } diff --git a/docs/threads-automation.md b/docs/threads-automation.md index 26bc5ef..eb7c651 100644 --- a/docs/threads-automation.md +++ b/docs/threads-automation.md @@ -2,13 +2,13 @@ ## 동작 -PM2가 상시 실행하는 워커가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각까지 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 게시 문구는 별도 JSON 파일에서 수정하며, 촬영에 사용된 날씨에 따라 선택합니다. 날씨는 페이지의 기존 기상자료를 사용합니다. +PM2가 상시 실행하는 워커가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 일출 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각에서 5분 더 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 게시 문구는 별도 JSON 파일에서 수정하며, 촬영에 사용된 날씨에 따라 선택합니다. 날씨는 페이지의 기존 기상자료를 사용합니다. 사진 순서는 ① 일출 방향을 바라보는 캐릭터 뒷모습과 풍경 ② 캐릭터 얼굴과 하늘입니다. 캐릭터는 일출을 향한 자세를 유지하고 두 번째 카메라만 방위각 +200°, 위쪽 20°로 회전합니다. 기존 3인칭 카메라의 지형 충돌 보정을 사용합니다. 구도와 해상도는 `src/constants/threads.ts`의 `angles`, `viewport`에서 조정합니다. 두 사진 모두 준비된 뒤 캐러셀을 만들며, 한 장이라도 실패하면 일부만 게시하지 않습니다. 이전 단일 사진 버전의 `.capture.json` 기록은 재사용하지 않습니다. 해당 기록이 남아 있다면 보관 위치로 옮긴 뒤 다음 일출부터 실행하세요. 이미 게시했거나 게시 결과가 불명확한 날짜의 `.json` 게시 기록은 유지해야 중복을 방지할 수 있습니다. -SunCalc의 일출은 관측 고도를 반영한 천문학적 일출입니다. 주변 산 능선 뒤에서 태양이 실제로 드러나는 시각과 다를 수 있습니다. 서버 시계 동기화(NTP)를 켜 두세요. 캡처 후 업로드·Meta 처리 때문에 게시 시각은 일출보다 늦습니다. +SunCalc의 일출은 관측 고도를 반영한 천문학적 일출입니다. 주변 산 능선 뒤에서 태양이 실제로 드러나는 시각과 다를 수 있습니다. 서버 시계 동기화(NTP)를 켜 두세요. 문구에는 계산된 일출 시각을 표시하고 촬영은 그로부터 5분 뒤에 합니다(예: 일출 06:53 → 촬영 06:58). 캡처 후 업로드·Meta 처리 시간이 추가됩니다. 촬영 지연은 src/constants/threads.ts의 captureDelayMs에서 조정합니다. 즉시 테스트 게시와 preview는 이 지연 없이 바로 촬영합니다. 변경된 프런트엔드를 먼저 배포해야 합니다. 작업은 `?mode=solo&site=gwanaksan&capture=threads`로 열며, 해당 모드에서만 브라우저 내 촬영 인터페이스가 노출됩니다. 인증 토큰은 브라우저로 전달하지 않습니다. @@ -75,7 +75,7 @@ npm run threads:post-now ## 5. 문구 수정과 날씨 조건 -프로젝트 루트에서 샘플을 복사합니다. 실제 문구 파일은 Git에서 제외되므로 배포로 덮어쓰지 않습니다. 파일이 없으면 샘플을 사용합니다. +프로젝트 루트에서 샘플을 복사합니다. 실제 문구 파일은 Git에서 제외되므로 배포로 덮어쓰지 않습니다. 파일이 없으면 샘플을 사용합니다. 기존 `.threads-captions.json`은 자동으로 덮어쓰지 않습니다. 새 기본 문구로 바꾸려면 기존 파일을 백업한 뒤 `.threads-captions.sample.json` 내용으로 교체하세요. ```bash cp -n .threads-captions.sample.json .threads-captions.json @@ -84,9 +84,9 @@ npm run threads:captions ``` - `messages`: 날씨별 문구입니다. 배열의 각 항목은 한 줄이며, 여러 줄을 쓰면 모두 순서대로 표시됩니다(무작위 선택 아님). -- `template`: 전체 게시글의 줄 구성입니다. 기본은 문구, 빈 줄, 사이트 링크입니다. +- `template`: 전체 게시글의 줄 구성입니다. 기본은 “오늘의 관악산 일출이에요”, 날짜·일출 시각, 날씨, 사이트 주소의 네 줄입니다. - `testPrefix`: 즉시 테스트 게시 앞에 붙일 표시입니다. -- 치환값: `{message}`, `{date}`(한국 날짜), `{time}`(촬영 HH:mm), `{sunrise}`(일출 HH:mm), `{url}`. +- 치환값: `{message}`, `{date}`(한국 날짜 YYYY-MM-DD), `{dateDots}`(YYYY.MM.DD), `{time}`(촬영 HH:mm), `{sunrise}`(일출 HH:mm), `{url}`(전체 URL), `{hostname}`(도메인만). - `thresholds`: 낮은 시정 기준(m), 흐림·구름 조금 운량 기준(%)입니다. 날씨 우선순위는 눈 → 비 → 낮은 시정 → 흐림 → 구름 조금 → 맑음입니다. 기본 시정 기준은 1000m 미만, 흐림은 운량 80% 이상, 구름 조금은 30% 이상입니다. 강수 정보가 없거나 날씨 로딩/조회가 실패하면 `unknown`을 사용합니다. 낮은 시정은 안개로 단정하지 않도록 기본 문구에 표현했습니다. 웹페이지에 적용된 기상 데이터를 캡처 기록에 함께 저장하며, 재시도도 그 사진의 날씨를 사용합니다. @@ -112,7 +112,7 @@ pm2 status pm2 logs sunrise-threads --lines 100 ``` -설정은 프로젝트 위치를 자동으로 계산하고 워커를 단일 fork 프로세스로 실행합니다. 기존 웹 서버 PM2 프로세스는 건드리지 않습니다. 날짜 계산은 서버 시간대와 무관하게 Asia/Seoul 기준이며, 03:00 이후 새로 시작하면 오늘 작업을 바로 확인합니다. 이미 게시했다면 중복 게시하지 않고, 일출을 놓쳤고 저장된 사진도 없다면 다음 날짜를 기다립니다. 실행 중에는 실제 촬영 5분 전에만 Chromium을 엽니다. +설정은 프로젝트 위치를 자동으로 계산하고 워커를 단일 fork 프로세스로 실행합니다. 기존 웹 서버 PM2 프로세스는 건드리지 않습니다. 날짜 계산은 서버 시간대와 무관하게 Asia/Seoul 기준이며, 03:00 이후 새로 시작하면 오늘 작업을 바로 확인합니다. 이미 게시했다면 중복 게시하지 않고, 일출+5분 촬영 시간을 놓쳤고 저장된 사진도 없다면 다음 날짜를 기다립니다. 실행 중에는 일출 5분 전에 Chromium을 열고 일출 5분 뒤 촬영합니다. ```bash pm2 restart sunrise-threads # 코드/실행 설정 변경 후 @@ -132,6 +132,6 @@ pm2 save # 변경된 목록 저장 `publishing` 상태가 남았다면 요청이 성공했지만 응답이 유실됐을 수 있습니다. 이때 자동으로 재게시하지 않습니다. 계정과 기록의 컨테이너 ID를 확인한 뒤 수동으로 처리하세요. 기존 기록을 무조건 지우면 중복 게시될 수 있습니다. -촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 촬영 자체를 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다. +촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 일출+5분의 촬영 시간을 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다. 정지: `pm2 stop sunrise-threads`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요. diff --git a/server/threads/captions.ts b/server/threads/captions.ts index cccfe54..a23cad5 100644 --- a/server/threads/captions.ts +++ b/server/threads/captions.ts @@ -50,6 +50,7 @@ export function renderCaption(config: Captions, capture: { const date = new Date(capture.capturedAt); const values: Record = { message: config.messages[condition].join('\n'), date: kstFields(date).date, + dateDots: kstFields(date).date.replaceAll('-', '.'), hostname: new URL(capture.url).hostname, time: kstClock(date).slice(0, 5), sunrise: kstClock(new Date(capture.sunrise)).slice(0, 5), url: capture.url, }; const text = (capture.test ? config.testPrefix : '') + config.template.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => { diff --git a/server/threads/publisher.ts b/server/threads/publisher.ts index 4717fc6..65c119e 100644 --- a/server/threads/publisher.ts +++ b/server/threads/publisher.ts @@ -7,7 +7,11 @@ import { THREADS } from '../../src/constants/threads.ts'; export function dailySchedule(now: Date) { const sunrise = calculateSun(now, observer).sunrise; if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise for this date'); - return { date: kstFields(now).date, sunrise, prepareAt: new Date(+sunrise - THREADS.prepareMs) }; + return { date: kstFields(now).date, sunrise, captureAt: sunriseCaptureTime(sunrise), prepareAt: new Date(+sunrise - THREADS.prepareMs) }; +} + +export function sunriseCaptureTime(sunrise: Date): Date { + return new Date(+sunrise + THREADS.captureDelayMs); } export async function threadsRequest(token: string, path: string, params: Record, method = 'GET', request: typeof fetch = fetch) { diff --git a/server/threads/run.ts b/server/threads/run.ts index 1beb43e..ec0631e 100644 --- a/server/threads/run.ts +++ b/server/threads/run.ts @@ -5,7 +5,7 @@ import { setTimeout as delay } from 'node:timers/promises'; import { chromium } from 'playwright'; import { THREADS } from '../../src/constants/threads.ts'; import { kstClock, kstFields } from '../../src/ui/time.ts'; -import { dailySchedule, publishCarousel, threadsRequest } from './publisher.ts'; +import { dailySchedule, publishCarousel, threadsRequest, sunriseCaptureTime } from './publisher.ts'; import { loadThreadsConfig } from './config.ts'; import { cleanupPublished } from './cleanup.ts'; import { loadCaptions, renderCaption } from './captions.ts'; @@ -79,8 +79,8 @@ async function main() { const immediate = postNow || (dryRun && args.includes('--now')); const capturePath = join(stateDir, `${runId}.capture.json`); const savedCapture = dryRun ? null : await readJson(capturePath); - if (!savedCapture && !immediate && Date.now() > +schedule.sunrise + THREADS.maxLatenessMs) { - console.log(`${schedule.date}: sunrise has passed; skipping late capture`); + if (!savedCapture && !immediate && Date.now() > +schedule.captureAt + THREADS.maxLatenessMs) { + console.log(`${schedule.date}: sunrise +5 minute capture window has passed; skipping late capture`); return; } const captured = savedCapture ?? await captureSunrise(schedule, immediate); @@ -142,14 +142,16 @@ async function captureSunrise(schedule: ReturnType, immedi sunrise = new Date(await page.evaluate(() => window.sunriseThreads!.sunrise())); if (!Number.isFinite(+sunrise) || kstFields(sunrise).date !== schedule.date) throw new Error('Unexpected page sunrise date'); console.log(`Page sunrise: ${sunrise.toISOString()} (${kstClock(sunrise)} KST)`); + const captureAt = sunriseCaptureTime(sunrise); + console.log(`Capture target: ${captureAt.toISOString()} (${kstClock(captureAt)} KST)`); if (!immediate) { - if (Date.now() > +sunrise + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline'); - await waitUntil(sunrise); + if (Date.now() > +captureAt + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline'); + await waitUntil(captureAt); } const capture = await page.evaluate(() => window.sunriseThreads!.capture()); capturedAt = capture.capturedAt; weather = capture.weather ?? null; - if (!immediate && Math.abs(Date.parse(capturedAt) - +sunrise) > THREADS.maxLatenessMs) throw new Error('Capture missed sunrise deadline'); + if (!immediate && Math.abs(Date.parse(capturedAt) - +captureAt) > THREADS.maxLatenessMs) throw new Error('Capture missed sunrise +5 minute deadline'); if (capture.images?.length !== THREADS.angles.length) throw new Error('Deploy the two-angle capture frontend first'); for (const [index, shot] of capture.images.entries()) { if (shot.name !== THREADS.angles[index].name) throw new Error('Unexpected capture angle order'); diff --git a/src/constants/threads.ts b/src/constants/threads.ts index 0ec3b23..0785081 100644 --- a/src/constants/threads.ts +++ b/src/constants/threads.ts @@ -2,6 +2,7 @@ export const THREADS = { siteUrl: 'https://sunrise.horoli.kr', apiUrl: 'https://graph.threads.net', prepareMs: 5 * 60_000, + captureDelayMs: 5 * 60_000, maxLatenessMs: 30_000, requestTimeoutMs: 30_000, mediaPollMs: 5_000, diff --git a/tests/threads-captions.test.ts b/tests/threads-captions.test.ts index 3649105..7875f52 100644 --- a/tests/threads-captions.test.ts +++ b/tests/threads-captions.test.ts @@ -3,6 +3,12 @@ import assert from 'node:assert/strict'; import { loadCaptions, weatherKey, renderCaption } from '../server/threads/captions.ts'; const sample = { cloudCover: 10, visibility: 10000, rain: 0, snowfall: 0 }; +test('default post shows sunrise time rather than the five-minute-later capture time', () => { + const config = loadCaptions(new URL('../.threads-captions.sample.json', import.meta.url)); + const result = renderCaption(config, { weather: { ...sample, cloudCover: 90 }, capturedAt: '2026-11-01T21:58:00Z', + sunrise: '2026-11-01T21:53:00Z', url: 'https://sunrise.horoli.kr', test: false }); + assert.equal(result.text, '오늘의 관악산 일출이에요\n2026.11.02 · 일출 06:53\n날씨: 흐림\nsunrise.horoli.kr'); +}); test('caption weather uses precipitation first and does not assume missing data means clear', () => { const config = loadCaptions(); assert.equal(weatherKey({ ...sample, rain: 1, snowfall: 1 }, config), 'snow'); diff --git a/tests/threads.test.ts b/tests/threads.test.ts index 2b7b152..f2f9f01 100644 --- a/tests/threads.test.ts +++ b/tests/threads.test.ts @@ -10,6 +10,7 @@ test('schedule uses the Korean date and existing SunCalc sunrise', () => { assert.equal(schedule.date, '2026-11-02'); assert.equal(schedule.sunrise.getTime(), calculateSun(now, observer).sunrise!.getTime()); assert.equal(+schedule.sunrise - +schedule.prepareAt, 300_000); + assert.equal(+schedule.captureAt - +schedule.sunrise, 300_000); }); test('publishing waits for ready media and persists intent before sending', async () => {