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
+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"]),