首页
/ Scrapy SEP-004:从“Library API”提案到 CrawlerRunner 脚本化运行体系

Scrapy SEP-004:从“Library API”提案到 CrawlerRunner 脚本化运行体系

2026-09-04 19:12:41作者:虞亚竹Luna

SEP-004 是 Scrapy 2009 年提出的一份早期增强提案,核心诉求只有一个:让 Scrapy 能被当作一个标准库直接 import 使用——只写回调函数就能跑爬虫,而不用先建一整个项目。这份提案最终被“部分实现”:官方给出的实现路径是 CrawlerCrawlerRunnerCrawlerProcess 这组类(见 scrapy/crawler.py),并且明确了一个边界:爬虫必须在 Twisted reactor(或现代的 asyncio 事件循环)内部运行,不能在外部独立启动。本文以这份归档提案为骨架,对照当前仓库源码,讲清楚提案中三个设计点——轻量入口、Crawler 类实例化、Spider Manager——各自演变成了什么。

一、提案背景:像 os.walk 一样用 Scrapy

sep/sep-004.rst 开篇的动机非常直白:希望 Scrapy 提供一个“快速、轻量”的机制,只靠回调函数就能定义爬虫,从而在脚本里像调用标准库(文档以 os.walk 作类比)一样调用 Scrapy,免去“从零搭建整个项目”的开销。提案给出的概念验证代码长这样:

#!/usr/bin/env python
from scrapy.http import Request
from scrapy import Crawler

# 存放抓取结果的容器
scraped_items = []

def parse_start_page(response):
    # 把要跟随的 URL 收集到 urls_to_follow 列表
    requests = [Request(url, callback=parse_other_page) for url in urls_to_follow]
    return requests

def parse_other_page(response):
    # ... 从 response 内容中解析 items ...
    scraped_items.extend(parsed_items)

start_urls = ["http://www.example.com/start_page.html"]

cr = Crawler(start_urls, callback=parse_start_page)
cr.run()  # 阻塞调用 —— 执行完后 scraped_items 被填充

print("%d items scraped" % len(scraped_items))
# ... 对 scraped_items 做更有意义的事 ...

提案还强调了两点:其一是行为仍由 Scrapy settings 控制,默认 settings 应当足够用、不需要额外配置,但需要时(例如挂自定义中间件)也能注入;其二是实现成本不高,因为这只是 Scrapy 现有能力的一个(小)子集,同时能成为吸引新用户的加分项。

文档开头的 note 直接给出了最终结论:

the library API has been implemented, but slightly different from proposed in this SEP. You can run a Scrapy crawler inside a Twisted reactor, but not outside it.

也就是说,“回调直传 Crawler(start_urls, callback=...) + 阻塞式 run()”这个具体 API 没有落地,取而代之的是 Spider 类 + Crawler/CrawlerRunner/CrawlerProcess 的组合,且运行必须依托一个事件循环。

二、提案与实现的对照:Crawler 类

提案“Crawler class”一节设想 Crawler 的实例参数包括 enginesettingsspidersextensions(并指出当时这些大多还是单例)。对照当前实现 Crawler.init

def __init__(
    self,
    spidercls: type[Spider],
    settings: dict[str, Any] | Settings | None = None,
    init_reactor: bool = False,
):
    ...
    self.spidercls: type[Spider] = spidercls
    self.settings: Settings = settings.copy()
    self.spidercls.update_settings(self.settings)
    ...
    self.addons: AddonManager = AddonManager(self)
    self.signals: SignalManager = SignalManager(self)
    self._init_reactor: bool = init_reactor

可以看到提案的意图得到了结构性继承,但形态发生了关键变化:

  • 实例化取代单例Crawler 现在接受一个 spider 类和一份 settings,并在内部复制 settings(settings.copy()),把 spider 的 custom_settings 合并进来——每个 Crawler 拥有独立的 engine、extensions、中间件与解析后的配置。这正对应了 docs/topics/practices.rst 中“同一进程内跑多个 spider”章节的表述:每次 crawl() 调用都会创建一个独立的 Crawler,其余 spider 之间不共享任何东西。
  • 延迟属性机制engineextensionslogformatterrequest_fingerprinterstats 这些提案里设想为构造参数的组件,如今通过 _LateAttribute 描述符(scrapy/crawler.py#L61-L98)管理——它们在 crawl() 启动时才真正构建,提前读取会抛出 RuntimeError 并提示“它在爬虫启动时才被设置”。这解决了提案未涉及的时序问题:组件必须等 settings 完整合并(含 reactor 安装)后才能安全构建。
  • 启动链路Crawler.crawl()scrapy/crawler.py#L259-L290)依次做四件事:实例化 spider(self.spidercls.from_crawler(self, ...))→ _apply_settings()(安装/校验 reactor、构建 ExtensionManager、冻结 settings、打印 overridden settings)→ 创建 ExecutionEngine → 打开 spider 并启动引擎。2.14 起还新增了协程版 crawl_async()。注意其中两个防御性检查:重复调用 crawl() 会抛 RuntimeError(“Crawling already taking place” / “Cannot run ... more than once on the same instance”),与提案中“一次脚本跑一个爬虫”的轻量模型一致。

提案里“阻塞式 cr.run()”的等价物在实现中由上层 runner/process 承担,这正是下一节的主题。

三、提案的“子集”落地:从脚本运行 Scrapy

Crawler 只是引擎宿主,真正替代提案中 Crawler(start_urls, callback=...).run() 体验的是 CrawlerRunnerCrawlerProcess 两层封装(均定义在 scrapy/crawler.py),官方使用文档见 docs/topics/practices.rst 的 “Run Scrapy from a script” 章节。

3.1 CrawlerProcess:脚本场景的一站式入口

CrawlerProcess 负责启动 reactor、配置顶层日志、安装 Ctrl-C 等信号处理器——也就是提案中“run() 阻塞调用”想一步到位干的事。最小示例(来自官方文档):

import scrapy
from scrapy.crawler import AsyncCrawlerProcess

class MySpider(scrapy.Spider):
    # 你的 spider 定义
    ...

process = AsyncCrawlerProcess(
    settings={
        "FEEDS": {
            "items.json": {"format": "json"},
        },
    }
)

process.crawl(MySpider)
process.start()  # 脚本会阻塞在这里,直到爬取结束

注意提案中“默认 settings 应当足够、但也可以覆盖”的设想如何落地:settings 参数直接接受 dict,CrawlerRunnerBase.__init__ 会把它包成 Settings 对象(scrapy/crawler.py#L440-L447);若传入的是 Crawler 实例,create_crawler() 会把 runner 的 settings 作为低优先级默认值合并进它(scrapy/crawler.py#L455-L479),即“Crawler 已有且优先级不低的设置保持不变”。

在现代 Scrapy 中这套 API 分协程/Deferred 两族:AsyncCrawlerProcess/AsyncCrawlerRunner 提供 async/await 接口,CrawlerProcess/CrawlerRunner 提供 Twisted Deferred 接口。另外还有一个重要分叉:当 TWISTED_REACTOR_ENABLED 设为 False 时,Scrapy 可以完全脱离 Twisted reactor、基于 asyncio 事件循环运行,此时下载器切换到 _httpx 处理器并禁用 telnet 控制台(见 Crawler._apply_reactorless_default_settings()scrapy/crawler.py#L242-L254):

import asyncio

import scrapy
from scrapy.crawler import AsyncCrawlerRunner
from scrapy.utils.log import configure_logging

class MySpider(scrapy.Spider):
    ...

async def main():
    configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"})
    runner = AsyncCrawlerRunner(settings={"TWISTED_REACTOR_ENABLED": False})
    await runner.crawl(MySpider)  # spider 结束时完成

asyncio.run(main())

CrawlerProcess.start() 本身的行为(scrapy/crawler.py#L870-L897):按 TWISTED_DNS_RESOLVER 安装 DNS 解析器、把线程池调整到 REACTOR_THREADPOOL_MAXSIZE、注册 stop() 作为 join() 完成后的收尾动作,最后 reactor.run() 阻塞运行;重复发送关闭信号会触发强制退出(_signal_shutdown / _signal_kill)。

3.2 CrawlerRunner:嵌入已有 Twisted 应用的细粒度封装

CrawlerRunner 是更薄的封装:它只管理爬虫、不碰 reactor,适合你的应用已经在运行 Twisted 的场景。官方示例展示了手动管理 reactor 的完整写法:

import scrapy
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.reactor import install_reactor
from twisted.internet.task import react

class MySpider(scrapy.Spider):
    custom_settings = {
        "TWISTED_REACTOR": "twisted.internet.epollreactor.EPollReactor",
    }
    ...

def crawl(_):
    configure_logging({"LOG_FORMAT": "%(levelname)s: %(message)s"})
    runner = CrawlerRunner()
    d = runner.crawl(MySpider)
    return d  # spider 结束时该 Deferred 触发

install_reactor("twisted.internet.epollreactor.EPollReactor")
react(crawl)

CrawlerRunner.crawl() 的返回值语义就是提案中 cr.run() 的异步化版本:返回的 Deferred(或 AsyncCrawlerRunner 返回的 asyncio.Task)在爬取完成时触发,你可以在它完成之后停止 reactor 或执行任意后续代码(CrawlerRunner.crawl 的 docstring 明确了这一点)。它同时暴露 stop()join():前者并发停止所有在跑的爬虫,后者等待 self._active 中的全部任务结束。

仓库里的测试脚本可以直接印证这套用法。例如 tests/CrawlerRunner/simple.py 是最小可运行脚本:

from twisted.internet.task import react
from scrapy import Spider
from scrapy.crawler import CrawlerRunner
from scrapy.utils.log import configure_logging
from scrapy.utils.reactor import install_reactor

class NoRequestsSpider(Spider):
    name = "no_request"

    async def start(self):
        self.logger.info(f"is_reactorless(): {is_reactorless()}")
        return
        yield

def main(reactor):
    configure_logging()
    runner = CrawlerRunner()
    return runner.crawl(NoRequestsSpider)

install_reactor("twisted.internet.asyncioreactor.AsyncioSelectorReactor")
react(main)

同目录下的 tests/CrawlerRunner/multi_parallel.pymulti_seq.pyno_reactor.pyreactorless.py 等)与 tests/CrawlerProcess/ 覆盖了多爬虫并行/串行、reactorless、自定义事件循环等矩阵,是这套 API 行为的事实依据;回归测试入口在 tests/test_crawler_runners.py

四、提案的第三块拼图:Spider Manager

SEP-004 还预见了另一个职责——“Spider Manager”:负责从 URL 和域名“解析”出 spider,并提议把它移出 scrapy.spider(那里只保留 BaseSpider)。这个职责在当前代码中由两个协作组件承担:

  1. SpiderLoaderscrapy/spiderloader.py):负责“找到并加载”项目里的 spider。构造时按 SPIDER_MODULES 设置递归遍历模块、缓存 name -> spider class 映射(_load_all_spiders()),并对重名 spider 发出警告(_check_name_duplicates())。三个对外方法与提案设想的“resolve”一一对应:

    • load(spider_name):按名字加载,找不到抛 KeyError("Spider not found: ...")——这就是脚本里 process.crawl("followall", ...) 能传字符串的原因(CrawlerRunnerBase._create_crawler() 中对字符串参数调用 self.spider_loader.load(),见 scrapy/crawler.py#L481-L484);
    • list():列出项目内全部 spider 名,scrapy list 命令的数据来源;
    • find_by_request(request)按请求匹配 spider——遍历所有 spider 调用 cls.handles_request(request),用域名(allowed_domains)判断哪个 spider 能处理该请求,这正是提案中“resolve spiders from URLs and domains”的落点(scrapy/spiderloader.py#L127-L136)。

    loader 本身也是可插拔的:默认类由 SPIDER_LOADER_CLASS = "scrapy.spiderloader.SpiderLoader" 指定(scrapy/settings/default_settings.py#L566),SPIDER_MODULES 默认为空列表(scrapy/settings/default_settings.py#L581)。get_spider_loader() 还负责按该设置实例化 loader 并传入冻结后的 settings(scrapy/spiderloader.py#L22-L26)。

  2. get_project_settings() 与项目内运行:在 Scrapy 项目目录中运行脚本时,可以用 scrapy.utils.project.get_project_settings 拿到带项目配置的 Settings,再把 spider 名直接交给 runner——这正是官方文档给出的“项目内脚本”范式(docs/topics/practices.rst):

    from scrapy.crawler import AsyncCrawlerProcess
    from scrapy.utils.project import get_project_settings
    
    process = AsyncCrawlerProcess(get_project_settings())
    # 'followall' 是项目内某个 spider 的名字
    process.crawl("followall", domain="scrapy.org")
    process.start()
    

至于提案末尾悬而未决的 close_spider() 问题(“对从未被 spider manager 解析过的 spider 也会被调用,需要决定怎么处理”),当前代码中 spider 生命周期统一走 spider_opened/spider_closed 信号与 Crawler 自身的 crawl()/stop_async() 流程(CrawlerRunner._crawlfinally 块负责从 crawlers 集合中摘除实例,见 scrapy/crawler.py#L555-L571),即每个 Crawler 显式管理自己的 spider,不再依赖全局 manager 隐式兜底。

五、小结:提案的“已实现”到底意味着什么

sep/sep-004.rst 的四个诉求逐条对照当前仓库,可以得到一份清晰的验收表:

SEP-004 提案点 当前实现 证据位置
轻量入口:不建项目即可运行 CrawlerProcess / CrawlerRunner(含 Async 变体),脚本内 import scrapy 即可跑 scrapy/crawler.pydocs/topics/practices.rst
行为由 settings 控制,默认即可用 Crawler(spidercls, settings) 接受 dict/Settings,默认 settings 合并 spider custom_settings scrapy/crawler.py#L117-L147
Crawler 实例化 engine/settings/extensions(去单例) Crawler 实例持有独立 AddonManagerSignalManager,engine/extensions 延迟构建 scrapy/crawler.py#L61-L147
Spider Manager:从 URL/域名解析 spider SpiderLoader.find_by_request() / load() / list(),可经 SPIDER_LOADER_CLASS 替换 scrapy/spiderloader.py

提案中没有落地的部分是那个具体的 Crawler(start_urls, callback=...) + 阻塞 run() 签名——官方文档开头的 note 说明了原因:Scrapy 的异步核心必须绑定在一个事件循环上,爬虫只能“在 reactor/事件循环内部运行”。换言之,SEP-004 交付的不是“一个更轻的 API”,而是把 Crawler 从单例体系改造为可实例化对象、补上 runner/process 两个运行层、并把 spider 发现逻辑收敛到 SpiderLoader——这三件事共同构成了今天所有 scrapy crawl 命令和脚本化用法背后的同一套基础设施。对于读者而言,实际可用的结论是:写独立脚本选 AsyncCrawlerProcess;嵌入已有 Twisted 或 asyncio 应用选 AsyncCrawlerRunner;按名字调用项目内 spider 依赖 SpiderLoader + SPIDER_MODULES,三者行为均可在上文列出的源码文件与 tests/CrawlerRunner/ 测试脚本中验证。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.78 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
987
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384