// 발행 게이트 규칙. 전부 순수 함수다. 입력은 문자열·객체, 출력은 { 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(//g, ' ') .replace(//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(/]*>([\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(/]*>([\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 = /[가-힣](? 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.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.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})`)); } // 본문 금칙 표현(의료법 56조·효과 보장·경험담 화법). v1부터 유지. export const BANNED_BODY = [ /완치/, /100\s*%/, /부작용\s*(이|은)?\s*없(?!다는\s*뜻|다는\s*의미)/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/, /제가\s*받아\s*봤는데/, /수술\s*받고\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 []; } // ---------- 묶음: 글 한 편의 소스 검사 ---------- export function checkPostSource({ data, body }, { physicians, supporters } = {}) { const fields = postTextFields(data, body); 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), ...fields.flatMap((f) => checkPlainEndings(f.text, f.field)), ...fields.flatMap((f) => checkPressVoice(f.text, f.field)), ]; }