// 근거 수집기 (NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §7-3, §4-1). // 병원 홈페이지의 원문 페이지(주의사항·애프터케어·검진·안전·시술 상세·의료진·오시는길·지점)를 curl+파서로 받아 // evidence// 에 구조화해 저장한다. 요약 도구를 쓰지 않는다(§3-10). 없는 것은 없다고 남긴다. // // node scripts/collect_evidence.mjs --clinic viewclinic --url https://www.viewclinic.com [--out ../evidence] [--max 80] [--delay 400] // [--extra-url http://group-site.example/branches] 같은 수집기로 받을 추가 시작 페이지(쉼표 구분·반복 가능. 다른 호스트 허용) // // 산출물 // evidence//index.json 페이지 목록(유형·글자 수·표·이미지·플래그) // evidence//pages/.json 페이지별 원문(제목·헤딩·섹션·표·목록·이미지·링크) // evidence//doctors.json 의료진 페이지에서 읽은 이름·직함·약력(이미지 글자면 textInImages=true) // evidence//facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기"). 지점 목록 페이지가 있으면 branches 배열 // evidence//home_text.txt 전 페이지 본문 공백 제거본. 발행 게이트의 40자 연속 일치 검사 기준 // // 사이트 편차 대응(2026-09-10 오라클피부과 실측으로 추가) // 1. 루트가 프레임셋·meta refresh·JS location.href 껍데기인 사이트: 리다이렉트를 최대 3단계 따라가 실제 홈을 시작 URL로 쓴다. 상대 URL은 갱신된 홈 기준으로 푼다. // 2. EUC-KR 사이트: HTTP Content-Type → meta charset → utf-8 순으로 감지해 TextDecoder 로 디코드한다. // 3. index.php?doc=N 처럼 쿼리가 페이지를 구분하는 사이트: 추적용 파라미터만 빼고 쿼리를 보존한다. slug 에도 쿼리를 넣는다. // 4. 페이지 유형이 URL 경로에 없는 사이트: 앵커 텍스트·제목·h1 의 키워드(시술 어휘 포함)로 분류한다. 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 args = (k) => process.argv.flatMap((x, i) => (x === k && process.argv[i + 1] ? process.argv[i + 1].split(',') : [])).map((s) => s.trim()).filter(Boolean); const CLINIC = arg('--clinic'); const START = arg('--url'); if (!CLINIC || !START) { console.error('사용법: --clinic --url [--extra-url ]'); 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 EXTRA = args('--extra-url'); const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0 Safari/537.36 INFINITH-supporters-evidence/1.0 (+mailto:o2oteam@o2o.kr)'; // 일부 병원(원진)은 비브라우저 UA 를 403 으로 막는다. 브라우저 UA + 식별 문자열 const today = new Date().toISOString().slice(0, 10); // ---------- 페이지 유형 ---------- // 시술 어휘. URL 경로에 유형이 없는 사이트(index.php?doc=N)는 앵커·제목이 시술명 그대로라서 이 사전으로 잡는다. // 성형외과·피부과 공통. 병원 상호에 흔한 "성형외과"·"피부과" 자체는 넣지 않는다(제목 접미어 오분류 방지). const PROCEDURE_VOCAB = /토닝|기미|색소|잡티|주근깨|검버섯|리프팅|울쎄라|써마지|인모드|슈링크|리쥬란|스킨부스터|물광|보톡스|톡신|필러|여드름|흉터|모공|제모|다한증|홍조|주름|탈모|모발이식|비만|지방분해|지방흡입|스케일링|필링|미백|레이저|사마귀|무좀|피부염|아토피|점\s*제거|문신|타투|눈성형|코성형|쌍꺼풀|매몰|절개|트임|눈매교정|안검|눈재수술|코재수술|콧볼|매부리|양악|윤곽|사각턱|광대|무턱|턱끝|가슴확대|가슴축소|가슴재수술|힙업|리프트|줄기세포|재생|주사|시술|수술|성형술/; // 순서가 우선순위. URL 경로와 앵커 텍스트 둘 다 본다. branches 는 지점 목록·지점별 페이지(네트워크 병원). 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|원장\s*(?:소개|단)/i], ['branches', /\bbranch|\bnetwork\b|지점|네트워크\s*병원|(?:^|\s)[가-힣A-Za-z0-9]{1,10}점$/i], ['direction', /\bdirections?\d*\b|\blocation\b|오시는\s*길|찾아오|\bcontact\b|\bway-?to/i], ['facilities', /facilit|\btour\b|둘러보|시설|병원\s*전경/i], ['about', /\babout\b|\bintro|병원\s*소개|특별함|specialt|\bgreeting|인사말|소개$/i], ['reservation', /\breserv|\bcounsel|예약|상담/i], ['pricing', /\bprice|\bcost\b|비급여|수가|비용/i], ['procedure', PROCEDURE_VOCAB], ]; const BRANCH_CAP = 20; // 지점별 페이지는 20개까지. 네트워크 병원은 지점이 수십 개라 MAX 를 다 먹는다 const SKIP = /\/(board|news|event|community|review|gallery|model|login|register|mypage|cart|feed|tag|category|author|wp-|xmlrpc)|[?&](s|q|search|lang|language)=|\/(en|eng|zh|cn|chn|jp|jpn|ja|ru|th|vi|vn|mn|id)(\/|$)|before-?and-?after|before-?after|\/(before|after)\/|com\.php\?code=|photo\.php|\/mem_|\/join|\/logout|\/claim|\/clause|\/privacy|\/policy|\/recruit|\/real\.php|_movi|\/alliance|\.(jpg|jpeg|png|gif|svg|webp|pdf|css|js|ico|zip|ai|eps|psd|doc|docx|hwp|xls|xlsx|ppt|pptx|mp4|mov|avi)(\?|$)|#|mailto:|tel:|javascript:/i; const PROCEDURE_HINT = /수술\s*시간|마취|회복|실밥|입원|붓기|절개|시술\s*시간|수술\s*방법/; // 지점명 후보에서 뺄 일반 명사("장점"·"단점" 같은 X점) const NOT_BRANCH = new Set(['장점', '단점', '관점', '시점', '초점', '중점', '종점', '요점', '강점', '득점', '이점', '문제점', '차이점', '공통점', '출발점', '기준점', '지점', '거점', '정점', '원점', '점점', '접점', '분기점', '전환점', '만점', '평점', '채점', '감점', '흑점', '반점', '얼룩점']); // 경로를 먼저 보고, 경로로 못 정하면 짧은 앵커 텍스트(메뉴 항목)만 본다. 메뉴 전체가 붙은 긴 앵커는 무시. function classify(path, anchor = '') { const segs = path.split('/').filter(Boolean).map((x) => x.replace(/[-_]/g, ' ')); // 경로에는 시술 어휘를 적용하지 않는다(경로 "/surgery/" 같은 일반어가 전부 procedure 로 잡히는 것을 막는다) for (const [t, re] of TYPES) if (t !== 'procedure' && (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'; } // breadcrumb 상위 항목 전용 어휘. "Home > 메디칼스킨케어 > 여드름/모공관리 > 알라딘필" 처럼 제목이 제품명이어도 상위 분류로 시술 페이지임을 안다 const CRUMB_PROCEDURE = /피부과|성형외과|피부\s*관리|스킨\s*케어|시술|수술|클리닉|치료|안티에이징|에스테틱|메디칼|메디컬/; // 본문 첫 항목들에서 breadcrumb("Home > A > B > 제목") 을 찾아 상위 항목 배열로 function breadcrumbs(items) { for (const it of items.slice(0, 6)) { const m = (it.text ?? '').match(/^(?:home|홈|메인)\s*[>›»]\s*(.+)$/i); if (m) return m[1].split(/\s*[>›»]\s*/).map((x) => x.trim()).filter(Boolean); } return []; } // 제목·h1 → breadcrumb 상위 항목 순으로 분류. 경로·앵커로 못 정한 페이지(candidate)에만 쓴다. 사이트 공통 접미어("| 뷰성형외과")는 잘라낸다. function classifyByTitle(title = '', h1 = '', crumbs = []) { const head = [h1, title.split(/\s*[|::]\s*|\s+-\s+/)[0]].map((s) => (s ?? '').trim()).filter((s) => s && s.length <= 40); for (const s of head) for (const [t, re] of TYPES) if (re.test(s)) return t; for (const c of crumbs.slice(0, -1)) { for (const [t, re] of TYPES) if (re.test(c)) return t; if (CRUMB_PROCEDURE.test(c)) return 'procedure'; } return null; } // 본문에서 지점명 후보("청담점"·"부산서면점"). 8개 이상 나오면 지점 목록 페이지로 본다 function branchNames(text) { const names = new Set(); for (const m of text.matchAll(/(?:^|[\s>|·,(\-])((?:[가-힣]{1,4}\s)?[가-힣A-Za-z0-9]{1,10}점)(?=$|[\s<|·,)\-:])/g)) { const n = m[1].replace(/^(서울|경기|인천|부산|대구|대전|광주|울산|세종)\s/, ''); if (!NOT_BRANCH.has(n) && !/^\d+점$/.test(n)) names.add(n); } return [...names]; } // ---------- fetch ---------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); // charset: HTTP 헤더 → 첫 4KB 의 meta charset → utf-8. EUC-KR 계열 별칭은 모두 euc-kr 로 (Node 22 TextDecoder 가 지원) function detectCharset(contentType, bytes) { const fromHeader = /charset=["']?\s*([\w-]+)/i.exec(contentType ?? '')?.[1]; if (fromHeader) return fromHeader; const head = new TextDecoder('latin1').decode(bytes.subarray(0, 4096)); return /]+charset=["']?\s*([\w-]+)/i.exec(head)?.[1] ?? 'utf-8'; } function decodeBody(bytes, charset) { let label = charset.toLowerCase().replace(/^x-/, ''); if (/^(euc-?kr|ks_?c_?5601(?:-1987|-1992)?|ksc5601|cp949|windows-949|uhc)$/.test(label)) label = 'euc-kr'; try { return new TextDecoder(label).decode(bytes); } catch { return new TextDecoder('utf-8').decode(bytes); } } 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 bytes = new Uint8Array(await res.arrayBuffer()); const contentType = res.headers.get('content-type') ?? ''; const charset = detectCharset(contentType, bytes); return { status: res.status, finalUrl: res.url, body: decodeBody(bytes, charset), contentType, charset }; } catch (e) { if (i === tries) return { status: 0, finalUrl: url, body: '', error: e.message }; await sleep(800 * (i + 1)); } } } // ---------- robots (origin 별 캐시) ---------- const robotsCache = new Map(); async function loadRobots(origin) { if (robotsCache.has(origin)) return robotsCache.get(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); } } } const out = { raw: r.body, disallow: dis, allowed: (path) => !dis.some((d) => path.startsWith(d)) }; robotsCache.set(origin, out); return out; } // ---------- 링크 정규화 ---------- const TRACKING = /^(utm_|fbclid|gclid|ref$|PHPSESSID|sid$|_ga)/i; function normalize(href, base) { try { const u = new URL(href, base); if (!/^https?:$/.test(u.protocol)) return null; u.hash = ''; // 쿼리는 보존한다(index.php?doc=14 처럼 쿼리가 곧 페이지인 사이트). 추적 파라미터만 빼고 키 순으로 정렬해 같은 페이지가 한 키가 되게 한다 const keep = [...u.searchParams.entries()].filter(([k]) => !TRACKING.test(k)).sort(([a], [b]) => a.localeCompare(b)); u.search = ''; for (const [k, v] of keep) u.searchParams.append(k, v); // 슬래시를 강제로 붙이지 않는다. 뷰성형외과(WordPress)는 /x → /x/ 로 301 하지만, 원진(k-wonjin.co.kr)은 /x/ 가 404 다. // 키는 슬래시 없는 형태로 통일하고(루트 제외), 404 면 collect() 에서 반대 형태를 한 번 더 시도한다. let s = u.toString(); if (u.pathname !== '/' && !u.search && s.endsWith('/')) s = s.slice(0, -1); return s; } catch { return null; } } const hostOf = (u) => new URL(u).host.replace(/^www\./, ''); const HOSTS = new Set([hostOf(START)]); // 허용 호스트: 시작 URL + 리다이렉트로 옮겨간 홈 + --extra-url const sameHost = (u) => { try { return HOSTS.has(hostOf(u)); } catch { return false; } }; // ---------- 시작 URL 해석(프레임셋·meta refresh·JS 리다이렉트) ---------- // 껍데기 페이지(프레임셋이 있거나 본문이 200자 미만)에서만 리다이렉트 대상을 찾는다. 일반 페이지의 onclick 핸들러 속 location.href 는 대상이 아니다. function redirectTargets(html, base) { const out = []; const add = (h) => { const u = h && normalize(h.trim(), base); if (u && u !== normalize(base, base)) out.push(u); }; for (const m of html.matchAll(/]+http-equiv=["']?refresh["']?[^>]*content=["']?\s*\d+\s*;\s*url=\s*([^"'>\s]+)/gi)) add(m[1]); for (const m of html.matchAll(/]*\ssrc=["']?([^"'>\s]+)/gi)) add(m[1]); for (const s of html.matchAll(/]*>([\s\S]*?)<\/script>/gi)) { // 주석을 지우고, 중괄호 깊이 0(함수·블록 바깥)의 문장만 남긴다 const src = s[1].replace(/\/\*[\s\S]*?\*\//g, '').replace(/(^|[^:'"\\])\/\/[^\n]*/g, '$1'); let depth = 0, top = ''; for (const ch of src) { if (ch === '{') depth++; else if (ch === '}') depth = Math.max(0, depth - 1); else if (depth === 0) top += ch; } for (const m of top.matchAll(/(?:window\.|document\.|top\.|self\.|parent\.)?location(?:\.href)?\s*=\s*["']([^"']+)["']|location\.replace\(\s*["']([^"']+)["']/g)) add(m[1] ?? m[2]); } return [...new Set(out)]; } const isShell = (html, ex) => / i.text ?? '').join(' ')).length < 200; // ---------- 본문 추출 ---------- 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 seen = new Set(); const rows = el.querySelectorAll('tr').map((tr) => tr.querySelectorAll('td,th').map((c) => clean(c.text)).filter((c, i, a) => i === 0 || c !== a[i - 1])).filter((r) => r.some(Boolean)).filter((r) => { const k = r.join('|'); if (seen.has(k)) return false; seen.add(k); return true; }); 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))]; // 가 아니라 onclick 의 location.href·window.open 으로만 이어지는 페이지(지점 카드 등). 앵커 텍스트가 없으니 후순위 const jsLinks = [...new Set([...html.matchAll(/(?:location(?:\.href)?\s*=|location\.replace\(|window\.open\()\s*["']([^"'\s]+)["']/g)].map((m) => normalize(m[1], url)).filter(Boolean))]; return { title, metaDesc, mainSelector, items, images, links, anchors, jsLinks }; } // 사실 후보용 전문(메뉴·푸터 포함). body 가 없는 옛 HTML 은 root 전체 const fullText = (html) => { const r = parse(html); return clean((r.querySelector('body') ?? r).text ?? '').slice(0, 200000); }; /** 항목을 헤딩 기준 섹션으로 묶는다 */ 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; } // ---------- 사실 후보(정규식) ---------- const REGION = '(?:서울|경기|인천|부산|대구|대전|광주|울산|세종|강원|충북|충남|충청|전북|전남|전라|경북|경남|경상|제주)'; const PHONE = '(?:0\\d{1,2}[-.)\\s]\\d{3,4}[-.\\s]\\d{4}|1[5-9]\\d{2}[-.\\s]\\d{4})'; // 02-544-2777 · 02)352-2184 · 1577-1175(대표번호) // 주소 시작: 지역명 뒤에 행정단위 접미어(특별시·광역시·도 등)나 공백이 와야 한다. "서울대입구점서초점…" 처럼 띄어쓰기 없는 지점명 나열을 주소로 잡지 않게 const REGION_START = `${REGION}(?:(?:특별시|광역시|특별자치시|특별자치도|남도|북도|도|시)\\s*|\\s+)(?:[가-힣]{1,6}(?:시|구|군|읍|면)\\s)?`; // 주소: 지역명 ~ (빌딩|층|호|번지|로 N|길 N) 까지 짧게 잡고, 뒤에 "한일빌딩 4층" 같은 건물·층 토큰이 이어지면 붙인다 // 확장 토큰은 "한일빌딩"·"4층"·"304호"·"B동" 꼴만. "지점연결번호" 처럼 호로 끝나는 일반어는 붙지 않게 한다 const ADDR_EXT = '(?:,?\\s(?:\\d+[-\\d]*호|\\d{1,3}(?:,\\d{1,3})?층|[A-Za-z0-9]{1,3}동|[A-Za-z0-9가-힣]{1,12}(?:빌딩|건물|타워|프라자|센터|센타|아파트|상가|병원)))*'; const ADDRESS = new RegExp(`(${REGION_START}[^\\n|·]{4,60}?(?:빌딩|건물|층|호|\\d+번지|로\\s?\\d+(?:길\\s?\\d+)?(?:-\\d+)?|길\\s?\\d+(?:-\\d+)?)${ADDR_EXT})`, 'g'); function factCandidates(text) { const pick = (re) => [...new Set([...text.matchAll(re)].map((m) => clean(m[1] ?? m[0])))]; return { phone: pick(new RegExp(`(? 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 ? (page.flags.textInImages ? '이름 미확인(이미지 글자) · 확인 대기' : '이름 미확인 · 확인 대기') : 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 }; } // ---------- 메인 ---------- mkdirSync(join(OUT, 'pages'), { recursive: true }); console.log(`[1/4] 시작 ${START}`); // 시작 URL 해석: 껍데기면 리다이렉트 대상들을 받아 같은 호스트 링크가 가장 많은 페이지를 홈으로, 나머지는 씨앗 페이지로 let home = await fetchText(START); if (home.status !== 200) { console.error(`홈 요청 실패: ${home.status} ${home.error ?? ''}`); process.exit(1); } let homeEx = extract(home.body, home.finalUrl); const seeds = []; // { url, ex } 홈 외에 앵커를 긁을 페이지(프레임·리다이렉트 대상·--extra-url) const redirectChain = []; for (let hop = 0; hop < 3 && isShell(home.body, homeEx); hop++) { const targets = redirectTargets(home.body, home.finalUrl).filter(sameHost).slice(0, 5); if (!targets.length) break; const fetched = []; for (const u of targets) { const r = await fetchText(u); if (r.status === 200 && /html/.test(r.contentType)) fetched.push({ url: u, r, ex: extract(r.body, r.finalUrl) }); } if (!fetched.length) break; fetched.sort((a, b) => b.ex.anchors.filter(([u]) => sameHost(u)).length - a.ex.anchors.filter(([u]) => sameHost(u)).length); const [best, ...rest] = fetched; redirectChain.push({ from: home.finalUrl, to: best.r.finalUrl, kind: / x.r.finalUrl) }); console.log(` 껍데기 페이지(${redirectChain.at(-1).kind}) → 홈 ${best.r.finalUrl}${rest.length ? ` · 씨앗 ${rest.map((x) => x.r.finalUrl).join(', ')}` : ''}`); for (const x of rest) seeds.push({ url: x.r.finalUrl, ex: x.ex, anchor: '' }); home = best.r; homeEx = best.ex; } HOSTS.add(hostOf(home.finalUrl)); const origin = new URL(home.finalUrl).origin; const robots = await loadRobots(origin); console.log(` 홈 ${home.finalUrl} (charset ${home.charset ?? 'utf-8'} · robots Disallow ${robots.disallow.length}개)`); for (const u of EXTRA) { HOSTS.add(hostOf(u)); await loadRobots(new URL(u).origin); const r = await fetchText(u); if (r.status !== 200 || !/html/.test(r.contentType)) { console.log(` 추가 URL 실패 ${u}: ${r.status} ${r.error ?? ''}`); continue; } seeds.push({ url: r.finalUrl, ex: extract(r.body, r.finalUrl), anchor: '', extra: true }); console.log(` 추가 URL ${r.finalUrl} (charset ${r.charset})`); } // 링크 발견: 홈 + 씨앗 + 사이트맵 const EXTRA_SET = new Set(seeds.filter((s) => s.extra).map((s) => normalize(s.url, s.url))); const robotsAllowed = (u) => { const o = new URL(u).origin; const rb = robotsCache.get(o); return rb ? rb.allowed(new URL(u).pathname) : true; }; const queue = new Map(); // url → { anchor, fromHome } const addLink = (u, anchor = '', fromHome = false) => { if (!u || !sameHost(u) || SKIP.test(u) || /\.xml$/i.test(u) || !robotsAllowed(u)) return; const cur = queue.get(u); if (!cur) queue.set(u, { anchor, fromHome }); else { if (fromHome && !cur.fromHome) cur.fromHome = true; if (!cur.anchor && anchor) cur.anchor = anchor; } }; for (const [u, a] of homeEx.anchors) addLink(u, a, true); for (const s of seeds) { addLink(normalize(s.url, s.url), s.anchor, true); for (const [u, a] of s.ex.anchors) addLink(u, a, true); } for (const u of [...homeEx.jsLinks, ...seeds.flatMap((s) => s.ex.jsLinks)]) addLink(u, '', false); 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(/\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(/\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; } queue.delete(normalize(home.finalUrl, home.finalUrl)); queue.delete(normalize(START, START)); 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) })); // 같은 경로의 쿼리 변형(?id=1, ?tab=x)은 클라이언트 상태인 경우가 많다(원진 doctor?id=N·뷰 after-care?tab=X 는 같은 HTML). // 경로별 첫 변형(variant 0)을 모든 유형에서 먼저 받고, 나머지 변형은 그 뒤로. index.php?doc=N 처럼 쿼리가 곧 페이지인 사이트는 변형이 곧 본문이라 뒤에 오더라도 MAX 안에서 받는다 { const byPath = new Map(); for (const x of ranked.slice().sort((a, b) => a.url.localeCompare(b.url))) { const k = hostOf(x.url) + new URL(x.url).pathname.replace(/\/$/, ''); const n = byPath.get(k) ?? 0; x.variant = n; byPath.set(k, n + 1); } } const prio = (t) => { const i = TYPES.findIndex(([k]) => k === t); return i === -1 ? 99 : i; }; const vk = (x) => Math.min(x.variant, 1); const isDoctorDetail = (x) => x.type === 'doctor' && /\/doctors?\/[^/]+\/?$/.test(new URL(x.url).pathname); // 버킷 순서: 유형 확정(시술 제외) → 의료진 상세(40명까지) → 지점(BRANCH_CAP 까지) → 시술 → 후보(홈 메뉴에서 연결된 것 → 얕은 경로 순). // 시술 페이지는 수가 많아(원진 55·오라클 110) 앞에 두면 의료진 상세를 밀어낸다 const bucket = (x) => (x.type === 'candidate' ? 4 : x.type === 'procedure' ? 3 : x.type === 'branches' ? 2 : isDoctorDetail(x) ? 1 : 0); // 유형 버킷 안에서는 홈 메뉴 순서(큐 삽입 순)를 지킨다(의료진은 대표원장이 먼저 나온다). 후보만 URL 길이·사전순으로 안정화 const ordered = ranked.slice().sort((a, b) => vk(a) - vk(b) || bucket(a) - bucket(b) || (bucket(a) === 4 ? Number(b.fromHome) - Number(a.fromHome) : prio(a.type) - prio(b.type)) || a.depth - b.depth || (bucket(a) === 4 ? a.url.length - b.url.length || a.url.localeCompare(b.url) : 0)); const caps = { 1: 40, 2: BRANCH_CAP }; const taken = {}; const targets = ordered.filter((x) => { const b = bucket(x); taken[b] = (taken[b] ?? 0) + 1; return !(b in caps) || taken[b] <= caps[b]; }).slice(0, MAX); const n = (b) => ranked.filter((x) => bucket(x) === b).length; console.log(` 유형 확정 ${n(0)} · 의료진 상세 ${Math.min(n(1), 40)}/${n(1)} · 지점 ${Math.min(n(2), BRANCH_CAP)}/${n(2)} · 시술 ${n(3)} · 후보 ${n(4)} · 쿼리 변형 ${ranked.filter((x) => x.variant > 0).length} → 수집 ${targets.length}`); // 수집 const pages = []; const slugOf = (u) => { const x = new URL(u); return ((x.pathname.replace(/^\/|\/$/g, '') + (x.search ? '-' + x.search.slice(1) : '')).replace(/[^a-zA-Z0-9가-힣]+/g, '-').replace(/^-|-$/g, '') || 'home'); }; async function collect(t) { let r = await fetchText(t.url); if (r.status === 404) { // 슬래시 유무 반대 형태 재시도 const alt = t.url.endsWith('/') ? t.url.slice(0, -1) : t.url + '/'; const r2 = await fetchText(alt); if (r2.status === 200) r = r2; } 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'); // 경로·앵커로 못 정한 페이지: 제목·h1 키워드 → 본문의 지점명 밀도 → 시술 본문 단서 순 if (type === 'candidate') type = classifyByTitle(ex.title, h1, breadcrumbs(ex.items)) ?? (branchNames(bodyText).length >= 8 ? 'branches' : 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, extra: EXTRA_SET.has(t.url) || undefined, anchor: t.anchor, status: r.status, charset: r.charset, title: ex.title, metaDesc: ex.metaDesc, h1, mainSelector: ex.mainSelector, items: ex.items, images: ex.images, links: ex.links.filter(sameHost), rawText: bodyText, fullTextForFacts: fullText(r.body), 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, charset: home.charset, title: homeEx.title, metaDesc: homeEx.metaDesc, h1, mainSelector: homeEx.mainSelector, items: homeEx.items, images: homeEx.images, links: homeEx.links.filter(sameHost), rawText: homeEx.items.map((i) => i.kind === 'table' ? i.rows.flat().join(' ') : i.text).join('\n'), fullTextForFacts: fullText(home.body), flags: {} }); } // slug 충돌 방지(같은 slug 로 정규화되는 두 URL) { const used = new Map(); for (const p of pages) { const n = used.get(p.slug) ?? 0; used.set(p.slug, n + 1); if (n) p.slug = `${p.slug}-${n + 1}`; } } // 같은 경로에서 본문(제목+항목)이 같은 쿼리 변형은 중복. 첫 페이지만 남기고 나머지는 type=duplicate 로 index 에만 기록한다 { const seen = new Map(); for (const p of pages.slice().sort((a, b) => a.url.length - b.url.length)) { // 짧은 URL(쿼리 없는 쪽)을 대표로 if (p.status !== 200 || p.error) continue; const key = hostOf(p.url) + new URL(p.url).pathname + '\n' + p.title + '\n' + p.rawText; // http/https 는 같은 페이지 const first = seen.get(key); if (first) { p.type = 'duplicate'; p.duplicateOf = first.url; p.items = []; p.images = []; if (p.extra) first.extra = true; } else seen.set(key, p); } } const dupCount = pages.filter((p) => p.type === 'duplicate').length; console.log(`\n[3/4] 페이지 ${pages.length}개 수집${dupCount ? ` (쿼리 변형 중복 ${dupCount})` : ''}. 반복 줄 제거`); // 반복 줄(boilerplate) 제거: 페이지의 30% 이상(최소 3)에 같은 줄이 있으면 메뉴·푸터로 본다 const ok = pages.filter((p) => p.status === 200 && !p.error && p.type !== 'duplicate'); 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; // 이미지 글자: 본문 500자 미만에 이미지 3장 이상, 또는 본문 100자 미만에 본문 이미지가 1장이라도 있으면(시술 설명을 통짜 이미지로 넣는 사이트) p.flags = { textInImages: (p.chars < 500 && p.images.length >= 3) || (p.chars < 100 && p.images.length >= 1), 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, duplicateOf: p.duplicateOf, 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, resolvedStart: home.finalUrl, redirectChain, extraUrls: EXTRA, charset: home.charset ?? null, fetchedAt: today, robotsDisallow: robots.disallow, boilerplateLines: boiler.size, byType, pages: index }, null, 1)); // 의료진: 상세 페이지가 있으면 상세, 없으면 목록 페이지(이미지 글자면 이름 미확인으로 남긴다) let doctors = ok.filter((p) => p.type === 'doctor-detail').map(parseDoctor); const doctorSource = doctors.length ? 'doctor-detail' : 'doctor'; if (!doctors.length) doctors = ok.filter((p) => p.type === 'doctor').map(parseDoctor); writeFileSync(join(OUT, 'doctors.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, source: doctorSource, note: 'textInImages=true 인 약력은 이미지 글자라 본문에 없다. authors.json에 옮기기 전에 사람이 확인한다.', doctors }, null, 1)); // 사실 후보 const factSrc = ok.filter((p) => ['home', 'direction', 'about', 'reservation'].includes(p.type) || p.extra); // --extra-url 로 지정한 페이지는 사람이 고른 원천이라 항상 포함 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); } } // 지점 후보: 지점 목록·지점별 페이지의 "지점명 주소 TEL" 3종 세트. 본문(메뉴·푸터 제외)에서 먼저 찾고, 없으면 전문에서 const branches = []; const branchPagesNap = []; for (const p of ok.filter((p) => p.type === 'branches' || p.extra)) { let found = branchCandidates(p.text ?? ''); if (!found.length) found = branchCandidates(p.fullTextForFacts); for (const b of found) if (!branches.some((x) => x.name === b.name && x.phone === b.phone)) branches.push({ ...b, source: p.url }); // 지점 한 곳짜리 페이지(net_main.php?netId=N 같은): 3종 세트가 없으면 본문의 주소·전화·진료시간을 그대로 후보로 if (found.length < 3) { const c = factCandidates(p.text ?? ''); if (c.address.length || c.phone.length || c.hours.length) branchPagesNap.push({ url: p.url, title: p.title, h1: p.h1, address: c.address, phone: c.phone, hours: c.hours }); } } writeFileSync(join(OUT, 'facts.draft.json'), JSON.stringify({ clinic: CLINIC, fetchedAt: today, note: '정규식 후보. 값마다 출처 URL. factSheet.json에 넣기 전에 사람이 홈페이지 표기와 대조한다. 후보가 없으면 "확인 대기". branches 는 지점 목록 페이지에서 뽑은 지점명·주소·전화 후보.', candidates: facts, branches, branchPages: branchPagesNap }, null, 1)); // 40자 중복 검사 기준 텍스트 writeFileSync(join(OUT, 'home_text.txt'), ok.map((p) => p.text.replace(/\s+/g, '')).join('\n')); // 인용 URL 커버리지: --posts 의 글 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])) 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}명, 출처 ${doctorSource}) · 지점 후보 ${branches.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(', ')}`);