知识

如何在 Scrapy 中用自定义中间件轮换住宅代理

Scrapy 按 meta 设代理,但 Proxy-Authorization 缓存这个坑会让轮换失效。一个自定义中间件,干净地轮换身份、并在遇到封锁时重试。

Chris Collins

Chris Collins

2026年8月8日 · 2 分钟阅读

Scrapy 是当一次抓取超出脚本能力时你会伸手去取的框架:它开箱即提供调度、并发、重试和管线。接入一个住宅代理很直接,但 Scrapy 做这件事的方式和一个朴素的 HTTP 客户端不同。代理是一个由下载器中间件处理的、按请求的设置,而那套架构在认证周围有一个特定的坑,会无声地让轮换失效。理解这个中间件模型,一切就都归位了。

这是与用 Python 使用住宅代理(它讲 requests 和 httpx)并列的 Scrapy 篇。Scrapy 的下载器中间件管线是另一头野兽,所以它在这里单独拿出来讲。

下面的一切都使用 Shifter 的住宅 gateway:一个端点 p.shifter.io:443,所有定位都编码在用户名里。换成别家供应商,就换掉主机和凭据;形态是一样的。

一段话讲清 gateway 模型

代理用户名同时承载你的认证你的定位。你不是靠切换端点来换国家或换会话,而是靠改用户名字符串:

customer-USERNAME-country-us-sid-abc123-ttl-600

country-us 定位美国,sid 固定一个粘性会话,ttl 把那个 IP 保持 N 秒。去掉 sid/ttl,每一条新连接都会轮换。密码是恒定的。在 Scrapy 里,那个用户名会变成 Proxy-Authorization 头,而轮换身份就意味着按请求去改它。

Scrapy 怎么处理代理

Scrapy 把每个请求的代理都经由内建的 HttpProxyMiddleware 路由,它读 request.meta['proxy']。天真的搭法是把凭据内联进那个 URL:

# 诱人的一行。它管用——直到你去轮换。
request.meta['proxy'] = 'http://customer-USER-country-us:PASS@p.shifter.io:443'

这对一个单一固定身份管用。它会在你试图轮换的那一刻坏掉,而原因就是那个值得知道的坑。

那个坑:Proxy-Authorization 被缓存了

HttpProxyMiddleware 在代理 URL 里看到凭据时,它会把它们 base64 编码进一个 Proxy-Authorization 头,并且——关键地——把那个头缓存在请求上。如果一个请求后来被重试或重定向、而它的 meta['proxy'] 变成了一个不同的身份,中间件并不总会重算那个头,于是请求带着一个为前一个用户名而生的、陈旧的 Proxy-Authorization 发出去。在一个”用户名承载你的地理和会话”的 gateway 上,这意味着你的轮换无声地并没有在轮换:你在 meta['proxy'] 里改了用户名,但请求仍以旧的那个去认证。

修法是:干脆别把凭据放进代理 URL。把代理主机设成不带 userinfo,并自己在每个请求上、显式地设 Proxy-Authorization 头。这正是一个自定义中间件的用途。

一个自定义轮换中间件

把主机放进 meta['proxy']、不带凭据,并按请求、从你想要的身份去计算认证头。因为定位住在用户名里,选一个国家和一个会话,只是去构建那个正确的用户名。

middlewares.py
import os
from w3lib.http import basic_auth_header
class ShifterProxyMiddleware:
def __init__(self):
self.user = os.environ['SHIFTER_USER']
self.password = os.environ['SHIFTER_PASS']
self.endpoint = 'http://p.shifter.io:443' # 只有主机,没有凭据
def process_request(self, request, spider):
country = request.meta.get('country', 'us')
sid = request.meta.get('sid') # 设它得到一个粘性会话,省略它则轮换
username = f"{self.user}-country-{country}" + (f"-sid-{sid}-ttl-600" if sid else "")
request.meta['proxy'] = self.endpoint
request.headers['Proxy-Authorization'] = basic_auth_header(username, self.password)

启用它,并让它在内建代理中间件之前运行,好让你设的那个头就是发出去的那个:

settings.py
DOWNLOADER_MIDDLEWARES = {
'myproject.middlewares.ShifterProxyMiddleware': 350, # 在 HttpProxyMiddleware(750)之前
}

现在每个请求都带着它自己新算的 Proxy-Authorization,所以在一个请求的 meta 里改 countrysid,就真的改变了身份。给属于同一个逻辑工作单元的那些请求设 sid,好让它们共享一个 IP;把它省掉则按连接轮换(粘性 vs 轮换讲了这个区分)。这样把工作映射到身份,就是 Scrapy 形态下的负载均衡模式。

在重试时轮换,而不只是按计划

Scrapy 的 RetryMiddleware 已经会重试超时和 5xx,但默认它用同一个身份去重试——而如果失败的原因正是那个身份被封了,这就毫无意义。高价值的一招,是专门在一个请求失败、或带着挑战回来时去轮换身份。在你的中间件里,检测一次软封锁或一个 403/429,并用一个新身份把请求重新排期:

def process_response(self, request, response, spider):
if response.status in (403, 429) or looks_blocked(response):
new = request.copy()
new.meta.pop('sid', None) # 丢掉烧掉的会话 -> 新鲜 IP
new.dont_filter = True
return new # 经一个新身份重试
return response

检测软封锁本身是一门功夫——一个 200 仍可能是一张封锁页——所以把这个和检测被封或伪造内容里的那些检查搭配起来。把一个带挑战的响应当作一个轮换的信号、而不是接受它,正是让一次长爬取存活下来的东西。

用好 Scrapy 的礼貌旋钮

Scrapy 给你那些”一个手搓的抓取器得自己造”的限速控制,而有了一支代理机队,它们更要紧、而非更不要紧。按域名给并发封顶,好让一个目标不被猛击;加一个延迟;并打开 AutoThrottle,好去适应站点的响应:

settings.py
CONCURRENT_REQUESTS = 32
CONCURRENT_REQUESTS_PER_DOMAIN = 8 # 按目标的上限,就是那个要紧的
DOWNLOAD_DELAY = 0.5
AUTOTHROTTLE_ENABLED = True
RETRY_ENABLED = True
RETRY_TIMES = 3

按域名的并发,正是那个拦着你别把一个代理池变成一柄分布式大锤的旋钮。越过一个目标的容忍度之后,更多并行买来的是封锁、而不是吞吐量(如何避免被封负责任地抓取都适用),而 AutoThrottle 在响应变慢时退避,正是一次健康的、长期运行的爬取所需要的那份克制。

验证你确实在走代理

把一个 spider 指向一个回显 IP 的端点,在信任一次运行之前先查出口 IP:

def start_requests(self):
yield scrapy.Request('http://ip-api.com/json',
meta={'country': 'us'},
callback=self.parse) # 期望一个美国住宅 IP

返回你自己的 IP,说明中间件没生效、或被排在了 HttpProxyMiddleware 之后。一堵超时之墙,说明认证头错了或缺失。这两种都在超时诊断指南里有讲。

常见问题

为什么我在 Scrapy 里的代理轮换实际上没在轮换? 几乎肯定是 Proxy-Authorization 缓存那个坑:你把凭据放进了代理 URL,HttpProxyMiddleware 缓存了认证头,于是当你在一次重试里改 meta['proxy'] 时,请求仍发送旧凭据。把主机设成不带凭据,并在一个中间件里、按请求自己计算 Proxy-Authorization 头。

定位标志放哪儿? 放进用户名里,它会变成 Proxy-Authorization。一个自定义中间件从按请求的 meta 构建 customer-USER-country-<cc>-sid-<id>-ttl-<秒>,所以选地理和会话,只是在请求上设 countrysid

我怎么给某个特定请求一个粘性会话? 在那个请求的 meta 里设一个稳定的 sid,并在应当归在一起的那些请求之间复用它;省略 sid 则每条连接都轮换。中间件会把它变成那个正确的用户名。

我该每个请求都轮换,还是在重试时轮换? 两者各有其位。对正常流量按逻辑工作单元轮换,并且额外地,当一个请求带着封锁或限速回来时,强制换一个新鲜身份,好让一个烧掉的 IP 不被以它自己重试。

用轮换代理时我还需要 DOWNLOAD_DELAY 和 AutoThrottle 吗? 需要。轮换把负载分摊到各 IP 上,但按域名并发、延迟和 AutoThrottle 拦着你别压垮一个单一目标——无论你有多少 IP。礼貌和轮换解决的是不同的问题。

底线

一旦你绕开它那唯一的坑,Scrapy 加住宅代理就很强大:别把凭据放进代理 URL,因为被缓存的 Proxy-Authorization 会无声地击败轮换。取而代之,写一个小小的下载器中间件,把主机设进 meta['proxy']、并按请求从你想要的身份计算 Proxy-Authorization 头;按逻辑工作单元去轮换那个身份,并在任何被封或被限速的响应上再轮换一次;并倚靠 Scrapy 的按域名并发和 AutoThrottle 来保持礼貌。

做到这些,Scrapy 的调度器、重试和管线就会与你的代理层协作、而不是对着干。把爬取指向住宅 gateway,并记住池的质量决定了你到底会不会频繁重试(IP 信誉)。定价页面有按 GB 计费的套餐,可以拿它对着你自己的目标试用。

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.75/GB。

立即开始