docker-stealthy-auto-browse: El Navegador Que No Sabe Que Lo Están Automatizando

Llevo años automatizando navegadores. Selenium, Puppeteer, Playwright, los he usado todos, los he visto caer a todos. La carrera armamentística entre la detección de bots y la automatización de navegadores viene desde los albores del scraping, ¿y adivina quién va perdiendo? Absolutamente cada herramienta de automatización basada en Chromium de este puto planeta.
El problema no son las herramientas en sí. Playwright es buen software. Puppeteer funciona bien. El problema es Chrome DevTools Protocol, el mecanismo que todas usan para hablar con el navegador. CDP es la forma en que tu framework de automatización dice “haz clic en este botón” o “escribe en este campo”. También es la forma en que Cloudflare, DataDome, PerimeterX y cualquier otro servicio de detección de bots de la Tierra saben que no eres humano. Puedes instalar plugins de stealth, parchear navigator.webdriver, falsificar huellas hasta que te sangren los ojos, CDP sigue ahí, y te lo van a encontrar.
Así que construí docker-stealthy-auto-browse. Que quede claro, no inventé yo nada de esta mierda de stealth. El trabajo duro lo hacen Camoufox, Playwright, PyAutoGUI y browserforge. Son proyectos brillantes, hechos por gente más lista que yo. Lo que hice fue coger toda esta mierda, cablearla junta dentro de un container de Docker y plantarle una API HTTP encima, para que puedas controlar el invento entero a distancia con comandos curl. Un container, un endpoint, cero coñazo de instalación.

El Problema de Fondo de Todos los Demás Enfoques

Esto es lo que hace cada herramienta de automatización basada en Chromium: abre Chrome, se conecta a él por CDP y manda comandos a través de ese protocolo. El navegador lo sabe. El JavaScript que corre en la página lo sabe. ¿El script del servicio de detección de bots, ese que se cargó antes que tu contenido? Ese lo sabe segurísimo.
Puedes intentar esconderlo:

  • Parchear navigator.webdriver para que devuelva false, los detectores comprueban si ha sido parcheado
  • Instalar plugins de stealth, los detectores buscan los efectos secundarios de esos plugins
  • Falsificar huellas, los detectores comparan la huella del contexto principal con la de los web workers y encuentran incoherencias
  • Usar modo headless, los detectores buscan señales de headless

Es un juego del gato y el ratón donde el gato tiene todas las ventajas. CDP deja rastros por todas partes, en el runtime de JavaScript, en cómo se despachan los eventos, en los patrones de temporización, en el estado interno del navegador. Estás intentando fingir que una marioneta no es una marioneta mientras los hilos se ven a kilómetros.

El Enfoque: Ningún Hilo En Absoluto

docker-stealthy-auto-browse no esconde las señales de automatización. Las elimina por completo.
Camoufox en lugar de Chromium. Un fork de Firefox hecho a medida. No hay Chrome DevTools Protocol porque Firefox no lo usa. Los detectores de bots que buscan señales de CDP no encuentran nada, no porque las hayamos escondido, sino porque no existen. navigator.webdriver es false, no parcheado para devolver false, falso de verdad, porque Camoufox ni lo pone.
Playwright para controlar el navegador. Se encarga de lo de nivel DOM, navegación, selección de elementos, inspección de página. El modo de input cómodo pero detectable pasa por Playwright. Combinado con Camoufox, no filtra las señales de automatización CDP típicas que sí filtran los montajes basados en Chromium.
PyAutoGUI en lugar de eventos DOM. Cuando necesitas stealth, el ratón se mueve físicamente por la pantalla virtual, con curvas de humano, jitter aleatorio y aceleración suavizada. Cuando escribes, se generan pulsaciones de tecla reales a nivel de sistema operativo, con retardos aleatorios entre caracteres. El navegador las recibe como input de usuario auténtico. Ningún JavaScript del mundo puede distinguir el input de PyAutoGUI de un humano de carne y hueso sentado ante un teclado.
Huellas reales vía browserforge. La huella se genera una vez y se aplica de forma coherente en el contexto principal y en los web workers. Sin falsificación significa sin incoherencias, un vector de detección habitual que caza a la mayoría de herramientas de suplantación de huella.
Xvfb para una pantalla de verdad. El navegador corre con una pantalla gráfica completa dentro del container, a través de un framebuffer virtual. Sin modo headless, sin señales de headless. Por lo que al navegador y a cualquier script de detección respecta, esto corre en un escritorio normal.
Mi aportación es el pegamento: una API HTTP en Python que enlaza todo esto, el container de Docker que empaqueta todo en un solo docker run, el sistema de page loaders para automatización disparada por URL, la abstracción de los dos modos de input (system y playwright) y la integración de noVNC para verlo en directo. La tecnología de stealth es el genio de otros. El empaquetado y la API son míos.

Cómo Funciona

Levantas el container y expone una API HTTP en el puerto 8080. Mandas comandos JSON, recibes respuestas JSON. Esa es toda la interfaz.

docker run -d --name browser 
  -p 8080:8080 
  -p 5900:5900 
  psyb0t/stealthy-auto-browse

El puerto 8080 es la API. El puerto 5900 es un visor noVNC para que mires el navegador en tiempo real desde el tuyo: abres http://localhost:5900/ y ves exactamente lo que ve el navegador automatizado.
Navega a algún sitio:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "goto", "url": "https://example.com"}'

Desde la v2.6.0, goto (y refresh y new_tab) aceptan controles de navegación opcionales por llamada: timeout en segundos (30 por defecto), retry_count para reintentos acotados cuando una carga expira (1 por defecto) y retry_delay entre esos reintentos (1s por defecto). Los mismos tres mandos, lo llames por HTTP, por la herramienta MCP o como paso dentro de un run_script.
Lee la página:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "get_text"}'

Encuentra todo lo clicable de la página:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "get_interactive_elements"}'

Eso te devuelve cada botón, enlace e input, con sus coordenadas de viewport, el texto y los selectores CSS. Ahora haz clic en uno con un movimiento de ratón de verdad:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "system_click", "x": 500, "y": 300}'

Escribe con pulsaciones de tecla reales:

curl -X POST https://ciprian.51k.eu80 
  -H "Content-Type: application/json" 
  -d '{"action": "system_type", "text": "hello world"}'

Haz una captura:

curl https://ciprian.51k.eu80/screenshot/browser?whLargest=512 -o screenshot.png

Lanza scripts de varios pasos en una sola petición con run_script, sin mandar un curl por acción:

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"}
    ]
  }'

También acepta "yaml": "...", con el mismo formato que en modo script. En modo de instancia única, las peticiones se serializan automáticamente, manda varios scripts en paralelo y se ponen en cola en vez de pisarse.

Dos Modos de Input, y Esto Importa

El container te da dos maneras de interactuar con las páginas, y elegir la correcta es la diferencia entre pasar y que te bloqueen.

Input de Sistema: Indetectable

system_click, mouse_move, system_type, send_key, scroll, todos usan PyAutoGUI para generar eventos reales a nivel de sistema operativo. El ratón se mueve con curvas de humano. Las pulsaciones llevan temporización aleatoria. El navegador no tiene forma alguna de saber que no vienen de una persona real.
Trabajas con coordenadas de viewport, las sacas de get_interactive_elements.

Input de Playwright: Detectable Pero Cómodo

click, fill, type, esos usan la automatización de DOM de Playwright, con selectores CSS o XPath. Más rápido, más fácil, sin cuentas de coordenadas. Pero los patrones de inyección de eventos son teóricamente detectables por un análisis de comportamiento serio.
La regla es simple: ¿el sitio tiene detección de bots? Input de sistema. Siempre. ¿Solo estás scrapeando algo que no se defiende? El input de Playwright vale.

Un Flujo de Login de Verdad

Así es como se ve un login indetectable, cada interacción usa input a nivel de sistema operativo:

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}'

El sitio ve a un humano de verdad tecleando a velocidad natural con retardos aleatorios. Sin señales CDP. Sin huellas de automatización. Nada.

Page Loaders: Automatización en Piloto Automático

Los page loaders son como los userscripts de Greasemonkey pero para la API HTTP. Escribes un fichero YAML que dice “cada vez que el navegador visite este dominio, ejecuta estos pasos automáticamente”. Los montas en el container y te olvidas.

# 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.3

Ahora cada goto a news-site.com espera automáticamente al contenido, se carga el popup de cookies, se carga el modal de newsletter y hace scroll para disparar las imágenes de carga perezosa. Se acabó mandar 5 comandos después de cada navegación.

La API Completa

La API HTTP cubre todo lo que necesitarías:

  • Navegación: goto, refresh, con condiciones de espera configurables
  • Input de sistema: system_click, mouse_move, system_type, send_key, scroll, todo a nivel de sistema operativo, todo indetectable
  • Inspección de página: get_interactive_elements, get_text, get_html, eval
  • Condiciones de espera: wait_for_element, wait_for_text, wait_for_url, wait_for_network_idle, porque sleep es para aficionados
  • Gestión de pestañas: list_tabs, new_tab, switch_tab, close_tab
  • Cookies y almacenamiento: CRUD completo de cookies, localStorage, sessionStorage
  • Descargas y subidas: gestionar descargas de ficheros e inputs de fichero por programa
  • Registro de red: grabar todas las peticiones HTTP que hace la página, encontrar endpoints de API, depurar, verificar
  • Capturas: el viewport del navegador o el escritorio entero, con parámetros de redimensionado
  • Gestión de diálogos: aceptación automática o respuestas configuradas para alert, confirm y prompt
  • Grabación de pantalla: start_recording, stop_recording, recording_status, MP4 de los píxeles realmente renderizados

Dos endpoints de captura te dan el viewport del navegador (cómo se ve la página) o el escritorio virtual entero (incluida la interfaz del navegador). Ambos admiten parámetros de redimensionado, para no bajarte PNGs de 1920×1080 cada vez:

# 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.png

Cámara y Micrófono Virtuales

Metes un fichero de vídeo y/o audio en un directorio /media montado y las páginas reciben pistas de cámara y micrófono desde esos ficheros, vía navigator.mediaDevices.getUserMedia(). Las rutas se validan al arrancar, los symlinks se resuelven, para quedarse dentro del directorio de medios configurado. Pide un tipo que no está configurado y la petición falla, no cae calladamente a un dispositivo real, que es justo el comportamiento que quieres cuando toda la gracia es que no hay ningún dispositivo real.
Los ficheros estáticos fueron la primera versión. Pon VIRTUAL_MEDIA_DYNAMIC=true y puedes cambiar la fuente en tiempo de ejecución: set_virtual_media_source elige un fichero existente de dentro, upload_virtual_media acepta un payload base64 acotado. Ambos conservan las identidades de las pistas de cámara y micrófono que una página ya ha adquirido, así que una página puede cambiar lo que ve sin volver a pedir getUserMedia() y sin enterarse de que ha pasado algo.
Las subidas no son un agujero: nombres de fichero generados a prueba de colisiones en vez de sobrescribir una fuente con nombre, decodificación base64 estricta, un techo configurable VIRTUAL_MEDIA_UPLOAD_MAX_BYTES (50 MiB por defecto) y una comprobación con ffprobe del flujo pedido antes de que nada se guarde o se active. La selección de fuente solo acepta ficheros normales contenidos en VIRTUAL_MEDIA_DIR, sin URLs remotas, sin flujos WebSocket, sin rutas arbitrarias del anfitrión, sin ninguna otra entrada viva. get_virtual_media_state informa del estado del modo dinámico, del nombre base de la fuente activa y de un contador de revisión, sin filtrar rutas de origen ni bytes subidos.

Scrapear Sin Escribir JavaScript

Cuatro acciones que cubren lo que de otro modo te escribirías a mano en una llamada a evaluate cada vez: get_page_info, get_element, get_elements y get_computed_style. Datos de página y de CSS directos, sin JS que escribir, sin pesadilla de comillas para colarlos por JSON. get_elements ahora devuelve 20 resultados por defecto, de forma coherente, en la API HTTP, en la documentación de MCP y en la fixture de test, cosa que no siempre fue así.

Grabación de Pantalla

Las capturas te dicen cómo se veía una página. No te dicen qué pasó. Así que ahora el navegador se graba a sí mismo: ffmpeg con x11grab contra la pantalla Xvfb, escribiendo MP4 en un volumen /recordings montado. Los píxeles realmente renderizados, incluido el cursor del ratón a nivel de sistema operativo moviéndose por ahí, porque en este invento el cursor es real.
Tres modos: window coge la ventana entera de Camoufox, viewport recorta la interfaz del navegador usando los offsets calibrados mozInnerScreenX/Y (no adivinanzas hardcodeadas), y desktop coge la pantalla Xvfb entera.

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.mp4

El slug se da al parar, a propósito, bautizas la grabación cuando la ejecución ha terminado y de verdad sabes si es login-success o login-broke-again. Los slugs se sanean contra el recorrido de rutas y las colisiones se renombran solas. Una grabación activa por container. A prueba de caídas: cierre limpio con SIGINT más un barrido al arrancar en busca de temporales huérfanos.
show_cursor es true por defecto, pero apágalo cuando hagas capturas de regresión visual y los píxeles del cursor te envenenarían el diff. El descriptor de la respuesta te devuelve el flag, para que quien llama confirme lo que le ha tocado.
Funciona por la API HTTP y por MCP. En modo clúster, arrancar y parar tienen que vivir dentro de la misma llamada a run_script para que den en la misma instancia, si no le estás diciendo a un container que pare una grabación que arrancó otro.
Resolución. La grabación se rompía por encima de 1920×1080, porque el entrypoint arrancaba Xvfb a ese tamaño y xrandr no puede agrandar el framebuffer raíz a posteriori, así que ffmpeg capturaba tan feliz fuera de la pantalla real. El framebuffer ahora se reserva a XVFB_RESOLUTION desde el principio y el redimensionado ha desaparecido. Las resoluciones cuadradas y altas funcionan, el techo de 1920×1080 ya no existe.

Cuando Camoufox Se Muere, Vuelve

Un usuario se topó con “Connection closed while reading from the driver” tras unas cuantas ejecuciones de n8n contra Facebook. La causa raíz era fea: el navegador se guardaba en caché un objeto Page muerto una vez que Camoufox se había muerto, así que cada petición posterior intentaba hablar con un cadáver.
Ahora hay un health check de verdad. is_healthy() hace un viaje de ida y vuelta hasta el driver en lugar de fiarse del estado cacheado, y ensure_healthy() relanza el contexto persistente si ese viaje falla, el perfil sobrevive, así que las cookies y la huella siguen ahí. Tanto el getter interno de página como el accesor de página activa curan primero y usan después. La petición que dispara la recuperación se come unos 4-5 segundos, todo lo que viene después va a toda velocidad.
Cada recuperación suelta además un postmortem a nivel WARNING: las líneas OOM de dmesg, meminfo, loadavg y la lista de procesos camoufox-bin supervivientes. Así que cuando se muere te llevas la causa real en el log JSON en vez de una parada misteriosa de container.

La Configuración de Stealth Que De Verdad Importa

Unas cuantas variables de entorno que sí afectan a si te pillan:
Coincidencia de zona horaria. Los detectores de bots comparan la zona horaria de tu navegador con la geolocalización de tu IP. Si tu IP dice Rumanía y tu zona dice UTC, eso es una bandera roja. Pon TZ=Europe/Bucharest (o lo que case con tu IP) y ese vector desaparece.
Soporte de proxy. Manda todo el tráfico por la salida que quieras con PROXY_URL, sea http://user:pass@host:port o socks5://host:port, lo que tengas. Combinado con la coincidencia de zona horaria, pareces un usuario real desde la ubicación de esa salida, razón por la que la documentación dice sin rodeos que debería ser una salida autorizada cuya ubicación case con la huella que estás probando. El repo documenta ahora un montaje en el que la salida es tuya en vez de alquilada: una celda WireGuard pr0xteus desechable. Ese recorrido se reescribió para pr0xteus v0.11.0, que devuelve dos URLs por arrendamiento en vez de una, y el navegador ahora coge la HTTP: jq -er '.proxies.http'. No es una preferencia de estilo. Firefox no hace SOCKS5 autenticado de forma fiable, así que apuntar Camoufox a la URL de SOCKS es la manera de conseguir un proxy que funciona justo hasta que necesita credenciales. Lo demás sale de ahí: llegas al controlador por --network host en vez de meterte en una red de salida, dejas la API de control en 127.0.0.1:8000 y el proxy HTTP en 127.0.0.1:8080, y mueves la API del navegador a 8090 con HTTP_LISTEN_PORT=8090 para que las dos no se peleen por el 8080. Nada de este montaje es alcanzable desde fuera del anfitrión. Recorrido completo en docs/configuration.md.
Perfiles persistentes. Monta un directorio en /userdata y tus cookies, localStorage, sesiones y huella sobreviven a los reinicios del container. Sin esto, cada reinicio es una identidad nueva, que a veces es justo lo que quieres y a veces es sospechoso de cojones.

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-browse

Extensiones Preinstaladas

Cada container viene con extensiones de privacidad ya configuradas:

  • uBlock Origin, bloquea anuncios, rastreadores y molestias. Menos ruido, menos scripts de tracking corriendo
  • LocalCDN, intercepta las peticiones a CDN y sirve los recursos en local. Google y Cloudflare ya no pueden seguirte de sitio en sitio
  • ClearURLs, quita los parámetros de tracking (utm_source, fbclid, gclid) de las URLs
  • Consent-O-Matic, rechaza automáticamente los popups de consentimiento de cookies para que no tengas que lidiar con esa mierda

¿Quieres más? Monta un perfil persistente, abre VNC, ve a about:addons e instala lo que quieras. Sobrevivirán a los reinicios.

Resultados en los Tests de Detección de Bots

Probado contra todo lo que importa y pasado en todo:

  • CreepJS, coherencia de huella de canvas y WebGL, detección de mentiras, comparación con workers: pasa
  • BrowserScan, bandera WebDriver, señales CDP, propiedades de navigator: pasa
  • Pixelscan, coherencia de huella, coincidencia de zona horaria e IP, fugas de WebRTC: pasa
  • Cloudflare, páginas de challenge, Turnstile, bot management: pasa
  • SannySoft, tests de Intoli más escáner de huella: pasa
  • Incolumitas, técnicas de detección modernas: pasa
  • Rebrowser, detección de fugas CDP, webdriver, análisis de viewport: pasa
  • BrowserLeaks WebRTC, detección de fuga de IP por WebRTC: pasa
  • DeviceAndBrowserInfo, 19 comprobaciones, todas en verde, “You are human!”: pasa
  • IpHey, calificación “Trustworthy”: pasa
  • Fingerprint.com, identificado como Firefox normal, sin banderas de bot: pasa

Pasa porque no hay nada que detectar. Ningún CDP que encontrar, porque Firefox no lo tiene. Ninguna huella falsificada, porque la huella es real y coherente. Ninguna bandera de automatización, porque navigator.webdriver es falso de verdad. Ningún evento de input falso, porque PyAutoGUI los genera reales a nivel de sistema operativo.

Te Dice Que Hay un CAPTCHA, y Nada Más

detect_challenge informa de evidencias acotadas y reducidas al mínimo de datos para integraciones documentadas de Turnstile, reCAPTCHA, hCaptcha, Friendly Captcha, ALTCHA, Arkose, AWS WAF y GeeTest, más pistas prudentes para las genéricas visibles. Disponible por HTTP, en modo script, en run_script y por MCP.
Lee los verbos con atención, porque las omisiones son el diseño: nunca hace clic, nunca resuelve, nunca entra en el frame del challenge y nunca expone query strings, claves de sitio ni tokens de respuesta. Las evidencias de recurso de Arkose llevan censurados los segmentos de ruta que portan claves antes de que ninguna respuesta de API, script o MCP pueda devolverlos. Esto te dice que un challenge está ahí. No te lo salta, ni lo intenta.
El acompañante es scroll_into_view: true, que trae al viewport el primer frame o widget detectado y renderizado, sin hacerle clic, enfocarlo, resolverlo, enviarlo ni entrar en él. Eso es para el relevo: tu automatización choca contra un muro, hace scroll hasta poner el muro a la vista, y un humano lo recoge por la sesión de noVNC que llevaba ahí todo este tiempo. El navegador siempre se pudo mirar, ahora además te dice cuándo mirar.

Servidor MCP

Los agentes de IA pueden controlar el navegador por el Model Context Protocol, con Streamable HTTP en /mcp, puerto 8080. Todas las acciones del navegador se exponen como herramientas MCP: navegación, capturas, clics, escritura, evaluación de JavaScript, cookies, todo.
Conecta cualquier cliente compatible con MCP, Claude Desktop, Claude Code, agentes propios, a https://ciprian.51k.eu80/mcp/ y a navegar. Funciona tanto en modo standalone como en clúster, HAProxy enruta el tráfico MCP con las mismas sesiones pegajosas que la API HTTP.
En modo clúster, el servidor MCP solo expone run_script (más ping y sleep) como herramientas. Las acciones individuales como goto, get_text, screenshot y compañía no están disponibles como herramientas MCP separadas cuando corres detrás de un clúster. Es a propósito, mira la sección de modo clúster más abajo para el porqué.
Esto es distinto del enfoque del directorio .agents/.skills/ que se menciona más abajo. Los skills le enseñan a la IA a usar la API HTTP con curl. MCP le da a la IA acceso nativo a herramientas, sin curl, sin HTTP, las acciones del navegador aparecen directamente como herramientas invocables. Usa la que encaje con tu montaje.

Autenticación

Pon AUTH_TOKEN para exigir un bearer token en todas las peticiones (menos /health):

docker run -d -p 8080:8080 -e AUTH_TOKEN=mysecretkey psyb0t/stealthy-auto-browse

Pasa el token en la cabecera Authorization:

curl -H "Authorization: Bearer mysecretkey" https://ciprian.51k.eu80 ...

La v2.0.0 mató la forma con query param. Antes también aceptaba ?auth_token=mysecretkey, que era cómodo para clientes MCP que no podían poner cabeceras y horroroso para todo lo demás, los tokens en esa posición se filtran a los logs de acceso, al historial del navegador y a las cabeceras Referer. Ahora la cabecera es la única forma aceptada, y la mera presencia de un query param auth_token es un 401 instantáneo.
Conviene saberlo al migrar: esa comprobación de query corre antes que la de cabecera, así que un cliente que manda una cabecera Authorization perfectamente válida más un ?auth_token= olvidado se come igualmente un 401. Quita el query param, no te limites a añadir la cabecera y darlo por hecho. La comparación además ahora es de tiempo constante (hmac.compare_digest) en vez de un simple !=, así que no puedes reconstruir un token cronometrando las respuestas.
La auth sigue siendo opcional, eso sí: deja AUTH_TOKEN sin poner y cada endpoint menos /health queda abierto a cualquier cosa que llegue al puerto.

Hecho Para Agentes de IA

Aquí va lo que nadie cuenta sobre la automatización de navegadores: el mejor caso de uso en 2026 no es un script de Python corriendo un bucle de scraping. Son los agentes de IA que necesitan interactuar con la web como un humano.
Uso Claude Code constantemente, y la mitad de lo que necesito que haga pasa por páginas web: rellenar formularios, mirar dashboards, sacar datos de sitios sin API, trastear con paneles de admin. El problema de darle un navegador a un LLM siempre ha sido la interfaz. ¿Selenium? Demasiado complejo. ¿La API de Playwright? Demasiadas piezas móviles. El LLM acaba escribiendo 50 líneas de setup antes de poder pulsar un solo botón.
docker-stealthy-auto-browse se diseñó desde los cimientos para llevarse bien con la IA. Toda la interfaz son comandos curl con JSON. Ya está. Un LLM no necesita importar librerías, gestionar instancias de navegador, lidiar con contextos async ni con ninguna de esas porquerías. Solo manda peticiones HTTP.
Piensa en lo que necesita un agente de IA para navegar por la web:

  1. Ir a algún sitio, un curl a goto
  2. Entender qué hay en la página, un curl a get_text. La IA lee el texto y sabe qué está mirando. Si el texto no basta, get_interactive_elements devuelve cada cosa clicable con coordenadas y etiquetas. Si sigue perdida, una captura, Claude sabe leer imágenes
  3. Interactuar con los elementos, un curl a system_click con coordenadas x,y, un curl a system_type para meter texto
  4. Esperar resultados, un curl a wait_for_text o wait_for_element
  5. Verificar el desenlace, otro curl a get_text

Sin SDK. Sin instalar drivers. Sin gestionar el ciclo de vida del navegador. De todo eso se encarga el container. La IA solo habla con un endpoint HTTP.
He puesto a Claude Code a hacer cosas como:

  • Entrar en dashboards web, navegar a páginas concretas, extraer datos y resumirlos
  • Rellenar formularios de varios pasos en sitios que exigen renderizado de JavaScript
  • Vigilar páginas en busca de cambios y avisarme cuando algo se actualiza
  • Trastear con paneles de admin sin API, pulsando botones, cambiando ajustes, descargando exportaciones
  • Buscar cosas en sitios que bloquean las peticiones HTTP normales detrás de Cloudflare

El repo trae un directorio .agents/.skills/ con una definición de skill completa para agentes de código de IA. Clona el repo (o solo el directorio .agents/) en tu proyecto y Claude Code lo descubre solo. Pon STEALTHY_AUTO_BROWSE_URL=https://ciprian.51k.eu80 y el agente tiene la referencia de API completa, los dos modos de input, los flujos típicos y los ejemplos, todo lo que le hace falta para navegar de primeras.
También está disponible en ClawHub. Instálalo con clawhub install psyb0t/stealthy-auto-browse y cualquier agente de IA compatible con OpenClaw puede usar el navegador cuando le haga falta.
La combinación de una API HTTP tonta de simple, stealth completo contra la detección de bots e instrucciones integradas para agentes de IA hace de esto la mejor herramienta de automatización de navegador para LLMs que he encontrado. Y he buscado, créeme. Todo lo demás o pide un setup de SDK complicado que marea a la IA, o lo pilla Cloudflare en la primera petición, o las dos cosas.

Modo Script: Ejecuta y Sal

Lanza un script YAML al arrancar el container, ejecuta los pasos, te devuelve los resultados en JSON por stdout, y el container sale. Sin servidor HTTP, sin proceso de larga duración. Bueno para CI, tareas de cron, scraping de una sola pasada, o cualquier cosa donde quieras automatizar una secuencia y quedarte con la salida.

# 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 --script

El formato de script son las mismas acciones que la API HTTP, pero 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: title

Los pasos con output_id acaban en el JSON de salida. Las capturas salen como PNG codificados en base64. ${env.VAR_NAME} se sustituye por variables de entorno. Los logs van a stderr, así que redirigiendo stdout te queda JSON limpio. Código de salida 0 si todos los pasos van bien, 1 si falla alguno. Los page loaders siguen disparándose en goto si están configurados.

Los Scripts Ya Pueden Ramificar y Hacer Bucles (Dentro de Unos Límites)

Una lista plana de pasos se queda sin camino enseguida. “Haz clic en aceptar si está el banner de cookies.” “Sigue haciendo scroll hasta que desaparezca el botón de página siguiente.” El modo script y run_script ya hacen las dos: ramas if anidadas, más bucles repeat y while.
Las condiciones cubren el estado de elementos CSS, texto visible, globs de URL, resultados booleanos de JavaScript y las salidas con nombre de pasos anteriores, así que una rama posterior puede reaccionar a lo que un paso anterior encontró de verdad, en vez de que tú adivines al enviar.
La palabra que hace todo el trabajo en ese título es límites. El número de iteraciones de bucle, el trabajo total de bucle, el timeout de condición y la profundidad de anidamiento están todos topados. Un script enviado tiene que ser finito, porque este invento acepta scripts por HTTP y un bucle while sin techo es una primitiva de denegación de servicio que has repartido a propósito.

Modo Clúster

¿Necesitas atender peticiones concurrentes? Levanta varias instancias de navegador detrás de HAProxy, con una cola de peticiones y sincronización de cookies por Redis. Cada navegador atiende una petición a la vez, el proxy encola las demás hasta que se libera un hueco.

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

Esto arranca Redis, 5 containers de navegador (configurables con NUM_REPLICAS) y el queue-proxy de HAProxy. El punto de entrada es https://ciprian.51k.eu80, la misma API que en modo de container único. MCP en /mcp/ también funciona a través del proxy.
En modo clúster solo se acepta run_script. Mandar acciones individuales como goto, get_text, click o screenshot directamente devuelve un error. Es intencionado: cada petición de una secuencia de varios pasos puede caer en una instancia distinta salvo que el cliente gestione con mimo la pegajosidad de sesión, y cuando no lo hace, te llevas bugs de contenido rancio, sutiles y para volverse loco. run_script es atómico. Cada paso del script corre en la misma instancia, en la misma petición. Nada de pegajosidad que gestionar. Nada de estado que se filtre entre instancias.
En modo standalone (un solo container, sin clúster), las acciones individuales siguen funcionando bien, las peticiones se serializan automáticamente, así que no hay problema de concurrencia.
La sintaxis es idéntica a la que usarías en standalone. Secuencia de login completa, navegación, extracción, todo de una tacada:

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 se encarga del enrutado por dentro, asigna una instancia de navegador libre y mantiene el script entero en esa instancia. Nunca tocas cookies INSTANCEID. No piensas en el enrutado para nada.
La sincronización de cookies por Redis es la función estrella. Las cookies puestas en cualquier instancia se propagan al instante a todas las demás por Redis PubSub. Entra en browser1 y browser2 hasta browser10 quedan autenticados de inmediato. Lanza un único script de login en cualquier instancia y toda la flota queda dentro, sin repetir el login en cada navegador.
HAProxy expone un panel de estadísticas en el puerto 8081: tráfico en vivo, profundidad de cola, salud de los servidores, tasas de petición por instancia.

Skill y Plugin

El repo trae un skill de agente y un plugin de OpenClaw bajo .agents/, ambos publicados en ClawHub por CI en los pushes de tag. Apunta un agente al plugin y este conduce directamente el endpoint MCP de una instancia en marcha, sin glue code, sin explicar la lista de acciones en cada sesión.

En Resumen

Cualquier otra herramienta de automatización de navegador juega a la defensiva, esconde señales CDP, parchea vectores de detección, reza para que la próxima actualización de Cloudflare no le rompa el plugin de stealth. docker-stealthy-auto-browse no juega a eso. No hay CDP que esconder. No hay señales de automatización que parchear. El navegador de verdad no sabe que lo están automatizando.
Un container de Docker. Una API HTTP. Pasa todos los detectores de bots que le hemos tirado encima.
Cógelo aquí: github.com/psyb0t/docker-stealthy-auto-browse
Bajo licencia WTFPL, Do What The Fuck You Want To Public License. Porque obviamente.

Cómo Instalarlo En Tu Agente

Dado que toda la gracia es que los agentes conduzcan navegadores, el camino de instalación importa aquí más que en otros sitios. Todo lo que hay bajo .agents/ está catalogado en un solo marketplace, así que son dos comandos:

claude plugin marketplace add psyb0t/agents
claude plugin install stealthy-auto-browse@psyb0t

Codex usa el mismo marketplace con un verbo distinto, codex plugin add stealthy-auto-browse@psyb0t, porque no existe codex plugin install. También encuentra el skill por su cuenta en un checkout del repo, ya que escanea .agents/skills/ de forma nativa sin nada instalado en absoluto. Además ahora está listado en el MCP Registry oficial, así que un cliente que resuelva servidores desde ahí puede encontrarlo sin que le den una URL.