- 템플릿: 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>
264 lines
27 KiB
JavaScript
264 lines
27 KiB
JavaScript
// 새 병원의 첫 배치 기획 목록(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(', ')}` : ''}`);
|