"""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}