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 0000000..12ac5bd Binary files /dev/null and b/tcaptcha/tests/fixtures/1ms_icon_click_1_bg.jpg differ diff --git a/tcaptcha/tests/fixtures/1ms_icon_click_1_prompt.jpg b/tcaptcha/tests/fixtures/1ms_icon_click_1_prompt.jpg new file mode 100644 index 0000000..056698a Binary files /dev/null and b/tcaptcha/tests/fixtures/1ms_icon_click_1_prompt.jpg differ diff --git a/tcaptcha/tests/fixtures/1ms_slider_1_bg.jpg b/tcaptcha/tests/fixtures/1ms_slider_1_bg.jpg new file mode 100644 index 0000000..700ab91 Binary files /dev/null and b/tcaptcha/tests/fixtures/1ms_slider_1_bg.jpg differ 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 0000000..9311026 Binary files /dev/null and b/tcaptcha/tests/fixtures/1ms_slider_1_sprite.png differ 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); });