
1. 为什么要给Scrapy造工具从裸用框架到拥有专属工具箱先聊个体验。用过Scrapy一段时间的同学大概率都有这种感受框架本身很强命令行敲scrapy crawl xxx就能跑爬虫scrapy shell能调试页面scrapy list能看所有爬虫。但用久了你会发现日常操作里有一大堆事情框架自带的命令根本不管每天上线前要先跑一遍全部爬虫检查哪些能正常抓到数据想批量生成新爬虫的骨架文件手动复制粘贴经常漏改配置爬虫跑完要统计成功条数、失败URL、耗时然后把结果推到群里或写到文件要调试一个动态iframe里的数据时scrapy shell默认只抓静态响应根本加载不出来想把项目部署到服务器上还得写一堆启动脚本。这些事情你当然可以每次手动做也可以写一堆零散的Python脚本去调用Scrapy API。但更优雅、更工程化的做法是直接把它们变成scrapy命令本身的能力——想跑全部爬虫就敲scrapy run_all想生成新爬虫就敲scrapy newspider product想调试动态页面就敲scrapy render https://...。这就是Scrapy自定义命令要和扩展机制一起讲的原因。命令是“主动发起”的工具入口扩展是“被动响应”的生命周期消费方两者配合起来你能把一整套路线的爬虫工作流沉淀成自己团队或个人的专属工具箱。这篇文章不会只贴官方文档我主要讲实际开发中真正用得上的设计思路和代码细节把原理、踩坑、完整示例一次说清楚。这篇文章适合谁看已经会用Scrapy写基本爬虫、想进一步提升工程效率的Python开发者团队里负责爬虫基础设施、想让所有人共用一套标准流程的工程师以及纯粹想搞懂Scrapy内部机制的爱好者。你不需要懂Scrapy源码但最好有跑通一个基础爬虫的经验。2. 核心思路拆解命令、扩展与信号的职责边界很多人一开始容易把“自定义命令”和“扩展”混为一谈觉得不都是往Scrapy里加功能嘛。实际上它们解决的问题完全不同理解清楚边界后面写代码才不会拧巴。2.1 自定义命令的本质把重复流程固化为CLI操作Scrapy的命令系统本质上是基于ScrapyCommand类的一组命令行入口。scrapy crawl是命令scrapy shell是命令scrapy genspider也是命令。自定义命令就是在不修改Scrapy源码的前提下注册一个自己的ScrapyCommand子类让scrapy这个入口认识它。它适合承载什么一切“主动发起、有明确目标、需要参数输入”的操作。比如批量运行一组爬虫输入爬虫名字列表输出运行结果汇总生成爬虫骨架输入爬虫名、域名输出一个文件清理历史数据输入爬虫名输出删除后的状态报告主动触发一次数据导出或格式转换输入数据文件路径输出新格式文件。这背后的思路是凡是超过两次的重复操作就应该固化成工具。写命令的收益在于把逻辑集中到一个入口团队里的其他人不需要知道内部实现敲一行命令就能完成整套操作。2.2 扩展的本质在爬虫生命周期里挂回调扩展Extension走的是另一条路。它不主动做事而是监听Scrapy运行过程中的各种信号在特定事件发生时执行回调函数。典型的适用场景是“被动观测和干预”统计数据每秒抓了多少条、请求了多少次、失败了多少次发送通知爬虫结束、异常退出时自动把日志发到邮箱或聊天工具资源保护空闲时间过长时自动关闭爬虫动态调整根据运行过程中累计的错误率决定要不要临时降低并发。命令和扩展经常搭档出现。举个例子你写了一个scrapy watch命令用来“实时盯着爬虫状态”它负责启动爬虫并订阅状态输出而真正采集状态并推送信息的工作由一个扩展在后台完成。命令是“前台操作台”扩展是“后台守护者”。2.3 信号让扩展知道“现在该干嘛”的广播网Scrapy内部有一套信号机制你可以把它理解成一场广播节目。爬虫运行到某个阶段广播电台Crawler就会喊一声“各位注意Item刚刚被爬下来了”“各位注意爬虫即将关闭”。扩展就像收音机在初始化时把自己调到某个频道听到消息后干活。常用的Scrapy内置信号包括信号名触发时机典型用途engine_started引擎开始跑记录启动时间spider_openedspider实例创建并打开初始化临时资源request_scheduled一个请求被加入调度队列统计请求数response_received收到响应统计响应码分布item_scraped一个Item成功通过pipeline统计成功条数item_errorItem在pipeline中出错记录失败明细spider_errorspider回调函数抛异常记录错误堆栈spider_idlespider没有待处理请求判断是否该关闭spider_closedspider运行结束发汇总通知理解信号机制是写扩展的关键。你不需要去猜“爬虫运行到哪一步了”信号系统已经把生命周期切分成了一个个明确节点你要做的只是选择在哪个节点入场。3. 自定义命令的完整实操从零打造一个批量跑虫命令下面开始动手。这一节我用一个非常实用的案例串起来写一个自定义命令run_all功能是列出项目中所有的爬虫逐个运行最后汇总每个爬虫的成功条数、失败条数和运行时长。3.1 工程结构准备movement在哪定义命令自定义命令的目录结构有固定要求。假设你的Scrapy项目叫myproject目录结构一般长这样myproject/ ├── scrapy.cfg └── myproject/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py ├── spiders/ │ ├── __init__.py │ └── product.py └── commands/ ├── __init__.py └── run_all.py注意commands目录不是Scrapy默认创建的需要你手动新建。这个目录可以放在项目包myproject/里面也可以放在项目包外面关键是要能让Scrapy找到。然后打开settings.py加一行配置COMMANDS_MODULE myproject.commandsCOMMANDS_MODULE的值就是包含命令的Python模块路径。Scrapy启动时会在加载内置命令之外额外扫描这个模块下的所有.py文件把里面有Command类的文件注册为可用命令。3.2 第一个命令骨架理解Command类的关键方法在commands/run_all.py里写如下代码from scrapy.commands import ScrapyCommand class Command(ScrapyCommand): requires_project True def short_desc(self): return 批量运行项目中所有爬虫 def run(self, args, opts): pass这里有几个关键点需要展开解释类名必须是CommandScrapy就是靠类名来识别一个文件是不是命令的。如果类名不叫Command哪怕文件名再对它也不会被加载。requires_project True表示这个命令只有在Scrapy项目目录下才能运行。如果设置为False命令可以在任意目录执行适用于不依赖项目的独立工具比如全局性的URL检查命令。short_desc()方法返回一行简短描述。当你敲scrapy help时这条描述会出现在命令列表里。run(self, args, opts)是命令的真正入口。args是位置参数列表opts是解析后的可选参数对象。写好这个骨架放到项目里然后随便找一个项目目录位置执行scrapy run_all此时命令会执行但因为run方法还是空的所以什么都不做。我们一步步把功能填进去。3.3 获取所有爬虫并逐个运行现在的关键问题是怎么在命令里获取项目中所有的爬虫Scrapy的crawler对象已经提供了方法。self.crawler是命令运行时创建的一个Crawler实例。要拿到spider列表可以用self.crawler.spiders这个属性它其实是SpiderLoader的实例。SpiderLoader提供了list()方法返回所有爬虫名称的列表。在run方法里面写def run(self, args, opts): spider_loader self.crawler.spiders spider_names spider_loader.list() self.logger.info(发现 {} 个爬虫: {}.format(len(spider_names), spider_names))运行scrapy run_all你应该能在控制台看到爬虫列表。那怎么逐个运行呢Scrapy给了我们CrawlerProcess和CrawlerRunner两个核心类。在命令里self.crawler_process是当前项目的CrawlerProcess实例。我们可以用它来串行或并行运行爬虫。最简单的用法是调用self.crawler_process.crawl(spider_name)这个方法内部会创建并运行一个爬虫。但需要注意crawl方法返回一个Deferred它是Twisted框架的异步对象。要等待它完成需要把它加入self.crawler_process的任务队列。Scrapy底层是通过CrawlerRunner的crawl和join方法来控制流程的。一个干净的做法是from twisted.internet import reactor def run(self, args, opts): self.crawler_process.crawl(spider_name) self.crawler_process.start()但start()会阻塞住直到所有crawl任务完成。如果我们想在两个爬虫之间插入自己的代码比如打印日志、记录耗时用CrawlerRunner的join更灵活。下面是完整的run_all命令实现import time from scrapy.commands import ScrapyCommand from scrapy.crawler import CrawlerRunner from scrapy.utils.project import get_project_settings class Command(ScrapyCommand): requires_project True def short_desc(self): return 批量运行项目中所有爬虫 def add_options(self, parser): super().add_options(parser) parser.add_argument( --concurrent, actionstore_true, help是否并发运行所有爬虫, ) def run(self, args, opts): settings get_project_settings() runner CrawlerRunner(settings) spider_names self.crawler.spiders.list() self.logger.info(开始运行共 {} 个爬虫.format(len(spider_names))) start_time time.time() results {} if opts.concurrent: # 并发模式把所有爬虫的deferred都收集起来最后一起join deferreds [] for name in spider_names: deferreds.append(runner.crawl(name)) runner.join().addBoth(lambda _: self._finish_concurrent(runner, results, start_time, spider_names)) else: # 串行模式一个接一个 deferred runner.crawl(spider_names[0]) if spider_names else None # 这个命令必须调用reactor.run()来启动Twisted事件循环 from twisted.internet import reactor reactor.run()不过这里有个容易犯的错误add_options里super().add_options(parser)一定要保留否则会丢失Scrapy自带的一些选项比如--logfile、--loglevel。另外reactor.run()是必须的——即使你已经用CrawlerRunner安排了任务不启动事件循环异步代码永远不会执行。我实际跑测试的时候发现一个更简单的模式直接用CrawlerProcess然后循环调用crawl并立即start()它就会自动等待所有任务结束。这里给一个经过验证、代码更短并且适合大多数场景的串行版本import time from scrapy.commands import ScrapyCommand from scrapy.crawler import CrawlerProcess from scrapy.utils.project import get_project_settings class Command(ScrapyCommand): requires_project True def short_desc(self): return 批量运行项目中所有爬虫 def add_options(self, parser): super().add_options(parser) parser.add_argument( --sleep, typeint, default0, help每个爬虫运行结束后等待的秒数, ) def run(self, args, opts): settings get_project_settings() spider_names self.crawler.spiders.list() if not spider_names: self.logger.warning(项目里没有任何爬虫) return for name in spider_names: self.logger.info(开始运行爬虫: {}.format(name)) start time.time() process CrawlerProcess(settings) process.crawl(name) process.start() elapsed time.time() - start self.logger.info(爬虫 {} 运行完成耗时 {:.2f} 秒.format(name, elapsed)) if opts.sleep 0: time.sleep(opts.sleep)这个版本的好处是CrawlerProcess.start()会阻塞直到当前爬虫跑完然后你再启动下一个爬虫天然就是串行。如果你想并发就用前面的CrawlerRunner方案但要注意并发跑多个爬虫时数据库连接、文件写入这些共享资源要处理好否则会出现连接冲突。3.4 从命令行传递参数支持指定要运行的爬虫刚才的run_all是一次性全跑。但实际使用中我经常只想跑其中几个。所以增加一个可选参数--spider允许多次指定。def add_options(self, parser): super().add_options(parser) parser.add_argument( -s, --spider, actionappend, destspiders, default[], help指定要运行的爬虫名称可多次使用例如 -s product -s user, ) def run(self, args, opts): if opts.spiders: spider_names opts.spiders else: spider_names self.crawler.spiders.list() # 后续逻辑不变actionappend的作用是允许同一个参数出现多次每次的参数值会被追加到一个列表中。命令行用法scrapy run_all -s product -s user这样做的好处是不用改代码临时想跑哪几个爬虫敲命令的时候直接指定。如果要把这个命令分享给团队其他人别人只需要看scrapy run_all -h就能了解所有参数用法。3.5 亲测有效的注意事项命令开发时的几个坑自定义命令开发过程中我踩过不少坑这里挑几个影响最大的提醒不要在生产环境用CrawlerProcess跑start()两次。如果你在同一个进程里连续创建多个CrawlerProcess第二次调用start()时Twisted的reactor可能已经停止必须重新启动reactor才能再次运行。这也是为什么我在串行循环里每次新建CrawlerProcess——避免复用已停掉的reactor。如果你需要更复杂的生命周期管理建议深入学习CrawlerRunner配合reactor的方式。命令里打印日志用self.logger而不是print。self.logger是Scrapy日志系统的一部分它会统一输出到控制台和日志文件能带上时间戳和日志级别。用print的话日志会乱糟糟的而且无法通过--loglevel控制。requires_project False的命令self.crawler可能为None。如果你在命令里访问self.crawler.spiders之类的东西一定要确保命令需要项目环境或者先判断self.crawler是否为空。否则在项目外执行命令会直接报AttributeError。add_options里面不要漏了super()。这个我在前面提过但值得再说一遍。漏了的话--logfile、--loglevel这些Scrapy通用参数会丢失可能引起一些难以排查的怪异行为。4. 扩展机制深度拆解写一个可以统计成功率的监控扩展命令解决了“主动控制”的问题。现在来看“被动观察”的那半边。4.1 理解Scrapy扩展的加载流程from_crawler是关键入口在Scrapy中一个扩展本质上就是一个类但硬性要求是实现一个from_crawler类方法。Scrapy在加载扩展时会调用这个类方法把crawler实例传进去然后由这个类方法负责创建扩展实例并完成信号连接。from_crawler的签名通常是classmethod def from_crawler(cls, crawler): ext cls(crawler) crawler.signals.connect(ext.spider_opened, signalsignals.spider_opened) return extcrawler对象是Scrapy中的核心它聚合了settings配置、signals信号、stats统计信息等重要组件。在扩展里我们主要通过crawler.signals.connect()来监听信号通过crawler.stats来读写统计指标。4.2 实战一个成功率监控扩展的完整代码下面写一个实际工作中很常用的扩展统计每个爬虫抓取成功了多少条Item、失败了多少条Item、请求了多少次并在爬虫结束时输出汇总。from scrapy import signals from datetime import datetime class SuccessRateMonitor: def __init__(self, crawler): self.crawler crawler self.stats crawler.stats # 记录启动时间 self.start_time datetime.now() # 记录每个spider的items数量 self.item_success_count 0 self.item_error_count 0 self.request_count 0 self.response_error_count 0 classmethod def from_crawler(cls, crawler): ext cls(crawler) crawler.signals.connect(ext.item_scraped, signalsignals.item_scraped) crawler.signals.connect(ext.item_error, signalsignals.item_error) crawler.signals.connect(ext.request_scheduled, signalsignals.request_scheduled) crawler.signals.connect(ext.response_received, signalsignals.response_received) crawler.signals.connect(ext.spider_closed, signalsignals.spider_closed) return ext def item_scraped(self, item, response, spider): self.item_success_count 1 def item_error(self, item, response, spider, failure): self.item_error_count 1 def request_scheduled(self, request, spider): self.request_count 1 def response_received(self, response, request, spider): # 注意这里只统计HTTP状态码400的响应 if response.status 400: self.response_error_count 1 def spider_closed(self, spider, reason): total_items self.item_success_count self.item_error_count success_rate (self.item_success_count / total_items * 100) if total_items 0 else 0 elapsed datetime.now() - self.start_time self.stats.set_value(monitor/success_rate, success_rate, spiderspider) self.stats.set_value(monitor/item_error_count, self.item_error_count, spiderspider) self.stats.set_value(monitor/elapsed_seconds, elapsed.total_seconds(), spiderspider) spider.logger.info( 监控统计: 请求数{}, 成功Item{}, 失败Item{}, 异常响应{}, 成功率{:.2f}%.format( self.request_count, self.item_success_count, self.item_error_count, self.response_error_count, success_rate, ) )这段代码的核心逻辑是item_scraped的签名是(item, response, spider)这个签名必须与Scrapy信号定义匹配。如果参数写错信号连接会自动报错或回调不执行。有疑问时直接查Scrapy文档里的信号定义最稳。item_error的信号定义里有一个failure参数它是Twisted的Failure对象里面包含了具体的异常堆栈。你可以用failure.getErrorMessage()获取错误信息甚至可以打印追溯栈。response_received的作用是统计异常响应。这里我用了response.status 400作为判断条件实际业务中可能还关心3xx重定向或者特定状态码按需调整。stats.set_value()可以把自定义指标写入Scrapy的统计系统。运行结束后这些指标会出现在scrapy crawl的最终统计输出里也可以通过扩展进一步读取。4.3 注册扩展settings.py中的EXTENSIONS配置写完扩展类还要告诉Scrapy“加载这个扩展”。在settings.py中加入EXTENSIONS { myproject.extensions.SuccessRateMonitor: 100, }键是扩展类的Python导入路径值是优先级。Scrapy加载扩展时会按照优先级从低到高依次加载。数值越小加载越早。默认的TelnetConsole扩展优先级是0LogStats是10左右。如果你不关心加载顺序统一设成100就行。注意Scrapy自带的扩展在scrapy/extensions目录下它们的加载是默认开启的。如果某个扩展导致冲突你可以通过把优先级设为None来禁用比如EXTENSIONS { scrapy.extensions.telnet.TelnetConsole: None, }4.4 扩展中常见的坑信号连接失败与静默异常写扩展时最容易遇到的问题就是“爬虫正常跑但我的扩展完全没有输出”。排查方向基本是这三个扩展类没有被加载。检查EXTENSIONS配置里路径是否写错比如是不是漏了.py文件里的类名。最笨但最有效的方法是在from_crawler第一行加一个print看它会不会执行。如果连print都不输出说明扩展根本没被加载。信号连接失败但没报错。signals.connect()在参数不匹配的时候有时候不会立即报错而是等触发时才报。建议在测试阶段故意触发一次事件确认回调真的执行了。回调函数抛异常被静默吞掉。Scrapy的信号回调如果抛出异常可能会被Twisted捕获并记录日志但通常不会导致爬虫崩溃所以你可能完全没注意到。我在开发中会把回调函数包上try/except并打印错误堆栈这样即使出错也能快速发现。def item_scraped(self, item, response, spider): try: self._do_something(item, response, spider) except Exception: import traceback traceback.print_exc()这不算是最优雅的写法但在调试阶段非常实用。5. 实战组合把动态页面渲染能力做成自定义命令前面把命令和扩展分开讲了。现在把它们组合起来解决一个真实场景动态iframe页面的调试问题。5.1 为什么scrapy shell搞不定动态iframe很多爬虫会碰到这种页面主要HTML结构能正常抓取但某些关键数据放在一个iframe里而这个iframe的内容是JavaScript动态加载的。用默认的scrapy shell去请求拿到的是一个空壳iframe或者干脆连iframe标签都看不到。这时候你得借助无头浏览器比如Playwright去渲染整个页面。参考社区里常见做法是把Playwright和Scrapy结合让Scrapy能拿到渲染后的DOM。很多团队确实会直接往项目里塞一个scrapy-playwright中间件让所有请求都走浏览器渲染。但问题在于不是所有请求都需要渲染全量渲染会大幅降低抓取速度。更灵活的做法是写一个自定义命令scrapy render只在需要调试的时候主动用Playwright打开目标URL打印渲染后的页面标题、iframe数量和关键元素帮助你快速确认数据藏在哪里。5.2 用Playwright写一个render命令的思路与实现这个命令不依赖Scrapy项目所以把requires_project设为False。import asyncio from scrapy.commands import ScrapyCommand class Command(ScrapyCommand): requires_project False def short_desc(self): return 渲染动态页面并输出关键信息依赖Playwright def add_options(self, parser): super().add_options(parser) parser.add_argument(url, help要渲染的页面URL) parser.add_argument(--wait, typeint, default3000, help等待渲染的毫秒数) parser.add_argument(--selector, defaultNone, help可选CSS选择器打印匹配元素数量) def run(self, args, opts): url opts.url wait_ms opts.wait async def _render(): from playwright.async_api import async_playwright async with async_playwright() as p: browser await p.chromium.launch(headlessTrue) page await browser.new_page() try: await page.goto(url, timeout30000) await page.wait_for_timeout(wait_ms) title await page.title() iframes await page.query_selector_all(iframe) print(页面标题:, title) print(iframe数量:, len(iframes)) if opts.selector: matches await page.query_selector_all(opts.selector) print(CSS选择器 {} 匹配数量: {}.format(opts.selector, len(matches))) if matches: # 打印前5个匹配元素的文本内容 for idx, el in enumerate(matches[:5]): text await el.inner_text() print( [{}] {}....format(idx, text[:100])) except Exception as e: print(渲染出错:, e) finally: await browser.close() asyncio.get_event_loop().run_until_complete(_render())这里我用asyncio.get_event_loop().run_until_complete()在命令的同步上下文里运行异步的Playwright代码这是最简单稳妥的桥接方式。如果你用的Python版本较新也可以用asyncio.run()但要注意Scrapy本身基于Twisted在命令环境中asyncio.run()有可能会干扰reactor的运行所以最安全的是get_event_loop().run_until_complete()。这个命令的开发思路很值得借鉴它不替代正常爬虫流程而是作为辅助调试工具嵌入到Scrapy的命令体系里。敲一行scrapy render https://example.com --selector .product-price就能直观看到动态渲染后有哪些iframe、目标选择器匹配到了多少元素。这个信息对于决定爬虫策略至关重要是直接用API接口还是需要模拟点击还是继续深入iframe。5.3 再进一步把render功能封装成一个可复用的扩展如果你不仅想要调试时渲染而是在爬虫运行过程中碰到某种特定条件比如页面包含某个标记时自动调用Playwright渲染那就需要把渲染能力以扩展的形式挂载到信号上。思路是监听response_received信号检查响应的body中是否存在特定标记比如一个空iframe占位符如果存在就调用Playwright重新渲染页面并把渲染后的HTML替换掉原始响应体。这个方案比较重需要谨慎处理异步同步问题不是所有项目都需要。我的建议是先解决90%的静态内容抓取剩下10%的动态页面用scrapy render做人工分析然后再用API请求或AJAX接口直接抓取。这样才能保持整个爬虫项目的稳定和速度。6. 常见问题与排查技巧实录开发了一段时间自定义命令和扩展后我把实际遇到频率最高的问题整理成了一张速查表希望能帮你少走弯路。现象可能原因排查与解决办法scrapy xxx提示“Unknown command”COMMANDS_MODULE配置路径错误或者commands目录下没有__init__.py先确认settings.py中COMMANDS_MODULE的值是否正确检查commands目录是否有__init__.py确认命令文件里的类名是否叫Command命令执行了但什么都没发生run方法里可能写了异步代码但没有启动reactor如果用到CrawlerRunner检查是否调用了reactor.run()或者干脆改用CrawlerProcess.start()这种封装好的方法扩展类没有被加载EXTENSIONS配置路径错误或优先级被设为None检查导入路径是否完整在from_crawler里打印日志验证是否执行扩展回调不触发信号连接时参数签名不匹配对照Scrapy官方文档检查回调函数参数列表先打印signal名字和相关对象确认连接点是否正确爬虫运行正常但自定义统计值为0stats.set_value没有指定spider导致统计到了全局但查看的是单爬虫级别stats.set_value(key, value, spiderspider)明确指定spider或者用self.crawler.stats.get_value(key)读取时注意作用域两个爬虫并发跑时数据库报连接错误多个spider共享同一个数据库连接并发写冲突在pipeline中改用连接池或者限制并发模式用串行运行scrapy render报Event loop is closedasyncio事件循环在Scrapy / Twisted环境中被关闭使用asyncio.get_event_loop()获取已有循环不要用asyncio.run()实在不行可以创建新循环并设置为当前事件循环排查这类问题我有一条建议永远先确保“能不能看到输出”。在自定义命令的run方法、扩展的from_crawler里加一个极端明显的print标记是快速定位加载问题的最好办法。等到功能稳定后再把调试输出移除或改为logger.debug。7. 把定制能力真正用起来的一些个人经验最后说点实在的体会这些经验是我反复踩坑之后提炼出来的。第一自定义命令的价值不在于“炫技”而在于“减少重复判断”。我在自己的项目里写最频繁的三个命令一个是run_all用来统一批量跑虫一个是render用来调试动态页面还有一个是生成新爬虫骨架的newspider命令它会把模板里的变量自动替换成爬虫名、域名、起始URL。这三个命令覆盖了日常超过80%的操作。每次要执行重复动作时先想一想“这个动作以后还会不会再做”如果会就值得花20分钟固化成命令。长期积累下来这套工具箱会让整个团队的开发效率提升一个台阶。第二扩展要尽量做“无侵入”设计。一个好的扩展应该是即使它挂了爬虫本身也不受影响。这意味着在扩展的回调函数里尽量不要抛出影响主流程的异常如果要做外部调用比如发HTTP通知一定要加超时和重试。我在团队里常跟同事强调扩展可以记录问题、提醒问题但不要自动去改数据、删数据除非你明确知道自己在干什么。第三自定义命令与扩展结合最典型的落地场景就是“定时任务结果通知”。写一个命令scrapy scheduled来启动一个带调度器的进程在扩展里监听spider_closed信号汇总所有运行指标后推送到内部群。这样每天一早到工位打开群消息就知道昨晚爬虫跑得顺不顺不用自己一条条翻日志。顺带提一句Scrapy社区里已经有大量第三方扩展和中间件比如处理代理、限速、去重、分布式队列等。遇到问题时先搜索一下是不是已经有人写好轮子了再决定要不要自己造。造轮子的正确时机是现有轮子配置起来太复杂、运行太不稳、或者完全不符合你的使用场景。这些工具思路不限于Scrapy本身。你可以把同样的模式应用到其他框架里凡是可扩展的框架都值得围绕自己的日常工作流做一层薄薄的定制。薄薄的定制层带来的却是操作体验上的巨大差别——这也是我这么多年一直喜欢折腾这类框架定制的原因。