Measured against the tools scrapers use
What the agent and the API make of the open-source automation and evasion tools
scrapers actually run — each run as shipped and with the evasions its own
documentation recommends — and what they do not catch yet. Every row is a probe
in the repository (web/probes/<tool>.mjs) that drives the real tool against the
real agent and fails when the result changes, so this page is regenerated by
running them, not written from memory.
How it is measured. Chrome 155 (Firefox 156 for Camoufox, Chromium 150 for fingerprint-chromium) on Windows,
headless, against the served, obfuscated agent and the API in process. A
score is the identification's suspectScore (0–100); bot is
products.bot.result: "bad", which also refuses the browser's receipt at the
edge. Scores in the 50s and 60s are the challenge band; the edge refuses a
receipt from 70 (see first-party integration), and holds the
challenge band to a press-and-hold where ANTIBOT_ENFORCE_STEP_UP=hold. Beside every
tool, an honest Chrome, Firefox and WebKit are run with ordinary settings —
another language, dark mode, high contrast, zoom, a small window — and must stay
quiet; they do, at 7.
Results
One reading reaches most of the table at once: a headless driver that replaces its
User-Agent over the DevTools protocol — Puppeteer's setUserAgent, Playwright's context
userAgent, nodriver's and zendriver's patches, the stealth plugins' — replaces it per target, and
a SharedWorker is a target of its own that keeps saying HeadlessChrome
(automation:sharedHeadless). The rows below name what else gives each tool away.
| Tool | As shipped | With its evasions | What gives it away |
|---|---|---|---|
| Puppeteer + puppeteer-extra-plugin-stealth | bot | bot, anti-detect (≥ 70) | a decoy brand from before Chrome 103, a macOS WebGL driver on Windows, platformVersion written by hand |
| Playwright / patchright 1.63 | bot (HeadlessChrome) |
bot (100) | client hints computed from the User-Agent string (platformVersion: "10.0") |
| rebrowser-puppeteer 25 | bot (52) | bot (100, with stealth) | as above |
| undetected-chromedriver 3.5 | bot (52) | bot (100) | --user-agent on the command line blanks every high-entropy hint |
| selenium-driverless 1.9 | bot (100) | bot (100) | as above |
| nodriver 0.50 / zendriver 0.17 | bot | bot (100) | its User-Agent override leaves the hints stripped |
| SeleniumBase 4.56 (UC Mode, CDP Mode) | bot (100) | bot (100) | as undetected-chromedriver |
| Botasaurus 4.0 | bot (100) | bot (99–100, random User-Agent and window) | hints stripped; a random User-Agent's version against the engine |
Pydoll 3.0 (apply_fingerprint) |
bot (52) | bot (100; 99 with its documented profile) | a full version list written from the reduced string (155.0.0.0); a patched native's frame on the stack; WebGPU naming another GPU than WebGL |
| browser-use 0.13 (an AI agent framework; its click and typing handlers, dispatched as its tools dispatch them) | bot (52 as the page loads; 100 once it acts) | bot (99 with Chrome's own User-Agent; 100 once it acts) | HeadlessChrome, or the User-Agent override's stripped hints; its clicks on the exact centre of each element and its keys held five milliseconds — read on any identity |
Stagehand 4.2 (Browserbase's AI agent SDK, driving Chrome through an extension of its own over chrome.debugger; its act() given the actions a model would choose, no model called) |
bot (60 as the page loads) | bot (100 with Chrome's own User-Agent in its arguments); 13 behind a clean identity — what its launcher's default, a window on a desktop, shows — and 53, a challenge, once it fills a form | HeadlessChrome; a --user-agent with every high-entropy hint left blank; its fill — Backspace in the field, then the whole text in one insertion with no key that writes (textInsertedWhole), read on any desktop identity |
A computer-use agent's input (a model shown screenshots, acting through xdotool mousemove, click and type, as Anthropic's reference computer use runs them), its events dispatched to Chrome as the system delivers them |
— | 7 behind a clean identity; 57, a challenge, after its sign-in; the session pulse reads the same after identification; held to the edge's step-up, its hold — the pointer set down on the button and held still — not counted (jumpedToTheButton) |
every click reached in one jump of the pointer, on the centre or not (jumpedClicks, jumped_clicks); its keys released as they are pressed, xdotool's way (machineDwell) |
| Crawlee fingerprint-suite (3 drawn fingerprints) | — | bot (99) | the page's client hints written back over an override that emptied the Worker's; a toString proxy that reads its receiver |
| playwright-stealth 2.0 (Python) | bot (52) | bot (100) | Playwright's hints, and its patches' frames and receiver reads |
| DrissionPage 4.1 (a Python driver over the DevTools protocol, no WebDriver) | bot (60) | bot (100, with its own User-Agent setting carrying Chrome's) | HeadlessChrome; a --user-agent with every high-entropy hint left blank |
| puppeteer-real-browser 1.4 (rebrowser's patches and ghost-cursor, made for Cloudflare) | bot (60) | bot (60, after its humanised click) | HeadlessChrome, and navigator.webdriver in the page and its frames. Its README's page.realClick throws in 1.4.4 (ghost-cursor's click, unbound) — the click was made through page.realCursor |
Scrapling 0.5 (its browser fetcher, StealthyFetcher: Patchright with browserforge's drawn fingerprints) |
bot (100) | bot (100, with the options its README names for anti-bot sites: solve_cloudflare, hide_canvas, block_webrtc, real_chrome) |
HeadlessChrome in its SharedWorker; a platformVersion its drawn hints wrote by hand |
Crawl4AI 0.9.5 (an AI crawler: Playwright, enable_stealth with playwright-stealth, or its undetected adapter on Patchright) |
bot (99) | bot (99, either) | HeadlessChrome in its SharedWorker; a User-Agent naming an older Chrome than the engine; with stealth, the patches' frames and receiver reads |
| Playwright's own Firefox 132 and WebKit 18.2 (Playwright 1.49) | bot (52; WebKit 74, a Mac's Safari on Windows) | bot (Firefox 52 to 92, WebKit 74 to 82: navigator.webdriver hidden by a getter before the page runs, with a desktop User-Agent; that getter's toString made to say native and Playwright's binding deleted too; the browser's own getter behind a Proxy; in Firefox, the flag and the binding deleted outright). Firefox's own dom.webdriver.enabled preference does not turn the flag off — not under Playwright, and not in Mozilla's Firefox 159 under Puppeteer either. 7 and 29, bot not detected, before the getter was read |
navigator.webdriver; hidden, the getter that hid it (webdriverHidden: no browser's own answers a receiver that is no Navigator), or its absence where Chrome and Firefox always have it; the binding Playwright leaves on every page's window, by its exact name (playwrightBinding). In WebKit the flag and the binding both deleted are not read |
| Puppeteer 25 driving Mozilla's own Firefox 159 (its Firefox support, over WebDriver BiDi) | bot (48: navigator.webdriver) |
bot (48: the flag hidden by a getter, or deleted, before the page runs; Firefox's dom.webdriver.enabled preference hides nothing). 3, bot not detected, before the getter and the missing flag were read |
navigator.webdriver; hidden, the getter that hid it or its absence (webdriverHidden) |
| Puppeteer / Playwright device presets (iPhone, Pixel) on Chrome | bot (webdriver) |
challenged (79, iPhone); refused (94, Pixel — its preset's User-Agent is one no Chrome sends), automation flag removed | a phone's User-Agent over Win32; Safari's name over a V8 engine; a User-Agent no Chrome sends |
| Camoufox 0.5.8 (Firefox) | refused (79) or challenged (54), by the profile it draws | the same | its rewritten WebGL renderer beside the machine's own; and when it draws a Mac's or a Linux box's profile — 6 launches in 10, measured on Windows — a renderer drawn through Direct3D under that system's name (79). A Windows profile with a GPU of the machine's own class leaves the renderer strings agreeing — 2 launches in 8 scored 0 — and is challenged (54) by its voice list instead, emptied and refilled in sight of the page (speechVoicesReplaced): every launch caught, 10 in 10 |
| fingerprint-chromium 150 (ungoogled-chromium rewriting its fingerprint from a seed, driven over raw CDP) | never identifies: its renderer freezes on new RTCPeerConnection() (seed or not, a proxy set or not) and on reading back the agent's canvas and WebGL renders under a seed, so it never earns a receipt |
0 at identification once canvas spoofing is off and WebRTC deleted before the page runs; then challenged (50) by the session pulse with a Windows profile, refused (70, receipt revoked) with a Mac's | its GPU: WebGL rewritten (an Intel Iris Xe, an Apple M5) where WebGPU, asked after identification, names this machine's AMD (gpu_contradicts_webgpu, gpu_not_apple_silicon). The same build with no seed, and honest Chrome, Edge and Brave, stay quiet |
| Puppeteer / Playwright behind a clean identity (flag removed; Chrome's own User-Agent on the command line, so every scope says it; Chrome's own hints) | 7 until it acts | in the 40s after clicking, or typing with keyboard.type(…, { delay }) / pressSequentially, or filling a field with Playwright's fill() (a challenge beside anything else); bot after page.evaluate(() => fetch(…)) — at the next identification, or in a single-page app at the session's next beacon |
every click on the exact centre of the element; keys pressed back to back; a field's text arriving whole with no key pressed for it; the driver's evaluated code on the stack of a call into the page |
jsdom 26 and happy-dom 18 (the served agent run in an emulated DOM, as a token farm mints identifications without a browser: Node's fetch, WebCrypto and a Chrome User-Agent handed in) |
bot (runtimeStack; happy-dom also says navigator.webdriver) |
bot, with the stack disguised as a page's (Error.prepareStackTrace): the integrity key's program finds nothing laid out, its scenes measuring zero, and DOM methods written in JavaScript — four to six emulatedDom readings; asking for the key without a program is vmAnswerMissing |
what the program asks: a box's width, a scene's layout, a method's native code, CSS, a canvas; and, before it, their navigator.webdriver, a getter written in JavaScript where every browser's is native (webdriverHidden, in every run, the stack disguised or not) |
| ghost-cursor 1.4 (Bézier paths, Fitts's-law timing) on Puppeteer behind the same clean identity | — | 47 for the cursor alone under a Windows User-Agent (a challenge beside anything else); 79, refused, with its README's sign-in typed by Puppeteer's type(…, { delay }); 7 to 17 for the cursor under another system's |
its curves stepping between the screen's pixels, 96–100% of their steps, where Windows moves a mouse in whole ones; each key pressed a millisecond after the last was released |
What is not caught yet
- ghost-cursor's humanised pointer, past a challenge. Its paths' shape and
timing read as a hand's, but its curves step between the screen's pixels,
which a mouse on Windows never does (
pointerBetweenPixels): 47 with that, where it was 7 to 17 — a challenge beside anything else, not a refusal on its own (79, refused, when the same script types with Puppeteer'skeyboard.type). Not read under macOS or Linux, where a hand's pointer lands between pixels too, and not yet calibrated against real people's mice. A script that rounds its curve to whole pixels is a hand again. - A headful driver that neither clicks nor runs code that calls the page —
a CDP session reading the DOM. From JavaScript it is the browser it drives.
How it moves gives it away. A driver's
gotoarrives as an address typed does, page after page, where the edge places the agent (navigationsTyped, a challenge); one that navigates from its evaluate (location.href = …, a link'sclick()) has its own frame on the stack of the page'snavigateevent, and the next page says so (driverEvaluation, as for a fetch from there: bot). One that follows the site's links with clicks is read as clicks are, across the tab's pages: one click a page on each link's centre is named from the third (centredClicks). One that does none of these is left to the server's side: the handshake, the address's record, the rate of pages without a receipt, and readings trained on real traffic. navigator.webdriverdeleted in WebKit, with Playwright's binding: an undriven Safari was not measured, so a missing flag is read in Chrome and Firefox only. Every other way of hiding it measured is read in all three — the getters the guides write, one whosetoStringlies, one betweennavigatorand its prototype, and the browser's own behind a Proxy, refusing or handing on, with the stack emptied or not (probes/playwright-engines.mjs). What is left is a refusal whose error's stack the trap writes itself.- Tools that are not open source — commercial anti-detect browsers and scraping services — have not been measured here.
What it does not accuse
The readings above are measured against honest browsers as well as against the
tools: a real Microsoft Edge and a real Brave (probes/real-browsers.mjs: both score 0, nothing named), Mozilla's own Firefox (159), not driven, as installed and with privacy.resistFingerprinting on (probes/real-firefox.mjs: both score 0, nothing named), and Chrome, Edge and Brave run as people run them — windowed, not driven, nothing said for them (probes/undriven-chromium.mjs: nothing named), settings a person chooses, privacy browsers and modes (Brave, Firefox's
resistFingerprinting), an Angular app's zone.js, browser extensions that patch
without hiding it, an Electron app's window (probes/electron-window.mjs: a real
Chromium as an app's window is — Electron's User-Agent or Chrome's own, no
window.chrome, with and without Node's globals — scores 7, nothing convicting),
an iPhone and an iPad on the mobile
site, a screen reader's activation. Where a reading can be produced by a person
— DevTools' device mode, a User-Agent switcher, a screen reader's mouse-click
command — it is weighed to challenge, never to convict, and
smart-signals.md says which.
The networks people are on. A browser behind a TLS-inspecting middlebox — a
corporate gateway, an antivirus scanning HTTPS: 4–11% of connections where it was
measured (Durumeric et al., NDSS 2017) — arrives on the middlebox's handshake, often
TLS 1.2 and HTTP/1.1, with its own headers: a request assessment challenges it once
(50), from a cloud-delivered gateway's address too, and the receipt it earns there
carries it after; its identification adds 15. A consumer VPN is allowed in a request
assessment (40: its exit's datacentre address and the VPN), and switching one on adds
11 to 17 to a returning device's identification (the VPN, 6 or 12 by its confidence,
and 5 for a network the device had not used), not the same exit counted three times.
Tor Browser is challenged on its exit — 55 from a hosted one, 75 beside a medium
network reputation — and an identification
counts Tor once (25, and 5 for a network the device had not used). iCloud Private Relay
and Cloudflare WARP are relays — a person — never a datacentre, and a carrier's or an
office's shared address says nothing for its first visits in an hour and a reason, not
a challenge, past a third of the sustained mark (test:assess,
test:muted-readings, test:ip-classify). Its people browsing with receipts
are not counted toward its pace at all: the newcomer among them pays for no one
(test:assess-routes).
The browsers and addresses people bring. A link opened inside Instagram or
Facebook — WKWebView on an iPhone, its User-Agent without a Safari version; an
Android WebView naming itself in its hints — and Samsung Internet and Opera,
naming themselves too, are let through a request assessment with nothing said
(test:assess). An email address a privacy relay made for the site — Sign in
with Apple's Hide My Email, Firefox Relay, DuckDuckGo, SimpleLogin, addy.io,
Proton Pass — is reported (email:relay) and never weighed; its random name is
not read as a generated account's.
The scripts a site's own page carries (probes/third-party.mjs, the current npm releases,
each set up as its quick start does with tracing, replay and interaction tracking on, reporting
to the page's own origin): error trackers — Sentry with tracing and Session Replay, Bugsnag, Rollbar
with every auto-instrumentation, TrackJS; real-user monitors — Datadog RUM with Session Replay, New
Relic's browser agent, Elastic APM, Azure Application Insights; product analytics and recorders —
PostHog, Amplitude with Session Replay, Mixpanel with its recorder, Segment, Microsoft Clarity's
recorder, OpenReplay, Highlight, rrweb; and what replaces or extends the natives themselves — core-js
in full, es6-shim, Prototype 1.7 and MooTools 1.6 (from Google's hosted libraries, as old shops load
them, pinned by digest) — alone and all together, leave an honest Chrome's verdict as the bare
page's: nothing named, not a point added, and the identification goes through. rrweb recording canvases wraps the very canvas
methods a fingerprint spoofer patches, and is seen (patchedNatives, nativeFrames,
receiverRead) and convicts nothing, as Canvas Fingerprint Defender is below. Not measured: what
only runs from its vendor's servers — LogRocket's npm package fetches its SDK from the vendor's CDN,
as Hotjar, FullStory and Google's tags load — since nothing in these runs reaches past the machine.
The extensions people run (probes/extensions.mjs, one at a time in an honest
Chromium, on a shop's sign-in page, the current store versions): uBlock Origin Lite,
Adblock Plus, Ghostery, Privacy Badger, DuckDuckGo Privacy Essentials, Dark Reader,
Grammarly, Bitwarden, LastPass, Google Translate, Honey, Tampermonkey, WebRTC Leak
Prevent and User-Agent Switcher (as installed), the wallets that put a provider on every page
(MetaMask, Phantom, Coinbase Wallet), Adobe Acrobat, Google Docs Offline, Avast Online Security,
Loom, and what developers run (React, Vue and Redux DevTools, Wappalyzer, ColorZilla) score 0 to 7
with nothing named —
the same as the browser bare, though blockers inject scriptlets into the page's own
world, Dark Reader and Grammarly rewrite the page and the password managers fill its
form. Canvas Fingerprint Defender's patches are seen (nativeFrames,
receiverRead) and convict nothing: 7. JShelter, which rewrites WebGL, core
counts and timers in JavaScript, differently per scope, is an evasion layer by
construction and reads as one — 54, a challenge, never a refusal; recognising it to
let it through would let through any bot that installs it. In Firefox (Playwright's,
which says it is driven, so what is read is what each add-on adds to the bare
browser): uBlock Origin, Privacy Badger, DuckDuckGo, Dark Reader, Bitwarden,
Multi-Account Containers, ClearURLs, Cookie AutoDelete, CanvasBlocker and Chameleon
add nothing. NoScript is the exception that is not a reading: it blocks the agent
itself until the site is trusted, as it blocks every script — such a visitor earns
no receipt, at the edge or anywhere, until they allow the site's scripts.