Skip to content

章节 5:反爬对抗与逆向工程

学习目标

  • 理解代理 IP 的分类与代理池架构设计
  • 掌握验证码识别与滑块对抗技术
  • 熟悉 JS 逆向中常见加密算法的还原方法
  • 能够突破 JS 混淆与无限 Debugger 反调试
  • 了解字体反爬与 Canvas 指纹检测的原理

5.1 代理 IP 与代理池架构

5.1.1 代理 IP 分类

定义:代理 IP 是介于客户端和目标服务器之间的中间服务器,爬虫通过代理 IP 访问目标以隐藏真实 IP 或绕过 IP 限制。

类型匿名度速度稳定性获取方式
透明代理❌ 暴露真实 IP免费代理网站
匿名代理✅ 隐藏真实 IP付费/免费均可
高匿代理✅✅ 完全伪装多为付费
隧道代理✅✅ 动态切换付费 API 调用

常见代理协议

python
# HTTP 代理(明文)
proxies = {"http": "http://127.0.0.1:8080", "https": "http://127.0.0.1:8080"}

# HTTPS 代理(支持 CONNECT 隧道)
proxies = {"https": "https://proxy.example.com:443"}

# SOCKS5 代理(支持 TCP/UDP,更通用)
# pip install requests[socks]
proxies = {"http": "socks5://127.0.0.1:1080", "https": "socks5://127.0.0.1:1080"}

5.1.2 代理池架构设计

定义:代理池是一个自动采集、校验、存储和分发代理 IP 的系统,保证爬虫随时有可用代理。

                     ┌──────────────────────┐
                     │   代理源(免费/付费)    │
                     └──────────┬───────────┘

                     ┌──────────────────────┐
                     │   采集模块(定时抓取)  │
                     └──────────┬───────────┘

                     ┌──────────────────────┐
                     │   校验模块(可用性检测) │
                     │   ┌──────────────┐    │
                     │   │ 有效性检查     │    │
                     │   │ 匿名度检查     │    │
                     │   │ 响应速度检查   │    │
                     │   └──────────────┘    │
                     └──────────┬───────────┘

                     ┌──────────────────────┐
                     │   Redis 存储(Sorted Set)│
                     │   Score = 速度/成功率    │
                     └──────────┬───────────┘

                     ┌──────────────────────┐
                     │   API 接口(获取代理)   │
                     │   爬虫 → 随机/最优代理  │
                     └──────────────────────┘

5.1.3 Redis 代理池实现

python
import redis
import requests
import time
import threading
import random


class ProxyPool:
    """基于 Redis 的代理池"""

    def __init__(self, redis_url="redis://localhost:6379/0"):
        self.redis = redis.from_url(redis_url)
        self.key = "proxy_pool"

    def add_proxy(self, proxy, score=10):
        """添加代理,score 初始值 10(满分)"""
        self.redis.zadd(self.key, {proxy: score})

    def get_proxy(self):
        """获取可用代理(随机取分数最高的前 20 个)"""
        proxies = self.redis.zrevrange(self.key, 0, 19)
        if not proxies:
            return None
        return random.choice(proxies).decode()

    def remove_proxy(self, proxy):
        """移除无效代理"""
        self.redis.zrem(self.key, proxy)

    def decrease_score(self, proxy):
        """代理失败时降低分数"""
        score = self.redis.zscore(self.key, proxy)
        if score and score > 0:
            self.redis.zincrby(self.key, -1, proxy)
            if score <= 1:
                self.remove_proxy(proxy)

    def count(self):
        """代理总数"""
        return self.redis.zcard(self.key)


class ProxyChecker:
    """代理校验器"""

    TEST_URL = "https://httpbin.org/ip"
    TIMEOUT = 10

    def __init__(self, pool):
        self.pool = pool

    def check_proxy(self, proxy):
        """检测单个代理是否可用"""
        try:
            start = time.time()
            resp = requests.get(
                self.TEST_URL,
                proxies={"http": proxy, "https": proxy},
                timeout=self.TIMEOUT,
            )
            elapsed = time.time() - start
            if resp.status_code == 200:
                # 速度越快分数越高(10 ~ 5 分)
                score = max(5, 10 - int(elapsed))
                self.pool.add_proxy(proxy, score)
                return True
        except:
            pass
        self.pool.decrease_score(proxy)
        return False

    def batch_check(self, proxies):
        """批量校验"""
        for proxy in proxies:
            self.check_proxy(proxy)
            time.sleep(0.5)


# 使用示例
pool = ProxyPool()
checker = ProxyChecker(pool)

# 添加代理源
sources = [
    "http://proxy1.example.com:8080",
    "http://proxy2.example.com:8080",
]
for p in sources:
    checker.check_proxy(p)

# 爬虫中使用代理
proxy = pool.get_proxy()
print(f"使用代理: {proxy}")
resp = requests.get(
    "https://httpbin.org/ip",
    proxies={"http": proxy, "https": proxy},
    timeout=10,
)
print(resp.json())

5.2 验证码识别与滑块对抗

5.2.1 验证码分类

类型识别难度常用对抗方式
数字/字母OCR(Tesseract、ddddocr)
中文点选目标检测模型
滑块拼图中高缺口检测 + 轨迹模拟
旋转验证角度检测 + OpenCV
行为验证无头浏览器指纹 + 真人行为模拟
极验/无感极高第三方打码平台

5.2.2 ddddocr——识别数字/字母验证码

python
import ddddocr

# 初始化 OCR
ocr = ddddocr.DdddOcr()

# 识别图片验证码
with open("captcha.png", "rb") as f:
    image_bytes = f.read()

result = ocr.classification(image_bytes)
print(f"验证码识别结果: {result}")

# 带旧版 API(针对部分老验证码)
ocr = ddddocr.DdddOcr(old=True)

5.2.3 滑块验证码——缺口定位

定义:滑块验证码的核心是找到缺口位置(offset),然后模拟人类拖动的轨迹。

python
import cv2
import numpy as np


def detect_gap(bg_path, slider_path):
    """
    识别滑块缺口位置
    :param bg_path: 带缺口的背景图路径
    :param slider_path: 滑块小图路径
    :return: 缺口左上角 x 坐标
    """
    # 读取图片
    bg = cv2.imread(bg_path, cv2.IMREAD_GRAYSCALE)
    slider = cv2.imread(slider_path, cv2.IMREAD_GRAYSCALE)

    # 模板匹配
    result = cv2.matchTemplate(bg, slider, cv2.TM_CCOEFF_NORMED)

    # 找到最佳匹配位置
    min_val, max_val, min_loc, max_loc = cv2.minMaxLoc(result)

    # 缺口左上角坐标
    x, y = max_loc
    return x


# 示例
gap_x = detect_gap("captcha_bg.png", "captcha_slider.png")
print(f"缺口位置: x={gap_x}")

5.2.4 滑块的轨迹模拟

定义:简单的线性拖动会被反爬检测,需要模拟人类拖动的加速度曲线(先快后慢 + 微调)。

python
import random
import time


def generate_track(distance):
    """
    生成模拟人类拖动的轨迹
    :param distance: 需要拖动的总距离(像素)
    :return: [(x偏移, y偏移, 时间间隔), ...]
    """
    track = []
    current = 0
    mid = distance * 3 / 4  # 减速点

    # 分段模拟:先加速后减速
    while current < distance:
        if current < mid:
            # 加速阶段——大步前进
            move = random.randint(3, 8)
        else:
            # 减速阶段——小步微调
            move = random.randint(1, 3)

        # 加入随机 Y 轴偏移(模拟手抖)
        y_offset = random.randint(-2, 2)
        # 加入随机延迟(100-300ms)
        delay = random.uniform(0.1, 0.3)

        current += move
        if current > distance:
            current = distance
        track.append((move, y_offset, delay))

    # 尾部微调(超过一点再拉回,模拟真人)
    if current > distance:
        track.append((-2, 0, 0.05))
        track.append((1, 0, 0.05))

    return track


def simulate_drag(page, slider_selector, distance):
    """
    模拟滑块拖动
    :param page: Playwright page 对象
    :param slider_selector: 滑块 CSS 选择器
    :param distance: 拖动距离
    """
    slider = page.locator(slider_selector)
    box = slider.bounding_box()

    # 起始位置:滑块中心
    start_x = box["x"] + box["width"] / 2
    start_y = box["y"] + box["height"] / 2

    track = generate_track(distance)

    # 按轨迹移动
    for x_offset, y_offset, delay in track:
        page.mouse.move(start_x + x_offset, start_y + y_offset)
        time.sleep(delay)

    # 释放鼠标
    page.mouse.up()

5.3 JS 逆向与加密还原

5.3.1 常见加密算法识别

定义:JS 逆向是通过分析前端 JavaScript 代码,还原数据加密/签名逻辑的技术。爬虫逆向工程师需要能快速识别和还原常见加密算法。

算法特征常用场景
MD532/16 位十六进制,不可逆密码哈希、签名
SHA140 位十六进制签名、证书
SHA25664 位十六进制JWT、区块链
AES需要 Key + IV,可逆数据加密传输
RSA非对称,有公钥/私钥,PKCS#1 填充登录密码加密、支付
Base64末尾常有 = 填充,A-Za-z0-9+/编码传输
HMAC带密钥的哈希,需要 KeyAPI 签名

5.3.2 Python 还原加密

MD5 还原

python
# JS: const hash = md5(password + salt)
import hashlib

password = "123456"
salt = "abc"
raw = password + salt
hash_value = hashlib.md5(raw.encode()).hexdigest()
print(hash_value)  # 32 位十六进制

SHA256 还原

python
import hashlib

data = "user=admin&time=1700000000"
hash_value = hashlib.sha256(data.encode()).hexdigest()
print(hash_value)  # 64 位十六进制

# HMAC-SHA256
import hmac
key = b"secret_key"
message = b"hello"
hmac_value = hmac.new(key, message, hashlib.sha256).hexdigest()
print(hmac_value)

AES 解密还原

python
# JS: CryptoJS.AES.encrypt(data, key, {iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7})
from Crypto.Cipher import AES
from Crypto.Util.Padding import unpad
import base64

def aes_decrypt_cbc(encrypted_data, key, iv):
    """
    AES-CBC 解密
    :param encrypted_data: Base64 编码的密文
    :param key: 密钥(16/24/32 字节)
    :param iv: 初始向量(16 字节)
    """
    cipher = AES.new(key.encode("utf-8"), AES.MODE_CBC, iv.encode("utf-8"))
    decrypted = cipher.decrypt(base64.b64decode(encrypted_data))
    return unpad(decrypted, AES.block_size).decode("utf-8")

# 示例
data = "uY3D6EwB1qC2rF3gH4iJ5kL6mN7oP8qR=="
key = "16byteskey1234567"
iv = "16bytesiv12345678"
plain = aes_decrypt_cbc(data, key, iv)
print(plain)

RSA 加密还原

python
# JS: new JSEncrypt().setPublicKey(publicKey).encrypt(password)
import base64
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5


def rsa_encrypt(data, public_key_str):
    """RSA 公钥加密"""
    public_key = RSA.import_key(public_key_str)
    cipher = PKCS1_v1_5.new(public_key)
    encrypted = cipher.encrypt(data.encode("utf-8"))
    return base64.b64encode(encrypted).decode("utf-8")


# 示例公钥(PKCS#8 格式)
pub_key = """-----BEGIN PUBLIC KEY-----
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQC...
-----END PUBLIC KEY-----"""

encrypted_pwd = rsa_encrypt("123456", pub_key)
print(f"加密后: {encrypted_pwd}")

5.3.3 JS 代码定位技巧

定义:在浏览器中定位加密函数是逆向的关键步骤,以下是最常用的方法:

方法操作使用场景
搜索关键字全局搜索 encryptmd5sign快速定位加密入口
XHR 断点Sources → XHR Breakpoints → URL 模式拦截 API 请求
调用栈回溯在 XHR 断点处查看 Call Stack追踪加密调用链
DOM 事件断点元素上右键 → Break on → attribute modifications定位动态属性
替换脚本(Overrides)Sources → Overrides → 本地替换修改/调试加密代码

步骤示例

1. 打开 Chrome DevTools → Network 面板
2. 找到目标 API 请求,右键 → Copy as cURL
3. 分析请求参数中的加密字段(如 sign、token)
4. 在 Sources 面板搜索加密字段名
5. 找到加密函数,在函数入口处断点
6. 刷新页面,查看断点处的变量值
7. 分析加密逻辑,用 Python 还原

5.4 JS 混淆与反调试

5.4.1 常见的 JS 混淆技术

定义:JS 混淆通过变量名替换、代码扁平化、字符串加密等方式增加代码可读性难度。

javascript
// 原始代码
function getSign(data) {
    return md5(data + "secret_key");
}

// 混淆后(变量名替换 + 字符串切割)
function _0x3f2a(_0x1a5b, _0x2c8d) {
    return _0x4e7f['md5'](_0x1a5b + _0x2c8d['concat']('secret_key'));
}

常见混淆类型

混淆类型特征还原方法
变量名替换_0x1234$a$b 等无意义命名搜索关键字符串定位
字符串编码\x68\x65\x6c\x6c\x6f(十六进制)DevTools Console 直接解码
控制流平坦化switch-case 循环结构逻辑分析,定位关键路径
自执行函数(function(){...})() 包裹提取内部逻辑
死代码注入大量无意义的赋值和分支提取关键代码片段

5.4.2 绕过无限 Debugger

定义:无限 Debugger 是反爬虫常用的反调试技术,通过不断创建 debugger 语句阻止开发者调试代码。

javascript
// 无限 Debugger 示例
(function() {
    function banDebug() {
        debugger;
        setTimeout(banDebug, 100);
    }
    banDebug();
})();

绕过方法

方法一:禁用断点(DevTools 中 Deactivate breakpoints 按钮)
方法二:编辑代码(Sources → 找到该代码 → 右键 Never pause here)
方法三:在 Console 中执行:
    setTimeout = function() {};
    setInterval = function() {};
方法四:使用 Fiddler 或 Playwright 注入脚本替换
    // 通过 Playwright 路由拦截替换 debugger

Playwright 注入绕过

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=False)
    page = browser.new_page()

    # 注入脚本禁用 debugger
    page.add_init_script("""
        // 覆盖 Function.prototype.toString 隐藏注入
        const originalDebugger = window.Function.prototype.apply;
        window.Function.prototype.apply = function() {};

        // 禁用 setTimeout/setInterval 中的 debugger
        const originalSetTimeout = window.setTimeout;
        window.setTimeout = function(fn, delay, ...args) {
            if (fn.toString().includes('debugger')) {
                return;
            }
            return originalSetTimeout.call(window, fn, 0, ...args);
        };
    """)

    page.goto("https://example.com")
    # 继续正常操作

5.4.3 使用 PyExecJS 执行 JS

定义:对于无法用 Python 完全还原的加密逻辑,可以直接通过 PyExecJS 在 Python 中调用 JS 代码。

bash
pip install pyexecjs
python
import execjs

# 方式一:执行 JS 表达式
js_code = """
function getSign(data) {
    return md5(data + "secret_key");
}
function md5(str) {
    // ... 完整的 md5 实现 ...
    return str;
}
"""

# 编译 JS
ctx = execjs.compile(js_code)

# 调用 JS 函数
sign = ctx.call("getSign", "page=1&size=20")
print(sign)

# 方式二:从文件加载
with open("encrypt.js", "r", encoding="utf-8") as f:
    js_code = f.read()
ctx = execjs.compile(js_code)
result = ctx.call("encrypt", "hello")

5.5 字体反爬与 Canvas 逆向

5.5.1 字体反爬

定义:字体反爬是将页面中的关键数字/文字用自定义字体(@font-face)渲染,使得 HTML 中的文本与实际显示内容不对应,普通爬虫直接提取 HTML 文本会得到乱码。

html
<!-- 字体反爬示例:HTML 中显示的是乱码,但浏览器渲染为正常数字 -->
<style>
@font-face {
    font-family: 'myfont';
    src: url('custom.woff2') format('woff2');
}
.price { font-family: 'myfont'; }
</style>

<span class="price">һӶӶӶӹ</span>  <!-- 实际显示为 ¥1299.00 -->

破解方法

python
from fontTools.ttLib import TTFont
import re
import requests


def crack_font_anti_spider(font_url, font_mapping_cache=None):
    """
    破解字体反爬——建立字符映射关系
    """
    # 下载字体文件
    resp = requests.get(font_url)
    with open("temp.woff2", "wb") as f:
        f.write(resp.content)

    # 解析字体
    font = TTFont("temp.woff2")

    # 获取 cmap 表(字符映射)
    cmap = font.getBestCmap()

    # 建立映射:字体中的 Unicode → 真实数字
    # 需要与页面中的乱码对照分析
    mapping = {}
    for code, glyph_name in cmap.items():
        char_code = chr(code)
        # 通过 glyph 的顺序推断真实数字
        # 通常字体文件中的 glyph 顺序对应 0-9
        real_char = str(list(cmap.keys()).index(code) % 10)
        mapping[char_code] = real_char

    return mapping


def decode_font_text(text, mapping):
    """使用映射表还原真实文本"""
    result = ""
    for char in text:
        if char in mapping:
            result += mapping[char]
        else:
            result += char
    return result

5.5.2 Canvas 指纹检测

定义:Canvas 指纹是通过让浏览器绘制特定图形,然后提取图像的像素数据来生成唯一标识的技术。反爬系统可以通过 Canvas 指纹检测是否在无头浏览器中运行。

检测原理

javascript
// Canvas 指纹检测
function getCanvasFingerprint() {
    const canvas = document.createElement('canvas');
    canvas.width = 200;
    canvas.height = 50;
    const ctx = canvas.getContext('2d');
    ctx.textBaseline = 'top';
    ctx.font = '14px Arial';
    ctx.fillStyle = '#f60';
    ctx.fillRect(125, 1, 62, 20);
    ctx.fillStyle = '#069';
    ctx.fillText('Cwm fjordbank glyphs vext quiz, 😃', 2, 15);
    return canvas.toDataURL();  // 返回 base64 图片数据
}

Playwright 中绕过

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(headless=True)
    page = browser.new_page()

    # 注入脚本覆盖 Canvas.toDataURL
    page.add_init_script("""
        // 固定 Canvas 指纹
        const originalToDataURL = HTMLCanvasElement.prototype.toDataURL;
        HTMLCanvasElement.prototype.toDataURL = function(type) {
            if (type === 'image/png') {
                // 返回固定的指纹数据
                return 'data:image/png;base64,iVBORw0KGgoAAAAN...';
            }
            return originalToDataURL.call(this, type);
        };

        // 覆盖 getImageData
        const originalGetImageData = CanvasRenderingContext2D.prototype.getImageData;
        CanvasRenderingContext2D.prototype.getImageData = function() {
            // 返回固定的像素数据
            return {data: new Uint8ClampedArray(400), width: 10, height: 10};
        };
    """)

    page.goto("https://example.com")

小结

  1. 代理池:Redis Sorted Set 是代理池的最佳存储方案,分数机制实现自动淘汰劣质代理
  2. 验证码对抗:ddddocr 可以简单识别文字验证码,滑块验证码需要缺口检测 + 轨迹模拟
  3. JS 逆向:核心是识别加密算法 → 定位加密函数 → 还原 Python 实现或使用 PyExecJS
  4. 反调试绕过:无限 Debugger 可通过禁用断点或注入脚本覆盖 setTimeout 绕过
  5. 字体反爬:通过解析 woff2 字体文件的 cmap 表建立乱码→真实字符的映射

练习

  1. 代理池实现:使用 Redis 实现一个完整的代理池系统,包括添加、校验、获取、降分四个功能函数。
  2. 滑块轨迹模拟:编写轨迹生成函数,对比三段式轨迹(加速→匀速→减速)和真人轨迹的差异。
  3. AES 解密:已知某网站的加密参数使用 AES-CBC 加密,密钥为 0123456789abcdef,IV 为 abcdef0123456789,密文为 kL7mN8oP9qR0sT1uV2wX3yZ4==,用 Python 还原。
  4. JS 混淆分析:找到一个使用了 JS 混淆的网站(如某加速乐保护的网站),使用 DevTools 定位其加密函数入口,分析加密参数生成方式。
  5. 字体反爬实验:找一个使用字体反爬的网站(如猫眼电影票房),下载字体文件,用 fontTools 建立字符映射表,还原真实数据。

Python 学习资料