第 39 課 · 實戰 · 19 分鐘

Python 分頁與去重

抓取就是一個帶記憶和預算的佇列。

dequevisited 集合廣度優先分頁

商品目錄有四十頁,第十二頁又鏈回第三頁。沒有 visited 集合的爬蟲會原地繞圈。什麼該進佇列,什麼該進集合?

分頁會把一個頁面變成很多頁面,連結還可能形成環。抓取需要佇列儲存下一步,需要 visited 集合記錄已經存取過的位址,還需要預算決定何時停止。這些都是普通資料結構,不是爬蟲魔法。 你還會在下一頁重複時停止,並把分頁限制在同一來源。

用佇列儲存待存取位址

deque 是高效先進先出佇列:append 加到右端,popleft 從左端取出。這個順序是廣度優先,爬蟲會先完成近處頁面再深入。列表也能用,但 popleft 正是 deque 擅長的操作。

from collections import deque

queue = deque(["/page/1", "/page/2"])
print(queue.popleft())
print(list(queue))

先進先出

記住存取過什麼

集合的成員判斷很快。從佇列取出 URL 後加入集合,如果已經存在就跳過。沒有這份記憶,兩個互相連結的頁面就能讓爬蟲一直忙下去。

seen = set()
for url in ["/a", "/a", "/b"]:
    if url in seen:
        continue
    seen.add(url)
print(len(seen))

每個位址只存取一次

給抓取設定邊界

即使佇列正確,也可能執行太久。設定最大頁面數、最大深度或總時間預算。佇列為空或預算用完時停止。記錄停止原因,避免把結果誤認為完整覆蓋。

pages = {"/1": ["/2"], "/2": ["/3"], "/3": []}
queue = ["/1"]
seen = []
while queue and len(seen) < 2:
    url = queue.pop(0)
    if url in seen:
        continue
    seen.append(url)
    queue.extend(pages.get(url, []))
print(seen)

明確的小預算

先補全,再比較

同一個目的地可能寫成 /docs、../docs 和 https://example.com/docs。用 urljoin 補全相對連結,去掉片段,再檢查 visited 集合。統一規範化,否則集合裡會堆滿重複位址。

from urllib.parse import urljoin, urlsplit, urlunsplit

base = "https://example.com/docs/page.html"
raw = "../guide.html#top"
absolute = urljoin(base, raw)
parts = urlsplit(absolute)
clean = urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, ""))
print(clean)

補全、去片段、再比較

下一頁重複時停止

站點忽略非法頁碼或永遠重複最後一頁時,分頁很容易成環。規範化每個下一頁 URL 並維護 seen 集合;發現已經在集合裡就停止,另外再加一個硬性的頁數預算。

from urllib.parse import urljoin, urlsplit, urlunsplit

def key(url):
    parts = urlsplit(url)
    return urlunsplit((parts.scheme, parts.netloc, parts.path, parts.query, ""))

start = "https://example.com/list"
links = ["/page/1", "/page/2", "/page/1"]
seen = set()
order = []
for link in links:
    url = urljoin(start, link)
    marker = key(url)
    if marker in seen:
        print("loop at", url)
        break
    seen.add(marker)
    order.append(url)
print(len(order), order)

seen 集合終止迴圈

讓分頁留在同一來源

下一頁連結可能指向另一臺主機,尤其是遇到開放重定向時。加入待抓取佇列前,比較協議、主機和埠。這是範圍決策,不是說其他主機一定惡意;爬蟲應停在站點所有者定義的邊界內。

from urllib.parse import urljoin, urlsplit

base = "https://example.com/list?page=1"
base_parts = urlsplit(base)
for link in [
    "/list?page=2",
    "https://evil.example/list?page=3",
    "//example.com/list?page=4",
]:
    target = urljoin(base, link)
    parts = urlsplit(target)
    same = (parts.scheme, parts.hostname, parts.port) == (
        base_parts.scheme,
        base_parts.hostname,
        base_parts.port,
    )
    print(link, same, parts.netloc)

有邊界的前沿佇列

本課要點

  • deque 為抓取邊界提供高效的先進先出行為。
  • visited 集合防止環和重複頁面。
  • 最大頁數、深度和時間都是明確的停止條件。
  • 連結加入佇列前先補全相對位址。
  • 比較 URL 是否重複前去掉片段。
  • 規範化下一頁連結後去重;入隊前比較來源。

常見錯誤

  • 不檢查是否存取過,就把連結加入佇列。
  • 使用沒有頁面數或時間預算的無限 while 迴圈。
  • 把相對位址和絕對位址當成同一個字串比較。
  • 沒有頁數預算就跟著下一頁跑,並信任每個重定向目標。
輪到你

用 deque 和 visited 集合走訪一個小型頁面圖,存取三頁後停止,並列印存取順序。

動手試試

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

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

練習本課

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

查看 12 道題答案開始練習
← URL 解析robots 與禮貌抓取 →