From 5220c67a99761a5a34b628acc25c92d157436d2b Mon Sep 17 00:00:00 2001 From: yml2213 Date: Fri, 11 Sep 2026 23:36:23 +0800 Subject: [PATCH] =?UTF-8?q?feat(tcaptcha):=20=E5=AE=8C=E6=88=90=E8=85=BE?= =?UTF-8?q?=E8=AE=AF=E5=A4=A9=E5=BE=A1=E7=82=B9=E9=80=89=E7=BA=AF=E5=8D=8F?= =?UTF-8?q?=E8=AE=AE=E5=85=A8=E8=87=AA=E5=8A=A8=E5=8C=96=E5=AE=A2=E6=88=B7?= =?UTF-8?q?=E7=AB=AF=E4=B8=8E=E9=9B=A8=E4=BA=91=E4=B8=9A=E5=8A=A1=E9=80=82?= =?UTF-8?q?=E9=85=8D=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- tcaptcha/benchmark_verify.py | 83 +++++++++ tcaptcha/client.py | 327 ++++++++++++++++++++++++++++++++++ tcaptcha/rainyun_adapter.py | 115 ++++++++++++ tcaptcha/slicer.py | 18 +- tcaptcha/solver.py | 29 +-- tcaptcha/tdc_worker.js | 332 +++++++++++++++++++++++++++++++++++ 6 files changed, 889 insertions(+), 15 deletions(-) create mode 100644 tcaptcha/benchmark_verify.py create mode 100644 tcaptcha/client.py create mode 100644 tcaptcha/rainyun_adapter.py create mode 100644 tcaptcha/tdc_worker.js diff --git a/tcaptcha/benchmark_verify.py b/tcaptcha/benchmark_verify.py new file mode 100644 index 0000000..a5638aa --- /dev/null +++ b/tcaptcha/benchmark_verify.py @@ -0,0 +1,83 @@ +""" +腾讯天御点选验证码纯协议端到端实战压测基准脚本 +连续发起 N 轮真实线上验证请求,统计: +1. 首检通过率 (Success Rate) +2. 端到端耗时分布 (Min / Mean / Max Latency) +3. 异常与失败分类 +""" + +import argparse +import sys +import time +from pathlib import Path +from typing import List + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +from tcaptcha.client import TencentCaptchaClient +from tcaptcha.solver import CaptchaSolver + + +def run_benchmark(rounds: int = 10, aid: str = "2039519451", interval: float = 3.0): + print("=" * 65) + print(f" 🚀 开始腾讯天御验证码纯协议线上压力基准测试 (AppID: {aid})") + print(f" 测试轮数: {rounds}, 请求间隔: {interval}s") + print("=" * 65) + + # 预加载模型,避免把模型初始化耗时算入单次请求 + print("[*] 正在预加载 YOLO 目标检测器与 ResNet18 分类器模型...") + solver = CaptchaSolver() + client = TencentCaptchaClient(aid=aid, solver=solver) + print("[*] 模型就绪,开始线上请求...\n") + + results: List[dict] = [] + latencies: List[float] = [] + successes = 0 + + for i in range(1, rounds + 1): + print(f"--- [Round {i}/{rounds}] ---") + res = client.solve() + results.append(res) + latencies.append(res["elapsed_ms"]) + + if res["success"]: + successes += 1 + print(f" ✅ 成功! 耗时: {res['elapsed_ms']}ms | randstr: {res['randstr']} | ticket: {res['ticket'][:24]}...") + else: + print(f" ❌ 失败! 耗时: {res['elapsed_ms']}ms | 原因: {res['error']}") + + # 间隔停顿模拟真实请求,避免瞬间高频触发频控 + if i < rounds and interval > 0: + time.sleep(interval) + + # 汇总报表 + success_rate = (successes / rounds) * 100 + avg_latency = sum(latencies) / len(latencies) if latencies else 0 + min_latency = min(latencies) if latencies else 0 + max_latency = max(latencies) if latencies else 0 + + print("\n" + "=" * 65) + print(" 📊 纯协议实战基准测试统计汇总") + print("=" * 65) + print(f"总测试轮数: {rounds}") + print(f"成功次数: {successes}") + print(f"失败次数: {rounds - successes}") + print(f"综合首检通过率: {success_rate:.2f}%") + print(f"平均单次耗时: {avg_latency:.1f} ms") + print(f"最短单次耗时: {min_latency} ms") + print(f"最长单次耗时: {max_latency} ms") + print("=" * 65) + + return success_rate, avg_latency + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Tencent Captcha Benchmark Tool") + parser.add_argument("--rounds", type=int, default=10, help="Number of test rounds (default: 10)") + parser.add_argument("--interval", type=float, default=3.0, help="Interval between rounds in seconds (default: 3.0)") + parser.add_argument("--aid", type=str, default="2039519451", help="AppID (default: 2039519451)") + args = parser.parse_args() + + run_benchmark(rounds=args.rounds, aid=args.aid, interval=args.interval) diff --git a/tcaptcha/client.py b/tcaptcha/client.py new file mode 100644 index 0000000..67b2b7d --- /dev/null +++ b/tcaptcha/client.py @@ -0,0 +1,327 @@ +""" +腾讯天御点选验证码工业级纯协议客户端 (Tencent Turing Captcha Protocol Client) +实现端到端全自动纯协议闭环: +1. GET /cap_union_prehandle 获取初始会话与 PoW 题目 +2. 毫秒级求解 Proof of Work 哈希碰撞 +3. 并发拉取动态混淆的 tdc.js 脚本 +4. POST /cap_union_new_getsig 获取当前轮次题目与图片 +5. YOLOv8n + ResNet18 模型进行视觉检测、分类与全局非冲突点选排序 +6. Node.js 沙箱环境运行 tdc.js 并拟合生成拟人化贝塞尔轨迹密文 collect 与 eks +7. POST /cap_union_new_verify 提交验证,获取最终 ticket 与 randstr +""" + +import hashlib +import json +import logging +import random +import re +import subprocess +import sys +import time +from pathlib import Path +from typing import Dict, List, Optional, Tuple + +PROJECT_ROOT = Path(__file__).resolve().parent.parent +if str(PROJECT_ROOT) not in sys.path: + sys.path.insert(0, str(PROJECT_ROOT)) + +import httpx + +from tcaptcha.solver import CaptchaSolver + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("tcaptcha_client") + + +class TencentCaptchaClient: + BASE_URL = "https://turing.captcha.qcloud.com" + DEFAULT_AID = "2039519451" + WORKER_SCRIPT = PROJECT_ROOT / "tcaptcha/tdc_worker.js" + + def __init__( + self, + aid: str = DEFAULT_AID, + solver: Optional[CaptchaSolver] = None, + timeout: float = 15.0, + user_agent: Optional[str] = None, + ): + self.aid = aid + self.timeout = timeout + self.user_agent = user_agent or ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" + ) + self.solver = solver or CaptchaSolver() + + self.http_client = httpx.Client( + base_url=self.BASE_URL, + trust_env=False, # 屏蔽环境代理干扰 + headers={ + "User-Agent": self.user_agent, + "Referer": "https://turing.captcha.qcloud.com/", + "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + }, + timeout=self.timeout, + follow_redirects=True, + ) + + def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]: + """求解 Proof of Work 哈希碰撞""" + t0 = time.time() + for i in range(2000000): + candidate = f"{prefix}{i}" + if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5: + calc_time = max(1, round((time.time() - t0) * 1000)) + return candidate, calc_time + raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}") + + def _generate_telemetry(self, tdc_code: str, click_points: List[Tuple[int, int]]) -> Dict[str, str]: + """通过 Node.js 沙箱运行 tdc.js 生成真实行为轨迹遥测数据""" + points_arg = json.dumps([[int(p[0]), int(p[1])] for p in click_points]) + proc = subprocess.run( + ["node", str(self.WORKER_SCRIPT), "-", points_arg], + input=tdc_code, + text=True, + capture_output=True, + check=True, + ) + data = json.loads(proc.stdout.strip()) + return { + "collect": data.get("collect", ""), + "tlg": str(data.get("tlg", 0)), + "eks": data.get("eks", ""), + } + + def solve(self, max_retries: int = 2) -> Dict: + """ + 端到端全自动解题并提交验证 + 返回格式: + { + "success": bool, + "ticket": str, + "randstr": str, + "elapsed_ms": int, + "points": [(x1, y1), (x2, y2), (x3, y3)], + "raw_response": dict, + "error": str + } + """ + start_time = time.time() + active_sess = None + tkid = None + tdc_code = None + pow_answer = None + pow_calc_time = None + last_raw = {} + last_error = "Max retries exceeded or verification rejected" + + for attempt in range(max_retries): + try: + # 1. 预检请求 (Prehandle) 或在会话内刷新 + cb_name = f"_aq_{random.randint(100000, 999999)}" + if not tkid: + tkid = str(random.randint(100000000, 999999999)) + + if not active_sess: + # 首次进入:执行 Prehandle + ua_b64 = "TW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1Mi4wLjAuMCBTYWZhcmkvNTM3LjM2" + params = { + "aid": self.aid, + "protocol": "https", + "accver": "1", + "showtype": "popup", + "ua": ua_b64, + "noheader": "1", + "fb": "1", + "aged": "0", + "enableAged": "0", + "enableDarkMode": "0", + "grayscale": "1", + "clientype": "2", + "lang": "zh-cn", + "entry_url": "https://app.rainyun.com/account/reward/earn#", + "elder_captcha": "0", + "js": "/tcaptcha-frame.ba387dd1.js", + "wb": "2", + "tkid": tkid, + "subsid": str(random.randint(1, 5)), + "callback": cb_name, + } + + pre_resp = self.http_client.get( + "/cap_union_prehandle", + params=params, + headers={"Referer": "https://app.rainyun.com/"}, + ) + match = re.search(rf"{cb_name}\((.*)\)", pre_resp.text) + if not match: + logger.warning("Failed to parse prehandle callback, retrying...") + continue + + pre_data = json.loads(match.group(1)) + initial_sess = pre_data.get("sess") + comm_cfg = pre_data.get("data", {}).get("comm_captcha_cfg", {}) + + # 2. 计算 PoW + pow_cfg = comm_cfg.get("pow_cfg", {}) + pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], pow_cfg["md5"]) + + # 3. 拉取当前会话专属动态 tdc.js + tdc_path = comm_cfg.get("tdc_path", "") + tdc_resp = self.http_client.get( + tdc_path, + headers={"Referer": "https://turing.captcha.gtimg.com/"}, + ) + tdc_code = tdc_resp.text + current_sess = initial_sess + else: + # 会话内刷新:直接使用前一轮返回的 sess + current_sess = active_sess + + # 4. 获取题目信息与图片 URL (POST /cap_union_new_getsig) + sig_resp = self.http_client.post( + "/cap_union_new_getsig", + data={"sess": current_sess, "tkid": tkid}, + headers={ + "Content-Type": "application/x-www-form-urlencoded", + "Origin": "https://turing.captcha.gtimg.com", + "Referer": "https://turing.captcha.gtimg.com/", + }, + ) + sig_json = sig_resp.json() + if str(sig_json.get("ret")) != "0": + logger.warning(f"getsig returned non-zero ret: {sig_json}") + active_sess = None + continue + + dyn_info = sig_json.get("data", {}) + bg_url = dyn_info.get("bg_elem_cfg", {}).get("img_url", "") + prompt_url = bg_url.replace("img_index=1", "img_index=0") + + # 提取图片 URL 中的最新有效 sess + sess_match = re.search(r"sess=([^&]+)", bg_url) + active_sess = sess_match.group(1) if sess_match else current_sess + + # 5. 下载背景大图与提示图标图 + bg_bytes = self.http_client.get( + bg_url, + headers={"Referer": "https://turing.captcha.gtimg.com/"}, + ).content + prompt_bytes = self.http_client.get( + prompt_url, + headers={"Referer": "https://turing.captcha.gtimg.com/"}, + ).content + + # 6. AI 模型高精度求解 + pts = self.solver.solve(bg_bytes, prompt_bytes) + if len(pts) < 3: + logger.warning("Solver failed to find 3 valid points, retrying...") + continue + + # 7. 格式化答案 ans + ans_list = [ + {"elem_id": 1, "type": "DynAnswerType_POS", "data": f"{pts[0][0]},{pts[0][1]}"}, + {"elem_id": 2, "type": "DynAnswerType_POS", "data": f"{pts[1][0]},{pts[1][1]}"}, + {"elem_id": 3, "type": "DynAnswerType_POS", "data": f"{pts[2][0]},{pts[2][1]}"}, + ] + ans_str = json.dumps(ans_list, separators=(",", ":")) + + # 8. Node 沙箱环境生成真实轨迹 collect 与 eks + telemetry = self._generate_telemetry(tdc_code, pts) + + # 9. 提交验证 POST /cap_union_new_verify + verify_body = { + "ans": ans_str, + "collect": telemetry["collect"], + "tlg": telemetry["tlg"], + "eks": telemetry["eks"], + "sess": active_sess, + "tkid": tkid, + "pow_answer": pow_answer, + "pow_calc_time": str(pow_calc_time), + } + + verify_resp = self.http_client.post( + "/cap_union_new_verify", + data=verify_body, + headers={ + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Origin": "https://turing.captcha.gtimg.com", + "Referer": "https://turing.captcha.gtimg.com/", + }, + ) + + v_json = verify_resp.json() + total_elapsed = int((time.time() - start_time) * 1000) + + if str(v_json.get("errorCode")) == "0": + return { + "success": True, + "ticket": v_json.get("ticket", ""), + "randstr": v_json.get("randstr", ""), + "elapsed_ms": total_elapsed, + "points": pts, + "raw_response": v_json, + "error": "", + } + else: + err_code = str(v_json.get("errorCode")) + err_msg = v_json.get("errMessage", "") + if err_code == "12": + last_error = f"触发腾讯接口频控限制 (errorCode: 12, f_limit),建议稍候或更换IP" + elif err_code == "50": + last_error = f"验证坐标未命中或会话需刷新 (errorCode: 50)" + else: + last_error = f"验证失败 (errorCode: {err_code}, msg: {err_msg})" + + logger.warning(f"Attempt {attempt + 1} verify: {last_error}") + last_raw = v_json + # 如果接口返回了新的会话 sess,下一轮直接沿用该会话重试刷新 + new_sess = v_json.get("sess") + if new_sess: + active_sess = new_sess + else: + active_sess = None + except Exception as e: + last_error = f"请求异常: {e}" + logger.error(f"Attempt {attempt + 1} encountered exception: {e}") + + total_elapsed = int((time.time() - start_time) * 1000) + return { + "success": False, + "ticket": "", + "randstr": "", + "elapsed_ms": total_elapsed, + "points": [], + "raw_response": last_raw, + "error": last_error, + } + + +def main(): + import argparse + + parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client") + parser.add_argument("--aid", default="2039519451", help="Captcha AppID (default: 2039519451 for Rainyun)") + args = parser.parse_args() + + client = TencentCaptchaClient(aid=args.aid) + print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid})...") + result = client.solve() + + if result["success"]: + print("\n" + "=" * 50) + print(" 🎉 验证码纯协议求解成功!") + print("=" * 50) + print(f"耗时: {result['elapsed_ms']} ms") + print(f"点击坐标: {result['points']}") + print(f"randstr: {result['randstr']}") + print(f"ticket: {result['ticket']}") + print("=" * 50) + else: + print(f"\n❌ 验证失败: {result['error']}") + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tcaptcha/rainyun_adapter.py b/tcaptcha/rainyun_adapter.py new file mode 100644 index 0000000..ff426ae --- /dev/null +++ b/tcaptcha/rainyun_adapter.py @@ -0,0 +1,115 @@ +""" +雨云 (Rainyun) 业务接口适配器 (Rainyun Captcha Adapter) +将腾讯天御点选验证码纯协议客户端无缝注入雨云核心业务流程: +1. 任务签到与积分领取: POST /user/reward/tasks (带 vticket 与 vrandstr) +2. 账号密码登录: POST /user/login (带 vticket 与 vrandstr) +""" + +import json +import logging +from typing import Dict, Optional, Tuple + +import httpx + +from tcaptcha.client import TencentCaptchaClient + +logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") +logger = logging.getLogger("rainyun_adapter") + + +class RainyunAdapter: + API_BASE = "https://api.v2.rainyun.com" + RAINYUN_AID = "2039519451" + + def __init__( + self, + api_key: Optional[str] = None, + cookie: Optional[str] = None, + timeout: float = 15.0, + ): + self.api_key = api_key + self.cookie = cookie + self.timeout = timeout + self.captcha_client = TencentCaptchaClient(aid=self.RAINYUN_AID) + + headers = { + "User-Agent": ( + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) " + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36" + ), + "Referer": "https://app.rainyun.com/", + "Origin": "https://app.rainyun.com", + "Accept": "application/json, text/plain, */*", + } + if self.api_key: + headers["X-Api-Key"] = self.api_key + if self.cookie: + headers["Cookie"] = self.cookie + + self.http_client = httpx.Client( + base_url=self.API_BASE, + trust_env=False, + headers=headers, + timeout=self.timeout, + ) + + def get_captcha_ticket(self) -> Tuple[Optional[str], Optional[str]]: + """调用纯协议验证码求解管线,获取有效 ticket 与 randstr""" + logger.info("正在调用腾讯天御验证码求解客户端...") + res = self.captcha_client.solve() + if res["success"]: + logger.info(f"验证码求解成功 (耗时: {res['elapsed_ms']}ms, randstr: {res['randstr']})") + return res["ticket"], res["randstr"] + else: + logger.error(f"验证码求解失败: {res['error']}") + return None, None + + def claim_reward_task(self, task_name: str = "加入用户群") -> Dict: + """ + 带验证码提交雨云积分任务 + 接口: POST https://api.v2.rainyun.com/user/reward/tasks + """ + ticket, randstr = self.get_captcha_ticket() + if not ticket or not randstr: + return {"code": -1, "message": "验证码获取失败"} + + payload = { + "task_name": task_name, + "verifyCode": "", + "vticket": ticket, + "vrandstr": randstr, + } + + logger.info(f"正在提交雨云任务: {task_name}...") + resp = self.http_client.post("/user/reward/tasks", json=payload) + try: + res_json = resp.json() + logger.info(f"雨云任务接口返回: {res_json}") + return res_json + except Exception as e: + logger.error(f"雨云任务接口解析失败: {e}") + return {"code": resp.status_code, "text": resp.text} + + def login(self, field: str, password: str) -> Dict: + """ + 雨云用户登录 (自动注入验证码凭证) + 接口: POST https://api.v2.rainyun.com/user/login + """ + ticket, randstr = self.get_captcha_ticket() + if not ticket or not randstr: + return {"code": -1, "message": "验证码获取失败"} + + payload = { + "field": field, + "password": password, + "verifyCode": "", + "vticket": ticket, + "vrandstr": randstr, + } + + logger.info(f"正在发起雨云登录: {field}...") + resp = self.http_client.post("/user/login", json=payload) + try: + return resp.json() + except Exception as e: + return {"code": resp.status_code, "text": resp.text} diff --git a/tcaptcha/slicer.py b/tcaptcha/slicer.py index f410e0c..6ea7564 100644 --- a/tcaptcha/slicer.py +++ b/tcaptcha/slicer.py @@ -9,12 +9,19 @@ import numpy as np from PIL import Image -def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]: +import io + +def slice_prompt_image(prompt_img: str | Path | bytes | Image.Image, padding: int = 2) -> List[Tuple[Image.Image, Tuple[int, int, int, int]]]: """ 自动识别并切割提示图中的 3 个字符/图标 返回: [(PIL_Image, (x1, y1, x2, y2)), ...] """ - img = Image.open(prompt_img_path).convert("L") + if isinstance(prompt_img, Image.Image): + img = prompt_img.convert("L") + elif isinstance(prompt_img, bytes): + img = Image.open(io.BytesIO(prompt_img)).convert("L") + else: + img = Image.open(prompt_img).convert("L") arr = np.array(img) h, w = arr.shape @@ -48,7 +55,12 @@ def slice_prompt_image(prompt_img_path: str | Path, padding: int = 2) -> List[Tu segments = [(i * step + 5, (i + 1) * step - 5) for i in range(3)] # 原始彩色图用于切片 - orig_img = Image.open(prompt_img_path).convert("RGBA") + if isinstance(prompt_img, Image.Image): + orig_img = prompt_img.convert("RGBA") + elif isinstance(prompt_img, bytes): + orig_img = Image.open(io.BytesIO(prompt_img)).convert("RGBA") + else: + orig_img = Image.open(prompt_img).convert("RGBA") crops = [] for seg_start, seg_end in segments[:3]: diff --git a/tcaptcha/solver.py b/tcaptcha/solver.py index 04c7751..b79a12c 100644 --- a/tcaptcha/solver.py +++ b/tcaptcha/solver.py @@ -7,11 +7,12 @@ 4. 全局匹配: 全局最大后验概率分配 -> 输出毫秒级精准有序点击坐标 [(x1, y1), (x2, y2), (x3, y3)] """ +import io import itertools import logging import sys from pathlib import Path -from typing import List, Tuple +from typing import List, Tuple, Union PROJECT_ROOT = Path(__file__).resolve().parent.parent if str(PROJECT_ROOT) not in sys.path: @@ -75,11 +76,11 @@ class CaptchaSolver: bin_t = cv2.resize(((arr < bg - 20).astype(np.uint8) * 255), (48, 48), interpolation=cv2.INTER_AREA) self.templates.append((cid, bin_t)) - def parse_prompt_targets(self, prompt_img_path: Path) -> List[int]: + def parse_prompt_targets(self, prompt_img: str | Path | bytes | Image.Image) -> List[int]: """ 自动识别并解析提示图中 3 个目标图标的真实类别 ID (0~11) """ - crops = slice_prompt_image(prompt_img_path) + crops = slice_prompt_image(prompt_img) target_classes = [] for crop_img, _ in crops[:3]: arr = np.array(crop_img.convert("L")) @@ -92,12 +93,19 @@ class CaptchaSolver: return target_classes - def _extract_candidates(self, bg_img_path: Path) -> List[Tuple[int, int, Image.Image]]: + def _extract_candidates(self, bg_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int, Image.Image]]: """从背景图中提取所有去重后的候选图标 (cx, cy, patch)""" - results = self.detector(str(bg_img_path), conf=0.03, verbose=False) + if isinstance(bg_img, Image.Image): + bg_pil = bg_img.convert("RGB") + elif isinstance(bg_img, bytes): + bg_pil = Image.open(io.BytesIO(bg_img)).convert("RGB") + else: + bg_pil = Image.open(bg_img).convert("RGB") + + # Ultralytics detector natively accepts PIL.Image + results = self.detector(bg_pil, conf=0.03, verbose=False) boxes = results[0].boxes.xyxy.cpu().numpy() - bg_pil = Image.open(bg_img_path).convert("RGB") w_img, h_img = bg_pil.size raw_cands = [] @@ -120,22 +128,19 @@ class CaptchaSolver: return candidates - def solve(self, bg_img_path: str | Path, prompt_img_path: str | Path) -> List[Tuple[int, int]]: + def solve(self, bg_img: str | Path | bytes | Image.Image, prompt_img: str | Path | bytes | Image.Image) -> List[Tuple[int, int]]: """ 端到端求解验证码点选坐标 返回 3 个目标的像素坐标: [(x1, y1), (x2, y2), (x3, y3)] """ - bg_path = Path(bg_img_path) - prompt_path = Path(prompt_img_path) - # 1. 确定提示图的目标序列 [T1, T2, T3] - target_classes = self.parse_prompt_targets(prompt_path) + target_classes = self.parse_prompt_targets(prompt_img) if len(target_classes) < 3: logger.warning("提示图未成功切出 3 个目标") return [] # 2. 定位背景图所有候选图标 - candidates = self._extract_candidates(bg_path) + candidates = self._extract_candidates(bg_img) if len(candidates) < 3: logger.warning(f"背景图检出候选图标数量不足: {len(candidates)}") return [] diff --git a/tcaptcha/tdc_worker.js b/tcaptcha/tdc_worker.js new file mode 100644 index 0000000..09e8d93 --- /dev/null +++ b/tcaptcha/tdc_worker.js @@ -0,0 +1,332 @@ +/** + * Tencent TDC.js Sandboxed Worker & Mouse Trajectory Generator + * Runs TDC.js in an isolated mock DOM environment to generate authentic + * encrypted telemetry (`collect`) and session encryption key (`eks`). + */ + +const fs = require('fs'); +const vm = require('vm'); + +class Element { + constructor(tagName) { + this.tagName = (tagName || 'div').toUpperCase(); + this.children = []; + this.style = {}; + this.attributes = {}; + } + appendChild(child) { + this.children.push(child); + return child; + } + removeChild(child) { + const idx = this.children.indexOf(child); + if (idx !== -1) this.children.splice(idx, 1); + return child; + } + insertBefore(newChild, refChild) { + const idx = this.children.indexOf(refChild); + if (idx !== -1) this.children.splice(idx, 0, newChild); + else this.children.push(newChild); + return newChild; + } + cloneNode(deep) { + const el = new Element(this.tagName); + el.style = { ...this.style }; + return el; + } + setAttribute(k, v) { this.attributes[k] = String(v); } + getAttribute(k) { return this.attributes[k] || null; } + hasAttribute(k) { return k in this.attributes; } + removeAttribute(k) { delete this.attributes[k]; } + getBoundingClientRect() { + return { top: 0, left: 0, width: 360, height: 360, bottom: 360, right: 360 }; + } +} + +class CanvasElement extends Element { + constructor() { + super('CANVAS'); + this.width = 300; + this.height = 150; + } + getContext(type) { + if (type.includes('webgl')) { + return { + getSupportedExtensions: () => [ + 'ANGLE_instanced_arrays', 'EXT_blend_minmax', 'EXT_texture_filter_anisotropic', + 'OES_element_index_uint', 'OES_standard_derivatives', 'OES_texture_float', + 'OES_texture_half_float', 'OES_vertex_array_object', 'WEBGL_compressed_texture_s3tc', + 'WEBGL_debug_renderer_info', 'WEBGL_depth_texture', 'WEBGL_lose_context' + ], + getExtension: (ext) => { + if (ext === 'WEBGL_debug_renderer_info') { + return { UNMASKED_VENDOR_WEBGL: 37445, UNMASKED_RENDERER_WEBGL: 37446 }; + } + return null; + }, + getParameter: (p) => { + if (p === 37445) return 'Apple'; + if (p === 37446) return 'Apple M1'; + if (p === 7936) return 'WebKit'; + if (p === 7937) return 'WebKit WebGL'; + return 0; + }, + createBuffer: () => ({}), + bindBuffer: () => {}, + bufferData: () => {}, + createProgram: () => ({}), + createShader: () => ({}), + shaderSource: () => {}, + compileShader: () => {}, + attachShader: () => {}, + linkProgram: () => {}, + useProgram: () => {}, + drawArrays: () => {}, + viewport: () => {}, + clearColor: () => {}, + clear: () => {} + }; + } + return { + fillRect: () => {}, + fillText: () => {}, + getImageData: () => ({ data: new Uint8ClampedArray(4) }), + measureText: (txt) => ({ width: txt.length * 10 }), + beginPath: () => {}, + arc: () => {}, + fill: () => {}, + stroke: () => {} + }; + } + toDataURL() { + return 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=='; + } +} + +/** + * Generate human-like mouse movement trajectory using cubic Bézier curves and Gaussian tremor. + */ +function generateBezierTrajectory(p0, p1, steps = 25) { + // Control points with natural curve deviation + const dx = p1.x - p0.x; + const dy = p1.y - p0.y; + const dist = Math.hypot(dx, dy); + + // Lateral deviation perpendicular to direct vector + const perpX = -dy / (dist || 1); + const perpY = dx / (dist || 1); + const curvature = (Math.random() - 0.5) * Math.min(60, dist * 0.4); + + const cp1 = { + x: p0.x + dx * 0.3 + perpX * curvature + (Math.random() - 0.5) * 10, + y: p0.y + dy * 0.3 + perpY * curvature + (Math.random() - 0.5) * 10, + }; + const cp2 = { + x: p0.x + dx * 0.7 + perpX * curvature * 0.6 + (Math.random() - 0.5) * 10, + y: p0.y + dy * 0.7 + perpY * curvature * 0.6 + (Math.random() - 0.5) * 10, + }; + + const points = []; + for (let i = 0; i <= steps; i++) { + const t = i / steps; + // Ease in-out parameterization + const easeT = t * t * (3 - 2 * t); + const invT = 1 - easeT; + + const bx = invT * invT * invT * p0.x + + 3 * invT * invT * easeT * cp1.x + + 3 * invT * easeT * easeT * cp2.x + + easeT * easeT * easeT * p1.x; + const by = invT * invT * invT * p0.y + + 3 * invT * invT * easeT * cp1.y + + 3 * invT * easeT * easeT * cp2.y + + easeT * easeT * easeT * p1.y; + + // Subtle tremor (except start and end) + const tremor = (i === 0 || i === steps) ? 0 : (Math.random() - 0.5) * 1.5; + points.push({ + x: Math.round(bx + tremor), + y: Math.round(by + tremor) + }); + } + return points; +} + +function runTdcCode(rawCode, clickPoints) { + const listeners = []; + function addL(type, fn) { + listeners.push({ type, fn }); + } + + const docBody = new Element('BODY'); + const docElement = new Element('HTML'); + + const baseWindow = { + location: { + href: 'https://turing.captcha.qcloud.com/cap_union_new_show?aid=2039519451', + protocol: 'https:', + host: 'turing.captcha.qcloud.com', + hostname: 'turing.captcha.qcloud.com', + pathname: '/cap_union_new_show', + search: '?aid=2039519451', + hash: '' + }, + document: { + createElement: (tag) => { + if ((tag || '').toLowerCase() === 'canvas') return new CanvasElement(); + return new Element(tag); + }, + addEventListener: addL, + removeEventListener: () => {}, + body: docBody, + documentElement: docElement, + referrer: 'https://app.rainyun.com/', + cookie: '', + getElementsByTagName: (t) => [t === 'body' ? docBody : docElement], + getElementById: () => null, + title: '安全验证', + head: new Element('HEAD') + }, + navigator: { + userAgent: 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/152.0.0.0 Safari/537.36', + platform: 'MacIntel', + languages: ['zh-CN', 'zh', 'en'], + language: 'zh-CN', + cookieEnabled: true, + plugins: [{ name: 'PDF Viewer' }], + mimeTypes: [] + }, + screen: { + width: 1920, + height: 1080, + availWidth: 1920, + availHeight: 1055, + colorDepth: 24, + pixelDepth: 24 + }, + performance: { + now: () => Date.now(), + timing: { + navigationStart: Date.now() - 2500, + loadEventEnd: Date.now() - 800 + } + }, + history: { length: 2 }, + indexedDB: {}, + sessionStorage: { getItem: () => null, setItem: () => {} }, + localStorage: { getItem: () => null, setItem: () => {} }, + Date: Date, + Math: Math, + setTimeout: setTimeout, + clearTimeout: clearTimeout, + setInterval: setInterval, + clearInterval: clearInterval, + console: { log: () => {}, error: () => {}, warn: () => {} }, + innerWidth: 360, + innerHeight: 360, + outerWidth: 1920, + outerHeight: 1080, + devicePixelRatio: 2, + addEventListener: addL, + removeEventListener: () => {} + }; + baseWindow.window = baseWindow; + baseWindow.self = baseWindow; + baseWindow.top = baseWindow; + baseWindow.parent = baseWindow; + + // Execute TDC.js + vm.runInNewContext(rawCode, baseWindow); + + // Map 672x480 coordinates to 360x360 iframe display space + const scale = 320.0 / 672.0; + const targetPoints = clickPoints.map(p => ({ + x: Math.round(20 + p[0] * scale), + y: Math.round(60 + p[1] * scale) + })); + + // Generate path: entrance -> P1 -> P2 -> P3 + let currentPoint = { + x: Math.round(150 + (Math.random() - 0.5) * 60), + y: Math.round(320 + (Math.random() - 0.5) * 20) + }; + + const mmListener = listeners.find(l => l.type === 'mousemove'); + let currentTime = Date.now() - 1500; + + if (mmListener) { + for (const target of targetPoints) { + // Leg trajectory + const steps = Math.floor(18 + Math.random() * 12); + const leg = generateBezierTrajectory(currentPoint, target, steps); + for (const pt of leg) { + currentTime += Math.floor(12 + Math.random() * 16); + mmListener.fn({ + clientX: pt.x, + clientY: pt.y, + pageX: pt.x, + pageY: pt.y, + screenX: pt.x + 300, + screenY: pt.y + 200, + timeStamp: currentTime, + type: 'mousemove', + isTrusted: true + }); + } + currentPoint = target; + // Dwell pause on icon (150ms ~ 350ms) + const dwellSteps = Math.floor(4 + Math.random() * 5); + for (let i = 0; i < dwellSteps; i++) { + currentTime += Math.floor(25 + Math.random() * 30); + mmListener.fn({ + clientX: currentPoint.x + Math.round((Math.random() - 0.5) * 2), + clientY: currentPoint.y + Math.round((Math.random() - 0.5) * 2), + pageX: currentPoint.x, + pageY: currentPoint.y, + screenX: currentPoint.x + 300, + screenY: currentPoint.y + 200, + timeStamp: currentTime, + type: 'mousemove', + isTrusted: true + }); + } + } + } + + const collect = baseWindow.TDC.getData(); + const info = baseWindow.TDC.getInfo(); + const eks = info ? info.info : ''; + + return { + collect, + tlg: collect ? collect.length : 0, + eks + }; +} + +function runTdc(tdcFilePath, clickPoints) { + const rawCode = fs.readFileSync(tdcFilePath, 'utf8'); + return runTdcCode(rawCode, clickPoints); +} + +if (require.main === module) { + const tdcPath = process.argv[2]; + const pointsArg = process.argv[3]; + const points = pointsArg ? JSON.parse(pointsArg) : [[626, 253], [120, 120], [438, 33]]; + + if (tdcPath === '-') { + let input = ''; + process.stdin.setEncoding('utf8'); + process.stdin.on('data', chunk => { input += chunk; }); + process.stdin.on('end', () => { + const result = runTdcCode(input, points); + console.log(JSON.stringify(result)); + }); + } else { + const result = runTdc(tdcPath, points); + console.log(JSON.stringify(result)); + } +} + +module.exports = { runTdc, runTdcCode }; +