
1. 下载中间件在整个请求链路里的正确坐标我最早接触Scrapy的时候把大部分精力花在了Spider的解析逻辑上总觉得下载中间件是个“高级配置”跟业务没多大关系。直到有一次爬虫在线上跑了不到半天就开始大面积超时代理IP频繁失效日志里全是RetryMiddleware耗尽重试次数的告警我才意识到爬虫的稳定性尤其是反爬对抗和请求容错几乎全压在下载中间件这一层。如果这一层写不好Spider端写得再漂亮也是空中楼阁。1.1 一次请求从发出到返回到底经过了多少道关卡先看一个请求的完整生命周期。Scrapy是异步框架但请求的流转路径是固定的Spider中的回调比如parse通过yield Request(...)生成请求对象。引擎Crawler拿到请求后先交给调度器Scheduler排队去重。调度器弹出请求引擎将其送入下载器Downloader之前会先经过下载中间件链。下载器真正发起HTTP请求拿到响应后响应再原路返回再次经过下载中间件链。响应最终回到Spider的回调函数里开始解析。也就是说下载中间件卡在“引擎—下载器—引擎”这条通道的必经之路上。它的特殊之处在于它在请求和响应两个方向上都能插手既能改写即将发出的请求也能在响应刚到手还没交给Spider时动手脚。这个位置决定了它天然适合处理代理切换、请求头伪装、重试容错这类“跟具体业务无关但影响全局”的脏活。我习惯把它类比成快递分拣中心Spider是写快递单的人调度器是仓库货架下载器是运输货车下载中间件就是分拣员的双手——他可以在包裹上车前贴新面单改请求、换承运商换代理也可以在包裹卸货后检查有没有破损检查响应、直接原路退回重发请求。1.2 它和Spider中间件最大的区别不关心解析逻辑很多初学者容易把下载中间件和Spider中间件搞混其实两者的边界非常清晰Spider中间件处理的是Spider的输入响应和输出Item、Request它关联的是具体的爬虫逻辑而下载中间件处理的是请求和响应本身完全不知道也不关心这些请求会被哪个回调消费。这个区别在实际工程里很关键。比如你要做一个全站UA轮换如果放在Spider中间件里你得在每个Spider的start_requests里手动给每个请求加headers但如果放在下载中间件里只需要在process_request里统一改写request.headers所有Spider的所有请求都自动生效。下载中间件的“无业务侵入性”正是它适合做横切关注点的根本原因。顺带说一句下载中间件虽然叫“中间件”但它不依赖Spider实例。它通过from_crawler拿到的是全局的Crawler对象可以读取settings、访问统计收集器stats所以即使是多Spider共存的工程一套下载中间件配置也能通吃。2. 四个钩子方法逐个拆解返回值决定请求的流向下载中间件的核心是四个方法process_request、process_response、process_exception、from_crawler。其中前三个是钩子方法from_crawler是类方法用来做依赖注入。很多文章会罗列这些方法的签名但真正让新手懵圈的是“返回什么”。因为中间件的返回值不是普通函数的返回值而是直接决定了请求下一步往哪儿走。2.1 process_request的返回值语义process_request(request, spider)在请求发往下载器之前被调用通常被用来改请求。比如给请求设置代理request.meta[proxy] http://127.0.0.1:8080加随机UArequest.headers[User-Agent] random.choice(UA_LIST)加签名参数request.headers[X-Sign] sign(request.url)但这个方法最重要的不是能做什么而是返回值。我整理了一个判断逻辑只要记住几个分支就够了返回值后续行为返回None或不写return请求继续沿着中间件链往下走最终到达下载器返回Response对象短路后续中间件不再执行下载器也不发起请求直接把该响应交回给引擎返回Request对象短路原请求被放弃新请求会被重新交给调度器排队抛出IgnoreRequest请求被丢弃触发errback回调第一个分支最常用绝大多数改写场景都靠它。第二个分支很妙常用于“本地缓存优先”的场景比如你维护了一个HTTP缓存请求过来时先在缓存里查命中就直接返回一个预制Response下载器根本不用发请求。第三个分支则常见于“请求被拒后换个地址重来”的场景。2.2 process_response的返回值语义process_response(request, response, spider)在下载器拿到响应后、响应交给Spider之前执行。这个方法同样有短路能力返回值后续行为返回Response对象继续向上传递交给后续中间件最终到Spider返回Request对象不再传递响应该请求重新排队常用于重试抛出IgnoreRequest响应被丢弃使用request.errback触发错误回调这里最容易踩的坑是重定向状态码。Scrapy的默认下载器会自动处理重定向到最终页面默认REDIRECT_ENABLED True这个过程不会触发process_exception而是在HTTP层直接完成跳转。如果你想自己掌控301/302的走向不能指望异常处理必须在process_response里检查response.status手动决定是否重新调度。2.3 process_exception下载器抛错后的补救机会当下载器因网络问题DNS解析失败、连接拒绝、超时抛出异常时process_exception(request, exception, spider)会被调用。这个方法的返回值有三种有意思的分支返回None异常继续向上抛最终触发errback或被内置重试中间件接管。返回Response把异常“洗白”成响应相当于你拿到了一个空壳响应可以自行加工后交给Spider。返回Request丢弃异常重新调度请求——这是自定义重试的关键。实际项目中process_exception最常见的用法是当某个代理IP连接失败时从代理池里剔除该IP并重新生成一个Request或直接复用原Request再次尝试。这一层做得好爬虫对外呈现出来的状态就是“慢一点但稳定”而不是“疯狂报错”。2.4 from_crawler让中间件能读到全局配置from_crawler(cls, crawler)是Scrapy各个组件通用的工厂方法。通过它中间件可以拿到crawler.settings全局配置和crawler.stats统计收集器也能注册信号。比如代理池中间件的典型初始化逻辑classmethod def from_crawler(cls, crawler): middleware cls( proxy_api_urlcrawler.settings.get(PROXY_API_URL), max_fail_countcrawler.settings.getint(PROXY_MAX_FAIL_COUNT, 3), ) crawler.signals.connect(middleware.spider_opened, signalsignals.spider_opened) return middleware这里有个细节信号不是必须的但如果你要做“代理池在Spider打开时预热刷新”这是最干净的位置。没有from_crawler中间件就退化成纯函数式工具拿不到任何工程上下文。2.5 优先级与调用顺序洋葱模型的理解方式下载中间件可以挂多个它们的执行顺序由DOWNLOADER_MIDDLEWARES字典里的数字决定。数字越小越靠近引擎数字越大越靠近下载器。关键点在于process_request按数字从小到大从引擎往下载器执行process_response按数字从大到小从下载器往引擎执行。这就好理解为什么有人把它叫洋葱模型请求像穿针一样从外往里穿响应再从里往外弹。如果你想在UA改写之后再做代理分配就把UA中间件数字写小一点代理中间件数字写大一点。这个顺序问题我见过不少人踩坑——写成一样大的数字Scrapy会报配置错误而且报错信息不够直观。3. 实战代理IP池切换中间件从设计到落地代理是下载中间件最经典的使用场景。为什么选在下载中间件里做而不是在Spider里做或者用第三方代理库在外部做原因很简单爬虫框架中能对“每个请求”进行动态干预、且不影响解析代码的位置就是下载中间件。你总不能在Spider的每个回调里手写一遍“取代理、绑定代理、失败后剔除”的逻辑那会把业务代码搞得一团糟。3.1 代理中间件的基本结构一个生产可用的代理中间件至少要解决四个问题池子从哪儿来、如何分发给请求、如何判定失效、如何重新调度。我用轮询取号的方式举例代理池维护一个可用代理列表process_request里每次从列表头部取一个轮转分配给请求写进request.meta[proxy]。这是最简单、但也是实际效果最稳定的策略——比纯随机分配更能避免同一个代理被高频集中使用。import itertools import random class RotatingProxyMiddleware: def __init__(self, settings): self.proxies [] self.proxy_iter itertools.cycle(self.proxies) self.max_fail_count settings.getint(PROXY_MAX_FAIL_COUNT, 3) self.fail_count {} classmethod def from_crawler(cls, crawler): return cls(crawler.settings) def process_request(self, request, spider): if request.meta.get(dont_proxy): return None if not self.proxies: spider.logger.warning(代理池为空请求直连) return None proxy next(self.proxy_iter) request.meta[proxy] proxy request.meta[proxy_fail_count] self.fail_count.get(proxy, 0) def process_response(self, request, response, spider): if response.status in (407, 403): self._mark_proxy_failed(request, spider) return request return response def _mark_proxy_failed(self, request, spider): proxy request.meta.get(proxy) if not proxy: return self.fail_count[proxy] self.fail_count.get(proxy, 0) 1 if self.fail_count[proxy] self.max_fail_count: try: self.proxies.remove(proxy) except ValueError: pass self.fail_count.pop(proxy, None) spider.logger.warning(f代理 {proxy} 达到失败阈值已剔除)注意我在process_request里加了一个dont_proxy的meta标记。这个开关很实用有些内部域名或健康检查请求不需要走代理直接放行。类似的开关还可以扩展成proxy_region在meta里指定代理地区中间件就按地区分组分配。3.2 代理池刷新线程别阻塞主循环生产环境中的代理池不是静态配置的而是从代理API或自建池动态拉取。我建议用一个后台线程每30~60秒拉取一次最新代理并做一轮弱校验比如连接http://httpbin.org/ip看返回速度。之所以用线程而不是用Scrapy的定时任务是因为下载中间件本身是纯同步代码块直接在里面发起阻塞网络请求会卡住整个下载链路。线程更新的核心逻辑不复杂但要注意线程安全代理列表的更新放在加锁区域process_request里读取时也尽量拷贝一份引用避免迭代时列表被修改抛异常。一个粗糙但可靠的写法import threading import time class ProxyPoolRefresher(threading.Thread): def __init__(self, middleware, api_url, interval60): super().__init__(daemonTrue) self.middleware middleware self.api_url api_url self.interval interval def run(self): while True: try: new_proxies fetch_proxies_from_api(self.api_url) if new_proxies: with self.middleware.lock: self.middleware.proxies new_proxies self.middleware.fail_count.clear() except Exception as e: print(f代理刷新失败: {e}) time.sleep(self.interval)刷新线程在from_crawler里启动即可。记得设成daemonTrue否则进程退出时会被线程阻塞。3.3 实测心得代理失效不能只看状态码我在真实调试中发现代理失效的判定远不止状态码这一种。很多代理IP在连接层就已经失败了根本到不了process_response那时走的是process_exception。所以要双管齐下process_response里判断HTTP状态码process_exception里判断TimeoutError、ConnectionRefusedError等异常类型。否则你做的“失效剔除”永远不会触发因为代理池里的坏代理全部在异常分支里被悄悄放过了。另外代理URL的写法看起来是小坑实际是巨坑。request.meta[proxy]的值必须带协议头比如http://1.2.3.4:8080不能只写1.2.3.4:8080。如果目标站点是HTTPS有些HTTP代理不支持隧道转发你需要区分http://代理给HTTP站点https://代理给HTTPS站点。混合用会导致大量的SSL握手失败日志里全是CERTIFICATE_VERIFY_FAILED。4. 请求重试与容错机制给中间件装上“决策大脑”Scrapy本身带了一个RetryMiddleware默认重试3次对500、502、504、超时等常见异常都会触发。但直接用它当生产方案你会遇到两个问题第一它不管代理失效重试时还会用同一个已失效的代理等于白重试第二它的重试次数是全局的无法针对单个请求动态调整。所以我在项目里倾向于自己定制重试逻辑思路是“按错误类型分策略”。4.1 一个区分错误类型的重试中间件这个中间件的核心是process_exception判断异常类型如果是网络层的瞬断允许重试且强制换代理如果是解析层或协议层错误直接放弃不浪费下一次请求机会。from scrapy.exceptions import IgnoreRequest class SmartRetryMiddleware: def __init__(self, max_retry): self.max_retry max_retry classmethod def from_crawler(cls, crawler): return cls(crawler.settings.getint(SMART_RETRY_TIMES, 2)) def process_exception(self, request, exception, spider): retry_times request.meta.get(retry_times, 0) if retry_times self.max_retry: spider.logger.warning(f请求 {request.url} 重试次数耗尽) return None if isinstance(exception, TimeoutError): request.meta[retry_times] retry_times 1 request.meta[proxy] None # 清除失效代理 request.meta[_force_new_proxy] True # 标记给代理中间件 return request if isinstance(exception, ConnectionError): request.meta[retry_times] retry_times 1 request.meta[_force_new_proxy] True return request spider.logger.warning(f不可重试异常 {exception}丢弃请求) return None注意这里一个小技巧把request.meta[proxy]设为None并不等于“直连”某些下载器实现仍会读取这个meta键。我更推荐用_force_new_proxy这类自定义标记让代理中间件在process_request里看到这个标记后直接从池子里换一个IP而不是复用当前request.meta[proxy]。4.2 配合代理中间件的完整流程这样一来两个中间件就形成了一套组合拳请求第一次发出代理中间件分配一个IP。下载器用该IP请求失败抛出超时异常。SmartRetryMiddleware捕获异常标记_force_new_proxy重新调度请求。请求再次进入下载中间件链代理中间件看到标记分配新IP重试再次发起。这个流程能跑通的关键是中间件顺序SmartRetryMiddleware的数字要大于RotatingProxyMiddleware这样在process_exception阶段重试中间件先被调用数字大的在响应方向的调用顺序靠前它修改完meta后请求重进process_request时代理中间件数字小的才后执行——刚好错开逻辑顺畅。我算过一个典型场景目标站单IP日请求上限约5000次用20个代理的池子理论上不做任何限速也能打到10万请求/天。但如果重试策略不区分错误类型每次都重试3遍实际有效请求率只有约1/3等于代理资源被白白浪费。所以重试策略和代理池是绑定设计的不能单独优化。4.3 重试与数据完整性的关系还有一点值得展开重试次数的上限不是拍脑袋定的要看你抓什么数据。如果目标是存量数据错过一条后面补抓的成本很高那就多给几次重试比如5次如果抓的是增量数据错过一条下次还能补上重试2次就够了。另外重试会对目标站产生指数级压力重试次数越高被封的风险就越大。我通常会在中间件里加一个限速逻辑同一域名下重试请求的延迟是正常请求的两倍这样既能保住数据又不至于激怒对方服务器的风控。5. 动态渲染与iframe内容把Playwright请进下载层现在很多网站在关键位置都上了JavaScript动态渲染甚至把核心数据塞进iframe里。热搜词里提到“scrapy playwright 动态 iframe”这确实是很多爬虫工程师绕不过去的坎。传统的做法是写个Selenium脚本先渲染再交给Scrapy解析架构很别扭Selenium的会话管理、等待逻辑、窗口状态全部要自己维护还容易拖垮并发。正确的思路就是借助scrapy-playwright这类集成方案把渲染能力下沉到下载层。原理上它通过自定义的下载处理器DownloadHandler接管请求凡是request.meta里标记了playwrightTrue的请求不再走普通HTTP客户端而是走Playwright的浏览器内核加载页面、执行JS、等待选择器最后把渲染好的HTML包装成Response返回给Spider。5.1 配置一个可切换的渲染管道先看settings里的配置。Scrapy 2.0以后引入了DOWNLOAD_HANDLERS机制它比下载中间件更底层——直接替换下载器本身。DOWNLOAD_HANDLERS { http: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, https: scrapy_playwright.handler.ScrapyPlaywrightDownloadHandler, } TWISTED_REACTOR twisted.internet.asyncioreactor.AsyncioSelectorReactor DOWNLOAD_HANDLERS_BASE { http: scrapy.core.downloader.handlers.http.HTTPDownloadHandler, https: scrapy.core.downloader.handlers.http.HTTPDownloadHandler, }然后在请求里按需开启渲染yield scrapy.Request( url, meta{ playwright: True, playwright_include_page: True, playwright_page_methods: [ PageMethod(wait_for_selector, div.product-list, timeout5000), ], }, callbackself.parse_product, )这样Spider就可以保持纯粹的解析逻辑拿到Response后直接response.xpath取数据完全感受不到底层是浏览器渲染还是普通HTTP请求。想切回静态模式只需要删除meta里的playwright字段。这种“渲染方式由meta标记动态决定”的设计比写两套爬虫可维护太多了。5.2 iframe内容的获取和处理iframe的问题比较特殊。普通HTTP请求拿到的HTML里iframe只是一个标签内容是另一个独立文档response.xpath根本摸不到。用Playwright渲染完后你需要先定位frame再读取frame里的内容存到Response上。一个可行思路在process_response阶段用Page对象遍历page.frames把每个frame的URL和内容都挂到meta里Spider端按需取用async def process_response(self, request, response, spider): page request.meta.get(page) if page is None: return response frames_content [] for frame in page.frames: content await frame.content() frames_content.append({ url: frame.url, html: content, }) response.meta[frames] frames_content return response这里有个容易忽视的细节page.frames包含主frame本身主frame的内容与Response的body是有重叠的。如果你只需要某个具体iframe的数据最好在取内容前先判断frame.url是否包含目标路径前缀再做提取避免重复抓取和浪费内存。5.3 并发和资源效率的实际约束无头浏览器不仅慢而且占内存。我实测下来一个Playwright Chromium实例空闲时约占80~120MB内存繁忙时会冲到300MB以上。如果开着并发16光是浏览器进程就能吃掉几GB内存。所以不能简单把并发调到很高必须给Playwright请求单独设限普通静态请求保持高并发动态渲染请求低并发。这个我在中间件里用scrapy.core.downloader.rate限速配合信号量控制整体爬取效率相对均衡。还有一个经验能用PageMethod(wait_for_selector)精准等待就别用固定sleep。固定等待你只能拍脑袋定时间动态页面网络一抖动就挂选择器等待则是等到关键节点出现才继续快的时候几百毫秒慢的时候撑满超时整体效率高得多。6. 下载中间件与extensions的职责边界热搜词里还有一个“scrapy中 extensions是什么”的疑问。很多人在阅读源码时会看到EXTENSIONS_BASE第一反应是“这不就是中间件吗”。其实两者职责完全不同中间件处理的是单次请求/响应的流转而扩展处理的是整个爬虫引擎的生命周期事件。它们的关注粒度不在一个层面千万别混着用否则代码很快会变成一团浆糊。6.1 Scrapy内置扩展的几个典型例子打开scrapy/extensions/目录你会看到一堆内置扩展StatsCollector负责收集运行统计如response_received_countTelnetConsole提供telnet调试端口LogStats定时打印抓取进度SpiderState在关闭时保存Spider状态方便下次断点续爬。这些扩展的共同点是什么呢它们的回调挂在引擎事件上——比如spider_opened、spider_idle、stats_collected而不是挂在请求链路里。用代码说会更直观class MyStatsExtension: def __init__(self, stats): self.stats stats classmethod def from_crawler(cls, crawler): ext cls(crawler.stats) crawler.signals.connect(ext.spider_opened, signalsignals.spider_opened) crawler.signals.connect(ext.spider_closed, signalsignals.spider_closed) return ext def spider_opened(self, spider): self.stats.set_value(custom_start_time, time.time()) def spider_closed(self, spider): self.stats.set_value(custom_elapsed, time.time() - self.stats.get_value(custom_start_time))从实现上就能看出来扩展跟具体请求完全无关它只关心“爬虫什么时候开、什么时候关、收集了什么统计指标”。6.2 两者各该管什么一张表说清楚我根据自己的维护经验画了一张职责边界表维度下载中间件扩展Extensions粒度单次请求/响应引擎生命周期触发时机每次请求发出前后事件触发启动、关闭、Idle等典型用途代理切换、UA轮换、限速、重试、请求签名统计上报、状态持久化、资源初始化是否依赖Spider通过spider参数访问上下文但不依赖解析逻辑可以通过信号拿到Spider引用但更常是全局性的能改请求吗能直接改meta、headers不能通常只响应事件能阻塞吗不能阻塞太久会影响并发可以在事件回调里做耗时操作但要小心阻塞整个事件循环这个表不是我随手画的它是踩过坑之后的总结。之前有一段代码把数据库连接池放进了下载中间件每次请求都去ping一次数据库当天晚上数据库就被打挂了。连接池这种全局资源必须放到扩展里管理在spider_opened时初始化在spider_closed时统一关闭下载中间件只管处理请求。6.3 什么内容不该塞进下载中间件什么必须塞我在不少开源爬虫项目里见到过“万能下载中间件”——里面既有代理逻辑又有验证码识别还顺手做了数据去重。这种中间件最致命的不是代码长而是每多一个请求都要为它埋单本来1000万请求的爬虫光一些用不到的判断逻辑就凭空消耗了上千万次CPU时间。必须放进下载中间件的UA/Referer/Cookie的动态注入代理分配与失效剔除请求级别的限速配合DOWNLOAD_DELAY做精细控制请求签名、加密参数改写针对特定状态码的响应重试不该放进下载中间件的数据库读写、连接池管理全局去重逻辑应该用Scrapy的dupefilter统计指标汇总交给扩展那层的StatsCollector验证码识别策略识别模型调用很慢放中间件里会阻塞请求流简单一句话原则下载中间件只碰Request和Response本身所有跟时间跨度、全局资源、跨请求状态相关的逻辑都往extensions方向赶。7. 调试与验证让中间件按预期跑起来最后分享几个我调试下载中间件的经验。中间件这类“隐形代码”不像Spider有明确的输出问题往往在运行几十分钟后才暴露调试难度高所以要学会用工具和日志来观察它的行为。7.1 利用Scrapy日志验证中间件调用顺序Scrapy的日志有一个很微妙的特性下载中间件在process_request阶段执行时如果你打印日志日志会出现在INFO: Sending request之后但如果你的中间件把请求直接短路了比如返回了Response日志流程会跳过发送环节直接进入INFO: Received response。这个细节能帮你快速定位某个中间件到底有没有生效。我习惯在关键中间件里加一个开关日志def process_request(self, request, spider): if request.meta.get(debug_middleware): spider.logger.info(f[中间件调试] 请求 {request.url} meta{request.meta})跑一个单请求测试任务观察日志输出顺序基本就能推断出每个中间件是否按预期执行、顺序是否符合预期。7.2 单测与集成测试两手抓下载中间件是可以脱离Spider单独测试的。写单元测试时我通常构造一个假Request和假Response调用中间件的钩子方法断言返回值是期望的分支。比如测试代理中间件核心断言就是process_request执行后request.meta[proxy]是否来自池子、process_response遇到407时是否返回了原Request。集成测试则更简单写一个最简Spider只请求一个已知会被风控的测试站跑10分钟观察日志里的请求成功率和失败率。中间件的改动合入前我至少会跑一次这样的冒烟测试确认没有影响正常的下载链路。7.3 从最小场景开始迭代别一上来就写全功能给新手的建议只有一个先写一个什么都不做只打印日志的最小中间件跑通流程后再逐步增加代理、重试、动态渲染这些功能。这样每次改动都能定位到具体是哪个环节出了问题而不是一次堆了一堆功能出错了连排查入口都找不到。我踩过最深的坑就是第一版代理中间件就试图同时做代理池刷新、失效剔除、状态码跳转、请求重试四件事结果日志输出乱成一锅粥排查了一整天才定位到是代理列表迭代时被并发修改了。一些实际操作中的补充这几年经手过不少Scrapy爬虫项目下载中间件这一层几乎决定了一个爬虫的生死代理策略对不对、重试策略合不合理、渲染链路有没有阻塞全都在这里体现。很多人觉得中间件“默认就好”真到线上被限流、被封IP、被超时拖垮的时候才意识到默认配置在一套严谨的反爬策略面前几乎是裸奔。我在实际调试中的一个小技巧是每个下载中间件类的首行都加一个简单的类注释标注它依赖了哪些settings项、它的中间件数字编号和依赖顺序。这个习惯帮我省了非常多回溯时间——因为中间件的调用顺序太隐蔽了一旦多个中间件互相依赖没有记录就只能靠日志硬猜。另外要提醒的是下载中间件不是越强大越好。它是把双刃剑写得好了爬虫稳定得像块石头写得烂了每一个请求都在为你的“过度设计”付出性能代价。从最小场景起步、用meta标记控制分支、把翻译逻辑和状态管理分开这三条是我认为下载中间件落地时性价比最高的准则。