Examples & benchmarks / #81 · 2026-11-04 · Benchmark · Medium · for web scraping, ai agents
Behaviour scoring: Camoufox humanize on and off vs plain Chrome clicks on a public bot-test form
A public behavioural bot-test page scores how a demo login form was filled in: clicks, keystrokes, timing. Camoufox with `humanize` off, on and slowed down, and headful Chrome with plain Playwright clicks, each filled it the same way three times, typing fast and typing paced. What the page flagged, what it measured, and whether Google's reCAPTCHA v3 demo moved after real cursor moves and a click. Dated 2026-11-04.
Run on production on 2026-11-04: ✓ Node.js ✓ Python ✓ Java ✓ C# ✓ Go
A benchmark: the results below are what this setup measured on the dates shown, on our own test runs. Sites, networks and browsers change, so rerun the code before you rely on the numbers.
The problem
Fingerprint checks look at what a browser is; behaviour checks look at what it does — whether the cursor travelled before a click, where the click landed, whether the events were trusted, how fast the keys came. Camoufox's humanize option moves the cursor along a curved path instead of jumping, and it is on by default. Does that change what a behaviour-scoring page concludes? Case 68 loaded Google's reCAPTCHA v3 demo with nothing clicked and every setup scored 0.9, so that demo alone can't answer it. This case fills in a public demo form built for exactly this test, with the same actions in every setup, and records the page's verdict, the flags it raised and what it measured. It is a dated measurement on public test pages, not an evasion guide.
What we used, and why
| What | Why |
|---|---|
| Four setups | Camoufox (based on Firefox source; not affiliated with Mozilla) with humanize: false, humanize: true (the default: a curved cursor path of up to 1.5 s) and humanize: 3 (up to 3 s), all headless: true; and headful Google Chrome as the reference, driven by plain Playwright. Every launch uses record: false. |
proxy: cdpfleet-resi-country-us-session-<id> | One sticky residential exit for the whole run, shared by every setup, so a difference between rows is the browser and how it was driven, not the IP. The form page says it uses no IP reputation; the exit only keeps the network the same. |
| deviceandbrowserinfo.com's behavioural bot test | A public demo login form (/are_you_a_bot_interactions) that records clicks, keystrokes and timing; on submit it posts them with a fingerprint to its own endpoint and shows "You are human" or "You are a bot" with the flags behind it. The page says repeated tests don't affect the result. Any email and password are fine; nothing is logged in to. |
page.click() and keyboard.type() | The same five actions in every setup: click the email field, type [email protected], click the password field, type an 11-character password, click Login. Fast typing sends keys with no delay, paced with 120 ms between keys. humanize changes how the cursor gets to each click target, not where the click lands or how keys are typed. |
page.on("request") | Reads the body the page posts to its own endpoint, so the table can show what the page itself measured: time from the first field interaction to submit, whether each click hit the exact centre of its target, untrusted events, its CDP mouse-coordinate check, the median gap between keys. Nothing is changed or blocked. |
page.evaluate() | Counts mousemove events on the form page, and reads the verdict the page prints. |
| Google's reCAPTCHA v3 demo | In each session: load the demo, wait for the score it asks for on load, move the cursor twice and click its "Try again" link, read the score of the reloaded page. |
How it works
- Three rounds; in each, the four setups one after another — one session at a time, never in parallel.
- In each session, open the behavioural test form in a fresh context, fill it in with fast typing and submit; read the verdict (up to 30 s). Then the same in another fresh context with paced typing.
- In a third fresh context, load the reCAPTCHA v3 demo, move the cursor, click "Try again", and read the new score.
- A page load that fails on the network is tried once more; a verdict is never retried.
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)
// npm install [email protected]
// env: CDPFLEET_API_KEY (one session at a time; public test pages only; nothing is solved or retried to get a result)
import { randomBytes } from 'node:crypto';
import { chromium, firefox } from 'playwright';
const KEY = process.env.CDPFLEET_API_KEY;
// One sticky residential exit for the whole run, shared by every setup: a difference between rows is the browser
// and how it was driven, not the IP.
const PROXY = `cdpfleet-resi-country-us-session-c81${randomBytes(3).toString('hex')}-lifetime-30`;
const REPEATS = 3;
// Camoufox's humanize option: false = the cursor jumps straight to the target, true (the default) = a curved path of
// up to 1.5 s, a number = a path of up to that many seconds. Chrome is the reference: plain Playwright clicks.
const SETUPS = [
{ key: 'cf_off', setup: 'Camoufox, humanize: false', engine: 'camoufox', type: firefox, body: { headless: true, humanize: false } },
{ key: 'cf_on', setup: 'Camoufox, humanize: true (default)', engine: 'camoufox', type: firefox, body: { headless: true, humanize: true } },
{ key: 'cf_slow', setup: 'Camoufox, humanize: 3', engine: 'camoufox', type: firefox, body: { headless: true, humanize: 3 } },
{ key: 'chrome', setup: 'Google Chrome, headful', engine: 'chrome', type: chromium, body: { headless: false } },
];
// The same actions in every cell; only the typing speed differs between the two form runs of a session.
const TYPING = { fast: 0, paced: 120 }; // ms between keys
const EMAIL = '[email protected]';
const PASSWORD = 'lab-81!pass'; // the demo form wants 8+ characters with a special character
// deviceandbrowserinfo.com's behavioural bot test: a demo login form that records clicks, keystrokes and timing
// and, on submit, posts them with a fingerprint to its own endpoint, which answers human or bot with its flags.
const FORM = 'https://deviceandbrowserinfo.com/are_you_a_bot_interactions';
// Google's public reCAPTCHA v3 demo prints its server's verify response (with the score); its "Try again" link reloads it.
const RECAPTCHA = 'https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php';
// The same expressions run in every language.
// Count mousemove events on the page, so the table shows what humanize changed.
const COUNT_MOVES = `(() => { window.__lab81 = 0; document.addEventListener('mousemove', () => { window.__lab81++; }, true); return true; })()`;
// The verdict the page shows after submit: isBot and every detail flag that is true.
const VERDICT = `(() => {
const e = document.getElementById('jsonResult');
const t = e ? e.innerText : '';
if (t.length < 10) return null;
const r = JSON.parse(t);
const d = r.details || {};
return { is_bot: r.isBot === true, flags: Object.keys(d).filter((k) => d[k] === true), moves: window.__lab81 };
})()`;
// What the page itself measured about the interaction: the "interactions" part of the body it posted, which is
// embedded as a JSON string and passed to this function.
const MEASURED = `(body) => {
const i = JSON.parse(body).interactions || {};
const s = i.emailTimingStats || {};
return {
time_to_fill_ms: Math.round(i.timeToFill),
exact_center_clicks: [i.hasClickedEmailFieldExactCenter, i.hasClickedPasswordFieldExactCenter, i.hasClickedSubmitExactCenter].filter(Boolean).length,
untrusted_event: i.hasUntrustedEvent === true,
cdp_mouse_leak: i.cdpMouseLeak === true,
key_gap_median_ms: typeof s.medianTimeBetweenKeys === 'number' ? Math.round(s.medianTimeBetweenKeys) : null,
};
}`;
const SCORE = `(() => { if (window.__lab81rc) return null; const m = /"score"\\s*:\\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()`;
async function launch(s) {
for (let attempt = 1; ; attempt++) {
const res = await fetch(`https://starter.cdpfleet.com/${s.engine}/session`, {
method: 'POST', headers: { 'x-api-key': KEY, 'content-type': 'application/json' },
body: JSON.stringify({ proxy: PROXY, record: false, ...s.body }),
});
if (res.ok) return s.type.connect((await res.json()).wsUrl, { headers: { 'x-api-key': KEY } });
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
// A load that fails on the network (proxy timeout) is tried once more; the page's answer is never retried.
async function open(tab, url) {
for (let attempt = 1; attempt <= 2; attempt++) {
try {
await tab.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
return true;
} catch { /* network failure */ }
}
return false;
}
// One form run: click the email field, type, click the password field, type, click Login; read the verdict.
async function form(browser, typing) {
const out = { typing, verdict: 'error', flags: [], moves: null, action_seconds: null, measured: null };
const context = await browser.newContext(); // a fresh context: every run is a first visit
try {
const tab = await context.newPage();
let posted = null;
tab.on('request', (req) => { if (req.url().includes('/fingerprint_bot_test')) posted = req.postData(); });
if (!(await open(tab, FORM))) return out;
await sleep(1000);
await tab.evaluate(COUNT_MOVES);
const t0 = Date.now();
await tab.click('#email');
await tab.keyboard.type(EMAIL, { delay: TYPING[typing] });
await tab.click('#password');
await tab.keyboard.type(PASSWORD, { delay: TYPING[typing] });
await tab.click('#loginForm button[type=submit]');
out.action_seconds = Math.round((Date.now() - t0) / 100) / 10;
let v = null;
for (let i = 0; i < 60 && v === null; i++) { await sleep(500); v = await tab.evaluate(VERDICT).catch(() => null); }
if (v === null) { out.verdict = 'no verdict'; return out; }
Object.assign(out, { verdict: v.is_bot ? 'bot' : 'human', flags: v.flags, moves: v.moves });
if (posted) out.measured = await tab.evaluate(`(${MEASURED})(${JSON.stringify(posted)})`);
return out;
} catch {
return out;
} finally {
await context.close().catch(() => {});
}
}
// Google's v3 demo: wait for the score it asks for on load, move the cursor and click "Try again", read the new score.
async function recaptcha(browser) {
const out = { before: null, after: null };
const context = await browser.newContext();
try {
const tab = await context.newPage();
if (!(await open(tab, RECAPTCHA))) return out;
for (let i = 0; i < 40 && out.before === null; i++) { await sleep(500); out.before = await tab.evaluate(SCORE).catch(() => null); }
await tab.evaluate('window.__lab81rc = 1'); // marks this document, so the score is read from the reloaded one
await tab.mouse.move(300, 300);
await tab.mouse.move(600, 200);
await tab.click('a[href="/recaptcha-v3-request-scores.php"]');
for (let i = 0; i < 40 && out.after === null; i++) { await sleep(500); out.after = await tab.evaluate(SCORE).catch(() => null); }
return out;
} catch {
return out;
} finally {
await context.close().catch(() => {});
}
}
const runs = [];
const scores = [];
// Serial: one session at a time; the repeats are interleaved so a slow minute doesn't land on one setup.
for (let repeat = 1; repeat <= REPEATS; repeat++) {
for (const s of SETUPS) {
const base = { setup: s.setup, key: s.key, repeat };
let browser;
try {
browser = await launch(s);
} catch {
for (const typing of Object.keys(TYPING)) runs.push({ ...base, typing, verdict: 'error', flags: [], moves: null, action_seconds: null, measured: null });
scores.push({ ...base, before: null, after: null });
continue;
}
for (const typing of Object.keys(TYPING)) runs.push({ ...base, ...(await form(browser, typing)) });
scores.push({ ...base, ...(await recaptcha(browser)) });
await browser.close().catch(() => {});
}
}
console.log(JSON.stringify({ measured_at: new Date().toISOString().slice(0, 10), runs, recaptcha: scores }, null, 2));
# pip install playwright==1.60.0 requests
# env: CDPFLEET_API_KEY (one session at a time; public test pages only; nothing is solved or retried to get a result)
import json
import os
import secrets
import time
from datetime import datetime, timezone
import requests
from playwright.sync_api import sync_playwright
KEY = os.environ["CDPFLEET_API_KEY"]
# One sticky residential exit for the whole run, shared by every setup: a difference between rows is the browser
# and how it was driven, not the IP.
PROXY = f"cdpfleet-resi-country-us-session-c81{secrets.token_hex(3)}-lifetime-30"
REPEATS = 3
# Camoufox's humanize option: False = the cursor jumps straight to the target, True (the default) = a curved path of
# up to 1.5 s, a number = a path of up to that many seconds. Chrome is the reference: plain Playwright clicks.
SETUPS = [
{"key": "cf_off", "setup": "Camoufox, humanize: false", "engine": "camoufox", "type": "firefox", "body": {"headless": True, "humanize": False}},
{"key": "cf_on", "setup": "Camoufox, humanize: true (default)", "engine": "camoufox", "type": "firefox", "body": {"headless": True, "humanize": True}},
{"key": "cf_slow", "setup": "Camoufox, humanize: 3", "engine": "camoufox", "type": "firefox", "body": {"headless": True, "humanize": 3}},
{"key": "chrome", "setup": "Google Chrome, headful", "engine": "chrome", "type": "chromium", "body": {"headless": False}},
]
# The same actions in every cell; only the typing speed differs between the two form runs of a session.
TYPING = {"fast": 0, "paced": 120} # ms between keys
EMAIL = "[email protected]"
PASSWORD = "lab-81!pass" # the demo form wants 8+ characters with a special character
# deviceandbrowserinfo.com's behavioural bot test: a demo login form that records clicks, keystrokes and timing
# and, on submit, posts them with a fingerprint to its own endpoint, which answers human or bot with its flags.
FORM = "https://deviceandbrowserinfo.com/are_you_a_bot_interactions"
# Google's public reCAPTCHA v3 demo prints its server's verify response (with the score); its "Try again" link reloads it.
RECAPTCHA = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"
# The same expressions run in every language.
# Count mousemove events on the page, so the table shows what humanize changed.
COUNT_MOVES = "(() => { window.__lab81 = 0; document.addEventListener('mousemove', () => { window.__lab81++; }, true); return true; })()"
# The verdict the page shows after submit: isBot and every detail flag that is true.
VERDICT = """(() => {
const e = document.getElementById('jsonResult');
const t = e ? e.innerText : '';
if (t.length < 10) return null;
const r = JSON.parse(t);
const d = r.details || {};
return { is_bot: r.isBot === true, flags: Object.keys(d).filter((k) => d[k] === true), moves: window.__lab81 };
})()"""
# What the page itself measured about the interaction: the "interactions" part of the body it posted, which is
# embedded as a JSON string and passed to this function.
MEASURED = """(body) => {
const i = JSON.parse(body).interactions || {};
const s = i.emailTimingStats || {};
return {
time_to_fill_ms: Math.round(i.timeToFill),
exact_center_clicks: [i.hasClickedEmailFieldExactCenter, i.hasClickedPasswordFieldExactCenter, i.hasClickedSubmitExactCenter].filter(Boolean).length,
untrusted_event: i.hasUntrustedEvent === true,
cdp_mouse_leak: i.cdpMouseLeak === true,
key_gap_median_ms: typeof s.medianTimeBetweenKeys === 'number' ? Math.round(s.medianTimeBetweenKeys) : null,
};
}"""
SCORE = r"""(() => { if (window.__lab81rc) return null; const m = /"score"\s*:\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()"""
def launch(p, s):
body = {"proxy": PROXY, "record": False, **s["body"]}
for attempt in range(1, 6):
res = requests.post(f"https://starter.cdpfleet.com/{s['engine']}/session", headers={"x-api-key": KEY}, json=body, timeout=120)
if res.ok:
return getattr(p, s["type"]).connect(res.json()["wsUrl"], headers={"x-api-key": KEY})
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))
def safe(tab, expr):
try:
return tab.evaluate(expr)
except Exception:
return None
# A load that fails on the network (proxy timeout) is tried once more; the page's answer is never retried.
def open_page(tab, url):
for _ in range(2):
try:
tab.goto(url, wait_until="domcontentloaded", timeout=30000)
return True
except Exception:
pass
return False
# One form run: click the email field, type, click the password field, type, click Login; read the verdict.
def form(browser, typing):
out = {"typing": typing, "verdict": "error", "flags": [], "moves": None, "action_seconds": None, "measured": None}
context = browser.new_context() # a fresh context: every run is a first visit
try:
tab = context.new_page()
posted = {}
def on_request(req):
if "/fingerprint_bot_test" in req.url:
posted["body"] = req.post_data
tab.on("request", on_request)
if not open_page(tab, FORM):
return out
time.sleep(1)
tab.evaluate(COUNT_MOVES)
t0 = time.time()
tab.click("#email")
tab.keyboard.type(EMAIL, delay=TYPING[typing])
tab.click("#password")
tab.keyboard.type(PASSWORD, delay=TYPING[typing])
tab.click("#loginForm button[type=submit]")
out["action_seconds"] = round((time.time() - t0) * 10) / 10
v = None
for _ in range(60):
time.sleep(0.5)
v = safe(tab, VERDICT)
if v is not None:
break
if v is None:
out["verdict"] = "no verdict"
return out
out.update({"verdict": "bot" if v["is_bot"] else "human", "flags": v["flags"], "moves": v["moves"]})
if posted.get("body"):
out["measured"] = tab.evaluate(f"({MEASURED})({json.dumps(posted['body'])})")
return out
except Exception:
return out
finally:
try:
context.close()
except Exception:
pass
# Google's v3 demo: wait for the score it asks for on load, move the cursor and click "Try again", read the new score.
def recaptcha(browser):
out = {"before": None, "after": None}
context = browser.new_context()
try:
tab = context.new_page()
if not open_page(tab, RECAPTCHA):
return out
for _ in range(40):
time.sleep(0.5)
out["before"] = safe(tab, SCORE)
if out["before"] is not None:
break
tab.evaluate("window.__lab81rc = 1") # marks this document, so the score is read from the reloaded one
tab.mouse.move(300, 300)
tab.mouse.move(600, 200)
tab.click('a[href="/recaptcha-v3-request-scores.php"]')
for _ in range(40):
time.sleep(0.5)
out["after"] = safe(tab, SCORE)
if out["after"] is not None:
break
return out
except Exception:
return out
finally:
try:
context.close()
except Exception:
pass
runs = []
scores = []
with sync_playwright() as p:
# Serial: one session at a time; the repeats are interleaved so a slow minute doesn't land on one setup.
for repeat in range(1, REPEATS + 1):
for s in SETUPS:
base = {"setup": s["setup"], "key": s["key"], "repeat": repeat}
try:
browser = launch(p, s)
except Exception:
for typing in TYPING:
runs.append({**base, "typing": typing, "verdict": "error", "flags": [], "moves": None, "action_seconds": None, "measured": None})
scores.append({**base, "before": None, "after": None})
continue
for typing in TYPING:
runs.append({**base, **form(browser, typing)})
scores.append({**base, **recaptcha(browser)})
try:
browser.close()
except Exception:
pass
print(json.dumps({"measured_at": datetime.now(timezone.utc).strftime("%Y-%m-%d"), "runs": runs, "recaptcha": scores}, 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 (one session at a time; public test pages only; nothing is solved or retried to get a result)
import com.google.gson.*;
import com.microsoft.playwright.*;
import com.microsoft.playwright.options.WaitUntilState;
import java.net.URI;
import java.net.http.*;
import java.security.SecureRandom;
import java.time.Instant;
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();
// One sticky residential exit for the whole run, shared by every setup: a difference between rows is the browser
// and how it was driven, not the IP.
static final String PROXY = String.format("cdpfleet-resi-country-us-session-c81%06x-lifetime-30", new SecureRandom().nextInt(1 << 24));
static final int REPEATS = 3;
record Setup(String key, String name, String engine, String type, boolean headless, Object humanize) {}
// Camoufox's humanize option: false = the cursor jumps straight to the target, true (the default) = a curved path of
// up to 1.5 s, a number = a path of up to that many seconds. Chrome is the reference: plain Playwright clicks.
static final List<Setup> SETUPS = List.of(
new Setup("cf_off", "Camoufox, humanize: false", "camoufox", "firefox", true, false),
new Setup("cf_on", "Camoufox, humanize: true (default)", "camoufox", "firefox", true, true),
new Setup("cf_slow", "Camoufox, humanize: 3", "camoufox", "firefox", true, 3),
new Setup("chrome", "Google Chrome, headful", "chrome", "chromium", false, null));
// The same actions in every cell; only the typing speed differs between the two form runs of a session.
static final LinkedHashMap<String, Double> TYPING = new LinkedHashMap<>(); // ms between keys
static {
TYPING.put("fast", 0.0);
TYPING.put("paced", 120.0);
}
static final String EMAIL = "[email protected]";
static final String PASSWORD = "lab-81!pass"; // the demo form wants 8+ characters with a special character
// deviceandbrowserinfo.com's behavioural bot test: a demo login form that records clicks, keystrokes and timing
// and, on submit, posts them with a fingerprint to its own endpoint, which answers human or bot with its flags.
static final String FORM = "https://deviceandbrowserinfo.com/are_you_a_bot_interactions";
// Google's public reCAPTCHA v3 demo prints its server's verify response (with the score); its "Try again" link reloads it.
static final String RECAPTCHA = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php";
// The same expressions run in every language.
// Count mousemove events on the page, so the table shows what humanize changed.
static final String COUNT_MOVES = "(() => { window.__lab81 = 0; document.addEventListener('mousemove', () => { window.__lab81++; }, true); return true; })()";
// The verdict the page shows after submit: isBot and every detail flag that is true.
static final String VERDICT = """
(() => {
const e = document.getElementById('jsonResult');
const t = e ? e.innerText : '';
if (t.length < 10) return null;
const r = JSON.parse(t);
const d = r.details || {};
return { is_bot: r.isBot === true, flags: Object.keys(d).filter((k) => d[k] === true), moves: window.__lab81 };
})()""";
// What the page itself measured about the interaction: the "interactions" part of the body it posted, which is
// embedded as a JSON string and passed to this function.
static final String MEASURED = """
(body) => {
const i = JSON.parse(body).interactions || {};
const s = i.emailTimingStats || {};
return {
time_to_fill_ms: Math.round(i.timeToFill),
exact_center_clicks: [i.hasClickedEmailFieldExactCenter, i.hasClickedPasswordFieldExactCenter, i.hasClickedSubmitExactCenter].filter(Boolean).length,
untrusted_event: i.hasUntrustedEvent === true,
cdp_mouse_leak: i.cdpMouseLeak === true,
key_gap_median_ms: typeof s.medianTimeBetweenKeys === 'number' ? Math.round(s.medianTimeBetweenKeys) : null,
};
}""";
static final String SCORE = "(() => { if (window.__lab81rc) return null; const m = /\"score\"\\s*:\\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()";
static Playwright playwright;
static Browser launch(Setup s) throws Exception {
JsonObject body = new JsonObject();
body.addProperty("proxy", PROXY);
body.addProperty("record", false);
body.addProperty("headless", s.headless());
if (s.humanize() instanceof Boolean b) body.addProperty("humanize", b);
else if (s.humanize() instanceof Number n) body.addProperty("humanize", n);
for (int attempt = 1; ; attempt++) {
HttpResponse<String> res = HTTP.send(HttpRequest.newBuilder(URI.create("https://starter.cdpfleet.com/" + s.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) {
String wsUrl = JsonParser.parseString(res.body()).getAsJsonObject().get("wsUrl").getAsString();
BrowserType type = s.type().equals("firefox") ? playwright.firefox() : playwright.chromium();
return type.connect(wsUrl, new BrowserType.ConnectOptions().setHeaders(Map.of("x-api-key", KEY)));
}
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);
}
}
static Object safe(Page tab, String expr) {
try { return tab.evaluate(expr); } catch (PlaywrightException err) { return null; }
}
// A load that fails on the network (proxy timeout) is tried once more; the page's answer is never retried.
static boolean open(Page tab, String url) {
for (int attempt = 1; attempt <= 2; attempt++) {
try {
tab.navigate(url, new Page.NavigateOptions().setWaitUntil(WaitUntilState.DOMCONTENTLOADED).setTimeout(30000));
return true;
} catch (PlaywrightException err) { /* network failure */ }
}
return false;
}
static JsonObject emptyRun(JsonObject base, String typing) {
JsonObject out = base.deepCopy();
out.addProperty("typing", typing);
out.addProperty("verdict", "error");
out.add("flags", new JsonArray());
out.add("moves", JsonNull.INSTANCE);
out.add("action_seconds", JsonNull.INSTANCE);
out.add("measured", JsonNull.INSTANCE);
return out;
}
// One form run: click the email field, type, click the password field, type, click Login; read the verdict.
static JsonObject form(Browser browser, JsonObject base, String typing) throws Exception {
JsonObject out = emptyRun(base, typing);
BrowserContext context = browser.newContext(); // a fresh context: every run is a first visit
try {
Page tab = context.newPage();
String[] posted = {null};
tab.onRequest(req -> { if (req.url().contains("/fingerprint_bot_test")) posted[0] = req.postData(); });
if (!open(tab, FORM)) return out;
Thread.sleep(1000);
tab.evaluate(COUNT_MOVES);
long t0 = System.currentTimeMillis();
double delay = TYPING.get(typing);
tab.click("#email");
tab.keyboard().type(EMAIL, new Keyboard.TypeOptions().setDelay(delay));
tab.click("#password");
tab.keyboard().type(PASSWORD, new Keyboard.TypeOptions().setDelay(delay));
tab.click("#loginForm button[type=submit]");
out.addProperty("action_seconds", Math.round((System.currentTimeMillis() - t0) / 100.0) / 10.0);
Object v = null;
for (int i = 0; i < 60 && v == null; i++) { Thread.sleep(500); v = safe(tab, VERDICT); }
if (v == null) { out.addProperty("verdict", "no verdict"); return out; }
JsonObject verdict = GSON.toJsonTree(v).getAsJsonObject();
out.addProperty("verdict", verdict.get("is_bot").getAsBoolean() ? "bot" : "human");
out.add("flags", verdict.get("flags"));
out.addProperty("moves", verdict.get("moves").getAsInt());
if (posted[0] != null) {
JsonObject m = GSON.toJsonTree(tab.evaluate("(" + MEASURED + ")(" + new Gson().toJson(posted[0]) + ")")).getAsJsonObject();
JsonObject measured = new JsonObject();
measured.addProperty("time_to_fill_ms", m.get("time_to_fill_ms").getAsInt());
measured.addProperty("exact_center_clicks", m.get("exact_center_clicks").getAsInt());
measured.add("untrusted_event", m.get("untrusted_event"));
measured.add("cdp_mouse_leak", m.get("cdp_mouse_leak"));
JsonElement gap = m.get("key_gap_median_ms");
if (gap == null || gap.isJsonNull()) measured.add("key_gap_median_ms", JsonNull.INSTANCE);
else measured.addProperty("key_gap_median_ms", gap.getAsInt());
out.add("measured", measured);
}
return out;
} catch (RuntimeException err) { // a PlaywrightException, or a verdict without a field
return out;
} finally {
try { context.close(); } catch (PlaywrightException err) { }
}
}
static void putScore(JsonObject out, String name, Object score) {
if (score == null) out.add(name, JsonNull.INSTANCE); else out.addProperty(name, ((Number) score).doubleValue());
}
// Google's v3 demo: wait for the score it asks for on load, move the cursor and click "Try again", read the new score.
static JsonObject recaptcha(Browser browser, JsonObject base) throws Exception {
JsonObject out = base.deepCopy();
Object before = null, after = null;
BrowserContext context = browser.newContext();
try {
Page tab = context.newPage();
if (open(tab, RECAPTCHA)) {
for (int i = 0; i < 40 && before == null; i++) { Thread.sleep(500); before = safe(tab, SCORE); }
tab.evaluate("window.__lab81rc = 1"); // marks this document, so the score is read from the reloaded one
tab.mouse().move(300, 300);
tab.mouse().move(600, 200);
tab.click("a[href=\"/recaptcha-v3-request-scores.php\"]");
for (int i = 0; i < 40 && after == null; i++) { Thread.sleep(500); after = safe(tab, SCORE); }
}
} catch (PlaywrightException err) {
// keep what was read
} finally {
try { context.close(); } catch (PlaywrightException err) { }
}
putScore(out, "before", before);
putScore(out, "after", after);
return out;
}
public static void main(String[] args) throws Exception {
playwright = Playwright.create();
JsonArray runs = new JsonArray();
JsonArray scores = new JsonArray();
// Serial: one session at a time; the repeats are interleaved so a slow minute doesn't land on one setup.
for (int repeat = 1; repeat <= REPEATS; repeat++) {
for (Setup s : SETUPS) {
JsonObject base = new JsonObject();
base.addProperty("setup", s.name());
base.addProperty("key", s.key());
base.addProperty("repeat", repeat);
Browser browser;
try {
browser = launch(s);
} catch (Exception err) {
for (String typing : TYPING.keySet()) runs.add(emptyRun(base, typing));
JsonObject sc = base.deepCopy();
sc.add("before", JsonNull.INSTANCE);
sc.add("after", JsonNull.INSTANCE);
scores.add(sc);
continue;
}
// A session the server ends leaves the remaining cells as errors.
for (String typing : TYPING.keySet()) {
try { runs.add(form(browser, base, typing)); } catch (PlaywrightException err) { runs.add(emptyRun(base, typing)); }
}
try {
scores.add(recaptcha(browser, base));
} catch (PlaywrightException err) {
JsonObject sc = base.deepCopy();
sc.add("before", JsonNull.INSTANCE);
sc.add("after", JsonNull.INSTANCE);
scores.add(sc);
}
try { browser.close(); } catch (PlaywrightException err) { }
}
}
playwright.close();
JsonObject result = new JsonObject();
result.addProperty("measured_at", Instant.now().toString().substring(0, 10));
result.add("runs", runs);
result.add("recaptcha", scores);
System.out.println(GSON.toJson(result));
}
}
// dotnet add package Microsoft.Playwright --version 1.60.0
// env: CDPFLEET_API_KEY (one session at a time; public test pages only; nothing is solved or retried to get a result)
using System.Diagnostics;
using System.Net.Http.Json;
using System.Security.Cryptography;
using System.Text.Encodings.Web;
using System.Text.Json;
using System.Text.Json.Nodes;
using Microsoft.Playwright;
var key = Environment.GetEnvironmentVariable("CDPFLEET_API_KEY")!;
// One sticky residential exit for the whole run, shared by every setup: a difference between rows is the browser
// and how it was driven, not the IP.
var proxy = $"cdpfleet-resi-country-us-session-c81{Convert.ToHexString(RandomNumberGenerator.GetBytes(3)).ToLowerInvariant()}-lifetime-30";
const int Repeats = 3;
using var http = new HttpClient();
http.DefaultRequestHeaders.Add("x-api-key", key);
using var playwright = await Playwright.CreateAsync();
// Camoufox's humanize option: false = the cursor jumps straight to the target, true (the default) = a curved path of
// up to 1.5 s, a number = a path of up to that many seconds. Chrome is the reference: plain Playwright clicks.
var setups = new List<(string Key, string Name, string Engine, IBrowserType Type, JsonObject Body)>
{
("cf_off", "Camoufox, humanize: false", "camoufox", playwright.Firefox, new JsonObject { ["headless"] = true, ["humanize"] = false }),
("cf_on", "Camoufox, humanize: true (default)", "camoufox", playwright.Firefox, new JsonObject { ["headless"] = true, ["humanize"] = true }),
("cf_slow", "Camoufox, humanize: 3", "camoufox", playwright.Firefox, new JsonObject { ["headless"] = true, ["humanize"] = 3 }),
("chrome", "Google Chrome, headful", "chrome", playwright.Chromium, new JsonObject { ["headless"] = false }),
};
// The same actions in every cell; only the typing speed differs between the two form runs of a session.
var typing = new List<(string Name, float Delay)> { ("fast", 0), ("paced", 120) }; // ms between keys
const string Email = "[email protected]";
const string Password = "lab-81!pass"; // the demo form wants 8+ characters with a special character
// deviceandbrowserinfo.com's behavioural bot test: a demo login form that records clicks, keystrokes and timing
// and, on submit, posts them with a fingerprint to its own endpoint, which answers human or bot with its flags.
const string Form = "https://deviceandbrowserinfo.com/are_you_a_bot_interactions";
// Google's public reCAPTCHA v3 demo prints its server's verify response (with the score); its "Try again" link reloads it.
const string Recaptcha = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php";
// The same expressions run in every language.
// Count mousemove events on the page, so the table shows what humanize changed.
const string CountMoves = "(() => { window.__lab81 = 0; document.addEventListener('mousemove', () => { window.__lab81++; }, true); return true; })()";
// The verdict the page shows after submit: isBot and every detail flag that is true.
const string Verdict = """
(() => {
const e = document.getElementById('jsonResult');
const t = e ? e.innerText : '';
if (t.length < 10) return null;
const r = JSON.parse(t);
const d = r.details || {};
return { is_bot: r.isBot === true, flags: Object.keys(d).filter((k) => d[k] === true), moves: window.__lab81 };
})()
""";
// What the page itself measured about the interaction: the "interactions" part of the body it posted, which is
// embedded as a JSON string and passed to this function.
const string Measured = """
(body) => {
const i = JSON.parse(body).interactions || {};
const s = i.emailTimingStats || {};
return {
time_to_fill_ms: Math.round(i.timeToFill),
exact_center_clicks: [i.hasClickedEmailFieldExactCenter, i.hasClickedPasswordFieldExactCenter, i.hasClickedSubmitExactCenter].filter(Boolean).length,
untrusted_event: i.hasUntrustedEvent === true,
cdp_mouse_leak: i.cdpMouseLeak === true,
key_gap_median_ms: typeof s.medianTimeBetweenKeys === 'number' ? Math.round(s.medianTimeBetweenKeys) : null,
};
}
""";
const string Score = """(() => { if (window.__lab81rc) return null; const m = /"score"\s*:\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()""";
async Task<IBrowser> Launch((string Key, string Name, string Engine, IBrowserType Type, JsonObject Body) s)
{
var body = new JsonObject { ["proxy"] = proxy, ["record"] = false };
foreach (var (k, v) in s.Body) body[k] = v?.DeepClone();
for (var attempt = 1; ; attempt++)
{
var res = await http.PostAsJsonAsync($"https://starter.cdpfleet.com/{s.Engine}/session", body);
if (res.IsSuccessStatusCode)
{
var wsUrl = (await res.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("wsUrl").GetString()!;
return await s.Type.ConnectAsync(wsUrl, new() { Headers = new Dictionary<string, string> { ["x-api-key"] = key } });
}
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));
}
}
// A load that fails on the network (proxy timeout) is tried once more; the page's answer is never retried.
async Task<bool> Open(IPage tab, string url)
{
for (var attempt = 1; attempt <= 2; attempt++)
{
try { await tab.GotoAsync(url, new() { WaitUntil = WaitUntilState.DOMContentLoaded, Timeout = 30000 }); return true; }
catch (PlaywrightException) { /* network failure */ }
catch (TimeoutException) { /* network failure */ }
}
return false;
}
async Task<JsonElement?> Eval(IPage tab, string expr)
{
try
{
var v = await tab.EvaluateAsync<JsonElement>(expr);
return v.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined ? null : v;
}
catch (PlaywrightException) { return null; }
}
JsonObject EmptyRun(JsonObject @base, string name) => new()
{
["setup"] = @base["setup"]!.DeepClone(), ["key"] = @base["key"]!.DeepClone(), ["repeat"] = @base["repeat"]!.DeepClone(),
["typing"] = name, ["verdict"] = "error", ["flags"] = new JsonArray(), ["moves"] = null, ["action_seconds"] = null, ["measured"] = null,
};
// One form run: click the email field, type, click the password field, type, click Login; read the verdict.
async Task<JsonObject> FormRun(IBrowser browser, JsonObject @base, string name, float delay)
{
var output = EmptyRun(@base, name);
IBrowserContext? context = null;
try
{
context = await browser.NewContextAsync(); // a fresh context: every run is a first visit
var tab = await context.NewPageAsync();
string? posted = null;
tab.Request += (_, req) => { if (req.Url.Contains("/fingerprint_bot_test")) posted = req.PostData; };
if (!await Open(tab, Form)) return output;
await Task.Delay(1000);
await tab.EvaluateAsync(CountMoves);
var watch = Stopwatch.StartNew();
await tab.ClickAsync("#email");
await tab.Keyboard.TypeAsync(Email, new() { Delay = delay });
await tab.ClickAsync("#password");
await tab.Keyboard.TypeAsync(Password, new() { Delay = delay });
await tab.ClickAsync("#loginForm button[type=submit]");
output["action_seconds"] = Math.Round(watch.Elapsed.TotalSeconds * 10) / 10;
JsonElement? v = null;
for (var i = 0; i < 60 && v == null; i++) { await Task.Delay(500); v = await Eval(tab, Verdict); }
if (v is not { } verdict) { output["verdict"] = "no verdict"; return output; }
output["verdict"] = verdict.GetProperty("is_bot").GetBoolean() ? "bot" : "human";
output["flags"] = JsonNode.Parse(verdict.GetProperty("flags").GetRawText());
output["moves"] = verdict.GetProperty("moves").GetInt32();
if (posted != null && await Eval(tab, $"({Measured})({JsonSerializer.Serialize(posted)})") is { } m)
{
var gap = m.GetProperty("key_gap_median_ms");
output["measured"] = new JsonObject
{
["time_to_fill_ms"] = m.GetProperty("time_to_fill_ms").GetInt32(),
["exact_center_clicks"] = m.GetProperty("exact_center_clicks").GetInt32(),
["untrusted_event"] = m.GetProperty("untrusted_event").GetBoolean(),
["cdp_mouse_leak"] = m.GetProperty("cdp_mouse_leak").GetBoolean(),
["key_gap_median_ms"] = gap.ValueKind == JsonValueKind.Number ? gap.GetInt32() : null,
};
}
return output;
}
catch (Exception e) when (e is PlaywrightException or TimeoutException or KeyNotFoundException or InvalidOperationException)
{
return output;
}
finally
{
if (context != null) try { await context.CloseAsync(); } catch (PlaywrightException) { }
}
}
async Task<double?> ReadScore(IPage tab) => await Eval(tab, Score) is { } v ? v.GetDouble() : null;
// Google's v3 demo: wait for the score it asks for on load, move the cursor and click "Try again", read the new score.
async Task<JsonObject> RecaptchaRun(IBrowser browser, JsonObject @base)
{
double? before = null, after = null;
IBrowserContext? context = null;
try
{
context = await browser.NewContextAsync();
var tab = await context.NewPageAsync();
if (await Open(tab, Recaptcha))
{
for (var i = 0; i < 40 && before == null; i++) { await Task.Delay(500); before = await ReadScore(tab); }
await tab.EvaluateAsync("window.__lab81rc = 1"); // marks this document, so the score is read from the reloaded one
await tab.Mouse.MoveAsync(300, 300);
await tab.Mouse.MoveAsync(600, 200);
await tab.ClickAsync("a[href=\"/recaptcha-v3-request-scores.php\"]");
for (var i = 0; i < 40 && after == null; i++) { await Task.Delay(500); after = await ReadScore(tab); }
}
}
catch (Exception e) when (e is PlaywrightException or TimeoutException) { /* keep what was read */ }
finally
{
if (context != null) try { await context.CloseAsync(); } catch (PlaywrightException) { }
}
var output = (JsonObject)@base.DeepClone();
output["before"] = before;
output["after"] = after;
return output;
}
var runs = new JsonArray();
var scores = new JsonArray();
// Serial: one session at a time; the repeats are interleaved so a slow minute doesn't land on one setup.
for (var repeat = 1; repeat <= Repeats; repeat++)
{
foreach (var s in setups)
{
var @base = new JsonObject { ["setup"] = s.Name, ["key"] = s.Key, ["repeat"] = repeat };
IBrowser browser;
try { browser = await Launch(s); }
catch (Exception)
{
foreach (var t in typing) runs.Add(EmptyRun(@base, t.Name));
var sc = (JsonObject)@base.DeepClone();
sc["before"] = null;
sc["after"] = null;
scores.Add(sc);
continue;
}
foreach (var t in typing) runs.Add(await FormRun(browser, @base, t.Name, t.Delay));
scores.Add(await RecaptchaRun(browser, @base));
try { await browser.CloseAsync(); } catch (PlaywrightException) { }
}
}
var result = new JsonObject { ["measured_at"] = DateTime.UtcNow.ToString("yyyy-MM-dd"), ["runs"] = runs, ["recaptcha"] = scores };
Console.WriteLine(result.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 (one session at a time; public test pages only; nothing is solved or retried to get a result)
package main
import (
"bytes"
"crypto/rand"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"math"
"net/http"
"os"
"strconv"
"strings"
"sync"
"time"
"github.com/playwright-community/playwright-go"
)
var key = os.Getenv("CDPFLEET_API_KEY")
// One sticky residential exit for the whole run, shared by every setup: a difference between rows is the browser
// and how it was driven, not the IP.
var proxy = func() string {
b := make([]byte, 3)
rand.Read(b)
return "cdpfleet-resi-country-us-session-c81" + hex.EncodeToString(b) + "-lifetime-30"
}()
const repeats = 3
type setup struct {
Key, Name, Engine, Type string
Body map[string]any
}
// Camoufox's humanize option: false = the cursor jumps straight to the target, true (the default) = a curved path of
// up to 1.5 s, a number = a path of up to that many seconds. Chrome is the reference: plain Playwright clicks.
var setups = []setup{
{"cf_off", "Camoufox, humanize: false", "camoufox", "firefox", map[string]any{"headless": true, "humanize": false}},
{"cf_on", "Camoufox, humanize: true (default)", "camoufox", "firefox", map[string]any{"headless": true, "humanize": true}},
{"cf_slow", "Camoufox, humanize: 3", "camoufox", "firefox", map[string]any{"headless": true, "humanize": 3}},
{"chrome", "Google Chrome, headful", "chrome", "chromium", map[string]any{"headless": false}},
}
// The same actions in every cell; only the typing speed differs between the two form runs of a session.
var typings = []struct {
Name string
Delay float64 // ms between keys
}{{"fast", 0}, {"paced", 120}}
const email = "[email protected]"
const password = "lab-81!pass" // the demo form wants 8+ characters with a special character
// deviceandbrowserinfo.com's behavioural bot test: a demo login form that records clicks, keystrokes and timing
// and, on submit, posts them with a fingerprint to its own endpoint, which answers human or bot with its flags.
const formURL = "https://deviceandbrowserinfo.com/are_you_a_bot_interactions"
// Google's public reCAPTCHA v3 demo prints its server's verify response (with the score); its "Try again" link reloads it.
const recaptchaURL = "https://recaptcha-demo.appspot.com/recaptcha-v3-request-scores.php"
// The same expressions run in every language.
// Count mousemove events on the page, so the table shows what humanize changed.
const countMovesJS = `(() => { window.__lab81 = 0; document.addEventListener('mousemove', () => { window.__lab81++; }, true); return true; })()`
// The verdict the page shows after submit: isBot and every detail flag that is true.
const verdictJS = `(() => {
const e = document.getElementById('jsonResult');
const t = e ? e.innerText : '';
if (t.length < 10) return null;
const r = JSON.parse(t);
const d = r.details || {};
return { is_bot: r.isBot === true, flags: Object.keys(d).filter((k) => d[k] === true), moves: window.__lab81 };
})()`
// What the page itself measured about the interaction: the "interactions" part of the body it posted, which is
// embedded as a JSON string and passed to this function.
const measuredJS = `(body) => {
const i = JSON.parse(body).interactions || {};
const s = i.emailTimingStats || {};
return {
time_to_fill_ms: Math.round(i.timeToFill),
exact_center_clicks: [i.hasClickedEmailFieldExactCenter, i.hasClickedPasswordFieldExactCenter, i.hasClickedSubmitExactCenter].filter(Boolean).length,
untrusted_event: i.hasUntrustedEvent === true,
cdp_mouse_leak: i.cdpMouseLeak === true,
key_gap_median_ms: typeof s.medianTimeBetweenKeys === 'number' ? Math.round(s.medianTimeBetweenKeys) : null,
};
}`
const scoreJS = `(() => { if (window.__lab81rc) return null; const m = /"score"\s*:\s*([0-9.]+)/.exec(document.body ? document.body.innerText : ''); return m ? Number(m[1]) : null; })()`
type measured struct {
TimeToFillMs int `json:"time_to_fill_ms"`
ExactCenterClicks int `json:"exact_center_clicks"`
UntrustedEvent bool `json:"untrusted_event"`
CdpMouseLeak bool `json:"cdp_mouse_leak"`
KeyGapMedianMs *int `json:"key_gap_median_ms"`
}
type formRun struct {
Setup string `json:"setup"`
Key string `json:"key"`
Repeat int `json:"repeat"`
Typing string `json:"typing"`
Verdict string `json:"verdict"`
Flags []string `json:"flags"`
Moves *int `json:"moves"`
ActionSeconds *float64 `json:"action_seconds"`
Measured *measured `json:"measured"`
}
type scoreRun struct {
Setup string `json:"setup"`
Key string `json:"key"`
Repeat int `json:"repeat"`
Before *float64 `json:"before"`
After *float64 `json:"after"`
}
var pw *playwright.Playwright
func launch(s setup) (playwright.Browser, error) {
body := map[string]any{"proxy": proxy, "record": false}
for k, v := range s.Body {
body[k] = v
}
payload, _ := json.Marshal(body)
for attempt := 1; ; attempt++ {
req, _ := http.NewRequest("POST", "https://starter.cdpfleet.com/"+s.Engine+"/session", bytes.NewReader(payload))
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
}
msg, _ := io.ReadAll(res.Body)
res.Body.Close()
if res.StatusCode == 200 {
var session struct{ WsURL string }
json.Unmarshal(msg, &session)
bt := pw.Chromium
if s.Type == "firefox" {
bt = pw.Firefox
}
return bt.Connect(session.WsURL, playwright.BrowserTypeConnectOptions{Headers: map[string]string{"x-api-key": key}})
}
if (res.StatusCode != 429 && res.StatusCode != 503) || attempt == 5 {
return nil, errors.New("launch " + strconv.Itoa(res.StatusCode) + " " + string(msg))
}
wait, _ := strconv.Atoi(res.Header.Get("retry-after"))
if wait == 0 {
wait = 5
}
time.Sleep(time.Duration(wait) * time.Second)
}
}
// A load that fails on the network (proxy timeout) is tried once more; the page's answer is never retried.
func open(tab playwright.Page, url string) bool {
for attempt := 1; attempt <= 2; attempt++ {
_, err := tab.Goto(url, playwright.PageGotoOptions{WaitUntil: playwright.WaitUntilStateDomcontentloaded, Timeout: playwright.Float(30000)})
if err == nil {
return true
}
}
return false
}
// decode copies what evaluate returned into a typed value.
func decode(v any, into any) bool {
if v == nil {
return false
}
raw, err := json.Marshal(v)
return err == nil && json.Unmarshal(raw, into) == nil
}
func score(tab playwright.Page) *float64 {
v, err := tab.Evaluate(scoreJS)
if err != nil || v == nil {
return nil
}
var f float64
if !decode(v, &f) {
return nil
}
return &f
}
// One form run: click the email field, type, click the password field, type, click Login; read the verdict.
func form(browser playwright.Browser, run formRun, delay float64) formRun {
run.Verdict, run.Flags = "error", []string{}
ctx, err := browser.NewContext() // a fresh context: every run is a first visit
if err != nil {
return run
}
defer ctx.Close()
tab, err := ctx.NewPage()
if err != nil {
return run
}
var mu sync.Mutex
posted := ""
tab.OnRequest(func(req playwright.Request) {
if strings.Contains(req.URL(), "/fingerprint_bot_test") {
if body, err := req.PostData(); err == nil {
mu.Lock()
posted = body
mu.Unlock()
}
}
})
if !open(tab, formURL) {
return run
}
time.Sleep(time.Second)
if _, err := tab.Evaluate(countMovesJS); err != nil {
return run
}
t0 := time.Now()
steps := []func() error{
func() error { return tab.Click("#email") },
func() error { return tab.Keyboard().Type(email, playwright.KeyboardTypeOptions{Delay: playwright.Float(delay)}) },
func() error { return tab.Click("#password") },
func() error { return tab.Keyboard().Type(password, playwright.KeyboardTypeOptions{Delay: playwright.Float(delay)}) },
func() error { return tab.Click("#loginForm button[type=submit]") },
}
for _, step := range steps {
if err := step(); err != nil {
return run
}
}
secs := math.Round(time.Since(t0).Seconds()*10) / 10
run.ActionSeconds = &secs
var v struct {
IsBot bool `json:"is_bot"`
Flags []string `json:"flags"`
Moves *int `json:"moves"`
}
got := false
for i := 0; i < 60 && !got; i++ {
time.Sleep(500 * time.Millisecond)
if r, err := tab.Evaluate(verdictJS); err == nil {
got = decode(r, &v)
}
}
if !got {
run.Verdict = "no verdict"
return run
}
run.Verdict, run.Flags, run.Moves = "human", v.Flags, v.Moves
if v.IsBot {
run.Verdict = "bot"
}
mu.Lock()
body := posted
mu.Unlock()
if body != "" {
arg, _ := json.Marshal(body)
if r, err := tab.Evaluate("(" + measuredJS + ")(" + string(arg) + ")"); err == nil {
var m measured
if decode(r, &m) {
run.Measured = &m
}
}
}
return run
}
// Google's v3 demo: wait for the score it asks for on load, move the cursor and click "Try again", read the new score.
func recaptcha(browser playwright.Browser, run scoreRun) scoreRun {
ctx, err := browser.NewContext()
if err != nil {
return run
}
defer ctx.Close()
tab, err := ctx.NewPage()
if err != nil || !open(tab, recaptchaURL) {
return run
}
for i := 0; i < 40 && run.Before == nil; i++ {
time.Sleep(500 * time.Millisecond)
run.Before = score(tab)
}
// Marks this document, so the score is read from the reloaded one.
if _, err := tab.Evaluate("window.__lab81rc = 1"); err != nil {
return run
}
if tab.Mouse().Move(300, 300) != nil || tab.Mouse().Move(600, 200) != nil || tab.Click(`a[href="/recaptcha-v3-request-scores.php"]`) != nil {
return run
}
for i := 0; i < 40 && run.After == nil; i++ {
time.Sleep(500 * time.Millisecond)
run.After = score(tab)
}
return run
}
func main() {
var err error
pw, err = playwright.Run(&playwright.RunOptions{SkipInstallBrowsers: true})
if err != nil {
log.Fatal(err)
}
defer pw.Stop()
runs := []formRun{}
scores := []scoreRun{}
// Serial: one session at a time; the repeats are interleaved so a slow minute doesn't land on one setup.
for repeat := 1; repeat <= repeats; repeat++ {
for _, s := range setups {
browser, err := launch(s)
for _, t := range typings {
run := formRun{Setup: s.Name, Key: s.Key, Repeat: repeat, Typing: t.Name, Verdict: "error", Flags: []string{}}
if err == nil {
run = form(browser, run, t.Delay)
}
runs = append(runs, run)
}
sc := scoreRun{Setup: s.Name, Key: s.Key, Repeat: repeat}
if err == nil {
sc = recaptcha(browser, sc)
browser.Close()
}
scores = append(scores, sc)
}
}
out, _ := json.MarshalIndent(map[string]any{"measured_at": time.Now().UTC().Format("2006-01-02"), "runs": runs, "recaptcha": scores}, "", " ")
fmt.Println(string(out))
}
What we got
| Setup | Fast typing: verdict | Paced typing: verdict | Time to fill, median (fast · paced) | mousemove events per form, median | Clicks on the exact centre | reCAPTCHA v3 demo after moves + click | Measured |
|---|---|---|---|---|---|---|---|
| Camoufox, humanize: false | bot 1/3 (suspiciousClientSideBehavior 1, superHumanSpeed 1) | human 3/3 | 1.9 s · 4.3 s | 3 | 18/18 | 0.9 · 0.9 · 0.9 | 2026-11-04 |
| Camoufox, humanize: true (default) | human 3/3 | human 3/3 | 3.2 s · 5.2 s | 72 | 18/18 | 0.9 · 0.9 · 0.9 | 2026-11-04 |
| Camoufox, humanize: 3 | human 3/3 | human 3/3 | 3.1 s · 5.0 s | 67 | 18/18 | 0.9 · 0.9 · 0.9 | 2026-11-04 |
| Google Chrome, headful | bot 3/3 (hasInconsistentTimingResolution 3, isAutomatedWithCDP 3, isAutomatedWithCDPInWebWorker 3, hasSuspiciousWeakSignals 3, suspiciousClientSideBehavior 3, superHumanSpeed 3) | bot 3/3 (hasInconsistentTimingResolution 3, isAutomatedWithCDP 3, isAutomatedWithCDPInWebWorker 3, hasSuspiciousWeakSignals 3, suspiciousClientSideBehavior 3) | 192 ms · 4.1 s | 3 | 18/18 | 0.9 · none · 0.9 | 2026-11-04 |
From the Node.js run on 2026-11-04. 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)
{
"measured_at": "2026-11-04",
"runs": [
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 1,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 3,
"action_seconds": 2.7,
"measured": {
"time_to_fill_ms": 2302,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 16
}
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 1,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 3,
"action_seconds": 4.5,
"measured": {
"time_to_fill_ms": 4254,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 125
}
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 1,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 58,
"action_seconds": 6.8,
"measured": {
"time_to_fill_ms": 4316,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 9
}
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 1,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 84,
"action_seconds": 6.4,
"measured": {
"time_to_fill_ms": 5261,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 126
}
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 1,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 78,
"action_seconds": 2.6,
"measured": {
"time_to_fill_ms": 1335,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 3
}
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 1,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 49,
"action_seconds": 5.5,
"measured": {
"time_to_fill_ms": 4391,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 123
}
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 1,
"typing": "fast",
"verdict": "bot",
"flags": [
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals",
"suspiciousClientSideBehavior",
"superHumanSpeed"
],
"moves": 3,
"action_seconds": 0.8,
"measured": {
"time_to_fill_ms": 350,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 3
}
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 1,
"typing": "paced",
"verdict": "bot",
"flags": [
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals",
"suspiciousClientSideBehavior"
],
"moves": 3,
"action_seconds": 4.5,
"measured": {
"time_to_fill_ms": 4156,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 125
}
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 2,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 3,
"action_seconds": 2.2,
"measured": {
"time_to_fill_ms": 1939,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 4
}
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 2,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 3,
"action_seconds": 4.2,
"measured": {
"time_to_fill_ms": 4016,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 128
}
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 2,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 75,
"action_seconds": 5.9,
"measured": {
"time_to_fill_ms": 3225,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 14
}
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 2,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 72,
"action_seconds": 6.2,
"measured": {
"time_to_fill_ms": 5243,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 127
}
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 2,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 67,
"action_seconds": 6.5,
"measured": {
"time_to_fill_ms": 4621,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 32
}
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 2,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 76,
"action_seconds": 6.7,
"measured": {
"time_to_fill_ms": 5518,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 129
}
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 2,
"typing": "fast",
"verdict": "bot",
"flags": [
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals",
"suspiciousClientSideBehavior",
"superHumanSpeed"
],
"moves": 3,
"action_seconds": 0.6,
"measured": {
"time_to_fill_ms": 192,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 3
}
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 2,
"typing": "paced",
"verdict": "bot",
"flags": [
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals",
"suspiciousClientSideBehavior"
],
"moves": 3,
"action_seconds": 4.4,
"measured": {
"time_to_fill_ms": 4061,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 125
}
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 3,
"typing": "fast",
"verdict": "bot",
"flags": [
"suspiciousClientSideBehavior",
"superHumanSpeed"
],
"moves": 3,
"action_seconds": 0.9,
"measured": {
"time_to_fill_ms": 508,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 4
}
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 3,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 3,
"action_seconds": 4.6,
"measured": {
"time_to_fill_ms": 4305,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 126
}
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 3,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 43,
"action_seconds": 4.2,
"measured": {
"time_to_fill_ms": 3067,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 14
}
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 3,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 75,
"action_seconds": 6.1,
"measured": {
"time_to_fill_ms": 4937,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 124
}
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 3,
"typing": "fast",
"verdict": "human",
"flags": [],
"moves": 69,
"action_seconds": 4.4,
"measured": {
"time_to_fill_ms": 3123,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 7
}
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 3,
"typing": "paced",
"verdict": "human",
"flags": [],
"moves": 65,
"action_seconds": 6.2,
"measured": {
"time_to_fill_ms": 5014,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 126
}
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 3,
"typing": "fast",
"verdict": "bot",
"flags": [
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals",
"suspiciousClientSideBehavior",
"superHumanSpeed"
],
"moves": 3,
"action_seconds": 0.5,
"measured": {
"time_to_fill_ms": 171,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 3
}
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 3,
"typing": "paced",
"verdict": "bot",
"flags": [
"hasInconsistentTimingResolution",
"isAutomatedWithCDP",
"isAutomatedWithCDPInWebWorker",
"hasSuspiciousWeakSignals",
"suspiciousClientSideBehavior"
],
"moves": 3,
"action_seconds": 4.2,
"measured": {
"time_to_fill_ms": 3958,
"exact_center_clicks": 3,
"untrusted_event": false,
"cdp_mouse_leak": false,
"key_gap_median_ms": 125
}
}
],
"recaptcha": [
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 1,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 1,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 1,
"before": 0.9,
"after": 0.9
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 1,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 2,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 2,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 2,
"before": 0.9,
"after": 0.9
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 2,
"before": null,
"after": null
},
{
"setup": "Camoufox, humanize: false",
"key": "cf_off",
"repeat": 3,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: true (default)",
"key": "cf_on",
"repeat": 3,
"before": 0.9,
"after": 0.9
},
{
"setup": "Camoufox, humanize: 3",
"key": "cf_slow",
"repeat": 3,
"before": 0.9,
"after": 0.9
},
{
"setup": "Google Chrome, headful",
"key": "chrome",
"repeat": 3,
"before": 0.9,
"after": 0.9
}
]
}Takeaways
- Plain Playwright in headful Chrome was a bot in all 30 runs, and paced typing didn't change that. On 2026-11-04 the page raised
isAutomatedWithCDP(and its web-worker variant) every time, which is about the browser being driven over CDP, not about the cursor;suspiciousClientSideBehaviorcame with it in all 30 runs, paced or not.superHumanSpeedappeared in all 15 fast runs (form filled in a median 0.3 s) and in none of the 15 paced ones (about 4 s). humanizechanged how the cursor travelled, not where it clicked. Withhumanize: falsethe page saw 3mousemoveevents per form, one per click; withhumanize: trueit saw 43–86 and withhumanize: 349–90. Every click in every setup (354 of 354 measured) still landed within 1 px of the exact centre of its target, because Playwright aims at the centre andhumanizeonly shapes the path. The page records centre clicks; on their own they didn't make it say "bot". No run had an untrusted event or tripped its CDP mouse-coordinate check.- With paced typing, Camoufox raised no behaviour flag in 44 of 44 runs —
humanizeoff, on or 3. The verdict was "human" in 43; the one "bot" (humanize on) came from a single fingerprint flag,hasInconsistentTimingResolution, with no behaviour flag at all. - What the page flagged in Camoufox was speed. With fast typing,
humanize: falsegotsuspiciousClientSideBehavior+superHumanSpeedin 5 of 14 runs;humanize: trueandhumanize: 3in 0 of 15 each. Every flagged run had filled the form in under 0.7 s (time from the first field click to submit, as the page measures it); no run that took 1.5 s or longer was flagged.humanizedoesn't change typing, but its cursor travel made filling the form slower: median 1.4 s off, 2.6 s withhumanize: trueand 2.6 s with3(fastest humanized run: 1.0 s). - Google's reCAPTCHA v3 demo still said 0.9 after real cursor moves and a click. 56 of 60 sessions got a score after the reload, every one 0.9, for all four setups, the same as the score each got before moving (55 of 60, all 0.9) and as case 68 with nothing touched. The demo doesn't separate these setups; a page built to score behaviour did.
- A demo page's verdict is not a real site's. This page scores one form with fixed rules, says it ignores IP reputation, and lets you repeat the test. Real protections weigh far more — see the lab cases from Cloudflare to Kasada and AWS WAF and the scoreboard.