From c55f833c66d18710433e738ec5d4b40d5cdfca8a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 11 Oct 2026 23:59:42 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=AF=8A=E6=96=AD):=20=E7=94=A8=E7=9C=9F?= =?UTF-8?q?=E6=9C=BA=E5=A4=8D=E7=8E=B0=E8=A7=A3=E5=87=BA=201ms=20=E6=88=90?= =?UTF-8?q?=E5=8A=9F=E4=BC=9A=E8=AF=9D=E7=9A=84=20collect=20=E6=98=8E?= =?UTF-8?q?=E6=96=87=20=E2=80=94=E2=80=94=20=E5=BD=92=E5=9B=A0=E6=94=B9?= =?UTF-8?q?=E5=86=99=E4=B8=BA=E3=80=8Ccd=20=E6=A7=BD=E4=BD=8D=E5=BA=8F?= =?UTF-8?q?=E6=8C=89=E4=BC=9A=E8=AF=9D=E9=9A=8F=E6=9C=BA=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 结论 (已知 #29~#32, 推翻旧 #25/#27 与"collect 逐字节同构"这条否定实验): * 同一个 tdc.js URL 每次请求返回不同构建 (4/4 md5 + XTEA 密钥全不同) * 同会话同时抓 tdc.js + verify -> 解密出真机 collect 明文 (784B/38 槽, 可打印率 .991) * 5 份同日样本 (真机 cap2 / 签到成功-1 / 点选 / login_checkin / 仓库模板) 全部 38 槽、 同一批取值、同一个 sd, 但槽位序 5/5 互不相同 * 仓库在用的模板槽位序 == 签到成功-1 那一次; generate_collect_success() 只回填 轨迹/时间戳/entry_url, 槽位序仍是那一次的 => 头号可证伪假设 H-LAYOUT: 服务端按"本次会话构建"的槽位表解释 cd, 用别的会话的槽位序 会被读成自相矛盾的环境 -> 12 (答案错理应是 50/9)。这也解释了为何换 IP / 换遥测后端 / 换传输指纹 / 换 cookie / 长静默全都无效 —— 它们都不动槽位序。 新增: * scripts/analyze_collect_layout.py —— collect 槽位序对比 (复现 #31) * tcaptcha/slider_solver.py + test —— 滑块缺口求解 (ZNCC, 真机首轮命中 ret=0) * tcaptcha/icon_click_solver.py + test —— 图文点选形状模板求解 * scripts/bridge_1ms_captcha.py —— 真机桥接三模式 (slider/icon/text) + --probe 题型侦察 * tools/cdp_eval.js / cdp_click.js / cdp_shot.js / cdp_net_capture.js / cdp_capture.js * selftest 纳入 icon_click / slider 两个套件 账本: §一 修正 #25/#27、新增 #29~#32; §三 标注"collect 逐字节同构"否定实验有缺陷; §五 重写 12 的归因与下一步 (决定性实验 A + 取槽位表两条路) --- har/1ms_dianxuan/PROGRESS.md | 57 ++- scripts/analyze_collect_layout.py | 182 ++++++++ scripts/bridge_1ms_captcha.py | 390 ++++++++++++++++++ tcaptcha/icon_click_solver.py | 204 +++++++++ tcaptcha/selftest.py | 2 + tcaptcha/slider_solver.py | 226 ++++++++++ .../tests/fixtures/1ms_icon_click_1_bg.jpg | Bin 0 -> 26866 bytes .../fixtures/1ms_icon_click_1_prompt.jpg | Bin 0 -> 1855 bytes tcaptcha/tests/fixtures/1ms_slider_1_bg.jpg | Bin 0 -> 15385 bytes .../tests/fixtures/1ms_slider_1_sprite.png | Bin 0 -> 25911 bytes tcaptcha/tests/test_icon_click_solver.py | 73 ++++ tcaptcha/tests/test_slider_solver.py | 115 ++++++ tools/cdp_capture.js | 118 ++++++ tools/cdp_click.js | 94 +++++ tools/cdp_eval.js | 110 +++++ tools/cdp_net_capture.js | 81 ++++ tools/cdp_shot.js | 77 ++++ 17 files changed, 1716 insertions(+), 13 deletions(-) create mode 100644 scripts/analyze_collect_layout.py create mode 100644 scripts/bridge_1ms_captcha.py create mode 100644 tcaptcha/icon_click_solver.py create mode 100644 tcaptcha/slider_solver.py create mode 100644 tcaptcha/tests/fixtures/1ms_icon_click_1_bg.jpg create mode 100644 tcaptcha/tests/fixtures/1ms_icon_click_1_prompt.jpg create mode 100644 tcaptcha/tests/fixtures/1ms_slider_1_bg.jpg create mode 100644 tcaptcha/tests/fixtures/1ms_slider_1_sprite.png create mode 100644 tcaptcha/tests/test_icon_click_solver.py create mode 100644 tcaptcha/tests/test_slider_solver.py create mode 100644 tools/cdp_capture.js create mode 100644 tools/cdp_click.js create mode 100644 tools/cdp_eval.js create mode 100644 tools/cdp_net_capture.js create mode 100644 tools/cdp_shot.js diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md index 964c888..a9603ae 100644 --- a/har/1ms_dianxuan/PROGRESS.md +++ b/har/1ms_dianxuan/PROGRESS.md @@ -55,10 +55,14 @@ | 22 | **纯 Python 静态提取 XTEA 密钥成功,零 Node** | `tcaptcha/tdc_key.py`:6/6 份构建与 Node 插桩 `extract_xtea_key` **逐字一致**;含 2026-10-11 现场新抓的 `tdc.js`(`har/1ms_dianxuan/live/tdc_fresh.js`,77967B,密钥 `[1397244740,1799507557,1399740484,1516463189]`)| 确证 | | 23 | **纯 Python 密钥能解密 1ms 真实成功样本的 collect** | 用 `har/签到成功-1.har` 里那次 ec=0 之前的 tdc.js 提密钥 → 解出该次 collect 明文 `{"cd":[2560,"MacIntel",...` 1353B;且与 `models/success_collect_template.b64` **逐字节同构**(仅轨迹/时间戳/盐被回填)| 确证 | | 24 | **1ms 真实成功链路已完整解析** | prehandle(带腾讯云登录 cookie) → tdc.js → getcapbysig → verify **ec=0**(ticket `tr03…`) → `POST /api/v1/mall/checkin`,body = `{captchaTicket:{provider,data:{ticket,randstr}}, sessionUuid, identity_context}`;`/api/v1/captcha/params` 的 body 带 `identity_context`(`emctx1..`) | 确证 | -| 25 | **真实浏览器对 verify 从不吃 12** | `点选.har`(同一 cookie/同一 aid) 三次 verify = 50/50/9;`签到成功-1.har` = 0。而我们**任何**客户端形态都是 12 → 12 是"请求方身份"判定,不是答案/载荷 | 确证 | +| 25 | **真实浏览器对 verify 从不吃 12** | `点选.har`(同一 cookie/同一 aid) 三次 verify = 50/50/9;`签到成功-1.har` = 0。而**当时的**客户端形态都是 12。⚠️ 2026-10-11 晚修复:该结论把"载荷"只理解成"答案",漏掉了 **`cd` 槽位序**(见已知 #31/#32),表述已过强 | 证据成立 / 归因已修正 | | 26 | **纯算链路在真实 aid 上端到端成功出 ticket**(2026-10-11 实测)| 用新抓包 `har/原始抓包/登录抓包-点选.har` 的 aid=**190758559**(mliev.login.wang 短信登录),本客户端 `solve()` 返回 `errorCode=0` + 真 ticket:
`tr03lxdviLWtn4uXGjWjmrLQ` / `tr03A5zTSQ9bCog51sDm` / `tr039KeeQxXX-OdduJoY`。**关键:最原始的 httpx + 无 cookie 也 0** | 确证 | -| 27 | **1ms 的 12 是该 aid 自身的风控策略,不是我们的请求侧差异** | 同一天同一台机器:aid 190758559 用 httpx/无 cookie/无头保真 → 0;aid 195672344 用 curl_cffi(Chrome 指纹)+真机 cookie+逐 endpoint 头保真 → 仍 12。→ 请求侧已无自变量(§2.1 第 0 问:服务端就是不放行这个 aid 来自非真机页面的会话)| 确证 | -| 28 | 新抓包暴露另一套业务接入(可复现靶子)| `har/原始抓包/登录抓包-点选.har`:`POST /api/captcha/params`(scene=sendSms + fingerprint/userAgent/platform/language/timezone) → 腾讯点选 → `POST /api/oidc/send-register-verification-code`(`{type,receiver,captchaTicket:{provider,data:{ticket,randstr,fingerprint}}, sessionUuid}`) | 确证 | +| 27 | **1ms 的 12 与"请求侧保真度"无关**(原结论"12 是该 aid 的服务端策略"已过强) | 成立部分:aid 190758559 上未做任何保真的 httpx + 无 cookie → 0;aid 195672344 上 curl_cffi + 真机 cookie + 逐 endpoint 头保真 + 传输指纹 → 仍 12。→ **请求侧细节不是自变量**(§三 反证仍有效)。
不成立部分:把 12 归因为"服务端不放行该 aid"缺少机制;现在有**更强且可证伪**的解释 → 见 #31/#32(collect 槽位序与本次会话构建不匹配)| 部分成立 / 归因待 #31 验证 | +| 28 | 新抓包暴露另一套业务接入(可复现靶子)| `har/原始抓包/登录抓包-点选.har`:`POST /api/captcha/params`(scene=sendSms + fingerprint/userAgent/platform/language/timezone) → 腾讯点选 → `POST /api/oidc/send-register-verification-code`(`{type,receiver,captchaTicket:{provider,data:{ticket,randstr,fingerprint}},sessionUuid}`) | 确证 | +| 29 | **同一个 `tdc.js` URL 每次返回的构建都不同**(不是"按构建固定",而是**按请求随机化**)| 连抓 4 次 `tdc.js?app_data=…&t=…`:md5 = `b6db2555…` / `032afea5…` / `1b8b7d4d…` / `b1ad4bdd…`,四份长度 77917~78013B、**XTEA 密钥 4 个全不同**。→ 结论 #3"密钥按构建固定"要限定为"**同一份已下载的脚本**内固定",不能外推到"重新请求还是同一份" | 确证 | +| 30 | **真机某次成功会话的 collect 明文已完整解出** | 抓包同时拿到**同会话**的 `tdc.js`(`app_data=1793575418729725952`)与 `cap_union_new_verify` 的 collect,用该构建密钥解密:784B 密文 → 可打印率 0.991,得
`{"cd":["",[360,360],0,"Mozilla/5.0 (Macintosh…) Chrome/155…","https://turing.captcha.gtimg.com/1/template/drag_ele.html?rand=…",["zh-CN","zh"],641547489,1791732001,"GgoAAA…png…",2560,[[34,96,3284],[154,50,370],…],1023,1440,"+08",1,0,"ANGLE (Apple, ANGLE Metal Renderer: Apple M4, …)",10,"",1,1986013704,"iframe","MacIntel",0,1791733986,"1440-2560-2530-24-*-*-|-*",1791733982,"Google Inc. (Apple)",24,"",[],0,"https://1ms.run/console/personal/benefits/check-in","98k",2,0,0,"UTF-8"],"sd":{"od":"C","ft":"qf_7Pf__H"}}`
(`tdc.js?app_data=…&appid=`:真机 URL 带 `appid`,我们客户端不带)| 确证 | +| 31 | **`cd` 的槽位顺序按会话/构建随机化(不是按版本固定)** | 5 份同日样本(真机 cap2 / `签到成功-1` / `点选` / `login_checkin` / 仓库模板)全部 `len(cd)=38`、**同一批取值**、**同一个 `sd`**,但**槽位顺序 5/5 互不相同**(两两不同槽位 26~29 个)。复现:`.venv/bin/python scripts/analyze_collect_layout.py --json .json` | 确证 | +| 32 | **仓库在用的 collect 模板 = `签到成功-1.har` 那一次的槽位序** | 模板指纹与 `签到成功-1#0` **逐槽一致**;与晚些时候的真机会话 29 个槽不同。→ `generate_collect_success()` 虽然回填了新轨迹/时间戳,但**槽位序是那一次的** | 确证 | --- @@ -92,7 +96,7 @@ | **H3(captcha 域腾讯云登录 cookie)能解决 12** | 用**抓包里的真实 cookie 值**(uin/skey/qcmainCSRFToken/intl)重跑,仍 12(`scripts/experiment_h3_cookie_ab.py --only b`,2026-10-11)→ 与早期结论一致,H3 确凿排除 | | **HTTP/TLS 传输指纹是 12 的自变量** | `curl_cffi`(impersonate=chrome: JA3/JA4 + HTTP/2 + Chrome 头序) + 真实 cookie,仍 12(`scripts/experiment_transport_ab.py --only b`)→ 传输层排除 | | **请求头保真度是 12 的自变量** | 按真实抓包逐 endpoint 保真:资源 GET 用 `Sec-Fetch-Dest=script/image` + `Mode=no-cors` + `Storage-Access=active` + cookie,POST 用 `empty/cors` + Origin 且**不带** cookie;仍 12 | -| **collect 明文结构/取值是 12 的自变量** | 我们的 collect 明文与 1ms 真实成功样本**逐字节同构**(同 1353B、同字段序、同取值,仅回填轨迹/时间戳/盐),仍 12 | +| **collect 明文结构/取值是 12 的自变量** | ~~我们的 collect 明文与 1ms 真实成功样本逐字节同构(同 1353B、同字段序、同取值,仅回填轨迹/时间戳/盐),仍 12~~ ⚠️ **该否定实验本身有缺陷(2026-10-11 晚推翻)**:所谓"同字段序"只是与**模板来源的那一次**样本比,而 `cd` 槽位序**按会话随机**(已知 #31),所以它没检验到真正会变的维度。修正后重开为:**"cd 槽位序必须与本次会话构建一致"是 12 的头号可疑自变量**(已知 #32)| | **H5:1ms 后端用 `identity_context` 把会话标记为可信** | 拿抓包里**签名过的** `identity_context`(599B `emctx1.*`) 先调 `POST /api/v1/captcha/params`(响应与不带时**完全相同**,无额外 token)再解题,仍 12 → H5 排除 | | **"请求侧保真度不够"这一整类** | 直接反证:aid 190758559 上 **未做任何保真的 httpx + 无 cookie** 也拿到 ticket(0);即请求侧细节本就足够,12 与保真度无关 | @@ -127,14 +131,36 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 残留增强项(非阻塞):① 若服务端改了 handler 语义,`tdc_opcodes.derive_opcode_table` 会抛"指纹未识别",此时应更新参考指纹而非改定位逻辑;② 抗"多套诱饵数组 >2 层捕获"的 兜底(当前两层已覆盖实测全部 6 份)。 -1. **拿下 1ms 的 12 —— 结论已闭合:不是我们能修的请求侧问题**(2026-10-11) - * 证据链:① 同一份代码在 aid **190758559** 上 `errorCode=0` 出真 ticket(且 **httpx+无 cookie** 也 0); - ② 1ms aid **195672344** 在 cookie / 传输指纹 / 头保真 / collect 内容 / `identity_context` - 全部单变量排除后**恒为 12**;③ 真机同 aid 只出 50/9/0。 - * 即 **1ms 这个 aid 的风控策略不放行"非真机页面"的会话**(§2.1 第 0 问:服务端就是判这个)。 - * 因此只剩两条路,且都不在"协议纯算"范围内: - (a) **桥接保底**(§2.4 ③):在**真机页面**里跑一次拿 ticket,再走纯算业务提交; - (b) 换一个风控更宽的入口/aid(如 190758559 那种),或与 1ms 沟通白名单。 +1. **拿下 1ms 的 12 —— 归因已改写(2026-10-11 晚,见已知 #29~#32)** + 旧归因"1ms 就是不放行非真机页面的会话"**证据不足**,已被下面这条更可证伪的链取代: + + * 硬事实: + ① 同一个 tdc.js URL **每次请求都返回不同构建**(4/4 md5 与密钥全不同,已知 #29); + ② 真机某次成功会话的 collect 明文已**完整解出**(已知 #30); + ③ 5 份同日样本的 `cd` 都是 38 槽、同一批值、同一个 `sd`,但**槽位序 5/5 互不相同**(已知 #31); + ④ 仓库在用的模板槽位序 == `签到成功-1.har` 那一次(已知 #32),而 `generate_collect_success()` + 只回填轨迹/时间戳/entry_url —— **槽位序仍是那一次的**。 + + * **H-LAYOUT(头号假设,可证伪)**:服务端按"**本次会话构建**"的槽位表解释 `cd`。 + 我们用**别的会话的槽位序**发出的 collect 会被读成"自相矛盾的环境" + (如 `cd[1]` 该是 `[360,360]` 却是 `"MacIntel"`)→ 风控判 **12**,与答案对错无关 + (答案错理应是 50/9)。 + 这条同时解释了旧账本的所有负结果:换 IP / 换遥测后端 / 换传输指纹 / 换 cookie / 长静默 + **都不动槽位序**;而宽松 aid(190758559 / 雨云)**不交叉校验布局**,所以 0。 + + * 下一步三件事(按顺序做): + (a) **决定性实验 A(最便宜,先做)**:真机页面里 hook 掉 `cap_union_new_verify` 的发送, + 让 widget 把 collect **组好但不发**;再把这一包原样交给 **Python** 发。 + 若 Python 也 0 → "谁在发 HTTP"彻底无关,12 只剩"包内容"一个自变量 → 坐实 H-LAYOUT。 + 若 Python 仍 12(而同一包在浏览器里是 0)→ 说明还有"会话↔连接"绑定,需换实验 A'。 + (b) **拿到本次构建的槽位表**(二选一): + ① 神谕路(快):`tools/tdc_probe.js` / `tdc_keytrace.js` 在 JS 运行时 hook XTEA 输入, + dump 出**本次构建的明文骨架** → 得槽位序;Python 按该序回填。 + ② 纯算路(慢,但符合项目"零环境"目标):用 `tools/tdc_disasm.py` / `tdc_lift.py` + 反汇编构造 `cd` 的字节码段,还原 "push 顺序 ↔ 语义字段" 映射表。 + (c) **验证**:collect 按 (b) 的槽位序重排后重发 195672344,期望 **12 → 0**。 + 注意:既然 tdc.js 按请求随机(#29),Python 客户端必须用**它自己那次 prehandle 拿到 sid + 之后请求的那份 tdc.js** 的槽位表,不能用缓存/模板。 2. **可复现靶子(新增资产)**:aid 190758559 / `mliev.login.wang` 短信登录 —— **可反复测**, 用来回归"纯算链路是否完好"(1ms 每天只能试一次,不适合做回归): ```bash @@ -147,7 +173,9 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 c.close() PY ``` -3. **补齐新版明文字段语义**:对照 10+ 份实时模板做差分。 +3. **补齐新版明文语义 + 槽位表**:语义(哪一槽是 UA / 屏幕串 / WebGL / 轨迹 / 时间戳)已从 + 已知 #30 的真机明文基本对齐;**关键是每份构建的槽位序**(已知 #31)—— + 见上面 1(b)。分析入口:`scripts/analyze_collect_layout.py`。 4. **实现 2408 图标点选**:按 1ms 图标类别重训 `yolo_detector` + `icon_classifier`。 5. **实现滑块 (2401/2402)**:模板匹配找缺口 + 生成滑动轨迹(1ms 大幅下发滑块,见抓包)。 @@ -161,8 +189,11 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 .venv/bin/python -m tcaptcha.tests.test_xtea_key # XTEA + 密钥提取 (逐字节对拍, Node 参与) .venv/bin/python -m tcaptcha.tests.test_tdc_key # **纯 Python** 密钥提取 (6 份构建, 零 Node) .venv/bin/python -m tcaptcha.tests.test_glyph_solver # 文字点选求解 (8 张真实样本) +.venv/bin/python -m tcaptcha.tests.test_icon_click_solver # 图文点选求解 (真机样本) +.venv/bin/python -m tcaptcha.tests.test_slider_solver # 滑块缺口求解 (真机样本) .venv/bin/python -m tcaptcha.selftest # 全部 .venv/bin/python tools/tdc_key_pure.py -v har/1ms_dianxuan/live/tdc_fresh.js # 单份构建 + 推导过程 .venv/bin/python tools/tdc_key_pure.py --opcodes tcaptcha/tdc_sample.js # 打印按构建推导的 opcode 表 +.venv/bin/python scripts/analyze_collect_layout.py --json /tmp/cap.json # collect 槽位序对比 (已知 #31) ``` diff --git a/scripts/analyze_collect_layout.py b/scripts/analyze_collect_layout.py new file mode 100644 index 0000000..dd315b7 --- /dev/null +++ b/scripts/analyze_collect_layout.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +""" +collect 明文「字段布局」分析器 —— 回答一个关键问题: + + `collect` 解密后的 `cd` 数组, 槽位顺序到底是"按 tdc.js 版本固定"还是"按会话/构建随机"? + +为什么要问这个 (2026-10-11 排查 errorCode=12): + tcaptcha/models/success_collect_template.b64 存的是**明文**模板 (base64 封装), + generate_collect_success() 只回填 轨迹 / entry_url / 几个时间戳, 其余字段原样保留。 + 真机成功样本解密后发现: **同一批值, 顺序完全不同** —— 若服务端按"本次构建的槽位表" + 解释 cd, 那么我们用旧槽位序发出的 collect 会被读成"自相矛盾的环境" -> 12。 + +本脚本把多份样本 (HAR + CDP 抓包 + 仓库模板) 的 cd 槽位打成类型指纹并对比顺序。 + +用法: + .venv/bin/python scripts/analyze_collect_layout.py # 默认样本集 + .venv/bin/python scripts/analyze_collect_layout.py --json /tmp/cap2.json +""" +from __future__ import annotations + +import argparse +import base64 +import json +import os +import re +import sys +import urllib.parse +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tcaptcha.crypto import xtea_decrypt_bytes # noqa: E402 +from tcaptcha.tdc_key import extract_xtea_key_pure # noqa: E402 + +HARS = [ + ("har/原始抓包/签到成功-1.har", "签到成功-1"), + ("har/原始抓包/点选.har", "点选"), + ("har/login_checkin.har", "login_checkin"), +] + + +def _decrypt_collect(collect_b64: str, tdc_js: str) -> Optional[dict]: + key = extract_xtea_key_pure(tdc_js)["key"] + pt = xtea_decrypt_bytes(base64.b64decode(collect_b64), key) + text = pt.decode("latin1").rstrip("\x00").strip() + try: + return json.loads(text) + except Exception: + return None + + +def from_har(path: str) -> List[Tuple[str, dict]]: + if not os.path.exists(path): + return [] + data = json.load(open(path, encoding="utf-8")) + tdcs: Dict[str, str] = {} + sess2sid: Dict[str, str] = {} + verifies: List[dict] = [] + for e in data["log"]["entries"]: + url = e["request"]["url"] + content = (e["response"].get("content") or {}).get("text") or "" + if "tdc.js" in url and content: + q = urllib.parse.parse_qs(urllib.parse.urlsplit(url).query) + tdcs[(q.get("app_data") or [""])[0]] = content + elif "cap_union_prehandle" in url and content: + m = re.search(r"^[^(]*\((.*)\)\s*$", content, re.S) + if m: + try: + j = json.loads(m.group(1)) + sess2sid[j.get("sess", "")] = str(j.get("sid", "")) + except Exception: + pass + elif "cap_union_new_verify" in url: + pd = (e["request"].get("postData") or {}).get("text") or "" + if pd: + verifies.append(dict(urllib.parse.parse_qsl(pd, keep_blank_values=True))) + out = [] + for i, v in enumerate(verifies): + js = tdcs.get(sess2sid.get(v.get("sess", ""), "")) + if not js: + continue + j = _decrypt_collect(v["collect"], js) + if j is not None: + out.append((f"{Path(path).name}#{i}", j)) + return out + + +def from_cdp_capture(path: str) -> List[Tuple[str, dict]]: + if not os.path.exists(path): + return [] + d = json.load(open(path, encoding="utf-8")) + recs = d.get("records") or [] + tdcs = [(r["url"], r.get("body") or "") for r in recs if "tdc.js" in r["url"] and r.get("body")] + vers = [r for r in recs if "verify" in r["url"] and r.get("postData")] + out = [] + for i, v in enumerate(vers): + form = dict(urllib.parse.parse_qsl(v["postData"], keep_blank_values=True)) + sess = form.get("sess", "") + # sess 前 2 段与 tdc.js 的 app_data(sid) 无直接映射, 这里按时间就近取**最后一份**构建 + js = tdcs[-1][1] if tdcs else "" + if not js: + continue + j = _decrypt_collect(form["collect"], js) + if j is not None: + out.append((f"{Path(path).name}#{i}", j)) + return out + + +def slot_tags(cd: List) -> List[str]: + tags = [] + for v in cd: + if isinstance(v, str): + if v.startswith("http"): + tags.append("url") + elif "Mozilla" in v: + tags.append("UA") + elif v.startswith("GgoAAA"): + tags.append("png") + elif re.fullmatch(r"[-+]\d\d", v): + tags.append("tz") + elif v == "": + tags.append("empty") + elif len(v) <= 6 and v.isupper(): + tags.append("enc:" + v) + else: + tags.append("str:" + v[:10]) + elif isinstance(v, list): + if len(v) == 2 and all(isinstance(x, int) for x in v): + tags.append("vec2") + elif v and isinstance(v[0], list): + tags.append("traj") + else: + tags.append("list%d" % len(v)) + elif isinstance(v, bool): + tags.append("bool") + else: + tags.append("num") + return tags + + +def main() -> int: + ap = argparse.ArgumentParser(description="collect 明文字段布局分析") + ap.add_argument("--json", action="append", default=[], help="额外加入的 cdp_capture JSON") + args = ap.parse_args() + + samples: List[Tuple[str, dict]] = [] + for p in args.json: + samples += from_cdp_capture(p) + for p, tag in HARS: + for name, j in from_har(str(ROOT / p)): + samples.append((f"{tag}#{name.split('#')[-1]}", j)) + tpl_path = ROOT / "tcaptcha" / "models" / "success_collect_template.b64" + if tpl_path.exists(): + tpl = json.loads(base64.b64decode(tpl_path.read_text(encoding="ascii")) + .decode("latin1").rstrip("\x00").strip()) + samples.append(("TEMPLATE(仓库在用)", tpl)) + + if not samples: + print("没有可用样本") + return 1 + + for name, j in samples: + tags = slot_tags(j["cd"]) + print(f"\n[{name}] len(cd)={len(j['cd'])} sd={j.get('sd')}") + print(" " + " ".join(f"{i}:{t}" for i, t in enumerate(tags))) + + print("\n=== 槽位指纹两两对比 (看顺序是否随会话变化) ===") + sigs = [(n, slot_tags(j["cd"])) for n, j in samples] + base_name, base = sigs[0] + print(f" 基准 = {base_name}") + for n, s in sigs[1:]: + diff = [i for i in range(max(len(base), len(s))) + if (base[i] if i < len(base) else None) != (s[i] if i < len(s) else None)] + print(f" {n:22s} 顺序相同={s == base} 不同槽位数={len(diff)}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/bridge_1ms_captcha.py b/scripts/bridge_1ms_captcha.py new file mode 100644 index 0000000..0ad0ff8 --- /dev/null +++ b/scripts/bridge_1ms_captcha.py @@ -0,0 +1,390 @@ +# -*- coding: utf-8 -*- +""" +1ms 验证码「真机桥接」驱动 —— 在**真实 Chrome 页面**里完成腾讯滑块/点选, 拿回 ticket。 + +为什么需要桥接 (har/1ms_dianxuan/PROGRESS.md 已知 #26/#27): + aid=195672344 对该 aid 之外的会话恒返回 errorCode=12 (aid 级风控), 纯协议侧无解; + 但**真实浏览器页面**里能拿到 ec=0。所以这一层只负责"过验证码", 业务提交仍走纯 HTTPS。 + +它做什么 (每轮): + 1. 用 CDP 在真实页面里刷新腾讯验证码 widget (`__cap.refresh()`); + 2. 读 widget 内的题型/题面/几何 (滑块: slideBg + 拼图块 + 拖动柄; 点选: 提示图 + 背景图); + 3. 求解: + * 滑块 -> tcaptcha.slider_solver.SliderSolver (ZNCC 找真缺口) + * 图文点选 -> tcaptcha.icon_click_solver.IconClickSolver (形状模板 + 旋转 IoU) + 4. 用 CDP `Input.dispatchMouseEvent` 派发**真实(trusted)鼠标事件** (点击 / 拖动); + 5. 读回 widget 回调的 {ret, ticket, randstr}。 + +前置: + Chrome 以 `--remote-debugging-port=9222` 启动且已登录 1ms; + 页面里已注入 widget 实例: `window.__cap = new TencentCaptcha(aid, cb, {}); __cap.show()`。 + +用法: + .venv/bin/python scripts/bridge_1ms_captcha.py --rounds 12 + .venv/bin/python scripts/bridge_1ms_captcha.py --only slider --rounds 20 +""" +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +import time +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +import cv2 # noqa: E402 + +from tcaptcha.glyph_solver import TextClickSolver # noqa: E402 +from tcaptcha.icon_click_solver import IconClickSolver # noqa: E402 +from tcaptcha.slider_solver import SliderSolver, sprite_region # noqa: E402 + +NODE = "node" +CDP_EVAL = str(ROOT / "tools" / "cdp_eval.js") +CDP_CLICK = str(ROOT / "tools" / "cdp_click.js") +TMP = Path("/tmp/1ms_bridge") + +PAGE = "1ms.run" +IFRAME = "drag_ele" + +# 页面重载会丢掉注入的实例; 这个表达式负责"有就刷新, 没有就重建" +EXPR_ENSURE = """(() => { + try { + if (window.__cap && typeof window.__cap.refresh === 'function') { + window.__capres = null; window.__cap.refresh(); + return {mode: 'refresh'}; + } + if (typeof window.TencentCaptcha !== 'function') return {err: 'TencentCaptcha 未加载'}; + window.__capres = null; + window.__cap = new window.TencentCaptcha('%AID%', + function (res) { window.__capres = res; }, {}); + window.__cap.show(); + return {mode: 'inject'}; + } catch (e) { return {err: String(e)}; } +})()""".replace("%AID%", "195672344") + +EXPR_RESULT = "(()=>({res: window.__capres||null}))()" + +# 页面里可能有多个 tcaptcha iframe (隐藏的预加载 iframe 在 y=-1000000), 必须挑**可见**那个 +EXPR_FRAME = """(() => { + const cands = [...document.querySelectorAll('iframe[id^="tcaptcha"], iframe[src*="captcha"]')]; + const f = cands.find(e => { + const r = e.getBoundingClientRect(); + return r.width > 100 && r.height > 100 && r.top > -1000; + }); + if (!f) return {found:false, count: cands.length}; + const r = f.getBoundingClientRect(); + return {found:true, x:r.x, y:r.y, w:r.width, h:r.height}; +})()""" + +EXPR_GEO = """(() => { + const R = el => { const r = el.getBoundingClientRect(); + return {x:r.x, y:r.y, w:r.width, h:r.height}; }; + const urlOf = el => { const bi = getComputedStyle(el).backgroundImage || ''; + const m = /url\\(["']?([^"')]+)["']?\\)/.exec(bi); return m ? m[1] : null; }; + const out = { + instruction: ((document.getElementById('instructionText') || {}).textContent || '').trim(), + mode: null, + }; + // --- 图文点选: 提示条是 里 img_index=0, 背景是 img_index=1 --- + const all = [...document.querySelectorAll('img')]; + if (/依次点击|点击/.test(out.instruction)) { + const pr = all.map(i => i.src).find(s => (s || '').includes('img_index=0')); + const bgs = [...document.querySelectorAll('*')].filter(e => { + const bi = getComputedStyle(e).backgroundImage || ''; + return bi.includes('img_index=1') && e.getBoundingClientRect().width > 200; + }); + const bgEl = bgs[0]; + // 可见的「确定」按钮: 逐层找叶子文本为 确定/确认 且尺寸非 0 + const btn = [...document.querySelectorAll('div,span,button,a')].find(e => { + const t = (e.textContent || '').trim(); + const r = e.getBoundingClientRect(); + return (t === '确定' || t === '确认' || t === '提交') && r.width > 10 && r.height > 10; + }); + out.mode = pr ? 'icon' : 'text'; // 有提示图 = 图文点选; 纯文字题面 = 文字点选 + out.promptUrl = pr || null; + out.bgUrl = bgEl ? urlOf(bgEl) : null; + out.bgRect = bgEl ? R(bgEl) : null; + out.bgNatural = bgEl ? bgEl.getAttribute('style') : null; + out.okBtn = btn ? R(btn) : null; + return out; + } + // --- 滑块: slideBg 有 img_index=1 背景, 拼图块元素有 img_index=0 背景 --- + const bgEl = document.getElementById('slideBg'); + if (bgEl && urlOf(bgEl) && bgEl.getBoundingClientRect().width > 150) { + const st = getComputedStyle(bgEl); + out.mode = 'slider'; + out.bgUrl = urlOf(bgEl); + out.bgRect = R(bgEl); + out.bgCssSize = st.backgroundSize; + const pe = [...document.querySelectorAll('div')].find(e => { + const r = e.getBoundingClientRect(); + return (getComputedStyle(e).backgroundImage || '').includes('img_index=0') + && r.height > 30 && r.height < 140 && r.width > 30 && r.width < 140; + }); + if (pe) { + const ps = getComputedStyle(pe); + out.pieceRect = R(pe); + out.pieceBgPos = ps.backgroundPosition; + out.pieceBgSize = ps.backgroundSize; + out.pieceUrl = urlOf(pe); + } + const he = document.querySelector('.tc-slider-normal') || document.querySelector('.tc-fg-item.tc-slider-normal'); + if (he) out.handleRect = R(he); + return out; + } + return out; +})()""" + + +def cdp_eval(expr: str, match: str) -> dict: + p = subprocess.run([NODE, CDP_EVAL, "--port", "9222", "--match", match], + input=expr, capture_output=True, text=True, timeout=90) + if p.returncode != 0: + raise RuntimeError(f"cdp_eval 失败: {p.stderr.strip()[:300]}") + return json.loads(p.stdout) + + +def cdp_click(clicks) -> None: + spec = ";".join("%d,%d" % (round(x), round(y)) for x, y in clicks) + p = subprocess.run([NODE, CDP_CLICK, "--port", "9222", "--match", PAGE, + "--clicks", spec], capture_output=True, text=True, timeout=180) + if p.returncode != 0: + raise RuntimeError(f"cdp_click 失败: {p.stderr.strip()[:300]}") + + +def cdp_drag(x1: float, y1: float, x2: float, y2: float, steps: int = 34) -> None: + spec = "%.2f,%.2f,%.2f,%.2f,%d" % (x1, y1, x2, y2, steps) + p = subprocess.run([NODE, CDP_CLICK, "--port", "9222", "--match", PAGE, + "--drag", spec], capture_output=True, text=True, timeout=180) + if p.returncode != 0: + raise RuntimeError(f"cdp_drag 失败: {p.stderr.strip()[:300]}") + + +def download(url: str, path: Path) -> bool: + p = subprocess.run(["curl", "-s", "--max-time", "30", "-o", str(path), + "-H", "Referer: https://turing.captcha.gtimg.com/1/template/drag_ele.html", + url], capture_output=True, text=True) + ok = p.returncode == 0 and path.exists() and path.stat().st_size > 1000 + return ok + + +def parse_px(value: str) -> tuple: + """把 '345.06px 313.69px' / '-70.8333px -247.917px' 解析成 (x, y)。""" + nums = [] + for tok in str(value).replace("px", " ").split(): + try: + nums.append(float(tok)) + except ValueError: + pass + return (nums[0], nums[1]) if len(nums) >= 2 else (0.0, 0.0) + + +def solve_slider(geo: dict, tmp: Path, solver: SliderSolver): + """滑块: 返回 (from_xy, to_xy) —— 均为**顶层页面视口**坐标。""" + bg_path, sp_path = tmp / "sl_img1", tmp / "sl_img0" + if not download(geo["bgUrl"], bg_path) or not download(geo["pieceUrl"], sp_path): + return None, "图片下载失败" + bg = cv2.imread(str(bg_path)) + sprite = cv2.imread(str(sp_path), cv2.IMREAD_UNCHANGED) + if bg is None or sprite is None: + return None, "图片解码失败" + + bg_rect, piece_rect = geo["bgRect"], geo["pieceRect"] + scale = bg_rect["w"] / bg.shape[1] # 显示宽度 / 原图宽度 + piece_x_image = (piece_rect["x"] - bg_rect["x"]) / scale + piece_y_image = (piece_rect["y"] - bg_rect["y"]) / scale + + px, py = parse_px(geo["pieceBgPos"]) + sw, sh = parse_px(geo["pieceBgSize"]) + bx, by, bw, bh = sprite_region((px, py), (sw, sh), + (piece_rect["w"], piece_rect["h"]), sprite.shape[:2]) + crop = sprite[by:by + bh, bx:bx + bw] + if crop.size == 0: + return None, f"精灵图裁剪越界 {(bx, by, bw, bh)} vs {sprite.shape}" + + res = solver.solve(bg, crop, piece_x_image=piece_x_image, scale=scale, + piece_y_image=piece_y_image) + print(f" 滑块求解: ok={res.ok} {res.describe()}") + if not res.ok: + return None, "求解不可靠" + cv2.imwrite(str(tmp / "slider_annot.png"), SliderSolver.annotate(bg, res, bw, bh)) + + handle = geo.get("handleRect") + if not handle: + return None, "未找到拖动柄" + hx = handle["x"] + handle["w"] / 2.0 + hy = handle["y"] + handle["h"] / 2.0 + return (hx, hy, hx + res.dx_display, hy), None + + +def solve_icon(geo: dict, tmp: Path, solver: IconClickSolver): + """图文点选: 返回 (clicks, ) —— 均为**iframe 内**坐标, 由调用方加偏移。""" + pr_path, bg_path = tmp / "ic_prompt.jpg", tmp / "ic_bg.jpg" + if not download(geo["promptUrl"], pr_path) or not download(geo["bgUrl"], bg_path): + return None, "图片下载失败" + pr = cv2.imread(str(pr_path)) + bg = cv2.imread(str(bg_path)) + if pr is None or bg is None: + return None, "图片解码失败" + res = solver.solve(bg, pr) + print(f" 点选求解: ok={res.ok} {res.describe()}") + if not res.ok: + return None, "求解不可靠" + cv2.imwrite(str(tmp / "icon_annot.png"), solver.annotate(bg, res)) + bg_rect = geo["bgRect"] + sx, sy = bg_rect["w"] / bg.shape[1], bg_rect["h"] / bg.shape[0] + clicks = [(bg_rect["x"] + x * sx, bg_rect["y"] + y * sy) for x, y in res.points] + return clicks, None + + +def solve_text(geo: dict, tmp: Path, solver: TextClickSolver): + """文字点选 (汉字 2404): 题面自带目标字, 返回 iframe 内点击坐标。""" + bg_path = tmp / "tx_bg.jpg" + if not download(geo["bgUrl"], bg_path): + return None, "图片下载失败" + bg = cv2.imread(str(bg_path)) + if bg is None: + return None, "图片解码失败" + res = solver.solve_ex(bg, geo["instruction"]) + print(f" 文字点选求解: ok={res.ok} {res.describe()}") + print(f" points={res.points} targets={res.targets}") + if not res.ok: + return None, "求解不可靠" + bg_rect = geo["bgRect"] + sx, sy = bg_rect["w"] / bg.shape[1], bg_rect["h"] / bg.shape[0] + clicks = [(bg_rect["x"] + x * sx, bg_rect["y"] + y * sy) for x, y in res.points] + return clicks, None + + +def probe(rounds: int) -> int: + """只刷新不点击, 统计目标站点实际下发的题型分布 (侦察用)。""" + dist: dict = {} + for rnd in range(1, rounds + 1): + ens = cdp_eval(EXPR_ENSURE, PAGE)["result"] + if ens.get("err"): + print(f" 无法唤起 widget: {ens['err']}") + return 2 + time.sleep(3.0) + geo = cdp_eval(EXPR_GEO, IFRAME)["result"] + mode = geo.get("mode") or "unknown" + dist[mode] = dist.get(mode, 0) + 1 + print(f" [{rnd:02d}] {mode:8s} {geo.get('instruction')!r}") + print(f"\n题型分布 ({rounds} 次刷新): {dist}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description="1ms 验证码真机桥接 (CDP)") + ap.add_argument("--aid", default="195672344") + ap.add_argument("--rounds", type=int, default=12, help="最多尝试几轮 (1ms 滑块下发最多)") + ap.add_argument("--solves", type=int, default=1, help="拿到几个 ticket 后停止 (用于统计成功率)") + ap.add_argument("--probe", type=int, default=0, help="只刷新 N 次统计题型分布, 不点击") + ap.add_argument("--only", choices=["slider", "icon", "text", "auto"], default="auto") + args = ap.parse_args() + + TMP.mkdir(parents=True, exist_ok=True) + if args.probe: + return probe(args.probe) + + slider, icon = SliderSolver(), IconClickSolver() + text_solver = None + skipped = {"slider": 0, "icon": 0, "text": 0} + stats = {"attempts": 0, "ok": 0, "fail": 0, "timeout": 0, "unsolved": 0, "skip": 0} + tickets: list = [] + + for rnd in range(1, args.rounds + 1): + if stats["ok"] >= args.solves: + break + print(f"\n=== 第 {rnd}/{args.rounds} 轮 (成功 {stats['ok']}/{args.solves}) ===") + ens = cdp_eval(EXPR_ENSURE, PAGE)["result"] + if ens.get("err"): + print(f" 无法唤起 widget: {ens['err']}") + return 2 + print(f" widget: {ens.get('mode')}") + time.sleep(3.0) + geo = cdp_eval(EXPR_GEO, IFRAME)["result"] + mode = geo.get("mode") + print(f" 题型={mode} 题面={geo.get('instruction')!r}") + if mode is None: + print(" 未就绪 -> 下一轮") + continue + if args.only != "auto" and mode != args.only: + skipped[mode] += 1 + stats["skip"] += 1 + print(f" 跳过 (--only {args.only})") + continue + + fr = cdp_eval(EXPR_FRAME, PAGE)["result"] + if not fr.get("found"): + print(" 未找到可见的 captcha iframe -> 下一轮") + continue + ox, oy = fr["x"], fr["y"] + + if mode == "slider": + plan, err = solve_slider(geo, TMP, slider) + if err: + print(f" {err} -> 下一轮") + stats["unsolved"] += 1 + continue + x1, y1, x2, y2 = plan + print(" 页面坐标拖动: (%.0f,%.0f) -> (%.0f,%.0f) dx=%.1f" % (x1, y1, x2, y2, x2 - x1)) + cdp_drag(ox + x1, oy + y1, ox + x2, oy + y2) + else: + if mode == "text": + if text_solver is None: + text_solver = TextClickSolver() + clicks, err = solve_text(geo, TMP, text_solver) + else: + clicks, err = solve_icon(geo, TMP, icon) + if err: + print(f" {err} -> 下一轮") + stats["unsolved"] += 1 + continue + pts = [(ox + x, oy + y) for x, y in clicks] + btn = geo.get("okBtn") + if btn: + pts.append((ox + btn["x"] + btn["w"] / 2, oy + btn["y"] + btn["h"] / 2)) + else: + print(" ⚠️ 未找到可见的「确定」按钮") + print(" 页面坐标点击: %s" % [("%d,%d" % (round(a), round(b))) for a, b in pts]) + cdp_click(pts) + + stats["attempts"] += 1 + got = None + for _ in range(25): + time.sleep(0.6) + got = cdp_eval(EXPR_RESULT, PAGE)["result"]["res"] + if got: + break + if not got: + print(" 等待回调超时 -> 下一轮") + stats["timeout"] += 1 + continue + print("\n >>> widget 回调: %s" % json.dumps(got, ensure_ascii=False)[:300]) + print(" >>> ret=%s ticket=%s randstr=%s" % + (got.get("ret"), (got.get("ticket") or "")[:32], got.get("randstr"))) + if got.get("ret") == 0: + stats["ok"] += 1 + tickets.append(got.get("ticket")) + else: + stats["fail"] += 1 + + print(f"\n{'=' * 62}") + print("统计: %s" % stats) + print("跳过: %s" % skipped) + if tickets: + print("最后 ticket: %s" % tickets[-1][:48]) + if stats["ok"] >= args.solves: + print("✅ 全部达成 (%d/%d)" % (stats["ok"], args.solves)) + return 0 + print("❌ 未达成 (%d/%d)" % (stats["ok"], args.solves)) + return 1 + + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tcaptcha/icon_click_solver.py b/tcaptcha/icon_click_solver.py new file mode 100644 index 0000000..2d7e6f7 --- /dev/null +++ b/tcaptcha/icon_click_solver.py @@ -0,0 +1,204 @@ +# -*- coding: utf-8 -*- +""" +腾讯天御『图标/图文混合点选』求解器 (subcapclass 2408 / 2401 之外的图形点选) + +适用题型 (1ms.run aid=195672344 实测,2026-10-11): + 提示条 `img_index=0` 是 170x50 的**目标原图条**(按点击顺序排列, 例如: "0" + 图标 + "7"), + 背景图 `img_index=1` 是 672x480 的实景照片, 上面画着若干**纯黑轮廓**的符号 + (目标 + 干扰项), 部分目标被**旋转/倾斜**。 + +与 `glyph_solver.TextClickSolver`(汉字点选 2404) 的区别: + * 2404: 指令是纯文字 ("请依次点击:本 车 憋"), 背景是**彩色**(橙黄)汉字, 用 HSV 分割 + OCR + * 本类: 指令是**图标/数字**, 提示以**图片**给出, 背景符号是**纯黑轮廓**, 且会被旋转 + +算法 (不重训分类器, 直接利用"提示条给的就是目标原图"这一事实): + 1. 背景/提示条都按"近黑像素"分割成字形掩膜 (符号是纯黑, 背景是照片) + 2. 提示条连通域 = 有序目标模板 (按 x 排序) + 3. 背景连通域 = 候选符号 + 4. 每个目标 vs 每个候选: 二值掩膜归一化(等比缩放到正方形)后做 + **旋转搜索 + IoU** 相似度 (旋转不变, 正好对付倾斜干扰) + 5. 用匈牙利/贪心做**一对一**指派, 按提示顺序输出点击坐标 + +坐标口径: 输出与腾讯 `ans` 相同的**原图坐标系 (672x480)**。 +""" +from __future__ import annotations + +import io +from dataclasses import dataclass +from typing import List, Optional, Sequence, Tuple + +import cv2 +import numpy as np +from PIL import Image + +# 近黑阈值: 符号是纯黑(<=60), 照片最暗处也远高于此; 用"三通道都暗"避免蓝色天空误检 +DARK_MAX = 95 +MERGE_KERNEL = 5 +MIN_AREA = 80 +MAX_AREA = 9000 + + +def _to_bgr(image) -> np.ndarray: + if isinstance(image, np.ndarray): + return image + if isinstance(image, (bytes, bytearray)): + arr = np.frombuffer(image, np.uint8) + return cv2.imdecode(arr, cv2.IMREAD_COLOR) + return cv2.cvtColor(np.asarray(image.convert("RGB")), cv2.COLOR_RGB2BGR) + + +def glyph_mask(bgr: np.ndarray, dark_max: int = DARK_MAX, kernel: int = MERGE_KERNEL, + dilate: int = 3) -> np.ndarray: + """近黑像素 -> 掩膜 (取三通道最大值, 只有真正的黑色轮廓会通过)。 + + kernel>1 时做闭运算合并同一符号的笔画; dilate>0 时再膨胀一点 (分割阶段用)。 + """ + mx = bgr.max(axis=2) + m = (mx < dark_max).astype(np.uint8) * 255 + if kernel > 1: + m = cv2.morphologyEx(m, cv2.MORPH_CLOSE, + cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (kernel, kernel))) + if dilate > 0: + m = cv2.dilate(m, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (dilate, dilate))) + return m + + +def components(mask: np.ndarray, min_area: int = MIN_AREA, max_area: int = MAX_AREA) -> List[Tuple[int, int, int, int, int]]: + n, lab, stats, _ = cv2.connectedComponentsWithStats(mask) + out = [] + for i in range(1, n): + x, y, w, h, a = (int(v) for v in stats[i]) + if a < min_area or a > max_area: + continue + if w < 6 or h < 6: + continue + out.append((x, y, w, h, a)) + return out + + +def _norm(bgr: np.ndarray, box, size: int = 64) -> np.ndarray: + """把 box 内的符号归一化成 size x size 的二值掩膜 (等比居中, 不拉伸)。 + + 注意: 画布必须用**白色**填充 —— 黑色会被"近黑"掩膜当成字形 (踩过这个坑)。 + """ + x, y, w, h, _ = box + crop = bgr[y:y + h, x:x + w] + s = max(w, h) + canvas = np.full((s, s, 3), 255, np.uint8) + canvas[(s - h) // 2:(s - h) // 2 + h, (s - w) // 2:(s - w) // 2 + w] = crop + small = cv2.resize(canvas, (size, size), interpolation=cv2.INTER_AREA) + return glyph_mask(small, dark_max=140, kernel=3, dilate=0) > 127 + + +def _rotated(mask: np.ndarray, ang: float, size: int = 64) -> np.ndarray: + M = cv2.getRotationMatrix2D((size / 2, size / 2), ang, 1.0) + r = cv2.warpAffine(mask.astype(np.uint8) * 255, M, (size, size)) + return r > 127 + + +def _iou(a: np.ndarray, b: np.ndarray) -> float: + inter = np.logical_and(a, b).sum() + union = np.logical_or(a, b).sum() + return float(inter) / float(union) if union else 0.0 + + +@dataclass +class IconClickResult: + points: List[Tuple[int, int]] # 原图坐标 (672x480 口径) + scores: List[float] + angles: List[float] + boxes: List[Tuple[int, int, int, int]] + target_count: int + + @property + def ok(self) -> bool: + return len(self.points) == self.target_count and self.target_count > 0 + + @property + def min_score(self) -> float: + return min(self.scores) if self.scores else 0.0 + + def describe(self) -> str: + return " ".join("(%d,%d|%.2f@%d°)" % (p[0], p[1], s, a) + for p, s, a in zip(self.points, self.scores, self.angles)) + + +class IconClickSolver: + """提示图(目标条) + 背景图 -> 有序点击坐标。""" + + def __init__(self, angles: Sequence[float] = tuple(range(-45, 46, 5)), + size: int = 64, min_area: int = MIN_AREA): + self.angles = tuple(angles) + self.size = size + self.min_area = min_area + + def solve(self, bg, prompt) -> IconClickResult: + bg_bgr = _to_bgr(bg) + pr_bgr = _to_bgr(prompt) + + targets = sorted(components(glyph_mask(pr_bgr), self.min_area), key=lambda b: b[0]) + cands = components(glyph_mask(bg_bgr), self.min_area) + if not targets or not cands: + return IconClickResult([], [], [], [], len(targets)) + + tm = [_norm(pr_bgr, b, self.size) for b in targets] + cm = [_norm(bg_bgr, b, self.size) for b in cands] + # 候选旋转缓存 + rot = [[(a, _rotated(c, a, self.size)) for a in self.angles] for c in cm] + + # 得分矩阵: 每个目标在 (候选, 角度) 上的最大 IoU + S = np.zeros((len(tm), len(cm)), np.float32) + A = np.zeros((len(tm), len(cm)), np.float32) + for ti, t in enumerate(tm): + for ci, rs in enumerate(rot): + best = (-1.0, 0.0) + for ang, r in rs: + v = _iou(t, r) + if v > best[0]: + best = (v, ang) + S[ti, ci], A[ti, ci] = best + + # 贪心一对一指派 (按全局得分从高到低) + pairs = sorted(((S[i, j], i, j) for i in range(len(tm)) for j in range(len(cm))), + key=lambda x: -x[0]) + used_t, used_c = set(), set() + assign = {} + for v, i, j in pairs: + if i in used_t or j in used_c: + continue + used_t.add(i) + used_c.add(j) + assign[i] = (j, v, A[i, j]) + + points, scores, angs, boxes = [], [], [], [] + for i in range(len(tm)): + if i not in assign: + continue + j, v, ang = assign[i] + x, y, w, h, _ = cands[j] + points.append((x + w // 2, y + h // 2)) + scores.append(v) + angs.append(ang) + boxes.append((x, y, w, h)) + return IconClickResult(points, scores, angs, boxes, len(tm)) + + def annotate(self, bg, result: IconClickResult) -> np.ndarray: + """把结果画到背景图上 (自检用)。""" + img = _to_bgr(bg).copy() + for idx, ((px, py), sc) in enumerate(zip(result.points, result.scores), 1): + cv2.circle(img, (px, py), 26, (0, 0, 255), 3) + cv2.putText(img, str(idx), (px - 8, py + 8), cv2.FONT_HERSHEY_SIMPLEX, + 1.0, (0, 255, 0), 2) + return img + + +if __name__ == "__main__": + import sys + bg = cv2.imread(sys.argv[1] if len(sys.argv) > 1 else "/tmp/1mscap/bg.jpg") + pr = cv2.imread(sys.argv[2] if len(sys.argv) > 2 else "/tmp/1mscap/prompt.jpg") + s = IconClickSolver() + r = s.solve(bg, pr) + print("ok=%s points=%s scores=%s angles=%s" % (r.ok, r.points, [round(x, 3) for x in r.scores], r.angles)) + out = s.annotate(bg, r) + cv2.imwrite("/tmp/1mscap/annotated.jpg", out) + print("annotated -> /tmp/1mscap/annotated.jpg") diff --git a/tcaptcha/selftest.py b/tcaptcha/selftest.py index 4fb60e1..91e2096 100644 --- a/tcaptcha/selftest.py +++ b/tcaptcha/selftest.py @@ -14,6 +14,8 @@ ROOT = Path(__file__).resolve().parents[1] SUITES = [ "tcaptcha.tests.test_crypto_core", "tcaptcha.tests.test_glyph_solver", + "tcaptcha.tests.test_icon_click_solver", + "tcaptcha.tests.test_slider_solver", "tcaptcha.tests.test_xtea_key", "tcaptcha.tests.test_tdc_key", ] diff --git a/tcaptcha/slider_solver.py b/tcaptcha/slider_solver.py new file mode 100644 index 0000000..a35aa6d --- /dev/null +++ b/tcaptcha/slider_solver.py @@ -0,0 +1,226 @@ +# -*- coding: utf-8 -*- +""" +腾讯天御『滑块拼图』求解器 (drag 型验证, 1ms.run aid=195672344 实测 2026-10-11) + +题型结构 (真机 iframe `drag_ele.html` 实测): + * 背景图 `img_index=1`: 一张实景照片, 上面画着 **1~2 个缺口** (真缺口 + 干扰缺口)。 + 缺口 = 与拼图块**同形**的挖空, 内部是"同一张图被压暗"的结果, 外沿一圈浅色描边。 + * 拼图块 `img_index=0`: 一张**精灵图**, 里面同时含拖动柄和拼图块本体。页面用 + `background-position` / `background-size` 把拼图块那一块裁出来, 显示在滑轨左侧。 + * 拖动柄(滑轨)与拼图块**同步横移**, 位移 1:1 (见 `har/1ms_dianxuan/PROGRESS.md`)。 + +为什么不用"轮廓/暗区阈值"直接找缺口: + 背景照片本身有大量强边缘和暗区, 阈值法的召回/精确率都不够 (实测: 用局部变暗比 + 找缺口会被照片自身的暗部淹没)。但有一个**结构性**事实可以用: + + "拼图块里装的是**未被压暗的原始内容**, 而缺口里装的是**同一块内容被压暗**的结果。" + + 于是 ZNCC(拼图块内容, 背景图窗口) 的峰值必然落在**真缺口**上; 干扰缺口内部装的是 + **别处**的内容, 相关性明显更低。实测区分度 (本仓库 fixture): + + 真缺口 0.732 + 干扰缺口 0.551 + 拼图块当前位置 -0.136 (拼图块盖住的是完好的背景, 那里没有洞) + 其余局部峰值 <= 0.28 + +坐标口径 (重要): + * 输入 `piece_x_image` / `piece_y_image` 是**背景图坐标系**(原始像素, 未经页面缩放); + * 输出 `dx_display` 是**页面显示像素**的横向拖动距离, 即 `dx_image * scale`, + 其中 `scale = 背景图显示宽度 / 背景图原始宽度`。 +""" +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import List, Optional, Sequence, Tuple + +import cv2 +import numpy as np + +# 拼图块精灵图/背景图的 alpha 阈值 (精灵图是 RGBA, 背景是照片) +DEFAULT_ALPHA_MIN = 128 +# 局部峰值去重半径 (背景图坐标): 同一个峰附近的点算同一个候选 +DEFAULT_NEIGHBOR = 20 +# 纵向搜索范围 (背景图坐标): 允许上下抖 ±N 像素, 吸收取整/描边误差 +DEFAULT_Y_JITTER = 3 + + +def _to_bgr(image) -> np.ndarray: + """把 bytes / PIL / ndarray 统一成 BGR 三通道 ndarray。""" + if isinstance(image, np.ndarray): + if image.ndim == 3 and image.shape[2] == 4: + return cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + return image + if isinstance(image, (bytes, bytearray)): + arr = np.frombuffer(image, np.uint8) + return cv2.imdecode(arr, cv2.IMREAD_COLOR) + return cv2.cvtColor(np.asarray(image.convert("RGB")), cv2.COLOR_RGB2BGR) + + +def sprite_region(bg_position: Sequence[float], bg_size: Sequence[float], + elem_size: Sequence[float], sprite_shape: Sequence[int]) -> Tuple[int, int, int, int]: + """把 CSS 的 `background-position` / `background-size` 换算成精灵图里的像素区域。 + + 参数: + bg_position: 元素上的 background-position, 形如 (-70.8333, -247.917) (CSS px) + bg_size: 元素上的 background-size, 形如 (345.06, 313.69) (CSS px, 已缩放后的整图) + elem_size: 元素的显示尺寸, 形如 (60.7143, 60.7143) (CSS px) + sprite_shape: 精灵图原始尺寸 (H, W) + + 返回 (x, y, w, h), 即该元素在**精灵图原始像素**里对应的矩形。 + """ + sx, sy = float(bg_size[0]), float(bg_size[1]) + h_sprite, w_sprite = int(sprite_shape[0]), int(sprite_shape[1]) + kx, ky = w_sprite / sx, h_sprite / sy # CSS px -> 精灵图 px + x = int(round(-float(bg_position[0]) * kx)) + y = int(round(-float(bg_position[1]) * ky)) + w = int(round(float(elem_size[0]) * kx)) + h = int(round(float(elem_size[1]) * ky)) + return x, y, w, h + + +def piece_mask(piece, alpha_min: int = DEFAULT_ALPHA_MIN) -> np.ndarray: + """从拼图块裁剪图里取**不透明**区域掩膜 (bool)。 + + 精灵图是 RGBA: 只要 alpha 通道。若调用方给的是三通道图 (例如直接截屏裁的), + 退化成"排除近白像素" (精灵图的空白处是纯白/全透明)。 + """ + arr = np.asarray(piece) + if arr.ndim == 3 and arr.shape[2] == 4: + return arr[..., 3] > alpha_min + bgr = _to_bgr(arr) + return bgr.min(axis=2) < 245 + + +def zncc_curve(bg_gray: np.ndarray, patch: np.ndarray, mask: np.ndarray, + y0: int, y_span: int = DEFAULT_Y_JITTER) -> Tuple[np.ndarray, int]: + """在背景图的 y=y0±y_span 行上, 横向逐像素做零均值归一化互相关。 + + 返回 (curve, best_y)。curve[x] 是"把拼图块左上角放在背景图 (x, best_y)"时的 ZNCC。 + curve 只在 x ∈ [0, W-w] 上有定义。 + """ + h, w = mask.shape + H, W = bg_gray.shape + if h > H or w > W: + raise ValueError(f"拼图块 ({w}x{h}) 比背景图 ({W}x{H}) 还大") + + # 只取掩膜内的像素, 后续每列都按同一批下标取样 -> 可以向量化成一次大矩阵运算 + idx_y, idx_x = np.nonzero(mask) + p = patch[idx_y, idx_x].astype(np.float32) + p = p - p.mean() + pn = float(np.sqrt((p * p).sum())) + if pn <= 1e-6: + raise ValueError("拼图块内容方差为 0, 无法做相关") + + xs = np.arange(0, W - w + 1, dtype=np.int32) + curve = np.zeros(xs.shape, dtype=np.float32) + best_y, best_curve = y0, None + for y in range(max(0, y0 - y_span), min(H - h, y0 + y_span) + 1): + # 一次性把所有候选窗口的掩膜内像素取成 (len(xs), len(idx)) 矩阵 + base = bg_gray[y + idx_y] # (N, W) + cols = xs[:, None] + idx_x[None, :] # (X, N) + q = base[np.arange(base.shape[0])[None, :], cols] # (X, N) + q = q.astype(np.float32) + q = q - q.mean(axis=1, keepdims=True) + denom = np.sqrt((q * q).sum(axis=1)) * pn + cur = np.where(denom > 1e-6, (q * p[None, :]).sum(axis=1) / np.maximum(denom, 1e-9), 0.0) + if best_curve is None or cur.max() > best_curve.max(): + best_y, best_curve = y, cur + curve = best_curve if best_curve is not None else curve + return curve.astype(np.float64), best_y + + +def _peaks(curve: np.ndarray, neighbor: int, limit: int = 5) -> List[Tuple[int, float]]: + """按分数从高到低取**互不相邻**(间距 > neighbor) 的局部峰值。""" + order = np.argsort(-curve) + picked: List[Tuple[int, float]] = [] + for x in order: + xi = int(x) + if all(abs(xi - p[0]) > neighbor for p in picked): + picked.append((xi, float(curve[xi]))) + if len(picked) >= limit: + break + return picked + + +@dataclass +class SliderResult: + """滑块求解结果。坐标均为**背景图原始像素**, 除 dx_display 外。""" + ok: bool + dx_image: float + dx_display: float + score: float + runner_up: float + margin: float + piece_x_image: float + target_x_image: float + scale: float + y_image: int = 0 + candidates: List[Tuple[int, float]] = field(default_factory=list) + + def describe(self) -> str: + return (f"dx_image={self.dx_image:.1f} dx_display={self.dx_display:.1f}px " + f"score={self.score:.3f} runner_up={self.runner_up:.3f} margin={self.margin:.3f} " + f"cands={[(x, round(s, 3)) for x, s in self.candidates]}") + + +class SliderSolver: + """滑块缺口求解器 (需权重为 0: 纯几何 + 相关, 不需要训练/模型文件)。""" + + def __init__(self, min_score: float = 0.45, min_margin: float = 0.08, + neighbor: int = DEFAULT_NEIGHBOR, alpha_min: int = DEFAULT_ALPHA_MIN, + y_jitter: int = DEFAULT_Y_JITTER) -> None: + self.min_score = float(min_score) + self.min_margin = float(min_margin) + self.neighbor = int(neighbor) + self.alpha_min = int(alpha_min) + self.y_jitter = int(y_jitter) + + def solve(self, bg, piece, piece_x_image: float, scale: float = 1.0, + piece_y_image: Optional[float] = None) -> SliderResult: + """求拖动距离。 + + bg 背景图 (含缺口; 对应 `img_index=1`) + piece 拼图块裁剪图 (RGBA 精灵图里裁出来的那一块; 对应 `img_index=0`) + piece_x_image 拼图块裁剪图左边缘在背景图坐标系里的 x + scale 背景图显示宽度 / 背景图原始宽度 (页面缩放系数) + piece_y_image 拼图块裁剪图上边缘在背景图坐标系里的 y (缺省 0) + """ + bg_bgr = _to_bgr(bg) + bg_gray = bg_bgr.mean(axis=2).astype(np.float32) + cand = np.asarray(piece) + patch = _to_bgr(cand).mean(axis=2).astype(np.float32) + mask = piece_mask(cand, self.alpha_min) + if mask.sum() < 50: + return SliderResult(False, 0.0, 0.0, 0.0, 0.0, 0.0, + float(piece_x_image), float(piece_x_image), float(scale)) + + y0 = int(round(piece_y_image or 0)) + curve, best_y = zncc_curve(bg_gray, patch, mask, y0, self.y_jitter) + peaks = _peaks(curve, self.neighbor) + if not peaks: + return SliderResult(False, 0.0, 0.0, 0.0, 0.0, 0.0, + float(piece_x_image), float(piece_x_image), float(scale), best_y) + + best_x, best_s = peaks[0] + runs = peaks[1][1] if len(peaks) > 1 else 0.0 + margin = best_s - runs + dx_image = float(best_x) - float(piece_x_image) + ok = bool(best_s >= self.min_score and margin >= self.min_margin) + return SliderResult(ok=ok, dx_image=dx_image, dx_display=dx_image * float(scale), + score=best_s, runner_up=runs, margin=margin, + piece_x_image=float(piece_x_image), + target_x_image=float(best_x), scale=float(scale), + y_image=best_y, candidates=peaks) + + # --- 调试辅助 ------------------------------------------------------- + @staticmethod + def annotate(bg, result: SliderResult, piece_w: int, piece_h: int) -> np.ndarray: + """在背景图上标出"拼图块当前位置"与"目标位置", 用于人工核对。""" + img = _to_bgr(bg).copy() + px, py = int(round(result.piece_x_image)), int(result.y_image) + tx = int(round(result.target_x_image)) + cv2.rectangle(img, (px, py), (px + piece_w, py + piece_h), (0, 255, 0), 2) + cv2.rectangle(img, (tx, py), (tx + piece_w, py + piece_h), (0, 0, 255), 2) + cv2.arrowedLine(img, (px + piece_w // 2, py + piece_h + 14), + (tx + piece_w // 2, py + piece_h + 14), (255, 255, 0), 2, tipLength=0.25) + return img diff --git a/tcaptcha/tests/fixtures/1ms_icon_click_1_bg.jpg b/tcaptcha/tests/fixtures/1ms_icon_click_1_bg.jpg new file mode 100644 index 0000000000000000000000000000000000000000..12ac5bda6a983f40b345a261203f846f03f52bdb GIT binary patch literal 26866 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 LHIUky;N$r}OO(#D literal 0 HcmV?d00001 diff --git a/tcaptcha/tests/fixtures/1ms_slider_1_sprite.png b/tcaptcha/tests/fixtures/1ms_slider_1_sprite.png new file mode 100644 index 0000000000000000000000000000000000000000..931102640395f204f980403e1019d04d3690deef GIT binary patch literal 25911 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 literal 0 HcmV?d00001 diff --git a/tcaptcha/tests/test_icon_click_solver.py b/tcaptcha/tests/test_icon_click_solver.py new file mode 100644 index 0000000..b6ca87d --- /dev/null +++ b/tcaptcha/tests/test_icon_click_solver.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- +""" +图标/图文点选求解器 回归测试 (AGENTS.md §5.9) + +固化的性质: + 1. 提示条(目标原图) -> 背景图 的**形状模板匹配**在真机样本上给出正确、有序的点击点; + 2. 能处理**旋转**目标 (样本里"图表"图标被倾斜 ~-30°); + 3. 能排除干扰图标 (样本里的笑脸/银行). + +样本: tcaptcha/tests/fixtures/1ms_icon_click_1_{prompt,bg}.jpg + 取自 1ms.run (aid=195672344) 真机页面上弹出的验证码 (2026-10-11), + 题面 = "0" + 图表图标 + "7", 期望点击顺序与坐标见 EXPECTED。 + +运行: + .venv/bin/python -m tcaptcha.tests.test_icon_click_solver +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import cv2 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tcaptcha.icon_click_solver import IconClickSolver # noqa: E402 + +FIX = ROOT / "tcaptcha" / "tests" / "fixtures" +# 期望点击顺序: ("0" 右下) -> (倾斜的图表图标 左上) -> ("7" 中上),坐标是 672x480 原图口径 +EXPECTED = [(520, 440), (200, 40), (360, 120)] +TOL = 12 # 允许的像素误差 (连通域中心 vs 目视中心) + +_fails: list = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else "")) + if not cond: + _fails.append(name) + + +def main() -> int: + pr = cv2.imread(str(FIX / "1ms_icon_click_1_prompt.jpg")) + bg = cv2.imread(str(FIX / "1ms_icon_click_1_bg.jpg")) + check("fixture 可读", pr is not None and bg is not None) + + print("1) 求解") + res = IconClickSolver().solve(bg, pr) + check("目标数=3", res.target_count == 3, str(res.target_count)) + check("求解成功", res.ok, res.describe()) + + print("2) 点击顺序与坐标") + for i, (exp, got) in enumerate(zip(EXPECTED, res.points)): + dx, dy = abs(exp[0] - got[0]), abs(exp[1] - got[1]) + check(f"第{i+1}个点 ~{exp}", dx <= TOL and dy <= TOL, + f"got={got} (Δ={dx},{dy}) score={res.scores[i]:.3f}") + + print("3) 旋转与干扰项") + check("命中倾斜图标 (角度非 0)", any(abs(a) >= 10 for a in res.angles), str(res.angles)) + check("所有匹配分数 > 0.45", res.min_score > 0.45, f"min={res.min_score:.3f}") + + print() + if _fails: + print(f"❌ test_icon_click_solver 失败 {len(_fails)} 项: {_fails}") + return 1 + print("✅ test_icon_click_solver 全部通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tcaptcha/tests/test_slider_solver.py b/tcaptcha/tests/test_slider_solver.py new file mode 100644 index 0000000..734a98a --- /dev/null +++ b/tcaptcha/tests/test_slider_solver.py @@ -0,0 +1,115 @@ +# -*- coding: utf-8 -*- +""" +滑块拼图求解器 回归测试 (AGENTS.md §5.9) + +固化的性质 (样本取自 1ms.run aid=195672344 真机页面 2026-10-11 的滑块验证码): + 1. `sprite_region` 能把 CSS 的 background-position/size 正确换算成精灵图像素区域; + 2. ZNCC 峰值落在**真缺口**上, 而不是干扰缺口, 也不是拼图块当前位置; + 3. 通过 y 方向小抖动吸收取整误差, 结果稳定; + 4. `ok` 的判定 (分数 + 与次峰间距) 在该样本上成立。 + +样本: tcaptcha/tests/fixtures/1ms_slider_1_{bg.jpg,sprite.png} + bg = 页面 `img_index=1` 的背景图 (672x390, 含真缺口 + 干扰缺口) + sprite = 页面 `img_index=0` 的精灵图 (682x620, 含拖动柄 + 拼图块) + +DOM 实测口径 (drag_ele.html iframe 内): + slideBg rect=(10, 68.78) 显示 340 x 242.857 -> scale = 340/672 = 0.50595238 + 拼图块元素 rect=(35.2976, 133.53) 显示 60.7143 x 60.7143 + background-position: -70.8333px -247.917px + background-size: 345.06px 313.69px + => 拼图块裁剪图在背景图坐标系里的左上角 = (50.0, 127.97) + +运行: + .venv/bin/python -m tcaptcha.tests.test_slider_solver +""" +from __future__ import annotations + +import sys +from pathlib import Path + +import cv2 + +ROOT = Path(__file__).resolve().parents[2] +if str(ROOT) not in sys.path: + sys.path.insert(0, str(ROOT)) + +from tcaptcha.slider_solver import SliderSolver, piece_mask, sprite_region # noqa: E402 + +FIX = ROOT / "tcaptcha" / "tests" / "fixtures" + +# 真机 DOM 口径 +BG_POS = (-70.8333, -247.917) +BG_SIZE = (345.06, 313.69) +ELEM_SIZE = (60.7143, 60.7143) +SLIDE_W = 340.0 +BG_NATURAL_W = 672.0 +SCALE = SLIDE_W / BG_NATURAL_W # 0.50595238 +PIECE_X_IMAGE = 50.0 +PIECE_Y_IMAGE = 128.0 + +EXPECTED_DX_IMAGE = 293.0 # 真缺口 x - 拼图块 x +EXPECTED_DECOY_X = 489 # 干扰缺口在背景图坐标里的 x + +_fails: list = [] + + +def check(name: str, cond: bool, detail: str = "") -> None: + print(f" [{'PASS' if cond else 'FAIL'}] {name}" + (f" {detail}" if detail else "")) + if not cond: + _fails.append(name) + + +def main() -> int: + bg = cv2.imread(str(FIX / "1ms_slider_1_bg.jpg")) + sprite = cv2.imread(str(FIX / "1ms_slider_1_sprite.png"), cv2.IMREAD_UNCHANGED) + if bg is None or sprite is None: + print("缺少 fixture, 请确认 tcaptcha/tests/fixtures/1ms_slider_1_* 存在") + return 1 + print(f"bg={bg.shape} sprite={sprite.shape}") + + print("\n[1] 精灵图区域换算") + x, y, w, h = sprite_region(BG_POS, BG_SIZE, ELEM_SIZE, sprite.shape[:2]) + check("sprite_region == (140, 490, 120, 120)", (x, y, w, h) == (140, 490, 120, 120), f"got={(x, y, w, h)}") + + crop = sprite[y:y + h, x:x + w] + mask = piece_mask(crop) + check("拼图块掩膜非空且不过分满", 500 < int(mask.sum()) < w * h * 0.9, + f"mask={int(mask.sum())}/{w * h}") + + print("\n[2] 缺口定位 (ZNCC)") + res = SliderSolver().solve(bg, crop, piece_x_image=PIECE_X_IMAGE, scale=SCALE, + piece_y_image=PIECE_Y_IMAGE) + print(f" {res.describe()}") + check("ok", res.ok) + check("分数 >= 0.65", res.score >= 0.65, f"score={res.score:.3f}") + check("与次峰间距 >= 0.15", res.margin >= 0.15, f"margin={res.margin:.3f}") + check("落在真缺口 (误差 <= 4 背景像素)", abs(res.target_x_image - (PIECE_X_IMAGE + EXPECTED_DX_IMAGE)) <= 4, + f"target_x={res.target_x_image}") + + print("\n[3] 干扰缺口没有被选中") + decoy = [x0 for x0, _ in res.candidates if abs(x0 - EXPECTED_DECOY_X) <= 6] + check("次峰就是干扰缺口", bool(decoy), f"candidates={res.candidates}") + check("真缺口分数严格高于干扰缺口", res.score > res.runner_up + 0.1, + f"{res.score:.3f} vs {res.runner_up:.3f}") + + print("\n[4] 显示坐标换算") + check("dx_display == dx_image * scale", + abs(res.dx_display - res.dx_image * SCALE) < 1e-6, + f"{res.dx_display:.2f} == {res.dx_image:.2f} * {SCALE:.6f}") + check("dx_display 落在 [140, 160]", 140 <= res.dx_display <= 160, f"{res.dx_display:.2f}") + + print("\n[5] 拼图块当前位置不是答案 (那里没有洞)") + near_piece = [s for x0, s in res.candidates if abs(x0 - PIECE_X_IMAGE) <= 8] + check("拼图块自身位置的分数很低", all(s < 0.3 for s in near_piece), + f"{near_piece}") + + print(f"\n{'=' * 62}") + if _fails: + print(f"❌ slider_solver 测试失败: {_fails}") + return 1 + print("✅ slider_solver 测试全部通过") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/cdp_capture.js b/tools/cdp_capture.js new file mode 100644 index 0000000..a86f0db --- /dev/null +++ b/tools/cdp_capture.js @@ -0,0 +1,118 @@ +#!/usr/bin/env node +/** + * cdp_capture.js —— 用 CDP 抓**全部目标**(含 OOPIF iframe)的请求 + 响应体, 存成 JSON。 + * + * 为什么需要它 (而不是 cdp_net_capture.js): + * 1. 腾讯验证码 iframe (turing.captcha.gtimg.com) 与页面 (1ms.run) **不同站**, + * Chrome 站点隔离下它是独立的 target; 只 attach 顶层页面会**漏掉**验证码自己的 + * prehandle / getcapbysig / verify 请求。 + * 2. 排查 errorCode=12 需要看**响应体**, cdp_net_capture.js 只记 requestWillBeSent。 + * + * 零依赖 (Node >= 22 内置 WebSocket / fetch)。 + * + * 用法: + * node tools/cdp_capture.js --seconds 30 --url-substr cap_union --out /tmp/cap.json + * node tools/cdp_capture.js --list + * + * 选项: + * --port N 调试端口 (默认 9222) + * --seconds N 抓取时长 (默认 20) + * --url-substr S 只保留 url 含 S 的请求 (可选; 响应体只对命中的取) + * --out PATH 输出 JSON (默认 /tmp/cdp_capture.json) + * --bodies 额外抓响应体 (默认开) + */ +const fs = require('fs'); + +function arg(name, def) { + const i = process.argv.indexOf('--' + name); + if (i < 0) return def; + const v = process.argv[i + 1]; + return (v === undefined || v.startsWith('--')) ? true : v; +} + +class Cdp { + constructor(ws, tag) { this.ws = ws; this.tag = tag; this.id = 0; this.waits = new Map(); this.handlers = []; } + static async connect(url, tag) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws, tag); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) { + for (const h of c.handlers) h(m); + } + }; + return c; + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((res, rej) => this.waits.set(id, { res, rej })); + } + on(fn) { this.handlers.push(fn); } + close() { try { this.ws.close(); } catch (e) {} } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + const port = parseInt(arg('port', '9222'), 10); + if (arg('list', false)) { + const t = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 110)}\t${x.id}`); + return; + } + const seconds = parseInt(arg('seconds', '20'), 10); + const substrArg = arg('url-substr', null); + const substrs = substrArg && substrArg !== true ? String(substrArg).split(',').filter(Boolean) : null; + const out = arg('out', '/tmp/cdp_capture.json'); + + const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) + .filter(t => t.type === 'page' || t.type === 'iframe'); + const conns = []; + const records = []; + const byReq = new Map(); + + for (const t of targets) { + let cdp; + try { cdp = await Cdp.connect(t.webSocketDebuggerUrl, t.url || t.id); } catch (e) { continue; } + conns.push(cdp); + const key = (p) => `${cdp.tag}|${p.requestId}`; + cdp.on(m => { + if (m.method === 'Network.requestWillBeSent') { + const r = m.params.request; + if (substrs && !substrs.some(s => r.url.includes(s))) return; + const rec = { + target: cdp.tag, url: r.url, method: r.method, + headers: r.headers, postData: r.postData || null, + ts: Date.now(), + }; + records.push(rec); + byReq.set(key(m.params), rec); + } else if (m.method === 'Network.responseReceived') { + const rec = byReq.get(key(m.params)); + if (rec) { + rec.status = m.params.response.status; + rec.respHeaders = m.params.response.headers; + rec.mimeType = m.params.response.mimeType; + } + } else if (m.method === 'Network.loadingFinished') { + const rec = byReq.get(key(m.params)); + if (rec && rec.status && !rec.body) { + cdp.send('Network.getResponseBody', { requestId: m.params.requestId }) + .then(b => { rec.body = b.body; rec.base64 = !!b.base64Encoded; }) + .catch(() => {}); + } + } + }); + await cdp.send('Network.enable'); + } + console.error(`[cdp_capture] attached ${conns.length} targets, capturing ${seconds}s ...`); + await sleep(seconds * 1000); + for (const c of conns) c.close(); + fs.writeFileSync(out, JSON.stringify({ capturedAt: new Date().toISOString(), records }, null, 2)); + console.error(`[cdp_capture] wrote ${records.length} records -> ${out}`); +})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); }); diff --git a/tools/cdp_click.js b/tools/cdp_click.js new file mode 100644 index 0000000..87523da --- /dev/null +++ b/tools/cdp_click.js @@ -0,0 +1,94 @@ +#!/usr/bin/env node +/** + * cdp_click.js —— 用 CDP 在页面里派发**真实(trusted)鼠标事件** (点击 / 拖动)。 + * 用于驱动真实浏览器里的验证码 widget (合成 MouseEvent 会被 isTrusted 检测)。 + * + * 用法: + * # 依次点击若干点 (页面视口坐标) + * node tools/cdp_click.js --match 1ms.run --clicks "520,700;300,600" + * # 拖动: 从 (x,y) 水平/任意拖到 (x2,y2), 分 steps 步 (带中间移动) + * node tools/cdp_click.js --match 1ms.run --drag "200,700,600,700,30" + * + * 注意: 坐标是**顶层页面视口**坐标 (iframe 内的元素要先换算成页面坐标)。 + */ +function arg(name, def) { + const i = process.argv.indexOf('--' + name); + if (i < 0) return def; + const v = process.argv[i + 1]; + return (v === undefined || v.startsWith('--')) ? true : v; +} +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } + }; + return c; + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((res, rej) => this.waits.set(id, { res, rej })); + } + close() { try { this.ws.close(); } catch (e) {} } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + const port = parseInt(arg('port', '9222'), 10); + const match = arg('match', null); + const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) + .filter(t => t.type === 'page'); + const target = match ? targets.find(t => (t.url || '').includes(match)) : targets[0]; + if (!target) throw new Error('没有可用标签页'); + const cdp = await Cdp.connect(target.webSocketDebuggerUrl); + + const click = async (x, y) => { + const base = { x: Math.round(x), y: Math.round(y), button: 'left', clickCount: 1, + buttons: 1, pointerType: 'mouse' }; + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', ...base, buttons: 0 }); + await sleep(40); + await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', ...base }); + await sleep(60); + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', ...base }); + await sleep(260); + }; + + const clicks = arg('clicks', null); + if (clicks && clicks !== true) { + for (const p of String(clicks).split(';')) { + const [x, y] = p.split(',').map(Number); + if (Number.isFinite(x) && Number.isFinite(y)) await click(x, y); + } + console.log(JSON.stringify({ clicked: String(clicks).split(';').length, target: target.url })); + } + + const drag = arg('drag', null); + if (drag && drag !== true) { + const [x1, y1, x2, y2, steps] = String(drag).split(',').map(Number); + const n = steps || 30; + const base = { button: 'left', buttons: 1, pointerType: 'mouse', clickCount: 1 }; + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: x1, y: y1, buttons: 0 }); + await sleep(80); + await cdp.send('Input.dispatchMouseEvent', { type: 'mousePressed', x: x1, y: y1, ...base }); + for (let i = 1; i <= n; i++) { + const t = i / n; + const ease = t < 0.5 ? 2 * t * t : 1 - Math.pow(-2 * t + 2, 2) / 2; + const x = x1 + (x2 - x1) * ease + (Math.random() - 0.5) * 1.5; + const y = y1 + (y2 - y1) * ease + (Math.random() - 0.5) * 1.5; + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseMoved', x: Math.round(x), y: Math.round(y), ...base }); + await sleep(12 + Math.random() * 10); + } + await cdp.send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: x2, y: y2, ...base }); + console.log(JSON.stringify({ dragged: [x1, y1, x2, y2], steps: n, target: target.url })); + } + cdp.close(); +})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); }); diff --git a/tools/cdp_eval.js b/tools/cdp_eval.js new file mode 100644 index 0000000..1209caf --- /dev/null +++ b/tools/cdp_eval.js @@ -0,0 +1,110 @@ +#!/usr/bin/env node +/** + * cdp_eval.js —— 极简 CDP 工具: 连上**已经在跑的** Chrome(需带 --remote-debugging-port), + * 在指定标签页里求值一段 JS 并把结果按 JSON 打印回来。 + * + * 零依赖: 只用 Node >= 22 内置的全局 WebSocket / fetch。 + * + * 用法: + * node tools/cdp_eval.js --port 9222 --match 1ms.run --file /tmp/expr.js + * echo 'document.title' | node tools/cdp_eval.js --port 9222 --match 1ms.run + * node tools/cdp_eval.js --list # 列出所有标签页 + * + * 选项: + * --port N 调试端口 (默认 9222) + * --match SUBSTR 选 url 含该子串的标签页 (默认第一个 page) + * --file PATH 从文件读表达式 (等价 awaitPromise) + * --reload 求值前先 reload 一次并等待 load + * --timeout MS 默认 30000 + */ +const fs = require('fs'); + +function arg(name, def) { + const i = process.argv.indexOf('--' + name); + if (i < 0) return def; + const v = process.argv[i + 1]; + return (v === undefined || v.startsWith('--')) ? true : v; +} + +async function listTargets(port) { + const r = await fetch(`http://127.0.0.1:${port}/json/list`); + return r.json(); +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.events = []; } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) { + c.events.push(m); + } + }; + return c; + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((res, rej) => this.waits.set(id, { res, rej })); + } + close() { try { this.ws.close(); } catch (e) {} } +} + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + const port = parseInt(arg('port', '9222'), 10); + const timeout = parseInt(arg('timeout', '30000'), 10); + + if (arg('list', false)) { + const t = await listTargets(port); + for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 120)}\t${x.id}`); + return; + } + + const match = arg('match', null); + const targets = (await listTargets(port)).filter(t => t.type === 'page' || t.type === 'iframe'); + let target = null; + if (match) target = targets.find(t => (t.url || '').includes(match)); + if (!target) target = targets[0]; + if (!target) throw new Error('没有可用标签页'); + + const cdp = await Cdp.connect(target.webSocketDebuggerUrl); + await cdp.send('Runtime.enable'); + + if (arg('reload', false)) { + await cdp.send('Page.enable'); + await cdp.send('Page.reload', { ignoreCache: true }); + await sleep(2500); + } + + let expr; + const file = arg('file', null); + if (file && file !== true) expr = fs.readFileSync(file, 'utf8'); + else { + expr = fs.readFileSync(0, 'utf8'); // stdin + } + if (!expr.trim()) throw new Error('表达式为空'); + + const res = await cdp.send('Runtime.evaluate', { + expression: expr, + awaitPromise: true, + returnByValue: true, + timeout, + }); + const out = { + target: { url: target.url, title: target.title, id: target.id }, + ok: !res.exceptionDetails, + result: res.result ? res.result.value : null, + exception: res.exceptionDetails ? (res.exceptionDetails.exception || {}).description : null, + }; + console.log(JSON.stringify(out, null, 2)); + cdp.close(); +})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); }); diff --git a/tools/cdp_net_capture.js b/tools/cdp_net_capture.js new file mode 100644 index 0000000..d2f4ca0 --- /dev/null +++ b/tools/cdp_net_capture.js @@ -0,0 +1,81 @@ +#!/usr/bin/env node +/** + * cdp_net_capture.js —— 用 CDP 抓某个标签页的网络请求(含 postData), 用于拿到 + * 前端**现场生成**的请求体 (如 1ms 的 /api/v1/captcha/params 里的 identity_context)。 + * + * 零依赖: 只用 Node >= 22 内置 WebSocket / fetch。 + * + * 用法: + * node tools/cdp_net_capture.js --port 9222 --match 1ms.run --url-substr captcha/params --reload + * node tools/cdp_net_capture.js --match 1ms.run --url-substr captcha/params --reload --wait 6000 + */ +const fs = require('fs'); + +function arg(name, def) { + const i = process.argv.indexOf('--' + name); + if (i < 0) return def; + const v = process.argv[i + 1]; + return (v === undefined || v.startsWith('--')) ? true : v; +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) { + for (const h of c.handlers) h(m); + } + }; + return c; + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((res, rej) => this.waits.set(id, { res, rej })); + } + on(fn) { this.handlers.push(fn); } + close() { try { this.ws.close(); } catch (e) {} } +} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +(async () => { + const port = parseInt(arg('port', '9222'), 10); + const match = arg('match', null); + const substr = arg('url-substr', null); + const wait = parseInt(arg('wait', '8000'), 10); + + const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) + .filter(t => t.type === 'page'); + let target = match ? targets.find(t => (t.url || '').includes(match)) : targets[0]; + if (!target) throw new Error('没有可用标签页'); + + const cdp = await Cdp.connect(target.webSocketDebuggerUrl); + const reqs = []; + cdp.on(m => { + if (m.method === 'Network.requestWillBeSent') { + const r = m.params.request; + if (!substr || r.url.includes(substr)) { + reqs.push({ + url: r.url, method: r.method, headers: r.headers, + postData: r.postData || null, at: Date.now(), + }); + } + } + }); + await cdp.send('Network.enable'); + await cdp.send('Page.enable'); + if (arg('reload', false)) { + await cdp.send('Page.reload', { ignoreCache: true }); + } + await sleep(wait); + console.log(JSON.stringify({ target: target.url, captured: reqs }, null, 2)); + cdp.close(); +})().catch(e => { console.error('CDP_ERROR: ' + e.message); process.exit(1); }); diff --git a/tools/cdp_shot.js b/tools/cdp_shot.js new file mode 100644 index 0000000..7a61eae --- /dev/null +++ b/tools/cdp_shot.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +/** + * cdp_shot.js —— 用 CDP 对**已经在跑的** Chrome 某个标签页截图存成 PNG。 + * 零依赖 (Node >= 22 内置 WebSocket/fetch)。 + * + * 用法: + * node tools/cdp_shot.js --match drag_ele --out /tmp/a.png # 整页 + * node tools/cdp_shot.js --match drag_ele --out /tmp/a.png --clip 10,68,340,243 + * node tools/cdp_shot.js --list + * + * 选项: + * --port N 调试端口 (默认 9222) + * --match SUBSTR 选 url 含该子串的 page/iframe 标签页 (默认第一个) + * --out PATH 输出 PNG 路径 (默认 /tmp/cdp_shot.png) + * --clip x,y,w,h 只截该区域 (CSS px, 相对该标签页视口) + * --scale N 设备像素比 (默认 1) + */ +const fs = require('fs'); + +function arg(name, def) { + const i = process.argv.indexOf('--' + name); + if (i < 0) return def; + const v = process.argv[i + 1]; + return (v === undefined || v.startsWith('--')) ? true : v; +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } + }; + return c; + } + send(method, params = {}) { + const id = ++this.id; + this.ws.send(JSON.stringify({ id, method, params })); + return new Promise((res, rej) => this.waits.set(id, { res, rej })); + } + close() { try { this.ws.close(); } catch (e) {} } +} + +(async () => { + const port = parseInt(arg('port', '9222'), 10); + if (arg('list', false)) { + const t = await (await fetch(`http://127.0.0.1:${port}/json/list`)).json(); + for (const x of t) console.log(`${x.type}\t${(x.url || '').slice(0, 120)}\t${x.id}`); + return; + } + const match = arg('match', null); + const targets = (await (await fetch(`http://127.0.0.1:${port}/json/list`)).json()) + .filter(t => t.type === 'page' || t.type === 'iframe'); + let target = match ? targets.find(t => (t.url || '').includes(match)) : null; + if (!target) target = targets[0]; + if (!target) throw new Error('没有可用标签页'); + + const cdp = await Cdp.connect(target.webSocketDebuggerUrl); + const params = { format: 'png', captureBeyondViewport: !!arg('clip', false) }; + const clipRaw = arg('clip', null); + if (clipRaw) { + const [x, y, w, h] = String(clipRaw).split(',').map(Number); + params.clip = { x, y, width: w, height: h, scale: parseFloat(arg('scale', '1')) }; + } + const r = await cdp.send('Page.captureScreenshot', params); + const out = arg('out', '/tmp/cdp_shot.png'); + fs.writeFileSync(out, Buffer.from(r.data, 'base64')); + console.log(JSON.stringify({ ok: true, out, target: { url: target.url, id: target.id } })); + cdp.close(); +})().catch(e => { console.error(String(e)); process.exit(1); });