143 lines
12 KiB
JavaScript
143 lines
12 KiB
JavaScript
// 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11).
|
||
// 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다.
|
||
// 병원마다 페이지 구성이 다르므로(뷰: 주의사항·애프터케어·안전 페이지 있음 / 원진: 시술 페이지·소개·예약만 텍스트) 두 층으로 고른다.
|
||
// 1층: 전용 페이지(precautions·aftercare·safety·direction)가 있을 때의 문항
|
||
// 2층: 어느 병원에나 있는 근거(시술 상세·병원 소개·의료진·예약)로 만드는 문항
|
||
// 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다.
|
||
//
|
||
// node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6]
|
||
import { readFileSync, writeFileSync, existsSync } from 'node:fs';
|
||
import { join, resolve } from 'node:path';
|
||
|
||
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 EV = resolve(opt('evidence', `../evidence/${clinic}`)); const SITE = resolve(opt('site', '.'));
|
||
const max = Number(opt('max', '6'));
|
||
if (!clinic || !existsSync(join(EV, 'index.json'))) { console.error('--clinic 과 --evidence <dir> (index.json 있는 폴더) 필요'); process.exit(2); }
|
||
|
||
const index = JSON.parse(readFileSync(join(EV, 'index.json'), 'utf8'));
|
||
const fact = JSON.parse(readFileSync(join(SITE, 'src/data/factSheet.json'), 'utf8'));
|
||
const authors = JSON.parse(readFileSync(join(SITE, 'src/data/authors.json'), 'utf8'));
|
||
const name = fact.shortName || fact.name || clinic;
|
||
// 조사: 받침 있으면 은/을, 없으면 는/를
|
||
const hasBatchim = (w) => { const c = w.charCodeAt(w.length - 1); return c >= 0xac00 && c <= 0xd7a3 ? (c - 0xac00) % 28 !== 0 : false; };
|
||
const eun = (w) => (hasBatchim(w) ? '은' : '는');
|
||
|
||
// 글 근거로 쓰지 않는 페이지: 약관·개인정보·이벤트·자가진단·학술·채용·게시판 목록
|
||
const NOISE = /약관|개인정보|policy|private|terms|event|이벤트|자가진단|trtest|학술|academic|채용|recruit|board|notice|news|blog|login|join|cart|sitemap/i;
|
||
const pages = index.pages.filter((p) => p.status === 200 && p.chars >= 200 && !NOISE.test(p.url + ' ' + (p.title ?? '')));
|
||
const uniq = (list) => { const seen = new Set(); return list.filter((p) => { const k = p.url.replace(/\/$/, '').toLowerCase(); if (seen.has(k)) return false; seen.add(k); return true; }); };
|
||
const byType = (t) => uniq(pages.filter((p) => p.type === t)).sort((a, b) => b.chars - a.chars);
|
||
const urls = (...types) => [...new Set(types.flatMap((t) => byType(t).slice(0, 3).map((p) => p.url)))];
|
||
const physicianIds = Object.keys(authors.physicians ?? {});
|
||
const rep = physicianIds.find((id) => /대표/.test(authors.physicians[id].title ?? '')) ?? physicianIds[0];
|
||
const anesth = physicianIds.find((id) => /마취/.test(authors.physicians[id].title ?? '')) ?? rep;
|
||
|
||
// 시술 페이지를 영역(URL 의 시술 세그먼트)별로 묶는다. /contents/facial/zygoma → facial, /breast/breast-augmentation/motiva → breast
|
||
const PROC_WORD = /(성형|수술|술|리프팅|보톡스|필러|이식|교정|축소|확대|재배치|절개|매몰|거상)/;
|
||
const procPages = byType('procedure').filter((p) => PROC_WORD.test(p.title ?? '') || PROC_WORD.test(decodeURIComponent(p.url)));
|
||
const areaOf = (u) => { const segs = new URL(u).pathname.split('/').filter(Boolean); const i = segs.findIndex((s) => /^(contents?|procedure|treatment|surgery)$/i.test(s)); return (i >= 0 ? segs[i + 1] : segs[0]) ?? 'etc'; };
|
||
const areas = new Map();
|
||
for (const p of procPages) { const a = areaOf(p.url); if (!areas.has(a)) areas.set(a, []); areas.get(a).push(p); }
|
||
const topAreas = [...areas.entries()].map(([area, list]) => ({ area, list: list.sort((a, b) => b.chars - a.chars), chars: list.reduce((s, p) => s + p.chars, 0) })).filter((a) => a.chars >= 1500).sort((a, b) => b.chars - a.chars).slice(0, 3);
|
||
const areaLabel = (a) => (a.list[0].title ?? '').replace(new RegExp(`[-–·|].*${name}.*$`), '').replace(/\(.*?\)/, '').replace(/당신만 봅니다|-\s*$/g, '').trim() || a.area;
|
||
|
||
const AVOID = ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담'];
|
||
const candidates = [];
|
||
const add = (c) => candidates.push(c);
|
||
|
||
// ---------- 1층: 전용 페이지가 있을 때 ----------
|
||
if (byType('precautions').length) add({
|
||
id: 'precautions-guide', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-06', 'D-09'],
|
||
title: '수술 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?',
|
||
intent: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.',
|
||
pages: urls('precautions', 'aftercare'), reviewer: rep,
|
||
});
|
||
if (byType('safety').length) add({
|
||
id: 'safety-system', category: 'E', categoryLabel: '안전·마취', qbIds: ['E-01', 'E-02', 'E-04'],
|
||
title: `${name}의 안전 시스템은 무엇으로 이루어져 있나요?`,
|
||
intent: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.',
|
||
pages: urls('safety', 'checkup'), reviewer: anesth,
|
||
});
|
||
if (byType('aftercare').length) add({
|
||
id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'],
|
||
title: '수술 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?',
|
||
intent: '애프터케어 페이지의 부위별 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".',
|
||
pages: urls('aftercare', 'checkup'), reviewer: rep,
|
||
});
|
||
if (byType('safety').length || byType('aftercare').length) add({
|
||
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
|
||
title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?',
|
||
intent: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.',
|
||
pages: urls('safety', 'aftercare', 'checkup'), reviewer: rep,
|
||
});
|
||
|
||
// ---------- 2층: 어느 병원에나 있는 근거 ----------
|
||
if (byType('about').length || byType('doctor').length) add({
|
||
id: 'what-is-clinic', category: 'A', categoryLabel: '병원 소개', qbIds: ['A-02', 'A-05', 'A-07'],
|
||
title: `${name}${eun(name)} 어떤 병원인가요? 진료 분야·의료진 구성·위치`,
|
||
intent: '병원 소개·진료과·의료진 수·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.',
|
||
pages: urls('about', 'doctor', 'facilities', 'direction'), reviewer: rep,
|
||
});
|
||
if (byType('direction').length || byType('reservation').length) add({
|
||
id: 'visit-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-01', 'B-03', 'B-05'],
|
||
title: `${name} 방문 안내: 위치·진료시간·주차·예약은 어떻게 하나요?`,
|
||
intent: '오시는길·진료시간·주차·예약 경로를 표로. 팩트 시트 값과 오시는길·예약 페이지만 근거로. 확인되지 않은 시간·주차는 "전화 확인".',
|
||
pages: urls('direction', 'reservation', 'about'), reviewer: null,
|
||
});
|
||
for (const a of topAreas) add({
|
||
id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: ['D-01', 'D-06'],
|
||
title: `${name} ${areaLabel(a)}${eun(areaLabel(a))} 어떤 방법이 있고 시술 시간·마취·회복은 어떻게 되나요?`,
|
||
intent: `${areaLabel(a)} 시술 페이지들이 공개한 방법·대상·수술 시간·마취·입원·실밥·일상 복귀를 방법별 조건 표로. 페이지에 없는 칸은 "확인". 효과 서술은 옮기지 않고 절차·조건만.`,
|
||
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, area: a,
|
||
});
|
||
if (!candidates.some((c) => c.id === 'consult-questions') && topAreas.length >= 2) add({
|
||
id: 'consult-questions', category: 'C', categoryLabel: '선택 기준', qbIds: ['C-02', 'E-08'],
|
||
title: '상담에서 꼭 물어봐야 할 질문은 무엇인가요?',
|
||
intent: '시술 페이지가 공개한 방법·마취·회복 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 질문마다 왜 묻는지 한 줄. 답을 적는 칸.',
|
||
pages: [...new Set(topAreas.flatMap((a) => a.list.slice(0, 2).map((p) => p.url)))].slice(0, 5), reviewer: rep,
|
||
});
|
||
|
||
// 글 ↔ 원장 영상 매칭: 사이트 데이터 videos.json(collect_youtube.mjs 산출)의 정보형 롱폼 중 제목이 글 주제 키워드와 겹치는 것 최대 3편.
|
||
// 영상은 "이어서 볼 영상"과 카드 썸네일로만 쓴다. 자막이 없으므로 발언은 인용하지 않는다(§4-2).
|
||
const videosPath = join(SITE, 'src/data/videos.json');
|
||
const vdata = existsSync(videosPath) ? JSON.parse(readFileSync(videosPath, 'utf8')) : null;
|
||
// 후보 = 정보형(info) 롱폼(쇼츠 아님, 90초 이상, 제목에 #shorts 없음)만. collect_youtube 가 붙인 info 판정을 그대로 쓴다.
|
||
const longs = (vdata?.topLong ?? []).concat((vdata?.all ?? []).filter((v) => !v.isShort && v.duration >= 90 && v.info !== false && !/#\s?shorts/i.test(v.title))).filter((v, i, a) => a.findIndex((x) => x.id === v.id) === i);
|
||
// 병원 소개·방문 안내는 영상을 붙이지 않는다(주제와 맞는 영상이 없고 아무 영상이나 붙으면 오해를 준다)
|
||
const KW = {
|
||
'what-is-clinic': [], 'visit-guide': [], 'safety-system': ['안전', '마취', '응급', 'CCTV', '검진'],
|
||
'precautions-guide': ['주의사항', '수술 후', '관리', '실밥', '부기', '붓기'], 'aftercare-program': ['관리', '애프터', '회복', '수술 후'], 'consult-questions': ['상담', '질문', '고를 때', '기준', '체크'],
|
||
};
|
||
const areaKw = (a) => { const t = areaLabel(a); return [t, ...t.split(/[·,/\s]+/).filter((w) => w.length >= 2), a.area]; };
|
||
function matchVideos(c) {
|
||
if (!longs.length) return [];
|
||
const kws = (KW[c.id] ?? []).concat(c.area ? areaKw(c.area) : []).filter(Boolean);
|
||
if (!kws.length) return [];
|
||
return longs.filter((v) => kws.some((k) => v.title.includes(k))).slice(0, 3).map((v) => ({ id: v.id, title: v.title, published: v.published }));
|
||
}
|
||
// 글 ↔ 이미지: images.json(collect_images.mjs)의 시술 도해·장비 중 근거 페이지에서 나온 것을 히어로 1 + 갤러리 최대 3. 병원 소개 글은 시설 사진.
|
||
const imagesPath = join(SITE, 'src/data/images.json');
|
||
const imgs = existsSync(imagesPath) ? JSON.parse(readFileSync(imagesPath, 'utf8')).items ?? [] : [];
|
||
const norm = (u) => String(u).replace(/\/$/, '').toLowerCase();
|
||
function matchImages(c, pg) {
|
||
const pages = new Set(pg.map(norm));
|
||
let pool = c.id === 'what-is-clinic' ? imgs.filter((i) => i.category === 'clinic') : imgs.filter((i) => ['procedure', 'equipment'].includes(i.category) && pages.has(norm(i.page.url)));
|
||
if (c.id === 'visit-guide' || c.id === 'consult-questions') pool = imgs.filter((i) => i.category === 'clinic').slice(0, 2);
|
||
const pick = pool.slice(0, 4).map((i) => ({ src: i.src, alt: i.alt, caption: i.caption }));
|
||
return { hero: pick[0], gallery: pick.slice(1, 4) };
|
||
}
|
||
const posts = candidates.filter((c) => c.pages.length).slice(0, max).map(({ pages: pg, reviewer, area, ...c }) => {
|
||
const { hero, gallery } = matchImages(c, pg);
|
||
return {
|
||
...c, avoid: AVOID, reviewerCandidate: reviewer ?? undefined,
|
||
evidence: { pages: [...new Set(pg)], shorts: [], news: [], regulation: [], platform: false },
|
||
videos: matchVideos({ ...c, area }), ...(hero ? { hero, thumbnail: hero.src } : {}), gallery,
|
||
};
|
||
});
|
||
|
||
const out = { clinic, generatedAt: new Date().toISOString().slice(0, 10), note: '근거 수집 결과에서 자동 제안한 첫 배치. 사람이 확인·수정한 뒤 생성기에 넣는다(v2 §3-11).', evidenceTypes: index.byType, posts };
|
||
const outPath = resolve(opt('out', join(SITE, 'briefs.json')));
|
||
writeFileSync(outPath, JSON.stringify(out, null, 2) + '\n');
|
||
console.log(`brief ${posts.length}편 → ${outPath} (${posts.map((p) => p.id).join(', ') || '없음'})${topAreas.length ? ` · 시술 영역 ${topAreas.map((a) => `${a.area}(${a.list.length})`).join(', ')}` : ''}`);
|