/** * 구조화 데이터 ↔ 화면 값 대조. **★ 절대규칙 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 는 `` 과 `<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> = { '&': '&', '<': '<', '>': '>', '"': '"', ''': "'", ' ': ' ', }; 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 로 나갈 때 `&` 가 `&` 로 이스케이프된다. * 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 엔티티로 이스케이프된 자리(& 를 낀 쿼리)는 * 원문 그대로는 안 잡힌다. */ 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; }