广度优先爬取与深度优先爬取

简而言之:广度优先爬取会先访问同一链接层级的每个页面,然后再深入下一层。深度优先爬取则沿一条路径尽可能深入,之后再回溯。选择哪种策略,决定了爬虫会先到达哪些页面,以及提前停止时能够覆盖哪些内容。

这两种策略描述了爬虫从其 URL 待爬取队列中取出 URL 的顺序。两者在机制上的差异很小:广度优先使用 FIFO 队列,深度优先使用 LIFO 栈。但它们对覆盖范围、内存占用和礼貌爬取的实际影响非常显著。

各自的遍历方式:

假设从一个链接到页面 A、B 和 C 的种子 URL 开始,其中 A 又链接到 A1 和 A2:

广度优先顺序:种子、A、B、C、A1、A2、B1、B2、C1、C2。深度 1 的所有页面全部处理完毕后,才会访问任何深度 2 的页面。

深度优先顺序:种子、A、A1、A1a、A1b、A2、B、B1。爬虫会沿着 A 尽可能深入,然后才会查看 B。

直接比较:

属性 广度优先 深度优先
队列结构 FIFO LIFO
内存占用 高,需要保存整个层级 低,只需保存一条路径
提前停止时的覆盖范围 完整覆盖较浅层级 深入一条分支,其余分支未访问
主机分布 分散在多个主机之间 集中在单个主机
礼貌性 良好,请求自然分散 较差,会密集请求单个主机
陷阱风险 较低,陷阱 URL 会与其他 URL 竞争 较高,可能无限深入
到达重要页面 较早,因为重要页面通常位于较浅层级 不可预测

实现:

from collections import deque

def crawl(seed, max_depth, strategy="bfs"):
    queue = deque([(seed, 0)])
    seen = {seed}

    while queue:
        url, depth = queue.popleft() if strategy == "bfs" else queue.pop()

        if depth >= max_depth:
            continue

        for link in extract_links(fetch(url), url):
            if link not in seen:
                seen.add(link)
                queue.append((link, depth + 1))

两种策略仅有一个方法调用的差异。使用 popleft 会取出队列中最早加入的 URL,从而产生广度优先顺序。使用 pop 会取出最近加入的 URL,从而产生深度优先顺序。

为什么广度优先占主导地位:

几乎所有生产环境中的网页爬虫都会使用广度优先遍历或其优先级变体,原因包括以下几个相互强化的方面。

重要页面通常位于较浅层级。网站会从首页和主导航链接到最有价值的内容。广度优先可以立即到达这些内容,而深度优先可能会在归档页面中发出数千次请求后,才访问主要分类页面。

部分爬取结果仍然有用。广度优先爬取无论在哪个时间点停止,都能完整覆盖到某个深度。深度优先爬取若提前停止,则只会深入探索一条分支,而其他分支一无所获,这样的数据集通常无法使用。

天然具备礼貌性。由于一个层级通常横跨多个主机,连续请求往往会发送到不同的服务器,因此无需额外调度即可实现礼貌爬取。深度优先则恰恰相反,会将请求集中到单个主机。

陷阱得到控制。如果日历不断生成无限页面,深度优先爬虫会永远陷在其中。在广度优先策略下,这些 URL 会与同一层级的其他 URL 一起排队,只消耗其应占的抓取预算。

深度优先适用的场景:

深度优先只在少数特定情况下是更好的选择,通常适用于预先知道目标结构的场景。

提取单个文档章节、沿分页结果等线性链路访问,或在内存限制严格的环境中运行,都更适合深度优先。它在内存占用方面确实更有优势:对大型网站执行广度优先爬取时,单个层级可能需要保存数百万个 URL,而深度优先只需保存当前路径及其同级节点。

对于一般的数据采集任务,这种内存优势很少能够抵消其在覆盖范围和礼貌性方面的代价。

生产环境中的爬虫实际如何运行:

大规模爬虫工具很少以纯粹形式实现其中任一种策略。它们使用优先队列,并以广度优先作为默认排序方式,同时根据页面重要性、更新频率、域名配额和商业价值等信号进行调整。

最终效果类似于带有例外情况的广度优先:在深度 4 发现的高价值产品页面,可能会比深度 2 的低价值标签页面更早抓取。但系统仍会坚持逐层推进的基本原则,正是这一点让覆盖范围保持可预测,并使爬取深度限制具有实际意义。

准备好开始了吗?