@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.
- package/README.md +245 -0
- package/dist/bot-detector.cjs.js +2629 -0
- package/dist/bot-detector.cjs.js.map +7 -0
- package/dist/bot-detector.esm.js +2609 -0
- package/dist/bot-detector.esm.js.map +7 -0
- package/dist/bot-detector.iife.js +2631 -0
- package/dist/bot-detector.iife.js.map +7 -0
- package/dist/bot-detector.iife.min.js +1 -0
- package/package.json +57 -0
- package/src/core/BotDetector.js +284 -0
- package/src/core/ScoringEngine.js +134 -0
- package/src/core/Signal.js +181 -0
- package/src/core/VerdictEngine.js +132 -0
- package/src/index.js +273 -0
- package/src/signals/automation/PhantomJSSignal.js +137 -0
- package/src/signals/automation/PlaywrightSignal.js +129 -0
- package/src/signals/automation/PuppeteerSignal.js +117 -0
- package/src/signals/automation/SeleniumSignal.js +151 -0
- package/src/signals/automation/index.js +8 -0
- package/src/signals/behavior/InteractionTimingSignal.js +170 -0
- package/src/signals/behavior/KeyboardPatternSignal.js +235 -0
- package/src/signals/behavior/MouseMovementSignal.js +215 -0
- package/src/signals/behavior/ScrollBehaviorSignal.js +236 -0
- package/src/signals/behavior/index.js +8 -0
- package/src/signals/environment/HeadlessSignal.js +97 -0
- package/src/signals/environment/NavigatorAnomalySignal.js +117 -0
- package/src/signals/environment/PermissionsSignal.js +76 -0
- package/src/signals/environment/WebDriverSignal.js +58 -0
- package/src/signals/environment/index.js +8 -0
- package/src/signals/fingerprint/AudioContextSignal.js +158 -0
- package/src/signals/fingerprint/CanvasSignal.js +133 -0
- package/src/signals/fingerprint/PluginsSignal.js +106 -0
- package/src/signals/fingerprint/ScreenSignal.js +157 -0
- package/src/signals/fingerprint/WebGLSignal.js +146 -0
- package/src/signals/fingerprint/index.js +9 -0
- package/src/signals/timing/DOMContentTimingSignal.js +159 -0
- package/src/signals/timing/PageLoadSignal.js +165 -0
- 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 };
|