iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
ChatGPT & Codex

ChatGPT + Codex 打造高效能 AI 開發工作流系列 第 20

Day 20: 自動化 Web Scraping 工作流:結合 BeautifulSoup / Puppeteer 與 LLM 解析

  • 分享至 

  • xImage
  •  

Day 20: 自動化 Web Scraping 工作流:結合 BeautifulSoup / Puppeteer 與 LLM 解析 (Scraping plus LLM Parse)

本日核心價值 (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。本系列要的是可重跑的解析管線,不是通用爬蟲:

  • 只刮自己的站、staging、或合約允許的資料源。
  • 抓取前讀該主機的 robots.txt,禁止的路徑不要抓。
  • 對第三方公開網頁做未授權大量抓取,不在本系列範圍。
  • User-Agent 標明機器人名稱與聯絡方式;頻率低於一般瀏覽。

工作流拆成三個檔案,避免「邊抓邊讓模型發明選擇器」:

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.txtCrawl-delay 若存在,fetch 層要遵守,而不是用並行把內網工具站打滿。

注意事項與常見失敗 (Pitfalls)

  • 把「公開網站」當成可以任意刮:公開不等於授權,且可能違反該站條款。修法:只處理自有或合約來源;CI 預設跑 fixture。
  • Fetch 與 parse 寫在同一 Prompt:模型失敗時會改抓取策略。修法:兩個程式、兩次呼叫;parse 只吃檔案路徑。
  • 整頁 HTML 進 LLM:token 貴,且 footer / 使用者留言可造成 Prompt Injection。修法:BeautifulSoup 先抽 article 節點。
  • 不快取:重跑解析每次打主機,既慢又像攻擊流量。修法:URL → SHA-256 檔;解析只讀 cache。
  • 忽略 robots.txt:內網工具站也可能標明 Disallow。修法:RobotFileParser.can_fetch 失敗就拋錯。
  • 向 Codex 要「動態頁抓不到時的繞過方法」:那會滑向未授權存取。修法:渲染層只開自己的 URL;失敗就修自己的前端或授權 API,而不是對抗他人站點。
  • 把使用者留言、HTML 註解原封不動送進 LLM:那是 Prompt Injection 的常見入口(明天會專題處理)。修法:只送 to_records() 後的欄位,去掉 HTML 標籤與註解。
  • 快取目錄進 Git:二進位與個資容易洩漏。修法:scrape/cache/ 列入 .gitignore,CI 用 artifact 傳遞。

本日總結 (Takeaways)

  • 刮取範圍限於你擁有或已獲許可的來源,並遵守 robots.txt
  • 靜態頁:BeautifulSoup 處理本機或快取 HTML;動態頁:Playwright / Puppeteer 只做高層次渲染,不提供繞過防護的作法。
  • 抓取、解析、LLM 抽 JSON 分成三步;模型不得改 fetcher。
  • HTML 快取後再解析,CI 用 fixture,避免把外網當成單元測試。
  • LLM 輸出必須符合 schema,禁止臆造 SKU 或庫存。

明日預告 (Next)

明天進入資安防護工作流:說明 Prompt Injection 的攻擊樣態與 LLM 防禦策略,重點放在輸入隔離、工具白名單與不可讓模型執行未審查指令。


上一篇
Day 19: 大規模資料處理工作流:使用 Python/Codex 自動化 ETL 與 Data Migration
系列文
ChatGPT + Codex 打造高效能 AI 開發工作流20
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言