跳转到主要内容
UUIProxy

Python 轮换代理完整设置指南

用 requests、httpx 和 Scrapy 接入轮换代理的完整实践:会话保持、重试策略、响应分类,以及那些在悄悄浪费流量预算的常见错误。

周美琳阅读约 7 分钟
Python 请求经由轮换代理出口转发的抽象插画

大多数「突然不能用了」的 Python 爬虫,其实从一开始就没真正跑通过——它们一直在拿到状态码 200、内容却是验证页的响应,直到三周后解析出来的数据集是空的,才有人发现。轮换代理确实解决了一个真实问题,但前提是:外围代码得分得清「一个页面」和「一次拒绝」的区别。

这篇文章先讲机制——怎么把轮换代理接入 requestshttpx 和 Scrapy——然后讲大多数教程会跳过的部分:什么时候该轮换、怎么给响应分类,以及如何让流量账单和你真正采到的数据成正比。

轮换到底买到了什么

一个想限制自动化访问的目标站,手上有若干个成本很低的信号。IP 地址是它最先拿来用的那个,因为在客户端发出任何一个应用层字节之前,IP 是唯一存在的标识。限速、信誉查询、地域规则,全都挂在它上面。

轮换出口地址能破解其中最朴素的一种:按 IP 计数请求。但它对 TLS 指纹、请求头顺序、Cookie 状态和行为时序毫无作用。如果你的爬虫之所以被封,是因为它自报家门是 python-requests/2.31.0、每秒抓 40 个页面、还不带 Referer,那么加上住宅代理只会让这个问题变得更贵,而不会让它消失。

轮换是其中一层,就该把它当成一层来看。

凭据格式

各家服务商暴露轮换能力的方式略有差异,但通行做法是把会话和定位参数编码进代理用户名。UUIProxy 的住宅出口长这样:

http://USERNAME-country-us-session-abc123:[email protected]:7000

这串字符里发生了三件事:

  • country-us 把出口固定在美国。去掉它就是整个 IP 池。
  • session-abc123 让携带这个标识的所有请求复用同一个出口 IP,最长 120 分钟。去掉它则每个请求都换新 IP。
  • 主机和端口是网关地址,始终不变。

轮换的全部 API 面就是这些。下面所有内容,都是在讨论这串字符里该填什么、什么时候填。

requests 逐请求轮换

最简单可用的配置是完全省略会话标识,网关会为每次调用分配一个新出口:

import os
import requests
 
PROXY = (
    f"http://{os.environ['PROXY_USER']}-country-us:"
    f"{os.environ['PROXY_PASS']}@gate.uuipproxy.com:7000"
)
 
proxies = {"http": PROXY, "https": PROXY}
 
response = requests.get(
    "https://example.com/catalogue",
    proxies=proxies,
    timeout=30,
    headers={"Accept-Language": "en-US,en;q=0.9"},
)
print(response.status_code, len(response.content))

有两个细节比看上去重要得多。

永远设置 timeout 不设的话 requests 会无限等待,一个卡住的住宅出口就能把一个工作线程永久占死。代理场景下 30 秒已经很宽裕,10 秒通常就够。

Accept-Language 与出口国家一致。 一个法兰克福的 IP 索要 en-US 内容是明显的不一致,修起来零成本,不修则极易被识别。

用 requests.Session 做粘性会话

当一个业务流程跨越多个页面——搜索、列表、详情——你会希望它们全部来自同一个地址。为每个逻辑会话生成一个标识并复用:

import os
import uuid
import requests
 
def sticky_session(country: str = "us") -> requests.Session:
    token = uuid.uuid4().hex[:12]
    proxy = (
        f"http://{os.environ['PROXY_USER']}-country-{country}-session-{token}:"
        f"{os.environ['PROXY_PASS']}@gate.uuipproxy.com:7000"
    )
    session = requests.Session()
    session.proxies = {"http": proxy, "https": proxy}
    session.headers.update({
        "Accept-Language": "en-US,en;q=0.9",
        "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    })
    return session
 
with sticky_session() as session:
    session.get("https://example.com/search?q=widgets", timeout=30)
    detail = session.get("https://example.com/product/1234", timeout=30)

requests.Session 还会在这些调用之间保持 Cookie——这正是「看起来像一个访客而不是三个互不相干的访客」的另一半。

用 httpx 做异步轮换

一旦每分钟请求量超过几百,同步版本就不再是你该关心的瓶颈了。httpx 提供了相同的代理语义,并且支持 asyncio:

import asyncio
import os
import uuid
import httpx
 
BASE = "gate.uuipproxy.com:7000"
 
def proxy_url(country: str = "us") -> str:
    token = uuid.uuid4().hex[:12]
    user = f"{os.environ['PROXY_USER']}-country-{country}-session-{token}"
    return f"http://{user}:{os.environ['PROXY_PASS']}@{BASE}"
 
async def fetch(url: str, country: str = "us") -> httpx.Response:
    async with httpx.AsyncClient(
        proxy=proxy_url(country),
        timeout=httpx.Timeout(30.0, connect=10.0),
        follow_redirects=True,
    ) as client:
        return await client.get(url)
 
async def main(urls: list[str]) -> None:
    semaphore = asyncio.Semaphore(20)
 
    async def bounded(url: str):
        async with semaphore:
            return await fetch(url)
 
    results = await asyncio.gather(*(bounded(url) for url in urls))
    for url, response in zip(urls, results):
        print(url, response.status_code)
 
asyncio.run(main(["https://example.com/a", "https://example.com/b"]))

注意那个信号量。代理侧并发不计费,但目标站点有它自己能从容承载的上限,超过这个上限是把一个能跑的爬虫变成被封爬虫的最快方式。

Scrapy 集成

Scrapy 内置的 HttpProxyMiddleware 读取 request.meta["proxy"],所以轮换就是一个五行的中间件:

# middlewares.py
import os
import uuid
 
class RotatingProxyMiddleware:
    def __init__(self):
        self.user = os.environ["PROXY_USER"]
        self.password = os.environ["PROXY_PASS"]
 
    def process_request(self, request, spider):
        country = request.meta.get("proxy_country", "us")
        token = request.meta.get("proxy_session") or uuid.uuid4().hex[:12]
        user = f"{self.user}-country-{country}-session-{token}"
        request.meta["proxy"] = f"http://{user}:{self.password}@gate.uuipproxy.com:7000"

把它注册在内置中间件之前:

# settings.py
DOWNLOADER_MIDDLEWARES = {
    "myproject.middlewares.RotatingProxyMiddleware": 340,
    "scrapy.downloadermiddlewares.httpproxy.HttpProxyMiddleware": 750,
}
RETRY_TIMES = 3
RETRY_HTTP_CODES = [403, 407, 408, 429, 500, 502, 503, 504]
DOWNLOAD_TIMEOUT = 30
CONCURRENT_REQUESTS_PER_DOMAIN = 8

request.meta 中设置 proxy_session,可以让单个 spider 把某个多步骤流程钉在同一个出口上,而其余请求继续轮换。

对响应分类,而不是对状态码分类

这是「能用的爬虫」和「看起来能用的爬虫」之间的分水岭。

验证页、空结果集、被截断的列表,全都带着 HTTP 200 返回。如果你的重试逻辑只看 response.status_code,这些全部会被记为成功,并作为缺失数据写进你的数据集。

BLOCK_MARKERS = (
    "captcha",
    "are you a human",
    "access denied",
    "unusual traffic",
    "请完成验证",
)
 
def classify(response) -> str:
    if response.status_code in (403, 407, 429):
        return "blocked"
    if response.status_code >= 500:
        return "server_error"
    if response.status_code != 200:
        return "unexpected"
 
    body = response.text.lower()
    if any(marker in body for marker in BLOCK_MARKERS):
        return "challenged"
    if len(response.content) < 2048:
        return "suspicious_short"
    return "ok"

长度检查能抓住最常见的静默失败:一个在浏览器里渲染正常、但完全不包含解析器所需数据的骨架页。这个阈值要用你自己目标站的已知正常响应来校准。

升级出口,而不是原地反复重试

响应分类之后,重试就从一个循环变成了一个路由决策。持续产出最低「单条数据成本」的模式是这样的:

  1. 请求先走数据中心代理,这是你手上最便宜的出口。
  2. 对响应分类。结果是 ok 就结束——绝大多数请求都会是。
  3. 结果是 blockedchallenged,重新排队到住宅出口。
  4. 住宅也失败,再升级一次到动态移动代理,并在这一层封顶。
TIERS = ["datacenter", "residential", "mobile"]
 
def fetch_with_escalation(url: str, tier_index: int = 0):
    if tier_index >= len(TIERS):
        return None
    response = fetch_via(url, tier=TIERS[tier_index])
    if classify(response) == "ok":
        return response
    return fetch_with_escalation(url, tier_index + 1)

在典型的电商目标上,需要离开第一层的请求通常不到五分之一。与「全部走住宅代理」相比,月账单一般能差出三到四倍。关于两类出口的取舍,可以看住宅代理与数据中心代理该怎么选

让出口与请求相互匹配

只有在每个出口与它承载的请求内部一致时,轮换 IP 池才有意义。三类不匹配造成了大多数本可避免的失败:

地理位置与语言不符。 意大利住宅 IP 请求 Accept-Language: en-US 是明显异常。请从你固定的出口国家推导这个请求头。

地理位置与内容不符。 用巴西出口请求某平台的德国站点,往往会直接返回巴西站点,而你的解析器会不声不响地记下错误的价格。

会话与 Cookie 不符。 如果你轮换了出口 IP 却保留了 Cookie,等于告诉目标站:一个登录身份在会话中途跨越了大洲。要么一起换,要么都别换。

关于 IP 之外的其他信号,可以看爬虫被封 IP 怎么办:请求指纹实战排查指南

按主机限速

克制不只是一种道德立场,它还是成本最低的防封手段。按主机名做令牌桶,可以避免单个 worker 的突发流量把整个采集任务拖进限速区间:

import asyncio
import time
from collections import defaultdict
 
class HostLimiter:
    def __init__(self, per_second: float = 4.0):
        self.interval = 1.0 / per_second
        self.next_slot = defaultdict(float)
        self.lock = asyncio.Lock()
 
    async def acquire(self, host: str) -> None:
        async with self.lock:
            now = time.monotonic()
            wait = max(0.0, self.next_slot[host] - now)
            self.next_slot[host] = max(now, self.next_slot[host]) + self.interval
        if wait:
            await asyncio.sleep(wait)

从每主机每秒 4 个请求起步,再根据目标站自身的响应时间调整。如果并发上去之后延迟明显上升,说明已经超出了对方能从容承载的范围。

上量之前的检查清单

把轮换代理方案投入生产负载之前,先过一遍:

  • 每个请求都有明确的超时设置,且连接超时短于读取超时。
  • 响应按内容分类,而不只是看状态码。
  • 重试是在出口层级之间升级,而不是在同一层重复。
  • 凡是跨多个页面的流程都使用了粘性会话。
  • Accept-Language 与依赖时区的参数与出口国家一致。
  • 并发按主机名设上限,而不只是全局上限。
  • 按目标域名统计流量用量,让昂贵的站点在账单到来之前就被看见。
  • 遵守目标站的 robots.txt 指令,只采集可公开访问的页面。

光是前两条,就能解决我们在技术支持里见到的大部分「代理不管用了」的反馈。轮换本身很简单,判断它是否真的生效,才是实际的工程工作。

排查「静默 200」

最贵的失败不是 403,而是状态码 200、正文却不是你要的页面。验证页、地区选择页、「请启用 JavaScript」的空壳、被截断的列表,全都共享同一个状态码。把每条响应正文的前 512 字节,连同 URL、出口国家、会话标识和耗时一起记下来。解析器开始吐空字段时,先搜这份日志,而不是先加购流量。

一个能用的做法是按类别保留滚动样本,而不是存下每一份正文。每种分类(okchallengeemptywrong_localetruncated)各留 50 个例子,有新失败再轮换。这份归档通常足够判断:拦截率上升是因为出现了新的验证页模板,还是只是更嘈杂的一个小时。

如果肉眼都分不清这些类别,说明分类器还不存在。规则要对照这些样本来写,而不是对照你想象中目标站「大概会返回什么」。

能查得动的日志

四十个 worker 的采集任务里,print 会消失。每条请求打一行固定字段的 JSON:

  • urlhoststatusbyteselapsed_ms
  • exit_countrysession_idtierdatacenter / residential / mobile
  • 分类器给出的 class
  • 若发生升级,记录 retry_countfinal_tier

把这些行送到你现成的采集链路即可,标准输出进收集器就够用。一周之后你真正会问的问题永远是这几个:哪些主机吃掉最多 GB、某次发布之后哪种类别在涨、住宅流量占比是不是在慢慢爬升。这些都无法从堆栈跟踪里读出来。

不要记录凭据、完整 Cookie 罐,也不要记录含账号数据的请求体。网关用户名里已经编码了国家和会话,那就是你需要的标识。

用进程,不要用线程

CPython 的 GIL 让线程池不适合「TLS 握手 + HTML 解析」这种混合负载。按 CPU 核心数起进程,每个进程自带 httpx.AsyncClient(或等价物)和自己的 HostLimiter。进程之间只共享 URL 队列和分类结果的出口,别的都不要共享。

如果必须在进程间共享登录 Cookie——也就是带登录态的抓取——把这条工作流钉在单个进程和静态出口上。把一个登录态拆到多个 worker,是账号被挑战的典型路径。其余任务都可以铺开。

一台 16 核机器跑 16 个异步 worker、每主机每秒 4 个请求,会在打满代理网关之前先打满绝大多数「有礼貌」的目标站。你应该盯着的瓶颈是限速器,不是硬件。

分享这篇文章

周美琳 的头像

关于作者

周美琳

UUIProxy 解决方案架构师

周美琳与亚太地区的增长团队和研究团队合作,负责账号基础设施与多市场数据采集方案。UUIProxy 的大部分入门资料出自她手,她花了很多时间解释一件事:被封禁时,答案很少是「买更贵的代理」。

高性价比、长期稳定的全球代理 IP

为你的数据业务打好基础设施底座,随业务一起扩张。

  • 多协议支持
  • 自动 IP 轮换
  • 无带宽限制

无需信用卡,即时开通。