o2o-infinith-demo/templates/supporters-astro/scripts/default_briefs.mjs
Haewon Kam aa5b05227f feat(supporters): 회복 일정 플래너를 템플릿·워커에 통합 (/plan·/en/plan), /recovery·/stay 를 /plan 으로 정리
- 템플릿: 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>
2026-09-11 11:27:59 +09:00

264 lines
27 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 새 병원의 첫 배치 기획 목록(brief)을 근거 수집 결과에서 만든다 (v1 §3 Phase 4, v2 §3-11, 피부과 PRD §7·§8).
// 질문 뱅크 카테고리 7종 중 홈페이지 근거만으로 답할 수 있는 A·B·C·D·E 를 골라, 근거 페이지가 실제로 있는 것만 낸다.
// 병원마다 페이지 구성이 다르므로(뷰: 주의사항·애프터케어·안전 페이지 있음 / 원진: 시술 페이지·소개·예약만 텍스트) 두 층으로 고른다.
// 1층: 전용 페이지(precautions·aftercare·safety·direction)가 있을 때의 문항
// 2층: 어느 병원에나 있는 근거(시술 상세·병원 소개·의료진·예약)로 만드는 문항
// 업종(industry)은 plastic(성형외과) / derm(피부과) 두 가지다.
// plastic: 시술 영역을 URL 세그먼트(/contents/facial/...)로 묶는다. 세그먼트가 일반어(new·doc·index.php·숫자)면 제목·헤딩·본문 키워드로 정한다.
// derm : URL 과 무관하게 제목·헤딩·본문 키워드로 피부과 8영역(질문 뱅크 D1~D8)에 배정한다. 시술 글은 조건 표 8행을 요구한다.
// --industry 를 주지 않으면 병원 이름과 시술 페이지 제목의 어휘 비율로 감지하고, 판단이 안 서면 plastic 이다.
// 사람 승인(§3-11 "기획 목록 승인")은 이 파일을 리포트 페이지에서 보고 확정하는 것으로 한다.
//
// node scripts/default_briefs.mjs --clinic <id> --evidence <evidence/<id>> --site <siteDir> [--out briefs.json] [--max 6] [--industry derm|plastic] [--areas N]
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 factsDraftPath = join(EV, 'facts.draft.json');
const factsDraft = existsSync(factsDraftPath) ? JSON.parse(readFileSync(factsDraftPath, '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;
const safeDecode = (u) => { try { return decodeURIComponent(u); } catch { return u; } };
// ---------- 업종 사전 ----------
// 피부과 8영역. 질문 뱅크 D1~D8 과 1:1 (PRD §6·§7). kw 는 제목·헤딩·본문에서 찾는 낱말(소문자 비교).
export const DERM_AREAS = [
{ key: 'pigment', label: '색소·토닝', qb: 'D1', kw: ['색소', '토닝', '기미', '잡티', '주근깨', '흑자', '검버섯', '점빼기', '점 제거', '미백', '피코', '레이저토닝', '오타모반', '색소침착', '멜라닌', '화이트닝', 'ipl', '브이빔', '주근깨'] },
{ key: 'lifting', label: '리프팅·탄력', qb: 'D2', kw: ['리프팅', '탄력', '처짐', '울쎄라', '써마지', '인모드', '슈링크', '실리프팅', '고주파', '하이푸', 'hifu', '튠페이스', '올리지오', '덴서티', '잔주름', '안티에이징', '탄력저하', '볼처짐', '이중턱'] },
{ key: 'booster', label: '스킨부스터·재생', qb: 'D3', kw: ['스킨부스터', '리쥬란', '쥬베룩', '엑소좀', '물광', '연어주사', 'pdrn', '재생', '수분', '피부결', '콜라겐', '샤넬주사', '힐러', '스킨보톡스', '더마샤인', '피부재생', '진피', '리쥬엔'] },
{ key: 'toxin-filler', label: '톡신·필러', qb: 'D4', kw: ['보톡스', '톡신', '보툴리눔', '필러', '주름', '사각턱', '턱끝', '팔자', '이마', '미간', '눈가', '입술', '코필러', '볼륨', '승모근', '히알루론산', '윤곽주사', '지방분해주사'] },
{ key: 'acne', label: '여드름·흉터·모공', qb: 'D5', kw: ['여드름', '흉터', '모공', '피지', '블랙헤드', '압출', '스케일링', '필링', '아그네스', '프락셀', '피코프락셀', '패인 흉터', '튼살', '켈로이드', '수술흉터', 'aha', 'pha', '좁쌀'] },
{ key: 'hair-body', label: '제모·바디', qb: 'D6', kw: ['제모', '레이저제모', '바디', '체형', '셀룰라이트', '겨드랑이', '비키니', '종아리', '쿨스컬프팅', '지방 감소', '몸매', '팔뚝', '복부', '허벅지', '뱃살'] },
{ key: 'disease-hair', label: '피부질환·모발', qb: 'D7', kw: ['홍조', '안면홍조', '혈관', '실핏줄', '다한증', '액취증', '아토피', '건선', '두드러기', '사마귀', '티눈', '무좀', '습진', '탈모', '두피', '모발', '백반증', '피부염', '알레르기', '한관종', '비립종', '쥐젖', '지루', '피부암', '대상포진', '흉터 없는 점', '피부질환'] },
{ key: 'plan', label: '계획·주기·조합', qb: 'D8', kw: ['시술 주기', '권장 주기', '회차', '병행', '조합', '시술 순서', '시술 간격', '유지 관리', '프로그램', '패키지', '캘린더', '연간', '시술 계획', '같이 받', '함께 받', '결혼 전', '행사 전'] },
];
// 성형외과 폴백 사전. URL 세그먼트가 일반어(new·doc·숫자)라 영역을 못 정할 때만 쓴다. 뷰·원진처럼 세그먼트가 있는 사이트는 이 사전을 타지 않는다.
export const PLASTIC_AREAS = [
{ key: 'eye', label: '눈성형', kw: ['눈성형', '쌍꺼풀', '안검', '눈매', '트임', '눈밑', '상안검', '하안검'] },
{ key: 'nose', label: '코성형', kw: ['코성형', '코끝', '콧대', '비중격', '매부리', '코 재수술', '휜코', '들창코'] },
{ key: 'facial', label: '안면윤곽', kw: ['윤곽', '광대', '사각턱', '턱끝', '양악', '안면', 'v라인'] },
{ key: 'breast', label: '가슴성형', kw: ['가슴', '보형물', '유방', '모티바', '프리저베', '유두', '부유방'] },
{ key: 'lifting', label: '리프팅', kw: ['리프팅', '거상', '주름', '탄력', '안티에이징', '실리프팅'] },
{ key: 'body', label: '바디', kw: ['지방흡입', '지방이식', '복부', '허벅지', '팔뚝', '바디', '체형'] },
{ key: 'skin', label: '피부', kw: ['피부', '레이저', '보톡스', '필러', '스킨', '토닝', '여드름'] },
{ key: 'hair', label: '모발이식', kw: ['모발', '이식', '탈모', '헤어라인'] },
];
// 업종 감지용 어휘. 시술 페이지 제목·URL 에서 센다.
const DERM_VOCAB = /피부과|토닝|울쎄라|써마지|인모드|슈링크|리쥬란|스킨부스터|제모|여드름|모공|기미|잡티|탈모|다한증|홍조|레이저|필링|보톡스|톡신|필러|물광|색소|흉터|주름|탄력|사마귀|아토피|액취증|건선|피부질환|점빼기|검버섯|주근깨/i;
const PLASTIC_VOCAB = /성형(?!외과)|수술|절개|매몰|보형물|지방이식|지방흡입|양악|윤곽|거상|재수술|안검|쌍꺼풀|콧대|비중격|가슴|코끝|눈매|트임/;
// 시술 페이지로 볼 제목·URL 낱말 (업종별)
const PROC_WORD = /(성형|수술|술|리프팅|보톡스|필러|이식|교정|축소|확대|재배치|절개|매몰|거상)/;
const DERM_PROC_WORD = new RegExp(`${DERM_VOCAB.source}|시술|치료|주사|관리|클리닉|피부`, 'i');
// URL 세그먼트가 이런 값이면 영역 이름으로 쓰지 않는다 (오라클: /new/doc/index.php?doc=N)
const GENERIC_SEG = /^(new|old|doc|docs|page|pages|view|sub|content|contents|html|php|asp|jsp|index(\.\w+)?|m|mobile|ko|kr|en|web|www|\d+|[a-z]\d+|.*\.(php|asp|aspx|jsp|html?))$/i;
function detectIndustry() {
const forced = opt('industry');
if (forced) { if (!['derm', 'plastic'].includes(forced)) { console.error(`--industry 는 derm 또는 plastic (받은 값: ${forced})`); process.exit(2); } return { industry: forced, how: '인자' }; }
// 병원 이름에 업종이 있으면 그것으로. "피부과성형외과"처럼 둘 다 있으면 먼저 나온 쪽(등록 진료과목 순서로 본다)
const nm = `${fact.name ?? ''} ${fact.shortName ?? ''} ${index.pages.find((p) => p.type === 'home')?.title ?? ''}`;
const iDerm = nm.indexOf('피부과'), iPlastic = nm.indexOf('성형외과');
if (iDerm >= 0 && (iPlastic < 0 || iDerm < iPlastic)) return { industry: 'derm', how: '병원 이름' };
if (iPlastic >= 0) return { industry: 'plastic', how: '병원 이름' };
// 이름에 없으면 시술 페이지 제목·URL 어휘를 센다. 본문이 이미지라 글자 수가 적은 페이지도 제목은 있으므로 status 200 전부를 본다
const titles = index.pages.filter((p) => p.status === 200 && ['procedure', 'other', 'candidate'].includes(p.type)).map((p) => `${p.title ?? ''} ${p.h1 ?? ''} ${safeDecode(p.url)}`);
const derm = titles.filter((t) => DERM_VOCAB.test(t)).length, plastic = titles.filter((t) => PLASTIC_VOCAB.test(t)).length;
if (derm >= 3 && derm > plastic) return { industry: 'derm', how: `시술 페이지 어휘 (피부과 ${derm} > 성형외과 ${plastic})` };
return { industry: 'plastic', how: `기본값 (피부과 어휘 ${derm}, 성형외과 어휘 ${plastic})` };
}
const { industry, how: industryHow } = detectIndustry();
const isDerm = industry === 'derm';
const op = isDerm ? '시술' : '수술';
const areasMax = Number(opt('areas', '0')) || (isDerm ? 8 : 3);
// ---------- 시술 페이지 → 영역 ----------
const pageCache = new Map();
const loadPage = (p) => { if (!p.file) return null; if (!pageCache.has(p.file)) { const f = join(EV, p.file); pageCache.set(p.file, existsSync(f) ? JSON.parse(readFileSync(f, 'utf8')) : null); } return pageCache.get(p.file); };
/** 제목(5점)·헤딩(2점)·본문(등장 횟수 × 0.5, 최대 2.5점) 키워드 점수로 사전의 영역 하나를 고른다. 2점 미만이면 null. */
function classifyByKeywords(p, dict) {
const page = loadPage(p);
const title = `${p.title ?? ''} ${p.h1 ?? ''} ${safeDecode(p.url)}`.toLowerCase();
const heads = (page?.items ?? []).filter((i) => i.kind === 'h').map((i) => i.text).join(' ').toLowerCase();
const body = String(page?.text ?? '').toLowerCase().slice(0, 6000);
let best = null;
for (const a of dict) {
let score = 0;
for (const k of a.kw) {
const kk = k.toLowerCase();
if (title.includes(kk)) score += 5;
if (heads.includes(kk)) score += 2;
const n = body.split(kk).length - 1; if (n) score += Math.min(n, 5) * 0.5;
}
if (score > (best?.score ?? 0)) best = { area: a, score };
}
return best && best.score >= 2 ? best.area : null;
}
// URL 의 시술 세그먼트. /contents/facial/zygoma → facial, /breast/breast-augmentation/motiva → breast. 세그먼트가 없으면 'etc'(기존 동작), 일반어·숫자·파일명이면 null(키워드 폴백).
const areaOfUrl = (u) => { const segs = new URL(u).pathname.split('/').filter(Boolean); const i = segs.findIndex((s) => /^(contents?|procedure|treatment|surgery)$/i.test(s)); const seg = (i >= 0 ? segs[i + 1] : segs[0]); if (seg === undefined) return 'etc'; return GENERIC_SEG.test(seg) ? null : seg; };
// 시술 후보 페이지. 피부과는 procedure 외에 other·candidate 중 피부과 어휘가 있는 페이지도 넣는다(수집기 분류가 doc=N 페이지를 other 로 둘 수 있다).
const procPages = isDerm
? uniq(pages.filter((p) => ['procedure', 'other', 'candidate'].includes(p.type))).filter((p) => DERM_PROC_WORD.test(`${p.title ?? ''} ${p.h1 ?? ''} ${safeDecode(p.url)}`))
: byType('procedure').filter((p) => PROC_WORD.test(p.title ?? '') || PROC_WORD.test(safeDecode(p.url)));
const areas = new Map();
for (const p of procPages) {
let key, meta = null;
if (isDerm) { const a = classifyByKeywords(p, DERM_AREAS); if (!a) continue; key = a.key; meta = a; }
else { key = areaOfUrl(p.url); if (!key) { const a = classifyByKeywords(p, PLASTIC_AREAS); key = a?.key ?? 'etc'; meta = a; } }
if (!areas.has(key)) areas.set(key, { area: key, list: [], meta });
areas.get(key).list.push(p);
}
// 근거 충분성: 영역의 페이지 글자 합이 1,500자 이상이어야 글 하나를 기획한다
const topAreas = [...areas.values()].map((a) => ({ ...a, list: a.list.sort((x, y) => y.chars - x.chars), chars: a.list.reduce((s, p) => s + p.chars, 0) })).filter((a) => a.chars >= 1500).sort((a, b) => b.chars - a.chars).slice(0, areasMax);
const areaLabel = (a) => a.meta?.label ?? ((a.list[0].title ?? '').replace(new RegExp(`[-–·|].*${name}.*$`), '').replace(/\(.*?\)/, '').replace(/당신만 봅니다|-\s*$/g, '').trim() || a.area);
const AVOID = ['전후 사진', '가격 금액', '다른 병원·업계 평균 비교', '효과 보장', '환자 경험담'];
const DERM_AVOID = [...AVOID, '이벤트가·할인·% 표기', '정품 정량 인증 같은 인증 표현', '영구·완치·재발 없음 같은 효과 보장', '병원 페이지에 없는 장비·제제 이름', '허가 범위 밖 효능', '전후 사진·비포애프터를 보라는 유도'];
const CONDITION_ROWS = '시술시간·마취·통증 정도·회복(다운타임)·유지기간·권장 회차·권장 주기·시술자';
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: `${op} 전·후 주의사항에서 꼭 지켜야 할 것은 무엇인가요?`,
intent: isDerm
? '시술 전(약·화장·자외선·시술 간격)과 후(세안·자외선 차단·사우나·화장·운동)의 시점을 시술 공통 항목 중심으로 표. 시술별로 다른 값은 시술명을 명시. 부작용(화상·색소침착·멍·부기·감염)은 페이지에 있는 항목만. 개인차 조건 필수.'
: '수술 전(약·금식·흡연)과 후(부기·샤워·운동·음주)의 시점을 부위 공통 항목 중심으로 표. 부위별로 다른 값은 부위를 명시. 개인차 조건 필수.',
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: isDerm
? '안전 페이지가 공개한 항목(시술자 정책·장비 허가·응급 장비·부작용 대응 절차·정품 정량 서약)을 항목 표로. 정품 정량은 병원이 밝힌 사실로만 쓰고 인증 표현으로 넓히지 않는다. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.'
: '안전 시스템 페이지가 공개한 항목(마취 관리·응급 장비·검진·CCTV 등)을 항목 표로. 다른 병원·업계 평균과 비교하는 수치는 쓰지 않는다. 각 항목마다 상담에서 확인할 질문 하나.',
pages: urls('safety', 'checkup'), reviewer: anesth,
});
if (byType('aftercare').length) add({
id: 'aftercare-program', category: 'D', categoryLabel: '시술 정보', qbIds: ['D-08', 'E-06'],
title: `${op} 후 관리 프로그램은 무엇이 포함되고 언제까지 이어지나요?`,
intent: `애프터케어 페이지의 ${isDerm ? '시술별' : '부위별'} 프로그램·기간을 표로. 견적 포함 여부는 "병원 확인 대기".`,
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: isDerm
? '안전·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 8~10개("- [ ]" 형식). 시술자(의사 직접인지)·장비 이름과 허가·권장 회차와 주기·부작용 대응·비급여 고지 위치를 반드시 포함. 답을 적는 칸을 두고, 질문마다 왜 묻는지 한 줄.'
: '안전·검진·애프터케어 페이지가 공개한 항목을 근거로 상담 질문 체크리스트 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: isDerm
? '병원 소개·진료 분야·의료진 수·지점 수·본사(운영법인)·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장, 다지점이면 지점 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.'
: '병원 소개·진료과·의료진 수·위치를 사실만으로 정리한다. 수상·방송 이력은 홈페이지 텍스트에 있는 것만. 표 하나(진료 분야 × 담당 원장). 팩트 시트의 전화·주소를 쓴다.',
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,
});
// 지점 선택 가이드 (피부과·다지점). 지점 유형 페이지가 있거나 팩트 시트·facts.draft 에 branches 가 있을 때만.
const branchPages = uniq([...byType('branches'), ...byType('branch')]);
const factBranches = Array.isArray(fact.branches) ? fact.branches : [];
const draftBranches = Array.isArray(factsDraft.branches) ? factsDraft.branches : Array.isArray(factsDraft.candidates?.branches) ? factsDraft.candidates.branches : [];
if (isDerm && (branchPages.length || factBranches.length > 1 || draftBranches.length > 1)) {
const srcUrls = [...factBranches, ...draftBranches].map((b) => b?.source ?? b?.src ?? b?.url).filter((u) => typeof u === 'string' && /^https?:/.test(u));
add({
id: 'branch-guide', category: 'B', categoryLabel: '방문·예약', qbIds: ['B-02', 'B-04'],
title: `${name} 지점은 어떻게 고르나요? 지점별 진료시간·담당 원장·예약 경로`,
intent: '팩트 시트 branches 와 지점 페이지가 공개한 지점명·주소·진료시간·담당 원장·예약 경로를 지점 × 항목 표로. 없는 칸은 "확인 대기". 지점 간 우열·추천 서술 없음. 팩트 시트 밖의 지점을 만들지 않는다. 글 하단 "검토 지점"은 사람이 정한다.',
pages: [...new Set([...branchPages.slice(0, 6).map((p) => p.url), ...srcUrls, ...urls('direction', 'about')])].slice(0, 8), reviewer: null,
});
}
for (const a of topAreas) add(isDerm ? {
id: `procedure-${a.area.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`, category: 'D', categoryLabel: '시술 정보', qbIds: [`${a.meta.qb}-01`],
title: `${name} ${areaLabel(a)} 시술은 무엇이 있고 시술시간·통증·회복·유지기간·권장 주기는 어떻게 되나요?`,
intent: `${areaLabel(a)} 시술 페이지들이 공개한 시술 종류·대상·장비(페이지에 적힌 이름만)를 정리하고, 시술마다 조건 표(${CONDITION_ROWS}) 8행을 만든다. 페이지에 없는 칸은 "확인"으로 두고 표 아래에 개인차와 병원 확인 대기를 쓴다. 시술자는 페이지에 "의사 직접"·"의사 감독" 표기가 없으면 "확인 대기". 부작용은 페이지에 있는 항목만 화상·색소침착·멍·부기·감염 같은 구체 어휘로. 효과 서술·전후 사진 언급·금액은 옮기지 않는다.`,
mustCover: [`시술 조건 표 8행(${CONDITION_ROWS})`, '시술 후 관리와 부작용(페이지에 있는 항목만)', '상담에서 확인할 것 3개(시술자·허가·회차 포함)'],
pages: a.list.slice(0, 4).map((p) => p.url), reviewer: rep, area: a,
} : {
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: isDerm
? '시술 페이지가 공개한 시술·장비·회차 항목을 근거로, 상담에서 확인할 질문 체크리스트 8~10개("- [ ]" 형식). 시술자(의사 직접인지)·장비 이름과 허가·권장 회차와 주기·부작용 대응·비급여 고지 위치를 반드시 포함. 질문마다 왜 묻는지 한 줄. 답을 적는 칸.'
: '시술 페이지가 공개한 방법·마취·회복 항목을 근거로, 상담에서 확인할 질문 체크리스트 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': [], 'branch-guide': [], 'safety-system': ['안전', '마취', '응급', 'CCTV', '검진', '시술자', '정품'],
'precautions-guide': ['주의사항', `${op}`, '관리', '실밥', '부기', '붓기', '자외선'], 'aftercare-program': ['관리', '애프터', '회복', `${op}`], 'consult-questions': ['상담', '질문', '고를 때', '기준', '체크'],
};
const areaKw = (a) => { if (a.meta) return [a.meta.label, ...a.meta.kw.slice(0, 8)]; 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 (['visit-guide', 'consult-questions', 'branch-guide'].includes(c.id)) 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, industry, avoid: isDerm ? DERM_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, industry, industryHow, 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(`[${industry} · ${industryHow}] brief ${posts.length}편 → ${outPath} (${posts.map((p) => p.id).join(', ') || '없음'})${topAreas.length ? ` · 시술 영역 ${topAreas.map((a) => `${a.area}(${a.list.length})`).join(', ')}` : ''}`);