我们的立场:我们是 Geonode,主要销售代理服务——这些代理是爬虫工作的必要资源,而非列表管理工具。 坦率地说,管理不善的爬取列表所造成的损失,远比选择不当的代理方案要大得多。 没有进行 URL 规范化的爬虫,会因查询参数顺序的不同而多次访问同一页面,而在按每千兆字节计费的模式下,您必须为每次访问付费。 没有预算限制的爬虫会无休止地按照日历安排运行,并向您收取相应费用。修正爬取列表是免费的;但在收到账单后才修正则要付出代价。相关内容见下文,建议您优先阅读这一部分。
爬取列表究竟是什么
有三点常被混淆。
种子列表 —— 起点。可能是几个 URL,也可能是完整的站点地图。
前沿集 —— 已发现并排入队列但尚未抓取的 URL。这是实际运行的数据结构,其中包含所有设计决策。
已访问集 —— 已抓取的 URL,保留这些 URL 以避免重复抓取。
其生命周期是一个循环:从前沿中取出一个 URL,抓取该页面,提取链接,对链接进行规范化处理,剔除已存在于已访问集或已排入队列的链接,将剩余链接添加到前沿,并标记该 URL 为已访问。重复此过程,直到前沿清空或预算耗尽。
整体框架看似简单。但每个步骤都存在细节,一旦处理不当,可能会让你耗费一整天的时间。
种子数据:首批 URL 的来源
按节省的工作量从多到少排序。
网站地图。 这是最好的种子数据来源,因为它是网站自身的目录,并包含 lastmod 时间戳,可告知您哪些内容发生了变化。通过 robots.txt 中的 Sitemap: 指令查找它,并递归遍历网站地图索引文件。
**合作伙伴数据源或 API。**如果存在此类资源,您可能完全不需要进行爬取。
**网络档案库。**互联网档案馆(Internet Archive)的 CDX API 会返回某个域名的历史 URL,包括那些已不再被任何地方链接的页面。这不会给目标网站带来任何成本,且能发现爬取无法找到的“孤儿页面”。
搜索运算符。 site: 查询可显示已收录的页面,更有用的是,还能发现您此前未知的子域名。
分类和索引页面。 对于定向爬取,从您关心的特定列表页面开始爬取,远比从首页开始并寄希望于随机发现要高效得多。
首页。 这是最后的手段,也是人们首先会采用的默认方法。从一个 URL 开始,通过链接遍历来发现所有内容,这是覆盖率最差且速度最慢的途径。
我们在 如何查找网站上的所有页面 中详细探讨了完整的源文件层次结构。简而言之:一份优质的种子列表能将爬取转变为快速抓取。
URL 规范化:人人都忽略的步骤
这是爬虫中最具价值的工程环节,却也是最常被忽略的。
如果没有这一步,对你的已访问集合而言,这将有五个不同的 URL,而对服务器来说则只有一个页面:
http://Example.com/Products
http://example.com/products
http://example.com/products/
http://example.com:80/products
http://example.com/products?utm_source=email
RFC 3986 定义了始终安全的规范化规则。
大小写规范化。 “方案和主机不区分大小写,因此应规范化为小写。例如,URI <HTTP://www.EXAMPLE.com/>
等同于 <http://www.example.com/>."
。请注意限制:‘除非方案另有明确规定,否则其他通用语法组件被视为区分大小写。’” 路径区分大小写——请勿将其转换为小写。
此外:“百分比编码三元组中的十六进制数字(例如,%3a
与 %3A
)不区分大小写,因此应规范化为大写字母”。
百分比编码规范化。 RFC 将此称为“在其他方面完全相同的 URI 之间产生差异的常见原因”,因为“某些 URI 生成者会对不需要百分比编码的八位字节进行百分比编码”。这些“应通过解码任何对应于未保留字符的百分比编码八位字节来进行规范化”。
路径段规范化。 通过应用remove_dot_segments
算法移除.
和..
段,因为“某些已部署的实现错误地认为,当引用本身已经是URI时,无需进行引用解析”。
基于方案的规范化。 RFC给出了一个典型的示例——以下四种形式是等价的:
http://example.com
http://example.com/
http://example.com:/
http://example.com:80/
因此,空路径“应规范化为 /
”,而默认或空端口“应通过基于方案的规范化予以移除”。
除规范要求外,以下三种规范化方式虽非严格安全,但具有实用价值,值得谨慎应用:
去除跟踪参数。 utm_*
、fbclid
、gclid
、 会话标识符。这些参数几乎不会改变内容,却会使 URL 数量激增。建议维护一份列表,而非凭空猜测。
对剩余的查询参数进行排序。 ?a=1&b=2
和 ?b=2&a=1
通常指向同一页面。通常如此——但某些应用程序对顺序敏感,因此需通过样本进行测试。
移除片段。 #section
属于客户端操作,永远不会到达服务器。出于抓取目的,删除它总是安全的。
**并尊重 rel="canonical"
。** 如果一个页面声明了规范 URL,这意味着网站正在告诉你,在多个地址中哪个才是真正的地址。尊重它相当于免费去重,并且背后有网站自身的权威性作为支撑。
前沿:队列设计
有三项特性决定了你的爬虫能否实现可扩展性。
去重操作必须成本低廉。 在添加一个 URL 之前,你需要检查它是否已被收录。当 URL 数量达到一百万时,线性扫描便无法使用。 哈希集在一定程度上可行;但超过一定规模后,布隆过滤器能在占用极少内存的情况下提供常数时间成员检查,且误报率较低——这意味着你偶尔会跳过一个未曾访问过的 URL。对于大多数爬取任务而言,这种权衡是可以接受的;而在完整性至关重要的情况下,应在过滤器后端配备精确存储。
顺序必须可控。 普通的 FIFO 队列会进行广度优先遍历,这通常正是你所需要的——它能尽早实现广泛覆盖,同时保持浅层遍历。LIFO 栈则进行深度优先遍历,会深入某个分支,但这对于网站爬取很少有用。 优先级队列允许你按任意标准排序,相关内容将在下一节中详细说明。
必须跟踪每个主机的状态。 实际上,爬行前沿并非由单一队列构成,而是每个主机对应一个队列,这样礼貌限制才能独立应用于每个队列。如果使用一个带有全局速率限制的全局队列,那么一个大型网站就会导致其他所有网站资源枯竭。
能够实现可扩展性的结构是一组按主机划分的队列,加上一名调度器,该调度器负责挑选下一个符合条件的、可从其获取数据的主机——所谓“符合条件”,是指自上次向该主机发送请求以来已过去足够长的时间。
优先级排序
当无法抓取所有 URL 时,应优先抓取哪个 URL。
按深度排序。 深度较浅的页面通常更重要。这是一个简单而有效的默认规则。
按路径模式。 若要抓取产品页面,应优先处理与 /product/ 匹配的 URL。对于定向抓取而言,这是回报最高的启发式方法,且计算成本极低。
按 lastmod。 来自网站地图。抓取发生变更的内容。
按变更历史。 对于定期爬取,过去变更频繁的页面未来通常仍会频繁变更。
按入站链接数量。 从许多地方被链接的页面通常更重要。在爬取过程中计算成本较高,但对于大型任务来说是值得的。
按预估价值。 无论您的实际目标是什么。如果您需要价格信息,请优先处理可能包含价格的页面。
实际的实现方案是在入队时,根据上述几个信号计算出一个较小的整数分数,并将其用作优先队列的键。复杂的方案往往难以证明其复杂性是合理的;深度加上路径模式加分通常就能满足大多数需求。
边界控制:预算与陷阱
如果没有限制,某些爬取操作将永无止境。这并非特例。
最大爬取深度。 链接中的链接中的链接。将爬取深度限制在五到六层,几乎可以覆盖任何真实的网站结构。
每个主机最大页面数。 这是一个硬性数值。达到该数值时,应停止并报告,而非继续爬取。
总页面数上限。 针对整个任务。
最大带宽。 特别是在按流量计费的代理服务中,无限制的爬取意味着无限制的账单。
模式排除。 日历是典型的无限空间——next month
链接会无限生成 URL。大型目录中的多维导航会产生组合爆炸。通过以下模式排除这些情况:
/calendar/
/?filter=
/*?sort=
重复内容检测。 对正文进行哈希处理。如果一百个 URL 返回完全相同的内容,说明你发现的其实是一个生成空间,而非一百个独立页面。
并关注发现率。 最有用的单一警报指标:追踪每获取一页所发现的新 URL 数量。 在规模有限的网站上,该比率会稳步趋近于零。如果该比率保持平稳或上升,则说明有某种机制正在以超过你处理速度的速度生成 URL——可能是陷阱、日历功能,或是分面导航引发的组合爆炸。关于蓄意设置的版本,我们已在 蜜罐陷阱 中进行过讨论。
重新抓取调度
对于需要运行多次的任务,该列表即成为一个调度计划。
按内容变动频率分级。 每小时都会更新的页面需要每小时检查一次;而一年只更新一次的页面则无需如此。按照变动最频繁的项目所需的频率抓取所有内容,是导致资源超支的最常见原因。
使用条件请求。 If-Modified-Since 和 If-None-Match 会将重新抓取转化为一系列 304 Not Modified 响应,每次响应仅消耗几百字节。在大部分页面未发生变化的重新抓取中,这能使您的费用和目标服务器的负载都减少一个数量级。
根据观察调整。 如果一个页面在十次检查中均未发生变化,则减少检查频率;如果最近三次检查中有变化,则增加检查频率。简单的乘法退避算法就足够了。
明确检测页面删除。 页面会消失,而网站很少会主动通知。要么监听 404 错误,要么采用策略——连续三次抓取中未出现的 URL 被标记为非活动状态。如果不这样做,您的数据集将充斥着已不存在的条目,这比缺失条目更快速地侵蚀数据的可信度。
存储与扩展
关于内存中处理方法何时失效的说明。
在URL数量不超过约十万个的情况下,Python的集合和列表完全够用。不要过度设计。
在几百万以内,可以使用本地数据库(SQLite 效果很好),并在 URL 哈希值和抓取状态上建立索引。数据持久化还意味着爬虫崩溃后可以继续运行而非从头开始,这一点比性能更重要。
超过该数量时,应采用规范的队列和键值存储,并将边界按主机进行分区,以便将整个主机分配给工作节点,从而在无需协调的情况下保持礼让机制的正确性。
以下两项设计决策在任何规模下都能带来回报:
存储 URL 哈希值,而不仅仅是 URL 本身。 对固定长度哈希的比较和索引操作开销更低,且占用存储空间更小。
**存储原始响应,而不仅仅是解析后的结果。**当解析器出现故障时(这在所难免),重新解析现有数据是零成本的,而重新抓取则会消耗带宽并损害用户好感。
一个最简实现
整个实现大约四十行代码,以便将各个组成部分具体化。
import time
from collections import deque, defaultdict
from urllib.parse import urlsplit, urlunsplit, parse_qsl, urlencode
TRACKING = {"utm_source", "utm_medium", "utm_campaign", "fbclid", "gclid"}
def normalise(url):
p = urlsplit(url)
host = p.hostname or ""
port = "" if p.port in (None, 80, 443) else f":{p.port}"
query = urlencode(sorted(
(k, v) for k, v in parse_qsl(p.query, keep_blank_values=True)
if k.lower() not in TRACKING
))
return urlunsplit((p.scheme.lower(), host + port, p.path or "/", query, ""))
class Frontier:
def __init__(self, delay=1.5, max_per_host=5000):
self.queues = defaultdict(deque)
self.seen = set()
self.next_ok = defaultdict(float)
self.counts = defaultdict(int)
self.delay, self.max_per_host = delay, max_per_host
def add(self, url, depth=0):
url = normalise(url)
if url in self.seen or depth > 5:
return False
host = urlsplit(url).hostname
if self.counts[host] >= self.max_per_host:
return False
self.seen.add(url)
self.counts[host] += 1
self.queues[host].append((url, depth))
return True
def next(self):
now = time.monotonic()
for host, q in self.queues.items():
if q and self.next_ok[host] <= now:
self.next_ok[host] = now + self.delay
return q.popleft()
return None
其中可见五项设计决策,每项都对应上文的一个部分。
规范化在 add() 处进行,而非在抓取时进行。 只有当规范形式被加入已访问集合时,去重操作才正确,因此若在后期进行规范化,则意味着此时已存储了重复项。
已访问集合是在入队时填充的,而非在任务完成时。 否则,一个在二十个页面上发现的 URL 会在首次抓取完成前被排队二十次。
队列按主机划分,礼貌策略也按主机执行。 next_ok 记录了每个主机下次可被联系的时间,因此一个大型网站不会挤占其他网站的资源,延迟也会在应有的地方生效。
预算限制在进入队列时即被强制执行。 深度限制和按主机设置的上限会在 URL 消耗内存之前将其拒绝,这正是受限爬取与因耗尽 RAM 而发现自身极限的爬取之间的区别。
next() 会返回 None 而不是阻塞。 这使调用方可以自由决定是等待、处理其他任务还是结束操作——如果调度器在数据结构内部进入休眠状态,你就无法对其进行监控。
该设计刻意省略了持久化功能,而对于任何实际应用而言,这是首要需要添加的功能。一个崩溃后必须从种子列表重新开始的爬虫,损失的不仅仅是时间;它还需重新抓取所有内容,这会消耗带宽并损害用户好感。
对列表进行监控
需要测量哪些指标?因为仅报告“已获取页面”数量的爬取结果几乎无法提供任何有价值的信息。
随时间变化的前沿规模。 该数值应趋近于零。若持续上升,则表明发现过程无止境。
发现率。 每获取一个页面所发现的新 URL 数量,如上所述。
按主机分类的状态检索结果。 汇总数据会掩盖某个主机完全失败的情况。
重复率。 已发现的 URL 中有多少是已知的。标准化处理后重复率仍高,说明你的标准化过程遗漏了某些内容。
每页有效数据字节数。 这个指标将爬取过程与账单关联起来,也能揭示无头浏览器是否抓取了你并不需要的数兆字节的图片。
内容验证。 页面是否包含您预期的标记。爬虫报告100%成功率却返回被软屏蔽的页面,这是一种代价高昂的失败,而只有内容验证才能发现此类问题。
大家还问
什么是爬取列表?
爬虫计划访问的一组 URL,通常由种子列表、已发现但尚未抓取的 URL 边界以及已访问集合组成。对边界的管理——包括顺序、去重和资源配额——在很大程度上决定了爬取任务能否完成。
如何对 URL 进行规范化处理以便爬取?
将方案和主机名转换为小写(路径除外),将百分比编码的十六进制数字转换为大写,解码不必要的百分比编码,移除点分段,舍弃默认端口,将空路径规范化为 /,移除片段,并去除已知的跟踪参数。RFC 3986 定义了除最后两项以外的所有内容。
什么是爬取前沿?
指已发现但尚未抓取的 URL 队列。实际上,它由一组按主机划分的队列以及一个调度器组成,该调度器会在礼貌限制范围内挑选下一个符合条件的主机;因为如果使用单一的全局队列,一个大型网站会占用所有资源,导致其他网站无法获得资源。
如何防止爬虫无限期运行?
设置硬性限制:最大爬取深度、单个主机及整体的最大页面数,以及带宽上限。为日历和分面导航添加模式排除规则,并在新发现 URL 与已抓取页面之比停止下降时触发警报。
如何避免重复抓取同一页面?
在去重之前先规范化 URL,因为同一页面可能有多个有效地址。然后检查该页面是否属于已访问集合——小规模时使用哈希集,大规模时使用布隆过滤器或数据库。如果页面声明了 rel="canonical",请予以尊重。
应该多久重新爬取一次?
尽可能减少爬取频率(在需求允许的范围内),并根据每个页面实际变化的频率进行分级。利用站点地图中的 lastmod 以及条件请求,使未更改的页面仅消耗 304,而非完整抓取。以易变页面的频率爬取所有内容是资源浪费的最常见原因。
什么是布隆过滤器?我需要它吗?
布隆过滤器是一种概率集,它能在常数时间内以极少的内存消耗判断元素是否属于集合,但存在微小的误报概率——这意味着你偶尔会跳过一个实际上未曾访问过的 URL。当 URL 数量超过几百万时,使用它就值得;若少于这个数量,则没有必要,因为普通集合更简单且结果精确。
如何检测页面已被删除?
监控 404 错误,并对不再出现的页面应用相应策略——例如,如果某个 URL 在连续三次抓取中均未出现,则将其标记为“非活动”。如果不这样做,数据集中会积累不再存在的条目,这比数据缺失更快速地损害数据的可信度。
总结
爬网工作主要涉及数据管理。数据抓取本身借助库函数已不是难题;真正的工程挑战在于决定抓取什么、识别已抓取的内容,以及判断何时停止。
有三件事虽然看似简单,却值得格外关注。规范化,因为如果不进行规范化,你可能会通过十几个不同的地址访问同一个页面,并为每个地址都付费——而 RFC 3986 明确指出了哪些转换是安全的。 预算,因为某些 URL 空间确实是无限的,而没有硬性限制的爬虫终将陷入其中。还有发现率,因为这个单一数字能在账单送达之前,就揭示出陷阱、时间表或分类信息爆炸式增长的情况。
然后要做好种子工作。网站地图能将爬取转化为对变更内容的抓取,归档查询则能发现链接遍历永远无法触及的页面,而这两者对目标网站都无需任何成本。从首页开始并寄希望于运气,是通向最不完整结果的最慢途径,但这依然是默认做法。
