feat(slider): 滑块 (2401/2402) 端到端打通 —— errorCode=0

做了什么:
- solvers/slider.py: 新增 SliderChallenge(把课题 JSON 解析成解题参数)+ piece_from_sprite
  (按 sprite_pos/size_2d 裁拼图块)+ track_limit 解析 / clamp_x / answer(按 data_type 组答案)
- telemetry/values.py: 新增 drag_trajectory_points(拖动轨迹:按下->横移->松手,
  首点绝对、其余增量、尾点固定 [1,1,12]);_sample_dt 抽成模块级供两条轨迹复用
- telemetry/{values,crypto}.py: generate_collect / generate_telemetry 接受 trajectory=
- protocol/client.py: 新增滑块分支(拉底图 + 精灵图 -> 裁块 -> ZNCC -> 组答案 -> 拖动轨迹),
  accept_kinds 默认扩为 文字点选 + 图标点选 + 滑块
- scripts/verify/send_test.py: 加 --kind text|slider|icon|any

关键结论(账本 #60~#63):
- 滑块答案是 DynAnswerType_POS,但坐标用**背景图原始像素**(不是页面显示像素):
  所以解题用 scale=1.0 直接提交 target_x_image;elem_id / data_type 均取自 move_cfg
- 拼图块可从课题配置直接裁,与旧 DOM 换算结果**逐像素相同**(测试已加交叉断言)
- **ddddocr 的滑块距离识别不可用**:fixture 偏 59px、实时偏 60px。判定手段是把拼图块
  alpha 轮廓叠到背景上目视 —— 本项目 ZNCC 与缺口描边严丝合缝,ddddocr 整体右偏
- 低置信度(margin=0.024 < 0.08)那次被正确拒答并换题,换题后立刻成功,安全网有效

怎么验证:
- .venv/bin/python -m tcaptcha.tests.test_slider_solver   全绿(新增 [6]/[7] 两组断言)
- .venv/bin/python -m tcaptcha.selftest                   全绿
- .venv/bin/python scripts/verify/send_test.py --kind slider   3/3 errorCode=0
- 文字点选回归不受影响:--kind text 仍为 errorCode=0
This commit is contained in:
yml2213
2026-10-12 12:46:35 +08:00
parent 344eea2194
commit 6a8b3b3ce4
7 changed files with 294 additions and 33 deletions
+16 -7
View File
@@ -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 构造方式:<br>`python_values`(本次会话构建 → 槽序与取值都对)→ **0**(3/3);<br>`python_keyed`(仓库里那份 **2026-10-11** 成功样本模板回填:槽序来自**另一份构建**,屏串还是**另一台设备**的 `2560-1440-1345-24`)→ **12**(2/2)。<br>→ **推翻已知 #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 自动刷新到目标题型
# (刷新耗尽就退出、不提交 —— 那是没抽到题,不是求解失败)。
```
+13 -2
View File
@@ -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")))
+73 -5
View File
@@ -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"]),
+82 -1
View File
@@ -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 外。"""
+3 -1
View File
@@ -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"]}
+53 -16
View File
@@ -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)
+54 -1
View File
@@ -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}")