"""Human-like mouse movement and clicking with Bézier curves. Provides realistic mouse movement patterns using Bézier curves with randomized control points and natural acceleration/deceleration. """ from typing import Tuple, List, Optional, Callable from dataclasses import dataclass import time import math import random import logging import pyautogui import numpy as np logger = logging.getLogger(__name__) # Disable pyautogui failsafe for production use pyautogui.FAILSAFE = False @dataclass class MousePath: """Represents a mouse movement path with timing.""" points: List[Tuple[int, int]] delays: List[float] total_duration: float class BezierCurve: """Bézier curve generation for natural mouse movement.""" @staticmethod def cubic_bezier(t: float, p0: Tuple[float, float], p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float]) -> Tuple[float, float]: """Calculate point on cubic Bézier curve at parameter t. Args: t: Parameter from 0 to 1 p0: Start point p1: First control point p2: Second control point p3: End point Returns: (x, y) point on curve """ x = (1-t)**3 * p0[0] + 3*(1-t)**2*t * p1[0] + 3*(1-t)*t**2 * p2[0] + t**3 * p3[0] y = (1-t)**3 * p0[1] + 3*(1-t)**2*t * p1[1] + 3*(1-t)*t**2 * p2[1] + t**3 * p3[1] return (x, y) @staticmethod def generate_control_points(start: Tuple[int, int], end: Tuple[int, int], randomness: float = 0.3) -> Tuple[Tuple[float, float], Tuple[float, float]]: """Generate random control points for natural curve. Args: start: Starting position end: Ending position randomness: Amount of randomness (0.0 to 1.0) Returns: Tuple of two control points """ dx = end[0] - start[0] dy = end[1] - start[1] distance = math.sqrt(dx*dx + dy*dy) # Control point offset based on distance and randomness offset_magnitude = distance * randomness * random.uniform(0.2, 0.8) # Random angles for control points angle1 = random.uniform(-math.pi, math.pi) angle2 = random.uniform(-math.pi, math.pi) # First control point (closer to start) cp1_x = start[0] + dx * 0.25 + math.cos(angle1) * offset_magnitude cp1_y = start[1] + dy * 0.25 + math.sin(angle1) * offset_magnitude # Second control point (closer to end) cp2_x = start[0] + dx * 0.75 + math.cos(angle2) * offset_magnitude cp2_y = start[1] + dy * 0.75 + math.sin(angle2) * offset_magnitude return ((cp1_x, cp1_y), (cp2_x, cp2_y)) class MouseController: """Controller for human-like mouse interactions.""" def __init__(self): """Initialize mouse controller.""" self.current_pos = pyautogui.position() self.movement_speed = 1.0 # Multiplier for movement speed self.click_variance = 3 # Pixel variance for click positions # Movement timing parameters self.min_duration = 0.1 # Minimum movement time self.max_duration = 1.5 # Maximum movement time self.base_speed = 1000 # Base pixels per second def get_current_position(self) -> Tuple[int, int]: """Get current mouse position. Returns: (x, y) tuple of current position """ self.current_pos = pyautogui.position() return self.current_pos def calculate_movement_duration(self, start: Tuple[int, int], end: Tuple[int, int]) -> float: """Calculate realistic movement duration based on distance. Args: start: Starting position end: Ending position Returns: Movement duration in seconds """ dx = end[0] - start[0] dy = end[1] - start[1] distance = math.sqrt(dx*dx + dy*dy) # Fitts' Law inspired calculation # Time increases logarithmically with distance base_time = distance / (self.base_speed * self.movement_speed) fitts_factor = math.log2(1 + distance / 10) / 10 duration = base_time + fitts_factor # Add some randomness duration *= random.uniform(0.8, 1.2) # Clamp to reasonable bounds return max(self.min_duration, min(self.max_duration, duration)) def generate_movement_path(self, start: Tuple[int, int], end: Tuple[int, int], duration: Optional[float] = None, steps: Optional[int] = None) -> MousePath: """Generate Bézier curve path for mouse movement. Args: start: Starting position end: Ending position duration: Movement duration, or None to calculate steps: Number of steps, or None to calculate Returns: MousePath with points and timing """ if duration is None: duration = self.calculate_movement_duration(start, end) if steps is None: # Calculate steps based on distance and duration distance = math.sqrt((end[0] - start[0])**2 + (end[1] - start[1])**2) steps = max(10, int(distance / 10)) # Roughly 10 pixels per step # Generate control points cp1, cp2 = BezierCurve.generate_control_points(start, end) # Generate path points points = [] delays = [] for i in range(steps + 1): t = i / steps # Use ease-in-out curve for timing timing_t = self._ease_in_out(t) # Calculate position on Bézier curve x, y = BezierCurve.cubic_bezier(timing_t, start, cp1, cp2, end) points.append((int(x), int(y))) # Calculate delay for this step if i < steps: delay = duration / steps # Add small random variation delay *= random.uniform(0.8, 1.2) delays.append(delay) return MousePath(points, delays, duration) def move_to(self, target: Tuple[int, int], duration: Optional[float] = None) -> None: """Move mouse to target position using Bézier curve. Args: target: Target (x, y) position duration: Movement duration, or None to calculate """ start = self.get_current_position() path = self.generate_movement_path(start, target, duration) logger.debug(f"Moving mouse from {start} to {target} in {path.total_duration:.2f}s") for i, point in enumerate(path.points[1:], 1): pyautogui.moveTo(point[0], point[1], duration=0) if i <= len(path.delays): time.sleep(path.delays[i-1]) self.current_pos = target def click(self, position: Optional[Tuple[int, int]] = None, button: str = 'left', move_first: bool = True) -> None: """Click at specified position with human-like variation. Args: position: Click position, or None for current position button: Mouse button ('left', 'right', 'middle') move_first: Whether to move to position first """ if position is None: position = self.get_current_position() else: # Add small random offset for more human-like clicking offset_x = random.randint(-self.click_variance, self.click_variance) offset_y = random.randint(-self.click_variance, self.click_variance) position = (position[0] + offset_x, position[1] + offset_y) if move_first and position != self.get_current_position(): self.move_to(position) # Random pre-click delay time.sleep(random.uniform(0.01, 0.05)) logger.debug(f"Clicking {button} button at {position}") pyautogui.click(position[0], position[1], button=button) # Random post-click delay time.sleep(random.uniform(0.01, 0.08)) def double_click(self, position: Optional[Tuple[int, int]] = None, move_first: bool = True) -> None: """Double-click at specified position. Args: position: Click position, or None for current position move_first: Whether to move to position first """ if position is None: position = self.get_current_position() if move_first and position != self.get_current_position(): self.move_to(position) # Random delay before double-click time.sleep(random.uniform(0.01, 0.05)) logger.debug(f"Double-clicking at {position}") pyautogui.doubleClick(position[0], position[1]) # Random delay after double-click time.sleep(random.uniform(0.05, 0.1)) def drag(self, start: Tuple[int, int], end: Tuple[int, int], button: str = 'left', duration: Optional[float] = None) -> None: """Drag from start to end position. Args: start: Starting position end: Ending position button: Mouse button to drag with duration: Drag duration, or None to calculate """ # Move to start position self.move_to(start) # Mouse down time.sleep(random.uniform(0.01, 0.03)) pyautogui.mouseDown(start[0], start[1], button=button) # Wait briefly before starting drag time.sleep(random.uniform(0.05, 0.1)) # Generate drag path path = self.generate_movement_path(start, end, duration) logger.debug(f"Dragging from {start} to {end}") # Execute drag movement for i, point in enumerate(path.points[1:], 1): pyautogui.moveTo(point[0], point[1], duration=0) if i <= len(path.delays): time.sleep(path.delays[i-1]) # Mouse up time.sleep(random.uniform(0.01, 0.03)) pyautogui.mouseUp(end[0], end[1], button=button) self.current_pos = end def scroll(self, clicks: int, position: Optional[Tuple[int, int]] = None) -> None: """Scroll at specified position. Args: clicks: Number of scroll clicks (positive = up, negative = down) position: Scroll position, or None for current position """ if position is not None and position != self.get_current_position(): self.move_to(position) # Random delay before scrolling time.sleep(random.uniform(0.05, 0.15)) # Scroll with small delays between clicks for more human-like behavior for i in range(abs(clicks)): scroll_direction = 1 if clicks > 0 else -1 pyautogui.scroll(scroll_direction) if i < abs(clicks) - 1: # Don't delay after last scroll time.sleep(random.uniform(0.02, 0.08)) def _ease_in_out(self, t: float) -> float: """Ease-in-out function for smooth acceleration/deceleration. Args: t: Input parameter (0 to 1) Returns: Eased parameter (0 to 1) """ return t * t * (3.0 - 2.0 * t) def set_movement_speed(self, speed: float) -> None: """Set movement speed multiplier. Args: speed: Speed multiplier (1.0 = normal, 2.0 = double speed, etc.) """ self.movement_speed = max(0.1, min(5.0, speed)) logger.info(f"Mouse movement speed set to {self.movement_speed}x") def set_click_variance(self, variance: int) -> None: """Set click position variance in pixels. Args: variance: Maximum pixel offset for clicks """ self.click_variance = max(0, min(10, variance)) logger.info(f"Click variance set to {self.click_variance} pixels")