不少写爬虫的朋友遇到过同一个问题:代码在浏览器里能正常访问的页面,用requests一请求就返回403或者一串验证码页面。排查半天IP也没问题,换代理也无效,真正的元凶往往藏在请求头里。服务器端的反爬系统会检查请求头各个字段的合法性,任何一处不符合浏览器特征,请求都会被拦下。本文围绕请求头合法性这一核心,介绍一套多策略混合验证的实战方案。

一、为什么你的请求头会被反爬系统识破
反爬系统判断一个请求是否来自真实浏览器,通常不是只看某一个字段,而是做交叉验证。最常见的问题有三个:User-Agent写死成一个老版本、Header字段残缺不全、字段之间的顺序和取值互相矛盾。
举个例子,很多教程给的示例代码里User-Agent写的是Chrome 40的版本字符串,而请求头里却带着sec-ch-ua这类只有Chrome 90以上才会发送的字段,两者明显冲突。再比如声明自己是移动端浏览器,却请求了桌面端的页面资源,这种矛盾在风控系统眼里非常扎眼。
此外,requests默认的请求行为也和浏览器不同。浏览器访问页面时总会携带Accept、Accept-Language、Referer等一整套字段,而requests默认只发送极少的几个头,这种请求一到达服务器就会被标记为可疑流量。
二、构建完整的请求头并实现UA轮换
第一步是把请求头补全。一个合格的请求头至少要包含User-Agent、Accept、Accept-Language、Connection这几个基础字段。下面是一套可用的模板代码,配合随机UA实现轮换:
import requests
import random
from fake_useragent import UserAgent
ua = UserAgent()
def build_headers(referer=None):
agent = ua.chrome # 只使用Chrome的UA,保证特征一致
headers = {
"User-Agent": agent,
"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",
"Cache-Control": "no-cache",
"Pragma": "no-cache",
}
if referer:
headers["Referer"] = referer
return headers
session = requests.Session()
resp = session.get("https://ipipp.com/news/list", headers=build_headers())
print(resp.status_code)
这里有两个细节值得注意。第一,UA轮换不要用所有浏览器的UA随机混用,同一会话内应保持同一身份,最好固定一个浏览器家族。第二,使用Session对象可以让服务器set-cookie返回的Cookie在后续请求中自动携带,很多网站的第一步校验就是发放Cookie然后检查你是否带回来,跳过这一步会直接触发二次验证。
Referer的设置同样重要。直接从首页点进详情页的请求一定会带Referer,如果你的请求光秃秃地什么都没有,很容易被判定为脚本行为。正确做法是先请求列表页,再把列表页地址作为Referer去请求详情页。
三、多策略混合验证:请求前自检,请求后探测
光把请求头写好还不够,更稳妥的做法是建立一套验证机制,在请求发出前对请求头做合法性自检,在响应返回后判断是否被拦截,两者结合形成闭环。
请求前的自检包括:UA版本号与sec-ch-ua字段是否匹配、必填字段是否齐全、UA与Accept-Language的语言地区是否一致。可以写一个校验函数:
REQUIRED_HEADERS = {"User-Agent", "Accept", "Accept-Language"}
def validate_headers(headers):
# 检查必填字段
missing = REQUIRED_HEADERS - set(headers.keys())
if missing:
raise ValueError(f"缺少必要请求头: {missing}")
# 检查UA与客户端提示字段的一致性
ua = headers.get("User-Agent", "")
sec_ch = headers.get("sec-ch-ua", "")
if "Chrome/120" in ua and "Chromium" not in sec_ch:
raise ValueError("UA与sec-ch-ua字段版本不匹配")
return True
请求后的探测则要识别伪装得很像正常响应的拦截页。有些网站被封时不返回403,而是返回200加一个验证码页面,只看状态码会误判成功。可靠的判断依据包括:响应体长度异常偏短、内容中包含滑块或验证码关键词、正文结构与历史响应差异过大。一旦探测到被拦截,就触发降级策略:更换UA、补充Cookie、降低请求频率后重试。
def is_blocked(resp):
if resp.status_code in (403, 412, 461):
return True
text = resp.text
trap_words = ["验证码", "滑动验证", "访问异常", "captcha"]
if any(w in text for w in trap_words):
return True
if len(resp.content) < 500 and resp.status_code == 200:
return True # 页面短得反常,大概率是拦截页
return False
def fetch_with_retry(session, url, max_retry=3):
for i in range(max_retry):
headers = build_headers(referer="https://ipipp.com/")
resp = session.get(url, headers=headers, timeout=10)
if not is_blocked(resp):
return resp
time.sleep(random.uniform(3, 6)) # 被拦截后降温再试
return None
四、进阶:TLS指纹与HTTP/2层面的伪装
当对方站点反爬等级较高时,仅靠请求头已经不够。服务器还能通过TLS握手指纹识别客户端:requests基于urllib3的TLS特征与Chrome差异明显,请求头再完美也会露馅。这时可以改用基于浏览器内核或专门模拟浏览器TLS的库。
常用的方案有两类。一类是curl_cffi,它内置了Chrome的TLS指纹模拟,用法几乎和requests一致:
from curl_cffi import requests as creq
# impersonate参数指定模拟Chrome浏览器的TLS与HTTP/2指纹
resp = creq.get("https://ipipp.com/data", impersonate="chrome")
print(resp.status_code)
另一类是直接用Playwright驱动真实浏览器,指纹层面几乎无懈可击,代价是资源消耗大、速度慢。工程实践中的合理分工是:普通页面用requests加完整请求头处理,遇到强校验的接口用curl_cffi,只有涉及复杂JS计算或滑块验证时才动用Playwright,形成三级降级结构。
最后要强调,抓取成功率与请求频率是一对矛盾体。无论请求头做得多逼真,短时间高频访问任何网站都会触发风控。在多策略混合验证的基础上叠加随机延时、并发控制和遵守robots协议,才能让爬虫长期稳定地运行下去。