o2o-site-AEO/solution/site/src/seo/verify.ts
Mina Choi 328d9e18ee [feat] solution: 지역 이야기 생성 · 발행본 섹션 손질 · 마이그레이션 주석 축약
- 지역 이야기(가요·인물·연표·엽서·퀴즈) 생성 경로: story_service · grounding/story ·
  section_prompts. 지금까지 만들 자리가 없어 시안에만 손으로 넣은 3만 자였다
- 발행본 섹션: ItinerarySection · Carousel 레일 자동재생(use-rail-autoplay) ·
  Festival · LocalGuide · Weather · Gallery · Header/Footer
- 목업 payload 를 payloads-mockup/ 으로 분리 — 발행 대상과 섞이지 않게
- DB 새 구조 후속: site_payload · local_content_crud 조인 정리 · 테스트
- 마이그레이션 주석 축약: 9개 파일 합계 주석 비율 48% → 25%.
  실측과 밟은 함정만 남기고 논증은 커밋 메시지로 옮겼다

검증: site·frontend 빌드 통과

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-10 14:36:00 +09:00

278 lines
12 KiB
TypeScript

/**
* 구조화 데이터 ↔ 화면 값 대조. **★ 절대규칙 3 을 코드로 강제하는 자리다.**
*
* JSON-LD 는 AI 검색이 읽는 값이고 화면은 사람이 읽는 값이다. 둘이 다르면
* '검색엔진에만 다른 말을 하는' 상태가 된다 — 클로킹으로 취급될 수 있고, 무엇보다 거짓이다.
*
* ★ 왜 여기(렌더러 안)에 있나
* 예전에는 백엔드가 **자기만의 HTML 을 따로 구워** 그걸 대조했다. 방문자가 보는 페이지는
* 이 렌더러가 굽는데, 검사는 아무도 안 보는 페이지에 대고 하고 있었다 — 두 렌더러가
* 어긋나는 순간 게이트는 통과인데 실제 페이지는 다른 내용이 된다.
* 검사는 **나갈 바로 그 HTML** 에 대고 해야 의미가 있다.
*
* ★ 검사 방식
* JSON-LD 의 모든 스칼라 값이 화면 텍스트(또는 src/href)에 나타나는지 본다.
* 나타나지 않으면 "화면에 없는 것을 구조화 데이터가 주장하고 있다" 는 뜻이고, 그게 거짓이다.
*/
import type {Json} from './jsonld';
/** 화면에 텍스트로 나타나지 않는 구조·메타 값. 대조 대상에서 제외한다. */
const STRUCTURAL = new Set([
'@context',
'@type',
'@id',
'priceCurrency',
// ★ ㎡의 ISO 코드('MTK'). priceCurrency 와 같은 성격이라 화면에 나올 값이 아니다 —
// 빠져 있어서 객실 면적(room_size)이 있는 사업장은 전부 발행이 막혔다(실측: 가은채 객실 12개).
// ★ 사람이 읽는 단위 표기(`unitText`)는 여기 넣지 않는다 — 그건 화면에 있어야 하는 말이다.
'unitCode',
// ★ units.length 로 만든 파생 카운트다. 화면에는 객실 카드가 그 개수만큼 있을 뿐
// '12' 라는 숫자가 글자로 있지는 않다. 객실이 2~3개인 사업장만 우연히 통과했다.
'numberOfRooms',
'addressCountry',
'inLanguage',
// 좌표는 지도 핀용 메타지 본문에 쓸 값이 아니다. 대신 payload 와 직접 대조한다(verifyGeo).
'latitude',
'longitude',
]);
/** 이름과 값을 따로 대조하는 서브트리. 일반 순회에서는 건너뛴다. */
const SPECIAL_SUBTREE = new Set(['amenityFeature', 'makesOffer', 'geo']);
/**
* **페이지를 설명하는** 노드. 본문 대조 대상이 아니다.
*
* ★ 이게 왜 필요한가
* WebPage 의 name·description·datePublished 는 `<title>` 과 `<meta>` 로 나가는 값이지
* 본문에 찍히는 문장이 아니다. 이걸 본문에서 찾으면 멀쩡한 사이트가 전부 실패한다.
* 대조해야 할 것은 **사업장에 대한 주장**(상호·주소·전화·가격·편의시설)과 FAQ 문답이다.
* 그것들이 화면에 없으면서 구조화 데이터에만 있으면 그게 거짓이다.
*/
const PAGE_META_TYPES = new Set(['WebPage', 'WebSite', 'BreadcrumbList', 'ImageObject']);
/**
* **합성값** 속성. 통째로는 화면에 없고, 구성 요소가 각각 화면에 있으면 통과다.
*
* 예: priceRange `'20,000원 ~ 60,000원'` 는 객실 요금에서 계산한 요약이다. 화면에는
* `20,000원` 과 `60,000원` 이 각 객실 카드에 따로 나온다 — 두 끝값이 다 보이면
* 그 범위는 화면이 뒷받침하는 주장이다. 통짜 비교를 고집하면 사실인 값이 발행을 막는다.
* ★ 대신 **구성 요소가 하나라도 화면에 없으면 실패**다 — 검사를 느슨하게 하는 게 아니다.
*/
const COMPOSITE_PROPS = new Set(['priceRange']);
/** 합성값을 구성 요소로 쪼갠다(범위 구분자·쉼표 기준). 빈 조각은 버린다. */
function splitComposite(token: string): string[] {
return token
.split(/\s*[~–—]\s*|\s+-\s+/)
.map((part) => part.trim())
.filter(Boolean);
}
const SCRIPT_RE = /<script\b[^>]*>[\s\S]*?<\/script>/gi;
const STYLE_RE = /<style\b[^>]*>[\s\S]*?<\/style>/gi;
const TAG_RE = /<[^>]+>/g;
const ENTITIES: Record<string, string> = {
'&amp;': '&',
'&lt;': '<',
'&gt;': '>',
'&quot;': '"',
'&#39;': "'",
'&nbsp;': ' ',
};
function unescapeHtml(value: string): string {
return value
.replace(/&(?:amp|lt|gt|quot|#39|nbsp);/g, (m) => ENTITIES[m] ?? m)
.replace(/&#(\d+);/g, (_, code) => String.fromCodePoint(Number(code)))
.replace(/&#x([0-9a-f]+);/gi, (_, code) => String.fromCodePoint(parseInt(code, 16)));
}
/**
* 화면에 실제로 보이는 텍스트.
*
* ★ script(= JSON-LD 자신)와 style 을 먼저 걷어낸다. 이걸 안 하면 JSON-LD 가
* 자기 자신을 근거로 통과해버려 검사가 통째로 무의미해진다.
*/
export function visibleText(html: string): string {
const body = html.replace(SCRIPT_RE, ' ').replace(STYLE_RE, ' ').replace(TAG_RE, ' ');
return unescapeHtml(body).replace(/\s+/g, ' ').trim();
}
/** 리스트 인덱스를 건너뛴 실제 속성 이름. */
function propName(path: string[]): string {
for (let i = path.length - 1; i >= 0; i -= 1) {
if (!/^\d+$/.test(path[i])) return path[i];
}
return path[path.length - 1] ?? '';
}
type Scalar = string | number | boolean;
function* iterScalars(node: unknown, path: string[] = []): Generator<[string[], Scalar]> {
if (Array.isArray(node)) {
for (let i = 0; i < node.length; i += 1) yield* iterScalars(node[i], [...path, String(i)]);
return;
}
if (node && typeof node === 'object') {
for (const [key, value] of Object.entries(node as Record<string, unknown>)) {
if (SPECIAL_SUBTREE.has(key)) continue;
yield* iterScalars(value, [...path, key]);
}
return;
}
if (node === null || node === undefined) return;
yield [path, node as Scalar];
}
/** 주소로 볼 값인지 — 절대 URL 이거나 루트 절대경로. 본문 텍스트가 아니라 속성에 있다. */
function isUrlLike(token: string): boolean {
return token.startsWith('http://') || token.startsWith('https://') || token.startsWith('/');
}
/** 절대 URL 에서 오리진을 뗀 경로. 절대경로는 그대로 돌려준다. */
function pathOf(token: string): string {
if (token.startsWith('/')) return token;
try {
const {pathname, search} = new URL(token);
return `${pathname}${search}`;
} catch {
return token;
}
}
/** 숫자를 화면 표기(1,000 단위 구분)로. 화면이 그렇게 그리므로 대조도 같은 표기를 본다. */
function asShown(value: Scalar): string[] {
if (typeof value === 'number' && Number.isFinite(value)) {
return [String(value), value.toLocaleString('ko-KR')];
}
return [String(value)];
}
/**
* JSON-LD 값이 전부 화면에 나타나는지 대조한다. 불일치 목록을 돌려준다(비면 통과).
*
* @param html 실제로 나갈 페이지 HTML
* @param nodes 그 페이지에 실린 JSON-LD 노드 전부
*/
export function verifyJsonLd(html: string, nodes: Json[]): string[] {
const text = visibleText(html);
/**
* URL 대조용 사본 — 엔티티를 되돌린 HTML.
*
* ★ 왜 필요한가 (실측 2026-09-07, 데모 payload)
* `<img src="…?auto=format&fit=crop">` 는 HTML 로 나갈 때 `&` 가 `&amp;` 로 이스케이프된다.
* JSON-LD 의 `image` 는 원본 `&` 를 갖고 있으므로 원본 HTML 문자열에서는 절대 안 찾아진다 —
* **화면에 실제로 있는 이미지가 "화면에 없다"로 잡혀** 발행이 막혔다. 쿼리스트링 있는
* 이미지 URL 을 쓰는 사이트는 전부 이 오탐에 걸린다.
* 숫자 표기 차이를 `asShown()` 으로 흡수하는 것과 같은 이유다 — **표기 차이는 거짓이 아니다.**
* ★ 반대로 느슨해지지는 않는다: 되돌린 사본에서도 못 찾으면 그대로 실패다.
*/
const unescaped = unescapeHtml(html);
const problems: string[] = [];
const seen = new Set<string>();
const report = (message: string) => {
// 같은 값이 여러 노드에 반복되면(name 은 사업장·WebSite·WebPage 에 다 있다)
// 같은 사유가 여러 번 쌓인다 — 운영자가 볼 목록이지 통계가 아니다.
if (seen.has(message)) return;
seen.add(message);
problems.push(message);
};
for (const node of nodes) {
// 페이지 메타 노드는 head 로 나가는 값이라 본문에 없다 — 대조하면 전부 오탐이 된다.
if (PAGE_META_TYPES.has(String(node['@type'] ?? ''))) continue;
for (const [path, value] of iterScalars(node)) {
const prop = propName(path);
if (STRUCTURAL.has(prop) || value === '' || typeof value === 'boolean') continue;
const token = String(value);
// URL·이미지는 본문 텍스트가 아니라 요소 속성(src/href)에 있다.
if (isUrlLike(token)) {
/*
* ★ 경로로도 대조한다 (2026-09-09)
* 우리 자산을 가리키는 이미지는 HTML 에 **루트 절대경로**(`/assets/…`)로 박히는데
* JSON-LD 에는 오리진이 붙은 절대 URL 로 나간다(jsonld `imageUrl`). 토큰을 통째로
* 찾으면 같은 그림인데 "화면에 없다"가 된다 — 실측(2026-09-09): 사진을 우리 쪽으로
* 미러한 순간 게이트가 사이트 셋을 다 막았다.
* 반대 방향(HTML 절대·JSON-LD 상대)도 같은 이유로 함께 본다.
* ★ `unescaped` 도 같이 본다 — HTML 엔티티로 이스케이프된 자리(&amp; 를 낀 쿼리)는
* 원문 그대로는 안 잡힌다.
*/
const tokenPath = pathOf(token);
if (
html.includes(token) ||
unescaped.includes(token) ||
html.includes(tokenPath) ||
unescaped.includes(tokenPath)
) {
continue;
}
report(`${prop}: '${token}' 이 화면에 없다`);
continue;
}
if (asShown(value).some((shown) => text.includes(shown))) continue;
if (COMPOSITE_PROPS.has(prop)) {
// 구성 요소가 전부 화면에 있으면 통과. 하나라도 없으면 그 조각을 사유로 남긴다.
const parts = splitComposite(token);
const missing = parts.filter((part) => !text.includes(part));
if (parts.length > 1 && missing.length === 0) continue;
report(`${prop}: '${missing.join(' · ') || token}' 이 화면에 없다`);
continue;
}
report(`${prop}: '${token}' 이 화면에 없다`);
}
// 편의시설·가격은 이름과 값을 따로 본다(구조가 name/value 쌍이라 일반 순회로는 약하다).
for (const feature of asArray(node.amenityFeature)) {
const name = String(feature?.name ?? '');
if (name && !text.includes(name)) report(`amenityFeature[${name}]: 화면에 없다`);
}
for (const offer of asArray(node.makesOffer)) {
const name = String(offer?.name ?? '');
if (name && !text.includes(name)) report(`makesOffer[${name}]: 화면에 없다`);
const price = offer?.price;
if (price !== undefined && price !== null && !asShown(price as Scalar).some((s) => text.includes(s))) {
report(`makesOffer[${name}].price: '${price}' 이 화면에 없다`);
}
}
}
return problems;
}
function asArray(value: unknown): Record<string, unknown>[] {
if (Array.isArray(value)) return value.filter((v) => v && typeof v === 'object');
if (value && typeof value === 'object') return [value as Record<string, unknown>];
return [];
}
/**
* 좌표가 payload 와 같은지. 지도 핀이 엉뚱한 데 찍히는 것도 거짓 정보다.
*
* 좌표는 화면 텍스트로 나오지 않아 본문 대조로는 확인할 수 없다 —
* 검사에서 빼는 것과 검사를 안 하는 것은 다르므로, 원본과 직접 맞춘다.
*/
export function verifyGeo(nodes: Json[], latitude?: number, longitude?: number): string[] {
const problems: string[] = [];
const TOLERANCE = 1e-6;
for (const node of nodes) {
const geo = node.geo as Record<string, unknown> | undefined;
if (!geo) continue;
const pairs: [string, unknown, number | undefined][] = [
['latitude', geo.latitude, latitude],
['longitude', geo.longitude, longitude],
];
for (const [name, shown, source] of pairs) {
if (source === undefined || typeof shown !== 'number' || Math.abs(shown - source) > TOLERANCE) {
problems.push(`geo.${name}: 구조화 데이터 ${String(shown)} != payload ${String(source)}`);
}
}
}
return problems;
}