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:
@@ -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 自动刷新到目标题型
|
||||
# (刷新耗尽就退出、不提交 —— 那是没抽到题,不是求解失败)。
|
||||
```
|
||||
|
||||
@@ -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")))
|
||||
|
||||
@@ -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"]),
|
||||
|
||||
@@ -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 外。"""
|
||||
|
||||
@@ -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"]}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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}")
|
||||
|
||||
Reference in New Issue
Block a user