From 6ce32d33fe7ba96f837a32a92b50e7fef52118e9 Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 2 Nov 2026 04:07:40 +0900 Subject: [PATCH] Add editable weather captions and manage Threads scheduling with PM2 --- .gitignore | 1 + .threads-captions.sample.json | 14 +++++++ deploy/ecosystem.config.cjs | 19 ++++++++++ deploy/sunrise-threads.service | 16 -------- deploy/sunrise-threads.timer | 11 ------ docs/threads-automation.md | 63 +++++++++++++++++++++++++------ package.json | 2 + server/threads/captions.ts | 61 ++++++++++++++++++++++++++++++ server/threads/previewCaptions.ts | 18 +++++++++ server/threads/run.ts | 26 +++++++++---- server/threads/schedule.ts | 12 ++++++ server/threads/worker.ts | 53 ++++++++++++++++++++++++++ src/constants/threads.ts | 7 ++++ src/main.ts | 4 +- src/ui/threadsCapture.ts | 10 +++-- tests/threads-captions.test.ts | 28 ++++++++++++++ tests/threads-schedule.test.ts | 10 +++++ 17 files changed, 305 insertions(+), 50 deletions(-) create mode 100644 .threads-captions.sample.json create mode 100644 deploy/ecosystem.config.cjs delete mode 100644 deploy/sunrise-threads.service delete mode 100644 deploy/sunrise-threads.timer create mode 100644 server/threads/captions.ts create mode 100644 server/threads/previewCaptions.ts create mode 100644 server/threads/schedule.ts create mode 100644 server/threads/worker.ts create mode 100644 tests/threads-captions.test.ts create mode 100644 tests/threads-schedule.test.ts diff --git a/.gitignore b/.gitignore index b3b85e9..f036d39 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ __pycache__/ !/.env.threads.sample.json /.config.json /data/threads/ +/.threads-captions.json diff --git a/.threads-captions.sample.json b/.threads-captions.sample.json new file mode 100644 index 0000000..99e33e5 --- /dev/null +++ b/.threads-captions.sample.json @@ -0,0 +1,14 @@ +{ + "template": ["{message}", "", "{url}"], + "testPrefix": "[테스트] ", + "thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 }, + "messages": { + "clear": ["오늘은 하늘이 맑아. 여기서 같이 해 기다릴래? 🌅"], + "partlyCloudy": ["구름 사이로 아침이 오는 중이야. 조금만 더 기다려 볼까? 🐑"], + "cloudy": ["오늘은 구름이 잔뜩이네. 해가 숨어 있어도 난 여기 있어 ☁️"], + "fog": ["오늘은 멀리까지 잘 안 보여. 천천히 하늘을 기다리는 중이야 🐑"], + "rain": ["비 오는 관악산에서 해를 기다렸어. 오늘은 빗소리도 같이 듣자 ☔"], + "snow": ["눈 오는 관악산에 왔어. 오늘도 여기서 해를 기다릴게 ❄️"], + "unknown": ["오늘도 관악산에 자리 잡았어. 같이 하늘 보고 갈래? 🐑"] + } +} diff --git a/deploy/ecosystem.config.cjs b/deploy/ecosystem.config.cjs new file mode 100644 index 0000000..405a8ab --- /dev/null +++ b/deploy/ecosystem.config.cjs @@ -0,0 +1,19 @@ +const path = require('node:path'); + +module.exports = { + apps: [{ + name: 'sunrise-threads', + cwd: path.resolve(__dirname, '..'), + script: process.execPath, + args: ['server/threads/worker.ts'], + interpreter: 'none', + instances: 1, + exec_mode: 'fork', + autorestart: true, + restart_delay: 60000, + kill_timeout: 15000, + watch: false, + time: true, + env: { NODE_ENV: 'production', TZ: 'Asia/Seoul' }, + }], +}; diff --git a/deploy/sunrise-threads.service b/deploy/sunrise-threads.service deleted file mode 100644 index ae2381b..0000000 --- a/deploy/sunrise-threads.service +++ /dev/null @@ -1,16 +0,0 @@ -[Unit] -Description=Capture sunrise and publish to Threads -Wants=network-online.target -After=network-online.target -StartLimitIntervalSec=1h -StartLimitBurst=5 - -[Service] -Type=oneshot -User=sunrise -WorkingDirectory=/opt/sunrise -ExecStart=/usr/bin/node /opt/sunrise/server/threads/run.ts -TimeoutStartSec=8h -Restart=on-failure -RestartSec=60s -UMask=0022 diff --git a/deploy/sunrise-threads.timer b/deploy/sunrise-threads.timer deleted file mode 100644 index 0cf2d49..0000000 --- a/deploy/sunrise-threads.timer +++ /dev/null @@ -1,11 +0,0 @@ -[Unit] -Description=Prepare daily sunrise capture at 03:00 Korea time - -[Timer] -OnCalendar=*-*-* 03:00:00 Asia/Seoul -Persistent=true -AccuracySec=1s -Unit=sunrise-threads.service - -[Install] -WantedBy=timers.target diff --git a/docs/threads-automation.md b/docs/threads-automation.md index e9e2502..26bc5ef 100644 --- a/docs/threads-automation.md +++ b/docs/threads-automation.md @@ -2,7 +2,7 @@ ## 동작 -Linux systemd 타이머가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각까지 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 사진은 실제 풍경이 아니라 시뮬레이션임을 게시 문구에 표시합니다. 날씨는 페이지의 기존 기상자료를 사용합니다. +PM2가 상시 실행하는 워커가 매일 **한국 시간 03:00**에 작업을 시작합니다. 기존 `calculateSun` / SunCalc 2.x로 당일 관악산 일출을 계산하고, 5분 전에 Chromium을 실행합니다. 배포 페이지의 실제 관측 위치·고도로 계산한 일출 시각까지 기다린 뒤, UI 없는 **1080×1728 세로 JPEG 두 장**을 같은 시각의 장면에서 촬영하고 **한 개의 Threads 캐러셀 게시물**로 올립니다. 게시 문구는 별도 JSON 파일에서 수정하며, 촬영에 사용된 날씨에 따라 선택합니다. 날씨는 페이지의 기존 기상자료를 사용합니다. 사진 순서는 ① 일출 방향을 바라보는 캐릭터 뒷모습과 풍경 ② 캐릭터 얼굴과 하늘입니다. 캐릭터는 일출을 향한 자세를 유지하고 두 번째 카메라만 방위각 +200°, 위쪽 20°로 회전합니다. 기존 3인칭 카메라의 지형 충돌 보정을 사용합니다. 구도와 해상도는 `src/constants/threads.ts`의 `angles`, `viewport`에서 조정합니다. 두 사진 모두 준비된 뒤 캐러셀을 만들며, 한 장이라도 실패하면 일부만 게시하지 않습니다. @@ -63,7 +63,7 @@ npm run threads:run -- --dry-run npm run threads:post-now ``` -이 명령은 **일출까지 기다리지 않고 현재 시각의 두 앵글을 실제 Threads 계정에 게시**합니다. 게시 문구에는 테스트 게시임과 실제 촬영 시각을 표시합니다. 실행할 때마다 새 테스트 게시물이 생성되며, `test-날짜-UUID.json` 기록을 사용하므로 당일 일출 예약 게시를 막지 않습니다. 예약 서비스와 동일한 일반 사용자로 실행하세요. root에서는 Chromium sandbox가 동작하지 않습니다. +이 명령은 **일출까지 기다리지 않고 현재 시각의 두 앵글을 실제 Threads 계정에 게시**합니다. 게시 문구에는 설정된 testPrefix(기본 [테스트])를 붙입니다. 촬영 시각은 템플릿에 {time}을 넣으면 표시됩니다. 실행할 때마다 새 테스트 게시물이 생성되며, `test-날짜-UUID.json` 기록을 사용하므로 당일 일출 예약 게시를 막지 않습니다. 예약 서비스와 동일한 일반 사용자로 실행하세요. root에서는 Chromium sandbox가 동작하지 않습니다. 게시 ID를 받은 즉시 두 원본 JPEG를 삭제합니다. `Threads published: ...` 다음 `Deleted both source images after publication` 로그를 확인하세요. 삭제 실패 시 게시 기록은 유지하고 다음 게시 작업 시작 때 삭제를 재시도합니다. 게시 실패 또는 결과 불명확 상태에서는 이미지를 보관하며, 촬영 전용 preview 파일도 확인용으로 유지합니다. 서버 원본 삭제는 Threads에 게시된 사진을 삭제하는 작업이 아닙니다. 별도 CDN을 사용한다면 캐시 사본은 CDN의 만료 정책을 따릅니다. @@ -73,19 +73,58 @@ npm run threads:post-now `npm run build`는 dist를 다시 생성합니다. 운영에서는 **배포와 독립적인 이미지 디렉터리**를 권장합니다. 예를 들어 `"imageDir": "/var/lib/sunrise/captures"`로 지정하고 Nginx에서 `/threads-captures/`를 해당 디렉터리에 연결하세요. Nginx 사용자에게 디렉터리 탐색·파일 읽기 권한을 부여하세요. `imageBaseUrl`도 실제 URL과 일치해야 합니다. 이미지에는 토큰이 포함되지 않습니다. 예약 게시와 테스트 게시 모두 성공 후 원본 이미지를 즉시 삭제합니다. -## 5. 자동 실행 활성화 +## 5. 문구 수정과 날씨 조건 -`deploy/sunrise-threads.service`의 사용자, 작업 디렉터리, Node 경로를 수정한 뒤: +프로젝트 루트에서 샘플을 복사합니다. 실제 문구 파일은 Git에서 제외되므로 배포로 덮어쓰지 않습니다. 파일이 없으면 샘플을 사용합니다. ```bash -sudo cp deploy/sunrise-threads.service deploy/sunrise-threads.timer /etc/systemd/system/ -sudo systemctl daemon-reload -sudo systemctl enable --now sunrise-threads.timer -systemctl list-timers sunrise-threads.timer -journalctl -u sunrise-threads.service -n 100 +cp -n .threads-captions.sample.json .threads-captions.json +nano .threads-captions.json +npm run threads:captions ``` -타이머는 03:00에 준비 작업을 시작하고 프로세스가 계산된 일출까지 기다립니다. 서버의 기본 시간대와 관계없이 `Asia/Seoul` 기준입니다. 일출을 놓친 뒤 서버가 켜지면 그날 촬영은 건너뜁니다. 촬영 허용 지연은 최대 30초이며, 로딩 지연이나 절전 등으로 이를 초과하면 게시하지 않습니다. 장애 시 journal에 오류가 남습니다. 별도 알림 채널은 연결하지 않았습니다. +- `messages`: 날씨별 문구입니다. 배열의 각 항목은 한 줄이며, 여러 줄을 쓰면 모두 순서대로 표시됩니다(무작위 선택 아님). +- `template`: 전체 게시글의 줄 구성입니다. 기본은 문구, 빈 줄, 사이트 링크입니다. +- `testPrefix`: 즉시 테스트 게시 앞에 붙일 표시입니다. +- 치환값: `{message}`, `{date}`(한국 날짜), `{time}`(촬영 HH:mm), `{sunrise}`(일출 HH:mm), `{url}`. +- `thresholds`: 낮은 시정 기준(m), 흐림·구름 조금 운량 기준(%)입니다. + +날씨 우선순위는 눈 → 비 → 낮은 시정 → 흐림 → 구름 조금 → 맑음입니다. 기본 시정 기준은 1000m 미만, 흐림은 운량 80% 이상, 구름 조금은 30% 이상입니다. 강수 정보가 없거나 날씨 로딩/조회가 실패하면 `unknown`을 사용합니다. 낮은 시정은 안개로 단정하지 않도록 기본 문구에 표현했습니다. 웹페이지에 적용된 기상 데이터를 캡처 기록에 함께 저장하며, 재시도도 그 사진의 날씨를 사용합니다. + +문구는 게시 직전에 다시 읽으므로 수정 후 빌드나 PM2 재시작이 필요 없습니다. `npm run threads:captions`는 모든 날씨별 문구를 출력만 하며 게시하지 않습니다. 실제 촬영 시 선택된 문구도 `threads:preview` 로그로 확인할 수 있습니다. 완성된 문구가 500자를 넘거나 JSON이 잘못됐으면 게시를 중단합니다. + +## 6. PM2로 자동 실행 + +기존 systemd 작업을 설정했다면 먼저 중지합니다. 두 예약 작업을 동시에 실행하지 마세요. + +```bash +sudo systemctl disable --now sunrise-threads.timer +sudo systemctl stop sunrise-threads.service +``` + +이전 systemd 작업이 없다면 위 명령은 생략합니다. PM2는 Chromium을 설치한 **동일한 일반 사용자**로 실행하세요. PM2가 없다면 관리자 권한으로 `npm install -g pm2`를 한 번 실행합니다. 이후 일반 사용자 터미널에서: + +```bash +cd /opt/sunrise +pm2 start deploy/ecosystem.config.cjs +pm2 save +pm2 status +pm2 logs sunrise-threads --lines 100 +``` + +설정은 프로젝트 위치를 자동으로 계산하고 워커를 단일 fork 프로세스로 실행합니다. 기존 웹 서버 PM2 프로세스는 건드리지 않습니다. 날짜 계산은 서버 시간대와 무관하게 Asia/Seoul 기준이며, 03:00 이후 새로 시작하면 오늘 작업을 바로 확인합니다. 이미 게시했다면 중복 게시하지 않고, 일출을 놓쳤고 저장된 사진도 없다면 다음 날짜를 기다립니다. 실행 중에는 실제 촬영 5분 전에만 Chromium을 엽니다. + +```bash +pm2 restart sunrise-threads # 코드/실행 설정 변경 후 +pm2 stop sunrise-threads # 중지 +pm2 delete sunrise-threads # PM2에서 제거 +pm2 save # 변경된 목록 저장 +``` + +재부팅 후 복구는 일반 사용자로 `pm2 startup`을 실행하고, 출력되는 관리자 명령을 그대로 실행한 뒤 `pm2 save`합니다. Linux 배포판에 따라 PM2 자체 부팅 등록에는 systemd가 사용될 수 있지만, 일출 예약과 프로세스 관리는 PM2 워커가 담당합니다. + +- [PM2 프로세스 설정](https://pm2.keymetrics.io/docs/usage/application-declaration/) +- [PM2 재부팅 복구](https://pm2.keymetrics.io/docs/usage/startup/) ## 게시 기록과 재시도 @@ -93,6 +132,6 @@ journalctl -u sunrise-threads.service -n 100 `publishing` 상태가 남았다면 요청이 성공했지만 응답이 유실됐을 수 있습니다. 이때 자동으로 재게시하지 않습니다. 계정과 기록의 컨테이너 ID를 확인한 뒤 수동으로 처리하세요. 기존 기록을 무조건 지우면 중복 게시될 수 있습니다. -촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 systemd가 60초 뒤 재시도합니다(1시간 내 최대 5회 시작). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 촬영 자체를 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다. +촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 촬영 자체를 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다. -정지: `sudo systemctl disable --now sunrise-threads.timer` (이미 실행 중인 작업도 중지하려면 `sudo systemctl stop sunrise-threads.service`). +정지: `pm2 stop sunrise-threads`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요. diff --git a/package.json b/package.json index 7995fff..bb1c5a3 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "threads:run": "node server/threads/run.ts", "threads:preview": "node server/threads/run.ts --dry-run --now", "threads:post-now": "node server/threads/run.ts --post-now", + "threads:captions": "node server/threads/previewCaptions.ts", + "threads:worker": "node server/threads/worker.ts", "dev": "vite --host 127.0.0.1", "prepare:buildings": "node scripts/prepare-building-assets.ts", "check:buildings": "node scripts/prepare-building-assets.ts --check", diff --git a/server/threads/captions.ts b/server/threads/captions.ts new file mode 100644 index 0000000..cccfe54 --- /dev/null +++ b/server/threads/captions.ts @@ -0,0 +1,61 @@ +import { readFileSync } from 'node:fs'; +import type { WeatherSample } from '../../src/weather/weather.ts'; +import { kstClock, kstFields } from '../../src/ui/time.ts'; +import { THREADS } from '../../src/constants/threads.ts'; + +type Condition = typeof THREADS.captionConditions[number]; +export interface Captions { + template: string[]; + testPrefix: string; + thresholds: { fogVisibilityMeters: number; cloudyPercent: number; partlyCloudyPercent: number }; + messages: Record; +} + +export function loadCaptions(path: string | URL = new URL('../../.threads-captions.json', import.meta.url)): Captions { + let text: string; + try { text = readFileSync(path, 'utf8'); } + catch (error) { + if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; + text = readFileSync(new URL('../../.threads-captions.sample.json', import.meta.url), 'utf8'); + } + let config: Captions; + try { config = JSON.parse(text); } catch { throw new Error('Invalid JSON in .threads-captions.json'); } + const lines = (value: unknown): value is string[] => Array.isArray(value) && value.length > 0 && value.every(line => typeof line === 'string'); + if (!config || !lines(config.template) || typeof config.testPrefix !== 'string' + || !THREADS.captionConditions.every(key => lines(config.messages?.[key]))) throw new Error('Invalid caption template or weather messages'); + const t = config.thresholds; + if (!t || !Number.isFinite(t.fogVisibilityMeters) || t.fogVisibilityMeters < 0 + || !Number.isFinite(t.partlyCloudyPercent) || !Number.isFinite(t.cloudyPercent) + || t.partlyCloudyPercent < 0 || t.cloudyPercent > 100 || t.partlyCloudyPercent >= t.cloudyPercent) { + throw new Error('Invalid caption weather thresholds'); + } + return config; +} + +export function weatherKey(weather: WeatherSample | null, config: Captions): Condition { + if (!weather) return 'unknown'; + if ((weather.snowfall ?? 0) > 0) return 'snow'; + if ((weather.rain ?? 0) > 0) return 'rain'; + if (weather.visibility !== null && weather.visibility < config.thresholds.fogVisibilityMeters) return 'fog'; + if (weather.rain === null || weather.snowfall === null || !Number.isFinite(weather.cloudCover)) return 'unknown'; + if (weather.cloudCover >= config.thresholds.cloudyPercent) return 'cloudy'; + if (weather.cloudCover >= config.thresholds.partlyCloudyPercent) return 'partlyCloudy'; + return 'clear'; +} + +export function renderCaption(config: Captions, capture: { + weather: WeatherSample | null; capturedAt: string; sunrise: string; url: string; test: boolean; +}) { + const condition = weatherKey(capture.weather, config); + const date = new Date(capture.capturedAt); + const values: Record = { + message: config.messages[condition].join('\n'), date: kstFields(date).date, + 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) => { + if (!(key in values)) throw new Error(`Unknown caption placeholder: ${key}`); + return values[key]; + }); + if (!text.trim() || [...text].length > THREADS.captionMaxLength) throw new Error('Caption must contain 1–500 characters'); + return { text, condition }; +} diff --git a/server/threads/previewCaptions.ts b/server/threads/previewCaptions.ts new file mode 100644 index 0000000..8ca816a --- /dev/null +++ b/server/threads/previewCaptions.ts @@ -0,0 +1,18 @@ +import { loadCaptions, renderCaption } from './captions.ts'; +import { dailySchedule } from './publisher.ts'; +import { loadThreadsConfig } from './config.ts'; +import type { WeatherSample } from '../../src/weather/weather.ts'; + +const config = loadCaptions(); +const site = loadThreadsConfig(undefined, true); +const base = { cloudCover: 0, rain: 0, snowfall: 0, visibility: 20000 }; +const samples: Record = { + clear: base, partlyCloudy: { ...base, cloudCover: config.thresholds.partlyCloudyPercent }, + cloudy: { ...base, cloudCover: config.thresholds.cloudyPercent }, fog: { ...base, visibility: 0 }, + rain: { ...base, rain: 1 }, snow: { ...base, snowfall: 1 }, unknown: null, +}; +for (const [condition, weather] of Object.entries(samples)) { + const { text } = renderCaption(config, { weather, capturedAt: new Date().toISOString(), + sunrise: dailySchedule(new Date()).sunrise.toISOString(), url: new URL(site.siteUrl).origin, test: false }); + console.log(`\n[${condition}]\n${text}`); +} diff --git a/server/threads/run.ts b/server/threads/run.ts index f706252..1beb43e 100644 --- a/server/threads/run.ts +++ b/server/threads/run.ts @@ -8,6 +8,9 @@ import { kstClock, kstFields } from '../../src/ui/time.ts'; import { dailySchedule, publishCarousel, threadsRequest } from './publisher.ts'; import { loadThreadsConfig } from './config.ts'; import { cleanupPublished } from './cleanup.ts'; +import { loadCaptions, renderCaption } from './captions.ts'; +import { WEATHER } from '../../src/constants/weather.ts'; +import type { WeatherSample } from '../../src/weather/weather.ts'; import type {} from '../../src/ui/threadsCapture.ts'; const args = process.argv.slice(2); @@ -85,9 +88,11 @@ async function main() { if (!Array.isArray(images) || images.length !== THREADS.angles.length) { throw new Error('Saved capture is not a two-angle capture; archive the old capture record before running again'); } - const sunrise = new Date(captured.sunrise); + if (!dryRun && !savedCapture) await saveJson(capturePath, captured); + const { text, condition } = renderCaption(loadCaptions(), { weather: captured.weather ?? null, + capturedAt, sunrise: captured.sunrise, url: siteUrl.origin, test: postNow }); + console.log(`Caption (${condition}):\n${text}`); if (dryRun) { console.log('Dry run: no Threads API calls or publication records'); return; } - if (!savedCapture) await saveJson(capturePath, captured); // Authentication/network outages must not lose the time-sensitive capture. const token = await accessToken(); for (const { imageUrl, imagePath } of images) { @@ -96,10 +101,8 @@ async function main() { const remoteImage = Buffer.from(await check.arrayBuffer()); if (!check.ok || !remoteImage.equals(await readFile(imagePath))) throw new Error('Public image URL does not serve the captured image'); } - const text = postNow - ? `📷 관악산 두 앵글 테스트 게시\n${kstFields(new Date(capturedAt)).date} · ${kstClock(new Date(capturedAt))} KST\n현재 시각에 촬영한 3D 시뮬레이션입니다.\n${siteUrl.origin}` - : `🌅 오늘의 관악산 일출\n${schedule.date} · ${kstClock(sunrise)} KST\n관악산 정상의 일출 시각을 담은 3D 시뮬레이션입니다.\n${siteUrl.origin}`; - const record = { date: schedule.date, mode: postNow ? 'test' : 'scheduled', sunrise: sunrise.toISOString(), capturedAt, images, status: 'publishing' }; + const record = { date: schedule.date, mode: postNow ? 'test' : 'scheduled', sunrise: captured.sunrise, + capturedAt, images, weather: captured.weather ?? null, condition, text, status: 'publishing' }; const postId = await publishCarousel(token, images.map((image: { imageUrl: string }) => image.imageUrl), text, async containerId => { // Atomic claim survives restarts and blocks concurrent/ambiguous publication retries. await writeFile(recordPath, JSON.stringify({ ...record, containerId }, null, 2), { flag: 'wx', mode: 0o600 }); @@ -120,6 +123,7 @@ async function captureSunrise(schedule: ReturnType, immedi const images: { name: string; imagePath: string; imageUrl: string }[] = []; let sunrise: Date; let capturedAt: string; + let weather: WeatherSample | null; try { const page = await browser.newPage({ viewport: THREADS.viewport, deviceScaleFactor: 1, timezoneId: 'Asia/Seoul' }); const url = new URL(siteUrl); @@ -128,6 +132,13 @@ async function captureSunrise(schedule: ReturnType, immedi await page.goto(url.href, { waitUntil: 'domcontentloaded', timeout: 120_000 }); await page.waitForFunction(() => Boolean(window.sunriseThreads), undefined, { timeout: 180_000 }); await page.waitForFunction(() => !document.getElementById('lobby') || document.getElementById('lobby')!.hidden); + // An unavailable forecast gets a neutral caption, never a fabricated clear sky. + try { + await page.waitForFunction(() => window.sunriseThreads!.weatherStatus?.() !== 'loading', undefined, { timeout: WEATHER.timeoutMs }); + } catch (error) { + if (!(error instanceof Error) || error.name !== 'TimeoutError') throw error; + console.warn('Weather is still loading; capture may use the unknown-weather caption'); + } 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)`); @@ -137,6 +148,7 @@ async function captureSunrise(schedule: ReturnType, immedi } 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 (capture.images?.length !== THREADS.angles.length) throw new Error('Deploy the two-angle capture frontend first'); for (const [index, shot] of capture.images.entries()) { @@ -151,7 +163,7 @@ async function captureSunrise(schedule: ReturnType, immedi console.log(`Saved ${shot.name} capture: ${imagePath}`); } } finally { await browser.close(); } - return { images, capturedAt, sunrise: sunrise.toISOString() }; + return { images, capturedAt, sunrise: sunrise.toISOString(), weather }; } void main().catch(error => { diff --git a/server/threads/schedule.ts b/server/threads/schedule.ts new file mode 100644 index 0000000..a4b7120 --- /dev/null +++ b/server/threads/schedule.ts @@ -0,0 +1,12 @@ +import { fromKstInput, kstFields } from '../../src/ui/time.ts'; +import { THREADS } from '../../src/constants/threads.ts'; + +export function nextRunAt(now: Date, completedDate?: string): Date { + const date = kstFields(now).date; + const today = fromKstInput(date, THREADS.scheduleHourKst * 60); + if (completedDate === date) { + const tomorrow = new Date(+fromKstInput(date, 12 * 60) + 86_400_000); + return fromKstInput(kstFields(tomorrow).date, THREADS.scheduleHourKst * 60); + } + return +now < +today ? today : now; +} diff --git a/server/threads/worker.ts b/server/threads/worker.ts new file mode 100644 index 0000000..b2c3202 --- /dev/null +++ b/server/threads/worker.ts @@ -0,0 +1,53 @@ +import { spawn, type ChildProcess } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; +import { setTimeout as delay } from 'node:timers/promises'; +import { nextRunAt } from './schedule.ts'; +import { kstFields } from '../../src/ui/time.ts'; +import { THREADS } from '../../src/constants/threads.ts'; + +const stop = new AbortController(); +let child: ChildProcess | undefined; +for (const signal of ['SIGINT', 'SIGTERM'] as const) { + process.once(signal, () => { stop.abort(); child?.kill('SIGTERM'); }); +} + +async function runJob() { + return new Promise((resolve, reject) => { + const job = spawn(process.execPath, [fileURLToPath(new URL('./run.ts', import.meta.url))], { stdio: 'inherit' }); + child = job; + let timedOut = false; + let forceStop: ReturnType | undefined; + const deadline = setTimeout(() => { + timedOut = true; + console.error('Daily job exceeded eight hours; terminating before retry'); + job.kill('SIGTERM'); + forceStop = setTimeout(() => job.kill('SIGKILL'), THREADS.jobStopGraceMs); + }, THREADS.jobTimeoutMs); + const clear = () => { clearTimeout(deadline); clearTimeout(forceStop); child = undefined; }; + job.once('error', error => { clear(); reject(error); }); + job.once('exit', code => { clear(); resolve(code === 0 && !timedOut); }); + }); +} + +async function main() { + let completedDate: string | undefined; + while (!stop.signal.aborted) { + const next = nextRunAt(new Date(), completedDate); + console.log(`Next daily preparation: ${next.toISOString()} (schedule: 03:00 Asia/Seoul)`); + while (+next > Date.now()) await delay(Math.min(+next - Date.now(), 60_000), undefined, { signal: stop.signal }); + if (stop.signal.aborted) break; + const date = kstFields(new Date()).date; + for (let attempt = 1; attempt <= THREADS.retryAttempts && !stop.signal.aborted; attempt++) { + if (await runJob()) break; + console.error(`Daily job failed (${attempt}/${THREADS.retryAttempts})`); + if (attempt < THREADS.retryAttempts) await delay(THREADS.retryMs, undefined, { signal: stop.signal }); + } + completedDate = date; + } +} + +void main().catch(error => { + if (stop.signal.aborted) return; + console.error(error instanceof Error ? error.message : 'Threads worker failed'); + process.exitCode = 1; +}); diff --git a/src/constants/threads.ts b/src/constants/threads.ts index 8e4644c..0ec3b23 100644 --- a/src/constants/threads.ts +++ b/src/constants/threads.ts @@ -7,6 +7,13 @@ export const THREADS = { mediaPollMs: 5_000, mediaPollAttempts: 24, tokenRefreshMs: 7 * 86_400_000, + captionMaxLength: 500, + captionConditions: ['clear', 'partlyCloudy', 'cloudy', 'fog', 'rain', 'snow', 'unknown'], + scheduleHourKst: 3, + retryAttempts: 5, + retryMs: 60_000, + jobTimeoutMs: 8 * 60 * 60_000, + jobStopGraceMs: 10_000, viewport: { width: 1080, height: 1728 }, angles: [ { name: 'sunrise', offset: 0, pitch: 0, alt: '일출을 바라보는 캐릭터의 뒷모습과 관악산 풍경' }, diff --git a/src/main.ts b/src/main.ts index a8f93fd..b0d33f2 100644 --- a/src/main.ts +++ b/src/main.ts @@ -128,8 +128,10 @@ export async function start(options: StartOptions = {}) { world.update(state); }; const weatherStatus = document.getElementById('weather-status')!; + let captureWeather: WeatherStatus = { status: 'loading', weather: null }; const applyWeather = ({ status, weather, source }: WeatherStatus) => { if (import.meta.env.DEV && new URLSearchParams(location.search).has('profile')) return; + captureWeather = { status, weather, source }; world.setWeather(weather); weatherStatus.textContent = status === 'ready' && weather ? `${source} · 운량 ${Math.round(weather.cloudCover)}% · ${weather.visibility === null @@ -243,7 +245,7 @@ export async function start(options: StartOptions = {}) { window.addEventListener('pageshow', event => { if (event.persisted && sharedMode) join(); }); if (query.get('capture') === 'threads' && !sharedMode) { const { installThreadsCapture } = await import('./ui/threadsCapture.ts'); - installThreadsCapture(effectiveObserver, world); + installThreadsCapture(effectiveObserver, world, () => captureWeather); } options.ready?.(); } catch (error) { diff --git a/src/ui/threadsCapture.ts b/src/ui/threadsCapture.ts index 07b4a9a..8125edd 100644 --- a/src/ui/threadsCapture.ts +++ b/src/ui/threadsCapture.ts @@ -1,18 +1,21 @@ import type { ObserverConfig } from '../config/observer.ts'; import { calculateSun, sunriseAzimuth } from '../astronomy/sunCalculator.ts'; import { THREADS } from '../constants/threads.ts'; +import type { WeatherSample, WeatherStatus } from '../weather/weather.ts'; export interface ThreadsCapture { sunrise(): string; - capture(): { images: { name: string; image: string }[]; capturedAt: string }; + weatherStatus(): WeatherStatus['status']; + capture(): { images: { name: string; image: string }[]; capturedAt: string; weather: WeatherSample | null }; } declare global { interface Window { sunriseThreads?: ThreadsCapture } } export function installThreadsCapture(observer: ObserverConfig, world: { update: (state: ReturnType) => void; lookAtSunrise: (azimuth: number) => void; - captureAngle: (azimuth: number, pitch: number) => HTMLCanvasElement }) { + captureAngle: (azimuth: number, pitch: number) => HTMLCanvasElement }, getWeather: () => WeatherStatus) { window.sunriseThreads = { + weatherStatus: () => getWeather().status, sunrise() { const sunrise = calculateSun(new Date(), observer).sunrise; if (!sunrise || !Number.isFinite(+sunrise)) throw new Error('No sunrise'); @@ -25,7 +28,8 @@ export function installThreadsCapture(observer: ObserverConfig, world.lookAtSunrise(azimuth); const images = THREADS.angles.map(angle => ({ name: angle.name, image: world.captureAngle((azimuth + angle.offset) % 360, angle.pitch).toDataURL('image/jpeg', 0.95) })); - return { images, capturedAt: now.toISOString() }; + const weather = getWeather(); + return { images, capturedAt: now.toISOString(), weather: weather.status === 'ready' ? weather.weather : null }; }, }; } diff --git a/tests/threads-captions.test.ts b/tests/threads-captions.test.ts new file mode 100644 index 0000000..3649105 --- /dev/null +++ b/tests/threads-captions.test.ts @@ -0,0 +1,28 @@ +import { test } from 'node:test'; +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('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'); + assert.equal(weatherKey({ ...sample, rain: 1, visibility: 100 }, config), 'rain'); + assert.equal(weatherKey({ ...sample, visibility: 500 }, config), 'fog'); + assert.equal(weatherKey({ ...sample, cloudCover: 90 }, config), 'cloudy'); + assert.equal(weatherKey({ ...sample, cloudCover: 50 }, config), 'partlyCloudy'); + assert.equal(weatherKey(sample, config), 'clear'); + assert.equal(weatherKey(null, config), 'unknown'); + assert.equal(weatherKey({ ...sample, rain: null }, config), 'unknown'); +}); + +test('editable caption expands Korean date/time and test marker without publishing', () => { + const config = loadCaptions(); + config.template = ['{message}', '{date} {time} / {sunrise}', '{url}']; + config.messages.rain = ['비 오는 날이야 ☔']; + const result = renderCaption(config, { weather: { ...sample, rain: 1 }, capturedAt: '2026-11-01T18:00:00Z', + sunrise: '2026-11-01T21:53:00Z', url: 'https://sunrise.horoli.kr', test: true }); + assert.ok(result.text.includes('비 오는 날이야 ☔')); + assert.ok(result.text.includes('2026-11-02 03:00 / 06:53')); + assert.ok(result.text.startsWith(config.testPrefix)); + assert.equal(result.condition, 'rain'); +}); diff --git a/tests/threads-schedule.test.ts b/tests/threads-schedule.test.ts new file mode 100644 index 0000000..4d56c06 --- /dev/null +++ b/tests/threads-schedule.test.ts @@ -0,0 +1,10 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { nextRunAt } from '../server/threads/schedule.ts'; + +test('PM2 scheduler prepares at 03:00 Korea time regardless of host timezone', () => { + assert.equal(nextRunAt(new Date('2026-11-01T17:00:00Z')).toISOString(), '2026-11-01T18:00:00.000Z'); + assert.equal(nextRunAt(new Date('2026-11-01T20:00:00Z')).toISOString(), '2026-11-01T20:00:00.000Z'); + assert.equal(nextRunAt(new Date('2026-11-01T20:00:00Z'), '2026-11-02').toISOString(), '2026-11-02T18:00:00.000Z'); + assert.equal(nextRunAt(new Date('2026-12-31T20:00:00Z'), '2027-01-01').toISOString(), '2027-01-01T18:00:00.000Z'); +});