@axeptio/behavior-detection 1.0.2 → 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.
- package/README.md +126 -12
- package/dist/behavior-detection.esm.min.js +1 -1
- package/dist/behavior-detection.esm.min.js.map +4 -4
- package/dist/behavior-detection.min.js +1 -1
- package/dist/behavior-detection.min.js.map +3 -3
- package/dist/cjs/index.cjs +2320 -0
- package/dist/esm/browser.js +0 -2
- package/dist/esm/index.d.ts +1 -1
- package/dist/esm/index.js +1 -1
- package/dist/esm/strategies/click.d.ts +8 -0
- package/dist/esm/strategies/click.js +108 -9
- package/dist/esm/strategies/environment.d.ts +39 -0
- package/dist/esm/strategies/environment.js +188 -7
- package/dist/esm/strategies/index.d.ts +4 -0
- package/dist/esm/strategies/index.js +4 -0
- package/dist/esm/strategies/mouse.d.ts +53 -1
- package/dist/esm/strategies/mouse.js +198 -2
- package/dist/esm/strategies/timing.d.ts +64 -0
- package/dist/esm/strategies/timing.js +308 -0
- package/dist/esm/strategies/visibility.d.ts +64 -0
- package/dist/esm/strategies/visibility.js +295 -0
- package/dist/index.d.ts +1 -1
- package/dist/strategies/click.d.ts +8 -0
- package/dist/strategies/environment.d.ts +39 -0
- package/dist/strategies/index.d.ts +4 -0
- package/dist/strategies/mouse.d.ts +53 -1
- package/dist/strategies/timing.d.ts +64 -0
- package/dist/strategies/visibility.d.ts +64 -0
- package/package.json +16 -15
- package/dist/cjs/behavior-detector.d.ts +0 -102
- package/dist/cjs/behavior-detector.js +0 -315
- package/dist/cjs/browser.d.ts +0 -33
- package/dist/cjs/browser.js +0 -226
- package/dist/cjs/index.d.ts +0 -38
- package/dist/cjs/index.js +0 -55
- package/dist/cjs/math-utils.d.ts +0 -84
- package/dist/cjs/math-utils.js +0 -141
- package/dist/cjs/strategies/click.d.ts +0 -39
- package/dist/cjs/strategies/click.js +0 -173
- package/dist/cjs/strategies/environment.d.ts +0 -52
- package/dist/cjs/strategies/environment.js +0 -148
- package/dist/cjs/strategies/index.d.ts +0 -18
- package/dist/cjs/strategies/index.js +0 -36
- package/dist/cjs/strategies/keyboard.d.ts +0 -43
- package/dist/cjs/strategies/keyboard.js +0 -233
- package/dist/cjs/strategies/mouse.d.ts +0 -39
- package/dist/cjs/strategies/mouse.js +0 -159
- package/dist/cjs/strategies/resize.d.ts +0 -21
- package/dist/cjs/strategies/resize.js +0 -97
- package/dist/cjs/strategies/scroll.d.ts +0 -37
- package/dist/cjs/strategies/scroll.js +0 -149
- package/dist/cjs/strategies/tap.d.ts +0 -38
- package/dist/cjs/strategies/tap.js +0 -214
- package/dist/cjs/strategy.d.ts +0 -107
- package/dist/cjs/strategy.js +0 -33
- package/dist/cjs/types.d.ts +0 -168
- package/dist/cjs/types.js +0 -26
- package/dist/esm/browser-iife.d.ts +0 -5
- package/dist/esm/browser-iife.js +0 -157
|
@@ -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
|
+
}
|