345 lines
No EOL
12 KiB
Python
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") |