为什么你的爬虫活不过一个晚上:请求指纹、IP 池与节奏控制的一些实在经验
—— 写给那些"一跑就被封"、又不明所以的朋友
去年帮一个朋友抓某公开榜单,脚本写得飞起,前两个小时数据哗哗地进库,正得意呢,第二天一早全变 403,IP 直接进了黑名单。那会儿我俩的第一反应是"对方反爬好厉害",后来翻日志才发现自己蠢——不是人家多强,是我们自己"太像个机器人":同一个 User-Agent、同一个出口 IP、每 1.000 秒准时发一个请求,这套特征摊在任何一个反爬系统面前都等于举着牌子写"我是爬虫"。
这事儿之后我算是把"怎么像个真人一样把页面拿下来"认真琢磨了一遍。下面这几块不是什么黑科技,但几乎是所有反爬的第一道关卡,先把它们过顺了,再谈更深的对抗。
你以为的"伪装",其实啥都没做
直接用 requests.get() 发出的请求,默认 User-Agent 是 python-requests/2.x,服务端看到这个字段基本不用猜。更隐蔽的是,浏览器平时还会带一堆"习惯动作":Accept、Accept-Language、Referer、Connection: keep-alive 这些头,裸 requests 要么不带、要么顺序不对,凑在一起就更可疑。
还有一层很多人忽略的:TLS 指纹。现代反爬会用 JA3 / JA4 这种握手特征来识别客户端——requests 底层是固定套件,握手长什么样一抓一个准,光改 UA 救不了。这一步要么上 curl_cffi 这种能模拟浏览器指纹的库,要么就别把目标选在重指纹校验的站点上。
一个带 UA 池、带齐常用头的基础请求长这样:
import random
import requests
UA_POOL = [
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36",
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/17.4 Safari/605.1.15",
"Mozilla/5.0 (X11; Linux x86_64; rv:125.0) Gecko/20100101 Firefox/125.0",
]
def build_headers():
return {
"User-Agent": random.choice(UA_POOL),
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8",
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"Referer": "https://www.baidu.com/",
"Connection": "keep-alive",
}
resp = requests.get("https://example.com/list", headers=build_headers(), timeout=10)
print(resp.status_code, len(resp.text))
IP 不是越多越好,是越"干净"越好
很多新手一上来就搜"免费代理池",结果踩得满脚血。免费代理三个通病:慢、存活率低、还可能是别人摆的蜜罐——你以为在用它隐藏自己,其实对方正拿你的请求做中间人,账号密码token全看光。真要规模采集,老老实实买付费住宅代理,比免费池省下的 debugging 时间值钱得多。
买代理也分两类:数据中心 IP 便宜量大,但特征明显,大站一眼识破;住宅 IP 是真实家庭宽带出口,隐蔽性好,价格也高。选型就是一笔账——目标站反爬强度高,就上住宅;只是普通资讯站,数据中心足够。
不管哪种,把死代理塞进轮询都是纯浪费请求。发之前先打个验活请求:
def is_alive(proxy, test="https://httpbin.org/ip", timeout=5):
try:
r = requests.get(test, proxies={"http": proxy, "https": proxy},
timeout=timeout)
return r.status_code == 200
except requests.RequestException:
return False
def pick_proxy(pool):
# 随机挑一个,先验活再返回;连续几次都死就放弃本轮
for _ in range(5):
p = random.choice(pool)
if is_alive(p):
return p
return None
这里有个权衡:每次都验活会多吃一个请求、拖慢整体速度。规模小的时候无所谓,量级上去了可以把验活挪到后台定时任务里批量刷,主抓取线程只读"已验证存活"的名单。
节奏这件事,规律就是原罪
我见过最典型的自杀式写法:for url in urls: fetch(url); time.sleep(1)。固定 1 秒间隔,人类根本做不到——人点网页有快有慢,还会走神。机器人才会"滴、滴、滴"这么均匀。
两个改法:一是把间隔换成随机抖动;二是遇到失败做指数退避,别傻重试。真碰上 429,服务端通常会回 Retry-After,照着它等,比自己硬刚礼貌也有效:
import time, random, requests
def fetch_with_care(url, headers, proxy, backoff=1.0):
try:
r = requests.get(url, headers=headers,
proxies={"http": proxy, "https": proxy}, timeout=10)
if r.status_code == 429:
wait = int(r.headers.get("Retry-After", 30))
time.sleep(wait + random.uniform(0, 2))
return fetch_with_care(url, headers, proxy, backoff)
r.raise_for_status()
time.sleep(random.uniform(0.5, 2.5)) # 抖动,而非固定间隔
return r
except requests.RequestException:
# 失败退避:本轮等 backoff 秒,下次翻倍,封顶 30 秒
time.sleep(min(backoff, 30) + random.uniform(0, 1))
return None
解析层:正则不是不能用,但别滥用
刚学爬虫的人容易走两个极端:要么全程正则硬抠,要么无脑上重型框架。我的习惯是看场景——如果页面结构稳定、你只抠一两个字段,正则最快最省事,零依赖;但凡是批量抓列表、HTML 层级复杂、哪天对方改个 class 名就崩的活儿,就用选择器。
CSS 选择器 / XPath(parsel / lxml)容错明显更好,能按层级定位、能批量取,结构微调不至于全废:
from parsel import Selector
sel = Selector(text=resp.text)
for item in sel.css("div.card"):
title = item.css("h2.title::text").get(default="").strip()
link = item.css("a::attr(href)").get(default="")
if title and link:
print(title, link)
取舍就一句话:正则适合"结构钉死、单点提取",选择器适合"结构化、批量、要抗小改动"。别为了显酷上框架,也别为了省事正则梭哈。
收个尾,但没必要煽情
爬虫这门手艺,难的从来不是"把页面拿下来"——requests 一行就完事了。难的是"拿得稳、拿得久、还不给对方添堵"。上面这三关——请求指纹、IP 干净度、访问节奏——几乎横在所有反爬的第一线,先把它们想透,再去碰验证码、JS 逆向那些更深的对抗,会顺手很多。
robots.txt;明确标注受限、需要授权的接口不要硬钻;涉及个人信息的字段不要落库;把抓取频率压在对方站点能轻松承受的范围内。把这些工具用在公开数据的合规采集上,才是它能长久跑下去的前提。
