@loamly/tracker 1.6.0 → 1.8.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/dist/index.cjs +827 -1
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +101 -2
- package/dist/index.d.ts +101 -2
- package/dist/index.mjs +825 -1
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +605 -1
- package/dist/loamly.iife.global.js.map +1 -1
- package/dist/loamly.iife.min.global.js +8 -1
- package/dist/loamly.iife.min.global.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +4 -1
- package/src/core.ts +201 -1
- package/src/detection/agentic-browser.ts +328 -0
- package/src/detection/behavioral-classifier.ts +489 -0
- package/src/detection/focus-blur.ts +251 -0
- package/src/detection/index.ts +21 -2
- package/src/index.ts +7 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,489 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight Behavioral ML Classifier
|
|
3
|
+
*
|
|
4
|
+
* LOA-180: Client-side Naive Bayes classifier for AI traffic detection.
|
|
5
|
+
* Research: 75-90% accuracy with 5-8 behavioral signals (Perplexity Dec 2025)
|
|
6
|
+
*
|
|
7
|
+
* @module @loamly/tracker/detection/behavioral-classifier
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Behavioral signal types for classification
|
|
12
|
+
*/
|
|
13
|
+
export type BehavioralSignal =
|
|
14
|
+
// Time-based signals
|
|
15
|
+
| 'time_to_first_click_immediate' // < 500ms
|
|
16
|
+
| 'time_to_first_click_fast' // 500ms - 2s
|
|
17
|
+
| 'time_to_first_click_normal' // 2s - 10s
|
|
18
|
+
| 'time_to_first_click_delayed' // > 10s
|
|
19
|
+
// Scroll signals
|
|
20
|
+
| 'scroll_speed_none' // No scroll
|
|
21
|
+
| 'scroll_speed_uniform' // Bot-like uniform scrolling
|
|
22
|
+
| 'scroll_speed_variable' // Human-like variable
|
|
23
|
+
| 'scroll_speed_erratic' // Very erratic
|
|
24
|
+
// Navigation signals
|
|
25
|
+
| 'nav_timing_paste'
|
|
26
|
+
| 'nav_timing_click'
|
|
27
|
+
| 'nav_timing_unknown'
|
|
28
|
+
// Context signals
|
|
29
|
+
| 'no_referrer'
|
|
30
|
+
| 'has_referrer'
|
|
31
|
+
| 'deep_landing' // Non-homepage first page
|
|
32
|
+
| 'homepage_landing'
|
|
33
|
+
// Mouse signals
|
|
34
|
+
| 'mouse_movement_none'
|
|
35
|
+
| 'mouse_movement_linear' // Bot-like straight lines
|
|
36
|
+
| 'mouse_movement_curved' // Human-like curves
|
|
37
|
+
// Form signals
|
|
38
|
+
| 'form_fill_instant' // < 100ms per field
|
|
39
|
+
| 'form_fill_fast' // 100-500ms per field
|
|
40
|
+
| 'form_fill_normal' // > 500ms per field
|
|
41
|
+
// Focus signals
|
|
42
|
+
| 'focus_blur_rapid' // Rapid tab switching
|
|
43
|
+
| 'focus_blur_normal'
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Classification result
|
|
47
|
+
*/
|
|
48
|
+
export interface BehavioralClassificationResult {
|
|
49
|
+
/** Overall classification */
|
|
50
|
+
classification: 'human' | 'ai_influenced' | 'uncertain'
|
|
51
|
+
/** Human probability (0-1) */
|
|
52
|
+
humanProbability: number
|
|
53
|
+
/** AI-influenced probability (0-1) */
|
|
54
|
+
aiProbability: number
|
|
55
|
+
/** Confidence in classification (0-1) */
|
|
56
|
+
confidence: number
|
|
57
|
+
/** Signals detected */
|
|
58
|
+
signals: BehavioralSignal[]
|
|
59
|
+
/** Time when classification was made */
|
|
60
|
+
timestamp: number
|
|
61
|
+
/** Session duration when classified */
|
|
62
|
+
sessionDurationMs: number
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Behavioral data collector
|
|
67
|
+
*/
|
|
68
|
+
interface BehavioralData {
|
|
69
|
+
firstClickTime: number | null
|
|
70
|
+
scrollEvents: { time: number; position: number }[]
|
|
71
|
+
mouseEvents: { time: number; x: number; y: number }[]
|
|
72
|
+
formEvents: { fieldId: string; startTime: number; endTime: number }[]
|
|
73
|
+
focusBlurEvents: { type: 'focus' | 'blur'; time: number }[]
|
|
74
|
+
startTime: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Pre-trained Naive Bayes weights
|
|
79
|
+
* Research-validated weights from Perplexity Dec 2025 research
|
|
80
|
+
*/
|
|
81
|
+
const NAIVE_BAYES_WEIGHTS = {
|
|
82
|
+
human: {
|
|
83
|
+
time_to_first_click_delayed: 0.85,
|
|
84
|
+
time_to_first_click_normal: 0.75,
|
|
85
|
+
time_to_first_click_fast: 0.50,
|
|
86
|
+
time_to_first_click_immediate: 0.25,
|
|
87
|
+
scroll_speed_variable: 0.80,
|
|
88
|
+
scroll_speed_erratic: 0.70,
|
|
89
|
+
scroll_speed_uniform: 0.35,
|
|
90
|
+
scroll_speed_none: 0.45,
|
|
91
|
+
nav_timing_click: 0.75,
|
|
92
|
+
nav_timing_unknown: 0.55,
|
|
93
|
+
nav_timing_paste: 0.35,
|
|
94
|
+
has_referrer: 0.70,
|
|
95
|
+
no_referrer: 0.45,
|
|
96
|
+
homepage_landing: 0.65,
|
|
97
|
+
deep_landing: 0.50,
|
|
98
|
+
mouse_movement_curved: 0.90,
|
|
99
|
+
mouse_movement_linear: 0.30,
|
|
100
|
+
mouse_movement_none: 0.40,
|
|
101
|
+
form_fill_normal: 0.85,
|
|
102
|
+
form_fill_fast: 0.60,
|
|
103
|
+
form_fill_instant: 0.20,
|
|
104
|
+
focus_blur_normal: 0.75,
|
|
105
|
+
focus_blur_rapid: 0.45,
|
|
106
|
+
},
|
|
107
|
+
ai_influenced: {
|
|
108
|
+
time_to_first_click_immediate: 0.75,
|
|
109
|
+
time_to_first_click_fast: 0.55,
|
|
110
|
+
time_to_first_click_normal: 0.40,
|
|
111
|
+
time_to_first_click_delayed: 0.35,
|
|
112
|
+
scroll_speed_none: 0.55,
|
|
113
|
+
scroll_speed_uniform: 0.70,
|
|
114
|
+
scroll_speed_variable: 0.35,
|
|
115
|
+
scroll_speed_erratic: 0.40,
|
|
116
|
+
nav_timing_paste: 0.75,
|
|
117
|
+
nav_timing_unknown: 0.50,
|
|
118
|
+
nav_timing_click: 0.35,
|
|
119
|
+
no_referrer: 0.65,
|
|
120
|
+
has_referrer: 0.40,
|
|
121
|
+
deep_landing: 0.60,
|
|
122
|
+
homepage_landing: 0.45,
|
|
123
|
+
mouse_movement_none: 0.60,
|
|
124
|
+
mouse_movement_linear: 0.75,
|
|
125
|
+
mouse_movement_curved: 0.25,
|
|
126
|
+
form_fill_instant: 0.80,
|
|
127
|
+
form_fill_fast: 0.55,
|
|
128
|
+
form_fill_normal: 0.30,
|
|
129
|
+
focus_blur_rapid: 0.60,
|
|
130
|
+
focus_blur_normal: 0.40,
|
|
131
|
+
},
|
|
132
|
+
} as const
|
|
133
|
+
|
|
134
|
+
// Prior probabilities (base rates)
|
|
135
|
+
const PRIORS = {
|
|
136
|
+
human: 0.85,
|
|
137
|
+
ai_influenced: 0.15,
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Default weight for unknown signals
|
|
141
|
+
const DEFAULT_WEIGHT = 0.5
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Behavioral Classifier
|
|
145
|
+
*
|
|
146
|
+
* Lightweight Naive Bayes classifier (~2KB) for client-side AI traffic detection.
|
|
147
|
+
* Collects behavioral signals and classifies after configurable session time.
|
|
148
|
+
*/
|
|
149
|
+
export class BehavioralClassifier {
|
|
150
|
+
private data: BehavioralData
|
|
151
|
+
private classified = false
|
|
152
|
+
private result: BehavioralClassificationResult | null = null
|
|
153
|
+
private minSessionTime: number
|
|
154
|
+
private onClassify: ((result: BehavioralClassificationResult) => void) | null = null
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Create a new classifier
|
|
158
|
+
* @param minSessionTimeMs Minimum session time before classification (default: 10s)
|
|
159
|
+
*/
|
|
160
|
+
constructor(minSessionTimeMs = 10000) {
|
|
161
|
+
this.minSessionTime = minSessionTimeMs
|
|
162
|
+
this.data = {
|
|
163
|
+
firstClickTime: null,
|
|
164
|
+
scrollEvents: [],
|
|
165
|
+
mouseEvents: [],
|
|
166
|
+
formEvents: [],
|
|
167
|
+
focusBlurEvents: [],
|
|
168
|
+
startTime: Date.now(),
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Set callback for when classification completes
|
|
174
|
+
*/
|
|
175
|
+
setOnClassify(callback: (result: BehavioralClassificationResult) => void): void {
|
|
176
|
+
this.onClassify = callback
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Record a click event
|
|
181
|
+
*/
|
|
182
|
+
recordClick(): void {
|
|
183
|
+
if (this.data.firstClickTime === null) {
|
|
184
|
+
this.data.firstClickTime = Date.now()
|
|
185
|
+
}
|
|
186
|
+
this.checkAndClassify()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Record a scroll event
|
|
191
|
+
*/
|
|
192
|
+
recordScroll(position: number): void {
|
|
193
|
+
this.data.scrollEvents.push({ time: Date.now(), position })
|
|
194
|
+
// Keep only last 50 events to limit memory
|
|
195
|
+
if (this.data.scrollEvents.length > 50) {
|
|
196
|
+
this.data.scrollEvents = this.data.scrollEvents.slice(-50)
|
|
197
|
+
}
|
|
198
|
+
this.checkAndClassify()
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Record mouse movement
|
|
203
|
+
*/
|
|
204
|
+
recordMouse(x: number, y: number): void {
|
|
205
|
+
this.data.mouseEvents.push({ time: Date.now(), x, y })
|
|
206
|
+
// Keep only last 100 events
|
|
207
|
+
if (this.data.mouseEvents.length > 100) {
|
|
208
|
+
this.data.mouseEvents = this.data.mouseEvents.slice(-100)
|
|
209
|
+
}
|
|
210
|
+
this.checkAndClassify()
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Record form field interaction start
|
|
215
|
+
*/
|
|
216
|
+
recordFormStart(fieldId: string): void {
|
|
217
|
+
const existing = this.data.formEvents.find(e => e.fieldId === fieldId && e.endTime === 0)
|
|
218
|
+
if (!existing) {
|
|
219
|
+
this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 })
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Record form field interaction end
|
|
225
|
+
*/
|
|
226
|
+
recordFormEnd(fieldId: string): void {
|
|
227
|
+
const event = this.data.formEvents.find(e => e.fieldId === fieldId && e.endTime === 0)
|
|
228
|
+
if (event) {
|
|
229
|
+
event.endTime = Date.now()
|
|
230
|
+
}
|
|
231
|
+
this.checkAndClassify()
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Record focus/blur event
|
|
236
|
+
*/
|
|
237
|
+
recordFocusBlur(type: 'focus' | 'blur'): void {
|
|
238
|
+
this.data.focusBlurEvents.push({ type, time: Date.now() })
|
|
239
|
+
// Keep only last 20 events
|
|
240
|
+
if (this.data.focusBlurEvents.length > 20) {
|
|
241
|
+
this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20)
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Check if we have enough data and classify
|
|
247
|
+
*/
|
|
248
|
+
private checkAndClassify(): void {
|
|
249
|
+
if (this.classified) return
|
|
250
|
+
|
|
251
|
+
const sessionDuration = Date.now() - this.data.startTime
|
|
252
|
+
if (sessionDuration < this.minSessionTime) return
|
|
253
|
+
|
|
254
|
+
// Need at least some behavioral data
|
|
255
|
+
const hasData =
|
|
256
|
+
this.data.scrollEvents.length >= 2 ||
|
|
257
|
+
this.data.mouseEvents.length >= 5 ||
|
|
258
|
+
this.data.firstClickTime !== null
|
|
259
|
+
|
|
260
|
+
if (!hasData) return
|
|
261
|
+
|
|
262
|
+
this.classify()
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/**
|
|
266
|
+
* Force classification (for beforeunload)
|
|
267
|
+
*/
|
|
268
|
+
forceClassify(): BehavioralClassificationResult | null {
|
|
269
|
+
if (this.classified) return this.result
|
|
270
|
+
return this.classify()
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Perform classification
|
|
275
|
+
*/
|
|
276
|
+
private classify(): BehavioralClassificationResult {
|
|
277
|
+
const sessionDuration = Date.now() - this.data.startTime
|
|
278
|
+
const signals = this.extractSignals()
|
|
279
|
+
|
|
280
|
+
// Naive Bayes log-probability calculation
|
|
281
|
+
let humanLogProb = Math.log(PRIORS.human)
|
|
282
|
+
let aiLogProb = Math.log(PRIORS.ai_influenced)
|
|
283
|
+
|
|
284
|
+
for (const signal of signals) {
|
|
285
|
+
const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal as keyof typeof NAIVE_BAYES_WEIGHTS.human] ?? DEFAULT_WEIGHT
|
|
286
|
+
const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal as keyof typeof NAIVE_BAYES_WEIGHTS.ai_influenced] ?? DEFAULT_WEIGHT
|
|
287
|
+
|
|
288
|
+
humanLogProb += Math.log(humanWeight)
|
|
289
|
+
aiLogProb += Math.log(aiWeight)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// Convert to probabilities using log-sum-exp trick
|
|
293
|
+
const maxLog = Math.max(humanLogProb, aiLogProb)
|
|
294
|
+
const humanExp = Math.exp(humanLogProb - maxLog)
|
|
295
|
+
const aiExp = Math.exp(aiLogProb - maxLog)
|
|
296
|
+
const total = humanExp + aiExp
|
|
297
|
+
|
|
298
|
+
const humanProbability = humanExp / total
|
|
299
|
+
const aiProbability = aiExp / total
|
|
300
|
+
|
|
301
|
+
// Determine classification
|
|
302
|
+
let classification: 'human' | 'ai_influenced' | 'uncertain'
|
|
303
|
+
let confidence: number
|
|
304
|
+
|
|
305
|
+
if (humanProbability > 0.6) {
|
|
306
|
+
classification = 'human'
|
|
307
|
+
confidence = humanProbability
|
|
308
|
+
} else if (aiProbability > 0.6) {
|
|
309
|
+
classification = 'ai_influenced'
|
|
310
|
+
confidence = aiProbability
|
|
311
|
+
} else {
|
|
312
|
+
classification = 'uncertain'
|
|
313
|
+
confidence = Math.max(humanProbability, aiProbability)
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
this.result = {
|
|
317
|
+
classification,
|
|
318
|
+
humanProbability,
|
|
319
|
+
aiProbability,
|
|
320
|
+
confidence,
|
|
321
|
+
signals,
|
|
322
|
+
timestamp: Date.now(),
|
|
323
|
+
sessionDurationMs: sessionDuration,
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
this.classified = true
|
|
327
|
+
|
|
328
|
+
// Call callback if set
|
|
329
|
+
if (this.onClassify) {
|
|
330
|
+
this.onClassify(this.result)
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
return this.result
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
/**
|
|
337
|
+
* Extract behavioral signals from collected data
|
|
338
|
+
*/
|
|
339
|
+
private extractSignals(): BehavioralSignal[] {
|
|
340
|
+
const signals: BehavioralSignal[] = []
|
|
341
|
+
// Session duration available for future enhancements
|
|
342
|
+
// const sessionDuration = Date.now() - this.data.startTime
|
|
343
|
+
|
|
344
|
+
// Time to first click
|
|
345
|
+
if (this.data.firstClickTime !== null) {
|
|
346
|
+
const timeToClick = this.data.firstClickTime - this.data.startTime
|
|
347
|
+
if (timeToClick < 500) {
|
|
348
|
+
signals.push('time_to_first_click_immediate')
|
|
349
|
+
} else if (timeToClick < 2000) {
|
|
350
|
+
signals.push('time_to_first_click_fast')
|
|
351
|
+
} else if (timeToClick < 10000) {
|
|
352
|
+
signals.push('time_to_first_click_normal')
|
|
353
|
+
} else {
|
|
354
|
+
signals.push('time_to_first_click_delayed')
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
// Scroll behavior
|
|
359
|
+
if (this.data.scrollEvents.length === 0) {
|
|
360
|
+
signals.push('scroll_speed_none')
|
|
361
|
+
} else if (this.data.scrollEvents.length >= 3) {
|
|
362
|
+
const scrollDeltas: number[] = []
|
|
363
|
+
for (let i = 1; i < this.data.scrollEvents.length; i++) {
|
|
364
|
+
const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time
|
|
365
|
+
scrollDeltas.push(delta)
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
// Calculate coefficient of variation for scroll timing
|
|
369
|
+
const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length
|
|
370
|
+
const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length
|
|
371
|
+
const stdDev = Math.sqrt(variance)
|
|
372
|
+
const cv = mean > 0 ? stdDev / mean : 0
|
|
373
|
+
|
|
374
|
+
if (cv < 0.2) {
|
|
375
|
+
signals.push('scroll_speed_uniform') // Very consistent = bot-like
|
|
376
|
+
} else if (cv < 0.6) {
|
|
377
|
+
signals.push('scroll_speed_variable') // Natural variation
|
|
378
|
+
} else {
|
|
379
|
+
signals.push('scroll_speed_erratic')
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
// Mouse movement analysis
|
|
384
|
+
if (this.data.mouseEvents.length === 0) {
|
|
385
|
+
signals.push('mouse_movement_none')
|
|
386
|
+
} else if (this.data.mouseEvents.length >= 10) {
|
|
387
|
+
// Calculate linearity using R² of best-fit line
|
|
388
|
+
const n = Math.min(this.data.mouseEvents.length, 20)
|
|
389
|
+
const recentMouse = this.data.mouseEvents.slice(-n)
|
|
390
|
+
|
|
391
|
+
let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0
|
|
392
|
+
for (const event of recentMouse) {
|
|
393
|
+
sumX += event.x
|
|
394
|
+
sumY += event.y
|
|
395
|
+
sumXY += event.x * event.y
|
|
396
|
+
sumX2 += event.x * event.x
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
const denominator = (n * sumX2 - sumX * sumX)
|
|
400
|
+
const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0
|
|
401
|
+
const intercept = (sumY - slope * sumX) / n
|
|
402
|
+
|
|
403
|
+
// Calculate R²
|
|
404
|
+
let ssRes = 0, ssTot = 0
|
|
405
|
+
const yMean = sumY / n
|
|
406
|
+
for (const event of recentMouse) {
|
|
407
|
+
const yPred = slope * event.x + intercept
|
|
408
|
+
ssRes += Math.pow(event.y - yPred, 2)
|
|
409
|
+
ssTot += Math.pow(event.y - yMean, 2)
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
const r2 = ssTot !== 0 ? 1 - (ssRes / ssTot) : 0
|
|
413
|
+
|
|
414
|
+
if (r2 > 0.95) {
|
|
415
|
+
signals.push('mouse_movement_linear') // Too straight = bot-like
|
|
416
|
+
} else {
|
|
417
|
+
signals.push('mouse_movement_curved') // Natural curves
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
// Form fill timing
|
|
422
|
+
const completedForms = this.data.formEvents.filter(e => e.endTime > 0)
|
|
423
|
+
if (completedForms.length > 0) {
|
|
424
|
+
const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length
|
|
425
|
+
|
|
426
|
+
if (avgFillTime < 100) {
|
|
427
|
+
signals.push('form_fill_instant')
|
|
428
|
+
} else if (avgFillTime < 500) {
|
|
429
|
+
signals.push('form_fill_fast')
|
|
430
|
+
} else {
|
|
431
|
+
signals.push('form_fill_normal')
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Focus/blur patterns
|
|
436
|
+
if (this.data.focusBlurEvents.length >= 4) {
|
|
437
|
+
const recentFB = this.data.focusBlurEvents.slice(-10)
|
|
438
|
+
const intervals: number[] = []
|
|
439
|
+
|
|
440
|
+
for (let i = 1; i < recentFB.length; i++) {
|
|
441
|
+
intervals.push(recentFB[i].time - recentFB[i - 1].time)
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length
|
|
445
|
+
|
|
446
|
+
if (avgInterval < 1000) {
|
|
447
|
+
signals.push('focus_blur_rapid')
|
|
448
|
+
} else {
|
|
449
|
+
signals.push('focus_blur_normal')
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
// Context signals (will be set from outside)
|
|
454
|
+
// These are typically added by the tracker itself
|
|
455
|
+
|
|
456
|
+
return signals
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/**
|
|
460
|
+
* Add context signals (set by tracker from external data)
|
|
461
|
+
*/
|
|
462
|
+
addContextSignal(_signal: BehavioralSignal): void {
|
|
463
|
+
// These will be picked up on next classification
|
|
464
|
+
// For now, we'll handle them in the classify method
|
|
465
|
+
// TODO: Store signals for next classification run
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
/**
|
|
469
|
+
* Get current result (null if not yet classified)
|
|
470
|
+
*/
|
|
471
|
+
getResult(): BehavioralClassificationResult | null {
|
|
472
|
+
return this.result
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* Check if classification has been performed
|
|
477
|
+
*/
|
|
478
|
+
hasClassified(): boolean {
|
|
479
|
+
return this.classified
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
/**
|
|
484
|
+
* Create a classifier instance with standard configuration
|
|
485
|
+
*/
|
|
486
|
+
export function createBehavioralClassifier(minSessionTimeMs = 10000): BehavioralClassifier {
|
|
487
|
+
return new BehavioralClassifier(minSessionTimeMs)
|
|
488
|
+
}
|
|
489
|
+
|