Scrapy SEP-004:从“Library API”提案到 CrawlerRunner 脚本化运行体系
SEP-004 是 Scrapy 2009 年提出的一份早期增强提案,核心诉求只有一个:让 Scrapy 能被当作一个标准库直接 import 使用——只写回调函数就能跑爬虫,而不用先建一整个项目。这份提案最终被“部分实现”:官方给出的实现路径是 Crawler、CrawlerRunner、CrawlerProcess 这组类(见 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 的实例参数包括 engine、settings、spiders、extensions(并指出当时这些大多还是单例)。对照当前实现 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 之间不共享任何东西。 - 延迟属性机制:
engine、extensions、logformatter、request_fingerprinter、stats这些提案里设想为构造参数的组件,如今通过_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() 体验的是 CrawlerRunner 与 CrawlerProcess 两层封装(均定义在 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.py、multi_seq.py、no_reactor.py、reactorless.py 等)与 tests/CrawlerProcess/ 覆盖了多爬虫并行/串行、reactorless、自定义事件循环等矩阵,是这套 API 行为的事实依据;回归测试入口在 tests/test_crawler_runners.py。
四、提案的第三块拼图:Spider Manager
SEP-004 还预见了另一个职责——“Spider Manager”:负责从 URL 和域名“解析”出 spider,并提议把它移出 scrapy.spider(那里只保留 BaseSpider)。这个职责在当前代码中由两个协作组件承担:
-
SpiderLoader(scrapy/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)。 -
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._crawl 的 finally 块负责从 crawlers 集合中摘除实例,见 scrapy/crawler.py#L555-L571),即每个 Crawler 显式管理自己的 spider,不再依赖全局 manager 隐式兜底。
五、小结:提案的“已实现”到底意味着什么
把 sep/sep-004.rst 的四个诉求逐条对照当前仓库,可以得到一份清晰的验收表:
| SEP-004 提案点 | 当前实现 | 证据位置 |
|---|---|---|
| 轻量入口:不建项目即可运行 | CrawlerProcess / CrawlerRunner(含 Async 变体),脚本内 import scrapy 即可跑 |
scrapy/crawler.py、docs/topics/practices.rst |
| 行为由 settings 控制,默认即可用 | Crawler(spidercls, settings) 接受 dict/Settings,默认 settings 合并 spider custom_settings |
scrapy/crawler.py#L117-L147 |
Crawler 实例化 engine/settings/extensions(去单例) |
Crawler 实例持有独立 AddonManager、SignalManager,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/ 测试脚本中验证。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0623
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00