// 근거 수집기 (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] // // 산출물 // evidence//index.json 페이지 목록(유형·글자 수·표·이미지·플래그) // evidence//pages/.json 페이지별 원문(제목·헤딩·섹션·표·목록·이미지·링크) // evidence//doctors.json 의료진 페이지에서 읽은 이름·직함·약력(이미지 글자면 textInImages=true) // evidence//facts.draft.json 푸터·오시는길에서 정규식으로 뽑은 NAP 후보 (사람 확인 전 "확인 대기") // evidence//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 --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 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 경로와 앵커 텍스트 둘 다 본다. 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 = ''; // 슬래시를 강제로 붙이지 않는다. 뷰성형외과(WordPress)는 /x → /x/ 로 301 하지만, 원진(k-wonjin.co.kr)은 /x/ 가 404 다. // 키는 슬래시 없는 형태로 통일하고(루트 제외), 404 면 collect() 에서 반대 형태를 한 번 더 시도한다. let s = u.toString(); if (u.pathname !== '/' && s.endsWith('/')) s = s.slice(0, -1); 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(/(? 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(/\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; } 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) { 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'); 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 의 글 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(', ')}`);