refactor(scroll): move trainer/models/collector to tools/scroll/

This commit is contained in:
2026-05-12 00:34:05 +08:00
parent ba52c49edf
commit 6c96ab68c8
11 changed files with 11 additions and 13 deletions

99
tools/scroll/collector.py Normal file
View File

@@ -0,0 +1,99 @@
"""Scroll collection state and target generation.
The actual scroll state machine runs in JavaScript (wheel events are
client-side). This module handles server-side target generation and
trace persistence.
"""
from __future__ import annotations
import logging
import random
from tools.config import SCROLL_MODES
logger = logging.getLogger(__name__)
class ScrollCollector:
"""Manages scroll collection sessions."""
def __init__(
self,
mode: str,
count: int,
page_height: int = 10000,
viewport_height: int = 900,
):
if mode not in SCROLL_MODES:
raise ValueError(f"Unknown mode: {mode}. Use: {', '.join(SCROLL_MODES)}")
self.mode = mode
self.count = count
self.page_height = page_height
self.viewport_height = viewport_height
self.collected = 0
cfg = SCROLL_MODES[mode]
self.dist_min = cfg.dist_min
self.dist_max = cfg.dist_max
self.success_radius = cfg.success_radius
def next_target(self, current_scrollY: int) -> dict:
"""Generate next target scroll position.
The target must be reachable — i.e. the user must be able to scroll
such that the target band enters the viewport's success zone (centered).
Reachability constraint:
To hit a target at T, the user needs scrollTop ≈ T - viewportCenter + bandHeight/2.
scrollTop must be in [0, pageHeight - viewportHeight].
So valid target range is:
T_min = viewportCenter - successRadius (scrollTop=0 → target at top of success zone)
T_max = maxScrollTop + viewportCenter + successRadius
Returns:
{"target_scrollY": int, "direction": "up"|"down"}
"""
viewport_center = self.viewport_height // 2
max_scroll_top = self.page_height - self.viewport_height
# Valid range for targetScrollY so it's always reachable
target_min = viewport_center - self.success_radius
target_max = max_scroll_top + viewport_center + self.success_radius
# Clamp to ensure sane bounds
target_min = max(0, target_min)
target_max = min(self.page_height, target_max)
max_down = min(self.page_height - current_scrollY, target_max - current_scrollY)
max_up = min(current_scrollY, current_scrollY - target_min)
for _ in range(100):
dist = random.randint(self.dist_min, self.dist_max)
direction = random.choice(["up", "down"])
if direction == "down" and 0 < dist <= max_down:
candidate = current_scrollY + dist
if target_min <= candidate <= target_max:
return {"target_scrollY": candidate, "direction": "down"}
elif direction == "up" and 0 < dist <= max_up:
candidate = current_scrollY - dist
if target_min <= candidate <= target_max:
return {"target_scrollY": candidate, "direction": "up"}
# Fallback: generate a valid target within bounds
valid_down = min(max_down, self.dist_max)
valid_up = min(max_up, self.dist_max)
if valid_down >= self.dist_min:
dist = random.randint(self.dist_min, max(self.dist_min, valid_down))
return {"target_scrollY": current_scrollY + dist, "direction": "down"}
elif valid_up >= self.dist_min:
dist = random.randint(self.dist_min, max(self.dist_min, valid_up))
return {"target_scrollY": current_scrollY - dist, "direction": "up"}
else:
# Edge case: move to center of valid range
target = max(target_min, min(target_max, (target_min + target_max) // 2))
direction = "down" if target > current_scrollY else "up"
return {"target_scrollY": target, "direction": direction}