百度旋转验证码:被虐了一整晚之后,我把方案整理出来了适用场景:百度系(搜索资源平台 ziyuan.baidu.com 加站 / 登录)弹出的「拖动左侧滑块使图片为正」旋转验证码。代码落点:captcha.py(算角度)+ baidu_flow.py(拖动 / 闭环 / 重试)一句话结论:可以全自动过,不用人工,也不用买打码平台——纯本地视觉算法 + 一个

扫码分享
二维码

详细介绍

百度旋转验证码:被虐了一整晚之后,我把方案整理出来了

适用场景:百度系(搜索资源平台 ziyuan.baidu.com 加站 / 登录)弹出的「拖动左侧滑块使图片为正」旋转验证码。
代码落点:
captcha.py(算角度)+ baidu_flow.py(拖动 / 闭环 / 重试)
一句话结论:可以全自动过,不用人工,也不用买打码平台——纯本地视觉算法 + 一个拟人拖拽闭环 + 正确的重试姿势。

0. 先交代背景,不然你不知道我为什么这么激动

我接的活儿:自动给百度资源平台加站 → 过验证码 → 验证网站 → 拿 API TOKEN → 批量推链接。
前面都挺顺,直到撞上这个旋转验证码。

我一开始的想法(很天真):

不就是个滑块吗?算个角度拖过去,收工。

后来的现实(被打脸):

  • 角度明明拖对了,它说「验证失败,请重试」;

  • 再拖第二次,图片还是原来那张,我用同样的角度又拖一遍,它继续说失败;

  • 好不容易提示"通过"了,提交上去告诉我「验证码错误,请重试提交」;

  • 有时候干脆给个 403 空响应,页面就一行红字「提交失败,请重试提交」;

  • 最骚的是,连你点了几次刷新按钮都可能是它的风控依据……

所以有了下面这份文档。按顺序看完,至少能帮你省一晚上。


1. 先认脸:这验证码由什么组成

DOM(百度用的是一套叫 sdkMachine 的 SDK,脚本是 mkd_v2.js):

元素 / 选择器作用
#spin-0整个验证码弹层,判断"验证码还在不在"就看它
img.passMod_spin-background被旋转的圆形图片,截图就截它
.passMod_slide-btn拖动用的滑块
.passMod_verify-success成功状态节点(可见 = 过了)
.passMod_verify-fail失败状态节点
.passMod_verify-loading「加载中...」遮罩,这个坑我踩得最惨
.passMod_dialog-footer-refresh刷新按钮

背后的接口(看懂这几个你就知道"通过"到底是谁说了算):

接口干什么
passport.baidu.com/cap/init初始化,返回 tk
passport.baidu.com/cap/style返回背景图信息 backstr
passport.baidu.com/cap/img返回图片二进制
passport.baidu.com/cap/log上报拖动结果,返回 as / ds
业务接口(如 POST /site/add)带着 tk + ds 提交,它才是最终裁判

最后一行请记住,第 7 节全靠它。


2. 核心算法:到底该转多少度

三步,不复杂,但每一步都有坑。

2.1 第一步:梯度方向轴对齐(只能算出 mod 180)

思路:一张"正常的"照片里,地平线、建筑边缘这类强边缘,方向大多集中在水平/垂直。
把图片转某个角度
a 之后,所有强边缘的梯度方向会集中到 0°/90°("轴对齐")。
这个
a 就是要转的角度……的一半。

代码(captcha.py: axisangle,第 19 行):

gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
mag = np.sqrt(gx * gx + gy * gy) * mask     # mask:圆形遮罩,只要图中间
th  = np.arctan2(gy, gx)
# 在 0~180 度里以 0.25 度为步长找最优 a
score(a) = np.sum(mag * np.abs(np.cos(2 * (th + radians(a)))))
  • 用 cos(2θ) 而不是 cos(θ):因为"轴对齐"是 180° 周期的(水平线转 180° 还是水平线);

  • mask 取半径 0.48 * min(w, h) 的圆:图片是圆的、四角发黑,不遮掉会被骗;

  • 步长 0.25° 够用,再细收益很小。

坑:算出来的永远是 mod 180——a 和 a + 180 得分几乎一样。
我第一次想当然取了一个,然后 50% 概率翻车。

2.2 第二步:180° 歧义怎么消——"天空一般在上"

两个候选都"轴对齐",那就靠"哪张更像正常照片"投票。我用的启发式很朴素:天空通常在上半部分。

代码(captcha.py: naturalscore,第 42 行):

bright  = 上半平均亮度 - 下半平均亮度
blue    = 上半平均蓝分量 - 下半平均蓝分量     # 天空偏蓝
red_hot = 下半平均红分量 - 上半平均红分量     # 地面偏暖
smooth  = 下半梯度强度 - 上半梯度强度         # 天空更平滑
score = bright*0.6 + blue*1.8 + red_hot*0.6 + smooth*1.2

两个候选各算一次取分高的;日志里会打「置信差」,差得越多越可信。

坑:这套启发式不是万能的(遇到室内全景、纯色背景就白搭)。
所以不能"一次定生死",必须配合第 3 节的"同一张图反过来再试一次"。

2.3 第三步:闭环拖动(不是"算完拖一次"就完事)

我第一版:读目标角度 → 换算像素 → 一把拖到位 → 松手。然后在"验证失败"里反复横跳。

原因是 deg_per_px(一像素多少度)不是常数,视口缩放、轨道宽度一变就偏。
正确姿势是闭环(
baidu_flow.py: dragto_angle,第 654 行):

  1. 按住滑块不放;

  1. 读当前 CSS 旋转角 → 算误差 → 按 deg_per_px 换算成像素 → 移动;

  1. 再读一次角度,误差仍大于容差(我用 3°)就继续微调;

  1. 误差够了再松手(松手前再确认一次,角度可能有微小回弹)。

同时要处理两个幺蛾子:

  • 撞墙:误差是正的,但滑块已经在最右端了(或反之)。这时候不能硬顶,

要把"绕一圈 360°"的剩余误差算出来,改走反向;

  • 度数怎么读:transform: rotate(x deg) 或 matrix(...),后者用 atan2(b, a) 反解。

实测数据(日志里的,可当新环境的初始值):

滑块轨道: 起点 x=594, 可移动 241px (约 362 度)  ->  约 1.5 度/像素
容差 3 度稳定够用

2.4 拟人化:别让鼠标"瞬移"

mouse.move() 一把到位就是一条直线 + 瞬移,闭着眼都知道是脚本。
baidu_flow.py: humanmove(第 610 行)做了这些:

  • 缓入缓出(先慢后快再慢);

  • 中间加一点纵向抖动,别走完美直线;

  • 到目标附近过冲一点再回拉;

  • 按下之后、松手之前各随机停 100~500ms。


3. 重试策略:这部分比算法更重要

第一版是"失败 → 点刷新 → 重来",结果:识别错了就在错误答案上打转,而且疯狂点刷新本身就像脚本。

现在是这么干的(baidu_flow.py: solve_rotate_captcha,第 757 行):

第 1 次:用算法算出的角度 A
第 2 次:不换图,直接用 A + 180 度(把 180 度歧义反着再赌一次,几乎必中)
第 3 次:才点"刷新"换一张新图重新识别(refresh_every = 3)
...最多 20 次(max_tries = 20)

还有一个特别实用的兜底:

如果两次截到的图片字节完全一样,说明"刷新"根本没生效(新图还没加载出来)。
这时候别再用同一个角度,直接
+180° 重试。

成功/失败判定(waitverify_result,第 739 行):

  • .passMod_verify-success 可见 → 过;

  • .passMod_verify-fail 可见 → 没过;

  • #spin-0 整个消失 → 当作过(弹层关了)。

另外:验证码元素突然消失,不要直接报错终止。可能是它自己提交了,或者页面被风控刷新了——
把判断权交给外层(等第二步页面出现 / 看提示文案),比在这儿
raise 靠谱得多。


4. 血泪坑清单(按我踩的顺序)

#现象真实原因解决
1识别结果离谱,怎么拖都不对截到了「加载中...」的转圈遮罩,识别的是转圈图截图前等 .passMod_verify-loading 消失(waitimage_loaded)
2拖对了也提示失败180° 歧义选错候选打分;还错就反向 +180° 再试
3刷新后还是同一张图,重复失败新图没加载完就截图比对两次截图字节,相同就直接反向重试,别重复同一角度
4滑块拖到两端后动不了行程不够(角度不等于 360°)算"绕一圈"的剩余误差,反向绕行
5点击报 intercepts pointer events页面全屏遮罩(#fullscreen)挡着用 JS 点击 / force=True,或等遮罩消失
6提示"通过"了,业务接口还说验证码错误业务服务器才是最终裁判,它拿 tk+ds 去验当成一次普通失败:重新点"下一步"→ 再过一次验证码 → 再提交,反复重试
7接口 403、响应体空CDN 层风控(server: bfe),跟验证码无关别调验证码参数了!退避重试 / 重新登录换会话 / 换网络或代理
8老式无头浏览器一上来就被拦无头指纹太明显真实 Chrome 内核 + 新版无头(--headless=new),并把 navigator.webdriver 置为 false
9一把拖到底偶尔过、多数不过鼠标轨迹太"完美"缓入缓出 + 抖动 + 过冲回拉 + 随机停顿
10换台机器/换个缩放就全错deg_per_px 变了实时量滑块轨道宽度与可移动行程,别写死

5. 参数表(config.json → captcha)

"captcha": {
  "mode": "auto",          // auto=本地视觉算法(默认);api=打码平台;model=本地模型;manual=人工
  "max_tries": 20,         // 一次弹窗内最多试几次
  "tolerance_deg": 3,      // 角度容差
  "deg_per_px": 1.5,       // 1 像素等于多少度(实测百度约 1.5,换环境要重新量)
  "refresh_every": 3,      // 每 3 次失败才真正换图,其余用反向 180 度再试
  "save_dir": "logs"       // 每次识别的图片存档,排错全靠它
}

6. 下次遇到同类验证码,照这个流程抄

  1. 先开存档:每次截图都落地(save_dir),不然就是盲调;

  1. 认元素:找出弹层 / 图片 / 滑块 / 成功 / 失败节点的选择器,先写脚本把它们的状态打出来;

  1. 上识别:captcha.auto_upright_angle(图片字节) 直接拿到"还要逆时针转多少度";

  1. 量比例:打开一次验证码,读滑块起点/终点坐标,算出每像素多少度;

  1. 闭环拖:按住 → 读角度 → 微调 → 确认误差 → 松手;

  1. 加反向兜底:同一张图 +180° 再试一次(覆盖 180° 歧义);

  1. 只在真换图时才换图(对比图片字节);

  1. 区分"验证码没过"和"业务没接受":后者要重走整轮,调角度是没用的。

复用的核心就一个纯函数,不依赖浏览器:

from captcha import auto_upright_angle
need = auto_upright_angle(png_bytes)   # 返回 0~360:还要逆时针转多少度

7. 怎么判断"验证码过了,任务还是失败"

这是最容易怀疑人生的地方,一张对照表搞定:

你看到的真正的意思该做什么
页面红字「验证码错误,请重试提交」,接口 200 且 {"status":101}业务服务器不认这次的 tk/ds重新点"下一步"→ 再过一次验证码 → 再提交(反复重试)
接口 403、空响应、server: bfeCDN 层风控拦了,跟验证码无关退避重试 / 重新登录换 cookies / 换网络或代理
页面「提交失败,请重试提交」就是上面 403 那条的页面文案同上
「该站点为低质站点」(102)、「主域数量超限」(2000001)、「未实名」(3000002)业务规则限制换域名 / 删旧站 / 去实名,重试没用
「建议您添加带 www 的主站」(-1)提示类,不是错误点「仍然继续」,或改填带 www 的主域

8. 最后

整套东西的结论其实很朴素:

算法算角度 + 闭环拖动 + 同一张图反向再试 + 把"业务拒绝"当成普通失败重走整轮。

就这四句话,我花了整整一晚上才从"这滑块怎么这么邪门"里爬出来。
希望下一个接手的人,别再从头踩一遍。


相关代码:captcha.py(识别);baidu_flow.py 的 solve_rotate_captcha / dragto_angle / humanmove / waitimage_loaded / refreshcaptcha / waitverify_result;参数:config.json → captcha。