Scrapy 代理中间件:每个请求自动换 IP

给 Scrapy 挂代理最省事的写法是在 start_requests 里逐个设 meta["proxy"]。小任务没问题,但一旦开了并发就会暴露问题:代理不生效你都不知道,重试也换不了 IP。正规做法是写一个下载中间件——它能在请求发出前塞代理,也能在请求失败后换一条再来。

中间件里的两个钩子

下载中间件有一组方法,我们只需要关心两个:

  • process_request(request, spider):请求发出之前调用,在这里给 request.meta["proxy"] 赋值;
  • process_exception(request, exception, spider):请求抛异常之后调用,在这里换一条新 IP 并把 request 返回,Scrapy 会用它重新调度。

很多人只写了第一个,于是"代理失败之后重试还是那条死 IP",白白浪费重试次数。

返回值语义要记牢:process_request 返回 None 表示继续往下走;返回 Request 会替换掉当前请求;返回 Response 则直接短路。写错返回值最典型的后果是请求莫名消失。

启用顺序(数字越小越先执行)

中间件的数字决定顺序。代理中间件放在重试中间件之后比较合算——让 Scrapy 先自己重试,重试都用尽了再轮到我们换 IP:

DOWNLOADER_MIDDLEWARES = {
    "proxy_middleware.XydailiProxyMiddleware": 543,
}

完整代码

下面这份可以直接用,池子就放在中间件实例里。注意 trust_env = False 那一行——取 IP 的请求必须直连,不然容器里的 HTTP_PROXY 环境变量会把这一跳也代理掉。

import time

import requests

API = "http://api.xydaili.net:2022/tools/ip.ashx"


class XydailiProxyMiddleware(object):
    def __init__(self, order, api=API, qty=10, area="", isp="",
                 pool_size=5, max_age=170):
        self.order = order
        self.api = api
        self.qty = qty
        self.area = area
        self.isp = isp
        self.pool_size = pool_size
        self.max_age = max_age
        self._items = []

    @classmethod
    def from_crawler(cls, crawler):
        s = crawler.settings
        order = s.get("XYDAILI_ORDER")
        if not order:
            raise ValueError("请在 settings.py 里配置 XYDAILI_ORDER(订单号)")
        return cls(
            order=order,
            qty=s.getint("XYDAILI_QTY", 10),
            area=s.get("XYDAILI_AREA", ""),
            isp=s.get("XYDAILI_ISP", ""),
        )

    def _refill(self):
        params = {
            "action": "GetIP",
            "OrderNumber": self.order,
            "protocol": 1,
            "qty": self.qty,
            "split": "json",
        }
        if self.area:
            params["Area"] = self.area
        if self.isp:
            params["Isp"] = self.isp

        session = requests.Session()
        session.trust_env = False        # 取 IP 必须直连
        try:
            data = session.get(self.api, params=params, timeout=10).json()
        finally:
            session.close()

        if data.get("status") != 200 or not data.get("data"):
            return
        now = time.time()
        for item in data["data"]:
            self._items.append(
                ("http://%s:%s" % (item["ip"], item["port"]), now + self.max_age)
            )

    def _take(self):
        now = time.time()
        self._items = [it for it in self._items if it[1] > now]
        if len(self._items) < self.pool_size:
            self._refill()
        return self._items.pop(0)[0] if self._items else None

    def process_request(self, request, spider):
        proxy = self._take()
        if proxy:
            request.meta["proxy"] = proxy
        return None

    def process_exception(self, request, exception, spider):
        """代理不可用时立刻换一条,并把请求重新入队。"""
        proxy = self._take()
        if not proxy:
            return None
        request.meta["proxy"] = proxy
        return request

对应的 settings.py:

XYDAILI_ORDER = "你的订单号"
XYDAILI_QTY = 10          # 一次批量取多少条,别循环单条提取
XYDAILI_AREA = ""         # 可选:指定城市,比如 "南京"
XYDAILI_ISP = ""          # 可选:指定运营商,比如 "电信"

CONCURRENT_REQUESTS = 16  # 并发别一下开太大
RETRY_TIMES = 2
DOWNLOAD_TIMEOUT = 15

DOWNLOADER_MIDDLEWARES = {
    "proxy_middleware.XydailiProxyMiddleware": 543,
}

三个踩过的坑

1. 重试没换 IP

Scrapy 自带的重试中间件会重新调度请求,但它不会改 meta["proxy"]。所以只靠 RETRY_TIMES 是不够的,必须靠 process_exception 去换。判断有没有生效很简单:看日志里同一个 URL 重试时打印的代理是不是变了。

2. 加了代理,失败请求反而变多

大概率是并发和池子大小不匹配。比如 CONCURRENT_REQUESTS = 32,池子一次只取 10 条,结果就是 32 个请求抢 10 条 IP,每条 IP 被压得死死的,目标站一看就是异常流量。我的经验是:池子大小 ≈ 并发数的 1/3 到 1/2,单条 IP 并发控制在 2~5。

3. 池子空了以后请求没有代理

上面 process_request 里写的是「取不到就跳过」,于是请求会以直连方式发出去——如果目标站封了你的 IP,这批请求会全挂,而且日志上看不出来。线上更稳的做法是取不到时抛 IgnoreRequest,让它明确失败,而不是糊里糊涂直连。

from scrapy.exceptions import IgnoreRequest

def process_request(self, request, spider):
    proxy = self._take()
    if not proxy:
        raise IgnoreRequest("代理池为空,跳过 %s" % request.url)
    request.meta["proxy"] = proxy
    return None

怎么确认代理真的生效了

别靠感觉。第一次跑的时候先抓一个回显 IP 的地址,把返回打出来:

import scrapy

class IpCheckSpider(scrapy.Spider):
    name = "ipcheck"
    start_urls = ["http://httpbin.org/ip"]

    def parse(self, response):
        self.logger.info("出口信息: %s", response.text)

看到返回的 IP 跟本机公网 IP 不一样,说明中间件生效了。这一步花两分钟,能省掉后面一堆"到底是代理没生效还是目标站反爬"的争论。

小结:process_request 给代理、process_exception 换代理,两个都要写;并发、池子大小、单 IP 压力三者要一起调,只改一个参数往往没用。

文件我放在 github-xydaili-examples 的 scrapy/ 目录下,文件头写了 settings 该怎么配。我用的代理线路是 星月代理,短效 IP 会自动轮换,配合这种中间件用起来比较顺——它一次能批量取多条,正好对上这里的池子设计。