diff --git a/har/1ms_dianxuan/PROGRESS.md b/har/1ms_dianxuan/PROGRESS.md index 023a83f..ce18c35 100644 --- a/har/1ms_dianxuan/PROGRESS.md +++ b/har/1ms_dianxuan/PROGRESS.md @@ -169,6 +169,10 @@ crypto.build_collect(c1,traj,c2,sd,key) -> 4 段独立加密后拼接 | 57 | **纯离线闭环已打通**(本任务的核心阻塞点) | `scripts/verify/verify_offline.py`:tdc.js → 37 语义名 → 38 槽取值 → 4 段明文 → 4 段加密拼接 → 解密/切段/解析 → **逐槽全等 + 分段几何一致**;产物末尾 `==` 只来自未 pad 的 sd 段 | 确证 | | 58 | **纯离线生成的 collect 在 1ms aid=195672344 上端到端拿到 `errorCode=0` + 真 ticket**(3/3) | 2026-10-12 实测 `scripts/verify/send_test.py --mode python_values`(aid 195672344 / 签到页 / 2404 文字点选 / 零 Node)。三次分别拿到 `tr03tgI-J732…`、`tr03RLpt4OTMy…`、`tr03n_V7CXAFI…`。每颗 collect 都由**本次会话同一份 tdc.js** 纯离线生成:段几何 461/625/147/16、460/640/148/16、81/708/529/16(每份构建清单序不同 → 分界点不同),轨迹 61/63/71 点、尾点一律 `[1,1,12]` | 确证 | | 59 | **A/B 隔离出真正自变量:collect 必须「用本次会话的构建」生成** | 同 aid / 同一天 / 同客户端 / 同求解器、答案都正确,只换 collect 构造方式:
`python_values`(本次会话构建 → 槽序与取值都对)→ **0**(3/3);
`python_keyed`(仓库里那份 **2026-10-11** 成功样本模板回填:槽序来自**另一份构建**,屏串还是**另一台设备**的 `2560-1440-1345-24`)→ **12**(2/2)。
→ **推翻已知 #40**("谁在发 HTTP");同时把 #31/#32 的"槽序/取值必须与本次会话构建同源"从假设升级为确证 | 确证(含推翻 #40) | +| 60 | **滑块答案格式 = `DynAnswerType_POS`,坐标用「背景图原始像素」** | 真机课题 JSON:拼图块元素(id=1)的 `move_cfg = {track_limit:"x>=50&&x<=552", move_factor:[1,0], data_type:["DynAnswerType_POS"]}`;`init_pos=[50,128]` 与提交答案的 data 是**同一坐标系**(`bg_elem_cfg.size_2d = 672x480`)。所以解题时用 `scale=1.0`,直接把 `target_x_image` 当答案,不要换成页面显示像素 | 确证 | +| 61 | **滑块的拼图块可直接从课题配置裁剪,不必解析 DOM** | 拼图块元素自带 `sprite_pos=[140,490]` + `size_2d=[120,120]`;与早先从 CSS `background-position/size` 反算出的 `sprite_region() == (140,490,120,120)` **逐像素相同**(测试里已加交叉断言) | 确证 | +| 62 | **ddddocr 的滑块距离识别在腾讯滑块上不可用** | fixture(真值 target_x=343 / dx=293):`slide_match` 给 402/352(**偏 59px**),`simple_target=True` 同样 402,`slide_comparison` 直接报尺寸不匹配;实时样本(真值 236)给 296(**偏 60px**)。判定手段:把拼图块 alpha 轮廓按候选位置叠到背景图上目视 —— 本项目 ZNCC 的 236 与缺口描边**严丝合缝**,ddddocr 的 296 整体右偏 | 确证(负结果,别再试) | +| 63 | **滑块端到端已打通**(纯离线 collect + ZNCC 定位) | `send_test.py --kind slider` **3/3 拿到 errorCode=0 + 真 ticket**;轨迹为拖动型(~34 点、尾点 `[1,1,12]`)。其中一次 `margin=0.024 < 阈值 0.08` 被**正确拒答并换题**,换题后立刻成功 —— 置信度安全网有效 | 确证 | --- @@ -342,7 +346,10 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 已知 #30 的真机明文基本对齐;**关键是每份构建的槽位序**(已知 #31)—— 见上面 1(b)。分析入口:`scripts/analyze_collect_layout.py`。 4. **实现 2408 图标点选**:按 1ms 图标类别重训 `yolo_detector` + `icon_classifier`。 -5. **实现滑块 (2401/2402)**:模板匹配找缺口 + 生成滑动轨迹(1ms 大幅下发滑块,见抓包)。 +5. ✅ **已完成(2026-10-12)—— 滑块 (2401/2402)**:不用模板匹配,改用**拼图块内容与缺口做 ZNCC** + (缺口 = 同一块内容被压暗,真缺口相关性最高、干扰缺口次之)。答案格式从课题 JSON 的 + `move_cfg.data_type` 取(`DynAnswerType_POS`,坐标=背景图原始像素),轨迹走`drag_trajectory_points`。 + 实测 3/3 拿到 `errorCode=0`。**ddddocr 的滑块距离识别已验证不可用**(偏 59~60px,见已知 #62)。 --- @@ -389,10 +396,12 @@ TencentCaptchaClient(telemetry_mode="python_keyed") # 默认 .venv/bin/python scripts/verify/verify_offline.py /tmp/1ms_bridge/session_build.js # 期望: 逐槽全等=是、分段几何=一致 -# ⑩ 真实发送测试(**只打 1ms 签到页的文字点选**;会真的 POST verify) -.venv/bin/python scripts/verify/send_test.py --mode python_values # 期望 errorCode=0 -.venv/bin/python scripts/verify/send_test.py --mode python_keyed # 对照:期望 12 -# 脚本会 hook generate_telemetry,打印这一颗 collect 的段几何 + 轨迹点数, -# 用来证明发出去的确实是"本次会话构建"生成的包。注意 prehandle 会混发滑块, -# 脚本按 accept_kinds 自动刷新到 2404 文字点选为止(刷新耗尽则退出、不提交)。 +# ⑩ 真实发送测试(1ms 签到页;会真的 POST verify) +.venv/bin/python scripts/verify/send_test.py --kind text --mode python_values # 文字点选,期望 0 +.venv/bin/python scripts/verify/send_test.py --kind slider --mode python_values # 滑块,期望 0 +.venv/bin/python scripts/verify/send_test.py --kind icon --mode python_values # 图标点选 +# --kind any 表示三种都接。脚本会 hook generate_telemetry 打印这一颗 collect 的 +# 段几何 + 轨迹点数,用来证明发出去的确实是"本次会话构建"生成的包。 +# 注意 prehandle 三种题型混发,脚本会按 accept_kinds 自动刷新到目标题型 +# (刷新耗尽就退出、不提交 —— 那是没抽到题,不是求解失败)。 ``` diff --git a/scripts/verify/send_test.py b/scripts/verify/send_test.py index 5f8f952..6fc188a 100644 --- a/scripts/verify/send_test.py +++ b/scripts/verify/send_test.py @@ -25,17 +25,27 @@ sys.path.insert(0, str(ROOT)) sys.path.insert(0, str(ROOT / "tools")) import tcaptcha.protocol.client as client_mod # noqa: E402 -from tcaptcha.protocol.client import KIND_TEXT_CLICK, TencentCaptchaClient # noqa: E402 +from tcaptcha.protocol.client import (KIND_ICON_CLICK, KIND_SLIDER, # noqa: E402 + KIND_TEXT_CLICK, TencentCaptchaClient) AID = "195672344" ENTRY = "https://1ms.run/console/personal/benefits/check-in" REFERER = "https://1ms.run/" +KIND_MAP = { + "text": (KIND_TEXT_CLICK,), + "slider": (KIND_SLIDER,), + "icon": (KIND_ICON_CLICK,), + "any": (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER), +} + def main() -> int: ap = argparse.ArgumentParser(description="1ms 签到页文字点选 · 真实发送测试") ap.add_argument("--mode", default="python_values", choices=["python_values", "python_keyed", "python"]) + ap.add_argument("--kind", default="text", choices=list(KIND_MAP), + help="只解哪种题(默认 text=文字点选)") ap.add_argument("--max-refreshes", type=int, default=12) ap.add_argument("--max-retries", type=int, default=2) args = ap.parse_args() @@ -73,7 +83,7 @@ def main() -> int: c = TencentCaptchaClient( aid=AID, entry_url=ENTRY, referer=REFERER, wb="1", - accept_kinds=(KIND_TEXT_CLICK,), + accept_kinds=KIND_MAP[args.kind], telemetry_mode=args.mode, proxy=None, ) @@ -86,6 +96,7 @@ def main() -> int: print() print("=" * 70) print("模式 : %s" % args.mode) + print("题型 : %s" % args.kind) print("耗时 : %.1fs" % (time.time() - t0)) print("success : %s" % res.get("success")) print("kind : %s 指令=%r" % (res.get("kind"), res.get("instruction"))) diff --git a/tcaptcha/protocol/client.py b/tcaptcha/protocol/client.py index d182a4e..c7b1061 100644 --- a/tcaptcha/protocol/client.py +++ b/tcaptcha/protocol/client.py @@ -40,6 +40,8 @@ if str(PROJECT_ROOT) not in sys.path: sys.path.insert(0, str(PROJECT_ROOT)) from tcaptcha.telemetry.crypto import extract_eks, generate_telemetry +from tcaptcha.telemetry.values import drag_trajectory_points +from tcaptcha.solvers.slider import SliderChallenge, SliderSolver, piece_from_sprite from tcaptcha.solvers.text_click import TextClickSolver, parse_instruction logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s") @@ -144,7 +146,7 @@ class TencentCaptchaClient: referer: Optional[str] = None, wb: str = "2", js: str = "/tcaptcha-frame.ba387dd1.js", - accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK), + accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK, KIND_SLIDER), min_posterior: float = 0.95, pow_timeout: int = 4000000, inter_attempt_delay: float = 0.25, @@ -200,6 +202,7 @@ class TencentCaptchaClient: # 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重) self._icon_solver = solver self._text_solver = text_solver + self._slider_solver: Optional[SliderSolver] = None self.subsid = 1 # 可选: 透传 captcha 域的腾讯云登录 cookie (账本 §7.5 的 H3 单变量实验入口)。 @@ -263,6 +266,12 @@ class TencentCaptchaClient: self._icon_solver = CaptchaSolver() return self._icon_solver + @property + def slider_solver(self) -> SliderSolver: + if self._slider_solver is None: + self._slider_solver = SliderSolver() + return self._slider_solver + def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]: """求解 Proof of Work 哈希碰撞""" t0 = time.time() @@ -444,7 +453,9 @@ class TencentCaptchaClient: continue # ---- 视觉求解 - points: List[Tuple[int, int]] + points: List[Tuple[int, int]] = [] + slider_ch: Optional[SliderChallenge] = None + drag_traj: Optional[List[List[int]]] = None if challenge.kind == KIND_TEXT_CLICK: bg = bg_bytes if bg_bytes else self.fetch_image(challenge.img_url, img_index=1) @@ -491,7 +502,7 @@ class TencentCaptchaClient: time.sleep(wait_refresh) continue points = result.points - else: # KIND_ICON_CLICK + elif challenge.kind == KIND_ICON_CLICK: 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 prompt: @@ -511,6 +522,58 @@ class TencentCaptchaClient: refreshes += 1 time.sleep(wait_refresh) continue + else: # KIND_SLIDER (2401/2402) + import cv2 + import numpy as np + + try: + slider_ch = SliderChallenge.from_challenge(challenge.raw or {}) + except ValueError as exc: + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("滑块课题配置解析失败 (%s), 等待 %.2fs 后刷新", exc, wait_refresh) + refreshes += 1 + challenge = None + time.sleep(wait_refresh) + continue + + bg = bg_bytes if bg_bytes else self.fetch_image(slider_ch.bg_url, img_index=1) + sprite = cv2.imdecode( + np.frombuffer(self.fetch_image(slider_ch.sprite_url, img_index=0), np.uint8), + cv2.IMREAD_UNCHANGED) + if bg is None or sprite is None: + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("滑块图片拉取失败, 等待 %.2fs 后刷新题目", wait_refresh) + refreshes += 1 + challenge = None + time.sleep(wait_refresh) + continue + + # 落盘一张底图便于人工核对缺口定位 + (debug_dir / f"{ts}_slider_bg.jpg").write_bytes(bg) + crop = piece_from_sprite(sprite, slider_ch.sprite_rect) + (debug_dir / f"{ts}_slider_piece.png").write_bytes( + cv2.imencode(".png", crop)[1].tobytes()) + sres = self.slider_solver.solve(bg, crop, + piece_x_image=slider_ch.init_pos[0], + piece_y_image=slider_ch.init_pos[1], + scale=1.0) + logger.info("滑块求解: %s", sres.describe()) + if not sres.ok: + wait_refresh = random.uniform(2.5, 3.8) + logger.warning("滑块缺口定位置信度不足 (score=%.3f margin=%.3f), 等待 %.2fs 后刷新题目", + sres.score, sres.margin, wait_refresh) + refreshes += 1 + challenge = None + time.sleep(wait_refresh) + continue + + target_x = slider_ch.clamp_x(sres.target_x_image) + points = [(int(round(target_x)), int(sres.y_image))] + drag_traj = drag_trajectory_points(slider_ch.init_pos, points[0], + img_w=slider_ch.bg_size[0] or 672) + logger.info("滑块答案: elem_id=%d target=(%d,%d) dx=%d 轨迹点=%d", + slider_ch.elem_id, points[0][0], points[0][1], + points[0][0] - int(slider_ch.init_pos[0]), len(drag_traj)) # ---- 模拟真实人类阅读审题、定位字形与逐个点击耗时 (5.5 ~ 7.5 秒,对齐抓包实测 7.41s) elapsed = time.time() - round_start_time @@ -522,12 +585,17 @@ class TencentCaptchaClient: # ---- 组包并提交 attempts += 1 - ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS", - "data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)] + if slider_ch is not None: + # 滑块: 单个元素, elem_id / data_type 都取自课题配置的 move_cfg + ans_list = slider_ch.answer(points[0][0], points[0][1]) + else: + ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS", + "data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)] 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, + trajectory=drag_traj, ) logger.info("遥测密文来源=%s collect_len=%s eks_len=%s", telemetry.get("source"), len(telemetry["collect"]), diff --git a/tcaptcha/solvers/slider.py b/tcaptcha/solvers/slider.py index a35aa6d..6508fb1 100644 --- a/tcaptcha/solvers/slider.py +++ b/tcaptcha/solvers/slider.py @@ -30,8 +30,9 @@ """ from __future__ import annotations +import re from dataclasses import dataclass, field -from typing import List, Optional, Sequence, Tuple +from typing import Dict, List, Optional, Sequence, Tuple import cv2 import numpy as np @@ -142,6 +143,86 @@ def _peaks(curve: np.ndarray, neighbor: int, limit: int = 5) -> List[Tuple[int, return picked +def piece_from_sprite(sprite: np.ndarray, rect: Sequence[int]) -> np.ndarray: + """按课题配置里的 `sprite_pos` / `size_2d` 从精灵图裁出拼图块(保留 alpha)。""" + x, y, w, h = (int(v) for v in rect) + if w <= 0 or h <= 0: + raise ValueError(f"精灵图矩形非法: {rect!r}") + return sprite[y:y + h, x:x + w] + + +def _parse_track_limit(expr: str) -> Optional[Tuple[float, float]]: + """把 `x>=50&&x<=552` 解析成 (50.0, 552.0);解析不出来返回 None。""" + nums = re.findall(r"x\s*[<>=]+\s*(-?\d+(?:\.\d+)?)", expr or "") + if len(nums) >= 2: + return float(nums[0]), float(nums[1]) + return None + + +@dataclass +class SliderChallenge: + """滑块参数 —— 全部来自课题 JSON,不依赖 DOM。 + + 真机结构(aid=195672344,subcapclass 2401/2402 实测): + + bg_elem_cfg : {img_url, size_2d:[672,480], sprite_pos:[0,0]} + sprite_url : 精灵图(底图与拼图块、滑轨都在这一张里) + fg_elem_list: [滑轨(id=3, size 672x32), 拼图块(id=1, 带 move_cfg)] + 拼图块.move_cfg = {track_limit:"x>=50&&x<=552", move_factor:[1,0], + data_type:["DynAnswerType_POS"]} + + 坐标口径(关键): + `init_pos` 与**提交答案里的坐标**都是「背景图原始像素」(即 `size_2d` 那套), + 不是页面显示像素 —— 所以解题时用 `scale=1.0`,直接拿 target_x_image 组答案。 + """ + + bg_url: str + sprite_url: str + sprite_rect: Tuple[int, int, int, int] + init_pos: Tuple[float, float] + elem_id: int + data_type: str + bg_size: Tuple[int, int] + track_limit: Optional[Tuple[float, float]] = None + + @classmethod + def from_challenge(cls, raw: Dict) -> "SliderChallenge": + bg_cfg = raw.get("bg_elem_cfg") or {} + fgs = raw.get("fg_elem_list") or [] + move_el = next((e for e in fgs if e.get("move_cfg")), None) + if move_el is None or not raw.get("sprite_url") or not bg_cfg.get("img_url"): + raise ValueError( + "课题配置里没有滑块元素(缺 move_cfg / sprite_url / bg_elem_cfg.img_url)") + sx, sy = (move_el.get("sprite_pos") or (0, 0))[:2] + sw, sh = (move_el.get("size_2d") or (0, 0))[:2] + init = (move_el.get("init_pos") or (0, 0)) + mc = move_el.get("move_cfg") or {} + data_types = mc.get("data_type") or [] + size2d = (bg_cfg.get("size_2d") or (0, 0)) + return cls( + bg_url=str(bg_cfg["img_url"]), + sprite_url=str(raw["sprite_url"]), + sprite_rect=(int(sx), int(sy), int(sw), int(sh)), + init_pos=(float(init[0]), float(init[1])), + elem_id=int(move_el.get("id", 1)), + data_type=str(data_types[0]) if data_types else "DynAnswerType_POS", + bg_size=(int(size2d[0]), int(size2d[1])), + track_limit=_parse_track_limit(str(mc.get("track_limit") or "")), + ) + + def answer(self, target_x: float, y: float) -> List[Dict]: + """按 `move_cfg.data_type` 组提交用的 ans(坐标 = 背景图原始像素)。""" + return [{"elem_id": self.elem_id, "type": self.data_type, + "data": "%d,%d" % (int(round(target_x)), int(round(y)))}] + + def clamp_x(self, x: float) -> float: + """夹到 track_limit 允许的范围内(配置没给就不动)。""" + if not self.track_limit: + return float(x) + lo, hi = self.track_limit + return float(min(max(x, lo), hi)) + + @dataclass class SliderResult: """滑块求解结果。坐标均为**背景图原始像素**, 除 dx_display 外。""" diff --git a/tcaptcha/telemetry/crypto.py b/tcaptcha/telemetry/crypto.py index 34a8588..64c1cc9 100644 --- a/tcaptcha/telemetry/crypto.py +++ b/tcaptcha/telemetry/crypto.py @@ -298,6 +298,7 @@ def generate_telemetry( referrer: str = "https://1ms.run/console/personal/benefits/check-in", mode: str = "python_values", node_bin: str = "node", + trajectory: Optional[List[List[int]]] = None, ) -> Dict[str, object]: """统一遥测生成入口 —— **纯离线**(唯一模式 `python_values`)。 @@ -318,7 +319,8 @@ def generate_telemetry( from .values import generate_collect - out = generate_collect(tdc_js_code, click_points, referrer=referrer) + out = generate_collect(tdc_js_code, click_points, referrer=referrer, + trajectory=trajectory) return {"collect": out["collect"], "tlg": out["tlg"], "eks": out["eks"], "source": out["source"], "key": out["key"]} diff --git a/tcaptcha/telemetry/values.py b/tcaptcha/telemetry/values.py index 5f19270..3510bf1 100644 --- a/tcaptcha/telemetry/values.py +++ b/tcaptcha/telemetry/values.py @@ -97,6 +97,26 @@ def default_trajectory() -> List[List[int]]: return [[1, 1, 12]] +def _sample_dt() -> int: + """采样一个事件间隔(ms)。分布按真机成功样本拟合:浏览器合并事件会出现 0。""" + r = random.random() + if r < 0.50: + return random.randint(0, 16) + if r < 0.75: + return random.randint(17, 24) + if r < 0.90: + return random.randint(25, 60) + if r < 0.98: + return random.randint(61, 160) + return random.randint(161, 400) + + +def _to_display(x: float, y: float, img_w: int) -> Tuple[float, float]: + """背景图原始像素 -> iframe 显示坐标(与真机点击轨迹同一套口径)。""" + scale = 320.0 / img_w + return 20 + x * scale, 60 + y * scale + + def trajectory_points(click_points: Sequence[Sequence[int]], img_w: int = 672) -> List[List[int]]: """按点击点生成鼠标轨迹:首点绝对坐标 + 增量点 + 固定收尾 `[1,1,12]`。 @@ -114,31 +134,43 @@ def trajectory_points(click_points: Sequence[Sequence[int]], cur = [random.randint(140, 220), random.randint(300, 340)] events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]] - def dt() -> int: - r = random.random() - if r < 0.50: - return random.randint(0, 16) - if r < 0.75: - return random.randint(17, 24) - if r < 0.90: - return random.randint(25, 60) - if r < 0.98: - return random.randint(61, 160) - return random.randint(161, 400) - for tx, ty in targets: seg = random.randint(14, 20) for i in range(1, seg + 1): nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1) ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1) - events.append([int(nx) - cur[0], int(ny) - cur[1], dt()]) + events.append([int(nx) - cur[0], int(ny) - cur[1], _sample_dt()]) cur = [int(nx), int(ny)] for _ in range(random.randint(2, 5)): # 命中后短暂驻留 - events.append([random.randint(-1, 1), random.randint(-1, 1), dt()]) + events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()]) events.append([1, 1, 12]) # 固定收尾(真机如此) return events +def drag_trajectory_points(start: Sequence[float], end: Sequence[float], + img_w: int = 672, steps: int = 30) -> List[List[int]]: + """滑块**拖动**轨迹:按下 -> 横向移动 -> 松手。 + + 与 `trajectory_points` 同一套口径(输入是背景图原始像素,输出是 iframe 显示坐标), + 同样以固定 `[1,1,12]` 收尾。速度曲线用 ease-out:人手拖动是先快后慢、末尾微调。 + """ + sx, sy = _to_display(float(start[0]), float(start[1]), img_w) + ex, ey = _to_display(float(end[0]), float(end[1]), img_w) + cur = [int(round(sx)), int(round(sy))] + events: List[List[int]] = [[cur[0], cur[1], random.randint(300, 600)]] # 按下前停顿 + for i in range(1, steps + 1): + t = i / steps + e = 1.0 - (1.0 - t) ** 3 + nx = int(round(sx + (ex - sx) * e)) + random.randint(-1, 1) + ny = int(round(sy + (ey - sy) * e)) + random.randint(-1, 1) + events.append([nx - cur[0], ny - cur[1], _sample_dt()]) + cur = [nx, ny] + for _ in range(random.randint(2, 4)): # 落点附近的微调 + events.append([random.randint(-1, 1), random.randint(-1, 1), _sample_dt()]) + events.append([1, 1, 12]) + return events + + def rows_from_source(js_text: str) -> List[Dict]: """分析 tdc.js **源码**(tdc_modules 的入口收的是路径,这里落临时文件再分析)。""" import os @@ -175,11 +207,14 @@ def generate_collect(tdc_js_code: str, sd: Optional[Dict] = None, session: Optional[Session] = None, img_w: int = 672, - referrer: Optional[str] = None) -> Dict[str, Any]: + referrer: Optional[str] = None, + trajectory: Optional[Sequence[Sequence[int]]] = None) -> Dict[str, Any]: """**纯离线**生成一颗 collect:只吃 tdc.js 源码,不需要任何真机明文。 流程: tdc.js -> 37 个语义名(清单序)-> 38 槽取值 -> 4 段明文 -> 4 段加密拼接。 密钥默认用纯 Python 静态提取(`tcaptcha.telemetry.key`),零 Node。 + + trajectory 直接给轨迹点(滑块用 `drag_trajectory_points`);不给则按 click_points 推。 """ from tcaptcha.telemetry.crypto import build_collect, extract_eks # 局部导入避免环形依赖 from tcaptcha.telemetry.key import extract_xtea_key_pure @@ -188,7 +223,9 @@ def generate_collect(tdc_js_code: str, if key is None: key = extract_xtea_key_pure(tdc_js_code)["key"] sess = session or Session(referrer=referrer or DEFAULT_REFERRER) - if click_points: + if trajectory is not None: + sess.set_trajectory(trajectory) + elif click_points: sess.set_trajectory(trajectory_points(click_points, img_w=img_w)) cd = generate_cd(rows, sess) diff --git a/tcaptcha/tests/test_slider_solver.py b/tcaptcha/tests/test_slider_solver.py index 3ab6560..59776d5 100644 --- a/tcaptcha/tests/test_slider_solver.py +++ b/tcaptcha/tests/test_slider_solver.py @@ -28,15 +28,35 @@ import sys from pathlib import Path import cv2 +import numpy as np ROOT = Path(__file__).resolve().parents[2] if str(ROOT) not in sys.path: sys.path.insert(0, str(ROOT)) -from tcaptcha.solvers.slider import SliderSolver, piece_mask, sprite_region # noqa: E402 +from tcaptcha.solvers.slider import ( # noqa: E402 + SliderChallenge, SliderSolver, piece_mask, piece_from_sprite, sprite_region, +) +from tcaptcha.telemetry.values import drag_trajectory_points # noqa: E402 FIX = ROOT / "tcaptcha" / "tests" / "fixtures" +# 真机课题配置快照(aid=195672344 下滑块 2401/2402 的 dyn_show_info, +# 只把两个很长的 image/sess 参数截短,字段结构一字未改) +REAL_SLIDER_CHALLENGE = { + "lang": "zh-cn", + "instruction": "拖动下方滑块完成拼图", + "bg_elem_cfg": {"sprite_pos": [0, 0], "size_2d": [672, 480], + "img_url": "/cap_union_new_getcapbysig?img_index=1&image=XX&sess=YY"}, + "sprite_url": "/cap_union_new_getcapbysig?img_index=0&image=XX&sess=YY", + "fg_elem_list": [ + {"id": 3, "sprite_pos": [0, 422], "size_2d": [672, 32], "init_pos": [0, 422]}, + {"id": 1, "sprite_pos": [140, 490], "size_2d": [120, 120], "init_pos": [50, 128], + "move_cfg": {"track_limit": "x>=50&&x<=552", "move_factor": [1, 0], + "data_type": ["DynAnswerType_POS"]}}, + ], +} + # 真机 DOM 口径 BG_POS = (-70.8333, -247.917) BG_SIZE = (345.06, 313.69) @@ -59,6 +79,34 @@ def check(name: str, cond: bool, detail: str = "") -> None: _fails.append(name) +def test_challenge_config() -> None: + """课题配置 -> 解题参数 -> 提交答案(滑块端到端里最容易出错的一环)。""" + print("\n[6] 课题配置解析 (SliderChallenge)") + sc = SliderChallenge.from_challenge(REAL_SLIDER_CHALLENGE) + check("sprite_rect == (140,490,120,120)", sc.sprite_rect == (140, 490, 120, 120), + str(sc.sprite_rect)) + check("init_pos == (50,128)", sc.init_pos == (50.0, 128.0), str(sc.init_pos)) + check("elem_id == 1(拼图块,不是滑轨的 3)", sc.elem_id == 1, str(sc.elem_id)) + check("data_type == DynAnswerType_POS", sc.data_type == "DynAnswerType_POS", sc.data_type) + check("track_limit == (50,552)", sc.track_limit == (50.0, 552.0), str(sc.track_limit)) + check("bg_size == (672,480)", sc.bg_size == (672, 480), str(sc.bg_size)) + check("clamp_x 把越界值夹回轨道", sc.clamp_x(600) == 552.0 and sc.clamp_x(10) == 50.0, + f"{sc.clamp_x(600)} / {sc.clamp_x(10)}") + + ans = sc.answer(343.0, 128.4) + check("answer 结构 == [{elem_id,type,data}]", + ans == [{"elem_id": 1, "type": "DynAnswerType_POS", "data": "343,128"}], str(ans)) + + print("\n[7] 拖动轨迹 (drag_trajectory_points)") + traj = drag_trajectory_points(sc.init_pos, (343.0, 128.0), img_w=sc.bg_size[0]) + check("首点是绝对坐标", len(traj[0]) == 3 and traj[0][0] > 20, str(traj[0])) + check("尾点固定 [1,1,12]", traj[-1] == [1, 1, 12], str(traj[-1])) + net = sum(p[0] for p in traj[1:-1]) + expect = (343.0 - 50.0) * (320.0 / 672.0) + check("净横向位移 ≈ dx*scale", abs(net - expect) <= 6, f"{net} vs {expect:.1f}") + check("点数在合理范围", 20 <= len(traj) <= 50, str(len(traj))) + + def main() -> int: bg = cv2.imread(str(FIX / "1ms_slider_1_bg.jpg")) sprite = cv2.imread(str(FIX / "1ms_slider_1_sprite.png"), cv2.IMREAD_UNCHANGED) @@ -72,6 +120,9 @@ def main() -> int: check("sprite_region == (140, 490, 120, 120)", (x, y, w, h) == (140, 490, 120, 120), f"got={(x, y, w, h)}") crop = sprite[y:y + h, x:x + w] + # 交叉核对:课题配置里直接给的 sprite 矩形,应当与 DOM 换算结果完全一致 + crop_cfg = piece_from_sprite(sprite, (140, 490, 120, 120)) + check("配置矩形裁出的块 == DOM 换算裁出的块", np.array_equal(crop, crop_cfg)) mask = piece_mask(crop) check("拼图块掩膜非空且不过分满", 500 < int(mask.sum()) < w * h * 0.9, f"mask={int(mask.sum())}/{w * h}") @@ -103,6 +154,8 @@ def main() -> int: check("拼图块自身位置的分数很低", all(s < 0.3 for s in near_piece), f"{near_piece}") + test_challenge_config() + print(f"\n{'=' * 62}") if _fails: print(f"❌ slider_solver 测试失败: {_fails}")