o2o-infinith-demo/supporters/scripts/collect_recovery.mjs
Haewon Kam aa5b05227f feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리
- 템플릿: 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>
2026-09-11 11:27:59 +09:00

250 lines
18 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 병원 홈페이지 시술 페이지 원문에서 "시술 후 관리" 문장을 골라 /stay·/en/stay 의 "시술 후 관리 안내" 블록 데이터를 만든다.
// 근거는 collect_evidence.mjs 결과(index.json + pages/*.json). 이미지 글자를 OCR 한 페이지(flags.ocr)도 text 에 들어 있으므로 같이 본다.
// 의학 판단을 하지 않는다. 병원이 적어 둔 문장을 그대로 옮기고, 출처 URL 과 확인일을 붙인다.
//
// node scripts/collect_recovery.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--translate] [--max-per-page 5] [--max-pages 30] [--max-cost-usd 0.5]
//
// 채택 조건 (문장 단위. 마침표·물음표·줄바꿈·" / " 로 나누고 10~160자)
// (a) 시점 표현: 시술 후 / 치료 후 / 수술 후 / 당일 / 다음 날 / N일·주·개월 (정도·간·이내·후·까지) / 이후 / 기간
// (b) 행동·주의 표현: 피하·삼가·주의·금지·권장·가능·세안·자외선·사우나·음주·흡연·운동·화장·샤워·부기·멍·실밥 등
// 둘 다 있어야 채택. 효과·홍보 문장(효과·개선·매력·아름다·만족·최고·추천)은 제외. 병원 이름·전화·주소 문장 제외.
// 수술 전 준비 문장("수술 전 3일간 금주")은 시술 후 관리가 아니므로 제외.
// OCR 문장은 그대로 쓰되 글자가 깨진 흔적(~~, 홀로 남은 조사, 표 라벨 뒤섞임)이 있으면 뺀다.
// 발행 게이트(gate/rules.mjs)의 금칙어·효과 보장·금액·운영자 어휘·비교 수치 규칙을 문장마다 적용해 걸리는 것은 뺀다.
//
// 출력 <site>/src/data/recoveryNotes.json. 빈 결과여도 파일을 만든다(items []). 페이지는 items 가 없으면 블록을 그리지 않는다.
// --translate : OPENAI_API_KEY 로 gpt-4.1-mini 배치 번역. 숫자·단위가 원문과 같지 않으면 그 문장의 en 은 비우고 enFailed 에 남긴다.
// 시술 이름도 같은 배치로 번역해 procedureEn 에 넣는다(고유명사·제품명은 로마자 병기 허용. 예 "물광주사" → "Water-glow injection (Mulgwang)").
// 같은 ko 문장은 캐시(<site>/src/data/recoveryNotes.cache.json)에서 다시 쓴다. 비용 상한 기본 $0.5.
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { join, resolve, dirname } from 'node:path';
import { fileURLToPath } from 'node:url';
import * as R from './gate/rules.mjs';
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
const SUP = here('../');
const args = process.argv.slice(2);
const opt = (k, d) => { const i = args.indexOf(`--${k}`); if (i >= 0 && args[i + 1] && !args[i + 1].startsWith('--')) return args[i + 1]; const eq = args.find((a) => a.startsWith(`--${k}=`)); return eq ? eq.slice(k.length + 3) : d; };
const flag = (k) => args.includes(`--${k}`);
const clinic = opt('clinic');
if (!clinic) { console.error('사용법: --clinic <id> --evidence <dir> --site <dir> [--translate]'); process.exit(2); }
const EV = resolve(opt('evidence', join(SUP, '..', 'evidence', clinic)));
const SITE = resolve(opt('site', SUP));
const maxPerPage = Number(opt('max-per-page', '5'));
// 주의사항·애프터케어 전용 페이지는 관리 문장이 수십 개라 상한을 따로 둔다 (haewon 결정 2026-09-11: "수술 전후 주의사항 길게 해도 돼")
const maxPerCarePage = Number(opt('max-per-care-page', '40'));
const capFor = (type) => (type === 'aftercare' || type === 'precautions' ? maxPerCarePage : maxPerPage);
const maxPages = Number(opt('max-pages', '30'));
const maxCostUsd = Number(opt('max-cost-usd', '0.5'));
const doTranslate = flag('translate');
const today = new Date().toISOString().slice(0, 10);
// ---------- .env (generate_posts.mjs 와 같은 방식: 저장소 루트까지 위로 4단계 탐색) ----------
function loadEnv() {
let dir = SUP;
for (let i = 0; i < 4; i++) {
const p = join(dir, '.env');
if (existsSync(p)) {
for (const line of readFileSync(p, 'utf8').split('\n')) {
const m = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/);
if (m && !process.env[m[1]]) process.env[m[1]] = m[2].replace(/^["']|["']$/g, '');
}
}
dir = dirname(dir);
}
}
loadEnv();
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
const OUT = join(SITE, 'src', 'data', 'recoveryNotes.json');
const CACHE = join(SITE, 'src', 'data', 'recoveryNotes.cache.json');
const writeOut = (j) => { mkdirSync(dirname(OUT), { recursive: true }); writeFileSync(OUT, JSON.stringify(j, null, 2) + '\n'); };
if (!existsSync(join(EV, 'index.json'))) {
console.error(`근거 없음: ${EV}/index.json. 빈 결과를 씁니다.`);
writeOut({ clinic, generatedAt: today, sourceNote: '병원 홈페이지 시술 페이지 원문', items: [], counts: { pages: 0, candidates: 0, sentences: 0, translated: 0, enFailed: 0, costUsd: 0 } });
process.exit(0);
}
const index = readJson(join(EV, 'index.json'));
const factPath = join(SITE, 'src', 'data', 'factSheet.json');
const fact = existsSync(factPath) ? readJson(factPath) : {};
const clinicNames = [fact.name, fact.shortName, index.clinic].filter((s) => s && /[가-힣]/.test(s));
const phone = fact.phone || '';
// 홈페이지 40자 연속 일치 검사 기준(발행 게이트와 같은 파일). 인용 블록은 게이트에서 제외되지만, 다른 페이지의 문장과 겹치는지는 여기서 보지 않는다.
// ---------- 문장 규칙 ----------
const TIME = /시술\s*후|치료\s*후|수술\s*후|당일|다음\s*날|\d+\s*(일|주|개월)\s*(정도|간|이내|후|까지)|이후|기간/;
const ACTION = /피하|피해|삼가|주의|금지|권장|가능|세안|자외선|선크림|사우나|찜질|음주|흡연|운동|화장|샤워|목욕|일상\s*생활|복귀|출근|부기|멍|딱지|보습|냉찜질|온찜질|실밥/;
const PROMO = /효과|개선|매력|아름다|만족|최고|추천|노하우|프리미엄|서비스|제공|전담|책임|케어해|서포트|장비|기기|시스템|자체|생기있|탄탄|오래\s*유지|정밀|안전한|거의\s*(보이지|남지)\s*않|에\s*비해|단축/; // 뒤쪽은 시술 소개·장비 홍보·타 술식 비교 문장의 어휘. 관리 지시문에는 잘 안 나온다
// 수술 전 준비 문장. "수술 전 3일간 금주" 는 시술 후 관리가 아니다. "수술 전/후" 처럼 후가 같이 있으면 통과.
const PRE_OP = /(수술|시술|치료)\s*(\d+\s*(일|주|시간|개월)\s*)?전(?!\s*[\/·후])/;
const CONTACT = /\d{2,4}[-.]\d{3,4}[-.]\d{4}|\d{4}-\d{4}|(서울|부산|대구|인천|광주|대전|울산|경기|강원|충북|충남|전북|전남|경북|경남|제주)[특별광역]*[시도]?\s*[가-힣]+[구군시]|[가-힣]+(로|길)\s*\d+|빌딩|층\b|오시는\s*길|찾아오시는/;
// 표 라벨(수술시간·마취방법 등)이 한 줄에 뒤섞인 OCR 문장은 행 순서가 깨져 있어 통째로 뺀다.
const TABLE_LABEL = /(수술\s*시간|마취\s*방법|입원\s*여부|입원\s*기간|시술\s*시간|수술\s*방법)\s*[::]/;
// 라벨 뒤만 살리는 경우: "회복기간 : …" 처럼 콜론이 있는 라벨, 또는 소제목("수술 후 관리", "시술 후 주의점")이 문장 앞에 붙은 OCR 문장
const COLON_LABEL = /(회복\s*기간|실밥\s*제거)\s*[::]\s*/;
const HEADING_GLUE = /^.{0,12}?(시술|수술)\s*후\s*(주의\s*(점|사항)|관리(\s*방법)?)\s+(?=\S)/;
// OCR 파손 흔적: 물결 두 개, 홀로 남은 조사, 숫자 뒤 알파벳(3-4t시간), 표시 기호, 단독 숫자
const BROKEN = /~~|\s[을를이가은는의]\s|\d[a-zA-Z]시간|[✓√■►▶▪◆●▲]|(?<![\d~\-.,:])\d{1,2}(?!\d|\s*(분|일|주|개월|시간|cc|mm|cm|회|%|배|도|번|시|세|주일|달|kg|g|ml|차|층|단계|가지)|\s*[~\-:]\s*\d|[.,]\d)/;
const compact = (s) => s.replace(/\s+/g, '');
const clean = (s) => s
.replace(/^[\s\-•●▪■#*·√✓※]+/, '') // 목록 기호
.replace(/^\(?\d{1,2}[.)]\s+(?=[가-힣])/, '') // "5. 다음날부터" 목록 번호
.replace(/^\d{1,2}\s+(?=[가-힣])/, '') // "5 다음날부터"
.replace(/\s+\d{1,2}[.,]?$/, '') // 문장 끝에 붙은 다음 항목 번호 "… 가능 2."
.replace(/\s+/g, ' ')
.trim();
function splitSentences(text) {
return String(text || '')
.split(/(?<=[.?!。])\s+|\n+|\s\/\s/)
.map(clean)
.filter(Boolean);
}
/** 채택 여부. 실패 사유를 문자열로 돌려주고, 통과면 정제된 문장을 돌려준다. */
function judge(raw, { ocr }) {
let s = raw;
if (TABLE_LABEL.test(s)) return { drop: 'table' };
const g = s.match(HEADING_GLUE);
if (g) s = s.slice(g[0].length).trim();
const c = s.match(COLON_LABEL);
if (c && c.index > 0) s = s.slice(c.index + c[0].length).trim();
if (s.length < 10 || s.length > 160) return { drop: 'length' };
if (!TIME.test(s)) return { drop: 'time' };
if (!ACTION.test(s)) return { drop: 'action' };
if (s.split(' ').length < 4) return { drop: 'fragment' }; // "수술 다음날부터 가능" 처럼 무엇이 가능한지 없는 표 조각
if (PROMO.test(s)) return { drop: 'promo' };
if (PRE_OP.test(s) && !/후/.test(s)) return { drop: 'preop' };
if (CONTACT.test(s) || clinicNames.some((n) => s.includes(n)) || (phone && s.includes(phone))) return { drop: 'contact' };
if (BROKEN.test(s)) return { drop: ocr ? 'ocr-broken' : 'broken' };
if (ocr && s.length > 100 && !/[,.]/.test(s.slice(0, 80))) return { drop: 'ocr-broken' }; // 표 칸이 한 줄로 이어 붙은 OCR 문장(쉼표·마침표 없이 길다)
if (!/[.다요음함됨]\s*[.)]?$/.test(s) && !/(가능|불가|금지|권장|주의|필요)$/.test(s)) return { drop: 'unfinished' }; // 문장이 끝나지 않은 조각
// 발행 게이트 규칙. 걸리면 뺀다.
const fields = [{ field: 'ko', text: s }];
const gate = [
...R.checkBannedBody(s),
...R.checkOperatorVocab(s),
...R.checkPriceMention(fields),
...R.checkDeviceClaim({}, fields),
...R.checkConclusions(fields),
].filter((x) => x.level === R.E);
if (gate.length) return { drop: `gate:${gate[0].code}` };
return { ok: s };
}
// ---------- 페이지 순회 ----------
const TYPES = new Set(['procedure', 'aftercare', 'precautions']);
const pages = (index.pages ?? []).filter((p) => TYPES.has(p.type) && p.status === 200 && p.file);
const dropCounts = {};
const items = [];
const seenGlobal = new Set(); // 페이지가 달라도 같은 문장(공백 제거 후)은 한 번만
const cleanTitle = (t) => {
let s = String(t || '').split('|')[0].trim();
for (const n of clinicNames) s = s.replace(new RegExp(`^${n.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\s*`), '');
return s.trim();
};
for (const meta of pages) {
const fp = join(EV, meta.file);
if (!existsSync(fp)) continue;
const pg = readJson(fp);
const flags = Array.isArray(pg.flags) ? pg.flags : Object.keys(pg.flags ?? {}).filter((k) => pg.flags[k]);
const ocr = flags.includes('ocr');
const text = pg.text || '';
// 시술 영역: 빵부스러기("Home > 필러 & 보톡스 > 보톡스") 의 첫 항목
const crumb = text.split('\n').find((l) => /^Home\s*>/.test(l.trim()));
const area = crumb ? (crumb.split('>').map((s) => s.trim())[1] ?? '') : '';
const ko = [];
const seenPage = new Set();
for (const raw of splitSentences(text)) {
const j = judge(raw, { ocr });
if (j.drop) { dropCounts[j.drop] = (dropCounts[j.drop] ?? 0) + 1; continue; }
const key = compact(j.ok);
if (seenPage.has(key) || seenGlobal.has(key)) { dropCounts.dup = (dropCounts.dup ?? 0) + 1; continue; }
seenPage.add(key); seenGlobal.add(key);
ko.push(j.ok);
if (ko.length >= capFor(meta.type)) break;
}
if (!ko.length) continue;
items.push({ procedure: cleanTitle(pg.h1) || cleanTitle(pg.title) || cleanTitle(meta.title), procedureEn: '', area, url: pg.url || meta.url, fetchedAt: pg.fetchedAt || index.fetchedAt || today, ko, en: ko.map(() => ''), ocr });
}
items.sort((a, b) => b.ko.length - a.ko.length || a.procedure.localeCompare(b.procedure, 'ko'));
const kept = items.slice(0, maxPages);
const totalSentences = kept.reduce((n, it) => n + it.ko.length, 0);
// ---------- 번역 (선택) ----------
const PRICE = { in: 0.4, out: 1.6 }; // gpt-4.1-mini USD / 1M tokens (2025 목록가)
const numsOf = (s) => (s.match(/\d+(?:[.,]\d+)?/g) ?? []).map((x) => x.replace(',', '.')).sort().join('|');
let costUsd = 0, translated = 0, enFailed = 0, cacheHits = 0;
const failed = [];
async function translateAll() {
const key = process.env.OPENAI_API_KEY;
const cache = existsSync(CACHE) ? readJson(CACHE) : {};
const pending = []; // { it, i, ko, kind } · kind 'name' 은 시술 이름(i = -1). 캐시 키는 'name:' 접두어로 문장과 구분한다.
for (const it of kept) {
it.ko.forEach((s, i) => {
if (cache[s]) { it.en[i] = cache[s]; cacheHits++; }
else pending.push({ it, i, ko: s, kind: 'sentence' });
});
if (it.procedure) {
if (cache[`name:${it.procedure}`]) { it.procedureEn = cache[`name:${it.procedure}`]; cacheHits++; }
else pending.push({ it, i: -1, ko: it.procedure, kind: 'name' });
}
}
if (!pending.length) return;
if (!key) { console.warn('OPENAI_API_KEY 없음. 번역을 비운 채 씁니다.'); return; }
const clinicEn = fact.shortNameEn || fact.nameEn || 'the clinic';
const system = [
'You translate Korean post-treatment care instructions from a medical clinic website into plain English for international patients.',
'Rules: translate each sentence faithfully as an instruction; do not add, soften or strengthen medical advice; do not add explanations.',
'Keep every number exactly as written in the source (digits, ranges such as 3~4 become "3 to 4", percentages, units converted to English words like days/weeks/months/hours). Never turn a number word into a digit and never drop a digit.',
'Items with kind "name" are treatment or page names, not sentences: give a short English treatment name as a title (no trailing period); for proper nouns, brand or product names, keep a romanized Korean form in parentheses, e.g. "물광주사" -> "Water-glow injection (Mulgwang)", "맥스리프트" -> "Max Lift". Glossary: 주걱턱 = protruding chin (mandibular prognathism), 무턱 = receding chin, 사각턱 = square jaw, 매몰법 = non-incision double eyelid (buried suture), 앞트임/뒷트임 = epicanthoplasty / lateral canthoplasty, 광대 = cheekbone, 양악 = two-jaw (orthognathic), 눈매교정 = ptosis correction.',
`The clinic is ${clinicEn}. Do not mention any other clinic. Output JSON only.`,
].join(' ');
const schema = { name: 'translations', strict: true, schema: { type: 'object', additionalProperties: false, properties: { items: { type: 'array', items: { type: 'object', additionalProperties: false, properties: { i: { type: 'integer' }, en: { type: 'string' } }, required: ['i', 'en'] } } }, required: ['items'] } };
const BATCH = 40;
for (let b = 0; b < pending.length; b += BATCH) {
const batch = pending.slice(b, b + BATCH);
const est = (batch.reduce((n, x) => n + x.ko.length, 0) * 1.5 + 400) / 1e6 * PRICE.in + (batch.reduce((n, x) => n + x.ko.length, 0) * 1.2) / 1e6 * PRICE.out;
if (costUsd + est > maxCostUsd) { console.warn(`비용 상한 $${maxCostUsd} 도달 예상. 남은 ${pending.length - b}문장은 번역하지 않습니다.`); break; }
const user = JSON.stringify({ items: batch.map((x, i) => ({ i, kind: x.kind, ko: x.ko })) });
const body = { model: 'gpt-4.1-mini', temperature: 0, messages: [{ role: 'system', content: system }, { role: 'user', content: user }], response_format: { type: 'json_schema', json_schema: schema } };
let j = null;
for (let attempt = 0; attempt < 3; attempt++) {
const res = await fetch('https://api.openai.com/v1/chat/completions', { method: 'POST', headers: { 'content-type': 'application/json', authorization: `Bearer ${key}` }, body: JSON.stringify(body) });
if (res.status === 429 || res.status >= 500) { await new Promise((r) => setTimeout(r, 4000 * (attempt + 1))); continue; }
if (!res.ok) { console.warn(`번역 호출 실패 ${res.status}: ${(await res.text()).slice(0, 200)}`); break; }
j = await res.json(); break;
}
if (!j) { batch.forEach((x) => { enFailed++; failed.push({ ko: x.ko, why: 'api' }); }); continue; }
const u = j.usage ?? {};
costUsd += ((u.prompt_tokens ?? 0) * PRICE.in + (u.completion_tokens ?? 0) * PRICE.out) / 1e6;
let out;
try { out = JSON.parse(j.choices?.[0]?.message?.content ?? '{}').items ?? []; } catch { out = []; }
const byI = new Map(out.map((o) => [o.i, String(o.en ?? '').trim()]));
batch.forEach((x, i) => {
const en = byI.get(i) ?? '';
if (!en) { if (x.kind !== 'name') { enFailed++; failed.push({ ko: x.ko, why: 'empty' }); } return; }
if (x.kind === 'name') { x.it.procedureEn = en.replace(/\.$/, ''); cache[`name:${x.ko}`] = x.it.procedureEn; translated++; return; }
if (numsOf(en) !== numsOf(x.ko)) { enFailed++; failed.push({ ko: x.ko, en, why: 'numbers' }); return; }
x.it.en[x.i] = en; cache[x.ko] = en; translated++;
});
}
writeFileSync(CACHE, JSON.stringify(cache, null, 1) + '\n');
}
if (doTranslate && kept.length) await translateAll();
// ---------- 출력 ----------
const result = {
clinic,
generatedAt: today,
sourceNote: '병원 홈페이지 시술 페이지 원문',
items: kept.map(({ fetchedAt, ...it }) => ({ ...it, fetchedAt })),
counts: { pagesScanned: pages.length, pagesWithNotes: items.length, pages: kept.length, sentences: totalSentences, translated: translated + cacheHits, cacheHits, enFailed, costUsd: +costUsd.toFixed(4), dropped: dropCounts },
...(failed.length ? { enFailed: failed } : {}),
};
writeOut(result);
console.log(`시술 후 관리 문장: 페이지 ${pages.length}개 중 ${items.length}개에서 ${totalSentences}문장 (상위 ${kept.length}페이지) · 번역 ${translated + cacheHits}(캐시 ${cacheHits}) · 실패 ${enFailed} · $${costUsd.toFixed(4)} → ${OUT}`);