feat: anti-détection robot — Bézier mouse + frappe char-by-char

Pour les environnements Citrix avec détection de robots :
- Souris : courbe de Bézier quadratique avec déviation aléatoire
  et vitesse variable (25 étapes, plus lent début/fin)
- Texte : frappe caractère par caractère via KeyCode.from_char()
  avec délai aléatoire 40-120ms (pas de copier-coller)
- Plus de presse-papiers (Ctrl+V détectable)

Annulation du fix raw_keys→clipboard (plus nécessaire).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dom
2026-03-31 16:25:43 +02:00
parent 6937b94f2a
commit a74056ca22
2 changed files with 53 additions and 52 deletions

View File

@@ -1375,58 +1375,43 @@ Example: x_pct=0.50, y_pct=0.30"""
# =========================================================================
def _type_text(self, text: str):
"""Saisir du texte via copier-coller (methode principale) ou keyboard.type (fallback).
"""Saisir du texte caractère par caractère (anti-détection robot).
Le copier-coller via le presse-papiers est la methode principale car
keyboard.type() de pynput envoie les scancodes QWERTY, ce qui produit
des caracteres incorrects sur les claviers AZERTY (ex: "ce" -> "ci").
Le copier-coller est agnostique du layout clavier.
Chaque caractère est tapé individuellement avec un délai aléatoire
pour simuler une frappe humaine. Les caractères spéciaux AZERTY
(@ # € etc.) utilisent les bons VK codes via KeyCode.from_char().
Pas de copier-coller (détectable par les systèmes anti-robot Citrix).
"""
import random
if not text:
return
clipboard_ok = False
try:
import pyperclip
# Sauvegarder le contenu actuel du presse-papiers
for char in text:
try:
old_clipboard = pyperclip.paste()
# Taper le caractère via from_char (respecte le layout clavier)
self.keyboard.press(KeyCode.from_char(char))
self.keyboard.release(KeyCode.from_char(char))
except Exception:
old_clipboard = None
pyperclip.copy(text)
# Ctrl+V pour coller
self.keyboard.press(Key.ctrl)
time.sleep(0.02)
self.keyboard.press('v')
self.keyboard.release('v')
self.keyboard.release(Key.ctrl)
time.sleep(0.1)
# Restaurer le presse-papiers original
if old_clipboard is not None:
# Fallback : keyboard.type pour les cas spéciaux
try:
pyperclip.copy(old_clipboard)
except Exception:
pass
self.keyboard.type(char)
except Exception as e:
logger.debug(f"Impossible de taper '{char}': {e}")
# Délai humain entre les frappes (40-120ms)
time.sleep(random.uniform(0.04, 0.12))
clipboard_ok = True
logger.debug(f"Texte saisi via presse-papiers ({len(text)} chars)")
except ImportError:
logger.debug("pyperclip non disponible, fallback sur keyboard.type()")
except Exception as e:
logger.warning(f"Copier-coller echoue ({e}), fallback sur keyboard.type()")
if not clipboard_ok:
self.keyboard.type(text)
logger.debug(f"Texte saisi char-by-char ({len(text)} chars)")
def _click(self, pos, button_name):
"""Deplacer la souris et cliquer.
"""Deplacer la souris via courbe de Bézier puis cliquer.
Supporte les boutons : left, right, double (double-clic gauche).
Le mouvement en courbe de Bézier simule un déplacement humain
(anti-détection robot pour Citrix et systèmes surveillés).
"""
self.mouse.position = pos
time.sleep(0.1) # Delai pour simuler le temps de reaction humain
self._bezier_move(pos)
time.sleep(0.05)
if button_name == "double":
self.mouse.click(Button.left, 2)
@@ -1435,6 +1420,35 @@ Example: x_pct=0.50, y_pct=0.30"""
else:
self.mouse.click(Button.left)
def _bezier_move(self, target, steps=25):
"""Déplacer la souris vers target via une courbe de Bézier cubique.
Génère un mouvement naturel avec un point de contrôle aléatoire
pour éviter les lignes droites détectables par les anti-bots.
"""
import random
start = self.mouse.position
sx, sy = start
tx, ty = target
# Point de contrôle aléatoire (déviation latérale)
dist = ((tx - sx) ** 2 + (ty - sy) ** 2) ** 0.5
deviation = max(20, dist * 0.2)
cx = (sx + tx) / 2 + random.uniform(-deviation, deviation)
cy = (sy + ty) / 2 + random.uniform(-deviation, deviation)
for i in range(1, steps + 1):
t = i / steps
# Bézier quadratique : B(t) = (1-t)²·S + 2(1-t)t·C + t²·T
inv_t = 1 - t
x = inv_t * inv_t * sx + 2 * inv_t * t * cx + t * t * tx
y = inv_t * inv_t * sy + 2 * inv_t * t * cy + t * t * ty
self.mouse.position = (int(x), int(y))
# Vitesse variable (plus lent au début et à la fin)
speed = 0.005 + 0.01 * (1 - abs(2 * t - 1))
time.sleep(speed)
def _execute_key_combo(self, keys: list):
"""
Executer une combinaison de touches.

View File

@@ -1258,19 +1258,6 @@ def build_replay_from_raw_events(
if session_dir_path:
_attach_expected_screenshots(result, events, session_dir_path)
# ── 9. Supprimer raw_keys incomplets (chars fusionnés depuis key_combos) ──
# Quand le texte contient des caractères venant de key_combos convertis
# (ex: @ de AltGr), les raw_keys ne les couvrent pas. Forcer le copier-coller.
for action in result:
if action.get("type") == "type" and action.get("raw_keys"):
recon = _reconstruct_text_from_raw_keys(action["raw_keys"])
if len(recon) < len(action.get("text", "")):
del action["raw_keys"]
logger.debug(
"raw_keys supprimés pour '%s' (recon=%d < text=%d)",
action["text"][:30], len(recon), len(action["text"]),
)
# Stats visual replay
visual_clicks = sum(
1 for a in result