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}")