- 게이트: DISCLOSURE_TOP_MISSING, APPROVED_*, UNAPPROVED_IN_INDEXABLE_BUILD, PRESS_VOICE 규칙화, 픽스처 추가 (16/16) - 생성기: --site 옵션, 병원 확인 답변(clinicAnswers) 근거 [A] - default_briefs.mjs 첫 배치 자동 기획, workers/supporters-build/run.mjs - 랜딩: SAMPLE REPORT 라벨 "실제 사례"로 (haewon 지시) - v2 §11-6·§11-7 기록, E2E 리포트 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
180 lines
13 KiB
JavaScript
180 lines
13 KiB
JavaScript
// 서포터즈 자동 빌드 워커 (v1 §3 파이프라인, v2 §7-6, §12-3). 병원 URL 하나로 근거 수집 → 데이터 → 글 생성 → 빌드·게이트 → (배포) 까지 순서대로 돈다.
|
|
// 실행 환경에 독립적이다. 로컬·GitHub Actions·Trigger.dev 태스크 어디서든 `node workers/supporters-build/run.mjs ...` 로 부른다.
|
|
//
|
|
// node workers/supporters-build/run.mjs --clinic <id> --url <https://...> [--name 상호] [--work ~/supporters-builds/<id>]
|
|
// [--inputs inputs.json | --from-supabase] [--brief briefs.json] [--max-posts 6] [--max-cost-usd 3]
|
|
// [--deploy] [--build-id <supporter_builds.id>] [--from <phase>] [--skip-generate]
|
|
//
|
|
// 단계: evidence → data → inputs → briefs → generate → build → deploy → done
|
|
// 단계마다 <work>/status.json 을 쓰고, --build-id 와 SUPABASE_SERVICE_ROLE_KEY 가 있으면 supporter_builds 행을 갱신한다.
|
|
// 게이트(check.mjs)가 error 를 내면 status=gate_failed 로 멈춘다. 사람 게이트(검토·승인)는 여기서 자동화하지 않는다.
|
|
import { spawnSync } from 'node:child_process';
|
|
import { cpSync, existsSync, mkdirSync, readFileSync, writeFileSync, readdirSync, rmSync } from 'node:fs';
|
|
import { join, resolve, dirname } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { homedir } from 'node:os';
|
|
|
|
const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url)));
|
|
const SUP = join(ROOT, 'supporters');
|
|
const TEMPLATE = join(ROOT, 'templates', 'supporters-astro');
|
|
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}`);
|
|
const clinic = opt('clinic'); const url = opt('url');
|
|
if (!clinic || !url) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
|
|
const WORK = resolve(opt('work', join(homedir(), 'supporters-builds', clinic)));
|
|
const today = new Date().toISOString().slice(0, 10);
|
|
const PHASES = ['evidence', 'data', 'inputs', 'briefs', 'generate', 'build', 'deploy', 'done'];
|
|
const startFrom = opt('from', 'evidence');
|
|
|
|
(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 statusPath = join(WORK, 'status.json');
|
|
const status = existsSync(statusPath) ? JSON.parse(readFileSync(statusPath, 'utf8')) : { clinic, url, status: 'queued', phases: [], posts: [], createdAt: new Date().toISOString() };
|
|
async function setStatus(patch) {
|
|
Object.assign(status, patch, { updatedAt: new Date().toISOString() });
|
|
mkdirSync(WORK, { recursive: true });
|
|
writeFileSync(statusPath, JSON.stringify(status, null, 1));
|
|
const id = opt('build-id'); const sb = process.env.SUPABASE_URL ?? process.env.VITE_SUPABASE_URL; const key = process.env.SUPABASE_SERVICE_ROLE_KEY;
|
|
if (id && sb && key) {
|
|
const body = { status: status.status, phase: status.phase, phases: status.phases, posts: status.posts, preview_url: status.previewUrl ?? null, report: status.report ?? null, error: status.error ?? null, updated_at: new Date().toISOString() };
|
|
const res = await fetch(`${sb}/rest/v1/supporter_builds?id=eq.${id}`, { method: 'PATCH', headers: { apikey: key, authorization: `Bearer ${key}`, 'content-type': 'application/json', prefer: 'return=minimal' }, body: JSON.stringify(body) }).catch((e) => ({ ok: false, text: async () => String(e) }));
|
|
if (!res.ok) console.warn(` (supporter_builds 갱신 실패: ${res.status ?? ''} ${(await res.text()).slice(0, 120)})`);
|
|
}
|
|
}
|
|
function run(cmd, argv, { cwd = SUP, env = {} } = {}) {
|
|
console.log(` $ ${cmd} ${argv.join(' ')}`);
|
|
const r = spawnSync(cmd, argv, { cwd, stdio: ['ignore', 'pipe', 'pipe'], env: { ...process.env, ...env }, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 });
|
|
const out = (r.stdout ?? '') + (r.stderr ?? '');
|
|
return { ok: r.status === 0, code: r.status, out };
|
|
}
|
|
const tail = (s, n = 12) => s.trim().split('\n').slice(-n).join('\n');
|
|
|
|
async function phase(name, fn) {
|
|
if (PHASES.indexOf(name) < PHASES.indexOf(startFrom)) { console.log(`· ${name}: 건너뜀 (--from ${startFrom})`); return; }
|
|
const entry = { phase: name, status: 'running', startedAt: new Date().toISOString() };
|
|
status.phases = status.phases.filter((p) => p.phase !== name).concat(entry);
|
|
await setStatus({ status: 'running', phase: name });
|
|
console.log(`\n▶ ${name}`);
|
|
try {
|
|
const summary = await fn();
|
|
Object.assign(entry, { status: 'ok', finishedAt: new Date().toISOString(), summary: summary ?? '' });
|
|
await setStatus({});
|
|
console.log(` ✓ ${name}${summary ? ` · ${summary}` : ''}`);
|
|
} catch (e) {
|
|
Object.assign(entry, { status: 'failed', finishedAt: new Date().toISOString(), log: String(e.message ?? e).slice(0, 4000) });
|
|
await setStatus({ status: e.gate ? 'gate_failed' : 'failed', error: String(e.message ?? e).slice(0, 2000) });
|
|
console.error(` ✗ ${name}: ${String(e.message ?? e).slice(0, 1500)}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
const EV_ROOT = join(WORK, 'evidence'); const EV = join(EV_ROOT, clinic);
|
|
const SITE = join(WORK, 'site'); const DATA = join(SITE, 'src/data'); const POSTS = join(SITE, 'src/content/posts');
|
|
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
|
const writeJson = (p, j) => writeFileSync(p, JSON.stringify(j, null, 2) + '\n');
|
|
|
|
// 1. 근거 수집
|
|
await phase('evidence', () => {
|
|
const r = run('node', ['scripts/collect_evidence.mjs', '--clinic', clinic, '--url', url, '--out', EV_ROOT]);
|
|
if (!r.ok || !existsSync(join(EV, 'index.json'))) throw new Error(`근거 수집 실패\n${tail(r.out)}`);
|
|
const idx = readJson(join(EV, 'index.json'));
|
|
return `${idx.pages.length}페이지 · 유형 ${Object.entries(idx.byType).map(([k, v]) => `${k} ${v}`).join(', ')}`;
|
|
});
|
|
|
|
// 2. 템플릿 복제 + 팩트 시트·의료진 초안
|
|
await phase('data', () => {
|
|
if (!existsSync(TEMPLATE)) throw new Error(`템플릿 없음: ${TEMPLATE} (supporters 에서 npm run export:template)`);
|
|
rmSync(SITE, { recursive: true, force: true });
|
|
cpSync(TEMPLATE, SITE, { recursive: true, filter: (s) => !/\/(node_modules|dist|\.astro)(\/|$)/.test(s) });
|
|
for (const rel of ['astro.config.mjs', 'vercel.json']) { const p = join(SITE, rel); writeFileSync(p, readFileSync(p, 'utf8').split('__CLINIC_ID__').join(clinic)); }
|
|
cpSync(join(EV, 'home_text.txt'), join(SITE, 'scripts', 'home_text.txt'));
|
|
|
|
// 팩트 시트: 후보 중 첫 값. 사람이 대조하기 전까지 "확인 대기"가 붙는 필드가 많다(v2 §4-5).
|
|
const fact = readJson(join(DATA, 'factSheet.json'));
|
|
const draft = existsSync(join(EV, 'facts.draft.json')) ? readJson(join(EV, 'facts.draft.json')).candidates ?? {} : {};
|
|
const first = (k) => Object.keys(draft[k] ?? {})[0] ?? '';
|
|
const name = opt('name') || (readJson(join(EV, 'index.json')).pages.find((p) => p.type === 'home')?.title ?? '').replace(/\s*[|·-].*$/, '').trim();
|
|
Object.assign(fact, { name: name || fact.name, shortName: name || fact.shortName, url: url.replace(/\/$/, ''), phone: first('phone').replace(/\./g, '-'), representative: /^[가-힣]{2,4}$/.test(first('representative')) ? first('representative') : '', businessNo: first('businessNo'), checkedAt: today, nextCheck: new Date(Date.now() + 30 * 864e5).toISOString().slice(0, 10) });
|
|
const addr = first('address'); if (addr) { fact.address.full = addr; fact.address.street = addr.replace(/^(서울(특별시|시)?|경기도?|부산(광역시)?|인천(광역시)?|대구(광역시)?|대전(광역시)?|광주(광역시)?)\s*[가-힣]+구?\s*/, ''); fact.address.source = Object.values(draft.address ?? {})[0]?.[0] ?? ''; }
|
|
writeJson(join(DATA, 'factSheet.json'), fact);
|
|
|
|
// 의료진: 이름·직함·URL 만. 약력이 이미지 글자면 빈 배열(사람 입력).
|
|
const authors = readJson(join(DATA, 'authors.json'));
|
|
const doctors = existsSync(join(EV, 'doctors.json')) ? readJson(join(EV, 'doctors.json')).doctors ?? [] : [];
|
|
authors.physicians = Object.fromEntries(doctors.filter((d) => d.name).map((d) => [d.id, { name: d.name, title: d.title || '원장', credentials: d.credentials ?? [], url: d.url, sameAs: [], image: null, specialty: '' }]));
|
|
authors.supporters = authors.supporters && Object.keys(authors.supporters).length ? authors.supporters : { editor: { name: '확인 대기', role: '편집 책임', bio: '', email: '', sameAs: [] } };
|
|
writeJson(join(DATA, 'authors.json'), authors);
|
|
const site = readJson(join(DATA, 'site.json'));
|
|
Object.assign(site, { siteName: site.siteName || `${fact.shortName || '병원'} 서포터즈`, editorId: site.editorId || Object.keys(authors.supporters)[0], clinicDoctors: Object.keys(authors.physicians).slice(0, 4) });
|
|
writeJson(join(DATA, 'site.json'), site);
|
|
status.report = { ...(status.report ?? {}), physicians: Object.entries(authors.physicians).map(([id, p]) => ({ id, name: p.name, title: p.title })) };
|
|
return `${fact.shortName || '(상호 확인 대기)'} · 전화 ${fact.phone || '확인 대기'} · 의료진 ${doctors.length}명`;
|
|
});
|
|
|
|
// 3. 병원 입력 반영 (있으면)
|
|
await phase('inputs', () => {
|
|
if (!opt('inputs') && !flag('from-supabase')) return '입력 없음 (확인 대기로 발행)';
|
|
const argv = ['scripts/apply_inputs.mjs', '--clinic', clinic, ...(opt('inputs') ? ['--inputs', resolve(opt('inputs'))] : ['--from-supabase'])];
|
|
// apply_inputs 는 자기 폴더 기준으로 데이터를 읽으므로 사이트 복제본의 scripts 로 실행한다
|
|
cpSync(join(SUP, 'scripts', 'apply_inputs.mjs'), join(SITE, 'scripts', 'apply_inputs.mjs'));
|
|
const r = run('node', argv, { cwd: SITE });
|
|
if (!r.ok) throw new Error(tail(r.out));
|
|
return tail(r.out, 3);
|
|
});
|
|
|
|
// 4. 기획 목록
|
|
await phase('briefs', () => {
|
|
if (opt('brief')) { cpSync(resolve(opt('brief')), join(WORK, 'briefs.json')); return `사람이 준 brief 사용: ${opt('brief')}`; }
|
|
const r = run('node', ['scripts/default_briefs.mjs', '--clinic', clinic, '--evidence', EV, '--site', SITE, '--out', join(WORK, 'briefs.json'), '--max', opt('max-posts', '6')]);
|
|
if (!r.ok) throw new Error(tail(r.out));
|
|
return tail(r.out, 1);
|
|
});
|
|
|
|
// 5. 글 생성
|
|
await phase('generate', () => {
|
|
if (flag('skip-generate')) return '건너뜀 (--skip-generate)';
|
|
const GEN = join(WORK, 'gen');
|
|
const argv = ['scripts/generate_posts.mjs', '--clinic', clinic, '--site', SITE, '--evidence', EV, '--brief', join(WORK, 'briefs.json'), '--out', GEN, '--today', today];
|
|
if (opt('max-cost-usd')) argv.push('--max-cost-usd', opt('max-cost-usd'));
|
|
const r = run('node', argv);
|
|
if (!existsSync(join(GEN, 'report.json'))) throw new Error(`생성기 실패\n${tail(r.out, 20)}`);
|
|
const report = readJson(join(GEN, 'report.json'));
|
|
mkdirSync(POSTS, { recursive: true });
|
|
const accepted = report.posts.filter((p) => ['ok', 'ok_needs_review'].includes(p.status));
|
|
for (const p of accepted) cpSync(join(GEN, 'posts', `${p.id}.md`), join(POSTS, `${p.id}.md`));
|
|
status.posts = accepted.map((p) => ({ id: p.id, title: p.title, category: p.category, status: p.status, reviewStatus: 'pending', pending: p.pending ?? [], claimFlags: p.claimFlags ?? [] }));
|
|
status.report = { ...(status.report ?? {}), generator: report.summary, totalCostUsd: report.totalCostUsd };
|
|
if (!accepted.length) throw new Error(`통과한 글이 없음\n${tail(r.out, 20)}`);
|
|
return `${accepted.length}/${report.posts.length}편 채택 · $${report.totalCostUsd} · 게이트 실패 ${report.summary.gateFailed}`;
|
|
});
|
|
|
|
// 6. 빌드 + 게이트
|
|
await phase('build', () => {
|
|
const inst = run('npm', ['ci', '--no-audit', '--no-fund'], { cwd: SITE });
|
|
if (!inst.ok) throw new Error(`npm ci 실패\n${tail(inst.out)}`);
|
|
const b = run('npx', ['astro', 'build'], { cwd: SITE });
|
|
if (!b.ok) throw new Error(`astro build 실패\n${tail(b.out, 25)}`);
|
|
const g = run('node', ['scripts/check.mjs', '--today', today], { cwd: SITE });
|
|
status.report = { ...(status.report ?? {}), gate: tail(g.out, 1) };
|
|
if (!g.ok) { const e = new Error(`발행 게이트 실패\n${tail(g.out, 30)}`); e.gate = true; throw e; }
|
|
return tail(g.out, 1);
|
|
});
|
|
|
|
// 7. 배포 (선택) : Vercel CLI 로 병원당 프로젝트 supporters-<id>, noindex(vercel.json 의 X-Robots-Tag) 유지
|
|
await phase('deploy', () => {
|
|
if (!flag('deploy')) { status.previewUrl = null; return '건너뜀 (--deploy 없음). dist/ 는 ' + join(SITE, 'dist'); }
|
|
const project = `supporters-${clinic}`;
|
|
const link = run('vercel', ['link', '--yes', '--project', project], { cwd: SITE });
|
|
if (!link.ok) throw new Error(`vercel link 실패\n${tail(link.out)}`);
|
|
const dep = run('vercel', ['deploy', '--prod', '--yes'], { cwd: SITE, env: { SITE_URL: `https://${project}.vercel.app` } });
|
|
const m = dep.out.match(/https:\/\/[^\s]+\.vercel\.app/g);
|
|
if (!dep.ok || !m) throw new Error(`vercel deploy 실패\n${tail(dep.out)}`);
|
|
status.previewUrl = `https://${project}.vercel.app`;
|
|
return status.previewUrl;
|
|
});
|
|
|
|
await phase('done', async () => { await setStatus({ status: 'preview' }); return `status.json → ${statusPath}`; });
|
|
console.log(`\n완료: ${status.posts.length}편 · ${status.previewUrl ?? '미배포'} · ${WORK}`);
|