第 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 与礼貌抓取 →