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