o2o-infinith-demo/workers/supporters-build/poll.mjs
Haewon Kam ee8c06f53b feat(supporters): haewon 결정 반영 — 채널 인기 전체 탭 제거, 단가 상한 $10 기본, 큐 폴러(poll.mjs)+launchd, /supporters/:id 페이지, URL 신청→빌드 큐, 수집기 UA·슬래시 수정, 기획 목록 일반화(원진 6편)
WIP: 원진성형외과 회귀는 글 생성 단계에서 네트워크 끊김(fetch failed)으로 중단. 다음 세션에 --from generate 로 재실행.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 18:54:01 +09:00

63 lines
3.9 KiB
JavaScript

// 빌드 큐 폴러. supporter_builds 에서 status=queued 행을 하나 집어 run.mjs 를 돌린다 (v2 §12-3 워커 위치와 무관하게 동작).
// 어디서든 돈다: 지금은 haewon 의 Mac(launchd) 또는 Claude 세션에서 수동, 나중에 Trigger.dev 태스크·VPS cron 에 같은 파일을 올린다.
//
// node workers/supporters-build/poll.mjs 큐를 한 번 비우고 종료 (cron/launchd 용)
// node workers/supporters-build/poll.mjs --loop 60 60초마다 반복 (상주 프로세스)
// 옵션: --no-deploy --max-cost-usd 10 --work-root ~/supporters-builds
//
// 필요한 환경: .env 의 VITE_SUPABASE_URL, SUPABASE_SERVICE_ROLE_KEY, OPENAI_API_KEY, (배포 시) vercel CLI 로그인.
// 같은 행을 두 폴러가 집지 않도록 queued → running 갱신을 조건부 PATCH(status=eq.queued)로 하고, 갱신된 행이 없으면 건너뛴다.
import { spawnSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import { homedir } from 'node:os';
const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url)));
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
const flag = (k) => args.includes(`--${k}`);
(function loadEnv() { const p = join(ROOT, '.env'); if (!existsSync(p)) return; for (const line of readFileSync(p, 'utf8').split('\n')) { const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/); if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, ''); } })();
const SB = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const KEY = process.env.SUPABASE_SERVICE_ROLE_KEY;
if (!SB || !KEY) { console.error('VITE_SUPABASE_URL / SUPABASE_SERVICE_ROLE_KEY 없음'); process.exit(2); }
const H = { apikey: KEY, authorization: `Bearer ${KEY}`, 'content-type': 'application/json' };
const WORK_ROOT = resolve(opt('work-root', join(homedir(), 'supporters-builds')));
const loopSec = Number(opt('loop', '0'));
async function nextQueued() {
const r = await fetch(`${SB}/rest/v1/supporter_builds?status=eq.queued&order=created_at.asc&limit=1&select=id,clinic_id,url,clinic_name`, { headers: H });
const rows = await r.json();
return rows[0] ?? null;
}
async function claim(id) {
const r = await fetch(`${SB}/rest/v1/supporter_builds?id=eq.${id}&status=eq.queued`, { method: 'PATCH', headers: { ...H, prefer: 'return=representation' }, body: JSON.stringify({ status: 'running', phase: 'claimed', updated_at: new Date().toISOString() }) });
const rows = await r.json();
return Array.isArray(rows) && rows.length > 0;
}
async function runOne(row) {
const argv = [join(ROOT, 'workers/supporters-build/run.mjs'), '--clinic', row.clinic_id, '--url', row.url, '--work', join(WORK_ROOT, `${row.clinic_id}-${row.id.slice(0, 8)}`), '--build-id', row.id, '--max-cost-usd', opt('max-cost-usd', '10')];
if (row.clinic_name) argv.push('--name', row.clinic_name);
if (!flag('no-deploy')) argv.push('--deploy');
console.log(`▶ ${row.clinic_id} ${row.url} (${row.id})`);
const r = spawnSync('node', argv, { cwd: ROOT, stdio: 'inherit', env: process.env });
console.log(`${r.status === 0 ? '✓' : '✗'} ${row.clinic_id} exit ${r.status}`);
}
async function drain() {
let n = 0;
for (;;) {
const row = await nextQueued();
if (!row) break;
if (!(await claim(row.id))) continue;
await runOne(row); n++;
}
return n;
}
if (loopSec > 0) {
console.log(`큐 폴링 ${loopSec}초 간격 · ${SB}`);
for (;;) { try { const n = await drain(); if (n) console.log(`처리 ${n}건`); } catch (e) { console.error('폴링 오류', e.message); } await new Promise((r) => setTimeout(r, loopSec * 1000)); }
} else {
const n = await drain();
console.log(n ? `처리 ${n}건` : '큐 비어 있음');
}