Downloader Middleware完全指南 - 请求响应拦截与反爬策略详解

📂 所属阶段:第三阶段 — 攻防演练(中间件与反爬篇) 🔗 相关章节:Spider 实战 · Pipeline管道实战

在 Scrapy 的江湖里,你辛辛苦苦写好的爬虫,上线跑起来才发现处处碰壁:要么被各种验证码拦住,要么返回一堆乱码,更惨的直接被封了 IP。这时候,是该把 Downloader Middleware 这位“神兵利器”请出场了。它就像 Scrapy 的智能外交官,在你正式发起请求前和收到响应后,都能灵活地插上一手,帮你和网站打好交道。

本文将带你从概念深入实战,彻底玩转 Middleware 这个强大工具。

目录

Middleware基础概念

Downloader Middleware 位于 Scrapy 引擎(Engine)和下载器(Downloader)之间,是构成反爬策略的核心枢纽。一切从你发出的请求(Request)到服务器返回的响应(Response),都必须先经过它的地盘。

它的主要工作,我们总结为以下四项:

  1. 请求拦截与伪装:在请求发出去之前,给它换上一身行头。比如修改请求头(Headers)、添加认证令牌(Token)、设置代理 IP 等等,让我们的爬虫看起来更像普通用户。
  2. 响应验货与处理:收到服务器返回的数据后,先别急着用。中间件可以帮你先“验个货”,看看是不是被反爬了、是不是跳转到了验证码页面、或者需要对加密数据进行解密。
  3. 异常危机公关:当下载过程中出现网络超时、DNS解析失败等意外时,中间件可以启动应急方案,比如优雅地重试,或是干脆换个代理再战,避免爬虫直接崩溃。
  4. 反爬策略演练场:应对网站反爬的十八般武艺,比如 User-Agent 轮换、IP 代理池、请求频率限制等,主战场都在这里。

来看看一个请求在 Scrapy 中的完整“旅程”:

引擎(Engine)→ 调度器(Scheduler)→ 引擎(Engine)

             下载器中间件(Downloader Middleware)

             下载器(Downloader)

引擎(Engine)→ 爬虫中间件(Spider Middleware)→ 爬虫(Spider)

可以看到,下载器中间件在请求和响应的必经之路上,任何数据流转都逃不过它的眼睛。

Middleware生命周期

每个 Downloader Middleware 运行时,都有生命周期方法能让我们在不同的时间点“插手”干预请求和响应的处理。

我们先来认识一个最基础的中间件长什么样:

class BaseMiddleware:
    """一个下载器中间件的标准模板"""

    @classmethod
    def from_crawler(cls, crawler):
        """工厂方法:Scrapy会调用它来创建中间件实例,同时可以拿到全局配置crawler对象"""
        return cls()

    def process_request(self, request, spider):
        """处理即将发出的请求"""
        # 返回None:放行,让这个请求继续走后面的流程。
        # 返回Response对象:直接拦截,不再下载,把这个Response当作最终结果。
        # 返回Request对象:将当前请求拦截,改为发起一个新的请求。
        # 抛出IgnoreRequest异常:直接丢弃这个请求,无响应返回。
        return None

    def process_response(self, request, response, spider):
        """处理下载完成的响应"""
        # 这个方法必须返回一个Response对象,可以是原始响应,也可以是新的。
        return response

    def process_exception(self, request, exception, spider):
        """处理下载过程中抛出的异常"""
        # 返回Response:表示异常已处理,用这个响应替代报错。
        # 返回Request:重试操作,返回一个新的请求。
        # 返回None:表示这个异常我没辙了,让下一个中间件来处理。
        return None

写好之后,别忘了在 settings.py 中给它“注册”:

# settings.py
DOWNLOADER_MIDDLEWARES = {
    # 键是中间件的路径,值是优先级(数字越小越先执行)
    'myproject.middlewares.UserAgentMiddleware': 543,
    'myproject.middlewares.ProxyMiddleware': 544,
    'myproject.middlewares.CookiesMiddleware': 545,
}

# 你可能会好奇怎么定这个优先级数字,记住这几点就行:
# · Scrapy内置中间件的优先级范围在 0-1000。
# · 我们自定义的中间件,通常推荐放在 500-1000 之间。
# · 100 这个数字,一般是 Downloader 内部的界限,太靠前小心抢戏。

核心处理方法

下面我们深入这三个核心方法的具体用法。理解它们的逻辑,就拿到了驾驭 Middleware 的钥匙。

请求处理

process_request 是请求发出前的最后一站,最适合用来为请求添加通用配置或认证信息。

class BasicRequestMiddleware:
    """基础请求处理中间件"""

    def process_request(self, request, spider):
        # 给请求头添加默认值,不会覆盖已有的设置
        request.headers.setdefault('Accept', 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8')
        request.headers.setdefault('Accept-Language', 'zh-CN,zh;q=0.8,en-US;q=0.5,en;q=0.3')

        # 从全局settings里拿到动态的认证Token
        auth_token = spider.crawler.settings.get('AUTH_TOKEN')
        if auth_token:
            request.headers['Authorization'] = f'Bearer {auth_token}'

        return None

响应处理

process_response 是响应到达 Spider 前的检查站。你经常遇到的那些“访问太频繁”、“请输入验证码”之类的反扒信息,最适合在这里处理。

class RetryResponseMiddleware:
    """响应重试中间件:识别反爬页面并自动重试"""

    def process_response(self, request, response, spider):
        response_text = response.text.lower()

        # 定义一个常见的反爬关键词黑名单
        anti_crawl_indicators = [
            '访问过于频繁', '请稍后重试', 'blocked', 'forbidden',
            '验证码', 'captcha', 'rate limit', 'too many requests'
        ]

        # 如果命中了黑名单,说明需要重试
        if any(indicator in response_text for indicator in anti_crawl_indicators):
            retry_times = request.meta.get('retry_times', 0)
            max_retries = spider.crawler.settings.getint('MAX_RETRY_TIMES', 3)

            if retry_times < max_retries:
                spider.logger.info(f"检测到反爬,正在重试 {request.url},第 {retry_times + 1} 次")
                new_request = request.copy()
                new_request.meta['retry_times'] = retry_times + 1
                new_request.dont_filter = True
                return new_request

        return response

exception-handling

当网络波动或服务器抽风导致请求失败时,process_exception 就成了救命稻草,让我们能体面地处理异常。

from twisted.internet.error import TimeoutError, DNSLookupError

class BasicExceptionMiddleware:
    """基础exception-handling中间件:处理网络超时"""

    def process_exception(self, request, exception, spider):
        if isinstance(exception, TimeoutError):
            spider.logger.warning(f"请求超时:{request.url}")
            return self.handle_retry(request, exception, spider)

        return None

    def handle_retry(self, request, exception, spider):
        retry_times = request.meta.get('retry_times', 0)
        max_retries = spider.crawler.settings.getint('MAX_RETRY_TIMES', 2)

        if retry_times < max_retries:
            new_request = request.copy()
            new_request.meta['retry_times'] = retry_times + 1
            new_request.dont_filter = True
            return new_request

        return None

User-Agent轮换策略

网站反爬的第一道门槛,往往就是看你的 User-Agent(一个标识你浏览器身份的字符串)。总是用一个老的 UA 字符串去访问,无异于自报家门。轮换 UA,是最低成本也是最有效的伪装。

import random

class UserAgentMiddleware:
    """负责给每个请求随机挑选一件‘浏览器外衣’"""

    def __init__(self):
        self.user_agents = [
            # Windows Chrome
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            # Mac Chrome
            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
            # Windows Firefox
            'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:109.0) Gecko/20100101 Firefox/121.0',
            # Mac Safari
            'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Safari/605.1.15',
            # iPhone Safari
            'Mozilla/5.0 (iPhone; CPU iPhone OS 17_1 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.1 Mobile/15E148 Safari/604.1',
        ]

    def process_request(self, request, spider):
        # 关键一步,干活前给你换件衣服
        ua = random.choice(self.user_agents)
        request.headers['User-Agent'] = ua
        return None

代理IP管理

如果网站开始封你的 IP,说明你需要一批“马甲”了。代理 IP 管理是个技术活,既要能管理一个代理池,还要能根据代理的健康状态进行智能切换。下面的代码示例构建了一个基础的、带有健康检查的代理中间件。

import random
from collections import defaultdict

class ProxyMiddleware:
    """智能代理中间件,能记住哪个代理干的好,哪个总掉链子"""

    def __init__(self):
        # 代理池,实际场景可以从配置或API动态获取
        self.proxy_pool = [
            'http://proxy1:port',
            'http://proxy2:port',
            'http://proxy3:port',
        ]

        # 给代理们建立一份成绩单
        self.proxy_stats = defaultdict(lambda: {
            'success_count': 0,
            'failure_count': 0,
            'ban_count': 0
        })

    def process_request(self, request, spider):
        if request.meta.get('proxy'):
            return None

        proxy = self._select_proxy()
        if proxy:
            request.meta['proxy'] = proxy

        return None

    def process_response(self, request, response, spider):
        proxy = request.meta.get('proxy')
        if proxy:
            if response.status in [200, 301, 302]:
                self.proxy_stats[proxy]['success_count'] += 1
            elif response.status in [403, 404, 429, 503]:
                self.proxy_stats[proxy]['failure_count'] += 1
                if response.status == 403:
                    self.proxy_stats[proxy]['ban_count'] += 1

        return response

    def process_exception(self, request, exception, spider):
        proxy = request.meta.get('proxy')
        if proxy:
            self.proxy_stats[proxy]['failure_count'] += 1

    def _select_proxy(self):
        """只选择历史成绩好的‘优等生’代理"""
        healthy_proxies = []

        for proxy, stats in self.proxy_stats.items():
            if stats['failure_count'] < 5 and stats['ban_count'] < 3:
                healthy_proxies.append(proxy)

        if not healthy_proxies:
            # 实在没有优等生了,就随机选一个碰碰运气
            return random.choice(self.proxy_pool) if self.proxy_pool else None

        return random.choice(healthy_proxies)

Cookies管理

登录之后、购物车、个性化推荐这些场景,都离不开 Cookies。一个好的 Cookies 中间件能帮你管理这些会话信息。

import time
from urllib.parse import urlparse

class CookiesMiddleware:
    """按域名管理Cookies,维持会话状态"""

    def __init__(self):
        self.domain_cookies = {}

    def process_request(self, request, spider):
        domain = self._extract_domain(request.url)
        cookies = self.domain_cookies.get(domain, {})

        if cookies:
            cookie_header = '; '.join([f"{name}={data['value']}"
                                     for name, data in cookies.items()])
            request.headers['Cookie'] = cookie_header

        return None

    def process_response(self, request, response, spider):
        domain = self._extract_domain(request.url)
        set_cookies = response.headers.getlist('Set-Cookie')

        for cookie_header in set_cookies:
            cookie_data = self._parse_cookie(cookie_header.decode('utf-8'))
            if cookie_data:
                if domain not in self.domain_cookies:
                    self.domain_cookies[domain] = {}

                self.domain_cookies[domain][cookie_data['name']] = {
                    'value': cookie_data['value'],
                    'timestamp': time.time()
                }

        return response

    def _extract_domain(self, url):
        parsed = urlparse(url)
        return parsed.netloc

    def _parse_cookie(self, cookie_str):
        parts = cookie_str.split(';')
        if not parts:
            return None

        name_value = parts[0].split('=', 1)
        if len(name_value) != 2:
            return None

        return {
            'name': name_value[0].strip(),
            'value': name_value[1].strip()
        }

请求延迟与限速

有节制的访问是爬虫长期生存的法则。设置适当的下载延迟和随机化,能让你的爬虫行为更接近人类。

import time
import random
from collections import defaultdict

class RateLimitMiddleware:
    """为每个域名设置独立的访问节奏"""

    def __init__(self):
        self.domain_last_request = defaultdict(float)

    def process_request(self, request, spider):
        domain = self._extract_domain(request.url)

        min_delay = spider.crawler.settings.getfloat('DOWNLOAD_DELAY', 1)
        randomize_delay = spider.crawler.settings.getbool('RANDOMIZE_DOWNLOAD_DELAY', True)

        delay = min_delay
        if randomize_delay:
            # 让请求间隔在0.5倍到1.5倍基础延迟之间随机浮动
            delay = random.uniform(min_delay * 0.5, min_delay * 1.5)

        current_time = time.time()
        wait_time = (self.domain_last_request[domain] + delay) - current_time

        if wait_time > 0:
            time.sleep(wait_time)

        self.domain_last_request[domain] = time.time()
        return None

    def _extract_domain(self, url):
        from urllib.parse import urlparse
        return urlparse(url).netloc

常见问题与解决方案

排坑指南,让你在中间件开发中少走弯路。

问题1:中间件不生效

现象:在 settings.py 里配了中间件,但它的代码压根没跑。

解决方案

# 1. 重点检查settings.py里的路径写对没,类名大小写敏感!
DOWNLOADER_MIDDLEWARES = {
    'myproject.middlewares.MyMiddleware': 543,  # 确认这个路径能直接 import 到你的类
}

# 2. 去对应的 Python 文件里,检查类名是否一致,有没有忘记 import 依赖。
# 3. 看看 Spider 运行时控制台有无报错,如果中间件初始化就挂了,Scrapy 会禁用它的。

问题2:请求被无限重定向

现象:爬虫卡在一个请求上,不停地返回新的请求,似乎永远不会结束。

解决方案

class SafeRedirectMiddleware:
    def process_request(self, request, spider):
        # 给重定向次数加上限,避免跳进无底洞
        redirect_times = request.meta.get('redirect_times', 0)
        if redirect_times > 5:
            return None

        # ...这里是你的重定向逻辑
        new_request = request.replace(url=new_url)
        new_request.meta['redirect_times'] = redirect_times + 1
        return new_request

问题3:代理切换不及时

现象:明明某个代理已经被网站封了,后续请求还在傻乎乎地用。

解决方案

class SmartProxyMiddleware:
    def process_response(self, request, response, spider):
        # 一旦发现代理被禁或受限,就地正法并马上发起重试
        if response.status in [403, 429, 503]:
            current_proxy = request.meta.get('proxy')
            if current_proxy:
                self.mark_proxy_failed(current_proxy)
                # 构造一个新请求,打上'换代理'的标记
                new_request = request.copy()
                new_request.meta['change_proxy'] = True
                new_request.dont_filter = True
                return new_request

        return response

最佳实践建议

打造一个稳健而优雅的中间件体系,这里有几点心法送给你。

设计原则

  1. 模块化:千万别把 UAS 轮换、IP 代理、Cookies 管理这些全挤在一个中间件里。每个中间件只专注一件事,方便组合和排错。
  2. 可配置:硬编码是万恶之源。任何可能变化的参数(重试次数、延迟秒数等),都通过 settings.py 或自定义配置来接管。
  3. 健壮性:你的中间件代码应该像瑞士军刀一样可靠,妥善处理所有潜在异常,避免因为一个中间件的小错误导致整个爬虫挂掉。
  4. 性能考虑process_requestprocess_response 是性能敏感地带,尽量避免在这里做复杂的计算或阻塞的IO操作。

安全与合规

  1. 隐私保护:如果你在中间件里处理了登录态、token 等敏感信息,确保它们不出现在公开的日志中。
  2. 合规性:君子有所为有所不为。始终留意目标网站的 robots.txt 和用户协议,选择合适的反爬策略,在合理范围内使用你的技术。
  3. 频率控制:心怀对目标服务器的尊重。合理控制请求频率,不要让你的爬虫成为对别人网站的破坏性攻击。

💡 核心要点 Downloader Middleware 是你驾驭 Scrapy、应对复杂反爬环境的方向盘。从请求到响应,它给了你无与伦比的控制力。记住,最有效的反爬策略,源自于对目标网站行为的细致观察和对这些工具恰到好处的组合运用。现在,去为你的“网络外交官”定制策略吧!