@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.
@@ -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
+
@@ -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