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>
This commit is contained in:
parent
a441f8a4ad
commit
49c5e46f23
@ -1053,13 +1053,23 @@ def main() -> int:
|
||||
auditor = Auditor(args.main_url, args.service_url, args.name)
|
||||
results = auditor.run()
|
||||
|
||||
# 실측 규칙(NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2 §3-10): 표본 URL이 5개 미만이면 사이트 전체로 일반화하지 않는다.
|
||||
# 최소 표본 = 홈 + 시술 상세 3개 + 오시는길 + 블로그 서브도메인. 이 채점기는 아직 2개(홈·대표 시술)만 읽는다.
|
||||
sample_urls = [args.main_url, args.service_url]
|
||||
sample_warning = None
|
||||
if len(sample_urls) < 5:
|
||||
sample_warning = (f"표본 URL {len(sample_urls)}개 (<5). 판정은 홈·대표 시술 페이지 기준이며 사이트 전체로 일반화하지 말 것. "
|
||||
f"시술 상세 3개·오시는길·블로그를 curl+파서로 추가 확인할 것 (2026-08-28 뷰성형외과 오판 3건의 원인)")
|
||||
print(f"\n ⚠ {sample_warning}")
|
||||
|
||||
auto_scored = [r for r in results if r["level"] != "unverified"]
|
||||
unv = [r for r in results if r["level"] == "unverified"]
|
||||
print(f"\n[3/3] 결과: 자동 판정 {len(auto_scored)}항목 / 미검증 {len(unv)}항목 (semi·manual 포함)")
|
||||
|
||||
if args.json:
|
||||
with open(args.json, "w", encoding="utf-8") as f:
|
||||
json.dump(results, f, ensure_ascii=False, indent=2)
|
||||
json.dump({"_meta": {"sampleUrls": sample_urls, "sampleWarning": sample_warning}, "results": results}
|
||||
if sample_warning else results, f, ensure_ascii=False, indent=2)
|
||||
print(f" → JSON: {args.json}")
|
||||
|
||||
if not args.skip_ts:
|
||||
|
||||
3
supporters/package-lock.json
generated
3
supporters/package-lock.json
generated
@ -10,6 +10,9 @@
|
||||
"dependencies": {
|
||||
"astro": "^7.3.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"js-yaml": "^4.3.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.12.0"
|
||||
}
|
||||
|
||||
@ -10,12 +10,16 @@
|
||||
"build": "astro build && node scripts/check.mjs",
|
||||
"preview": "astro preview",
|
||||
"astro": "astro",
|
||||
"check": "node scripts/check.mjs"
|
||||
"check": "node scripts/gate/test.mjs && node scripts/check.mjs",
|
||||
"gate:test": "node scripts/gate/test.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"astro": "^7.3.1"
|
||||
},
|
||||
"allowScripts": {
|
||||
"esbuild": true
|
||||
},
|
||||
"devDependencies": {
|
||||
"js-yaml": "^4.3.2"
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,49 +1,99 @@
|
||||
// 발행 게이트. 빌드 결과(dist)를 검사해 하나라도 실패하면 배포를 막는다.
|
||||
// 1) JSON-LD 문법·필수 타입 2) 이미지 alt 3) 금칙 표현(의료법 56조·효과 보장) 4) H1 1개 5) 홈페이지 문장 연속 일치
|
||||
import { readdirSync, readFileSync, statSync, existsSync } from 'node:fs';
|
||||
// 발행 게이트 러너. 빌드 결과(dist)와 소스(글 frontmatter·데이터)를 검사해 error가 하나라도 있으면 배포를 막는다.
|
||||
// 규칙 본체는 scripts/gate/rules.mjs. 근거는 docs/NEXT_SESSION_SUPPORTERS_AUTOBUILD_v2.md §3.
|
||||
//
|
||||
// node scripts/check.mjs 검사
|
||||
// node scripts/check.mjs --update-layout 구조 기준선(§3-2) 갱신. 룩앤필 변경을 haewon이 승인한 뒤에만
|
||||
// node scripts/check.mjs --today=YYYY-MM-DD
|
||||
import { readdirSync, readFileSync, statSync, existsSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import * as R from './gate/rules.mjs';
|
||||
|
||||
const DIST = fileURLToPath(new URL('../dist/', import.meta.url));
|
||||
const BANNED = [
|
||||
/완치/, /100\s*%/, /부작용\s*(이|은)?\s*없/, /(무조건|반드시|확실히)\s*(예뻐|성공|만족)/, /최고의\s*병원/, /1위\s*병원/,
|
||||
/제가\s*받아\s*봤는데/, /수술\s*받고\s*나서\s*(너무|정말)\s*(좋|만족)/, /후기를\s*보면\s*다들/, /강추/,
|
||||
];
|
||||
const HOME_TEXT = existsSync(fileURLToPath(new URL('./home_text.txt', import.meta.url))) ? readFileSync(fileURLToPath(new URL('./home_text.txt', import.meta.url)), 'utf8') : '';
|
||||
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
const DIST = here('../dist/');
|
||||
const POSTS = here('../src/content/posts/');
|
||||
const LAYOUT_BASELINE = here('./gate/layout-baseline.json');
|
||||
const args = process.argv.slice(2);
|
||||
const updateLayout = args.includes('--update-layout');
|
||||
const today = args.find((a) => a.startsWith('--today='))?.slice(8) ?? new Date().toISOString().slice(0, 10);
|
||||
|
||||
const HOME_TEXT = existsSync(here('./home_text.txt')) ? readFileSync(here('./home_text.txt'), 'utf8') : '';
|
||||
const authors = JSON.parse(readFileSync(here('../src/data/authors.json'), 'utf8'));
|
||||
const fact = JSON.parse(readFileSync(here('../src/data/factSheet.json'), 'utf8'));
|
||||
const videos = JSON.parse(readFileSync(here('../src/data/videos.json'), 'utf8'));
|
||||
|
||||
// 구조 기준선을 보는 페이지. 글 목록은 글 수에 따라 카드가 늘므로 집합 비교, 나머지는 순서 비교.
|
||||
const LAYOUT_PAGES = { '/index.html': 'sequence', '/posts.html': 'set', '/posts/anesthesia-choice.html': 'sequence' };
|
||||
|
||||
function walk(dir, out = []) {
|
||||
for (const f of readdirSync(dir)) { const p = join(dir, f); statSync(p).isDirectory() ? walk(p, out) : f.endsWith('.html') && out.push(p); }
|
||||
return out;
|
||||
}
|
||||
const files = walk(DIST);
|
||||
let errors = 0;
|
||||
const fail = (f, m) => { errors++; console.error(` ✗ ${f.replace(DIST, '')}: ${m}`); };
|
||||
|
||||
const findings = []; // { where, level, code, msg }
|
||||
const add = (where, list) => list.forEach((x) => findings.push({ where, ...x }));
|
||||
|
||||
// ---------- 1. dist 검사 ----------
|
||||
if (!existsSync(DIST)) { console.error('dist/ 없음. astro build 먼저'); process.exit(1); }
|
||||
const files = walk(DIST);
|
||||
const layoutNow = {};
|
||||
for (const f of files) {
|
||||
const html = readFileSync(f, 'utf8');
|
||||
const rel = f.replace(DIST, '');
|
||||
const rel = f.replace(DIST, '/');
|
||||
const text = R.stripHtml(html);
|
||||
|
||||
// JSON-LD
|
||||
const blocks = [...html.matchAll(/<script type="application\/ld\+json">([\s\S]*?)<\/script>/g)].map((m) => m[1]);
|
||||
for (const b of blocks) {
|
||||
try {
|
||||
const j = JSON.parse(b);
|
||||
const types = JSON.stringify(j);
|
||||
if (rel.startsWith('/posts/') && rel !== '/posts.html' && !/FAQPage|Article/.test(types)) fail(f, 'Article/FAQPage 스키마 없음');
|
||||
} catch (e) { fail(f, `JSON-LD 파싱 실패: ${e.message}`); }
|
||||
const types = JSON.stringify(JSON.parse(b));
|
||||
if (rel.startsWith('/posts/') && !/FAQPage|Article/.test(types)) add(rel, [{ level: R.E, code: 'SCHEMA_MISSING', msg: 'Article/FAQPage 스키마 없음' }]);
|
||||
} catch (e) { add(rel, [{ level: R.E, code: 'JSONLD_PARSE', msg: `JSON-LD 파싱 실패: ${e.message}` }]); }
|
||||
}
|
||||
if (!blocks.length && !/404/.test(rel)) fail(f, 'JSON-LD 없음');
|
||||
if (!blocks.length && !/404/.test(rel)) add(rel, [{ level: R.E, code: 'JSONLD_MISSING', msg: 'JSON-LD 없음' }]);
|
||||
// alt
|
||||
for (const img of html.matchAll(/<img\b[^>]*>/g)) { if (!/\balt="[^"]+"/.test(img[0])) fail(f, `alt 없는 이미지: ${img[0].slice(0, 80)}`); }
|
||||
for (const img of html.matchAll(/<img\b[^>]*>/g)) if (!/\balt="[^"]+"/.test(img[0])) add(rel, [{ level: R.E, code: 'IMG_ALT', msg: `alt 없는 이미지: ${img[0].slice(0, 80)}` }]);
|
||||
// H1
|
||||
const h1 = (html.match(/<h1\b/g) || []).length; if (h1 !== 1) fail(f, `H1 ${h1}개 (1개여야 함)`);
|
||||
// banned phrases (본문만)
|
||||
const text = html.replace(/<script[\s\S]*?<\/script>/g, '').replace(/<[^>]+>/g, ' ');
|
||||
for (const re of BANNED) { const m = text.match(re); if (m) fail(f, `금칙 표현: "${m[0]}"`); }
|
||||
// 홈페이지 문장 연속 일치(40자 이상)
|
||||
if (HOME_TEXT) {
|
||||
const clean = text.replace(/\s+/g, '');
|
||||
for (let i = 0; i + 40 <= clean.length; i += 20) { const chunk = clean.slice(i, i + 40); if (HOME_TEXT.includes(chunk)) { fail(f, `홈페이지 문장 40자 연속 일치: "${chunk}"`); break; } }
|
||||
}
|
||||
const h1 = (html.match(/<h1\b/g) || []).length; if (h1 !== 1) add(rel, [{ level: R.E, code: 'H1_COUNT', msg: `H1 ${h1}개 (1개여야 함)` }]);
|
||||
// 금칙 표현·홈페이지 중복·운영자 어휘·제목 화법
|
||||
add(rel, R.checkBannedBody(text));
|
||||
add(rel, R.checkHomeDuplicate(text, HOME_TEXT));
|
||||
add(rel, R.checkOperatorVocab(text, rel));
|
||||
add(rel, R.checkHeadingEndings(html));
|
||||
// 구조 지문
|
||||
if (LAYOUT_PAGES[rel]) layoutNow[rel] = R.layoutFingerprint(html);
|
||||
}
|
||||
console.log(`검사 파일 ${files.length}개, 오류 ${errors}건`);
|
||||
if (errors) process.exit(1);
|
||||
|
||||
// §3-2 구조 기준선
|
||||
if (updateLayout) {
|
||||
writeFileSync(LAYOUT_BASELINE, JSON.stringify(layoutNow, null, 1));
|
||||
console.log(`구조 기준선 갱신: ${Object.keys(layoutNow).join(', ')}`);
|
||||
} else {
|
||||
const base = existsSync(LAYOUT_BASELINE) ? JSON.parse(readFileSync(LAYOUT_BASELINE, 'utf8')) : {};
|
||||
for (const [rel, mode] of Object.entries(LAYOUT_PAGES)) if (layoutNow[rel]) add(rel, R.checkLayout(layoutNow[rel], base[rel], rel, { mode }));
|
||||
}
|
||||
|
||||
// ---------- 2. 소스 검사 (글 frontmatter·본문) ----------
|
||||
const posts = readdirSync(POSTS).filter((f) => f.endsWith('.md'));
|
||||
for (const f of posts) {
|
||||
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
|
||||
add(`posts/${f}`, R.checkPostSource(parsed, { physicians: authors.physicians, supporters: authors.supporters }));
|
||||
}
|
||||
|
||||
// ---------- 3. 데이터 검사 ----------
|
||||
add('data/factSheet.json', R.checkNextCheck(fact, today));
|
||||
add('data/videos.json', R.checkVideoTitles(videos.topLong, { label: '설명 영상 탭' }));
|
||||
add('data/videos.json', R.checkVideoTitles(videos.shortsInfo, { label: '1분 답변 탭' }));
|
||||
// "채널 인기 전체" 탭은 제거 여부가 haewon 결정 대기(v2 §9-1). 결정 전까지 경고만.
|
||||
add('data/videos.json', R.checkVideoTitles(videos.top, { level: R.W, label: '채널 인기 전체 탭(결정 대기)' }));
|
||||
|
||||
// ---------- 출력 ----------
|
||||
const errors = findings.filter((x) => x.level === R.E);
|
||||
const warns = findings.filter((x) => x.level === R.W);
|
||||
const byCode = (list) => Object.entries(list.reduce((m, x) => ((m[x.code] = (m[x.code] ?? 0) + 1), m), {})).map(([k, v]) => `${k}×${v}`).join(', ');
|
||||
for (const x of errors) console.error(` ✗ ${x.where}: [${x.code}] ${x.msg}`);
|
||||
const shownWarn = warns.slice(0, 40);
|
||||
for (const x of shownWarn) console.warn(` △ ${x.where}: [${x.code}] ${x.msg}`);
|
||||
if (warns.length > shownWarn.length) console.warn(` △ … 경고 ${warns.length - shownWarn.length}건 더 있음`);
|
||||
console.log(`검사 파일 ${files.length}개 · 글 ${posts.length}편 · 오류 ${errors.length}건${errors.length ? ` (${byCode(errors)})` : ''} · 경고 ${warns.length}건${warns.length ? ` (${byCode(warns)})` : ''}`);
|
||||
if (errors.length) process.exit(1);
|
||||
|
||||
15
supporters/scripts/gate/fixtures/_base.yml
Normal file
15
supporters/scripts/gate/fixtures/_base.yml
Normal file
@ -0,0 +1,15 @@
|
||||
# 픽스처 공통 frontmatter. test.mjs가 각 픽스처의 frontmatter와 병합한다.
|
||||
title: "픽스처 글입니다"
|
||||
category: D
|
||||
categoryLabel: 시술 정보
|
||||
qbIds: [D-01]
|
||||
summary:
|
||||
- "첫 번째 요약입니다. 영상 사례 기준입니다."
|
||||
- "두 번째 요약입니다. 상담에서 확인하세요."
|
||||
description: "픽스처"
|
||||
author: ansm
|
||||
reviewStatus: pending
|
||||
datePublished: "2026-09-07"
|
||||
dateModified: "2026-09-07"
|
||||
sources:
|
||||
- { label: "병원 홈페이지", url: "https://example.com/a", accessed: "2026-09-07", type: clinic }
|
||||
12
supporters/scripts/gate/fixtures/conclusion-phrase.md
Normal file
12
supporters/scripts/gate/fixtures/conclusion-phrase.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [CONCLUSION_PHRASE]
|
||||
---
|
||||
## 결론
|
||||
|
||||
네 가지 질문에 답하면 안전 체계가 실제로 돌아가고 있다고 볼 수 있습니다. 수술 후 다음 날 출근할 수 있을 정도로 회복이 빠릅니다.
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
2
supporters/scripts/gate/fixtures/good-post.html
Normal file
2
supporters/scripts/gate/fixtures/good-post.html
Normal file
@ -0,0 +1,2 @@
|
||||
<!-- expect: (none) -->
|
||||
<body><main><h1>상담 전 질문</h1><h2>답하는 방식</h2><p>원장 설명 영상과 병원 공개 자료를 근거로 답합니다.</p></main></body>
|
||||
16
supporters/scripts/gate/fixtures/number-date-mismatch.md
Normal file
16
supporters/scripts/gate/fixtures/number-date-mismatch.md
Normal file
@ -0,0 +1,16 @@
|
||||
---
|
||||
expect: [NUMBER_DATE_MISMATCH]
|
||||
summary:
|
||||
- "강남언니 후기 19,373건 (2026-09-04 확인) 입니다."
|
||||
- "상담에서 확인하세요."
|
||||
faq:
|
||||
- q: "후기는 몇 건인가요?"
|
||||
a: "강남언니 기준 19,373건입니다 (8월 28일 확인)."
|
||||
---
|
||||
## 표
|
||||
|
||||
| 플랫폼 | 후기 수 | 확인일 |
|
||||
|---|---|---|
|
||||
| 강남언니 | 19,373건 | 2026-09-04 |
|
||||
|
||||
플랫폼마다 확인일이 다릅니다. 상담에서 확인하세요.
|
||||
2
supporters/scripts/gate/fixtures/operator-vocab.html
Normal file
2
supporters/scripts/gate/fixtures/operator-vocab.html
Normal file
@ -0,0 +1,2 @@
|
||||
<!-- expect: OPERATOR_VOCAB -->
|
||||
<body><main><h1>서포터즈 소개</h1><p>이 글은 답변엔진이 인용하는 단락으로 구성했습니다. 질문 뱅크 120문항 중 QB D 카테고리입니다.</p></main></body>
|
||||
12
supporters/scripts/gate/fixtures/regulation-no-source.md
Normal file
12
supporters/scripts/gate/fixtures/regulation-no-source.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [REGULATION_SOURCE_MISSING]
|
||||
---
|
||||
## 승인
|
||||
|
||||
이 보형물은 2024년 미국 FDA 승인을 받았습니다.
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
12
supporters/scripts/gate/fixtures/reviewed-no-date.md
Normal file
12
supporters/scripts/gate/fixtures/reviewed-no-date.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [REVIEWED_WITHOUT_DATE]
|
||||
reviewer: dr-choi
|
||||
reviewStatus: reviewed
|
||||
---
|
||||
## 표
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
12
supporters/scripts/gate/fixtures/reviewer-unknown.md
Normal file
12
supporters/scripts/gate/fixtures/reviewer-unknown.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [REVIEWER_UNKNOWN]
|
||||
reviewer: dr-nobody
|
||||
reviewStatus: pending
|
||||
---
|
||||
## 표
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
12
supporters/scripts/gate/fixtures/source-no-type.md
Normal file
12
supporters/scripts/gate/fixtures/source-no-type.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [SOURCE_TYPE_MISSING]
|
||||
sources:
|
||||
- { label: "병원 홈페이지", url: "https://example.com/a", accessed: "2026-09-07" }
|
||||
---
|
||||
## 표
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
12
supporters/scripts/gate/fixtures/video-title-banned.md
Normal file
12
supporters/scripts/gate/fixtures/video-title-banned.md
Normal file
@ -0,0 +1,12 @@
|
||||
---
|
||||
expect: [BANNED_VIDEO_TITLE]
|
||||
videos:
|
||||
- { id: abc123, title: "코성형+지방이식 전후 #Shorts", published: "2024-01-01" }
|
||||
---
|
||||
## 표
|
||||
|
||||
| 구분 | 내용 |
|
||||
|---|---|
|
||||
| 항목 | 값 |
|
||||
|
||||
상담에서 확인합니다.
|
||||
746
supporters/scripts/gate/layout-baseline.json
Normal file
746
supporters/scripts/gate/layout-baseline.json
Normal file
@ -0,0 +1,746 @@
|
||||
{
|
||||
"/index.html": [
|
||||
"div.sample-banner",
|
||||
"header.site-header",
|
||||
"div.bar.wrap-wide",
|
||||
"a.brand",
|
||||
"img.brand-logo",
|
||||
"span.brand-sep",
|
||||
"span.brand-text",
|
||||
"small",
|
||||
"div.right",
|
||||
"nav.nav",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a.cta",
|
||||
"main",
|
||||
"section.hero-light",
|
||||
"div.a.blob",
|
||||
"div.b.blob",
|
||||
"div.inner.wrap-wide",
|
||||
"div",
|
||||
"div.eyebrow",
|
||||
"h1",
|
||||
"span.accent",
|
||||
"p.lede",
|
||||
"div.chips",
|
||||
"span.chip",
|
||||
"span.dot",
|
||||
"span.chip",
|
||||
"span.dot",
|
||||
"div..actions.hero",
|
||||
"a.btn.primary",
|
||||
"a.btn.secondary",
|
||||
"div.collage.hero-img",
|
||||
"img",
|
||||
"section.section.tint",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"div.grid",
|
||||
"a.card.entry",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"p",
|
||||
"span.more",
|
||||
"a.card.entry",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"p",
|
||||
"span.more",
|
||||
"a.card.entry",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"p",
|
||||
"span.more",
|
||||
"section.dark.section",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"div.stats",
|
||||
"div.stat",
|
||||
"b",
|
||||
"span.label",
|
||||
"span.desc",
|
||||
"div.stat",
|
||||
"b",
|
||||
"span.label",
|
||||
"span.desc",
|
||||
"div.stat",
|
||||
"b",
|
||||
"span.label",
|
||||
"span.desc",
|
||||
"section.light.section",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.row",
|
||||
"div",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"a",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.section.tint",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"div.video-grid",
|
||||
"figure.compact.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"iframe",
|
||||
"figure.compact.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"figure.compact.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"section.light.section",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"div.cols-3.gallery",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"p",
|
||||
"section.dark.section",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"div.grid",
|
||||
"div.card",
|
||||
"h3",
|
||||
"p",
|
||||
"div.card",
|
||||
"h3",
|
||||
"p",
|
||||
"div.card",
|
||||
"h3",
|
||||
"p",
|
||||
"a",
|
||||
"section.section.tint",
|
||||
"div.wrap-wide",
|
||||
"div.sec-head",
|
||||
"div.eyebrow",
|
||||
"h2",
|
||||
"p.sub",
|
||||
"aside.fact-block",
|
||||
"h2",
|
||||
"dl",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"a",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"button.totop",
|
||||
"footer.site-footer",
|
||||
"div.cols.wrap-wide",
|
||||
"div",
|
||||
"p",
|
||||
"strong",
|
||||
"p",
|
||||
"p",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"p",
|
||||
"img",
|
||||
"p",
|
||||
"strong",
|
||||
"p",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div.powered.wrap-wide"
|
||||
],
|
||||
"/posts/anesthesia-choice.html": [
|
||||
"div.sample-banner",
|
||||
"header.site-header",
|
||||
"div.bar.wrap-wide",
|
||||
"a.brand",
|
||||
"img.brand-logo",
|
||||
"span.brand-sep",
|
||||
"span.brand-text",
|
||||
"small",
|
||||
"div.right",
|
||||
"nav.nav",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a.cta",
|
||||
"div.backbar-wrap",
|
||||
"div.backbar.wrap-wide",
|
||||
"a.backbtn",
|
||||
"main",
|
||||
"article.wrap",
|
||||
"header.article-head",
|
||||
"div.eyebrow",
|
||||
"h1",
|
||||
"div.meta",
|
||||
"span",
|
||||
"span",
|
||||
"span",
|
||||
"span",
|
||||
"section.summary",
|
||||
"h2",
|
||||
"ol",
|
||||
"li",
|
||||
"li",
|
||||
"li",
|
||||
"figure.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"iframe",
|
||||
"div.doctor-strip",
|
||||
"img",
|
||||
"div",
|
||||
"div.name",
|
||||
"span",
|
||||
"p.creds",
|
||||
"a",
|
||||
"h2",
|
||||
"table",
|
||||
"thead",
|
||||
"tr",
|
||||
"th",
|
||||
"th",
|
||||
"th",
|
||||
"tbody",
|
||||
"tr",
|
||||
"td",
|
||||
"td",
|
||||
"td",
|
||||
"tr",
|
||||
"td",
|
||||
"td",
|
||||
"td",
|
||||
"tr",
|
||||
"td",
|
||||
"td",
|
||||
"td",
|
||||
"tr",
|
||||
"td",
|
||||
"td",
|
||||
"td",
|
||||
"tr",
|
||||
"td",
|
||||
"td",
|
||||
"td",
|
||||
"p",
|
||||
"h2",
|
||||
"p",
|
||||
"h2",
|
||||
"ol",
|
||||
"li",
|
||||
"li",
|
||||
"li",
|
||||
"p",
|
||||
"h2",
|
||||
"p",
|
||||
"div.cols-2.gallery",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"figure.figure",
|
||||
"img",
|
||||
"figcaption",
|
||||
"section",
|
||||
"h2",
|
||||
"div.video-grid",
|
||||
"figure.compact.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"figure.compact.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"figure.compact.yt",
|
||||
"button.yt-btn",
|
||||
"img",
|
||||
"span.play",
|
||||
"figcaption",
|
||||
"strong",
|
||||
"span",
|
||||
"section.faq",
|
||||
"h2",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"details",
|
||||
"summary",
|
||||
"p",
|
||||
"section.review-box",
|
||||
"img",
|
||||
"div",
|
||||
"div",
|
||||
"strong",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"strong",
|
||||
"span.badge.reviewed",
|
||||
"div",
|
||||
"section.sources",
|
||||
"h2",
|
||||
"ul",
|
||||
"li",
|
||||
"span.stype",
|
||||
"a",
|
||||
"span",
|
||||
"li",
|
||||
"span.stype",
|
||||
"a",
|
||||
"span",
|
||||
"li",
|
||||
"span.stype",
|
||||
"a",
|
||||
"span",
|
||||
"li",
|
||||
"span.stype",
|
||||
"a",
|
||||
"span",
|
||||
"li",
|
||||
"span.stype",
|
||||
"a",
|
||||
"span",
|
||||
"p",
|
||||
"section.history",
|
||||
"h2",
|
||||
"ul",
|
||||
"li",
|
||||
"time",
|
||||
"li",
|
||||
"time",
|
||||
"aside.fact-block",
|
||||
"h2",
|
||||
"dl",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"a",
|
||||
"dt",
|
||||
"dd",
|
||||
"dt",
|
||||
"dd",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"p.disclosure",
|
||||
"button.totop",
|
||||
"footer.site-footer",
|
||||
"div.cols.wrap-wide",
|
||||
"div",
|
||||
"p",
|
||||
"strong",
|
||||
"p",
|
||||
"p",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"p",
|
||||
"img",
|
||||
"p",
|
||||
"strong",
|
||||
"p",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div.powered.wrap-wide"
|
||||
],
|
||||
"/posts.html": [
|
||||
"div.sample-banner",
|
||||
"header.site-header",
|
||||
"div.bar.wrap-wide",
|
||||
"a.brand",
|
||||
"img.brand-logo",
|
||||
"span.brand-sep",
|
||||
"span.brand-text",
|
||||
"small",
|
||||
"div.right",
|
||||
"nav.nav",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a.cta",
|
||||
"div.backbar-wrap",
|
||||
"div.backbar.wrap-wide",
|
||||
"a.backbtn",
|
||||
"main",
|
||||
"div.wrap-wide",
|
||||
"header.article-head",
|
||||
"div.eyebrow",
|
||||
"h1.serif",
|
||||
"p",
|
||||
"div.cat-grid",
|
||||
"section.cat.span-1",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.cat.span-1",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.cat.span-1",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.cat.span-3",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.cat.span-2",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.cat.span-1",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"section.cat.span-1",
|
||||
"div.section-title",
|
||||
"h2",
|
||||
"span",
|
||||
"div.grid",
|
||||
"article.card.has-thumb",
|
||||
"a.thumb",
|
||||
"img",
|
||||
"div.body",
|
||||
"div.cat",
|
||||
"h3",
|
||||
"a",
|
||||
"p",
|
||||
"div.foot",
|
||||
"button.totop",
|
||||
"footer.site-footer",
|
||||
"div.cols.wrap-wide",
|
||||
"div",
|
||||
"p",
|
||||
"strong",
|
||||
"p",
|
||||
"p",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div",
|
||||
"p",
|
||||
"img",
|
||||
"p",
|
||||
"strong",
|
||||
"p",
|
||||
"p",
|
||||
"a",
|
||||
"a",
|
||||
"a",
|
||||
"div.powered.wrap-wide"
|
||||
]
|
||||
}
|
||||
270
supporters/scripts/gate/rules.mjs
Normal file
270
supporters/scripts/gate/rules.mjs
Normal file
@ -0,0 +1,270 @@
|
||||
// 발행 게이트 규칙. 전부 순수 함수다. 입력은 문자열·객체, 출력은 { 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)),
|
||||
];
|
||||
}
|
||||
65
supporters/scripts/gate/test.mjs
Normal file
65
supporters/scripts/gate/test.mjs
Normal file
@ -0,0 +1,65 @@
|
||||
// 게이트 규칙 회귀 테스트. 빌드 없이 돈다.
|
||||
// 1) fixtures/*.md : frontmatter `expect:` 에 적힌 error 코드가 실제로 나와야 통과
|
||||
// 2) fixtures/*.html: 첫 줄 주석의 expect 코드 (none = 오류 0)
|
||||
// 3) src/content/posts/*.md 전편이 소스 규칙 error 0 이어야 통과 (정답지 회귀)
|
||||
// node scripts/gate/test.mjs
|
||||
import { readdirSync, readFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import yaml from 'js-yaml';
|
||||
import * as R from './rules.mjs';
|
||||
|
||||
const here = (p) => fileURLToPath(new URL(p, import.meta.url));
|
||||
const FIX = here('./fixtures/');
|
||||
const POSTS = here('../../src/content/posts/');
|
||||
const authors = JSON.parse(readFileSync(here('../../src/data/authors.json'), 'utf8'));
|
||||
const ctx = { physicians: authors.physicians, supporters: authors.supporters };
|
||||
const base = yaml.load(readFileSync(join(FIX, '_base.yml'), 'utf8'));
|
||||
|
||||
let pass = 0, failCount = 0;
|
||||
const ok = (name, msg = '') => { pass++; console.log(` ✓ ${name}${msg ? ' · ' + msg : ''}`); };
|
||||
const bad = (name, msg) => { failCount++; console.error(` ✗ ${name}: ${msg}`); };
|
||||
const codes = (list, level = R.E) => [...new Set(list.filter((x) => x.level === level).map((x) => x.code))];
|
||||
|
||||
// 1) md 픽스처
|
||||
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.md'))) {
|
||||
const { data, body } = R.parseMarkdown(readFileSync(join(FIX, f), 'utf8'));
|
||||
const { expect = [], ...fm } = data;
|
||||
const merged = { ...base, ...fm };
|
||||
const got = codes(R.checkPostSource({ data: merged, body }, ctx));
|
||||
const missing = expect.filter((c) => !got.includes(c));
|
||||
const extra = got.filter((c) => !expect.includes(c));
|
||||
if (missing.length) bad(f, `기대한 오류가 안 나옴: ${missing.join(', ')} (실제: ${got.join(', ') || '없음'})`);
|
||||
else if (extra.length) bad(f, `기대 밖 오류: ${extra.join(', ')}`);
|
||||
else ok(f, got.join(', '));
|
||||
}
|
||||
|
||||
// 2) html 픽스처 (dist 규칙)
|
||||
for (const f of readdirSync(FIX).filter((x) => x.endsWith('.html'))) {
|
||||
const html = readFileSync(join(FIX, f), 'utf8');
|
||||
const expect = html.match(/expect:\s*([A-Z_]+|\(none\))/)?.[1];
|
||||
const text = R.stripHtml(html);
|
||||
const got = codes([...R.checkOperatorVocab(text, `/${f}`), ...R.checkBannedBody(text), ...R.checkHeadingEndings(html)]);
|
||||
if (expect === '(none)') got.length ? bad(f, `오류 없어야 하는데: ${got.join(', ')}`) : ok(f, '오류 0');
|
||||
else got.includes(expect) ? ok(f, got.join(', ')) : bad(f, `기대 ${expect}, 실제 ${got.join(', ') || '없음'}`);
|
||||
}
|
||||
|
||||
// 3) 정답지 회귀: 실제 18편은 error 0
|
||||
let postErrors = 0;
|
||||
for (const f of readdirSync(POSTS).filter((x) => x.endsWith('.md'))) {
|
||||
const parsed = R.parseMarkdown(readFileSync(join(POSTS, f), 'utf8'));
|
||||
const errs = R.checkPostSource(parsed, ctx).filter((x) => x.level === R.E);
|
||||
if (errs.length) { postErrors += errs.length; errs.forEach((e) => bad(`posts/${f}`, `[${e.code}] ${e.msg}`)); }
|
||||
}
|
||||
if (!postErrors) ok('src/content/posts 전편', '소스 규칙 error 0');
|
||||
|
||||
// 4) 부정문·임상 의견 표기는 통과해야 한다 (정답지에 실제로 있는 문장)
|
||||
const negatives = [
|
||||
'다만 답변만으로 운영 실태나 수술 안전이 보장되는 것은 아닙니다.',
|
||||
'나노텍스처를 선호한다는 것이 손유성 원장의 임상 판단이며, 독립 연구로 비교우위가 확정됐다는 뜻은 아닙니다.',
|
||||
];
|
||||
const negGot = R.checkConclusions(negatives.map((text) => ({ field: 'body', text })));
|
||||
negGot.length ? bad('부정문 통과', negGot.map((x) => x.msg).join(' | ')) : ok('부정문·임상 의견 문장 통과');
|
||||
|
||||
console.log(`통과 ${pass} · 실패 ${failCount}`);
|
||||
if (failCount) process.exit(1);
|
||||
@ -49,7 +49,7 @@ thumbnail: "https://i.ytimg.com/vi/HtyYwMJsWqA/hqdefault.jpg"
|
||||
|
||||
미국 FDA는 보형물이 평생 쓰는 기기가 아니며 오래 지닐수록 합병증 가능성이 커진다고 밝힙니다. 같은 페이지에서 실리콘 보형물의 무증상 파열을 찾는 데 MRI가 가장 효과적이고, 증상이 없는 사람에게는 초음파도 받아들일 수 있는 대안이라고 안내합니다. FDA는 2021년 제품 라벨 갱신에서 파열 검진 권고를 두었는데, 구체적인 주기는 제품 라벨과 담당 의사에게 확인해야 합니다. 이 글은 확인되지 않은 주기를 쓰지 않습니다.
|
||||
|
||||
두 자료를 합치면 결론은 하나입니다. 연수로 바꾸는 것이 아니라, 정기 검진으로 상태를 보고 정한다.
|
||||
두 자료를 합치면 결론은 하나입니다. 연수로 바꾸는 것이 아니라, 정기 검진으로 상태를 보고 정합니다.
|
||||
|
||||
## 뷰성형외과의 검진
|
||||
|
||||
|
||||
@ -48,7 +48,7 @@
|
||||
{
|
||||
"date": "2026-09-07",
|
||||
"page": "/posts/visit-guide",
|
||||
"what": "답변엔진이 이미지 글자를 읽지 못한다는 기술 설명이 본문에 삽입",
|
||||
"what": "이미지 속 글자를 읽지 못한다는 기술 설명이 본문에 삽입",
|
||||
"fix": "삭제하고 방문 전 전화 확인 안내로 교체",
|
||||
"source": "내부 검토 (Astra 개선안)"
|
||||
}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user