J’automatise des navigateurs depuis des années. Selenium, Puppeteer, Playwright, je les ai tous utilisés, je les ai tous vus se faire prendre. La course à l’armement entre la détection de bots et l’automatisation de navigateur dure depuis les débuts du scraping, et devine qui perd? Absolument chaque outil d’automatisation basé sur Chromium de cette putain de planète.
Le problème, ce ne sont pas les outils. Playwright est un bon logiciel. Puppeteer marche très bien. Le problème, c’est Chrome DevTools Protocol, le mécanisme par lequel ils parlent tous au navigateur. CDP, c’est la façon dont ton framework d’automatisation dit “clique ce bouton” ou “écris dans ce champ”. C’est aussi la façon dont Cloudflare, DataDome, PerimeterX et tous les autres services de détection de bots de la Terre savent que tu n’es pas humain. Tu peux installer des plugins de stealth, patcher navigator.webdriver, falsifier des empreintes jusqu’à t’en crever les yeux, CDP est toujours là, et ils le trouveront.
Alors j’ai construit docker-stealthy-auto-browse. Soyons clairs, je n’ai rien inventé de cette merde de stealth. Le gros du boulot est fait par Camoufox, Playwright, PyAutoGUI et browserforge. Ce sont des projets brillants, faits par des gens plus malins que moi. Ce que j’ai fait, c’est prendre toute cette merde, la câbler ensemble dans un container Docker et coller une API HTTP par-dessus, pour que tu pilotes tout ça à distance avec des commandes curl. Un container, un endpoint, zéro emmerde à l’installation.
Le Problème de Fond de Toutes les Autres Approches
Voilà ce que fait chaque outil d’automatisation basé sur Chromium: il ouvre Chrome, s’y connecte via CDP et envoie des commandes par ce protocole. Le navigateur sait. Le JavaScript qui tourne dans la page sait. Le script du service de détection de bots, celui qui s’est chargé avant ton contenu? Celui-là sait à coup sûr.
Tu peux essayer de cacher:
- Patcher
navigator.webdriverpour qu’il renvoiefalse, les détecteurs vérifient s’il a été patché - Installer des plugins de stealth, les détecteurs cherchent les effets de bord de ces plugins
- Falsifier des empreintes, les détecteurs comparent l’empreinte du contexte principal à celle des web workers et trouvent les incohérences
- Passer en mode headless, les détecteurs cherchent les signaux de headless
C’est un jeu du chat et de la souris où le chat a tous les avantages. CDP laisse des traces partout, dans le runtime JavaScript, dans la façon dont les événements sont dispatchés, dans les motifs de timing, dans l’état interne du navigateur. Tu essaies de faire croire qu’une marionnette n’en est pas une, alors que les ficelles se voient comme le nez au milieu de la figure.
L’Approche: Pas de Ficelles du Tout
docker-stealthy-auto-browse ne cache pas les signaux d’automatisation. Il les supprime entièrement.
Camoufox à la place de Chromium. Un fork de Firefox sur mesure. Il n’y a pas de Chrome DevTools Protocol, parce que Firefox ne l’utilise pas. Les détecteurs de bots qui cherchent des signaux CDP ne trouvent rien, pas parce qu’on les a cachés, mais parce qu’ils n’existent pas. navigator.webdriver vaut false, pas patché pour renvoyer false, vraiment faux, parce que Camoufox ne le pose même pas.
Playwright pour le contrôle du navigateur. Il gère le niveau DOM, navigation, sélection d’éléments, inspection de page. Le mode d’input pratique mais détectable passe par Playwright. Combiné à Camoufox, il ne fuite pas les signaux d’automatisation CDP habituels que fuitent les configs basées sur Chromium.
PyAutoGUI à la place des événements DOM. Quand tu as besoin de stealth, la souris se déplace physiquement sur l’écran virtuel, avec des courbes humaines, du jitter aléatoire et une accélération adoucie. Quand tu tapes, de vraies frappes clavier au niveau de l’OS sont générées, avec des délais randomisés entre les caractères. Le navigateur les reçoit comme de l’input utilisateur authentique. Aucun JavaScript au monde ne peut faire la différence entre de l’input PyAutoGUI et un vrai humain assis devant un clavier.
De vraies empreintes via browserforge. L’empreinte est générée une fois et appliquée de façon cohérente au contexte principal et aux web workers. Pas de falsification veut dire pas d’incohérences, un vecteur de détection courant qui attrape la plupart des outils d’usurpation d’empreinte.
Xvfb pour un vrai affichage. Le navigateur tourne avec un affichage graphique complet dans le container, via un framebuffer virtuel. Pas de mode headless, pas de signaux de headless. Du point de vue du navigateur et de n’importe quel script de détection, ça tourne sur un bureau normal.
Ma contribution, c’est la colle: une API HTTP en Python qui relie tout ça, le container Docker qui empaquette le tout dans une seule commande docker run, le système de page loaders pour l’automatisation déclenchée par URL, l’abstraction des deux modes d’input (system et playwright) et l’intégration noVNC pour regarder en direct. La technologie de stealth, c’est le génie des autres. L’empaquetage et l’API sont à moi.
Comment Ça Marche
Tu lances le container, il expose une API HTTP sur le port 8080. Tu envoies des commandes JSON, tu reçois des réponses JSON. C’est toute l’interface.
docker run -d --name browser
-p 8080:8080
-p 5900:5900
psyb0t/stealthy-auto-browseLe port 8080, c’est l’API. Le port 5900, c’est un viewer noVNC pour regarder le navigateur en temps réel depuis le tien: tu ouvres http://localhost:5900/ et tu vois exactement ce que voit le navigateur automatisé.
Va quelque part:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{"action": "goto", "url": "https://example.com"}'Depuis la v2.6.0, goto (et refresh et new_tab) acceptent des contrôles de navigation optionnels, par appel: timeout en secondes (30 par défaut), retry_count pour des réessais bornés quand un chargement expire (1 par défaut) et retry_delay entre ces réessais (1s par défaut). Les mêmes trois boutons, que tu l’appelles en HTTP, par l’outil MCP, ou comme étape à l’intérieur d’un run_script.
Lis la page:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{"action": "get_text"}'Trouve tout ce qui est cliquable dans la page:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{"action": "get_interactive_elements"}'Ça te renvoie chaque bouton, lien et input, avec leurs coordonnées dans le viewport, le texte et les sélecteurs CSS. Maintenant clique dessus avec un vrai mouvement de souris:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{"action": "system_click", "x": 500, "y": 300}'Tape avec de vraies frappes clavier:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{"action": "system_type", "text": "hello world"}'Prends une capture d’écran:
curl https://ciprian.51k.eu80/screenshot/browser?whLargest=512 -o screenshot.pngLance des scripts multi-étapes en une seule requête avec run_script, sans envoyer un curl par action:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{
"action": "run_script",
"steps": [
{"action": "goto", "url": "https://example.com", "wait_until": "domcontentloaded"},
{"action": "sleep", "duration": 2},
{"action": "get_text", "output_id": "text"},
{"action": "eval", "expression": "document.title", "output_id": "title"}
]
}'Accepte aussi "yaml": "...", avec le même format qu’en mode script. En mode instance unique, les requêtes sont sérialisées automatiquement, envoie plusieurs scripts en parallèle et ils font la queue au lieu de se rentrer dedans.
Deux Modes d’Input, et Ça Compte
Le container te donne deux façons d’interagir avec les pages, et choisir la bonne, c’est la différence entre passer et se faire bloquer.
Input Système: Indétectable
system_click, mouse_move, system_type, send_key, scroll, tous utilisent PyAutoGUI pour générer de vrais événements au niveau de l’OS. La souris bouge avec des courbes humaines. Les frappes ont un timing randomisé. Le navigateur n’a aucun moyen de savoir que ça ne vient pas d’une vraie personne.
Tu travailles avec des coordonnées du viewport, tu les récupères de get_interactive_elements.
Input Playwright: Détectable Mais Pratique
click, fill, type, ceux-là passent par l’automatisation DOM de Playwright, avec des sélecteurs CSS ou XPath. Plus rapide, plus simple, pas de calcul de coordonnées. Mais les motifs d’injection d’événements sont théoriquement détectables par une analyse comportementale sérieuse.
La règle est simple: le site a de la détection de bots? Input système. Toujours. Tu scrapes juste un truc qui ne se défend pas? L’input Playwright fera l’affaire.
Un Vrai Flux de Login
Voilà à quoi ressemble un login indétectable, chaque interaction passe par de l’input au niveau de l’OS:
API=https://ciprian.51k.eu80
# Navigate to login
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "goto", "url": "https://example.com/login"}'
# Find all interactive elements
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "get_interactive_elements"}'
# Click the email field (coordinates from above)
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "system_click", "x": 400, "y": 200}'
# Type email with human-like keystrokes
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "system_type", "text": "[email protected]"}'
# Tab to password field
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "send_key", "key": "tab"}'
# Type password
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "system_type", "text": "secretpassword"}'
# Submit
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "send_key", "key": "enter"}'
# Wait for redirect
curl -X POST $API -H 'Content-Type: application/json'
-d '{"action": "wait_for_url", "url": "**/dashboard", "timeout": 15}'Le site voit un vrai humain qui tape à vitesse naturelle avec des délais randomisés. Pas de signaux CDP. Pas d’empreintes d’automatisation. Rien.
Page Loaders: L’Automatisation en Pilote Automatique
Les page loaders, c’est comme des userscripts Greasemonkey mais pour l’API HTTP. Tu écris un fichier YAML qui dit “chaque fois que le navigateur visite ce domaine, lance ces étapes automatiquement”. Tu les montes dans le container et tu oublies.
# loaders/news_site.yaml
name: News Site Cleanup
match:
domain: news-site.com
steps:
- action: goto
url: "${url}"
wait_until: networkidle
- action: wait_for_element
selector: "article"
timeout: 10
- action: eval
expression: "document.querySelector('.cookie-consent')?.remove()"
- action: eval
expression: "document.querySelector('.newsletter-overlay')?.remove()"
- action: scroll_to_bottom
delay: 0.3Maintenant chaque goto vers news-site.com attend automatiquement le contenu, tue le popup de cookies, tue la modale de newsletter et scrolle pour déclencher les images en chargement paresseux. Fini d’envoyer 5 commandes après chaque navigation.
L’API Complète
L’API HTTP couvre tout ce dont tu aurais besoin:
- Navigation:
goto,refresh, avec des conditions d’attente configurables - Input système:
system_click,mouse_move,system_type,send_key,scroll, tout au niveau de l’OS, tout indétectable - Inspection de page:
get_interactive_elements,get_text,get_html,eval - Conditions d’attente:
wait_for_element,wait_for_text,wait_for_url,wait_for_network_idle, parce quesleepc’est pour les amateurs - Gestion des onglets:
list_tabs,new_tab,switch_tab,close_tab - Cookies et stockage: CRUD complet sur les cookies, localStorage, sessionStorage
- Téléchargements et envois: gérer les téléchargements de fichiers et les inputs de fichier par programme
- Journalisation réseau: enregistrer toutes les requêtes HTTP que fait la page, trouver des endpoints d’API, déboguer, vérifier
- Captures d’écran: le viewport du navigateur ou le bureau entier, avec des paramètres de redimensionnement
- Gestion des dialogues: acceptation automatique ou réponses configurées pour alert, confirm et prompt
- Enregistrement d’écran:
start_recording,stop_recording,recording_status, du MP4 des pixels réellement rendus
Deux endpoints de capture te donnent le viewport du navigateur (à quoi ressemble la page) ou le bureau virtuel entier (interface du navigateur comprise). Les deux acceptent des paramètres de redimensionnement, pour ne pas télécharger des PNG en 1920×1080 à chaque fois:
# Resize longest side to 512px
curl https://ciprian.51k.eu80/screenshot/browser?whLargest=512 -o shot.png
# Full desktop including browser chrome
curl https://ciprian.51k.eu80/screenshot/desktop?whLargest=512 -o desktop.pngCaméra et Micro Virtuels
Tu poses un fichier vidéo et/ou audio dans un répertoire /media monté, et les pages reçoivent des pistes caméra et micro depuis ces fichiers, via navigator.mediaDevices.getUserMedia(). Les chemins sont validés au démarrage, les symlinks résolus, pour rester dans le répertoire média configuré. Demande un type qui n’est pas configuré et la requête échoue, elle ne retombe pas discrètement sur un vrai périphérique, ce qui est exactement le comportement voulu quand tout l’intérêt est qu’il n’y a aucun périphérique réel.
Les fichiers statiques, c’était la première version. Pose VIRTUAL_MEDIA_DYNAMIC=true et tu peux changer la source à l’exécution: set_virtual_media_source choisit un fichier existant à l’intérieur, upload_virtual_media prend un payload base64 borné. Les deux préservent les identités des pistes caméra et micro qu’une page a déjà acquises, donc une page peut changer ce qu’elle voit sans redemander getUserMedia() et sans remarquer qu’il s’est passé quelque chose.
Les envois ne sont pas un trou: noms de fichiers générés à l’abri des collisions plutôt qu’écrasement d’une source nommée, décodage base64 strict, un plafond configurable VIRTUAL_MEDIA_UPLOAD_MAX_BYTES (50 Mio par défaut) et un contrôle ffprobe du flux demandé avant que quoi que ce soit soit stocké ou activé. La sélection de source n’accepte que des fichiers ordinaires contenus dans VIRTUAL_MEDIA_DIR, pas d’URL distantes, pas de flux WebSocket, pas de chemins arbitraires de l’hôte, aucune autre entrée vivante. get_virtual_media_state rapporte l’état du mode dynamique, le nom de base de la source active et un compteur de révision, sans fuiter de chemins de source ni d’octets envoyés.
Scraper Sans Écrire de JavaScript
Quatre actions qui couvrent ce que tu écrirais sinon à la main dans un appel evaluate, à chaque fois: get_page_info, get_element, get_elements et get_computed_style. Les données de page et de CSS directement, pas de JS à écrire, pas de cauchemar de guillemets pour les faire passer en JSON. get_elements renvoie désormais 20 résultats par défaut, de façon cohérente, dans l’API HTTP, dans la documentation MCP et dans la fixture de test, ce qui n’a pas toujours été le cas.
Enregistrement d’Écran
Les captures te disent à quoi ressemblait une page. Elles ne te disent pas ce qui s’est passé. Donc le navigateur s’enregistre lui-même maintenant: ffmpeg en x11grab sur l’affichage Xvfb, qui écrit du MP4 dans un volume /recordings monté. Les pixels réellement rendus, y compris le curseur de souris au niveau de l’OS qui se balade, parce que dans ce truc le curseur est réel.
Trois modes: window prend toute la fenêtre Camoufox, viewport coupe l’interface du navigateur en utilisant les offsets calibrés mozInnerScreenX/Y (pas des valeurs devinées en dur), et desktop prend tout l’écran Xvfb.
mkdir -p ./recordings
docker run -d -p 8080:8080 -v ./recordings:/recordings psyb0t/stealthy-auto-browse
# start
curl -X POST https://ciprian.51k.eu80/action
-d '{"action": "start_recording", "mode": "viewport", "fps": 20}'
# ... drive the browser ...
# stop — you name the file at stop time, after you know how it went
curl -X POST https://ciprian.51k.eu80/action
-d '{"action": "stop_recording", "slug": "my-flow"}'
# → ./recordings/my-flow.mp4Le slug se donne à l’arrêt, exprès, tu baptises l’enregistrement une fois le run terminé, quand tu sais vraiment si c’est login-success ou login-broke-again. Les slugs sont nettoyés contre la traversée de chemin et les collisions se renomment toutes seules. Un seul enregistrement actif par container. Résistant au crash: arrêt propre sur SIGINT, plus un balayage au démarrage des fichiers temporaires orphelins.
show_cursor vaut true par défaut, mais éteins-le quand tu fais des captures de régression visuelle et que les pixels du curseur empoisonneraient le diff. Le descripteur de la réponse renvoie le flag, pour que l’appelant confirme ce qu’il a obtenu.
Marche via l’API HTTP et via MCP. En mode cluster, le démarrage et l’arrêt doivent vivre dans le même appel run_script pour toucher la même instance, sinon tu demandes à un container d’arrêter un enregistrement lancé par un autre container.
Résolution. L’enregistrement cassait au-dessus de 1920×1080, parce que l’entrypoint démarrait Xvfb à cette taille et que xrandr ne peut pas agrandir le framebuffer racine après coup, donc ffmpeg capturait joyeusement hors de l’écran réel. Le framebuffer est maintenant alloué à XVFB_RESOLUTION d’entrée, et le redimensionnement a disparu. Les résolutions carrées et hautes marchent, le plafond de 1920×1080 n’existe plus.
Quand Camoufox Meurt, Il Revient
Un utilisateur s’est pris “Connection closed while reading from the driver” après quelques runs n8n sur Facebook. La cause profonde était moche: le navigateur gardait en cache un objet Page mort une fois Camoufox lui-même mort, donc chaque requête suivante essayait de parler à un cadavre.
Maintenant il y a un vrai health check. is_healthy() fait un aller-retour jusqu’au driver au lieu de faire confiance à l’état en cache, et ensure_healthy() relance le contexte persistant si l’aller-retour échoue, le profil survit, donc les cookies et l’empreinte passent. Le getter de page interne et l’accesseur de page active soignent d’abord et utilisent ensuite. La requête qui déclenche la récupération mange 4-5 secondes, tout ce qui suit tourne à pleine vitesse.
Chaque récupération dépose aussi un postmortem au niveau WARNING: les lignes OOM de dmesg, meminfo, loadavg et la liste des processus camoufox-bin survivants. Donc quand ça meurt, tu as la vraie cause dans le log JSON au lieu d’un arrêt mystérieux de container.
La Configuration de Stealth Qui Compte Vraiment
Quelques variables d’environnement qui influencent réellement le fait de te faire prendre ou pas:
Correspondance du fuseau horaire. Les détecteurs de bots comparent le fuseau de ton navigateur à la géolocalisation de ton IP. Si ton IP dit Roumanie et que ton fuseau dit UTC, c’est un drapeau rouge. Pose TZ=Europe/Bucharest (ou ce qui correspond à ton IP) et ce vecteur disparaît.
Support des proxys. Fais passer tout le trafic par n’importe quelle sortie avec PROXY_URL, soit http://user:pass@host:port, soit socks5://host:port, ce que tu as. Combiné à la correspondance de fuseau, tu ressembles à un vrai utilisateur depuis l’emplacement de cette sortie, raison pour laquelle la doc dit sans détour que ça doit être une sortie autorisée dont l’emplacement correspond à l’empreinte que tu testes. Le repo documente maintenant un montage où la sortie t’appartient plutôt que d’être louée: une cellule WireGuard pr0xteus jetable. Ce guide a été réécrit pour pr0xteus v0.11.0, qui rend deux URL par bail au lieu d’une, et le navigateur prend désormais la HTTP: jq -er '.proxies.http'. Ce n’est pas une préférence de style. Firefox ne fait pas de SOCKS5 authentifié de façon fiable, donc pointer Camoufox sur l’URL SOCKS, c’est la méthode pour obtenir un proxy qui marche pile jusqu’au moment où il a besoin d’identifiants. Le reste en découle: tu joins le contrôleur via --network host plutôt qu’en rejoignant un réseau d’egress, tu gardes l’API de contrôle sur 127.0.0.1:8000 et le proxy HTTP sur 127.0.0.1:8080, et tu déplaces l’API du navigateur sur 8090 avec HTTP_LISTEN_PORT=8090 pour que les deux ne se battent pas sur 8080. Rien dans ce montage n’est joignable depuis l’extérieur de l’hôte. Guide complet dans docs/configuration.md.
Profils persistants. Monte un répertoire sur /userdata et tes cookies, localStorage, sessions et empreinte survivent aux redémarrages du container. Sans ça, chaque redémarrage est une identité neuve, ce qui est parfois exactement ce que tu veux, et parfois suspect à mort.
docker run -d
-e TZ=Europe/Bucharest
-e PROXY_URL=http://user:pass@proxy:8888
-v ./my-profile:/userdata
-p 8080:8080
-p 5900:5900
psyb0t/stealthy-auto-browseExtensions Préinstallées
Chaque container arrive avec des extensions de confidentialité déjà configurées:
- uBlock Origin, bloque pubs, traqueurs et autres agaceries. Moins de bruit, moins de scripts de tracking qui tournent
- LocalCDN, intercepte les requêtes vers les CDN et sert les ressources en local. Google et Cloudflare ne peuvent plus te suivre de site en site
- ClearURLs, retire les paramètres de tracking (utm_source, fbclid, gclid) des URL
- Consent-O-Matic, refuse automatiquement les popups de consentement aux cookies, pour que tu n’aies plus à te coltiner cette merde
Tu en veux plus? Monte un profil persistant, ouvre VNC, va sur about:addons et installe ce que tu veux. Ça survivra aux redémarrages.
Résultats aux Tests de Détection de Bots
Testé contre tout ce qui compte, et passé partout:
- CreepJS, cohérence d’empreinte canvas et WebGL, détection de mensonges, comparaison aux workers: passé
- BrowserScan, drapeau WebDriver, signaux CDP, propriétés du navigator: passé
- Pixelscan, cohérence d’empreinte, correspondance fuseau et IP, fuites WebRTC: passé
- Cloudflare, pages de challenge, Turnstile, bot management: passé
- SannySoft, tests Intoli plus scanner d’empreinte: passé
- Incolumitas, techniques de détection modernes: passé
- Rebrowser, détection de fuites CDP, webdriver, analyse du viewport: passé
- BrowserLeaks WebRTC, détection de fuite d’IP par WebRTC: passé
- DeviceAndBrowserInfo, 19 contrôles, tous au vert, “You are human!”: passé
- IpHey, note “Trustworthy”: passé
- Fingerprint.com, identifié comme un Firefox normal, aucun drapeau de bot: passé
Ça passe parce qu’il n’y a rien à détecter. Aucun CDP à trouver, parce que Firefox n’en a pas. Aucune empreinte falsifiée, parce que l’empreinte est réelle et cohérente. Aucun drapeau d’automatisation, parce que navigator.webdriver est authentiquement faux. Aucun événement d’input bidon, parce que PyAutoGUI en génère des vrais au niveau de l’OS.
Il Te Dit Qu’il y a un CAPTCHA, et Rien d’Autre
detect_challenge rapporte des preuves bornées et réduites au minimum de données pour les intégrations documentées de Turnstile, reCAPTCHA, hCaptcha, Friendly Captcha, ALTCHA, Arkose, AWS WAF et GeeTest, plus des indices prudents pour les génériques visibles. Disponible en HTTP, en mode script, dans run_script et via MCP.
Lis les verbes attentivement, parce que les omissions sont le design: il ne clique jamais, ne résout jamais, n’entre jamais dans la frame du challenge et n’expose jamais de query strings, de clés de site ni de tokens de réponse. Les preuves de ressource Arkose ont leurs segments de chemin porteurs de clé caviardés avant qu’une réponse d’API, de script ou de MCP puisse les renvoyer. Ça te dit qu’un challenge est là. Ça ne te fait pas passer, et ça n’essaie pas.
Le compagnon, c’est scroll_into_view: true, qui amène la première frame ou le premier widget détecté et rendu dans le viewport, sans cliquer dessus, le focaliser, le résoudre, le soumettre ni y entrer. Ça, c’est pour le passage de relais: ton automatisation se cogne à un mur, fait défiler le mur dans le champ de vision, et un humain reprend la main par la session noVNC qui était là depuis le début. Le navigateur était toujours regardable, maintenant il peut aussi te dire quand regarder.
Serveur MCP
Les agents IA peuvent piloter le navigateur par le Model Context Protocol, en Streamable HTTP sur /mcp, port 8080. Toutes les actions du navigateur sont exposées comme outils MCP: navigation, captures, clics, saisie, évaluation de JavaScript, cookies, tout.
Branche n’importe quel client compatible MCP, Claude Desktop, Claude Code, agents maison, sur https://ciprian.51k.eu80/mcp/ et lance-toi. Marche en mode standalone comme en mode cluster, HAProxy route le trafic MCP avec les mêmes sessions collantes que l’API HTTP.
En mode cluster, le serveur MCP n’expose que run_script (plus ping et sleep) comme outils. Les actions individuelles comme goto, get_text, screenshot et compagnie ne sont pas disponibles comme outils MCP séparés derrière un cluster. C’est voulu, voir la section mode cluster plus bas pour le pourquoi.
C’est distinct de l’approche par répertoire .agents/.skills/ mentionnée plus bas. Les skills apprennent à l’IA à se servir de l’API HTTP avec curl. MCP donne à l’IA un accès natif aux outils, pas de curl, pas de HTTP, les actions du navigateur apparaissent directement comme des outils appelables. Prends celui qui colle à ton montage.
Authentification
Pose AUTH_TOKEN pour exiger un bearer token sur toutes les requêtes (sauf /health):
docker run -d -p 8080:8080 -e AUTH_TOKEN=mysecretkey psyb0t/stealthy-auto-browsePasse le token dans l’en-tête Authorization:
curl -H "Authorization: Bearer mysecretkey" https://ciprian.51k.eu80 ...La v2.0.0 a tué la forme en query param. Avant, ça acceptait aussi ?auth_token=mysecretkey, ce qui était pratique pour les clients MCP incapables de poser des en-têtes et atroce pour tout le reste, les tokens à cette position fuitent dans les logs d’accès, dans l’historique du navigateur et dans les en-têtes Referer. L’en-tête est maintenant la seule forme acceptée, et la simple présence d’un query param auth_token vaut 401 immédiat.
Bon à savoir quand tu migres: ce contrôle de query tourne avant le contrôle d’en-tête, donc un client qui envoie un en-tête Authorization parfaitement valide plus un ?auth_token= oublié se prend quand même un 401. Enlève le query param, ne te contente pas d’ajouter l’en-tête en croyant que c’est réglé. La comparaison est aussi en temps constant désormais (hmac.compare_digest) plutôt qu’un simple !=, donc tu ne peux pas reconstituer un token en chronométrant les réponses.
L’auth reste optionnelle, cela dit: laisse AUTH_TOKEN vide et chaque endpoint sauf /health est ouvert à tout ce qui atteint le port.
Fait Pour les Agents IA
Voilà le truc dont personne ne parle avec l’automatisation de navigateur: le meilleur cas d’usage en 2026, ce n’est pas un script Python qui tourne en boucle de scraping. Ce sont les agents IA qui doivent interagir avec le web comme un humain.
J’utilise Claude Code en permanence, et la moitié de ce que je lui demande passe par des pages web: remplir des formulaires, vérifier des dashboards, récupérer des données sur des sites sans API, bidouiller des panneaux d’admin. Le problème quand on donne un navigateur à un LLM, ça a toujours été l’interface. Selenium? Trop compliqué. L’API de Playwright? Trop de pièces mobiles. Le LLM finit par écrire 50 lignes de setup avant de pouvoir cliquer sur un seul bouton.
docker-stealthy-auto-browse a été conçu dès le départ pour être sympa avec l’IA. Toute l’interface, ce sont des commandes curl avec du JSON. Point. Un LLM n’a pas besoin d’importer des bibliothèques, de gérer des instances de navigateur, de jongler avec des contextes async ni aucune de ces saletés. Il envoie juste des requêtes HTTP.
Réfléchis à ce qu’il faut à un agent IA pour naviguer sur le web:
- Aller quelque part, un curl vers
goto - Comprendre ce qu’il y a sur la page, un curl vers
get_text. L’IA lit le texte et sait ce qu’elle regarde. Si le texte ne suffit pas,get_interactive_elementsrenvoie chaque chose cliquable avec coordonnées et libellés. Si elle est toujours perdue, une capture d’écran, Claude sait lire des images - Interagir avec les éléments, un curl vers
system_clickavec des coordonnées x,y, un curl verssystem_typepour la saisie - Attendre les résultats, un curl vers
wait_for_textouwait_for_element - Vérifier le résultat, encore un curl vers
get_text
Pas de SDK. Pas d’installation de driver. Pas de gestion du cycle de vie du navigateur. Le container s’occupe de tout ça. L’IA parle juste à un endpoint HTTP.
J’ai fait faire à Claude Code des trucs comme:
- Se connecter à des dashboards web, aller sur des pages précises, extraire des données et les résumer
- Remplir des formulaires multi-étapes sur des sites qui exigent un rendu JavaScript
- Surveiller des pages et me prévenir quand quelque chose change
- Bidouiller des panneaux d’admin sans API, cliquer des boutons, changer des réglages, télécharger des exports
- Chercher des trucs sur des sites qui bloquent les requêtes HTTP normales derrière Cloudflare
Le repo livre un répertoire .agents/.skills/ qui contient une définition de skill complète pour les agents de code IA. Clone le repo (ou juste le répertoire .agents/) dans ton projet et Claude Code le découvre tout seul. Pose STEALTHY_AUTO_BROWSE_URL=https://ciprian.51k.eu80 et l’agent a la référence d’API complète, les deux modes d’input, les flux typiques et les exemples, tout ce qu’il lui faut pour naviguer d’emblée.
C’est aussi disponible sur ClawHub. Installe-le avec clawhub install psyb0t/stealthy-auto-browse et n’importe quel agent IA compatible OpenClaw peut se servir du navigateur à la demande.
La combinaison d’une API HTTP bête comme chou, d’un stealth complet contre la détection de bots et d’instructions intégrées pour agents IA en fait le meilleur outil d’automatisation de navigateur pour LLM que j’aie trouvé. Et j’ai cherché, crois-moi. Tout le reste demande soit un setup SDK compliqué qui embrouille l’IA, soit se fait prendre par Cloudflare dès la première requête, soit les deux.
Mode Script: Lance et Sors
Lance un script YAML au démarrage du container, il exécute les étapes, te rend les résultats en JSON sur stdout, et le container sort. Pas de serveur HTTP, pas de processus longue durée. Bon pour la CI, les jobs cron, le scraping en un coup, ou tout ce où tu veux automatiser une séquence et récupérer la sortie.
# Pipe a script in, get JSON results out
cat my-script.yaml | docker run --rm -i
psyb0t/stealthy-auto-browse --script > results.json
# Parameterize with environment variables
cat my-script.yaml | docker run --rm -i
-e TARGET_URL=https://example.com
psyb0t/stealthy-auto-browse --scriptLe format de script, ce sont les mêmes actions que l’API HTTP, mais en YAML:
name: Scrape Example
on_error: stop # "stop" (default) or "continue"
steps:
- action: goto
url: ${env.TARGET_URL}
wait_until: networkidle
- action: save_screenshot
output_id: page_screenshot
whLargest: 1024
- action: get_text
output_id: page_text
- action: eval
expression: "document.title"
output_id: titleLes étapes avec un output_id atterrissent dans le JSON de sortie. Les captures sortent en PNG encodés en base64. ${env.VAR_NAME} est remplacé par les variables d’environnement. Les logs partent sur stderr, donc rediriger stdout te donne du JSON propre. Code de sortie 0 si toutes les étapes réussissent, 1 si une échoue. Les page loaders se déclenchent toujours sur goto s’ils sont configurés.
Les Scripts Peuvent Brancher et Boucler Maintenant (Dans des Limites)
Une liste plate d’étapes est vite à court de route. “Clique accepter si la bannière de cookies est là.” “Continue à scroller jusqu’à ce que le bouton page suivante disparaisse.” Le mode script et run_script gèrent les deux maintenant: des branches if imbriquées, plus des boucles repeat et while.
Les conditions couvrent l’état d’éléments CSS, le texte visible, des globs d’URL, des résultats booléens de JavaScript et les sorties nommées d’étapes antérieures, donc une branche plus tardive peut réagir à ce qu’une étape antérieure a réellement trouvé au lieu que tu devines au moment de soumettre.
Le mot qui fait tout le travail dans ce titre, c’est limites. Le nombre d’itérations de boucle, le travail total de boucle, le timeout de condition et la profondeur d’imbrication sont tous plafonnés. Un script soumis doit être fini, parce que ce truc accepte des scripts en HTTP et qu’une boucle while sans plafond est une primitive de déni de service que tu as distribuée exprès.
Mode Cluster
Besoin de gérer des requêtes concurrentes? Lance plusieurs instances de navigateur derrière HAProxy, avec une file de requêtes et une synchro de cookies par Redis. Chaque navigateur traite une requête à la fois, le proxy met les autres en file jusqu’à ce qu’une place se libère.
curl -LO https://raw.githubusercontent.com/psyb0t/docker-stealthy-auto-browse/main/docker-compose.cluster.yml
curl -LO https://raw.githubusercontent.com/psyb0t/docker-stealthy-auto-browse/main/haproxy.cfg.template
docker compose -f docker-compose.cluster.yml up -dÇa démarre Redis, 5 containers de navigateur (configurable via NUM_REPLICAS) et le queue-proxy HAProxy. Le point d’entrée est https://ciprian.51k.eu80, la même API qu’en mode container unique. MCP sur /mcp/ passe par le proxy aussi.
En mode cluster, seul run_script est accepté. Envoyer des actions individuelles comme goto, get_text, click ou screenshot directement renvoie une erreur. C’est intentionnel: chaque requête d’une séquence multi-étapes peut atterrir sur une instance différente si le client ne gère pas soigneusement l’adhérence de session, et quand il ne le fait pas, tu récoltes des bugs de contenu périmé, subtils et rendant fou. run_script est atomique. Chaque étape du script tourne sur la même instance, dans la même requête. Pas d’adhérence à gérer. Pas d’état qui bave d’une instance à l’autre.
En mode standalone (un seul container, pas de cluster), les actions individuelles marchent toujours très bien, les requêtes sont sérialisées automatiquement, donc pas de souci de concurrence.
La syntaxe est identique à ce que tu utiliserais en standalone. Séquence de login complète, navigation, extraction, le tout d’un coup:
curl -X POST https://ciprian.51k.eu80
-H "Content-Type: application/json"
-d '{
"action": "run_script",
"steps": [
{"action": "goto", "url": "https://example.com/login", "wait_until": "domcontentloaded"},
{"action": "system_click", "x": 400, "y": 200},
{"action": "system_type", "text": "[email protected]"},
{"action": "send_key", "key": "tab"},
{"action": "system_type", "text": "secretpassword"},
{"action": "send_key", "key": "enter"},
{"action": "wait_for_url", "url": "**/dashboard", "timeout": 15},
{"action": "get_text", "output_id": "page"}
]
}'HAProxy gère le routage en interne, il affecte une instance de navigateur libre et garde tout le script sur celle-là. Tu ne touches jamais aux cookies INSTANCEID. Tu ne penses pas du tout au routage.
La synchro de cookies par Redis est la fonctionnalité qui tue. Les cookies posés sur n’importe quelle instance se propagent instantanément à toutes les autres, par Redis PubSub. Connecte-toi sur browser1, et browser2 jusqu’à browser10 sont immédiatement authentifiés. Lance un seul script de login sur n’importe quelle instance et toute la flotte est connectée, sans répéter le login sur chaque navigateur.
HAProxy expose un tableau de bord de stats sur le port 8081: trafic en direct, profondeur de file, santé des serveurs, débits de requêtes par instance.
Skill et Plugin
Le repo livre un skill d’agent et un plugin OpenClaw sous .agents/, tous deux publiés sur ClawHub par la CI aux pushs de tags. Pointe un agent sur le plugin et il pilote directement l’endpoint MCP d’une instance qui tourne, sans glue code, sans réexpliquer la liste d’actions à chaque session.
En Résumé
Tous les autres outils d’automatisation de navigateur jouent en défense, cachent des signaux CDP, patchent des vecteurs de détection, espèrent que la prochaine mise à jour de Cloudflare ne cassera pas leur plugin de stealth. docker-stealthy-auto-browse ne joue pas à ce jeu. Il n’y a pas de CDP à cacher. Il n’y a pas de signaux d’automatisation à patcher. Le navigateur ne sait vraiment pas qu’il est automatisé.
Un container Docker. Une API HTTP. Il passe tous les détecteurs de bots qu’on lui a balancés.
Va le chercher: github.com/psyb0t/docker-stealthy-auto-browse
Sous licence WTFPL, Do What The Fuck You Want To Public License. Parce qu’évidemment.
L’Installer Dans Ton Agent
Vu que tout l’intérêt, c’est des agents qui pilotent des navigateurs, le chemin d’installation compte ici plus qu’ailleurs. Tout ce qui est sous .agents/ est catalogué dans un seul marketplace, donc ça fait deux commandes:
claude plugin marketplace add psyb0t/agents
claude plugin install stealthy-auto-browse@psyb0tCodex utilise le même marketplace avec un verbe différent, codex plugin add stealthy-auto-browse@psyb0t, parce qu’il n’existe pas de codex plugin install. Il trouve aussi le skill tout seul dans un checkout du repo, puisqu’il scanne .agents/skills/ nativement sans que rien ne soit installé. Il est aussi listé sur le MCP Registry officiel maintenant, donc un client qui résout ses serveurs depuis là peut le trouver sans qu’on lui donne une URL.