3.3 Signals:框架的广播频道 本节摘要:信号是框架在旅程关键节点发出的广播:引擎启动、请求调度、下载失败、Item 落库、爬虫关闭,每个事件都有对应信号可订阅。本节讲订阅模式与高频信号的实战用法,让你在不侵入框架源码的前提下给旅程安插眼线。 本章前两节管住了"票怎么排、拨盘怎么调",还有一个盲区:运行中的事件怎么感知?Spider 回调只覆盖"到站"这一种事件,而工程上你关心的远不止于此——爬虫为什么停了、失败率什么时候升的、Item 落了多少。信号系统就是框架对这些问题的正规回答:每个旅程节点广播一个事件,你订阅感兴趣的频道。
本节摘要:信号是框架在旅程关键节点发出的广播:引擎启动、请求调度、下载失败、Item 落库、爬虫关闭,每个事件都有对应信号可订阅。本节讲订阅模式与高频信号的实战用法,让你在不侵入框架源码的前提下给旅程安插眼线。
本章前两节管住了"票怎么排、拨盘怎么调",还有一个盲区:运行中的事件怎么感知?Spider 回调只覆盖"到站"这一种事件,而工程上你关心的远不止于此——爬虫为什么停了、失败率什么时候升的、Item 落了多少。信号系统就是框架对这些问题的正规回答:每个旅程节点广播一个事件,你订阅感兴趣的频道。
信号通过调度器连接,处理函数与信号签名匹配即可:
from scrapy import signals from scrapy.signalmanager import dispatcher def on_item_scraped(item, response, spider): # Item 通过了全部管道:落库成功一次,广播一次 print("[统计] 已产出:", item.get("title")) def on_spider_closed(spider, reason): # reason 是关闭原因字符串:finished 表示正常跑完 print("[收尾] 爬虫关闭,原因:", reason) dispatcher.connect(on_item_scraped, signal=signals.item_scraped) dispatcher.connect(on_spider_closed, signal=signals.spider_closed)
每个信号携带的参数固定:item_scraped 带 item、response、spider 三个实参;spider_closed 带 spider 与 reason。签名不匹配会在触发时报错,所以订阅前先查该信号的参数表。
工程里更常见的做法是把订阅写进扩展类——从 SpiderManager 拿到 crawler 对象后在其 signals 上挂接。下面这个收尾统计扩展是实战模板:
class CloseStatsExtension: def __init__(self, crawler): crawler.signals.connect(self.spider_closed, signal=signals.spider_closed) crawler.signals.connect(self.item_scraped, signal=signals.item_scraped) self.count = 0 @classmethod def from_crawler(cls, crawler): return cls(crawler) def item_scraped(self, item, response, spider): self.count += 1 def spider_closed(self, spider, reason): spider.logger.info("本次旅程共产出 %s 条数据,关闭原因 %s", self.count, reason)
在 settings 的扩展清单里登记即启用:
EXTENSIONS = { "myproject.extensions.CloseStatsExtension": 0, }
| 信号 | 触发节点 | 典型用途 |
|---|---|---|
| engine_started / engine_stopped | 引擎开关 | 进程级监控、资源清理 |
| spider_opened / spider_closed | 爬虫开关 | 收尾统计、失败原因上报 |
| request_scheduled | 票进候车大厅 | 调度审计、延迟观测 |
| request_left_downloader | 票离开下行隧道 | 与调度时刻相减算排队耗时 |
| response_downloaded | 下载完成 | 体量统计、慢站发现 |
| item_scraped | Item 过完全部管道 | 落库计数、增量告警 |
| item_error | 管道抛错 | 数据质量告警 |
这张表的用法不是背下来,而是遇到问题时查"这个事件有没有对应的频道"。比如想回答"请求从排队到发出花了多久",取 request_scheduled 与 request_left_downloader 的时间差即可——这比翻框架源码打补丁便宜太多。
# 运行日志中,信号驱动的扩展输出长这样 2026-08-29 12:40:11 [bookstation] INFO: 本次旅程共产出 340 条数据,关闭原因 finished
两者都能"在旅程中插手",分界线是:中间件能改数据流,信号只能看。要改请求头、拦响应,用中间件;要计数、告警、收尾,用信号。把观测逻辑写进中间件是常见的过度设计——观测代码不该有能力影响主流程,这是权限最小化原则在爬虫工程里的投影。
⚠️ 常见坑:在信号处理函数里抛异常不会中断爬虫,但会污染日志并可能吞掉后续订阅者的执行。观测代码必须自带兜底——它自己是工具,不该成为新的故障点。
信号最出彩的场景是回答"时间都花在哪了"。某次排查:爬虫跑得比平时慢一倍,统计里响应延迟却正常。用两个调度相关信号打点,把每张票的排队耗时算出来:
import time class LatencyProbeExtension: def __init__(self, crawler): self.crawler = crawler self.scheduled = {} # 请求指纹 到 调度时刻 crawler.signals.connect(self.on_scheduled, signal=signals.request_scheduled) crawler.signals.connect(self.on_left, signal=signals.request_left_downloader) @classmethod def from_crawler(cls, crawler): return cls(crawler) def on_scheduled(self, request, spider): self.scheduled[request.fingerprint] = time.monotonic() def on_left(self, request, spider): t0 = self.scheduled.pop(request.fingerprint, None) if t0: wait = time.monotonic() - t0 if wait > 5: spider.logger.warning("票排队 %s 秒才发车: %s", round(wait, 1), request.url)
运行结果很快给出答案:大量票排队超过 5 秒,且集中在同一域名。排队久而下载快,说明瓶颈在候车侧的并发闸门——单域并发太保守。把 CONCURRENT_REQUESTS_PER_DOMAIN 上调后恢复正常。这次排查的模式值得记下:先分清"慢在排队还是慢在下载",再决定调哪组参数——两类慢的药方完全不同,而信号是把两者分开的唯一干净手段。
候车大厅到此走完:排队、查重、拨盘、广播,你已能解释请求发出前的一切。下一章,票真正上路——下行隧道里的中间件与下载器,是全框架对抗最激烈的一段。