iso-bot/engine/input/mouse.py

345 lines
No EOL
12 KiB
Python

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