- 템플릿: 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>
411 lines
25 KiB
JavaScript
411 lines
25 KiB
JavaScript
// 발행 게이트 규칙. 전부 순수 함수다. 입력은 문자열·객체, 출력은 { 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(/ /g, ' ')
|
||
.replace(/&/g, '&')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/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)),
|
||
];
|
||
}
|