Files
yml2213 344eea2194 refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
  * protocol/  client.py  transport.py  proxy.py
  * telemetry/ crypto.py  values.py  key.py  opcodes.py
               disasm.py  program.py  layout.py  modules.py
  * solvers/   text_click.py  icon_click.py  icon_click_rotate.py  slider.py
  * ocr/       engines.py  slicer.py
  * adapters/  one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
  (放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
  generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
  icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime

删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
  + 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
  siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
  siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M

为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
  tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑

怎么验证:
- .venv/bin/python -m tcaptcha.selftest                    全绿
- scripts/verify/verify_offline.py                         逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py                          4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values         1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
2026-10-12 12:34:57 +08:00

391 lines
16 KiB
Python

# -*- 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.solvers.slider.SliderSolver (ZNCC 找真缺口)
* 图文点选 -> tcaptcha.solvers.icon_click.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.solvers.text_click import TextClickSolver # noqa: E402
from tcaptcha.solvers.icon_click_rotate import IconClickSolver # noqa: E402
from tcaptcha.solvers.slider 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())