From 586e59608e993ed0af4926d69df7bf5da99f4913 Mon Sep 17 00:00:00 2001 From: Horoli Date: Mon, 2 Nov 2026 04:46:17 +0900 Subject: [PATCH] Add automatic Threads link replies and sunrise preview card --- .threads-captions.sample.json | 3 +- docs/threads-automation.md | 29 +++++++++++++++++ index.html | 8 +++++ public/og-sunrise.jpg | Bin 0 -> 48049 bytes server/threads/captions.ts | 11 +++++-- server/threads/previewCaptions.ts | 3 +- server/threads/publisher.ts | 10 ++++++ server/threads/reply.ts | 25 ++++++++++++++ server/threads/run.ts | 12 +++++-- tests/threads-captions.test.ts | 3 +- tests/threads-reply.test.ts | 52 ++++++++++++++++++++++++++++++ tests/threads.test.ts | 20 +++++++++++- 12 files changed, 168 insertions(+), 8 deletions(-) create mode 100644 public/og-sunrise.jpg create mode 100644 server/threads/reply.ts create mode 100644 tests/threads-reply.test.ts diff --git a/.threads-captions.sample.json b/.threads-captions.sample.json index 9c528ca..bcb45a9 100644 --- a/.threads-captions.sample.json +++ b/.threads-captions.sample.json @@ -1,5 +1,6 @@ { - "template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}", "{hostname}"], + "template": ["오늘의 관악산 일출이에요", "{dateDots} · 일출 {sunrise}", "날씨: {message}"], + "replyTemplate": ["관악산 일출 보러 가기", "{url}"], "testPrefix": "[테스트] ", "thresholds": { "fogVisibilityMeters": 1000, "cloudyPercent": 80, "partlyCloudyPercent": 30 }, "messages": { diff --git a/docs/threads-automation.md b/docs/threads-automation.md index eb7c651..a057fd3 100644 --- a/docs/threads-automation.md +++ b/docs/threads-automation.md @@ -135,3 +135,32 @@ pm2 save # 변경된 목록 저장 촬영 성공 시 별도 `YYYY-MM-DD.capture.json`을 저장합니다. 토큰 갱신·공개 URL 확인·컨테이너 준비가 실패하면 워커가 60초 뒤 재시도합니다(하루 작업 최대 5회 시도). 같은 날 재실행은 저장된 일출 사진을 재사용하므로 게시 준비 중 오류가 나도 촬영을 잃지 않습니다. 일출+5분의 촬영 시간을 놓쳤다면 그날은 건너뜁니다. 다음 날짜에 전날 사진을 자동 게시하지 않습니다. 정지: `pm2 stop sunrise-threads`. 게시 결과가 불명확한 작업은 재실행 전 계정과 기록을 확인하세요. +# 본문과 링크 댓글 + +`.threads-captions.json`의 `template`은 사진 본문, `replyTemplate`은 자동 댓글입니다. +두 항목 모두 문자열 배열이며 기존 날짜·날씨·`{url}` 치환자를 사용할 수 있습니다. +기본 본문에는 링크가 없고 댓글에는 사이트 링크가 들어갑니다. +기존 파일에 `replyTemplate`이 없으면 이전 동작을 유지하므로 새 샘플을 복사하거나 해당 항목을 추가하세요. + +```sh +cd /opt/sunrise +git pull --ff-only +cp .threads-captions.sample.json .threads-captions.json +npm run threads:captions +npm run build +pm2 restart sunrise-threads +``` + +복사하면 사용자 지정 문구도 샘플로 교체됩니다. 이후 문구만 편집할 때는 빌드나 재시작이 필요 없습니다. +사이트 서버가 HTML을 메모리에 보관한다면 사이트 프로세스도 재시작하세요. + +본문 게시 성공 → 촬영 이미지 두 장 삭제 → 링크 댓글 게시 순서로 실행됩니다. +댓글은 `reply_to_id`와 `link_attachment`를 사용합니다. 링크 대상은 설정의 `siteUrl` origin입니다. +사이트 HTML의 Open Graph 메타데이터와 `/og-sunrise.jpg`가 미리보기 카드에 사용됩니다. +이 대표 이미지는 사이트의 정적 자산이며 매일 생성·삭제하는 촬영 사진과 별개입니다. +카드 표시 여부와 캐시 갱신 시점은 Threads가 결정합니다. + +댓글 문구는 본문 게시 기록에 저장됩니다. 댓글 생성 실패는 다음 작업 실행 때 재시도하며 +본문을 다시 게시하지 않습니다. 과거 기록에 댓글 문구가 없으면 소급 게시하지 않습니다. +별도 `<본문기록>.reply.json`에 `publishing` 상태가 남으면 중복 방지를 위해 자동 재게시하지 않습니다. +로그와 실제 Threads 댓글을 확인한 뒤 복구해야 합니다. `published` 상태에는 댓글 ID가 기록됩니다. diff --git a/index.html b/index.html index 0edec66..2a481a0 100644 --- a/index.html +++ b/index.html @@ -6,6 +6,14 @@ 해보러가기 + + + + + + + + diff --git a/public/og-sunrise.jpg b/public/og-sunrise.jpg new file mode 100644 index 0000000000000000000000000000000000000000..f506d8bbe499be8cc8909f98a4d2e70dec158a3f GIT binary patch literal 48049 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̚(Q#;#EzFf+W_o=!%RMdFAVMa!T?`jMJ$zw!-Z0 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 YfZ@)+eKD$mT{eUh06; @@ -22,6 +23,7 @@ export function loadCaptions(path: string | URL = new URL('../../.threads-captio 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' + || (config.replyTemplate !== undefined && !lines(config.replyTemplate)) || !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 @@ -53,10 +55,15 @@ export function renderCaption(config: Captions, capture: { dateDots: kstFields(date).date.replaceAll('-', '.'), hostname: new URL(capture.url).hostname, 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) => { + const expand = (lines: string[]) => lines.join('\n').replace(/\{(\w+)\}/g, (_, key: string) => { if (!(key in values)) throw new Error(`Unknown caption placeholder: ${key}`); return values[key]; }); + const text = (capture.test ? config.testPrefix : '') + expand(config.template); + const replyText = config.replyTemplate ? expand(config.replyTemplate) : undefined; if (!text.trim() || [...text].length > THREADS.captionMaxLength) throw new Error('Caption must contain 1–500 characters'); - return { text, condition }; + if (replyText !== undefined && (!replyText.trim() || [...replyText].length > THREADS.captionMaxLength)) { + throw new Error('Reply must contain 1–500 characters'); + } + return { text, condition, replyText }; } diff --git a/server/threads/previewCaptions.ts b/server/threads/previewCaptions.ts index 8ca816a..5d42834 100644 --- a/server/threads/previewCaptions.ts +++ b/server/threads/previewCaptions.ts @@ -12,7 +12,8 @@ const samples: Record = { 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(), + const { text, replyText } = 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}`); + if (replyText) console.log(`\n[댓글]\n${replyText}`); } diff --git a/server/threads/publisher.ts b/server/threads/publisher.ts index 65c119e..61db32c 100644 --- a/server/threads/publisher.ts +++ b/server/threads/publisher.ts @@ -60,6 +60,16 @@ export async function publishCarousel(token: string, imageUrls: string[], text: return publishContainer(token, container.id, beforePublish, request, wait); } +export async function publishReply(token: string, postId: string, text: string, linkUrl: string, + beforePublish: (id: string) => Promise, request: typeof fetch = fetch, + wait: (ms: number) => Promise = delay) { + const container = await threadsRequest(token, '/me/threads', { + media_type: 'TEXT', text, reply_to_id: postId, link_attachment: linkUrl, + }, 'POST', request); + if (!container.id) throw new Error('Threads returned no reply container ID'); + return publishContainer(token, container.id, beforePublish, request, wait); +} + async function publishContainer(token: string, id: string, beforePublish: (id: string) => Promise, request: typeof fetch, wait: (ms: number) => Promise) { await waitForMedia(token, id, request, wait); diff --git a/server/threads/reply.ts b/server/threads/reply.ts new file mode 100644 index 0000000..b458515 --- /dev/null +++ b/server/threads/reply.ts @@ -0,0 +1,25 @@ +import { readFile, writeFile, rename } from 'node:fs/promises'; +import { randomUUID } from 'node:crypto'; +import { publishReply } from './publisher.ts'; + +/** A separate exclusive claim prevents concurrent or ambiguous reply retries. */ +export async function replyToPublished(recordPath: string, getToken: () => Promise, + publish: typeof publishReply = publishReply) { + const record = JSON.parse(await readFile(recordPath, 'utf8')); + if (record.status !== 'published' || !record.postId || !record.imagesDeletedAt || !record.replyText) return; + const replyPath = `${recordPath}.reply.json`; + try { + const existing = JSON.parse(await readFile(replyPath, 'utf8')); + if (existing.status !== 'published') console.warn(`Reply outcome unknown; inspect ${replyPath} before retrying`); + return; + } catch (error) { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error; } + const token = await getToken(); + const replyId = await publish(token, record.postId, record.replyText, record.replyUrl, async containerId => { + await writeFile(replyPath, JSON.stringify({ status: 'publishing', postId: record.postId, containerId }, null, 2), + { flag: 'wx', mode: 0o600 }); + }); + const temporary = `${replyPath}.${randomUUID()}.tmp`; + await writeFile(temporary, JSON.stringify({ status: 'published', postId: record.postId, replyId }, null, 2), { mode: 0o600 }); + await rename(temporary, replyPath); + console.log(`Threads reply published: ${replyId}`); +} diff --git a/server/threads/run.ts b/server/threads/run.ts index ec0631e..c74e5a5 100644 --- a/server/threads/run.ts +++ b/server/threads/run.ts @@ -8,6 +8,7 @@ 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 { replyToPublished } from './reply.ts'; import { loadCaptions, renderCaption } from './captions.ts'; import { WEATHER } from '../../src/constants/weather.ts'; import type { WeatherSample } from '../../src/weather/weather.ts'; @@ -69,10 +70,14 @@ async function main() { 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); + // An older failed reply must not prevent today's time-sensitive capture. + try { await replyToPublished(join(stateDir, name), accessToken); } + catch (error) { console.warn(`Pending reply ${name}: ${error instanceof Error ? error.message : 'failed'}`); } } } } if (!dryRun && await readJson(recordPath)) { + await replyToPublished(recordPath, accessToken); console.log(`${schedule.date}: publication already recorded; inspect the record if status is publishing`); return; } @@ -89,9 +94,10 @@ async function main() { 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, + const { text, condition, replyText } = renderCaption(loadCaptions(), { weather: captured.weather ?? null, capturedAt, sunrise: captured.sunrise, url: siteUrl.origin, test: postNow }); console.log(`Caption (${condition}):\n${text}`); + if (replyText) console.log(`Reply:\n${replyText}`); 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(); @@ -102,7 +108,8 @@ async function main() { 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' }; + capturedAt, images, weather: captured.weather ?? null, condition, text, replyText, + replyUrl: siteUrl.origin, 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 }); @@ -112,6 +119,7 @@ async function main() { console.log(`Threads published: ${postId}`); await cleanupPublished(recordPath, imageDir); console.log('Deleted both source images after publication'); + await replyToPublished(recordPath, accessToken); } async function captureSunrise(schedule: ReturnType, immediate: boolean) { diff --git a/tests/threads-captions.test.ts b/tests/threads-captions.test.ts index 7875f52..b1856dc 100644 --- a/tests/threads-captions.test.ts +++ b/tests/threads-captions.test.ts @@ -7,7 +7,8 @@ test('default post shows sunrise time rather than the five-minute-later capture const config = loadCaptions(new URL('../.threads-captions.sample.json', import.meta.url)); const result = renderCaption(config, { weather: { ...sample, cloudCover: 90 }, capturedAt: '2026-11-01T21:58:00Z', sunrise: '2026-11-01T21:53:00Z', url: 'https://sunrise.horoli.kr', test: false }); - assert.equal(result.text, '오늘의 관악산 일출이에요\n2026.11.02 · 일출 06:53\n날씨: 흐림\nsunrise.horoli.kr'); + assert.equal(result.text, '오늘의 관악산 일출이에요\n2026.11.02 · 일출 06:53\n날씨: 흐림'); + assert.equal(result.replyText, '관악산 일출 보러 가기\nhttps://sunrise.horoli.kr'); }); test('caption weather uses precipitation first and does not assume missing data means clear', () => { const config = loadCaptions(); diff --git a/tests/threads-reply.test.ts b/tests/threads-reply.test.ts new file mode 100644 index 0000000..81c71a5 --- /dev/null +++ b/tests/threads-reply.test.ts @@ -0,0 +1,52 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtemp, writeFile, readFile, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { replyToPublished } from '../server/threads/reply.ts'; + +test('confirmed reply is recorded separately and skipped on restart', async () => { + const dir = await mkdtemp(join(tmpdir(), 'threads-reply-')); + const path = join(dir, 'record.json'); + let calls = 0; + try { + const original = JSON.stringify({ status: 'published', postId: 'parent', imagesDeletedAt: 'now', + replyText: 'Link', replyUrl: 'https://example.com' }); + await writeFile(path, original); + const publish = async (_token: string, post: string, text: string, url: string, claim: (id: string) => Promise) => { + calls++; + assert.equal(post, 'parent'); + assert.equal(text, 'Link'); + assert.equal(url, 'https://example.com'); + await claim('container'); + return 'reply-id'; + }; + await replyToPublished(path, async () => 'token', publish); + await replyToPublished(path, async () => 'token', publish); + assert.equal(calls, 1); + assert.equal(await readFile(path, 'utf8'), original); + assert.equal(JSON.parse(await readFile(`${path}.reply.json`, 'utf8')).replyId, 'reply-id'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); + +test('reply waits for image cleanup, and a lost publish response is never sent twice', async () => { + const dir = await mkdtemp(join(tmpdir(), 'threads-reply-')); + const path = join(dir, 'record.json'); + const record = { status: 'published', postId: 'parent', replyText: 'Link', replyUrl: 'https://example.com' }; + let calls = 0; + const publish = async (_token: string, _post: string, _text: string, _url: string, claim: (id: string) => Promise) => { + calls++; + await claim('container'); + throw new Error('Connection lost'); + }; + try { + await writeFile(path, JSON.stringify(record)); + await replyToPublished(path, async () => 'token', publish); + assert.equal(calls, 0); + await writeFile(path, JSON.stringify({ ...record, imagesDeletedAt: 'now' })); + await assert.rejects(replyToPublished(path, async () => 'token', publish), /Connection lost/); + await replyToPublished(path, async () => 'token', publish); + assert.equal(calls, 1); + assert.equal(JSON.parse(await readFile(`${path}.reply.json`, 'utf8')).status, 'publishing'); + } finally { await rm(dir, { recursive: true, force: true }); } +}); diff --git a/tests/threads.test.ts b/tests/threads.test.ts index f2f9f01..04b1a7a 100644 --- a/tests/threads.test.ts +++ b/tests/threads.test.ts @@ -2,7 +2,25 @@ import { test } from 'node:test'; import assert from 'node:assert/strict'; import { calculateSun } from '../src/astronomy/sunCalculator.ts'; import { observer } from '../src/config/observer.ts'; -import { dailySchedule, publishImage, publishCarousel } from '../server/threads/publisher.ts'; +import { dailySchedule, publishImage, publishCarousel, publishReply } from '../server/threads/publisher.ts'; + +test('reply targets the published parent and attaches a preview link, claiming before publish', async () => { + const replies = [{ id: 'reply-container' }, { status: 'FINISHED' }, { id: 'reply' }]; + let claimed = false; + const id = await publishReply('token', 'parent-post', 'Visit us', 'https://sunrise.horoli.kr', async () => { + claimed = true; + }, async (url, init) => { + const params = init?.body as URLSearchParams; + if (String(url).endsWith('/me/threads')) { + assert.equal(params.get('reply_to_id'), 'parent-post'); + assert.equal(params.get('link_attachment'), 'https://sunrise.horoli.kr'); + assert.equal(params.get('media_type'), 'TEXT'); + } + if (String(url).endsWith('/me/threads_publish')) assert.ok(claimed); + return new Response(JSON.stringify(replies.shift())); + }, async () => {}); + assert.equal(id, 'reply'); +}); test('schedule uses the Korean date and existing SunCalc sunrise', () => { const now = new Date('2026-11-01T18:00:00Z');