- 템플릿: 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>
250 lines
18 KiB
JavaScript
250 lines
18 KiB
JavaScript
// 병원 홈페이지 시술 페이지 원문에서 "시술 후 관리" 문장을 골라 /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}`);
|