iT邦幫忙

2026 iThome 鐵人賽

DAY 8
0
Claude AI

把 Claude 練成專家:30 天打造可驗證的 Agent Skills系列 第 8

Day 08|實作 check_domain.py:讓「官方」由程式決定

  • 分享至 

  • xImage
  •  

昨日回顧

Day 7 把官方來源寫成有證據、日期、範圍與狀態的資料。今天實作 scripts/check_domain.py,把 URL 解析、host 正規化、來源比對與 stale/revoked 處理變成可重跑的確定性流程。

腳本的責任邊界

這支腳本只做五件事:

  1. 驗證輸入是不是完整 HTTP(S) URL。
  2. 正規化 host。
  3. 讀取來源紀錄。
  4. 依規則分類。
  5. 輸出固定 JSON。

它不抓網頁、不登入、不判斷頁面設計,也不替使用者買票。證據更新是維護流程,分類是腳本流程,兩者分開。

先固定輸出

無論成功或失敗,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

四個細節值得注意:

  • 使用 parser,不用 "official.com" in url
  • hostname 會分開處理 port 與帳號資訊
  • 移除 DNS 合法但容易造成比對差異的尾點
  • Unicode host 轉成 IDNA ASCII,避免同一 host 有兩種表示

第二步:讀取來源資料

第一版讓 sources.md 內的 fenced YAML 成為機器資料。為了讓本文聚焦比對邏輯,假設 loader 已把它解析成 list:

sources = load_sources("references/sources.md")

每筆來源至少已通過 schema 驗證:idhostmatchverified_atreview_afterstatus 都存在。載入失敗時,腳本應直接失敗並把錯誤送到 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

預期依序是:OFFICIALOFFICIALUNCONFIRMED、依紀錄為 stale/revoked 的 UNCONFIRMEDINSUFFICIENT_INPUTINSUFFICIENT_INPUT

明天預告

Day 9 把來源 loader 與 schema 驗證補完整:設定錯誤要在啟動時失敗,而不是悄悄污染每一次分類。


上一篇
Day 07|官方來源不是域名清單:設計可維護的 sources.md
下一篇
Day 09|實作 sources loader:設定錯誤要先爆炸
系列文
把 Claude 練成專家:30 天打造可驗證的 Agent Skills10
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言