做了什么:
- 包改为分层: 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
391 lines
16 KiB
Python
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())
|