docker-stealthy-auto-browse: De Browser Die Niet Weet Dat Hij Geautomatiseerd Wordt

Ik automatiseer al jaren browsers. Selenium, Puppeteer, Playwright, allemaal gebruikt, allemaal zien sneuvelen. De wapenwedloop tussen botdetectie en browserautomatisering loopt al sinds de begindagen van scraping, en raad eens wie er verliest? Werkelijk elke op Chromium gebaseerde automatiseringstool op deze verdomde planeet.
Het probleem zijn de tools niet. Playwright is goede software. Puppeteer werkt prima. Het probleem is Chrome DevTools Protocol, het mechanisme waarmee ze allemaal met de browser praten. CDP is hoe jouw automatiseringsframework zegt “klik op deze knop” of “typ in dit veld”. Het is ook hoe Cloudflare, DataDome, PerimeterX en elke andere botdetectiedienst op aarde weten dat je geen mens bent. Je kunt stealth-plugins installeren, navigator.webdriver patchen, vingerafdrukken vervalsen tot je ogen bloeden, CDP zit er nog steeds, en ze vinden het.
Dus heb ik docker-stealthy-auto-browse gebouwd. Even duidelijk zijn, ik heb niets van die stealth-shit zelf bedacht. Het zware werk doen Camoufox, Playwright, PyAutoGUI en browserforge. Dat zijn briljante projecten, gebouwd door mensen die slimmer zijn dan ik. Wat ik heb gedaan: al die shit gepakt, in een Docker-container aan elkaar gesoldeerd en er een HTTP-API bovenop geplakt, zodat je het hele ding op afstand bestuurt met curl-commando’s. Eén container, één endpoint, nul gedoe bij het opzetten.

Het Kernprobleem van Alle Andere Aanpakken

Dit doet elke op Chromium gebaseerde automatiseringstool: hij opent Chrome, verbindt via CDP en stuurt commando’s door dat protocol. De browser weet het. Het JavaScript dat op de pagina draait weet het. Het script van de botdetectiedienst, dat vóór jouw pagina-inhoud laadde? Dat weet het gegarandeerd.
Je kunt proberen het te verbergen:

  • navigator.webdriver patchen zodat hij false teruggeeft, de detectoren controleren of er gepatcht is
  • Stealth-plugins installeren, de detectoren zoeken naar de neveneffecten van die plugins
  • Vingerafdrukken vervalsen, de detectoren vergelijken de vingerafdruk van de hoofdcontext met die van de web workers en vinden tegenstrijdigheden
  • Headless draaien, de detectoren zoeken naar headless-signalen

Het is een kat-en-muisspel waarin de kat alle voordelen heeft. CDP laat overal sporen achter, in de JavaScript-runtime, in hoe events worden afgevuurd, in timingpatronen, in de interne staat van de browser. Je probeert vol te houden dat een marionet geen marionet is terwijl de touwtjes duidelijk zichtbaar hangen.

De Aanpak: Helemaal Geen Touwtjes

docker-stealthy-auto-browse verbergt geen automatiseringssignalen. Het haalt ze volledig weg.
Camoufox in plaats van Chromium. Een op maat gemaakte Firefox-fork. Er is geen Chrome DevTools Protocol, want Firefox gebruikt het niet. Botdetectoren die naar CDP-signalen zoeken vinden niets, niet omdat wij ze verstopt hebben, maar omdat ze niet bestaan. navigator.webdriver is false, niet gepatcht om false terug te geven, maar echt onwaar, omdat Camoufox hem niet eens zet.
Playwright voor de browserbesturing. Die doet het DOM-niveau, navigatie, elementselectie, pagina-inspectie. De handige maar detecteerbare invoermodus loopt via Playwright. Samen met Camoufox lekt hij niet de gebruikelijke CDP-automatiseringssignalen die Chromium-opstellingen wel lekken.
PyAutoGUI in plaats van DOM-events. Als je stealth nodig hebt beweegt de muis fysiek over het virtuele scherm, met menselijke curves, willekeurige jitter en soepele acceleratie. Als je typt worden er echte toetsaanslagen op OS-niveau gegenereerd, met willekeurige vertragingen tussen de tekens. De browser krijgt ze binnen als echte gebruikersinvoer. Geen enkele JavaScript ter wereld kan PyAutoGUI-invoer onderscheiden van een echt mens achter een toetsenbord.
Echte vingerafdrukken via browserforge. De vingerafdruk wordt één keer gegenereerd en consistent toegepast op de hoofdcontext en de web workers. Geen vervalsing betekent geen tegenstrijdigheden, een veelgebruikte detectievector die de meeste vingerafdrukvervalsers pakt.
Xvfb voor een echt scherm. De browser draait met een volledig grafisch scherm in de container, via een virtuele framebuffer. Geen headless-modus, geen headless-signalen. Wat de browser en elk detectiescript betreft draait dit op een normale desktop.
Mijn bijdrage is de lijm: een HTTP-API in Python die dit allemaal aan elkaar knoopt, de Docker-container die alles in één docker run propt, het page loader-systeem voor automatisering die door een URL wordt getriggerd, de abstractie van de twee invoermodi (system en playwright) en de noVNC-integratie om live mee te kijken. De stealth-techniek is het genie van anderen. De verpakking en de API zijn van mij.

Hoe Het Werkt

Je start de container en hij biedt een HTTP-API aan op poort 8080. Je stuurt JSON-commando’s, je krijgt JSON-antwoorden. Dat is de hele interface.

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

Poort 8080 is de API. Poort 5900 is een noVNC-viewer zodat je de browser in realtime kunt bekijken vanuit je eigen browser: je opent http://localhost:5900/ en ziet precies wat de geautomatiseerde browser ziet.
Ga ergens heen:

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

Sinds v2.6.0 nemen goto (en refresh en new_tab) optionele navigatieknoppen per aanroep: timeout in seconden (standaard 30), retry_count voor begrensde herkansingen als een laadactie verloopt (standaard 1) en retry_delay tussen die herkansingen (standaard 1s). Dezelfde drie knoppen, of je het nu over HTTP aanroept, via de MCP-tool, of als stap binnen een run_script.
Lees de pagina:

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

Vind alles wat klikbaar is op de pagina:

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

Dat geeft elke knop, link en invoerveld terug, met hun viewport-coördinaten, de tekst en de CSS-selectors. Klik er nu één aan met een echte muisbeweging:

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

Typ met echte toetsaanslagen:

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

Maak een screenshot:

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

Draai scripts met meerdere stappen in één verzoek met run_script, zonder per actie een curl te sturen:

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

Accepteert ook "yaml": "...", met hetzelfde formaat als in scriptmodus. In de modus met één instantie worden verzoeken automatisch geserialiseerd, stuur meerdere scripts parallel en ze gaan in de rij staan in plaats van elkaar omver te lopen.

Twee Invoermodi, En Dat Maakt Uit

De container geeft je twee manieren om met pagina’s om te gaan, en de juiste kiezen is het verschil tussen erdoorheen komen en geblokkeerd worden.

Systeeminvoer: Niet Te Detecteren

system_click, mouse_move, system_type, send_key, scroll, die gebruiken allemaal PyAutoGUI om echte events op OS-niveau te genereren. De muis beweegt in menselijke curves. Toetsaanslagen hebben willekeurige timing. De browser heeft geen enkele manier om te weten dat ze niet van een echt persoon komen.
Je werkt met viewport-coördinaten, die haal je uit get_interactive_elements.

Playwright-invoer: Detecteerbaar Maar Handig

click, fill, type, die gebruiken Playwrights DOM-automatisering met CSS-selectors of XPath. Sneller, makkelijker, geen gereken met coördinaten. Maar de patronen waarmee events worden geïnjecteerd zijn theoretisch te detecteren door serieuze gedragsanalyse.
De regel is simpel: heeft de site botdetectie? Systeeminvoer. Altijd. Scrape je alleen iets dat niet terugvecht? Dan voldoet Playwright-invoer.

Een Echte Loginflow

Zo ziet een niet te detecteren login eruit, elke interactie gebruikt invoer op OS-niveau:

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

De site ziet een echt mens die op natuurlijke snelheid typt met willekeurige vertragingen. Geen CDP-signalen. Geen automatiseringsvingerafdrukken. Niets.

Page Loaders: Automatisering op de Automatische Piloot

Page loaders zijn net Greasemonkey-userscripts, maar voor de HTTP-API. Je schrijft een YAML-bestand dat zegt “elke keer dat de browser dit domein bezoekt, voer dan deze stappen automatisch uit”. Je mount ze in de container en je vergeet ze.

# 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

Nu wacht elke goto naar news-site.com automatisch op de inhoud, sloopt de cookiepopup, sloopt de nieuwsbriefmodal en scrollt om lui geladen afbeeldingen te triggeren. Geen 5 commando’s meer na elke navigatie.

De Volledige API

De HTTP-API dekt alles wat je nodig zou hebben:

  • Navigatie: goto, refresh, met instelbare wachtcondities
  • Systeeminvoer: system_click, mouse_move, system_type, send_key, scroll, allemaal op OS-niveau, allemaal niet te detecteren
  • Pagina-inspectie: get_interactive_elements, get_text, get_html, eval
  • Wachtcondities: wait_for_element, wait_for_text, wait_for_url, wait_for_network_idle, want sleep is voor amateurs
  • Tabbeheer: list_tabs, new_tab, switch_tab, close_tab
  • Cookies en opslag: volledige CRUD voor cookies, localStorage, sessionStorage
  • Downloads en uploads: bestandsdownloads en programmatische bestandsinvoer afhandelen
  • Netwerklogging: alle HTTP-verzoeken van de pagina opnemen, API-endpoints vinden, debuggen, verifiëren
  • Screenshots: de browser-viewport of het hele bureaublad, met formaatparameters
  • Dialoogafhandeling: automatisch accepteren of ingestelde antwoorden op alert, confirm en prompt
  • Schermopname: start_recording, stop_recording, recording_status, MP4 van de daadwerkelijk gerenderde pixels

Twee screenshot-endpoints geven je de browser-viewport (hoe de pagina eruitziet) of het hele virtuele bureaublad (inclusief de browserinterface). Beide ondersteunen formaatparameters, zodat je niet elke keer PNG’s van 1920×1080 binnenhaalt:

# 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

Virtuele Camera en Microfoon

Je zet een video- en/of audiobestand in een gemounte map /media en pagina’s krijgen camera- en microfoonsporen uit die bestanden, via navigator.mediaDevices.getUserMedia(). Paden worden bij het opstarten gevalideerd, symlinks opgelost, om binnen de ingestelde mediamap te blijven. Vraag om een soort die niet is ingesteld en het verzoek faalt, het valt niet stilletjes terug op een echt apparaat, wat precies het gedrag is dat je wilt als het hele punt is dat er geen echt apparaat is.
Statische bestanden waren de eerste versie. Zet VIRTUAL_MEDIA_DYNAMIC=true en je kunt de bron tijdens het draaien wisselen: set_virtual_media_source kiest een bestaand bestand van binnen, upload_virtual_media neemt een begrensde base64-payload. Beide behouden de identiteiten van de camera- en microfoonsporen die een pagina al heeft verkregen, dus een pagina kan veranderen wat hij ziet zonder getUserMedia() opnieuw aan te vragen en zonder te merken dat er iets is gebeurd.
Uploads zijn geen gat: botsingsveilig gegenereerde bestandsnamen in plaats van een benoemde bron overschrijven, strikte base64-decodering, een instelbaar plafond VIRTUAL_MEDIA_UPLOAD_MAX_BYTES (standaard 50 MiB) en een ffprobe-controle op de gevraagde stream voordat er iets wordt opgeslagen of geactiveerd. Bronselectie accepteert alleen gewone bestanden die in VIRTUAL_MEDIA_DIR zitten, geen externe URL’s, geen WebSocket-feeds, geen willekeurige hostpaden, geen andere levende ingang. get_virtual_media_state rapporteert de staat van de dynamische modus, de basisnaam van de actieve bron en een revisieteller, zonder bronpaden of geüploade bytes te lekken.

Scrapen Zonder JavaScript Te Schrijven

Vier acties die dekken wat je anders elke keer met de hand in een evaluate-aanroep zou schrijven: get_page_info, get_element, get_elements en get_computed_style. Pagina- en CSS-data rechtstreeks, geen eigen JS te schrijven, geen aanhalingstekennachtmerrie om het door JSON te krijgen. get_elements geeft nu standaard 20 resultaten, consistent, in de HTTP-API, in de MCP-documentatie en in de test-fixture, wat niet altijd zo was.

Schermopname

Screenshots vertellen je hoe een pagina eruitzag. Ze vertellen je niet wat er gebeurde. Dus de browser neemt zichzelf nu op: ffmpeg met x11grab op het Xvfb-scherm, dat MP4 schrijft naar een gemount volume /recordings. De daadwerkelijk gerenderde pixels, inclusief de muiscursor op OS-niveau die rondbeweegt, want in dit ding is de cursor echt.
Drie modi: window pakt het hele Camoufox-venster, viewport snijdt de browserinterface eraf met de gekalibreerde mozInnerScreenX/Y-offsets (geen hardgecodeerde gokken), en desktop pakt het hele Xvfb-scherm.

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

De slug geef je met opzet bij het stoppen mee, je doopt de opname als de run klaar is, wanneer je echt weet of het login-success of login-broke-again is. Slugs worden geschoond tegen pad-traversal en botsingen hernoemen zichzelf. Eén actieve opname per container. Crashbestendig: nette afsluiting op SIGINT plus een veegronde bij het opstarten naar verweesde tijdelijke bestanden.
show_cursor staat standaard op true, maar zet hem uit als je visuele regressieopnames maakt en cursorpixels de diff zouden vergiftigen. De descriptor in het antwoord geeft de vlag terug, zodat de aanroeper kan bevestigen wat hij heeft gekregen.
Werkt via de HTTP-API en via MCP. In clustermodus moeten starten en stoppen binnen dezelfde run_script-aanroep zitten zodat ze dezelfde instantie raken, anders vertel je de ene container een opname te stoppen die een andere container is begonnen.
Resolutie. De opname ging boven 1920×1080 kapot, omdat het entrypoint Xvfb op dat formaat startte en xrandr de root-framebuffer achteraf niet kan vergroten, dus ffmpeg nam vrolijk buiten het echte scherm op. De framebuffer wordt nu vooraf op XVFB_RESOLUTION aangemaakt en het naderhand schalen is weg. Vierkante en hoge resoluties werken, het plafond van 1920×1080 bestaat niet meer.

Als Camoufox Doodgaat, Komt Hij Terug

Een gebruiker liep tegen “Connection closed while reading from the driver” aan na een handvol n8n-runs tegen Facebook. De oorzaak was lelijk: de browser hield een dood Page-object in cache zodra Camoufox zelf was overleden, dus elk volgend verzoek probeerde met een lijk te praten.
Nu is er een echte health check. is_healthy() doet een roundtrip naar de driver in plaats van de gecachete staat te vertrouwen, en ensure_healthy() herstart de persistente context als die roundtrip faalt, het profiel overleeft, dus cookies en vingerafdruk gaan mee. Zowel de interne page-getter als de accessor voor de actieve pagina genezen eerst en gebruiken daarna. Het verzoek dat het herstel uitlokt kost ongeveer 4-5 seconden, alles daarna draait op volle snelheid.
Elk herstel dumpt ook een postmortem op WARNING-niveau: de OOM-regels uit dmesg, meminfo, loadavg en de lijst met overlevende camoufox-bin-processen. Dus als het sneuvelt krijg je de echte oorzaak in het JSON-log in plaats van een mysterieuze containerstop.

De Stealth-instellingen Die Er Echt Toe Doen

Een paar omgevingsvariabelen die daadwerkelijk bepalen of je gepakt wordt:
Tijdzone laten kloppen. Botdetectoren vergelijken de tijdzone van je browser met de geolocatie van je IP. Als je IP Roemenië zegt en je tijdzone UTC, is dat een rode vlag. Zet TZ=Europe/Bucharest (of wat bij je IP past) en die vector verdwijnt.
Proxyondersteuning. Stuur al het verkeer via een willekeurige uitgang met PROXY_URL, of http://user:pass@host:port, of socks5://host:port, wat je maar hebt. Gecombineerd met de kloppende tijdzone lijk je een echte gebruiker vanaf de locatie van die uitgang, en daarom zegt de documentatie botweg dat het een geautoriseerde uitgang moet zijn waarvan de locatie past bij de vingerafdruk die je test. De repo documenteert nu ook een opstelling waarin de uitgang van jezelf is in plaats van gehuurd: een wegwerp-WireGuardcel van pr0xteus. Die handleiding is herschreven voor pr0xteus v0.11.0, dat per lease twee URL’s teruggeeft in plaats van één, en de browser pakt nu de HTTP-variant: jq -er '.proxies.http'. Dat is geen stijlvoorkeur. Firefox doet geauthenticeerde SOCKS5 niet betrouwbaar, dus Camoufox naar de SOCKS-URL wijzen is precies hoe je een proxy krijgt die werkt tot het moment dat hij inloggegevens nodig heeft. De rest volgt daaruit: je bereikt de controller via --network host in plaats van een egress-netwerk binnen te gaan, je houdt de control-API op 127.0.0.1:8000 en de HTTP-proxy op 127.0.0.1:8080, en je verhuist de API van de browser naar 8090 met HTTP_LISTEN_PORT=8090 zodat die twee niet om 8080 vechten. Niets in deze opstelling is van buiten de host bereikbaar. Volledige handleiding in docs/configuration.md.
Persistente profielen. Mount een map op /userdata en je cookies, localStorage, sessies en vingerafdruk overleven herstarts van de container. Zonder dat is elke herstart een frisse identiteit, wat soms precies is wat je wilt en soms verdacht als de pest.

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

Voorgeïnstalleerde Extensies

Elke container komt met privacy-extensies die al zijn ingesteld:

  • uBlock Origin, blokkeert advertenties, trackers en irritaties. Minder ruis, minder trackingscripts die draaien
  • LocalCDN, onderschept CDN-verzoeken en serveert de bestanden lokaal. Google en Cloudflare kunnen je niet meer van site naar site volgen
  • ClearURLs, haalt trackingparameters (utm_source, fbclid, gclid) uit URL’s
  • Consent-O-Matic, weigert cookie-consentpopups automatisch zodat jij je niet met die shit hoeft bezig te houden

Wil je meer? Mount een persistent profiel, open VNC, ga naar about:addons en installeer wat je wilt. Ze overleven herstarts.

Resultaten van de Botdetectietests

Getest tegen alles wat ertoe doet en overal doorheen:

  • CreepJS, consistentie van canvas- en WebGL-vingerafdruk, leugendetectie, vergelijking met workers: geslaagd
  • BrowserScan, WebDriver-vlag, CDP-signalen, navigator-eigenschappen: geslaagd
  • Pixelscan, coherentie van de vingerafdruk, tijdzone die bij het IP past, WebRTC-lekken: geslaagd
  • Cloudflare, challengepagina’s, Turnstile, bot management: geslaagd
  • SannySoft, Intoli-tests plus vingerafdrukscanner: geslaagd
  • Incolumitas, moderne detectietechnieken: geslaagd
  • Rebrowser, detectie van CDP-lekken, webdriver, viewportanalyse: geslaagd
  • BrowserLeaks WebRTC, detectie van IP-lekken via WebRTC: geslaagd
  • DeviceAndBrowserInfo, 19 controles, allemaal groen, “You are human!”: geslaagd
  • IpHey, beoordeling “Trustworthy”: geslaagd
  • Fingerprint.com, herkend als gewone Firefox, geen botvlaggen: geslaagd

Het slaagt omdat er niets te detecteren valt. Geen CDP om te vinden, want Firefox heeft het niet. Geen vervalste vingerafdrukken, want de vingerafdruk is echt en consistent. Geen automatiseringsvlaggen, want navigator.webdriver is oprecht onwaar. Geen nep-invoerevents, want PyAutoGUI genereert echte op OS-niveau.

Het Zegt Dat Er een CAPTCHA Is, En Verder Niets

detect_challenge rapporteert begrensd en tot het minimum aan gegevens beperkt bewijs voor gedocumenteerde integraties van Turnstile, reCAPTCHA, hCaptcha, Friendly Captcha, ALTCHA, Arkose, AWS WAF en GeeTest, plus voorzichtige aanwijzingen voor zichtbare generieke. Beschikbaar via HTTP, in scriptmodus, in run_script en via MCP.
Lees de werkwoorden zorgvuldig, want de weglatingen zijn het ontwerp: het klikt nooit, lost nooit op, gaat nooit het challenge-frame in en toont nooit query strings, sitesleutels of antwoordtokens. Bij Arkose-bronbewijs worden padsegmenten die sleutels dragen geredigeerd voordat enig API-, script- of MCP-antwoord ze kan teruggeven. Dit vertelt je dat een challenge er is. Het brengt je er niet langs, en probeert dat ook niet.
De metgezel is scroll_into_view: true, dat het eerste gerenderde gedetecteerde frame of widget in de viewport brengt, zonder erop te klikken, hem te focussen, op te lossen, te versturen of binnen te gaan. Dat is voor de overdracht: jouw automatisering loopt tegen een muur, scrollt de muur in beeld, en een mens neemt het over via de noVNC-sessie die er al die tijd al was. De browser was altijd te bekijken, nu kan hij je ook vertellen wanneer je moet kijken.

MCP-server

AI-agents kunnen de browser besturen via het Model Context Protocol, met Streamable HTTP op /mcp, poort 8080. Alle browseracties zijn beschikbaar als MCP-tools: navigatie, screenshots, klikken, typen, JavaScript evalueren, cookies, alles.
Hang er een willekeurige MCP-compatibele client aan, Claude Desktop, Claude Code, eigen agents, op https://ciprian.51k.eu80/mcp/ en beginnen maar. Werkt in zowel standalone- als clustermodus, HAProxy routeert MCP-verkeer met dezelfde sticky sessions als de HTTP-API.
In clustermodus biedt de MCP-server alleen run_script (plus ping en sleep) als tools aan. Losse acties als goto, get_text, screenshot enzovoort zijn achter een cluster niet als aparte MCP-tools beschikbaar. Dat is met opzet, zie de sectie over clustermodus hieronder voor het waarom.
Dit staat los van de aanpak met de map .agents/.skills/ die hieronder wordt genoemd. Skills leren de AI hoe hij de HTTP-API met curl gebruikt. MCP geeft de AI native toolgang, geen curl, geen HTTP, de browseracties verschijnen direct als aanroepbare tools. Gebruik wat bij je opstelling past.

Authenticatie

Zet AUTH_TOKEN om bij alle verzoeken een bearer token te eisen (behalve /health):

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

Geef het token mee in de Authorization-header:

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

v2.0.0 heeft de query-paramvorm gesloopt. Vroeger accepteerde dit ook ?auth_token=mysecretkey, wat handig was voor MCP-clients die geen headers konden zetten en afschuwelijk voor al het andere, tokens op die plek lekken naar toegangslogs, naar browsergeschiedenis en naar Referer-headers. De header is nu de enige geaccepteerde vorm, en alleen al de aanwezigheid van een auth_token-queryparameter is meteen een 401.
Goed om te weten bij het migreren: die querycontrole draait vóór de headercontrole, dus een client die een volkomen correcte Authorization-header stuurt plus een vergeten ?auth_token= vangt alsnog een 401. Haal de queryparameter weg, plak niet alleen de header erbij en denk dat je klaar bent. De vergelijking gaat nu ook in constante tijd (hmac.compare_digest) in plaats van een simpele !=, dus je kunt een token niet stuk voor stuk afleiden door de antwoorden te klokken.
De auth blijft wel optioneel: laat AUTH_TOKEN ongezet en elk endpoint behalve /health staat open voor alles wat de poort bereikt.

Gebouwd Voor AI-agents

Dit is het stuk waar niemand het over heeft bij browserautomatisering: de beste toepassing in 2026 is geen Python-script dat een scrapinglus draait. Het zijn AI-agents die met het web moeten omgaan als een mens.
Ik gebruik Claude Code voortdurend, en de helft van wat ik ervan nodig heb loopt via webpagina’s: formulieren invullen, dashboards checken, data plukken van sites zonder API, in adminpanelen rommelen. Het probleem van een LLM een browser geven was altijd de interface. Selenium? Te ingewikkeld. De API van Playwright? Te veel bewegende delen. De LLM eindigt met 50 regels setup voordat hij één knop kan indrukken.
docker-stealthy-auto-browse is van de grond af ontworpen om AI-vriendelijk te zijn. De hele interface bestaat uit curl-commando’s met JSON. Meer niet. Een LLM hoeft geen bibliotheken te importeren, geen browserinstanties te beheren, niet met async-contexten te jongleren of met dat soort rotzooi om te gaan. Hij stuurt gewoon HTTP-verzoeken.
Denk na over wat een AI-agent nodig heeft om het web op te gaan:

  1. Ergens heen navigeren, één curl naar goto
  2. Begrijpen wat er op de pagina staat, één curl naar get_text. De AI leest de tekst en weet waar hij naar kijkt. Is tekst niet genoeg, dan geeft get_interactive_elements elk klikbaar ding terug met coördinaten en labels. Is hij nog steeds in de war, dan een screenshot, Claude kan afbeeldingen lezen
  3. Met elementen omgaan, één curl naar system_click met x,y-coördinaten, één curl naar system_type voor tekstinvoer
  4. Op resultaten wachten, één curl naar wait_for_text of wait_for_element
  5. De uitkomst verifiëren, nog een curl naar get_text

Geen SDK. Geen driver installeren. Geen browserlevenscyclus beheren. De container regelt dat allemaal. De AI praat gewoon met een HTTP-endpoint.
Ik heb Claude Code dingen laten doen als:

  • Inloggen op webdashboards, naar specifieke pagina’s navigeren, data eruit halen en samenvatten
  • Formulieren met meerdere stappen invullen op sites die JavaScript-rendering eisen
  • Pagina’s in de gaten houden op wijzigingen en me waarschuwen als er iets verandert
  • In adminpanelen zonder API rommelen, knoppen indrukken, instellingen wijzigen, exports downloaden
  • Dingen opzoeken op sites die gewone HTTP-verzoeken achter Cloudflare blokkeren

De repo levert een map .agents/.skills/ met een volledige skill-definitie voor AI-codeeragents. Kloon de repo (of alleen de map .agents/) in je project en Claude Code ontdekt hem vanzelf. Zet STEALTHY_AUTO_BROWSE_URL=https://ciprian.51k.eu80 en de agent heeft de volledige API-referentie, beide invoermodi, de typische flows en de voorbeelden, alles wat hij nodig heeft om meteen het web op te gaan.
Het staat ook op ClawHub. Installeer het met clawhub install psyb0t/stealthy-auto-browse en elke OpenClaw-compatibele AI-agent kan de browser op afroep gebruiken.
De combinatie van een idioot simpele HTTP-API, volledige stealth tegen botdetectie en ingebouwde instructies voor AI-agents maakt dit de beste browserautomatiseringstool voor LLM’s die ik heb gevonden. En ik heb gezocht, geloof me. Al het andere vraagt ofwel een ingewikkelde SDK-opzet die de AI in de war schopt, of wordt bij het eerste verzoek door Cloudflare gepakt, of allebei.

Scriptmodus: Draaien en Weg

Draai bij het opstarten van de container een YAML-script, het voert de stappen uit, geeft je de resultaten als JSON op stdout, en de container stopt. Geen HTTP-server, geen langlopend proces. Goed voor CI, cronjobs, scrapen in één klap, of alles waarbij je een reeks wilt automatiseren en de uitvoer wilt hebben.

# 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

Het scriptformaat zijn dezelfde acties als de HTTP-API, maar in 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

Stappen met een output_id belanden in de uitvoer-JSON. Screenshots komen eruit als base64-gecodeerde PNG’s. ${env.VAR_NAME} wordt vervangen door omgevingsvariabelen. Logs gaan naar stderr, dus stdout omleiden levert schone JSON op. Exitcode 0 als alle stappen slagen, 1 als er eentje faalt. Page loaders gaan nog steeds af bij goto als ze zijn ingesteld.

Scripts Kunnen Nu Vertakken en Loopen (Binnen Grenzen)

Een platte lijst stappen is snel door zijn weg heen. “Klik accepteren als de cookiebanner er staat.” “Blijf scrollen tot de volgende-pagina-knop verdwijnt.” Scriptmodus en run_script kunnen nu allebei: geneste if-vertakkingen plus repeat– en while-lussen.
De condities dekken de staat van CSS-elementen, zichtbare tekst, URL-globs, booleaanse JavaScript-resultaten en de benoemde uitvoer van eerdere stappen, dus een latere tak kan reageren op wat een eerdere stap echt heeft gevonden, in plaats van dat jij bij het insturen zit te gokken.
Het woord dat in die kop het werk doet is grenzen. Het aantal lusiteraties, het totale luswerk, de conditie-timeout en de nestdiepte zijn allemaal gemaximeerd. Een ingestuurd script moet eindig zijn, want dit ding accepteert scripts over HTTP en een while-lus zonder plafond is een denial-of-serviceprimitief dat je met opzet hebt uitgedeeld.

Clustermodus

Moet je gelijktijdige verzoeken aan? Draai meerdere browserinstanties achter HAProxy, met een verzoekwachtrij en cookiesynchronisatie via Redis. Elke browser doet één verzoek tegelijk, de proxy zet de rest in de rij tot er een plek vrijkomt.

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

Dit start Redis, 5 browsercontainers (in te stellen met NUM_REPLICAS) en de queue-proxy van HAProxy. Het toegangspunt is https://ciprian.51k.eu80, dezelfde API als in de modus met één container. MCP op /mcp/ werkt ook door de proxy heen.
In clustermodus wordt alleen run_script geaccepteerd. Losse acties als goto, get_text, click of screenshot rechtstreeks sturen levert een fout op. Dat is opzettelijk: elk verzoek in een reeks van meerdere stappen kan op een andere browserinstantie landen tenzij de client de sessiebinding zorgvuldig beheert, en als hij dat niet doet krijg je bugs met verouderde inhoud, subtiel en om gek van te worden. run_script is atomair. Elke stap in het script draait op dezelfde browserinstantie, in hetzelfde verzoek. Geen binding te beheren. Geen staat die tussen instanties doorlekt.
In standalone-modus (één container, geen cluster) werken losse acties nog prima, verzoeken worden automatisch geserialiseerd, dus er is geen gelijktijdigheidsprobleem.
De syntaxis is identiek aan wat je in standalone zou gebruiken. Volledige loginreeks, navigatie, extractie, allemaal in één keer:

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 regelt het routeren intern, hij wijst een vrije browserinstantie toe en houdt het hele script op die instantie. Je raakt nooit INSTANCEID-cookies aan. Je denkt helemaal niet aan routeren.
Cookiesynchronisatie via Redis is de killerfeature. Cookies die op welke instantie dan ook worden gezet verspreiden zich via Redis PubSub onmiddellijk naar alle andere. Log in op browser1, en browser2 tot en met browser10 zijn meteen geauthenticeerd. Draai één loginscript op een willekeurige instantie en de hele vloot is ingelogd, zonder het inloggen op elke browser te herhalen.
HAProxy biedt een statistiekendashboard aan op poort 8081: live verkeer, wachtrijdiepte, servergezondheid, verzoeksnelheden per instantie.

Skill en Plugin

De repo levert een agent-skill en een OpenClaw-plugin onder .agents/, beide door CI gepubliceerd op ClawHub bij tag-pushes. Wijs een agent naar de plugin en hij bestuurt rechtstreeks het MCP-endpoint van een draaiende instantie, zonder glue code, zonder elke sessie de actielijst uit te leggen.

Kort Samengevat

Elke andere browserautomatiseringstool speelt verdedigend, verbergt CDP-signalen, patcht detectievectoren, hoopt dat de volgende Cloudflare-update hun stealth-plugin niet sloopt. docker-stealthy-auto-browse speelt dat spel niet. Er is geen CDP om te verbergen. Er zijn geen automatiseringssignalen om te patchen. De browser weet echt niet dat hij geautomatiseerd wordt.
Eén Docker-container. Eén HTTP-API. Hij komt door elke botdetector die we ertegenaan hebben gegooid.
Pak het hier: github.com/psyb0t/docker-stealthy-auto-browse
Onder WTFPL-licentie, Do What The Fuck You Want To Public License. Omdat het niet anders kan.

Zo Zet Je Het In Je Agent

Aangezien het hele punt agents zijn die browsers besturen, telt het installatiepad hier zwaarder dan elders. Alles onder .agents/ staat in één marketplace gecatalogiseerd, dus het zijn twee commando’s:

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

Codex gebruikt dezelfde marketplace met een ander werkwoord, codex plugin add stealthy-auto-browse@psyb0t, omdat codex plugin install niet bestaat. Hij vindt de skill ook uit zichzelf in een checkout van de repo, aangezien hij .agents/skills/ native scant zonder dat er iets geïnstalleerd is. Het staat nu ook in het officiële MCP Registry, dus een client die zijn servers daarvandaan oplost kan het vinden zonder dat je hem een URL geeft.