Appearance
章节 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 代码,还原数据加密/签名逻辑的技术。爬虫逆向工程师需要能快速识别和还原常见加密算法。
| 算法 | 特征 | 常用场景 |
|---|---|---|
| MD5 | 32/16 位十六进制,不可逆 | 密码哈希、签名 |
| SHA1 | 40 位十六进制 | 签名、证书 |
| SHA256 | 64 位十六进制 | JWT、区块链 |
| AES | 需要 Key + IV,可逆 | 数据加密传输 |
| RSA | 非对称,有公钥/私钥,PKCS#1 填充 | 登录密码加密、支付 |
| Base64 | 末尾常有 = 填充,A-Za-z0-9+/ | 编码传输 |
| HMAC | 带密钥的哈希,需要 Key | API 签名 |
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 代码定位技巧
定义:在浏览器中定位加密函数是逆向的关键步骤,以下是最常用的方法:
| 方法 | 操作 | 使用场景 |
|---|---|---|
| 搜索关键字 | 全局搜索 encrypt、md5、sign | 快速定位加密入口 |
| 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 路由拦截替换 debuggerPlaywright 注入绕过:
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 pyexecjspython
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 result5.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")小结
- 代理池:Redis Sorted Set 是代理池的最佳存储方案,分数机制实现自动淘汰劣质代理
- 验证码对抗:ddddocr 可以简单识别文字验证码,滑块验证码需要缺口检测 + 轨迹模拟
- JS 逆向:核心是识别加密算法 → 定位加密函数 → 还原 Python 实现或使用 PyExecJS
- 反调试绕过:无限 Debugger 可通过禁用断点或注入脚本覆盖 setTimeout 绕过
- 字体反爬:通过解析 woff2 字体文件的 cmap 表建立乱码→真实字符的映射
练习
- 代理池实现:使用 Redis 实现一个完整的代理池系统,包括添加、校验、获取、降分四个功能函数。
- 滑块轨迹模拟:编写轨迹生成函数,对比三段式轨迹(加速→匀速→减速)和真人轨迹的差异。
- AES 解密:已知某网站的加密参数使用 AES-CBC 加密,密钥为
0123456789abcdef,IV 为abcdef0123456789,密文为kL7mN8oP9qR0sT1uV2wX3yZ4==,用 Python 还原。 - JS 混淆分析:找到一个使用了 JS 混淆的网站(如某加速乐保护的网站),使用 DevTools 定位其加密函数入口,分析加密参数生成方式。
- 字体反爬实验:找一个使用字体反爬的网站(如猫眼电影票房),下载字体文件,用 fontTools 建立字符映射表,还原真实数据。