- 게이트: 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>
297 lines
22 KiB
JavaScript
297 lines
22 KiB
JavaScript
// 근거 수집기 (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(', ')}`);
|