本日核心價值 (Core Focus): 只抓你擁有或已獲授權的網站,遵守
robots.txt;靜態 HTML 用 BeautifulSoup,動態頁面才在高層次使用 Playwright / Puppeteer。抓取與 LLM 解析必須分開,HTML 要快取,模型只輸出符合 schema 的 JSON。
概念說明與實戰情境 (Overview)
把 LLM 直接對著「任意網址」下指令,等於把未授權存取、個資與 Prompt Injection 一次引入工作流。正確做法是資料管線,不是爬蟲武器:僅處理你擁有或有書面授權的來源;先讀 robots.txt;抓取層只負責拿到 HTML 並快取;解析層用 BeautifulSoup 在你控制的 HTML上取節點,再用 LLM 依 JSON Schema 抽欄位。JavaScript 渲染頁可用 Playwright 或 Puppeteer 做「對自己系統的瀏覽器自動化」,本文不提供繞過登入牆、驗證碼或他人防護的作法。抓取失敗就停,不要讓模型改用「別的技巧」去拿頁。
關鍵操作與範例 (Implementation & Example)
把下列規則寫進 repo 的 AGENTS.md 與 job 說明,並讓 Codex 讀到同一份文字,避免它把「再試別的網址」寫進 fetcher。本系列要的是可重跑的解析管線,不是通用爬蟲:
robots.txt,禁止的路徑不要抓。工作流拆成三個檔案,避免「邊抓邊讓模型發明選擇器」:
scrape/
fixtures/catalog.html # 你控制的樣本,測試用
fetch_page.py # 授權來源 + 快取
parse_html.py # BeautifulSoup -> 純文字/片段
extract_llm.py # schema JSON
cache/ # 原始 HTML,不進 Git
用本機 fixture 示範靜態解析(這是預設路徑;正式環境把 source 換成授權內網頁):
<!-- scrape/fixtures/catalog.html -->
<!doctype html>
<html lang="zh-Hant">
<body>
<article data-sku="A-100">
<h2>不鏽鋼螺絲 M6</h2>
<p class="price" data-currency="TWD">12.50</p>
<p class="stock">42</p>
</article>
<article data-sku="A-101">
<h2>墊片 6mm</h2>
<p class="price" data-currency="TWD">3.00</p>
<p class="stock">0</p>
</article>
</body>
</html>
抓取與解析分開。快取鍵用 URL 的 SHA-256,同一頁不要重複打主機:
from __future__ import annotations
import hashlib
from pathlib import Path
from urllib.parse import urlparse
from urllib.robotparser import RobotFileParser
CACHE = Path("scrape/cache")
UA = "AIironBot/1.0 (+https://example.com/bot)"
def allowed(url: str) -> bool:
parsed = urlparse(url)
robots = f"{parsed.scheme}://{parsed.netloc}/robots.txt"
rp = RobotFileParser()
rp.set_url(robots)
rp.read()
return rp.can_fetch(UA, url)
def cache_path(url: str) -> Path:
name = hashlib.sha256(url.encode("utf-8")).hexdigest() + ".html"
return CACHE / name
def fetch(url: str, session_get) -> str:
"""Fetch only authorized URLs. session_get is your HTTP client."""
if not allowed(url):
raise PermissionError(f"robots.txt disallows {url}")
path = cache_path(url)
if path.exists():
return path.read_text(encoding="utf-8")
response = session_get(url, headers={"User-Agent": UA}, timeout=20)
response.raise_for_status()
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(response.text, encoding="utf-8")
return response.text
BeautifulSoup 先把 DOM 收成規則化片段,再交給 LLM。不要把整頁雜訊(導覽、腳本)丟進 prompt:
from bs4 import BeautifulSoup
def to_records(html: str) -> list[dict[str, str]]:
soup = BeautifulSoup(html, "html.parser")
rows: list[dict[str, str]] = []
for item in soup.select("article[data-sku]"):
price = item.select_one(".price")
stock = item.select_one(".stock")
title = item.select_one("h2")
rows.append(
{
"sku": item.get("data-sku", ""),
"title": title.get_text(strip=True) if title else "",
"price": price.get_text(strip=True) if price else "",
"currency": (price.get("data-currency") if price else "") or "",
"stock": stock.get_text(strip=True) if stock else "",
}
)
return rows
LLM 只做「欄位正規化與缺值標註」,輸入已是結構化片段。用 JSON Schema(延續 Day 03)鎖輸出:
SCHEMA = {
"type": "object",
"properties": {
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"sku": {"type": "string"},
"title": {"type": "string"},
"price": {"type": "number"},
"currency": {"type": "string", "enum": ["TWD", "USD"]},
"stock_qty": {"type": "integer", "minimum": 0},
},
"required": ["sku", "title", "price", "currency", "stock_qty"],
"additionalProperties": False,
},
}
},
"required": ["items"],
"additionalProperties": False,
}
PROMPT = """Normalize these catalog fragments into the schema.
Do not invent SKUs. If stock is missing, skip that item.
Input JSON:
"""
Playwright / Puppeteer 僅在「自己的前端需要瀏覽器才能產出 HTML」時使用,而且停留在高層次:啟動瀏覽器、開你控制的 URL、等已知 CSS 選擇器、把 page.content() 寫進同一套 cache,然後結束,後續仍走 BeautifulSoup + LLM。不要在文章或程式碼裡放:繞過 CAPTCHA、隱藏欄位、竄改 cookie、對抗 rate limit、或「避開 WAF」之類步驟。Codex 的 Prompt 同樣要寫死:
Parse scrape/fixtures/catalog.html with parse_html.py, then fill SCHEMA.
Do not fetch arbitrary URLs. Do not change the fetcher to bypass access controls.
為什麼不把整頁丟給 LLM、省掉 BeautifulSoup?因為 schema 只能約束輸出形狀,不能告訴模型「價錢在 .price」。DOM 抽取是決定性步驟,LLM 只負責型別與列舉正規化("42" → 42、"TWD")。兩者反過來,選擇器會變成每次都不一樣的自然語言,測試無法鎖定。快取也讓解析可以重跑:改 schema 不必再打主機。快取失效用來源的 ETag / Last-Modified(若你的站有提供),或對授權內網頁做版本號檔名;不要用「每次都重抓」當預設。
測試用 fixture,CI 不打外網。授權內網頁的 fetch 另開 job,成功後只上傳 cache artifact,解析 job 不持有外網憑證。這與 Day 17 的「金鑰與不可信程式碼分開」同一原則。Playwright 的等待條件用你自己頁面上穩定的 CSS(例如 article[data-sku]),不要寫成對第三方站的隱含流程。渲染結果一樣進 scrape/cache,之後的 parse / LLM 步驟與靜態 HTML 完全相同。
fixture 要當成契約:HTML 結構變了,to_records 的測試必須紅,而不是靠 LLM 猜。授權內網若需要 cookie,由 fetch 層從 secrets 讀取,不要讓模型拼 Cookie 標頭。Playwright 只開 headless 與 timeout,不要借用個人瀏覽器 profile。快取檔名除了 URL 雜湊,可再放抓取時間的 sidecar,方便稽核「這份 JSON 來自哪一次 HTML」。解析失敗時保留 HTML 與模型原始輸出各一份以便除錯,但兩者都不要提交進 Git。robots.txt 的 Crawl-delay 若存在,fetch 層要遵守,而不是用並行把內網工具站打滿。
注意事項與常見失敗 (Pitfalls)
article 節點。robots.txt:內網工具站也可能標明 Disallow。修法:RobotFileParser.can_fetch 失敗就拋錯。to_records() 後的欄位,去掉 HTML 標籤與註解。scrape/cache/ 列入 .gitignore,CI 用 artifact 傳遞。本日總結 (Takeaways)
robots.txt。明日預告 (Next)
明天進入資安防護工作流:說明 Prompt Injection 的攻擊樣態與 LLM 防禦策略,重點放在輸入隔離、工具白名單與不可讓模型執行未審查指令。