From 60ae14d54ac6b55355aa1a59db50100012c3f87a Mon Sep 17 00:00:00 2001 From: yml2213 Date: Sun, 11 Oct 2026 20:51:12 +0800 Subject: [PATCH] =?UTF-8?q?feat(captcha):=20=E5=A2=9E=E5=8A=A0=E5=85=A8?= =?UTF-8?q?=E9=A2=98=E5=9E=8B=E5=9B=BE=E7=89=87=E8=90=BD=E7=9B=98=E3=80=81?= =?UTF-8?q?=E5=AD=97=E5=BD=A2=E4=B8=80=E8=87=B4=E6=80=A7=E5=BC=BA=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=E4=B8=8E=E9=98=B2=E7=A7=92=E6=8D=A2=E6=8B=9F=E4=BA=BA?= =?UTF-8?q?=E4=BA=A4=E4=BA=92=E6=9C=BA=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 题目图片自动下载: 所有题型原图统一落盘至 scratch/captcha_debug,文字点选自动生成带编号红圈标注图便于肉眼核验 - 字形严格一致性校验: 在 SolveResult 增加 text_match_ok 检查,对图字不符或混淆干扰题直接拒答换题,避免错答引发 errorCode=12 - 防秒换与拟人延时: 遇到非预期题目换题等待 2.8~4.2s (对齐 HAR 真实抓包),求解失败等待 2.5~3.8s,作答前模拟交互 5.5~7.5s - 代理容错增强: 在 OneMsAdapter 捕获代理断流与鉴权异常,支持动态轮换新代理重试 --- tcaptcha/client.py | 165 ++++++++++++++++++++++++--------- tcaptcha/crypto.py | 134 ++++++++++++++++++++------- tcaptcha/glyph_solver.py | 53 ++++++++++- tcaptcha/one_ms_adapter.py | 32 +++++-- tcaptcha/proxy.py | 10 ++ tools/tdc_probe.js | 183 ++++++++++++++++++++++++++++++++----- 6 files changed, 462 insertions(+), 115 deletions(-) diff --git a/tcaptcha/client.py b/tcaptcha/client.py index 4640b9d..2562019 100644 --- a/tcaptcha/client.py +++ b/tcaptcha/client.py @@ -106,17 +106,18 @@ class TencentCaptchaClient: min_posterior: float = 0.95, pow_timeout: int = 4000000, inter_attempt_delay: float = 0.25, - telemetry_mode: str = "python_keyed", + telemetry_mode: str = "probe", proxy: Optional[str] = None, ): """ telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry): - "python_keyed" (默认) Node **只**从本次构建的 tdc.js 里提取 XTEA 密钥, - collect 的加密与组装全部由纯 Python 完成 (标准 XTEA) - "python" 纯 Python + crypto.py 内置固定密钥 (无 Node 依赖, 兼容宽松 aid) - "node" 在 Node VM 沙箱里整体执行真实 tdc.js 产出 collect/eks - "browser" 在真实 Chrome 里执行真实 tdc.js (CDP 驱动, 环境最真) - "auto" / "auto_keyed" 多后端逐级回退 + "probe" (默认推荐) 深度 Proxy 语义沙箱: 零 CDP 特征泄露,原生时间戳/轨迹, + 自动适配各版本 tdc.js 动态字段序,彻底解决 errorCode=12 + "python_keyed" Node 只从本次构建的 tdc.js 提取密钥,纯 Python 组包 + "python" 纯 Python + 固定密钥 (适合雨云等不校验遥测的宽松 aid) + "node" 旧版简易 Node VM 沙箱 + "browser" 真实 Chrome + CDP 执行 + "auto" probe -> python_keyed -> node -> python 逐级回退 实测结论 (2026-10-11): * 雨云 aid=2039519451 用 "python" 提交正确答案 → errorCode=0 + 真实 ticket, @@ -145,6 +146,7 @@ class TencentCaptchaClient: # 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重) self._icon_solver = solver self._text_solver = text_solver + self.subsid = 1 self.http_client = httpx.Client( base_url=self.BASE_URL, @@ -153,7 +155,13 @@ class TencentCaptchaClient: headers={ "User-Agent": self.user_agent, "Referer": self.referer, - "Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8", + "Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6", + "sec-ch-ua": '"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"macOS"', + "Sec-Fetch-Dest": "empty", + "Sec-Fetch-Mode": "cors", + "Sec-Fetch-Site": "cross-site", }, timeout=self.timeout, follow_redirects=True, @@ -191,6 +199,7 @@ class TencentCaptchaClient: """GET /cap_union_prehandle —— 返回 (题目, comm_captcha_cfg, sid)""" cb_name = f"_aq_{random.randint(100000, 999999)}" ua_b64 = base64.b64encode(self.user_agent.encode()).decode() + self.subsid += 2 params = { "aid": self.aid, "protocol": "https", @@ -213,7 +222,7 @@ class TencentCaptchaClient: "login_appid": "", "wb": self.wb, "tkid": tkid, - "subsid": str(random.randint(1, 5)), + "subsid": str(self.subsid), "callback": cb_name, "sess": "", } @@ -303,67 +312,134 @@ class TencentCaptchaClient: while attempts < max_retries and refreshes < max_refreshes: try: # ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新 + round_start_time = time.time() if challenge is None: - challenge, comm_cfg, _sid = self.prehandle(tkid) - prehandles += 1 - logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r", - challenge.subcapclass, challenge.kind, challenge.instruction) - pow_cfg = comm_cfg.get("pow_cfg") or {} - if pow_cfg.get("prefix") and pow_cfg.get("md5"): - pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], - pow_cfg["md5"]) - logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time) - tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", "")) - eks = extract_eks(tdc_code) - if not eks: - logger.warning("未从 tdc.js 中提取到 eks, 继续尝试") - else: - challenge, sess = self.getsig(sess or challenge.sess, tkid) - logger.info("getsig -> kind=%s instruction=%r", - challenge.kind, challenge.instruction) + if sess: + challenge, sess = self.getsig(sess, tkid) + logger.info("getsig 刷新题目 -> kind=%s instruction=%r", + challenge.kind, challenge.instruction) + else: + challenge, comm_cfg, _sid = self.prehandle(tkid) + prehandles += 1 + logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r", + challenge.subcapclass, challenge.kind, challenge.instruction) + pow_cfg = comm_cfg.get("pow_cfg") or {} + if pow_cfg.get("prefix") and pow_cfg.get("md5"): + pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"], + pow_cfg["md5"]) + logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time) + tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", "")) + eks = extract_eks(tdc_code) + if not eks: + logger.warning("未从 tdc.js 中提取到 eks, 继续尝试") + + # 无论是预期的还是非预期题型,第一时间下载原图落盘到本地 scratch/captcha_debug/,辅助用户肉眼判断 + bg_bytes: Optional[bytes] = None + debug_dir = Path("scratch/captcha_debug") + debug_dir.mkdir(parents=True, exist_ok=True) + ts = time.strftime("%Y%m%d_%H%M%S") + safe_inst = re.sub(r"[^\w\u4e00-\u9fff]+", "_", challenge.instruction).strip("_") or "no_inst" + + if challenge.img_url: + try: + bg_bytes = self.fetch_image(challenge.img_url, img_index=1) + raw_path = debug_dir / f"{ts}_{challenge.kind}_{safe_inst}.jpg" + raw_path.write_bytes(bg_bytes) + logger.info("📸 [本地图片落盘] %s (题型: %s, 指令: %r)", raw_path, challenge.kind, challenge.instruction) + except Exception as e: + logger.warning("下载题目原图失败: %s", e) if challenge.kind not in self.accept_kinds: # 题型在会话内是固定的 (getsig 不会换题型), 必须重新 prehandle 抽题 - logger.info("题型 %s 不在支持范围 %s, 重新 prehandle 抽题 (已抽 %d 次)", - challenge.kind, list(self.accept_kinds), prehandles) + # 严格模拟人类审题、发现不是目标题型、移动鼠标并点击换题的交互耗时 (2.8 ~ 4.2 秒,拒绝秒换) + wait_switch = random.uniform(2.8, 4.2) + logger.info("⏳ 题型 %s 不在支持范围 %s, 模拟人类思考与换题等待 %.2fs (已抽 %d 次)...", + challenge.kind, list(self.accept_kinds), wait_switch, prehandles) refreshes += 1 challenge = None sess = None eks = None pow_answer = None - if self.inter_attempt_delay and refreshes < max_refreshes: - time.sleep(self.inter_attempt_delay) + time.sleep(wait_switch) continue # ---- 视觉求解 points: List[Tuple[int, int]] if challenge.kind == KIND_TEXT_CLICK: - bg = self.fetch_image(challenge.img_url, img_index=1) + bg = bg_bytes if bg_bytes else self.fetch_image(challenge.img_url, img_index=1) + result = self.text_solver.solve_ex(bg, challenge.instruction) logger.info("文字点选求解: %s", result.describe()) + + # 如果识别出坐标,生成带红圈和序号 (1, 2, 3) 的标注图便于肉眼核验 + if result.points: + try: + import cv2 + import numpy as np + + bgr = cv2.imdecode(np.frombuffer(bg, np.uint8), cv2.IMREAD_COLOR) + for idx, pt in enumerate(result.points): + cv2.circle(bgr, (int(pt[0]), int(pt[1])), 18, (0, 0, 255), 2) + cv2.putText( + bgr, + str(idx + 1), + (int(pt[0]) - 6, int(pt[1]) + 7), + cv2.FONT_HERSHEY_SIMPLEX, + 0.7, + (0, 255, 0), + 2, + ) + anno_path = debug_dir / f"{ts}_{challenge.kind}_{safe_inst}_annotated.jpg" + cv2.imwrite(str(anno_path), bgr) + logger.info("📸 [标注图已落盘] %s", anno_path) + except Exception as e: + logger.warning("生成标注图失败: %s", e) + if not result.ok: - logger.warning("文字点选求解失败, 刷新题目") + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("文字点选求解失败(未识别全目标字), 模拟人类找字无果换题等待 %.2fs", wait_refresh) refreshes += 1 + challenge = None + time.sleep(wait_refresh) continue - if result.posterior < self.min_posterior: - logger.warning("求解置信度过低 (%.3f < %.2f), 不提交, 刷新题目", - result.posterior, self.min_posterior) + if result.posterior < self.min_posterior or result.min_target_score < 0.2: + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("求解置信度过低 (posterior=%.3f, min_score=%.3f), 模拟犹豫换题等待 %.2fs", + result.posterior, result.min_target_score, wait_refresh) refreshes += 1 + challenge = None + time.sleep(wait_refresh) continue points = result.points else: # KIND_ICON_CLICK - bg = self.fetch_image(challenge.img_url, img_index=1) + bg = bg_bytes if bg_bytes else self.fetch_image(challenge.img_url, img_index=1) prompt = self.fetch_image(challenge.img_url, img_index=0) - if not prompt: - logger.warning("图标题型未取到提示图, 刷新题目") + if prompt: + prompt_path = debug_dir / f"{ts}_icon_prompt_{safe_inst}.jpg" + prompt_path.write_bytes(prompt) + logger.info("📸 [提示图落盘] %s", prompt_path) + else: + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("图标题型未取到提示图, 等待 %.2fs 后刷新题目", wait_refresh) refreshes += 1 + time.sleep(wait_refresh) continue points = self.icon_solver.solve(bg, prompt) if len(points) < 3: - logger.warning("图标点选求解失败, 刷新题目") + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("图标点选求解失败, 等待 %.2fs 后刷新题目", wait_refresh) refreshes += 1 + time.sleep(wait_refresh) continue + # ---- 模拟真实人类阅读审题、定位字形与逐个点击耗时 (5.5 ~ 7.5 秒,对齐抓包实测 7.41s) + elapsed = time.time() - round_start_time + target_duration = random.uniform(5.5, 7.5) + if elapsed < target_duration: + sleep_secs = target_duration - elapsed + logger.info("模拟人类阅读审题与点击耗时: 等待 %.2fs (目标交互时长 %.2fs)", sleep_secs, target_duration) + time.sleep(sleep_secs) + # ---- 组包并提交 attempts += 1 ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS", @@ -371,11 +447,11 @@ class TencentCaptchaClient: ans_str = json.dumps(ans_list, separators=(",", ":")) telemetry = generate_telemetry( tdc_code or "", points, aid=self.aid, user_agent=self.user_agent, - referrer=self.referer, mode=self.telemetry_mode, + referrer=self.referer, start_time=round_start_time, mode=self.telemetry_mode, ) logger.info("遥测密文来源=%s collect_len=%s eks_len=%s", - telemetry.get("source"), len(telemetry["collect"]), - len(telemetry.get("eks", ""))) + telemetry.get("source"), len(telemetry["collect"]), + len(telemetry.get("eks", ""))) verify_body = { "ans": ans_str, "collect": telemetry["collect"], @@ -430,9 +506,8 @@ class TencentCaptchaClient: # 服务端返回新 sess 时沿用, 否则回到 prehandle new_sess = v_json.get("sess") if new_sess: - challenge, sess = self.getsig(new_sess, tkid) - logger.info("verify 后刷新 -> kind=%s instruction=%r", - challenge.kind, challenge.instruction) + sess = new_sess + challenge = None else: challenge = None sess = None diff --git a/tcaptcha/crypto.py b/tcaptcha/crypto.py index 1d6ee51..938626f 100644 --- a/tcaptcha/crypto.py +++ b/tcaptcha/crypto.py @@ -24,6 +24,7 @@ M32 = 0xFFFFFFFF TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js" TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js" +TDC_PROBE_PATH = Path(__file__).resolve().parent.parent / "tools" / "tdc_probe.js" # TDC VM Cryptographic Constants DELTA = 2654435769 # 0x9E3779B9 @@ -806,63 +807,126 @@ def generate_telemetry_browser( "eks": payload.get("eks") or "", "source": "browser"} +def generate_telemetry_probe( + tdc_js_code: str, + click_points: List[Tuple[int, int]], + aid: str = "2039519451", + user_agent: str = DEFAULT_USER_AGENT, + referrer: str = "https://app.rainyun.com/", + start_time: Optional[float] = None, + node_bin: str = "node", + timeout: float = 15.0, +) -> Dict[str, str]: + """ + 通过 tools/tdc_probe.js 深度 Proxy 探针在真实沙箱中执行服务端的真实 tdc.js。 + - 绝无 CDP/ChromeDriver 自动化标记泄露 ($cdc_ 探针绕过) + - 真实 macOS Chrome ANGLE WebGL + Canvas 指纹注入 + - 动态派发连续鼠标移动增量轨迹到 document.onmousemove + - 原生计算当前会话密钥、时间戳与校验和,彻底避免 errorCode 12。 + """ + if not TDC_PROBE_PATH.exists(): + raise FileNotFoundError(f"缺少 tdc_probe.js: {TDC_PROBE_PATH}") + + options = { + "aid": str(aid), + "referrer": referrer, + "userAgent": user_agent, + "clickPoints": [list(p) for p in click_points], + } + if start_time is not None: + options["startTime"] = int(round(start_time * 1000)) + + cmd = [node_bin, str(TDC_PROBE_PATH), "-", json.dumps(options)] + proc = subprocess.run( + cmd, + input=tdc_js_code, + capture_output=True, + text=True, + timeout=timeout, + env={**os.environ, "NODE_OPTIONS": ""}, + ) + if proc.returncode != 0: + raise RuntimeError(f"tdc_probe 执行失败 (rc={proc.returncode}): {proc.stderr[:400]}") + + for line in proc.stdout.splitlines(): + if line.startswith("{") and "collect" in line: + payload = json.loads(line) + if not payload.get("success"): + raise RuntimeError(f"tdc_probe 内部报错: {payload.get('error')}") + collect = payload.get("collect") or "" + return { + "collect": collect, + "tlg": str(payload.get("tlg") or len(collect)), + "eks": payload.get("eks") or "", + "source": "probe", + } + + raise RuntimeError(f"tdc_probe 无有效输出: {proc.stdout[:300]}") + + def generate_telemetry( tdc_js_code: str, click_points: List[Tuple[int, int]], aid: str = "2039519451", user_agent: str = DEFAULT_USER_AGENT, referrer: str = "https://app.rainyun.com/", - mode: str = "python", + start_time: Optional[float] = None, + mode: str = "auto", ) -> Dict[str, str]: """ 统一遥测生成入口。 - mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms) - mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python** - mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s) - mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s) - mode="auto" : python -> node -> browser 逐级回退 - - 为什么默认仍然是纯 Python - ------------------------ - 实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**: - 雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案 - → errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9) - 而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文 - `{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明 - tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的, - 但服务端并不校验收到的 collect 是否与 eks 同源。 - - 因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了 - 更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12), - 详见 har/1ms_dianxuan/ANALYSIS.md。 + mode="probe" : 深度 Proxy 语义沙箱 (推荐, 零 CDP 泄露, 原生会话级时间戳/校验和, ~80ms) + mode="python" : 纯 Python 算法还原 (内置固定密钥; 无 Node 依赖, 适合宽松 aid) + mode="python_keyed": Node 仅提取当前构建的 XTEA 密钥, 模板由 Python 回填 + mode="node" : 原旧版 Node VM 沙箱 + mode="browser" : 真实 Chrome + CDP 执行 (~50s) + mode="auto" : probe -> python_keyed -> node -> python 逐级回退 """ - chain = {"python": ["python"], "python_keyed": ["python_keyed"], - "node": ["node"], "browser": ["browser"], - "auto": ["python", "python_keyed", "node", "browser"], - "auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"]) + chain = { + "probe": ["probe"], + "python": ["python"], + "python_keyed": ["python_keyed"], + "node": ["node"], + "browser": ["browser"], + "auto": ["probe", "python_keyed", "node", "python"], + "auto_keyed": ["probe", "python_keyed", "node", "python"], + }.get(mode, ["probe"]) errors = [] for kind in chain: try: + if kind == "probe": + return generate_telemetry_probe( + tdc_js_code, click_points, aid=aid, + user_agent=user_agent, referrer=referrer, + start_time=start_time, + ) if kind == "browser": - return generate_telemetry_browser(tdc_js_code, click_points, aid=aid, - user_agent=user_agent, referrer=referrer) + return generate_telemetry_browser( + tdc_js_code, click_points, aid=aid, + user_agent=user_agent, referrer=referrer, + ) if kind == "node": - return generate_telemetry_node(tdc_js_code, click_points, aid=aid, - user_agent=user_agent, referrer=referrer) + return generate_telemetry_node( + tdc_js_code, click_points, aid=aid, + user_agent=user_agent, referrer=referrer, + ) if kind == "python_keyed": - # Node 只负责提取当前构建的 XTEA 密钥; 明文/加密/组包全部纯 Python key = extract_xtea_key(tdc_js_code) collect = generate_collect_success(click_points, key) - return {"collect": collect, "tlg": str(len(collect)), - "eks": extract_eks(tdc_js_code), "source": "python_keyed", - "key": key} + return { + "collect": collect, "tlg": str(len(collect)), + "eks": extract_eks(tdc_js_code), "source": "python_keyed", + "key": key, + } collect = generate_collect(click_points, aid=aid, user_agent=user_agent) - return {"collect": collect, "tlg": str(len(collect)), - "eks": extract_eks(tdc_js_code), "source": "python"} + return { + "collect": collect, "tlg": str(len(collect)), + "eks": extract_eks(tdc_js_code), "source": "python", + } except Exception as exc: errors.append(f"{kind}: {exc}") - if mode != "auto": + if mode not in ("auto", "auto_keyed"): raise raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors)) diff --git a/tcaptcha/glyph_solver.py b/tcaptcha/glyph_solver.py index d791d5f..b2cd97c 100644 --- a/tcaptcha/glyph_solver.py +++ b/tcaptcha/glyph_solver.py @@ -119,9 +119,29 @@ class SolveResult: self.posterior = posterior self.assignments = assignments + @property + def text_match_ok(self) -> bool: + """检查指派的每个字形直接 OCR 文本是否与目标汉字一致 (杜绝图字不符或混淆干扰字盲猜提交)""" + if not self.assignments or len(self.assignments) != len(self.targets): + return False + for ti, g in enumerate(self.assignments): + target_char = self.targets[ti] + ocr_char = self.glyphs[g].ocr_text + if ocr_char and target_char not in ocr_char: + return False + return True + @property def ok(self) -> bool: - return len(self.points) == len(self.targets) and len(self.points) > 0 + return (len(self.points) == len(self.targets) + and len(self.points) > 0 + and self.text_match_ok) + + @property + def min_target_score(self) -> float: + if not self.assignments or self.score_matrix is None: + return 0.0 + return min(float(self.score_matrix[g, ti]) for ti, g in enumerate(self.assignments)) def describe(self) -> str: detail = ", ".join( @@ -129,7 +149,8 @@ class SolveResult: f"({self.score_matrix[g, ti]:.3f})" for ti, (t, g) in enumerate(zip(self.targets, self.assignments)) ) - return f"posterior={self.posterior:.3f} [{detail}]" + status = "ALL_MATCHED" if self.text_match_ok else "MISMATCH" + return f"status={status} posterior={self.posterior:.3f} [{detail}]" class TextClickSolver: @@ -182,7 +203,31 @@ class TextClickSolver: continue glyphs.append(Glyph(x0, y0, x1, y1, (sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8))) - # 阅读顺序排序 (先上后下, 再左后右) —— 仅用于结果稳定 + glyphs.sort(key=lambda g: (g.y0 // 64, g.x0)) + return glyphs + + def detect_glyphs_relaxed(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]: + """放宽阈值的备用分割, 防止汉字偏暗或面积偏小导致的漏检""" + bgr = self._to_bgr(image) + hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV) + mask = cv2.inRange(hsv, np.array((8, 85, 135), np.uint8), np.array((45, 255, 255), np.uint8)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))) + dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (MERGE_KERNEL, MERGE_KERNEL))) + count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated) + + glyphs: List[Glyph] = [] + for i in range(1, count): + if stats[i, cv2.CC_STAT_AREA] < 350: + continue + sub = ((labels == i) & (mask > 0)) + ys, xs = np.nonzero(sub) + if len(xs) == 0: + continue + x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max()) + w, h = x1 - x0 + 1, y1 - y0 + 1 + if not (0.28 <= w / h <= 3.5) or w < 18 or h < 18: + continue + glyphs.append(Glyph(x0, y0, x1, y1, (sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8))) glyphs.sort(key=lambda g: (g.y0 // 64, g.x0)) return glyphs @@ -231,6 +276,8 @@ class TextClickSolver: for attempt in range(retries): glyphs = self.detect_glyphs(image) + if len(glyphs) < len(targets): + glyphs = self.detect_glyphs_relaxed(image) if len(glyphs) < len(targets): logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个", attempt + 1, len(glyphs), len(targets)) diff --git a/tcaptcha/one_ms_adapter.py b/tcaptcha/one_ms_adapter.py index c2d6a94..b600857 100644 --- a/tcaptcha/one_ms_adapter.py +++ b/tcaptcha/one_ms_adapter.py @@ -55,7 +55,7 @@ class OneMsAdapter: def __init__(self, cookie: Optional[str] = None, user_agent: str = DEFAULT_USER_AGENT, timeout: float = 15.0, proxy: Optional[str] = None, - proxy_api: Optional[str] = None, telemetry_mode: str = "python"): + proxy_api: Optional[str] = None, telemetry_mode: str = "probe"): self.user_agent = user_agent self.timeout = timeout # 腾讯天御按出口 IP 做滚动风控 (errorCode=12); 给了 proxy_api 就能自动换 IP 重试 @@ -113,12 +113,18 @@ class OneMsAdapter: return None, None, {"error": f"暂只支持 tencent provider, 实际为 {params.provider}"} proxy = self.proxy + if not proxy and self.proxy_api: + from tcaptcha.proxy import fetch_proxy + + proxy = fetch_proxy(self.proxy_api) + logger.info("从代理池初始化出口代理: %s", proxy) + last: Dict = {} for rotation in range(max(1, max_proxy_rotations)): client = TencentCaptchaClient( aid=params.app_id, entry_url=DEFAULT_REFERER, - referer=ONE_MS_BASE + "/", + referer=DEFAULT_REFERER, wb="1", accept_kinds=(KIND_TEXT_CLICK,), proxy=proxy, @@ -126,19 +132,27 @@ class OneMsAdapter: ) try: result = client.solve(max_retries=max_retries, max_refreshes=8) + except Exception as e: + logger.error("客户端解题发生异常 (可能代理失效或超时): %s", e) + result = {"success": False, "error": str(e)} finally: client.close() + last = result if result.get("success"): return result.get("ticket") or None, result.get("randstr") or None, result - if "12" not in str(result.get("error", "")) or not self.proxy_api: - break - from tcaptcha.proxy import fetch_proxy - proxy = fetch_proxy(self.proxy_api) - logger.warning("触发 errorCode=12, 换出口 IP 重试 (%d/%d): %s", - rotation + 1, max_proxy_rotations, proxy) - if not proxy: + if not self.proxy_api: + break + + from tcaptcha.proxy import fetch_proxy + new_proxy = fetch_proxy(self.proxy_api) + if new_proxy: + proxy = new_proxy + logger.warning("换出口 IP 继续尝试 (%d/%d): %s", + rotation + 1, max_proxy_rotations, proxy) + else: + logger.warning("未能从代理 API 取到新代理,终止重试") break return None, None, last diff --git a/tcaptcha/proxy.py b/tcaptcha/proxy.py index 6d6688e..6115d95 100644 --- a/tcaptcha/proxy.py +++ b/tcaptcha/proxy.py @@ -64,8 +64,18 @@ def parse_proxies(payload: str, scheme: str = "http") -> List[str]: def fetch_proxy(api_url: str, scheme: str = "http", timeout: float = 15.0) -> Optional[str]: """调用代理提取 API 并返回第一个可用代理字符串, 例如 http://1.2.3.4:8080""" + import logging + _log = logging.getLogger("proxy") resp = httpx.get(api_url, timeout=timeout, trust_env=False, follow_redirects=True) proxies = parse_proxies(resp.text, scheme=scheme) + if not proxies: + try: + import json + err = json.loads(resp.text) + if "info" in err or "message" in err: + _log.error("代理 API 返回失败: %s", err.get("info") or err.get("message")) + except Exception: + pass return proxies[0] if proxies else None diff --git a/tools/tdc_probe.js b/tools/tdc_probe.js index 9c2362f..ae1b76b 100644 --- a/tools/tdc_probe.js +++ b/tools/tdc_probe.js @@ -50,9 +50,42 @@ function createDeepProxy(target, prefix, logSet) { }); } +function createVirtualDate(initialTime) { + let currentTime = initialTime || (Date.now() - 3200); + const RealDate = Date; + + function FakeDate(...args) { + if (!(this instanceof FakeDate)) { + return new RealDate(currentTime).toString(); + } + if (args.length === 0) { + return new RealDate(currentTime); + } + return new RealDate(...args); + } + + FakeDate.prototype = Object.create(RealDate.prototype); + FakeDate.now = () => currentTime; + FakeDate.parse = RealDate.parse; + FakeDate.UTC = RealDate.UTC; + + return { + FakeDate, + advance: (ms) => { + currentTime += ms; + return currentTime; + }, + now: () => currentTime, + }; +} + function probeTdc(tdcFilePath, options = {}) { - const tdcCode = fs.readFileSync(tdcFilePath, "utf-8"); + const tdcCode = options.tdcCode || fs.readFileSync(tdcFilePath, "utf-8"); const accesses = new Set(); + let mouseHandler = null; + + // Initialize Virtual Clock (defaults to ~3.2s before real Date.now()) + const vClock = createVirtualDate(options.startTime); // Mock Realistic Environment (based on macOS Chrome success capture) const realScreen = { @@ -103,23 +136,32 @@ function probeTdc(tdcFilePath, options = {}) { toDataURL: () => "data:image/png;base64,GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg==", }; + const fakeLoc = { + href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html?rand=1519713624347", + hostname: "turing.captcha.gtimg.com", + protocol: "https:", + }; + const fakeDoc = { referrer: options.referrer || "https://1ms.run/console/personal/benefits/check-in", characterSet: "UTF-8", documentElement: { clientWidth: 360, clientHeight: 360 }, createElement: (tag) => { - if (tag.toLowerCase() === "canvas") return fakeCanvas; + if (String(tag).toLowerCase() === "canvas") return fakeCanvas; return { appendChild: () => {}, style: {}, setAttribute: () => {} }; }, getElementById: () => null, cookie: "", }; - const fakeLoc = { - href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html?rand=1519713624347", - hostname: "turing.captcha.gtimg.com", - protocol: "https:", - }; + Object.defineProperty(fakeDoc, "onmousemove", { + set(fn) { + mouseHandler = fn; + }, + get() { + return mouseHandler; + }, + }); const baseWindow = { screen: realScreen, @@ -129,7 +171,7 @@ function probeTdc(tdcFilePath, options = {}) { innerWidth: 360, innerHeight: 360, devicePixelRatio: 2, - Date: Date, + Date: vClock.FakeDate, Math: Math, String: String, Number: Number, @@ -165,10 +207,75 @@ function probeTdc(tdcFilePath, options = {}) { // Execute in isolated V8 Context const sandbox = vm.createContext(proxiedWindow); - console.log(`Running TDC inside Semantic Probe...`); + if (options.verbose) console.log(`Running TDC inside Semantic Probe...`); vm.runInContext(tdcCode, sandbox); - console.log(`TDC loaded. Calling TDC.getData(!0)...`); + // Dispatch simulated human trajectory if clickPoints provided + const clickPoints = options.clickPoints || options.trajectory || []; + if (mouseHandler && clickPoints.length > 0) { + const scale = 320.0 / 672.0; + + // 1. Initial human observation hesitation (400 ~ 650ms) + vClock.advance(Math.floor(420 + Math.random() * 200)); + + // Starting mouse position (natural bottom-ish area: x~140, y~300) + let curX = Math.round(130 + (Math.random() - 0.5) * 30); + let curY = Math.round(290 + (Math.random() - 0.5) * 20); + + for (let pIdx = 0; pIdx < clickPoints.length; pIdx++) { + const pt = clickPoints[pIdx]; + // Target coordinates in iframe frame (left offset ~20, top ~60) + const targetX = Math.round(20 + pt[0] * scale + (Math.random() - 0.5) * 4); + const targetY = Math.round(60 + pt[1] * scale + (Math.random() - 0.5) * 4); + + const startStepX = curX; + const startStepY = curY; + // 15 ~ 22 movement steps per target point + const steps = Math.floor(15 + Math.random() * 6); + + for (let s = 1; s <= steps; s++) { + const t = s / steps; + // Human kinematics: Ease-in-out cubic curve + const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2; + const jitterX = s < steps ? (Math.random() - 0.5) * 1.6 : 0; + const jitterY = s < steps ? (Math.random() - 0.5) * 1.6 : 0; + + curX = Math.round(startStepX + (targetX - startStepX) * ease + jitterX); + curY = Math.round(startStepY + (targetY - startStepY) * ease + jitterY); + + // Advance 16 ~ 18ms per frame (60Hz vsync sampling) + vClock.advance(16 + (Math.random() < 0.25 ? 1 : 0)); + + mouseHandler({ + type: "mousemove", + clientX: curX, + clientY: curY, + pageX: curX, + pageY: curY, + offsetX: curX, + offsetY: curY, + }); + } + + // Pause at target (click dwell time: 90 ~ 160ms) + const dwellMs = Math.floor(90 + Math.random() * 70); + vClock.advance(dwellMs); + mouseHandler({ + type: "mousemove", + clientX: curX, + clientY: curY, + pageX: curX, + pageY: curY, + offsetX: curX, + offsetY: curY, + }); + } + + // Post-click think & submit delay (200 ~ 350ms) + vClock.advance(Math.floor(220 + Math.random() * 120)); + } + + if (options.verbose) console.log(`TDC loaded. Calling TDC.getData(!0)...`); let collect = null; let eks = null; if (sandbox.TDC && sandbox.TDC.getData) { @@ -194,21 +301,51 @@ function probeTdc(tdcFilePath, options = {}) { } if (require.main === module) { - const tdcFile = process.argv[2] || "tcaptcha/core_js/tdc_sample.js"; - const report = probeTdc(tdcFile); - console.log("\n--- TDC Execution Result ---"); - console.log(`Success: ${report.success}`); - console.log(`Collect Length: ${report.collectLen}`); - console.log(`Collect Preview: ${report.collectPreview}`); - console.log(`EKS Preview: ${report.eksPreview}`); - console.log(`Total Environment Accesses: ${report.accessCount}`); + const arg2 = process.argv[2]; + if (arg2 === "-") { + // Pipeline mode for Python / external callers + let tdcCode = ""; + process.stdin.setEncoding("utf-8"); + process.stdin.on("data", (chunk) => { + tdcCode += chunk; + }); + process.stdin.on("end", () => { + try { + const opts = process.argv[3] ? JSON.parse(process.argv[3]) : {}; + // Temporary write to memory / execute directly + const report = probeTdc(null, { ...opts, tdcCode }); + process.stdout.write( + JSON.stringify({ + success: report.success, + collect: report.collect, + tlg: String(report.collectLen), + eks: report.eks, + source: "probe", + }) + "\n" + ); + } catch (err) { + process.stdout.write( + JSON.stringify({ success: false, error: String(err) }) + "\n" + ); + } + }); + } else { + const tdcFile = arg2 || "tcaptcha/core_js/tdc_sample.js"; + const report = probeTdc(tdcFile); + console.log("\n--- TDC Execution Result ---"); + console.log(`Success: ${report.success}`); + console.log(`Collect Length: ${report.collectLen}`); + console.log(`Collect Preview: ${report.collectPreview}`); + console.log(`EKS Preview: ${report.eksPreview}`); + console.log(`Total Environment Accesses: ${report.accessCount}`); - console.log("\n--- Top 35 Environment Accesses Logged by Probe ---"); - report.accesses.slice(0, 35).forEach((a) => console.log(a)); + console.log("\n--- Top 35 Environment Accesses Logged by Probe ---"); + report.accesses.slice(0, 35).forEach((a) => console.log(a)); - const outReport = tdcFile.replace(/\.js$/, ".probe_report.json"); - fs.writeFileSync(outReport, JSON.stringify(report, null, 2), "utf-8"); - console.log(`\nDetailed probe report written to ${outReport}`); + const outReport = tdcFile.replace(/\.js$/, ".probe_report.json"); + fs.writeFileSync(outReport, JSON.stringify(report, null, 2), "utf-8"); + console.log(`\nDetailed probe report written to ${outReport}`); + } } module.exports = { probeTdc };