反爬机制与对抗技术
基础原理 里已经讲过 TLS 指纹和浏览器指纹这两个识别点。本篇处理的是更完整的问题:服务端到底从哪几个维度识别爬虫,以及每个维度对应的工程解法。
⚖️ 合规边界(先看这个)
本篇是技术原理整理,不是绕过任何特定系统的操作手册。实际使用时的红线:
- 不得绕过实质性的访问控制:需要登录才能看的数据、明确的付费墙、
robots.txt明令禁止的路径 - 不得抓取个人信息:《个人信息保护法》下,手机号、身份证、住址、行踪轨迹等即使公开可见也受保护
- 不得影响目标服务可用性:高频请求造成服务降级可能构成破坏计算机信息系统罪
- 不得规避技术保护措施用于商业竞争:《反不正当竞争法》互联网专条
技术上可行 ≠ 法律上允许。公开数据、合理频率、非个人信息、不影响服务 是安全区。
一、识别维度全景
服务端的反爬判断几乎都落在这四层。判断自己被哪一层拦住,是选对解法的前提。
| 层次 | 服务端看什么 | 典型表现 | 对应解法 |
|---|---|---|---|
| 网络层 | IP 归属、频率、ASN 类型 | 403 / 429,或直接超时 | 代理池(第二节) |
| 协议层 | TLS 指纹(JA3/JA4)、HTTP/2 帧序、header 顺序与大小写 | 浏览器能开,代码必被拦 | curl_cffi、真实浏览器 |
| 环境层 | Cookie、登录态、JS 挑战、Canvas/WebGL 指纹、navigator 属性 | 返回挑战页或空数据 | 账号池、浏览器渲染 |
| 行为层 | 请求间隔规律性、鼠标轨迹、页面停留、访问路径顺序 | 前 N 次正常,之后触发验证码 | 限速随机化、轨迹模拟 |
| 内容层 | 不隐藏数据,但把数据混淆后给你 | 抓到的是乱码或错位文字 | 字体/CSS 还原(第五节) |
💡 排查顺序
被拦时按 协议层 → 网络层 → 环境层 → 行为层 依次排除,不要一上来就换 IP。
最快的判断方法:用 Copy as cURL 把浏览器请求原样重放。
- cURL 能成功、Python 失败 → 协议层(TLS 指纹)
- cURL 也失败 → 网络层或环境层(IP 已被标记,或缺凭证)
- 都成功但跑一会儿就失败 → 行为层(频率)
二、IP 与代理
2.1 代理类型的实质差异
| 类型 | 来源 | 被识别难度 | 成本 | 适用 |
|---|---|---|---|---|
| 数据中心代理 | 机房 IP(IDC ASN) | 低——ASN 一查就知道 | 最低 | 弱反爬站点 |
| 住宅代理 | 真实家庭宽带 | 高——与真实用户同源 | 高(按流量计费) | 中强反爬 |
| 移动代理 | 4G/5G 蜂窝网络 | 最高——运营商 NAT 后大量用户共享 | 最高 | 强风控 |
| ADSL 拨号 | 自建拨号服务器 | 中——可主动换 IP | 中(需自建) | 自有资源、长期项目 |
关键认知:反爬系统通过 ASN 就能判断 IP 是机房还是住宅。数据中心代理再多,在把 IDC 段整体降权的站点面前也没有意义——这时候要换的是代理类型,不是代理数量。
按匿名度还需区分透明 / 匿名 / 高匿:透明代理会通过 X-Forwarded-For、Via 头暴露真实 IP,做爬虫只能用高匿代理。
2.2 各库的代理设置
# requests / httpx
proxies = {"http://": "http://user:pass@host:port",
"https://": "http://user:pass@host:port"}
httpx.Client(proxies=proxies) # httpx 的 key 带 ://
requests.get(url, proxies={"http": "...", "https": "..."}) # requests 不带
# SOCKS5 需要额外依赖
# pip install "httpx[socks]" / "requests[socks]"
proxies = {"all://": "socks5://host:port"}
# Playwright:上下文级别
browser.new_context(proxy={"server": "http://host:port",
"username": "u", "password": "p"})
# Scrapy:在 Downloader Middleware 里设 meta
request.meta["proxy"] = "http://host:port"⚠️ 浏览器代理认证的坑
Chromium 不支持在 URL 里内嵌 user:pass@。Selenium 下需要用扩展注入凭证,或改用无需认证的 IP 白名单模式;Playwright 则通过 proxy 参数的 username/password 字段传递。
2.3 代理池架构
代理池要解决的核心问题是:代理会不断失效,必须持续获取、持续验证、按质量调度。标准结构是四个解耦的模块:
用 Redis ZSET 做存储是关键设计,因为分数天然支持「质量排序 + 淘汰」:
| 事件 | 分数操作 | 含义 |
|---|---|---|
| 新代理入池 | 设为初始分(如 10) | 待验证 |
| 检测通过 | 直接置为满分(如 100) | 立即可用,不做渐进加分 |
| 检测失败 | 分数 −1 | 给一次网络抖动的容错 |
| 分数降到 0 | 从 ZSET 移除 | 淘汰 |
| 取用代理 | 优先取满分;无满分则按分数区间随机取 | 保证可用性同时打散 |
# 核心操作,体现「满分优先 + 降级随机」的取用策略
MAX_SCORE, MIN_SCORE, INIT_SCORE = 100, 0, 10
def add(self, proxy: str) -> None:
if not self.db.zscore(KEY, proxy): # 已存在则不覆盖分数
self.db.zadd(KEY, {proxy: INIT_SCORE})
def rate_success(self, proxy: str) -> None:
self.db.zadd(KEY, {proxy: MAX_SCORE}) # 可用即满分
def rate_failure(self, proxy: str) -> None:
score = self.db.zscore(KEY, proxy)
if score and score > MIN_SCORE:
self.db.zincrby(KEY, -1, proxy)
else:
self.db.zrem(KEY, proxy) # 分数耗尽,淘汰
def random(self) -> str:
best = self.db.zrangebyscore(KEY, MAX_SCORE, MAX_SCORE)
pool = best or self.db.zrangebyscore(KEY, MIN_SCORE, MAX_SCORE)
if not pool:
raise PoolEmptyError
return choice(pool)检测器的两个设计要点:
- 用目标站点自身做检测 URL,而不是通用测试站。一个代理能访问
httpbin.org不代表没被目标站封禁——这是代理池「看起来有货实际全废」的最常见原因。 - 检测要并发。串行检测几千个代理,跑完一轮已经过期了;用
asyncio+aiohttp并发,一轮控制在分钟级。
2.4 与爬虫的对接
代理池以 HTTP 接口暴露(如 GET /random),爬虫侧只需在失败时换 IP 并回报:
def fetch_with_proxy(url: str, max_retry: int = 3):
for _ in range(max_retry):
proxy = requests.get(f"{POOL_API}/random").text.strip()
try:
resp = requests.get(url, proxies={"https": f"http://{proxy}"}, timeout=10)
if resp.status_code in (403, 429):
requests.get(f"{POOL_API}/report?proxy={proxy}") # 回报失效
continue
return resp
except (requests.Timeout, requests.ConnectionError):
requests.get(f"{POOL_API}/report?proxy={proxy}")
raise RuntimeError("重试耗尽")三、登录态:Cookie 与账号池
3.1 两种登录态载体
| 机制 | 凭证位置 | 服务端状态 | 失效判断 |
|---|---|---|---|
| Session + Cookie | Cookie 头,服务端存 session | 有状态 | 重定向到登录页 / 返回登录 HTML |
| JWT | Authorization: Bearer <token> | 无状态,token 自带签名与过期 | 401,或本地解 payload 看 exp |
JWT 的结构是 header.payload.signature,三段 Base64URL。payload 可以本地解开(它只是编码不是加密),因此可以在请求前主动判断是否过期,避免无谓请求:
import base64, json, time
def jwt_expired(token: str, skew: int = 60) -> bool:
payload = token.split(".")[1]
payload += "=" * (-len(payload) % 4) # 补齐 Base64 padding
exp = json.loads(base64.urlsafe_b64decode(payload)).get("exp", 0)
return time.time() > exp - skew # 留 60 秒余量提前刷新⚠️ 别改 JWT 的 payload
签名由服务端密钥生成,任何篡改都会导致验签失败。能本地读取只是因为它没加密,不代表能伪造。
3.2 账号池架构
单账号高频访问必然触发风控。账号池与代理池同构,但多了「凭证生成」这一步:
| 模块 | 职责 | 关键点 |
|---|---|---|
| 生成器 | 对没有有效凭证的账号执行登录 | 登录动作本身要限频,否则触发账号风控 |
| 检测器 | 定期用凭证访问一个需要登录的轻量接口 | 检测 URL 必须真正校验登录态,否则检测无效 |
| 存储 | Redis Hash 存 账号 → 凭证 | 凭证设 TTL,比服务端过期时间略短 |
| 接口 | 随机返回可用凭证 | 同一账号避免并发使用,否则可能互相踢下线 |
账号与代理必须绑定:同一账号频繁更换 IP 地域,本身就是强风控信号。实践上应保持 账号 ↔ 代理 的稳定映射关系。
3.3 登录流程的实现要点
# Session/Cookie 型:用 Session 对象自动管理 cookie 流转
session = requests.Session()
session.post(login_url, data={"username": u, "password": p})
# 后续请求自动携带登录后的 cookie
resp = session.get(protected_url)
# 提取 cookie 用于持久化
cookies = session.cookies.get_dict()三个常见障碍:
- 隐藏表单字段:登录表单常带
csrf_token、lt、execution等一次性字段,必须先 GET 登录页解析出来再随表单提交。 - 密码在前端加密:password 字段提交的是 JS 加密后的密文。需要 JS 逆向 还原加密函数,或用浏览器完成登录后只导出 Cookie。
- 登录本身带验证码:进入下一节的问题。
四、验证码
4.1 技术路线全景
| 类型 | 识别难度 | 主流技术路线 |
|---|---|---|
| 静态图形字符 | 低 | 图像预处理 + OCR |
| 滑块(缺口) | 中 | 缺口定位(CV 或目标检测)+ 轨迹生成 |
| 点选文字/图标 | 中高 | 目标检测定位 + 分类/相似度匹配 |
| 计算/逻辑题 | 中 | OCR + 表达式求值 |
| 无感风控(Turnstile、reCAPTCHA v3 等) | 极高 | 本地识别不现实,转向真实浏览器环境 |
4.2 图形验证码:预处理决定成败
OCR 本身不难,难的是把噪点、干扰线、粘连字符处理干净。预处理带来的准确率提升远大于换 OCR 引擎。
from PIL import Image
import numpy as np
img = Image.open("captcha.png").convert("L") # 1. 灰度化
arr = np.array(img)
arr = np.where(arr > 128, 255, 0).astype(np.uint8) # 2. 二值化,阈值需按样本调
# 3. 降噪:去除孤立像素点(8 邻域内黑点数少于阈值则置白)
# 4. 倾斜校正 / 字符切分(投影法找列方向的空白间隔)| 引擎 | 特点 |
|---|---|
tesserocr / pytesseract | 通用 OCR,对规整字符尚可,对强干扰验证码准确率很低 |
ddddocr | 针对验证码场景训练的开源模型,开箱即用,通用字符码效果明显更好 |
| 自训练 CNN | 样本充足时准确率最高,但需要标注数据 |
4.3 滑块:缺口定位 + 轨迹生成
两个独立子问题,都要解决才算通过。
缺口定位的三条路线:
- 图像比对:同时拿到带缺口图和完整背景图时,逐像素比较 RGB 差值,差值超阈值的最左侧列即缺口横坐标。最简单,但很多站点不提供完整背景图。
- 边缘检测 + 模板匹配:OpenCV 的
Canny提边缘后,用matchTemplate匹配滑块形状。对光照和噪声敏感,需要调参。 - 目标检测模型:用 YOLO 等模型直接检测缺口位置。需要标注数据训练,但对各种样式的泛化能力最强,是当前的主流做法。
import cv2
def find_gap(bg_path: str, tpl_path: str) -> int:
bg = cv2.Canny(cv2.imread(bg_path, 0), 100, 200)
tpl = cv2.Canny(cv2.imread(tpl_path, 0), 100, 200)
res = cv2.matchTemplate(bg, tpl, cv2.TM_CCOEFF_NORMED)
_, _, _, max_loc = cv2.minMaxLoc(res)
return max_loc[0] # 缺口左边界的 x 坐标轨迹生成才是真正的判定重点。风控系统检测的是运动学特征,匀速直线拖动 100% 会被判定为机器:
- 真人轨迹符合「加速 → 减速 → 末端微调抖动」的模式
- 需要在 x 方向叠加先正后负的加速度,并在接近目标时产生过冲与回调
- y 方向必须有小幅随机抖动(真人手不可能走直线)
- 时间间隔要非均匀,且总时长落在人类合理区间(通常 0.5~3 秒)
def build_track(distance: int) -> list[tuple[int, int, float]]:
"""生成 (dx, dy, dt) 序列:先加速后减速,末端过冲回调。"""
track, current, v = [], 0, 0
mid = distance * random.uniform(0.7, 0.8) # 减速点
while current < distance:
a = random.uniform(2, 4) if current < mid else -random.uniform(3, 5)
dt = random.uniform(0.01, 0.03) # 非均匀时间片
v0, v = v, max(v + a * dt, 0.1)
move = v0 * dt + 0.5 * a * dt ** 2
current += move
track.append((round(move), random.choice([-1, 0, 0, 1]), dt))
return track4.4 无感验证:为什么不该硬碰
Cloudflare Turnstile、reCAPTCHA v3 这类系统不产出可识别的图像题目,而是综合以下信号打出风险分:
浏览器环境完整性(数百项 API 检测)、TLS/HTTP 指纹、IP 信誉、鼠标与键盘事件流、页面停留与滚动模式、历史行为关联。
本地"识别"在原理上就不成立——没有题目可解,需要的是通过环境和行为检测。可行方向只有两条:
- 用真实浏览器 + 优质住宅 IP + 合理行为节奏,让风险分自然落在通过区间;
- 人工打码平台的 token 模式:由平台在真实环境完成验证,返回响应 token 供你提交。
⚠️ 打码平台的三个现实问题
成本:单次 0.5~3 分不等,百万级请求成本不可忽略。 延迟:人工识别 5~30 秒,会成为吞吐瓶颈。 合规:把目标站数据(验证码图片乃至上下文)传给第三方,可能同时违反目标站条款与数据出境规定。
五、内容层混淆:数据在但不可读
这类反爬不阻止你抓取,而是让抓到的内容不能直接使用。识别特征:HTML 抓到了,肉眼看渲染正常,但提取出的文本是乱码或顺序错乱。
5.1 字体反爬
原理:网站加载自定义字体文件(woff/woff2),把字符编码与字形的映射关系打乱。HTML 里写的是 ,浏览器用自定义字体渲染成「3」,而你抓到的就是 。
破解思路:字体文件里存着 cmap(编码 → 字形名)和 glyf(字形名 → 轮廓坐标)两张表。轮廓坐标是不会变的——同一个「3」无论映射到哪个编码,它的贝塞尔曲线控制点始终一致。
from fontTools.ttLib import TTFont
font = TTFont("custom.woff")
cmap = font.getBestCmap() # {0xe602: 'glyph00007', ...}
glyf = font["glyf"]
# 用字形轮廓坐标作为指纹,与已知基准字体比对,还原真实字符
coords = glyf["glyph00007"].coordinates完整流程:
- 下载一份字体文件,人工标注出「编码 → 真实字符」的基准映射;
- 对每个字形提取轮廓坐标(或渲染成小图做感知哈希)作为指纹;
- 目标站动态更换字体文件时,下载新字体,用指纹匹配基准库,自动还原新的映射关系。
关键点:成熟站点会每次请求下发不同的字体文件(编码随机、字形顺序打乱)。因此不能硬编码映射表,必须做基于字形指纹的自动匹配,这是这类反爬能否规模化破解的分水岭。
5.2 CSS 位置偏移反爬
原理:HTML 里的字符顺序是故意打乱的,再用 CSS 的绝对定位、margin-left、order、direction: rtl 等把它们视觉上摆回正确顺序。直接取 text() 得到的是乱序结果。
破解思路:不要只解析 DOM,要解析 CSS 并按视觉位置重排。
# 1. 提取每个字符节点的 class
# 2. 从 CSS 文件里解析该 class 对应的定位属性(如 left: -48px)
# 3. 按解析出的位置值排序,重新拼接字符
chars = [(parse_left(css_rules[node.attrib["class"]]), node.text)
for node in nodes]
real_text = "".join(c for _, c in sorted(chars))变体还包括:用伪元素 ::before { content: "8" } 注入真实字符(此时字符根本不在 DOM 文本里,必须解析 CSS 的 content 属性)、插入 display:none 的干扰字符(需要先剔除隐藏节点)。
5.3 通用兜底:渲染后取值
所有内容层混淆都有一个共同的降级方案——让浏览器渲染完,再取渲染结果:
# 取渲染后的可见文本,浏览器已经完成字体映射与 CSS 重排
text = page.locator(".price").inner_text()
# 伪元素内容必须用 JS 读取
text = page.evaluate(
"el => getComputedStyle(el, '::before').content",
page.query_selector(".price"),
)代价是吞吐下降一个数量级。数据量小就直接渲染,数据量大才值得投入做映射还原。
六、行为层:让请求模式像人
即使 IP、指纹、登录态全部正确,固定节奏依然会暴露。
| 特征 | 机器表现 | 应对 |
|---|---|---|
| 请求间隔 | 精确的固定值 | 随机化,且用对数正态分布而非均匀分布(更接近真人) |
| 并发模式 | 瞬时打满后归零 | 平滑并发,配合自动限速 |
| 访问路径 | 直接命中详情页,无来路 | 补 Referer,模拟「列表 → 详情」的真实路径 |
| 时间分布 | 凌晨 3 点满负荷 | 按目标站用户的活跃时段分布任务 |
| 请求头一致性 | UA 是 Chrome,但缺 sec-ch-ua | 整组 header 必须自洽(见下) |
💡 请求头一致性是最容易翻车的细节
换 UA 时,与之关联的头必须一起换:sec-ch-ua、sec-ch-ua-platform、sec-ch-ua-mobile、Accept、Accept-Language、Accept-Encoding、Sec-Fetch-*。
声称是 Chrome 138 却发送 Firefox 的 Accept 值、或完全没有 sec-ch-* 头,比不改 UA 更容易被识别。最可靠的做法是从真实浏览器整组复制,而不是逐项手写。
七、方案选择的成本排序
被拦截时按成本从低到高尝试,不要一步跳到最贵的方案:
- 补全请求头(成本≈0)——大量"反爬"其实只是缺
Referer或sec-ch-* - 降频 + 随机化(成本≈0)——解决相当比例的 429
- 换 TLS 指纹库(
curl_cffi,成本低)——解决"cURL 能通、Python 不通" - 加代理池(成本中)——解决 IP 封禁
- 加账号池(成本中)——解决登录态限流
- 浏览器渲染(成本高,吞吐降一个数量级)——解决 JS 挑战和内容混淆
- 打码 / 第三方服务(成本最高,有合规风险)——最后手段
💡 反向思路
在投入第 4 步之前,先回头确认一次:目标数据是否有公开 API、开放数据集、RSS 或合作接口? 相当比例的爬虫工程量,本可以用一次数据申请替代。
📚 模块与官方文档
请求与代理
| 模块 | 安装 | 官方文档 |
|---|---|---|
httpx | pip install "httpx[socks]" | python-httpx.org · 代理配置 |
requests | pip install "requests[socks]" | requests.readthedocs.io · 代理配置 |
curl_cffi | pip install curl_cffi | curl-cffi.readthedocs.io |
playwright | pip install playwright | playwright.dev/python |
redis-py | pip install redis | redis.readthedocs.io · ZSET 命令 |
验证码识别
| 模块 | 安装 | 官方文档 |
|---|---|---|
ddddocr | pip install ddddocr | GitHub |
opencv-python | pip install opencv-python | docs.opencv.org · 模板匹配 |
Pillow | pip install Pillow | pillow.readthedocs.io |
numpy | pip install numpy | numpy.org/doc |
pytesseract | pip install pytesseract | GitHub |
tesserocr | pip install tesserocr | GitHub |
| Tesseract 引擎 | 系统包 | tesseract-ocr.github.io |
内容混淆还原
| 模块 | 安装 | 官方文档 |
|---|---|---|
fontTools | pip install fonttools | fonttools.readthedocs.io · TTFont API |
cssutils | pip install cssutils | cssutils.readthedocs.io |
tinycss2 | pip install tinycss2 | tinycss2.readthedocs.io |
规范与合规
| 资源 | 说明 |
|---|---|
| MDN - HTTP 请求头 | 各请求头语义与取值规范 |
| MDN - Client Hints | sec-ch-ua 系列头的定义 |
| RFC 9309 - robots.txt | 爬虫排除协议正式标准 |
| RFC 6265 - HTTP Cookie | Cookie 机制规范 |
| RFC 7519 - JWT | JWT 结构与校验规则 |
| JA3 / JA4 指纹 | TLS 指纹算法说明 |
| 个人信息保护法 | 数据合规的核心依据 |