2026年8月31日 星期一

Python一下:用Beautiful Soup整理地方活動網頁——負責任的HTML擷取

《Python一下:從風土資料到智慧生活》第 24 篇

用Beautiful Soup整理地方活動網頁——負責任的HTML擷取

先用自建教學頁學會解析,再把robots、條款、頻率、個資與著作權放進真正的擷取流程。

CH12 HTMLBeautiful Soup負責任擷取
學習目標
完成本篇後,你能使用Beautiful Soup解析HTML,透過標籤、屬性與CSS選擇器擷取文字和連結,處理缺漏節點與相對網址,建立結構驗證及離線測試,並在擷取前檢查robots.txt、使用條款、授權與網站負載。

一、API、HTML與瀏覽器畫面不同

若網站提供正式API或資料下載,應優先使用;HTML主要為人閱讀,版面一改,選擇器就可能失效。有些頁面內容由JavaScript執行後才出現,requests取得的初始HTML未必包含瀏覽器畫面上的全部內容。

來源優點限制
官方API/下載檔欄位較明確、適合程式仍可能改版或限制頻率
靜態HTMLBeautiful Soup容易解析版面變動會影響選擇器
JavaScript動態內容互動豐富初始HTML可能沒有資料
負責任擷取原則:公開可瀏覽不等於可任意大量蒐集或再利用。先確認網站條款、robots.txt、資料授權與個資風險;使用可識別的User-Agent、低頻率、快取與有限重試。robots.txt是自動客戶端應尊重的規則,但不是存取授權,也不取代著作權、個資與契約判斷。

二、安裝Beautiful Soup

python -m pip install beautifulsoup4 requests

Beautiful Soup套件名稱是 beautifulsoup4,程式匯入名稱則是 bs4。本篇使用Python內建的 html.parser,減少額外環境需求。

三、先用自建HTML練習

from bs4 import BeautifulSoup

html_text = """
<section id="events">
  <article class="event-card" data-id="E001">
    <h2 class="title">水井三寶導覽體驗</h2>
    <time datetime="2026-10-13">2026年10月13日</time>
    <p class="place">教學示範場域</p>
    <a class="detail" href="/events/E001">活動資訊</a>
  </article>
</section>
"""

soup = BeautifulSoup(html_text, "html.parser")
print(soup.title)
print(soup.find("h2").get_text(strip=True))

這是自建教學資料,不代表真實活動公告。先在固定HTML上開發與測試,避免學習階段反覆請求外部網站。

四、find與find_all

first_card = soup.find("article", class_="event-card")
all_cards = soup.find_all("article", class_="event-card")

print(first_card.get("data-id"))
print("活動數:", len(all_cards))

Python的 class 是保留字,所以依CSS class搜尋時使用 class_。屬性不存在時,tag.get("name") 回傳 None,比直接索引更適合可缺欄資料。

五、CSS選擇器

Beautiful Soup官方文件說明,可使用CSS選擇器搜尋解析樹。select_one() 取第一個結果,select() 取全部。

card = soup.select_one("#events .event-card")
title = card.select_one(".title")
date_tag = card.select_one("time[datetime]")
detail = card.select_one("a.detail[href]")

print(title.get_text(" ", strip=True))
print(date_tag.get("datetime"))
print(detail.get("href"))

選擇器應鎖定有語意且相對穩定的id、class或 data-* 屬性;避免依賴「第3個div裡第2個span」等脆弱位置。

六、把卡片轉成字典

def required_text(card, selector, field_name):
    tag = card.select_one(selector)
    if tag is None:
        raise ValueError(f"缺少欄位:{field_name}")
    text = tag.get_text(" ", strip=True)
    if not text:
        raise ValueError(f"欄位空白:{field_name}")
    return text


def parse_event_card(card):
    event_id = (card.get("data-id") or "").strip()
    date_tag = card.select_one("time[datetime]")
    link_tag = card.select_one("a.detail[href]")

    if not event_id:
        raise ValueError("缺少活動id")
    if date_tag is None or link_tag is None:
        raise ValueError("缺少日期或詳情連結")

    return {
        "id": event_id,
        "title": required_text(card, ".title", "title"),
        "date": date_tag.get("datetime", "").strip(),
        "place": required_text(card, ".place", "place"),
        "href": link_tag.get("href", "").strip(),
    }

七、把相對網址轉成完整網址

from urllib.parse import urljoin, urlparse


def normalize_link(base_url, href):
    absolute = urljoin(base_url, href)
    parsed = urlparse(absolute)
    if parsed.scheme != "https":
        raise ValueError("只接受HTTPS連結")
    if parsed.hostname != "example.edu":
        raise ValueError("連結超出允許網域")
    return absolute


print(normalize_link(
    "https://example.edu/activities/",
    "/events/E001",
))

範例網域只供教學。若擷取結果含連結,應限制允許的協定與網域;不要直接把不可信連結交給後續下載程式。

八、批次解析與錯誤隔離

def parse_event_page(html_text, base_url):
    soup = BeautifulSoup(html_text, "html.parser")
    records = []
    errors = []
    used_ids = set()

    for index, card in enumerate(
        soup.select("#events article.event-card"),
        start=1,
    ):
        try:
            record = parse_event_card(card)
            if record["id"] in used_ids:
                raise ValueError(f"重複id:{record['id']}")
            record["url"] = normalize_link(
                base_url, record.pop("href")
            )
            used_ids.add(record["id"])
            records.append(record)
        except ValueError as error:
            errors.append({"序號": index, "原因": str(error)})

    return records, errors

九、先檢查robots.txt

Python標準函式庫 urllib.robotparser 可讀取robots.txt並詢問特定User-Agent是否可擷取網址。

from urllib.parse import urljoin
from urllib.robotparser import RobotFileParser


def robots_allows(url, user_agent):
    robots_url = urljoin(url, "/robots.txt")
    parser = RobotFileParser()
    parser.set_url(robots_url)
    parser.read()
    return parser.can_fetch(user_agent, url)


# 真實使用前,另確認網站條款與資料授權
# allowed = robots_allows(
#     "https://example.edu/activities/",
#     "NFU-Python-USR-Education/1.0",
# )

robots.txt的錯誤回應與快取有標準化處理細節;正式爬蟲應依RFC 9309實作或使用成熟工具。即使允許擷取,也不代表取得內容再出版、商用或訓練AI的權利。

十、負責任地下載一個頁面

import requests


def fetch_html(url):
    headers = {
        "User-Agent": "NFU-Python-USR-Education/1.0"
    }
    response = requests.get(
        url,
        headers=headers,
        timeout=(3.05, 15),
    )
    response.raise_for_status()

    content_type = response.headers.get(
        "Content-Type", ""
    ).lower()
    if "text/html" not in content_type:
        raise ValueError("回應不是HTML")
    if len(response.content) > 2_000_000:
        raise ValueError("頁面超過教學程式允許大小")

    return response.text
不要在迴圈中無節制請求:抓取多頁前先取得網站許可與規則,加入間隔、快取、最大頁數、有限重試及停止機制。遇到429或Retry-After要尊重服務端要求。

十一、先保存快照,再離線解析

from datetime import datetime, timezone
from pathlib import Path


def save_html_snapshot(folder, html_text):
    timestamp = datetime.now(timezone.utc).strftime(
        "%Y%m%dT%H%M%SZ"
    )
    path = Path(folder) / f"activity_{timestamp}.html"
    path.parent.mkdir(parents=True, exist_ok=True)
    with path.open("x", encoding="utf-8") as file:
        file.write(html_text)
    return path

保存快照可減少重複請求,也能重現當時的解析結果。快照若含個資或受著作權保護內容,必須限制保存範圍、期限與存取權限。

十二、HTML改版要被測試發現

def validate_page_structure(html_text):
    soup = BeautifulSoup(html_text, "html.parser")
    container = soup.select_one("#events")
    cards = soup.select("#events article.event-card")

    if container is None:
        raise ValueError("找不到活動容器,網頁可能改版")
    if not cards:
        raise ValueError("找不到活動卡片,請人工檢查")
    return len(cards)

不要把「擷取到0筆」自動解讀成「今天沒有活動」。這也可能是網站改版、權限變化或動態內容造成。

十三、動態網頁怎麼辦?

先使用瀏覽器開發工具確認資料是否來自官方JSON API;若有,依規範使用API。若網站明確禁止自動化或需登入,不要繞過。只有在取得許可且確有必要時,才考慮瀏覽器自動化;不要破解驗證碼、存取控制或反爬蟲機制。

十四、不要大量重製原文

擷取活動標題、日期、地點與來源連結,通常比複製整篇文章更符合資料最小化。摘要與再利用仍須依網站授權及著作權判斷;人物照片、電話、報名名單與訪談內容更需要個資及同意檢查。

十五、常見錯誤檢查表

現象原因修正
select_one回傳None選擇器錯誤或網頁改版先檢查節點,不直接.get_text()
瀏覽器看得到,requests卻沒有內容由JavaScript載入尋找官方API或取得許可後調整方案
相對連結無法開啟未與基底網址合併使用urljoin並驗證網域
網站回傳429請求過於頻繁停止、尊重Retry-After並降低頻率
擷取0筆就說沒有活動忽略結構變動結構驗證失敗時人工檢查

十六、USR實作挑戰

挑戰A|離線活動頁
自行建立含三張活動卡的HTML,其中一張缺日期、一張重複ID,輸出有效清單與錯誤報告。
挑戰B|擷取前檢核表
為預計使用的公開網站記錄API、robots.txt、條款、授權、個資、頻率、快取、停止條件與聯絡方式。
挑戰C|改版測試
修改教學HTML的class名稱,確認測試會明確失敗,而不是輸出空資料假裝成功。

十七、與生成式AI協作

請擔任Beautiful Soup與負責任網頁擷取助教。
請檢查我的地方活動擷取程式:
1. 有官方API時優先使用API;
2. 擷取前檢查robots.txt、條款與授權;
3. 使用可識別User-Agent、timeout、快取及低頻率;
4. 驗證Content-Type、大小與HTML結構;
5. 節點缺漏不可直接當成沒有活動;
6. 相對連結須urljoin並限制HTTPS允許網域;
7. 不蒐集不必要個資,不大量重製原文;
8. 不繞過登入、驗證碼、存取控制或反爬機制。
請先列風險,再提供離線測試HTML與測試案例。
本篇小結
Beautiful Soup能把HTML轉成可搜尋的樹,但負責任的擷取還包括來源選擇、網站規則、頻率、資料最小化、授權與改版偵測。技術上抓得到,不等於應該抓、可以公開。

沒有留言:

張貼留言