o2o-infinith-demo/workers/supporters-build/run.mjs
Haewon Kam f6aae1c396 feat(share): 링크 공유 미리보기 이미지와 파비콘
카카오톡·슬랙·iMessage 에 사이트 주소를 붙이면 회색 링크 아이콘만 나왔다.
webforai.kr 은 og:image 와 파비콘이 없었고, 서포터즈 사이트는 robots.txt 가
미리보기 봇까지 막았으며 파비콘이 Astro 기본 로고였다.

- scripts/make_share_assets.py: 1200x630 공유 이미지(JPEG)와 파비콘 세트 생성.
  서포터즈는 해외 환자용 영어 판(병원 공식 영문 표기 shareNameEn), 파비콘은 영문 첫 글자.
- 워커 build 단계에서 자동 생성. briefs/<clinic>/clinic.json 에 shareNameEn 과 출처.
- Base.astro: og:image 기본값 /og.jpg, 크기·twitter:card·아이콘 링크.
- robots.txt(샘플): 미리보기 봇만 허용. 검색·AI 크롤러 차단과 noindex 는 유지.
- vite.config.ts: discovery 빌드에만 og:image·아이콘 태그. public/ 은 infinith-demo 와
  공용이라 파일명에 discovery- 접두어.
- Astro 기본 favicon.svg 삭제는 직전 커밋(f2fe99b)에 섞여 들어갔다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 09:36:45 +09:00

538 lines
44 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] [--domain <주소>] [--indexable] [--build-id <supporter_builds.id>] [--from <phase>] [--skip-generate]
// [--news-board <URL> | --no-news] [--no-tourism] [--no-recovery] [--regenerate]
// --domain 정식 주소를 Vercel 프로젝트에 붙인다 (예: viewpl.webforai.kr). 서브도메인은 clinic_registry 에서 읽는다
// --indexable 색인 허용 빌드. 법무 3건이 끝나기 전에는 붙이지 않는다
//
// 단계: evidence → ocr → data → discover → youtube → news → images → tourism → recovery → planner
// → inputs → briefs → generate → heroes → build → deploy → publish → done
// tourism 주변 안내 /stay · /en/stay (관광공사 TourAPI 장소·축제·동선). 회복 일정 /plan 과 별개 페이지다
// recovery 시술 후 관리 안내 (병원 시술 페이지 원문 인용·번역)
// planner 회복 일정 플래너 /plan · /en/plan 데이터
// heroes 글 대표 이미지 배정 (글마다 다른 사진)
// publish 발행(색인) 작업과 사람 체크리스트. 색인 허용 빌드가 아니면 알리지 않는다
// 단계마다 <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';
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 <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', 'ocr', 'data', 'discover', 'youtube', 'news', 'images', 'tourism', 'recovery', 'planner', 'inputs', 'briefs', 'generate', 'heroes', 'build', 'deploy', 'publish', '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, ''); } })();
// 사이트 주소와 색인 허용 여부를 vercel.json 에 쓴다. 원격 빌드가 이 값으로 canonical·사이트맵·robots 를 만든다.
// data 단계와 build 단계에서 모두 부른다. --from build 처럼 중간부터 돌려도 주소가 반영되게 하기 위해서다.
// 예전에는 data 단계에만 있어서 --from build 로 재배포하면 옛 주소가 canonical 로 그대로 나갔다(2026-09-14).
function applySiteUrl() {
const liveUrl = opt('domain') ? `https://${opt('domain').replace(/^https?:\/\//, '').replace(/\/$/, '')}` : `https://supporters-${clinic}.vercel.app`;
const vp = join(SITE, 'vercel.json');
if (!existsSync(vp)) return liveUrl;
const vj = readJson(vp);
vj.build = vj.build ?? {};
vj.build.env = { ...(vj.build.env ?? {}), SITE_URL: liveUrl, PUBLIC_INDEXABLE: flag('indexable') ? 'true' : 'false' };
if (flag('indexable')) vj.headers = (vj.headers ?? []).filter((h) => !(h.headers ?? []).some((x) => x.key === 'X-Robots-Tag'));
writeJson(vp, vj);
return liveUrl;
}
// 템플릿의 코드 파일을 빌드 폴더에 다시 덮는다. 병원 데이터·글·이미지는 건드리지 않는다.
// 템플릿은 data 단계에서만 복사되므로, --from build 처럼 중간부터 돌리면 그 사이에 고친 화면 코드가
// 기존 병원 폴더에 닿지 않는다. 2026-09-14 에 소유 확인 메타와 플래너 계측이 이 이유로 배포되지 않았다.
// 병원별로 갈리는 것(astro.config.mjs·vercel.json 의 치환값, 구조 기준선, home_text)은 덮지 않는다.
const CODE_DIRS = ['src/layouts', 'src/components', 'src/pages', 'src/styles', 'src/lib'];
const CODE_FILES = ['src/lib.ts', 'src/content.config.ts'];
function refreshTemplateCode() {
if (!existsSync(TEMPLATE)) return '템플릿 없음';
let n = 0;
const copyIfDiff = (rel) => {
const from = join(TEMPLATE, rel), to = join(SITE, rel);
if (!existsSync(from)) return;
if (existsSync(to) && readFileSync(from).equals(readFileSync(to))) return;
mkdirSync(dirname(to), { recursive: true });
cpSync(from, to, { recursive: true });
n += 1;
};
for (const d of CODE_DIRS) {
const from = join(TEMPLATE, d);
if (!existsSync(from)) continue;
const walk = (sub) => {
for (const e of readdirSync(join(from, sub), { withFileTypes: true })) {
const rel = join(d, sub, e.name);
if (e.isDirectory()) walk(join(sub, e.name)); else copyIfDiff(rel);
}
};
walk('');
}
for (const f of CODE_FILES) copyIfDiff(f);
return n ? `화면 코드 ${n}개 갱신` : '화면 코드 최신';
}
// ---------- 상태 ----------
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 {
// 단계는 문자열(요약) 또는 { warn: true, summary } 를 돌려준다. warn 이면 빌드는 계속하되 status.json 에 'warning' 으로 남긴다.
const res = await fn();
const summary = (res && typeof res === 'object') ? res.summary ?? '' : res ?? '';
const warn = Boolean(res && typeof res === 'object' && res.warn);
Object.assign(entry, { status: warn ? 'warning' : 'ok', finishedAt: new Date().toISOString(), summary });
await setStatus({});
console.log(` ${warn ? '△' : '✓'} ${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(', ')}`;
});
// 이미지 글자 OCR (macOS Vision). 시술 설명을 통짜 이미지로 넣는 사이트(오라클피부과: 시술 84페이지 중 83개)에서 근거를 확보한다.
// 유료 API 없음. --no-ocr 로 끄고, swift 가 없는 환경이면 경고로 건너뛴다. 결과는 evidence 페이지 JSON 에 source:"ocr" 섹션으로 들어가 원문과 구분된다.
await phase('ocr', () => {
if (flag('no-ocr')) return '건너뜀 (--no-ocr)';
if (process.platform !== 'darwin') return { warn: true, summary: '건너뜀 (macOS Vision 필요. 다른 OS 에서는 OCR 없이 진행)' };
const r = run('node', ['scripts/ocr_evidence.mjs', '--clinic', clinic, '--evidence', EV, '--max-images', opt('max-ocr-images', '200')]);
if (!r.ok) return { warn: true, summary: `OCR 실패, 원문만으로 진행\n${tail(r.out, 3)}` };
const idx = readJson(join(EV, 'index.json'));
const o = idx.ocr ?? {};
return `이미지 ${o.images ?? '?'}장 · 페이지 ${o.pages ?? '?'}개 · OCR ${o.chars ?? '?'}자 · 의료진 후보 ${(existsSync(join(EV, 'doctors.json')) ? readJson(join(EV, 'doctors.json')).doctors ?? [] : []).length}명`;
});
// 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)); }
applySiteUrl();
// IndexNow 키: 사이트마다 하나. public/<key>.txt 로 두면 빙·네이버가 소유를 확인한다. 검색엔진 소유 확인 메타 값은 briefs/<clinic>/clinic.json 의 site 로 들어온다(inputs 단계).
{ const sp = join(DATA, 'site.json'); const sj = readJson(sp); if (!sj.indexNowKey) { sj.indexNowKey = [...Array(32)].map(() => Math.floor(Math.random() * 16).toString(16)).join(''); writeJson(sp, sj); }
mkdirSync(join(SITE, 'public'), { recursive: true }); writeFileSync(join(SITE, 'public', `${sj.indexNowKey}.txt`), sj.indexNowKey); }
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] ?? '';
// 상호: 사람이 확정한 고정값(briefs/<clinic>/clinic.json 의 factSheet) > --name > 홈 제목 추정.
// 홈 제목 추정은 슬로건이 붙은 제목을 그대로 상호로 삼는다("안전을 최우선하는 뷰성형외과").
// 고정값을 여기서 먼저 읽어야 상호를 쓰는 뒤 단계(discover·news)까지 올바른 값이 간다.
const pinFact = (() => { const f = join(SUP, 'briefs', clinic, 'clinic.json'); return existsSync(f) ? (readJson(f).factSheet ?? {}) : {}; })();
const name = pinFact.shortName || pinFact.name || opt('name') || (readJson(join(EV, 'index.json')).pages.find((p) => p.type === 'home')?.title ?? '').replace(/\s*[|·-].*$/, '').trim();
Object.assign(fact, { name: pinFact.name || name || fact.name, shortName: pinFact.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 <svg|png 파일>) + 유튜브 채널 집계 (--youtube <채널> 또는 발견 단계의 CONFIRMED 유튜브)
await phase('youtube', () => {
const notes = [];
// 로고: 헤더·푸터·카드 대체 이미지·파비콘. SVG 는 viewBox 를 정사각 파비콘으로 감싼다.
const pinLogo = existsSync(join(SUP, 'briefs', clinic, 'clinic.json')) ? readJson(join(SUP, 'briefs', clinic, 'clinic.json')).logo : null;
const logoPath = opt('logo') ? resolve(opt('logo')) : pinLogo ? resolve(ROOT, pinLogo) : 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]*?<svg[^>]*>/, '').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 = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64"><rect width="64" height="64" rx="12" fill="#ffffff"/><svg x="${(64 - w) / 2}" y="${(64 - h) / 2}" width="${w}" height="${h}" viewBox="${vx} ${vy} ${vw} ${vh}">${inner}</svg></svg>`;
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 을 찾는다
// 고정값: supporters/briefs/<clinic>/clinic.json (haewon·병원 결정) > --youtube > 발견 단계 CONFIRMED(영상 수 최대) > 근거 링크
const pinPath = join(SUP, 'briefs', clinic, 'clinic.json');
const pin = existsSync(pinPath) ? readJson(pinPath) : {};
let channel = pin.youtube ?? opt('youtube') ?? null;
if (pin.youtube) notes.push(`유튜브 고정 채널: ${pin.youtubeHandle ?? pin.youtube}`);
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.videos ?? 0) - (a.videos ?? 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(' · ');
});
// 2d. 뉴스룸: 네이버 뉴스 검색 + (있으면) 홈페이지 언론보도 게시판. 병원 이름이 제목·요약에 있는 기사만 싣고, 규제·사건 보도와 후기·전후 비교·변신 제목은 뺀다(게이트 NEGATIVE_NEWS·BANNED_NEWS_TITLE 와 같은 규칙, v3 §5 "수집 단계에서 같은 규칙으로 거른다").
// 게시판 위치: --news-board > supporters/briefs/<clinic>/clinic.json newsBoard > 홈 링크 자동 발견. 없으면 네이버만.
await phase('news', () => {
if (flag('no-news')) return '건너뜀 (--no-news)';
const fact = readJson(join(DATA, 'factSheet.json'));
const name = opt('name') || fact.shortName || fact.name;
if (!name) throw new Error('뉴스 검색에 쓸 상호가 없음 (--name 또는 factSheet.name)');
const argv = ['scripts/collect_news.mjs', '--clinic', clinic, '--url', url, '--site', SITE, '--name', name];
if (opt('news-board')) argv.push('--board', opt('news-board'));
const r = run('node', argv);
if (!r.ok || !existsSync(join(DATA, 'news.json'))) throw new Error(`뉴스 수집 실패\n${tail(r.out)}`);
const n = readJson(join(DATA, 'news.json'));
status.report = { ...(status.report ?? {}), news: { total: n.items.length, kinds: n.stats.kinds, outlets: n.stats.outlets, board: n.sources.board, boardNote: n.sources.boardNote, excluded: { irrelevant: n.stats.naver.irrelevant, negative: n.stats.naver.excludedNegative + n.stats.board.excludedNegative, banned: n.stats.naver.excludedBanned + n.stats.board.excludedBanned } } };
return tail(r.out, 1);
});
// 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 ? '설정' : '없음'}`;
});
// 2e. 주변 안내(/stay·/en/stay): 팩트 시트 주소를 기준 좌표로 한국관광공사 TourAPI 에서 주변 장소·축제를 모아 영문·국문 JSON 을 쓴다.
// 좌표는 scripts/fetch_medical_tourism.py 가 네이버 지역검색으로 상호+주소를 대조해 정한다(지어내지 않는다). 못 정하면 경고만 남기고 빌드는 계속한다.
// 데이터가 없으면 페이지는 템플릿의 빈 데이터(origin lat 0)로 "확인 대기"를 그린다. 키가 없으면 건너뛴다.
await phase('tourism', () => {
if (flag('no-tourism')) return '건너뜀 (--no-tourism)';
if (!process.env.TOUR_API_KEY) return '건너뜀 (키 없음: TOUR_API_KEY)';
const fact = readJson(join(DATA, 'factSheet.json'));
const address = fact.address?.full || fact.address?.street || '';
const name = opt('name') || fact.shortName || fact.name || '';
if (!address || !name) return { warn: true, summary: `건너뜀 (팩트 시트에 ${!address ? '주소' : '상호'} 없음. 페이지는 확인 대기로 발행)` };
const label = `${name} (${fact.address?.street || address})`;
const outEn = join(DATA, 'medicalTourism.json'), outKo = join(DATA, 'medicalTourismKo.json');
const argv = [join(ROOT, 'scripts', 'fetch_medical_tourism.py'), '--clinic', clinic, '--name', name, '--address', address, '--label', label, '--out-en', outEn, '--out-ko', outKo];
if (flag('no-naver-images')) argv.push('--no-naver-images');
const r = run('python3', argv, { cwd: ROOT });
if (!r.ok || !existsSync(outEn) || !existsSync(outKo)) {
// 실패한 실행이 파일을 반쯤 남기지 않으므로(언어별로 수집이 끝난 뒤에만 쓴다) 템플릿의 빈 데이터가 그대로 남는다.
return { warn: true, summary: `주변 안내 수집 실패 (페이지는 확인 대기로 발행)\n${tail(r.out, 3)}` };
}
const en = readJson(outEn), ko = readJson(outKo);
if (en.meta?.origin?.lat) { fact.geo = { lat: en.meta.origin.lat, lng: en.meta.origin.lng, source: en.meta.origin.coordSource || '관광공사 수집 기준 좌표' }; writeJson(join(DATA, 'factSheet.json'), fact); } // 오시는 길 지도(VisitMap)가 쓴다
status.report = { ...(status.report ?? {}), tourism: { origin: en.meta.origin, en: { places: en.filters.total, festivals: en.festivals.length }, ko: { places: ko.filters.total, festivals: ko.festivals.length }, fetchedAt: en.meta.fetchedAt } };
return `기준 ${en.meta.origin.lat}, ${en.meta.origin.lng} (${en.meta.origin.coordSource}) · 영문 장소 ${en.filters.total}·축제 ${en.festivals.length} · 국문 장소 ${ko.filters.total}·축제 ${ko.festivals.length}`;
});
// 2f. 시술 후 관리 안내(회복 일정 플래너 /plan 의 병원 원문 제약): 병원 시술 페이지 원문(OCR 포함)에서 시술 후 관리 문장을 고르고 영문으로 번역해 recoveryNotes.json 을 쓴다.
// 의학 판단이 아니라 원문 인용이다. 게이트 규칙(금칙어·효과 보장·금액)에 걸리는 문장은 스크립트가 뺀다. 실패해도 빌드는 계속한다(페이지는 블록을 그리지 않는다).
await phase('recovery', () => {
if (flag('no-recovery')) return '건너뜀 (--no-recovery)';
const r = run('node', ['scripts/collect_recovery.mjs', '--clinic', clinic, '--evidence', EV, '--site', SITE, '--translate']);
const outPath = join(DATA, 'recoveryNotes.json');
if (!r.ok || !existsSync(outPath)) return { warn: true, summary: `시술 후 관리 문장 수집 실패 (블록 없이 발행)\n${tail(r.out, 3)}` };
const rn = readJson(outPath);
status.report = { ...(status.report ?? {}), recovery: rn.counts ?? {} };
const c = rn.counts ?? {};
return `시술 ${c.pages ?? 0}개 · ${c.sentences ?? 0}문장 · 번역 ${c.translated ?? 0} · 실패 ${c.enFailed ?? 0} · $${c.costUsd ?? 0}${c.enFailed ? ' (실패 문장은 영문 화면에 한글 원문으로 표시)' : ''}`;
});
// 2g. 회복 일정 플래너(/plan·/en/plan) 병원별 데이터: 업종별 기본 규칙표 + 병원 시술 페이지 원문(recoveryNotes) + 큐레이션 장소(briefs) 또는 범용 기본표(관광공사 기준 좌표).
// 계산은 브라우저에서 하고 여기서는 데이터 파일만 만든다. 실패하면 템플릿 기본표(성형외과·범용 장소)로 발행하고 경고를 남긴다.
await phase('planner', () => {
if (flag('no-planner')) return '건너뜀 (--no-planner)';
const argv = ['scripts/build_planner_data.mjs', '--clinic', clinic, '--site', SITE];
if (opt('industry')) argv.push('--industry', opt('industry'));
const r = run('node', argv);
if (!r.ok) return { warn: true, summary: `플래너 데이터 생성 실패 (템플릿 기본표로 발행)\n${tail(r.out, 3)}` };
status.report = { ...(status.report ?? {}), planner: tail(r.out, 1) };
return tail(r.out, 1);
});
// 3. 병원 입력 반영 (있으면)
await phase('inputs', () => {
const notes = [];
if (opt('inputs') || flag('from-supabase')) {
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));
notes.push(tail(r.out, 3));
} else notes.push('입력 없음 (확인 대기로 발행)');
// 사람이 확정한 고정값(supporters/briefs/<clinic>/clinic.json). 자동값 위에 덮어써 재실행해도 유지된다.
// factSheet: 팩트 시트 필드 얕은 병합 · site: site.json 필드(heroImage·insideGallery 등, src 대신 srcMatch 로 파일명 일부 지정 가능)
// excludeImages: public/img 에서 파일명이 이 문자열로 시작하는 이미지를 지우고 갤러리에서 뺀다(시술 설명 이미지·모델 얼굴·전후 사진)
const pinPath = join(SUP, 'briefs', clinic, 'clinic.json');
if (existsSync(pinPath)) {
const pin = readJson(pinPath);
if (pin.factSheet) { const fact = readJson(join(DATA, 'factSheet.json')); Object.assign(fact, pin.factSheet); writeJson(join(DATA, 'factSheet.json'), fact); notes.push(`팩트 시트 고정값 ${Object.keys(pin.factSheet).join('·')}`); }
const imgDir = join(SITE, 'public', 'img', 'clinic');
const files = existsSync(imgDir) ? readdirSync(imgDir) : [];
// srcMatch: 자동 수집 파일명 일부 · file: 사람이 준 파일(저장소 상대 경로)을 public/img/clinic 으로 복사해 쓴다(병원 제공 사진·그룹 사이트 건물 외관 등)
const resolveSrc = (o) => {
if (!o || o.src) return o;
if (o.file) { const srcFile = resolve(ROOT, o.file); if (!existsSync(srcFile)) return null; mkdirSync(imgDir, { recursive: true }); const name = 'pin-' + srcFile.split('/').pop(); cpSync(srcFile, join(imgDir, name)); const { file, ...rest } = o; return { src: `/img/clinic/${name}`, ...rest }; }
if (!o.srcMatch) return o;
const f = files.find((x) => x.includes(o.srcMatch)); const { srcMatch, ...rest } = o; return f ? { src: `/img/clinic/${f}`, ...rest } : null;
};
if (pin.site || pin.excludeImages) {
const site = readJson(join(DATA, 'site.json'));
const ex = pin.excludeImages ?? [];
let removed = 0;
for (const f of files) if (ex.some((e) => f.startsWith(e))) { rmSync(join(imgDir, f), { force: true }); removed++; }
const keep = (list) => (list ?? []).filter((i) => !ex.some((e) => (i.src ?? '').split('/').pop().startsWith(e)));
for (const k of ['insideGallery', 'clinicGallery']) site[k] = keep(site[k]);
// 이미지 매니페스트(images.json)에서도 빼야 기획기(default_briefs)가 글 히어로·갤러리로 다시 집지 않는다 (2026-09-10 오라클: 제외한 시술 이미지가 글 썸네일로 들어가 깨진 그림이 됐다)
const manPath = join(DATA, 'images.json');
if (existsSync(manPath)) { const man = readJson(manPath); const before = (man.items ?? []).length; man.items = (man.items ?? []).filter((i) => !ex.some((e) => (i.src ?? '').split('/').pop().startsWith(e))); man.excludedByPin = before - man.items.length; writeJson(manPath, man); }
if (site.heroImage?.src && ex.some((e) => site.heroImage.src.split('/').pop().startsWith(e))) site.heroImage = site.insideGallery[0] ? { ...site.insideGallery[0], width: 600, height: 330 } : {};
for (const [k, v] of Object.entries(pin.site ?? {})) site[k] = Array.isArray(v) ? v.map(resolveSrc).filter(Boolean) : (resolveSrc(v) ?? site[k]);
writeJson(join(DATA, 'site.json'), site);
notes.push(`이미지 제외 ${removed}장 · site 고정값 ${Object.keys(pin.site ?? {}).join('·') || '없음'}`);
}
}
return notes.join(' · ');
});
// 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. 빌드 + 게이트
// 6a. 글 대표 이미지. 글마다 다른 사진을 배정한다. PostCard 의 대체 순서가 건물 사진 하나로 수렴해
// 목록에 같은 사진이 나란히 서는 것을 막는다. 의료진 사진은 쓰지 않고, 이미지가 모자라면 비워 둔다.
await phase('heroes', () => {
cpSync(join(SUP, 'scripts', 'assign_post_heroes.mjs'), join(SITE, 'scripts', 'assign_post_heroes.mjs'));
const r = run('node', ['scripts/assign_post_heroes.mjs', '--site', SITE], { cwd: SITE });
if (!r.ok) return { warn: true, summary: `대표 이미지 배정 실패, 기존 값으로 진행\n${tail(r.out, 3)}` };
return tail(r.out, 1);
});
await phase('build', () => {
// 주소·색인 설정을 먼저 적용한다. 원격 빌드가 이 값으로 canonical·사이트맵을 만든다.
applySiteUrl();
const refreshed = refreshTemplateCode();
// 링크 공유 미리보기 이미지(og.png)와 파비콘. 실패해도 빌드는 계속한다(Base.astro 가 건물 사진으로 대신 낸다).
const share = run('python3', [join(ROOT, 'scripts', 'make_share_assets.py'), 'supporters', '--site', SITE]);
status.report = { ...(status.report ?? {}), shareAssets: share.ok ? tail(share.out, 1) : `공유 이미지 생성 실패\n${tail(share.out, 3)}` };
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 domain = opt('domain') ? opt('domain').replace(/^https?:\/\//, '').replace(/\/$/, '') : '';
if (domain) { const d = run('vercel', ['domains', 'add', domain, project, '--yes'], { cwd: SITE }); status.report = { ...(status.report ?? {}), domain: d.ok ? `연결 요청됨 ${domain}` : `도메인 추가 실패(DNS·소유 확인 필요)\n${tail(d.out, 3)}` }; }
const dep = run('vercel', ['deploy', '--prod', '--yes'], { cwd: SITE, env: { SITE_URL: domain ? `https://${domain}` : `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`;
status.liveUrl = domain ? `https://${domain}` : status.previewUrl;
return status.liveUrl;
});
// 8. 발행(색인) 단계: 배포가 끝이 아니다. 라이브 확인 → IndexNow(빙·네이버) → Google Search Console(서비스 계정 있을 때) → 사람 체크리스트(네이버 등록·GSC 소유 확인·도메인·법무). haewon 결정 2026-09-11.
await phase('publish', () => {
if (!status.previewUrl) return '건너뜀 (미배포)';
const argv = ['scripts/publish_site.mjs', '--clinic', clinic, '--site', SITE, '--url', status.liveUrl || status.previewUrl, '--work', WORK];
if (flag('indexable')) argv.push('--indexable');
const r = run('node', argv);
const m = r.out.match(/__PUBLISH_JSON__(.+)$/m);
const pub = m ? JSON.parse(m[1]) : null;
if (!r.ok || !pub) return { warn: true, summary: `발행 단계 실패 (체크리스트 없이 종료)\n${tail(r.out, 4)}` };
status.report = { ...(status.report ?? {}), publish: pub };
status.humanTasks = pub.humanTasks;
const n = pub.humanTasks.length;
return `색인 허용 ${pub.indexable ? '예' : '아니오'} · IndexNow ${pub.indexnow?.status ?? pub.indexnow?.skipped ?? '-'} · GSC ${pub.google?.sitemapSubmit ?? pub.google?.skipped ?? '-'} · 사람이 할 일 ${n}건 → ${join(WORK, 'publish-checklist.md')}`;
});
await phase('done', async () => { await setStatus({ status: flag('indexable') ? (status.humanTasks?.length ? 'published_pending_tasks' : 'published') : 'preview' }); return `status.json → ${statusPath}`; });
console.log(`\n완료: ${status.posts.length}편 · ${status.liveUrl ?? status.previewUrl ?? '미배포'} · ${WORK}${status.humanTasks?.length ? `\n사람이 할 일 ${status.humanTasks.length}건 (publish-checklist.md):\n - ${status.humanTasks.join('\n - ')}` : ''}`);