处理封锁和重试,别白白烧掉代理流量
在按 GB 计费的代理上,一个粗心的重试循环会为同一个封锁页一遍遍付钱。哪些失败该重试,怎么退避,以及怎么提前停止下载。
一个在你笔记本上看着无害的重试循环,可能悄悄变成按 GB 计费账单上最大的一项。常见的罪魁祸首是一个开始用封锁页回应你的网站:每次尝试都下载同样几百 KB 的「请证明你是真人」,而循环还在不停地问。
这篇讲的是只把流量花在你想要的响应上。它默认你用的是按 GB 计费的产品,比如我们的住宅代理;在按 IP 计费的线路上,同样的习惯能省时间,也能让你的 IP 保持更好的名声。
一次失败的尝试要花多少
取决于回来的是什么。
| 结果 | 网站有正文返回吗? | 在我们的用量表上 |
|---|---|---|
| 超时、连接重置、我们网关返回的错误(比如 407 或 502) | 没有 | 免费,日志里可见 |
| 网站返回的 429、5xx | 通常很小 | 和任何响应一样计量 |
| 403 页面、验证码、“请证明你是真人” | 有 | 和任何响应一样计量 |
| 装着封锁页的 200 | 有 | 和任何响应一样计量 |
只有第一行免费:连接失败了,网站那边什么都没穿过代理。其余几行都是网站在回应你,这些字节就是流量。对 HTTPS 网站来说,状态码走在加密隧道里面,我们的代理读不到,所以用量表数的是穿过隧道的字节,分不出 429 和 200。规则见计量部分。
429 或 503 通常是很短的响应,只有一个正常页面的零头,所以好好等一段时间再重试,花不了多少。封锁页要重得多,一遍又一遍去抓封锁页的循环,才是重试烧钱的地方。
只重试会变的
决定再试一次之前,先把响应分类:
- 退避后重试:超时、连接错误、429、502、503、504。这些是暂时性的,或者是网站让你等一等。每收到一个 429 或 5xx,都是一小笔计量的响应,所以两次尝试之间要等够,也要给重试次数设上限。
- 换个新 IP 重试一次,然后停:403 和明显的封锁页。有轮换的话,在新地址上再试一次是合理的。第二次还被封,说明问题出在你的请求、你的频率或你的指纹上,排查清单见爬虫为什么突然开始被封了。
- 不要重试:404、410、400。答案不会变。
退避,并遵守 Retry-After
带一点随机性的指数退避,能防止一池子工作进程齐步重试。网站发来 Retry-After 时,它已经告诉你该等多久了。
import random
import time
def backoff(attempt):
return min(60, 2 ** attempt) * random.uniform(0.5, 1.0)
def retry_after(response):
value = response.headers.get("Retry-After", "")
return int(value) if value.isdigit() else None
提前停止下载
用了 stream=True,requests 会先读取状态行和响应头,等你要正文时才去取。这给了你两个省钱的出口:在正文到达之前就拒绝你不想要的状态码;读第一块数据,在下载剩余部分之前认出封锁页。提前关闭响应,意味着不想要的正文大部分根本不会穿过代理。
import requests
PROXY = "http://USERNAME:PASSWORD@HOST:PORT"
PROXIES = {"http": PROXY, "https": PROXY}
RETRYABLE = {429, 502, 503, 504}
BLOCK_MARKERS = (b"captcha", b"verify you are human", b"access denied")
MAX_BYTES = 3_000_000
def fetch(url, attempts=4):
blocked_once = False
for attempt in range(attempts):
try:
response = requests.get(url, proxies=PROXIES, timeout=30, stream=True)
except (requests.ConnectionError, requests.Timeout):
time.sleep(backoff(attempt))
continue
with response:
if response.status_code in RETRYABLE:
time.sleep(retry_after(response) or backoff(attempt))
continue
if response.status_code == 403:
if blocked_once:
return None
blocked_once = True
continue
if response.status_code != 200:
return None
if int(response.headers.get("Content-Length") or 0) > MAX_BYTES:
return None
chunks = response.iter_content(65_536)
first = next(chunks, b"")
if any(marker in first.lower() for marker in BLOCK_MARKERS):
if blocked_once:
return None
blocked_once = True
continue
return first + b"".join(chunks)
return None
BLOCK_MARKERS 要从目标站实际返回的封锁页里挑;在浏览器里打开一个,复制其中一句话。中文网站的封锁页,就把对应的中文短语编码成字节放进去。HOST、PORT、USERNAME 和 PASSWORD 来自我们控制台里该服务的页面。
整个站点都在拒绝时,先停下
按 URL 的重试注意不到一个站点上所有 URL 都在失败。为每个站点保留一个最近结果的短窗口,当其中大多数都是封锁时,暂停一段时间不再发送。
from collections import defaultdict, deque
recent = defaultdict(lambda: deque(maxlen=20))
def record(host, blocked):
recent[host].append(blocked)
def should_pause(host):
window = recent[host]
return len(window) == window.maxlen and sum(window) > window.maxlen // 2
暂停不花钱。同一段时间里一百个工作进程在下载封锁页,就是账单上实实在在的一项。
一开始就少要一些字节
- 保持压缩开启。 多数 HTTP 客户端默认会发送
Accept-Encoding: gzip。确认一下你的也是。 - 对会回访的页面用条件请求。 把上次看到的
ETag作为If-None-Match发出去;没变的页面会以不带正文的304返回。 - 优先用页面自己加载的 JSON。 网站自己的数据接口,通常只有渲染出来的页面的一小部分。
- 在无头浏览器里,屏蔽你用不到的东西。 图片、字体和媒体文件,常常比 HTML 本身重上很多倍。
etags = {}
def fetch_if_changed(url):
headers = {"If-None-Match": etags[url]} if url in etags else {}
response = requests.get(url, headers=headers, proxies=PROXIES, timeout=30)
if response.status_code == 304:
return None
if "ETag" in response.headers:
etags[url] = response.headers["ETag"]
return response.content
想看看这些到底省了多少,就在改动前后分别拿你自己数的字节和用量表对比;脚本在按 GB 计费怎么算钱里。关于某个具体目标站的问题,欢迎带到 Discord。