95 lines
8.0 KiB
JavaScript
95 lines
8.0 KiB
JavaScript
// 회복 일정 플래너 병원별 데이터 생성 (워커 planner 단계). 설계 v0.2 §13.
|
|
// node scripts/build_planner_data.mjs --clinic <id> --site <siteDir> [--industry derm|plastic]
|
|
//
|
|
// 만드는 것
|
|
// <site>/src/data/procedures.json 업종별 기본 규칙표(scripts/template/planner/procedures.<industry>.json)에
|
|
// 병원 시술 페이지 원문(recoveryNotes.json)의 관리 문장을 matchKeywords 로 시술에 붙인다.
|
|
// 원문 문장은 status clinic 으로 출처(URL)와 함께 들어가고, 나머지 값은 provisional(병원 확인 전) 그대로다.
|
|
// <site>/src/data/places.json briefs/<clinic>/planner.places.json 이 있으면 그것(병원별 큐레이션), 없으면 범용 기본표
|
|
// (scripts/template/planner/places.base.json)에 병원 이름·지역을 치환하고 origin 을 관광공사 수집 기준 좌표로 채운다.
|
|
// 원칙: 없는 값을 채우지 않는다. 의학 수치는 병원 원문 문장만 clinic 이고, 그 밖은 화면에 '병원 확인 전' 배지가 붙는다.
|
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { join, resolve } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const SUP = resolve(fileURLToPath(new URL('../', import.meta.url)));
|
|
const args = process.argv.slice(2);
|
|
const opt = (k, d) => { const i = args.indexOf(`--${k}`); return i >= 0 && args[i + 1] && !args[i + 1].startsWith('--') ? args[i + 1] : d; };
|
|
const clinic = opt('clinic'); const SITE = opt('site');
|
|
if (!clinic || !SITE) { console.error('사용법: --clinic <id> --site <siteDir> [--industry derm|plastic]'); process.exit(2); }
|
|
const DATA = join(SITE, 'src', 'data');
|
|
const readJson = (p) => JSON.parse(readFileSync(p, 'utf8'));
|
|
const writeJson = (p, o) => { mkdirSync(join(p, '..'), { recursive: true }); writeFileSync(p, JSON.stringify(o, null, 2) + '\n'); };
|
|
|
|
const fact = existsSync(join(DATA, 'factSheet.json')) ? readJson(join(DATA, 'factSheet.json')) : {};
|
|
const pinPath = join(SUP, 'briefs', clinic, 'clinic.json');
|
|
const pin = existsSync(pinPath) ? readJson(pinPath) : {};
|
|
|
|
// 업종: --industry > briefs/clinic.json industry > 팩트 시트 kind(피부과) > plastic
|
|
const industry = (() => {
|
|
const forced = opt('industry') || pin.industry;
|
|
if (forced && ['derm', 'plastic'].includes(forced)) return forced;
|
|
return /피부/.test(String(fact.kind ?? '') + String(pin.factSheet?.kind ?? '')) ? 'derm' : 'plastic';
|
|
})();
|
|
const basePath = join(SUP, 'scripts', 'template', 'planner', `procedures.${industry}.json`);
|
|
const base = readJson(basePath);
|
|
|
|
// ---------- 규칙표: 병원 원문 문장 붙이기 ----------
|
|
const notesPath = join(DATA, 'recoveryNotes.json');
|
|
const notes = existsSync(notesPath) ? readJson(notesPath) : { items: [] };
|
|
const shortName = fact.shortName || fact.name || clinic;
|
|
// 병원 원문 중 '회복기 관리·시기' 문장만 쓴다. 효과 주장·홍보문·정형 합병증 고지·표 조각·재수술 시기 조언은 규칙표에 넣지 않는다(의료광고 규정, 그리고 플래너의 목적 밖).
|
|
const CARE = /일상\s?생활|일상\s?복귀|실밥|봉합|샤워|세안|화장|운동|사우나|찜질|음주|흡연|술|담배|압박|붓기|부기|냉찜질|온찜질|자외선|차단제|마사지|누워|수면|식사|음식|유동식|안정|보호자|컷트|펌|염색|가글|양치|콘택트|안경|모자|주의해야|피해|삼가|금지|퇴원|입원/;
|
|
const NOT_CARE = /티가 나지|자연스러|가능할 만큼|부담이 적|원해요|싶어요|예측 가능|만족|아름|예쁘|낮아지지 않|보존되고|수유가 가능|합병증인|재수술|수술을 결정|\|/;
|
|
const isCare = (ko) => ko.length >= 6 && CARE.test(ko) && !NOT_CARE.test(ko);
|
|
// 페이지 제목에서 세부 시술명만: "코성형(매부리코성형)-당신만 봅니다-원진성형외과" → "매부리코성형"
|
|
const subName = (title) => { const full = String(title); const m = full.match(/\(([^)]+)\)/); if (m) return m[1].replace(/FAQ.*$/, '').trim(); return full.split(/\s*[-|]\s*/)[0].replace(/FAQ.*$/, '').trim(); };
|
|
let attached = 0, unmatched = [], dropped = 0;
|
|
const seen = new Set();
|
|
for (const it of notes.items ?? []) {
|
|
const target = base.procedures.find((p) => p.matchKeywords && new RegExp(p.matchKeywords).test(it.procedure));
|
|
if (!target) { unmatched.push(it.procedure); continue; }
|
|
const doc = (it.url.match(/doc=(\d+)/) || [])[1] ?? it.url.replace(/[^a-z0-9]+/gi, '-').slice(-24);
|
|
const srcId = `note-${doc}`;
|
|
if (!base.clinicSources.some((s) => s.id === srcId)) {
|
|
base.clinicSources.push({ id: srcId, label: { ko: `${shortName} 홈페이지 · ${it.procedure} 시술 안내`, en: `${shortName} website · ${it.procedureEn || it.procedure}` }, url: it.url, accessed: it.fetchedAt || notes.generatedAt || '' });
|
|
}
|
|
const sub = subName(it.procedure), subEn = (it.procedureEn || sub).split(/\s*[-|]\s*/)[0].trim();
|
|
(it.ko ?? []).forEach((ko, i) => {
|
|
if (!isCare(ko)) { dropped++; return; }
|
|
const key = `${target.id}|${ko.replace(/\s+/g, '')}`; if (seen.has(key)) return; seen.add(key);
|
|
const en = (it.en ?? [])[i] || ko; // 번역 실패 문장은 한글 원문 그대로 (워커 recovery 단계와 같은 규칙)
|
|
const cid = `${srcId}-${i}`;
|
|
base.constraints[cid] = { ko: `${sub}: ${ko.trim()}`, en: `${subEn}: ${en.trim()}` };
|
|
if (!target.constraints.includes(cid)) target.constraints.unshift(cid); // 병원 원문이 잠정 안내보다 먼저 보이게
|
|
attached++;
|
|
});
|
|
if (target.constraints.some((c) => c.startsWith(srcId + '-')) && !target.sources.includes(srcId)) target.sources.push(srcId);
|
|
}
|
|
for (const p of base.procedures) delete p.matchKeywords;
|
|
base.clinicSources = base.clinicSources.filter((src) => base.procedures.some((p) => p.sources.includes(src.id)));
|
|
base._comment = `${shortName} 회복 일정 플래너 규칙표 (${industry === 'derm' ? '피부과' : '성형외과'} 기본표 + 병원 시술 페이지 원문 ${attached}문장). clinic = 병원 원문, provisional = 병원 확인 전 잠정값(화면 배지). 생성 ${new Date().toISOString().slice(0, 10)}, 워커 planner 단계.`;
|
|
writeJson(join(DATA, 'procedures.json'), base);
|
|
|
|
// ---------- 장소: 병원별 큐레이션 또는 범용 기본표 ----------
|
|
const curated = join(SUP, 'briefs', clinic, 'planner.places.json');
|
|
let placesSummary;
|
|
if (existsSync(curated)) {
|
|
writeJson(join(DATA, 'places.json'), readJson(curated));
|
|
placesSummary = `큐레이션 ${readJson(curated).places.length}곳 (briefs/${clinic}/planner.places.json)`;
|
|
} else {
|
|
const tpl = readJson(join(SUP, 'scripts', 'template', 'planner', 'places.base.json'));
|
|
const tourPath = join(DATA, 'medicalTourismKo.json');
|
|
const origin = existsSync(tourPath) ? (readJson(tourPath).meta?.origin ?? {}) : {};
|
|
const areaKo = fact.areaLabel || fact.address?.locality || shortName;
|
|
const areaEn = fact.areaLabelEn || fact.address?.localityEn || (fact.shortNameEn || shortName);
|
|
const nameEn = fact.shortNameEn || fact.nameEn || shortName;
|
|
const sub = (s) => String(s).replaceAll('{CLINIC_KO}', shortName).replaceAll('{CLINIC_EN}', nameEn).replaceAll('{AREA_KO}', areaKo).replaceAll('{AREA_EN}', areaEn);
|
|
const out = JSON.parse(sub(JSON.stringify(tpl)));
|
|
out.origin = { label: { ko: `${shortName}${fact.address?.street ? ` (${fact.address.street})` : ''}`, en: nameEn }, lat: origin.lat ?? 0, lng: origin.lng ?? 0 };
|
|
out._comment = `${shortName} 범용 큐레이션(기본표 치환). 병원별 큐레이션은 briefs/${clinic}/planner.places.json 으로 준다. 숙소·관광지·행사는 관광공사 데이터(medicalTourism*.json)가 맡는다.`;
|
|
writeJson(join(DATA, 'places.json'), out);
|
|
placesSummary = `범용 기본표 ${out.places.length}곳 (origin ${origin.lat ? `${origin.lat}, ${origin.lng}` : '없음'})`;
|
|
}
|
|
console.log(`planner: 업종 ${industry} · 시술 ${base.procedures.length}종 · 병원 원문 ${attached}문장 붙임(관리 문장 아님 ${dropped}건 제외)${unmatched.length ? ` · 매칭 안 된 시술 ${unmatched.length}(${unmatched.slice(0, 4).join(', ')})` : ''} · ${placesSummary}`);
|