Simplify Threads captions and capture five minutes after sunrise

This commit is contained in:
2026-11-02 04:14:11 +09:00
parent 6ce32d33fe
commit 39374cea05
8 changed files with 37 additions and 22 deletions
+8 -8
View File
@@ -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": ["정보 없음"]
}
}
+7 -7
View File
@@ -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`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요.
+1
View File
@@ -50,6 +50,7 @@ export function renderCaption(config: Captions, capture: {
const date = new Date(capture.capturedAt);
const values: Record<string, string> = {
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) => {
+5 -1
View File
@@ -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<string, string>, method = 'GET', request: typeof fetch = fetch) {
+8 -6
View File
@@ -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<typeof dailySchedule>, 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');
+1
View File
@@ -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,
+6
View File
@@ -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');
+1
View File
@@ -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 () => {