From 8c2113a323bdd5f2b6a862e83cbb6747bad26a75 Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 2 Nov 2026 03:44:10 +0900 Subject: [PATCH] Add immediate Threads test posting and clean up published images --- docs/threads-automation.md | 14 ++++++++-- package.json | 1 + server/threads/cleanup.ts | 32 +++++++++++++++++++++++ server/threads/run.ts | 32 +++++++++++++++++------ tests/threads-cleanup.test.ts | 49 +++++++++++++++++++++++++++++++++++ 5 files changed, 118 insertions(+), 10 deletions(-) create mode 100644 server/threads/cleanup.ts create mode 100644 tests/threads-cleanup.test.ts diff --git a/docs/threads-automation.md b/docs/threads-automation.md index 396ac97..e9e2502 100644 --- a/docs/threads-automation.md +++ b/docs/threads-automation.md @@ -55,13 +55,23 @@ npm run threads:preview npm run threads:run -- --dry-run ``` -`--now`는 `--dry-run`과 함께만 허용됩니다. 테스트 때문에 실제 계정에 게시하지 않습니다. +`--now`는 `--dry-run`과 함께만 허용됩니다. 위 촬영 검증 명령은 실제 계정에 게시하지 않습니다. + +### 지금 실제 테스트 게시하기 + +```bash +npm run threads:post-now +``` + +이 명령은 **일출까지 기다리지 않고 현재 시각의 두 앵글을 실제 Threads 계정에 게시**합니다. 게시 문구에는 테스트 게시임과 실제 촬영 시각을 표시합니다. 실행할 때마다 새 테스트 게시물이 생성되며, `test-날짜-UUID.json` 기록을 사용하므로 당일 일출 예약 게시를 막지 않습니다. 예약 서비스와 동일한 일반 사용자로 실행하세요. root에서는 Chromium sandbox가 동작하지 않습니다. + +게시 ID를 받은 즉시 두 원본 JPEG를 삭제합니다. `Threads published: ...` 다음 `Deleted both source images after publication` 로그를 확인하세요. 삭제 실패 시 게시 기록은 유지하고 다음 게시 작업 시작 때 삭제를 재시도합니다. 게시 실패 또는 결과 불명확 상태에서는 이미지를 보관하며, 촬영 전용 preview 파일도 확인용으로 유지합니다. 서버 원본 삭제는 Threads에 게시된 사진을 삭제하는 작업이 아닙니다. 별도 CDN을 사용한다면 캐시 사본은 CDN의 만료 정책을 따릅니다. ## 4. 이미지 공개 URL 기본 저장 위치는 `dist/threads-captures/`, 공개 URL은 `https://sunrise.horoli.kr/threads-captures/`입니다. 기존 Node 정적 파일 서버가 이미지를 제공합니다. 저장된 JPEG의 공개 URL을 외부에서 열어 확인하세요. 게시 직전에 작업도 실제 다운로드한 바이트가 저장된 이미지와 같은지 확인합니다. Meta 서버가 로그인 없이 이미지를 읽을 수 있어야 합니다. -`npm run build`는 dist를 다시 생성합니다. 운영에서는 **배포와 독립적인 이미지 디렉터리**를 권장합니다. 예를 들어 `"imageDir": "/var/lib/sunrise/captures"`로 지정하고 Nginx에서 `/threads-captures/`를 해당 디렉터리에 연결하세요. Nginx 사용자에게 디렉터리 탐색·파일 읽기 권한을 부여하세요. `imageBaseUrl`도 실제 URL과 일치해야 합니다. 이미지에는 토큰이 포함되지 않습니다. 이미지 파일은 자동 삭제하지 않으므로 저장 용량을 관리하세요. +`npm run build`는 dist를 다시 생성합니다. 운영에서는 **배포와 독립적인 이미지 디렉터리**를 권장합니다. 예를 들어 `"imageDir": "/var/lib/sunrise/captures"`로 지정하고 Nginx에서 `/threads-captures/`를 해당 디렉터리에 연결하세요. Nginx 사용자에게 디렉터리 탐색·파일 읽기 권한을 부여하세요. `imageBaseUrl`도 실제 URL과 일치해야 합니다. 이미지에는 토큰이 포함되지 않습니다. 예약 게시와 테스트 게시 모두 성공 후 원본 이미지를 즉시 삭제합니다. ## 5. 자동 실행 활성화 diff --git a/package.json b/package.json index 3146d37..7995fff 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "scripts": { "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", "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/cleanup.ts b/server/threads/cleanup.ts new file mode 100644 index 0000000..cd62b33 --- /dev/null +++ b/server/threads/cleanup.ts @@ -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; +} diff --git a/server/threads/run.ts b/server/threads/run.ts index d65867b..f706252 100644 --- a/server/threads/run.ts +++ b/server/threads/run.ts @@ -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, immediate: boolean) { diff --git a/tests/threads-cleanup.test.ts b/tests/threads-cleanup.test.ts new file mode 100644 index 0000000..e23f374 --- /dev/null +++ b/tests/threads-cleanup.test.ts @@ -0,0 +1,49 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, mkdir, writeFile, readFile, access, unlink, rmdir } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { cleanupPublished } from '../server/threads/cleanup.ts'; + +test('cleanup preserves unconfirmed images, deletes confirmed images and is repeatable', async () => { + const root = await mkdtemp(join(tmpdir(), 'threads-cleanup-')); + const recordPath = join(root, 'record.json'); + const images = [join(root, 'one.jpg'), join(root, 'two.jpg')]; + try { + for (const image of images) await writeFile(image, 'image'); + const record = { status: 'publishing', images: images.map(imagePath => ({ imagePath })) }; + await writeFile(recordPath, JSON.stringify(record)); + assert.equal(await cleanupPublished(recordPath, root), false); + for (const image of images) await access(image); + await writeFile(recordPath, JSON.stringify({ ...record, status: 'published', postId: 'post-123' })); + assert.equal(await cleanupPublished(recordPath, root), true); + for (const image of images) await assert.rejects(access(image), { code: 'ENOENT' }); + const saved = JSON.parse(await readFile(recordPath, 'utf8')); + assert.equal(saved.postId, 'post-123'); + assert.ok(saved.imagesDeletedAt); + assert.equal(await cleanupPublished(recordPath, root), false); + } finally { + for (const path of [...images, recordPath]) await unlink(path).catch(() => {}); + await rmdir(root); + } +}); + +test('cleanup validates every target before deleting and rejects outside files', async () => { + const root = await mkdtemp(join(tmpdir(), 'threads-cleanup-')); + const imageDir = join(root, 'images'); + await mkdir(imageDir); + const inside = join(imageDir, 'one.jpg'), outside = join(root, 'outside.jpg'), recordPath = join(root, 'record.json'); + try { + await writeFile(inside, 'image'); await writeFile(outside, 'keep'); + await writeFile(recordPath, JSON.stringify({ status: 'published', postId: 'post', + images: [{ imagePath: inside }, { imagePath: outside }] })); + await assert.rejects(cleanupPublished(recordPath, imageDir), /outside/); + await access(inside); await access(outside); + await writeFile(recordPath, JSON.stringify({ status: 'published', postId: 'post', + images: [{ imagePath: inside }, { imagePath: join(imageDir, 'already-deleted.jpg') }] })); + assert.equal(await cleanupPublished(recordPath, imageDir), true); + } finally { + for (const path of [inside, outside, recordPath]) await unlink(path).catch(() => {}); + await rmdir(imageDir); await rmdir(root); + } +});