@niksbanna/bot-detector 1.0.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.
Files changed (38) hide show
  1. package/README.md +245 -0
  2. package/dist/bot-detector.cjs.js +2629 -0
  3. package/dist/bot-detector.cjs.js.map +7 -0
  4. package/dist/bot-detector.esm.js +2609 -0
  5. package/dist/bot-detector.esm.js.map +7 -0
  6. package/dist/bot-detector.iife.js +2631 -0
  7. package/dist/bot-detector.iife.js.map +7 -0
  8. package/dist/bot-detector.iife.min.js +1 -0
  9. package/package.json +57 -0
  10. package/src/core/BotDetector.js +284 -0
  11. package/src/core/ScoringEngine.js +134 -0
  12. package/src/core/Signal.js +181 -0
  13. package/src/core/VerdictEngine.js +132 -0
  14. package/src/index.js +273 -0
  15. package/src/signals/automation/PhantomJSSignal.js +137 -0
  16. package/src/signals/automation/PlaywrightSignal.js +129 -0
  17. package/src/signals/automation/PuppeteerSignal.js +117 -0
  18. package/src/signals/automation/SeleniumSignal.js +151 -0
  19. package/src/signals/automation/index.js +8 -0
  20. package/src/signals/behavior/InteractionTimingSignal.js +170 -0
  21. package/src/signals/behavior/KeyboardPatternSignal.js +235 -0
  22. package/src/signals/behavior/MouseMovementSignal.js +215 -0
  23. package/src/signals/behavior/ScrollBehaviorSignal.js +236 -0
  24. package/src/signals/behavior/index.js +8 -0
  25. package/src/signals/environment/HeadlessSignal.js +97 -0
  26. package/src/signals/environment/NavigatorAnomalySignal.js +117 -0
  27. package/src/signals/environment/PermissionsSignal.js +76 -0
  28. package/src/signals/environment/WebDriverSignal.js +58 -0
  29. package/src/signals/environment/index.js +8 -0
  30. package/src/signals/fingerprint/AudioContextSignal.js +158 -0
  31. package/src/signals/fingerprint/CanvasSignal.js +133 -0
  32. package/src/signals/fingerprint/PluginsSignal.js +106 -0
  33. package/src/signals/fingerprint/ScreenSignal.js +157 -0
  34. package/src/signals/fingerprint/WebGLSignal.js +146 -0
  35. package/src/signals/fingerprint/index.js +9 -0
  36. package/src/signals/timing/DOMContentTimingSignal.js +159 -0
  37. package/src/signals/timing/PageLoadSignal.js +165 -0
  38. package/src/signals/timing/index.js +6 -0
@@ -0,0 +1,284 @@
1
+ /**
2
+ * @fileoverview Main BotDetector orchestrator class.
3
+ */
4
+
5
+ import { Signal } from './Signal.js';
6
+ import { ScoringEngine } from './ScoringEngine.js';
7
+ import { VerdictEngine, Verdict } from './VerdictEngine.js';
8
+
9
+ /**
10
+ * Main bot detection orchestrator.
11
+ * Manages signals, runs detection, and produces verdicts.
12
+ *
13
+ * @example
14
+ * const detector = new BotDetector();
15
+ * const result = await detector.detect();
16
+ * console.log(result.verdict); // 'human', 'suspicious', or 'bot'
17
+ */
18
+ class BotDetector {
19
+ /**
20
+ * Creates a new BotDetector instance.
21
+ * @param {Object} [options={}] - Configuration options
22
+ * @param {Array<Signal>} [options.signals=[]] - Initial signals to register
23
+ * @param {Object.<string, number>} [options.weightOverrides={}] - Override signal weights
24
+ * @param {number} [options.humanThreshold=20] - Score threshold for human verdict
25
+ * @param {number} [options.suspiciousThreshold=50] - Score threshold for suspicious verdict
26
+ * @param {Array<string>} [options.instantBotSignals=[]] - Signal IDs that instantly flag as bot
27
+ * @param {boolean} [options.includeDefaults=true] - Include built-in signal detectors
28
+ * @param {number} [options.detectionTimeout=5000] - Timeout for detection in ms
29
+ */
30
+ constructor(options = {}) {
31
+ this.options = options;
32
+ this._signals = new Map();
33
+ this._scoringEngine = new ScoringEngine({
34
+ weightOverrides: options.weightOverrides,
35
+ });
36
+ this._verdictEngine = new VerdictEngine({
37
+ humanThreshold: options.humanThreshold,
38
+ suspiciousThreshold: options.suspiciousThreshold,
39
+ instantBotSignals: options.instantBotSignals,
40
+ });
41
+ this._lastDetection = null;
42
+ this._detectionTimeout = options.detectionTimeout || 5000;
43
+ this._isRunning = false;
44
+
45
+ // Register initial signals
46
+ if (options.signals) {
47
+ for (const signal of options.signals) {
48
+ this.registerSignal(signal);
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Register a signal detector.
55
+ * @param {Signal} signal - Signal instance to register
56
+ * @returns {BotDetector} this instance for chaining
57
+ * @throws {Error} If signal with same ID already registered
58
+ */
59
+ registerSignal(signal) {
60
+ if (!(signal instanceof Signal)) {
61
+ throw new Error('Signal must be an instance of Signal class');
62
+ }
63
+
64
+ const id = signal.id;
65
+ if (this._signals.has(id)) {
66
+ throw new Error(`Signal with ID "${id}" is already registered`);
67
+ }
68
+
69
+ this._signals.set(id, signal);
70
+ return this;
71
+ }
72
+
73
+ /**
74
+ * Register multiple signals at once.
75
+ * @param {Array<Signal>} signals - Array of signal instances
76
+ * @returns {BotDetector} this instance for chaining
77
+ */
78
+ registerSignals(signals) {
79
+ for (const signal of signals) {
80
+ this.registerSignal(signal);
81
+ }
82
+ return this;
83
+ }
84
+
85
+ /**
86
+ * Unregister a signal by ID.
87
+ * @param {string} signalId - Signal ID to remove
88
+ * @returns {boolean} True if signal was removed
89
+ */
90
+ unregisterSignal(signalId) {
91
+ return this._signals.delete(signalId);
92
+ }
93
+
94
+ /**
95
+ * Get a registered signal by ID.
96
+ * @param {string} signalId - Signal ID
97
+ * @returns {Signal|undefined}
98
+ */
99
+ getSignal(signalId) {
100
+ return this._signals.get(signalId);
101
+ }
102
+
103
+ /**
104
+ * Get all registered signals.
105
+ * @returns {Array<Signal>}
106
+ */
107
+ getSignals() {
108
+ return Array.from(this._signals.values());
109
+ }
110
+
111
+ /**
112
+ * Get signals by category.
113
+ * @param {string} category - Category name
114
+ * @returns {Array<Signal>}
115
+ */
116
+ getSignalsByCategory(category) {
117
+ return this.getSignals().filter(s => s.category === category);
118
+ }
119
+
120
+ /**
121
+ * Run all signal detectors and calculate verdict.
122
+ * @param {Object} [options={}] - Detection options
123
+ * @param {boolean} [options.skipInteractionSignals=false] - Skip signals requiring interaction
124
+ * @returns {Promise<DetectionResult>}
125
+ */
126
+ async detect(options = {}) {
127
+ if (this._isRunning) {
128
+ throw new Error('Detection is already running');
129
+ }
130
+
131
+ this._isRunning = true;
132
+ this._scoringEngine.reset();
133
+
134
+ const startTime = performance.now();
135
+ const signalResults = new Map();
136
+
137
+ try {
138
+ // Filter signals based on options
139
+ const signalsToRun = this.getSignals().filter(signal => {
140
+ if (options.skipInteractionSignals && signal.requiresInteraction) {
141
+ return false;
142
+ }
143
+ return true;
144
+ });
145
+
146
+ // Run all signals with timeout
147
+ const detectionPromises = signalsToRun.map(async signal => {
148
+ const result = await Promise.race([
149
+ signal.run(),
150
+ new Promise(resolve =>
151
+ setTimeout(() => resolve({
152
+ triggered: false,
153
+ value: null,
154
+ confidence: 0,
155
+ error: 'timeout'
156
+ }), this._detectionTimeout)
157
+ ),
158
+ ]);
159
+
160
+ return { signal, result };
161
+ });
162
+
163
+ const results = await Promise.all(detectionPromises);
164
+
165
+ // Process results
166
+ for (const { signal, result } of results) {
167
+ signalResults.set(signal.id, {
168
+ ...result,
169
+ category: signal.category,
170
+ weight: signal.weight,
171
+ description: signal.description,
172
+ });
173
+
174
+ this._scoringEngine.addResult(signal.id, result, signal.weight);
175
+ }
176
+
177
+ // Calculate score and verdict
178
+ const score = this._scoringEngine.calculate();
179
+ const triggeredSignals = this._scoringEngine.getTriggeredSignals();
180
+ const verdict = this._verdictEngine.getVerdict(score, triggeredSignals);
181
+
182
+ const detectionTime = performance.now() - startTime;
183
+
184
+ this._lastDetection = {
185
+ ...verdict,
186
+ signals: Object.fromEntries(signalResults),
187
+ breakdown: this._scoringEngine.getBreakdown(),
188
+ timestamp: Date.now(),
189
+ detectionTimeMs: Math.round(detectionTime),
190
+ totalSignals: signalsToRun.length,
191
+ triggeredSignals,
192
+ };
193
+
194
+ return this._lastDetection;
195
+ } finally {
196
+ this._isRunning = false;
197
+ }
198
+ }
199
+
200
+ /**
201
+ * Get the last detection result.
202
+ * @returns {DetectionResult|null}
203
+ */
204
+ getLastDetection() {
205
+ return this._lastDetection;
206
+ }
207
+
208
+ /**
209
+ * Get the current score (from last detection).
210
+ * @returns {number}
211
+ */
212
+ getScore() {
213
+ return this._lastDetection?.score ?? 0;
214
+ }
215
+
216
+ /**
217
+ * Get triggered signals from last detection.
218
+ * @returns {Array<string>}
219
+ */
220
+ getTriggeredSignals() {
221
+ return this._lastDetection?.triggeredSignals ?? [];
222
+ }
223
+
224
+ /**
225
+ * Check if detection is currently running.
226
+ * @returns {boolean}
227
+ */
228
+ isRunning() {
229
+ return this._isRunning;
230
+ }
231
+
232
+ /**
233
+ * Reset the detector state.
234
+ */
235
+ reset() {
236
+ this._scoringEngine.reset();
237
+ this._lastDetection = null;
238
+ for (const signal of this._signals.values()) {
239
+ signal.reset();
240
+ }
241
+ }
242
+
243
+ /**
244
+ * Update configuration options.
245
+ * @param {Object} options - New options
246
+ */
247
+ configure(options) {
248
+ if (options.humanThreshold !== undefined || options.suspiciousThreshold !== undefined) {
249
+ this._verdictEngine.setThresholds({
250
+ human: options.humanThreshold,
251
+ suspicious: options.suspiciousThreshold,
252
+ });
253
+ }
254
+ if (options.detectionTimeout !== undefined) {
255
+ this._detectionTimeout = options.detectionTimeout;
256
+ }
257
+ }
258
+
259
+ /**
260
+ * Create a detector with default signals.
261
+ * @param {Object} [options={}] - Configuration options
262
+ * @returns {BotDetector}
263
+ */
264
+ static withDefaults(options = {}) {
265
+ // This will be populated with default signals in the main export
266
+ return new BotDetector(options);
267
+ }
268
+ }
269
+
270
+ /**
271
+ * @typedef {Object} DetectionResult
272
+ * @property {string} verdict - 'human', 'suspicious', or 'bot'
273
+ * @property {number} score - Calculated score (0-100)
274
+ * @property {string} confidence - Confidence level
275
+ * @property {string} reason - Reason for verdict
276
+ * @property {Object} signals - Map of signal ID to results
277
+ * @property {Array<Object>} breakdown - Score contribution breakdown
278
+ * @property {number} timestamp - Detection timestamp
279
+ * @property {number} detectionTimeMs - Time taken for detection
280
+ * @property {number} totalSignals - Total signals evaluated
281
+ * @property {Array<string>} triggeredSignals - IDs of triggered signals
282
+ */
283
+
284
+ export { BotDetector, Verdict };
@@ -0,0 +1,134 @@
1
+ /**
2
+ * @fileoverview Scoring engine that calculates bot probability from signals.
3
+ */
4
+
5
+ /**
6
+ * Calculates a weighted score from signal results.
7
+ * Score represents the probability of the visitor being a bot.
8
+ */
9
+ class ScoringEngine {
10
+ /**
11
+ * Creates a new ScoringEngine instance.
12
+ * @param {Object} [options={}] - Configuration options
13
+ * @param {Object.<string, number>} [options.weightOverrides={}] - Override weights by signal ID
14
+ * @param {number} [options.maxScore=100] - Maximum possible score
15
+ */
16
+ constructor(options = {}) {
17
+ this.weightOverrides = options.weightOverrides || {};
18
+ this.maxScore = options.maxScore || 100;
19
+ this._results = new Map();
20
+ }
21
+
22
+ /**
23
+ * Get the effective weight for a signal.
24
+ * @param {string} signalId - Signal identifier
25
+ * @param {number} defaultWeight - Default weight from signal definition
26
+ * @returns {number}
27
+ */
28
+ getWeight(signalId, defaultWeight) {
29
+ return this.weightOverrides[signalId] ?? defaultWeight;
30
+ }
31
+
32
+ /**
33
+ * Add a signal result to the scoring calculation.
34
+ * @param {string} signalId - Signal identifier
35
+ * @param {Object} result - Signal detection result
36
+ * @param {boolean} result.triggered - Whether signal was triggered
37
+ * @param {number} result.confidence - Confidence level (0-1)
38
+ * @param {number} weight - Signal weight (0.1-1.0)
39
+ */
40
+ addResult(signalId, result, weight) {
41
+ const effectiveWeight = this.getWeight(signalId, weight);
42
+ this._results.set(signalId, {
43
+ ...result,
44
+ weight: effectiveWeight,
45
+ contribution: result.triggered ? effectiveWeight * result.confidence : 0,
46
+ });
47
+ }
48
+
49
+ /**
50
+ * Calculate the final score from all added results.
51
+ * Formula: (sum of contributions / sum of weights) * maxScore
52
+ *
53
+ * @returns {number} Score between 0 and maxScore
54
+ */
55
+ calculate() {
56
+ if (this._results.size === 0) {
57
+ return 0;
58
+ }
59
+
60
+ let totalWeight = 0;
61
+ let totalContribution = 0;
62
+
63
+ for (const [, data] of this._results) {
64
+ totalWeight += data.weight;
65
+ totalContribution += data.contribution;
66
+ }
67
+
68
+ if (totalWeight === 0) {
69
+ return 0;
70
+ }
71
+
72
+ const score = (totalContribution / totalWeight) * this.maxScore;
73
+ return Math.round(score * 100) / 100; // Round to 2 decimal places
74
+ }
75
+
76
+ /**
77
+ * Get detailed breakdown of score contributions.
78
+ * @returns {Array<Object>} Array of signal contributions
79
+ */
80
+ getBreakdown() {
81
+ const breakdown = [];
82
+
83
+ for (const [signalId, data] of this._results) {
84
+ breakdown.push({
85
+ signalId,
86
+ triggered: data.triggered,
87
+ confidence: data.confidence,
88
+ weight: data.weight,
89
+ contribution: data.contribution,
90
+ percentOfScore: this.calculate() > 0
91
+ ? (data.contribution / this.calculate() * 100).toFixed(1)
92
+ : '0.0',
93
+ });
94
+ }
95
+
96
+ // Sort by contribution (highest first)
97
+ return breakdown.sort((a, b) => b.contribution - a.contribution);
98
+ }
99
+
100
+ /**
101
+ * Get all triggered signals.
102
+ * @returns {Array<string>} Array of triggered signal IDs
103
+ */
104
+ getTriggeredSignals() {
105
+ const triggered = [];
106
+ for (const [signalId, data] of this._results) {
107
+ if (data.triggered) {
108
+ triggered.push(signalId);
109
+ }
110
+ }
111
+ return triggered;
112
+ }
113
+
114
+ /**
115
+ * Get the number of triggered signals.
116
+ * @returns {number}
117
+ */
118
+ getTriggeredCount() {
119
+ let count = 0;
120
+ for (const [, data] of this._results) {
121
+ if (data.triggered) count++;
122
+ }
123
+ return count;
124
+ }
125
+
126
+ /**
127
+ * Reset all stored results.
128
+ */
129
+ reset() {
130
+ this._results.clear();
131
+ }
132
+ }
133
+
134
+ export { ScoringEngine };
@@ -0,0 +1,181 @@
1
+ /**
2
+ * @fileoverview Base Signal class for bot detection signals.
3
+ * All signal detectors must extend this class.
4
+ */
5
+
6
+ /**
7
+ * Base class for all signal detectors.
8
+ * Signals detect specific indicators of automated browser behavior.
9
+ *
10
+ * @abstract
11
+ * @example
12
+ * class CustomSignal extends Signal {
13
+ * static id = 'custom-signal';
14
+ * static category = 'custom';
15
+ * static weight = 0.5;
16
+ * static description = 'Detects custom bot behavior';
17
+ *
18
+ * async detect() {
19
+ * const isSuspicious = // ... detection logic
20
+ * return {
21
+ * triggered: isSuspicious,
22
+ * value: someValue,
23
+ * confidence: 0.8
24
+ * };
25
+ * }
26
+ * }
27
+ */
28
+ class Signal {
29
+ /**
30
+ * Unique identifier for this signal.
31
+ * @type {string}
32
+ */
33
+ static id = 'base-signal';
34
+
35
+ /**
36
+ * Category this signal belongs to.
37
+ * Categories: 'environment', 'behavior', 'fingerprint', 'timing', 'automation'
38
+ * @type {string}
39
+ */
40
+ static category = 'uncategorized';
41
+
42
+ /**
43
+ * Weight of this signal in the scoring calculation.
44
+ * Range: 0.1 (low importance) to 1.0 (high importance)
45
+ * @type {number}
46
+ */
47
+ static weight = 0.5;
48
+
49
+ /**
50
+ * Human-readable description of what this signal detects.
51
+ * @type {string}
52
+ */
53
+ static description = 'Base signal class';
54
+
55
+ /**
56
+ * Whether this signal requires user interaction before it can detect.
57
+ * @type {boolean}
58
+ */
59
+ static requiresInteraction = false;
60
+
61
+ /**
62
+ * Creates a new Signal instance.
63
+ * @param {Object} [options={}] - Configuration options for this signal.
64
+ */
65
+ constructor(options = {}) {
66
+ this.options = options;
67
+ this._lastResult = null;
68
+ }
69
+
70
+ /**
71
+ * Get the signal's unique identifier.
72
+ * @returns {string}
73
+ */
74
+ get id() {
75
+ return this.constructor.id;
76
+ }
77
+
78
+ /**
79
+ * Get the signal's category.
80
+ * @returns {string}
81
+ */
82
+ get category() {
83
+ return this.constructor.category;
84
+ }
85
+
86
+ /**
87
+ * Get the signal's weight.
88
+ * @returns {number}
89
+ */
90
+ get weight() {
91
+ return this.options.weight ?? this.constructor.weight;
92
+ }
93
+
94
+ /**
95
+ * Get the signal's description.
96
+ * @returns {string}
97
+ */
98
+ get description() {
99
+ return this.constructor.description;
100
+ }
101
+
102
+ /**
103
+ * Check if this signal requires interaction.
104
+ * @returns {boolean}
105
+ */
106
+ get requiresInteraction() {
107
+ return this.constructor.requiresInteraction;
108
+ }
109
+
110
+ /**
111
+ * Get the last detection result.
112
+ * @returns {SignalResult|null}
113
+ */
114
+ get lastResult() {
115
+ return this._lastResult;
116
+ }
117
+
118
+ /**
119
+ * Perform the detection check.
120
+ * Must be overridden by subclasses.
121
+ *
122
+ * @abstract
123
+ * @returns {Promise<SignalResult>} Detection result
124
+ * @throws {Error} If not implemented by subclass
125
+ */
126
+ async detect() {
127
+ throw new Error(`Signal.detect() must be implemented by ${this.constructor.name}`);
128
+ }
129
+
130
+ /**
131
+ * Run detection and cache the result.
132
+ * @returns {Promise<SignalResult>}
133
+ */
134
+ async run() {
135
+ try {
136
+ this._lastResult = await this.detect();
137
+ return this._lastResult;
138
+ } catch (error) {
139
+ // Fail-safe: if detection throws, treat as not triggered
140
+ this._lastResult = {
141
+ triggered: false,
142
+ value: null,
143
+ confidence: 0,
144
+ error: error.message,
145
+ };
146
+ return this._lastResult;
147
+ }
148
+ }
149
+
150
+ /**
151
+ * Reset the signal state.
152
+ */
153
+ reset() {
154
+ this._lastResult = null;
155
+ }
156
+
157
+ /**
158
+ * Create a result object with defaults.
159
+ * @param {boolean} triggered - Whether the signal was triggered
160
+ * @param {*} [value=null] - Optional value associated with the detection
161
+ * @param {number} [confidence=1] - Confidence level (0-1)
162
+ * @returns {SignalResult}
163
+ */
164
+ createResult(triggered, value = null, confidence = 1) {
165
+ return {
166
+ triggered: Boolean(triggered),
167
+ value,
168
+ confidence: Math.max(0, Math.min(1, confidence)),
169
+ };
170
+ }
171
+ }
172
+
173
+ /**
174
+ * @typedef {Object} SignalResult
175
+ * @property {boolean} triggered - Whether the signal detected bot behavior
176
+ * @property {*} value - Associated value or evidence
177
+ * @property {number} confidence - Confidence level between 0 and 1
178
+ * @property {string} [error] - Error message if detection failed
179
+ */
180
+
181
+ export { Signal };