""" Humanizer - Simulation de comportement humain pour anti-détection Auteur : Dom, Claude - 14 janvier 2026 Ce module ajoute un "flou gaussien comportemental" aux actions RPA pour éviter la détection par les systèmes anti-robot (Citrix, etc.). Principes : - Mouvements de souris avec courbes naturelles - Positions de clic avec légère imprécision - Délais variables entre les actions - Frappe avec rythme humain et micro-erreurs """ import random import time import math from typing import Tuple, List, Optional, Callable from dataclasses import dataclass from enum import Enum class HumanProfile(Enum): """Profils de comportement humain.""" FAST = "fast" # Utilisateur expérimenté, rapide NORMAL = "normal" # Utilisateur standard SLOW = "slow" # Utilisateur prudent/débutant STEALTH = "stealth" # Maximum d'humanisation (environnements très surveillés) @dataclass class HumanConfig: """Configuration du comportement humain.""" # Mouvement souris mouse_speed_base: float = 0.3 # Durée base du mouvement (secondes) mouse_speed_variance: float = 0.15 # Variance de la durée mouse_curve_intensity: float = 0.3 # Intensité de la courbe (0-1) mouse_micro_movements: bool = True # Micro-tremblements # Position clic click_offset_max: int = 8 # Décalage max en pixels click_offset_sigma: float = 3.0 # Écart-type du décalage gaussien # Timing delay_base_ms: int = 200 # Délai base entre actions (ms) delay_variance_ratio: float = 0.4 # Ratio de variance (40%) pause_probability: float = 0.05 # Probabilité de pause réflexion pause_duration_ms: Tuple[int, int] = (500, 2000) # Durée pause min/max # Frappe clavier typing_speed_cpm: int = 300 # Caractères par minute typing_speed_variance: float = 0.25 # Variance vitesse typo_probability: float = 0.02 # Probabilité de faute de frappe typo_correct: bool = True # Corriger les fautes # Profils prédéfinis PROFILES = { HumanProfile.FAST: HumanConfig( mouse_speed_base=0.15, mouse_speed_variance=0.08, mouse_curve_intensity=0.15, click_offset_max=5, click_offset_sigma=2.0, delay_base_ms=100, delay_variance_ratio=0.3, pause_probability=0.02, typing_speed_cpm=400, typing_speed_variance=0.2, typo_probability=0.01, ), HumanProfile.NORMAL: HumanConfig(), # Valeurs par défaut HumanProfile.SLOW: HumanConfig( mouse_speed_base=0.5, mouse_speed_variance=0.2, mouse_curve_intensity=0.4, click_offset_max=10, click_offset_sigma=4.0, delay_base_ms=400, delay_variance_ratio=0.5, pause_probability=0.1, typing_speed_cpm=200, typing_speed_variance=0.3, typo_probability=0.03, ), HumanProfile.STEALTH: HumanConfig( mouse_speed_base=0.4, mouse_speed_variance=0.25, mouse_curve_intensity=0.5, mouse_micro_movements=True, click_offset_max=12, click_offset_sigma=4.5, delay_base_ms=350, delay_variance_ratio=0.5, pause_probability=0.15, pause_duration_ms=(800, 3000), typing_speed_cpm=250, typing_speed_variance=0.35, typo_probability=0.025, typo_correct=True, ), } class Humanizer: """ Classe principale pour humaniser les actions RPA. Usage: humanizer = Humanizer(profile=HumanProfile.NORMAL) # Mouvement souris humanisé humanizer.move_to(500, 300) # Clic avec position légèrement décalée humanizer.click(500, 300) # Frappe avec rythme humain humanizer.type_text("Hello World") # Délai humanisé humanizer.wait() """ def __init__( self, profile: HumanProfile = HumanProfile.NORMAL, config: Optional[HumanConfig] = None, enabled: bool = True ): """ Initialise le Humanizer. Args: profile: Profil de comportement prédéfini config: Configuration personnalisée (override le profil) enabled: Active/désactive l'humanisation """ self.enabled = enabled self.config = config if config else PROFILES.get(profile, PROFILES[HumanProfile.NORMAL]) self._pyautogui = None def _get_pyautogui(self): """Import lazy de pyautogui.""" if self._pyautogui is None: import pyautogui self._pyautogui = pyautogui return self._pyautogui # ========================================================================= # FONCTIONS MATHÉMATIQUES # ========================================================================= def _gaussian(self, mu: float = 0, sigma: float = 1) -> float: """Distribution gaussienne.""" return random.gauss(mu, sigma) def _clamp(self, value: float, min_val: float, max_val: float) -> float: """Limite une valeur entre min et max.""" return max(min_val, min(value, max_val)) def _bezier_point( self, t: float, p0: Tuple[float, float], p1: Tuple[float, float], p2: Tuple[float, float], p3: Tuple[float, float] ) -> Tuple[float, float]: """Calcule un point sur une courbe de Bézier cubique.""" u = 1 - t tt = t * t uu = u * u uuu = uu * u ttt = tt * t x = uuu * p0[0] + 3 * uu * t * p1[0] + 3 * u * tt * p2[0] + ttt * p3[0] y = uuu * p0[1] + 3 * uu * t * p1[1] + 3 * u * tt * p2[1] + ttt * p3[1] return (x, y) def _generate_bezier_path( self, start: Tuple[int, int], end: Tuple[int, int], num_points: int = 20 ) -> List[Tuple[int, int]]: """ Génère un chemin de souris avec courbe de Bézier. Args: start: Point de départ (x, y) end: Point d'arrivée (x, y) num_points: Nombre de points intermédiaires Returns: Liste de points (x, y) formant le chemin """ # Distance entre les points dx = end[0] - start[0] dy = end[1] - start[1] distance = math.sqrt(dx * dx + dy * dy) # Points de contrôle avec décalage aléatoire intensity = self.config.mouse_curve_intensity offset_range = distance * intensity # Point de contrôle 1 (près du départ) ctrl1 = ( start[0] + dx * 0.3 + self._gaussian(0, offset_range * 0.5), start[1] + dy * 0.3 + self._gaussian(0, offset_range * 0.5) ) # Point de contrôle 2 (près de l'arrivée) ctrl2 = ( start[0] + dx * 0.7 + self._gaussian(0, offset_range * 0.3), start[1] + dy * 0.7 + self._gaussian(0, offset_range * 0.3) ) # Générer les points sur la courbe path = [] for i in range(num_points + 1): t = i / num_points point = self._bezier_point(t, start, ctrl1, ctrl2, end) # Ajouter micro-tremblements if self.config.mouse_micro_movements and i > 0 and i < num_points: point = ( point[0] + self._gaussian(0, 1), point[1] + self._gaussian(0, 1) ) path.append((int(point[0]), int(point[1]))) return path # ========================================================================= # MOUVEMENT SOURIS # ========================================================================= def move_to(self, x: int, y: int, duration: Optional[float] = None) -> None: """ Déplace la souris vers une position avec mouvement humain. Args: x: Position X cible y: Position Y cible duration: Durée du mouvement (None = auto) """ pyautogui = self._get_pyautogui() if not self.enabled: pyautogui.moveTo(x, y) return # Position actuelle current_x, current_y = pyautogui.position() # Calculer la durée if duration is None: base = self.config.mouse_speed_base variance = self.config.mouse_speed_variance duration = max(0.05, base + self._gaussian(0, variance)) # Générer le chemin courbe path = self._generate_bezier_path( (current_x, current_y), (x, y), num_points=max(5, int(duration * 30)) ) # Suivre le chemin step_duration = duration / len(path) for point in path: pyautogui.moveTo(point[0], point[1], _pause=False) time.sleep(step_duration) def humanize_position(self, x: int, y: int) -> Tuple[int, int]: """ Ajoute un décalage gaussien à une position. Args: x: Position X originale y: Position Y originale Returns: Nouvelle position (x, y) avec décalage """ if not self.enabled: return (x, y) offset_x = int(self._gaussian(0, self.config.click_offset_sigma)) offset_y = int(self._gaussian(0, self.config.click_offset_sigma)) # Limiter le décalage max_offset = self.config.click_offset_max offset_x = self._clamp(offset_x, -max_offset, max_offset) offset_y = self._clamp(offset_y, -max_offset, max_offset) return (x + int(offset_x), y + int(offset_y)) # ========================================================================= # CLIC # ========================================================================= def click( self, x: int, y: int, button: str = 'left', clicks: int = 1, move_first: bool = True ) -> Tuple[int, int]: """ Effectue un clic avec comportement humain. Args: x: Position X y: Position Y button: Bouton ('left', 'right', 'middle') clicks: Nombre de clics move_first: Déplacer la souris avant de cliquer Returns: Position réelle du clic (avec décalage) """ pyautogui = self._get_pyautogui() # Humaniser la position real_x, real_y = self.humanize_position(x, y) if not self.enabled: pyautogui.click(real_x, real_y, clicks=clicks, button=button) return (real_x, real_y) # Déplacer la souris d'abord (mouvement naturel) if move_first: self.move_to(real_x, real_y) # Petit délai avant le clic (réflexe humain) time.sleep(random.uniform(0.02, 0.08)) # Clic if clicks == 2: # Double-clic avec intervalle variable pyautogui.click(real_x, real_y, button=button) time.sleep(random.uniform(0.08, 0.15)) pyautogui.click(real_x, real_y, button=button) else: pyautogui.click(real_x, real_y, clicks=clicks, button=button) return (real_x, real_y) def double_click(self, x: int, y: int) -> Tuple[int, int]: """Double-clic humanisé.""" return self.click(x, y, clicks=2) def right_click(self, x: int, y: int) -> Tuple[int, int]: """Clic droit humanisé.""" return self.click(x, y, button='right') # ========================================================================= # FRAPPE CLAVIER # ========================================================================= def type_text( self, text: str, field_x: Optional[int] = None, field_y: Optional[int] = None ) -> str: """ Tape du texte avec rythme humain. Args: text: Texte à taper field_x: Position X du champ (optionnel, pour cliquer avant) field_y: Position Y du champ (optionnel) Returns: Texte réellement tapé (peut différer si typos non corrigées) """ pyautogui = self._get_pyautogui() if not self.enabled: if field_x is not None and field_y is not None: pyautogui.click(field_x, field_y) pyautogui.typewrite(text, interval=0.05) return text # Cliquer sur le champ si position fournie if field_x is not None and field_y is not None: self.click(field_x, field_y) self.wait(100, 200) # Calculer l'intervalle moyen entre les touches cpm = self.config.typing_speed_cpm base_interval = 60.0 / cpm # secondes par caractère typed_text = "" i = 0 while i < len(text): char = text[i] # Vérifier si on fait une faute de frappe if random.random() < self.config.typo_probability and char.isalpha(): # Taper une mauvaise lettre wrong_char = self._get_nearby_key(char) pyautogui.press(wrong_char) typed_text += wrong_char # Pause de réalisation de l'erreur time.sleep(random.uniform(0.1, 0.3)) if self.config.typo_correct: # Corriger avec backspace pyautogui.press('backspace') typed_text = typed_text[:-1] time.sleep(random.uniform(0.05, 0.15)) # Taper le bon caractère pyautogui.press(char) if len(char) == 1 else pyautogui.typewrite(char) typed_text += char # Intervalle variable variance = self.config.typing_speed_variance interval = base_interval * (1 + self._gaussian(0, variance)) interval = max(0.02, interval) # Pause occasionnelle (réflexion) if random.random() < 0.02: # 2% de chance de pause interval += random.uniform(0.2, 0.5) time.sleep(interval) i += 1 return typed_text def _get_nearby_key(self, char: str) -> str: """Retourne une touche proche sur le clavier (pour simuler une typo).""" keyboard_neighbors = { 'a': 'sqzw', 'b': 'vghn', 'c': 'xdfv', 'd': 'erfcxs', 'e': 'rdsw', 'f': 'rtgvcd', 'g': 'tyhbvf', 'h': 'yujnbg', 'i': 'uojk', 'j': 'uikmnh', 'k': 'iolmj', 'l': 'opkm', 'm': 'njk', 'n': 'bhjm', 'o': 'iplk', 'p': 'ol', 'q': 'wa', 'r': 'etfd', 's': 'wedxa', 't': 'ryfg', 'u': 'yihj', 'v': 'cfgb', 'w': 'qeas', 'x': 'zsdc', 'y': 'tugh', 'z': 'asx' } char_lower = char.lower() if char_lower in keyboard_neighbors: neighbors = keyboard_neighbors[char_lower] wrong = random.choice(neighbors) return wrong.upper() if char.isupper() else wrong return char # ========================================================================= # DÉLAIS # ========================================================================= def wait( self, min_ms: Optional[int] = None, max_ms: Optional[int] = None ) -> float: """ Attend avec un délai humanisé. Args: min_ms: Délai minimum en ms (None = config) max_ms: Délai maximum en ms (None = calculé) Returns: Durée réelle de l'attente (secondes) """ if not self.enabled: base = min_ms if min_ms else self.config.delay_base_ms time.sleep(base / 1000) return base / 1000 # Calculer le délai base = min_ms if min_ms else self.config.delay_base_ms variance = self.config.delay_variance_ratio delay_ms = base + self._gaussian(0, base * variance) delay_ms = max(50, delay_ms) # Minimum 50ms if max_ms: delay_ms = min(delay_ms, max_ms) # Vérifier si on fait une pause réflexion if random.random() < self.config.pause_probability: pause_min, pause_max = self.config.pause_duration_ms delay_ms += random.uniform(pause_min, pause_max) delay_sec = delay_ms / 1000 time.sleep(delay_sec) return delay_sec def random_pause(self, probability: float = 0.1) -> bool: """ Fait une pause aléatoire avec une certaine probabilité. Args: probability: Probabilité de pause (0-1) Returns: True si une pause a été effectuée """ if random.random() < probability: pause_min, pause_max = self.config.pause_duration_ms duration = random.uniform(pause_min, pause_max) / 1000 time.sleep(duration) return True return False # ========================================================================= # INSTANCE GLOBALE ET FONCTIONS UTILITAIRES # ========================================================================= # Instance globale par défaut _default_humanizer: Optional[Humanizer] = None def get_humanizer(profile: HumanProfile = HumanProfile.NORMAL) -> Humanizer: """Obtient l'instance globale du Humanizer.""" global _default_humanizer if _default_humanizer is None: _default_humanizer = Humanizer(profile=profile) return _default_humanizer def set_humanizer_profile(profile: HumanProfile) -> None: """Change le profil du Humanizer global.""" global _default_humanizer _default_humanizer = Humanizer(profile=profile) def set_humanizer_enabled(enabled: bool) -> None: """Active/désactive l'humanisation globale.""" humanizer = get_humanizer() humanizer.enabled = enabled # Fonctions raccourcies pour usage direct def humanize_move(x: int, y: int, duration: Optional[float] = None) -> None: """Mouvement souris humanisé (fonction raccourcie).""" get_humanizer().move_to(x, y, duration) def humanize_click(x: int, y: int, button: str = 'left') -> Tuple[int, int]: """Clic humanisé (fonction raccourcie).""" return get_humanizer().click(x, y, button=button) def humanize_type(text: str) -> str: """Frappe humanisée (fonction raccourcie).""" return get_humanizer().type_text(text) def humanize_wait(min_ms: int = 200, max_ms: int = 500) -> float: """Attente humanisée (fonction raccourcie).""" return get_humanizer().wait(min_ms, max_ms) def humanize_position(x: int, y: int) -> Tuple[int, int]: """Position avec décalage gaussien (fonction raccourcie).""" return get_humanizer().humanize_position(x, y)