上半年我接了个公开资料整理的小任务把某资讯站历史列表页里出现过的条目全部捞下来去重之后存成CSV。这类活儿本身不复杂但把市面上的方案过了一遍之后我反倒纠结了两天——Scrapy这套生产级框架用起来像开坦克建项目骨架、定义Item、写Pipeline还没开始动手就先学一堆结构手写requests加BeautifulSoup倒是轻巧可重试、编码、翻页、去重这些事每次都从零拼一遍写出来的脚本三个月后连我自己都懒得回看。我真正需要的是一个拿着“采集规则”就能直接跑出结果的极简工具。于是就有了Caveman穴居人——名字取得很直白不玩花活原始但好用像穴居人扛着棍子去猎食目标明确、一击即中。这套工具断断续续打磨了两个多月现在跑在我一台小服务器上每周定时整理七八个公开数据源已经稳定运转了大半个季度。这篇文章就把整个项目的设计取舍、核心实现和踩坑记录都整理出来给同样在做信息整理、公开数据采集、或者单纯想了解“一个命令行采集器该怎么做”的朋友做个参考。1. 为什么要造Caveman现有采集方案的取舍1.1 Scrapy这类大而全框架的代价先说清楚Scrapy本身绝对是个好东西。调度器、中间件、Item Pipeline、分布式部署一套齐全几乎能应对任何采集场景。问题在于绝大多数人的场景根本不需要“任何”。我当时要做的只是“从某个列表页按规则提取若干字段翻页存文件”。但Scrapy要求我先搭一个完整工程项目目录结构、settings.py里十几个配置项、spider类里的parse回调、Item类定义。如果还想要UA轮换就得去写Downloader Middleware想要数据落地要理解Pipeline的process_item。再加上它默认基于Twisted异步模型调试时的异常栈对不熟悉的人非常劝退。我见过不少同事为了抓一个公开静态网站硬是把异步回调、信号量、扩展机制全学了一遍最后发现本来一小时能写完的小工具光框架初始化加配置就花了半天。这套复杂度不是说Scrapy不好而是要强调“复杂度超配”的问题。杀鸡用宰牛刀牛刀本身没毛病但鸡杀完了刀还要一直背着。Caveman的做法正好相反把框架能力砍到只剩“读配置、发请求、按规则提取、输出文件”其余一概不管。没有异步没有分布式没有中间件体系就是一个顺序执行的命令行程序。缺点很明显并发能力弱、吞吐量有限。但这正是我刻意要的边界——单机、低频、公开数据采集压根不需要重武器。1.2 手写requests脚本的甜头与后患不用框架很多人的下一步就是手写requests脚本。这个方案短期非常爽一个文件几十行代码跑通就是胜利。但后患会在两个时刻集中爆发。第一是重复劳动的爆发。第一个站点用requests加BeautifulSoup写完第二个站点来了换换选择器循环翻页的逻辑又得重写再遇到一个需要登录态的站又得加cookie处理再来一个动态加载的页面又要纠结要不要上浏览器自动化。每写一个新脚本都像是把上一个脚本的骨架拆了重组装。这些重复逻辑完全可以抽成一个固定流程只把“规则”暴露给用户改。第二是维护成本的爆发。三个月后目标站点的HTML结构变了脚本报错。这时你打开那个脚本发现当初是怎么处理编码的、为什么这个字段用正则而不是XPath自己都记不清了。选择器、请求逻辑、清洗代码全混在一起牵一发而动全身。我越来越觉得采集工具的核心问题不是“抓不到数据”而是“规则不够清晰”。于是Caveman从一开始就确立了一个原则规则与代码分离。采集的“是什么”选择器、字段映射、翻页方式和“怎么采”请求、重试、延时、编码处理彻底分开前者写在YAML配置里后者是固定的执行引擎。站点结构变了只需要改配置不需要动代码。1.3 Caveman的定位配置驱动的极简采集CLI基于上面的思考我给Caveman定下的能力边界。它能做的事读取一份YAML规则文件抓取种子页面按CSS/XPath选择器提取结构化字段处理分页去重然后输出成JSONL或CSV。它不做的事分布式调度、分布式存储、任务可视化、复杂的增量与全量切换、验证码破解、突破访问限制之类的事情一概不碰。名字叫Caveman除了自嘲“原始”其实也暗合了工具的设计哲学像穴居人那样用最简单的工具解决最日常的生存需求。真正的好工具不是功能多而是把一件事做扎实。我现在给朋友介绍Caveman时只说一句话“给你一个YAML文件一条命令拿走数据。”其余的事情它操心的越少越好。YAML作为输入格式也有它的道理比JSON可读性强可以写注释层级结构清晰非常适合表达“从哪个页面、抓哪些字段、怎么翻页”这种采集意图。后面我就用一份真实配置来拆解它的工作流程。2. Caveman的整体设计与工作流程2.1 YAML规则文件让采集意图一眼可见直接看一份能用的配置文件name: example_quotes request: headers: User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) timeout: 15 retries: 3 backoff_factor: 2.0 min_interval: 1.0 max_interval: 3.0 items: - name: quote selector: - div.quote - article.quote-item fields: - key: text selector: .text - key: author selector: .author pagination: next: li.next a max_pages: 50 output: format: jsonline path: ./output/quotes.jl拆解一下几个关键区块。request区块控制请求层的整体行为。timeout是单次请求超时retries是最大重试次数backoff_factor控制退避增长的倍数min_interval和max_interval则规定了相邻两次请求之间的随机延时范围。之所以做随机而不是固定数字是因为固定间隔很容易形成机械节奏对目标站点的请求日志也不友好随机区间更接近自然行为的分布。items区块定义“抓什么”。这里允许一个条目写多个候选选择器就是考虑到站点改版的情况后面我会专门讲这个设计。字段里的key是输出字段名selector是提取用的CSS选择器或者XPath。pagination区块负责翻页。next是下一页链接的选择器max_pages是页数上限防止因为选择器匹配异常导致无限循环下去。output区块一目了然输出格式和文件路径。这个配置写完后使用者不需要知道requests怎么处理Session不需要知道lxml内部怎么解析只要把“去哪抓、抓什么、怎么翻”这三件事说清楚剩下的是Caveman的执行引擎该管的。2.2 从种子URL到结构化数据的主流程Caveman的执行引擎是一个很朴素的循环没有事件驱动没有异步回调。流程拆开其实就是八步读取YAML做基础校验必填项缺失直接报错退出。发起种子页请求带上配置里的headers、timeout。对状态码做判断非2xx按退避重试重试次数耗尽则记录失败日志继续尝试下一个页面。拿到响应字节流后做编码处理再交给lxml解析成DOM树。按items定义定位目标节点对每个节点提取字段值。进入清洗管道strip掉空白、过滤空值、按规则做类型转换。将清洗后的item追加写入输出文件。解析翻页选择器如果有下一页且未达到max_pages循环回第2步。为什么坚持顺序执行因为好排查。异步框架最大的痛点是日志里的事件顺序交织在一起出了错要先还原调用关系。Caveman是单线程顺序跑每一条日志都对应一个明确步骤出了问题只要顺着日志往下看基本几分钟就能定位。2.3 输出与增量采集的取舍输出格式上我选了JSONL作为默认格式。JSONL就是每行一个JSON对象相对于单个大JSON文件它的好处是天然支持追加写入不怕写入一半进程崩掉导致整个文件损坏配合grep、awk这些命令行工具做数据抽样也非常顺手。需要CSV的时候Caveman再在JSONL基础上做一个转换子命令两边不冲突。增量采集是另一个花了不少心思的地方。Caveman维护一个去重指纹集合指纹由“页面URL关键字段内容的sha1哈希”组成。每次抓到一个item先算指纹已存在就丢弃不存在才追加写入。这样设计有个额外的好处如果同一站点隔几天之后重跑新增条目会顺利追加已删除的条目不会出现在新输出里而内容发生变化的条目因为哈希基于当前内容会被当作新记录写入和旧记录一对比很容易找出哪些条目更新过。这个小设计帮我省了不少对账的功夫。3. 请求层与编码处理最容易被忽略的两个细节3.1 请求头、随机延时与退避重试的搭配请求层是采集工具最容易翻车的地方也是最容易被新手忽略的地方。我在Caveman里把请求封装成了一个小函数核心逻辑可以简化成下面这段import random import time def request_with_backoff(session, url, retries, backoff_factor, min_interval, max_interval): for attempt in range(1, retries 2): delay random.uniform(min_interval, max_interval) time.sleep(delay) try: resp session.get(url, timeout15) if resp.status_code in (429, 503, 504): wait_time backoff_factor ** attempt random.uniform(0, 0.5) time.sleep(wait_time) continue resp.raise_for_status() return resp except Exception: if attempt retries: raise time.sleep(backoff_factor ** attempt) return None这里有几个设计要点。第一Session复用。requests.Session会复用底层的TCP连接对同一站点的多次请求可以省去重复握手的开销。Caveman启动后只创建一个Session所有页面共用性能提升很明显。第二基础延时是在每次请求之前执行的。random.uniform(min_interval, max_interval)提供一个1到3秒的随机等待窗口让请求时间点不形成机械的固定节奏。一开始我把min_interval设成0.2秒跑一个两百页的列表站采到一半日志里全是503。把间隔调到1到3秒之后同一个任务安静跑完了。这个教训我一直记住数据采集不是刷榜慢一点反而快。第三对429和503这类状态码单独做退避重试。backoff_factor ** attempt让重试等待时间指数增长第一次2秒第二次4秒第三次8秒。服务器已经在告诉我们“我忙不过来了”这时候再猛轰只会让情况更糟。退避加重试是对目标站点最基本的尊重也是让自己任务能顺利跑完的现实选择。3.2 编码自动探测从乱码到可靠解码乱码是采集公开网页时非常容易踩的坑尤其国内不少老站点还停留在GBK甚至GB2312编码。requests库在拿到响应时会通过HTTP头里的Content-Type判断编码没有明确声明就猜一个。这个“猜”经常出问题。很多老站点的服务器响应头里根本没写charset但页面HTML的meta标签里写了meta charsetgb2312。requests不知道它会用内置的编码探测逻辑猜一旦猜成latin-1或者utf-8中文字符直接变成乱码。我在Caveman里的处理顺序是这样的from charset_normalizer import from_bytes def robust_decode(resp_bytes, resp_headers): charset content_type resp_headers.get(Content-Type, ) if charset in content_type: charset content_type.split(charset)[-1].strip().strip(;) if not charset: best from_bytes(resp_bytes).best() charset best.encoding if best else utf-8 return resp_bytes.decode(charset, errorsreplace)优先级很简单先信HTTP头里明确声明的charset这个通常最可靠没有声明的话用charset_normalizer从字节内容里探测探测不出来最后兜底utf-8。解码时用errorsreplace宁可个别字符变成替换符也不能让整个任务因为一个字节序列非法而崩溃事后抽样检查再人工处理异常数据。这段逻辑看着不起眼但它解决了Caveman最脏最烦的一个坑。没有它我可能已经放弃支持GBK老站了。3.3 动态页面不一定要开浏览器XHR直连策略我遇到过不少“滚动加载更多”的列表页。静态HTML里只有前几条数据剩下的全靠页面上的一段JavaScript去请求后端接口再渲染出来。大多数人的第一反应是上无头浏览器Playwright或Selenium让浏览器渲染完之后再从头解析。这个方案能解决问题但也把资源开销和复杂度拉满每次要启动一个浏览器进程内存占用上百兆执行速度还慢。我的经验是先打开浏览器的开发者工具切到Network面板刷新页面看看那些XHR请求。对于很多滚动加载的场景后端接口返回的就是干干净净的JSON字段结构往往比解析HTML还要省事。Caveman因此在配置里增加了一个可选分支允许对某个item直接指定JSON字段路径比如data.list[].title。命中这种配置时引擎跳过HTML解析流程直接请求JSON接口并按下标路径提取字段。整个逻辑简单直接速度比浏览器渲染快一个数量级。但这个策略有个边界条件如果目标接口的URL带了签名、时间戳这类动态参数每次请求都要先执行一段JavaScript才能算出完整地址那XHR直连就不划算了。这时候才值得考虑无头浏览器方案。判断标准是接口能不能稳定直连。能就直连不能再上渲染。这个判断做对了可以替我省一大半时间。4. 选择器设计、命中率校验与页面改版容错4.1 把“选择器”当配置而非代码站点改版是采集工具绕不开的成年礼。我几乎可以断言任何一个你持续采集超过三个月的站点HTML结构迟早会变。区别只是改大改小。Caveman针对这个问题做了三个设计第一个就是前面配置里展示过的“多候选选择器”。每个目标节点允许写多个CSS或XPath选择器运行时按顺序尝试取第一个命中数量达到最小阈值的。别看只是多写了几个字符串它在关键时刻能兜住一条命。站点改版往往只是改掉了部分class名结构没变只要规则文件里有第二候选就能无缝切换。这里的关键是选择器本身是配置数据而不是代码。把选择器写死在Python文件里改版之后要动代码、重新部署写在YAML里改一行保存重新跑一下命令就行。这个习惯让我在几次改版面前都没有手忙脚乱。4.2 字段命中率阈值与fail-fast机制多候选选择器只是第一道防线。第二道防线是命中率校验。只靠选择器匹配成功还不够字段提取质量必须独立验证。我曾经因为一个选择器仍然“命中”了节点但字段全部提取为空结果任务正常退出、输出文件里写满了空对象。空数据比没数据更可怕因为没数据至少会立刻引起注意空数据可能会悄悄混进下游统计。Caveman定义了一个最小字段命中率参数默认0.8意思是对于采集结果中的必填字段每个字段的非空数量除以总条目数必须不低于这个比例。否则引擎直接抛错停止任务。min_field_ratio 0.8 field_success { field: sum(1 for item in extracted_items if item.get(field)) for field in required_fields } for field, count in field_success.items(): if total_items and count / total_items min_field_ratio: raise RuleValidationError(field, count / total_items)为什么默认是0.8而不是0.99因为真实网页里总有少量字段本来就没有比如某个作者页面没填写简介字段就是空这是正常情况。空值率超过两成才值得怀疑规则选错了节点。如果你采集的数据对完整性要求极高可以把阈值调到0.95代价是误报中断的概率也相应上升。fail-fast机制看似保守实际是保护下游的。宁可让任务中断也不能产出一批字段大量缺失的数据让后面的分析环节在垃圾上盖房子。4.3 一次真实改版事故的复盘讲一个具体案例。某书评站我原本用的列表项选择器是div.book-item翻页是一个“下一页”按钮。某天定时任务跑完日志显示0条数据落库。第一时间打开页面发现站点改版了列表项class换成了article.book-card分页从“下一页”按钮改成了无限滚动“下一页”链接没了。这时候多候选选择器起了作用。我在写规则时除了主选择器还顺手写了article.product-card作为备选——当时是因为另一个相似站点的结构就是这样就一并放进去了。改版后第一个选择器命中数量为0引擎自动切到了第二候选条目照常抓到。但翻页规则仍然失效页码停在1所以要手动更新pagination配置。整个定位过程花了不到十分钟。能这么快靠的其实不是运气而是两个机制一是候选选择器命中了“第几条”的信息被写进了日志二是页码停在1页这个事实直接指出了翻页环节出问题。这两个信息一结合问题范围立刻缩小到pagination配置。如果当初没有多候选和日志我得先确认是“规则失效”还是“网络被封”再逐个排查时间成本翻几倍。这个案例后来被我写进了项目的README作为“写规则时永远假设它会过期”的例证。多留一条退路日后省下的排查时间远超写那两行字的成本。5. 采集公开数据时的合规边界与工程化习惯5.1 动手前先看robots.txt与公开数据边界关于采集公开数据有几个原则我是坚持不动的Caveman的文档里也写得清清楚楚。第一动手之前先看目标站点的robots.txt。它位于站点根目录直接浏览器访问域名/robots.txt就能看到。里面会写明哪些路径允许爬虫抓取、哪些明确拒绝。Caveman在运行前可以配置一个respect_robots: true开关开启后引擎会先抓取并解析robots.txt被明确拒绝的路径直接跳过。第二合理判断数据是否属于真正的“公开”边界。无需登录就能看到的内容可以属于公开信息但不代表可以随意批量转存和二次分发。个人自用的整理、研究、有限范围内的引用是合理场景大规模抓出来拿去卖性质就完全不同了。Caveman的定位从来都是前者。第三绝不绕开访问控制。需要登录以后才能看到的数据不会通过破解、撞库之类的方式获取。这类功能Caveman压根就不提供。这些边界不是空话而是工具能不能长期安全跑下去的前提。采集这件事技术上的约束远不如规则上的约束重要。5.2 限速参数的设定经验慢就是快限速参数怎么定我基于自己跑下来的数据给了一个参考表格任务场景预计页面总量建议间隔区间小型静态列表页100页以内0.5 ~ 1.5秒中型站点包含正文页数千页1.0 ~ 3.0秒大站、高频更新源万级以上3.0 ~ 6.0秒这些只是起步值。实际跑的时候以日志里429和503的出现概率为准。如果出现频率上升就把interval整体翻倍再观察。不要心疼耗时1000页数据在2秒间隔下大约需要30到50分钟对“整理一批公开资料”这种场景完全能接受。我还有一个习惯避开目标站点的访问高峰尽量安排在凌晨或者业务低峰时段执行。定时任务放在Cron里错峰跑既减少对目标站点的影响自己也少碰到限流。5.3 日志、统计与数据校验的工程化习惯Caveman能稳定跑两个多月很大一部分功劳要记在日志设计上。每次请求日志里至少包含这些字段时间、URL、状态码、耗时、重试次数。任务结束时再输出一行汇总成功多少条、失败多少条、重复跳过多少条。我特别强调要把“命中第几个候选选择器”记下来。这看似不起眼但排障时几乎是救命信息。站点改版后第一候选失效、第二候选接棒日志里就记录了这一切换一看就知道站点结构变了马上检查配置是否需要更新。另一个工程化习惯是“数据数量异常的自动告警”。定时任务里加一段判断本次输出记录数与上一份输出记录数做比较波动超过30%就发提醒。改版导致规则失效时往往会表现为采集数量骤降到接近0这个告警能在我完全不知情的情况下拉一把。最后是输出数据的抽样校验。任务结束后自动从输出文件里随机抽几条记录打印到日志里。人眼扫一眼这些抽样结果就能发现字段错位、编码异常之类的问题。这个小动作成本很低但能避免把一批脏数据送进下游表格。说到底Caveman的技术含量谈不上高真正让它变成一个可靠工具的恰恰是这些“老实”的习惯多留日志、多做校验、多留退路。工具得做减法工程习惯要做加法。
阅读完成 · 觉得有帮助?