// 병원 홈페이지 시술 페이지 원문에서 "시술 후 관리" 문장을 골라 /stay·/en/stay 의 "시술 후 관리 안내" 블록 데이터를 만든다. // 근거는 collect_evidence.mjs 결과(index.json + pages/*.json). 이미지 글자를 OCR 한 페이지(flags.ocr)도 text 에 들어 있으므로 같이 본다. // 의학 판단을 하지 않는다. 병원이 적어 둔 문장을 그대로 옮기고, 출처 URL 과 확인일을 붙인다. // // node scripts/collect_recovery.mjs --clinic --evidence > --site [--translate] [--max-per-page 5] [--max-pages 30] [--max-cost-usd 0.5] // // 채택 조건 (문장 단위. 마침표·물음표·줄바꿈·" / " 로 나누고 10~160자) // (a) 시점 표현: 시술 후 / 치료 후 / 수술 후 / 당일 / 다음 날 / N일·주·개월 (정도·간·이내·후·까지) / 이후 / 기간 // (b) 행동·주의 표현: 피하·삼가·주의·금지·권장·가능·세안·자외선·사우나·음주·흡연·운동·화장·샤워·부기·멍·실밥 등 // 둘 다 있어야 채택. 효과·홍보 문장(효과·개선·매력·아름다·만족·최고·추천)은 제외. 병원 이름·전화·주소 문장 제외. // 수술 전 준비 문장("수술 전 3일간 금주")은 시술 후 관리가 아니므로 제외. // OCR 문장은 그대로 쓰되 글자가 깨진 흔적(~~, 홀로 남은 조사, 표 라벨 뒤섞임)이 있으면 뺀다. // 발행 게이트(gate/rules.mjs)의 금칙어·효과 보장·금액·운영자 어휘·비교 수치 규칙을 문장마다 적용해 걸리는 것은 뺀다. // // 출력 /src/data/recoveryNotes.json. 빈 결과여도 파일을 만든다(items []). 페이지는 items 가 없으면 블록을 그리지 않는다. // --translate : OPENAI_API_KEY 로 gpt-4.1-mini 배치 번역. 숫자·단위가 원문과 같지 않으면 그 문장의 en 은 비우고 enFailed 에 남긴다. // 시술 이름도 같은 배치로 번역해 procedureEn 에 넣는다(고유명사·제품명은 로마자 병기 허용. 예 "물광주사" → "Water-glow injection (Mulgwang)"). // 같은 ko 문장은 캐시(/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 --evidence --site [--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]시간|[✓√■►▶▪◆●▲]|(? 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}`);