not-a-robot
A Python library for building the detector side of a "prove you're not a robot" check: it extracts behavioral-telemetry features (mouse-movement dynamics, keystroke timing, overall pacing) from an interaction session and trains an ML classifier that scores how human-like the session looks.
Scope: builds the detector side of a "prove you're not a robot" check, for infrastructure you run yourself. Does not include CAPTCHA-solving, browser automation for third-party challenges, or trajectory generation meant to fool someone else's detection. Full statement under Scope.
Try it: https://not-a-robot-demo.onrender.com/ (free-tier hosting — may take a moment to cold-start after inactivity), or run dry-run/ locally with Docker or plain Python. One button, one real result: generates a synthetic batch across every archetype the library ships, scores and trains on it, reports the actual human-pass / bot-catch numbers the run just produced (not a mock), and runs the environment checks below against a real headless Chromium instance launched via Selenium, not a hardcoded example. See dry-run/README.md for what that found.
Status: alpha. Validated only on synthetic data so far, plus a small real-automation capture (see Capturing real training data); the pipeline ships here, real-traffic numbers are yours.
Install
pip install not-a-robot
(For an editable install from a checkout, see Development.)
Quickstart
from not_a_robot import BotDetector, InteractionSession, MouseEvent, KeyEvent
# Sessions you've captured and labeled from your own application.
# label=True for a known-human session, label=False for a known-bot session.
sessions = [
InteractionSession(
mouse_events=[MouseEvent(x=10, y=12, t=0), MouseEvent(x=14, y=20, t=35), ...],
key_events=[KeyEvent(t_down=500, t_up=560), ...],
page_load_t=0.0,
submit_t=4200.0,
label=True,
),
# ... more labeled sessions ...
]
detector = BotDetector()
detector.fit(sessions)
detector.save("bot_detector.joblib")
# Later, score a new session:
detector = BotDetector.load("bot_detector.joblib")
p_human = detector.score(new_session) # float in [0, 1]
is_human = detector.predict(new_session) # bool at the default 0.5 threshold
Run the end-to-end example (uses synthetic data, see below) from the repo root:
python -m examples.quickstart
What it extracts
- Mouse dynamics (
not_a_robot.features.mouse): path efficiency, velocity/acceleration/jerk, turning-angle statistics, direction reversals, pause count. - Timing / keystroke dynamics (
not_a_robot.features.timing): dwell time (key down -> up), flight time (key up -> next key down), time to first interaction, time to submit. - Scroll behavior (
not_a_robot.features.scroll): total distance, direction reversals, interval and delta statistics. - Click/tap behavior (
not_a_robot.features.clicks): click count, interval statistics, position variance (scripted clicks tend to land on the exact same pixel repeatedly). - Tab-focus and paste behavior (
not_a_robot.features.engagement): blur/refocus count, paste count and total pasted characters. - Enrichment ratios (
not_a_robot.features.enrichment): coefficients of variation and per-second rates derived from the groups above (e.g.mouse_velocity_cv,key_rate_per_sec,typed_vs_pasted_ratio), which tend to separate scripted, uniform behavior from naturally variable human behavior better than any single raw statistic.
Every field on InteractionSession is optional and defaults to empty —
the more of them you wire up client-side, the more signal the detector
has. An empty channel is ambiguous, though (no mouse activity vs. the
tracking script never firing) — not_a_robot.channel_coverage(session)
reports which channels captured anything at all, for debugging/audit,
not as model input.
All features combine into one fixed-order vector
(not_a_robot.session.FEATURE_NAMES) feeding a scikit-learn classifier
(RandomForestClassifier by default, wrapped in CalibratedClassifierCV
— pass your own via BotDetector(model=...)).
Training pipeline and success-rate validation
Three evaluation paths: run_training_pipeline() (one train/test split
— a single point estimate), evaluate_cv() (repeated stratified k-fold
CV on one sample, with per-group Wilson-CI recall and a cost-optimal
threshold), and summarize_across_seeds() (CLI: --seeds 0,1,2,3, the
one to actually quote — it's the only path that captures how much the
numbers move when the sample itself changes, not just the fold
partition).
python -m not_a_robot.train --data sessions.jsonl --seeds 0,1,2,3 # real traffic
python -m not_a_robot.train --synthetic --n-per-class 200 --seeds 0,1,2,3 # bundled demo data
Headline numbers from the bundled synthetic dataset (1,600 sessions, 4
seeds x 5-fold x 10-repeat CV, BotDetector's calibrated default
model): bot catch rate stable at 92–94% across resamples.
naive/evasive/headless archetypes caught near 100%.
sophisticated (reuses the human archetype's mouse/keyboard behavior
by design, so those channels carry zero signal against it) is caught at
34.3% pooled — but 10.7–54.3% per seed, a ~40-point spread that's
the honest finding: which seed the model trains on visibly changes how
much of this class gets caught, so don't quote either the 54.3% high or
the 34.3% pooled number alone as "real-world performance against
mimicry bots."
Full methodology — calibration's effect (and what it didn't fix), the
--drop-keys ablation, the synthetic generator's design, and the
real-Selenium/real-Playwright capture validation — is in
docs/validation.md. The synthetic data is
useful for exercising the pipeline before you have real traffic; it is
not a model of real bot or human behavior.
Deterministic automation checks (separate from the behavioral model)
not_a_robot.environment checks browser-observable automation artifacts
— navigator.webdriver, Selenium/ChromeDriver's cdc_* globals,
Playwright/Puppeteer markers, software-rendered WebGL (consistent with
headless without GPU passthrough):
from not_a_robot.environment import EnvironmentSignals, score_environment
env = score_environment(EnvironmentSignals(
webdriver_flag=True,
cdc_properties_present=False,
webgl_renderer="Google SwiftShader",
))
env.is_automated # True
env.reasons # ["navigator.webdriver is true", "WebGL is software-rendered ..."]
Not a fourth behavioral feature group, and not imported from the top-level package — it returns a boolean plus which signal fired, not a probability, so there's no calibration/CV story here. Combine both at your application layer:
if score_environment(signals).is_automated:
block() # near-certain; skip the behavioral score
else:
decide(detector.score(session)) # falls back to the statistical layer
What this does and doesn't buy you: every signal here is exactly
what stealth plugins and anti-detect browsers patch by default. A
positive result is cheap, strong evidence of unsophisticated automation.
A negative result means "no artifact observed," not "this is a human" —
a stealth-patched bot passes on purpose, which is the gap
BotDetector's behavioral scoring exists for. Verified against a real
browser (not just asserted) — see
docs/validation.md.
Deliberately excludes TLS/JA3-JA4 fingerprinting — that happens at the TCP/TLS handshake, before application code sees the request, and needs a reverse proxy/WAF, not a Python library.
A third layer: not_a_robot.request_fingerprint
Same pattern, one level up the stack: HTTP header/User-Agent
plausibility. Flags known non-browser User-Agents (python-requests,
curl, Scrapy, HeadlessChrome, ...), and — only when the UA claims
Chromium — missing headers real Chromium sends automatically
(Sec-Fetch-*, Sec-CH-UA, Accept-Language/Accept-Encoding):
from not_a_robot.request_fingerprint import signals_from_headers, score_request
report = score_request(signals_from_headers(request.headers)) # Flask/Werkzeug or any dict
report.is_suspicious # True for a known scraper UA, or missing Chromium headers
Deliberately does not check header order or TLS fingerprints (a WSGI app behind a proxy/CDN commonly sees headers reordered before your code sees them — a check that silently misbehaves per-infrastructure is worse than no check).
Also considered and declined: a bespoke rate limiter. Rate limiting is inherently distributed/stateful (multiple workers/pods) — a naive in-process counter is silently wrong past one worker, which is nearly every real deployment. Mature dedicated tools (Flask-Limiter, nginx, your CDN/WAF) already solve this; duplicating it badly here would be worse than not having it.
Auto-retrain per project
AutoRetrainStore automates when a project's detector retrains, not
what counts as ground truth. Each project gets its own store; nothing
trains on a session without an explicit label:
from not_a_robot import AutoRetrainStore
store = AutoRetrainStore("path/to/project/.not_a_robot", min_new_sessions=50)
store.record_session(session) # live path -- raises if session.label is None
p_human = store.score(new_session)
# Separate periodic job (cron/scheduled task), NOT the request path --
# fitting + multi-seed CV takes tens of seconds, not ms:
record = store.maybe_retrain() # None if under min_new_sessions since last retrain
Or as a scheduled command: python -m not_a_robot.autoretrain --root path/to/project/.not_a_robot --min-new-sessions 50.
The store trusts your labels — a honeypot that fires on humans teaches
the detector humans are bots; the model backup
(model.joblib.<timestamp>.bak) is the only rollback. Each retrain
fits on every session recorded so far, runs the same multi-seed CV as
above (so the record's ranges are defensible, not a single split),
backs up the model it replaces, and records group_composition() at
that retrain alongside the metrics — so a human-pass/bot-catch shift
between retrains is explainable (a skewed archetype mix), not
mysterious. Not built here, deliberately: any mechanism that would
label sessions from the detector's own predictions or unverified live
traffic — see Scope.
Client-side capture: not-a-robot.js
The library only defines the schema and feature math; you own serving
the client-side capture. js/not-a-robot.js is a reference
implementation matching the InteractionSession schema exactly — plain
JS, no dependencies, no build step. Not part of the PyPI package
(a JS file isn't Python wheel content); copy it into your own static
assets.
<script src="/not-a-robot.js"></script>
<script>
var collector = new NotARobot.Collector({ endpoint: "/telemetry" });
collector.attachToForm("#signup-form");
</script>
Fire-and-forget by default (fetch(..., { keepalive: true }) alongside
the real submit) — a telemetry failure should never block a real user.
examples/integrations/flask_app.py and fastapi_app.py show the
server side end to end (serving the script, receiving /telemetry,
scoring with session_from_dict() + BotDetector.score()):
pip install flask # or: fastapi uvicorn
PYTHONPATH=. python examples/integrations/flask_app.py
Verified against a real browser: a real headless Chromium session
(ActionChains mouse movement, send_keys() typing, real form submit)
produced a captured session the server correctly parsed and scored.
Capturing real training data
Tag each finished session with a label from a secondary signal you
trust (a CAPTCHA outcome, email verification, manual review), then pass
InteractionSession objects to run_training_pipeline() or persist
with not_a_robot.io.save_sessions_jsonl() for
python -m not_a_robot.train --data sessions.jsonl later. Tag group
when you want recall broken out by population ("known_bot_honeypot",
"known_bot_asn", etc.) — without it you only get the aggregate bot
catch rate, not which bots are slipping through.
examples/synthetic_data.py's bundled archetypes exist only so the
pipeline has example data before you have real traffic — replace it
before relying on this for anything. What real validation exists so
far: BotDetector trained purely on synthetic data caught 20/20
real captured Selenium sessions and 20/20 real captured Playwright
sessions (two different automation frameworks' default idioms, against
a plain local test page) — comfortably, not just barely. Full capture
methodology, the bug it found (a synthetic archetype's keystroke timing
was 50x too slow), and what this result does and doesn't generalize to,
are in docs/validation.md.
Realistic value by scenario
| Scenario | Value |
|---|---|
| Small site, comment spam, occasional scraping | High — pre-filter, reduce CAPTCHA frequency |
| Login/checkout on a mid-size site | Medium — worth adding, but IP reputation + rate limits + device fingerprinting do more |
| High-value target (banking, ticketing, account creation at scale) | Low on its own — needs to be one of 5–10 signals, most of which this package doesn't cover |
| Research, teaching, detector template | High — the methodology is the product |
| Replacing a commercial bot-management vendor | Not viable |
The honest one-liner: useful the way a smoke detector is useful —
catches the common cases cheaply, doesn't replace a fire-suppression
system. A detector whose limits are documented (this one prints
sophisticated passing, states the per-seed spread, says what
calibration didn't fix) is one you can build a layered defense around.
Coverage by bot class, if deployed as a pre-auth signal
| Bot class | Environment layer | Behavioral layer | Overall |
|---|---|---|---|
| Unpatched Selenium/Puppeteer | Caught | Caught | Caught |
| Headless Chrome (no GPU) | Caught | Caught | Caught |
| Stealth-patched, container (no GPU) | Caught (WebGL) | Sometimes caught | Usually caught |
| Stealth-patched, GPU passthrough | Passes | ~34% caught | Often passes |
| Anti-detect browser + human-like automation | Passes | Weak signal | Passes |
The bottom two rows aren't a gap this package can close with more checks: the environment layer only sees what JavaScript can observe (once every property is either patched or genuinely real, there's nothing left to detect), and the behavioral layer is a per-session statistical classifier that a GAN-trajectory generator is specifically trained to defeat. Closing those rows needs signals outside a per-session, client-observable library's reach entirely — cross-session fleet correlation, IP/ASN reputation, or your own accumulated production data.
What to do about it: don't gate on them, challenge on them. Treat
BotDetector.score() as a step-up trigger — allow above a
high-confidence threshold, block below a low-confidence one, route the
ambiguous middle (where rows 4-5 land) to an actual challenge, using
pipeline.cost_optimal_threshold() to pick the boundaries for your cost
ratio. This package "reduces how often you need that challenge," not
"replaces it."
Scope
This library builds defensive detection for a system you run and
control: a behavioral classifier (BotDetector), a deterministic
automation-artifact check (not_a_robot.environment), and a
deterministic HTTP header/User-Agent check
(not_a_robot.request_fingerprint). It intentionally does not
include: CAPTCHA-solving, browser automation for clicking through
third-party challenges, integrations with CAPTCHA-solving services,
synthetic mouse-trajectory generation meant to fool someone else's bot
detection, TLS/JA3-JA4 fingerprinting (needs a reverse proxy/WAF, not a
Python library), or rate limiting (a distributed/stateful problem with
mature dedicated tools already — Flask-Limiter, nginx, your CDN/WAF).
Those are a different (and, outside authorized testing of your own
systems, frequently abusive) category of tool.
Development
pip install -e ".[dev]"
pytest
Release files for not-a-robot 0.1.7
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| not_a_robot-0.1.7.tar.gz | 52.0 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| not_a_robot-0.1.7-py3-none-any.whl | Python 3 | none | any | Details |
Total release size:95.2 kB
Release files / not_a_robot-0.1.7.tar.gz
| Download URL | not_a_robot-0.1.7.tar.gz |
|---|---|
| Size | 52.0 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
b01e387aa4e2330d0a1500437302a2511bc916c6e6e0b58adf209ed64cf7b3e3
|
|
BLAKE2b-256 checksum How to use checksums |
295fbfab49eeffada873837d1c919e67dbcbd313845ee36c583b486b99e1e502
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency logRelease files / not_a_robot-0.1.7-py3-none-any.whl
| Download URL | not_a_robot-0.1.7-py3-none-any.whl |
|---|---|
| Size | 43.2 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
0f7f8dd4686b8cd4a34cc08af5499dee05b28fe553f82001078e2f5db7f273e1
|
|
BLAKE2b-256 checksum How to use checksums |
e86f8e3b9d58ad2f0f6e4291d43e965b99fdb9f7c430580bffd9de6181ef51cb
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 17, 2026.
Transparency log