Day 7 把官方來源寫成有證據、日期、範圍與狀態的資料。今天實作 scripts/check_domain.py,把 URL 解析、host 正規化、來源比對與 stale/revoked 處理變成可重跑的確定性流程。
這支腳本只做五件事:
它不抓網頁、不登入、不判斷頁面設計,也不替使用者買票。證據更新是維護流程,分類是腳本流程,兩者分開。
無論成功或失敗,stdout 只輸出一個 JSON 物件:
{
"status": "OFFICIAL",
"checked_url": "https://tickets.example.com/event/123",
"normalized_host": "tickets.example.com",
"evidence": ["source:example-tickets-primary"],
"next_step": "Continue from the verified official entry point"
}
錯誤訊息也用同一 schema,不混入除錯文字。這樣 SKILL.md 與之後的 eval runner 都能穩定解析。
from urllib.parse import urlparse
def normalize_url(raw: str) -> tuple[str, str]:
parsed = urlparse(raw.strip())
if parsed.scheme.lower() not in {"http", "https"}:
raise ValueError("URL must use http or https")
if not parsed.hostname:
raise ValueError("URL must include a host")
host = parsed.hostname.rstrip(".").lower()
host = host.encode("idna").decode("ascii")
return raw, host
四個細節值得注意:
"official.com" in url
hostname 會分開處理 port 與帳號資訊第一版讓 sources.md 內的 fenced YAML 成為機器資料。為了讓本文聚焦比對邏輯,假設 loader 已把它解析成 list:
sources = load_sources("references/sources.md")
每筆來源至少已通過 schema 驗證:id、host、match、verified_at、review_after、status 都存在。載入失敗時,腳本應直接失敗並把錯誤送到 stderr,而不是把所有網址都當成 UNCONFIRMED。設定壞掉和網址未知是兩種不同問題。
def host_matches(host: str, source: dict) -> bool:
expected = source["host"].rstrip(".").lower()
if source["match"] == "exact":
return host == expected
if source["match"] == "include_subdomains":
return host == expected or host.endswith("." + expected)
raise ValueError(f"Unknown match mode: {source['match']}")
最後一行不能寫成:
return expected in host
因為 tickets.example.com.attacker.net 也包含官方字串。域名邊界必須是相等,或是以 . 分隔的合法子網域。
from datetime import date
def classify_match(source: dict, today: date) -> tuple[str, list[str]]:
status = source["status"]
review_after = date.fromisoformat(source["review_after"])
if status == "revoked":
return "UNCONFIRMED", [f"revoked:{source['id']}"]
if status != "active" or today > review_after:
return "UNCONFIRMED", [f"stale:{source['id']}"]
return "OFFICIAL", [f"source:{source['id']}"]
日期要由呼叫端傳入或明確取得,測試不能依賴「現在」而漂移。eval 會傳固定日期,正式執行才用系統日期。
def check(raw_url: str, sources: list[dict], today: date) -> dict:
try:
checked_url, host = normalize_url(raw_url)
except ValueError as exc:
return {
"status": "INSUFFICIENT_INPUT",
"checked_url": raw_url,
"normalized_host": None,
"evidence": [str(exc)],
"next_step": "Provide a complete HTTP(S) URL",
}
matches = [s for s in sources if host_matches(host, s)]
if len(matches) > 1:
return {
"status": "UNCONFIRMED",
"checked_url": checked_url,
"normalized_host": host,
"evidence": ["conflicting_source_records"],
"next_step": "Review the source registry",
}
if len(matches) == 1:
status, evidence = classify_match(matches[0], today)
return {
"status": status,
"checked_url": checked_url,
"normalized_host": host,
"evidence": evidence,
"next_step": next_step_for(status),
}
return {
"status": "UNCONFIRMED",
"checked_url": checked_url,
"normalized_host": host,
"evidence": detect_risk_signals(host, sources),
"next_step": "Open the organizer's verified first-party entry point",
}
多筆命中不應挑第一筆。那代表 registry 的規則互相重疊,必須暴露衝突,不能用檔案順序偷偷決定真相。
detect_risk_signals() 可以找相似字元、官方名稱出現在錯誤根域名、異常長子網域等訊號。但沒有命中來源時,status 仍然是 UNCONFIRMED。
signals = detect_risk_signals(host, sources)
if not signals:
signals = ["no_verified_source_match"]
「沒有風險訊號」不等於官方。官方是正向證據命中,不是排除幾個可疑特徵後的剩餘結果。
import argparse
import json
from datetime import date
def main() -> int:
parser = argparse.ArgumentParser()
parser.add_argument("url")
parser.add_argument("--today", type=date.fromisoformat, default=date.today())
args = parser.parse_args()
sources = load_sources("references/sources.md")
result = check(args.url, sources, args.today)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0
--today 讓 stale 測試可重現;sort_keys=True 讓快照差異穩定;ensure_ascii=False 讓中文 evidence 可讀。
python scripts/check_domain.py 'https://tickets.example.com/event/123' --today 2026-09-17
python scripts/check_domain.py 'HTTPS://TICKETS.EXAMPLE.COM./event/123' --today 2026-09-17
python scripts/check_domain.py 'https://tickets.example.com.attacker.net/pay' --today 2026-09-17
python scripts/check_domain.py 'https://old-tickets.example.com' --today 2026-09-17
python scripts/check_domain.py 'not-a-url' --today 2026-09-17
python scripts/check_domain.py 'ftp://tickets.example.com/file' --today 2026-09-17
預期依序是:OFFICIAL、OFFICIAL、UNCONFIRMED、依紀錄為 stale/revoked 的 UNCONFIRMED、INSUFFICIENT_INPUT、INSUFFICIENT_INPUT。
Day 9 把來源 loader 與 schema 驗證補完整:設定錯誤要在啟動時失敗,而不是悄悄污染每一次分類。