代理 URL 的格式
requests 通过 proxies 参数走代理,格式是 协议://认证信息@主机:端口。住宅代理通常是 HTTP 网关,即使你访问的是 HTTPS 站点,代理本身也用 http://:
import requests
PROXY = "http://用户名:密码@gateway.example.com:8080"
proxies = {"http": PROXY, "https": PROXY}
resp = requests.get("https://httpbin.org/ip", proxies=proxies, timeout=30)
print(resp.json())
如果密码里包含 @、:、# 这类字符,必须做 URL 编码,否则会把代理地址解析错:
from urllib.parse import quote
user = quote("my@user", safe="")
pwd = quote("p@ss:w0rd#1", safe="")
PROXY = f"http://{user}:{pwd}@gateway.example.com:8080"
这个问题在本地调试时经常被忽略,因为测试账号的密码往往是简单的字母数字,换个正式账号就报错。
粘性会话:让出口在一段时间内不变
住宅代理默认会轮换出口 IP。需要登录态或分页连续抓取时,必须显式要求粘性会话。做法通常是在用户名后面追加会话标识参数,具体参数名以官方文档为准,各产品线写法不同:
# 典型写法:在用户名后追加 session 参数,值相同则出口相同
SESSION_ID = "abc123xyz"
user = f"用户名-session-{SESSION_ID}"
PROXY = f"http://{user}:密码@gateway.example.com:8080"
同一个 SESSION_ID 在有效期内会映射到同一个出口 IP。需要换 IP 时更换 SESSION_ID 即可,这比等待超时更可控。
验证出口是否真的固定,连续请求两次比对即可:
for i in range(3):
ip = requests.get("https://httpbin.org/ip", proxies=proxies, timeout=30).json()["origin"]
print(i, ip) # 粘性会话下应打印同一个 IP
Session 复用:连接池不是可有可无的优化
直接用 requests.get() 每次都新建连接,包括到代理的 TCP 握手。批量抓取时应该复用 Session,它内部维护连接池:
session = requests.Session()
session.proxies.update(proxies)
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/126.0.0.0 Safari/537.36",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
})
resp = session.get("https://shop.example.com/item/123", timeout=30)
超时:一个值是不够的
timeout=30 只限制了单次读写的等待,不区分连接阶段和读取阶段。代理环境里连接阶段和读取阶段的问题性质完全不同,应该分开设置:
TIMEOUT = (10, 45) # (连接超时, 读取超时)
resp = session.get(url, timeout=TIMEOUT)
连接超时通常意味着代理网关不可达或本地网络问题;读取超时意味着出口 IP 到目标站点这一段慢,可能是该 IP 被限速。分开设置能让日志直接指向原因。
带退避的重试
住宅代理偶发的连接失败是正常现象,关键是重试要有退避、有上限,并且在重试时换一个新出口——否则等于对同一个坏 IP 反复撞墙:
import time, random
from requests.exceptions import RequestException
def fetch(url: str, tries: int = 4):
last = None
for attempt in range(tries):
try:
# 每次重试换一个会话 ID,也就是换一个出口 IP
sid = f"{random.randint(10**6, 10**7-1)}"
proxies["http"] = proxies["https"] = build_proxy(sid)
session.proxies.update(proxies)
r = session.get(url, timeout=(10, 45))
if r.status_code in (403, 429):
raise RequestException(f"blocked {r.status_code}")
return r
except RequestException as e:
last = e
# 指数退避 + 抖动,避免所有重试同时打过来
time.sleep(min(2 ** attempt, 15) + random.uniform(0, 1.5))
raise last
两点值得注意:一是判断条件里包含 403/429,因为这两类状态码代表被风控,重试换 IP 是有意义的;二是加了随机抖动,避免多个线程的重试在同一时刻集中发出。
并发:线程数不是越高越好
requests 是同步库,通常用线程池提升吞吐。但并发过高会让同一个出口 IP 在目标站点那边形成明显的突发流量,反而提前触发限流:
from concurrent.futures import ThreadPoolExecutor, as_completed
with ThreadPoolExecutor(max_workers=8) as pool:
futures = {pool.submit(fetch, u): u for u in urls}
for f in as_completed(futures):
url = futures[f]
try:
print(url, len(f.get().content))
except Exception as e:
print("FAIL", url, e)
对中等防护的站点,单出口个位数并发是比较稳妥的起点,实际值应该由成功率测试反推,而不是拍脑袋设定。
排查清单
接不上时按这个顺序查,能覆盖绝大多数情况:
- 密码里的特殊字符是否做了 URL 编码
- 认证方式是否匹配(IP 白名单模式不需要用户名密码,反之亦然)
- 代理的端口和协议是否用对(住宅网关常见的是 8080 / 22225 这类端口)
- 是否真的走了代理——用
httpbin.org/ip确认出口 IP - 本地网络或云服务器安全组是否放行了出站连接
- 目标站点是否在拦截你,而不是代理本身有问题
第 4 步最容易被跳过,但它能一步区分”代理没生效”和”目标站点拦你”这两类完全不同的问题。
取舍结论
| 维度 | 结论 |
|---|---|
| 前提条件 | 代理网关地址、端口与认证方式已从官方文档确认,密码中的特殊字符已做 URL 编码。 |
| 核心取舍 | 粘性会话能维持登录态,但降低了 IP 分散度;轮换能规避风控,但会打断需要连续性的会话。 |
| 不适用场景 | 页面由 JS 渲染、或接口带签名参数时,requests 拿不到目标数据,必须改用浏览器方案。 |
一句话:连接超时和读取超时分开设置,重试时更换会话 ID,而不是对同一个坏出口反复撞墙。
在你的目标站点上验证这套方案
文中的做法都可以用试用额度直接跑通。用你自己的目标站点测一轮,成功率数据比任何评测都可靠。