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:
Haewon Kam 2026-09-07 13:57:31 +09:00
parent a441f8a4ad
commit 49c5e46f23
19 changed files with 1289 additions and 34 deletions

View File

@ -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:

View File

@ -10,6 +10,9 @@
"dependencies": {
"astro": "^7.3.1"
},
"devDependencies": {
"js-yaml": "^4.3.2"
},
"engines": {
"node": ">=22.12.0"
}

View File

@ -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"
}
}

View File

@ -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);

View 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 }

View File

@ -0,0 +1,12 @@
---
expect: [CONCLUSION_PHRASE]
---
## 결론
네 가지 질문에 답하면 안전 체계가 실제로 돌아가고 있다고 볼 수 있습니다. 수술 후 다음 날 출근할 수 있을 정도로 회복이 빠릅니다.
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,2 @@
<!-- expect: (none) -->
<body><main><h1>상담 전 질문</h1><h2>답하는 방식</h2><p>원장 설명 영상과 병원 공개 자료를 근거로 답합니다.</p></main></body>

View 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 |
플랫폼마다 확인일이 다릅니다. 상담에서 확인하세요.

View File

@ -0,0 +1,2 @@
<!-- expect: OPERATOR_VOCAB -->
<body><main><h1>서포터즈 소개</h1><p>이 글은 답변엔진이 인용하는 단락으로 구성했습니다. 질문 뱅크 120문항 중 QB D 카테고리입니다.</p></main></body>

View File

@ -0,0 +1,12 @@
---
expect: [REGULATION_SOURCE_MISSING]
---
## 승인
이 보형물은 2024년 미국 FDA 승인을 받았습니다.
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [REVIEWED_WITHOUT_DATE]
reviewer: dr-choi
reviewStatus: reviewed
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [REVIEWER_UNKNOWN]
reviewer: dr-nobody
reviewStatus: pending
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [SOURCE_TYPE_MISSING]
sources:
- { label: "병원 홈페이지", url: "https://example.com/a", accessed: "2026-09-07" }
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View File

@ -0,0 +1,12 @@
---
expect: [BANNED_VIDEO_TITLE]
videos:
- { id: abc123, title: "코성형+지방이식 전후 #Shorts", published: "2024-01-01" }
---
## 표
| 구분 | 내용 |
|---|---|
| 항목 | 값 |
상담에서 확인합니다.

View 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"
]
}

View 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(/&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)),
];
}

View 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);

View File

@ -49,7 +49,7 @@ thumbnail: "https://i.ytimg.com/vi/HtyYwMJsWqA/hqdefault.jpg"
미국 FDA는 보형물이 평생 쓰는 기기가 아니며 오래 지닐수록 합병증 가능성이 커진다고 밝힙니다. 같은 페이지에서 실리콘 보형물의 무증상 파열을 찾는 데 MRI가 가장 효과적이고, 증상이 없는 사람에게는 초음파도 받아들일 수 있는 대안이라고 안내합니다. FDA는 2021년 제품 라벨 갱신에서 파열 검진 권고를 두었는데, 구체적인 주기는 제품 라벨과 담당 의사에게 확인해야 합니다. 이 글은 확인되지 않은 주기를 쓰지 않습니다.
두 자료를 합치면 결론은 하나입니다. 연수로 바꾸는 것이 아니라, 정기 검진으로 상태를 보고 정다.
두 자료를 합치면 결론은 하나입니다. 연수로 바꾸는 것이 아니라, 정기 검진으로 상태를 보고 정합니다.
## 뷰성형외과의 검진

View File

@ -48,7 +48,7 @@
{
"date": "2026-09-07",
"page": "/posts/visit-guide",
"what": "답변엔진이 이미지 글자를 읽지 못한다는 기술 설명이 본문에 삽입",
"what": "이미지 글자를 읽지 못한다는 기술 설명이 본문에 삽입",
"fix": "삭제하고 방문 전 전화 확인 안내로 교체",
"source": "내부 검토 (Astra 개선안)"
}