feat(seo-schema-validator): back the upgraded SKILL.md with a working 5-layer pipeline

The "Upgrade Schema Validator" commit added SKILL.md referencing files that did
not exist. Implement them so the skill actually runs:

- scripts/validate_schema.py — 5-layer offline validator (L0 coverage, L1 syntax,
  L2 vocabulary/value-format, L3 rich-result, L4 consistency) with xlsx/csv/jsonl/
  json/dir/live-URL adapters. Gate = zero P0; exits 1 on failure.
- scripts/schema_rules.json — curated hotel-focused, offline rule set (edit-only
  extension point).
- scripts/make_sample.py + fixtures/sample_schema.csv — deliberately flawed fixture
  seeding ≥1 defect per layer; used to self-test.
- references/ — validation-methodology, defect-taxonomy (25 codes), hotel-type-map.
- templates/ — client-qa-report, decision-log.
- code/CLAUDE.md — redirect legacy single-URL tool to the new pipeline.

Noise control: MISSING_RECOMMENDED aggregated one-line-per-node; unexpected-property
checks opt-in via --strict. Generalized client-specific shilla-type-map → hotel-type-map.
Self-tested: default P0=5/P1=4/P2=14 FAIL, --strict --no-recommended P2=0, adapters verified.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-27 23:48:51 +09:00
parent ba88247496
commit 4f48ba3c59
12 changed files with 1636 additions and 130 deletions

View File

@@ -0,0 +1,160 @@
#!/usr/bin/env python3
"""
make_sample.py — generate fixtures/sample_schema.csv.
A small, deliberately FLAWED hotel dataset (Josun-style, fictional values) that
seeds at least one defect per validation layer. Use it to learn the tool and to
regression-test changes to validate_schema.py or schema_rules.json:
python make_sample.py
python validate_schema.py ../fixtures/sample_schema.csv --out /tmp/demo_out
Each row's comment names the defect(s) it is designed to trigger.
"""
import csv
import json
from pathlib import Path
OUT = Path(__file__).resolve().parent.parent / "fixtures" / "sample_schema.csv"
CTX = "https://schema.org"
SHARED_DESC = ("조선호텔앤리조트가 운영하는 럭셔리 호텔로, 도심 속에서 품격 있는 휴식을 "
"제공합니다. 최상의 서비스와 시설을 경험하실 수 있습니다.") # >30 chars, reused 3x
def jd(obj):
return json.dumps(obj, ensure_ascii=False)
# Each tuple: (url, lang, device, page_type, jsonld_string)
ROWS = []
# 1) CLEAN Organization — only a recommended gap (P2 MISSING_RECOMMENDED, aggregated)
ROWS.append((
"https://www.josunhotel.com/en/brand/grand", "en", "PC", "brand-hub",
jd({"@context": CTX, "@type": "Organization", "@id": "https://www.josunhotel.com/#org",
"name": "Josun Hotels & Resorts", "url": "https://www.josunhotel.com/",
"logo": "https://www.josunhotel.com/logo.png",
"sameAs": ["https://www.instagram.com/josunhotelsandresorts/"]}),
))
# 2) INVALID JSON (P0 INVALID_JSON) — trailing comma, unquoted key
ROWS.append((
"https://www.josunhotel.com/ko/grand", "ko", "PC", "hotel",
'{"@context": "https://schema.org", "@type": "Hotel", name: "그랜드조선",}',
))
# 3) MISSING @type (P1 NO_TYPE)
ROWS.append((
"https://www.josunhotel.com/ko/grand/rooms", "ko", "MOBILE", "rooms",
jd({"@context": CTX, "name": "디럭스룸", "url": "https://www.josunhotel.com/ko/grand/rooms"}),
))
# 4) WRONG @context (P1 WRONG_CONTEXT)
ROWS.append((
"https://www.josunhotel.com/ko/palace", "ko", "PC", "hotel",
jd({"@context": "https://example.org", "@type": "Hotel", "name": "조선팰리스",
"address": {"@type": "PostalAddress", "streetAddress": "테헤란로 231",
"addressLocality": "서울", "addressCountry": "KR"}}),
))
# 5) Hotel MISSING REQUIRED address (P0 MISSING_REQUIRED)
ROWS.append((
"https://www.josunhotel.com/ko/lescape", "ko", "PC", "hotel",
jd({"@context": CTX, "@type": "Hotel", "name": "레스케이프 호텔",
"telephone": "+82-2-317-4000", "description": SHARED_DESC}),
))
# 6) PLACEHOLDER text (P0 PLACEHOLDER_TEXT)
ROWS.append((
"https://www.josunhotel.com/ko/grand/dining", "ko", "PC", "restaurant",
jd({"@context": CTX, "@type": "Restaurant", "name": "예시 레스토랑",
"address": {"@type": "PostalAddress", "streetAddress": "수정필요",
"addressCountry": "KR"}, "servesCuisine": "Korean"}),
))
# 7a + 7b) NAP PHONE MISMATCH (P0 NAP_PHONE_MISMATCH) — same business, two phones
ROWS.append((
"https://www.josunhotel.com/ko/westin", "ko", "PC", "hotel",
jd({"@context": CTX, "@type": "Hotel", "name": "웨스틴 조선 서울",
"telephone": "+82-2-771-0500",
"address": {"@type": "PostalAddress", "streetAddress": "소공로 106",
"addressLocality": "서울", "addressCountry": "KR"},
"description": SHARED_DESC}),
))
ROWS.append((
"https://www.josunhotel.com/en/westin", "en", "PC", "hotel",
jd({"@context": CTX, "@type": "Hotel", "name": "웨스틴 조선 서울",
"telephone": "+82-2-771-9999",
"address": {"@type": "PostalAddress", "streetAddress": "소공로 106",
"addressLocality": "Seoul", "addressCountry": "KR"},
"description": SHARED_DESC}),
))
# 8) DANGLING @id reference (P1 DANGLING_ID) — publisher points at undefined node
ROWS.append((
"https://www.josunhotel.com/ko", "ko", "PC", "home",
jd({"@context": CTX, "@type": "WebSite", "name": "조선호텔앤리조트",
"url": "https://www.josunhotel.com/",
"publisher": {"@id": "https://www.josunhotel.com/#missing-org"}}),
))
# 9) SWAPPED geo (P1 GEO_SWAPPED) — lat/long transposed for Seoul
ROWS.append((
"https://www.josunhotel.com/ko/grand/location", "ko", "PC", "location",
jd({"@context": CTX, "@type": "Hotel", "name": "그랜드 조선 부산",
"address": {"@type": "PostalAddress", "streetAddress": "동백로 60",
"addressLocality": "부산", "addressCountry": "KR"},
"geo": {"@type": "GeoCoordinates", "latitude": 129.1603, "longitude": 35.1586}}),
))
# 10) BAD date (P2 BAD_DATE) in an Offer-bearing page
ROWS.append((
"https://www.josunhotel.com/ko/offers/spring", "ko", "PC", "offer",
jd({"@context": CTX, "@type": "Offer", "price": "350000", "priceCurrency": "KRW",
"validFrom": "2026년 3월 1일", "url": "https://www.josunhotel.com/ko/offers/spring"}),
))
# 11) BAD currency symbol (P2 BAD_CURRENCY)
ROWS.append((
"https://www.josunhotel.com/ko/offers/dining", "ko", "PC", "offer",
jd({"@context": CTX, "@type": "Offer", "price": "120000", "priceCurrency": "",
"availability": "https://schema.org/InStock"}),
))
# 12) UNKNOWN type (P2 UNKNOWN_TYPE)
ROWS.append((
"https://www.josunhotel.com/ko/spa", "ko", "PC", "facility",
jd({"@context": CTX, "@type": "SpaResort", "name": "조선 스파"}),
))
# 13) Third reuse of SHARED_DESC → triggers DUPLICATE_DESCRIPTION (P1) across rows 5,7a,7b,13
ROWS.append((
"https://www.josunhotel.com/ko/grand/intro", "ko", "PC", "hotel",
jd({"@context": CTX, "@type": "Hotel", "name": "그랜드 조선 제주",
"address": {"@type": "PostalAddress", "streetAddress": "중문관광로 75",
"addressLocality": "제주", "addressCountry": "KR"},
"description": SHARED_DESC}),
))
# 14) CLEAN FAQPage — exercises a passing entry (and an inventory-orphan URL for L0 demo)
ROWS.append((
"https://www.josunhotel.com/ko/faq?stale=1", "ko", "MOBILE", "faq",
jd({"@context": CTX, "@type": "FAQPage", "mainEntity": [
{"@type": "Question", "name": "체크인 시간은 언제인가요?",
"acceptedAnswer": {"@type": "Answer", "text": "오후 3시부터 체크인 가능합니다."}}]}),
))
def main():
OUT.parent.mkdir(parents=True, exist_ok=True)
with open(OUT, "w", newline="", encoding="utf-8-sig") as f:
w = csv.writer(f)
w.writerow(["url", "언어코드", "PC/MOBILE", "page_type", "스키마"]) # Korean aliases on purpose
w.writerows(ROWS)
print(f"Wrote {len(ROWS)} entries → {OUT}")
if __name__ == "__main__":
main()