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:
@@ -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 拉开间隔。
|
||||
@@ -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`。
|
||||
|
||||
@@ -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))
|
||||
@@ -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,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
|
||||
|
||||
|
||||
@@ -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)")
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"}) + "}",
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user