爬虫工程实践:从目标拆解到数据清洗与稳定运维的完整指南 最近整理代码仓库翻出来不少以前写的爬虫脚本。有些还能跑有些连我自己都看不懂当年为什么要写成那个鬼样子。趁着记忆还没完全模糊把这一路踩过的坑和沉淀下来的经验做个记录。这篇“爬虫小结”谈不上系统教学更像一个干过几年数据采集相关活儿的人把工具箱重新清点一遍。无论你是准备入门的初学者还是已经写过一阵子脚本的老手里面有些判断方法和排查思路应该都能派上用场。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, headersheaders, timeout(5, 10) # (连接超时, 读取超时) )这里的5秒是建立连接的最长等待10秒是等待响应的最长时长。两个值可以根据目标站点的响应速度做调整。站点快就设小一点站点慢就放宽但绝不能省略。我自己的血泪教训是曾经有一个采集任务因为漏掉了超时参数某个冷门接口偶尔要响应三四分钟整个队列被这一个慢请求堵住后续任务全部积压。加了超时之后脚本自动跳过异常请求原本偶发的卡死问题再也没有出现过。3.3 重试机制要写但不能无脑重试重试是爬虫健壮性的重要保障但无脑重试比不重试更可怕。一个已经挂了的目标站点你重试十次只会让日志变得更乱还会给对方服务器造成额外压力。需要重试的情况一般是两类网络抖动导致的连接失败以及服务端临时错误5xx。这两类值得重试。而客户端错误4xx比如404、403重试并不会改变结果反而是请求逻辑本身出了问题应该停下来检查。重试的策略也有讲究不能每次都隔固定的1秒重试。比较通用的做法是带指数退避的延时import time def fetch_with_retry(url, headers, max_retries3): for attempt in range(max_retries): try: resp requests.get(url, headersheaders, 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实体页面里的nbsp;、#39;之类的实体直接解析出来就是乱码。第三类来自字段本身比如价格字段里有“暂无定价”这样的文本简介字段存在换行和多余空格。第三类问题的处理思路是在解析阶段就提前做好规范化。比如价格字段先判断是否包含数字再删除货币符号和单位。简介字段做一次统一的空白压缩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之类的中文写进去自然变成问号。解决办法是在连接串里显式加上charsetutf8mb4。最后还要确认表结构本身的字符集。即使连接串对了如果建表时用了老旧的字符集写入一样会出问题。这个排查链路可以总结为三句话先确认源头解码对不对再确认客户端连接对不对最后确认表的字符集对不对。顺序不能乱因为每一层都有可能是坑。我后来建表时直接把表字符集定为utf8mb4连接串也固定写成utf8mb4从根上杜绝了这类问题。6. 爬虫上线之后的运行观日志、监控与容错意识6.1 日志是唯一的“案发现场”别偷懒爬虫任务一旦跑起来你基本不可能盯着屏幕看输出。出问题的时候日志就是唯一的案发现场。早期我写爬虫只print状态码后来发现print在任务跑了几小时之后根本没有可检索性只能翻终端窗口效率极低。现在我的习惯是用logging模块输出到文件并且给每条请求记录几个关键字段时间、URL、状态码、耗时、返回数据条数。格式大概是这样的2025-01-18 10:23:45 INFO fetched 200 https://example.com/xxx duration0.32s items12 2025-01-18 10:23:47 WARNING retry 1 https://example.com/yyy status503日志写够信息的好处是排查问题时可以直接在文件里grep按时间线把错误串起来看比大脑回忆可靠多了。遇到异常堆栈要完整记录不要为了日志好看而省略否则出事时缺少最关键的一层信息。6.2 失败任务的可恢复设计断点续抓长任务最怕跑到一半崩掉然后从头再来。解决办法是给任务加一个“检查点”机制。最朴素的做法是把已完成的批次记下来。比如按页采集的任务每成功解析完一页就把页码写入一个状态文件。任务重启时先读状态文件从断点继续而不是从第1页重跑。再进一步可以把待处理URL列表和已完成URL集合分开存储。每完成一个URL就更新已完成集合重启时跳过这些URL。这么做的好处是即使异常发生在某一页内部也不会造成大面积重复采集。对于长期任务我还会把抓取到一半的原始响应做临时缓存解析失败时优先重试解析而不是重新请求网络。网络I/O比本地解析昂贵得多能省一笔是一笔。6.3 页面改版是必然事件监控和迭代要跟得上爬虫上线三个月后最容易出的问题不是代码逻辑而是目标站点改版了。选择器全部失效解析结果全是空值采集任务还在傻乎乎地跑把一堆空数据写进数据库。应对页面改版技术上有几个方向。第一是解析层尽量少依赖脆弱的CSS选择器有稳定JSON接口时优先接接口。第二是写一个“字段完整性校验”连续N条记录解析出来的核心字段为空时自动触发告警。第三是定期人工检查每周花十分钟看看日志趋势比等到数据报表出问题再返工省力得多。长期运行的爬虫心态上要接受一个事实维护成本高于开发成本。写完脚本只是万里长征第一步后续的监控、告警、规则调整才是常态。它不像做一次性的demo跑通了就结束更像是在养一个需要定期体检的服务。如果让我重新把这一路走一遍我会把更多时间花在写需求清单、搭日志框架和断点续抓机制上而不是沉迷于找更酷的解析姿势。代码只是爬虫里最简单的那一层真正值钱的是对数据边界的判断和对异常链路的高效排查。这篇小结写到这里最大的收获大概就是这样一句大白话爬虫不等于写循环爬虫是一整套从目标规划到运维保障的工程习惯。