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
+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)