Add editable weather captions and manage Threads scheduling with PM2

This commit is contained in:
2026-11-02 04:07:40 +09:00
parent 8c2113a323
commit 6ce32d33fe
17 changed files with 305 additions and 50 deletions
+61
View File
@@ -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<Condition, string[]>;
}
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<string, string> = {
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 };
}
+18
View File
@@ -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<string, WeatherSample | null> = {
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}`);
}
+19 -7
View File
@@ -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<typeof dailySchedule>, 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<typeof dailySchedule>, 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<typeof dailySchedule>, 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<typeof dailySchedule>, 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 => {
+12
View File
@@ -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;
}
+53
View File
@@ -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<boolean>((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<typeof setTimeout> | 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;
});