- 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>
271 lines
13 KiB
JavaScript
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(/ /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|승인|연구(?!회|소|원|팀|실)/;
|
|
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)),
|
|
];
|
|
}
|