- 템플릿: Planner.astro, lib/plan.ts·tour.ts, styles/plan.css, planStrings, pages plan·en/plan·404. Base 내비 회복 일정 → /plan, 언어 짝 /plan↔/en/plan, 옛 주소 리다이렉트(vercel.json), 사이트맵 - 워커 planner 단계(recovery 다음): scripts/build_planner_data.mjs 가 업종별 기본 규칙표(scripts/template/planner/procedures.plastic|derm.json)에 병원 시술 페이지 원문(recoveryNotes)을 matchKeywords 로 붙이고, 장소는 briefs/<clinic>/planner.places.json(큐레이션) 또는 범용 기본표(관광공사 기준 좌표)로 만든다 - 브리프: viewclinic·oracle 큐레이션 장소. 빈 템플릿(관광 데이터 없음)도 빌드·검증 통과(plan.test 14건) - 이전 세션의 미커밋 작업(피부과 수집·OCR·게이트·언어 스위치, stay 페이지 제거)도 이 커밋에 함께 들어감. docs/prd 변경은 제외 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
242 lines
18 KiB
JavaScript
242 lines
18 KiB
JavaScript
// 근거 OCR 단계. collect_evidence.mjs 가 남긴 evidence/<clinic>/ 에서 글자가 이미지에 들어 있는 페이지(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 <id> --evidence <evidence/<id>> [--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/<clinic>/ocr/<slug>/<이미지> 내려받은 원본 이미지
|
|
// evidence/<clinic>/ocr/results.jsonl 이미지별 Vision 원출력(줄·신뢰도·좌표)
|
|
// evidence/<clinic>/pages/<slug>.json ocr[], sections 에 OCR 섹션 추가, text·chars 갱신, flags.ocr
|
|
// evidence/<clinic>/index.json chars·flags 갱신, ocr 요약
|
|
// evidence/<clinic>/home_text.txt 원문 + OCR (40자 연속 일치 게이트 기준. OCR 글자도 병원 문장이므로 합친다)
|
|
// evidence/<clinic>/home_text.ocr.txt OCR 만 따로
|
|
// evidence/<clinic>/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 <id> --evidence <evidence/<id>> (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 ? ' …' : ''}`);
|