这篇按版本号写。因为这个东西确实是一版一版长出来的,一开始我根本没打算写「框架」,只想抓点数据。
v0.1:一个 while 循环
需求很朴素:抓某个公开文档站的三万多个页面,做本地全文检索。我写了 30 行:
urls = [seed]
while urls:
u = urls.pop()
html = requests.get(u).text
save(u, html)
urls.extend(extract_links(html))跑了 20 分钟,进程挂了。原因是某个页面返回了 502,requests.get 抛异常,整个循环直接结束,前面抓的一万多页面全在内存里,没落盘。
这是第一个教训:爬虫的默认状态是失败,成功才是意外。
v0.2:队列和状态落盘
我把待抓队列换成了 SQLite 里的一张表,三个字段:url、status、retry_count。status 有 pending / running / done / failed 四个值。
这个改动看起来很笨重,但它解决了一个致命问题:可以随时 Ctrl+C,重启后从断点继续。我后来在这个项目上按了大概 60 次 Ctrl+C,这张表救了我 60 次。
顺手加了重试:失败的 URL retry_count +1,超过 3 次标记 failed。跑完之后我数了一下,三万个页面里有 214 个 failed,其中 190 个是真的 404。
v0.3:礼貌和限速
v0.2 跑起来大概 40 QPS。第二天我发现自己的 IP 被封了。
我当时的反应很不体面:第一想法是找代理池。冷静下来之后我去看了那个站的 robots.txt,里面写着 Crawl-delay: 2。也就是说人家明确要求两秒一次,而我在打 40 QPS。是我不对。
于是加了两个东西:
- 按域名分桶的令牌桶限速器,默认每域名 0.5 QPS,可以针对具体域名覆盖。
- 一个
robots.txt解析和缓存层,缓存 24 小时。
限速之后总耗时从 15 分钟变成了 16 小时。我挂在一台闲置的小主机上跑了一晚上,早上起来数据就齐了。慢是慢,但没人再来封我。
v0.4:去重比我想的难
抓完发现三万个页面里有大量重复内容。原因是同一篇文档有好几个入口 URL:带 ?from=nav 的、带 anchor 的、结尾多一个斜杠的、大小写不同的。
我写了一个 URL 规范化函数,做了这几件事:小写 host、去掉默认端口、去掉 fragment、按白名单过滤 query 参数、排序剩余 query、统一末尾斜杠。规范化之后,三万零六百个 URL 收敛到两万一千个。
但还有一类重复:内容一样,URL 确实不同(比如镜像路径)。我用正文文本的 SimHash 做了一层二次去重,海明距离小于 3 判为重复,又干掉了 1800 个。
v0.5:把它变成「框架」
到这一步我才动手抽象。抽象的边界只有三个:
fetch(url) -> Response:可替换,方便换成带浏览器渲染的实现。parse(response) -> (items, next_urls):业务逻辑唯一要写的地方。pipeline(item):落盘、清洗、入库。
其余的调度、限速、重试、去重、断点续传全部在框架里,用户看不见。第二个爬虫项目我只写了 40 行 parse 函数,半小时跑起来了。
回头看
我踩过最蠢的坑不是技术上的,是心态上的。v0.1 那会儿我觉得「爬虫嘛,不就是 requests 加 BeautifulSoup」。真正的复杂度全在异常路径上:网络抖动、编码错乱(我遇到过一个页面声明 UTF-8 实际是 GBK)、重定向环、内容协商、以及最烦人的——服务端限速时不返回 429,而是静悄悄给你一个空页面。
如果现在让我给要写爬虫的人一句建议,我会说:先把「记录每个 URL 处于什么状态」这件事做扎实,剩下的都好办。