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): 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 Chaque caractère est tapé individuellement avec un délai aléatoire
keyboard.type() de pynput envoie les scancodes QWERTY, ce qui produit pour simuler une frappe humaine. Les caractères spéciaux AZERTY
des caracteres incorrects sur les claviers AZERTY (ex: "ce" -> "ci"). (@ # € etc.) utilisent les bons VK codes via KeyCode.from_char().
Le copier-coller est agnostique du layout clavier.
Pas de copier-coller (détectable par les systèmes anti-robot Citrix).
""" """
import random
if not text: if not text:
return return
clipboard_ok = False for char in text:
try:
import pyperclip
# Sauvegarder le contenu actuel du presse-papiers
try: 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: except Exception:
old_clipboard = None # Fallback : keyboard.type pour les cas spéciaux
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:
try: try:
pyperclip.copy(old_clipboard) self.keyboard.type(char)
except Exception: except Exception as e:
pass 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 char-by-char ({len(text)} chars)")
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)
def _click(self, pos, button_name): 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 self._bezier_move(pos)
time.sleep(0.1) # Delai pour simuler le temps de reaction humain time.sleep(0.05)
if button_name == "double": if button_name == "double":
self.mouse.click(Button.left, 2) self.mouse.click(Button.left, 2)
@@ -1435,6 +1420,35 @@ Example: x_pct=0.50, y_pct=0.30"""
else: else:
self.mouse.click(Button.left) 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): def _execute_key_combo(self, keys: list):
""" """
Executer une combinaison de touches. Executer une combinaison de touches.

View File

@@ -1258,19 +1258,6 @@ def build_replay_from_raw_events(
if session_dir_path: if session_dir_path:
_attach_expected_screenshots(result, events, 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 # Stats visual replay
visual_clicks = sum( visual_clicks = sum(
1 for a in result 1 for a in result