not-a-robot 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- not_a_robot-0.1.0/LICENSE +21 -0
- not_a_robot-0.1.0/PKG-INFO +365 -0
- not_a_robot-0.1.0/README.md +337 -0
- not_a_robot-0.1.0/pyproject.toml +42 -0
- not_a_robot-0.1.0/setup.cfg +4 -0
- not_a_robot-0.1.0/src/not_a_robot/__init__.py +47 -0
- not_a_robot-0.1.0/src/not_a_robot/autoretrain.py +231 -0
- not_a_robot-0.1.0/src/not_a_robot/detector.py +86 -0
- not_a_robot-0.1.0/src/not_a_robot/features/__init__.py +15 -0
- not_a_robot-0.1.0/src/not_a_robot/features/clicks.py +47 -0
- not_a_robot-0.1.0/src/not_a_robot/features/engagement.py +25 -0
- not_a_robot-0.1.0/src/not_a_robot/features/enrichment.py +49 -0
- not_a_robot-0.1.0/src/not_a_robot/features/mouse.py +118 -0
- not_a_robot-0.1.0/src/not_a_robot/features/scroll.py +59 -0
- not_a_robot-0.1.0/src/not_a_robot/features/timing.py +49 -0
- not_a_robot-0.1.0/src/not_a_robot/io.py +90 -0
- not_a_robot-0.1.0/src/not_a_robot/pipeline.py +718 -0
- not_a_robot-0.1.0/src/not_a_robot/schema.py +82 -0
- not_a_robot-0.1.0/src/not_a_robot/session.py +84 -0
- not_a_robot-0.1.0/src/not_a_robot/train.py +212 -0
- not_a_robot-0.1.0/src/not_a_robot.egg-info/PKG-INFO +365 -0
- not_a_robot-0.1.0/src/not_a_robot.egg-info/SOURCES.txt +35 -0
- not_a_robot-0.1.0/src/not_a_robot.egg-info/dependency_links.txt +1 -0
- not_a_robot-0.1.0/src/not_a_robot.egg-info/requires.txt +6 -0
- not_a_robot-0.1.0/src/not_a_robot.egg-info/top_level.txt +1 -0
- not_a_robot-0.1.0/tests/test_autoretrain.py +107 -0
- not_a_robot-0.1.0/tests/test_click_features.py +21 -0
- not_a_robot-0.1.0/tests/test_detector.py +50 -0
- not_a_robot-0.1.0/tests/test_engagement_features.py +18 -0
- not_a_robot-0.1.0/tests/test_enrichment.py +28 -0
- not_a_robot-0.1.0/tests/test_evaluate_cv.py +126 -0
- not_a_robot-0.1.0/tests/test_io.py +47 -0
- not_a_robot-0.1.0/tests/test_mouse_features.py +40 -0
- not_a_robot-0.1.0/tests/test_pipeline.py +43 -0
- not_a_robot-0.1.0/tests/test_scroll_features.py +21 -0
- not_a_robot-0.1.0/tests/test_session.py +23 -0
- not_a_robot-0.1.0/tests/test_timing_features.py +31 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 not-a-robot project authors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: not-a-robot
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Behavioral-telemetry feature extraction and ML scoring for a bot-verification system you run yourself.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Repository, https://github.com/sergeyitaly/not_a_robot
|
|
7
|
+
Project-URL: Issues, https://github.com/sergeyitaly/not_a_robot/issues
|
|
8
|
+
Keywords: bot-detection,behavioral-biometrics,captcha,fraud-detection,machine-learning
|
|
9
|
+
Classifier: Development Status :: 3 - Alpha
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
17
|
+
Classifier: Topic :: Security
|
|
18
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
19
|
+
Requires-Python: >=3.9
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
License-File: LICENSE
|
|
22
|
+
Requires-Dist: numpy>=1.24
|
|
23
|
+
Requires-Dist: scikit-learn>=1.3
|
|
24
|
+
Requires-Dist: joblib>=1.3
|
|
25
|
+
Provides-Extra: dev
|
|
26
|
+
Requires-Dist: pytest>=7.4; extra == "dev"
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# not-a-robot
|
|
30
|
+
|
|
31
|
+
A Python library for building the **detector side** of a "prove you're not a
|
|
32
|
+
robot" check: it extracts behavioral-telemetry features (mouse-movement
|
|
33
|
+
dynamics, keystroke timing, overall pacing) from an interaction session and
|
|
34
|
+
trains an ML classifier that scores how human-like the session looks.
|
|
35
|
+
|
|
36
|
+
This is meant to run on infrastructure you control, as one signal alongside
|
|
37
|
+
your own verification flow — not to defeat verification on someone else's
|
|
38
|
+
site. See [Scope](#scope) below.
|
|
39
|
+
|
|
40
|
+
## Install
|
|
41
|
+
|
|
42
|
+
```bash
|
|
43
|
+
pip install -e ".[dev]"
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Quickstart
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from not_a_robot import BotDetector, InteractionSession, MouseEvent, KeyEvent
|
|
50
|
+
|
|
51
|
+
# Sessions you've captured and labeled from your own application.
|
|
52
|
+
# label=True for a known-human session, label=False for a known-bot session.
|
|
53
|
+
sessions = [
|
|
54
|
+
InteractionSession(
|
|
55
|
+
mouse_events=[MouseEvent(x=10, y=12, t=0), MouseEvent(x=14, y=20, t=35), ...],
|
|
56
|
+
key_events=[KeyEvent(t_down=500, t_up=560), ...],
|
|
57
|
+
page_load_t=0.0,
|
|
58
|
+
submit_t=4200.0,
|
|
59
|
+
label=True,
|
|
60
|
+
),
|
|
61
|
+
# ... more labeled sessions ...
|
|
62
|
+
]
|
|
63
|
+
|
|
64
|
+
detector = BotDetector()
|
|
65
|
+
detector.fit(sessions)
|
|
66
|
+
detector.save("bot_detector.joblib")
|
|
67
|
+
|
|
68
|
+
# Later, score a new session:
|
|
69
|
+
detector = BotDetector.load("bot_detector.joblib")
|
|
70
|
+
p_human = detector.score(new_session) # float in [0, 1]
|
|
71
|
+
is_human = detector.predict(new_session) # bool at the default 0.5 threshold
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Run the end-to-end example (uses synthetic data, see below) from the repo
|
|
75
|
+
root:
|
|
76
|
+
|
|
77
|
+
```bash
|
|
78
|
+
python -m examples.quickstart
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
## What it extracts
|
|
82
|
+
|
|
83
|
+
- **Mouse dynamics** (`not_a_robot.features.mouse`): path length vs.
|
|
84
|
+
straight-line distance ("path efficiency"), velocity/acceleration/jerk
|
|
85
|
+
statistics, turning-angle statistics, direction reversals, pause count.
|
|
86
|
+
- **Timing / keystroke dynamics** (`not_a_robot.features.timing`): dwell
|
|
87
|
+
time (key down -> up), flight time (key up -> next key down), time to
|
|
88
|
+
first interaction, time to submit.
|
|
89
|
+
- **Scroll behavior** (`not_a_robot.features.scroll`): total distance,
|
|
90
|
+
direction reversals, interval and delta statistics.
|
|
91
|
+
- **Click/tap behavior** (`not_a_robot.features.clicks`): click count,
|
|
92
|
+
interval statistics, position variance (scripted clicks tend to land on
|
|
93
|
+
the exact same pixel repeatedly).
|
|
94
|
+
- **Tab-focus and paste behavior** (`not_a_robot.features.engagement`):
|
|
95
|
+
blur/refocus count, paste count and total pasted characters.
|
|
96
|
+
- **Enrichment ratios** (`not_a_robot.features.enrichment`): coefficients
|
|
97
|
+
of variation and per-second rates derived from the feature groups above
|
|
98
|
+
(e.g. `mouse_velocity_cv`, `key_rate_per_sec`, `scroll_rate_per_sec`,
|
|
99
|
+
`typed_vs_pasted_ratio`), which normalize for session length/typing
|
|
100
|
+
speed and tend to separate scripted, uniform behavior from naturally
|
|
101
|
+
variable human behavior better than any single raw statistic.
|
|
102
|
+
|
|
103
|
+
Every field on `InteractionSession` (`mouse_events`, `key_events`,
|
|
104
|
+
`scroll_events`, `click_events`, `focus_events`, `paste_events`) is
|
|
105
|
+
optional and defaults to empty — you don't have to capture all of them to
|
|
106
|
+
use the library, but the more of them you wire up client-side, the more
|
|
107
|
+
signal the detector has to work with.
|
|
108
|
+
|
|
109
|
+
All features are combined into one fixed-order vector
|
|
110
|
+
(`not_a_robot.session.FEATURE_NAMES`) that feeds a scikit-learn classifier
|
|
111
|
+
(`RandomForestClassifier` by default — pass your own via `BotDetector(model=...)`).
|
|
112
|
+
|
|
113
|
+
## Training pipeline and success-rate validation
|
|
114
|
+
|
|
115
|
+
There are three evaluation paths, answering three different questions.
|
|
116
|
+
|
|
117
|
+
**`run_training_pipeline()`** fits the detector you'd actually deploy: one
|
|
118
|
+
stratified train/test split, fit on train, evaluated once on test. Useful
|
|
119
|
+
for producing a model + a quick report, but its metrics are a **single
|
|
120
|
+
point estimate** — on a dataset in the hundreds of sessions, one 75/25
|
|
121
|
+
split can look meaningfully better or worse than another from sampling
|
|
122
|
+
luck alone, before the model is even a variable.
|
|
123
|
+
|
|
124
|
+
**`evaluate_cv()`** runs repeated stratified k-fold CV (`n_splits x
|
|
125
|
+
n_repeats` independent folds, default 5x10=50) on *one* sample of
|
|
126
|
+
sessions, and reports mean +/- std per metric, **recall pooled by
|
|
127
|
+
`InteractionSession.group`** with a Wilson 95% confidence interval (not a
|
|
128
|
+
mean/std of per-fold rates — a rare group can have 0-2 members in a given
|
|
129
|
+
fold, where std is close to meaningless; pooling raw hit/total counts
|
|
130
|
+
across all folds is the number that's actually defensible), and the
|
|
131
|
+
**cost-optimal decision threshold** for a stated false-accept-vs-reject
|
|
132
|
+
cost ratio, with per-group recall at that threshold instead of just the
|
|
133
|
+
classifier's default 0.5 cut.
|
|
134
|
+
|
|
135
|
+
**`summarize_across_seeds()`** (CLI: `--seeds 0,1,2,3`) is the one to
|
|
136
|
+
actually quote. Repeated CV within one seed only captures fold-partition
|
|
137
|
+
variance — every fold in that run shares the same 400 sessions. Running
|
|
138
|
+
`evaluate_cv` at several seeds and pooling exposes the variance that
|
|
139
|
+
matters: how much the numbers move when the *sample itself* changes.
|
|
140
|
+
|
|
141
|
+
```python
|
|
142
|
+
from not_a_robot import run_training_pipeline, evaluate_cv, summarize_across_seeds
|
|
143
|
+
|
|
144
|
+
detector, report = run_training_pipeline(sessions, data_source="prod-2026-09")
|
|
145
|
+
detector.save("bot_detector.joblib")
|
|
146
|
+
|
|
147
|
+
cv_report = evaluate_cv(sessions, data_source="prod-2026-09")
|
|
148
|
+
print(cv_report.summary())
|
|
149
|
+
```
|
|
150
|
+
|
|
151
|
+
From the command line, against a real captured session log:
|
|
152
|
+
|
|
153
|
+
```bash
|
|
154
|
+
python -m not_a_robot.train --data sessions.jsonl --model-out bot_detector.joblib --report-out report.json
|
|
155
|
+
python -m not_a_robot.train --data sessions.jsonl --seeds 0,1,2,3 # the defensible report
|
|
156
|
+
```
|
|
157
|
+
|
|
158
|
+
`--synthetic` runs the same pipeline against the bundled demo dataset (see
|
|
159
|
+
below) so you can see a real, computed report before you have real traffic:
|
|
160
|
+
|
|
161
|
+
```bash
|
|
162
|
+
python -m not_a_robot.train --synthetic --n-per-class 200 --seeds 0,1,2,3
|
|
163
|
+
```
|
|
164
|
+
|
|
165
|
+
That produced (**1,600 sessions total**: 400/seed x 4 seeds, 5-fold x
|
|
166
|
+
10-repeat CV per seed, full feature set, `c_fa=10 : c_fr=1` for the
|
|
167
|
+
cost-optimal threshold, `BotDetector`'s calibrated default model — see
|
|
168
|
+
below):
|
|
169
|
+
|
|
170
|
+
```
|
|
171
|
+
seed accuracy human pass bot catch FAR FRR
|
|
172
|
+
0 92.5% 92.9% 92.1% 7.9% 7.1%
|
|
173
|
+
1 96.0% 98.2% 93.7% 6.3% 1.8%
|
|
174
|
+
2 94.7% 95.8% 93.6% 6.4% 4.2%
|
|
175
|
+
3 94.4% 96.5% 92.3% 7.7% 3.5%
|
|
176
|
+
|
|
177
|
+
Bot catch rate range across seeds: 92.1% - 93.7% <- the honest operating characteristic
|
|
178
|
+
|
|
179
|
+
Combined per-group recall (pooled across all seeds, Wilson 95% CI):
|
|
180
|
+
group weight n recall [95% CI]
|
|
181
|
+
human 50.0% 8000 95.9% [95.4%-96.3%]
|
|
182
|
+
naive 21.9% 3500 100.0% [99.9%-100.0%]
|
|
183
|
+
evasive 17.2% 2760 100.0% [99.9%-100.0%]
|
|
184
|
+
headless 5.5% 880 100.0% [99.6%-100.0%]
|
|
185
|
+
sophisticated 5.4% 860 34.3% [31.2%-37.5%]
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
**Read it as:** bot catch rate is stable at 92-94% across resamples, not a
|
|
189
|
+
single point estimate. `naive`/`evasive`/`headless` are caught at ~100%
|
|
190
|
+
with a tight interval (n in the thousands, pooled). `sophisticated` is
|
|
191
|
+
caught at 34.3% [31.2-37.5%] pooled — but **per-seed it ranges 10.7% to
|
|
192
|
+
54.3%**, a ~40-point spread the pooled interval doesn't show on its own.
|
|
193
|
+
That per-seed spread, not the pooled point estimate, is the honest
|
|
194
|
+
finding about this group: the only signal separating it from humans is
|
|
195
|
+
the scroll/click/engagement channels, and it's weak enough that which
|
|
196
|
+
seed the model happens to train on visibly changes how much of it gets
|
|
197
|
+
caught. **Do not treat any single seed's `sophisticated` recall as an
|
|
198
|
+
estimate of real-world performance against mimicry bots** — not the
|
|
199
|
+
54.3% from seed 2, and not the pooled 34.3% either, without also carrying
|
|
200
|
+
that per-seed range.
|
|
201
|
+
|
|
202
|
+
**Calibration, and what it did and didn't fix.** `BotDetector`'s default
|
|
203
|
+
model wraps its `RandomForestClassifier` in `CalibratedClassifierCV`
|
|
204
|
+
(isotonic) — a raw random forest's `predict_proba` is a vote fraction,
|
|
205
|
+
not a real probability, and a reliability check on the raw model showed
|
|
206
|
+
the predicted-vs-observed relationship breaking down badly in a sparse
|
|
207
|
+
mid-range (a handful of test sessions per 0.1-wide probability bin, not
|
|
208
|
+
tracking the observed human fraction there) while a real, if partial,
|
|
209
|
+
overlap between `sophisticated` bots and humans sits in exactly that
|
|
210
|
+
region. Calibrating **did** meaningfully improve default-threshold
|
|
211
|
+
`sophisticated` recall (23.7% pooled before calibration -> 34.3% after)
|
|
212
|
+
and nudged overall bot catch rate up a couple points. It did **not**,
|
|
213
|
+
however, change the cost-curve behavior at `c_fa=10:c_fr=1`: the
|
|
214
|
+
cost-optimal threshold is still 0.85-0.89 across seeds, with FAR pushed
|
|
215
|
+
to ~0% at the cost of a 13-16% false reject rate on real humans, both
|
|
216
|
+
before and after calibration. That similarity is itself informative: it
|
|
217
|
+
means that behavior was never primarily a calibration artifact — it's
|
|
218
|
+
what a 10:1 cost ratio actually does when `sophisticated` bots and a
|
|
219
|
+
minority of real humans (the ones who also don't scroll, blur, or paste
|
|
220
|
+
in a given session) genuinely overlap in score. **Whether trading a
|
|
221
|
+
~1-in-7 real-user rejection rate for catching most `sophisticated` bots
|
|
222
|
+
is worth it depends entirely on your own false-accept-vs-reject cost,
|
|
223
|
+
which is why `cost_fa`/`cost_fr` are parameters, not constants** — the
|
|
224
|
+
10:1 default here is illustrative, not a recommendation; pass
|
|
225
|
+
`--cost-fa`/`--cost-fr` with your actual deployment's asymmetry (a login
|
|
226
|
+
form and a comment form do not have the same one), and don't ship the
|
|
227
|
+
cost-optimal threshold without deciding you actually want that trade.
|
|
228
|
+
|
|
229
|
+
**`--drop-keys` ablation** (excludes keystroke-timing features, simulating
|
|
230
|
+
a mouse-only capture surface): removing them barely moved anything — bot
|
|
231
|
+
catch rate range 91.6-94.2% (vs. 92.1-93.7% with keys), combined
|
|
232
|
+
`sophisticated` recall 34.2% [31.1-37.4%] (vs. 34.3% with keys),
|
|
233
|
+
statistically indistinguishable. This holds both before and after
|
|
234
|
+
calibration, and contradicts what the single-split top-feature-importance
|
|
235
|
+
list suggested earlier (keystroke features ranked highest) — that ranking
|
|
236
|
+
reflected `naive`/`evasive` separability, not what actually separates
|
|
237
|
+
`sophisticated`. The reason is in the generator: `sophisticated` reuses
|
|
238
|
+
the human archetype's keystroke timing *and* mouse trajectory exactly, so
|
|
239
|
+
neither channel ever carried separating signal against it — only the
|
|
240
|
+
scroll/click/engagement features it doesn't fake do. Keystroke timing
|
|
241
|
+
helps separate `naive`/`evasive` (which fake it badly), but mouse
|
|
242
|
+
geometry alone already separates those too, so dropping keys is
|
|
243
|
+
redundant there, not costly. **The lesson isn't "keystroke timing matters
|
|
244
|
+
most" — it's "the channels a specific bot doesn't bother faking are what
|
|
245
|
+
catch it," a property of the bot, not of any one feature group.** Run
|
|
246
|
+
this against your own real data before assuming it transfers; a real
|
|
247
|
+
mouse-only capture surface (e.g. a slider puzzle with no text field) will
|
|
248
|
+
likely have worse `naive`/`evasive` separability than this synthetic set,
|
|
249
|
+
since here they still fail on mouse geometry too.
|
|
250
|
+
|
|
251
|
+
The synthetic generator (`examples/synthetic_data.py`) draws bots from
|
|
252
|
+
four weighted archetypes: naive (straight-line path, uniform keystrokes,
|
|
253
|
+
fixed click coordinate, 45%), evasive (jittered but still tighter than
|
|
254
|
+
human, scripted scroll, 35%), headless (near-instant submit, little/no
|
|
255
|
+
activity, 10%), and sophisticated (10%) — which reuses the human
|
|
256
|
+
archetype's mouse and keyboard distributions *exactly*, so those two
|
|
257
|
+
channels carry zero separable signal against it by construction (see
|
|
258
|
+
`description.txt` on GAN-generated mouse trajectories and keystroke
|
|
259
|
+
mimicry for why an attacker would specifically invest there). The
|
|
260
|
+
non-zero recall it shows comes entirely from the scroll/click/engagement
|
|
261
|
+
channels it does not mimic, plus (at the cost-optimal threshold) trading
|
|
262
|
+
human pass rate for `sophisticated`-bot recall. That is the pipeline
|
|
263
|
+
correctly recovering the partial signal the generator leaves available —
|
|
264
|
+
not a demonstration of general robustness against every kind of mimicry.
|
|
265
|
+
|
|
266
|
+
The report format and numbers above are real, computed output from this
|
|
267
|
+
repo. The input data is not: it's synthetic, generated locally, with no
|
|
268
|
+
interaction with any real website. Run
|
|
269
|
+
`python -m not_a_robot.train --data <your sessions.jsonl> --seeds 0,1,2,3`
|
|
270
|
+
on real, labeled traffic from your own site to get numbers you can
|
|
271
|
+
actually trust for a production decision.
|
|
272
|
+
|
|
273
|
+
## Auto-retrain per project
|
|
274
|
+
|
|
275
|
+
`AutoRetrainStore` automates *when* a project's detector gets retrained,
|
|
276
|
+
not *what counts as ground truth*. Each project gets its own store rooted
|
|
277
|
+
at its own directory -- no data or model is shared across projects, and
|
|
278
|
+
there's no code path that trains on anything but a session you've
|
|
279
|
+
explicitly labeled:
|
|
280
|
+
|
|
281
|
+
```python
|
|
282
|
+
from not_a_robot import AutoRetrainStore
|
|
283
|
+
|
|
284
|
+
store = AutoRetrainStore("path/to/project/.not_a_robot", min_new_sessions=50)
|
|
285
|
+
|
|
286
|
+
# From your live scoring path (cheap -- just a file append):
|
|
287
|
+
store.record_session(session) # raises if session.label is None
|
|
288
|
+
p_human = store.score(new_session)
|
|
289
|
+
|
|
290
|
+
# From a separate periodic job (cron, a scheduled task) -- NOT the
|
|
291
|
+
# request path: fitting + multi-seed CV takes tens of seconds, not ms.
|
|
292
|
+
record = store.maybe_retrain() # None if under min_new_sessions since last retrain
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
Or as a scheduled command:
|
|
296
|
+
|
|
297
|
+
```bash
|
|
298
|
+
python -m not_a_robot.autoretrain --root path/to/project/.not_a_robot --min-new-sessions 50
|
|
299
|
+
```
|
|
300
|
+
|
|
301
|
+
Real output from a run (30 sessions recorded, below the 50 threshold, then
|
|
302
|
+
20 more crossing it):
|
|
303
|
+
|
|
304
|
+
```
|
|
305
|
+
pending after 30 sessions: 30
|
|
306
|
+
maybe_retrain() result: None
|
|
307
|
+
pending after 50 sessions: 50
|
|
308
|
+
{
|
|
309
|
+
"timestamp": "2026-09-16T20:40:15.396101+00:00",
|
|
310
|
+
"n_sessions": 50,
|
|
311
|
+
"n_new_sessions": 50,
|
|
312
|
+
"seeds": [0, 1, 2],
|
|
313
|
+
"accuracy_range": [0.942, 0.946],
|
|
314
|
+
"human_pass_rate_range": [0.964, 0.972],
|
|
315
|
+
"bot_catch_rate_range": [0.92, 0.92]
|
|
316
|
+
}
|
|
317
|
+
model file exists: True
|
|
318
|
+
```
|
|
319
|
+
|
|
320
|
+
Each retrain fits on every session recorded so far, runs the same
|
|
321
|
+
multi-seed `evaluate_cv` used above (so the record's ranges are the
|
|
322
|
+
defensible cross-seed numbers, not a single split), backs up the model it
|
|
323
|
+
replaces (`model.joblib.<timestamp>.bak`, never deleted automatically --
|
|
324
|
+
rollback is a file copy), and appends the summary to `state.json`. Not
|
|
325
|
+
built here, deliberately: any mechanism that would label sessions from
|
|
326
|
+
the detector's own predictions or from unverified live traffic. That's
|
|
327
|
+
the difference between "automates when you retrain" (this) and "trains
|
|
328
|
+
itself on whatever it sees" (a real risk of training-data poisoning, and
|
|
329
|
+
out of scope for this library — see [Scope](#scope)).
|
|
330
|
+
|
|
331
|
+
## Capturing real training data
|
|
332
|
+
|
|
333
|
+
The library only defines the schema and the feature math; you own the
|
|
334
|
+
client-side capture. On the page you're protecting, record `mousemove`
|
|
335
|
+
coordinates + timestamps and `keydown`/`keyup` timestamps into
|
|
336
|
+
`MouseEvent`/`KeyEvent` objects, tag each finished session with a label
|
|
337
|
+
(from a secondary signal you trust — e.g. a CAPTCHA outcome, an email
|
|
338
|
+
verification, or manual review), and either pass the collected
|
|
339
|
+
`InteractionSession` objects straight to `run_training_pipeline()`, or
|
|
340
|
+
persist them with `not_a_robot.io.save_sessions_jsonl()` (one JSON object
|
|
341
|
+
per line) so `python -m not_a_robot.train --data sessions.jsonl` can pick
|
|
342
|
+
them up later.
|
|
343
|
+
|
|
344
|
+
`examples/synthetic_data.py` generates crude synthetic sessions (one
|
|
345
|
+
human archetype and four weighted bot archetypes, see above) purely so
|
|
346
|
+
the rest of the pipeline has example data to run against before you have
|
|
347
|
+
real, labeled traffic. It is not a model of real bot or human behavior —
|
|
348
|
+
replace it with your own data before relying on this for anything.
|
|
349
|
+
|
|
350
|
+
## Scope
|
|
351
|
+
|
|
352
|
+
This library builds a defensive behavioral classifier for a system you run
|
|
353
|
+
and control. It intentionally does **not** include: CAPTCHA-solving (OCR,
|
|
354
|
+
image-grid classifiers), browser automation for clicking through third-party
|
|
355
|
+
challenges, integrations with CAPTCHA-solving services, or synthetic
|
|
356
|
+
mouse-trajectory generation meant to fool someone else's bot detection.
|
|
357
|
+
Those are a different (and, outside authorized testing of your own systems,
|
|
358
|
+
frequently abusive) category of tool.
|
|
359
|
+
|
|
360
|
+
## Development
|
|
361
|
+
|
|
362
|
+
```bash
|
|
363
|
+
pip install -e ".[dev]"
|
|
364
|
+
pytest
|
|
365
|
+
```
|