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:
160
custom-skills/16-seo-schema-validator/scripts/make_sample.py
Normal file
160
custom-skills/16-seo-schema-validator/scripts/make_sample.py
Normal 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()
|
||||
Reference in New Issue
Block a user