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

raise NotConfigured 带不带消息,决定了它是一条 WARNING 还是零日志——而管道就是这么没的

MiddlewareManager 里那句 if e.args 决定一切:带消息的 NotConfigured 打一条 WARNING,不带的一行日志都没有。实测一个裸 raise NotConfigured 的管道——item 抓到 3 条,落库 0 条,退出码 0,日志零行。

前一篇讲 FEEDS 配错会让整个导出扩展下线。那篇没往下追的是为什么它这么安静

答案在 MiddlewareManager 里,一个 if

scrapy/middleware.py
for clspath in mwlist:
    try:
        mwcls = load_object(clspath)
        mw = build_from_crawler(mwcls, crawler)
        middlewares.append(mw)
        enabled.append(clspath)
    except NotConfigured as e:
        if e.args:                      # ← 就是这里
            logger.warning(
                "Disabled %(clspath)s: %(eargs)s",
                {"clspath": clspath, "eargs": e.args[0]},
                extra={"crawler": crawler},
            )

raise NotConfigured("需要 API key") → 打一条 WARNING。

raise NotConfigurede.args 是空元组,if 不成立,一行日志都没有

而「不带消息地 raise」恰恰是最自然的写法。

一、四种写法,四种结果#

scrapy 2.17.0  python 3.11.13
 
──── ① raise NotConfigured("需要 FOO_API_KEY")
  退出码 0
  与该扩展有关的日志: WARNING: Disabled __main__.WithMessage: 需要 FOO_API_KEY
  [启用的扩展] ['CoreStats', 'Fine', 'LogCount', 'LogStats', 'MemoryUsage']
 
──── ② raise NotConfigured        (无括号)
  退出码 0
  与该扩展有关的日志: (一行都没有)
  [启用的扩展] ['CoreStats', 'Fine', 'LogCount', 'LogStats', 'MemoryUsage']
 
──── ③ raise NotConfigured()      (有括号无参数)
  退出码 0
  与该扩展有关的日志: (一行都没有)
  [启用的扩展] ['CoreStats', 'Fine', 'LogCount', 'LogStats', 'MemoryUsage']
 
──── ④ raise RuntimeError         (不是 NotConfigured)
  退出码 1
  与该扩展有关的日志: Traceback (most recent call last):

②③ 完全一样——NotConfiguredNotConfigured()args 都是空元组。

④ 值得单独说:只有 NotConfigured 被捕获,别的异常一路上抛,启动直接失败、退出码 1。这个设计是对的——「组件按配置就该关掉」和「组件坏了」是两回事,后者必须响亮。

问题在于前者安静得过头了。

二、同一段代码管着四种组件#

scrapy/extension.py:18                class ExtensionManager(MiddlewareManager)
scrapy/core/spidermw.py:48            class SpiderMiddlewareManager(MiddlewareManager)
scrapy/core/downloader/middleware.py:34  class DownloaderMiddlewareManager(MiddlewareManager)
scrapy/pipelines/__init__.py:31       class ItemPipelineManager(MiddlewareManager)

扩展、爬虫中间件、下载中间件、Item Pipeline——全走同一个 from_crawler

扩展悄悄没了,通常只是少个功能。管道悄悄没了,是数据没落库。

三、管道版实测#

一个再普通不过的管道:需要 DB_DSN 才能工作,没配就停用。作者用了最自然的写法。

02-管道被静默停用.py
class SavePipelineSilentlyDead(SavePipeline):
    """少配了一项,作者用了最自然的写法:裸 raise NotConfigured"""
 
    @classmethod
    def from_crawler(cls, crawler):
        if not crawler.settings.get("DB_DSN"):
            raise NotConfigured                  # ← 没带消息
        return cls(Path(crawler.settings["OUT_FILE"]))

跑三个请求,每个产出一条 item:

──── ① 管道正常(对照组)
  退出码 0
  [统计] item_scraped_count=3  finish_reason=finished
  [落库] 3 条
  [启用的管道] ['__main__.SavePipeline']
 
──── ② 管道裸 raise NotConfigured(少配 DB_DSN)
  退出码 0
  [统计] item_scraped_count=3  finish_reason=finished
  [落库] 0 条
  [日志] 关于管道被停用:一行都没有
  [启用的管道] []

如果你的监控看的是 item_scraped_count——很多人是这么做的——这次运行看起来完美

四、唯一的线索#

[启用的管道] [] 这一行来自 Scrapy 自己打的 INFO 日志:

logger.info(
    "Enabled %(componentname)ss:\n%(enabledlist)s",
    {"componentname": cls.component_name, "enabledlist": pprint.pformat(enabled)},
    extra={"crawler": crawler},
)

启动时它会打出来:

[scrapy.middleware] INFO: Enabled item pipelines:
[]

这是唯一的信号,而且它是 INFO 级、出现在启动阶段、内容是一个空列表。 在几万行日志的最上面,长得完全不像一个问题。

五、三条应对#

一、自己写的组件,NotConfigured 一律带消息#

# 别这么写
raise NotConfigured
 
# 这么写
raise NotConfigured("未配置 DB_DSN,SavePipeline 已停用")

一个字符串的成本,换一条 WARNING。这是投入产出比最高的一条。

顺带一提,消息里写清楚是谁、缺什么——logger.warning 的格式是 Disabled <clspath>: <消息>,clspath 它会替你带上,你负责说清缺什么。

二、断言关键组件真的在#

from scrapy import signals
 
class AssertPipelines:
    """管道被静默停用时,item_scraped_count 照常增长,看不出任何异常。"""
 
    REQUIRED = {"SavePipeline"}
 
    def __init__(self, crawler):
        self.crawler = crawler
 
    @classmethod
    def from_crawler(cls, crawler):
        ext = cls(crawler)
        crawler.signals.connect(ext.opened, signal=signals.spider_opened)
        return ext          # ← 忘了 return,这个扩展自己就静默失效了
 
    def opened(self):
        names = {type(p).__name__ for p in self.crawler.engine.scraper.itemproc.middlewares}
        missing = self.REQUIRED - names
        if missing:
            raise RuntimeError(f"必需的管道没有启用:{sorted(missing)}")

挂在 spider_opened 上、而且直接抛异常——这类问题越早炸越好,跑完三小时再报警没有意义。

三、把启动日志里那两行捞出来#

上线前扫一眼,比事后查便宜得多:

scrapy crawl myspider -s CLOSESPIDER_ITEMCOUNT=1 2>&1 \
  | grep -A1 -E "Enabled (extensions|item pipelines|downloader middlewares|spider middlewares)"

看到任何一个是 [],或者少了你以为该在的东西,就说明有组件被悄悄停用了。

六、系列到这里#

五篇,五种静默失效:

失效点表面现象
1start_requests() 已无人调用请求数 0
2下载处理器建不起来且永久缓存报「不支持的协议」
3settings.set() 优先级低被丢弃配置没生效
4FEEDS 一处配错,导出全停目录是空的
5NotConfigured 不带消息 = 零日志管道没了,item 照常「抓到」

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

第 5 篇是第 4 篇的成因。查一下 FeedExporter 就知道它有多清楚这件事:

scrapy/extensions/feedexport.py
def _exporter_supported(self, format_: str) -> bool:
    if format_ in self.exporters:
        return True
    logger.error("Unknown feed format: %(format)s", {"format": format_})   # ← 自己先打
    return False
 
# 调用处
if not self._exporter_supported(feed_options["format"]):
    raise NotConfigured                                                     # ← 裸的

它的 raise NotConfigured 是裸的feedexport.py 的 505、507、509 三处都是),换句话说,靠 MiddlewareManager 那条 WARNING 是等不到的。第四篇里能看到那条 ERROR: Unknown feed format,是因为校验函数在 raise 之前自己先打了一条

这等于官方组件在用一个变通手法绕开本文说的这个坑——而且用的是 ERROR 级,比 WARNING 更响。你自己写组件时,要么学它先自己打日志,要么更简单:NotConfigured 带上消息

至于第四篇为什么还是那么难发现:那条 ERROR 出现在启动阶段,而失败的后果(目录是空的)要到结束时才显现。

把五篇的教训压成一句:


参考#


Minner
Minner

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