第 36 課 · 實戰 · 19 分鐘

Python 爬蟲 HTTP 請求

先構造一個禮貌的請求,再解析第一個標籤。

HTTPRequest請求頭狀態碼

供應商的報價頁只差一個禮貌的請求,可上一個實習生因為猛刷被封了。請求需要名字、超時和理由。解析之前,你該先檢查什麼?

爬蟲從請求開始,而不是從正規開始。請求說明你要存取哪個 URL、以什麼身份存取,以及客戶端願意等待多久。本課先構造請求物件,再解釋解析之前必須處理的失敗情況。 你還會編碼查詢參數、檢查 HTTPError,並限制重試。

請求不只有 URL

urllib.request.Request 儲存 URL 和可選請求頭。描述清楚的 User-Agent 告訴伺服器是誰在存取。構造請求不會真的發送;在普通 Python 環境裡,urlopen 才執行網路呼叫。

from urllib.request import Request

request = Request(
    "https://example.com/docs",
    headers={"User-Agent": "PyPracticeBot/1.0"},
)
print(request.full_url)
print(request.get_header("User-agent"))

先構造,再發送

為慢伺服器和故障做準備

網路會失敗,頁面會遷移。超時限制等待時間,狀態碼告訴你回應是否可用。2xx 視為成功,3xx 需要處理重定向,4xx 或 5xx 需要判斷,而不是盲目重試。

status = 404
if 200 <= status < 300:
    result = "ok"
elif 300 <= status < 400:
    result = "redirect"
else:
    result = "error"
print(result)

按狀態碼分支

把抓取和解析分開

把解析寫成接收 HTML 文字的函式。這樣可以用儲存的網頁樣本測試,不需要聯網。這個邊界也能清楚劃分限速和快取應放在哪裡。

from html.parser import HTMLParser

class Links(HTMLParser):
    def __init__(self):
        super().__init__()
        self.links = []

    def handle_starttag(self, tag, attrs):
        if tag == "a":
            href = dict(attrs).get("href")
            if href:
                self.links.append(href)

def parse_links(html):
    parser = Links()
    parser.feed(html)
    return parser.links

saved = '<a href="/docs">Docs</a><a href="/about">About</a>'
print(parse_links(saved))

可以離線測試的解析器

先編碼查詢參數,再構造請求

urlencode 會跳脫空格、& 和非 ASCII 文字。用編碼後的片段拼 URL,不要直接拼接原始使用者輸入。清楚的 User-Agent 和 Accept 頭也能讓請求更容易識別和偵錯。

from urllib.parse import urlencode
from urllib.request import Request

query = urlencode({"q": "green tea", "page": 2})
url = "https://example.com/search?" + query
request = Request(url, headers={"User-Agent": "PyPracticeGift/1.0"})
print(request.full_url)
print(request.get_header("User-agent"))

由編碼片段組成的 URL

把 HTTPError 當成回應來讀

urllib 遇到 4xx/5xx 會拋出 HTTPError,但例外裡仍有狀態碼、回應頭和可讀回應體。錯誤頁裡有有用資訊時可以捕獲並讀取;不要捕獲所有網路例外然後假裝請求成功。

from io import BytesIO
from urllib.error import HTTPError

error = HTTPError(
    "https://example.com/missing",
    404,
    "Not Found",
    {"Content-Type": "text/plain"},
    BytesIO(b"no such page"),
)
print(error.code, error.reason)
print(error.read().decode("utf-8"))

失敗回應體也是資料

限制重試並尊重 Retry-After

只重試可能暫時的錯誤,例如 429 和 503。Retry-After 可能是秒數,也可能是 HTTP 日期;要給它設上限,別讓伺服器讓你的任務睡幾個小時。重試次數也必須封頂。

def retry_delay(status, retry_after, cap=300):
    if status not in {429, 503}:
        return None
    try:
        seconds = int(retry_after)
    except (TypeError, ValueError):
        return 60
    return max(1, min(seconds, cap))

for status, header in [(200, None), (429, "120"), (503, "soon")]:
    print(status, retry_delay(status, header))

有上限的等待,不是無限睡眠

本課要點

  • Request 物件攜帶 URL 和請求頭,包括描述清楚的 User-Agent。
  • 設定超時,避免卡住的連接讓程式一直等待。
  • 認真處理狀態碼,不要假設每個回應都是 HTML。
  • 用儲存的 HTML 樣本測試解析器,而不是直接打真實網站。
  • 通過快取和限速減少不必要的請求。
  • 查詢參數要編碼,HTTPError 要主動讀,Retry-After 和重試次數都要封頂。

常見錯誤

  • 在迴圈裡發請求,卻沒有延遲、超時或停止條件。
  • 偽裝成瀏覽器來繞過存取控制或站點條款。
  • 還沒確認請求成功,就開始解析回應。
  • 連永久性的 404 也重試,給已經吃力的伺服器繼續加負載。
輪到你

為一個文件頁構造帶機器人 User-Agent 的 Request,在不發送的情況下列印 URL 和請求頭。

動手試試

改改變數、先預測輸出,再執行程式碼。第一次點擊「執行程式碼」時會下載 Python 執行時,可能需要幾秒鐘。

瀏覽器內 Python本機執行 · 無需帳號
首次執行會從 CDN 下載 Pyodide。修改後點擊「執行程式碼」。

練習本課

12 道題,作答後立即看到解釋。

查看 12 道題答案開始練習
← 日誌HTML 解析 →