import { mkdir, readFile, readdir, rename, writeFile } from 'node:fs/promises'; import { resolve, join } from 'node:path'; import { randomUUID } from 'node:crypto'; 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, sunriseCaptureTime } 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); const dryRun = args.includes('--dry-run'); const config = loadThreadsConfig(undefined, dryRun); const stateDir = resolve(config.stateDir); const imageDir = resolve(config.imageDir); const siteUrl = new URL(config.siteUrl); const publicUrl = new URL(config.imageBaseUrl); async function readJson(path: string) { try { return JSON.parse(await readFile(path, 'utf8')); } catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') return null; throw error; } } async function saveJson(path: string, data: unknown) { const temporary = `${path}.${randomUUID()}.tmp`; await writeFile(temporary, JSON.stringify(data, null, 2), { mode: 0o600 }); await rename(temporary, path); } async function accessToken() { const path = join(stateDir, 'token.json'); let saved = await readJson(path); if (!saved) { const token = config.accessToken.trim(); if (!token) throw new Error('Set accessToken in .env.threads.json to a long-lived Threads token'); saved = { token, refreshedAt: Date.now() }; await saveJson(path, saved); } if (Date.now() - saved.refreshedAt >= THREADS.tokenRefreshMs) { const refreshed = await threadsRequest(saved.token, '/refresh_access_token', { grant_type: 'th_refresh_token' }); if (!refreshed.access_token) throw new Error('Token refresh returned no access token'); saved = { token: refreshed.access_token, refreshedAt: Date.now() }; await saveJson(path, saved); } return saved.token as string; } async function waitUntil(date: Date) { while (+date > Date.now()) await delay(Math.min(+date - Date.now(), 60_000)); } async function main() { const postNow = args.includes('--post-now'); if (args.some(arg => !['--dry-run', '--now', '--post-now'].includes(arg)) || (args.includes('--now') && !dryRun) || (postNow && (dryRun || args.includes('--now')))) { throw new Error('Usage: run.ts [--dry-run [--now] | --post-now]. --post-now publishes a real test post.'); } const schedule = dailySchedule(new Date()); const runId = postNow ? `test-${schedule.date}-${randomUUID()}` : schedule.date; const recordPath = join(stateDir, `${runId}.json`); await mkdir(stateDir, { recursive: true, mode: 0o700 }); if (!dryRun) { // Retry interrupted deletions, including earlier manual test publications. for (const name of await readdir(stateDir)) { if (/^(?:\d{4}-\d{2}-\d{2}|test-\d{4}-\d{2}-\d{2}-[\da-f-]+)\.json$/.test(name)) { await cleanupPublished(join(stateDir, name), imageDir); } } } if (!dryRun && await readJson(recordPath)) { console.log(`${schedule.date}: publication already recorded; inspect the record if status is publishing`); return; } 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.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); const { images, capturedAt } = captured; 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'); } 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; } // Authentication/network outages must not lose the time-sensitive capture. const token = await accessToken(); for (const { imageUrl, imagePath } of images) { if (new URL(imageUrl).protocol !== 'https:') throw new Error('Public image URL must use HTTPS'); const check = await fetch(imageUrl, { signal: AbortSignal.timeout(THREADS.requestTimeoutMs) }); 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 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 }); }); const pending = await readJson(recordPath); await saveJson(recordPath, { ...pending, status: 'published', postId }); console.log(`Threads published: ${postId}`); await cleanupPublished(recordPath, imageDir); console.log('Deleted both source images after publication'); } async function captureSunrise(schedule: ReturnType, immediate: boolean) { console.log(`Sunrise estimate: ${schedule.sunrise.toISOString()} (${kstClock(schedule.sunrise)} KST)`); if (!immediate) await waitUntil(schedule.prepareAt); await mkdir(imageDir, { recursive: true }); const browser = await chromium.launch({ headless: true, chromiumSandbox: true, args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] }); 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); url.searchParams.set('mode', 'solo'); url.searchParams.set('site', 'gwanaksan'); url.searchParams.set('capture', 'threads'); 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)`); const captureAt = sunriseCaptureTime(sunrise); console.log(`Capture target: ${captureAt.toISOString()} (${kstClock(captureAt)} KST)`); if (!immediate) { 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) - +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'); const filename = `${schedule.date}-${randomUUID()}-${shot.name}.jpg`; const imagePath = join(imageDir, filename); const imageUrl = new URL(filename, publicUrl.href.replace(/\/?$/, '/')).href; const image = Buffer.from(shot.image.split(',')[1], 'base64'); if (image.length < 1000) throw new Error('Capture is empty'); await writeFile(imagePath, image); images.push({ name: shot.name, imagePath, imageUrl }); console.log(`Saved ${shot.name} capture: ${imagePath}`); } } finally { await browser.close(); } return { images, capturedAt, sunrise: sunrise.toISOString(), weather }; } void main().catch(error => { // Avoid logging fetch error causes/URLs that may contain credentials. console.error(error instanceof Error ? error.message : 'Threads job failed'); process.exitCode = 1; });