// 근거 OCR 단계. collect_evidence.mjs 가 남긴 evidence// 에서 글자가 이미지에 들어 있는 페이지(flags.textInImages 또는 본문 100자 미만)의 // 콘텐츠 이미지를 내려받아 macOS Vision(scripts/ocr/vision_ocr.swift)으로 읽고, 결과를 페이지 JSON·index.json·home_text·doctors.json 에 되돌려 넣는다. // 시술 설명을 통짜 JPG 로 넣는 사이트(오라클피부과: 시술 84페이지 중 83개가 이미지 글자)를 근거로 쓰기 위한 것이다. // // node scripts/ocr_evidence.mjs --clinic --evidence > [--min-width 400] [--max-images 200] [--min-conf 0.5] [--force] // // 원칙 // - 유료 API 를 쓰지 않는다. Vision 이 준 글자만 쓰고 교정·보완하지 않는다(없는 글자를 만들지 않는다). // - 신뢰도 0.5 미만 줄은 버린다. 줄 병합은 문장 부호 기준으로만 한다: 앞 줄이 . ? ! 로 끝나지 않으면 다음 줄을 같은 문장으로 이어 붙인다. // 세로 간격이 줄 높이의 1.6배를 넘으면 문단을 나눈다(나누기만 하고 붙이지는 않는다). // - 원문(HTML) 텍스트와 OCR 텍스트를 구분한다: 페이지 JSON 에 ocr[]·sections[{source:"ocr"}]·flags.ocr=true·textOriginal 을 남긴다. // - 다시 돌려도 결과가 같다(멱등): 이미 받은 이미지는 다시 받지 않고, 텍스트는 textOriginal 에서 다시 만든다. --force 면 OCR 도 다시 한다. // // 산출물(기존 파일을 갱신) // evidence//ocr//<이미지> 내려받은 원본 이미지 // evidence//ocr/results.jsonl 이미지별 Vision 원출력(줄·신뢰도·좌표) // evidence//pages/.json ocr[], sections 에 OCR 섹션 추가, text·chars 갱신, flags.ocr // evidence//index.json chars·flags 갱신, ocr 요약 // evidence//home_text.txt 원문 + OCR (40자 연속 일치 게이트 기준. OCR 글자도 병원 문장이므로 합친다) // evidence//home_text.ocr.txt OCR 만 따로 // evidence//doctors.json 의료진 페이지 OCR 에서 읽은 이름·직함·약력 후보 ("OCR · 확인 대기") import { mkdirSync, writeFileSync, readFileSync, existsSync, statSync, readdirSync } from 'node:fs'; import { join, basename, dirname } from 'node:path'; import { fileURLToPath } from 'node:url'; import { spawnSync } from 'node:child_process'; // ---------- 인자 ---------- const arg = (k, d) => { const i = process.argv.indexOf(k); return i > -1 ? process.argv[i + 1] : d; }; const CLINIC = arg('--clinic'); const EV = arg('--evidence'); if (!CLINIC || !EV || !existsSync(join(EV, 'index.json'))) { console.error('사용법: --clinic --evidence > (index.json 있는 폴더)'); process.exit(2); } const MIN_WIDTH = Number(arg('--min-width', 400)); const MAX_IMAGES = Number(arg('--max-images', 200)); const MIN_CONF = Number(arg('--min-conf', 0.5)); const FORCE = process.argv.includes('--force'); 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)'; const today = new Date().toISOString().slice(0, 10); const OCR_DIR = join(EV, 'ocr'); mkdirSync(OCR_DIR, { recursive: true }); // ---------- Vision 도구 빌드 ---------- const SWIFT_SRC = fileURLToPath(new URL('./ocr/vision_ocr.swift', import.meta.url)); const BIN = join(dirname(SWIFT_SRC), 'vision_ocr'); function ensureBinary() { const fresh = existsSync(BIN) && statSync(BIN).mtimeMs >= statSync(SWIFT_SRC).mtimeMs; if (fresh) return; console.log(` swiftc 빌드 ${basename(SWIFT_SRC)} → ${basename(BIN)}`); const r = spawnSync('swiftc', ['-O', '-o', BIN, SWIFT_SRC], { encoding: 'utf8' }); if (r.status !== 0) { console.error(r.stderr || r.stdout); console.error('vision_ocr 빌드 실패. Xcode Command Line Tools(swiftc) 가 필요하다'); process.exit(1); } } // ---------- 대상 페이지·이미지 ---------- // 로고·버튼·아이콘·배너·전후사진·장비 썸네일은 이름으로 뺀다. 폭은 Vision 도구가 --min-width 로 거른다 const NOT_CONTENT = /icon|logo|btn|button|arrow|blank|spacer|sns|share|banner|bnr|top_ban|_ban\d|main_bf|convenient|bg_|_bg\b|\/bg\.|dot\.|bullet|quick|visual|slide|roll|thumb|gallery\/|laiser_table|_next\/image|\.bmp$|\.gif$|\.svg$|before|after/i; const TYPE_PRIO = { 'doctor-detail': 0, doctor: 1, procedure: 2, precautions: 3, aftercare: 3, safety: 3, checkup: 3, about: 4, pricing: 4, facilities: 5, direction: 5, reservation: 6, branches: 6, home: 7, other: 8 }; const index = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8')); const loadPage = (f) => JSON.parse(readFileSync(join(EV, f), 'utf8')); const savePage = (f, p) => writeFileSync(join(EV, f), JSON.stringify(p, null, 1)); const flagOn = (p, k) => (Array.isArray(p.flags) ? p.flags.includes(k) : Boolean(p.flags?.[k])); const candidates = index.pages .filter((p) => p.status === 200 && p.type !== 'duplicate' && p.file) .map((p) => ({ ix: p, page: loadPage(p.file) })) .filter(({ page }) => flagOn(page, 'textInImages') || (page.flags?.charsOriginal ?? page.chars ?? 0) < 100) .sort((a, b) => (TYPE_PRIO[a.ix.type] ?? 9) - (TYPE_PRIO[b.ix.type] ?? 9)); console.log(`[1/4] ${CLINIC}: 이미지 글자 페이지 ${candidates.length}개 (전체 ${index.pages.length})`); // 페이지별 콘텐츠 이미지 → 내려받기 목록. 같은 이미지가 여러 페이지에 있으면 한 번만 받는다 const jobs = []; const seenSrc = new Map(); for (const c of candidates) { const imgs = (c.page.images ?? []).filter((im) => im.src && !NOT_CONTENT.test(im.src)); for (const im of imgs) { if (jobs.length >= MAX_IMAGES) break; const key = im.src.replace(/^https?:\/\//, ''); if (seenSrc.has(key)) { seenSrc.get(key).pages.push(c); continue; } const name = decodeURIComponent(basename(new URL(im.src).pathname)).replace(/[^a-zA-Z0-9가-힣._-]+/g, '_') || 'image'; const file = join(OCR_DIR, c.page.slug, name); const job = { src: im.src, alt: im.alt ?? '', file, pages: [c] }; seenSrc.set(key, job); jobs.push(job); } } console.log(` 콘텐츠 이미지 후보 ${jobs.length}개 (--max-images ${MAX_IMAGES}, 이름으로 제외 ${candidates.reduce((n, c) => n + (c.page.images ?? []).filter((im) => NOT_CONTENT.test(im.src ?? '')).length, 0)}개)`); // ---------- 내려받기 ---------- const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); async function download(job) { if (existsSync(job.file) && statSync(job.file).size > 0) { job.downloaded = 'cached'; return; } for (let i = 0; i < 3; i++) { try { const res = await fetch(job.src, { headers: { 'user-agent': UA, referer: job.pages[0].page.url }, signal: AbortSignal.timeout(30000) }); if (res.status !== 200) { job.error = `HTTP ${res.status}`; return; } const buf = new Uint8Array(await res.arrayBuffer()); if (buf.length < 1000) { job.error = `파일이 너무 작음 (${buf.length}B)`; return; } mkdirSync(dirname(job.file), { recursive: true }); writeFileSync(job.file, buf); job.downloaded = 'new'; return; } catch (e) { job.error = e.message; await sleep(600 * (i + 1)); } } } { let i = 0; const workers = Array.from({ length: 3 }, async () => { while (i < jobs.length) { const j = jobs[i++]; await download(j); process.stdout.write(`\r 내려받기 ${i}/${jobs.length}`); } }); await Promise.all(workers); } const fetched = jobs.filter((j) => !j.error); console.log(`\n[2/4] 내려받음 ${fetched.length}개 (새로 ${fetched.filter((j) => j.downloaded === 'new').length} · 캐시 ${fetched.filter((j) => j.downloaded === 'cached').length} · 실패 ${jobs.length - fetched.length})`); // ---------- OCR ---------- ensureBinary(); const RESULTS = join(OCR_DIR, 'results.jsonl'); const prior = new Map(); // file → 결과 (멱등: 이미 읽은 이미지는 다시 읽지 않는다) if (!FORCE && existsSync(RESULTS)) for (const l of readFileSync(RESULTS, 'utf8').split('\n').filter(Boolean)) { try { const o = JSON.parse(l); if (o.path && !o.error) prior.set(o.path, o); } catch { /* 깨진 줄 무시 */ } } const todo = fetched.filter((j) => !prior.has(j.file)); const results = new Map(prior); if (todo.length) { console.log(` Vision OCR ${todo.length}개 (캐시 ${fetched.length - todo.length}개)`); for (let i = 0; i < todo.length; i += 25) { // 한 프로세스에 25장씩. 실패해도 나머지는 진행 const batch = todo.slice(i, i + 25); const r = spawnSync(BIN, ['--min-width', String(MIN_WIDTH), '--langs', 'ko-KR,en-US', ...batch.map((j) => j.file)], { encoding: 'utf8', maxBuffer: 512 * 1024 * 1024 }); if (r.status !== 0 && !r.stdout) { console.error(` vision_ocr 실패: ${r.stderr?.slice(0, 500)}`); for (const j of batch) results.set(j.file, { path: j.file, error: `vision_ocr 종료 코드 ${r.status}` }); continue; } for (const l of r.stdout.split('\n').filter(Boolean)) { try { const o = JSON.parse(l); results.set(o.path, o); } catch { /* 무시 */ } } process.stdout.write(`\r OCR ${Math.min(i + 25, todo.length)}/${todo.length}`); } console.log(''); writeFileSync(RESULTS, [...results.values()].map((o) => JSON.stringify(o)).join('\n') + '\n'); } // ---------- 줄 → 문단 ---------- const ENDS = /[.?!。…]["')\]]?$/; /** 신뢰도로 거르고, 문장 부호 기준으로 줄을 잇는다. 세로 간격이 크면 문단을 나눈다 */ function linesToText(lines) { const kept = lines.filter((l) => l.confidence >= MIN_CONF && l.text.trim()); const paras = []; let cur = null; let prev = null; for (const l of kept) { const t = l.text.trim(); const gapBreak = prev && l.box && prev.box && (l.box[1] - (prev.box[1] + prev.box[3])) > Math.max(prev.box[3], l.box[3]) * 1.6; if (cur && !gapBreak && !ENDS.test(cur)) cur = `${cur} ${t}`; else { if (cur) paras.push(cur); cur = t; } prev = l; } if (cur) paras.push(cur); return { text: paras.join('\n'), kept: kept.length, dropped: lines.length - kept.length }; } // ---------- 페이지에 반영 ---------- let ocrImages = 0, ocrPages = 0, ocrChars = 0; const failed = []; const perPage = []; for (const c of candidates) { const page = c.page; const myJobs = jobs.filter((j) => j.pages.includes(c)); const entries = []; for (const j of myJobs) { const r = results.get(j.file); if (j.error) { failed.push({ page: page.url, src: j.src, reason: j.error }); continue; } if (!r) { failed.push({ page: page.url, src: j.src, reason: 'OCR 결과 없음' }); continue; } if (r.error) { failed.push({ page: page.url, src: j.src, reason: r.error }); continue; } if (r.skipped) continue; // 폭 부족 const { text, kept, dropped } = linesToText(r.lines ?? []); if (!text) continue; const conf = kept ? Math.round((r.lines.filter((l) => l.confidence >= MIN_CONF).reduce((s, l) => s + l.confidence, 0) / kept) * 1000) / 1000 : 0; entries.push({ src: j.src, alt: j.alt, file: j.file.replace(EV + '/', ''), width: r.width, height: r.height, tiles: r.tiles ?? 1, lines: r.lines.filter((l) => l.confidence >= MIN_CONF).map((l) => l.text), droppedLines: dropped, confidence: conf, text, engine: r.engine ?? 'apple-vision', at: today }); } // 원문 텍스트는 textOriginal 로 보존(재실행 시 여기서 다시 만든다) page.textOriginal ??= page.text ?? ''; page.flags ??= {}; page.flags.charsOriginal ??= page.textOriginal.replace(/\s+/g, '').length; page.sections = (page.sections ?? []).filter((s) => s.source !== 'ocr'); page.ocr = entries; const ocrText = entries.map((e) => e.text).join('\n'); if (entries.length) { page.sections.push({ heading: page.title || page.h1 || page.slug, level: 2, source: 'ocr', images: entries.map((e) => e.src), text: ocrText, paragraphs: ocrText.split('\n'), lists: [], tables: [] }); page.text = [page.textOriginal, ocrText].filter(Boolean).join('\n'); page.flags.ocr = true; page.flags.ocrImages = entries.length; page.flags.ocrChars = ocrText.replace(/\s+/g, '').length; ocrImages += entries.length; ocrPages++; ocrChars += page.flags.ocrChars; } else { page.text = page.textOriginal; delete page.flags.ocr; delete page.flags.ocrImages; delete page.flags.ocrChars; } page.chars = page.text.replace(/\s+/g, '').length; savePage(c.ix.file, page); c.ix.chars = page.chars; c.ix.flags = Object.entries(page.flags).filter(([, v]) => v === true).map(([k]) => k); perPage.push({ url: page.url, type: page.type, title: page.title, chars: page.chars, ocrChars: page.flags.ocrChars ?? 0, images: entries.length }); } index.byType = index.pages.reduce((m, x) => ((m[x.type] = (m[x.type] ?? 0) + 1), m), {}); index.ocr = { at: today, engine: 'apple-vision', pages: ocrPages, images: ocrImages, chars: ocrChars, minWidth: MIN_WIDTH, minConf: MIN_CONF, failed: failed.length }; writeFileSync(join(EV, 'index.json'), JSON.stringify(index, null, 1)); console.log(`[3/4] 페이지 ${ocrPages}개에 OCR 반영 (이미지 ${ocrImages}장 · OCR 글자 ${ocrChars.toLocaleString()}자 · 실패 ${failed.length})`); // ---------- home_text ---------- // 40자 연속 일치 게이트는 병원 문장의 그대로 옮김을 막는 것이다. OCR 글자도 병원이 쓴 문장이므로 home_text.txt 에 합친다. // OCR 오독으로 원문과 몇 글자 다를 수 있어 게이트가 그만큼 느슨해질 뿐, 잘못 막는 쪽으로는 가지 않는다. OCR 만 따로 home_text.ocr.txt 에도 남긴다. { const orig = []; const ocr = []; for (const ix of index.pages) { if (ix.status !== 200 || ix.type === 'duplicate' || !ix.file) continue; const p = loadPage(ix.file); const o = (p.textOriginal ?? p.text ?? '').replace(/\s+/g, ''); if (o) orig.push(o); const t = (p.ocr ?? []).map((e) => e.text).join('\n').replace(/\s+/g, ''); if (t) ocr.push(t); } writeFileSync(join(EV, 'home_text.txt'), [...orig, ...ocr].join('\n')); writeFileSync(join(EV, 'home_text.ocr.txt'), ocr.join('\n')); } // ---------- 의료진 ---------- // 의료진 페이지 OCR 줄에서 "이름 원장" 을 찾고, 다음 이름·지점 제목까지의 줄을 약력 후보로. 지점("X점")·지역("X지역")·진료과 제목은 소속으로 붙인다. const NAME_RE = /^[\s|1lI]*([가-힣]{2,4})\s*(대표\s*)?원장(?:님)?\s*$/; const BRANCH_RE = /^[\s|]*((?:[가-힣]{1,4}\s)?[가-힣A-Za-z0-9]{1,10}점)\s*$/; const REGION_RE = /^[\s|]*([가-힣]{2,6}지역)\s*$/; const DEPT_RE = /^[\s|]*(피부과|성형외과|치과|내과|비만\s*[•·]?\s*체형|피부\s*관리)\s*$/; const SPECIALTY_RE = /(성형외과|이비인후과|마취통증의학과|피부과|영상의학과|외과|치과|내과|가정의학과|산부인과)\s*전문의/; function parseDoctorsFromOcr(page) { const out = []; for (const e of page.ocr ?? []) { let region = null, branch = null, dept = null, cur = null; for (const raw of e.lines) { const t = raw.trim(); let m; if ((m = t.match(REGION_RE))) { region = m[1]; continue; } if ((m = t.match(BRANCH_RE))) { branch = m[1]; cur = null; continue; } if ((m = t.match(DEPT_RE))) { dept = m[1].replace(/\s+/g, ' '); continue; } if ((m = t.match(NAME_RE))) { cur = { name: m[1], isRep: Boolean(m[2]), region, branch, dept, credentials: [], image: e.src }; out.push(cur); continue; } if (cur && t.length >= 4 && t.length <= 60) cur.credentials.push(t); } } return out.map((d, i) => { const specialty = d.credentials.map((c) => c.match(SPECIALTY_RE)?.[0]).find(Boolean) ?? null; return { id: `ocr-${i + 1}-${d.name}`, url: page.url, name: d.name, title: [d.isRep ? '대표원장' : '원장', specialty].filter(Boolean).join(' · '), region: d.region, branch: d.branch, dept: d.dept, credentials: d.credentials.filter((c) => c !== specialty), image: d.image, source: 'ocr', status: 'OCR · 확인 대기' }; }); } { const path = join(EV, 'doctors.json'); const doc = existsSync(path) ? JSON.parse(readFileSync(path, 'utf8')) : { clinic: CLINIC, fetchedAt: today, doctors: [] }; const doctorPages = candidates.filter((c) => ['doctor', 'doctor-detail'].includes(c.page.type) && c.page.ocr?.length); const found = doctorPages.flatMap((c) => parseDoctorsFromOcr(c.page)); if (found.length) { // 상세 페이지에서 이미 이름이 읽힌 의사는 유지하고 OCR 약력만 붙인다. 이름 없이 남아 있던 항목(이미지 글자)은 OCR 결과로 바꾼다 // 지난 실행의 OCR 항목(source=ocr)은 버리고 다시 만든다(멱등) const kept = (doc.doctors ?? []).filter((d) => d.name && d.source !== 'ocr').map((d) => { const o = found.find((f) => f.name === d.name); return o ? { ...d, credentialsOcr: o.credentials, ocrImage: o.image, status: d.status === 'ok' ? d.status : 'OCR 약력 · 확인 대기' } : d; }); const add = found.filter((f) => !kept.some((d) => d.name === f.name)); doc.doctors = [...kept, ...add]; doc.ocr = { at: today, pages: doctorPages.map((c) => c.page.url), found: found.length, note: 'source=ocr 항목은 의료진 이미지에서 Vision 으로 읽은 이름·약력 후보. 오독 가능. authors.json 에 옮기기 전에 원본 이미지와 대조한다.' }; writeFileSync(path, JSON.stringify(doc, null, 1)); console.log(` 의료진 OCR 후보 ${found.length}명 (${[...new Set(found.map((f) => f.branch).filter(Boolean))].length}개 지점) → doctors.json ${doc.doctors.length}명`); } else console.log(' 의료진 OCR 후보 없음'); } // ---------- 보고 ---------- const dist = perPage.filter((p) => p.images); console.log(`[4/4] 저장 ${EV}`); console.log(` OCR 이미지 ${ocrImages}장 · 페이지 ${ocrPages}개 · 200자 이상 ${dist.filter((p) => p.ocrChars >= 200).length}개 · 1,000자 이상 ${dist.filter((p) => p.ocrChars >= 1000).length}개 · home_text ${index.pages.reduce((n, p) => n + (p.chars ?? 0), 0).toLocaleString()}자 (OCR ${ocrChars.toLocaleString()}자)`); if (failed.length) console.log(` 실패 ${failed.length}: ${failed.slice(0, 10).map((f) => `${f.src} (${f.reason})`).join(', ')}${failed.length > 10 ? ' …' : ''}`);