你的 Scrapy 爬虫在 100 个测试页面上表现完美,到了 10,000 页就直接崩了。那其实不一定是爬虫代码有 bug——更像是网络问题披着爬虫 bug 的外衣。大家第一时间想到的解决办法通常是代理,而把代理塞进 request.meta 也就三十秒的事。
问题在于,这个三十秒的方案往往也是很多入门教程的终点。它们会告诉你 meta={"proxy": "http://IP:PORT"},或者给你一个中间件类,然后就结束了。却很少提到代理在抓取途中失效怎么办、凭证如何避免泄露到 Git 历史里、或者为什么重试请求可能又回到了同一个坏代理。这些才是生产环境真正要考虑的点:失败即关闭的配置、密钥管理、在重试时有意识地选择代理、协议边界限制,以及可衡量的成本权衡。
Scrapy 里的代理中间件是什么?
代理中间件是一段位于 Scrapy 下载器流程中的代码,它会在请求真正发出去之前,决定这个请求看起来来自哪个 IP。Scrapy 自带了一个内置的 HttpProxyMiddleware,它会读取 request.meta 里的 proxy 字段,必要时附加代理认证信息,然后把请求交给真正负责建立连接的下载器处理。自定义代理中间件并不是替代这个传输步骤——它更像是一个“选择器”,负责在内置机制接管之前,先决定要塞入哪个代理。这个区别比看上去重要得多,也是“我的自定义中间件怎么没生效”这类问题的主要来源。
为什么代理对生产级 Scrapy 项目很重要?
代理会改变网络路径和表面上的来源 IP。这在合法的地域测试、分发已授权的请求流量、隔离网络故障时都很有帮助。但它并不会赋予你访问权限、绕过速率限制,或者保证一定能访问成功。到底要不要给爬虫加代理,取决于目标站点、其使用条款、请求频率,以及任务对稳定性的要求。
并不是所有代理都一样,选错档位本身就可能引发生产事故:
| 代理类型 | 可靠性 | 速度 | 被识别风险 | 典型成本 |
|---|---|---|---|---|
| 免费公共代理 | 波动极大 | 不稳定 | 往往很高 | 不收费,但安全和运维风险很大 |
| 数据中心代理 | 取决于服务商和目标站点 | 通常较快 | 取决于目标站点 | 常按流量或 IP 计费 |
| 住宅代理 | 取决于服务商和目标站点 | 不稳定 | 取决于目标站点 | 通常按流量计费 |
| ISP 代理 | 取决于服务商和目标站点 | 不稳定 | 取决于目标站点 | 由供应商具体定价 |
免费代理尤其值得单独提醒,因为实际风险非常高。那项为期 30 个月的 Free Proxies Unmasked 研究 跟踪了 11 家服务商的 64 万多个地址:其中 34.5% 至少曾经可用一次,16,923 个会篡改内容。这个结果足以对公共代理列表敲响强烈的安全警钟,但它并不能直接代表每一个当前列表、付费代理池、目标站点或工作负载的故障率。
代理也不是现代反爬检测的万能解法。像 Cloudflare 这类服务会从几十种信号评估请求——TLS 指纹、请求头一致性、JavaScript 执行情况、行为模式等等,其中 IP 只是输入之一。即使你用了干净的住宅代理,如果请求头前后不一致、又没有 cookie 容器,照样可能被标记。别一上来就围着“只要轮换 IP 就行”这个思路搭整套架构。
开始前需要准备什么
- 难度: 中级
- 所需时间: 完整生产级配置约 30–45 分钟,快速测试约 5 分钟
- 你需要准备: Python 3.9+、已安装 Scrapy(本文基于 Scrapy 2.17.0 测试,该版本于 2026 年 7 月发布)、一个通过
scrapy startproject创建并可运行的爬虫,以及至少一个代理入口(测试时任何数据中心代理服务商的免费试用都可以)
第 1 步:用 Request Meta 参数测试代理
验证代理是否可用的最快方法,就是先别管中间件架构,直接试一下。
Scrapy 内置的 HttpProxyMiddleware 会直接读取 request.meta 里的 proxy 字段,并把请求通过它发出去。不用改 settings,也不用写中间件类——只要一个参数就够了。
import scrapy
class ProxyTestSpider(scrapy.Spider):
name = "proxy_test"
start_urls = ["https://httpbin.org/ip"]
def start_requests(self):
for url in self.start_urls:
yield scrapy.Request(
url,
meta={"proxy": "http://203.0.113.10:8080"},
callback=self.parse,
)
def parse(self, response):
self.logger.info(response.text)
用 scrapy runspider proxy_test.py 运行。若代理正常,httpbin.org/ip 返回的会是代理 IP,而不是你自己的 IP——这就说明代理通了。如果请求一直卡住,最后报 TCP connection timed out,那通常就是代理挂了或者根本连不上。说白了,这种情况比代理服务商愿意承认的要常见得多。
这种方式适合一次性爬虫或者快速测试。一旦你有不止一个爬虫,它就会立刻变得不够用,因为你要在五个文件里重复写同一个代理字符串。
第 2 步:构建自定义代理中间件
只要不是单个爬虫,代理逻辑最好集中到一个地方。可以在项目的 middlewares.py 中创建一个 ProxyMiddleware 类:
from scrapy.exceptions import NotConfigured
class ProxyMiddleware:
def __init__(self, proxy_url):
self.proxy_url = proxy_url
@classmethod
def from_crawler(cls, crawler):
proxy_url = crawler.settings.get("PROXY_URL")
if not proxy_url:
raise NotConfigured("PROXY_URL is required; refusing silent direct fallback")
return cls(proxy_url=proxy_url)
def process_request(self, request, spider):
if "proxy" not in request.meta:
request.meta["proxy"] = self.proxy_url
然后在 settings.py 里注册它:
import os
PROXY_URL = os.environ.get("PROXY_URL")
DOWNLOADER_MIDDLEWARES = {
"myproject.middlewares.ProxyMiddleware": 350,
}
注意这里用了 from_crawler 类方法,而不是普通的 __init__。这正是 Scrapy 推荐的读取配置方式,而且我们后面在第 4 步讲密钥管理时还会再次用到这个入口。这样做的好处很直接:改一个配置,项目里所有爬虫都会自动使用新的代理。再也不用在五个文件里到处搜索替换失效的 IP 了。
第 3 步:搞清楚中间件执行顺序,别让代理悄悄失效
这一部分几乎所有其他教程都会轻描淡写地带过一句“把优先级设成 350 就行,相信我”。但 Scrapy 的下载器中间件是按照固定顺序执行的,如果你不理解这个顺序,代理配置会出现很多看起来和代理完全没关系的 bug。
请求方向的钩子(process_request)按优先级升序执行——数字越小越先执行。响应方向的钩子(process_response、process_exception)则按优先级降序执行——数字越大越先执行,然后逐层往回展开。
请求流向(升序):
Spider → [100] RobotsTxt → [300] HttpAuth → [350] 你的代理中间件
→ [500] UserAgent → [550] Retry → [700] Cookies → [750] HttpProxy
→ Downloader
响应流向(降序):
Downloader → [750] HttpProxy → [700] Cookies → [550] Retry
→ [500] UserAgent → [350] 你的代理中间件 → [300] HttpAuth
→ [100] RobotsTxt → Spider
下面是 Scrapy 内置中间件当前真实的默认优先级表(已按 2.17.0 验证):
| 中间件 | 默认优先级 |
|---|---|
| RobotsTxtMiddleware | 100 |
| HttpAuthMiddleware | 300 |
| DownloadTimeoutMiddleware | 350 |
| DefaultHeadersMiddleware | 400 |
| UserAgentMiddleware | 500 |
| RetryMiddleware | 550 |
| RedirectMiddleware | 600 |
| CookiesMiddleware | 700 |
| HttpProxyMiddleware | 750 |
| DownloaderStats | 850 |
| HttpCacheMiddleware | 900 |
当 RetryMiddleware 安排一次重试时,它会复制失败请求,包括其元数据,然后这个新请求会重新进入下载器中间件链。选择器和优先级 550 的数值关系并不能自动保证代理轮换。只有当选择器代码识别到这是重试请求,并且主动覆盖掉复制过来的 meta["proxy"] 时,轮换才会真正发生。把选择器放在 350 只是因为它会在内置传输步骤 750 之前运行,这样比较方便,但它本身并不等于轮换机制。

常见的中间件顺序错误
- 把你的中间件设置成和
HttpProxyMiddleware一样的优先级(750): 这会造成竞争条件,最后由 Scrapy 的字典顺序(而不是你的逻辑)决定哪个中间件先处理请求。症状就是:代理行为时好时坏,完全说不清原因。 - 在轮换选择器里用
setdefault()或if "proxy" not in request.meta: 重试时复制过来的请求会保留旧代理。症状是每次重试都还是同一条失败路线。修复方式:识别这是不是重试请求(例如retry_times > 0),然后明确覆盖选择器负责的代理值。 - 完全禁用
HttpProxyMiddleware: 有些教程会让你把"scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": None,理由是“自定义中间件已经处理好了”。其实并没有——你的自定义中间件只是选择器,不是传输层。禁用内置中间件意味着代理认证头不会被附加,请求会悄悄以未认证状态发出,或者干脆发不出去。
第 4 步:别把代理凭证硬编码进去
我看过的几乎所有高排名 Scrapy 代理教程,都会把 http://username:password@proxy.example.com:8080 直接写进 Python 文件里。这等于把一个凭证永久留在 Git 历史中,之后每次克隆、fork,甚至谁手滑 print() 到日志里,都可能把它泄露出去。
这件事其实可以分成三个层级:
| 方法 | 安全性 | 灵活性 | 适合场景 |
|---|---|---|---|
| 直接硬编码在 spider/settings.py | 很差——密钥留在仓库里 | 低 | 只适合本地快速测试 |
使用 http_proxy 环境变量(Scrapy 原生支持) | 更好——不在代码里 | 低(单个代理) | CI/CD 流水线、Docker |
.env 文件 + python-dotenv + from_crawler | 最好——不在代码里,且可按环境配置 | 高(支持多个代理、轮换) | 生产级爬虫 |
第三种方式值得认真搭建。先安装 python-dotenv,然后创建一个 .env 文件,并立刻把它加入 .gitignore——我是认真的,现在就做:
PROXY_USER=myuser
PROXY_PASSWORD=my$ecret!Pass
PROXY_HOST=proxy.example.com
PROXY_PORT=8080
然后在 settings.py 顶部加载它:
from dotenv import load_dotenv
import os
load_dotenv()
PROXY_USER = os.getenv("PROXY_USER")
PROXY_PASSWORD = os.getenv("PROXY_PASSWORD")
PROXY_HOST = os.getenv("PROXY_HOST")
PROXY_PORT = os.getenv("PROXY_PORT")
接着在中间件里通过 from_crawler 安全读取,这正好能解决论坛里经常有人问的那个问题:“如果每次运行 scrapy crawl 前都得换凭证,该怎么设置?”
import os
from urllib.parse import quote
class SecureProxyMiddleware:
def __init__(self, user, password, host, port):
self.user = quote(user, safe="")
self.password = quote(password, safe="")
self.host = host
self.port = port
@classmethod
def from_crawler(cls, crawler):
settings = crawler.settings
return cls(
user=settings.get("PROXY_USER"),
password=settings.get("PROXY_PASSWORD"),
host=settings.get("PROXY_HOST"),
port=settings.get("PROXY_PORT"),
)
def process_request(self, request, spider):
proxy_url = f"http://{self.user}:{self.password}@{self.host}:{self.port}"
request.meta["proxy"] = proxy_url
注意这里对凭证做了 urllib.parse.quote()。如果你的密码里包含 @、: 或 /(密码生成器特别爱加这些字符),URL 解析就会出问题,除非先做百分号编码。这是一个一行代码就能避免的坑,能省掉你一个小时去排查“invalid proxy URL”错误——而那个错误其实跟代理本身一点关系都没有。
请把凭证留在日志之外,并用真实但虚假的示例值测试百分号编码。HTTPS 隧道、SOCKS5,以及非拉丁字符的凭证,都会有各自的处理边界;不要想当然地认为某一种认证方式在所有协议上都通用,除非你有锁定版本的集成测试。

第 5 步:加入代理轮换
单个代理——哪怕质量很好——对同一个网站发 5,000 次请求后,也迟早会被盯上。你需要一个代理池。
方案 A——自己实现。 真的很简单:
import random
class RotatingProxyMiddleware:
def __init__(self, proxy_pool):
self.proxy_pool = proxy_pool
@classmethod
def from_crawler(cls, crawler):
return cls(proxy_pool=crawler.settings.getlist("PROXY_POOL"))
def process_request(self, request, spider):
request.meta["proxy"] = random.choice(self.proxy_pool)
这个方案适合基础使用,但它完全不知道哪些代理是真正活着的。每个请求都在掷骰子。
方案 B——使用 scrapy-rotating-proxies。 这个第三方包开箱即带有封禁检测和自动退避机制:
pip install scrapy-rotating-proxies
这里我得坦白提醒一句:它在 PyPI 上的最新版本 是 0.6.2,发布时间还是 2019 年,而且项目标记为 Alpha。它不一定就和现代 Scrapy 不兼容,但“2019 年后没有持续维护”绝不等于“已经在 2026 年的生产流量里被充分验证”。记得锁版本、拿真实目标站点做测试,也不要默认它能处理带认证的代理端点——它大多做不到。
第 6 步:构建容错型代理中间件(检测坏代理)
这一节通常会被竞争教程直接跳过,但它正是 demo 和能在无人值守情况下连续跑 6 小时的系统之间的区别。
| 场景 | 大多数教程怎么处理 | 这个中间件增加了什么 |
|---|---|---|
| 代理返回 407 | 不提 | 将其视为代理认证失败 |
| 目标站点返回 403/429 | 经常混在一起讲 | 把策略/限流反馈与代理健康状态分开 |
| 代理超时 | 不提 | 可配置超时阈值、健康分值衰减 |
| 所有代理都挂了 | 不提 | 优雅回退,或者记录警告后暂停爬取 |
| 代理抖动(间歇性失效) | 不提 | 重新加入代理池前先冷却一段时间 |
import time
import random
from scrapy.exceptions import IgnoreRequest
class FaultTolerantProxyMiddleware:
MAX_FAILURES = 3
COOLDOWN_SECONDS = 300
def __init__(self, proxy_pool):
self.pool = {p: {"failures": 0, "banned_until": 0} for p in proxy_pool}
@classmethod
def from_crawler(cls, crawler):
return cls(proxy_pool=crawler.settings.getlist("PROXY_POOL"))
def _healthy_proxies(self):
now = time.time()
return [p for p, s in self.pool.items() if s["banned_until"] < now]
def process_request(self, request, spider):
healthy = self._healthy_proxies()
if not healthy:
spider.logger.warning("All proxies unhealthy — pausing crawl")
raise IgnoreRequest("No healthy proxies available")
request.meta["proxy"] = random.choice(healthy)
def process_response(self, request, response, spider):
proxy = request.meta.get("proxy")
if proxy and response.status == 407:
self._mark_failure(proxy)
return response
def process_exception(self, request, exception, spider):
proxy = request.meta.get("proxy")
if proxy:
self._mark_failure(proxy)
def _mark_failure(self, proxy):
state = self.pool[proxy]
state["failures"] += 1
if state["failures"] >= self.MAX_FAILURES:
state["banned_until"] = time.time() + self.COOLDOWN_SECONDS
state["failures"] = 0
这里有几点是我在实际实现中学到的:不要把每个 403 都当成代理死了——RFC 9110 对 403 的定义是“服务器理解了请求,但拒绝执行”,这很可能只是你的请求头或会话状态看起来可疑,和代理本身无关。相比之下,407 明确表示代理拒绝了你的认证,这信号更强也更具体。把这些信号混成一锅乱炖,最后只会白白浪费好代理。
在生产环境里,把 Scrapy 的临时重试默认值显式写出来,方便审查者看清楚你的策略:
RETRY_HTTP_CODES = [500, 502, 503, 504, 522, 524, 408, 429]
RETRY_TIMES = 2
这些是 Scrapy 2.17 的默认值。不要一股脑把 403 或 407 也加进去:403 是目标站点拒绝访问,可能有很多原因;407 则是代理认证问题,靠普通请求重试并不能解决。如果某个特定目标契约确实要求对某个状态码重试,请把原因写清楚,并单独测试。

第 7 步:一种可度量的“先直连、再代理”模式
某些已授权目标站点可能会直接返回有效内容,而代理只需要在出现文档化的临时响应之后才启用。这样可以减少走代理的字节数,但并不存在一个可通用套用的节省比例,也没有可移植的“直连成功率”。在采用这个模式前,先对自己的工作负载做测量。
可以使用 Scrapy 公共的 get_retry_request() 辅助函数,并且只对你明确分类过的、与目标站点相关的状态码做升级处理。下面这个示例把 429 和 503 当作可重试的压力信号;它刻意不包含 403 和 407。
from scrapy.downloadermiddlewares.retry import get_retry_request
class CostAwareEscalationMiddleware:
def process_response(self, request, response, spider):
if response.status not in {429, 503}:
return response
retry = get_retry_request(
request,
spider=spider,
reason=f"proxy_escalation_{response.status}",
max_retry_times=2,
)
if retry is None:
return response
current_tier = request.meta.get("proxy_tier", "direct")
if current_tier == "direct":
retry.meta["proxy"] = spider.settings["DATACENTER_PROXY"]
retry.meta["proxy_tier"] = "datacenter"
elif current_tier == "datacenter":
retry.meta["proxy"] = spider.settings["RESIDENTIAL_PROXY"]
retry.meta["proxy_tier"] = "residential"
else:
return response
return retry
你需要记录直连有效内容比例、代理有效内容比例、每条成功记录消耗的字节数、每次成功对应的重试次数,以及额外延迟。只有在直连访问本身是被授权的、并且系统在需要代理时会明确失败关闭,这种“先直连”的设计才算可接受。这里节省下来的,是实际测得的代理流量减少,而不是凭空假设的百分比。
什么时候自己管理代理不值得?
我先说清楚,免得让人误会整篇文章都在反对自建:上面这些内容都是真实且有用的工程实践。对于很多项目——高吞吐爬取、自定义管道、以及需要完全掌控请求调度的场景——这确实是正确选择。
但如果你的真实目标是“从这个页面拿到结构化数据”,而不是“自己运维代理基础设施”,那抽取 API 可能会是更合适的边界。Thunderbit 当前的 POST /extract 文档 支持传入页面 URL 和可选的 JSON Schema;如果不提供 schema,服务也可以根据页面内容自动生成。这个接口还提供 none、basic 和 full 三种渲染模式,以及超时和加载后等待时间控制。纯提示词抽取并不在当前支持的请求能力范围内,因此生产集成应当基于文档中定义的 schema 契约来构建。这个方案只是把抽取接口收敛到一次请求中,并不意味着它能对所有反爬或验证码目标做出统一承诺。
| 因素 | 自建 Scrapy + 代理 | 基于 API 的抽取(例如 Thunderbit) | |---|---|---|---| | 搭建成本 | 高——中间件、轮换、重试逻辑都要自己做 | 低——一次 API 调用加 schema 就行 | | 网络/渲染行为 | 由你配置处理器、代理、请求头和延迟 | 通过文档化的 API 选项控制 | | 维护成本 | 你自己负责选择器、代理池健康和目标站点变化 | 你负责 schema 质量、校验和集成行为 | | 成本模型 | 代理费用 + 计算资源 + 工程时间 | 当前文档显示每页 20 个单位(核对于 2026-08-10) | | 控制力 | 完全可控——自定义管道和中间件链 | 受限于 API 能力 | | 适合场景 | 复杂爬取、高并发、自定义逻辑 | 定向抽取、原型验证、数据补全 |
如果你主要是在抓取用于线索列表、商品数据或研究用途的结构化页面,可以用 Thunderbit Chrome Extension 或 API 做一个代表性试跑,对比有效记录数、延迟、单位消耗和维护时间。如果你的项目需要自定义爬取图谱和完整管道控制,Scrapy 依然更适合。
延伸阅读
提示与常见坑
- 提示: 在真正接目标站点之前,先用
httpbin.org/ip测一下代理。这样可以在增加复杂度之前,最快确认路由是否真的生效。 - 坑点: 把代理写在
request.headers里,而不是request.meta里。这是一个非常常见、又很容易被忽略的错误——HttpProxyMiddleware只会读取meta["proxy"],而基于 header 的写法会静默失败,几乎没有明显报错。 - 坑点: 以为 HTTP 和 HTTPS 代理的配置方式完全一样。指向 HTTPS 目标站点的 HTTP 代理 URL,通常会通过兼容的处理器走 CONNECT 隧道;但把代理端点本身写成
https://,则是另一种支持度更低的配置——这两者不要混为一谈。 - 提示: 如果你需要 SOCKS5 支持,先确认你的 Scrapy 版本对应的下载处理器能力。Scrapy 2.17 的实验性 Httpx 处理器通过
httpx[socks]提供了 SOCKS5 支持,但它仍然标注为实验性——不要在没有自行测试的情况下把它当作生产依赖。
其他方法
除了 scrapy-rotating-proxies 之外,还有一些团队会把所有代理逻辑交给供应商网关来处理——也就是一个代理 URL,由服务商在后台完成轮换、会话粘性和地域定位。这样会牺牲一点控制权,但能少写很多中间件代码。在你从零自建代理池之前,值得先把这个方案的成本算清楚。
结论
在 Scrapy 中设置代理只要一行。但要让这套配置在真实生产抓取中稳定运行,你需要明确的失败策略、受保护的凭证、经过测试的处理器边界,以及在重试时会主动替换已复制代理元数据的选择器逻辑。如果你只带走两个习惯,那就记住这两个:当必须使用代理时,要失败即关闭;还有,永远不要把代理密码提交到 Python 文件里。
常见问题
如何在 Scrapy 中配置带认证的自定义代理?
使用 protocol://username:password@host:port 这种 URL 格式,但如果用户名或密码里含有特殊字符,要先用 urllib.parse.quote() 做百分号编码。生产环境中,最好通过 from_crawler 类方法从环境变量读取这些凭证,而不是直接写死在代码里。
Scrapy 自定义代理中间件应该用什么优先级?
350 是一个常见的选择器优先级,因为它会在 HttpProxyMiddleware 的 750 之前执行。但这并不能保证轮换发生。只有当选择器识别到这是复制出来的重试请求,并覆盖掉原先的 meta["proxy"] 值时,重试请求才会获得新的代理。
Scrapy 里怎么自动处理坏代理?
编写一个中间件,在 process_response 和 process_exception 中跟踪每个代理的失败次数;当失败达到阈值后把它从可用池中移除,并在冷却一段时间后再重新加入,而不是永久封禁。
Scrapy 可以使用 SOCKS5 代理吗?
Scrapy 2.17 的实验性 HttpxDownloadHandler 在安装 httpx[socks] 后文档中说明支持 SOCKS5。默认的 HTTP/1.1 处理器不支持 SOCKS 代理。请先锁定处理器和版本,并做集成测试,再把这条路径当作生产方案。
“先直连、再代理”的模式到底能省多少代理成本? 没有统一适用的比例。你需要测量:有多少已授权请求能直接返回有效内容、每一层代理实际传输了多少字节、每个成功结果平均重试几次,以及增加了多少延迟。观察到的代理流量减少量就是你的节省;如果直连访问没有授权,或者结果无效,就不要使用这个模式。


