← 返回博客

Scrapy代理中间件中的5个错误,浪费代理流量

在Scrapy中,五种典型的中间件错误,导致代理流量浪费,解析器被封禁。包含代码示例和现成解决方案。

📅2026年9月29日

Scrapy解析器因超时而崩溃,代理池的消耗速度远超预期,日志中充斥着407和403的响应——这是不是很熟悉?在90%的情况下,问题不在于代理本身,而在于DownloaderMiddleware的编写方式。我们将分析中间件中五个最常见的错误,这些错误将昂贵的流量转化为垃圾请求,并展示如何通过代码修复它们。

错误1:未考虑代理状态的原始轮换

在教程中最常见的构造是random.choice(PROXY_LIST)在process_request内部。问题在于,这种轮换并不知道哪个代理刚刚被封禁,哪个仍然可用。结果,解析器继续通过已经被封禁的IP发送请求,收到403/429,进行重试——然后再次选择同一个地址,因为选择是随机的,并且不排除“坏”节点。

正确的方法是跟踪每个代理的状态:成功请求的数量、错误的数量、最后使用的时间。以下是一个最小的工作示例:

import random
import time

class ProxyPool:
    def __init__(self, proxies):
        self.proxies = {p: {"fails": 0, "last_used": 0, "banned_until": 0} for p in proxies}

    def get_proxy(self):
        now = time.time()
        available = [
            p for p, state in self.proxies.items()
            if state["banned_until"] < now
        ]
        if not available:
            # 如果所有代理都被封禁,重置最“旧”的封禁
            available = list(self.proxies.keys())
        return random.choice(available)

    def mark_fail(self, proxy, cooldown=300):
        self.proxies[proxy]["fails"] += 1
        self.proxies[proxy]["banned_until"] = time.time() + cooldown

    def mark_success(self, proxy):
        self.proxies[proxy]["fails"] = 0
        self.proxies[proxy]["last_used"] = time.time()

这样的池在“冷却”时间内排除了被封禁的IP,并在之后将它们重新投入使用。这已经大大减少了流量消耗,因为你不会不断地攻击同一个被封禁的节点。

错误2:重试和状态码处理不当

第二个典型错误是使用Scrapy的标准RetryMiddleware而不进行修改。默认情况下,它会在同一个代理上重试请求,如果你没有在process_exception中明确添加更换代理的逻辑。结果是经典的场景:3次重试,3次封禁,请求仍然失败,而流量已经消耗。

第二个问题是,并不是所有状态码都需要以相同的方式重试。429(请求过多)需要暂停并更换IP,403通常表示特定代理被封禁(需要立即更换),而5xx通常是服务器端的临时问题,可以在同一代理上重试。如果将所有情况混为一谈,中间件要么过于激进地消耗代理,要么在需要立即更换IP的地方等待太久。

class SmartRetryMiddleware:
    def __init__(self, pool):
        self.pool = pool

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy")
        if response.status in (403, 407):
            if proxy:
                self.pool.mark_fail(proxy, cooldown=600)
            new_request = request.copy()
            new_request.meta["proxy"] = self.pool.get_proxy()
            new_request.dont_filter = True
            return new_request

        if response.status == 429:
            if proxy:
                self.pool.mark_fail(proxy, cooldown=120)
            new_request = request.copy()
            new_request.meta["proxy"] = self.pool.get_proxy()
            new_request.dont_filter = True
            return new_request

        if proxy:
            self.pool.mark_success(proxy)
        return response

在这里,重要的是根据错误类型划分冷却时间:严格封禁(403/407)——较长的暂停,频率限制(429)——较短的。这可以节省数十个百分点的流量,尤其是在长时间运行的情况下。

错误3:缺乏针对授权网站的粘性会话

如果解析器与需要登录、购物车、状态保持的分页或基于IP的验证码网站进行交互——每个请求更换代理会破坏会话。网站会看到请求#1来自一个IP,而请求#2(在同一cookie会话中)来自另一个IP,这会立即触发反机器人保护,即使两个IP都是“干净”的。

解决方案是将一个代理绑定到一个逻辑会话(例如,特定账户或同一域名内的请求链)上固定时间,而不是在每个请求中更换。这被称为粘性会话。

class StickySessionMiddleware:
    def __init__(self, pool, ttl=600):
        self.pool = pool
        self.ttl = ttl
        self.sessions = {}  # session_id -> (proxy, expires_at)

    def process_request(self, request, spider):
        session_id = request.meta.get("session_id")
        if not session_id:
            return

        now = time.time()
        session = self.sessions.get(session_id)

        if session and session[1] > now:
            request.meta["proxy"] = session[0]
        else:
            proxy = self.pool.get_proxy()
            self.sessions[session_id] = (proxy, now + self.ttl)
            request.meta["proxy"] = proxy

对于需要在整个会话中保持IP稳定的任务——授权、个人账户操作、多步骤表单——最适合使用 住宅代理,它们支持会话:它们允许在几分钟或几小时内保持相同的出站IP,然后以可控的方式更换,而不是在每个请求中随机更换。

错误4:代理授权传递不正确

第四个错误是技术性的,但几乎在每个第二个项目中都会出现。开发人员通过http://user:pass@ip:port的URL直接传递代理的用户名和密码,使用request.meta["proxy"]。在大多数情况下,这种方法有效,但在通过HTTPS隧道使用代理或与某些提供商合作时,这种授权方式会被标准的HttpProxyMiddleware错误处理,请求会因407 Proxy Authentication Required而失败,尽管凭据是正确的。

更可靠的方法是明确传递Proxy-Authorization头,以base64编码:

import base64

class ProxyAuthMiddleware:
    def process_request(self, request, spider):
        proxy = request.meta.get("proxy")
        if not proxy:
            return

        # 代理URL中不包含凭据
        request.meta["proxy"] = proxy

        user = request.meta.get("proxy_user")
        password = request.meta.get("proxy_pass")
        if user and password:
            credentials = f"{user}:{password}"
            encoded = base64.b64encode(credentials.encode()).decode()
            request.headers["Proxy-Authorization"] = f"Basic {encoded}"

这种方法在扩展到数百个并行请求时更稳定,并且不依赖于特定版本的库如何解析带有嵌入凭据的URL。这在使用移动代理时尤其重要,因为身份验证通常依赖于IP白名单或严格的头部检查。

错误5:缺乏监控和封禁日志

最后一个,可能是后果最严重的错误——缺乏记录哪些代理被封禁、封禁频率和封禁域名的日志。没有这些数据,无法理解是什么在消耗流量:是代理池在特定网站上耗尽,还是解析器本身的问题(请求过于频繁、缺乏延迟、可疑的头部)。

中间件中应记录的最小指标集:

  • 每个代理在解析会话中的请求数量
  • 每个代理的错误数量和状态码(403、407、429、5xx)
  • 代理在第一次被封禁前的存活时间
  • 封禁最频繁的域名
import logging
import json

logger = logging.getLogger("proxy_stats")

class ProxyStatsMiddleware:
    def __init__(self):
        self.stats = {}

    def process_response(self, request, response, spider):
        proxy = request.meta.get("proxy", "unknown")
        domain = request.url.split("/")[2]
        key = f"{proxy}|{domain}"

        entry = self.stats.setdefault(key, {"requests": 0, "errors": 0})
        entry["requests"] += 1
        if response.status in (403, 407, 429):
            entry["errors"] += 1

        if entry["requests"] % 50 == 0:
            logger.info(json.dumps(self.stats))

        return response

没有这样的统计数据,任何“优化”中间件的尝试都变成了猜测。通过这些数据,你可以清楚地看到:例如,如果特定域名在前10个请求中封禁了80%的代理池——问题不在于代理,而在于请求模式(没有User-Agent轮换、频率过高、请求之间缺乏延迟)。

完整的中间件工作示例

将所有内容汇总到settings.py中——中间件的顺序至关重要,因为它决定了检查的应用顺序:

DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.ProxyAuthMiddleware": 350,
    "myproject.middlewares.StickySessionMiddleware": 400,
    "myproject.middlewares.SmartRetryMiddleware": 550,
    "myproject.middlewares.ProxyStatsMiddleware": 900,
}

RETRY_ENABLED = False  # 禁用标准重试,使用自定义的
DOWNLOAD_TIMEOUT = 15
CONCURRENT_REQUESTS_PER_DOMAIN = 8

请注意RETRY_ENABLED = False——这一点非常重要,否则Scrapy内置的重试机制将与您的代理更换逻辑冲突,请求将开始重复或重试两次。同样,限制CONCURRENT_REQUESTS_PER_DOMAIN也很重要——即使使用代理轮换,对于一个域名过高的并发性也会引起反机器人系统的怀疑。

选择Scrapy的代理类型

中间件解决了问题的一半,另一半是根据任务正确选择代理池。以下是针对典型解析场景的比较。

代理类型 何时使用 优点 缺点
数据中心代理 大规模解析开放页面,无严格的反机器人保护 高速,低流量成本 容易被检测,常常在黑名单中
住宅代理 解析市场、具有JS保护的网站、授权 真实IP,低封禁率,支持粘性会话 速度低于数据中心代理
移动代理 解析移动版本的网站和具有严格反机器人保护的API 目标网站的最大信任度 最高的流量成本

实用规则:对于没有强大保护的静态页面解析,便宜的数据中心代理与本文中的合理中间件搭配使用。如果网站使用Cloudflare、PerimeterX、DataDome或类似系统——数据中心IP几乎会立即被封禁,此时更值得直接切换到住宅池,节省中间件调试的时间。

在将解析器投入生产前的检查清单

  • 中间件在冷却时间内排除被封禁的代理,而不是随机选择它们
  • 重试逻辑区分错误类型(403/407 vs 429 vs 5xx),并有不同的冷却时间
  • 对于会话任务,使用粘性代理绑定,而不是每个请求都更换代理
  • 代理授权通过Proxy-Authorization头传递,而不仅仅是通过URL
  • 记录每个域名和代理的封禁统计以诊断问题
  • 禁用标准的Scrapy RetryMiddleware,以免与自定义逻辑冲突
  • 并发限制在合理范围内,而不是设置为最大值

结论

大多数Scrapy中代理流量消耗的问题并不是通过购买更多的IP池来解决,而是通过修复中间件逻辑:按类型划分错误、为复杂场景提供粘性会话、正确的授权和持续监控封禁。本文中的代码可以作为基础,适应特定项目——结构同样适用于小型解析器和分布式Scrapy-Cluster安装。

如果中间件已经正确配置,但封禁仍然发生得太频繁——可能问题出在IP池的质量上。对于具有先进反机器人保护的网站解析,建议尝试 住宅代理,它们支持会话——在相同的中间件逻辑下,它们显著降低了保护触发的概率,相比于数据中心地址。