商品目录有四十页,第十二页又链回第三页。没有 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 运行时,可能需要几秒钟。
练习本课
12 道题,作答后立即看到解释。