Feat: Humanizer anti-détection pour environnements Citrix/VDI
- Module humanizer.py avec simulation comportement humain - Courbes de Bézier pour mouvements souris - Décalage gaussien pour positions de clic - Frappe avec rythme variable et micro-erreurs - 4 profils: fast, normal, slow, stealth - Intégré dans click_anchor et type_text (humanize=True par défaut) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
573
visual_workflow_builder/backend/utils/humanizer.py
Normal file
573
visual_workflow_builder/backend/utils/humanizer.py
Normal file
@@ -0,0 +1,573 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user