# -*- 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_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())