Add sunrise Threads automation and improve capture and character interactions
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { THREADS } from '../../src/constants/threads.ts';
|
||||
|
||||
export interface ThreadsConfig {
|
||||
accessToken: string;
|
||||
siteUrl: string;
|
||||
stateDir: string;
|
||||
imageDir: string;
|
||||
imageBaseUrl: string;
|
||||
}
|
||||
|
||||
export function loadThreadsConfig(path: string | URL = new URL('../../.env.threads.json', import.meta.url), optional = false): ThreadsConfig {
|
||||
const defaults: ThreadsConfig = {
|
||||
accessToken: '', siteUrl: THREADS.siteUrl, stateDir: 'data/threads',
|
||||
imageDir: 'dist/threads-captures', imageBaseUrl: `${THREADS.siteUrl}/threads-captures/`,
|
||||
};
|
||||
let contents: string;
|
||||
try { contents = readFileSync(path, 'utf8'); }
|
||||
catch (error) {
|
||||
if (optional && (error as NodeJS.ErrnoException).code === 'ENOENT') return defaults;
|
||||
throw new Error('Cannot read .env.threads.json; copy .env.threads.sample.json and configure it.');
|
||||
}
|
||||
let value: unknown;
|
||||
try { value = JSON.parse(contents); }
|
||||
catch { throw new Error('Invalid JSON in .env.threads.json'); }
|
||||
if (!value || typeof value !== 'object' || Array.isArray(value)) throw new Error('Invalid .env.threads.json: expected an object');
|
||||
const config = { ...defaults, ...value } as ThreadsConfig;
|
||||
for (const field of Object.keys(defaults) as (keyof ThreadsConfig)[]) {
|
||||
if (typeof config[field] !== 'string' || (field !== 'accessToken' && !config[field].trim())) {
|
||||
throw new Error(`Invalid .env.threads.json field: ${field}`);
|
||||
}
|
||||
}
|
||||
for (const field of ['siteUrl', 'imageBaseUrl'] as const) {
|
||||
try {
|
||||
if (!['http:', 'https:'].includes(new URL(config[field]).protocol)) throw new Error();
|
||||
} catch { throw new Error(`Invalid .env.threads.json field: ${field}`); }
|
||||
}
|
||||
return config;
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import { setTimeout as delay } from 'node:timers/promises';
|
||||
import { calculateSun } from '../../src/astronomy/sunCalculator.ts';
|
||||
import { observer } from '../../src/config/observer.ts';
|
||||
import { kstFields } from '../../src/ui/time.ts';
|
||||
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) };
|
||||
}
|
||||
|
||||
export async function threadsRequest(token: string, path: string, params: Record<string, string>, method = 'GET', request: typeof fetch = fetch) {
|
||||
const url = new URL(path, THREADS.apiUrl);
|
||||
if (method === 'GET') url.search = new URLSearchParams(params).toString();
|
||||
const response = await request(url, {
|
||||
method, headers: { Authorization: `Bearer ${token}` },
|
||||
body: method === 'POST' ? new URLSearchParams(params) : undefined,
|
||||
signal: AbortSignal.timeout(THREADS.requestTimeoutMs),
|
||||
});
|
||||
const data = await response.json();
|
||||
// Do not log response bodies: authentication errors may echo credentials.
|
||||
if (!response.ok || data.error) throw new Error(`Threads API failed (HTTP ${response.status}, code ${data.error?.code ?? 'unknown'})`);
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function publishImage(token: string, imageUrl: string, text: string,
|
||||
beforePublish: (containerId: string) => Promise<void>, request: typeof fetch = fetch,
|
||||
wait: (ms: number) => Promise<unknown> = delay) {
|
||||
const container = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'IMAGE', image_url: imageUrl, text,
|
||||
alt_text: '관악산 정상에서 바라본 일출 시각의 3D 시뮬레이션',
|
||||
}, 'POST', request);
|
||||
if (!container.id) throw new Error('Threads returned no container ID');
|
||||
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||
}
|
||||
|
||||
export async function publishCarousel(token: string, imageUrls: string[], text: string,
|
||||
beforePublish: (containerId: string) => Promise<void>, request: typeof fetch = fetch,
|
||||
wait: (ms: number) => Promise<unknown> = delay) {
|
||||
if (imageUrls.length !== THREADS.angles.length) throw new Error('Both sunrise angles are required');
|
||||
const children: string[] = [];
|
||||
for (const [index, imageUrl] of imageUrls.entries()) {
|
||||
const child = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'IMAGE', image_url: imageUrl, is_carousel_item: 'true',
|
||||
alt_text: `${THREADS.angles[index].alt} · 3D 시뮬레이션`,
|
||||
}, 'POST', request);
|
||||
if (!child.id) throw new Error('Threads returned no child container ID');
|
||||
await waitForMedia(token, child.id, request, wait);
|
||||
children.push(child.id);
|
||||
}
|
||||
const container = await threadsRequest(token, '/me/threads', {
|
||||
media_type: 'CAROUSEL', children: children.join(','), text,
|
||||
}, 'POST', request);
|
||||
if (!container.id) throw new Error('Threads returned no carousel ID');
|
||||
return publishContainer(token, container.id, beforePublish, request, wait);
|
||||
}
|
||||
|
||||
async function publishContainer(token: string, id: string, beforePublish: (id: string) => Promise<void>,
|
||||
request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
|
||||
await waitForMedia(token, id, request, wait);
|
||||
await beforePublish(id);
|
||||
const post = await threadsRequest(token, '/me/threads_publish', { creation_id: id }, 'POST', request);
|
||||
if (!post.id) throw new Error('Threads returned no post ID; check account before retrying');
|
||||
return String(post.id);
|
||||
}
|
||||
|
||||
async function waitForMedia(token: string, id: string, request: typeof fetch, wait: (ms: number) => Promise<unknown>) {
|
||||
for (let i = 0; i < THREADS.mediaPollAttempts; i++) {
|
||||
const media = await threadsRequest(token, `/${id}`, { fields: 'status,error_message' }, 'GET', request);
|
||||
if (media.status === 'FINISHED') return;
|
||||
if (['ERROR', 'EXPIRED', 'PUBLISHED'].includes(media.status)) throw new Error(`Threads container status: ${media.status}`);
|
||||
await wait(THREADS.mediaPollMs);
|
||||
}
|
||||
throw new Error('Threads media processing timed out');
|
||||
}
|
||||
@@ -0,0 +1,145 @@
|
||||
import { mkdir, readFile, 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 } from './publisher.ts';
|
||||
import { loadThreadsConfig } from './config.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() {
|
||||
if (args.some(arg => !['--dry-run', '--now'].includes(arg)) || (args.includes('--now') && !dryRun)) {
|
||||
throw new Error('Usage: run.ts [--dry-run [--now]]. --now is capture-only.');
|
||||
}
|
||||
const schedule = dailySchedule(new Date());
|
||||
const recordPath = join(stateDir, `${schedule.date}.json`);
|
||||
await mkdir(stateDir, { recursive: true, mode: 0o700 });
|
||||
if (!dryRun && await readJson(recordPath)) {
|
||||
console.log(`${schedule.date}: publication already recorded; inspect the record if status is publishing`);
|
||||
return;
|
||||
}
|
||||
const immediate = dryRun && args.includes('--now');
|
||||
const capturePath = join(stateDir, `${schedule.date}.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`);
|
||||
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');
|
||||
}
|
||||
const sunrise = new Date(captured.sunrise);
|
||||
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) {
|
||||
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 text = `🌅 오늘의 관악산 일출\n${schedule.date} · ${kstClock(sunrise)} KST\n관악산 정상의 일출 시각을 담은 3D 시뮬레이션입니다.\n${siteUrl.origin}`;
|
||||
const record = { date: schedule.date, sunrise: sunrise.toISOString(), capturedAt, images, 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}`);
|
||||
}
|
||||
|
||||
async function captureSunrise(schedule: ReturnType<typeof dailySchedule>, 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;
|
||||
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);
|
||||
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)`);
|
||||
if (!immediate) {
|
||||
if (Date.now() > +sunrise + THREADS.maxLatenessMs) throw new Error('Page loaded after capture deadline');
|
||||
await waitUntil(sunrise);
|
||||
}
|
||||
const capture = await page.evaluate(() => window.sunriseThreads!.capture());
|
||||
capturedAt = capture.capturedAt;
|
||||
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()) {
|
||||
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() };
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
Reference in New Issue
Block a user