{T}

反爬对抗策略

反爬对抗是爬虫工程师的核心战场。本章聚焦三大支柱:请求头伪装IP代理体系验证码识别,逐一拆解网站常见反爬手段的应对方法,帮助你从被动填坑转为主动防御。

1. 反爬机制全景

在进入具体方案之前,先用一张 mindmap 俯瞰主流反爬手段的完整格局。

图表渲染中…

网站通常不会只用一种手段,而是把上述几种叠加成"连环套"——例如某网站先用 IP 频率限制拦住大批量请求,扛不住时才弹出验证码,而验证码本身又嵌在 JS 动态渲染的表单里。因此,反爬对抗必须组合出招,单点突破很难走到最后。

2. 请求头伪装

最简单的反爬手段就是读取 HTTP 请求头,如果 User-Agent 是 python-requests/2.x,或者 Referer 为空,服务器直接扔 403。请求头伪装是最低成本的"隐身术"。

2.1 User-Agent 轮换

维护一个 UA 池,每次请求随机选取一个。现代浏览器 UA 数以百计,维护 50+ 条即可覆盖绝大多数场景。

python
import random

UA_POOL = [
    # Chrome 125 on macOS
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    # Chrome 125 on Windows
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36",
    # Firefox 127 on macOS
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:127.0) Gecko/20100101 Firefox/127.0",
    # Edge 125 on Windows
    "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/125.0.0.0 Safari/537.36 Edg/125.0.0.0",
    # Safari 17 on macOS
    "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Safari/605.1.15",
]

# 配合 requests Session 使用
import requests

session = requests.Session()
session.headers.update({
    "User-Agent": random.choice(UA_POOL),
    "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8",
    "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
    "Accept-Encoding": "gzip, deflate, br",
    "DNT": "1",
    "Upgrade-Insecure-Requests": "1",
})
关键提醒

不要只换 UA。一个真实的浏览器请求会携带 10+ 个 Header,如果只换了 UA 却忘了 AcceptAccept-Language 等字段,行为指纹还是暴露了"你不是浏览器"。

2.2 Referer 策略

Referer 告诉服务器"你从哪个页面跳过来的"。很多反爬系统校验 Referer 是否来自本站合法的上一页。

python
# 场景:从列表页翻到第2页
session.headers["Referer"] = "https://example.com/list?page=1"

# 场景:API请求需要来自合法来源
session.headers["Referer"] = "https://example.com/"
注意

某些浏览器隐私策略(如 Referrer-Policy)会将 Referer 截断为仅有域名部分(origin)。如果目标站点要求完整路径,需要调整策略。

Cookie 是重要的会话凭证。未登录、Cookie 过期都会触发反爬。

python
# 直接设置 Cookie 字符串
session.headers["Cookie"] = "session_id=abc123; user_token=xyz789; ..."

# 更推荐的方式:使用 Session + 登录流程自动管理
session.post("https://example.com/login", data={"user": "xxx", "pass": "xxx"})
# 之后 Session 会自动维护 Cookie,无需手动管理
Cookie 属性作用反爬要点
session_id / JSESSIONID会话标识必须保持一致性,不同会话不能共用
token / csrf_token防跨站请求伪造通常嵌入在页面 HTML 中,需先请求页面提取
_uuid / _device_id设备指纹固定值模拟同一设备,频繁切换可能触发风控
Hm_lvt_* / Hm_lpvt_*百度统计 Cookie部分网站不校验,但保持规范更有益

3. IP 代理体系

如果说请求头伪装是换张脸,那 IP 代理就是换个身份。当单 IP 请求频率超标(通常表现为 429 Too Many Requests 或 403 Forbidden),代理是继续采集的唯一出路。

3.1 代理类型速览

code
          ┌──────────────────┐
          │   代理获取方式     │
          └────────┬─────────┘
     ┌─────────────┼─────────────┐
     ▼             ▼             ▼
  免费代理      付费API型      隧道/ADSL型
 (可用率1-10%)  (可用率60-90%)  (可用率95%+)
代理类型成本可用率速度IP独占维护成本适用场景
免费公共代理免费1%~10%慢(1~10s)共享极高学习测试
付费 API 提取型几十元/天60%~90%中(0.5~3s)部分共享中等规模爬取
付费隧道型几百元/月95%+快(0.1~1s)共享大规模爬取
ADSL 拨号代理几十元/月+服务器99%+快(0.1~0.5s)完全独占高要求长期项目

3.2 代理设置速查

各请求库的代理设置方式:

python
# === requests ===
import requests
proxies = {"http": "http://127.0.0.1:8888", "https": "https://127.0.0.1:8888"}
resp = requests.get("http://httpbin.org/get", proxies=proxies, timeout=10)

# === urllib ===
from urllib.request import ProxyHandler, build_opener
handler = ProxyHandler({"http": "http://127.0.0.1:8888", "https": "https://127.0.0.1:8888"})
opener = build_opener(handler)
resp = opener.open("http://httpbin.org/get")

# === aiohttp (异步) ===
import aiohttp
async with aiohttp.ClientSession() as session:
    async with session.get("http://httpbin.org/get", proxy="http://127.0.0.1:8888") as resp:
        text = await resp.text()

# === Selenium / Chrome ===
from selenium import webdriver
options = webdriver.ChromeOptions()
options.add_argument("--proxy-server=http://127.0.0.1:8888")
browser = webdriver.Chrome(options=options)
常见错误

忘记给代理加协议前缀http://socks5://)是新手最高频的错误。requests 的 proxies 参数值必须是 http://ip:port 格式,不能直接传 ip:port。否则会报 ProxyError

3.3 代理池架构

代理池是一个全自动的代理 IP 管理系统:持续从多个来源获取代理、定时检测可用性、通过 API 接口对外提供可用代理。

图表渲染中…

分数机制设计

事件分数操作原因
新代理入库设为 10初始分数低,需验证
检测可用设为 100(直接满分)确保可用代理始终能被取到
检测不可用减 1给予 100 次失败机会,避免偶发失败误删
分数降至 0从池中移除连续 100 次失败才淘汰
为什么"可用即设100"而不是"加1"?

因为免费代理极不稳定,可能 5 次请求 2 次成功 3 次失败。如果每次成功只加 1 分,好代理几乎不可能积到高分,即便可用也无法被取到。"可用即满分"策略确保只要代理过检测就立即可用。

爬虫侧获取代理

python
import requests

PROXY_POOL_URL = "http://127.0.0.1:5555/random"

def get_proxy() -> str | None:
    """从代理池获取随机可用代理"""
    try:
        resp = requests.get(PROXY_POOL_URL, timeout=5)
        if resp.status_code == 200:
            return resp.text.strip()
    except requests.exceptions.RequestException:
        pass
    return None

# 使用示例
proxy = get_proxy()
if proxy:
    proxies = {"http": f"http://{proxy}", "https": f"https://{proxy}"}
    resp = requests.get("https://example.com", proxies=proxies, timeout=15)

3.4 免费代理 vs 付费代理 vs ADSL

维度免费代理池付费 API 型付费隧道型ADSL 拨号
可用率1%~10%60%~90%95%+99%+
IP 独占性共享部分共享共享完全独占
单 IP 被封概率极高极低
技术门槛极低(固定地址)高(需搭建运维)
推荐场景学习/测试中等规模大规模高要求长期项目

付费隧道型代理(如阿布云动态版)是最省心的方案:整个爬虫只需要配置一个固定的代理入口地址,后端自动为每个请求分配不同的出口 IP。无需代理池、无需手动轮换。

python
# 阿布云代理隧道示例 —— 固定地址,自动换IP
proxies = {
    "http": "http://USER:PASS@proxy.abuyun.com:9020",
    "https": "https://USER:PASS@proxy.abuyun.com:9020",
}
# 每次请求自动使用不同的出口IP
resp = requests.get("https://example.com", proxies=proxies)

ADSL 拨号代理是终极方案:购买一台 ADSL 拨号 VPS,每次 adsl-stop; adsl-start 就换一个公网 IP。装上 TinyProxy 作为 HTTP 代理,再用脚本定时拨号并把新 IP 更新到 Redis。

ADSL 拨号的关键设计
  1. 拨号前必须先从 Redis 移除旧代理,否则拨号间隙旧 IP 被取用会导致请求失败。
  2. 至少配置 2 台主机互为备份,一台拨号时另一台继续提供代理。
  3. 代理验证 URL 应设置为目标站点的地址,而非通用测试站点(如百度),以筛掉被目标站点封禁的 IP。

3.5 代理使用检查清单

  • 代理验证通过了吗?(请求 httpbin.org/get,看 origin 字段是否等于代理 IP)
  • 设置超时了吗?(timeout=10~30,代理慢是常态)
  • HTTP 和 HTTPS 代理都配置了吗?
  • 认证代理格式正确吗?(http://user:pass@ip:port
  • Selenium 的代理认证是否用了 Chrome 扩展插件?(Chrome 不支持命令行传认证信息)
  • 代理失败时是否自动切换下一个代理?

4. 验证码识别

验证码(CAPTCHA)是反爬的最后一道防线。当 IP 轮换和请求头伪装都没有瞒过风控系统时,验证码就会出现。以下是完整处理流程:

图表渲染中…

4.1 图形验证码:OCR 为主,预处理为辅

图形验证码是最经典的类型,由 4~6 位字母或数字组成,叠加干扰线、噪点和扭曲。

直接 OCR(低干扰场景):

python
import tesserocr
from PIL import Image

image = Image.open("captcha.jpg")
result = tesserocr.image_to_text(image)  # 或 .file_to_text("captcha.jpg")
print(result)  # 输出: JR42

预处理 + OCR(有干扰线时):

python
import tesserocr
from PIL import Image

def recognize_captcha(image_path: str, threshold: int = 127) -> str:
    """图形验证码识别(灰度化 + 二值化 + OCR)"""
    image = Image.open(image_path)

    # 步骤1: 灰度化
    image = image.convert("L")

    # 步骤2: 自定义阈值二值化
    table = [0 if i < threshold else 1 for i in range(256)]
    image = image.point(table, "1")

    # 步骤3: OCR 识别
    result = tesserocr.image_to_text(image)
    return result.strip()

result = recognize_captcha("code2.jpg", threshold=80)
print(result)  # 输出: PFRT

预处理三步曲

步骤操作目的
灰度化image.convert("L")去除颜色干扰,RGB 三通道压缩为单通道
二值化image.point(table, "1")将像素转为只有黑(0)白(255)两色
降噪中值滤波 / 形态学操作去除孤立噪点

设置白名单(提升识别精度):

python
result = tesserocr.image_to_text(
    image,
    config="-c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
)

4.2 滑动验证码:像素对比 + 轨迹模拟

以极验(GeeTest)为典型代表。核心挑战有两个:找到缺口位置生成拟人化拖动轨迹

缺口识别:像素对比法

python
def get_gap(image1: Image.Image, image2: Image.Image) -> int:
    """
    对比两张图(无缺口 vs 有缺口),找到缺口左边界
    :param image1: 不带缺口的原图
    :param image2: 带缺口的图片
    :return: 缺口左边界 x 坐标
    """
    threshold = 60  # RGB 差异阈值
    left = 60       # 从滑块右侧开始检测

    for i in range(left, image1.size[0]):
        for j in range(image1.size[1]):
            pixel1 = image1.load()[i, j]
            pixel2 = image2.load()[i, j]
            if (abs(pixel1[0] - pixel2[0]) > threshold or
                abs(pixel1[1] - pixel2[1]) > threshold or
                abs(pixel1[2] - pixel2[2]) > threshold):
                return i  # 找到第一个差异像素
    return left

轨迹生成:物理运动模型

匀速拖动是最大的坑

极验使用机器学习检测轨迹特征。匀速或简单分段变速的通过率不到 10%。必须使用先加速后减速的物理模型,并加入随机抖动。

python
import random

def get_track(distance: int) -> list[int]:
    """
    生成拟人化移动轨迹(加速-减速模型 + 随机抖动)
    :param distance: 需要移动的总距离(px)
    :return: 每步移动距离的列表
    """
    track = []
    current = 0
    mid = distance * 4 / 5         # 前 80% 加速,后 20% 减速
    t = 0.2                         # 时间间隔
    v = 0                           # 初速度

    while current < distance:
        if current < mid:
            a = 2 + random.uniform(-0.5, 0.5)   # 加速阶段
        else:
            a = -3 + random.uniform(-0.5, 0.5)  # 减速阶段
        v0 = v
        v = v0 + a * t
        move = v0 * t + 0.5 * a * t * t
        move += random.uniform(-1, 1)            # 微小随机抖动
        current += move
        track.append(round(move))
    return track

轨迹策略通过率对比

策略通过率说明
匀速移动<10%最容易被机器学习识别
简单分段变速~30%仍会被识别
物理模型(加速-减速)~70%推荐使用
物理模型 + 随机抖动~85%最佳效果
录制真实轨迹~95%最可靠但需收集真人数据

4.3 点触验证码:打码平台 + 坐标点击

点触验证码(如 12306 图片点选、TouClick 文字点选)要求用户点击图片中的特定目标。这类验证码 OCR 无法直接处理,打码平台是最优解

打码平台对比

平台支持类型价格响应速度准确率
超级鹰图形/点触/坐标中等5~15s90%+
云打码图形/点触较低3~10s85%+
若快图形/点触较低3~8s85%+
2CaptchareCAPTCHA/点触较高10~30s90%+

打码平台 API 封装

python
import requests
from hashlib import md5

class Chaojiying:
    """超级鹰打码平台 API"""

    def __init__(self, username: str, password: str, soft_id: int):
        self.username = username
        self.password = md5(password.encode()).hexdigest()
        self.soft_id = soft_id
        self.base_params = {
            "user": self.username,
            "pass2": self.password,
            "softid": self.soft_id,
        }
        self.headers = {
            "User-Agent": "Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)",
        }

    def post_pic(self, im: bytes, codetype: int) -> dict:
        """
        :param im: 图片字节流
        :param codetype: 验证码类型(9102=坐标多选, 1902=英文数字)
        :return: {"err_no": 0, "pic_str": "132,127|56,77"}
        """
        params = {"codetype": codetype, **self.base_params}
        files = {"userfile": ("ccc.jpg", im)}
        resp = requests.post(
            "http://upload.chaojiying.net/Upload/Processing.php",
            data=params, files=files, headers=self.headers
        )
        return resp.json()

    def report_error(self, im_id: str) -> dict:
        """识别错误时报告,返还题分"""
        return requests.post(
            "http://upload.chaojiying.net/Upload/ReportError.php",
            data={"id": im_id, **self.base_params},
            headers=self.headers,
        ).json()

坐标解析与模拟点击

python
def get_points(captcha_result: dict) -> list[list[int]]:
    """解析 pic_str: '132,127|56,77' -> [[132,127],[56,77]]"""
    return [[int(n) for n in g.split(",")] for g in captcha_result["pic_str"].split("|")]

# 用 Selenium ActionChains 模拟点击
from selenium.webdriver.common.action_chains import ActionChains

for x, y in locations:
    ActionChains(browser) \
        .move_to_element_with_offset(captcha_element, x, y) \
        .click().perform()

4.4 宫格验证码:模板匹配

微博宫格验证码的样式是有限的(最多 24 种排列组合),可以用模板匹配法高效识别。

python
from os import listdir
from PIL import Image

def detect_image(image: Image.Image, templates_folder: str = "templates/") -> list[int] | None:
    """
    模板匹配:遍历所有模板,找到匹配的验证码
    模板文件名如 "4132.png" 表示滑动顺序 4->1->3->2
    """
    for template_name in listdir(templates_folder):
        template = Image.open(templates_folder + template_name)
        # 计算像素相似度
        count = sum(
            1 for x in range(image.width) for y in range(image.height)
            if is_pixel_equal(image, template, x, y, threshold=20)
        )
        similarity = count / (image.width * image.height)
        if similarity > 0.99:
            # 从文件名解析滑动顺序
            return [int(n) for n in template_name.split(".")[0]]
    return None
方法准确率速度适用场景
模板匹配90%~99%1~2s样式有限(≤100种)
深度学习(CNN)85%~95%<1s样式无限或大量变体

4.5 验证码方案选择原则

  1. 先简后繁:OCR 能解决的不上打码平台(省钱)
  2. 模板优先:如果验证码样式有限,模板匹配是最快最准的方案
  3. 打码平台兜底:OCR 和模板都搞不定时,打码平台是最后的保障
  4. 识别错误要报错:使用打码平台时,识别错误一定要调用报错接口返还题分

5. 请求频率控制

即使 IP 和请求头都完美,如果发送请求的频率像机关枪一样均匀密集,也会触发风控。请求频率控制的本质是让机器的请求模式看起来像人类

5.1 随机延迟

python
import time
import random

# 每次请求之间随机等待 1~3 秒
time.sleep(random.uniform(1.0, 3.0))

# 翻页时的延迟应该更长(模拟人类翻页阅读)
time.sleep(random.uniform(3.0, 8.0))

5.2 指数退避

当请求返回 429(Too Many Requests)或 503(Service Unavailable)时,不要立即重试。使用指数退避算法逐步拉长重试间隔:

python
import time
import requests

def request_with_backoff(
    url: str, max_retries: int = 5, base_delay: float = 1.0
) -> requests.Response:
    """带指数退避的请求重试"""
    for attempt in range(max_retries):
        resp = requests.get(url)
        if resp.status_code == 200:
            return resp
        if resp.status_code in (429, 503):
            delay = base_delay * (2 ** attempt)  # 1, 2, 4, 8, 16 秒
            print(f"收到 {resp.status_code},{delay:.0f}秒后重试...")
            time.sleep(delay)
        else:
            resp.raise_for_status()
    raise RuntimeError(f"重试 {max_retries} 次后仍未成功")

5.3 白天/夜间差异化策略

python
import time
import random
from datetime import datetime

def get_sleep_interval() -> float:
    """根据当前时间返回不同的睡眠间隔"""
    hour = datetime.now().hour
    if 1 <= hour < 7:       # 凌晨:低风控,可以快一些
        return random.uniform(0.5, 1.5)
    elif 9 <= hour < 12:    # 上午:正常
        return random.uniform(1.0, 3.0)
    elif 14 <= hour < 18:   # 下午:高峰期,放缓
        return random.uniform(3.0, 8.0)
    else:                    # 默认
        return random.uniform(1.5, 4.0)

6. JS 逆向实操

当数据不是直接在 HTML 中,而是通过 JS 动态加载、加密后再提交到 API 时,就需要 JS 逆向。

6.1 JS 逆向的完整流程

图表渲染中…

6.2 技术栈与工具准备

工具安装用途
Chrome DevTools浏览器内置JS 断点调试、搜索加密入口
PyExecJSpip install PyExecJSPython 调用 JS 引擎执行加密函数
Node.js官网下载安装PyExecJS 的推荐 JS 运行环境
js2pypip install js2py纯 Python 实现的 JS 解释器(性能较差)
Playwrightpip install playwright浏览器自动化 + CDP 协议提取加密结果
Babel / AST Explorer在线工具 astexplorer.netJS AST 语法树分析

6.3 实战一:定位并复写简单加密函数

场景:目标网站的 API 请求携带 sign 参数,由 JS 函数生成。

步骤一:抓包定位

在 Chrome DevTools 的 Network 面板,找到目标 XHR 请求,查看其参数:

json
// 请求参数中有一个 sign 字段
{
  "keyword": "Python",
  "page": 1,
  "sign": "a3f5b8c2d1e4..."
}

步骤二:搜索加密入口

在 DevTools 的 Sources 面板(Ctrl+Shift+F 全局搜索),搜索关键词 signtokenencryptmd5shahmac。找到生成 sign 的函数:

javascript
// 网站源码中找到的加密函数
function generateSign(keyword, page, timestamp) {
    var str = keyword + '&' + page + '&' + timestamp;
    return md5(str);  // 使用了 md5 库
}

步骤三:Python 复写

python
import hashlib
import time

def generate_sign(keyword: str, page: int, timestamp: int) -> str:
    """Python 复写 JS 加密函数"""
    str_ = f"{keyword}&{page}&{timestamp}"
    return hashlib.md5(str_.encode('utf-8')).hexdigest()

# 发送请求
timestamp = int(time.time())
sign = generate_sign('Python', 1, timestamp)

response = requests.get(
    'https://api.example.com/search',
    params={
        'keyword': 'Python',
        'page': 1,
        'sign': sign,
        'timestamp': timestamp,
    },
)

6.4 实战二:PyExecJS 调用复杂 JS 加密

场景:加密函数使用了复杂的 JS 库(如 CryptoJS AES 加密),用 Python 复写成本太高。

python
import execjs
import requests

# 步骤1:提取 JS 加密函数源码
# 从网站源码中复制整个加密函数和相关依赖库

js_code = """
// 从网站源码中提取的加密函数(示例)
var CryptoJS = require('crypto-js');  // 或直接内联 CryptoJS 源码

function encryptData(data, key) {
    return CryptoJS.AES.encrypt(
        JSON.stringify(data),
        CryptoJS.enc.Utf8.parse(key),
        {
            mode: CryptoJS.mode.ECB,
            padding: CryptoJS.pad.Pkcs7
        }
    ).toString();
}

function generateSign(params, key) {
    var sortedKeys = Object.keys(params).sort();
    var str = sortedKeys.map(k => k + '=' + params[k]).join('&');
    return CryptoJS.HmacSHA256(str, key).toString();
}
"""

# 步骤2:创建 JS 执行上下文
ctx = execjs.compile(js_code)

# 步骤3:调用 JS 函数获取加密结果
params = {'keyword': 'Python', 'page': 1}
sign = ctx.call('generateSign', params, 'secret_key_123')
print(f"sign: {sign}")

# 步骤4:发送请求
response = requests.get(
    'https://api.example.com/search',
    params={'keyword': 'Python', 'page': 1, 'sign': sign},
)
PyExecJS 环境要求

PyExecJS 需要一个 JS 运行环境。推荐安装 Node.js(性能最好),PyExecJS 会自动检测。如果没有 Node.js,会回退到 PyV8 或 js2py,性能大幅下降。

bash
# 检查 PyExecJS 的运行环境
import execjs
print(execjs.get().name)  # 应输出 'Node.js (V8)'

6.5 实战三:WebDriver 检测与绕过

许多网站通过 navigator.webdriver 属性检测自动化浏览器:

javascript
// 网站的反爬检测代码
if (navigator.webdriver) {
    // 检测到 Selenium/Playwright,拒绝服务
    document.body.innerHTML = '';
}

方案一:undetected-chromedriver(推荐)

python
import undetected_chromedriver as uc

# 自动绕过 webdriver 检测
driver = uc.Chrome()
driver.get('https://example.com')

# navigator.webdriver 会返回 undefined(而非 true)
print(driver.execute_script('return navigator.webdriver'))  # None/undefined

方案二:Playwright + stealth 配置

python
from playwright.sync_api import sync_playwright

with sync_playwright() as p:
    browser = p.chromium.launch(
        headless=True,
        args=[
            '--disable-blink-features=AutomationControlled',
        ],
    )
    context = browser.new_context(
        user_agent='Mozilla/5.0 (Windows NT 10.0; Win64; x64) Chrome/120.0.0.0',
    )
    page = context.new_page()

    # 注入 stealth JS:隐藏 webdriver 标记
    page.add_init_script("""
        Object.defineProperty(navigator, 'webdriver', {
            get: () => undefined
        });
        // 覆盖 chrome 对象
        window.chrome = {
            runtime: {},
            loadTimes: function() {},
            csi: function() {},
            app: {}
        };
        // 覆盖 permissions 查询
        const originalQuery = window.navigator.permissions.query;
        window.navigator.permissions.query = (parameters) =>
            parameters.name === 'notifications'
                ? Promise.resolve({ state: Notification.permission })
                : originalQuery(parameters);
    """)

    page.goto('https://example.com')
    content = page.content()
    browser.close()

6.6 实战四:字体反爬破解

部分网站(如猫眼电影、58同城)使用自定义字体映射来隐藏关键数据(价格、评分等):

图表渲染中…
python
from fontTools.ttLib import TTFont
from io import BytesIO
import requests

# 下载并解析自定义字体
font_url = 'https://example.com/fonts/custom.woff'
font_data = requests.get(font_url).content

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

# cmap 示例:Unicode 编码 → glyph 名称
# {0xE001: 'glyph00000', 0xE002: 'glyph00001', ...}

# 建立映射:glyph名称 → 实际字符
# 需要通过渲染字体图片 + OCR 来确定每个 glyph 对应的数字
glyph_map = {
    'glyph00000': '3',  # OCR 识别出 glyph00000 渲染为数字 3
    'glyph00001': '5',
    'glyph00002': '9',
    # ...
}

# 替换源码中的编码数字
def decode_font_text(html: str, cmap: dict, glyph_map: dict) -> str:
    """将 HTML 中的字体编码替换为真实数字"""
    for unicode_code, glyph_name in cmap.items():
        real_char = glyph_map.get(glyph_name, '?')
        html = html.replace(chr(unicode_code), real_char)
    return html

6.7 JS 逆向方案选择决策

方案难度性能稳定性适用场景
Python 复写加密函数最高加密逻辑简单(MD5、SHA256、Base64)
PyExecJS 执行 JS加密逻辑复杂但可提取为独立函数
Playwright + CDP加密需要浏览器环境(DOM操作、Canvas等)
字体反爬解析数据被自定义字体映射隐藏
核心原则

优先找 API > Python 复写 > PyExecJS > 浏览器自动化。越往后方案越重、越慢,但覆盖面越广。90% 的 JS 渲染页面背后都有现成的 XHR API,直接请求 API 比执行 JS 简单得多。

7. 常见陷阱速查表

序号陷阱现象解决方案
1忘记协议前缀ProxyError: Cannot connect to proxy代理地址必须加 http://
2仅换 UA 不换其他 Header仍然被识别为爬虫模拟完整浏览器 Header 集合
3匀速拖动滑块极验验证失败使用加速-减速物理模型 + 随机抖动
4OCR 二值化阈值不当字符断裂或粘连多试几个阈值(50/80/100/127)
5代理检测 URL 不对代理显示可用但目标站拒绝将检测 URL 设为目标站地址
6未设置请求超时程序长时间卡死不报错所有请求加 timeout=10~30
7302 重定向自动跟随无法判断 IP 是否被封设置 allow_redirects=False
8代理拨号前未移除旧代理拨号间隙爬虫取到过期 IP拨号前先 remove_proxy()
9打码平台 codetype 选错识别结果完全不对确认验证码类型与 codetype 匹配
10高 DPI 屏幕坐标偏移模拟点击位置不准window.devicePixelRatio 缩放坐标
11免费代理直接使用不验证大量请求失败必须先验证后使用
12Cookie 过期未更新只能看首页,翻页就 302重新登录获取新 Cookie

8. 法律与伦理

反爬对抗技术是把双刃剑。以下原则是所有爬虫工程师的基本职业底线:

8.1 robots.txt

任何网站部署了反爬机制,说明它不希望被爬取。在动手之前,先访问该网站的 /robots.txt

code
User-agent: *
Disallow: /admin/
Disallow: /api/private/
Crawl-delay: 10
  • Disallow 列出的路径是明确禁止爬取的,无论技术是否可行,都应遵守。
  • Crawl-delay: 10 表示每次请求之间至少间隔 10 秒。
  • 虽然 robots.txt 协议本身没有法律强制力,但它体现了网站的意愿。

8.2 爬取频率

  • 不要打垮对方服务器:控制并发数(建议 ≤5),单个域名不要超过正常用户能产生的请求频率。
  • 尽量在低峰期爬取:避开网站的业务高峰时段。
  • 设置合理的 User-Agent 和联系方式:让网站管理员能找到你。

8.3 数据合规

  • 《网络安全法》规定,未经授权获取计算机信息系统数据可能构成违法。
  • 《个人信息保护法》严格限制个人信息的收集和处理,未经用户同意的爬取涉嫌违法。
  • 用于学习、研究的爬虫一般问题不大,但商业化使用需极其谨慎。
  • 爬取公开数据也需要评估:数据量级、是否涉及个人隐私、是否侵犯商业利益。
红线

以下行为在任何情况下都应避免:

  • 非法获取公民个人信息(电话、地址、身份证号等)
  • 破解付费内容的访问控制
  • 绕过安全措施进入未授权的系统
  • 爬取数据后用于诈骗、骚扰等违法用途
安全实践
  • 爬虫代码中附上联系邮箱,方便网站管理员反馈
  • 数据仅用于学术研究或个人学习,不商业转售
  • 如果收到网站运营方的通知要求停止爬取,应立即停止

9. 小结

反爬对抗的核心方法论可以用四句话概括:

  1. 请求头伪装是入场券 -- UA、Referer、Cookie 三位一体,缺一不可。
  2. IP 代理是发动机 -- 免费代理入不了场,付费代理是及格线,ADSL 拨号是天花板。
  3. 验证码识别是守门员 -- OCR 对图形验证码,像素对比对滑动验证码,打码平台对点触验证码,模板匹配对宫格验证码。
  4. 频率控制是人设 -- 随机延迟 + 指数退避 + 差异化节奏,让请求模式像人类。

这些技术必须组合使用,单靠某一种方式很难在现代化反爬体系下生存。同时,所有技术应建立在遵守法律、尊重网站意愿的基础之上。

版本差异(爬虫技术栈 → 当前版本)

本文编写时当前稳定版
requests2.28/2.312.32.x
Scrapy1.x/2.02.11.x(API 稳定)
httpx0.240.28.x
Playwright1.3x1.6x(Python 版)
lxml/BeautifulSoup旧版保持稳定
Python3.8-3.123.14(推荐)

本文讲解的爬虫原理(HTTP、解析、反爬、存储)与核心 API 在最新版本中成立;注意 Python 3.9 及以下已 EOL,新项目使用 3.13/3.14。