Scrapy代理中间件:旋转代理,避免封禁
Scrapy自0.8版本起就支持代理,但文档从未告诉你如何在大规模爬取中进行代理轮换、重试和避免封禁。这里是中间件的结构、代码和决定爬取是否完成的设置。
Scrapy自0.8版本起就提供了代理中间件,因此“Scrapy是否支持代理”这个问题在十五年前就已解决。文档从未完整呈现生产环境的情况:内置的Scrapy代理中间件如何实际处理凭证,何时为每个请求设置代理而非全局设置,以及当目标开始在爬取中途封禁出口时如何轮换和恢复。此指南涵盖整个链条——内置的HttpProxyMiddleware、每个请求的meta、带有封禁处理的自定义轮换中间件,以及决定10万页爬取是完成还是凌晨3点中断的设置。
内置Scrapy代理中间件的工作原理
scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware默认在DOWNLOADER_MIDDLEWARES_BASE中以优先级750启用。阅读其源码(在Scrapy 2.17中约100行)可以让你了解调试所需的一切:
- 启动时,它调用
urllib.request.getproxies(),因此http_proxy/https_proxy环境变量可以在无需代码的情况下配置爬虫。no_proxy仅对http和https方案有效。 - 对于每个请求,
request.meta['proxy']总是优先于环境变量。将其设置为None以强制该请求直接连接。 - 嵌入在代理URL中的凭证(
http://user:pass@host:port)会被剥离,进行Base64编码,并作为Proxy-Authorization: Basic头发送。编码默认为latin-1,可以通过HTTPPROXY_AUTH_ENCODING配置。 - 中间件在编码前会对凭证进行解码,因此在密码中使用
@等特殊字符时需要进行URL编码——它们会正确传递。 - 如果重试更改了代理,中间件会丢弃过时的认证头,而不是将其泄露给新代理——这是现代Scrapy中的凭证卫生修复,也是一个不使用旧版本的好理由。
实际结果:你几乎不需要第三方代理包。任何在优先级750之前将有效代理URL放入request.meta['proxy']的操作都能免费获得认证和头处理。
每个请求的代理与meta
最轻量的集成是直接在爬虫中设置meta——当只有某些请求需要代理,或不同目标需要不同出口国家时很有用:
import scrapy
class PricesSpider(scrapy.Spider):
name = "prices"
def start_requests(self):
yield scrapy.Request(
"https://example.com/product/1",
meta={"proxy": "http://USER:PASS@gate.quantumproxies.io:PORT"},
)
当代理选择本身是数据驱动时,每个请求的meta就显得尤为出色:通过德国定向用户名路由德国产品页面,通过廉价数据中心出口发送图像下载,而HTML则通过住宅出口,或者在第二次尝试时将顽固的URL升级到更持久的会话。因为meta在重试和重定向时仍然有效,你在构建请求时做出的决定会贯穿整个下载周期。手动为数千个请求设置meta并不具备可扩展性——这就是下载中间件的用途。
最简单的生产设置:旋转网关
在一个旋转代理后面使用单一网关端点,轮换在服务器端进行:每个通过相同URL的请求从90M+住宅池中200+国家的不同IP出口。你的中间件缩减到三行,无需健康检查、修剪或刷新列表:
# middlewares.py
class RotatingGatewayMiddleware:
PROXY = "http://USER:PASS@gate.quantumproxies.io:PORT"
def process_request(self, request, spider):
request.meta["proxy"] = self.PROXY
# settings.py
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.RotatingGatewayMiddleware": 350,
}
优先级350很重要:你的中间件必须在内置的750之前运行,以便HttpProxyMiddleware仍然可以将凭证转换为认证头。这种配对——网关轮换加上Scrapy的标准重试机制——是每行代码最高的可靠性,因为每次重试都会自动通过新的出口IP。

带有封禁处理的自定义旋转代理中间件
如果你确实运行自己的代理列表——静态ISP地址或混合池——经典模式(由scrapy-proxies包推广,推荐RETRY_TIMES = 10,因为免费列表代理失败率很高)是:每个请求随机选择,收到封禁信号时驱逐,重新排队请求:
import random
class ProxyListMiddleware:
BAN_CODES = {403, 429}
def __init__(self, proxies):
self.proxies = list(proxies)
@classmethod
def from_crawler(cls, crawler):
return cls(crawler.settings.getlist("PROXY_LIST"))
def process_request(self, request, spider):
if self.proxies and "proxy" not in request.meta:
request.meta["proxy"] = random.choice(self.proxies)
def process_response(self, request, response, spider):
if response.status in self.BAN_CODES:
bad = request.meta.get("proxy")
if bad in self.proxies and len(self.proxies) > 1:
self.proxies.remove(bad)
spider.logger.warning("Evicted %s (%d left)", bad, len(self.proxies))
return request.replace(dont_filter=True) # re-queue on a new proxy
return response
注意这个中间件必须做的一切,而网关可以免费完成:跟踪池健康,驱逐被封禁的IP,重新排队请求。在攻击目标上,20个静态IP的池可以在几分钟内消失。更广泛的反封禁策略(节奏、头、会话纪律)在我们的IP封禁避免清单中。
决定结果的Scrapy设置
中间件放置代理;设置决定爬取行为。这些最为重要:
# settings.py — a sane baseline for proxied crawls
RETRY_TIMES = 5
RETRY_HTTP_CODES = [429, 403, 500, 502, 503, 504, 408]
CONCURRENT_REQUESTS = 32
CONCURRENT_REQUESTS_PER_DOMAIN = 8
DOWNLOAD_TIMEOUT = 30
AUTOTHROTTLE_ENABLED = True
AUTOTHROTTLE_TARGET_CONCURRENCY = 4.0
- RETRY_HTTP_CODES — 添加429和403,以便软封禁触发重试(并且在轮换时,使用新IP),而不是失败项。
- RETRY_TIMES — 3-5适用于优质池;需要10则表明池本身不健康。
- CONCURRENT_REQUESTS_PER_DOMAIN — 真实的礼貌旋钮。轮换将负载分散到多个IP,但目标仍然看到总量。
- DOWNLOAD_TIMEOUT — 默认180秒让一个慢出口占用并发槽位三分钟。30秒足够。
- AUTOTHROTTLE — 根据观察到的延迟调整节奏;在速率限制目标上与轮换搭配良好。仍然淹没在429中?我们的速率限制指南深入探讨并发预算。

超越状态码的封禁检测
复杂的目标不会发送诚实的403。他们会提供包含CAPTCHA页面、空产品网格或剥离模板的200。健壮的爬虫验证内容,而不仅仅是状态——检查真实页面上唯一存在的元素,缺失时重新排队。如果页面即使通过良好代理返回结构上为空,内容可能是在客户端渲染的;请参阅为什么爬虫会得到空页面。当一个域无论IP如何都能击败纯HTTP时,将该域交给一个Scraper API,它渲染JavaScript并返回干净的HTML或markdown——Scrapy像处理任何其他响应一样处理它,你保持你的管道。
def parse(self, response):
if not response.css("div.product-grid"):
# 200 OK but the real content is missing: soft ban or JS wall
yield response.request.replace(dont_filter=True)
return
for product in response.css("div.product-grid article"):
yield {"name": product.css("h2::text").get()}
常见问题解答
Scrapy开箱即用支持代理吗?
是的。HttpProxyMiddleware在优先级750启用:它读取http_proxy / https_proxy环境变量,并在每个请求中遵循request.meta['proxy'],包括user:pass@凭证,它将其转换为Proxy-Authorization头。你只需编写代码来决定每个请求使用哪个代理。
如何在Scrapy中为单个请求设置代理?
在请求的meta中传递它:scrapy.Request(url, meta={'proxy': 'http://user:pass@host:port'})。Meta总是覆盖环境级别的代理,并将值设置为None强制该请求直接连接——在一个爬虫中混合代理和非代理流量时很有用。
如何在Scrapy中轮换代理?
要么编写一个下载器中间件,从列表中为每个请求选择一个代理并驱逐被封禁的代理,要么将每个请求指向一个旋转网关端点,该端点在服务器端分配一个新的出口IP。网关方法只需三行中间件,无需健康检查,并将每个Scrapy重试自动转化为轮换。
为什么我的Scrapy代理返回407?
代理拒绝了认证。检查凭证是否在meta中的代理URL内,特殊字符是否经过URL编码,以及如果你的计划使用IP认证,服务器IP是否被列入白名单。如果凭证包含非ASCII字符,请设置HTTPPROXY_AUTH_ENCODING以匹配你的提供商。
要记住的模式:尽早设置meta['proxy'],让内置中间件在750进行认证,添加429和403到你的重试代码,并优先选择服务器端轮换而不是列表管理。如果你的爬取部分需要JavaScript,在使用之前权衡无头浏览器与HTTP请求的成本——大多数Scrapy项目需要更好的IP,而不是浏览器。