// 서포터즈 자동 빌드 워커 (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 --url [--name 상호] [--work ~/supporters-builds/] // [--inputs inputs.json | --from-supabase] [--brief briefs.json] [--max-posts 6] [--max-cost-usd 3] // [--deploy] [--build-id ] [--from ] [--skip-generate] // // 단계: evidence → data → inputs → briefs → generate → build → deploy → done // 단계마다 /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'; import { createRequire } from 'node:module'; const ROOT = resolve(fileURLToPath(new URL('../../', import.meta.url))); const yaml = createRequire(join(ROOT, 'supporters', 'package.json'))('js-yaml'); // supporters 의존성 재사용 (js-yaml v4 load 는 safe) 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 --url '); 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', 'discover', 'youtube', 'images', '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)`); // 재실행이면 이미 만든 글과 유튜브 집계는 보존한다(생성 비용·API 절약). --regenerate 면 글도 버린다. const KEEP = join(WORK, 'keep'); rmSync(KEEP, { recursive: true, force: true }); mkdirSync(KEEP, { recursive: true }); if (existsSync(POSTS) && !flag('regenerate')) cpSync(POSTS, join(KEEP, 'posts'), { recursive: true }); if (existsSync(join(DATA, 'videos.json'))) cpSync(join(DATA, 'videos.json'), join(KEEP, 'videos.json')); rmSync(SITE, { recursive: true, force: true }); cpSync(TEMPLATE, SITE, { recursive: true, filter: (s) => !/\/(node_modules|dist|\.astro)(\/|$)/.test(s) }); if (existsSync(join(KEEP, 'posts'))) cpSync(join(KEEP, 'posts'), POSTS, { recursive: true }); if (existsSync(join(KEEP, 'videos.json'))) cpSync(join(KEEP, 'videos.json'), join(DATA, 'videos.json')); 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}명`; }); // 2a. 채널 발견 + 엔티티 검증 (datacrawling 스킬). 상호·URL 로 유튜브·인스타·페북·블로그·틱톡·카카오·강남언니·플레이스를 찾고, CONFIRMED 만 사이트에 반영한다. CANDIDATE 는 사람 확인 목록. await phase('discover', () => { if (flag('no-discover')) return '건너뜀 (--no-discover)'; const fact = readJson(join(DATA, 'factSheet.json')); const argv = ['scripts/discover_channels.mjs', '--clinic', clinic, '--url', url, '--evidence', EV, '--out', join(WORK, 'channels.json')]; if (opt('name') || fact.shortName) argv.push('--name', opt('name') || fact.shortName); const r = run('node', argv); if (!r.ok || !existsSync(join(WORK, 'channels.json'))) throw new Error(`채널 탐색 실패\n${tail(r.out)}`); const ch = readJson(join(WORK, 'channels.json')); cpSync(join(WORK, 'channels.json'), join(DATA, 'channels.json')); // CONFIRMED 첫 항목(구독자·점수 순)을 팩트 시트 surfaces 에. 유튜브는 youtube 단계가 이 값을 쓴다. const top = (p) => (ch.platforms[p] ?? []).find((x) => x.status === 'CONFIRMED'); const LABEL = { youtube: '유튜브', instagram: '인스타그램', facebook: '페이스북', naverBlog: '네이버 블로그', tiktok: '틱톡', kakao: '카카오톡 채널', gangnamunni: '강남언니', naverPlace: '네이버 플레이스', babitalk: '바비톡' }; fact.surfaces = fact.surfaces ?? {}; for (const p of Object.keys(LABEL)) { const x = top(p); if (!x) continue; fact.surfaces[p] = { ...(fact.surfaces[p] ?? {}), label: LABEL[p], url: x.url, title: x.title ?? undefined, checked: ch.checkedAt, ...(x.subscribers ? { subscribers: x.subscribers, videos: x.videos } : {}) }; } if (!fact.kakao && top('kakao')) fact.kakao = top('kakao').url; writeJson(join(DATA, 'factSheet.json'), fact); status.report = { ...(status.report ?? {}), channels: { inventory: ch.inventory, confirmed: Object.fromEntries(Object.entries(ch.platforms).map(([p, l]) => [p, l.filter((x) => x.status === 'CONFIRMED').map((x) => ({ key: x.key, url: x.url, title: x.title, subscribers: x.subscribers, evidence: x.evidence }))])), candidates: Object.fromEntries(Object.entries(ch.platforms).map(([p, l]) => [p, l.filter((x) => x.status === 'CANDIDATE').map((x) => ({ key: x.key, url: x.url, title: x.title, evidence: x.evidence }))])) } }; return ch.inventory.map((i) => `${i.platform} ${i.state}${i.confirmed ? `(${i.confirmed})` : ''}`).join(' · '); }); // 2b. 로고·파비콘 (--logo ) + 유튜브 채널 집계 (--youtube <채널> 또는 발견 단계의 CONFIRMED 유튜브) await phase('youtube', () => { const notes = []; // 로고: 헤더·푸터·카드 대체 이미지·파비콘. SVG 는 viewBox 를 정사각 파비콘으로 감싼다. const logoPath = opt('logo') ? resolve(opt('logo')) : null; if (logoPath && existsSync(logoPath)) { const ext = logoPath.toLowerCase().endsWith('.svg') ? 'svg' : logoPath.toLowerCase().endsWith('.png') ? 'png' : 'jpg'; mkdirSync(join(SITE, 'public/img'), { recursive: true }); cpSync(logoPath, join(SITE, 'public/img', `logo.${ext}`)); const site = readJson(join(DATA, 'site.json')); let width = 150, height = 40; if (ext === 'svg') { const svg = readFileSync(logoPath, 'utf8'); const vb = svg.match(/viewBox="([\d.\s-]+)"/)?.[1]?.split(/\s+/).map(Number); if (vb && vb[2] && vb[3]) { height = 40; width = Math.round((vb[2] / vb[3]) * 40); } // 파비콘: 흰 정사각 배경 + 로고 중앙 (가로 로고면 폭 기준 88%) const inner = svg.replace(/^[\s\S]*?]*>/, '').replace(/<\/svg>\s*$/, ''); const [vx, vy, vw, vh] = vb ?? [0, 0, 100, 100]; const scale = Math.min(56 / vw, 56 / vh); const w = vw * scale, h = vh * scale; const fav = `${inner}`; writeFileSync(join(SITE, 'public/favicon.svg'), fav); } else { cpSync(logoPath, join(SITE, 'public', `favicon.${ext}`)); } site.logo = { src: `/img/logo.${ext}`, alt: `${readJson(join(DATA, 'factSheet.json')).shortName || ''} 로고`.trim(), width, height }; site.logoFoot = site.logo; writeJson(join(DATA, 'site.json'), site); notes.push(`로고 ${ext} · 파비콘`); } else if (opt('logo')) notes.push(`로고 파일 없음: ${opt('logo')}`); // 유튜브: 채널 인자 없으면 근거 페이지 링크에서 youtube.com/channel|@handle 을 찾는다 let channel = opt('youtube') ?? null; if (!channel && existsSync(join(DATA, 'channels.json'))) { // 발견 단계에서 CONFIRMED 된 유튜브 중 구독자가 가장 많은 채널 const yt = (readJson(join(DATA, 'channels.json')).platforms?.youtube ?? []).filter((x) => x.status === 'CONFIRMED').sort((a, b) => (b.subscribers ?? 0) - (a.subscribers ?? 0))[0]; if (yt) { channel = yt.channelId ?? yt.key; notes.push(`유튜브 채널 자동 선택: ${yt.title ?? yt.key}`); } } if (!channel) { const idx = readJson(join(EV, 'index.json')); outer: for (const p of idx.pages.slice(0, 30)) { const pg = readJson(join(EV, p.file)); for (const l of pg.links ?? []) { const u = String(l.href ?? l.url ?? l ?? ''); const m = u.match(/youtube\.com\/(channel\/UC[\w-]+|@[\w.-]+)/); if (m) { channel = m[0]; break outer; } } } } if (!channel) return (notes.concat('유튜브 채널 없음 (--youtube 로 지정 가능)')).join(' · '); const prev = existsSync(join(DATA, 'videos.json')) ? readJson(join(DATA, 'videos.json')) : null; if (prev?.fetchedAt === today && prev.channel?.id) notes.push('오늘 집계본 재사용'); else { const r = run('node', ['scripts/collect_youtube.mjs', '--channel', channel, '--out', join(DATA, 'videos.json')]); if (!r.ok) throw new Error(`유튜브 집계 실패\n${tail(r.out)}`); } const v = readJson(join(DATA, 'videos.json')); // 홈 "Doctors Explain" 3편 + 팩트 시트 surfaces.youtube const site = readJson(join(DATA, 'site.json')); site.homeVideos = (v.topLong ?? []).slice(0, 3).map((x) => ({ id: x.id, title: x.title, published: x.published })); writeJson(join(DATA, 'site.json'), site); const fact = readJson(join(DATA, 'factSheet.json')); fact.surfaces = { ...(fact.surfaces ?? {}), youtube: { label: '유튜브', url: v.channel.url, title: v.channel.title, subscribers: v.channel.subscribers, videos: v.channel.videos, checked: v.fetchedAt } }; writeJson(join(DATA, 'factSheet.json'), fact); return notes.concat(`${v.channel.title} · 영상 ${v.channel.videos} · 설명형 롱폼 ${v.topLong.length} · 쇼츠 ${v.topShorts.length}`).join(' · '); }); // 2c. 홈페이지 이미지 (규칙: docs/INFINITH_Supporters_Image_Rules.md). 전후·사례·모델·비교·수상·수술 장면은 자동 제외. await phase('images', () => { if (flag('no-images')) return '건너뜀 (--no-images)'; const r = run('node', ['scripts/collect_images.mjs', '--clinic', clinic, '--evidence', EV, '--site', SITE]); if (!r.ok || !existsSync(join(DATA, 'images.json'))) throw new Error(`이미지 수집 실패\n${tail(r.out)}`); const m = readJson(join(DATA, 'images.json')); const by = (c) => m.items.filter((i) => i.category === c); const site = readJson(join(DATA, 'site.json')); const clinicImgs = by('clinic'); const wide = clinicImgs.filter((i) => i.width / i.height >= 1.4); if (wide[0] && !site.heroImage?.src) site.heroImage = { src: wide[0].src, alt: wide[0].alt, width: wide[0].width, height: wide[0].height }; const building = clinicImgs.find((i) => /외관|건물|building|exterior/.test(i.src + i.alt)) ?? clinicImgs[1] ?? clinicImgs[0]; if (building && !site.buildingImage?.src) site.buildingImage = { src: building.src, alt: building.alt, caption: building.caption }; const gal = (list) => list.map((i) => ({ src: i.src, alt: i.alt, caption: i.caption })); if (!site.insideGallery?.length) site.insideGallery = gal(clinicImgs.slice(0, 6)); if (!site.clinicGallery?.length) site.clinicGallery = gal(clinicImgs.slice(6, 12).length ? clinicImgs.slice(6, 12) : clinicImgs.slice(0, 6)); site.imageCredit = site.imageCredit || `사진 출처: ${readJson(join(DATA, 'factSheet.json')).shortName || '병원'} 공식 홈페이지 (${m.capturedAt} 캡처)`; writeJson(join(DATA, 'site.json'), site); const authors = readJson(join(DATA, 'authors.json')); let docImgs = 0; for (const i of by('doctors')) if (i.doctorId && authors.physicians?.[i.doctorId] && !authors.physicians[i.doctorId].image) { authors.physicians[i.doctorId].image = i.src; docImgs++; } writeJson(join(DATA, 'authors.json'), authors); return `${m.items.length}장 (의료진 ${by('doctors').length}, 시설 ${clinicImgs.length}, 시술 ${by('procedure').length}, 장비 ${by('equipment').length}) · 원장 사진 ${docImgs}명 · 히어로 ${site.heroImage?.src ? '설정' : '없음'}`; }); // 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'); // 이미 생성된 글이 있고 --regenerate 가 없으면 다시 만들지 않고, brief 의 영상만 frontmatter 에 붙인다(비용 0). const existing = existsSync(POSTS) ? readdirSync(POSTS).filter((f) => f.endsWith('.md')) : []; if (existing.length && !flag('regenerate')) { const briefs = readJson(join(WORK, 'briefs.json')).posts ?? []; let attached = 0; for (const b of briefs) { const p = join(POSTS, `${b.id}.md`); if (!existsSync(p)) continue; const src = readFileSync(p, 'utf8'); const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/); if (!m) continue; const data = yaml.load(m[1]) ?? {}; // brief 가 영상·히어로·갤러리·썸네일의 단일 원본. 같으면 건너뛴다 const next = { videos: b.videos ?? [], hero: b.hero ?? undefined, gallery: b.gallery ?? [], thumbnail: b.thumbnail ?? undefined }; const cur = { videos: data.videos ?? [], hero: data.hero ?? undefined, gallery: data.gallery ?? [], thumbnail: data.thumbnail ?? undefined }; if (JSON.stringify(cur) === JSON.stringify(next)) continue; data.videos = next.videos; data.gallery = next.gallery; if (next.hero) data.hero = next.hero; else delete data.hero; if (next.thumbnail) data.thumbnail = next.thumbnail; else delete data.thumbnail; writeFileSync(p, `---\n${yaml.dump(data, { lineWidth: -1, noRefs: true, quotingType: '"' })}---\n${m[2].trim()}\n`); attached++; } status.posts = (status.posts ?? []).map((x) => ({ ...x, videos: (briefs.find((b) => b.id === x.id)?.videos ?? []).length })); return `기존 글 ${existing.length}편 유지 (--regenerate 없음) · 영상 붙임 ${attached}편`; } const argv = ['scripts/generate_posts.mjs', '--clinic', clinic, '--site', SITE, '--evidence', EV, '--brief', join(WORK, 'briefs.json'), '--out', GEN, '--today', today]; argv.push('--max-cost-usd', opt('max-cost-usd', '10')); // v2 §9-5 haewon 결정: 병원당 한 번 실행 $10 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-, 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}`);