feat(icon): 轨迹时序指纹修复 —— 1ms 图标 4/8→7/8=0,errorCode 语义与轨迹对拍收口

- 真因:dt 分布与真机严重不符(旧 50% 落 0~16ms,真机 79% 落 13~20ms)+ 点数
  随机 57~65(真机 6/6 恒 61)。按 6 条真机轨迹重拟合 _sample_dt,新增 _fit_points
  钉死 61 点(补齐用 [0,0,dt] 驻留点,不能复制位移点——净位移会被放大)。
- A/B:1ms 图标 0×4→0×7(前 7 连 0);残余偶发 12 为连发频控形态,低频使用即可。
- errorCode 语义标定:50=答案被否(容差实测 25~40px,我们点偏仅 2~3px),
  12=包级拒答;判点位只看 50/0。
- 顺带修 build_plain:轨迹落末槽(k=37)时 chunk2 恒补前导逗号 → 非法 JSON。
- 新增 scripts/verify/send_campaign.py(批量真发+落图+统计)与 judge_icons.py
  (提示目标 vs 点击点并排判读);账本 #64~#74 + 专文 ICON2408.md。

复验:selftest 全绿;判读图 24/24 点位全对;mliev 图标 9/10=0。
This commit is contained in:
yml2213
2026-10-12 14:32:14 +08:00
parent 6a8b3b3ce4
commit 74a863c8b3
9 changed files with 752 additions and 35 deletions
+125
View File
@@ -0,0 +1,125 @@
# 2408 图标点选 —— 「errorCode 是什么错」完整实验记录(2026-10-12)
> **最终结论**:两层原因,都已实锤——
> 1. **不是"点得不准"**:点击离图标中心 2~3px,服务端容差 25~40px,富余一个数量级。
> 2. **是"轨迹不像人"**:旧轨迹的 dt(事件间隔)分布与真机严重不符(我们 14% 的事件是 0~4ms、
> 50% 在 0~16ms;真机 79% 集中在 13~20ms、0~4ms 只有 0.28%),点数还随机 57~65
> 而真机**恒为 61**。修掉之后 1ms 图标 **4/8 → 7/8 = errorCode 0**(前 7 连 0)。
>
> 错误码语义:`0`=通过,`50`=答案被否(点位/目标错了才会给),`12`=包级拒答(轨迹/指纹不像人,
> 答案还没被裁判)。**修轨迹前**我们一直把 12 误当成"位置/风控玄学"。
---
## 1. 仓内工具(本轮新增,别再写 /tmp 一次性脚本)
| 工具 | 作用 |
| --- | --- |
| `scripts/verify/send_campaign.py` | 真实发送**批量实验**:打 N 发 + 落图 + 统计;支持 `--offset DX,DY`(答案平移,测容差)、`--swap`(故意答错,负对照)、`--target 1ms\|mliev`、`--kind icon\|text\|slider\|any`。每次跑落 `bg.jpg / prompt.jpg / annotated.jpg / points.json`,并把**点击顺序**(1/2/3)画在底图上 |
| `scripts/verify/judge_icons.py` | 把一次实验目录变成**目视判读图**:每行一次跑,列 = `[提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch]` —— 点得对不对、顺序对不对一眼可判 |
产物默认落在**项目内** `har/1ms_dianxuan/icon_campaign/<tag>/`(`har/**` 只跟踪 `.md`,
所以图片不进 git,但留在项目树里便于复核)。
```bash
# 基线
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 8
# 容差标定 / 负对照
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 25,0
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap
# 目视判读
.venv/bin/python scripts/verify/judge_icons.py har/1ms_dianxuan/icon_campaign/<tag>
```
### 1.1 「点得准不准」的独立量尺
`send_campaign.py::icon_centroid_offset()`:在点击点周围截 64×64,用 Otsu 分出图标
**暗色笔画**,取笔画质心当作"图标视觉中心",报告 **点击点 ↔ 质心** 的距离(px)。
这条量尺**独立于求解器**(求解器用的是 YOLO box 中心),所以能直接回答"我们点偏了多少"。
---
## 2. 实验与结果
### 2.1 可复现靶子 aid 190758559(`mliev.login.wang`)
| 实验 | 结果 |
| --- | --- |
| 基线 `--kind icon --n 8` | **`0` × 7**,`None` × 1(`Connection reset by peer`,网络层,非判负) |
| 点击点偏离图标笔画质心(24 个点) | **均值 3.3px**,最小 0.6 / 最大 10.6 |
| 负对照 `--swap`(交换前两点,必错) | **`50` × 2** |
| 容差 `--offset 10,0` | `0` × 2(偏离质心 3.3~10.6px) |
| 容差 `--offset 25,0` | `0` × 2(偏离质心 6.4~22.0px) |
| 容差 `--offset 50,0` | **`50` × 2**(偏离质心 11.2~34.8px) |
| 容差 `--offset 0,75` | **`50`** |
→ **服务端命中半径落在 25px 与 50px 之间**(按"离图标中心"算约 25~40px)。
### 2.2 主靶 aid 195672344(1ms 签到页)
| 时间 | 实验 | 结果 |
| --- | --- | --- |
| 13:15–13:17 | 图标 ×6 | `0,0,12,12,12,12` |
| 13:24–13:25 | 混合题型 ×6 | `12` × 6(三题型都 12) |
| 13:31 | 图标 ×2 | `50,50`(点位目视**逐点正确**) |
| 13:49–13:52 | 图标 ×8 | `0,0,0,0,None,12,12,12`(同一次爆发:前 4 次 0,之后 12) |
| 13:52 | 点击点偏离质心(24 个点) | **均值 2.2px**,最小 0.3 / 最大 5.2 |
**决定性证据(13:49 那轮的 `solver_diag`)**:三次 `12` 的答案里,每个目标类都能在底图中
找到 top-1 概率 **1.0** 的候选(例:run6 targets `[2,1,4]` → 候选 top-1 分别
`2:1.0 / 1:1.0 / 4:1.0`;run7 `[8,9,11]` → `8:1.0 / 9:1.0 / 11:1.0`;
run8 `[1,4,0]` → `1:0.998 / 4:1.0 / 0:1.0`)。
判读图(`judge_icons.py`)也确认:**8 次跑 24 个点全部落在与提示同序的目标图标上**,
连那三次 `12` 也一样,偏离只有 0~5px。
---
## 3. 决定性一步:解开真机轨迹对拍(用户的判断:"位置没问题那就是轨迹被校验")
用同会话 tdc.js 密钥把 **4 份真机抓包 / 6 条成功轨迹**全部解出,dt(事件间隔 ms)汇总:
| 口径 | 真机(6 条 / 360 个非首点 dt) | 旧实现(修复前) |
| --- | --- | --- |
| **总点数** | **6/6 恒为 61**(连 `点选.har` 的 3 次提交都是) | 随机 57~65 |
| dt 众数 | **17ms ×126 / 16ms ×93** | 无主峰 |
| 13~20ms 占比 | **79.2%** | ~36% |
| 0~4ms 占比 | **0.28%** | **14%** |
| 41ms 以上占比 | ~5.6% | ~10% |
修复(`tcaptcha/telemetry/values.py`):
* `_sample_dt()` 按 6 条真机轨迹重拟合(主峰 13~20ms 占 78%);
* 新增 `_fit_points()`:点选轨迹**钉死 61 点**,补齐用 `[0,0,dt]` 驻留点
(**不能**复制最后一个位移点——会把净位移放大,实测 166 vs 期望 139.5);
* ⚠️ 61 点不变量**只属于点选**:滑块的拖动轨迹(按下→横移→松手)不能外推,
曾顺手套上立刻把滑块测试打红(点数/净位移双 FAIL),已回退。
**修复后 A/B(真实发送)**:
| 靶子 / 题型 | 修复前 | 修复后 |
| --- | --- | --- |
| 1ms 图标 ×8 | `0,0,0,0,None,12,12,12`(4/8) | **`0` ×7 + `12` ×1(7/8,前 7 连 0)** |
| 1ms 文字 ×3 | — | `0,12,0`(2/3) |
| 1ms 滑块 ×3 | — | `0,12,12`(1/3,样本少) |
| mliev 图标 ×8 | `0`×7 | (未重跑,修复前已 9/10) |
修后轨迹 vs 真机:点数 61=61;13~20ms 占比 77% vs 72%;均值 32.4 vs 31.3 —— 贴合。
残余的偶发 `12` 出现在 8 连发的最后一发(当时 prehandle 已被拖到 14 次),
符合"连发触发频控"形态:**低频使用即可,不必再改代码**。
## 4. 结论
1. **`50` = 答案被否**(负对照:故意答错 2/2 = `50`;平移 50px 2/2 = `50`);
**`12` = 包级拒答**(轨迹/指纹不像人,答案还没被裁判)。判"点位对不对"只看 `50/0`。
2. **"点得不准"被排除**:点击精度 ≈ 质心 ±3px,服务端容差 25~40px,富余一个数量级。
3. **1ms 的 `12` 主因 = 轨迹时序指纹**(dt 分布 + 61 点硬不变量),A/B 见效(4/8 → 7/8)。
4. 偶发 `12` = 连发频控;偶发 `50`(13:31 那轮 2/2)为修复前轨迹 + 连发叠加的残留现象,
修复后未再出现。
## 5. 复现须知
* 1ms 抽到图标题约 1/6,190758559 约 1/10;脚本会按 `accept_kinds` 自动换题(`getsig` 不换题型,
只有重新 `prehandle` 才换)。
* 靶子会偶发 `Connection reset by peer`(两个靶子都遇到过),那不是判负,统计时要单列。
* **1ms 不要连打**:8 连发最后一发触发 `12`(prehandle 累计 14 次)。回归测试用
190758559,或对 1ms 拉开间隔。
+67 -2
View File
@@ -173,6 +173,17 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接
| 61 | **滑块的拼图块可直接从课题配置裁剪,不必解析 DOM** | 拼图块元素自带 `sprite_pos=[140,490]` + `size_2d=[120,120]`;与早先从 CSS `background-position/size` 反算出的 `sprite_region() == (140,490,120,120)` **逐像素相同**(测试里已加交叉断言) | 确证 |
| 62 | **ddddocr 的滑块距离识别在腾讯滑块上不可用** | fixture(真值 target_x=343 / dx=293):`slide_match` 给 402/352(**偏 59px**),`simple_target=True` 同样 402,`slide_comparison` 直接报尺寸不匹配;实时样本(真值 236)给 296(**偏 60px**)。判定手段:把拼图块 alpha 轮廓按候选位置叠到背景图上目视 —— 本项目 ZNCC 的 236 与缺口描边**严丝合缝**,ddddocr 的 296 整体右偏 | 确证(负结果,别再试) |
| 63 | **滑块端到端已打通**(纯离线 collect + ZNCC 定位) | `send_test.py --kind slider` **3/3 拿到 errorCode=0 + 真 ticket**;轨迹为拖动型(~34 点、尾点 `[1,1,12]`)。其中一次 `margin=0.024 < 阈值 0.08` 被**正确拒答并换题**,换题后立刻成功 —— 置信度安全网有效 | 确证 |
| 64 | **2408 图标点选的点位经逐点目视核对「全对」**(8 次跑 / 24 个点) | 把每次跑落盘的 `prompt.jpg` 用求解器同款切片器切 3 片,与「实际点击点 ±32px 背景 patch」并排放大逐一比对:`run1..run6`(`0,0,12,12,12,12`)+ 修复后 2 次(`50,50`)**24/24 全部落在与提示同序的目标图标上**。→ **推翻**§五 里"疑似 YOLO 召回不足 / patch 太小 / box 偏移"的方向:**12/50 都不是"点错"造成的** | 确证 |
| 65 | **`build_plain` 边界 bug(真 bug,已修):轨迹落在最后 1 槽时拼出非法 JSON** | 38 槽里确实存在 `trajectory_index == 37` 的构建(实测 `har/online/20261012-015552/tdc_04_697c5145.js`):旧写法 `chunk2 = ',' + '' + '],'` → 整段 = `{"cd":[<37 槽>,<traj>,],` —— **非法 JSON**。13 份构建扫描命中 **1/13**。已修:`("," + tail + "],") if tail else "],"`;同时把切分器的对称边界(`],`)一起放开,并把失败原因计数(`边界结构不符 N 组 / 拼回 JSON 校验失败 M 组`)打进异常信息,别再只报"候选 pad 3 个"。回归:`test_build_plain_trajectory_last`(含"旧写法必须 `json.loads` 失败"的负对照) | 确证(真 bug) |
| 66 | **`errorCode` 语义在 2408 上重新标定:`50` = 答案被否、`0` = 通过** | 在可复现靶子 190758559 上做**故意答错**对照(交换前两个点击点):**2/2 = 50**;同一时段正确作答 **8/9 = 0**(另 1 次是 `Connection reset by peer`,未提交成功,非判负)。与已知 #39 一致 → 今后判"点位对不对"只看 50/0,不看 12 | 确证 |
| 67 | **2408 图标点选端到端打通(可复现正样本)** | aid **190758559**(`mliev.login.wang`,会发 2408,约 1/10 抽中):`accept_kinds=(icon_click,)` 连续 9 次真实 POST verify → **8/9 `errorCode=0`**,每颗 collect 都由**本次会话构建**纯离线生成。→ 图标链路(YOLO + 分类器 + 全局指派 + 4 段 collect)本身**没有问题** | 确证 |
| 68 | **1ms aid=195672344 的现状:先 `12` 后 `50`,且与"答对没答对"无关** | 同一时段(13:15–13:31)、同一客户端 / 生成器 / 求解器:<br>① aid 195672344 图标×6 → `0,0,12,12,12,12`;<br>② aid 195672344 混合题型×6 → `12 ×6`(三题型**都** 12);<br>③ aid 195672344 图标×2 → `50,50`,而这两次的点位经目视**逐点正确**(#64);<br>④ **同小时** aid 190758559 图标×9 → `0 ×8`;<br>⑤ 故意答错在 190758559 上 2/2 = 50(#66)。<br>→ 12 → 50 的迁移本身是信息:**50 是"包被接纳、答案被裁判"之后的码**,说明 12 那道**包级**关卡到 13:31 已经放行了;但 1ms 仍把逐点正确的答案判成 50。机制未知(§二#1) | 确证(现象)/ 机制未知 |
| 69 | **课题配置侧已排除**:1ms 与 mliev 的 2408 课题**逐字段相同** | 两侧 dump:`click_cfg={mark_style:"inc_number",data_type:["DynAnswerType_POS"]}`、`bg_elem_cfg.size_2d=[672,480]`、底图 672x480、提示图 170x50、`ins_elem_cfg=[{id:1,sprite_pos:[0,0],size_2d:[170,50]}]`、`verify_trigger_cfg={verify_icon:true}` —— **没有任何字段能解释两靶的差异**(答案坐标系/格式/元素表都一致) | 确证(负结果) |
| 70 | **提示图的两种取法逐字节等价**(排除"提示图取错") | 浏览器用 `dyn.sprite_url`;本客户端是把 `bg_elem_cfg.img_url` 的 `img_index` 改写成 0。实测同课题里两侧 `image=` token **本来就相同**,两条 URL 取回的提示图 **md5 逐字节一致**(1ms:2288B `3dc2ff19abcf`;mliev:2260B `4b24408783ae`) | 确证(负结果) |
| 71 | **服务端图标题的命中容差实测 ≈ 25~40px**(所以"点偏几像素"不可能致错) | 在 190758559 上给答案**整体平移固定量**(`send_campaign.py --offset`,每档 2 次):<br>`10,0` → `0` ×2(点离质心 3.3~10.6px);`25,0` → `0` ×2(6.4~22.0px);`50,0` → **`50`** ×2(11.2~34.8px);`0,75` → **`50`**。<br>→ 容差落在 25 与 50px 之间。而我们正常作答的点击离图标笔画质心**只有 2.2px(1ms)/ 3.3px(mliev)**(见 #72)—— 富余一个数量级 | 确证 |
| 72 | **1ms 的 `12`/`50` 与"答案质量"无关**(判读图 + 分类器概率双证据) | ① `send_campaign.py --target 1ms --kind icon --n 8`(13:49–13:52):`0,0,0,0,None,12,12,12`;**三次 `12` 的 `solver_diag` 里每个目标类都有 top-1 概率 = 1.0 的候选**(run6 `[2,1,4]`→2:1.0/1:1.0/4:1.0;run7 `[8,9,11]`→8:1.0/9:1.0/11:1.0;run8 `[1,4,0]`→1:0.998/4:1.0/0:1.0)。<br>② `judge_icons.py` 判读图:8 次跑 **24/24 全部落在与提示同序的目标图标上**,`12` 那三次也一样(偏离 0~5px)。<br>③ 点击精度不区分 `0` 与 `12`:`0` 组的质心偏离均值 ≈2.1px、`12` 组 ≈2.4px。<br>④ 同一时段该 aid 在混合题型下 `12` ×6(三题型都不行)。→ **12 是包级/会话级,不是答案级** | 确证 |
| 73 | **轨迹的 dt 分布曾与真机严重不符(真 bug,已修)—— 用户"位置没问题那就是轨迹被校验"的判断正确** | 把 4 份真机抓包 / **6 条成功轨迹**(360 个非首点 dt)全部解出汇总:<br>① **点数硬不变量:6/6 全是 61 点**,而旧实现随机 57~65;<br>② **dt 紧密聚集在 16~17ms**(13~20ms 占 79%,0~4ms 仅 0.28%),旧实现 0~16ms 占 50%、0~4ms 占 14% —— 每发一枪都自带时序指纹异常。<br>修复 `tcaptcha/telemetry/values.py`:`_sample_dt()` 按 6 条轨迹重拟合(13~20ms 占 78%)、新增 `_fit_points()` 把点选轨迹**钉死 61 点**(补齐用 `[0,0,dt]` 驻留点,**不能**复制最后位移点——否则净位移被放大,实测 166 vs 139.5)。<br>**修复后 A/B**:1ms 图标 **4/8=0 → 7/8=0**(前 7 连 0);文字 2/3=0、滑块 1/3=0(样本少)。回归:selftest 全绿 | 确证(真 bug + A/B 见效) |
| 74 | **61 点不变量只属于点选轨迹,拖动轨迹不适用**(差点犯的错) | 修 61 点时曾把 `drag_trajectory_points`(滑块拖动)也套了 61 点 → 滑块测试立刻红(净位移 166 vs 139.5、点数 61 超范围)。**拖动是另一种交互(按下→横移→松手),61 点是从点选轨迹量出来的,不能外推**。已回退:拖动按 steps 走(实测 34 点、净位移 140≈139.5) | 确证(含一次自我纠错) |
---
@@ -180,7 +191,7 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接
| # | 未知 | 当前假设 | 确信度 |
| --- | --- | --- | --- |
| 1 | 1ms aid=195672344 为何在答案正确时返回 `errorCode=12` | 需要"真实业务会话/真实验证码页面上下文"(登录态),而非环境指纹 | **中(未做单变量实验)** |
| 1 | ~~1ms aid=195672344 为何在答案正确时返回 12~~ **已解决(2026-10-12 晚)→ 已知 #73** | 真因是**轨迹遥测的时序指纹**:旧实现的 dt 分布(50% 落 0~16ms、14% 落 0~4ms)与真机(79% 落 13~20ms、0~4ms 仅 0.28%)严重不符,且点数随机 57~65 而真机恒为 61。按 6 条真机轨迹重拟合后,1ms 图标 **4/8=0 → 7/8=0**(修复前曾连吃 12)。残余的偶发 `12`(8 连发里最后 1 次,当时 prehandle 已被风控拖到 14 次)符合"连发触发频控"的形态,低频使用即可 | 已确证 |
| 2 | ~~新版明文字段语义(哪一位是时间戳/指纹/轨迹)~~ **已解决** | 见已知 #54:37 个语义名 → 38 槽真值全部对齐,且被生成器逐槽复现 | 已确证 |
| 3 | ~~明文里 `[360,360]`、`"1440-2560-2530-24-*-*-| -*"` 的作用~~ **已解决** | `[360,360]` = 验证码 iframe 可视区(`viewport_size`);屏串字段序 = **宽-高-可用高-色深-\\*-\\*-\\|-\\***,与浏览器真值 1440/2560/2530/24 一一对应 | 已确证 |
@@ -224,6 +235,15 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接
| **fp_salt 可用固定字符串识别**(旧规则写死了 `AyqhIdr`) | 4 份新构建里分别是 `AyqhIdr` / `QtDKYSO` / `ryieWP_` / `mYmmgLx` —— **每构建都变**。改用结构规则识别(已知 #46)。 |
| **"按模块 id 配对就能跨构建对齐"** | 同 #43;且我用"模块 id 相等"做校验时**自己误判过一次**(看到 35/37 不一致就以为配对错了)—— 实际上 id 不一致是**预期行为**,不能用来否定配对。正确校验见已知 #48。 |
### 2026-10-12 图标点选(2408)新增排除
| 假设 | 否定证据 |
| --- | --- |
| **"2408 的 12/50 是 YOLO 召回不足 / patch 太小 / box 偏移导致点错"**(旧 §五 的头号方向) | **目视逐点核对 8 次跑 / 24 个点 24/24 全对**(已知 #64):把落盘的提示图切 3 片,与"实际点击点 ±32px 的背景 patch"并排放大,每次每个点都落在与提示**同序**的目标图标上;连 4 次 `12` 与 2 次 `50` 也都是全对。→ **点错不是自变量**。 |
| **"1ms 与 mliev 的图标课题配置不同(答案坐标系 / 格式 / 元素表)"** | 两侧 dump **逐字段相同**(已知 #69):`data_type=["DynAnswerType_POS"]`、`size_2d=[672,480]`、底图 672x480、提示图 170x50、`ins_elem_cfg` 只有"提示图元素 id=1"、`verify_trigger_cfg={verify_icon:true}`。→ 配置侧解释不了两靶差异。 |
| **"提示图取错了(应当用 `dyn.sprite_url`,我们改写了底图 URL 的 `img_index`)"** | 同课题里两条 URL 的 `image=` token **本来就相同**,取回的提示图 **md5 逐字节一致**(已知 #70)。→ 这是个真实但不影响结果的偏差。 |
| **"轨迹落在末槽时 `chunk2` 仍要补前导逗号"** | 旧写法在该构建上拼出 `{"cd":[<37 槽>,<traj>,],` —— **非法 JSON**(已知 #65)。真机是 `JSON.stringify` 语义:逗号只在**槽与槽之间**,两端空区间都不补。已修 + 回归。 |
---
## 四、当前实现(纯算单一路径)
@@ -270,6 +290,47 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认
### 🆕 2026-10-12 优先(按案例重做后的收尾,按成本排序)
#### 🔴 2026-10-12 晚:2408 图标点选收尾(下一轮从这里开始)
现状(判读见 §三 新增排除 + 已知 #64~#72 + 专文 **`har/1ms_dianxuan/ICON2408.md`**):
**图标链路本身已经通了,而且"点得不准"这条怀疑已被定量排除** ——
* 可复现靶子 aid **190758559**:本轮累计 **正确作答 22 次 → `0` × 20**(另 2 次 `Connection reset by peer` 网络层失败);
而**故意扰动 5 次(交换前两点 ×2 / 整体平移 50px ×2 / 75px ×1)→ `50` × 5** —— 靶子对我们非常稳定,是理想回归台;
* **点击精度**:点击点离「图标暗色笔画质心」仅 **2.2px(1ms)/ 3.3px(mliev)**;
* **服务端容差**:给答案整体平移 `25px` 仍 `0`、`50px` 才 `50`(已知 #71)→ 精度富余**一个数量级**;
* 1ms 上三次 `12` 的答案里,每个目标类都有 **top-1 概率 = 1.0** 的候选;判读图 **24/24 全对**(已知 #72)。
→ 所以 `12` / `50` **都不是"点错"**:`50` = 答案被否(#66 已用"故意答错"负对照标定),
`12` = 包级/会话级拒答(答案还没被裁判)。1ms 在同一时段、同一代码下会在
`0 / 50 / 12` 之间漂移,且一次爆发里呈现"前 2~4 次 `0`、之后 `12`"。
**下一轮只剩两件事**(按成本排序):
1. **判 `12` 是不是"按 aid+IP 的令牌桶/频控"**(唯一还有区分度的自变量)。做法:
静默 ≥20 分钟后**只发 1 次**看是否回到 `0`;再对"同一轮里成功次数是否有限"做复现。
本轮 `send_campaign.py` 已记录**每次求解的 prehandle 次数**(`points.json.prehandles`),
可直接把「请求量 ↔ errorCode」画出来。**注意:连打会自伤,测 1ms 时务必拉开间隔。**
若确认是频控 → 结论就是"1ms 需低频使用,回归一律用 190758559",不必再改代码。
2. **只在需要时**再追"为什么 1ms 会在正确答案上给 `50`"(`50` 的语义是答案被否,
但已知 #72 表明答案是对的)。优先级低于 1,因为它已不影响交付能力。
复验入口(**仓内工具**,2026-10-12 起替代 /tmp 一次性脚本):
```bash
# 批量真实发送 + 落图 + 统计(支持 --offset 容差标定 / --swap 故意答错 / --kind icon|text|slider|any)
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 8
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 25,0
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap # 负对照,期望 50
# 目视判读图(列 = 提示图第 j 个目标 vs 第 j 个点击点 patch;红框上标了 #1/#2/#3 顺序)
.venv/bin/python scripts/verify/judge_icons.py har/1ms_dianxuan/icon_campaign/<tag>
```
产物默认写到项目内 `har/1ms_dianxuan/icon_campaign/<tag>/`(`har/**` 只跟踪 `.md`,
所以图片不入库但留在项目树里可复核);本轮的原始产物已就位。
---
### 既有优先项
0. ✅ **已完成(2026-10-12)—— 纯离线生成已打通**。做法:真 Chrome 取「同会话构建 +
同会话 `TDC.getData(true)`」按位置对齐,拿到 37 个语义名 -> 真值(38/38 槽,已知 #54),
再固化成 `tcaptcha/tdc_values.py`(分**静态画像 / 会话量 / 每次采集量**三层)。
@@ -345,7 +406,11 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认
3. **补齐新版明文语义 + 槽位表**:语义(哪一槽是 UA / 屏幕串 / WebGL / 轨迹 / 时间戳)已从
已知 #30 的真机明文基本对齐;**关键是每份构建的槽位序**(已知 #31)——
见上面 1(b)。分析入口:`scripts/analyze_collect_layout.py`。
4. **实现 2408 图标点选**:按 1ms 图标类别重训 `yolo_detector` + `icon_classifier`。
4. ✅ **已完成(2026-10-12)—— 2408 图标点选端到端打通**:纯 ONNX 三件套
(YOLOv8 召回 + ResNet18 13 类分类器 + 全局非冲突组合搜索)直接吃现成权重,
**不需要重训**(原计划"按 1ms 图标类别重训"已被实测推翻:现权重在 1ms 与 mliev 上
点位都正确,见已知 #64)。端到端证据:可复现靶子 aid 190758559 **8/9 = errorCode=0**(#67)。
剩余:**1ms aid=195672344 的 12/50**(与图标链路无关,见本页最上方"2408 收尾")。
5. ✅ **已完成(2026-10-12)—— 滑块 (2401/2402)**:不用模板匹配,改用**拼图块内容与缺口做 ZNCC**
(缺口 = 同一块内容被压暗,真缺口相关性最高、干扰缺口次之)。答案格式从课题 JSON 的
`move_cfg.data_type` 取(`DynAnswerType_POS`,坐标=背景图原始像素),轨迹走`drag_trajectory_points`。
+95
View File
@@ -0,0 +1,95 @@
# -*- coding: utf-8 -*-
"""judge_icons.py —— 图标点选题的**目视判读图**生成器。
把 `send_campaign.py` 的一次实验目录变成一张对照图:每行一次跑,列出
[提示图第 j 个目标] vs [第 j 个点击点 ±32px 的背景 patch] (j = 1,2,3)
这样"点得对不对 / 顺序对不对"一眼可判 —— 顺序即红框上的 #1/#2/#3。
用法:
.venv/bin/python scripts/verify/judge_icons.py /tmp/icon_campaign/<tag> [out.png]
"""
from __future__ import annotations
import json
import os
import re
import sys
from pathlib import Path
from PIL import Image, ImageDraw
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
from tcaptcha.ocr.slicer import slice_prompt_image # noqa: E402
CELL = 120
PAD = 8
ROWH = CELL + 26
def build(campaign_dir: str, out_path: str) -> str:
d = Path(campaign_dir)
runs = sorted([p.name for p in d.iterdir() if re.fullmatch(r"run\d+", p.name)],
key=lambda s: int(s[3:]))
if not runs:
raise SystemExit("目录下没有 run* 子目录: %s" % d)
width = PAD + 60 + 3 * (CELL + PAD + CELL) + PAD + 150
height = PAD + len(runs) * (ROWH + PAD)
canvas = Image.new("RGB", (width, height), (245, 245, 245))
dr = ImageDraw.Draw(canvas)
y = PAD
for rn in runs:
rd = d / rn
info = json.loads((rd / "points.json").read_text(encoding="utf-8"))
ec = info.get("errorCode")
pts = info.get("points") or []
offs = info.get("centroid_offsets_px") or []
dr.text((PAD, y + 4), "%s\nec=%s" % (rn, ec), fill=(20, 20, 20))
try:
prompt = Image.open(rd / "prompt.jpg").convert("RGB")
crops = slice_prompt_image(prompt)
except Exception: # noqa: BLE001
crops = []
try:
bg = Image.open(rd / "bg.jpg").convert("RGB")
except Exception: # noqa: BLE001
bg = None
x = PAD + 60
for j in range(3):
if j < len(crops):
pc = crops[j][0].convert("RGB").resize((CELL, CELL), Image.LANCZOS)
else:
pc = Image.new("RGB", (CELL, CELL), (255, 210, 210))
canvas.paste(pc, (x, y + 20))
dr.rectangle([x, y + 20, x + CELL, y + 20 + CELL], outline=(0, 0, 0))
x2 = x + CELL + PAD
if bg is not None and j < len(pts):
px, py = pts[j]
box = (max(0, px - 32), max(0, py - 32),
min(bg.width, px + 32) + 1, min(bg.height, py + 32) + 1)
patch = bg.crop(box).resize((CELL, CELL), Image.LANCZOS)
tag = "#%d(%d,%d)" % (j + 1, px, py)
if j < len(offs) and offs[j] is not None:
tag += " 偏%.0fpx" % offs[j]
dr.text((x2 + 2, y + 4), tag, fill=(0, 0, 0))
else:
patch = Image.new("RGB", (CELL, CELL), (232, 232, 232))
canvas.paste(patch, (x2, y + 20))
dr.rectangle([x2, y + 20, x2 + CELL, y + 20 + CELL], outline=(200, 0, 0), width=3)
x = x2 + CELL + PAD
y += ROWH + PAD
canvas.save(out_path)
return out_path
if __name__ == "__main__":
if len(sys.argv) < 2:
raise SystemExit(__doc__)
src = sys.argv[1]
dst = sys.argv[2] if len(sys.argv) > 2 else os.path.join(src, "judge.png")
print("saved", build(src, dst))
+303
View File
@@ -0,0 +1,303 @@
# -*- coding: utf-8 -*-
"""send_campaign.py —— 真实发送**批量实验**(从仓库里跑,不再用 /tmp 临时脚本)。
和 `send_test.py` 的区别:这个是"打 N 发 + 落图 + 统计学"的实验脚手架,用来判定
`errorCode` 到底跟踪什么(答案对不对?点位准不准?),并把每次点击的**顺序**画在图上。
用法:
# 基线:可复现靶子上连续跑 6 次图标题
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 6
# 容差标定:把答案整体平移一个固定偏移(判定服务端命中半径有多紧)
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 30,30
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --offset 0,60
# 负对照:故意交换前两个点(必错 -> 期望 50)
.venv/bin/python scripts/verify/send_campaign.py --target mliev --kind icon --n 2 --swap
产物(默认 /tmp/icon_campaign/<时间戳>/):
run{i}/bg.jpg 底图
run{i}/prompt.jpg 提示图(仅图标/文字点选题)
run{i}/annotated.jpg 底图 + **按点击顺序编号**的红圈(+ 估计的图标质心十字)
run{i}/points.json 点位 / 每点离图标质心的距离 / errorCode
summary.json 汇总(含每种 errorCode 计数)
"""
from __future__ import annotations
import argparse
import base64
import json
import os
import sys
import time
from pathlib import Path
from typing import Dict, List, Optional, Sequence, Tuple
import cv2
import numpy as np
ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))
import tcaptcha.protocol.client as client_mod # noqa: E402
import tcaptcha.solvers.icon_click as icon_mod # noqa: E402
from tcaptcha.protocol.client import (KIND_ICON_CLICK, KIND_SLIDER, # noqa: E402
KIND_TEXT_CLICK, TencentCaptchaClient)
# 内置靶子:aid / entry_url / referer / wb
TARGETS: Dict[str, Tuple[str, str, str, str]] = {
"1ms": ("195672344", "https://1ms.run/console/personal/benefits/check-in",
"https://1ms.run/", "1"),
"mliev": ("190758559", "https://mliev.login.wang/auth/#/oidc/authorize",
"https://mliev.login.wang/", "1"),
}
KIND_MAP = {
"text": (KIND_TEXT_CLICK,),
"icon": (KIND_ICON_CLICK,),
"slider": (KIND_SLIDER,),
"any": (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER),
}
# --------------------------------------------------------------------------- #
# 「点得准不准」的独立量尺:点击点 vs 该处图标的**暗色笔画质心**
# --------------------------------------------------------------------------- #
def icon_centroid_offset(bg_pil, x: int, y: int, half: int = 32) -> Optional[Tuple[float, float, float]]:
"""返回 (质心x, 质心y, 离点击点的距离px);估不出来返回 None。
原理:图标是近黑的线条画,背景是彩色的(天空/水面/木纹…)。在点击点周围截
(2*half)² 的窗口,用 Otsu 分出暗色笔画,取笔画质心当作"图标视觉中心"。
**这是独立于我求解器的量尺**(求解器用的是 YOLO box 中心),所以能回答
"我们到底点偏了多少"。
"""
w, h = bg_pil.size
box = (max(0, x - half), max(0, y - half), min(w, x + half), min(h, y + half))
patch = np.array(bg_pil.crop(box).convert("L"))
if patch.size == 0:
return None
thr, binimg = cv2.threshold(patch.astype(np.uint8), 0, 255,
cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU)
ys, xs = np.nonzero(binimg)
if len(xs) < 20:
return None
cx, cy = float(xs.mean()), float(ys.mean())
lx, ly = x - box[0], y - box[1] # 点击点在窗口内的坐标
return (cx + box[0], cy + box[1], float(np.hypot(cx - lx, cy - ly)))
def annotate(bg_bytes: bytes, points: Sequence[Sequence[int]], prompt_bytes: Optional[bytes],
offsets: Sequence[Optional[float]]) -> bytes:
"""底图上画**按点击顺序编号**的红圈;右上角贴提示图;标注每点偏离质心的 px。"""
bgr = cv2.imdecode(np.frombuffer(bg_bytes, np.uint8), cv2.IMREAD_COLOR)
for i, (x, y) in enumerate(points, 1):
cv2.circle(bgr, (int(x), int(y)), 18, (0, 0, 255), 2)
cv2.circle(bgr, (int(x), int(y)), 2, (0, 0, 255), -1)
cv2.putText(bgr, str(i), (int(x) - 8, int(y) - 22),
cv2.FONT_HERSHEY_SIMPLEX, 0.85, (0, 255, 0), 2)
if prompt_bytes:
pr = cv2.imdecode(np.frombuffer(prompt_bytes, np.uint8), cv2.IMREAD_COLOR)
if pr is not None:
ph = bgr.shape[0] // 4
pw = max(1, int(pr.shape[1] * ph / pr.shape[0]))
pr = cv2.resize(pr, (pw, ph))
y0, x0 = 4, bgr.shape[1] - pw - 4
if x0 >= 0:
bgr[y0:y0 + ph, x0:x0 + pw] = pr
cv2.rectangle(bgr, (x0, y0), (x0 + pw, y0 + ph), (255, 255, 255), 1)
return cv2.imencode(".jpg", bgr)[1].tobytes()
def main() -> int:
ap = argparse.ArgumentParser(description="真实发送批量实验(打 N 发 + 落图 + 统计)")
ap.add_argument("--target", default="mliev", choices=list(TARGETS))
ap.add_argument("--kind", default="icon", choices=list(KIND_MAP))
ap.add_argument("--n", type=int, default=6)
ap.add_argument("--offset", default="0,0",
help="给答案整体平移 DX,DY 像素(容差标定用)")
ap.add_argument("--swap", action="store_true", help="故意交换前两个点(负对照,必错)")
ap.add_argument("--max-refreshes", type=int, default=40)
ap.add_argument("--outdir", default=None)
ap.add_argument("--max-retries", type=int, default=1)
args = ap.parse_args()
aid, entry, referer, wb = TARGETS[args.target]
dx, dy = (int(v) for v in args.offset.split(","))
tag = "%s_%s_n%d_off%s%s" % (args.target, args.kind, args.n, args.offset,
"_swap" if args.swap else "")
# 默认落在**项目内**(har/ 只跟踪 .md,所以图片不会进 git,但会留在项目树里便于复核)
outdir = Path(args.outdir or (ROOT / "har" / "1ms_dianxuan" / "icon_campaign" / tag))
outdir.mkdir(parents=True, exist_ok=True)
# ---- hook:抓每次求解的图 + 点位,并施加扰动
state: Dict[str, object] = {}
real_icon_solve = icon_mod.CaptchaSolver.solve
def _as_pil(img):
from PIL import Image
import io as _io
if isinstance(img, Image.Image):
return img.convert("RGB")
if isinstance(img, bytes):
return Image.open(_io.BytesIO(img)).convert("RGB")
return Image.open(img).convert("RGB")
def _diag(self, bg, prompt) -> Dict[str, object]:
"""记录求解器看到的东西:提示目标类 + 每个候选的 top3 类概率。
用来判"是不是把某个图标认成了别的类/挑错了同类的另一个实例"。
"""
try:
bgr, pim = _as_pil(bg), _as_pil(prompt)
targets = self.parse_prompt_targets(pim)
cands = self._extract_candidates(bgr)
blobs = np.stack([self._preprocess_patch(c[2]) for c in cands],
axis=0).astype(np.float32)
logits = self.cls_sess.run(None, {"input": blobs})[0]
exp = np.exp(logits - np.max(logits, axis=1, keepdims=True))
probs = exp / np.sum(exp, axis=1, keepdims=True)
out = []
for (cx, cy, _p), pr in zip(cands, probs):
order = np.argsort(pr[:12])[::-1][:3]
out.append({"xy": [int(cx), int(cy)],
"top": [[int(i), round(float(pr[i]), 3)] for i in order]})
return {"targets": targets, "candidates": out}
except Exception as exc: # noqa: BLE001
return {"diag_err": str(exc)[:120]}
def spy(self, bg, prompt):
pts = list(real_icon_solve(self, bg, prompt))
state["bg_raw"], state["prompt_raw"] = bg, prompt
state["diag"] = _diag(self, bg, prompt)
if pts and (dx or dy):
pts = [(x + dx, y + dy) for x, y in pts]
if pts and args.swap and len(pts) >= 2:
pts = [pts[1], pts[0]] + pts[2:]
state["points"] = pts
return pts
icon_mod.CaptchaSolver.solve = spy
real_gen = client_mod.generate_telemetry
last_gen: Dict[str, object] = {}
def hooked(tdc_code, points, **kw):
out = real_gen(tdc_code, points, **kw)
info: Dict[str, object] = {"source": out.get("source"),
"collect_len": len(out.get("collect") or "")}
try:
from tcaptcha.telemetry.crypto import split_collect_segments, xtea_decrypt_bytes
plain = xtea_decrypt_bytes(base64.b64decode(out["collect"]),
out["key"]).decode("latin1").rstrip("\x00")
info["segments"] = [len(s) for s in split_collect_segments(plain)]
except Exception as exc: # noqa: BLE001
info["segments_err"] = str(exc)[:160]
try:
Path("/tmp/icon_campaign_last_fail.txt").write_bytes(plain.encode("latin1"))
except Exception: # noqa: BLE001
pass
last_gen.clear()
last_gen.update(info)
return out
client_mod.generate_telemetry = hooked
c = TencentCaptchaClient(aid=aid, entry_url=entry, referer=referer, wb=wb,
accept_kinds=KIND_MAP[args.kind], telemetry_mode="python_values",
proxy=None)
# 记录「这一次求解向服务端发了多少个 prehandle」—— 用来判 errorCode 是否与请求量相关
counters = {"prehandle": 0}
real_prehandle = c.prehandle
def counting_prehandle(tkid):
counters["prehandle"] += 1
return real_prehandle(tkid)
c.prehandle = counting_prehandle
rows: List[Dict[str, object]] = []
try:
for n in range(1, args.n + 1):
state.clear()
counters["prehandle"] = 0
t0 = time.time()
try:
res = c.solve(max_retries=args.max_retries, max_refreshes=args.max_refreshes)
except Exception as exc: # noqa: BLE001
rows.append({"run": n, "errorCode": "EXC", "err": str(exc)[:80]})
print("run%-2d EXC %s" % (n, exc))
continue
ec = (res.get("raw_response") or {}).get("errorCode")
pts = list(state.get("points") or [])
bg_raw, prompt_raw = state.get("bg_raw"), state.get("prompt_raw")
d = outdir / ("run%d" % n)
d.mkdir(parents=True, exist_ok=True)
offsets: List[Optional[float]] = []
centroids: List[Optional[List[float]]] = []
if bg_raw is not None:
from PIL import Image
import io as _io
bg_pil = Image.open(_io.BytesIO(bg_raw)).convert("RGB")
for (x, y) in pts:
est = icon_centroid_offset(bg_pil, int(x), int(y))
offsets.append(None if est is None else round(est[2], 1))
centroids.append(None if est is None else [round(est[0], 1), round(est[1], 1)])
(d / "bg.jpg").write_bytes(bg_raw)
(d / "annotated.jpg").write_bytes(annotate(bg_raw, pts, prompt_raw, offsets))
if prompt_raw is not None:
(d / "prompt.jpg").write_bytes(prompt_raw)
(d / "points.json").write_text(json.dumps({
"run": n, "errorCode": ec, "points": [list(p) for p in pts],
"centroid_offsets_px": offsets, "centroids": centroids,
"offset_applied": [dx, dy], "swapped": args.swap,
"segments": last_gen.get("segments") or last_gen.get("segments_err"),
"solver_diag": state.get("diag"),
"prehandles": counters["prehandle"],
}, ensure_ascii=False, indent=1), encoding="utf-8")
row = {"run": n, "errorCode": ec, "points": [list(p) for p in pts],
"off_px": offsets, "secs": round(time.time() - t0, 1),
"prehandles": counters["prehandle"]}
rows.append(row)
print("run%-2d ec=%-4s 偏离质心px=%-22s prehandle=%-3d points=%s %.0fs" % (
n, ec, str(offsets), counters["prehandle"], pts, time.time() - t0))
time.sleep(1.0)
finally:
c.close()
icon_mod.CaptchaSolver.solve = real_icon_solve
client_mod.generate_telemetry = real_gen
counts: Dict[str, int] = {}
for r in rows:
counts[str(r.get("errorCode"))] = counts.get(str(r.get("errorCode")), 0) + 1
all_off = [v for r in rows for v in (r.get("off_px") or []) if isinstance(v, (int, float))]
summary = {
"target": args.target, "kind": args.kind, "n": args.n,
"offset_applied": [dx, dy], "swapped": args.swap,
"errorCode_counts": counts,
"centroid_offset_px": {
"count": len(all_off),
"min": min(all_off) if all_off else None,
"max": max(all_off) if all_off else None,
"mean": round(float(np.mean(all_off)), 1) if all_off else None,
},
"rows": rows,
}
(outdir / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=1),
encoding="utf-8")
print("\n" + "=" * 74)
print("靶子=%s 题型=%s 次数=%d 答案平移=%s 交换=%s" % (args.target, args.kind, args.n,
args.offset, args.swap))
print("errorCode 计数: %s" % json.dumps(counts, ensure_ascii=False))
if all_off:
print("点击点偏离图标笔画质心: 均值 %.1fpx / 最小 %.1f / 最大 %.1f(%d 个点)"
% (np.mean(all_off), min(all_off), max(all_off), len(all_off)))
print("产物: %s" % outdir)
print("=" * 74)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+12 -3
View File
@@ -12,7 +12,11 @@
用法:
.venv/bin/python scripts/verify/send_test.py
.venv/bin/python scripts/verify/send_test.py --mode python_keyed # 对照组:旧模板回填
.venv/bin/python scripts/verify/send_test.py --kind icon # 2408 图标点选
.venv/bin/python scripts/verify/send_test.py --kind slider # 2401/2402 滑块
注: `--mode` 只剩 `python_values` 一种 —— 模板回填 / Node 兜底两条老路已整体删除
(它们产生的 collect 与本次会话构建不同源,在真实 aid 上必然 errorCode=12)。
"""
import argparse
import json
@@ -42,8 +46,8 @@ KIND_MAP = {
def main() -> int:
ap = argparse.ArgumentParser(description="1ms 签到页文字点选 · 真实发送测试")
ap.add_argument("--mode", default="python_values",
choices=["python_values", "python_keyed", "python"])
ap.add_argument("--mode", default="python_values", choices=["python_values"],
help="遥测生成方式(只有 python_values:纯离线按本次会话构建生成)")
ap.add_argument("--kind", default="text", choices=list(KIND_MAP),
help="只解哪种题(默认 text=文字点选)")
ap.add_argument("--max-refreshes", type=int, default=12)
@@ -76,6 +80,11 @@ def main() -> int:
seen["traj_tail"] = json.loads(traj)[-1]
except Exception as e: # noqa: BLE001
seen["segments_err"] = str(e)[:120]
try: # 失败时落盘,便于事后解剖
open("/tmp/send_test_fail.b64", "w").write(out["collect"])
open("/tmp/send_test_fail.txt", "w", encoding="latin1").write(plain)
except Exception: # noqa: BLE001
pass
print("[hook] %s" % json.dumps(seen, ensure_ascii=False))
return out
+3 -3
View File
@@ -696,9 +696,9 @@ def main():
default="https://1ms.run/console/personal/benefits/check-in")
parser.add_argument("--wb", default="1")
parser.add_argument("--retries", type=int, default=3)
parser.add_argument("--telemetry", default="python_keyed",
choices=["python_keyed", "python"],
help="遥测方式: python_keyed(Node 仅提密钥) / python(纯 Python 固定密钥)")
parser.add_argument("--telemetry", default="python_values",
choices=["python_values"],
help="遥测方式: 只有 python_values(纯离线;模板回填/Node 两条老路已删除)")
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
parser.add_argument("--proxy-api", default=None,
help="代理提取 API (可选; 注意换 IP 并不能解决 errorCode=12)")
+21 -5
View File
@@ -219,36 +219,52 @@ def split_collect_segments(plain: str) -> Tuple[str, str, str, str]:
sd = '"sd":{...}}'
前三段各用空格 pad 到 24 字节整数倍,sd 不 pad。
⚠️ 轨迹落在数组两端时,相邻那段的逗号按 **JSON.stringify 语义** 消失:
k == 0 -> chunk1 = '{"cd":[' (真机成功包实测)
k == n-1 -> chunk2 = '],' (2026-10-12 实测构建)
做法:先按模 24 算术列出**候选** pad,再对候选三元组做结构校验
(首段以 '{"cd":[' 开头、末段以 '"sd"' 开头、去掉 pad 后是合法 JSON)。
校验这一步不可省 —— 字符串里的普通空格同样会命中候选判据。
"""
data = plain.encode("latin1")
cands = _candidate_pads(data)
n_struct = n_json = 0
for (a1, b1), (a2, b2), (a3, b3) in itertools.combinations(cands, 3):
if not (b1 <= a2 and b2 <= a3):
continue
segs = [data[0:a1], data[b1:a2], data[b2:a3], data[b3:]]
# 段边界一定落在 JSON 记号边界上(3 份真机样本一致):
# chunk1 以 ',' 结尾;traj 是**一个**完整 JSON 值;chunk2 以 ',' 开头、以 '],' 结尾
if not (segs[0].startswith(b'{"cd":[') and segs[0].endswith(b',')
and segs[2].startswith(b',') and segs[2].endswith(b'],')
# 段边界一定落在 JSON 记号边界上:
# chunk1 = '{"cd":[' + 若干槽 + [',' 或 ''] —— 轨迹正好是第 0 槽时**没有那个逗号**
# (真机成功包实测:chunk1 就是 '{"cd":[',随后 17 个空格 pad)
# traj = **一个**完整 JSON 值
# chunk2 = [',' + 若干槽] + '],' —— 轨迹正好是最后 1 槽时**没有那个前导逗号**
if not (segs[0].startswith(b'{"cd":[')
and (segs[0].endswith(b',') or segs[0] == b'{"cd":[')
and segs[2].endswith(b'],')
and (segs[2].startswith(b',') or segs[2] == b'],')
and segs[3].startswith(b'"sd"')):
n_struct += 1
continue
try:
json.loads(segs[1].decode("latin1")) # traj 必须是独立可解析的 JSON 值
except Exception: # noqa: BLE001
n_struct += 1
continue
try:
obj = json.loads(b"".join(segs).decode("latin1"))
except Exception: # noqa: BLE001
n_json += 1
continue
if "cd" not in obj or "sd" not in obj:
n_json += 1
continue
return (segs[0].decode("latin1"), segs[1].decode("latin1"),
segs[2].decode("latin1"), segs[3].decode("latin1"))
raise ValueError(
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个);这份明文可能不是 4 段格式")
f"未能切出「四段加密」结构(候选 pad {len(cands)} 个,"
f"边界结构不符 {n_struct} 组,拼回 JSON 校验失败 {n_json} 组);"
f"这份明文可能不是 4 段格式,或生成侧拼出了非法 JSON")
def xtea_decrypt_bytes(data: bytes, key: List[int]) -> bytes:
+65 -21
View File
@@ -98,17 +98,31 @@ def default_trajectory() -> List[List[int]]:
def _sample_dt() -> int:
"""采样一个事件间隔(ms)。分布按真机成功样本拟合:浏览器合并事件会出现 0。"""
"""采样一个事件间隔(ms)——**按 6 条真机轨迹 / 360 个非首点 dt 实测重拟合**(2026-10-12)。
旧分布(账本里记为"旧拟合")是拿**一条**样本估的,偏差很大:它给了
≥50% 概率落在 0~16ms、约 6% 落在 41~200ms。真机实测分布是**紧密聚集在 16~17ms**:
0-4 0.28% 5-8 1.39% 9-12 6.39%
13-16 35.56% 17-20 43.61% 21-25 4.44%
26-40 2.78% 41-80 1.39% 81-200 2.22% >200 1.94%
(众数 17ms×126 / 16ms×93;均值 25.2,是被极少数长停顿拉高的)
判据:真机 **79%** 落在 13~20ms,而我们旧实现只有约 36% ——
"轨迹事件间隔"是服务端最便宜的一类时序特征,别再拿单条样本估分布。
"""
r = random.random()
if r < 0.50:
return random.randint(0, 16)
if r < 0.75:
return random.randint(17, 24)
if r < 0.90:
return random.randint(25, 60)
if r < 0.98:
return random.randint(61, 160)
return random.randint(161, 400)
if r < 0.0628: # ~6%:偏快的一档(9~12ms)
return random.randint(9, 12)
if r < 0.8472: # ~78%:主峰 13~20ms(真机 79%)
return random.randint(13, 20)
if r < 0.9000: # ~5%:21~25ms
return random.randint(21, 25)
if r < 0.9300: # ~3%:26~40ms
return random.randint(26, 40)
if r < 0.9700: # ~4%:41~200ms(偶尔的思考停顿)
return random.randint(41, 200)
return random.randint(201, 600) # ~3%:罕见的长时间歇
def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]:
@@ -117,16 +131,38 @@ def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]:
return 20 + x * scale, 60 + y * scale
REAL_TRAJECTORY_LEN = 61 # 真机硬不变量:6/6 条成功轨迹都是 61 点(见 _fit_points)
def _fit_points(events: List[List[int]]) -> List[List[int]]:
"""把轨迹**补齐/截断到真机的 61 点**,末尾再钉上固定收尾 `[1,1,12]`。
为什么点数是硬约束:4 份真机抓包 / **6 条点选成功轨迹**,点数**统统是 61**
(`点选.har` 里 3 次提交也一样),一条都不差 —— 这是服务端最容易查的一类
结构特征,而旧实现每次随机 57~65 点,等于每发一枪都自带一个异常值。
⚠️ 补齐必须用 `[0,0,dt]`(驻留/微抖动),**不能**复制最后一个 `[dx,dy,dt]` ——
复制会把位移量重复计入,净位移被放大(实测 166 vs 期望 139.5)。
"""
body = [e for e in events if e != [1, 1, 12]]
target = REAL_TRAJECTORY_LEN - 1
if len(body) >= target:
body = body[:target]
else:
while len(body) < target: # 驻留/微抖动:位移为 0
body.append([0, 0, _sample_dt()])
return body + [[1, 1, 12]]
def trajectory_points(click_points: Sequence[Sequence[int]],
img_w: int = 672) -> List[List[int]]:
"""按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。
形状与真机一致(3 份样本逐字核对):
* 首点是**绝对坐标**(含 300~600ms 初始停顿)
* 之后全是**增量** `[dx,dy,dt]`
* **最后一点固定是 `[1,1,12]`** —— 真机成功样本与"没动鼠标"样本都这么收尾
(没动鼠标时整条轨迹就是 `[[1,1,12]]`)
dt 分布按成功样本实测拟合(旧 crypto._success_style_trajectory 的统计)。
形状与真机一致(真机 6 条逐条核对,2026-10-12):
* 首点是**绝对坐标**(含一个"进入前"的停顿,真机实测 7~14766ms)
* 之后全是**增量** `[dx,dy,dt]`,dt 分布见 `_sample_dt`
* **最后一点固定是 `[1,1,12]`**
* **总点数固定 61**(真机 6/6 硬不变量,见 `_fit_points`)
"""
scale = 320.0 / img_w
targets = [(int(round(20 + x * scale)), int(round(60 + y * scale)))
@@ -143,8 +179,7 @@ def trajectory_points(click_points: Sequence[Sequence[int]],
cur = [int(nx), int(ny)]
for _ in range(random.randint(2, 5)): # 命中后短暂驻留
events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()])
events.append([1, 1, 12]) # 固定收尾(真机如此)
return events
return _fit_points(events)
def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
@@ -152,7 +187,11 @@ def drag_trajectory_points(start: Sequence[float], end: Sequence[float],
"""滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。
与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标),
同样以固定 `[1,1,12]` 收尾。速度曲线用 ease-out:人手拖动是先快后慢、末尾微调。
同样以固定 `[1,1,12]` 收尾、dt 同一套分布。速度曲线用 ease-out:
人手拖动是先快后慢、末尾微调。
⚠️ **拖动轨迹不套 61 点**:那个硬不变量是从**点选**轨迹量出来的(6/6),
拖动是另一种交互(按下→横移→松手),点数按 steps 走。别外推。
"""
sx, sy = _to_display(float(start[0]), float(start[1]), img_w)
ex, ey = _to_display(float(end[0]), float(end[1]), img_w)
@@ -374,9 +413,14 @@ def build_plain(cd: Sequence[Any], rows: Sequence[Dict],
head = ",".join(js(v) for v in cd[:k])
tail = ",".join(js(v) for v in cd[k + 1:])
return {
"chunk1": '{"cd":[' + head + ",",
# ⚠️ 逗号是**槽与槽之间**的分隔符(真机是 JSON.stringify 语义),所以两端的空区间
# 都**不能**再补分隔逗号,否则会拼出 `{"cd":[,` 或 `...,],` 这种非法 JSON。
# · 轨迹正好是第 0 槽 -> chunk1 就是 '{"cd":['(真机成功包实测,见账本已知 #58)
# · 轨迹正好是最后 1 槽 -> chunk2 就是 '],'(38 槽里 k=37 的构建实测,
# 2026-10-12;此前这里恒补前导逗号 -> 拼出非法 JSON)
"chunk1": '{"cd":[' + (head + "," if head else ""),
"trajectory": js(cd[k]),
"chunk2": "," + tail + "],",
"chunk2": ("," + tail + "],") if tail else "],",
"sd": '"sd":' + js(sd if sd is not None else {"od": "C"}) + "}",
}
+61 -1
View File
@@ -132,14 +132,74 @@ def test_offline_generate_and_roundtrip() -> None:
len(plain["chunk2"]), len(plain["sd"]))
def test_build_plain_trajectory_first() -> None:
"""轨迹就在第 0 槽时,chunk1 里没有任何槽 —— 不能再补分隔逗号。
实测触发场景:某些构建的清单里轨迹模块排在第 0 位,那样会拼出 `{"cd":[,`
—— 非法 JSON,服务端返回 errorCode=12。
"""
import json as _json
from tcaptcha.telemetry.values import build_plain, trajectory_index
rows = [{"name": "trajectory"}, {"name": "platform"}, {"name": "constant_98k"}]
assert trajectory_index(rows) == 0
cd = [[[1, 1, 12]], "MacIntel", "98k"]
p = build_plain(cd, rows)
assert p["chunk1"] == '{"cd":[', repr(p["chunk1"])
assert _json.loads(p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"])["cd"] == cd
# 轨迹在中间时,chunk1 仍必须以 ',' 结尾(真机实测口径)
rows2 = [{"name": "platform"}, {"name": "trajectory"}, {"name": "constant_98k"}]
p2 = build_plain(["MacIntel", [[1, 1, 12]], "98k"], rows2)
assert p2["chunk1"].endswith(",") and p2["chunk2"].startswith(",")
def test_build_plain_trajectory_last() -> None:
"""轨迹就在**最后 1 槽**时,chunk2 里没有任何槽 —— 不能再补前导逗号。
实测触发场景(2026-10-12):38 槽里有构建把轨迹排在 k=37(最后),
旧写法恒拼 `,],` -> 整段变成 `{"cd":[...,],` 非法 JSON。
本题同时钉住「切分器必须接受 `],` 这种对称边界」。
"""
import base64 as _b64
import json as _json
from tcaptcha.telemetry.crypto import DEFAULT_KEY
from tcaptcha.telemetry.values import build_plain, trajectory_index
rows = [{"name": "platform"}, {"name": "constant_98k"}, {"name": "trajectory"}]
assert trajectory_index(rows) == 2
cd = ["MacIntel", "98k", [[1, 1, 12]]]
p = build_plain(cd, rows)
assert p["chunk2"] == "],", repr(p["chunk2"])
whole = p["chunk1"] + p["trajectory"] + p["chunk2"] + p["sd"]
assert _json.loads(whole)["cd"] == cd
# 旧写法(恒补前导逗号)拼出来的确实是非法 JSON —— 这正是当年 12 的成因之一
bad = p["chunk1"] + p["trajectory"] + "," + p["chunk2"] + p["sd"]
try:
_json.loads(bad)
raise AssertionError("旧写法应当拼出非法 JSON")
except ValueError:
pass
# 走完整加密 -> 解密 -> 切分:切分器要吃下 `],` 这种边界
collect = build_collect(p["chunk1"], p["trajectory"], p["chunk2"], p["sd"], DEFAULT_KEY)
dec = xtea_decrypt_bytes(_b64.b64decode(collect), DEFAULT_KEY).decode("latin1").rstrip("\x00")
assert split_collect_segments(dec) == (p["chunk1"], p["trajectory"], p["chunk2"], p["sd"])
def main() -> int:
selfcheck()
test_split_single_space_pad()
test_split_ignores_space_inside_string()
test_build_collect_roundtrip_single_space_pad()
test_offline_generate_and_roundtrip()
test_build_plain_trajectory_first()
test_build_plain_trajectory_last()
print("✅ crypto 纯算法自检通过 (XTEA 往返 + encrypt_string 一致性"
" + 四段切分回归 + 纯离线闭环)")
" + 四段切分回归 + 纯离线闭环 + 轨迹首/末槽边界)")
return 0