每天15分钟玩转Scrapy - 工程化
开场
前面几篇的爬虫都是手动跑的。一句 scrapy crawl,看着日志滚完,收工。
练手这样挺好,但把它当成一个要交付的东西就不够了。别人得能调你的爬虫,得能一次跑几十个,得能在跑完之后拿到一份可核对的报告。这几种需求,命令行都给不了。
还有一个更现实的问题。一个爬虫上线之后最先失效的往往不是逻辑,是选择器。目标站改一次页面结构,你的 CSS 选择器就开始返回空值,而它不报错,只是数据变少了。等你在两周后发现问题,已经白跑了两周。
这一篇分三块。爬虫怎么从命令行搬进代码、跑一半崩了怎么接上、以及怎么让「页面结构变没变」这件事变成一条能自动跑的断言。
每段输出都是真跑出来的,目录结构、统计数字、契约失败时的输出我都贴出来。
从命令行到脚本
scrapy crawl 适合手动跑。它每天都能用,但它不适合当一个服务。
生产环境要的东西不太一样。别人得能调你的爬虫,得能一次跑几十个,得能在跑完之后拿到一份结果。这几种需求,命令行都给不了。你想想看,一个要交给同事传参调用的爬虫,总不能靠一句 scrapy crawl 加一串参数。
绕开命令行的写法有三种,差别不在写法,在选择依据。
主要看两件事,谁负责启动 reactor,以及一个进程里要不要跑多个蜘蛛。四种启动方式摆在一起,分岔点其实只有这两个。

1. CrawlerProcess 里排队跑多个蜘蛛
最省事的一种。它自己装 reactor、自己起循环,你只管往里塞蜘蛛。
| |
两个方法的分工要拎清楚。crawl() 往队列里放,start() 才点火。
我跑了一遍,两个蜘蛛在同一个进程里依次跑完,各自出各自的统计。
| 蜘蛛 | item | 响应 | 说明 |
|---|---|---|---|
| listing | 4 | 1 | 只抓列表页,不跟详情 |
| library | 8 | 10 | 2 个列表页加 8 个详情页 |
process.start() 是阻塞的,它会一直卡在那里直到最后一个蜘蛛收工。这一点在写服务的时候要记住,别指望它后面那行代码会立刻执行。
它内部做的事不复杂。先给 join() 建一个任务挂到当前循环上,然后调 reactor.run() 进主循环,等 join 任务完成再回头停掉 reactor。
⚠️ 也正因为如此,同一个 CrawlerProcess 的 start() 只能调一次。我试着调了第二次,直接抛 twisted.internet.error.ReactorNotRestartable。原因是 reactor 一旦停了就没法重启。要跑第二批,就新建一个进程。
2. CrawlerRunner 自己管 reactor
第二种写法把 reactor 的生死交回给你。什么时候起、什么时候停,都写在你的代码里。
代价是多一个必须记住的顺序,install_reactor() 要排在 import reactor 之前。
| |
顺序为什么重要,我把写反的结果也测了。
顶层直接 from twisted.internet import reactor,拿到的是默认的 SelectReactor。而 settings 里 TWISTED_REACTOR 写的是 asyncio 版本,两边对不上,装配的时候会抛。
| |
单独看这个报错,信息很清楚,照着改就行。麻烦的地方在于它出现的位置。
我当时写的是 d.addBoth(lambda _: reactor.stop()),这个回调把异常顺手吞了。于是命令行里一行报错都没有,程序就那么停在那儿,日志停在 Enabled addons 那一行不动。我盯着屏幕看了半天才想起来去查 reactor 类型。
所以 addBoth 这种写法要留个心眼,它会把异常吃掉。想稳妥一点,用 addErrback 单独把失败打出来。
顺序写对之后一切正常。reactor 类型是 AsyncioSelectorReactor,两个蜘蛛的数据和上一种写法完全一致。
CrawlerRunner 真正的用途不是省事,是嵌进已有的 Twisted 应用。你的程序本来就跑在 Twisted 上,那就别再让 Scrapy 抢着建 reactor。
3. 规则表驱动,加一行多一个爬虫
前两种写法解决的是启动方式,这一节解决的是数量。
手上只有三五条规则的时候,一个蜘蛛一个文件挺好。规则变成几十条、还天天变的时候,为每条规则新建一个 spider 文件就不划算了。
做法是把所有会变的东西抽成参数,写一个什么都不写死的蜘蛛。
| |
配置以表的形式放在外面,crawl() 的关键字参数会原样传给蜘蛛的构造函数。
| |
三行配置跑出来 12 条数据,按 channel 分正好各 4 条。
这套写法的好处是代码和规则彻底分开。规则存在数据库里、配置中心里、Excel 里都行,读出来变成一串字典就能跑。加一条渠道只是表里多一行,不用碰代码,也不用重新发版。
有个细节要注意,channel 这种来源标记一定要自己带上。同一个输出文件里混着多个蜘蛛的数据,没有来源列,后面根本分不清哪条是哪个渠道抓的。
断点续爬与磁盘队列
到这里爬虫都能跑起来了。接下来处理「跑不完」这件事。
几十万条 URL 的爬虫没有一次跑完的运气。中途断电、机器重启、你手滑按了两次 Ctrl-C,都是常态。跑不完之后的正确姿势不是从头再来,是从断的地方接上。
1. JOBDIR 三件套
给爬虫一个目录就行。
| |
跑起来之后目录里会多出三样东西。

| 文件 | 作用 |
|---|---|
requests.queue/ | 还没抓的请求,按域分目录 |
requests.seen | 已经见过的请求指纹,就是去重表 |
spider.state | SpiderState 扩展存的自定义变量 |
我在同一个目录上跑了三次,数据很能说明问题。
| 轮次 | 参数 | item | 响应 | 完成原因 |
|---|---|---|---|---|
| 冷启动 | 无 | 20 | 25 | finished |
| 第一次 | 加 JOBDIR,页面数限 6 | 14 | 19 | closespider_pagecount |
| 第二次 | 同一个 JOBDIR | 6 | 6 | finished |
第二次只抓了 6 个页面就收工,比冷启动需要的 25 个少了一大截,因为队列里剩下的活儿本来就少了。两轮加起来 14 加 6 正好 20,一条没漏也一条没重。
第二次的统计里还有一行 dupefilter/filtered 为 5,那是去重表挡掉的重复请求。这就是 requests.seen 在干活。
⚠️ 用 JOBDIR 有一条纪律要守,改了蜘蛛代码就不要接着旧目录爬。队列里存的是序列化之后的请求对象,带着当时的 URL、回调名、meta。你改掉回调函数名或者 meta 的键,那些老请求读回来之后会指向一个不存在的方法。表现出来是蜘蛛启动正常、然后一条数据都不出。
配合它用的还有两个参数。CLOSESPIDER_PAGECOUNT 和 CLOSESPIDER_ITEMCOUNT 可以让你主动提前收工,我上面就是用前者造出了「跑一半」的场景。这两个参数在调试断点续爬的时候特别好用,不用真的去等着它崩。
2. 2.19 新增的 SQLite 队列
默认的磁盘队列有个隐患。写到一半进程被强杀,队列文件可能就损坏了,一整个目录的进度全废。
2.19 新加了四个基于 SQLite 的队列,直接解决这个问题。
| |
四个新类的名字挺规律,Pickle 和 Marshal 是序列化方式,Fifo 和 Lifo 是出队顺序。
换上去之后目录结构一模一样,文件名也一样,但内容是另一回事。我把两个队列的第一个文件头拉出来对比。
| 队列 | 文件头 | 大小 |
|---|---|---|
PickleLifoDiskQueue | 00 00 00 04 80 04 95 22 | 1224 字节 |
PickleLifoSQLiteQueue | SQLite format 3. | 12288 字节 |
右边那个一眼就能认出来是 SQLite。128 条请求下体积差了十倍,这是 SQLite 的页分配开销。
代价是写入变慢。官方说的是「每条请求单独一个事务,换来非正常退出也不会损坏磁盘队列」,速度上要付代价。我拿 300 条请求在两个队列上各压了一遍。
| 队列 | 写入 300 条 | 读出 300 条 | 磁盘占用 |
|---|---|---|---|
LifoDiskQueue | 0.1 毫秒 | 11.7 毫秒 | 0 KB |
LifoSQLiteQueue | 386.3 毫秒 | 377.5 毫秒 | 84 KB |
⚠️ 这组数字要读得准确一点。磁盘队列那个 0.1 毫秒不是因为它快,是因为它在内存里缓冲,磁盘占用 0 KB 也说明了这一点,真正的落盘发生在页写满或者关闭的时候。SQLite 那个 386 毫秒是每条都提交一次的真实成本。
所以取舍很清楚。抓取量大、机器可能随时被回收的场景,用 SQLite 换可靠性;追求调度吞吐、能接受偶尔重来的场景,用默认队列。
契约测试
一个爬虫上线之后最先失效的不是逻辑,是选择器。目标站改一次页面结构,你的 CSS 选择器就开始返回空值。千万别以为这样没事,它不报错,只是数据变少了。
契约测试就是为了让这件事变成一条断言。
1. 契约写在 docstring 里
这是最容易看错的一点。契约不是 Python 装饰器,它写在回调的文档字符串里,@ 开头,一行一条。
| |
跑起来就一个命令。
| |
我写了三个回调、每条挂两到三个契约,一共 6 条,全通过。
| |
三个契约的含义值得逐个说清楚。@url 告诉它拿哪个地址做样本请求,这是唯一的必需项。@returns 断言输出条数,item 1 表示恰好一条 item。@scrapes 断言 item 里必须有哪些字段。
⚠️ 这三件事加起来,正好覆盖了选择器失效最常见的表现。@scrapes 是最值钱的那条,页面结构变了、某个字段取成 None,scrapy check 会立刻告诉你少字段,而不是等线上数据攒了两周才发现。
2. 2.19 新增的请求侧契约
以前契约只能断言响应侧的东西。要测一个 POST 接口就很别扭,没法在契约里声明请求方法。
2.19 补上了这一块,一次加了四个。
| 契约 | 作用 | 写法 |
|---|---|---|
@method | 声明请求方法 | @method POST |
@body | 带上请求体 | @body q=scrapy&page=1 |
@header | 加一个请求头 | @header Content-Type application/x-www-form-urlencoded |
@cookie | 加一个 Cookie | @cookie session demo |
⚠️ 这四个的语法都是名字和值用空格分开,不是冒号。我第一版顺手写成了 @header Content-Type: application/x-www-form-urlencoded,结果抛出来一个挺难懂的错。
| |
它把 Content-Type: 整个当成了请求头的名字。因为 HeaderContract 的实现是取第一个参数当名字、剩下的用空格拼起来当值,那个冒号就留在名字里了。
除了这四个,2.19 还有两处契约相关的改进。契约现在支持 async def 回调,包括异步生成器,写协程回调的蜘蛛也能用契约了。另外官方把自定义契约的扩展点改了,以前要重写 add_pre_hook() 和 add_post_hook(),现在改成定义 pre_process() 和 post_process(),老写法会打弃用警告。
3. 让它会失败
通过的输出没什么信息量,失败的样子才值得看。
我故意把 @scrapes 里加了一个不存在的字段,输出长这样。
| |
报错里三样东西都有。哪个蜘蛛、哪个回调、哪条契约、以及缺了哪个字段。这个格式直接扔进 CI 就行,退出码非零,流水线会红。

还有两个参数平时很有用。scrapy check -l 只列出有哪些契约不执行,改完一堆 docstring 之后想确认自己没写漏很方便。scrapy check -a name=value 可以把蜘蛛参数传进去,跟 crawl 的 -a 一样,用来测那些依赖参数的蜘蛛。
我的建议是给每个线上的蜘蛛至少配一条契约,就用真实页面地址。它运行成本极低,一个站点一秒钟就跑完了,但能提前告诉你页面改了。
容易栽的跟头
坑 1:管道和中间件方法里留着 spider 参数。 这是 2.19 里刚收紧的。open_spider(self, spider)、process_item(self, item, spider)、close_spider(self, spider) 这三个签名带上 spider 就会打弃用警告,原文如下。
| |
官方在警告里直接把替代写法说了,from_crawler 里存下 crawler,后面用 self.crawler.spider。我觉得这个改动值得顺手改掉,理由是它在 2.19 里已经同时覆盖下载器中间件、蜘蛛中间件和管道三处,下一轮就会真的不传参了。
这事儿还有个背景。以前 spider 参数是官方推荐的拿蜘蛛实例的方式,老教程全这么写。所以你要是从老项目搬代码过来,这三个签名基本都是带参数的版本,一跑就是一屏警告。
坑 2:在同一个进程里第二次调 CrawlerProcess.start()。 想「先跑完 A 再跑 B」,直觉写法是两次 start。我实测的结果是这样。
| |
注意最后那行冒号后面是空的。这个异常的消息体是空字符串,日志里就只有一行光秃秃的类名,很容易被当成别的问题。原因在 Twisted,reactor 是进程级单例,跑过一次就不能再跑。正确写法是把两个 crawler 都 crawl() 进同一个 CrawlerProcess,只 start 一次,Scrapy 会按顺序排队。
坑 3:以为 CrawlerProcess 上有 .signals。 我当初写多蜘蛛调度脚本的时候第一版就是这么写的,直接抛 AttributeError: 'CrawlerProcess' object has no attribute 'signals'。说到底这是个概念没摆正的问题,信号挂在 crawler 上,不在 process 上。要接信号得先拿到 crawler。
| |
create_crawler() 收的是蜘蛛名或者 Spider 类,不收 模块路径:类名 那种字符串。传错了报 KeyError: 'Spider not found: ...'。
坑 4:在 spider_closed 回调里读 elapsed_time_seconds。 这个统计项是 CoreStats 扩展在它的 spider_closed 里写进去的,而你的回调跟它谁先执行没有保证。我前几次跑出来一直是 None,换成在 spider_opened 里用 time.perf_counter() 自己掐表就稳了。
顺带一句,同类的还有 retry/reason_count 这几个统计。我在反爬那篇里读 429 的重试次数,键名是 '429 Unknown Status' 而不是 429,因为 Twisted 的状态短语表里没有这个码。想稳妥一点就按前缀遍历,别按精确键名取。
坑 5:改了蜘蛛代码,接着旧 JOBDIR 爬。 这条正文提到过,我在这里把它说透,因为它是最贵的一个坑。队列文件里存的是序列化之后的请求对象,带着当时的 URL、回调方法名和 meta。你把回调函数改个名字、或者调整了 meta 的键,那些老请求读回来之后指向的是一个不存在的方法。
它的表现形式特别难查。蜘蛛启动正常、日志正常、退出原因正常,就是一条数据都不出,连异常都没有。所以要么换一个新目录,要么先清掉 requests.queue/ 和 requests.seen 再重跑。老实说这条纪律写进注释里容易,真到赶进度的时候最容易忘,我的习惯是把目录名带上当天日期,跨天就一定是新目录。
坑 6:契约写在注释里,不写在 docstring 里。 契约只从回调函数的文档字符串里读,@ 开头,一行一条。函数体里的 # 注释它一眼都不看。我拿一个把契约写进注释的蜘蛛试了一下,输出是这样。
| |
契约数是零,退出码也是零。这个静默比报错难对付,因为它在 CI 里是绿的,看起来一切正常,实际上你一条断言都没有。所以新写契约之后,我建议先跑一次 scrapy check -l 把契约列出来,确认数量对得上再往下走。那三个 @url、@returns、@scrapes 写在 docstring 的哪一行无所谓,写在注释里就等于没写。
小结
这一篇三块内容,各管一件事。
从命令行到脚本那块,管的是别人能不能用你的爬虫。CrawlerProcess 适合一把跑完的场景,多蜘蛛排队、参数从代码传进去,都在它这儿。要长驻、要反复跑、要在自己的 asyncio 程序里嵌,就换 CrawlerRunner,reactor 交给自己管。
断点续爬那块,管的是跑不完的时候怎么办。JOBDIR 三件套给的是最小可用的方案,默认磁盘队列在进程被强杀时确实有损坏风险,SQLite 队列用写入速度换这份可靠性。我实测那组数字挺说明问题,300 条请求的写入从 0.1 毫秒变成 386 毫秒,差了三千多倍,但那个 0.1 毫秒是内存缓冲的假象,别拿它当真性能。
契约测试那块,管的是失效的时候你能不能第一时间知道。三块里我觉得它的性价比最高,一个站点一秒钟跑完,@scrapes 少一个字段就红给你看。上线之后真正会坏的东西不是你的逻辑,是别人家的页面结构,而这个东西只能靠定期去戳一下才知道。
要说我在这篇上花时间最多的地方,其实是调度脚本。CrawlerProcess 那个 ReactorNotRestartable 我试了两次才反应过来是设计如此,.signals 挂在哪也是翻了半天。这些都不是难懂的概念,纯粹是没有文档会主动告诉你的东西,所以我在这篇里把它们都写出来了。
这篇里要是有哪里讲得不对,欢迎拍砖。