Day 8 完成了 check_domain.py 的分類主流程:解析 URL、正規化 host、精確比對來源,再依日期與狀態輸出固定 JSON。但那篇先假設 load_sources() 已經可靠。今天補上這個缺口,讓 references/sources.md 在啟動時就接受完整驗證。
來源 registry 是分類器的信任根。若 YAML 壞掉、欄位漏寫或兩條規則重疊,最危險的做法不是程式直接停止,而是吞掉錯誤,繼續把所有網址標成 UNCONFIRMED。
那會把「設定已損壞」偽裝成「網址沒有命中」。操作者看到的是正常 JSON,卻不知道整個信任根已經失效。
所以 loader 的契約只有兩種結果:
不提供半成功模式。
sources.md 同時是給人讀的文件與給程式讀的資料。loader 只接受一個標記為 yaml 的 fenced block:
import re
FENCED_YAML = re.compile(
r"```(?:yaml|yml)\s*\n(?P<body>.*?)\n```",
re.DOTALL | re.IGNORECASE,
)
def extract_yaml(markdown: str) -> str:
blocks = [m.group("body") for m in FENCED_YAML.finditer(markdown)]
if len(blocks) != 1:
raise SourceConfigError(
f"expected exactly one fenced YAML block, found {len(blocks)}"
)
return blocks[0]
「剛好一個」是刻意的限制。沒有 block 代表資料缺失;有兩個 block 則無法知道哪一個才是正式 registry。不要猜,也不要把兩份資料偷偷合併。
import yaml
def parse_registry(yaml_text: str) -> list[dict]:
try:
data = yaml.safe_load(yaml_text)
except yaml.YAMLError as exc:
raise SourceConfigError(f"invalid YAML: {exc}") from exc
if not isinstance(data, dict):
raise SourceConfigError("registry root must be a mapping")
sources = data.get("sources")
if not isinstance(sources, list) or not sources:
raise SourceConfigError("sources must be a non-empty list")
return sources
一定要用 safe_load。來源檔是資料,不應有機會建構任意 Python 物件。
REQUIRED_FIELDS = {
"id",
"host",
"match",
"evidence_url",
"verified_at",
"review_after",
"status",
}
MATCH_MODES = {"exact", "include_subdomains"}
STATUSES = {"active", "inactive", "revoked"}
def validate_source(raw: dict, index: int) -> dict:
if not isinstance(raw, dict):
raise SourceConfigError(f"sources[{index}] must be a mapping")
missing = sorted(REQUIRED_FIELDS - raw.keys())
if missing:
raise SourceConfigError(
f"sources[{index}] missing fields: {', '.join(missing)}"
)
if raw["match"] not in MATCH_MODES:
raise SourceConfigError(
f"sources[{index}].match has unsupported value"
)
if raw["status"] not in STATUSES:
raise SourceConfigError(
f"sources[{index}].status has unsupported value"
)
return raw
錯誤要指出索引與欄位,但不要把整份設定或任何憑證印到 log。可診斷不等於無限制輸出。
PyYAML 可能直接把 ISO 日期解析成 date,也可能因資料格式而得到字串。先統一轉型,再檢查關係:
from datetime import date
def as_date(value: object, path: str) -> date:
if isinstance(value, date):
return value
if isinstance(value, str):
try:
return date.fromisoformat(value)
except ValueError as exc:
raise SourceConfigError(f"{path} must be YYYY-MM-DD") from exc
raise SourceConfigError(f"{path} must be a date")
def validate_dates(source: dict, index: int) -> dict:
verified = as_date(source["verified_at"], f"sources[{index}].verified_at")
review = as_date(source["review_after"], f"sources[{index}].review_after")
if review < verified:
raise SourceConfigError(
f"sources[{index}].review_after precedes verified_at"
)
return {**source, "verified_at": verified, "review_after": review}
這裡不判斷來源是否過期。過期是 Day 8 分類器根據 today 做的執行期決策;日期格式錯誤或時間倒置才是 loader 的設定錯誤。
registry 裡的 host 不該帶 scheme、path、port 或萬用字元:
def normalize_source_host(value: object, path: str) -> str:
if not isinstance(value, str) or not value.strip():
raise SourceConfigError(f"{path} must be a non-empty host")
host = value.rstrip(".").lower().encode("idna").decode("ascii")
if any(token in host for token in ("://", "/", ":", "*")):
raise SourceConfigError(f"{path} must contain only a hostname")
labels = host.split(".")
if len(labels) < 2 or any(not label for label in labels):
raise SourceConfigError(f"{path} is not a valid hostname")
return host
這讓 Day 8 的 host_matches() 可以只處理已正規化資料,不必每次分類都重新猜測設定作者的意圖。
def validate_uniqueness(sources: list[dict]) -> None:
seen_ids: set[str] = set()
seen_rules: set[tuple[str, str]] = set()
for source in sources:
if source["id"] in seen_ids:
raise SourceConfigError(f"duplicate source id: {source['id']}")
seen_ids.add(source["id"])
rule = (source["host"], source["match"])
if rule in seen_rules:
raise SourceConfigError(
f"duplicate source rule: {source['match']} {source['host']}"
)
seen_rules.add(rule)
重複紀錄不能靠檔案順序決定。即使兩筆目前內容相同,未來也可能只更新其中一筆,製造不一致。
重複不等於全部的衝突。以下兩筆雖然不同,卻會同時命中 pay.tickets.example.com:
sources:
- id: example-root
host: tickets.example.com
match: include_subdomains
evidence_url: https://organizer.example/sources/tickets
verified_at: 2026-09-01
review_after: 2026-12-01
status: active
- id: example-pay
host: pay.tickets.example.com
match: exact
evidence_url: https://organizer.example/sources/pay
verified_at: 2026-09-01
review_after: 2026-12-01
status: active
用一個小函式判斷兩條規則是否可能同時命中:
def covers(rule: dict, host: str) -> bool:
return (
host == rule["host"]
or rule["match"] == "include_subdomains"
and host.endswith("." + rule["host"])
)
def validate_no_overlap(sources: list[dict]) -> None:
for i, left in enumerate(sources):
for right in sources[i + 1:]:
if covers(left, right["host"]) or covers(right, left["host"]):
raise SourceConfigError(
f"overlapping source rules: {left['id']} and {right['id']}"
)
第一版選擇拒絕所有重疊,而不是設計優先順序。優先順序會增加隱性狀態,也讓修改 registry 的人更難預測結果。
from pathlib import Path
def load_sources(path: str | Path) -> list[dict]:
markdown = Path(path).read_text(encoding="utf-8")
yaml_text = extract_yaml(markdown)
raw_sources = parse_registry(yaml_text)
sources = []
for index, raw in enumerate(raw_sources):
source = validate_source(raw, index)
source = validate_dates(source, index)
source = {
**source,
"host": normalize_source_host(
source["host"], f"sources[{index}].host"
),
}
sources.append(source)
validate_uniqueness(sources)
validate_no_overlap(sources)
return sources
這個函式只在所有檢查通過後回傳。任何一步失敗,都不會留下可供分類器繼續使用的半成品。
import sys
def main() -> int:
try:
sources = load_sources("references/sources.md")
except (OSError, SourceConfigError) as exc:
print(f"source configuration error: {exc}", file=sys.stderr)
return 2
result = check(args.url, sources, args.today)
print(json.dumps(result, ensure_ascii=False, sort_keys=True))
return 0
設定錯誤走 stderr 與 exit code 2;網址輸入不足仍是分類結果 INSUFFICIENT_INPUT。這兩條失敗路徑不可混在一起。
1. 沒有 fenced YAML
2. 同時有兩個 fenced YAML
3. YAML 語法錯誤
4. 缺少 required field
5. 日期格式錯誤或 review_after 早於 verified_at
6. 不支援的 match/status
7. 重複 ID 或重複規則
8. include_subdomains 與更深層規則重疊
每個案例都應在分類任何 URL 之前失敗。再加一個完整有效的 fixture,確認 loader 回傳的 host 已正規化、日期已轉型,且紀錄順序保持不變。
Day 10 開始寫 pytest,先把 URL 正規化、精確比對、stale/revoked 與設定衝突固定成可重跑的回歸測試。