368 lines
No EOL
11 KiB
Python
368 lines
No EOL
11 KiB
Python
"""Human-like keyboard input simulation with realistic timing.
|
|
|
|
Provides keyboard input with natural typing patterns, including
|
|
varied keystroke timing and realistic human typing characteristics.
|
|
"""
|
|
|
|
from typing import List, Dict, Optional, Union
|
|
from dataclasses import dataclass
|
|
import time
|
|
import random
|
|
import logging
|
|
|
|
import pyautogui
|
|
from pynput import keyboard
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
@dataclass
|
|
class KeySequence:
|
|
"""Represents a sequence of keys with timing information."""
|
|
keys: List[str]
|
|
delays: List[float]
|
|
total_duration: float
|
|
|
|
|
|
class TypingProfile:
|
|
"""Defines typing characteristics for human-like input."""
|
|
|
|
def __init__(self, wpm: int = 60, accuracy: float = 0.95):
|
|
"""Initialize typing profile.
|
|
|
|
Args:
|
|
wpm: Words per minute typing speed
|
|
accuracy: Typing accuracy (0.0 to 1.0)
|
|
"""
|
|
self.wpm = wpm
|
|
self.accuracy = accuracy
|
|
|
|
# Calculate base timing from WPM (assuming 5 characters per word)
|
|
chars_per_minute = wpm * 5
|
|
self.base_char_delay = 60.0 / chars_per_minute
|
|
|
|
# Timing variations
|
|
self.min_delay = 0.02 # Minimum delay between keys
|
|
self.max_delay = 0.5 # Maximum delay between keys
|
|
self.word_pause = 0.1 # Additional pause after spaces
|
|
|
|
# Common typing patterns
|
|
self.difficult_sequences = {
|
|
'th', 'ch', 'sh', 'qu', 'tion', 'ing', 'er', 'ed'
|
|
}
|
|
|
|
# Keys that typically take longer to find
|
|
self.slow_keys = {
|
|
'q', 'z', 'x', 'j', 'k', 'shift', 'ctrl', 'alt'
|
|
}
|
|
|
|
|
|
class KeyboardController:
|
|
"""Controller for human-like keyboard input."""
|
|
|
|
def __init__(self, typing_profile: Optional[TypingProfile] = None):
|
|
"""Initialize keyboard controller.
|
|
|
|
Args:
|
|
typing_profile: Typing characteristics, or None for default
|
|
"""
|
|
self.profile = typing_profile or TypingProfile()
|
|
self.shift_held = False
|
|
self.ctrl_held = False
|
|
self.alt_held = False
|
|
|
|
# Key mapping for special keys
|
|
self.key_mapping = {
|
|
'enter': '\n',
|
|
'return': '\n',
|
|
'tab': '\t',
|
|
'space': ' ',
|
|
'backspace': 'backspace',
|
|
'delete': 'delete',
|
|
'escape': 'esc',
|
|
'esc': 'esc',
|
|
}
|
|
|
|
def calculate_key_delay(self, key: str, previous_key: Optional[str] = None) -> float:
|
|
"""Calculate realistic delay for typing a key.
|
|
|
|
Args:
|
|
key: Key to type
|
|
previous_key: Previously typed key for sequence analysis
|
|
|
|
Returns:
|
|
Delay in seconds before typing this key
|
|
"""
|
|
# Base delay from typing speed
|
|
delay = self.profile.base_char_delay
|
|
|
|
# Adjust for key difficulty
|
|
if key.lower() in self.profile.slow_keys:
|
|
delay *= random.uniform(1.2, 1.8)
|
|
|
|
# Adjust for difficult sequences
|
|
if previous_key:
|
|
sequence = previous_key.lower() + key.lower()
|
|
if any(seq in sequence for seq in self.profile.difficult_sequences):
|
|
delay *= random.uniform(1.1, 1.5)
|
|
|
|
# Add natural variation
|
|
delay *= random.uniform(0.7, 1.4)
|
|
|
|
# Extra pause after spaces (word boundaries)
|
|
if previous_key and previous_key == ' ':
|
|
delay += self.profile.word_pause * random.uniform(0.5, 1.5)
|
|
|
|
# Clamp to reasonable bounds
|
|
return max(self.profile.min_delay, min(self.profile.max_delay, delay))
|
|
|
|
def type_text(self, text: str, include_errors: bool = False) -> None:
|
|
"""Type text with human-like timing and optional errors.
|
|
|
|
Args:
|
|
text: Text to type
|
|
include_errors: Whether to include typing errors and corrections
|
|
"""
|
|
if not text:
|
|
return
|
|
|
|
logger.debug(f"Typing text: '{text[:50]}{'...' if len(text) > 50 else ''}'")
|
|
|
|
previous_key = None
|
|
|
|
for i, char in enumerate(text):
|
|
# Calculate delay before this character
|
|
delay = self.calculate_key_delay(char, previous_key)
|
|
|
|
# Sleep before typing
|
|
time.sleep(delay)
|
|
|
|
# Occasionally make typing errors if enabled
|
|
if include_errors and self.should_make_error():
|
|
self.make_typing_error(char)
|
|
else:
|
|
self.type_key(char)
|
|
|
|
previous_key = char
|
|
|
|
def should_make_error(self) -> bool:
|
|
"""Determine if a typing error should be made.
|
|
|
|
Returns:
|
|
True if an error should be made
|
|
"""
|
|
return random.random() > self.profile.accuracy
|
|
|
|
def make_typing_error(self, intended_key: str) -> None:
|
|
"""Make a typing error and correct it.
|
|
|
|
Args:
|
|
intended_key: The key that was supposed to be typed
|
|
"""
|
|
# Type wrong key (usually adjacent on keyboard)
|
|
wrong_key = self.get_adjacent_key(intended_key)
|
|
self.type_key(wrong_key)
|
|
|
|
# Pause as human realizes mistake
|
|
time.sleep(random.uniform(0.1, 0.4))
|
|
|
|
# Backspace to correct
|
|
self.type_key('backspace')
|
|
time.sleep(random.uniform(0.05, 0.15))
|
|
|
|
# Type correct key
|
|
self.type_key(intended_key)
|
|
|
|
def get_adjacent_key(self, key: str) -> str:
|
|
"""Get an adjacent key for typing errors.
|
|
|
|
Args:
|
|
key: Original key
|
|
|
|
Returns:
|
|
Adjacent key that could be mistyped
|
|
"""
|
|
# Simplified adjacent key mapping
|
|
adjacent_map = {
|
|
'a': 'sq', 'b': 'vgn', 'c': 'xvd', 'd': 'sfe', 'e': 'wrd',
|
|
'f': 'dgr', 'g': 'fht', 'h': 'gyu', 'i': 'uko', 'j': 'hnu',
|
|
'k': 'jmo', 'l': 'kpo', 'm': 'njk', 'n': 'bhm', 'o': 'ilp',
|
|
'p': 'olo', 'q': 'wa', 'r': 'etf', 's': 'adw', 't': 'rgy',
|
|
'u': 'yhi', 'v': 'cfg', 'w': 'qse', 'x': 'zdc', 'y': 'tgu',
|
|
'z': 'xas'
|
|
}
|
|
|
|
adjacent_keys = adjacent_map.get(key.lower(), 'abcd')
|
|
return random.choice(adjacent_keys)
|
|
|
|
def type_key(self, key: str) -> None:
|
|
"""Type a single key.
|
|
|
|
Args:
|
|
key: Key to type
|
|
"""
|
|
# Handle special keys
|
|
if key.lower() in self.key_mapping:
|
|
mapped_key = self.key_mapping[key.lower()]
|
|
if mapped_key in ['backspace', 'delete', 'esc']:
|
|
pyautogui.press(mapped_key)
|
|
else:
|
|
pyautogui.write(mapped_key)
|
|
else:
|
|
pyautogui.write(key)
|
|
|
|
def press_key_combination(self, *keys: str) -> None:
|
|
"""Press a combination of keys (e.g., Ctrl+C).
|
|
|
|
Args:
|
|
keys: Keys to press together
|
|
"""
|
|
logger.debug(f"Pressing key combination: {'+'.join(keys)}")
|
|
|
|
# Press all keys down
|
|
for key in keys:
|
|
pyautogui.keyDown(key)
|
|
time.sleep(random.uniform(0.01, 0.03))
|
|
|
|
# Hold briefly
|
|
time.sleep(random.uniform(0.05, 0.1))
|
|
|
|
# Release all keys (in reverse order)
|
|
for key in reversed(keys):
|
|
pyautogui.keyUp(key)
|
|
time.sleep(random.uniform(0.01, 0.03))
|
|
|
|
def press_key(self, key: str, duration: Optional[float] = None) -> None:
|
|
"""Press and release a key.
|
|
|
|
Args:
|
|
key: Key to press
|
|
duration: How long to hold key, or None for quick press
|
|
"""
|
|
if duration is None:
|
|
pyautogui.press(key)
|
|
else:
|
|
pyautogui.keyDown(key)
|
|
time.sleep(duration)
|
|
pyautogui.keyUp(key)
|
|
|
|
def hold_key(self, key: str) -> None:
|
|
"""Start holding a key down.
|
|
|
|
Args:
|
|
key: Key to hold
|
|
"""
|
|
pyautogui.keyDown(key)
|
|
|
|
# Track modifier keys
|
|
if key.lower() == 'shift':
|
|
self.shift_held = True
|
|
elif key.lower() in ['ctrl', 'control']:
|
|
self.ctrl_held = True
|
|
elif key.lower() == 'alt':
|
|
self.alt_held = True
|
|
|
|
def release_key(self, key: str) -> None:
|
|
"""Stop holding a key.
|
|
|
|
Args:
|
|
key: Key to release
|
|
"""
|
|
pyautogui.keyUp(key)
|
|
|
|
# Track modifier keys
|
|
if key.lower() == 'shift':
|
|
self.shift_held = False
|
|
elif key.lower() in ['ctrl', 'control']:
|
|
self.ctrl_held = False
|
|
elif key.lower() == 'alt':
|
|
self.alt_held = False
|
|
|
|
def release_all_keys(self) -> None:
|
|
"""Release all held modifier keys."""
|
|
if self.shift_held:
|
|
self.release_key('shift')
|
|
if self.ctrl_held:
|
|
self.release_key('ctrl')
|
|
if self.alt_held:
|
|
self.release_key('alt')
|
|
|
|
def type_number_sequence(self, numbers: Union[str, int],
|
|
use_numpad: bool = False) -> None:
|
|
"""Type a sequence of numbers.
|
|
|
|
Args:
|
|
numbers: Numbers to type
|
|
use_numpad: Whether to use numpad keys
|
|
"""
|
|
number_str = str(numbers)
|
|
|
|
for digit in number_str:
|
|
if digit.isdigit():
|
|
if use_numpad:
|
|
key = f'num{digit}'
|
|
else:
|
|
key = digit
|
|
|
|
self.type_key(key)
|
|
time.sleep(self.calculate_key_delay(digit))
|
|
|
|
def simulate_pause(self, pause_type: str = 'thinking') -> None:
|
|
"""Simulate natural pauses in typing.
|
|
|
|
Args:
|
|
pause_type: Type of pause ('thinking', 'reading', 'short')
|
|
"""
|
|
if pause_type == 'thinking':
|
|
duration = random.uniform(0.5, 2.0)
|
|
elif pause_type == 'reading':
|
|
duration = random.uniform(0.2, 0.8)
|
|
else: # short
|
|
duration = random.uniform(0.1, 0.3)
|
|
|
|
logger.debug(f"Simulating {pause_type} pause for {duration:.2f}s")
|
|
time.sleep(duration)
|
|
|
|
def generate_key_sequence(self, text: str) -> KeySequence:
|
|
"""Generate a key sequence with timing for given text.
|
|
|
|
Args:
|
|
text: Text to generate sequence for
|
|
|
|
Returns:
|
|
KeySequence with keys and delays
|
|
"""
|
|
keys = list(text)
|
|
delays = []
|
|
total_duration = 0.0
|
|
|
|
previous_key = None
|
|
|
|
for key in keys:
|
|
delay = self.calculate_key_delay(key, previous_key)
|
|
delays.append(delay)
|
|
total_duration += delay
|
|
previous_key = key
|
|
|
|
return KeySequence(keys, delays, total_duration)
|
|
|
|
def set_typing_speed(self, wpm: int) -> None:
|
|
"""Set typing speed.
|
|
|
|
Args:
|
|
wpm: Words per minute
|
|
"""
|
|
self.profile.wpm = max(10, min(200, wpm))
|
|
chars_per_minute = self.profile.wpm * 5
|
|
self.profile.base_char_delay = 60.0 / chars_per_minute
|
|
|
|
logger.info(f"Typing speed set to {self.profile.wpm} WPM")
|
|
|
|
def set_accuracy(self, accuracy: float) -> None:
|
|
"""Set typing accuracy.
|
|
|
|
Args:
|
|
accuracy: Accuracy from 0.0 to 1.0
|
|
"""
|
|
self.profile.accuracy = max(0.0, min(1.0, accuracy))
|
|
logger.info(f"Typing accuracy set to {self.profile.accuracy * 100:.1f}%") |