@axeptio/behavior-detection 1.0.3 → 1.1.0

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.
@@ -2,6 +2,11 @@
2
2
  * Mouse Movement Detection Strategy
3
3
  * Autonomous module that manages its own mouse event listeners and state
4
4
  * Tracks distance and angle to detect unnatural jumps and sharp turns
5
+ *
6
+ * Enhanced detection:
7
+ * - Pre-click stillness (micro-movements before clicks)
8
+ * - Entry point analysis (where mouse enters viewport)
9
+ * - Velocity consistency
5
10
  */
6
11
  import { BaseStrategy } from '../strategy.js';
7
12
  import { calculateStatistics, gaussian } from '../math-utils.js';
@@ -18,8 +23,15 @@ export class MouseStrategy extends BaseStrategy {
18
23
  this.rollingWindowMs = 5000;
19
24
  this.listener = null;
20
25
  this.leaveListener = null;
26
+ this.enterListener = null;
21
27
  this.isActive = false;
22
28
  this.screenDiagonal = 1;
29
+ /** Entry points tracking */
30
+ this.entryPoints = [];
31
+ /** Micro-movements in last 500ms (for stillness detection) */
32
+ this.microMovements = [];
33
+ /** Stillness window in ms */
34
+ this.STILLNESS_WINDOW = 500;
23
35
  if ((options === null || options === void 0 ? void 0 : options.rollingWindow) !== undefined)
24
36
  this.rollingWindowMs = options.rollingWindow;
25
37
  }
@@ -34,12 +46,21 @@ export class MouseStrategy extends BaseStrategy {
34
46
  this.listener = (e) => {
35
47
  const mouseEvent = e;
36
48
  const now = Date.now();
37
- const currentPos = { x: mouseEvent.clientX, y: mouseEvent.clientY };
49
+ const currentPos = { x: mouseEvent.clientX, y: mouseEvent.clientY, timestamp: now };
38
50
  // Calculate distance and angle from previous position
39
51
  if (this.lastPosition) {
40
52
  const dx = currentPos.x - this.lastPosition.x;
41
53
  const dy = currentPos.y - this.lastPosition.y;
42
54
  const pixelDistance = Math.sqrt(dx * dx + dy * dy);
55
+ // Track micro-movements (1-5px) for stillness detection
56
+ if (pixelDistance >= 1 && pixelDistance <= 5) {
57
+ this.microMovements.push({ dx, dy, timestamp: now });
58
+ // Clean up old micro-movements (shift from front, more efficient than filter)
59
+ const cutoffMicro = now - this.STILLNESS_WINDOW;
60
+ while (this.microMovements.length > 0 && this.microMovements[0].timestamp < cutoffMicro) {
61
+ this.microMovements.shift();
62
+ }
63
+ }
43
64
  // Normalize distance to screen diagonal (0-1 range)
44
65
  // A full diagonal movement = 1.0, a 10px movement on 2000px screen = 0.005
45
66
  const normalizedDistance = pixelDistance / this.screenDiagonal;
@@ -72,6 +93,67 @@ export class MouseStrategy extends BaseStrategy {
72
93
  this.lastPosition = currentPos;
73
94
  };
74
95
  document.addEventListener('mousemove', this.listener, { passive: true });
96
+ // Track mouse entry points into viewport
97
+ this.enterListener = (e) => {
98
+ const mouseEvent = e;
99
+ const now = Date.now();
100
+ const x = mouseEvent.clientX;
101
+ const y = mouseEvent.clientY;
102
+ const width = window.innerWidth;
103
+ const height = window.innerHeight;
104
+ // Calculate edge distances
105
+ const distTop = y;
106
+ const distBottom = height - y;
107
+ const distLeft = x;
108
+ const distRight = width - x;
109
+ const minEdgeDist = Math.min(distTop, distBottom, distLeft, distRight);
110
+ // Determine entry edge
111
+ let entryEdge;
112
+ const edgeThreshold = 50; // pixels from edge to count as edge entry
113
+ if (minEdgeDist < edgeThreshold) {
114
+ if (distTop <= minEdgeDist)
115
+ entryEdge = 'top';
116
+ else if (distBottom <= minEdgeDist)
117
+ entryEdge = 'bottom';
118
+ else if (distLeft <= minEdgeDist)
119
+ entryEdge = 'left';
120
+ else
121
+ entryEdge = 'right';
122
+ }
123
+ else if (x > width * 0.35 && x < width * 0.65 &&
124
+ y > height * 0.35 && y < height * 0.65) {
125
+ entryEdge = 'center'; // Suspicious - bots often start at center
126
+ }
127
+ else if ((distTop < edgeThreshold * 2 && distLeft < edgeThreshold * 2) ||
128
+ (distTop < edgeThreshold * 2 && distRight < edgeThreshold * 2) ||
129
+ (distBottom < edgeThreshold * 2 && distLeft < edgeThreshold * 2) ||
130
+ (distBottom < edgeThreshold * 2 && distRight < edgeThreshold * 2)) {
131
+ entryEdge = 'corner';
132
+ }
133
+ else {
134
+ // Entry from middle of an edge, normal
135
+ if (distTop < distBottom && distTop < distLeft && distTop < distRight)
136
+ entryEdge = 'top';
137
+ else if (distBottom < distLeft && distBottom < distRight)
138
+ entryEdge = 'bottom';
139
+ else if (distLeft < distRight)
140
+ entryEdge = 'left';
141
+ else
142
+ entryEdge = 'right';
143
+ }
144
+ this.entryPoints.push({
145
+ x,
146
+ y,
147
+ timestamp: now,
148
+ edgeDistance: minEdgeDist,
149
+ entryEdge,
150
+ });
151
+ // Keep only last 20 entry points (shift is more memory efficient than slice)
152
+ if (this.entryPoints.length > 20) {
153
+ this.entryPoints.shift();
154
+ }
155
+ };
156
+ document.addEventListener('mouseenter', this.enterListener, { passive: true });
75
157
  // Clear data when mouse leaves the window (discontinuous tracking)
76
158
  this.leaveListener = () => {
77
159
  this.distanceSeries = [];
@@ -79,6 +161,7 @@ export class MouseStrategy extends BaseStrategy {
79
161
  this.lastPosition = null;
80
162
  this.lastAngle = 0;
81
163
  this.cumulativeAngle = 0;
164
+ this.microMovements = [];
82
165
  };
83
166
  document.addEventListener('mouseleave', this.leaveListener, { passive: true });
84
167
  }
@@ -94,6 +177,10 @@ export class MouseStrategy extends BaseStrategy {
94
177
  document.removeEventListener('mouseleave', this.leaveListener);
95
178
  this.leaveListener = null;
96
179
  }
180
+ if (this.enterListener) {
181
+ document.removeEventListener('mouseenter', this.enterListener);
182
+ this.enterListener = null;
183
+ }
97
184
  }
98
185
  reset() {
99
186
  this.distanceSeries = [];
@@ -101,6 +188,8 @@ export class MouseStrategy extends BaseStrategy {
101
188
  this.lastPosition = null;
102
189
  this.lastAngle = 0;
103
190
  this.cumulativeAngle = 0;
191
+ this.entryPoints = [];
192
+ this.microMovements = [];
104
193
  }
105
194
  score() {
106
195
  if (this.distanceSeries.length < 10)
@@ -111,7 +200,7 @@ export class MouseStrategy extends BaseStrategy {
111
200
  }
112
201
  /**
113
202
  * Mouse-specific pattern detection
114
- * Detects bot-like patterns: constant velocity, linear paths
203
+ * Detects bot-like patterns: constant velocity, linear paths, suspicious entry points
115
204
  */
116
205
  detectMousePatterns() {
117
206
  if (this.distanceSeries.length < 10)
@@ -141,8 +230,112 @@ export class MouseStrategy extends BaseStrategy {
141
230
  score += gaussian(avgChange, 0.15, 0.12);
142
231
  factors++;
143
232
  }
233
+ // 3. ENTRY POINT ANALYSIS - Where does mouse enter the viewport?
234
+ const entryScore = this.scoreEntryPoints();
235
+ if (entryScore !== undefined) {
236
+ score += entryScore;
237
+ factors++;
238
+ }
239
+ // 4. MICRO-MOVEMENT PRESENCE - Humans have tremors, bots don't
240
+ const microScore = this.scoreMicroMovements();
241
+ if (microScore !== undefined) {
242
+ score += microScore;
243
+ factors++;
244
+ }
144
245
  return factors > 0 ? score / factors : undefined;
145
246
  }
247
+ /**
248
+ * Score based on viewport entry points
249
+ * Humans enter from edges with momentum; bots often start at (0,0) or center
250
+ */
251
+ scoreEntryPoints() {
252
+ if (this.entryPoints.length < 2)
253
+ return undefined;
254
+ let suspiciousCount = 0;
255
+ let centerCount = 0;
256
+ let cornerCount = 0;
257
+ let originCount = 0;
258
+ for (const entry of this.entryPoints) {
259
+ // Check for (0,0) or near-origin entries (very suspicious)
260
+ if (entry.x < 5 && entry.y < 5) {
261
+ originCount++;
262
+ suspiciousCount++;
263
+ }
264
+ else if (entry.entryEdge === 'center') {
265
+ centerCount++;
266
+ suspiciousCount++;
267
+ }
268
+ else if (entry.entryEdge === 'corner') {
269
+ cornerCount++;
270
+ // Corners are slightly suspicious but not as bad as center
271
+ if (entry.edgeDistance < 10)
272
+ suspiciousCount++;
273
+ }
274
+ }
275
+ const total = this.entryPoints.length;
276
+ const suspiciousRatio = suspiciousCount / total;
277
+ // High ratio of origin entries = definitely bot
278
+ if (originCount >= 2 || originCount / total >= 0.5)
279
+ return 0.1;
280
+ // High ratio of center entries = likely bot
281
+ if (centerCount / total >= 0.5)
282
+ return 0.2;
283
+ // Mix of suspicious entries
284
+ if (suspiciousRatio >= 0.7)
285
+ return 0.3;
286
+ if (suspiciousRatio >= 0.5)
287
+ return 0.5;
288
+ if (suspiciousRatio >= 0.3)
289
+ return 0.7;
290
+ // Mostly edge entries = human
291
+ return 1.0;
292
+ }
293
+ /**
294
+ * Score based on micro-movements (tremor detection)
295
+ * Humans have natural hand tremor causing 1-5px jitter
296
+ * Bots have perfect stillness or no micro-movements
297
+ */
298
+ scoreMicroMovements() {
299
+ // Need some movement data first
300
+ if (this.distanceSeries.length < 20)
301
+ return undefined;
302
+ const microCount = this.microMovements.length;
303
+ // Calculate expected micro-movements based on tracking duration
304
+ // Humans should have ~5-20 micro-movements per second when cursor is active
305
+ const oldestEvent = this.distanceSeries[0];
306
+ const newestEvent = this.distanceSeries[this.distanceSeries.length - 1];
307
+ const durationMs = newestEvent.timestamp - oldestEvent.timestamp;
308
+ if (durationMs < 1000)
309
+ return undefined; // Need at least 1 second of data
310
+ // Micro-movements per second
311
+ const microPerSecond = (microCount / durationMs) * 1000;
312
+ // No micro-movements at all = very suspicious
313
+ if (microCount === 0)
314
+ return 0.3;
315
+ // Very few micro-movements = somewhat suspicious
316
+ if (microPerSecond < 1)
317
+ return 0.5;
318
+ // Some micro-movements = more human-like
319
+ if (microPerSecond < 5)
320
+ return 0.7;
321
+ // Good amount of micro-movements = human
322
+ if (microPerSecond >= 5 && microPerSecond <= 30)
323
+ return 1.0;
324
+ // Excessive micro-movements (> 30/sec) might be artificial noise
325
+ return 0.8;
326
+ }
327
+ /**
328
+ * Get micro-movement count for external use (e.g., by ClickStrategy)
329
+ */
330
+ getMicroMovementCount() {
331
+ return this.microMovements.length;
332
+ }
333
+ /**
334
+ * Get last position for external use
335
+ */
336
+ getLastPosition() {
337
+ return this.lastPosition ? { x: this.lastPosition.x, y: this.lastPosition.y } : null;
338
+ }
146
339
  getDebugInfo() {
147
340
  return {
148
341
  eventCount: this.distanceSeries.length,
@@ -150,6 +343,9 @@ export class MouseStrategy extends BaseStrategy {
150
343
  isActive: this.isActive,
151
344
  distanceSeries: this.distanceSeries,
152
345
  angleSeries: this.angleSeries,
346
+ entryPoints: this.entryPoints,
347
+ microMovementCount: this.microMovements.length,
348
+ lastPosition: this.lastPosition,
153
349
  };
154
350
  }
155
351
  }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Timing Analysis Strategy
3
+ * Detects bot-like timing patterns in user interactions
4
+ *
5
+ * Key detection signals:
6
+ * - Pre-action stillness (humans have micro-movements before clicking)
7
+ * - Action timing patterns (machine-precise intervals)
8
+ * - Inactivity patterns (bursts after long silence, no idle variance)
9
+ * - Actions while tab is hidden (bots ignore visibility)
10
+ */
11
+ import { BaseStrategy } from '../strategy.js';
12
+ export declare class TimingStrategy extends BaseStrategy {
13
+ readonly name = "timing";
14
+ readonly defaultWeight = 0.15;
15
+ private actions;
16
+ private mouseStillness;
17
+ private microMovements;
18
+ private isActive;
19
+ private mouseListener;
20
+ private clickListener;
21
+ private keydownListener;
22
+ private scrollListener;
23
+ /** Time window to consider for pre-action stillness (ms) */
24
+ private readonly STILLNESS_WINDOW;
25
+ /** Minimum distance to count as meaningful movement (px) */
26
+ private readonly MICRO_MOVEMENT_THRESHOLD;
27
+ /** Maximum micro-movement to count (jitter vs intentional) (px) */
28
+ private readonly MAX_MICRO_MOVEMENT;
29
+ /** Machine-precision threshold (intervals within this are suspicious) */
30
+ private readonly MACHINE_PRECISION_THRESHOLD;
31
+ start(): void;
32
+ private recordAction;
33
+ stop(): void;
34
+ reset(): void;
35
+ score(): number | undefined;
36
+ /**
37
+ * Score based on pre-action stillness
38
+ * Humans have micro-movements before clicking; bots are perfectly still
39
+ */
40
+ private scorePreActionStillness;
41
+ /**
42
+ * Score mouse-to-click delay patterns (using pre-filtered cache)
43
+ */
44
+ private scoreMouseToClickDelayFromCache;
45
+ /**
46
+ * Score action intervals for machine-like precision
47
+ * Detects exact intervals: 100ms, 500ms, 1000ms
48
+ */
49
+ private scoreActionIntervals;
50
+ /**
51
+ * Score based on actions while document was hidden (using pre-computed count)
52
+ */
53
+ private scoreHiddenActionsFromCount;
54
+ getDebugInfo(): {
55
+ actionCount: number;
56
+ clickCount: number;
57
+ keydownCount: number;
58
+ scrollCount: number;
59
+ hiddenActionCount: number;
60
+ microMovementCount: number;
61
+ intervals: number[];
62
+ lastStillnessDuration: number | null;
63
+ };
64
+ }
@@ -0,0 +1,308 @@
1
+ /**
2
+ * Timing Analysis Strategy
3
+ * Detects bot-like timing patterns in user interactions
4
+ *
5
+ * Key detection signals:
6
+ * - Pre-action stillness (humans have micro-movements before clicking)
7
+ * - Action timing patterns (machine-precise intervals)
8
+ * - Inactivity patterns (bursts after long silence, no idle variance)
9
+ * - Actions while tab is hidden (bots ignore visibility)
10
+ */
11
+ import { BaseStrategy } from '../strategy.js';
12
+ import { calculateStatistics } from '../math-utils.js';
13
+ export class TimingStrategy extends BaseStrategy {
14
+ constructor() {
15
+ super(...arguments);
16
+ this.name = 'timing';
17
+ this.defaultWeight = 0.15;
18
+ this.actions = [];
19
+ this.mouseStillness = {
20
+ position: null,
21
+ lastMoveTime: 0,
22
+ microMovementCount: 0,
23
+ };
24
+ this.microMovements = [];
25
+ this.isActive = false;
26
+ this.mouseListener = null;
27
+ this.clickListener = null;
28
+ this.keydownListener = null;
29
+ this.scrollListener = null;
30
+ /** Time window to consider for pre-action stillness (ms) */
31
+ this.STILLNESS_WINDOW = 500;
32
+ /** Minimum distance to count as meaningful movement (px) */
33
+ this.MICRO_MOVEMENT_THRESHOLD = 1;
34
+ /** Maximum micro-movement to count (jitter vs intentional) (px) */
35
+ this.MAX_MICRO_MOVEMENT = 5;
36
+ /** Machine-precision threshold (intervals within this are suspicious) */
37
+ this.MACHINE_PRECISION_THRESHOLD = 5; // 5ms
38
+ }
39
+ start() {
40
+ if (this.isActive)
41
+ return;
42
+ this.isActive = true;
43
+ // Track mouse movement for stillness detection
44
+ this.mouseListener = (e) => {
45
+ const mouseEvent = e;
46
+ const now = Date.now();
47
+ const currentPos = { x: mouseEvent.clientX, y: mouseEvent.clientY };
48
+ if (this.mouseStillness.position) {
49
+ const dx = currentPos.x - this.mouseStillness.position.x;
50
+ const dy = currentPos.y - this.mouseStillness.position.y;
51
+ const distance = Math.sqrt(dx * dx + dy * dy);
52
+ // Track micro-movements (1-5px range)
53
+ if (distance >= this.MICRO_MOVEMENT_THRESHOLD && distance <= this.MAX_MICRO_MOVEMENT) {
54
+ this.microMovements.push({ timestamp: now, distance });
55
+ }
56
+ // Any meaningful movement updates the last move time
57
+ if (distance >= this.MICRO_MOVEMENT_THRESHOLD) {
58
+ this.mouseStillness.lastMoveTime = now;
59
+ }
60
+ }
61
+ this.mouseStillness.position = currentPos;
62
+ // Clean up old micro-movements (keep only last STILLNESS_WINDOW)
63
+ const cutoff = now - this.STILLNESS_WINDOW;
64
+ this.microMovements = this.microMovements.filter(m => m.timestamp >= cutoff);
65
+ };
66
+ // Track clicks with timing context
67
+ this.clickListener = (e) => {
68
+ this.recordAction('click', e);
69
+ };
70
+ // Track keydown with timing context
71
+ this.keydownListener = () => {
72
+ this.recordAction('keydown');
73
+ };
74
+ // Track scroll with timing context
75
+ this.scrollListener = () => {
76
+ this.recordAction('scroll');
77
+ };
78
+ document.addEventListener('mousemove', this.mouseListener, { passive: true });
79
+ document.addEventListener('click', this.clickListener, { passive: true });
80
+ document.addEventListener('keydown', this.keydownListener, { passive: true });
81
+ document.addEventListener('scroll', this.scrollListener, { passive: true });
82
+ }
83
+ recordAction(type, _event) {
84
+ const now = Date.now();
85
+ // Calculate time since last mouse movement
86
+ const timeSinceMouseMove = this.mouseStillness.lastMoveTime > 0
87
+ ? now - this.mouseStillness.lastMoveTime
88
+ : -1;
89
+ // Count micro-movements in stillness window (already filtered in mouse listener)
90
+ this.mouseStillness.microMovementCount = this.microMovements.length;
91
+ this.actions.push({
92
+ type,
93
+ timestamp: now,
94
+ timeSinceMouseMove,
95
+ wasHidden: document.hidden,
96
+ });
97
+ // Notify detector
98
+ this.notifyEvent(0.7);
99
+ // Keep only last 100 actions for memory efficiency (shift is O(n) but rare)
100
+ if (this.actions.length > 100) {
101
+ this.actions.shift();
102
+ }
103
+ }
104
+ stop() {
105
+ if (!this.isActive)
106
+ return;
107
+ this.isActive = false;
108
+ if (this.mouseListener) {
109
+ document.removeEventListener('mousemove', this.mouseListener);
110
+ this.mouseListener = null;
111
+ }
112
+ if (this.clickListener) {
113
+ document.removeEventListener('click', this.clickListener);
114
+ this.clickListener = null;
115
+ }
116
+ if (this.keydownListener) {
117
+ document.removeEventListener('keydown', this.keydownListener);
118
+ this.keydownListener = null;
119
+ }
120
+ if (this.scrollListener) {
121
+ document.removeEventListener('scroll', this.scrollListener);
122
+ this.scrollListener = null;
123
+ }
124
+ }
125
+ reset() {
126
+ this.actions = [];
127
+ this.microMovements = [];
128
+ this.mouseStillness = {
129
+ position: null,
130
+ lastMoveTime: 0,
131
+ microMovementCount: 0,
132
+ };
133
+ }
134
+ score() {
135
+ const actionCount = this.actions.length;
136
+ if (actionCount < 3)
137
+ return undefined;
138
+ let score = 0;
139
+ let factors = 0;
140
+ // Cache filtered arrays to avoid repeated iteration
141
+ const clicks = [];
142
+ const clicksWithMouse = [];
143
+ let hiddenCount = 0;
144
+ for (const action of this.actions) {
145
+ if (action.type === 'click') {
146
+ clicks.push(action);
147
+ if (action.timeSinceMouseMove >= 0) {
148
+ clicksWithMouse.push(action);
149
+ }
150
+ }
151
+ if (action.wasHidden)
152
+ hiddenCount++;
153
+ }
154
+ // 1. Pre-action stillness detection
155
+ if (clicks.length >= 2) {
156
+ const stillnessScore = this.scorePreActionStillness(clicks);
157
+ if (stillnessScore !== undefined) {
158
+ score += stillnessScore;
159
+ factors++;
160
+ }
161
+ }
162
+ // 2. Mouse-to-action delay analysis
163
+ if (clicksWithMouse.length >= 3) {
164
+ const mouseToClickScore = this.scoreMouseToClickDelayFromCache(clicksWithMouse);
165
+ if (mouseToClickScore !== undefined) {
166
+ score += mouseToClickScore;
167
+ factors++;
168
+ }
169
+ }
170
+ // 3. Action interval patterns
171
+ if (actionCount >= 5) {
172
+ const intervalScore = this.scoreActionIntervals();
173
+ if (intervalScore !== undefined) {
174
+ score += intervalScore;
175
+ factors++;
176
+ }
177
+ }
178
+ // 4. Hidden document actions
179
+ const hiddenScore = this.scoreHiddenActionsFromCount(hiddenCount, actionCount);
180
+ score += hiddenScore;
181
+ factors++;
182
+ return factors > 0 ? score / factors : undefined;
183
+ }
184
+ /**
185
+ * Score based on pre-action stillness
186
+ * Humans have micro-movements before clicking; bots are perfectly still
187
+ */
188
+ scorePreActionStillness(clicks) {
189
+ // Look at clicks with mouse data
190
+ const clicksWithMouseData = clicks.filter(c => c.timeSinceMouseMove >= 0);
191
+ if (clicksWithMouseData.length < 2)
192
+ return undefined;
193
+ // Count clicks with zero pre-action movement (100-500ms window)
194
+ const perfectlyStillClicks = clicksWithMouseData.filter(c => {
195
+ // Time since mouse move in the suspicious range (100-500ms of perfect stillness)
196
+ return c.timeSinceMouseMove >= 100 && c.timeSinceMouseMove <= 500 &&
197
+ this.mouseStillness.microMovementCount === 0;
198
+ }).length;
199
+ const stillRatio = perfectlyStillClicks / clicksWithMouseData.length;
200
+ // High ratio of perfectly still clicks = likely bot
201
+ // 0% still = very human (1.0)
202
+ // 50%+ still = suspicious (0.3)
203
+ // 100% still = very bot-like (0.1)
204
+ if (stillRatio >= 0.9)
205
+ return 0.1;
206
+ if (stillRatio >= 0.7)
207
+ return 0.3;
208
+ if (stillRatio >= 0.5)
209
+ return 0.5;
210
+ if (stillRatio >= 0.3)
211
+ return 0.7;
212
+ return 1.0;
213
+ }
214
+ /**
215
+ * Score mouse-to-click delay patterns (using pre-filtered cache)
216
+ */
217
+ scoreMouseToClickDelayFromCache(clicksWithMouse) {
218
+ const delays = clicksWithMouse.map(c => c.timeSinceMouseMove);
219
+ const stats = calculateStatistics(delays);
220
+ // Suspiciously fast clicks (< 10ms average)
221
+ if (stats.mean < 10)
222
+ return 0.1;
223
+ // Suspiciously consistent delays (CV < 0.2)
224
+ if (stats.cv < 0.2 && clicksWithMouse.length >= 5)
225
+ return 0.2;
226
+ // Normal human range (50-300ms with variation)
227
+ if (stats.mean >= 50 && stats.mean <= 300 && stats.cv >= 0.3)
228
+ return 1.0;
229
+ // Somewhat suspicious
230
+ if (stats.cv < 0.4)
231
+ return 0.6;
232
+ return 0.8;
233
+ }
234
+ /**
235
+ * Score action intervals for machine-like precision
236
+ * Detects exact intervals: 100ms, 500ms, 1000ms
237
+ */
238
+ scoreActionIntervals() {
239
+ if (this.actions.length < 5)
240
+ return undefined;
241
+ const intervals = [];
242
+ for (let i = 1; i < this.actions.length; i++) {
243
+ intervals.push(this.actions[i].timestamp - this.actions[i - 1].timestamp);
244
+ }
245
+ // Check for machine-precise intervals (multiples of common values)
246
+ const machineIntervals = [100, 200, 250, 500, 1000];
247
+ let preciseCount = 0;
248
+ for (const interval of intervals) {
249
+ for (const machineInterval of machineIntervals) {
250
+ // Check if interval is within threshold of machine interval or its multiples
251
+ const remainder = interval % machineInterval;
252
+ if (remainder < this.MACHINE_PRECISION_THRESHOLD ||
253
+ (machineInterval - remainder) < this.MACHINE_PRECISION_THRESHOLD) {
254
+ preciseCount++;
255
+ break;
256
+ }
257
+ }
258
+ }
259
+ const preciseRatio = preciseCount / intervals.length;
260
+ // High ratio of machine-precise intervals = bot
261
+ if (preciseRatio >= 0.8)
262
+ return 0.1;
263
+ if (preciseRatio >= 0.6)
264
+ return 0.3;
265
+ if (preciseRatio >= 0.4)
266
+ return 0.5;
267
+ // Also check coefficient of variation (should be high for humans)
268
+ const stats = calculateStatistics(intervals);
269
+ if (stats.cv < 0.15)
270
+ return 0.2; // Too consistent
271
+ return 1.0;
272
+ }
273
+ /**
274
+ * Score based on actions while document was hidden (using pre-computed count)
275
+ */
276
+ scoreHiddenActionsFromCount(hiddenCount, totalCount) {
277
+ if (hiddenCount === 0)
278
+ return 1.0; // Perfect - no hidden actions
279
+ const hiddenRatio = hiddenCount / totalCount;
280
+ // Any hidden actions are suspicious
281
+ if (hiddenRatio > 0.5)
282
+ return 0.1; // Major bot indicator
283
+ if (hiddenRatio > 0.2)
284
+ return 0.3;
285
+ if (hiddenRatio > 0)
286
+ return 0.5;
287
+ return 1.0;
288
+ }
289
+ getDebugInfo() {
290
+ const clicks = this.actions.filter(a => a.type === 'click');
291
+ const intervals = [];
292
+ for (let i = 1; i < this.actions.length; i++) {
293
+ intervals.push(this.actions[i].timestamp - this.actions[i - 1].timestamp);
294
+ }
295
+ return {
296
+ actionCount: this.actions.length,
297
+ clickCount: clicks.length,
298
+ keydownCount: this.actions.filter(a => a.type === 'keydown').length,
299
+ scrollCount: this.actions.filter(a => a.type === 'scroll').length,
300
+ hiddenActionCount: this.actions.filter(a => a.wasHidden).length,
301
+ microMovementCount: this.microMovements.length,
302
+ intervals: intervals.slice(-20), // Last 20 intervals
303
+ lastStillnessDuration: this.mouseStillness.lastMoveTime > 0
304
+ ? Date.now() - this.mouseStillness.lastMoveTime
305
+ : null,
306
+ };
307
+ }
308
+ }