o2o-infinith-demo/supporters/scripts/gate/rules.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

411 lines
25 KiB
JavaScript
Raw 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.

// 발행 게이트 규칙. 전부 순수 함수다. 입력은 문자열·객체, 출력은 { level, code, msg }[] 이다.
// level: 'error' = 배포 차단, 'warn' = 표시만. 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3.
import yaml from 'js-yaml';
export const E = 'error';
export const W = 'warn';
const r = (level, code, msg) => ({ level, code, msg });
// ---------- 공통 유틸 ----------
export function stripHtml(html) {
return html
.replace(/<script[\s\S]*?<\/script>/g, ' ')
.replace(/<style[\s\S]*?<\/style>/g, ' ')
.replace(/<[^>]+>/g, ' ')
.replace(/&nbsp;/g, ' ')
.replace(/&amp;/g, '&')
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")
.replace(/[ \t]+/g, ' ');
}
export function sentences(text) {
return text
.split(/(?<=[.!?。])\s+|\n+/)
.map((s) => s.trim())
.filter(Boolean);
}
export function paragraphs(text) {
return text.split(/\n\s*\n/).map((p) => p.trim()).filter(Boolean);
}
/** md 파일 → { data, body }. YAML frontmatter는 js-yaml로 읽는다. */
export function parseMarkdown(src) {
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/);
if (!m) return { data: {}, body: src };
return { data: yaml.load(m[1]) ?? {}, body: m[2] };
}
/** 글에서 검사 대상 텍스트 필드만 모은다. history(갱신 기록)는 규칙을 설명하는 문장이 들어가므로 제외한다. */
export function postTextFields(data, body) {
const summary = (data.summary ?? []).map((s) => ({ field: 'summary', text: String(s) }));
const faq = (data.faq ?? []).flatMap((f) => [{ field: 'faq.q', text: f.q }, { field: 'faq.a', text: f.a }]);
return [...summary, { field: 'body', text: body }, ...faq];
}
// ---------- §3-1 고객 화면과 운영자 설명의 분리 ----------
export const OPERATOR_VOCAB = /답변엔진|AI 인용|AEO|GEO|질문 뱅크|QB [A-G]|실측|단일 원본|수집 기준|API 집계|임베드|(?<![가-힣])복제|의료법 제\d+조|롱폼|쇼츠 분류/g;
export const OPERATOR_EXEMPT = [/^\/editorial\.html$/, /^\/404\.html$/];
export function checkOperatorVocab(text, rel = '') {
if (OPERATOR_EXEMPT.some((re) => re.test(rel))) return [];
const hits = [...new Set((text.match(OPERATOR_VOCAB) ?? []))];
return hits.map((h) => r(E, 'OPERATOR_VOCAB', `고객 화면에 운영자 어휘: "${h}"`));
}
// ---------- §3-2 확정 디자인의 보존 (구조 지문) ----------
/** 텍스트를 제거하고 body 안 요소의 tag.class 순서만 남긴다. 텍스트만 바꿨으면 지문이 같다. */
export function layoutFingerprint(html) {
const body = html.match(/<body[^>]*>([\s\S]*)<\/body>/)?.[1] ?? html;
const tokens = [];
for (const m of body.matchAll(/<([a-z][a-z0-9-]*)\b([^>]*)>/g)) {
const tag = m[1];
if (['script', 'style', 'link', 'meta', 'br', 'path', 'svg'].includes(tag)) continue;
const cls = m[2].match(/\bclass="([^"]*)"/)?.[1]?.trim().split(/\s+/).sort().join('.') ?? '';
tokens.push(cls ? `${tag}.${cls}` : tag);
}
return tokens;
}
export function checkLayout(current, baseline, rel, { mode = 'sequence' } = {}) {
if (!baseline) return [r(W, 'LAYOUT_NO_BASELINE', `구조 기준선 없음 (node scripts/check.mjs --update-layout 로 생성)`)];
if (mode === 'set') {
const a = new Set(current), b = new Set(baseline);
const added = [...a].filter((x) => !b.has(x)), removed = [...b].filter((x) => !a.has(x));
if (!added.length && !removed.length) return [];
return [r(W, 'LAYOUT_CHANGED', `${rel} 구조 변경 (추가 ${added.length}, 제거 ${removed.length}): ${[...added.slice(0, 3), ...removed.slice(0, 3).map((x) => '-' + x)].join(', ')}`)];
}
if (current.length === baseline.length && current.every((t, i) => t === baseline[i])) return [];
let i = 0; while (i < current.length && i < baseline.length && current[i] === baseline[i]) i++;
return [r(W, 'LAYOUT_CHANGED', `${rel} 구조 변경 (기준 ${baseline.length} → 현재 ${current.length} 요소, 첫 차이 #${i}: "${baseline[i] ?? '∅'}" → "${current[i] ?? '∅'}"). 룩앤필 변경은 haewon 승인 후 --update-layout`)];
}
// ---------- §3-3 한국어 화법 ----------
export const HEADING_VERB_END = /(합니다|하지 않습니다|밝힙니다)\s*$/;
export function checkHeadingEndings(html) {
const out = [];
for (const m of html.matchAll(/<h[123]\b[^>]*>([\s\S]*?)<\/h[123]>/g)) {
const t = stripHtml(m[1]).trim();
if (HEADING_VERB_END.test(t)) out.push(r(W, 'HEADING_VERB', `제목이 동사 종결 (명사구로): "${t}"`));
}
return out;
}
/** 반말 서술("채운다.", "있다.") 검출. 존댓말(니다.)과 개조식 명사 종결은 제외. 경고 수준. */
export const PLAIN_ENDING = /[가-힣](?<!니)다\.(?=\s|$)/;
export function checkPlainEndings(text, field = '') {
const hits = sentences(text).filter((s) => PLAIN_ENDING.test(s));
if (!hits.length) return [];
return [r(W, 'PLAIN_ENDING', `${field ? field + ': ' : ''}반말 서술 ${hits.length}문장 (예: "${hits[0].slice(0, 60)}")`)];
}
// ---------- §3-4 근거 유형과 원문 링크 ----------
export const SOURCE_TYPES = ['clinic', 'doctor', 'product', 'regulation', 'platform', 'secondary'];
export function checkSourcesType(data) {
const out = [];
(data.sources ?? []).forEach((s, i) => {
if (!s.type) out.push(r(E, 'SOURCE_TYPE_MISSING', `sources[${i}] type 없음: "${s.label ?? s.url}"`));
else if (!SOURCE_TYPES.includes(s.type)) out.push(r(E, 'SOURCE_TYPE_UNKNOWN', `sources[${i}] type "${s.type}" 은 허용 목록 밖`));
});
return out;
}
/** FDA·ISO·승인·연구 주장이 있으면 regulation 타입 원문 소스가 있어야 한다. 학회명(연구회·연구소)은 제외. */
export const REGULATION_CLAIM = /FDA|ISO\s?\d|(?<!게시\s?)승인(?!자|일)|연구(?!회|소|원|팀|실)/; // "게시 승인"(§12-2 개설자 승인)은 규제 주장이 아니다
export function checkRegulationSource(data, fields) {
const hasReg = (data.sources ?? []).some((s) => s.type === 'regulation');
if (hasReg) return [];
const hit = fields.map((f) => f.text.match(REGULATION_CLAIM)?.[0]).find(Boolean);
return hit ? [r(E, 'REGULATION_SOURCE_MISSING', `본문에 "${hit}" 주장이 있는데 regulation 타입 원문 소스가 없음`)] : [];
}
// ---------- §3-5 근거 범위 표현 ----------
const NEGATION = /아닙니다|아니다|아니며|아니고|않습니다|않는다|않으며|않아|없습니다|뜻은 아|것은 아/;
export const CONCLUSION_PATTERNS = [
{ re: /안정성이 확인/, why: '안정성 확인을 단정' },
{ re: /안전이 보장/, why: '안전 보장을 단정' },
{ re: /안전하다고 볼 수/, why: '안전 결론' },
{ re: /돌아가고 있다고 볼 수/, why: '운영 실태 결론' },
{ re: /장점을 합친/, why: '제품 비교 단정' },
{ re: /비교우위/, why: '비교우위 단정', allowIf: /원장|임상|의견|판단/ },
{ re: /다음\s*날 출근할 수 있을 정도/, why: '회복 기대치 일반화' },
{ re: /(효과|결과)(를|가) 보장/, why: '효과 보장' },
];
export function checkConclusions(fields) {
const out = [];
for (const { field, text } of fields) {
for (const s of sentences(text)) {
for (const p of CONCLUSION_PATTERNS) {
if (!p.re.test(s)) continue;
if (NEGATION.test(s)) continue;
if (p.allowIf && p.allowIf.test(s)) continue;
out.push(r(E, 'CONCLUSION_PHRASE', `${field}: ${p.why} "${s.slice(0, 70)}"`));
}
}
}
return out;
}
/** 시간·기간 수치가 있는 문단에 개인차·상담·영상·사례 한정이 없으면 경고. 날짜(9월 4일, 2026-09-04)는 제외. */
export const DURATION_NUM = /(?<![\d월\-])\d+\s*(분|일|주|개월)(?![\d])/;
export const QUALIFIER = /개인차|상담|영상|사례|기준|홈페이지|주의사항|애프터케어|병원 안내|안내|자료에 없음|병원 확인|확인 대기|도보|걸어서/;
export function checkNumericContext(fields) {
const out = [];
for (const { field, text } of fields) {
const ps = paragraphs(text);
for (let i = 0; i < ps.length; i++) {
const p = ps[i];
if (!DURATION_NUM.test(p)) continue;
// 표는 바로 다음 문단(해설)과 묶어서 본다
const unit = /^\|/.test(p) && ps[i + 1] ? p + '\n' + ps[i + 1] : p;
if (QUALIFIER.test(unit)) continue;
out.push(r(W, 'NUMERIC_NO_QUALIFIER', `${field}: 수치 문단에 개인차·상담·영상·사례 한정 없음 "${p.replace(/\s+/g, ' ').slice(0, 60)}"`));
}
}
return out;
}
// ---------- §3-6 수치·확인일 일관성 ----------
const DATE_RE = /(\d{4})-(\d{2})(?:-(\d{2}))?|(?:(\d{4})년\s*)?(\d{1,2})월(?:\s*(\d{1,2})일)?/g;
function normDate(m) {
if (m[1]) return { y: m[1], mo: m[2], d: m[3] ?? null };
return { y: m[4] ?? null, mo: String(m[5]).padStart(2, '0'), d: m[6] ? String(m[6]).padStart(2, '0') : null };
}
function dateConflict(a, b) {
if (a.mo !== b.mo) return true;
if (a.y && b.y && a.y !== b.y) return true;
if (a.d && b.d && a.d !== b.d) return true;
return false;
}
const fmtDate = (d) => `${d.y ?? '????'}-${d.mo}${d.d ? '-' + d.d : ''}`;
export function checkNumberDates(fields) {
const seen = new Map(); // number → { date, field }
const out = [];
for (const { field, text } of fields) {
for (const s of sentences(text)) {
const dates = [...s.matchAll(DATE_RE)].map((m) => ({ idx: m.index, d: normDate(m) }));
if (!dates.length) continue;
for (const n of s.matchAll(/\d{1,3}(?:,\d{3})+/g)) {
// 가장 가까운 날짜를 이 수치의 확인일로 본다
const near = dates.reduce((best, x) => (Math.abs(x.idx - n.index) < Math.abs(best.idx - n.index) ? x : best));
const prev = seen.get(n[0]);
if (prev && dateConflict(prev.d, near.d)) {
out.push(r(E, 'NUMBER_DATE_MISMATCH', `수치 ${n[0]} 의 확인일이 다름: ${fmtDate(prev.d)} (${prev.field}) vs ${fmtDate(near.d)} (${field})`));
} else if (!prev) seen.set(n[0], { d: near.d, field });
}
}
}
return out;
}
export function checkNextCheck(fact, today) {
if (!fact?.nextCheck) return [r(W, 'FACT_NEXTCHECK_MISSING', 'factSheet.nextCheck 없음')];
return fact.nextCheck < today ? [r(W, 'FACT_NEXTCHECK_PASSED', `factSheet 다음 확인일(${fact.nextCheck}) 경과. 플랫폼 집계를 재확인할 것`)] : [];
}
// ---------- §3-7 작성자·검토자·검토일 ----------
export function checkReview(data, physicians = {}, supporters = {}) {
const out = [];
if (data.author && !supporters[data.author]) out.push(r(E, 'AUTHOR_UNKNOWN', `author "${data.author}" 가 authors.supporters 에 없음`));
if (data.reviewer && !physicians[data.reviewer]) out.push(r(E, 'REVIEWER_UNKNOWN', `reviewer "${data.reviewer}" 가 authors.physicians 에 없음`));
if (data.reviewStatus === 'reviewed') {
if (!data.reviewer) out.push(r(E, 'REVIEWED_WITHOUT_REVIEWER', 'reviewStatus=reviewed 인데 reviewer 없음'));
if (!data.reviewedAt) out.push(r(E, 'REVIEWED_WITHOUT_DATE', 'reviewStatus=reviewed 인데 reviewedAt 없음'));
}
// 게시 승인(v2 §12-2): 승인일이 있으면 승인자(개설자·직함)도 있어야 하고, 의학 검토가 끝난 글만 승인할 수 있다.
if (data.approvedAt && !data.approvedBy) out.push(r(E, 'APPROVED_WITHOUT_BY', 'approvedAt 이 있는데 approvedBy(승인자) 없음'));
if (data.approvedBy && !data.approvedAt) out.push(r(E, 'APPROVED_WITHOUT_DATE', 'approvedBy 가 있는데 approvedAt 없음'));
if (data.approvedAt && data.reviewStatus !== 'reviewed') out.push(r(E, 'APPROVED_BEFORE_REVIEW', '의학 검토(reviewed) 전에 게시 승인됨'));
return out;
}
// ---------- §12-2 (3) 기자·전문가 화법 (의료법 56조 2항 10호 대응). 경고 수준 ----------
export const PRESS_VOICE = /본지|기자(?!회견)|전문가에 따르면|업계에서는|업계 관계자/;
export function checkPressVoice(text, field = '') {
const m = text.match(PRESS_VOICE);
return m ? [r(W, 'PRESS_VOICE', `${field ? field + ': ' : ''}기자·전문가 화법: "${m[0]}"`)] : [];
}
// ---------- §12-2 (1) 지원 관계 고지의 상단 배치 ----------
/** 글 페이지 HTML 에서 상단 고지(.disclosure-top)가 세 줄 요약(.summary) 앞에 있어야 한다. notice 가 비어 있으면(아직 미승인) 경고만. */
export function checkTopDisclosure(html, { required = false } = {}) {
const top = html.search(/class="[^"]*disclosure-top[^"]*"/);
const summary = html.search(/class="[^"]*\bsummary\b[^"]*"/);
if (top >= 0 && (summary < 0 || top < summary)) return [];
const level = required ? E : W;
return [r(level, 'DISCLOSURE_TOP_MISSING', top < 0 ? '글 첫 부분에 지원 관계 고지 없음 (site.sponsorNotice 를 채우면 렌더됨)' : '지원 관계 고지가 세 줄 요약보다 아래에 있음')];
}
// ---------- §3-8 싣지 않는 것 ----------
export const BANNED_VIDEO_TITLE = /전후|리뷰|성공|번호따|변신|역대급/;
export function checkVideoTitles(list, { level = E, label = '' } = {}) {
return (list ?? [])
.filter((v) => BANNED_VIDEO_TITLE.test(v.title ?? ''))
.map((v) => r(level, 'BANNED_VIDEO_TITLE', `${label ? label + ' ' : ''}영상 제목 금칙: "${v.title}" (${v.id})`));
}
/** 뉴스룸 항목 (news.json). 화면에 제목·매체·날짜가 그대로 나오므로 영상 제목과 같은 계열의 금칙을 본다.
* 1) 후기·전후·체험담 계열 제목(경험담 광고, 의료법 56조 2항 2호) 2) 병원에 대한 규제·사건 보도(사이트 원칙: 병원이 알리거나 원장이 설명한 기사만 싣는다)
* 수집기(collect_news.mjs)가 같은 규칙으로 먼저 거르고, 여기서는 거르지 않고 넘어온 것을 막는다. */
export const BANNED_NEWS_TITLE = /전후\s*(사진|비교|모습|공개)|비포|애프터|before\s*&?\s*after|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼|부작용\s*(이|은|도)?\s*(없|전혀)|완치|100\s*%\s*(효과|만족|안전)/i; // 효과·안전 보장형 보도자료 제목("부작용 없이 안전하게")도 고객 화면에서 뺀다 (2026-09-10 오라클) // "수술 전후 유방검진" 같은 시기 표현은 통과
export const NEGATIVE_NEWS = /공정위|제재|시정명령|뒷광고|적발|철퇴|덜미|기만|소송|사망|(?<!무)사고(?![력])|경찰|검찰|논란|피소|의료사고|압수|내돈내산|조작|가짜|대본|허위|과장\s*광고|벌금|징계|영업정지|고발|수사|불법/; // "무사고 13주년"은 통과
export function checkNewsItems(items, { level = E } = {}) {
const out = [];
for (const n of items ?? []) {
const t = n.title ?? '';
if (BANNED_NEWS_TITLE.test(t)) out.push(r(level, 'BANNED_NEWS_TITLE', `뉴스 제목 금칙: "${t}" (${n.url ?? ''})`));
if (NEGATIVE_NEWS.test(t)) out.push(r(level, 'NEGATIVE_NEWS', `규제·사건 보도는 싣지 않는다: "${t}" (${n.url ?? ''})`));
if (!/^\d{4}-\d{2}-\d{2}$/.test(n.date ?? '')) out.push(r(level, 'NEWS_DATE', `날짜 형식(YYYY-MM-DD) 아님: "${n.date}" · "${t}"`));
if (!['release', 'column', 'mention'].includes(n.kind)) out.push(r(level, 'NEWS_KIND', `kind 값 오류: "${n.kind}" · "${t}"`));
}
return out;
}
// 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지.
export const BANNED_BODY = [
/완치/, /100\s*%\s*(효과|만족|개선|제거|안전|성공|치료|회복|보장|완치|시술)|(효과|만족도?|개선|제거|안전|성공률?|치료|회복)\s*(이|가|은|는|을|를)?\s*100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
/제가\s*받아\s*봤는데/, /수술\s*받고\s*나서\s*(너무|정말)\s*(좋|만족)/, /후기를\s*보면\s*다들/, /강추/,
// 피부과 어휘 (PRD §9). 인증 표현: "정품 정량"은 병원 서약 사실("정품·정량 사용을 밝힙니다")로만 쓰고 인증 어휘(의료법 56조 2항 14호)로 넓히지 않는다
/정품[\s·,]*정량[^.\n]{0,12}(인증|보증서|인증서)/, /정품\s*인증/, /인증(된|받은)\s*정품/,
// 전후 사진·비포애프터 유도 문구 (의료법 56조 2항 2호 경험담·전후 사진 계열). "전후 사진을 싣지 않습니다" 같은 원칙 서술은 통과
/비포\s*애프터|비포애프터|before\s*(&|and)?\s*after/i, /전후\s*사진[^.\n]{0,12}(보세요|확인하세요|참고하세요|보시|확인해\s*보|비교해\s*보|참고해\s*보|공개|모음|갤러리)/, /전후\s*비교\s*사진/,
];
export function checkBannedBody(text) {
return [
...BANNED_BODY.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'BANNED_PHRASE', `금칙 표현: "${m[0]}"`)),
...checkComparison(text),
];
}
/** 타 병원·업계 평균과의 수치 비교 (§3-8 비교 광고, 의료법 56조 2항 4호). "다른 병원에서 한 수술" 같은 서술은 수치가 없어 통과. */
const CMP_SUBJECT = '(병원급|의원급|업계|타\\s?병원|다른\\s?병원|타원|경쟁\\s?병원)';
export const COMPARISON_CLAIM = [
new RegExp(`${CMP_SUBJECT}[^.\\n]{0,30}(평균|대비|보다)[^.\\n]{0,30}\\d+(\\.\\d+)?\\s?%`),
new RegExp(`\\d+(\\.\\d+)?\\s?%[^.\\n]{0,30}${CMP_SUBJECT}`),
/평균\s*\d+(\.\d+)?\s?%[^.\n]{0,30}(상주|보유|운영)/,
];
export function checkComparison(text) {
return COMPARISON_CLAIM.map((re) => text.match(re)).filter(Boolean).map((m) => r(E, 'COMPARISON_CLAIM', `타 병원·업계 비교 수치: "${m[0].slice(0, 70)}"`));
}
// ---------- §3-11 원천 콘텐츠 ----------
export function checkOriginalContent(data, body) {
if (!['D', 'E'].includes(data.category)) return [];
const hasTable = /^\|.*\|\s*$/m.test(body);
const hasList = /^\s*(- \[ \]|\d+\.)\s/m.test(body);
return hasTable || hasList ? [] : [r(W, 'NO_TABLE_OR_CHECKLIST', `${data.category} 카테고리 글에 표·체크리스트가 없음 (원천 콘텐츠 5유형 중 하나여야 함)`)];
}
/** 홈페이지 문장 40자 연속 일치. homeText는 공백 제거본. */
export function checkHomeDuplicate(text, homeText, { len = 40, step = 20 } = {}) {
if (!homeText) return [];
const clean = text.replace(/\s+/g, '');
for (let i = 0; i + len <= clean.length; i += step) {
const chunk = clean.slice(i, i + len);
if (homeText.includes(chunk)) return [r(E, 'HOME_DUPLICATE', `홈페이지 문장 ${len}자 연속 일치: "${chunk}"`)];
}
return [];
}
// ---------- 피부과 규정 대응 (Dermatology Supporters PRD §9). 성형외과 글에도 같이 적용한다 ----------
// 업종 판정. 우선순위: 호출 인자 → 글 frontmatter industry → 환경변수 SUPPORTERS_INDUSTRY → plastic
export const INDUSTRIES = ['plastic', 'derm'];
export function resolveIndustry(ctxIndustry, data = {}) {
const v = ctxIndustry ?? data?.industry ?? process.env.SUPPORTERS_INDUSTRY ?? 'plastic';
return INDUSTRIES.includes(v) ? v : 'plastic';
}
/** PRICE_MENTION (error). 근거: 의료법 45조(비급여 진료비용 고지는 의료기관의 의무. 서포터즈는 의료기관이 아니므로 금액을 쓰지 않고 병원 고지 페이지로 연결)
* · 의료법 56조 2항(이벤트가·할인 같은 유인 표현). 금액 형태만 잡는다. "비급여 진료비용 고지" 링크 문장은 통째로 허용한다. */
export const PRICE_PATTERNS = [
/(?<![\d.,\-])\d{1,3}(?:,\d{3})+\s*원(?![장인])/, // 150,000원
/(?<![\d.,\-])\d{4,}\s*원(?![장인])/, // 150000원
/(?<![\d.,\-])\d+(?:\.\d+)?\s*(만|천|백)\s*원(?![장인])/, // 15만원, 15만 원, 1.5만원
/₩\s*\d/, // ₩150,000
/\d+(?:\.\d+)?\s*%\s*(할인|세일|OFF|off)/, // 30% 할인
/(이벤트가|할인가|특가|프로모션가|행사가)\s*[::]?\s*\d/, // 이벤트가 99,000
/(?<![\d.])1\s*\+\s*1(?![\d.])/, // 1+1
];
export const PRICE_NOTICE_SENTENCE = /비급여\s*(진료)?\s*비용?\s*고지|비급여\s*고지|진료비용\s*고지|비급여\s*항목\s*고지/;
export function checkPriceMention(fields) {
const out = [];
for (const { field, text } of fields) {
for (const s of sentences(text)) {
if (PRICE_NOTICE_SENTENCE.test(s)) continue; // 병원 고지 페이지 안내 문장은 허용
const m = PRICE_PATTERNS.map((re) => s.match(re)).find(Boolean);
if (m) out.push(r(E, 'PRICE_MENTION', `${field}: 금액·할인 표기 "${m[0]}" (금액은 쓰지 않고 병원의 비급여 진료비용 고지 페이지로 안내)`));
}
}
return out;
}
/** PRACTITIONER_MISSING (warn). 근거: 의료법 27조(무면허 의료행위. 레이저·주사 시술은 의사가 해야 하고 간호사 위임은 대리시술)
* · PRD §9 시술자 표기. 피부과(derm) 시술 글(category D)에 시술자 표기(의사 직접 / 의사 감독 / 시술자 행의 "확인 대기") 중 하나가 없으면 경고.
* "시술자" 낱말이 있으면 조건 표 시술자 행으로 본다(값이 "확인 대기"여도 표기는 한 것). 성형외과 글은 검사하지 않는다. */
export const PRACTITIONER_MARK = /시술자|(의사|원장|전문의|의료진)(가|이|의|께서)?\s*직접|(의사|원장|전문의|의료진)(의|이|가)?\s*(감독|입회|관리\s*하에)/;
export function checkPractitioner(data, fields, industry = 'plastic') {
if (industry !== 'derm' || data.category !== 'D') return [];
const hit = fields.some((f) => PRACTITIONER_MARK.test(f.text));
return hit ? [] : [r(W, 'PRACTITIONER_MISSING', '피부과 시술 글에 시술자 표기 없음 (조건 표 시술자 행에 "의사 직접" / "의사 감독" / "확인 대기" 중 하나)')];
}
/** DEVICE_CLAIM (error). 근거: 의료기기법 24조(허가·인증 범위 밖 효능·효과 표현 금지) · 약사법 68조(전문의약품 대중광고 제한)
* · 의료법 56조 2항(효과 보장·과장). 두 갈래.
* (1) "FDA 승인·식약처 허가" 같은 허가 표현이 있는데 sources 에 regulation 타입이 없으면 error (허가 정보는 규제 원문 링크로만).
* (2) "영구·완치·재발 없음·100%·평생 유지" 류 효과 보장 표현은 근거와 무관하게 error. 부정문("영구적이라는 뜻은 아닙니다")은 통과.
* "반영구"(반영구 화장)는 시술명이라 제외한다. 뉴스 제목은 checkNewsItems 가 따로 보므로 여기에는 넣지 않는다. */
export const DEVICE_APPROVAL = /식약처\s*(허가|인증|승인)|MFDS|KFDA|FDA\s*(승인|허가|인증|클리어|clear)|CE\s*(인증|마크)/i;
export const EFFECT_GUARANTEE = [
{ re: /(?<!반)영구(적|히)?(으로)?\s*(제거|제모|유지|효과|탈모|개선|없어|사라)/, why: '영구 효과' },
{ re: /(?<!반)영구\s*(적인|한)\s*(효과|개선|제거)/, why: '영구 효과' },
{ re: /완치/, why: '완치' },
// 의료 맥락(효과·만족·개선·제거·안전·성공·치료·회복·보장)과 붙은 100% 만 잡는다. 주변 안내(/stay)의 "100% 국내산" 같은 제3자 소개문은 효과 보장이 아니다 (2026-09-10 오라클 빌드에서 오탐).
{ re: /100\s*%\s*(효과|만족|개선|제거|안전|성공|치료|회복|보장|완치|시술)|(효과|만족도?|개선|제거|안전|성공률?|치료|회복)\s*(이|가|은|는|을|를)?\s*100\s*%/, why: '100% 효과 보장' },
{ re: /재발\s*(이|은|도)?\s*(없|않|하지\s*않)/, why: '재발 없음' },
{ re: /평생\s*(유지|효과|지속|한\s*번)/, why: '평생 효과' },
{ re: /한\s*번(의|으로)?\s*(시술|치료)?\s*(으로|로)?\s*(끝|완전|영원)/, why: '1회 완결 보장' },
];
const GUARANTEE_NEGATION = /(뜻|것|말|의미)(은|이)?\s*아|아닙니다|아니며|아니라|않다는|않는다는|없다는|보장(하|되|할 수)\s*(지|없)|단정(하지|할 수 없)|기대(하기|할 수)\s*(어렵|없)|근거가\s*없|쓰지\s*않|표현(은|을)\s*(쓰지|피)/;
export function checkDeviceClaim(data, fields) {
const out = [];
const hasReg = (data.sources ?? []).some((s) => s.type === 'regulation');
for (const { field, text } of fields) {
for (const s of sentences(text)) {
if (!hasReg) {
const a = s.match(DEVICE_APPROVAL);
if (a) out.push(r(E, 'DEVICE_CLAIM', `${field}: 허가 표현 "${a[0]}" 이 있는데 regulation 타입 원문 소스가 없음 (허가 정보는 규제 원문 링크로)`));
}
if (GUARANTEE_NEGATION.test(s)) continue;
for (const g of EFFECT_GUARANTEE) {
const m = s.match(g.re);
if (m) { out.push(r(E, 'DEVICE_CLAIM', `${field}: 효과 보장 표현(${g.why}) "${m[0]}" · "${s.slice(0, 60)}"`)); break; }
}
}
}
return out;
}
// ---------- 묶음: 글 한 편의 소스 검사 ----------
// industry: 'plastic' | 'derm'. 주지 않으면 frontmatter industry → 환경변수 SUPPORTERS_INDUSTRY → plastic 순으로 정한다.
export function checkPostSource({ data, body }, { physicians, supporters, industry } = {}) {
const fields = postTextFields(data, body);
const ind = resolveIndustry(industry, data);
return [
...checkSourcesType(data),
...checkRegulationSource(data, fields),
...checkConclusions(fields),
...checkNumericContext(fields),
...checkNumberDates(fields),
...checkReview(data, physicians, supporters),
...checkVideoTitles([...(data.videos ?? []), ...(data.video ? [data.video] : [])], { label: 'frontmatter' }),
...checkOriginalContent(data, body),
...checkPriceMention(fields),
...checkDeviceClaim(data, fields),
...checkPractitioner(data, fields, ind),
...fields.flatMap((f) => checkPlainEndings(f.text, f.field)),
...fields.flatMap((f) => checkPressVoice(f.text, f.field)),
];
}