o2o-infinith-demo/supporters/scripts/gate/rules.mjs
Haewon Kam 49c5e46f23 feat(supporters): 발행 게이트 확장 — v2 §3 검사 11종을 규칙 모듈·픽스처 테스트로 분리
- scripts/gate/rules.mjs: 운영자 어휘·구조 지문·화법·근거 type·규제 원문·결론 문장·수치 한정·확인일 일관성·검토자/검토일·영상 제목·표/체크리스트 (순수 함수)
- scripts/check.mjs: dist + 소스 frontmatter + 데이터 검사 러너, --update-layout, error/warn 분리
- scripts/gate/test.mjs + fixtures 9개: 실패 재현 + 18편 정답지 회귀 + 부정문 통과
- corrections.json 「답변엔진」 표현 고객 화면에서 제거, 반말 문장 1건 수정
- audit_aeo_geo.py: 표본 URL 5개 미만 경고(§3-10)
- 미결: 「채널 인기 전체」 탭은 경고만 (haewon 결정 대기)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-07 13:57:31 +09:00

271 lines
13 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(/&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|승인|연구(?!회|소|원|팀|실)/;
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 없음'));
}
return out;
}
// ---------- §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*병원/, /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]}"`));
}
// ---------- §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)),
];
}