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:
@@ -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"]),
|
||||
|
||||
Reference in New Issue
Block a user