feat(诊断): 用真机复现解出 1ms 成功会话的 collect 明文 —— 归因改写为「cd 槽位序按会话随机」

结论 (已知 #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 + 取槽位表两条路)
This commit is contained in:
yml2213
2026-10-11 23:59:42 +08:00
parent bc8cc5e124
commit c55f833c66
17 changed files with 1716 additions and 13 deletions
+44 -13
View File
@@ -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.<b64>.<sig>`) | 确证 |
| 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:<br>`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。→ **请求侧细节不是自变量**(§三 反证仍有效)。<br>不成立部分:把 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,得<br>`{"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"}}`<br>(`tdc.js?app_data=…&appid=<aid>`:真机 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 <cap>.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)
```
+182
View File
@@ -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())
+390
View File
@@ -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> 里 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())
+204
View File
@@ -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")
+2
View File
@@ -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",
]
+226
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

+73
View File
@@ -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())
+115
View File
@@ -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())
+118
View File
@@ -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); });
+94
View File
@@ -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); });
+110
View File
@@ -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); });
+81
View File
@@ -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); });
+77
View File
@@ -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); });