反爬对抗是爬虫工程师的核心战场。本章聚焦三大支柱:请求头伪装、IP代理体系、验证码识别,逐一拆解网站常见反爬手段的应对方法,帮助你从被动填坑转为主动防御。
在进入具体方案之前,先用一张 mindmap 俯瞰主流反爬手段的完整格局。
网站通常不会只用一种手段,而是把上述几种叠加成"连环套"——例如某网站先用 IP 频率限制拦住大批量请求,扛不住时才弹出验证码,而验证码本身又嵌在 JS 动态渲染的表单里。因此,反爬对抗必须组合出招,单点突破很难走到最后。
最简单的反爬手段就是读取 HTTP 请求头,如果 User-Agent 是 python-requests/2.x,或者 Referer 为空,服务器直接扔 403。请求头伪装是最低成本的"隐身术"。
维护一个 UA 池,每次请求随机选取一个。现代浏览器 UA 数以百计,维护 50+ 条即可覆盖绝大多数场景。
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 却忘了 Accept、Accept-Language 等字段,行为指纹还是暴露了"你不是浏览器"。
Referer 告诉服务器"你从哪个页面跳过来的"。很多反爬系统校验 Referer 是否来自本站合法的上一页。
# 场景:从列表页翻到第2页
session.headers["Referer"] = "https://example.com/list?page=1"
# 场景:API请求需要来自合法来源
session.headers["Referer"] = "https://example.com/"某些浏览器隐私策略(如 Referrer-Policy)会将 Referer 截断为仅有域名部分(origin)。如果目标站点要求完整路径,需要调整策略。
Cookie 是重要的会话凭证。未登录、Cookie 过期都会触发反爬。
# 直接设置 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 | 部分网站不校验,但保持规范更有益 |
如果说请求头伪装是换张脸,那 IP 代理就是换个身份。当单 IP 请求频率超标(通常表现为 429 Too Many Requests 或 403 Forbidden),代理是继续采集的唯一出路。
┌──────────────────┐
│ 代理获取方式 │
└────────┬─────────┘
┌─────────────┼─────────────┐
▼ ▼ ▼
免费代理 付费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) | 完全独占 | 中 | 高要求长期项目 |
各请求库的代理设置方式:
# === 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。
代理池是一个全自动的代理 IP 管理系统:持续从多个来源获取代理、定时检测可用性、通过 API 接口对外提供可用代理。
| 事件 | 分数操作 | 原因 |
|---|---|---|
| 新代理入库 | 设为 10 | 初始分数低,需验证 |
| 检测可用 | 设为 100(直接满分) | 确保可用代理始终能被取到 |
| 检测不可用 | 减 1 | 给予 100 次失败机会,避免偶发失败误删 |
| 分数降至 0 | 从池中移除 | 连续 100 次失败才淘汰 |
因为免费代理极不稳定,可能 5 次请求 2 次成功 3 次失败。如果每次成功只加 1 分,好代理几乎不可能积到高分,即便可用也无法被取到。"可用即满分"策略确保只要代理过检测就立即可用。
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)| 维度 | 免费代理池 | 付费 API 型 | 付费隧道型 | ADSL 拨号 |
|---|---|---|---|---|
| 可用率 | 1%~10% | 60%~90% | 95%+ | 99%+ |
| IP 独占性 | 共享 | 部分共享 | 共享 | 完全独占 |
| 单 IP 被封概率 | 极高 | 中 | 低 | 极低 |
| 技术门槛 | 低 | 低 | 极低(固定地址) | 高(需搭建运维) |
| 推荐场景 | 学习/测试 | 中等规模 | 大规模 | 高要求长期项目 |
付费隧道型代理(如阿布云动态版)是最省心的方案:整个爬虫只需要配置一个固定的代理入口地址,后端自动为每个请求分配不同的出口 IP。无需代理池、无需手动轮换。
# 阿布云代理隧道示例 —— 固定地址,自动换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。
httpbin.org/get,看 origin 字段是否等于代理 IP)timeout=10~30,代理慢是常态)http://user:pass@ip:port)验证码(CAPTCHA)是反爬的最后一道防线。当 IP 轮换和请求头伪装都没有瞒过风控系统时,验证码就会出现。以下是完整处理流程:
图形验证码是最经典的类型,由 4~6 位字母或数字组成,叠加干扰线、噪点和扭曲。
直接 OCR(低干扰场景):
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(有干扰线时):
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)两色 |
| 降噪 | 中值滤波 / 形态学操作 | 去除孤立噪点 |
设置白名单(提升识别精度):
result = tesserocr.image_to_text(
image,
config="-c tessedit_char_whitelist=0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"
)以极验(GeeTest)为典型代表。核心挑战有两个:找到缺口位置和生成拟人化拖动轨迹。
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%。必须使用先加速后减速的物理模型,并加入随机抖动。
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% | 最可靠但需收集真人数据 |
点触验证码(如 12306 图片点选、TouClick 文字点选)要求用户点击图片中的特定目标。这类验证码 OCR 无法直接处理,打码平台是最优解。
打码平台对比:
| 平台 | 支持类型 | 价格 | 响应速度 | 准确率 |
|---|---|---|---|---|
| 超级鹰 | 图形/点触/坐标 | 中等 | 5~15s | 90%+ |
| 云打码 | 图形/点触 | 较低 | 3~10s | 85%+ |
| 若快 | 图形/点触 | 较低 | 3~8s | 85%+ |
| 2Captcha | reCAPTCHA/点触 | 较高 | 10~30s | 90%+ |
打码平台 API 封装:
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()坐标解析与模拟点击:
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
locations = get_points(captcha_result) # [[132,127],[56,77],...]
for x, y in locations:
ActionChains(browser) \
.move_to_element_with_offset(captcha_element, x, y) \
.click().perform()微博宫格验证码的样式是有限的(最多 24 种排列组合),可以用模板匹配法高效识别。
from os import listdir
from PIL import Image
def is_pixel_equal(image1: Image.Image, image2: Image.Image, x: int, y: int, threshold: int = 20) -> bool:
"""辅助函数:判断两张图同一位置像素是否接近(RGB 差异均小于阈值视为相同)"""
p1 = image1.load()[x, y]
p2 = image2.load()[x, y]
return all(abs(a - b) <= threshold for a, b in zip(p1[:3], p2[:3]))
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 | 样式无限或大量变体 |
即使 IP 和请求头都完美,如果发送请求的频率像机关枪一样均匀密集,也会触发风控。请求频率控制的本质是让机器的请求模式看起来像人类。
import time
import random
# 每次请求之间随机等待 1~3 秒
time.sleep(random.uniform(1.0, 3.0))
# 翻页时的延迟应该更长(模拟人类翻页阅读)
time.sleep(random.uniform(3.0, 8.0))当请求返回 429(Too Many Requests)或 503(Service Unavailable)时,不要立即重试。使用指数退避算法逐步拉长重试间隔:
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, timeout=10)
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} 次后仍未成功")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)当数据不是直接在 HTML 中,而是通过 JS 动态加载、加密后再提交到 API 时,就需要 JS 逆向。
| 工具 | 安装 | 用途 |
|---|---|---|
| Chrome DevTools | 浏览器内置 | JS 断点调试、搜索加密入口 |
| PyExecJS | pip install PyExecJS | Python 调用 JS 引擎执行加密函数 |
| Node.js | 官网下载安装 | PyExecJS 的推荐 JS 运行环境 |
| js2py | pip install js2py | 纯 Python 实现的 JS 解释器(性能较差) |
| Playwright | pip install playwright | 浏览器自动化 + CDP 协议提取加密结果 |
| Babel / AST Explorer | 在线工具 astexplorer.net | JS AST 语法树分析 |
场景:目标网站的 API 请求携带 sign 参数,由 JS 函数生成。
步骤一:抓包定位
在 Chrome DevTools 的 Network 面板,找到目标 XHR 请求,查看其参数:
// 请求参数中有一个 sign 字段
{
"keyword": "Python",
"page": 1,
"sign": "a3f5b8c2d1e4..."
}步骤二:搜索加密入口
在 DevTools 的 Sources 面板(Ctrl+Shift+F 全局搜索),搜索关键词 sign、token、encrypt、md5、sha、hmac。找到生成 sign 的函数:
// 网站源码中找到的加密函数
function generateSign(keyword, page, timestamp) {
var str = keyword + '&' + page + '&' + timestamp;
return md5(str); // 使用了 md5 库
}步骤三: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,
},
)场景:加密函数使用了复杂的 JS 库(如 CryptoJS AES 加密),用 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 需要一个 JS 运行环境。推荐安装 Node.js(性能最好),PyExecJS 会自动检测。如果没有 Node.js,会回退到 PyV8 或 js2py,性能大幅下降。
# 检查 PyExecJS 的运行环境
import execjs
print(execjs.get().name) # 应输出 'Node.js (V8)'许多网站通过 navigator.webdriver 属性检测自动化浏览器:
// 网站的反爬检测代码
if (navigator.webdriver) {
// 检测到 Selenium/Playwright,拒绝服务
document.body.innerHTML = '';
}方案一:undetected-chromedriver(推荐)
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 配置
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()部分网站(如猫眼电影、58同城)使用自定义字体映射来隐藏关键数据(价格、评分等):
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| 方案 | 难度 | 性能 | 稳定性 | 适用场景 |
|---|---|---|---|---|
| Python 复写加密函数 | 低 | 最高 | 高 | 加密逻辑简单(MD5、SHA256、Base64) |
| PyExecJS 执行 JS | 中 | 中 | 中 | 加密逻辑复杂但可提取为独立函数 |
| Playwright + CDP | 中 | 低 | 高 | 加密需要浏览器环境(DOM操作、Canvas等) |
| 字体反爬解析 | 高 | 高 | 中 | 数据被自定义字体映射隐藏 |
优先找 API > Python 复写 > PyExecJS > 浏览器自动化。越往后方案越重、越慢,但覆盖面越广。90% 的 JS 渲染页面背后都有现成的 XHR API,直接请求 API 比执行 JS 简单得多。
| 序号 | 陷阱 | 现象 | 解决方案 |
|---|---|---|---|
| 1 | 忘记协议前缀 | ProxyError: Cannot connect to proxy | 代理地址必须加 http:// |
| 2 | 仅换 UA 不换其他 Header | 仍然被识别为爬虫 | 模拟完整浏览器 Header 集合 |
| 3 | 匀速拖动滑块 | 极验验证失败 | 使用加速-减速物理模型 + 随机抖动 |
| 4 | OCR 二值化阈值不当 | 字符断裂或粘连 | 多试几个阈值(50/80/100/127) |
| 5 | 代理检测 URL 不对 | 代理显示可用但目标站拒绝 | 将检测 URL 设为目标站地址 |
| 6 | 未设置请求超时 | 程序长时间卡死不报错 | 所有请求加 timeout=10~30 |
| 7 | 302 重定向自动跟随 | 无法判断 IP 是否被封 | 设置 allow_redirects=False |
| 8 | 代理拨号前未移除旧代理 | 拨号间隙爬虫取到过期 IP | 拨号前先 remove_proxy() |
| 9 | 打码平台 codetype 选错 | 识别结果完全不对 | 确认验证码类型与 codetype 匹配 |
| 10 | 高 DPI 屏幕坐标偏移 | 模拟点击位置不准 | 用 window.devicePixelRatio 缩放坐标 |
| 11 | 免费代理直接使用不验证 | 大量请求失败 | 必须先验证后使用 |
| 12 | Cookie 过期未更新 | 只能看首页,翻页就 302 | 重新登录获取新 Cookie |
反爬对抗技术是把双刃剑。以下原则是所有爬虫工程师的基本职业底线:
任何网站部署了反爬机制,说明它不希望被爬取。在动手之前,先访问该网站的 /robots.txt:
User-agent: *
Disallow: /admin/
Disallow: /api/private/
Crawl-delay: 10Disallow 列出的路径是明确禁止爬取的,无论技术是否可行,都应遵守。Crawl-delay: 10 表示每次请求之间至少间隔 10 秒。robots.txt 协议本身没有法律强制力,但它体现了网站的意愿。以下行为在任何情况下都应避免:
反爬对抗的核心方法论可以用四句话概括:
这些技术必须组合使用,单靠某一种方式很难在现代化反爬体系下生存。同时,所有技术应建立在遵守法律、尊重网站意愿的基础之上。
| 库 | 本文编写时 | 当前稳定版 |
|---|---|---|
requests | 2.28/2.31 | 2.32.x |
Scrapy | 1.x/2.0 | 2.13.x(API 稳定,截至 2026-09) |
httpx | 0.24 | 0.28.x |
Playwright | 1.3x | 1.6x(Python 版) |
lxml/BeautifulSoup | 旧版 | 保持稳定 |
| Python | 3.8-3.12 | 3.14(推荐) |
本文讲解的爬虫原理(HTTP、解析、反爬、存储)与核心 API 在最新版本中成立;注意 Python 3.9 及以下已 EOL,新项目使用 3.13/3.14。