目录前言一、Scraper 在架构中的位置二、`Slot`:按字节算的背压三、三段式:入队、取队、等待`finally` 块为什么这么写四、`_scrape`:Response 和 Failure 走两条路errback 抛了新异常怎么办五、`call_spider_async`:回调是怎么被调起来的六、洋葱模型:`append` 与 `appendleft`默认的五个爬虫中间件七、实测:包裹顺序 ≠ item 流向`__qualname__` 那个坑八、`process_spider_input`:只能返回 None九、`process_spider_output` 必须是异步生成器十、异常恢复:`MutableAsyncChain`十一、`CONCURRENT_ITEMS`:并行处理产出十二、`start_itemproc_async`:Item 的三种结局十三、几个容易踩的坑坑 1:以为下载失败会经过爬虫中间件坑 2:在 `process_spider_output` 第一行打日志判断顺序坑 3:Pipeline 里用 `ValueError` 做过滤坑 4:回调里 `return` 一个值坑 5:工厂函数生成中间件类不改 `__qualname__`总结参考本文基于Scrapy 2.17.0的真实源码逐行分析,文中的中间件调用顺序是本机实跑出来的,不是照抄文档。其中process_spider_output必须是异步生成器、以及MutableAsyncChain的异常恢复机制,都是2.13+的行为,旧版本不同。前言前面几篇我们把请求侧走完了:Scrapy 2.17 源码解析:ExecutionEngine 引擎调度循环详解Scrapy 2.17 源码解析:Scheduler 调度器与磁盘/内存双队列知识拓展:RFPDupeFilter 与请求指纹算法详解引擎拿到Response之后,只做了一件事:yieldself.scraper.enqueue_scrape(result,request)剩下的全交给Scraper。这一篇我们看它怎么把一个响应变成 Item——中间要穿过爬虫中间件的洋葱、调用回调、再把产出分流到调度器和 Pipeline。一、Scraper 在架构中的位置Scraper自己不解析任何东西,它是个编排器,手里握着三样东西:classScraper:def__init__(self,crawler:Crawler)-None:self.slot:Slot|None=Noneself.spidermw:SpiderMiddlewareManager=SpiderMiddlewareManager.from_crawler(crawler)itemproc_cls:type[ItemPipelineManager]=load_object(crawler.settings["ITEM_PROCESSOR"])self.itemproc:ItemPipelineManager=itemproc_cls.from_crawler(crawler)self._itemproc_has_async:dict[str,bool]={}formethodin["open_spider","close_spider","process_item"]:self._check_deprecated_itemproc_method(method)self.concurrent_items:int=crawler.settings.getint("CONCURRENT_ITEMS")self.crawler:Crawler=crawler self.signals:SignalManager=crawler.signals self.logformatter:LogFormatter=crawler.logformatter成员作用slot队列 + 内存水位(背压)spidermw爬虫中间件管理器itemprocItem Pipeline 管理器concurrent_itemsCONCURRENT_ITEMS,默认 100二、Slot:按字节算的背压这个类我们在引擎篇提过,这里补全:classSlot:"""Scraper slot (one per running spider)"""MIN_RESPONSE_SIZE=1024def__init__(self,max_active_size:int=5000000):self.max_active_size:int=max_active_size self.queue:deque[QueueTuple]=deque()self.active:set[Request]=set()self.active_size:int=0self.itemproc_size:int=0self.closing:Deferred[Spider]|None=Nonedefadd_response_request(self,result:Response|Failure,request:Request)-Deferred[None]:# this Deferred will be awaited in enqueue_scrape()deferred:Deferred[None]=Deferred()self.queue.append((result,request,deferred))ifisinstance(result,Response):self.active_size+=max(len(result.body),self.MIN_RESPONSE_SIZE)else:self.active_size+=self.MIN_RESPONSE_SIZEreturndeferreddeffinish_response(self,result:Response|Failure,request:Request)-None:self.active.remove(request)ifisinstance(result,Response):self.active_size-=max(len(result.body),self.MIN_RESPONSE_SIZE)else:self.active_size-=self.MIN_RESPONSE_SIZEdefis_idle(self)-bool:returnnot(self.queueorself.active)defneeds_backout(self)-bool:returnself.active_sizeself.max_active_size两个要点:1. 加和减必须对称。add_response_request里用max(len(body), 1024)累加,finish_response里用完全相同的表达式递减。如果两边算法不一致,active_size会逐渐漂移——要么虚高导致爬虫越跑越慢,要么虚低导致背压失效、内存爆掉。2.itemproc_size只是给人看的。源码注释写着just for scrapy.utils.engine.get_engine_status(),它不参与任何判断,只在打印引擎状态时露面。三、三段式:入队、取队、等待Scraper处理一个响应要经过三个函数接力,这个结构初看有点绕:@inlineCallbacksdefenqueue_scrape(self,result:Response|Failure,request:Request)-Generator[Deferred[Any],Any,None]:ifself.slotisNone:raiseRuntimeError("Scraper slot not assigned")dfd=self.slot.add_response_request(result,request)self._scrape_next()try:yielddfd# fired in _wait_for_processing()exceptException:logger.error("Scraper bug processing %(request)s",{"request":request},exc_info=True,extra={"spider":self.crawler.spider},)finally:self.slot.finish_response(result,request)self._check_if_closing()self._scrape_next()def_scrape_next(self)-None:assertself.slotisnotNone# typingwhileself.slot.queue:result,request,queue_dfd=self.slot.next_response_request_deferred()_schedule_coro(self._wait_for_processing(result,request,queue_dfd))