跳转到正文

反爬机制与对抗技术

基础原理 里已经讲过 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-ForVia 头暴露真实 IP,做爬虫只能用高匿代理

2.2 各库的代理设置

python
# 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 移除淘汰
取用代理优先取满分;无满分则按分数区间随机取保证可用性同时打散
python
# 核心操作,体现「满分优先 + 降级随机」的取用策略
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)

检测器的两个设计要点

  1. 用目标站点自身做检测 URL,而不是通用测试站。一个代理能访问 httpbin.org 不代表没被目标站封禁——这是代理池「看起来有货实际全废」的最常见原因。
  2. 检测要并发。串行检测几千个代理,跑完一轮已经过期了;用 asyncio + aiohttp 并发,一轮控制在分钟级。

2.4 与爬虫的对接

代理池以 HTTP 接口暴露(如 GET /random),爬虫侧只需在失败时换 IP 并回报:

python
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("重试耗尽")

3.1 两种登录态载体

机制凭证位置服务端状态失效判断
Session + CookieCookie 头,服务端存 session有状态重定向到登录页 / 返回登录 HTML
JWTAuthorization: Bearer <token>无状态,token 自带签名与过期401,或本地解 payload 看 exp

JWT 的结构是 header.payload.signature,三段 Base64URL。payload 可以本地解开(它只是编码不是加密),因此可以在请求前主动判断是否过期,避免无谓请求:

python
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 登录流程的实现要点

python
# 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()

三个常见障碍:

  1. 隐藏表单字段:登录表单常带 csrf_tokenltexecution 等一次性字段,必须先 GET 登录页解析出来再随表单提交。
  2. 密码在前端加密:password 字段提交的是 JS 加密后的密文。需要 JS 逆向 还原加密函数,或用浏览器完成登录后只导出 Cookie。
  3. 登录本身带验证码:进入下一节的问题。

四、验证码

4.1 技术路线全景

类型识别难度主流技术路线
静态图形字符图像预处理 + OCR
滑块(缺口)缺口定位(CV 或目标检测)+ 轨迹生成
点选文字/图标中高目标检测定位 + 分类/相似度匹配
计算/逻辑题OCR + 表达式求值
无感风控(Turnstile、reCAPTCHA v3 等)极高本地识别不现实,转向真实浏览器环境

4.2 图形验证码:预处理决定成败

OCR 本身不难,难的是把噪点、干扰线、粘连字符处理干净。预处理带来的准确率提升远大于换 OCR 引擎

python
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 滑块:缺口定位 + 轨迹生成

两个独立子问题,都要解决才算通过。

缺口定位的三条路线:

  1. 图像比对:同时拿到带缺口图和完整背景图时,逐像素比较 RGB 差值,差值超阈值的最左侧列即缺口横坐标。最简单,但很多站点不提供完整背景图。
  2. 边缘检测 + 模板匹配:OpenCV 的 Canny 提边缘后,用 matchTemplate 匹配滑块形状。对光照和噪声敏感,需要调参。
  3. 目标检测模型:用 YOLO 等模型直接检测缺口位置。需要标注数据训练,但对各种样式的泛化能力最强,是当前的主流做法。
python
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 秒)
python
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 track

4.4 无感验证:为什么不该硬碰

Cloudflare Turnstile、reCAPTCHA v3 这类系统不产出可识别的图像题目,而是综合以下信号打出风险分:

浏览器环境完整性(数百项 API 检测)、TLS/HTTP 指纹、IP 信誉、鼠标与键盘事件流、页面停留与滚动模式、历史行为关联。

本地"识别"在原理上就不成立——没有题目可解,需要的是通过环境和行为检测。可行方向只有两条:

  1. 用真实浏览器 + 优质住宅 IP + 合理行为节奏,让风险分自然落在通过区间;
  2. 人工打码平台的 token 模式:由平台在真实环境完成验证,返回响应 token 供你提交。

⚠️ 打码平台的三个现实问题

成本:单次 0.5~3 分不等,百万级请求成本不可忽略。 延迟:人工识别 5~30 秒,会成为吞吐瓶颈。 合规:把目标站数据(验证码图片乃至上下文)传给第三方,可能同时违反目标站条款与数据出境规定。


五、内容层混淆:数据在但不可读

这类反爬不阻止你抓取,而是让抓到的内容不能直接使用。识别特征:HTML 抓到了,肉眼看渲染正常,但提取出的文本是乱码或顺序错乱。

5.1 字体反爬

原理:网站加载自定义字体文件(woff/woff2),把字符编码与字形的映射关系打乱。HTML 里写的是 &#xe602;,浏览器用自定义字体渲染成「3」,而你抓到的就是

破解思路:字体文件里存着 cmap(编码 → 字形名)和 glyf(字形名 → 轮廓坐标)两张表。轮廓坐标是不会变的——同一个「3」无论映射到哪个编码,它的贝塞尔曲线控制点始终一致。

python
from fontTools.ttLib import TTFont

font = TTFont("custom.woff")
cmap = font.getBestCmap()              # {0xe602: 'glyph00007', ...}
glyf = font["glyf"]

# 用字形轮廓坐标作为指纹,与已知基准字体比对,还原真实字符
coords = glyf["glyph00007"].coordinates

完整流程:

  1. 下载一份字体文件,人工标注出「编码 → 真实字符」的基准映射;
  2. 对每个字形提取轮廓坐标(或渲染成小图做感知哈希)作为指纹;
  3. 目标站动态更换字体文件时,下载新字体,用指纹匹配基准库,自动还原新的映射关系。

关键点:成熟站点会每次请求下发不同的字体文件(编码随机、字形顺序打乱)。因此不能硬编码映射表,必须做基于字形指纹的自动匹配,这是这类反爬能否规模化破解的分水岭。

5.2 CSS 位置偏移反爬

原理:HTML 里的字符顺序是故意打乱的,再用 CSS 的绝对定位、margin-leftorderdirection: rtl 等把它们视觉上摆回正确顺序。直接取 text() 得到的是乱序结果。

破解思路不要只解析 DOM,要解析 CSS 并按视觉位置重排

python
# 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 通用兜底:渲染后取值

所有内容层混淆都有一个共同的降级方案——让浏览器渲染完,再取渲染结果

python
# 取渲染后的可见文本,浏览器已经完成字体映射与 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-uasec-ch-ua-platformsec-ch-ua-mobileAcceptAccept-LanguageAccept-EncodingSec-Fetch-*

声称是 Chrome 138 却发送 Firefox 的 Accept 值、或完全没有 sec-ch-* 头,比不改 UA 更容易被识别。最可靠的做法是从真实浏览器整组复制,而不是逐项手写。


七、方案选择的成本排序

被拦截时按成本从低到高尝试,不要一步跳到最贵的方案:

  1. 补全请求头(成本≈0)——大量"反爬"其实只是缺 Referersec-ch-*
  2. 降频 + 随机化(成本≈0)——解决相当比例的 429
  3. 换 TLS 指纹库curl_cffi,成本低)——解决"cURL 能通、Python 不通"
  4. 加代理池(成本中)——解决 IP 封禁
  5. 加账号池(成本中)——解决登录态限流
  6. 浏览器渲染(成本高,吞吐降一个数量级)——解决 JS 挑战和内容混淆
  7. 打码 / 第三方服务(成本最高,有合规风险)——最后手段

💡 反向思路

在投入第 4 步之前,先回头确认一次:目标数据是否有公开 API、开放数据集、RSS 或合作接口? 相当比例的爬虫工程量,本可以用一次数据申请替代。


📚 模块与官方文档

请求与代理

模块安装官方文档
httpxpip install "httpx[socks]"python-httpx.org · 代理配置
requestspip install "requests[socks]"requests.readthedocs.io · 代理配置
curl_cffipip install curl_cfficurl-cffi.readthedocs.io
playwrightpip install playwrightplaywright.dev/python
redis-pypip install redisredis.readthedocs.io · ZSET 命令

验证码识别

模块安装官方文档
ddddocrpip install ddddocrGitHub
opencv-pythonpip install opencv-pythondocs.opencv.org · 模板匹配
Pillowpip install Pillowpillow.readthedocs.io
numpypip install numpynumpy.org/doc
pytesseractpip install pytesseractGitHub
tesserocrpip install tesserocrGitHub
Tesseract 引擎系统包tesseract-ocr.github.io

内容混淆还原

模块安装官方文档
fontToolspip install fonttoolsfonttools.readthedocs.io · TTFont API
cssutilspip install cssutilscssutils.readthedocs.io
tinycss2pip install tinycss2tinycss2.readthedocs.io

规范与合规

资源说明
MDN - HTTP 请求头各请求头语义与取值规范
MDN - Client Hintssec-ch-ua 系列头的定义
RFC 9309 - robots.txt爬虫排除协议正式标准
RFC 6265 - HTTP CookieCookie 机制规范
RFC 7519 - JWTJWT 结构与校验规则
JA3 / JA4 指纹TLS 指纹算法说明
个人信息保护法数据合规的核心依据

← 返回 Python 深度研究