Cases / #63 · 2026-10-20 · Easy
What four public bot-test pages say about five fleet browsers
bot.sannysoft.com, CreepJS, deviceandbrowserinfo.com and the fingerprint.com playground, each read for its own verdict on headless Chromium, headful Chrome, headful Patchright, Camoufox and headless WebKit. Headless Chromium fails the most checks everywhere; Camoufox is the only setup deviceandbrowserinfo never called a bot.
Chromium Google Chrome Patchright Camoufox WebKit
Run on production on 2026-10-20: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
The problem
Before pointing a scraper at a protected site, people run their browser past public bot-test pages to see what gives it away. Each page checks different things and reports in its own way: a table of passed and failed checks, a headless percentage, a yes/no verdict with reasons. This case reads every page's own verdict for five setups on the fleet, so you can see which signals each one keys on. It records what the pages report on one day, not how a particular protected site will treat you.
What we used, and why
| What | Why |
|---|---|
bot.sannysoft.com | A table of classic headless and automation checks (user agent, navigator.webdriver, the chrome object, plugins, permissions, WebGL). The program counts the rows marked failed and keeps their names. |
CreepJS (abrahamjuliot.github.io/creepjs) | Scores how much the browser looks headless ("like headless", "headless") and how much it looks like a stealth tool ("stealth"), as percentages. |
deviceandbrowserinfo.com/are_you_a_bot | A yes/no verdict (isBot) with the individual signals behind it, such as a bot user agent, CDP automation, webdriver true. |
demo.fingerprint.com/playground | The vendor's playground: a bot verdict ("Not detected", or "bad bot" with a type) and a suspect score. Only those two fields are read. |
page.waitForFunction() | Each page runs its checks after load; the program waits until the verdict is on the page before reading it. |
| One context per setup, one load per page | The pages see a fresh browser each time, and no page is loaded more than once per setup. |
How it works
- For each of five setups, launch a session through the same proxy and open a fresh context.
- Open each test page in turn and wait until it shows its result.
- Read the page's own verdict from its DOM, with the same expression in every language.
- Print one row per setup with the four verdicts.
The code
The same program in five languages (also on GitHub, with the raw output). Set these environment variables first:
CDPFLEET_API_KEY— your API key (dashboard → API keys)PROXY_URL— your proxy, e.g.http://user:[email protected]:8000
// npm install [email protected]
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one page load per test page per setup)
import { chromium, firefox, webkit } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
const PROXY = process.env.PROXY_URL;
const SETUPS = [
{ setup: 'Chromium, headless', engine: 'chromium', family: 'chromium', body: { headless: 'new' } },
{ setup: 'Google Chrome, headful', engine: 'chrome', family: 'chromium', body: { headless: false } },
{ setup: 'Patchright, headful', engine: 'patchright', family: 'chromium', body: { headless: false } },
{ setup: 'Camoufox, defaults', engine: 'camoufox', family: 'firefox', body: { headless: true } },
{ setup: 'WebKit, headless', engine: 'webkit', family: 'webkit', body: { headless: true } },
];
const TYPES = { chromium, firefox, webkit };
// Each test page's own verdict, read from its DOM. The same expressions run in every language;
// "ready" is polled until the page has finished its checks.
const PAGES = [
{
key: 'sannysoft', url: 'https://bot.sannysoft.com/',
ready: "document.querySelectorAll('td.passed, td.failed').length > 10",
read: "(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()",
},
{
key: 'creepjs', url: 'https://abrahamjuliot.github.io/creepjs/',
ready: "/\\d+% stealth/.test(document.body.innerText)",
read: "(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const m = /(\\d+)% like headless[\\s\\S]*?(\\d+)% headless:[\\s\\S]*?(\\d+)% stealth/.exec(t); return m ? { like_headless_pct: +m[1], headless_pct: +m[2], stealth_pct: +m[3] } : { like_headless_pct: null, headless_pct: null, stealth_pct: null }; })()",
},
{
key: 'deviceandbrowserinfo', url: 'https://deviceandbrowserinfo.com/are_you_a_bot',
ready: "document.body.innerText.indexOf('\"isBot\"') >= 0",
read: "(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('\"isBot\"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()",
},
{
key: 'fingerprint', url: 'https://demo.fingerprint.com/playground',
ready: "/Suspect Score \\d+/.test(document.body.innerText.replace(/\\s+/g, ' '))",
// Only the bot verdict and the suspect score are read (the page also names proxy networks).
read: "(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const bot = /\\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\\x20-\\x7e]/g, '').replace(/\\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()",
},
];
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async function launch(engine, body) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://starter.cdpfleet.com/${engine}/session`, {
method: 'POST',
headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: PROXY, record: false, ...body }),
});
if (res.ok) return res.json();
if (![429, 503].includes(res.status) || attempt === 5) throw new Error(`launch ${res.status} ${await res.text()}`);
await new Promise((r) => setTimeout(r, (Number(res.headers.get('retry-after')) || 5) * 1000));
}
}
const results = [];
for (const s of SETUPS) {
const session = await launch(s.engine, s.body);
const browser = await TYPES[s.family].connect(session.wsUrl, { headers: { 'x-api-key': KEY } });
const row = { setup: s.setup, engine: s.engine };
try {
const page = await (await browser.newContext()).newPage();
for (const p of PAGES) {
try {
await page.goto(p.url, { timeout: 60000 });
await page.waitForFunction(p.ready, null, { timeout: 45000 });
row[p.key] = await page.evaluate(p.read);
} catch (err) {
row[p.key] = { error: err.message.split('\n')[0].slice(0, 120) };
}
}
} finally {
await browser.close();
}
results.push(row);
}
console.log(JSON.stringify(results, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one page load per test page per setup)
import json
import os
import time
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
PROXY = os.environ["PROXY_URL"]
SETUPS = [
{"setup": "Chromium, headless", "engine": "chromium", "family": "chromium", "body": {"headless": "new"}},
{"setup": "Google Chrome, headful", "engine": "chrome", "family": "chromium", "body": {"headless": False}},
{"setup": "Patchright, headful", "engine": "patchright", "family": "chromium", "body": {"headless": False}},
{"setup": "Camoufox, defaults", "engine": "camoufox", "family": "firefox", "body": {"headless": True}},
{"setup": "WebKit, headless", "engine": "webkit", "family": "webkit", "body": {"headless": True}},
]
# Each test page's own verdict, read from its DOM. The same expressions run in every language;
# "ready" is polled until the page has finished its checks.
PAGES = [
{
"key": "sannysoft", "url": "https://bot.sannysoft.com/",
"ready": r"""document.querySelectorAll('td.passed, td.failed').length > 10""",
"read": r"""(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()""",
},
{
"key": "creepjs", "url": "https://abrahamjuliot.github.io/creepjs/",
"ready": r"""/\d+% stealth/.test(document.body.innerText)""",
"read": r"""(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const m = /(\d+)% like headless[\s\S]*?(\d+)% headless:[\s\S]*?(\d+)% stealth/.exec(t); return m ? { like_headless_pct: +m[1], headless_pct: +m[2], stealth_pct: +m[3] } : { like_headless_pct: null, headless_pct: null, stealth_pct: null }; })()""",
},
{
"key": "deviceandbrowserinfo", "url": "https://deviceandbrowserinfo.com/are_you_a_bot",
"ready": r"""document.body.innerText.indexOf('"isBot"') >= 0""",
"read": r"""(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('"isBot"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()""",
},
{
"key": "fingerprint", "url": "https://demo.fingerprint.com/playground",
"ready": r"""/Suspect Score \d+/.test(document.body.innerText.replace(/\s+/g, ' '))""",
# Only the bot verdict and the suspect score are read (the page also names proxy networks).
"read": r"""(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const bot = /\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()""",
},
]
# 503 means the fleet is momentarily busy: wait Retry-After and try again.
def launch(engine, body):
for attempt in range(1, 6):
res = requests.post(f"https://starter.cdpfleet.com/{engine}/session", headers={"x-api-key": KEY},
json={"proxy": PROXY, "record": False, **body}, timeout=120)
if res.ok:
return res.json()
if res.status_code not in (429, 503) or attempt == 5:
raise RuntimeError(f"launch {res.status_code} {res.text}")
time.sleep(int(res.headers.get("retry-after") or 5))
results = []
with sync_playwright() as p:
for s in SETUPS:
session = launch(s["engine"], s["body"])
browser = getattr(p, s["family"]).connect(session["wsUrl"], headers={"x-api-key": KEY})
row = {"setup": s["setup"], "engine": s["engine"]}
try:
page = browser.new_context().new_page()
for pg in PAGES:
try:
page.goto(pg["url"], timeout=60000)
page.wait_for_function(pg["ready"], timeout=45000)
row[pg["key"]] = page.evaluate(pg["read"])
except Exception as err:
row[pg["key"]] = {"error": str(err).split("\n")[0][:120]}
finally:
browser.close()
results.append(row)
print(json.dumps(results, indent=2))
// Maven: com.microsoft.playwright:playwright:1.60.0, com.google.code.gson:gson:2.11.0
// Run with PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1.
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one page load per test page per setup)
import com.google.gson.*;
import com.microsoft.playwright.*;
import java.net.URI;
import java.net.http.*;
import java.util.*;
public class Main {
static final String KEY = System.getenv("CDPFLEET_API_KEY");
static final Gson GSON = new GsonBuilder().setPrettyPrinting().disableHtmlEscaping().serializeNulls().create();
static final HttpClient HTTP = HttpClient.newHttpClient();
record Setup(String name, String engine, String family, String body) {}
static final List<Setup> SETUPS = List.of(
new Setup("Chromium, headless", "chromium", "chromium", "{\"headless\":\"new\"}"),
new Setup("Google Chrome, headful", "chrome", "chromium", "{\"headless\":false}"),
new Setup("Patchright, headful", "patchright", "chromium", "{\"headless\":false}"),
new Setup("Camoufox, defaults", "camoufox", "firefox", "{\"headless\":true}"),
new Setup("WebKit, headless", "webkit", "webkit", "{\"headless\":true}"));
record TestPage(String key, String url, String ready, String read) {}
// Each test page's own verdict, read from its DOM. The same expressions run in every language;
// ready is polled until the page has finished its checks.
static final List<TestPage> PAGES = List.of(
new TestPage("sannysoft", "https://bot.sannysoft.com/",
"document.querySelectorAll('td.passed, td.failed').length > 10",
"(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()"),
new TestPage("creepjs", "https://abrahamjuliot.github.io/creepjs/",
"/\\d+% stealth/.test(document.body.innerText)",
"(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const m = /(\\d+)% like headless[\\s\\S]*?(\\d+)% headless:[\\s\\S]*?(\\d+)% stealth/.exec(t); return m ? { like_headless_pct: +m[1], headless_pct: +m[2], stealth_pct: +m[3] } : { like_headless_pct: null, headless_pct: null, stealth_pct: null }; })()"),
new TestPage("deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot",
"document.body.innerText.indexOf('\"isBot\"') >= 0",
"(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('\"isBot\"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()"),
// Only the bot verdict and the suspect score are read (the page also names proxy networks).
new TestPage("fingerprint", "https://demo.fingerprint.com/playground",
"/Suspect Score \\d+/.test(document.body.innerText.replace(/\\s+/g, ' '))",
"(() => { const t = document.body.innerText.replace(/\\s+/g, ' '); const bot = /\\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\\x20-\\x7e]/g, '').replace(/\\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()"));
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
static String launch(String engine, String options) throws Exception {
JsonObject body = JsonParser.parseString(options).getAsJsonObject();
body.addProperty("proxy", System.getenv("PROXY_URL"));
body.addProperty("record", false);
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + engine + "/session"))
.header("x-api-key", KEY).header("content-type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body.toString())).build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() == 200) return JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
if ((res.statusCode() != 429 && res.statusCode() != 503) || attempt == 5) throw new RuntimeException("launch " + res.statusCode() + " " + res.body());
Thread.sleep(Long.parseLong(res.headers().firstValue("retry-after").orElse("5")) * 1000);
}
}
public static void main(String[] args) throws Exception {
JsonArray results = new JsonArray();
try (Playwright playwright = Playwright.create()) {
Map<String, BrowserType> types = Map.of("chromium", playwright.chromium(), "firefox", playwright.firefox(), "webkit", playwright.webkit());
for (Setup s : SETUPS) {
String wsUrl = launch(s.engine(), s.body());
Browser browser = types.get(s.family()).connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
JsonObject row = new JsonObject();
row.addProperty("setup", s.name());
row.addProperty("engine", s.engine());
try {
Page page = browser.newContext().newPage();
for (TestPage p : PAGES) {
try {
page.navigate(p.url(), new Page.NavigateOptions().setTimeout(60000));
page.waitForFunction(p.ready(), null, new Page.WaitForFunctionOptions().setTimeout(45000));
row.add(p.key(), GSON.toJsonTree(page.evaluate(p.read())));
} catch (PlaywrightException err) {
String msg = err.getMessage().split("\n")[0];
JsonObject e = new JsonObject();
e.addProperty("error", msg.length() > 120 ? msg.substring(0, 120) : msg);
row.add(p.key(), e);
}
}
} finally {
browser.close();
}
results.add(row);
}
}
System.out.println(GSON.toJson(results));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one page load per test page per setup)
using System.Net.Http.Json;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
var proxy = Environment.GetEnvironmentVariable("PROXY_URL");
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
var setups = new (string Name, string Engine, IBrowserType Type, JsonObject Body)[]
{
("Chromium, headless", "chromium", playwright.Chromium, new JsonObject { ["headless"] = "new" }),
("Google Chrome, headful", "chrome", playwright.Chromium, new JsonObject { ["headless"] = false }),
("Patchright, headful", "patchright", playwright.Chromium, new JsonObject { ["headless"] = false }),
("Camoufox, defaults", "camoufox", playwright.Firefox, new JsonObject { ["headless"] = true }),
("WebKit, headless", "webkit", playwright.Webkit, new JsonObject { ["headless"] = true }),
};
// Each test page's own verdict, read from its DOM. The same expressions run in every language;
// Ready is polled until the page has finished its checks.
var pages = new (string Key, string Url, string Ready, string Read)[]
{
("sannysoft", "https://bot.sannysoft.com/",
"""document.querySelectorAll('td.passed, td.failed').length > 10""",
"""(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()"""),
("creepjs", "https://abrahamjuliot.github.io/creepjs/",
"""/\d+% stealth/.test(document.body.innerText)""",
"""(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const m = /(\d+)% like headless[\s\S]*?(\d+)% headless:[\s\S]*?(\d+)% stealth/.exec(t); return m ? { like_headless_pct: +m[1], headless_pct: +m[2], stealth_pct: +m[3] } : { like_headless_pct: null, headless_pct: null, stealth_pct: null }; })()"""),
("deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot",
"""document.body.innerText.indexOf('"isBot"') >= 0""",
"""(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('"isBot"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()"""),
// Only the bot verdict and the suspect score are read (the page also names proxy networks).
("fingerprint", "https://demo.fingerprint.com/playground",
"""/Suspect Score \d+/.test(document.body.innerText.replace(/\s+/g, ' '))""",
"""(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const bot = /\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()"""),
};
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
async Task<string> Launch(string engine, JsonObject options)
{
var body = new JsonObject { ["proxy"] = proxy, ["record"] = false };
foreach (var (k, v) in options) body[k] = v?.DeepClone();
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{engine}/session", body);
if (res.IsSuccessStatusCode) return (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
var code = (int)res.StatusCode;
if ((code != 429 && code != 503) || attempt == 5) throw new Exception($"launch {code} {await res.Content.ReadAsStringAsync()}");
await Task.Delay(TimeSpan.FromSeconds(res.Headers.RetryAfter?.Delta?.TotalSeconds ?? 5));
}
}
var results = new JsonArray();
foreach (var s in setups)
{
var wsUrl = await Launch(s.Engine, s.Body);
var browser = await s.Type.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
var row = new JsonObject { ["setup"] = s.Name, ["engine"] = s.Engine };
try
{
var page = await (await browser.NewContextAsync()).NewPageAsync();
foreach (var p in pages)
{
try
{
await page.GotoAsync(p.Url, new() { Timeout = 60000 });
await page.WaitForFunctionAsync(p.Ready, null, new() { Timeout = 45000 });
row[p.Key] = JsonNode.Parse((await page.EvaluateAsync<JsonElement>(p.Read)).GetRawText());
}
catch (Exception err)
{
var msg = err.Message.Split('\n')[0];
row[p.Key] = new JsonObject { ["error"] = msg.Length > 120 ? msg[..120] : msg };
}
}
}
finally
{
await browser.CloseAsync();
}
results.Add(row);
}
Console.WriteLine(results.ToJsonString(new JsonSerializerOptions { WriteIndented = true, Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping }));
// go get github.com/playwright-community/[email protected]
// Driver: build playwright-core 1.60.0 from npm and set PLAYWRIGHT_DRIVER_PATH (see /docs/quickstart).
// env: CDPFLEET_API_KEY, PROXY_URL (an http(s) or socks5 proxy; one page load per test page per setup)
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"net/http"
"os"
"strconv"
"strings"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
type setup struct {
Name, Engine, Family string
Body map[string]any
}
var setups = []setup{
{"Chromium, headless", "chromium", "chromium", map[string]any{"headless": "new"}},
{"Google Chrome, headful", "chrome", "chromium", map[string]any{"headless": false}},
{"Patchright, headful", "patchright", "chromium", map[string]any{"headless": false}},
{"Camoufox, defaults", "camoufox", "firefox", map[string]any{"headless": true}},
{"WebKit, headless", "webkit", "webkit", map[string]any{"headless": true}},
}
type testPage struct{ Key, URL, Ready, Read string }
// Each test page's own verdict, read from its DOM. The same expressions run in every language;
// Ready is polled until the page has finished its checks.
var pages = []testPage{
{"sannysoft", "https://bot.sannysoft.com/",
`document.querySelectorAll('td.passed, td.failed').length > 10`,
`(() => { const rows = Array.from(document.querySelectorAll('tr')).filter((tr) => tr.querySelector('td.passed, td.failed, td.warn')); const failed = rows.filter((tr) => tr.querySelector('td.failed')).map((tr) => tr.cells[0].innerText.replace(/\s+/g, ' ').trim()); return { checks: rows.length, failed: failed.length, failed_names: failed }; })()`},
{"creepjs", "https://abrahamjuliot.github.io/creepjs/",
`/\d+% stealth/.test(document.body.innerText)`,
`(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const m = /(\d+)% like headless[\s\S]*?(\d+)% headless:[\s\S]*?(\d+)% stealth/.exec(t); return m ? { like_headless_pct: +m[1], headless_pct: +m[2], stealth_pct: +m[3] } : { like_headless_pct: null, headless_pct: null, stealth_pct: null }; })()`},
{"deviceandbrowserinfo", "https://deviceandbrowserinfo.com/are_you_a_bot",
`document.body.innerText.indexOf('"isBot"') >= 0`,
`(() => { const t = document.body.innerText; const s = t.indexOf('{', t.indexOf('"isBot"') - 10); let depth = 0, e = s; for (; e < t.length; e++) { if (t[e] === '{') depth++; if (t[e] === '}' && --depth === 0) break; } try { const j = JSON.parse(t.slice(s, e + 1)); return { is_bot: j.isBot, signals: Object.keys(j.details || {}).filter((k) => j.details[k] === true) }; } catch (err) { return { is_bot: null, signals: [] }; } })()`},
// Only the bot verdict and the suspect score are read (the page also names proxy networks).
{"fingerprint", "https://demo.fingerprint.com/playground",
`/Suspect Score \d+/.test(document.body.innerText.replace(/\s+/g, ' '))`,
`(() => { const t = document.body.innerText.replace(/\s+/g, ' '); const bot = /\bBot (.+?) VPN /.exec(t); const score = /Suspect Score (\d+)/.exec(t); return { bot: bot ? bot[1].replace(/[^\x20-\x7e]/g, '').replace(/\s+/g, ' ').trim() : null, suspect_score: score ? +score[1] : null }; })()`},
}
type row struct {
Setup string `json:"setup"`
Engine string `json:"engine"`
Sannysoft any `json:"sannysoft"`
Creepjs any `json:"creepjs"`
Deviceandbrowserinfo any `json:"deviceandbrowserinfo"`
Fingerprint any `json:"fingerprint"`
}
// 503 means the fleet is momentarily busy: wait Retry-After and try again.
func launch(engine string, options map[string]any) (map[string]any, error) {
payload := map[string]any{"proxy": os.Getenv("PROXY_URL"), "record": false}
for k, v := range options {
payload[k] = v
}
body, _ := json.Marshal(payload)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+engine+"/session", bytes.NewReader(body))
req.Header.Set("x-api-key", key)
req.Header.Set("content-type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
if res.StatusCode == http.StatusOK {
var session map[string]any
err := json.NewDecoder(res.Body).Decode(&session)
res.Body.Close()
return session, err
}
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 5 {
return nil, fmt.Errorf("launch %d %s", res.StatusCode, msg)
}
wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
if wait == 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
func errorOf(err error) map[string]any {
msg := strings.SplitN(err.Error(), "\n", 2)[0]
if len(msg) > 120 {
msg = msg[:120]
}
return map[string]any{"error": msg}
}
func run(pw *playwright.Playwright, s setup) row {
r := row{Setup: s.Name, Engine: s.Engine}
session, err := launch(s.Engine, s.Body)
if err != nil {
log.Fatal(err)
}
types := map[string]playwright.BrowserType{"chromium": pw.Chromium, "firefox": pw.Firefox, "webkit": pw.WebKit}
browser, err := types[s.Family].Connect(session["wsUrl"].(string), playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
if err != nil {
log.Fatal(err)
}
defer browser.Close()
ctx, err := browser.NewContext()
if err != nil {
log.Fatal(err)
}
page, err := ctx.NewPage()
if err != nil {
log.Fatal(err)
}
verdicts := []*any{&r.Sannysoft, &r.Creepjs, &r.Deviceandbrowserinfo, &r.Fingerprint}
for i, p := range pages {
*verdicts[i] = func() any {
if _, err := page.Goto(p.URL, playwright.PageGotoOptions{Timeout: playwright.Float(60000)}); err != nil {
return errorOf(err)
}
if _, err := page.WaitForFunction(p.Ready, nil, playwright.PageWaitForFunctionOptions{Timeout: playwright.Float(45000)}); err != nil {
return errorOf(err)
}
v, err := page.Evaluate(p.Read)
if err != nil {
return errorOf(err)
}
return v
}()
}
return r
}
func main() {
pw, err := playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
results := []row{}
for _, s := range setups {
results = append(results, run(pw, s))
}
out, _ := json.MarshalIndent(results, "", " ")
fmt.Println(string(out))
}
What we got
| Setup | sannysoft: failed checks | CreepJS: like headless / headless / stealth | deviceandbrowserinfo | fingerprint.com |
|---|---|---|---|---|
| Chromium, headless | 11 of 31 (User Agent (Old), Chrome (New), Permissions (New), Plugins Length (Old), Plugins is of type PluginArray, WebGL Renderer, HEADCHR_UA, HEADCHR_CHROME_OBJ, HEADCHR_PERMISSIONS, HEADCHR_IFRAME, CHR_MEMORY) | 94% / 67% / 0% | bot (hasBotUserAgent, hasInconsistentTimingResolution, isAutomatedWithCDP, isAutomatedWithCDPInWebWorker, hasSuspiciousWeakSignals) | You are a bad bot (type: headless_chrome), suspect score 23 |
| Google Chrome, headful | 1 of 31 (WebGL Renderer) | 50% / 0% / 0% | no result (page load failed) | You are a bad bot (type: chromium_automation), suspect score 43 |
| Patchright, headful | 1 of 31 (WebGL Renderer) | 50% / 0% / 0% | bot (hasSuspiciousWeakSignals) | You are a bad bot (type: chromium_automation), suspect score 31 |
| Camoufox, defaults | 1 of 31 (Chrome (New)) | 13% / 0% / 0% | not a bot | Not detected, suspect score 9 |
| WebKit, headless | 2 of 31 (WebDriver (New), Chrome (New)) | 19% / 33% / 0% | no result (page load failed) | You are a bad bot (type: webdriver), suspect score 33 |
From the Node.js run on 2026-10-20. IP addresses are replaced with placeholders (203.0.113.x); equal addresses stay equal. The other languages produced the same findings.
Raw output (Node.js)
[
{
"setup": "Chromium, headless",
"engine": "chromium",
"sannysoft": {
"checks": 31,
"failed": 11,
"failed_names": [
"User Agent (Old)",
"Chrome (New)",
"Permissions (New)",
"Plugins Length (Old)",
"Plugins is of type PluginArray",
"WebGL Renderer",
"HEADCHR_UA",
"HEADCHR_CHROME_OBJ",
"HEADCHR_PERMISSIONS",
"HEADCHR_IFRAME",
"CHR_MEMORY"
]
},
"creepjs": {
"like_headless_pct": 94,
"headless_pct": 67,
"stealth_pct": 0
},
"deviceandbrowserinfo": {
"is_bot": true,
"signals": [
"hasBotUserAgent",
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals"
]
},
"fingerprint": {
"bot": "You are a bad bot (type: headless_chrome)",
"suspect_score": 23
}
},
{
"setup": "Google Chrome, headful",
"engine": "chrome",
"sannysoft": {
"checks": 31,
"failed": 1,
"failed_names": [
"WebGL Renderer"
]
},
"creepjs": {
"like_headless_pct": 50,
"headless_pct": 0,
"stealth_pct": 0
},
"deviceandbrowserinfo": {
"error": "page.goto: Timeout 60000ms exceeded."
},
"fingerprint": {
"bot": "You are a bad bot (type: chromium_automation)",
"suspect_score": 43
}
},
{
"setup": "Patchright, headful",
"engine": "patchright",
"sannysoft": {
"checks": 31,
"failed": 1,
"failed_names": [
"WebGL Renderer"
]
},
"creepjs": {
"like_headless_pct": 50,
"headless_pct": 0,
"stealth_pct": 0
},
"deviceandbrowserinfo": {
"is_bot": true,
"signals": [
"hasSuspiciousWeakSignals"
]
},
"fingerprint": {
"bot": "You are a bad bot (type: chromium_automation)",
"suspect_score": 31
}
},
{
"setup": "Camoufox, defaults",
"engine": "camoufox",
"sannysoft": {
"checks": 31,
"failed": 1,
"failed_names": [
"Chrome (New)"
]
},
"creepjs": {
"like_headless_pct": 13,
"headless_pct": 0,
"stealth_pct": 0
},
"deviceandbrowserinfo": {
"is_bot": false,
"signals": []
},
"fingerprint": {
"bot": "Not detected",
"suspect_score": 9
}
},
{
"setup": "WebKit, headless",
"engine": "webkit",
"sannysoft": {
"checks": 31,
"failed": 2,
"failed_names": [
"WebDriver (New)",
"Chrome (New)"
]
},
"creepjs": {
"like_headless_pct": 19,
"headless_pct": 33,
"stealth_pct": 0
},
"deviceandbrowserinfo": {
"error": "page.goto: Timeout 60000ms exceeded."
},
"fingerprint": {
"bot": "You are a bad bot (type: webdriver)",
"suspect_score": 33
}
}
]Takeaways
- Headless Chromium is flagged by every page: it fails by far the most sannysoft checks (the
HeadlessChromeuser agent, the missingchromeobject, plugins, permissions), CreepJS scores it the most headless, deviceandbrowserinfo says bot, and fingerprint.com names the type:headless_chrome. - Headful Chrome and headful Patchright pass sannysoft except its WebGL renderer check (software rendering on a GPU-less server), but fingerprint.com still types both as
chromium_automation. deviceandbrowserinfo flags Chrome for CDP automation (in the page and in a worker); Patchright hides those CDP signals there and is called a bot onhasSuspiciousWeakSignalsalone. These pages look for the automation connection itself, not only headless traits (case 43). - WebKit is caught on
navigator.webdriver, which Playwright's WebKit sets to true: sannysoft fails its WebDriver check and the other pages name webdriver. - Camoufox drew the fewest flags: deviceandbrowserinfo found no signal in any run, CreepJS scored it least headless, and fingerprint.com reported "Not detected" in all five published runs, though an earlier run the same day (2026-10-20) got "bad bot", type
adspower: verdicts vary from load to load. Camoufox is based on Firefox source (not affiliated with Mozilla), so sannysoft's Chrome-object check fails by design. - These pages disagree with each other and with real protection vendors: passing all four doesn't mean a protected site will let you in, and failing one isn't always fatal. Use them to see which signals a setup gives away; see case 16 for headless tells and case 52 for fonts.