// 새 병원의 첫 배치 기획 목록(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 --evidence > --site [--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 (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(', ')}` : ''}`);