Add immediate Threads test posting and clean up published images
This commit is contained in:
@@ -0,0 +1,32 @@
|
||||
import { readFile, realpath, unlink, writeFile, rename } from 'node:fs/promises';
|
||||
import { dirname, relative, isAbsolute, resolve } from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
|
||||
/** Only confirmed publications may release their public source images. */
|
||||
export async function cleanupPublished(recordPath: string, imageDir: string) {
|
||||
const record = JSON.parse(await readFile(recordPath, 'utf8'));
|
||||
if (record.status !== 'published' || !record.postId || record.imagesDeletedAt) return false;
|
||||
const root = await realpath(imageDir).catch(error => {
|
||||
if ((error as NodeJS.ErrnoException).code === 'ENOENT') return resolve(imageDir);
|
||||
throw error;
|
||||
});
|
||||
const paths: string[] = [];
|
||||
for (const image of record.images) {
|
||||
let path: string;
|
||||
try { path = await realpath(image.imagePath); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code === 'ENOENT') continue; throw error; }
|
||||
const child = relative(root, path);
|
||||
if (!child || child.startsWith('..') || isAbsolute(child) || dirname(path) !== root) {
|
||||
throw new Error('Refusing image cleanup outside the capture directory');
|
||||
}
|
||||
paths.push(path);
|
||||
}
|
||||
for (const path of paths) {
|
||||
try { await unlink(path); }
|
||||
catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; }
|
||||
}
|
||||
const temporary = `${recordPath}.${randomUUID()}.tmp`;
|
||||
await writeFile(temporary, JSON.stringify({ ...record, imagesDeletedAt: new Date().toISOString() }, null, 2), { mode: 0o600 });
|
||||
await rename(temporary, recordPath);
|
||||
return true;
|
||||
}
|
||||
+24
-8
@@ -1,4 +1,4 @@
|
||||
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises';
|
||||
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';
|
||||
@@ -7,6 +7,7 @@ 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 { cleanupPublished } from './cleanup.ts';
|
||||
import type {} from '../../src/ui/threadsCapture.ts';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
@@ -51,18 +52,29 @@ async function waitUntil(date: Date) {
|
||||
}
|
||||
|
||||
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 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 recordPath = join(stateDir, `${schedule.date}.json`);
|
||||
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 = dryRun && args.includes('--now');
|
||||
const capturePath = join(stateDir, `${schedule.date}.capture.json`);
|
||||
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`);
|
||||
@@ -84,8 +96,10 @@ 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 = `🌅 오늘의 관악산 일출\n${schedule.date} · ${kstClock(sunrise)} KST\n관악산 정상의 일출 시각을 담은 3D 시뮬레이션입니다.\n${siteUrl.origin}`;
|
||||
const record = { date: schedule.date, sunrise: sunrise.toISOString(), capturedAt, images, status: 'publishing' };
|
||||
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 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 });
|
||||
@@ -93,6 +107,8 @@ async function main() {
|
||||
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<typeof dailySchedule>, immediate: boolean) {
|
||||
|
||||
Reference in New Issue
Block a user