目录
- 一、目标简介
- 二、接口分析:抓包与流程还原
- 三、验证码图片识别
-
- [3.1 ddddocr 识别测试:现实很残酷](#3.1 ddddocr 识别测试:现实很残酷)
- [3.2 更实际的解决方案](#3.2 更实际的解决方案)
- 四、完整实现
- 五、案例小结
本文是《JS逆向系统进阶》验证码案例系列的第一篇。如果你对验证码的验证流程、会话绑定机制还不熟悉,建议先阅读 验证码JS逆向(上)------ 前置知识与简单验证码逆向 了解前置知识。本案例聚焦 SpiderDemo 靶场的数英验证码,完整体现从接口分析到脚本落地的全过程。
一、目标简介
链接 :https://www.spiderdemo.cn/captcha/cap1_challenge/?challenge_type=cap1_challenge
挑战任务 :网站提供 100 页数据,每页包含 10 个随机数字,需要采集所有数字并计算总和提交。第 1 页可以直接获取 ,但从第 2 页开始,每次翻页都会弹出一个 4 位字母数字混合验证码,必须正确识别并提交后才能获取该页数据。
验证码样本 :


验证码特征分析:
- 字符组成:4 位,大小写字母 + 数字混合
- 背景干扰:白色背景上有随机散布的噪点(小圆点)和交叉干扰线
- 字符样式:深色字体、有轻微倾斜和大小变化
- 大小写处理 :前端 JS 会自动将输入转为大写(
this.value = this.value.toUpperCase()),说明 服务端验证不区分大小写 - 难度评级:中等偏低------干扰元素较少,字符未严重粘连或扭曲
二、接口分析:抓包与流程还原
打开 F12 开发者工具,观察 Network 面板中的 XHR/Fetch 请求,结合页面引用的 JS 文件 cap1_challenge.js 的源码分析,可以定位到 4 个核心接口:
| 接口 | 方法 | 作用 |
|---|---|---|
/captcha/api/cap1_challenge/init/ |
GET | 初始化挑战,获取第 1 页数据 |
/captcha/api/cap1_challenge/captcha_image/ |
GET | 获取验证码图片 |
/captcha/api/cap1_challenge/page/ |
POST | 提交验证码 + 获取指定页数据 |
/captcha/api/cap1_challenge/submit/ |
POST | 提交最终求和答案 |
完整交互流程:
┌──────────────────────────────────────────────────────────────────────────┐
│ SpiderDemo 数英验证码 交互流程 │
│ │
│ ┌──────────┐ ┌──────────┐ │
│ │ 客户端 │ │ 服务端 │ │
│ └─────┬─────┘ └─────┬─────┘ │
│ │ │ │
│ ① 初始化 GET /captcha/api/cap1_challenge/init/ │ │
│ │ ?challenge_type=cap1_challenge ──────────► │ │
│ │ │ │
│ │ ◄── {success, page_data: [10个数字], message} ──── │ │
│ │ (第1页数据直接返回,无需验证码) │ │
│ │ │ │
│ ② 翻页 需要第2~100页数据时... │ │
│ │ │ │
│ ③ 获取验证码图片 │ │
│ │ GET /captcha/api/cap1_challenge/captcha_image/ │ │
│ │ ?t=1761719202955 ──────────► │ │
│ │ │ │
│ │ ◄── PNG 图片流(直接返回图片,非Base64) ──────────── │ │
│ │ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ OCR 识别验证码 │ │ │
│ │ │ 结果: "5YDM" │ │ │
│ │ └──────────────────┘ │ │
│ │ │ │
│ ④ 提交验证码 + 请求页面数据 │ │
│ │ POST /captcha/api/cap1_challenge/page/ │ │
│ │ Body: { ──────────► │ │
│ │ "captcha_input": "5YDM", │ │
│ │ "page_num": 2, │ │
│ │ "challenge_type": "cap1_challenge" │ │
│ │ } │ │
│ │ ┌───────────────────────┤ │
│ │ │ 服务端验证验证码 │ │
│ │ │ 比对 Session 中的答案 │ │
│ │ │ 不区分大小写 │ │
│ │ └───────────────────────┤ │
│ │ │ │
│ │ ◄── {success: true, page_data: [10个数字]} ─────── │ │
│ │ 或 {success: false, error: "验证码错误"} │ │
│ │ │ │
│ ⑤ 重复③④,直到100页全部采集完毕 │ │
│ │ │ │
│ ⑥ 提交答案 │ │
│ │ POST /captcha/api/cap1_challenge/submit/ │ │
│ │ Body: { ──────────► │ │
│ │ "challenge_type": "cap1_challenge", │ │
│ │ "answer": 45623 │ │
│ │ } │ │
│ │ │ │
│ │ ◄── {success, is_correct, message} ────────────── │ │
│ ▼ ▼ │
└──────────────────────────────────────────────────────────────────────────┘
各接口详细分析 ,接口①:初始化挑战
GET /captcha/api/cap1_challenge/init/?challenge_type=cap1_challenge
响应示例:
{
"success": true,
"message": "继续现有挑战 (cap1_challenge)",
"challenge_type": "cap1_challenge",
"page_data": [
2861,
7072,
6611,
1447,
5668,
4834,
4667,
4126,
9934,
6979
],
"total_pages": 100,
"current_page": 1,
"has_passed_before": false
}
关键点:
- 第1页数据在初始化时直接返回,不需要验证码
- 服务端会创建 Session 记录本次挑战
- has_passed_before 表示用户是否已通过该挑战
接口②:获取验证码图片
GET /captcha/api/cap1_challenge/captcha_image/?t=1761719202955
关键点:
- 返回类型: Content-Type: image/png(直接返回图片流,不是Base64)
- 参数 t 是时间戳,仅用于防止浏览器缓存,不参与验证逻辑
- 服务端生成验证码后,将答案存入 Session
- 每次请求都会生成新的验证码(覆盖 Session 中旧的答案)
接口③:提交验证码 + 获取页面数据(核心接口)
POST /captcha/api/cap1_challenge/page/
Content-Type: application/json
请求体:
{
"captcha_input": "5YDM", // 验证码识别结果
"page_num": 2, // 目标页码
"challenge_type": "cap1_challenge" // 挑战类型
}
成功响应:
{
"success": true,
"data_type": "page",
"page_data": [
1797,
6172,
8184,
2969,
9939,
3657,
6026,
2485,
9285,
7000
],
"current_page": 2,
"total_pages": 100,
"challenge_type": "cap1_challenge",
"message": "验证码验证成功,获取第2页数据"
}
失败响应:
{"success": false, "error": "验证码错误"}
关键点:
- 验证码验证和数据获取合并为一个接口
- 没有加密、没有签名,全部明文传输
- 验证码不区分大小写(前端自动转大写)
- 会话通过 Cookie 维护(Django Session),无需手动管理 captcha_id
接口④:提交最终答案
POST /captcha/api/cap1_challenge/submit/
Content-Type: application/json
请求体:
{
"challenge_type": "cap1_challenge",
"answer": 45623 // 所有数字的总和
}
响应:
{
"success": true,
"is_correct": true,
"submitted_answer": 45623,
"challenge_type": "cap1_challenge",
"submitted_at": "2024-10-29T12:30:00Z",
"message": "恭喜你完成挑战!"
}
本案例的加密分析结论 ,通过分析 cap1_challenge.js 的源码和网络请求,可以确认:
- 传输方式:全部明文 JSON
- 会话管理 :依赖 Cookie(Django Session),无需手动管理
captcha_id - 验证方式:服务端验证------服务端在生成验证码图片时将答案存入 Session,提交验证码时从 Session 中取出答案比对
- 唯一的挑战:OCR 识别验证码图片中的 4 位字符
这是一个非常适合入门学习的案例:接口简单清晰、没有加密混淆,核心难点完全集中在 验证码图片的 OCR 识别 上。
拓展:Session 与 Cookie 的会话管理机制 。在上面的接口分析中,我们提到会话通过 Cookie 维护(Django Session),无需手动管理 captcha_id。这意味着什么?为什么不需要额外管理一个标识符?本小节详细拆解这个核心概念。
问题的本质:服务端怎么 "记住" 验证码答案?想象一个场景------你去银行取钱:
你: "我要取钱"
柜员: "请输入密码" ← 柜员心里记住了你的密码是 123456
你: "123456" ← 你输入密码
柜员: 对比 → 正确 → 给你钱
但银行同时有 1000 个客户在办业务,柜员怎么知道 你的密码是 123456 而不是别人的?答案是:每个客户取号(拿到一个唯一编号),柜员用这个编号在记录本上查对应的信息 。这就是 Session 的本质------服务端的 "记录本" ,而 Cookie 就是你的 "取号牌"。
Session + Cookie 的协作机制,将上面的类比映射到本案例的验证码流程:
┌─────────────────────────────────────────────────────────────────────┐
│ Session + Cookie 工作原理 │
│ │
│ ┌──────────┐ ┌──────────────┐ │
│ │ 浏览器 │ │ 服务端 │ │
│ │ (你的脚本) │ │ (Django) │ │
│ └─────┬─────┘ └──────┬───────┘ │
│ │ │ │
│ ① 第一次访问网站 │ │
│ │ ─────── 请求 ──────────────────────────────────► │ │
│ │ │ │
│ │ ┌───────────────────────┤ │
│ │ │ 服务端创建 Session: │ │
│ │ │ │ │
│ │ │ 在内存/数据库中创建记录 │ │
│ │ │ Session ID = "abc123" │ │
│ │ │ 数据 = {} (空的) │ │
│ │ └───────────────────────┤ │
│ │ │ │
│ │ ◄── 响应 + Set-Cookie: sessionid=abc123 ─────── │ │
│ │ (服务端把"取号牌"通过响应头塞给你) │ │
│ │ │ │
│ ② 请求验证码图片 │ │
│ │ ─── GET /captcha_image/ ───────────────────────► │ │
│ │ Cookie: sessionid=abc123 (浏览器自动携带) │ │
│ │ │ │
│ │ ┌───────────────────────┤ │
│ │ │ 服务端: │ │
│ │ │ 1. 看Cookie → abc123 │ │
│ │ │ 2. 生成验证码 "5YDm" │ │
│ │ │ 3. 把答案存入Session: │ │
│ │ │ abc123 → { │ │
│ │ │ captcha: "5YDM" │ │
│ │ │ } │ │
│ │ │ 4. 把图片返回给你 │ │
│ │ │ (只返回图片, 不返回答案!) │ │
│ │ └───────────────────────┤ │
│ │ │ │
│ │ ◄── 返回验证码 PNG 图片 ────────────────────── │ │
│ │ │ │
│ ③ 提交验证码答案 │ │
│ │ ─── POST /page/ {captcha_input: "5YDM"} ──────► │ │
│ │ Cookie: sessionid=abc123 (自动带上) │ │
│ │ │ │
│ │ ┌───────────────────────┤ │
│ │ │ 服务端: │ │
│ │ │ 1. 看Cookie → abc123 │ │
│ │ │ 2. 从Session中取答案: │ │
│ │ │ abc123.captcha │ │
│ │ │ → 存的是 "5YDM" │ │
│ │ │ 3. 对比: │ │
│ │ │ 你提交的 "5YDM" │ │
│ │ │ == 存的 "5YDM" │ │
│ │ │ → 匹配! 验证通过 │ │
│ │ └───────────────────────┤ │
│ │ │ │
│ │ ◄── {success: true, page_data: [...]} ──────── │ │
│ ▼ ▼ │
└─────────────────────────────────────────────────────────────────────┘
Cookie 与 Session 的关系:
Cookie = 你手里的"取号牌"(只有一个编号,存在浏览器端)
Session = 柜员的"记录本" (用编号查到完整记录,存在服务端)
┌──────────────┐ ┌─────────────────────────────────┐
│ 浏览器 │ │ 服务端内存/数据库 │
│ │ │ │
│ Cookie: │ ──查找──► │ Session 存储: │
│ sessionid= │ │ ┌───────────┬────────────────┐ │
│ "abc123" │ │ │ Session ID │ 数据 │ │
│ │ │ ├───────────┼────────────────┤ │
│ (只存编号, │ │ │ abc123 │ captcha:"5YDM" │ │
│ 不存答案) │ │ │ │ user: "张三" │ │
│ │ │ ├───────────┼────────────────┤ │
│ │ │ │ def456 │ captcha:"F6SX" │ │
│ │ │ │ │ user: "李四" │ │
│ │ │ └───────────┴────────────────┘ │
└──────────────┘ └─────────────────────────────────┘
关键:
- 你只知道自己的编号 (abc123)
- 答案 "5YDM" 存在服务端,你看不到,也拿不到
- 同时有多人访问时,服务端通过不同的 Session ID 区分每个人
Cookie 是由服务端通过 HTTP 响应头 Set-Cookie 设置的。一旦设置,浏览器在后续 每次 访问同一网站时,都会自动在请求头中带上它:
http
# 第一次访问时,服务端在响应头里"塞"给你:
HTTP/1.1 200 OK
Set-Cookie: sessionid=abc123; Path=/; HttpOnly
# 之后你的所有请求,浏览器都自动带上:
GET /captcha_image/ HTTP/1.1
Cookie: sessionid=abc123 ← 不需要你手动加,浏览器/requests.Session自动处理
并非所有验证码系统都用 Cookie/Session 来管理会话。在实战中你会遇到两种主流方案:Cookie/Session 隐式管理 (本案例)和 显式 captcha_id 管理 (极验、网易等第三方验证码)。深入理解它们的区别,是验证码逆向的必备知识。① 代码层面的区别:
python
# ========== 方案A: 显式 captcha_id(极验、网易等常见) ==========
# 标识符在请求参数中明确传递,你必须自己提取并管理
# 第1步: 初始化,服务端在响应体中返回 captcha_id
resp = requests.get("/captcha/init")
captcha_id = resp.json()["captcha_id"] # ← 你必须自己提取并保存
#=> captcha_id = "a836aaee-02fe-487d-87f2-577de6d798dc"
# 第2步: 获取图片时带上 captcha_id
resp = requests.get(f"/captcha/image?id={captcha_id}") # ← 手动传
# 第3步: 验证时也要带上 captcha_id
resp = requests.post("/captcha/verify", json={
"captcha_id": captcha_id, # ← 手动传(忘了就无法验证)
"answer": "5YDM"
})
# ========== 方案B: Cookie/Session 隐式管理(本案例的方式) ==========
# 标识符藏在 Cookie 中自动传递,你完全不用操心
session = requests.Session() # ← 关键! 用 Session 对象自动管理 Cookie
# 第1步: 初始化(服务端通过 Set-Cookie 响应头自动设置 Session ID)
session.get("/captcha/init")
# 响应头: Set-Cookie: sessionid=abc123
# requests.Session() 自动保存了这个 Cookie,你看不到也不需要看
# 第2步: 获取图片(Cookie 自动带上 → 服务端知道你是谁 → 把答案存你的 Session)
session.get("/captcha_image/")
# 请求头自动带上 Cookie: sessionid=abc123
# 第3步: 提交验证(Cookie 自动带上 → 服务端查你的 Session → 取出答案比对)
session.post("/page/", json={"captcha_input": "5YDM"})
# 请求头自动带上 Cookie: sessionid=abc123
# 全程你不需要处理任何标识符!
② 架构设计差异 。两种方案本质上解决的是同一个问题------怎么把生成验证码和验证答案这两个请求关联起来。但它们的设计哲学完全不同:
方案A 显式 captcha_id --- "令牌模式"
┌──────────┐ ┌──────────────────────────┐
│ 客户端 │ │ 服务端 │
│ │ │ │
│ │ init │ 生成 captcha_id = "uuid1" │
│ │ ◄────── │ 存储: uuid1 → 答案"5YDM" │
│ │ │ │
│ 我拿到了 │ │ 这里的存储可以是: │
│ uuid1 │ │ - Redis │
│ 我来保管! │ │ - 数据库 │
│ │ │ - 内存缓存 │
│ │ verify │ │
│ 带上uuid1 │ ──────► │ 查找 uuid1 → "5YDM" │
│ │ │ 对比 → 通过 │
└──────────┘ └──────────────────────────┘
特点: 标识符在"明面上",客户端负责保管和传递
方案B Cookie/Session --- "身份证模式"
┌──────────┐ ┌──────────────────────────┐
│ 客户端 │ │ 服务端 │
│ │ │ │
│ │ init │ 创建 Session abc123 │
│ 收到Cookie │ ◄────── │ Set-Cookie: abc123 │
│ 自动存好 │ │ │
│ (我不用管) │ │ │
│ │ image │ 看Cookie → abc123 │
│ Cookie │ ──────► │ 存入Session: │
│ 自动带上 │ │ abc123.captcha = "5YDM" │
│ │ │ │
│ │ verify │ 看Cookie → abc123 │
│ Cookie │ ──────► │ 取出 abc123.captcha │
│ 自动带上 │ │ 对比 → 通过 │
└──────────┘ └──────────────────────────┘
特点: 标识符"藏"在Cookie里,浏览器/Session对象自动管理
全方位对比表:
| 对比维度 | 方案A: 显式 captcha_id | 方案B: Cookie/Session |
|---|---|---|
| 标识符位置 | 在响应体 JSON 中返回,请求参数中传递 | 藏在 Set-Cookie / Cookie 请求头中 |
| 是否需要手动管理 | 是,必须从响应中提取并在后续请求中传递 | 否 ,requests.Session() 全自动 |
| 典型使用者 | 极验(GeeTest)、网易易盾、腾讯天御、hCaptcha | Django、Flask、Spring 等框架的内置验证码 |
| 为什么这样设计 | 验证码作为 独立第三方服务,嵌入到各种网站中,不能依赖宿主网站的 Session | 验证码是网站 自己开发 的,天然可以复用网站自身的 Session 机制 |
| 可并发性 | 高------每个 captcha_id 独立,同一用户可同时开多个验证码互不干扰 | 低------一个 Session 同一时间只存一个验证码答案,新的会覆盖旧的 |
| 跨域支持 | 支持------captcha_id 通过参数传递,不受同源策略限制 | 不支持------Cookie 受同源策略和 SameSite 限制 |
| 逆向难度 | 稍高------需要找到 captcha_id 从哪个接口返回、在哪些接口使用,有时还伴随签名/加密 | 较低------只要用 requests.Session() 保持 Cookie 即可,不用分析额外参数 |
| 逆向时的关注点 | 1.定位返回 captcha_id 的接口 2.追踪 captcha_id 在后续哪些接口中使用 3.是否有 sign/token 等伴随参数 | 1.确保用 Session 对象(不要用裸 requests) 2.检查是否有 CSRF Token(Django 常见) 3.注意 Cookie 的过期时间 |
安全性对比:
安全性维度对比
┌─────────────────────────────────────────────────────────┐
│ │
│ 维度1: 防重放攻击 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 方案A (captcha_id): │ │
│ │ 天然防重放------每个 captcha_id 只能用一次, │ │
│ │ 用完即从存储中删除, 无法重复使用 │ │
│ │ │ │
│ │ 方案B (Cookie/Session): │ │
│ │ 依赖实现------如果服务端验证后不清除 Session │ │
│ │ 中的答案, 同一个 Session 可能被重复验证 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 维度2: 防伪造/猜测 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 方案A (captcha_id): │ │
│ │ UUID 足够随机, 无法猜测 │ │
│ │ 但如果 captcha_id 泄露(比如在URL中), │ │
│ │ 攻击者可以用它来验证 │ │
│ │ │ │
│ │ 方案B (Cookie/Session): │ │
│ │ Session ID 通常有 HttpOnly 标记, │ │
│ │ JS 无法读取, 更难被 XSS 窃取 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 维度3: 绑定强度 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 方案A (captcha_id): │ │
│ │ captcha_id 是"不记名令牌"------谁拿到都能用。 │ │
│ │ 如果 A 用户的 captcha_id 被 B 截获, │ │
│ │ B 可以直接用它提交答案 │ │
│ │ │ │
│ │ 方案B (Cookie/Session): │ │
│ │ Session 天然绑定一个客户端------Cookie 不会 │ │
│ │ 跑到别人的浏览器里去。服务端还可以额外绑定 │ │
│ │ IP、User-Agent 等指纹来防止 Session 劫持 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
│ 维度4: 抗并发干扰 │
│ ┌─────────────────────────────────────────────────┐ │
│ │ 方案A (captcha_id): │ │
│ │ 每次生成独立的 ID, 多线程/多窗口互不影响 │ │
│ │ │ │
│ │ 方案B (Cookie/Session): │ │
│ │ 同一 Session 下只有一个验证码答案槽位。 │ │
│ │ 如果你请求了验证码A还没提交, 又请求了验证码B, │ │
│ │ 验证码A的答案就被覆盖了------提交A的答案必然失败 │ │
│ │ │ │
│ │ 这对逆向脚本的启示: │ │
│ │ 用 Session 方案的网站, 不能多线程并发请求 │ │
│ │ 验证码, 必须 "请求图片 → 识别 → 提交" 串行执行 │ │
│ └─────────────────────────────────────────────────┘ │
│ │
└─────────────────────────────────────────────────────────┘
③ 方案A + 方案B 混合使用 。上面的对比把两种方案画得泾渭分明,但实战场上很多站点是 两者同时存在 的------Cookie 和显式 captcha_id 各自负责不同的校验链路。
举个典型场景:一个接入了极验的网站,它的登录流程可能同时涉及两种标识符:
请求 /captcha/init → 服务端返回 {challenge: "abc...", gt: "xxx"}
同时 Set-Cookie: PHPSESSID=xyz789
│
▼
获取验证码图片 → URL 带 ?challenge=abc&...(方案A的参数)
Cookie 自动带 PHPSESSID=xyz789(方案B的标识)
│
▼
提交验证答案 → Body 带 challenge + validate + seccode(方案A的参数)
Cookie 自动带 PHPSESSID=xyz789(方案B的标识)
│
▼
服务端校验 → 用 challenge 查验证码答案(极验自己的存储)
用 PHPSESSID 查用户登录状态(网站自己的 Session)
→ 两个维度都通过,才返回 success
这种情况下,captcha_id(方案A)负责验证码本身的校验,Cookie/Session(方案B)负责用户身份和业务状态。逆向时两者缺一不可------忘了传 captcha_id 服务端找不到答案,忘了带 Cookie 可能直接被拦在登录态外面。
写脚本时的正确姿势:
python
session = requests.Session() # 管理 Cookie(方案B)
# 第1步:初始化,拿到 captcha_id(方案A)
resp = session.get("/captcha/init")
captcha_id = resp.json()["challenge"] # ← 显式提取
# session 自动保存了 Set-Cookie
# 第2步:获取验证码------两种标识符都要带
resp = session.get(f"/captcha/image?challenge={captcha_id}")
# Cookie 自动带(方案B),challenge 手动传(方案A)
# 第3步:提交答案------同上
resp = session.post("/captcha/verify", json={
"challenge": captcha_id, # ← 方案A
"answer": "5YDM"
})
# Cookie 自动带(方案B)
所以不要把两种方案理解成 "二选一",实战中更常见的问法是:这个站点的验证码用了哪些标识符?分别从哪里获取?分别在哪里传递?
实战中如何快速判断对方用的是哪种方案?拿到一个新的验证码站点时,按以下步骤 30 秒内判断:
步骤1: 打开 F12 → Network → 触发验证码 → 看初始化接口的响应体
响应体中有 captcha_id / challenge / token / verify_id 等字段?
│ │
YES NO
│ │
▼ ▼
方案A: 显式 ID 看 Set-Cookie 响应头
接下来找这个 ID 是否设置了 sessionid?
在后续哪些请求中出现 │
YES ──────┘
│
▼
方案B: Cookie/Session
步骤2: 确认方案后,选择对应的编程策略
方案A → 从响应中提取 captcha_id,手动传递
方案B → 用 requests.Session(),什么额外参数都不用管
实际案例速判:
| 网站/验证码 | 初始化响应中是否有显式 ID | 判断 |
|---|---|---|
| 极验 GeeTest | {"challenge": "abc...", "gt": "xxx"} |
方案A,需要提取 challenge 和 gt |
| 网易易盾 | {"token": "xxx", "captcha_id": "yyy"} |
方案A,需要提取 token 和 captcha_id |
| 本案例 SpiderDemo | 响应体无任何标识符,验证码答案存 Session | 方案B,用 requests.Session() 即可 |
| 大多数 Django/Flask 自建验证码 | 通常无显式 ID | 方案B |
小结:为什么本案例用方案B更合理 ?SpiderDemo 的验证码是 网站自己实现 的(不是接入第三方服务),运行在同一个 Django 后端上。在这种场景下,Cookie/Session 方案是最自然的选择:
- 不需要额外的存储系统 ------Django 自带 Session 框架,验证码答案直接存
request.session['captcha'],一行代码搞定 - 不需要设计 ID 生成/传递/清理机制------Session 的生命周期由框架自动管理
- 足够安全------对于自建验证码,不需要考虑跨域、第三方嵌入等复杂场景
而极验、网易等之所以用方案A,是因为它们是 独立的验证码服务商 ,需要嵌入到成千上万个不同的网站中------它们不可能访问宿主网站的 Session,所以必须用自己的 captcha_id 来独立管理验证状态。
在用 Python requests 库写脚本时,必须用 requests.Session() 而不是直接用 requests.get()/requests.post():
python
# 错误写法: 每次请求都是独立的,Cookie 不会保留
requests.get("/captcha_image/") # 服务端: 新用户! 给你 Cookie: aaa111, 答案存 aaa111
requests.post("/page/", json=...) # 服务端: 又一个新用户! Cookie: bbb222
# 服务端在 bbb222 里找不到验证码答案 → 验证失败!
# 正确写法: Session 对象记住 Cookie,所有请求共享同一个身份
s = requests.Session()
s.get("/captcha_image/") # 服务端: 给你 Cookie aaa111, 答案存在 aaa111 里
s.post("/page/", json=...) # 服务端: Cookie aaa111, 找到了! 取出答案比对 → 验证成功
不用 requests.Session() 就像每次去银行都重新取号------柜员永远不认识你,之前存的验证码答案也查不到。
前面讲的场景有一个前提:网站不需要登录就能获取验证码。但本案例的 SpiderDemo 必须先登录 才能访问挑战页面。这意味着你的 requests.Session() 必须先拥有一个 已登录状态的 Cookie ,否则所有接口都会返回 "请先登录" 。在这种情况下,最直接的做法是:先在浏览器中手动登录,然后把浏览器的 Cookie 复制到 Python 脚本中 。F12 开发者工具:
步骤:
1.在浏览器中登录网站
2.F12 打开开发者工具 → Network 选项卡
3.随便点击页面上的某个请求(或刷新页面)
4.在请求详情中找到 Request Headers → Cookie 一行
5.复制整个 Cookie 字符串
将复制的 Cookie 字符串直接设置到 Session 中:
python
# 将复制的 Cookie 字符串直接设置到 Session 中
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 ...",
"Cookie": "sessionid=abc123def456; csrftoken=xyz789; ..."
# ↑ 直接粘贴从 F12 复制的完整 Cookie 字符串
})
完整的带登录 Cookie 的脚本模板:
python
import requests
BASE_URL = "https://www.spiderdemo.cn"
# ========== 方式1: 直接粘贴 Cookie 字符串(最快) ==========
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36",
"Cookie": "sessionid=你从F12复制的值; csrftoken=你从F12复制的值"
})
# ========== 方式2: 从文件读取(推荐,避免硬编码) ==========
session = requests.Session()
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
# 将 Cookie 字符串保存在 cookie.txt 中,脚本读取
with open("cookie.txt", "r") as f:
cookie_str = f.read().strip()
session.headers["Cookie"] = cookie_str
# 验证登录状态是否有效
resp = session.get(f"{BASE_URL}/captcha/api/cap1_challenge/init/",
params={"challenge_type": "cap1_challenge"})
if resp.status_code == 200 and resp.json().get("success"):
print("Cookie 有效,登录状态正常")
else:
print("Cookie 无效或已过期,请重新从浏览器复制")
exit()
Cookie 不是永久有效的,Session Cookie 通常有过期时间(Django 默认 2 周)。当你的脚本突然报 "请先登录" 时,说明 Cookie 已过期,需要重新从浏览器复制。
python
# 在脚本中加入 Cookie 有效性检测
def check_login(session):
"""检查登录状态是否有效"""
resp = session.get(f"{BASE_URL}/captcha/api/cap1_challenge/init/",
params={"challenge_type": "cap1_challenge"})
if "请先登录" in resp.text or resp.status_code == 401:
print("Cookie 已过期!请重新登录并复制 Cookie")
return False
return True
Django 网站通常还会校验 CSRF Token(跨站请求伪造防护)。如果你的 POST 请求返回 403 Forbidden,很可能是缺少 CSRF Token。解决方法:
python
# CSRF Token 通常也在 Cookie 中,名为 csrftoken
# 需要同时在请求头的 X-CSRFToken 中带上
session = requests.Session()
session.headers["Cookie"] = "sessionid=xxx; csrftoken=yyy"
session.headers["X-CSRFToken"] = "yyy" # ← 值和 Cookie 中的 csrftoken 一致
# 或者更优雅的写法:从 Cookie 中自动提取
csrf_token = session.cookies.get("csrftoken")
if csrf_token:
session.headers["X-CSRFToken"] = csrf_token
拓展:翻页触发验证码的实现原理 。你可能会好奇:为什么点下一页会弹出验证码?验证码和页码又是怎么绑定的 ?这些问题看似简单,实际上理解它们能帮你搞懂验证码保护数据的通用模式。为什么翻页会弹验证码?答案在前端 JS 代码 cap1_challenge.js 中的 changePage() 函数里。关键逻辑(伪代码还原)如下:
javascript
// 用户点击翻页按钮时触发
function changePage(pageNum) {
if (pageNum === 1) {
// 第1页特殊: 直接加载,不需要验证码
loadPageData(1);
return;
}
// 第2~100页: 不直接加载数据,而是弹出验证码
pendingPageNum = pageNum; // ← 先记住用户想去哪一页
showCaptchaForPage(pageNum); // ← 弹出验证码输入框
}
就这么简单------前端 JS 做了一个 if 判断:第 1 页直接放行,其他页都先弹验证码。这不是什么服务端的高深黑科技,而是前端代码在用户操作和 API 请求之间插了一道关卡。先看一个常见的误解:
text
错误理解:
"请求验证码图片时就告诉了服务端我要第几页,
验证码和页码在服务端绑定了"
实际情况:
验证码和页码根本没有绑定!
它们是两个独立的事情,只是在提交时碰巧在同一个请求里
来看实际的请求流程就明白了:
text
步骤1: 获取验证码图片
GET /captcha/api/cap1_challenge/captcha_image/?t=1234567
→ 这个请求里根本没有页码参数!
→ 服务端只做一件事: 生成验证码 "5YDm",答案存入 Session
→ 这时候服务端根本不知道你要第几页
步骤2: 提交验证码 + 请求页面数据(同一个请求)
POST /captcha/api/cap1_challenge/page/
Body: {
"captcha_input": "5YDM", ← 验证码答案
"page_num": 2, ← 要第几页的数据
"challenge_type": "cap1_challenge"
}
→ 服务端做两件事:
1. 从 Session 取出答案 "5YDM",和你提交的比对 → 验证通过
2. 查询第 2 页的数据,返回给你
→ 如果验证码错误,直接拒绝,不给任何数据
用图来表示会更清楚:
text
┌──────────────────────────────────────────────────────────────────┐
│ 验证码和页码的关系: 不是"绑定",而是"门禁" │
│ │
│ │
│ ┌────────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ 获取验证码图片 │ │ 用户解答验证码 │ │ 提交验证码+页码 │ │
│ │ │ │ │ │ │ │
│ │ GET /captcha_ │ │ OCR识别: │ │ POST /page/ │ │
│ │ image/ │ │ "5YDM" │ │ { │ │
│ │ │ │ │ │ captcha: 5YDM │ │
│ │ 服务端: │ │ │ │ page_num: 2 │ │
│ │ 生成答案"5YDM" │ │ │ │ } │ │
│ │ 存入Session │ │ │ │ │ │
│ │ (不知道你要几页) │ │ │ │ 服务端: │ │
│ └───────┬────────┘ └──────┬───────┘ │ ①验证码对吗? ✅ │ │
│ │ │ │ ②对就给第2页数据 │ │
│ ▼ ▼ │ ③错就拒绝 │ │
│ 验证码图片 ──────► 识别 ──────► 提交 └──────────────────┘ │
│ │
│ 关键: 验证码是"门禁卡",页码是"房间号" │
│ 你必须先刷门禁卡(验证码正确),然后才能进房间(获取数据) │
│ 门禁卡本身不指定房间------它只证明"你是人类" │
└──────────────────────────────────────────────────────────────────┘
用 Django 视图的伪代码来展示服务端是怎么实现的:
python
# ===== 验证码图片生成接口 =====
def captcha_image_view(request):
# 1.随机生成4位字符
answer = generate_random_chars(4) # 例如 "5YDm"
# 2.把答案存入 Session(和页码无关!)
request.session['captcha_answer'] = answer.upper()
# 3.用答案生成图片,加噪点和干扰线
image = render_captcha_image(answer)
# 4.返回图片
return HttpResponse(image, content_type='image/png')
# ===== 翻页数据接口 =====
def page_data_view(request):
data = json.loads(request.body)
captcha_input = data['captcha_input'] # 用户提交的验证码
page_num = data['page_num'] # 用户要第几页
# 1.从 Session 取出之前存的答案
correct_answer = request.session.get('captcha_answer')
# 2.比对验证码(不区分大小写)
if captcha_input.upper() != correct_answer:
return JsonResponse({'success': False, 'error': '验证码错误'})
# 3.验证码正确!清除已用的验证码(一次性)
del request.session['captcha_answer']
# 4.查询指定页的数据并返回
page_data = get_numbers_for_page(page_num)
return JsonResponse({'success': True, 'page_data': page_data})
前端是怎么把验证码和页码凑到一起的?秘密在于一个变量 pendingPageNum------前端用它来记住用户正在等待哪一页数据:
javascript
let pendingPageNum = null; // 等待验证码验证的目标页码
// 点击翻页 → 记住页码 → 弹出验证码
function changePage(pageNum) {
pendingPageNum = pageNum; // ← 记住: 用户想去第 pageNum 页
showCaptchaForPage(pageNum); // ← 弹出验证码框
}
// 用户输入验证码后点"验证"
async function verifyCaptchaAndGetData() {
const captchaInput = document.getElementById('captcha-input').value;
// 把验证码和之前记住的页码一起发给服务端
const result = await apiGetPageData(
pendingPageNum, // ← 之前记住的页码
challengeType,
captchaInput // ← 用户刚输入的验证码
);
// ...
}
整个过程翻译成大白话:
text
1.用户点击"下一页(第3页)"
→ JS: "好的,记住了,你要第3页"(pendingPageNum = 3)
→ JS: "但先别急,我给你弹个验证码"
2.用户看到验证码图片,输入 "5YDM",点击"验证"
→ JS: "你输入了 5YDM,你之前要的是第3页"
→ JS: "我把这两个信息打包发给服务端"
→ POST /page/ {captcha_input: "5YDM", page_num: 3}
3.服务端:
→ "验证码 5YDM 对不对? 让我查查 Session... 对的!"
→ "你要第3页的数据? 给你。"
→ {success: true, page_data: [12, 45, ...]}
验证码是门禁卡,页码是房间号。获取验证码时服务端不知道你要几页,提交时你同时出示门禁卡和房间号,服务端先验卡、再开门。这种验证码保护 API 的模式非常通用------在各种需要防爬的场景中你都会反复遇到。
请求图片和请求数据,这两个操作之间到底是什么关系 ?看完上面你可能还有一个核心疑问:请求验证码图片是一个操作,请求页面数据是另一个操作,这两个操作各自独立吗?它们之间靠什么串起来的?
答案是:它们确实是两个独立的操作,靠 Cookie/Session 串起来的。
先看清楚这两个请求各自在干什么:
请求A: GET /captcha_image/
→ 这个请求的职责:生成验证码图片 + 把答案存到 Session 里
→ 跟"你要第几页数据"毫无关系
→ 它只负责一件事: 出题
请求B: POST /page/
→ 这个请求的职责: 验证答案 + 返回数据
→ 它需要两样东西: 验证码答案 + 页码
→ 它只负责一件事: 判卷 + 发数据
那问题来了 :请求A存的答案,请求B怎么取到的?靠的是 Cookie 。这两个请求都带着同一个 Cookie(sessionid=abc123),服务端通过这个 Cookie 找到同一块 Session 存储空间。请求A往里面写答案,请求B从里面读答案。画成时序图:
你的爬虫(用 requests.Session) 服务端
│ │
│ ① GET /captcha_image/ │
│ ─────────────────────────────────────→ │
│ Cookie: sessionid=abc123 │
│ │
│ 服务端做的事: │
│ 生成答案 "5YDM" │
│ 存到 Session: │
│ abc123 → {captcha_answer: "5YDM"}
│ 生成验证码图片 │
│ │
│ ← ───────────────────────────────────── │
│ 返回:验证码图片(png) │
│ │
│ │
│ (你用 OCR/打码平台 识别图片 → "5YDM") │
│ │
│ │
│ ② POST /page/ │
│ ─────────────────────────────────────→ │
│ Cookie: sessionid=abc123 ← 同一个! │
│ Body: {captcha:"5YDM", page_num:3} │
│ │
│ 服务端做的事: │
│ 拿 abc123 找 Session
│ 取出 captcha_answer → "5YDM"
│ 你交的 "5YDM" == 存的 "5YDM" ✅
│ 验证通过! │
│ 查第 3 页数据 │
│ │
│ ← ───────────────────────────────────── │
│ 返回:{success:true, data:[12,45,...]} │
所以两个请求的关系本质上是:
请求A(出题)──→ 往 Session 写入答案 ──→ Session(中转站)──→ 请求B(判卷)从 Session 读取答案
↑
Cookie 是钥匙
两个请求带同一个 Cookie
所以能访问同一块 Session 空间
打个比方:你去快递柜取件。
| 步骤 | 现实类比 | 本案例对应 |
|---|---|---|
| 第一步 | 快递员把包裹放进 3 号柜,你手机收到取件码 | 请求A:服务端生成验证码,答案存 Session,Cookie 就是取件码 |
| 第二步 | 你拿取件码去开 3 号柜,取出包裹 | 请求B:你带同一个 Cookie 发请求,服务端用 Cookie 找到 Session,取出答案比对 |
两步操作各自独立(快递员放包裹时不知道你啥时候来取),但靠取件码(Cookie)串起来了。
这就是为什么必须用 requests.Session():
python
# 错误写法: 两次请求没有共享 Cookie
resp1 = requests.get("/captcha_image/") # 服务端分配 Cookie: sessionid=aaa
resp2 = requests.post("/page/", ...) # 服务端分配 Cookie: sessionid=bbb
# → 服务端在 bbb 的 Session 里找不到验证码答案(答案存在 aaa 里)→ 失败
# 正确写法: requests.Session() 自动管理 Cookie,两次请求共享同一个
session = requests.Session()
resp1 = session.get("/captcha_image/") # Cookie: sessionid=abc123
resp2 = session.post("/page/", ...) # Cookie: sessionid=abc123 ← 同一个!
# → 服务端用 abc123 找到 Session,取出答案,比对成功 → 拿到数据
总结 :请求图片和请求数据确实是两个独立的 HTTP 操作,各有各的逻辑。它们之间没有请求A直接触发请求B的关系。它们的纽带是 Cookie/Session ------请求A把答案寄存在 Session 里,请求B用同一个 Cookie 从 Session 里取出答案来比对。这就是为什么用 requests.Session() 是必须的。
三、验证码图片识别
3.1 ddddocr 识别测试:现实很残酷
首先用 ddddocr 的默认模型和 beta 模型直接识别原图:
python
import ddddocr
ocr = ddddocr.DdddOcr(show_ad=False)
ocr_beta = ddddocr.DdddOcr(beta=True, show_ad=False)
with open("2.png", "rb") as f:
image_bytes = f.read()
print("默认模型:", ocr.classification(image_bytes))
print("Beta模型:", ocr_beta.classification(image_bytes))
测试结果 (使用样本 1.png 正确值 5YDm 和 2.png 正确值 F6Sx):
| 方法 | 1.png (5YDm) | 2.png (F6Sx) | 评价 |
|---|---|---|---|
| 默认模型(原图) | 5m |
fs |
仅识别部分字符 |
| Beta模型(原图) | 5xom |
rox |
稍好但仍有误差 |
结论 :直接用原图识别效果不佳,噪点和干扰线影响了识别准确率。我们需要图像预处理优化,通过 对比度增强 和 二值化 等预处理手段,可以显著提升识别率:
python
from PIL import Image, ImageEnhance
import ddddocr
import io
ocr_beta = ddddocr.DdddOcr(beta=True, show_ad=False)
def preprocess_captcha(image_bytes, threshold=160):
"""验证码图片预处理:增强对比度 + 二值化去噪"""
img = Image.open(io.BytesIO(image_bytes))
# 方法1:灰度 + 二值化(阈值分割去除浅色噪点)
gray = img.convert('L')
binary = gray.point(lambda x: 255 if x > threshold else 0)
buf = io.BytesIO()
binary.save(buf, format='PNG')
return buf.getvalue()
# 测试不同阈值
for thresh in [140, 150, 160, 170]:
processed = preprocess_captcha(image_bytes, threshold=thresh)
result = ocr_beta.classification(processed)
print(f"阈值={thresh}: {result}")
不同预处理方案的测试对比:
| 预处理方法 | 阈值 | 1.png (5YDm) | 2.png (F6Sx) |
|---|---|---|---|
| 二值化 + beta | 140 | yom |
F6s |
| 二值化 + beta | 160 | 5yom |
F8s |
| 二值化 + beta | 170 | yo |
F8sx |
| 对比度增强 + 默认 | --- | 5m |
f6s |
批量测试 20 张验证码的结果(下载 20 张样本,用多种预处理策略识别):
| 指标 | 数值 |
|---|---|
| 4位字符全部正确 | 约 10%~20% |
| 3位以上正确(靠运气能过) | 约 30% |
| 识别结果连 4 位都凑不出来 | 约 40% |
真实结论 :即使加了图像预处理和多策略融合,ddddocr 对这种验证码的 单次准确率只有 10%~20%,远达不到实用水平。原因是这种验证码的干扰线和噪点会与字符产生粘连,ddddocr 的通用模型无法有效区分。
3.2 更实际的解决方案
ddddocr 适合处理非常简单、几乎没有干扰的验证码。当它搞不定时,有以下几种更实际的方案:
-
方案一:第三方打码平台(最省心推荐) :当 OCR 准确率不够时,最直接的方案是调用第三方打码平台的 API。这些平台背后通常结合了训练好的 AI 模型和人工审核,准确率能到 95%+。
python#!/usr/bin/env python # coding:utf-8 import requests from hashlib import md5 class Chaojiying_Client(object): def __init__(self, username, password, soft_id): self.username = username password = password.encode('utf8') self.password = md5(password).hexdigest() self.soft_id = soft_id self.base_params = { 'user': self.username, 'pass2': self.password, 'softid': self.soft_id, } self.headers = { 'Connection': 'Keep-Alive', 'User-Agent': 'Mozilla/4.0 (compatible; MSIE 8.0; Windows NT 5.1; Trident/4.0)', } def PostPic(self, im, codetype): """ im: 图片字节 codetype: 题目类型 参考 http://www.chaojiying.com/price.html """ params = { 'codetype': codetype, } params.update(self.base_params) files = {'userfile': ('ccc.jpg', im)} r = requests.post('http://upload.chaojiying.net/Upload/Processing.php', data=params, files=files, headers=self.headers) return r.json() def PostPic_base64(self, base64_str, codetype): """ im: 图片字节 codetype: 题目类型 参考 http://www.chaojiying.com/price.html """ params = { 'codetype': codetype, 'file_base64':base64_str } params.update(self.base_params) r = requests.post('http://upload.chaojiying.net/Upload/Processing.php', data=params, headers=self.headers) return r.json() def ReportError(self, im_id): """ im_id:报错题目的图片ID """ params = {'id': im_id,} params.update(self.base_params) r = requests.post('http://upload.chaojiying.net/Upload/ReportError.php', data=params, headers=self.headers) return r.json() if __name__ == '__main__': with open("chaojiying.txt", "r", encoding="utf-8") as f: user_info = eval(f.read().strip()) # 用户中心>>软件ID 生成一个替换 96001 # chaojiying = Chaojiying_Client('你的账号', '你的密码', '你的软件ID') # 可以直接写死在这儿 我是将这些信息存储在了文本文件中,用的时候读一下 chaojiying = Chaojiying_Client(user_info["username"], user_info["password"], user_info["soft_id"]) # 本地图片文件路径 来替换 4.png 有时WIN系统须要// im = open('4.png', 'rb').read() # 3004 验证码类型 官方网站>>价格体系 3.4+版 print 后要加() # {'err_no': 0, 'err_str': 'OK', 'pic_id': '2322417540879980036', 'pic_str': 'cbsg', 'md5': '4b9ec85ede05819f11a8921c886f114f'} # pic_str就是识别结果 print(chaojiying.PostPic(im, 3004))常见打码平台:
平台 特点 单次价格 超级鹰(chaojiying) 国内老牌,API 稳定 约 1~3 分/次 图鉴(tujian) 速度快,类型全 约 1~2 分/次 2Captcha 国际平台,支持 reCAPTCHA 约 $2.99/1000次 -
方案二:大模型识别(效果好但成本高) :多模态大模型(Claude、GPT-4o 等)对验证码识别有天然优势------它们在训练时就见过大量文字图片。实测 Claude Sonnet 对本案例验证码 每次都能返回 4 位结果 ,准确率远超 ddddocr。很多人用中转站(API 代理)来调用大模型,它们通常兼容 OpenAI 的接口格式。下面的代码展示如何用 中转站 + requests 直接调用 ------不依赖特定 SDK,换任何中转站只改两个变量即可。
bashimport requests import base64 import json # ========== 配置区(换中转站只改这里) ========== API_KEY = "sk-你的中转站密钥" # 中转站给你的 API Key BASE_URL = "https://api.你的中转站.com" # 中转站的地址 MODEL = "claude-sonnet-4-6" # 模型名(不同中转站支持的名称可能不同) # 常见中转站的配置示例: # ┌────────────┬──────────────────────────────┬───────────────────────┐ # │ 中转站 │ BASE_URL │ 常见模型名 │ # ├────────────┼──────────────────────────────┼───────────────────────┤ # │ 某中转A │ https://api.xxx.site │ claude-sonnet-4-6 │ # │ 某中转B │ https://api.yyy.com │ claude-3-5-sonnet │ # │ 某中转C │ https://api.zzz.top │ gpt-4o │ # │ OpenAI官方 │ https://api.openai.com │ gpt-4o │ # │ Anthropic │ https://api.anthropic.com │ (需用Anthropic SDK) │ # └────────────┴──────────────────────────────┴───────────────────────┘ def recognize_by_llm(image_bytes): """ 用大模型识别验证码 - 通过中转站调用,兼容 OpenAI 接口格式 - 自动处理流式/非流式响应 """ b64_image = base64.standard_b64encode(image_bytes).decode("utf-8") resp = requests.post( f"{BASE_URL}/v1/chat/completions", headers={ "Authorization": f"Bearer {API_KEY}", "Content-Type": "application/json" }, json={ "model": MODEL, "max_tokens": 20, "stream": False, "messages": [{ "role": "user", "content": [ { "type": "image_url", "image_url": {"url": f"data:image/png;base64,{b64_image}"} }, { "type": "text", "text": "这是一张4位字母数字验证码图片,直接返回4个字符,不要解释。" } ] }] }, timeout=30 ) # 有些中转站会强制返回流式(SSE)响应,需要手动解析 if resp.text.startswith("data: "): # 流式响应:逐行解析 SSE text = "" for line in resp.text.strip().split("\n"): line = line.strip() if line.startswith("data: ") and line != "data: [DONE]": try: chunk = json.loads(line[6:]) choices = chunk.get("choices", []) if choices: content = choices[0].get("delta", {}).get("content", "") if content: text += content except json.JSONDecodeError: pass result = text.strip() else: # 标准 JSON 响应 data = resp.json() result = data["choices"][0]["message"]["content"].strip() # 清理:有些模型会返回带反引号的结果如 `xocA` result = result.strip("`").strip("'").strip('"').strip() return result # ========== 使用示例 ========== with open("captcha.png", "rb") as f: result = recognize_by_llm(f.read()) print(f"大模型识别: {result}") # "5YDm"实测结果(Claude Sonnet 4.6,5 张样本):
样本 返回结果 长度 cap_0 wJ4U4位 cap_1 EOSH4位 cap_2 xocA4位 cap_3 3S5D4位 cap_4 QIFN4位 每次都能稳定返回 4 位字符,格式干净。虽然没有标注数据无法计算精确准确率,但从结果的稳定性来看远超 ddddocr。
配置说明:
- 换中转站 :只需修改
API_KEY、BASE_URL、MODEL三个变量 - 换模型 :把
MODEL改成中转站支持的其他模型名即可(如gpt-4o、claude-3-5-sonnet等) - 成本:约 ¥0.01~0.05/次(取决于中转站定价),99 页总成本 ¥1~5
- 速度:约 1~3 秒/次,比打码平台稍慢但准确率更高
- Prompt 技巧 :明确告诉模型
"4位"、"字母数字"、"不要解释",可以大幅提升返回格式的规范性
- 换中转站 :只需修改
-
方案三:训练自定义模型(一劳永逸) :如果需要长期、大量地识别同一种验证码,可以收集样本训练自己的 CNN 模型。训练完成后准确率可达 95%+ 且推理速度极快,是成本最低的长期方案。
bash# 思路概述(不展开实现): # 1.收集 500~2000 张验证码图片 # 2.人工标注(或用打码平台辅助标注) # 3.训练 CNN 模型(常用 PyTorch / TensorFlow) # 4.导出模型,在脚本中直接调用推理 # 适用场景: 同一网站的验证码需要长期反复破解 # 不适用: 一次性任务、验证码样式经常变化的网站 -
方案四:ddddocr + 暴力重试(简单但低效):如果不想引入外部依赖,仍然可以用 ddddocr 配合大量重试。但要做好心理准备------可能需要重试 5~10 次才能通过一次验证:
pythondef fetch_page_with_retry(session, page_num, max_retries=10): """暴力重试模式:识别率低就多试几次""" for attempt in range(1, max_retries + 1): img_bytes = get_captcha_image(session) captcha_text = recognize_captcha(img_bytes) # ddddocr 多策略识别 result = get_page_data(session, page_num, captcha_text) if result.get("success"): print(f" [第{page_num}页] 第{attempt}次成功!") return result["page_data"] else: print(f" [第{page_num}页] 第{attempt}次失败, 重试...") time.sleep(0.3) raise Exception(f"第{page_num}页在{max_retries}次尝试后仍然失败") # 99 页 × 平均 5~8 次重试 = 约 500~800 次验证码请求 # 可以跑通,但耗时可能要 10~30 分钟
方案对比与选择建议:
| 方案 | 单次准确率 | 成本 | 速度 | 适用场景 |
|---|---|---|---|---|
| ddddocr(原始) | 10%~20% |
免费 | 极快 | 简单无干扰的验证码 |
| ddddocr + 暴力重试 | 10%~20%(靠量取胜) | 免费 | 很慢(大量重试) | 不想花钱、不赶时间 |
| 第三方打码平台 | ~95%+ | ~1分/次 | 1~3秒 | 推荐,省心省力 |
| 大模型(GPT-4o等) | 80%95% | 15分/次 | 1~3秒 | 有 API Key、少量识别 |
| 自训练 CNN 模型 | ~95%+ | 前期投入大 | 极快 | 长期大量识别同种验证码 |
结论:对于本案例(99 次验证码),推荐使用第三方打码平台 或者 大模型识别。
四、完整实现
下面给出打码平台版本的完整代码实现:
python
"""
SpiderDemo 第一代验证码挑战 - 打码平台版
准确率 95%+,99 页约 5 分钟跑完
"""
import requests
import time
from chaojiying import Chaojiying_Client
import io
BASE_URL = "https://www.spiderdemo.cn"
# ========== 2. 创建会话(从浏览器复制 Cookie) ==========
session = requests.Session()
# 将 Cookie 字符串保存在 cookie.txt 中,脚本读取
with open("cookie.txt", "r") as f:
cookie_str = f.read().strip()
session.headers["Cookie"] = cookie_str
session.headers.update({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
# ↓↓↓ 也可以直接从浏览器 F12 复制你的 Cookie 粘贴到这里 ↓↓↓
# 我还是通过读取文件的方式
# "Cookie": "sessionid=你的sessionid; csrftoken=你的csrftoken"
})
# 打码平台客户端
with open("chaojiying.txt", "r", encoding="utf-8") as f:
user_info = eval(f.read().strip())
chaojiying = Chaojiying_Client(user_info["username"], user_info["password"], user_info["soft_id"])
# ========== 3. 接口封装 ==========
def init_challenge():
"""初始化挑战,返回第1页数据"""
resp = session.get(f"{BASE_URL}/captcha/api/cap1_challenge/init/",
params={"challenge_type": "cap1_challenge"})
data = resp.json()
if data.get("success"):
return data["page_data"]
raise Exception(f"初始化失败: {data}")
def get_captcha_image():
"""获取验证码图片并放大预处理,提升识别准确率"""
resp = session.get(f"{BASE_URL}/captcha/api/cap1_challenge/captcha_image/",
params={"t": int(time.time() * 1000)})
# 放大3倍 + 增强对比度 + 锐化
from PIL import Image, ImageEnhance, ImageFilter
img = Image.open(io.BytesIO(resp.content))
resized = img.resize((img.width * 3, img.height * 3), Image.LANCZOS)
enhanced = ImageEnhance.Contrast(resized).enhance(1.5)
sharpened = enhanced.filter(ImageFilter.SHARPEN)
buf = io.BytesIO()
sharpened.save(buf, format='PNG')
return buf.getvalue()
def get_page_data(page_num, captcha_input):
"""提交验证码,获取指定页数据"""
resp = session.post(f"{BASE_URL}/captcha/api/cap1_challenge/page/",
json={
"captcha_input": captcha_input,
"page_num": page_num,
"challenge_type": "cap1_challenge"
})
return resp.json()
def submit_answer(total_sum):
"""提交最终答案"""
resp = session.post(f"{BASE_URL}/captcha/api/cap1_challenge/submit/",
json={
"challenge_type": "cap1_challenge",
"answer": total_sum
})
return resp.json()
# ========== 4. 带重试的翻页逻辑 ==========
def fetch_page_with_retry(page_num):
attempt = 0
while True:
attempt += 1
img_bytes = get_captcha_image()
result = chaojiying.PostPic(img_bytes, 3004)
if result['err_no'] == 0 and result['pic_str']:
captcha_text = result['pic_str']
print(f" [第{page_num}页] 第{attempt}次, 打码结果: {captcha_text}")
result = get_page_data(page_num, captcha_text.upper())
if result.get("success"):
print(f" [第{page_num}页] 成功!")
return result["page_data"]
else:
print(f" [第{page_num}页] 失败: {result.get('error')}")
# time.sleep(0.5)
raise Exception(f"第{page_num}页在{max_retries}次尝试后仍然失败")
# ========== 5. 主流程 ==========
def main():
all_numbers = []
# 第1步: 初始化 + 第1页
print("=" * 60)
page1_data = init_challenge()
all_numbers.extend(page1_data)
print(f"第1页: {page1_data}")
# 第2步: 采集 2~100 页
for page in range(2, 101):
page_data = fetch_page_with_retry(page)
all_numbers.extend(page_data)
# 第3步: 求和 + 提交
total = sum(all_numbers)
print(f"\n共 {len(all_numbers)} 个数字, 总和 = {total}")
result = submit_answer(total)
print(f"提交结果: {result}")
if __name__ == "__main__":
# 初始: 719129 717409
print(719129 - 717409) # 1720 一元1k题分 差不多2块钱
main()
# 开始时间: 18:40
# 结束时间: 18:58 差不多20分钟
大模型只需要变更 fetch_page_with_retry 函数部分逻辑:
python
from claude_sonnet import recognize_by_llm # 注意: 要导入函数recognize_by_llm
all_attempt = 0
success_attempt = 0 # 临时加的用来统计准确率
def fetch_page_with_retry(page_num):
global all_attempt, success_attempt
attempt = 0
while True:
all_attempt += 1
attempt += 1
img_bytes = get_captcha_image()
captcha_text = recognize_by_llm(img_bytes)
print(f" [第{page_num}页] 第{attempt}次, 打码结果: {captcha_text}")
result = get_page_data(page_num, captcha_text.upper())
if result.get("success"):
success_attempt += 1
print(f" [第{page_num}页] 成功!")
return result["page_data"]
else:
print(f" [第{page_num}页] 失败: {result.get('error')}")
上面的代码完整对应了第二章中讲解的验证码验证流程,我们回顾一下:
| 流程步骤 | 在本案例中的实现 |
|---|---|
| 第1步:请求验证码 | get_captcha_image() --- 获取验证码图片 |
| 第2步:返回挑战数据 | 服务端返回 PNG 图片流,答案存入 Session |
| 第3步:提交答案 | get_page_data(page_num, captcha_input) --- 明文 JSON 提交 |
| 第4步:验证并响应 | 服务端比对 Session 中的答案,返回 success/fail |
| 第5步:携带凭证发起业务请求 | 本案例中验证码验证与数据获取合二为一 |
五、案例小结
这个案例虽然简单,但完整体现了第五章方法论的每个步骤:
第1步识别与观察 → 4位字母数字混合验证码,噪点+干扰线,难度中低
第2步定位接口 → 4个接口,通过JS源码和Network面板定位
第3步分析请求 → 全部明文JSON,Cookie管理Session,无加密
第4步逆向参数 → 不需要!没有加密/签名,最简单的情况
第5步解题 → ddddocr + 图像预处理(对比度增强/二值化)
第6步提交验证 → POST接口,明文传输
第7步迭代加固 → 多策略OCR + 重试机制,提升整体成功率
从这个案例中可以总结的经验:
- 先分析 JS 源码 再抓包------JS 中直接写明了接口地址、参数格式、请求方式,比盲目抓包效率高
- ddddocr 不是万能的------它适合简单无干扰的验证码,对于有噪点和干扰线的验证码准确率很低(10%~20%)。遇到搞不定的验证码,果断上打码平台或大模型
- 图像预处理有帮助但有限------同一个 OCR 引擎,预处理前后的识别结果会有改善,但无法从根本上解决模型能力不足的问题
- 关注前端对输入的处理 ------
toUpperCase()这种细节说明不管验证码识别出来是小写英文字母还是大写英文字母,在传给服务端时需要统一成大写字符串,减少一个出错维度 - 需要登录的网站------必须从浏览器复制 Cookie,并注意 Cookie 过期和 CSRF Token