o2o-infinith-demo/templates/supporters-astro/scripts/gate/rules.mjs
Haewon Kam 484601b0f5 feat(supporters): 뉴스룸 워커 단계 + 뉴스 게이트 규칙, launchd 폴러 설치
- collect_news.mjs: 네이버 뉴스 검색 다중 질의(상호·원장·의료진·주제어, 429 백오프) + 언론보도 게시판 자동 발견/clinic.json newsBoard.
  관련성 = 제목·요약에 병원 이름(v2 §8). 제목·매체·날짜·원문 링크만 저장. news_outlets.mjs 는 build_news.py 매체 표 이식
- gate: BANNED_NEWS_TITLE(전후 사진·비교, 후기, 변신)·NEGATIVE_NEWS(규제·사건, "무사고" 통과)·checkNewsItems → check.mjs·test.mjs(19/19)
- 워커 news 단계(youtube 뒤, images 앞), --news-board/--no-news, status.report.news
- 뷰 news.json: 렛미인 "변신" 기사 41건 규칙으로 제외(excludedByGate 에 목록). 회귀 1,105건(수작업 1,096, 제목 일치 1,033), 원진 1,049건
- launchd 폴러 설치(haewon 지시): PATH 에 프레임워크 python3, bootstrap/bootout 안내
- 함정: JS \W 는 한글을 지움(제목 중복 판정 붕괴), 워드프레스 article:published_time 은 게시판 생성일, 네이버 질의당 1,000건
- v3 문서 §1·§2·§3·§5·§6 갱신, 템플릿 재내보내기

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-08 14:14:08 +09:00

324 lines
18 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|(?<!게시\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|후기|체험단|체험기|리뷰|번호따|변신|역대급|대반전|반전쇼/i; // "수술 전후 유방검진" 같은 시기 표현은 통과
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*뜻|다는\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)),
];
}