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 会自动轮换,配合这种中间件用起来比较顺——它一次能批量取多条,正好对上这里的池子设计。