圆海博客-探寻心灵的宁静

您现在的位置是:首页 > 博客 > 正文

博客

Scrapy动态设置代理IP全流程!中间件接入代码一键复制

2026-09-08 11:13:54博客
写 Scrapy 爬虫最怕什么?不是代码写不出来,而是跑了两百个请求,IP 就被封了。手动换 IP 不现实,写死一个代理又等于没换——这才是大多数爬虫开发者真正的痛点。
好消息是,Scrap……

写 Scrapy 爬虫最怕什么?不是代码写不出来,而是跑了两百个请求,IP 就被封了。手动换 IP 不现实,写死一个代理又等于没换——这才是大多数爬虫开发者真正的痛点。

好消息是,Scrapy 的中间件机制天然适合做代理管理。你只需要写一个 ProxyMiddleware,就能实现:每个请求自动分配不同 IP、失败自动切换、优质 IP 优先使用。下面这篇文章,从原理到完整代码,一步步带你把动态代理 IP 接入 Scrapy,所有代码段都可以直接复制粘贴。

一、为什么 Scrapy 必须用动态代理 IP

先搞清楚”动态”两个字到底在动态什么。

如果你把代理 IP 写死在 settings.pyHTTP_PROXY 里,那所有请求都走同一个出口。目标站点的风控系统只要识别到短时间内大量请求来自同一 IP,立刻就会触发反爬——轻则返回 403,重则直接 ban 掉这个 IP 段。

动态代理 IP 的核心价值在于”出口地址持续变化”。每一次 HTTP 请求,都可以走一个不同的代理节点。对目标站点来说,你的爬虫看起来就是几百个不同的用户在正常浏览,风控规则根本无从下手。

具体来说,动态代理能帮你解决三个问题:

① 突破频率限制:很多站点限制单 IP 每分钟最多请求 N 次。轮换 IP 后,每个 IP 的请求量被摊薄,轻松绕过限制。

② 规避地域封锁:某些内容只允许特定地区 IP 访问。动态代理池里如果覆盖了多个地区节点,你就能灵活切换。

③ 提高采集稳定性:某个代理节点挂了或延迟飙升,中间件可以立刻剔除并换下一个,爬虫不会卡死。

光络云提供的代理 IP 服务覆盖国内和海外多个节点,支持按请求计费,配合 Scrapy 中间件使用,基本可以实现”无感换 IP”。需要注意的是,光络云的代理 IP 需要你的服务器本身具备海外网络环境才能正常调用(TikTok 专线产品除外),部署前请确认网络条件。

二、中间件核心原理:请求是怎么被”劫持”换 IP 的

Scrapy 的请求生命周期是这样的:Spider 产生一个 Request 对象 → 经过 Downloader Middleware 链 → 发出 HTTP 请求 → 拿到 Response → 再经过中间件链 → 回到 Spider。

代理 IP 的注入就发生在 Downloader Middlewareprocess_request 钩子里。这个钩子在请求真正发出去之前执行,你可以在这里往 request.meta 里塞代理地址,Scrapy 的下载器就会自动走这个代理。

整个链路可以简化为五步:

① 请求发出:Spider 的 parse() 方法里 yield Request(url),此时还没有代理信息。

② 代理注入:你的 ProxyMiddleware.process_request() 被调用,从代理池里取出一个 IP,写入 request.meta['proxy']

③ 目标站点:Scrapy 下载器带着代理地址发出 HTTP 请求,目标站点看到的是代理 IP 而非你的真实 IP。

④ 响应返回:正常拿到 200 响应,数据回到 Spider 解析。

⑤ 失败重试:如果返回 403、429 或连接超时,process_responseprocess_exception 钩子捕获异常,把当前 IP 标记为不可用,重新从池里取一个新 IP 发起重试。

理解了这条链路,写代码就只是”把每一步的逻辑填进去”。

三、完整代码:从代理池到中间件,一键复制

下面给出一个可以直接跑的完整实现,包含代理池管理、中间件、重试逻辑和 Scrapy 配置。代码按文件拆分,你只需要对应放到你的项目里。

3.1 项目结构

my_spider/
├── my_spider/
│   ├── __init__.py
│   ├── settings.py
│   ├── proxy_pool.py      # 代理池
│   ├── middlewares.py     # 代理中间件
│   └── spiders/
│       └── example.py
└── scrapy.cfg

3.2 代理池(proxy_pool.py)

这一层负责管理所有可用代理。实际生产中,代理列表通常从光络云的管理后台 API 拉取,这里用一个本地列表做演示,结构完全一致。

# proxy_pool.py
import random
import time
from collections import defaultdict

class ProxyPool:
    """
    代理池:管理可用 IP,支持随机取、失败剔除、定时刷新。
    生产环境可替换为从光络云 API 动态拉取代理列表。
    """

    def __init__(self, proxies: list[str] | None = None):
        # 格式: "http://user:pass@host:port"
        # 从光络云后台获取的代理地址直接填这里即可
        self._all: list[str] = proxies or []
        self._available: list[str] = list(self._all)
        self._fail_count: dict[str, int] = defaultdict(int)
        self._last_refresh: float = 0.0
        self.refresh_interval = 300  # 每 5 分钟重新拉取一次代理列表
        self.max_failures = 3        # 连续失败 3 次则暂时剔除

    def get_proxy(self) -> str | None:
        """随机取一个可用代理"""
        if not self._available:
            self._reset()
        return random.choice(self._available) if self._available else None

    def mark_failure(self, proxy: str):
        """标记一次失败,达到阈值则剔除"""
        self._fail_count[proxy] += 1
        if self._fail_count[proxy] >= self.max_failures:
            self._available.remove(proxy)
            print(f"[ProxyPool] 剔除失败代理: {proxy}")

    def mark_success(self, proxy: str):
        """成功后重置失败计数"""
        self._fail_count[proxy] = 0

    def _reset(self):
        """重置代理池(定时或池空时触发)"""
        self._available = list(self._all)
        self._fail_count.clear()
        self._last_refresh = time.time()
        print(f"[ProxyPool] 代理池已重置,共 {len(self._all)} 个")

    def refresh(self):
        """
        生产环境:调用光络云 API 拉取最新代理列表。
        示例(替换为实际 API 地址和鉴权):
        
        import requests
        resp = requests.get(
            "https://api.ipipgo.com/proxies/list",
            headers={"Authorization": "Bearer YOUR_TOKEN"},
            params={"type": "residential", "country": "US"}
        )
        self._all = resp.json()["proxies"]
        self._reset()
        """
        self._reset()

3.3 代理中间件(middlewares.py)

这是核心文件,包含请求注入、响应处理、异常重试三个钩子。

# middlewares.py
import logging
from scrapy import signals
from scrapy.http import Request, Response
from scrapy.exceptions import IgnoreRequest
from .proxy_pool import ProxyPool

logger = logging.getLogger(__name__)

class DynamicProxyMiddleware:
    """
    Scrapy 动态代理 IP 中间件
    - 每个请求自动分配代理
    - 403/429/超时 自动换 IP 重试
    - 连续失败的 IP 自动剔除
    """

    MAX_RETRIES = 3  # 单个 URL 最多换 IP 重试 3 次

    def __init__(self, proxies: list[str]):
        self.pool = ProxyPool(proxies)

    @classmethod
    def from_crawler(cls, crawler):
        """Scrapy 标准中间件工厂方法,从 settings 读取配置"""
        proxies = crawler.settings.getlist("PROXY_LIST", [])
        middleware = cls(proxies)
        # 可选:注册定时刷新
        crawler.signals.connect(middleware._schedule_refresh, signal=signals.spider_opened)
        return middleware

    def process_request(self, request: Request, spider):
        """请求发出前:注入代理"""
        # 如果已经重试过,不再重复注入(避免死循环)
        if request.meta.get("proxy_retry_count", 0) >= self.MAX_RETRIES:
            logger.warning(f"URL 重试次数已达上限: {request.url}")
            return None  # 放行,不再换 IP

        proxy = self.pool.get_proxy()
        if proxy:
            request.meta["proxy"] = proxy
            request.meta["current_proxy"] = proxy
            # 带上重试计数
            request.meta["proxy_retry_count"] = request.meta.get("proxy_retry_count", 0) + 1
            logger.debug(f"使用代理 {proxy} 请求 {request.url}")
        else:
            logger.warning("代理池为空,走直连")

    def process_response(self, request: Request, response: Response, spider):
        """响应返回后:判断是否需要换 IP 重试"""
        proxy = request.meta.get("current_proxy")

        # 正常响应,标记成功
        if response.status == 200:
            if proxy:
                self.pool.mark_success(proxy)
            return response

        # 403 / 429 / 503 → 大概率是代理被识别,换 IP 重试
        if response.status in (403, 429, 503):
            if proxy:
                self.pool.mark_failure(proxy)
                logger.info(f"状态码 {response.status},换 IP 重试: {request.url}")
            return self._retry(request, spider)

        # 其他状态码正常返回给 Spider 处理
        return response

    def process_exception(self, request: Request, exception, spider):
        """连接超时 / DNS 失败等异常:换 IP 重试"""
        proxy = request.meta.get("current_proxy")
        if proxy:
            self.pool.mark_failure(proxy)
            logger.info(f"异常 {type(exception).__name__},换 IP 重试: {request.url}")
        return self._retry(request, spider)

    def _retry(self, request: Request, spider):
        """构造重试请求,重置代理"""
        if request.meta.get("proxy_retry_count", 0) >= self.MAX_RETRIES:
            logger.error(f"重试耗尽,放弃: {request.url}")
            return None  # 交给 Scrapy 的 RetryMiddleware 或 Spider 自行处理

        # 清除旧代理,让 process_request 重新分配
        del request.meta["proxy"]
        del request.meta["current_proxy"]
        return request  # 返回 Request 对象 → Scrapy 会重新走中间件链

    def _schedule_refresh(self, spider):
        """Spider 启动时刷新一次代理池"""
        self.pool.refresh()

3.4 Scrapy 配置(settings.py)

# settings.py

# ===== 代理 IP 配置 =====
# 从光络云后台获取的代理地址列表
# 格式: "http://user:pass@host:port"
PROXY_LIST = [
    "http://user:pass@1.2.3.4:8080",
    "http://user:pass@5.6.7.8:8080",
    "http://user:pass@9.10.11.12:8080",
    # ... 光络云代理列表
]

# 注册中间件(数字越小优先级越高)
DOWNLOADER_MIDDLEWARES = {
    "my_spider.middlewares.DynamicProxyMiddleware": 610,
    # 关闭 Scrapy 自带的重试,避免和我们的逻辑冲突
    "scrapy.downloadermiddlewares.retry.RetryMiddleware": None,
}

# ===== 请求行为 =====
CONCURRENT_REQUESTS = 10          # 并发数,配合代理池大小调整
CONCURRENT_REQUESTS_PER_DOMAIN = 5
DOWNLOAD_DELAY = 0.5              # 同域名请求间隔(秒)
DOWNLOAD_TIMEOUT = 15             # 超时时间(秒)

# 关闭自动代理(我们自己在中间件里管)
HTTPPROXY_ENABLED = False

# ===== 日志 =====
LOG_LEVEL = "INFO"

3.5 Spider 示例(spiders/example.py)

# spiders/example.py
import scrapy

class ExampleSpider(scrapy.Spider):
    name = "example"
    start_urls = ["https://example.com/page/1"]

    def parse(self, response):
        # 正常解析逻辑
        for item in response.css("div.item"):
            yield {
                "title": item.css("h2::text").get(),
                "link":  item.css("a::attr(href)").get(),
            }

        # 翻页
        next_page = response.css("a.next::attr(href)").get()
        if next_page:
            yield scrapy.Request(next_page, callback=self.parse)
            # 注意:不需要手动设 proxy,中间件会自动处理

把以上四个文件放进你的 Scrapy 项目,scrapy crawl example 就能跑起来。每个请求都会自动走代理池里的不同 IP,遇到 403 或超时会自动换 IP 重试,连续失败的 IP 会被暂时剔除。

四、动态切换策略:不止”随机”一种玩法

上面的代码用的是随机轮换——每次请求从池子里随机挑一个。这是最基础也最通用的策略,但根据实际场景,你还可以叠加更多规则:

策略 适用场景 实现要点
随机轮换 通用爬虫,代理池较大 random.choice(),每次请求取不同 IP
定时切换 目标站点有 IP 级频率限制 记录每个 IP 的使用时间,超过 N 秒强制换
失败熔断 代理池质量参差不齐 连续失败 3 次自动剔除,冷却 5 分钟后再放回
权重分配 部分代理延迟低、成功率高 按历史成功率加权随机,优质 IP 命中概率更高

失败熔断 + 冷却恢复为例,在 ProxyPool 里加一段逻辑:

import time

class ProxyPool:
    # ... 前面代码不变 ...

    def __init__(self, proxies=None):
        # ...
        self._cooldown: dict[str, float] = {}  # proxy → 冷却截止时间
        self.cooldown_seconds = 300  # 冷却 5 分钟

    def mark_failure(self, proxy: str):
        self._fail_count[proxy] += 1
        if self._fail_count[proxy] >= self.max_failures:
            self._available.remove(proxy)
            self._cooldown[proxy] = time.time() + self.cooldown_seconds
            print(f"[Pool] 熔断 {proxy},冷却 {self.cooldown_seconds}s")

    def get_proxy(self) -> str | None:
        # 先检查冷却期是否结束,恢复可用 IP
        now = time.time()
        recovered = [p for p, t in self._cooldown.items() if now >= t]
        for p in recovered:
            if p not in self._available:
                self._available.append(p)
            del self._cooldown[p]
            self._fail_count[p] = 0
            print(f"[Pool] 恢复 {p}")

        if not self._available:
            self._reset()
        return random.choice(self._available) if self._available else None

这样,一个”坏掉”的 IP 不会永远被丢弃,而是冷却一段时间后自动回到池子里重新参与轮换,代理利用率更高。

五、接入光络云代理的实操要点

上面代码里的 PROXY_LIST 是硬编码的,实际生产环境建议通过 API 动态拉取。光络云管理后台提供了代理列表接口,你只需要把 ProxyPool.refresh() 方法里的示例代码替换成实际调用即可。

几个实操建议:

① 代理池大小要匹配并发数。如果你 CONCURRENT_REQUESTS = 50,但池子里只有 5 个 IP,那每个 IP 同时扛 10 个请求,很容易被风控。建议代理数量 ≥ 并发数的 2-3 倍。

② 注意网络环境。光络云的代理 IP 服务需要你的服务器具备海外网络环境才能正常调用(TikTok 专线除外)。如果你的爬虫跑在国内服务器上,需要先确认出口网络条件,否则代理握手会超时。

③ 日志一定要开。调试阶段把 LOG_LEVEL 设为 "DEBUG",中间件里每条 logger.debug 都会打印当前用了哪个代理、目标 URL 是什么。排查”到底哪个 IP 被 ban 了”全靠它。

④ 别把重试逻辑和 Scrapy 自带的 RetryMiddleware 混用。上面 settings 里已经把 RetryMiddleware 设为 None,如果你同时启用两个重试机制,会出现”重试套重试”的情况,请求量翻倍但成功率不升反降。

常见问题

Q: 我的代理 IP 是光络云提供的,但 Scrapy 连不上,一直超时,怎么回事?
大概率是网络环境问题。光络云的代理 IP 需要你的服务器具备海外网络出口才能正常握手。请检查你的服务器是否能直接访问海外节点(curl -I https://api.ipipgo.com 测试)。TikTok 专线产品不受此限制,可直接使用。

Q: 代理池里只有 10 个 IP,能支撑多大的并发?
建议并发数不超过代理数量的 1/2 到 1/3。10 个 IP 的话,CONCURRENT_REQUESTS 设 3-5 比较安全。如果目标站点风控严格,甚至建议并发 1-2 + 每次请求换 IP 的方式,用时间换稳定。

Q: 中间件里 process_request 返回 None 和返回 Request 有什么区别?
返回 None 表示”我不处理,交给下一个中间件或下载器”,请求原样放行。返回一个新的 Request 对象表示”我替换了请求”,Scrapy 会用你返回的新请求继续走后续中间件链。在重试场景里,我们返回的是修改过的原 request 对象(清除了旧代理),让 process_request 重新分配。

Q: 怎么判断当前代理是不是被目标站点 ban 了,而不是网络本身的问题?
process_response 里看状态码:403、429 基本可以确定是代理被识别;连接超时(TimeoutError)可能是代理节点本身挂了,也可能是网络抖动。可以在中间件里加一个计数器,如果同一个代理连续 3 次超时,大概率是节点故障而非目标站点封禁。

Q: 光络云的代理 IP 支持按地区筛选吗?
支持。光络云管理后台和 API 都提供了按国家/地区筛选代理节点的参数。如果你的目标站点只允许特定地区 IP 访问,在 PROXY_LIST 拉取时加上地区参数即可,中间件代码不需要任何改动。

Q: 这套中间件能同时用于 Scrapy 和 requests 吗?
中间件代码是 Scrapy 专属的(依赖 process_request 等钩子)。如果你用 requests 库,逻辑类似但写法不同——直接在 session.get(url, proxies={"http": proxy}) 里传代理即可,不需要中间件。核心思路(代理池 + 失败剔除 + 重试)是通用的。