@loamly/tracker 1.7.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 +418 -2
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +82 -2
- package/dist/index.d.ts +82 -2
- package/dist/index.mjs +416 -2
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +196 -2
- 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 +67 -1
- package/src/detection/agentic-browser.ts +328 -0
- package/src/detection/focus-blur.ts +251 -0
- package/src/detection/index.ts +15 -0
- package/src/index.ts +7 -0
- package/src/types.ts +17 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agentic Browser Detection
|
|
3
|
+
*
|
|
4
|
+
* LOA-187: Detects AI agentic browsers like Perplexity Comet, ChatGPT Atlas,
|
|
5
|
+
* and other automated browsing agents.
|
|
6
|
+
*
|
|
7
|
+
* Detection methods:
|
|
8
|
+
* - DOM fingerprinting (Perplexity Comet overlay)
|
|
9
|
+
* - Mouse movement patterns (teleporting clicks)
|
|
10
|
+
* - CDP (Chrome DevTools Protocol) automation fingerprint
|
|
11
|
+
* - navigator.webdriver detection
|
|
12
|
+
*
|
|
13
|
+
* @module @loamly/tracker/detection/agentic-browser
|
|
14
|
+
* @license MIT
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Agentic detection result
|
|
19
|
+
*/
|
|
20
|
+
export interface AgenticDetectionResult {
|
|
21
|
+
/** Whether Perplexity Comet DOM element was detected */
|
|
22
|
+
cometDOMDetected: boolean
|
|
23
|
+
/** Whether CDP automation was detected */
|
|
24
|
+
cdpDetected: boolean
|
|
25
|
+
/** Mouse movement patterns */
|
|
26
|
+
mousePatterns: {
|
|
27
|
+
teleportingClicks: number
|
|
28
|
+
totalMovements: number
|
|
29
|
+
}
|
|
30
|
+
/** Overall agentic probability (0-1) */
|
|
31
|
+
agenticProbability: number
|
|
32
|
+
/** Detection signals */
|
|
33
|
+
signals: string[]
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Perplexity Comet DOM Detector
|
|
38
|
+
*
|
|
39
|
+
* Detects the Comet browser overlay stop button which is injected
|
|
40
|
+
* into the DOM when Comet is actively browsing.
|
|
41
|
+
*/
|
|
42
|
+
export class CometDetector {
|
|
43
|
+
private detected = false
|
|
44
|
+
private checkComplete = false
|
|
45
|
+
private observer: MutationObserver | null = null
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Initialize detection
|
|
49
|
+
* @param timeout - Max time to observe for Comet DOM (default: 5s)
|
|
50
|
+
*/
|
|
51
|
+
init(timeout = 5000): void {
|
|
52
|
+
if (typeof document === 'undefined') return
|
|
53
|
+
|
|
54
|
+
// Initial check
|
|
55
|
+
this.check()
|
|
56
|
+
|
|
57
|
+
if (!this.detected && document.body) {
|
|
58
|
+
// Observe for dynamic injection
|
|
59
|
+
this.observer = new MutationObserver(() => this.check())
|
|
60
|
+
this.observer.observe(document.body, { childList: true, subtree: true })
|
|
61
|
+
|
|
62
|
+
// Stop observing after timeout
|
|
63
|
+
setTimeout(() => {
|
|
64
|
+
if (this.observer && !this.detected) {
|
|
65
|
+
this.observer.disconnect()
|
|
66
|
+
this.observer = null
|
|
67
|
+
this.checkComplete = true
|
|
68
|
+
}
|
|
69
|
+
}, timeout)
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private check(): void {
|
|
74
|
+
// Perplexity Comet injects an overlay with this class
|
|
75
|
+
if (document.querySelector('.pplx-agent-overlay-stop-button')) {
|
|
76
|
+
this.detected = true
|
|
77
|
+
this.checkComplete = true
|
|
78
|
+
if (this.observer) {
|
|
79
|
+
this.observer.disconnect()
|
|
80
|
+
this.observer = null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
isDetected(): boolean {
|
|
86
|
+
return this.detected
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
isCheckComplete(): boolean {
|
|
90
|
+
return this.checkComplete
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
destroy(): void {
|
|
94
|
+
if (this.observer) {
|
|
95
|
+
this.observer.disconnect()
|
|
96
|
+
this.observer = null
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Mouse Movement Analyzer
|
|
103
|
+
*
|
|
104
|
+
* Detects unnatural mouse movements characteristic of automated browsers:
|
|
105
|
+
* - Teleporting clicks (instant large movements)
|
|
106
|
+
* - Perfect linear movements
|
|
107
|
+
* - No micro-adjustments
|
|
108
|
+
*/
|
|
109
|
+
export class MouseAnalyzer {
|
|
110
|
+
private lastX = -1
|
|
111
|
+
private lastY = -1
|
|
112
|
+
private teleportingClicks = 0
|
|
113
|
+
private totalMovements = 0
|
|
114
|
+
private readonly teleportThreshold: number
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* @param teleportThreshold - Distance in pixels to consider a teleport (default: 500)
|
|
118
|
+
*/
|
|
119
|
+
constructor(teleportThreshold = 500) {
|
|
120
|
+
this.teleportThreshold = teleportThreshold
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Initialize mouse tracking
|
|
125
|
+
*/
|
|
126
|
+
init(): void {
|
|
127
|
+
if (typeof document === 'undefined') return
|
|
128
|
+
|
|
129
|
+
document.addEventListener('mousemove', this.handleMove, { passive: true })
|
|
130
|
+
document.addEventListener('mousedown', this.handleClick, { passive: true })
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
private handleMove = (e: MouseEvent): void => {
|
|
134
|
+
this.totalMovements++
|
|
135
|
+
this.lastX = e.clientX
|
|
136
|
+
this.lastY = e.clientY
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
private handleClick = (e: MouseEvent): void => {
|
|
140
|
+
if (this.lastX !== -1 && this.lastY !== -1) {
|
|
141
|
+
const dx = Math.abs(e.clientX - this.lastX)
|
|
142
|
+
const dy = Math.abs(e.clientY - this.lastY)
|
|
143
|
+
|
|
144
|
+
// Large instant movement before click = teleporting
|
|
145
|
+
if (dx > this.teleportThreshold || dy > this.teleportThreshold) {
|
|
146
|
+
this.teleportingClicks++
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
this.lastX = e.clientX
|
|
150
|
+
this.lastY = e.clientY
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
getPatterns(): { teleportingClicks: number; totalMovements: number } {
|
|
154
|
+
return {
|
|
155
|
+
teleportingClicks: this.teleportingClicks,
|
|
156
|
+
totalMovements: this.totalMovements,
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
destroy(): void {
|
|
161
|
+
if (typeof document === 'undefined') return
|
|
162
|
+
document.removeEventListener('mousemove', this.handleMove)
|
|
163
|
+
document.removeEventListener('mousedown', this.handleClick)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* CDP (Chrome DevTools Protocol) Automation Detector
|
|
169
|
+
*
|
|
170
|
+
* Detects headless browsers and automation tools:
|
|
171
|
+
* - navigator.webdriver (set by Selenium, Puppeteer, Playwright)
|
|
172
|
+
* - Chrome automation flags
|
|
173
|
+
* - Missing browser APIs
|
|
174
|
+
*/
|
|
175
|
+
export class CDPDetector {
|
|
176
|
+
private detected = false
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run detection checks
|
|
180
|
+
*/
|
|
181
|
+
detect(): boolean {
|
|
182
|
+
if (typeof navigator === 'undefined') return false
|
|
183
|
+
|
|
184
|
+
// Check 1: navigator.webdriver (Chrome 76+, Firefox 60+)
|
|
185
|
+
// Set to true by automation frameworks
|
|
186
|
+
if ((navigator as Navigator & { webdriver?: boolean }).webdriver) {
|
|
187
|
+
this.detected = true
|
|
188
|
+
return true
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Check 2: Chrome automation extension (legacy check)
|
|
192
|
+
if (typeof window !== 'undefined') {
|
|
193
|
+
const win = window as Window & {
|
|
194
|
+
chrome?: { runtime?: unknown }
|
|
195
|
+
__webdriver_evaluate?: unknown
|
|
196
|
+
__selenium_evaluate?: unknown
|
|
197
|
+
__webdriver_script_function?: unknown
|
|
198
|
+
__webdriver_script_func?: unknown
|
|
199
|
+
__webdriver_script_fn?: unknown
|
|
200
|
+
__fxdriver_evaluate?: unknown
|
|
201
|
+
__driver_unwrapped?: unknown
|
|
202
|
+
__webdriver_unwrapped?: unknown
|
|
203
|
+
__driver_evaluate?: unknown
|
|
204
|
+
__selenium_unwrapped?: unknown
|
|
205
|
+
__fxdriver_unwrapped?: unknown
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// Selenium/WebDriver fingerprints
|
|
209
|
+
const automationProps = [
|
|
210
|
+
'__webdriver_evaluate',
|
|
211
|
+
'__selenium_evaluate',
|
|
212
|
+
'__webdriver_script_function',
|
|
213
|
+
'__webdriver_script_func',
|
|
214
|
+
'__webdriver_script_fn',
|
|
215
|
+
'__fxdriver_evaluate',
|
|
216
|
+
'__driver_unwrapped',
|
|
217
|
+
'__webdriver_unwrapped',
|
|
218
|
+
'__driver_evaluate',
|
|
219
|
+
'__selenium_unwrapped',
|
|
220
|
+
'__fxdriver_unwrapped',
|
|
221
|
+
]
|
|
222
|
+
|
|
223
|
+
for (const prop of automationProps) {
|
|
224
|
+
if (prop in win) {
|
|
225
|
+
this.detected = true
|
|
226
|
+
return true
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return false
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
isDetected(): boolean {
|
|
235
|
+
return this.detected
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Agentic Browser Analyzer
|
|
241
|
+
*
|
|
242
|
+
* Combines all detection methods into a unified result.
|
|
243
|
+
*/
|
|
244
|
+
export class AgenticBrowserAnalyzer {
|
|
245
|
+
private cometDetector: CometDetector
|
|
246
|
+
private mouseAnalyzer: MouseAnalyzer
|
|
247
|
+
private cdpDetector: CDPDetector
|
|
248
|
+
private initialized = false
|
|
249
|
+
|
|
250
|
+
constructor() {
|
|
251
|
+
this.cometDetector = new CometDetector()
|
|
252
|
+
this.mouseAnalyzer = new MouseAnalyzer()
|
|
253
|
+
this.cdpDetector = new CDPDetector()
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Initialize all detectors
|
|
258
|
+
*/
|
|
259
|
+
init(): void {
|
|
260
|
+
if (this.initialized) return
|
|
261
|
+
this.initialized = true
|
|
262
|
+
|
|
263
|
+
this.cometDetector.init()
|
|
264
|
+
this.mouseAnalyzer.init()
|
|
265
|
+
this.cdpDetector.detect()
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Get current detection result
|
|
270
|
+
*/
|
|
271
|
+
getResult(): AgenticDetectionResult {
|
|
272
|
+
const signals: string[] = []
|
|
273
|
+
let probability = 0
|
|
274
|
+
|
|
275
|
+
// Comet detection (85% confidence)
|
|
276
|
+
if (this.cometDetector.isDetected()) {
|
|
277
|
+
signals.push('comet_dom_detected')
|
|
278
|
+
probability = Math.max(probability, 0.85)
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// CDP detection (92% confidence)
|
|
282
|
+
if (this.cdpDetector.isDetected()) {
|
|
283
|
+
signals.push('cdp_detected')
|
|
284
|
+
probability = Math.max(probability, 0.92)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Mouse patterns (78% confidence per teleport)
|
|
288
|
+
const mousePatterns = this.mouseAnalyzer.getPatterns()
|
|
289
|
+
if (mousePatterns.teleportingClicks > 0) {
|
|
290
|
+
signals.push(`teleporting_clicks:${mousePatterns.teleportingClicks}`)
|
|
291
|
+
probability = Math.max(probability, 0.78)
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
cometDOMDetected: this.cometDetector.isDetected(),
|
|
296
|
+
cdpDetected: this.cdpDetector.isDetected(),
|
|
297
|
+
mousePatterns,
|
|
298
|
+
agenticProbability: probability,
|
|
299
|
+
signals,
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Cleanup resources
|
|
305
|
+
*/
|
|
306
|
+
destroy(): void {
|
|
307
|
+
this.cometDetector.destroy()
|
|
308
|
+
this.mouseAnalyzer.destroy()
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* Create and initialize an agentic browser analyzer
|
|
314
|
+
*/
|
|
315
|
+
export function createAgenticAnalyzer(): AgenticBrowserAnalyzer {
|
|
316
|
+
const analyzer = new AgenticBrowserAnalyzer()
|
|
317
|
+
|
|
318
|
+
if (typeof document !== 'undefined') {
|
|
319
|
+
if (document.readyState === 'loading') {
|
|
320
|
+
document.addEventListener('DOMContentLoaded', () => analyzer.init())
|
|
321
|
+
} else {
|
|
322
|
+
analyzer.init()
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
return analyzer
|
|
327
|
+
}
|
|
328
|
+
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Focus/Blur Event Sequence Analysis
|
|
3
|
+
*
|
|
4
|
+
* LOA-182: Detects paste vs click navigation patterns by analyzing
|
|
5
|
+
* the sequence of focus and blur events when a page loads.
|
|
6
|
+
*
|
|
7
|
+
* Research: 55-65% accuracy (improves when combined with other signals)
|
|
8
|
+
*
|
|
9
|
+
* @module @loamly/tracker/detection/focus-blur
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Focus/blur event record
|
|
14
|
+
*/
|
|
15
|
+
export interface FocusBlurEvent {
|
|
16
|
+
type: 'focus' | 'blur' | 'window_focus' | 'window_blur'
|
|
17
|
+
target: string
|
|
18
|
+
timestamp: number
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Focus/blur analysis result
|
|
23
|
+
*/
|
|
24
|
+
export interface FocusBlurResult {
|
|
25
|
+
/** Navigation pattern type */
|
|
26
|
+
nav_type: 'likely_paste' | 'likely_click' | 'unknown'
|
|
27
|
+
/** Confidence score (0-1) */
|
|
28
|
+
confidence: number
|
|
29
|
+
/** Detection signals */
|
|
30
|
+
signals: string[]
|
|
31
|
+
/** Event sequence (last 10 events) */
|
|
32
|
+
sequence: FocusBlurEvent[]
|
|
33
|
+
/** Time from page load to first interaction */
|
|
34
|
+
time_to_first_interaction_ms: number | null
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Focus/Blur Sequence Analyzer
|
|
39
|
+
*
|
|
40
|
+
* Tracks focus and blur events to detect paste navigation patterns.
|
|
41
|
+
* Paste navigation typically shows:
|
|
42
|
+
* 1. Early window focus event
|
|
43
|
+
* 2. Body focus without prior link navigation
|
|
44
|
+
* 3. No referrer blur pattern
|
|
45
|
+
*/
|
|
46
|
+
export class FocusBlurAnalyzer {
|
|
47
|
+
private sequence: FocusBlurEvent[] = []
|
|
48
|
+
private pageLoadTime: number
|
|
49
|
+
private firstInteractionTime: number | null = null
|
|
50
|
+
private analyzed = false
|
|
51
|
+
private result: FocusBlurResult | null = null
|
|
52
|
+
|
|
53
|
+
constructor() {
|
|
54
|
+
this.pageLoadTime = performance.now()
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Initialize event tracking
|
|
59
|
+
* Must be called after DOM is ready
|
|
60
|
+
*/
|
|
61
|
+
initTracking(): void {
|
|
62
|
+
// Track focus events (use capturing phase to catch all events)
|
|
63
|
+
document.addEventListener('focus', (e) => {
|
|
64
|
+
this.recordEvent('focus', e.target as HTMLElement)
|
|
65
|
+
}, true)
|
|
66
|
+
|
|
67
|
+
document.addEventListener('blur', (e) => {
|
|
68
|
+
this.recordEvent('blur', e.target as HTMLElement)
|
|
69
|
+
}, true)
|
|
70
|
+
|
|
71
|
+
// Track window focus/blur
|
|
72
|
+
window.addEventListener('focus', () => {
|
|
73
|
+
this.recordEvent('window_focus', null)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
window.addEventListener('blur', () => {
|
|
77
|
+
this.recordEvent('window_blur', null)
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
// Track first click/keypress as first interaction
|
|
81
|
+
const recordFirstInteraction = () => {
|
|
82
|
+
if (this.firstInteractionTime === null) {
|
|
83
|
+
this.firstInteractionTime = performance.now()
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
document.addEventListener('click', recordFirstInteraction, { once: true, passive: true })
|
|
87
|
+
document.addEventListener('keydown', recordFirstInteraction, { once: true, passive: true })
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Record a focus/blur event
|
|
92
|
+
*/
|
|
93
|
+
private recordEvent(type: FocusBlurEvent['type'], target: HTMLElement | null): void {
|
|
94
|
+
const event: FocusBlurEvent = {
|
|
95
|
+
type,
|
|
96
|
+
target: target?.tagName || 'WINDOW',
|
|
97
|
+
timestamp: performance.now()
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
this.sequence.push(event)
|
|
101
|
+
|
|
102
|
+
// Keep only last 20 events to limit memory
|
|
103
|
+
if (this.sequence.length > 20) {
|
|
104
|
+
this.sequence = this.sequence.slice(-20)
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Analyze the focus/blur sequence for paste patterns
|
|
110
|
+
*/
|
|
111
|
+
analyze(): FocusBlurResult {
|
|
112
|
+
if (this.analyzed && this.result) {
|
|
113
|
+
return this.result
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const signals: string[] = []
|
|
117
|
+
let confidence = 0
|
|
118
|
+
|
|
119
|
+
// Get early events (first 500ms after page load)
|
|
120
|
+
const earlyEvents = this.sequence.filter(e => e.timestamp < this.pageLoadTime + 500)
|
|
121
|
+
|
|
122
|
+
// Pattern 1: Window focus as first event
|
|
123
|
+
const hasEarlyWindowFocus = earlyEvents.some(e => e.type === 'window_focus')
|
|
124
|
+
if (hasEarlyWindowFocus) {
|
|
125
|
+
signals.push('early_window_focus')
|
|
126
|
+
confidence += 0.15
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Pattern 2: Body focus early (paste causes immediate body focus)
|
|
130
|
+
const hasEarlyBodyFocus = earlyEvents.some(
|
|
131
|
+
e => e.type === 'focus' && e.target === 'BODY'
|
|
132
|
+
)
|
|
133
|
+
if (hasEarlyBodyFocus) {
|
|
134
|
+
signals.push('early_body_focus')
|
|
135
|
+
confidence += 0.15
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Pattern 3: No link/anchor focus (would indicate click navigation)
|
|
139
|
+
const hasLinkFocus = this.sequence.some(
|
|
140
|
+
e => e.type === 'focus' && e.target === 'A'
|
|
141
|
+
)
|
|
142
|
+
if (!hasLinkFocus) {
|
|
143
|
+
signals.push('no_link_focus')
|
|
144
|
+
confidence += 0.10
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Pattern 4: First focus is on document/body (not a specific element)
|
|
148
|
+
const firstFocus = this.sequence.find(e => e.type === 'focus')
|
|
149
|
+
if (firstFocus && (firstFocus.target === 'BODY' || firstFocus.target === 'HTML')) {
|
|
150
|
+
signals.push('first_focus_body')
|
|
151
|
+
confidence += 0.10
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Pattern 5: No rapid tab switching (common in human navigation)
|
|
155
|
+
const windowEvents = this.sequence.filter(
|
|
156
|
+
e => e.type === 'window_focus' || e.type === 'window_blur'
|
|
157
|
+
)
|
|
158
|
+
if (windowEvents.length <= 2) {
|
|
159
|
+
signals.push('minimal_window_switches')
|
|
160
|
+
confidence += 0.05
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// Pattern 6: Time to first interaction
|
|
164
|
+
// Paste users often have longer time before first interaction
|
|
165
|
+
// (reading content they copied from AI)
|
|
166
|
+
if (this.firstInteractionTime !== null) {
|
|
167
|
+
const timeToInteraction = this.firstInteractionTime - this.pageLoadTime
|
|
168
|
+
if (timeToInteraction > 3000) {
|
|
169
|
+
signals.push('delayed_first_interaction')
|
|
170
|
+
confidence += 0.10
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Cap confidence based on research (55-65% accuracy)
|
|
175
|
+
confidence = Math.min(confidence, 0.65)
|
|
176
|
+
|
|
177
|
+
// Determine navigation type
|
|
178
|
+
let navType: FocusBlurResult['nav_type']
|
|
179
|
+
if (confidence >= 0.35) {
|
|
180
|
+
navType = 'likely_paste'
|
|
181
|
+
} else if (signals.length === 0) {
|
|
182
|
+
navType = 'unknown'
|
|
183
|
+
} else {
|
|
184
|
+
navType = 'likely_click'
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
this.result = {
|
|
188
|
+
nav_type: navType,
|
|
189
|
+
confidence,
|
|
190
|
+
signals,
|
|
191
|
+
sequence: this.sequence.slice(-10),
|
|
192
|
+
time_to_first_interaction_ms: this.firstInteractionTime
|
|
193
|
+
? Math.round(this.firstInteractionTime - this.pageLoadTime)
|
|
194
|
+
: null
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
this.analyzed = true
|
|
198
|
+
return this.result
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Get current result (analyze if not done)
|
|
203
|
+
*/
|
|
204
|
+
getResult(): FocusBlurResult {
|
|
205
|
+
return this.analyze()
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
/**
|
|
209
|
+
* Check if analysis has been performed
|
|
210
|
+
*/
|
|
211
|
+
hasAnalyzed(): boolean {
|
|
212
|
+
return this.analyzed
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Get the raw sequence for debugging
|
|
217
|
+
*/
|
|
218
|
+
getSequence(): FocusBlurEvent[] {
|
|
219
|
+
return [...this.sequence]
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Reset the analyzer
|
|
224
|
+
*/
|
|
225
|
+
reset(): void {
|
|
226
|
+
this.sequence = []
|
|
227
|
+
this.pageLoadTime = performance.now()
|
|
228
|
+
this.firstInteractionTime = null
|
|
229
|
+
this.analyzed = false
|
|
230
|
+
this.result = null
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/**
|
|
235
|
+
* Create a new focus/blur analyzer
|
|
236
|
+
*/
|
|
237
|
+
export function createFocusBlurAnalyzer(): FocusBlurAnalyzer {
|
|
238
|
+
const analyzer = new FocusBlurAnalyzer()
|
|
239
|
+
|
|
240
|
+
// Initialize tracking when DOM is ready
|
|
241
|
+
if (typeof document !== 'undefined') {
|
|
242
|
+
if (document.readyState === 'loading') {
|
|
243
|
+
document.addEventListener('DOMContentLoaded', () => analyzer.initTracking())
|
|
244
|
+
} else {
|
|
245
|
+
analyzer.initTracking()
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return analyzer
|
|
250
|
+
}
|
|
251
|
+
|
package/src/detection/index.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* traditional analytics miss.
|
|
6
6
|
*
|
|
7
7
|
* @module @loamly/tracker/detection
|
|
8
|
+
* @license MIT
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
export { detectNavigationType } from './navigation-timing'
|
|
@@ -15,3 +16,17 @@ export {
|
|
|
15
16
|
type BehavioralSignal,
|
|
16
17
|
type BehavioralClassificationResult
|
|
17
18
|
} from './behavioral-classifier'
|
|
19
|
+
export {
|
|
20
|
+
FocusBlurAnalyzer,
|
|
21
|
+
createFocusBlurAnalyzer,
|
|
22
|
+
type FocusBlurEvent,
|
|
23
|
+
type FocusBlurResult
|
|
24
|
+
} from './focus-blur'
|
|
25
|
+
export {
|
|
26
|
+
AgenticBrowserAnalyzer,
|
|
27
|
+
CometDetector,
|
|
28
|
+
MouseAnalyzer,
|
|
29
|
+
CDPDetector,
|
|
30
|
+
createAgenticAnalyzer,
|
|
31
|
+
type AgenticDetectionResult
|
|
32
|
+
} from './agentic-browser'
|
package/src/index.ts
CHANGED
|
@@ -5,7 +5,9 @@
|
|
|
5
5
|
* See what AI tells your customers — and track when they click.
|
|
6
6
|
*
|
|
7
7
|
* @module @loamly/tracker
|
|
8
|
+
* @version 1.8.0
|
|
8
9
|
* @license MIT
|
|
10
|
+
* @see https://github.com/loamly/loamly
|
|
9
11
|
* @see https://loamly.ai
|
|
10
12
|
*/
|
|
11
13
|
|
|
@@ -24,6 +26,11 @@ export type {
|
|
|
24
26
|
// Detection utilities (for advanced usage)
|
|
25
27
|
export { detectNavigationType } from './detection/navigation-timing'
|
|
26
28
|
export { detectAIFromReferrer, detectAIFromUTM } from './detection/referrer'
|
|
29
|
+
export {
|
|
30
|
+
AgenticBrowserAnalyzer,
|
|
31
|
+
createAgenticAnalyzer,
|
|
32
|
+
type AgenticDetectionResult
|
|
33
|
+
} from './detection/agentic-browser'
|
|
27
34
|
|
|
28
35
|
// Configuration
|
|
29
36
|
export { VERSION, AI_PLATFORMS, AI_BOT_PATTERNS } from './config'
|
package/src/types.ts
CHANGED
|
@@ -77,6 +77,20 @@ export interface BehavioralMLResult {
|
|
|
77
77
|
sessionDurationMs: number
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
/**
|
|
81
|
+
* Focus/Blur analysis result
|
|
82
|
+
*/
|
|
83
|
+
export interface FocusBlurMLResult {
|
|
84
|
+
/** Navigation pattern type */
|
|
85
|
+
navType: 'likely_paste' | 'likely_click' | 'unknown'
|
|
86
|
+
/** Confidence score (0-1) */
|
|
87
|
+
confidence: number
|
|
88
|
+
/** Detection signals */
|
|
89
|
+
signals: string[]
|
|
90
|
+
/** Time to first interaction in ms */
|
|
91
|
+
timeToFirstInteractionMs: number | null
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
export interface LoamlyTracker {
|
|
81
95
|
/** Initialize the tracker with configuration */
|
|
82
96
|
init: (config: LoamlyConfig) => void
|
|
@@ -108,6 +122,9 @@ export interface LoamlyTracker {
|
|
|
108
122
|
/** Get behavioral ML classification result */
|
|
109
123
|
getBehavioralML: () => BehavioralMLResult | null
|
|
110
124
|
|
|
125
|
+
/** Get focus/blur analysis result */
|
|
126
|
+
getFocusBlur: () => FocusBlurMLResult | null
|
|
127
|
+
|
|
111
128
|
/** Check if tracker is initialized */
|
|
112
129
|
isInitialized: () => boolean
|
|
113
130
|
|