最近整理代码仓库,翻出来不少以前写的爬虫脚本。有些还能跑,有些连我自己都看不懂当年为什么要写成那个鬼样子。趁着记忆还没完全模糊,把这一路踩过的坑和沉淀下来的经验做个记录。这篇“爬虫小结”谈不上系统教学,更像一个干过几年数据采集相关活儿的人,把工具箱重新清点一遍。无论你是准备入门的初学者,还是已经写过一阵子脚本的老手,里面有些判断方法和排查思路,应该都能派上用场。
1. 开工前的目标拆解,比爬虫框架选型重要十倍
1.1 先回答三个问题,再写第一行代码
我见过太多人拿到需求就打开编辑器,一边写一边问“这个字段到底要不要”。我自己也干过这种蠢事。有次接到一个需求,要把某个公开图书信息站点的书籍资料整理出来。听起来很简单,打开页面一看,左边是书名、作者、出版社,右边是简介、目录、定价,底下还有一堆推荐位。
当时我的第一反应是“用Requests加BeautifulSoup,最多一天搞定”。结果真正动手才发现:页面分页规律是什么?目录字段要不要拆成层级?缺书号的书怎么标记?某些书简介是空的算不算数据缺失?这些细节没有一个事先定义清楚,导致脚本改了四五轮,选择器换了一茬又一茬。
后来我给自己定了个规矩,开工前必须回答三个问题:
- 抓什么:明确数据范围,是整个站点还只是某个分类,是全部历史数据还是最近三个月。
- 为什么抓:是分析用、展示用、还是归档用,目的决定了字段精度和清洗标准。
- 抓完放哪:先落本地文件,还是直接进数据库,数据量级预估是多少。
这三个问题看起来基础,但大部分返工都源于没想清楚。尤其是字段定义,统计口径和展示口径完全不是一回事。展示只需要“书名+作者+封面链接”,统计可能还要页码、印次、开本尺寸。花半天把字段清单敲定,比我后来花三天补脚本要划算得多。
1.2 一次性任务和长期任务,从一开始就是两种写法
另一个容易犯的错是,把一次性任务设计成长期任务,或者反过来。
有一次我以为某个比赛数据只需要抓一次,图省事,脚本就是单文件里套一个for循环,数据直接往CSV里塞。结果数据量比预期大了一个量级,跑到一半网络抖动,脚本崩了,前面抓的几百条全得重来,因为没做断点续爬。
另一次,我按长期定时任务的规格去设计一个小型采集任务,上了消息队列、分布式调度、代理池。最后那个需求只需要抓两百条数据,我那个架构光维护成本就比抓数据本身还高,纯属过度设计。
我的经验是,先评估任务的生命周期和频次:
- 一次性:能写成函数就别拆模块,能存JSON就别建数据库表。
- 定期任务:才需要考虑增量更新、去重、日志和失败重试。
- 持续长期运行:才轮到分布式、任务队列、监控告警那些重武器。
判断标准很简单:这个任务下个月还要不要跑?如果答案是“大概率不跑了”,那就别为了所谓的优雅架构浪费时间。爬虫这行,甲方只看结果数据准不准,没人看你的类继承写得多漂亮。
2. 先聊合规再聊技术:公开数据、robots与频率约束
2.1 判断数据边界,是每个爬虫作者的第一课
技术圈经常讨论“爬虫合不合法”,但很多讨论都跑偏了。作为从业者,我的实操原则很简单:只管公开的、不需要登录就能访问的数据。需要登录、需要授权、需要绕过某种权限校验的内容,除非拿到了明确授权,否则一律不碰。这不仅是自我约束,也是风控常识。你没有授权的情况下强行去访问受限资源,一旦被追责,连解释的空间都没有。
判断边界最直接的参考就是站点根目录下的robots.txt文件。它虽然不是法律文件,但代表了站点运营方的意愿。Allow和Disallow声明的区域写得清清楚楚,尊重它是基本操作。我早期写过一个小工具,把需要采集的站点路径和robots规则做了一次比对,凡是Disallow的路径直接加入黑名单。这个小习惯帮我规避了不少风险。
还有一点容易被忽略:页面里标注了“未经许可禁止转载”“版权归原作者所有”之类的话,即使技术上能爬到,也要重新评估用途。爬虫代码好不好写是能力问题,该不该爬是选择问题。技术判断在线下工程里永远不如商业判断重要。
2.2 请求频率不是技术参数,而是礼貌参数
很多人把并发数、请求间隔当成纯性能调优参数,恨不得用最快的速度把对方服务器薅秃。我原来也这么干过,直到有一次把一个公开资料站爬得对方运维直接在站上挂了公告,说某个IP段访问异常。
后来我想明白一件事:请求频率本质上是礼貌参数。爬虫对目标站点的压力,和你的并发数、单次请求耗时直接相关。一个站点只有一台普通服务器,你每秒打五十个请求,和每秒打两个请求,体验是完全不同的。
业内常用的做法是给每次请求之间加延时,而且最好是随机延时。固定的0.5秒延时容易被识别成机器行为,完全固定间隔在长任务里也会造成规律性访问。我一般会在基础间隔上做±30%左右的随机抖动:
import random import time base_interval = 1.5 # 基础间隔,单位秒 time.sleep(base_interval + random.uniform(-0.5, 0.5))具体间隔设多大,没有标准答案,要看站点响应速度和数据量。保守一点的做法是先用单线程试跑几十条,观察站点响应时间,再根据数据总量估算总耗时。宁可多跑一会儿,也别把对方服务器拖垮。毕竟数据源一旦把IP拉黑,耽误的是整个项目进度。
2.3 遇到验证码,正确姿势是停而不是“绕”
爬虫做多了,迟早会遇到验证码。图形滑块、点选、文字识别之类的机制,本质是站点在表达“我不太确定你是不是真人”。这时候直接硬刚是最下策,想办法接打码平台、训练识别模型之类的手段,轻则白花钱,重则把自己卷进灰产边缘。
我现在的处理方式很简单:遇到验证码,把当前任务标记为异常,暂停该部分的采集,进入一个人工确认流程。验证码插件的出现频率本身就是一个信号,说明访问频率需要降下来,或者站点的数据边界比我以为的更窄。
合规的降级方案通常是这样:
- 降低请求频率,等待一段时间再继续。
- 换用站点提供的官方开放接口,很多数据其实有正规渠道。
- 如果不能解决,直接放弃这部分数据,并在最终成果里标注数据覆盖范围。
爬虫的最大价值在于稳定地拿到数据,而不是证明自己技术有多强。稳定这两个字,靠的是克制,不是绕。
3. 请求层最容易翻车的三个环节:请求头、超时与重试
3.1 请求头不是在“伪装”,而是在表明正常的客户端身份
新手写爬虫,最喜欢从头到尾只设一个User-Agent,别的什么都不管。早期有些站点确实只看这个字段,现在稍微认真点的站点都会看一组特征的组合:UA、Accept、Accept-Language、Referer、Cookie的完整性。
拿Referer举例,某些站点会校验请求是从哪个页面跳转过来的。如果你直接请求详情页,但Referer缺失,对方可能直接返回403。解决方式也很简单,把浏览器开发者工具里复制到的完整请求头拿过来,清理掉个人信息后作为基础的headers模板。
但这里要说明一个原则:设置合理的请求头,不是去伪装成搜索引擎或者伪装成某款老旧的浏览器版本,而是让请求看起来像一个正常的、真实的客户端。刻意伪造、藏头露尾反而容易被规则命中。我以前为了一时方便,给所有线程用了同一个UA,结果触发了一次站点风控,就是因为同一UA的请求频率异常集中。后来我把UA列表按线程做隔离,每个线程固定一个UA,请求特征自然就分散了。
3.2 超时参数不设置的后果,比你想象的严重
requests库如果不传timeout参数,默认行为是一直等下去。单条请求的等待你感觉不出来,但在批量任务里,一个响应很慢的接口可以让几百个线程全部挂起,堆积在内存里的任务对象越来越多,最后整个脚本像是僵尸一样卡在那里。
正确做法是每次请求都显式声明超时,而且最好区分连接超时和读取超时:
import requests resp = requests.get( url, headers=headers, timeout=(5, 10) # (连接超时, 读取超时) )这里的5秒是建立连接的最长等待,10秒是等待响应的最长时长。两个值可以根据目标站点的响应速度做调整。站点快就设小一点,站点慢就放宽,但绝不能省略。
我自己的血泪教训是,曾经有一个采集任务,因为漏掉了超时参数,某个冷门接口偶尔要响应三四分钟,整个队列被这一个慢请求堵住,后续任务全部积压。加了超时之后,脚本自动跳过异常请求,原本偶发的卡死问题再也没有出现过。
3.3 重试机制要写,但不能无脑重试
重试是爬虫健壮性的重要保障,但无脑重试比不重试更可怕。一个已经挂了的目标站点,你重试十次只会让日志变得更乱,还会给对方服务器造成额外压力。
需要重试的情况一般是两类:网络抖动导致的连接失败,以及服务端临时错误(5xx)。这两类值得重试。而客户端错误(4xx)比如404、403,重试并不会改变结果,反而是请求逻辑本身出了问题,应该停下来检查。
重试的策略也有讲究,不能每次都隔固定的1秒重试。比较通用的做法是带指数退避的延时:
import time def fetch_with_retry(url, headers, max_retries=3): for attempt in range(max_retries): try: resp = requests.get(url, headers=headers, timeout=(5, 10)) if resp.status_code >= 500: raise ConnectionError("server error") return resp except Exception: wait_seconds = 2 ** attempt + random.uniform(0, 1) time.sleep(wait_seconds) return None第1次重试等2秒,第2次等4秒,第3次等8秒,给服务端留出恢复窗口。还有一点要注意,如果多个线程同时失败、同时重试,会产生重试风暴。解决思路是给重试延时也加入随机抖动,让失败请求的重试时间自然错开。
4. 数据到手才是起点:清洗、去重与存储选型
4.1 脏数据都有固定的“产地”,知道产地就能堵住源头
很多入门教程讲到解析完HTML就结束了,好像数据到手就大功告成。实际做过的人都知道,解析只是开始,清洗才是真正消耗时间的地方。
脏数据的来源其实很有规律。第一类来自页面结构变化,站点改版之后,某个字段的选择器失效,解析结果变成空列表。第二类来自HTML实体,页面里的 、'之类的实体直接解析出来就是乱码。第三类来自字段本身,比如价格字段里有“暂无定价”这样的文本,简介字段存在换行和多余空格。
第三类问题的处理思路是在解析阶段就提前做好规范化。比如价格字段,先判断是否包含数字,再删除货币符号和单位。简介字段做一次统一的空白压缩:
import re def clean_text(value: str) -> str: if not value: return "" value = value.replace("\u00a0", " ") # 替换不换行空格 value = re.sub(r"\s+", " ", value) # 压缩连续空白 return value.strip()清洗逻辑放在解析层,比放到入库前统一处理要高效得多。爬虫每分钟产出的数据量不小,等到落库之后再逐条清洗,索引都建完了还要回头改数,那代价就大了。
4.2 去重策略:从内存集合到数据库唯一索引
去重是爬虫绕不开的话题,尤其是长期任务。去重可以发生在两个层面:请求前去重和解析后去重。
请求前去重解决的是“同一批URL不要重复抓”。维护一个已访问URL的集合,可以是内存里的set,也可以是长期任务里落盘的已完成列表。内存set适合一次性任务,几十万条URL放set里毫无压力。长期任务则要把已访问集合持久化,否则任务一重启就全忘了。
解析后去重解决的是“同一条业务数据不要重复入库”。这里的判断依据不再是URL,而是业务主键。拿图书数据举例,一本书的链接可能有两个版本,但ISBN只有一条。用ISBN作为数据库表的唯一索引,比用URL去重更可靠。同样的道理,赛事数据用“赛事ID+日期”,文章数据用“文章ID”。
数据量特别大的场景,内存set会撑不住,这时可以考虑布隆过滤器。布隆过滤器能判断“一定不存在”和“可能存在”,存在一定误判率。对于爬虫去重场景,几个百分点的误判意味着偶尔漏掉一条数据重抓,成本完全可接受。不过日常项目里,大部分场景用set加数据库唯一索引就足够了。
4.3 存储选型:别用牛刀杀鸡,也别用菜刀砍骨
存储方案的选择,应该匹配数据量级和查询需求。我自己一般是这么分的:
- 几百条到几千条:CSV文件或者JSON文件就够了。注意CSV的编码问题,写入时指定utf-8 with BOM,否则用Excel打开会乱码。
- 几万条左右:SQLite非常合适,单文件、零维护、支持SQL查询,做数据分析比翻CSV顺手太多。
- 几十万条以上,或者需要多人访问:再考虑上独立的数据库服务,同时要规划好表结构和索引。
这里给一个小建议:无论选哪种存储,落库前最好先做一次全量统计,看看字段缺失率和重复率。缺失率超过一定程度,说明源头页面结构可能已经变了,或者选择器写错了。这个步骤能帮你在数据污染之前发现问题。
5. 一次IP被限制与一次写入乱码:完整排查链路复盘
5.1 事件一:请求从正常到突然全部403
那次任务是从某个公开赛事数据页面采集比赛结果,跑了一个多小时,突然日志里的失败数量开始飙升,响应状态码清一色403。我第一反应是IP被封了,但仔细看日志发现,403并不是从某个时间点开始一条成功的都没有,而是“成功和失败交替出现”,而且成功的请求耗时明显变长。
这就说明IP没有被彻底封锁,更像是触发了频率限制。接着我查了几个信息:响应的头信息里有频率限制相关的提示字段,错误内容里出现了“too many requests”的英文提示。再回看代码,并发数在我试跑的时候是3,后来为了提速被我手动调到了8,而目标站点的承受能力明显没有我想象的强。
定位之后处理起来就简单了:把并发降回3,请求间隔调回原来的值,又等了十几分钟,成功比例慢慢恢复。整个过程就是一次典型的“经验型限流”,站点没有封死,而是通过限速来约束访问行为。
复盘之后我的结论是,并发参数的调整不能只凭感觉,要看实测响应时间和错误率曲线。提速的正确方式是逐步增加并发,比如从3调到5,观察十分钟再调到8。一口气拉满并发,等于给自己挖坑。
5.2 事件二:入库后中文全部变成乱码
另一个印象深刻的坑,是把数据写入数据库之后,发现中文字段全都变成了???。第一次遇到这种问题,我花了大半天才定位到源头。
先检查的采集端。requests库对网页编码的判断是用响应头里的charset字段,但有些页面响应头没声明,或者声明得不准确,requests就用手动识别的方式去猜。猜错的情况下,拿到的文本本身就是乱码。解决办法是在拿到响应后手动确认并设置编码:
resp.encoding = resp.apparent_encoding # 或者直接 resp.encoding = "utf-8"接着排查数据库端。连接数据库的字符串里如果没有指定charset,就会用数据库服务端的默认字符集,常见的老配置是latin1之类的,中文写进去自然变成问号。解决办法是在连接串里显式加上charset=utf8mb4。
最后还要确认表结构本身的字符集。即使连接串对了,如果建表时用了老旧的字符集,写入一样会出问题。
这个排查链路可以总结为三句话:先确认源头解码对不对,再确认客户端连接对不对,最后确认表的字符集对不对。顺序不能乱,因为每一层都有可能是坑。我后来建表时直接把表字符集定为utf8mb4,连接串也固定写成utf8mb4,从根上杜绝了这类问题。
6. 爬虫上线之后的运行观:日志、监控与容错意识
6.1 日志是唯一的“案发现场”,别偷懒
爬虫任务一旦跑起来,你基本不可能盯着屏幕看输出。出问题的时候,日志就是唯一的案发现场。早期我写爬虫只print状态码,后来发现print在任务跑了几小时之后根本没有可检索性,只能翻终端窗口,效率极低。
现在我的习惯是用logging模块,输出到文件,并且给每条请求记录几个关键字段:时间、URL、状态码、耗时、返回数据条数。格式大概是这样的:
2025-01-18 10:23:45 INFO fetched 200 https://example.com/xxx duration=0.32s items=12 2025-01-18 10:23:47 WARNING retry 1 https://example.com/yyy status=503日志写够信息的好处是,排查问题时可以直接在文件里grep,按时间线把错误串起来看,比大脑回忆可靠多了。遇到异常堆栈要完整记录,不要为了日志好看而省略,否则出事时缺少最关键的一层信息。
6.2 失败任务的可恢复设计:断点续抓
长任务最怕跑到一半崩掉,然后从头再来。解决办法是给任务加一个“检查点”机制。
最朴素的做法是把已完成的批次记下来。比如按页采集的任务,每成功解析完一页,就把页码写入一个状态文件。任务重启时先读状态文件,从断点继续,而不是从第1页重跑。
再进一步,可以把待处理URL列表和已完成URL集合分开存储。每完成一个URL就更新已完成集合,重启时跳过这些URL。这么做的好处是即使异常发生在某一页内部,也不会造成大面积重复采集。
对于长期任务,我还会把抓取到一半的原始响应做临时缓存,解析失败时优先重试解析而不是重新请求网络。网络I/O比本地解析昂贵得多,能省一笔是一笔。
6.3 页面改版是必然事件,监控和迭代要跟得上
爬虫上线三个月后最容易出的问题,不是代码逻辑,而是目标站点改版了。选择器全部失效,解析结果全是空值,采集任务还在傻乎乎地跑,把一堆空数据写进数据库。
应对页面改版,技术上有几个方向。第一是解析层尽量少依赖脆弱的CSS选择器,有稳定JSON接口时优先接接口。第二是写一个“字段完整性校验”,连续N条记录解析出来的核心字段为空时,自动触发告警。第三是定期人工检查,每周花十分钟看看日志趋势,比等到数据报表出问题再返工省力得多。
长期运行的爬虫,心态上要接受一个事实:维护成本高于开发成本。写完脚本只是万里长征第一步,后续的监控、告警、规则调整才是常态。它不像做一次性的demo,跑通了就结束,更像是在养一个需要定期体检的服务。
如果让我重新把这一路走一遍,我会把更多时间花在写需求清单、搭日志框架和断点续抓机制上,而不是沉迷于找更酷的解析姿势。代码只是爬虫里最简单的那一层,真正值钱的是对数据边界的判断和对异常链路的高效排查。这篇小结写到这里,最大的收获大概就是这样一句大白话:爬虫不等于写循环,爬虫是一整套从目标规划到运维保障的工程习惯。