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池的质量上。对于具有先进反机器人保护的网站解析,建议尝试 住宅代理,它们支持会话——在相同的中间件逻辑下,它们显著降低了保护触发的概率,相比于数据中心地址。