Scrapy 框架集成稳定 HTTP 代理:中间件配置与断线重试实战

一、问题

爬虫用代理不是稀罕事。但 Scrapy 默认的代理支持和重试机制是两个独立的模块,各管各的。这导致一个常见场景:

  • 你配了 HttpProxyMiddleware,代理也能通
  • 某个请求遇到代理超时或连接断开,RetryMiddleware 确实重试了
  • 但是重试时 没有换代理,还是同一个死掉的代理,重试几次都白费

结果就是:代理池里有 50 个可用代理,但每次重试都钉死在那个已经坏掉的代理上,直到它耗尽所有重试次数才换下一个。

本文解决的就是这个问题------把代理切换和重试逻辑绑在一起,做成一个中间件。

二、先看一眼 Scrapy 的下载中间件链条

Scrapy 的下载中间件是一个处理链。请求从引擎发出后,经过每个中间件的 process_request 方法,层层下传到下载器;响应再逆流而上经过 process_response

默认情况下:

复制代码
Request → HttpProxyMiddleware.process_request(设代理)
        → RetryMiddleware.process_response(检查失败,决定重试)

问题是 RetryMiddleware 只标记"这个请求要重试",它不管代理。重试请求走的是同一个 Request 对象,代理地址被 HttpProxyMiddleware 在上一次就设好了,所以重试时还是同一个代理。

三、核心思路

把代理选择和重试逻辑收进同一个中间件,分三步:

  1. 代理选择 :从池子里取一个可用代理,设到 request.meta['proxy']
  2. 异常捕获 :在 process_exception 里捕获连接错误、超时、Connection refused
  3. 换代理重试 :标记重试之前,换掉 request.meta['proxy'],把当前这个坏代理标记为不可用

这样重试三次,每次都是不同的代理,命中率会高很多。

四、代码实现

4.1 代理池(简化版)

先写一个简单的代理池,不做太复杂的健康检查,只维护"可用列表"和"黑名单":

复制代码
# proxy_pool.py
import random
import time


class ProxyPool:
    def __init__(self, proxies: list[str]):
        self._available = list(proxies)
        self._blacklist: dict[str, float] = {}  # proxy -> blacklisted_until

    def get(self) -> str | None:
        """从可用列表中随机取一个代理"""
        if not self._available:
            self._recover()
        if not self._available:
            return None
        return random.choice(self._available)

    def mark_bad(self, proxy: str, cooldown: int = 30):
        """标记代理不可用,冷却 cooldown 秒"""
        if proxy in self._available:
            self._available.remove(proxy)
        self._blacklist[proxy] = time.time() + cooldown

    def _recover(self):
        """恢复过期的黑名单代理"""
        now = time.time()
        expired = [p for p, t in self._blacklist.items() if t <= now]
        for p in expired:
            del self._blacklist[p]
            self._available.append(p)

不搞复杂的权重、延迟统计,够用就行。生产环境可以换成 Redis 后端或者用 scrapy-proxy-pool 那样的成熟方案。

4.2 自定义中间件

复制代码
# middlewares.py
from scrapy import signals
from scrapy.downloadermiddlewares.retry import RetryMiddleware
from scrapy.utils.response import response_status_message

from .proxy_pool import ProxyPool


class RetryProxyMiddleware(RetryMiddleware):
    """在重试时切换代理的下载中间件"""

    def __init__(self, settings):
        super().__init__(settings)
        proxy_list = settings.getlist("PROXY_LIST")
        self.proxy_pool = ProxyPool(proxy_list) if proxy_list else None

    @classmethod
    def from_crawler(cls, crawler):
        o = cls(crawler.settings)
        crawler.signals.connect(o.spider_opened, signal=signals.spider_opened)
        return o

    def spider_opened(self, spider):
        spider.logger.info(
            f"ProxyPool initialized with {len(self.proxy_pool._available) if self.proxy_pool else 0} proxies"
        )

    def process_request(self, request, spider):
        """每次请求前从池子取一个代理"""
        if self.proxy_pool and "proxy" not in request.meta:
            proxy = self.proxy_pool.get()
            if proxy:
                request.meta["proxy"] = proxy
                request.meta["download_timeout"] = self.max_timeout
        return None

    def process_exception(self, request, exception, spider):
        """捕获异常,换代理后重试"""
        proxy = request.meta.get("proxy")
        if proxy and self.proxy_pool:
            spider.logger.warning(
                f"Proxy {proxy} failed: {exception.__class__.__name__}. Switching..."
            )
            self.proxy_pool.mark_bad(proxy)

            # 换一个新代理
            new_proxy = self.proxy_pool.get()
            if new_proxy:
                request.meta["proxy"] = new_proxy
            else:
                del request.meta["proxy"]

        # 调用 RetryMiddleware 内置的异常处理
        return super().process_exception(request, exception, spider)

    def process_response(self, request, response, spider):
        """处理 HTTP 500+ 响应,换代理重试"""
        if response.status in self.retry_http_codes:
            proxy = request.meta.get("proxy")
            if proxy and self.proxy_pool:
                self.proxy_pool.mark_bad(proxy)
                new_proxy = self.proxy_pool.get()
                if new_proxy:
                    request.meta["proxy"] = new_proxy
                else:
                    del request.meta["proxy"]
        return super().process_response(request, response, spider)

这个中间件继承自 Scrapy 自带的 RetryMiddleware,保留了它原有的重试逻辑(重试次数、延时、可重试的 HTTP 状态码),只是在出错时多了一步:把坏代理丢进黑名单,换一个新的

4.3 settings.py 里启用

复制代码
# settings.py

# 配置代理列表(生产环境建议从配置文件或环境变量读)
PROXY_LIST = [
    "http://proxy1.example.com:8080",
    "http://proxy2.example.com:8080",
    "http://proxy3.example.com:8080",
]

# 禁用默认的重试中间件,用我们的代替
DOWNLOADER_MIDDLEWARES = {
    "scrapy.downloadermiddlewares.retry.RetryMiddleware": None,
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": None,
    "project.middlewares.RetryProxyMiddleware": 543,
}

# 重试参数
RETRY_ENABLED = True
RETRY_TIMES = 3
RETRY_HTTP_CODES = [500, 502, 503, 504, 408, 429]

注意中间件优先级:543 是 Scrapy 默认 RetryMiddleware 的值。用同样的优先级表示替换原有位置,避免打乱整个处理链的顺序。

五、断线重试的细节

5.1 哪些异常会被捕获

process_exception 收到的是下载器抛出的异常。常见的有:

异常 触发条件
ConnectionRefusedError 代理服务器拒绝连接
TimeoutError / TCPTimedOutError 代理超时无响应
ConnectionResetError 代理主动断连
DNSLookupError 代理域名解析失败

Scrapy 把这些异常统一用 retry_exceptions 配置控制。如果你不想对所有异常都重试,可以改这个配置:

复制代码
RETRY_EXCEPTIONS = [
    "twisted.internet.error.ConnectionRefusedError",
    "twisted.internet.error.TCPTimedOutError",
    "twisted.internet.error.ConnectionLost",
]

5.2 max_timeout 和代理速度匹配

代理一般比自己直连慢。如果 DOWNLOAD_TIMEOUT 设得太小(比如默认的 180 秒对某些慢代理来说都算短),那代理还没回复就被超时了,会造成大量假失败。

建议针对代理请求单独设一个超时:

复制代码
# 在 process_request 里
request.meta["download_timeout"] = 30  # 每个代理给 30 秒

或者在中间件初始化时设一个 max_timeout 类变量,通过 settings 给爬虫自己调。

5.3 重试次数不用太多

有人觉得"代理不稳定就多重试几次"。这个思路反了。一个好的代理池,每次换代理的命中率应该在 70% 以上。如果连续 3 次换代理都失败,那大概率不是代理的问题,而是目标站点封了你的请求特征(UA、Cookie、请求频率)。

复制代码
RETRY_TIMES = 2   # 换 2 次代理重试
# 第一次失败 → 换代理重试
# 第二次失败 → 再换代理重试
# 第三次还失败 → 放弃,记录日志

超过这个次数还失败,应该降级处理而不是无限制重试。

六、生产环境要关注的三个问题

6.1 代理质量比数量重要

假如池子里有 100 个代理,但其中 80 个是失效的,那每次 get() 都大概率摸到坏代理。你的重试机制再好,也救不了质量太差的池子。

我自己的做法:每次请求完成后记录代理的响应时间、成功率,定期清理存活率低于 50% 的代理。

6.2 HTTPS 代理要区分

HTTP 代理和 HTTPS 代理的配置方式不同。对于 HTTPS 请求,代理需要支持 CONNECT 隧道。Scrapy 原生 HttpProxyMiddleware 在处理 https:// URL 时会自动设置 proxyhttp:// 格式(因为 HTTP 代理的 CONNECT 方法走的是 HTTP 协议),所以写法是一样的:

复制代码
request.meta["proxy"] = "http://user:pass@proxy.example.com:8080"

但如果你的代理是 SOCKS5,那就需要额外装 scrapy-socks 或者用 socks5:// 前缀 + 自定义中间件处理。

6.3 被封了怎么降级

这是最容易被忽略的。当代理全部用完,proxy_pool.get() 返回 None 时,两种选择:

  • 改用直连(不设 proxy),让请求裸奔
  • 停止爬虫,等下次调度

我的建议是:如果爬的目标站点不是高敏感类型,临时降级为直连 + 大幅降低请求频率,比彻底停掉要划算。毕竟代理用完通常是暂时的(等冷却恢复就行)。

复制代码
new_proxy = self.proxy_pool.get()
if new_proxy:
    request.meta["proxy"] = new_proxy
else:
    # 没有可用代理了,临时直连,但加一个标志
    request.meta["proxy"] = None
    request.meta["proxy_exhausted"] = True
    # 顺便降低这个请求的速度
    request.meta["download_delay"] = 10

七、验证你的中间件有没有生效

测三件事就够了:

  1. 代理确实被用了 :在 log 里看到每个请求的 proxy meta

  2. 坏代理确实被换了:给一个已知的死代理,看中间件是否立刻切到下一个

  3. 重试确实换了代理 :故意把代理设成 127.0.0.1:1(肯定连不上),看两次重试的 proxy 值是否不同

    简单验证:在 spider 的 parse 方法里打印

    def parse(self, response):
    proxy = response.request.meta.get("proxy")
    retry_count = response.request.meta.get("retry_times", 0)
    self.logger.info(f"Used proxy: {proxy}, retried: {retry_count}")

八、总结

Scrapy 自带的 HttpProxyMiddlewareRetryMiddleware 分开工作,导致重试不换代理的问题。解决思路是写一个合并的中间件,继承 RetryMiddleware 并在 process_exceptionprocess_response 里加入代理切换逻辑。

核心改动就两处:

  • 出错时把当前代理丢进黑名单
  • 重试前从池子拿一个新代理

其他所有东西(重试次数、延时、可重试状态码)都复用 Scrapy 原生的逻辑,不用重新造。

如果你正在维护一个长期跑的 Scrapy 爬虫,代理这块值得花半小时配好。否则你会发现日志里全是重试成功但数据没回来的情况,排查起来更费时间。

相关推荐
phltxy2 小时前
LangChain_Agent中间件实战
人工智能·python·深度学习·语言模型·中间件·langchain
完美火龙篇 四月的友2 小时前
SpringBoot 即时聊天 IM 完整实现(HTTP会话管理 \+ WebSocket实时推送 \+ 离线消息)
spring boot·websocket·http
9527出列3 小时前
一次 HttpClient 连接池泄漏的完整排查与修复
http
Shell运维手记3 小时前
交换机(二层交换机)完整工作原理
运维·网络·网络协议·macos·交换机
智码看视界3 小时前
Day33-数据层 × 中间件AI化篇:Redis缓存经典问题-击穿、穿透、雪崩的终极解决方案
数据库·redis·缓存·中间件·穿透·雪崩·击穿
白色冰激凌3 小时前
[SECS/GEM研究] (四)HSMS 用TCP取代串口
网络·c++·网络协议·secs/gem
wbs_scy3 小时前
仿 muduo 高并发服务器项目:封装 HTTP 请求响应并用状态机完成增量解析
运维·服务器·http
ShineWinsu4 小时前
对于Linux:http的解析
linux·网络·c++·网络协议·http·请求·响应
可爱系程序猿17 小时前
Windows 打印链路诊断:从设备枚举、TCP/IP 端口到 Spooler 服务恢复
网络·网络协议·tcp/ip