☰
Caveman:用YAML配置驱动的极简网络爬虫工具设计与实践
2026/10/15 9:57:08 网站建设 项目流程

上半年我接了个公开资料整理的小任务:把某资讯站历史列表页里出现过的条目全部捞下来,去重之后存成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的执行引擎是一个很朴素的循环,没有事件驱动,没有异步回调。流程拆开其实就是八步:

  1. 读取YAML,做基础校验,必填项缺失直接报错退出。
  2. 发起种子页请求,带上配置里的headers、timeout。
  3. 对状态码做判断:非2xx按退避重试,重试次数耗尽则记录失败日志,继续尝试下一个页面。
  4. 拿到响应字节流后做编码处理,再交给lxml解析成DOM树。
  5. 按items定义定位目标节点,对每个节点提取字段值。
  6. 进入清洗管道:strip掉空白、过滤空值、按规则做类型转换。
  7. 将清洗后的item追加写入输出文件。
  8. 解析翻页选择器,如果有下一页且未达到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, timeout=15) 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 charset="gb2312">。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, errors="replace")

优先级很简单:先信HTTP头里明确声明的charset,这个通常最可靠;没有声明的话,用charset_normalizer从字节内容里探测;探测不出来最后兜底utf-8。解码时用errors="replace",宁可个别字符变成替换符,也不能让整个任务因为一个字节序列非法而崩溃,事后抽样检查再人工处理异常数据。

这段逻辑看着不起眼,但它解决了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的技术含量谈不上高,真正让它变成一个可靠工具的,恰恰是这些“老实”的习惯:多留日志、多做校验、多留退路。工具得做减法,工程习惯要做加法。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询