o2o-infinith-demo/templates/supporters-astro/scripts/collect_evidence.mjs
Haewon Kam 7e515b70ae feat(supporters): 승인 UI·입력 반영·워커 — §7-5 ClinicInputsPanel + supporter_builds/inputs 마이그레이션 + apply_inputs, §12-2 상단 고지·개설자 승인·색인 빌드 필터, §7-6 워커 E2E(뷰성형외과 URL → 5편 → 게이트 0오류)
- 게이트: 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>
2026-09-07 17:07:06 +09:00

297 lines
22 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 근거 수집기 (NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §7-3, §4-1).
// 병원 홈페이지의 원문 페이지(주의사항·애프터케어·검진·안전·시술 상세·의료진·오시는길)를 curl+파서로 받아
// evidence/<clinic>/ 에 구조화해 저장한다. 요약 도구를 쓰지 않는다(§3-10). 없는 것은 없다고 남긴다.
//
// node scripts/collect_evidence.mjs --clinic viewclinic --url https://www.viewclinic.com [--out ../evidence] [--max 80] [--delay 400]
//
// 산출물
// evidence/<clinic>/index.json 페이지 목록(유형·글자 수·표·이미지·플래그)
// evidence/<clinic>/pages/<slug>.json 페이지별 원문(제목·헤딩·섹션·표·목록·이미지·링크)
// evidence/<clinic>/doctors.json 의료진 페이지에서 읽은 이름·직함·약력(이미지 글자면 textInImages=true)
// evidence/<clinic>/facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기")
// evidence/<clinic>/home_text.txt 전 페이지 본문 공백 제거본. 발행 게이트의 40자 연속 일치 검사 기준
import { mkdirSync, writeFileSync, readFileSync, existsSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { parse } from 'node-html-parser';
// ---------- 인자 ----------
const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; };
const CLINIC = arg('--clinic'); const START = arg('--url');
if (!CLINIC || !START) { console.error('사용법: --clinic <id> --url <https://...>'); process.exit(2); }
const OUT = join(arg('--out', fileURLToPath(new URL('../../evidence/', import.meta.url))), CLINIC);
const MAX = Number(arg('--max', 250)); const DELAY = Number(arg('--delay', 400));
const UA = 'Mozilla/5.0 (compatible; INFINITH-evidence/0.1; +https://infinith-demo.vercel.app)';
const today = new Date().toISOString().slice(0, 10);
// ---------- 페이지 유형 ----------
// 순서가 우선순위. URL 경로와 앵커 텍스트 둘 다 본다.
const TYPES = [
['precautions', /precaution|caution|주의사항|주의\s*사항/i],
['aftercare', /after-?care|사후관리|애프터/i],
['checkup', /examination|check-?up|검진/i],
['safety', /\bsafe|안전/i],
['doctor', /\bdoctors?\b|의료진|\bstaff\b|physician/i],
['direction', /\bdirections?\d*\b|\blocation\b|오시는\s*길|찾아오|\bcontact\b|\bway-?to/i],
['facilities', /facilit|\btour\b|둘러보|시설/i],
['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting/i],
['reservation', /\breserv|\bcounsel|예약|상담/i],
['pricing', /\bprice|\bcost\b|비급여|수가|비용/i],
];
const SKIP = /\/(board|news|event|community|review|gallery|model|login|register|mypage|cart|feed|tag|category|author|wp-|xmlrpc|\?s=)|before-?and-?after|before-?after|\/(before|after)\/|\.(jpg|jpeg|png|gif|svg|pdf|css|js|ico|zip)(\?|$)|#|mailto:|tel:|javascript:/i;
const PROCEDURE_HINT = /수술\s*시간|마취|회복|실밥|입원|붓기|절개|시술\s*시간|수술\s*방법/;
// 경로를 먼저 보고, 경로로 못 정하면 짧은 앵커 텍스트(메뉴 항목)만 본다. 메뉴 전체가 붙은 긴 앵커는 무시.
function classify(path, anchor = '') {
const segs = path.split('/').filter(Boolean).map((x) => x.replace(/[-_]/g, ' '));
for (const [t, re] of TYPES) if (segs.some((x) => re.test(x)) || re.test(path)) return t;
if (anchor && anchor.length <= 30) for (const [t, re] of TYPES) if (re.test(anchor)) return t;
return 'candidate';
}
// ---------- fetch ----------
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
async function fetchText(url, tries = 2) {
for (let i = 0; i <= tries; i++) {
try {
const res = await fetch(url, { headers: { 'user-agent': UA, accept: 'text/html,application/xhtml+xml,*/*' }, redirect: 'follow', signal: AbortSignal.timeout(25000) });
const body = await res.text();
return { status: res.status, finalUrl: res.url, body, contentType: res.headers.get('content-type') ?? '' };
} catch (e) { if (i === tries) return { status: 0, finalUrl: url, body: '', error: e.message }; await sleep(800 * (i + 1)); }
}
}
// ---------- robots ----------
async function loadRobots(origin) {
const r = await fetchText(`${origin}/robots.txt`, 0);
const dis = [];
if (r.status === 200) {
let star = false;
for (const line of r.body.split('\n')) {
const l = line.trim();
if (/^user-agent:/i.test(l)) star = /user-agent:\s*\*/i.test(l);
else if (star && /^disallow:/i.test(l)) { const p = l.replace(/^disallow:\s*/i, '').trim(); if (p) dis.push(p); }
}
}
return { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) };
}
// ---------- 링크 정규화 ----------
function normalize(href, base) {
try {
const u = new URL(href, base);
if (!/^https?:$/.test(u.protocol)) return null;
u.hash = ''; u.search = '';
let s = u.toString();
if (!/\.[a-z0-9]{2,5}$/i.test(u.pathname) && !s.endsWith('/')) s += '/';
return s;
} catch { return null; }
}
const sameHost = (u, origin) => new URL(u).host.replace(/^www\./, '') === new URL(origin).host.replace(/^www\./, '');
// ---------- 본문 추출 ----------
const MAIN_SELECTORS = ['main', '#ajax-content-wrap', '#main-content', '#content', '#contents', '#container', '.container-wrap', '#sub_content', '.sub_content', '.sub-content', '#subContent', 'article', '#wrap', '#wrapper'];
const NOISE_TAGS = 'script,style,noscript,iframe,form,svg,template,video,audio,object,embed';
const NOISE_SELECTORS = 'header,footer,nav,[id*="menu"],[class*="menu"],[id*="popup"],[class*="popup"],[id*="modal"],[class*="modal"],[class*="share"],[class*="quick"],[class*="floating"],[class*="breadcrumb"],[class*="cookie"],[class*="sns"],[id*="search"],[class*="widget"],[class*="wpcf7"],[class*="eform"],[class*="offcanvas"],[class*="gnb"],[class*="lnb"],[id*="gnb"],[id*="lnb"],[class*="topbar"],[class*="top-bar"]';
const BLOCK = new Set(['div', 'section', 'article', 'main', 'aside', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'td', 'th', 'p', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'figure', 'figcaption', 'blockquote', 'dl', 'dt', 'dd', 'pre', 'address']);
const clean = (s) => s.replace(/ /g, ' ').replace(/[ \t\r\f\v]+/g, ' ').replace(/\s*\n\s*/g, ' ').trim();
function hasBlockDescendant(el) {
return el.querySelectorAll('*').some((c) => BLOCK.has(c.rawTagName?.toLowerCase()));
}
/** DOM → 순서 있는 항목 목록 [{kind:'h'|'p'|'li'|'table', level?, text?, rows?}] */
function walk(el, items) {
const tag = el.rawTagName?.toLowerCase();
if (!tag) return;
if (/^h[1-6]$/.test(tag)) { const t = clean(el.text); if (t) items.push({ kind: 'h', level: Number(tag[1]), text: t }); return; }
if (tag === 'table') {
const rows = el.querySelectorAll('tr').map((tr) => tr.querySelectorAll('td,th').map((c) => clean(c.text))).filter((r) => r.some(Boolean));
if (rows.length) items.push({ kind: 'table', rows });
return;
}
if (tag === 'li') { if (!hasBlockDescendant(el)) { const t = clean(el.text); if (t) items.push({ kind: 'li', text: t }); return; } }
if (BLOCK.has(tag) && !hasBlockDescendant(el)) { const t = clean(el.text); if (t) items.push({ kind: 'p', text: t }); return; }
for (const c of el.childNodes) {
if (c.nodeType === 1) walk(c, items);
else if (c.nodeType === 3) { const t = clean(c.rawText ?? c.text ?? ''); if (t && t.length > 1 && BLOCK.has(tag)) items.push({ kind: 'p', text: t }); }
}
}
function extract(html, url) {
const root = parse(html, { blockTextElements: { script: true, style: true, noscript: true, pre: true } });
const title = clean(root.querySelector('title')?.text ?? '');
const metaDesc = root.querySelector('meta[name="description"]')?.getAttribute('content') ?? '';
root.querySelectorAll(NOISE_TAGS).forEach((e) => e.remove());
const body = root.querySelector('body') ?? root;
// 링크 발견용 앵커는 메뉴를 지우기 전에 전체 body에서 모은다 (메뉴가 곧 사이트 구조다)
const anchors = body.querySelectorAll('a[href]').map((a) => [normalize(a.getAttribute('href'), url), clean(a.text)]).filter(([u]) => u);
// 본문 컨테이너: 후보 중 텍스트가 200자를 넘는 첫 것. 없으면(이미지 위주 페이지) 텍스트가 가장 많은 후보, 그것도 없으면 body
let main = null;
for (const sel of MAIN_SELECTORS) { const el = body.querySelector(sel); if (el && clean(el.text).length > 200) { main = el; break; } }
if (!main) { const found = MAIN_SELECTORS.map((sel) => body.querySelector(sel)).filter(Boolean); if (found.length) main = found.sort((a, b) => clean(b.text).length - clean(a.text).length)[0]; }
main = main ?? body;
const mainSelector = main === body ? 'body' : MAIN_SELECTORS.find((s) => body.querySelector(s) === main);
main.querySelectorAll(NOISE_SELECTORS).forEach((e) => e.remove());
const items = []; walk(main, items);
const images = main.querySelectorAll('img').map((i) => ({ src: normalize(i.getAttribute('data-src') || i.getAttribute('src') || '', url), alt: clean(i.getAttribute('alt') ?? '') })).filter((i) => i.src && !/\.(svg|gif)$/i.test(i.src) && !/icon|logo|btn|button|arrow|blank|spacer|sns|share|1x1/i.test(i.src));
const links = [...new Set(main.querySelectorAll('a[href]').map((a) => normalize(a.getAttribute('href'), url)).filter(Boolean))];
return { title, metaDesc, mainSelector, items, images, links, anchors };
}
/** 항목을 헤딩 기준 섹션으로 묶는다 */
function sectionize(items) {
const sections = []; let cur = { heading: null, level: 0, paragraphs: [], lists: [], tables: [] };
for (const it of items) {
if (it.kind === 'h') { if (cur.heading || cur.paragraphs.length || cur.lists.length || cur.tables.length) sections.push(cur); cur = { heading: it.text, level: it.level, paragraphs: [], lists: [], tables: [] }; }
else if (it.kind === 'p') cur.paragraphs.push(it.text);
else if (it.kind === 'li') cur.lists.push(it.text);
else if (it.kind === 'table') cur.tables.push(it.rows);
}
if (cur.heading || cur.paragraphs.length || cur.lists.length || cur.tables.length) sections.push(cur);
return sections;
}
// ---------- 사실 후보(정규식) ----------
function factCandidates(text) {
const pick = (re) => [...new Set([...text.matchAll(re)].map((m) => clean(m[1] ?? m[0])))];
return {
phone: pick(/(?<!\d)(0\d{1,2}[-.\s]\d{3,4}[-.\s]\d{4})(?!\d)/g),
address: pick(/((?:서울|경기|인천|부산|대구|대전|광주|울산|세종|강원|충북|충남|전북|전남|경북|경남|제주)[^\n|·]{6,60}?(?:빌딩|건물|층|호|\d+번지|로\s?\d+|길\s?\d+))/g),
businessNo: pick(/(?<!\d)(\d{3}-\d{2}-\d{5})(?!\d)/g),
representative: pick(/대표(?:자|원장|의사)?\s*[::]?\s*([가-힣]{2,4})(?=\s|$|[,·|])/g),
hours: pick(/((?:월|화|수|목|금|토|일|평일|주말|공휴일)[^\n|]{0,20}?\d{1,2}[:시]\d{0,2}\s*[~\-–]\s*\d{1,2}[:시]\d{0,2})/g),
founded: pick(/((?:19|20)\d{2})\s*년\s*(?:개원|설립|오픈)/g),
};
}
// ---------- 의료진 ----------
function parseDoctor(page) {
const text = [page.title, page.h1, ...page.items.filter((i) => i.kind !== 'table').map((i) => i.text)].join('\n');
const name = text.match(/([가-힣]{2,4})\s*(?:대표\s*)?원장/)?.[1] ?? null;
const specialty = text.match(/(성형외과|이비인후과|마취통증의학과|피부과|영상의학과|외과|치과|내과)\s*전문의/)?.[0] ?? null;
const isRep = /대표\s*원장/.test(text);
const credentials = page.items.filter((i) => i.kind === 'li' || (i.kind === 'p' && /대학|병원|학회|전문의|회원|교수|수료|연수|졸업|위촉|박사|석사/.test(i.text) && i.text.length < 80)).map((i) => i.text).filter((t) => !/원장$/.test(t));
const creds = [...new Set(credentials)].filter((c) => c !== specialty);
const status = !name ? '이름 미확인 · 확인 대기' : creds.length < 2 ? (page.flags.textInImages ? '약력 이미지 글자 · 확인 대기' : '약력 본문 없음 · 확인 대기') : 'ok';
return { id: page.slug.replace(/^.*doctors?-?/, ''), url: page.url, name, title: [isRep ? '대표원장' : '원장', specialty].filter(Boolean).join(' · ') || null, credentials: creds, images: page.images.slice(0, 5), textInImages: page.flags.textInImages, status };
}
// ---------- 메인 ----------
const origin = new URL(START).origin;
mkdirSync(join(OUT, 'pages'), { recursive: true });
const robots = await loadRobots(origin);
console.log(`[1/4] 시작 ${START} (robots Disallow ${robots.disallow.length}개)`);
// 링크 발견: 홈 + 사이트맵
const home = await fetchText(START);
if (home.status !== 200) { console.error(`홈 요청 실패: ${home.status} ${home.error ?? ''}`); process.exit(1); }
const homeEx = extract(home.body, home.finalUrl);
const queue = new Map(); // url → { anchor, fromHome }
const addLink = (u, anchor = '', fromHome = false) => { if (!u || !sameHost(u, origin) || SKIP.test(u) || /\.xml$/i.test(u) || !robots.allowed(new URL(u).pathname)) return; const cur = queue.get(u); if (!cur) queue.set(u, { anchor, fromHome }); else if (fromHome && !cur.fromHome) cur.fromHome = true; };
for (const [u, a] of homeEx.anchors) addLink(u, a, true);
for (const sm of ['/sitemap.xml', '/wp-sitemap.xml', '/sitemap_index.xml', '/page-sitemap.xml']) {
const r = await fetchText(`${origin}${sm}`, 0);
if (r.status !== 200 || !/<(urlset|sitemapindex)/.test(r.body)) continue;
const locs = [...r.body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)].map((m) => m[1]);
const subs = locs.filter((l) => /sitemap.*\.xml$/i.test(l));
const pageSubs = subs.filter((l) => /page/i.test(l)); // 글·영상(post)은 뺀다. 원문 페이지만
for (const l of pageSubs) { const sub = await fetchText(l, 0); for (const m of sub.body.matchAll(/<loc>\s*([^<\s]+)\s*<\/loc>/g)) addLink(normalize(m[1], origin)); }
if (!subs.length) for (const l of locs) addLink(normalize(l, origin));
console.log(` 사이트맵 ${sm}: 링크 누적 ${queue.size}`);
break;
}
console.log(`[2/4] 후보 링크 ${queue.size}개 → 유형 분류`);
// 유형 분류 후 우선순위대로 MAX까지
const ranked = [...queue.entries()].map(([u, { anchor, fromHome }]) => ({ url: u, anchor, fromHome, depth: new URL(u).pathname.split('/').filter(Boolean).length, type: classify(new URL(u).pathname, anchor) }));
const prio = (t) => { const i = TYPES.findIndex(([k]) => k === t); return i === -1 ? 99 : i; };
// 유형이 잡힌 페이지 먼저(의료진 상세는 40명까지), 그다음 후보는 홈 메뉴에서 연결된 것 → 얕은 경로 순
const isDoctorDetail = (x) => x.type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(x.url).pathname);
const typed = ranked.filter((x) => x.type !== 'candidate').sort((a, b) => prio(a.type) - prio(b.type) || a.depth - b.depth);
const doctorDetails = typed.filter(isDoctorDetail).slice(0, 40); const typedRest = typed.filter((x) => !isDoctorDetail(x));
const cands = ranked.filter((x) => x.type === 'candidate').sort((a, b) => Number(b.fromHome) - Number(a.fromHome) || a.depth - b.depth || a.url.length - b.url.length || a.url.localeCompare(b.url));
const targets = [...typedRest, ...doctorDetails, ...cands].slice(0, MAX);
console.log(` 유형 확정 ${typedRest.length} · 의료진 상세 ${doctorDetails.length} · 후보 ${cands.length} → 수집 ${targets.length}`);
// 수집
const pages = [];
const slugOf = (u) => (new URL(u).pathname.replace(/^\/|\/$/g, '').replace(/[^a-zA-Z0-9가-힣]+/g, '-') || 'home');
async function collect(t) {
const r = await fetchText(t.url);
if (r.status !== 200 || !/html/.test(r.contentType)) { pages.push({ url: t.url, slug: slugOf(t.url), type: t.type, status: r.status, error: r.error ?? 'non-html', items: [], images: [], links: [], flags: {} }); return; }
const ex = extract(r.body, r.finalUrl);
const h1 = ex.items.find((i) => i.kind === 'h' && i.level === 1)?.text ?? null;
let type = t.type;
const bodyText = ex.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n');
if (type === 'candidate') type = PROCEDURE_HINT.test(bodyText) ? 'procedure' : 'other';
if (type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(t.url).pathname) && !/\/doctors?\/?$/.test(new URL(t.url).pathname)) type = 'doctor-detail';
pages.push({ url: r.finalUrl, requestedUrl: t.url, slug: slugOf(r.finalUrl), type, anchor: t.anchor, status: r.status, title: ex.title, metaDesc: ex.metaDesc, h1, mainSelector: ex.mainSelector, items: ex.items, images: ex.images, links: ex.links.filter((l) => sameHost(l, origin)), rawText: bodyText, fullTextForFacts: clean(parse(r.body).querySelector('body')?.text ?? '').slice(0, 200000), flags: {} });
}
{
let i = 0; const workers = Array.from({ length: 3 }, async () => { while (i < targets.length) { const t = targets[i++]; await collect(t); process.stdout.write(`\r 수집 ${pages.length}/${targets.length}`); await sleep(DELAY); } });
await Promise.all(workers);
}
// 홈도 페이지로
{
const h1 = homeEx.items.find((i) => i.kind === 'h' && i.level === 1)?.text ?? null;
pages.unshift({ url: home.finalUrl, requestedUrl: START, slug: 'home', type: 'home', anchor: '', status: 200, title: homeEx.title, metaDesc: homeEx.metaDesc, h1, mainSelector: homeEx.mainSelector, items: homeEx.items, images: homeEx.images, links: homeEx.links.filter((l) => sameHost(l, origin)), rawText: homeEx.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n'), fullTextForFacts: clean(parse(home.body).querySelector('body')?.text ?? '').slice(0, 200000), flags: {} });
}
console.log(`\n[3/4] 페이지 ${pages.length}개 수집. 반복 줄 제거`);
// 반복 줄(boilerplate) 제거: 페이지의 30% 이상(최소 3)에 같은 줄이 있으면 메뉴·푸터로 본다
const ok = pages.filter((p) => p.status === 200 && !p.error);
const freq = new Map();
for (const p of ok) for (const t of new Set(p.items.filter((i) => i.kind !== 'table').map((i) => i.text))) freq.set(t, (freq.get(t) ?? 0) + 1);
const threshold = Math.max(3, Math.ceil(ok.length * 0.3));
const boiler = new Set([...freq.entries()].filter(([, n]) => n >= threshold).map(([t]) => t));
for (const p of ok) {
const before = p.items.length;
p.items = p.items.filter((i) => i.kind === 'table' || !boiler.has(i.text));
p.images = p.images.filter((im) => !ok.filter((q) => q !== p).some((q) => q.images.some((x) => x.src === im.src)) || ok.length < 3);
p.sections = sectionize(p.items);
p.text = p.items.map((i) => i.kind === 'table' ? i.rows.map((r) => r.join(' | ')).join('\n') : i.text).join('\n');
p.chars = p.text.replace(/\s+/g, '').length;
p.flags = { textInImages: p.chars < 500 && p.images.length >= 3, noText: p.chars < 100, boilerplateRemoved: before - p.items.length, tables: p.items.filter((i) => i.kind === 'table').length };
}
// 저장
const index = [];
for (const p of pages) {
const file = `pages/${p.slug}.json`;
const { fullTextForFacts, rawText, ...save } = p;
writeFileSync(join(OUT, file), JSON.stringify({ clinic: CLINIC, fetchedAt: today, ...save }, null, 1));
index.push({ file, url: p.url, type: p.type, status: p.status, mainSelector: p.mainSelector ?? null, title: p.title ?? null, h1: p.h1 ?? null, chars: p.chars ?? 0, tables: p.flags?.tables ?? 0, images: p.images?.length ?? 0, flags: Object.entries(p.flags ?? {}).filter(([k, v]) => v === true).map(([k]) => k) });
}
const byType = index.reduce((m, x) => ((m[x.type] = (m[x.type] ?? 0) + 1), m), {});
writeFileSync(join(OUT, 'index.json'), JSON.stringify({ clinic: CLINIC, start: START, fetchedAt: today, robotsDisallow: robots.disallow, boilerplateLines: boiler.size, byType, pages: index }, null, 1));
// 의료진
const doctors = ok.filter((p) => p.type === 'doctor-detail').map(parseDoctor);
writeFileSync(join(OUT, 'doctors.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: 'textInImages=true 인 약력은 이미지 글자라 본문에 없다. authors.json에 옮기기 전에 사람이 확인한다.', doctors }, null, 1));
// 사실 후보
const factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type));
const facts = {};
for (const p of factSrc) { const c = factCandidates(p.fullTextForFacts); for (const [k, vals] of Object.entries(c)) for (const val of vals) { facts[k] ??= {}; facts[k][val] ??= []; if (!facts[k][val].includes(p.url)) facts[k][val].push(p.url); } }
writeFileSync(join(OUT, 'facts.draft.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: '정규식 후보. 값마다 출처 URL. factSheet.json에 넣기 전에 사람이 홈페이지 표기와 대조한다. 후보가 없으면 "확인 대기".', candidates: facts }, null, 1));
// 40자 중복 검사 기준 텍스트
writeFileSync(join(OUT, 'home_text.txt'), ok.map((p) => p.text.replace(/\s+/g, '')).join('\n'));
// 인용 URL 커버리지: --posts <dir> 의 글 frontmatter에서 type: clinic 소스 URL을 뽑아 수집 여부를 본다 (정답지 회귀)
const postsDir = arg('--posts');
if (postsDir && existsSync(postsDir)) {
const { readdirSync } = await import('node:fs');
const cited = new Set();
for (const f of readdirSync(postsDir).filter((x) => x.endsWith('.md'))) for (const m of readFileSync(join(postsDir, f), 'utf8').matchAll(/url:\s*"([^"]+)",\s*accessed:[^}]*type:\s*clinic/g)) if (sameHost(m[1], origin)) cited.add(normalize(m[1], origin));
const have = new Set(pages.flatMap((p) => [p.url, p.requestedUrl].filter(Boolean)));
const missing = [];
for (const u of cited) { if (have.has(u)) continue; const r = await fetchText(u, 0); if (!have.has(normalize(r.finalUrl, origin))) missing.push(u); }
console.log(` 인용 URL 커버리지 ${cited.size - missing.length}/${cited.size}${missing.length ? ' · 누락: ' + missing.join(', ') : ''}`);
}
console.log(`[4/4] 저장 ${OUT}`);
console.log(` 유형별: ${Object.entries(byType).map(([k, v]) => `${k} ${v}`).join(' · ')}`);
console.log(` 의료진 ${doctors.length}명 (약력 확인 대기 ${doctors.filter((d) => d.status !== 'ok').length}명) · 반복 줄 ${boiler.size}개 제거 · home_text ${ok.reduce((n, p) => n + p.chars, 0).toLocaleString()}자`);
const bad = index.filter((x) => x.status !== 200); if (bad.length) console.log(` 실패 ${bad.length}: ${bad.map((b) => `${b.status} ${b.url}`).join(', ')}`);