#!/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()