网虫Spider订阅
← 文章列表
源码剖析6 min read

FEEDS 里写错一个字母,另一个完全正确的输出也消失了:item 抓到 3 条,文件 0 个

FEEDS 的校验在遍历循环里 raise NotConfigured,而这会让整个导出扩展被停用——不是跳过那个配错的输出,是全部。配两个输出、其中一个格式名拼错,两个都不会生成。

配两个输出,一个 JSON Lines 给下游,一个 CSV 给人看:

FEEDS = {
    "out.jsonl": {"format": "jsonlines"},
    "out.csv":   {"format": "csvv"},        # ← 手滑多打了一个 v
}

跑完,两个文件都没有。不是 CSV 那个没有,是两个都没有。

爬取本身完全正常:3 个请求、3 条 item、finish_reason: finished、退出码 0。

一、为什么一个错会连累全部#

校验写在 FeedExporter 的初始化里,遍历 FEEDS

scrapy/extensions/feedexport.py
for uri, feed_options in self.feeds.items():
    if not self._storage_supported(uri, feed_options):
        raise NotConfigured
    if not self._settings_are_valid():
        raise NotConfigured
    if not self._exporter_supported(feed_options["format"]):
        raise NotConfigured

三处 raise NotConfigured,都在循环体内。

NotConfigured 在 Scrapy 的扩展加载协议里有特定含义:「这个组件不该启用」。扩展管理器捕获它,把这个扩展从启用列表里拿掉,然后继续加载别的组件。

所以链条是:

Call chain
  1. 01FEEDS 里第 2 项格式名拼错_exporter_supported 返回 False
  2. 02raise NotConfigured在遍历循环里抛出,循环终止
  3. 03ExtensionManager 捕获按协议理解为「这个扩展不该启用」
  4. FeedExporter 整个被停用第 1 项那个完全正确的输出也一起没了

它没有「跳过坏的、保留好的」这个中间状态。 校验是全有或全无的。

二、实测四种情况#

每个场景独立子进程(Twisted 的 reactor 一个进程只能跑一次),用本机 HTTP 服务,不联网。

scrapy 2.17.0  python 3.11.13
 
──── ① 一个正确的输出(对照组)
  退出码 0
  [统计] 请求=3  抓到 item=3  结束原因=finished
  [feed统计] {'feedexport/success_count/FileFeedStorage': 1}
  [产物] out.jsonl  165 字节  3 行
 
──── ② 格式名拼错:jsonlinez
  退出码 0
  [统计] 请求=3  抓到 item=3  结束原因=finished
  [feed统计] (一条都没有)
  [产物] 目录是空的
  [日志] ERROR: Unknown feed format: jsonlinez
 
──── ③ 一对一错 —— 对的那个还写得出来吗
  退出码 0
  [统计] 请求=3  抓到 item=3  结束原因=finished
  [feed统计] (一条都没有)
  [产物] 目录是空的
  [日志] ERROR: Unknown feed format: jsonlinez
 
──── ④ 存储协议不认识:ftpz://
  退出码 0
  [统计] 请求=3  抓到 item=3  结束原因=finished
  [feed统计] (一条都没有)
  [产物] 目录是空的
  [日志] ERROR: Unknown feed storage scheme: ftpz

场景 ③ 就是开头那个例子:out.jsonl 的配置一个字都没错,它照样没生成。

三、最难受的一点:连一个可以报警的统计项都没有#

看正常情况下的统计:

{ "feedexport/success_count/FileFeedStorage": 1 }

再看失败时:

[feed统计] (一条都没有)

不是 failed_count: 1,是这个前缀下一个键都没有。

因为扩展压根没启用,feedexport/* 这些计数器从来没被创建过。存储层确实有失败计数:

scrapy/extensions/feedexport.py
except Exception:
    logger.error("Error storing %s", logmsg, exc_info=True, ...)
    self.crawler.stats.inc_value(f"feedexport/failed_count/{slot_type}")

但那是写文件时出错才会走到的路径——比如磁盘满了、S3 凭证过期。配置写错的情况根本走不到那里,因为扩展在启动阶段就下线了。

四、两条能用的检查#

一、断言导出成功过#

from scrapy import signals
 
class AssertExported:
    """item 抓到了但一个文件都没写出来,这不是成功。"""
 
    def __init__(self, crawler):
        self.crawler = crawler
 
    @classmethod
    def from_crawler(cls, crawler):
        ext = cls(crawler)
        # 必须用 engine_stopped,不能用 spider_closed —— 见下面那段
        crawler.signals.connect(ext.closed, signal=signals.engine_stopped)
        return ext          # ← 忘了 return,这个扩展自己就静默失效了
 
    def closed(self):
        st = self.crawler.stats
        items = st.get_value("item_scraped_count", 0)
        ok = sum(v for k, v in st.get_stats().items()
                 if k.startswith("feedexport/success_count/"))
        if items and not ok:
            self.crawler.spider.logger.error(
                "抓到 %d 条 item,但没有任何 feed 导出成功 —— "
                "多半是 FEEDS 配置有误,导出扩展在启动阶段就被停用了", items
            )
 
        # 顺带直接问一句它在不在,比从结果反推更直接
        names = {type(e).__name__ for e in self.crawler.extensions.middlewares}
        if "FeedExporter" not in names:
            self.crawler.spider.logger.error("FeedExporter 未启用 —— 检查 FEEDS 配置")

关键是 items and not ok 这个组合。单看哪一个都不够:没抓到 item 时没有导出是正常的,抓到了却没导出才是问题。

二、上线前先干跑一次#

配置错误在启动阶段就能暴露,不需要真的爬完:

scrapy crawl myspider -s CLOSESPIDER_ITEMCOUNT=1 2>&1 | grep -i "feedexport"

看到 ERROR: Unknown feed format 就说明配错了。看到 Stored ... feed (1 items) in: ... 才算通过。

改过 FEEDS 就跑一次这个,比爬三小时之后发现目录是空的便宜得多。

五、这个设计其实说得通#

值得说清楚:raise NotConfigured 在这里不算 bug。

NotConfigured 是 Scrapy 组件协议的一部分,语义就是「我不该被启用」。FeedExporter 拿不准该导出到哪,选择整体下线,比「部分导出」要诚实——半份数据比没有数据更危险,尤其当下游按文件存在与否判断任务成败时。

真正的问题是这个决定在运行时不可见

你看到的实际发生的
退出码 0爬取确实成功了
finish_reason: finished引擎确实正常结束
item_scraped_count: 3item 确实抓到了
目录是空的导出扩展从一开始就没启用

日志里那条 ERROR 是唯一的线索,而它出现在启动阶段——长跑任务里,它在几万行日志的最上面。

六、系列小结#

四篇了,四种不同的静默失效:

失效点表面现象
1start_requests() 已无人调用请求数 0
2下载处理器建不起来报「不支持的协议」
3settings.set() 优先级低被丢弃配置没生效
4FEEDS 一处配错,全部停用目录是空的

四个的退出码都是 0,finish_reason 都是 finished

如果只能记一条:不要用「有没有报错」判断任务成功,要用「该出现的东西出现了没有」。

具体到爬虫,最少要断言三个数:请求数不为 0、item 数不为 0、导出成功数不为 0。三个都是一行代码,而它们盖住了这四篇里的每一种失效。


参考#


Minner
Minner

长期做数据采集与逆向分析,同时负责采集系统后端与数据管道。方向集中在自媒体与电商平台的数据获取、接口协议还原,以及在此之上的数据工程与分析。