@loamly/tracker 2.1.1 → 2.4.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 CHANGED
@@ -33,645 +33,596 @@ __export(index_exports, {
33
33
  });
34
34
  module.exports = __toCommonJS(index_exports);
35
35
 
36
- // src/config.ts
37
- var VERSION = "2.1.1";
36
+ // src/behavioral/form-tracker.ts
38
37
  var DEFAULT_CONFIG = {
39
- apiHost: "https://app.loamly.ai",
40
- endpoints: {
41
- visit: "/api/ingest/visit",
42
- behavioral: "/api/ingest/behavioral",
43
- session: "/api/ingest/session",
44
- resolve: "/api/tracker/resolve",
45
- health: "/api/tracker/health",
46
- ping: "/api/tracker/ping"
47
- },
48
- pingInterval: 3e4,
49
- // 30 seconds
50
- batchSize: 10,
51
- batchTimeout: 5e3,
52
- sessionTimeout: 18e5,
53
- // 30 minutes
54
- maxTextLength: 100,
55
- timeSpentThresholdMs: 5e3
56
- // Only send time_spent when delta >= 5 seconds
57
- };
58
- var AI_PLATFORMS = {
59
- "chatgpt.com": "chatgpt",
60
- "chat.openai.com": "chatgpt",
61
- "claude.ai": "claude",
62
- "perplexity.ai": "perplexity",
63
- "bard.google.com": "bard",
64
- "gemini.google.com": "gemini",
65
- "copilot.microsoft.com": "copilot",
66
- "github.com/copilot": "github-copilot",
67
- "you.com": "you",
68
- "phind.com": "phind",
69
- "poe.com": "poe"
38
+ sensitiveFields: [
39
+ "password",
40
+ "pwd",
41
+ "pass",
42
+ "credit",
43
+ "card",
44
+ "cvv",
45
+ "cvc",
46
+ "ssn",
47
+ "social",
48
+ "secret",
49
+ "token",
50
+ "key",
51
+ "pin",
52
+ "security",
53
+ "answer"
54
+ ],
55
+ trackableFields: [
56
+ "email",
57
+ "name",
58
+ "phone",
59
+ "company",
60
+ "first",
61
+ "last",
62
+ "city",
63
+ "country",
64
+ "domain",
65
+ "website",
66
+ "url",
67
+ "organization"
68
+ ],
69
+ thankYouPatterns: [
70
+ /thank[-_]?you/i,
71
+ /success/i,
72
+ /confirmation/i,
73
+ /submitted/i,
74
+ /complete/i
75
+ ],
76
+ // LOA-482: Enable field value capture by default
77
+ captureFieldValues: true,
78
+ maxFieldValueLength: 200
70
79
  };
71
- var AI_BOT_PATTERNS = [
72
- "GPTBot",
73
- "ChatGPT-User",
74
- "ClaudeBot",
75
- "Claude-Web",
76
- "PerplexityBot",
77
- "Amazonbot",
78
- "Google-Extended",
79
- "CCBot",
80
- "anthropic-ai",
81
- "cohere-ai"
82
- ];
83
-
84
- // src/detection/navigation-timing.ts
85
- function detectNavigationType() {
86
- try {
87
- const entries = performance.getEntriesByType("navigation");
88
- if (!entries || entries.length === 0) {
89
- return { nav_type: "unknown", confidence: 0, signals: ["no_timing_data"] };
90
- }
91
- const nav = entries[0];
92
- const signals = [];
93
- let pasteScore = 0;
94
- const fetchStartDelta = nav.fetchStart - nav.startTime;
95
- if (fetchStartDelta < 5) {
96
- pasteScore += 0.25;
97
- signals.push("instant_fetch_start");
98
- } else if (fetchStartDelta < 20) {
99
- pasteScore += 0.15;
100
- signals.push("fast_fetch_start");
101
- }
102
- const dnsTime = nav.domainLookupEnd - nav.domainLookupStart;
103
- if (dnsTime === 0) {
104
- pasteScore += 0.15;
105
- signals.push("no_dns_lookup");
106
- }
107
- const connectTime = nav.connectEnd - nav.connectStart;
108
- if (connectTime === 0) {
109
- pasteScore += 0.15;
110
- signals.push("no_tcp_connect");
111
- }
112
- if (nav.redirectCount === 0) {
113
- pasteScore += 0.1;
114
- signals.push("no_redirects");
115
- }
116
- const timingVariance = calculateTimingVariance(nav);
117
- if (timingVariance < 10) {
118
- pasteScore += 0.15;
119
- signals.push("uniform_timing");
120
- }
121
- if (!document.referrer || document.referrer === "") {
122
- pasteScore += 0.1;
123
- signals.push("no_referrer");
124
- }
125
- const confidence = Math.min(pasteScore, 1);
126
- const nav_type = pasteScore >= 0.5 ? "likely_paste" : "likely_click";
127
- return {
128
- nav_type,
129
- confidence: Math.round(confidence * 1e3) / 1e3,
130
- signals
131
- };
132
- } catch {
133
- return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
134
- }
135
- }
136
- function calculateTimingVariance(nav) {
137
- const timings = [
138
- nav.fetchStart - nav.startTime,
139
- nav.domainLookupEnd - nav.domainLookupStart,
140
- nav.connectEnd - nav.connectStart,
141
- nav.responseStart - nav.requestStart
142
- ].filter((t) => t >= 0);
143
- if (timings.length === 0) return 100;
144
- const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
145
- const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length;
146
- return Math.sqrt(variance);
147
- }
148
-
149
- // src/detection/referrer.ts
150
- function detectAIFromReferrer(referrer) {
151
- if (!referrer) {
152
- return null;
153
- }
154
- try {
155
- const url = new URL(referrer);
156
- const hostname = url.hostname.toLowerCase();
157
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
158
- if (hostname.includes(pattern) || referrer.includes(pattern)) {
159
- return {
160
- isAI: true,
161
- platform,
162
- confidence: 0.95,
163
- // High confidence when referrer matches
164
- method: "referrer"
165
- };
80
+ var FormTracker = class {
81
+ constructor(config2 = {}) {
82
+ this.formStartTimes = /* @__PURE__ */ new Map();
83
+ this.interactedForms = /* @__PURE__ */ new Set();
84
+ this.mutationObserver = null;
85
+ this.handleFocusIn = (e) => {
86
+ const target = e.target;
87
+ if (!this.isFormField(target)) return;
88
+ const form = target.closest("form");
89
+ const formId = this.getFormId(form || target);
90
+ if (!this.formStartTimes.has(formId)) {
91
+ this.formStartTimes.set(formId, Date.now());
92
+ this.interactedForms.add(formId);
93
+ this.emitEvent({
94
+ event_type: "form_start",
95
+ form_id: formId,
96
+ form_type: this.detectFormType(form || target)
97
+ });
166
98
  }
167
- }
168
- return null;
169
- } catch {
170
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
171
- if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
172
- return {
173
- isAI: true,
174
- platform,
175
- confidence: 0.85,
176
- method: "referrer"
177
- };
99
+ const fieldName = this.getFieldName(target);
100
+ if (fieldName && !this.isSensitiveField(fieldName)) {
101
+ this.emitEvent({
102
+ event_type: "form_field",
103
+ form_id: formId,
104
+ form_type: this.detectFormType(form || target),
105
+ field_name: this.sanitizeFieldName(fieldName),
106
+ field_type: target.type || target.tagName.toLowerCase()
107
+ });
178
108
  }
179
- }
180
- return null;
181
- }
182
- }
183
- function detectAIFromUTM(url) {
184
- try {
185
- const params = new URL(url).searchParams;
186
- const utmSource = params.get("utm_source")?.toLowerCase();
187
- if (utmSource) {
188
- for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
189
- if (utmSource.includes(pattern.split(".")[0])) {
190
- return {
191
- isAI: true,
192
- platform,
193
- confidence: 0.99,
194
- // Very high confidence from explicit UTM
195
- method: "referrer"
196
- };
109
+ };
110
+ this.handleSubmit = (e) => {
111
+ const form = e.target;
112
+ if (!form || form.tagName !== "FORM") return;
113
+ const formId = this.getFormId(form);
114
+ const startTime = this.formStartTimes.get(formId);
115
+ const { fields, emailSubmitted } = this.captureFormFields(form);
116
+ this.emitEvent({
117
+ event_type: "form_submit",
118
+ form_id: formId,
119
+ form_type: this.detectFormType(form),
120
+ time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
121
+ is_conversion: true,
122
+ submit_source: "submit",
123
+ fields: fields.length > 0 ? fields : void 0,
124
+ email_submitted: emailSubmitted
125
+ });
126
+ };
127
+ this.handleClick = (e) => {
128
+ const target = e.target;
129
+ if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
130
+ const form = target.closest("form");
131
+ if (form && form.classList.contains("hs-form")) {
132
+ const formId = this.getFormId(form);
133
+ const startTime = this.formStartTimes.get(formId);
134
+ const { fields, emailSubmitted } = this.captureFormFields(form);
135
+ this.emitEvent({
136
+ event_type: "form_submit",
137
+ form_id: formId,
138
+ form_type: "hubspot",
139
+ time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
140
+ is_conversion: true,
141
+ submit_source: "click",
142
+ fields: fields.length > 0 ? fields : void 0,
143
+ email_submitted: emailSubmitted
144
+ });
197
145
  }
198
146
  }
199
- if (utmSource.includes("ai") || utmSource.includes("llm") || utmSource.includes("chatbot")) {
200
- return {
201
- isAI: true,
202
- platform: utmSource,
203
- confidence: 0.9,
204
- method: "referrer"
205
- };
206
- }
207
- }
208
- return null;
209
- } catch {
210
- return null;
211
- }
212
- }
213
-
214
- // src/detection/behavioral-classifier.ts
215
- var NAIVE_BAYES_WEIGHTS = {
216
- human: {
217
- time_to_first_click_delayed: 0.85,
218
- time_to_first_click_normal: 0.75,
219
- time_to_first_click_fast: 0.5,
220
- time_to_first_click_immediate: 0.25,
221
- scroll_speed_variable: 0.8,
222
- scroll_speed_erratic: 0.7,
223
- scroll_speed_uniform: 0.35,
224
- scroll_speed_none: 0.45,
225
- nav_timing_click: 0.75,
226
- nav_timing_unknown: 0.55,
227
- nav_timing_paste: 0.35,
228
- has_referrer: 0.7,
229
- no_referrer: 0.45,
230
- homepage_landing: 0.65,
231
- deep_landing: 0.5,
232
- mouse_movement_curved: 0.9,
233
- mouse_movement_linear: 0.3,
234
- mouse_movement_none: 0.4,
235
- form_fill_normal: 0.85,
236
- form_fill_fast: 0.6,
237
- form_fill_instant: 0.2,
238
- focus_blur_normal: 0.75,
239
- focus_blur_rapid: 0.45
240
- },
241
- ai_influenced: {
242
- time_to_first_click_immediate: 0.75,
243
- time_to_first_click_fast: 0.55,
244
- time_to_first_click_normal: 0.4,
245
- time_to_first_click_delayed: 0.35,
246
- scroll_speed_none: 0.55,
247
- scroll_speed_uniform: 0.7,
248
- scroll_speed_variable: 0.35,
249
- scroll_speed_erratic: 0.4,
250
- nav_timing_paste: 0.75,
251
- nav_timing_unknown: 0.5,
252
- nav_timing_click: 0.35,
253
- no_referrer: 0.65,
254
- has_referrer: 0.4,
255
- deep_landing: 0.6,
256
- homepage_landing: 0.45,
257
- mouse_movement_none: 0.6,
258
- mouse_movement_linear: 0.75,
259
- mouse_movement_curved: 0.25,
260
- form_fill_instant: 0.8,
261
- form_fill_fast: 0.55,
262
- form_fill_normal: 0.3,
263
- focus_blur_rapid: 0.6,
264
- focus_blur_normal: 0.4
265
- }
266
- };
267
- var PRIORS = {
268
- human: 0.85,
269
- ai_influenced: 0.15
270
- };
271
- var DEFAULT_WEIGHT = 0.5;
272
- var BehavioralClassifier = class {
273
- /**
274
- * Create a new classifier
275
- * @param minSessionTimeMs Minimum session time before classification (default: 10s)
276
- */
277
- constructor(minSessionTimeMs = 1e4) {
278
- this.classified = false;
279
- this.result = null;
280
- this.onClassify = null;
281
- this.minSessionTime = minSessionTimeMs;
282
- this.data = {
283
- firstClickTime: null,
284
- scrollEvents: [],
285
- mouseEvents: [],
286
- formEvents: [],
287
- focusBlurEvents: [],
288
- startTime: Date.now()
289
- };
290
- }
291
- /**
292
- * Set callback for when classification completes
293
- */
294
- setOnClassify(callback) {
295
- this.onClassify = callback;
296
- }
297
- /**
298
- * Record a click event
299
- */
300
- recordClick() {
301
- if (this.data.firstClickTime === null) {
302
- this.data.firstClickTime = Date.now();
303
- }
304
- this.checkAndClassify();
305
- }
306
- /**
307
- * Record a scroll event
308
- */
309
- recordScroll(position) {
310
- this.data.scrollEvents.push({ time: Date.now(), position });
311
- if (this.data.scrollEvents.length > 50) {
312
- this.data.scrollEvents = this.data.scrollEvents.slice(-50);
313
- }
314
- this.checkAndClassify();
315
- }
316
- /**
317
- * Record mouse movement
318
- */
319
- recordMouse(x, y) {
320
- this.data.mouseEvents.push({ time: Date.now(), x, y });
321
- if (this.data.mouseEvents.length > 100) {
322
- this.data.mouseEvents = this.data.mouseEvents.slice(-100);
323
- }
324
- this.checkAndClassify();
325
- }
326
- /**
327
- * Record form field interaction start
328
- */
329
- recordFormStart(fieldId) {
330
- const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
331
- if (!existing) {
332
- this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
333
- }
334
- }
335
- /**
336
- * Record form field interaction end
337
- */
338
- recordFormEnd(fieldId) {
339
- const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
340
- if (event) {
341
- event.endTime = Date.now();
342
- }
343
- this.checkAndClassify();
147
+ if (target.closest('[data-qa="submit-button"]')) {
148
+ this.emitEvent({
149
+ event_type: "form_submit",
150
+ form_id: "typeform_embed",
151
+ form_type: "typeform",
152
+ is_conversion: true,
153
+ submit_source: "click"
154
+ });
155
+ }
156
+ };
157
+ this.config = {
158
+ ...DEFAULT_CONFIG,
159
+ ...config2,
160
+ sensitiveFields: [
161
+ ...DEFAULT_CONFIG.sensitiveFields,
162
+ ...config2.sensitiveFields || []
163
+ ]
164
+ };
344
165
  }
345
166
  /**
346
- * Record focus/blur event
167
+ * Start tracking forms
347
168
  */
348
- recordFocusBlur(type) {
349
- this.data.focusBlurEvents.push({ type, time: Date.now() });
350
- if (this.data.focusBlurEvents.length > 20) {
351
- this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
352
- }
169
+ start() {
170
+ document.addEventListener("focusin", this.handleFocusIn, { passive: true });
171
+ document.addEventListener("submit", this.handleSubmit);
172
+ document.addEventListener("click", this.handleClick, { passive: true });
173
+ this.startMutationObserver();
174
+ this.checkThankYouPage();
175
+ this.scanForEmbeddedForms();
353
176
  }
354
177
  /**
355
- * Check if we have enough data and classify
178
+ * Stop tracking
356
179
  */
357
- checkAndClassify() {
358
- if (this.classified) return;
359
- const sessionDuration = Date.now() - this.data.startTime;
360
- if (sessionDuration < this.minSessionTime) return;
361
- const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
362
- if (!hasData) return;
363
- this.classify();
180
+ stop() {
181
+ document.removeEventListener("focusin", this.handleFocusIn);
182
+ document.removeEventListener("submit", this.handleSubmit);
183
+ document.removeEventListener("click", this.handleClick);
184
+ this.mutationObserver?.disconnect();
364
185
  }
365
186
  /**
366
- * Force classification (for beforeunload)
187
+ * Get forms that had interaction
367
188
  */
368
- forceClassify() {
369
- if (this.classified) return this.result;
370
- return this.classify();
189
+ getInteractedForms() {
190
+ return Array.from(this.interactedForms);
371
191
  }
372
192
  /**
373
- * Perform classification
193
+ * LOA-482: Capture form field values with privacy-safe sanitization
194
+ * - Never captures sensitive fields (password, credit card, etc.)
195
+ * - Truncates values to maxFieldValueLength
196
+ * - Extracts email if found
374
197
  */
375
- classify() {
376
- const sessionDuration = Date.now() - this.data.startTime;
377
- const signals = this.extractSignals();
378
- let humanLogProb = Math.log(PRIORS.human);
379
- let aiLogProb = Math.log(PRIORS.ai_influenced);
380
- for (const signal of signals) {
381
- const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
382
- const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
383
- humanLogProb += Math.log(humanWeight);
384
- aiLogProb += Math.log(aiWeight);
385
- }
386
- const maxLog = Math.max(humanLogProb, aiLogProb);
387
- const humanExp = Math.exp(humanLogProb - maxLog);
388
- const aiExp = Math.exp(aiLogProb - maxLog);
389
- const total = humanExp + aiExp;
390
- const humanProbability = humanExp / total;
391
- const aiProbability = aiExp / total;
392
- let classification;
393
- let confidence;
394
- if (humanProbability > 0.6) {
395
- classification = "human";
396
- confidence = humanProbability;
397
- } else if (aiProbability > 0.6) {
398
- classification = "ai_influenced";
399
- confidence = aiProbability;
400
- } else {
401
- classification = "uncertain";
402
- confidence = Math.max(humanProbability, aiProbability);
198
+ captureFormFields(form) {
199
+ const fields = [];
200
+ let emailSubmitted;
201
+ if (!this.config.captureFieldValues) {
202
+ return { fields, emailSubmitted };
403
203
  }
404
- this.result = {
405
- classification,
406
- humanProbability,
407
- aiProbability,
408
- confidence,
409
- signals,
410
- timestamp: Date.now(),
411
- sessionDurationMs: sessionDuration
412
- };
413
- this.classified = true;
414
- if (this.onClassify) {
415
- this.onClassify(this.result);
204
+ try {
205
+ const formData = new FormData(form);
206
+ for (const [name, value] of formData.entries()) {
207
+ if (this.isSensitiveField(name)) {
208
+ continue;
209
+ }
210
+ const input = form.elements.namedItem(name);
211
+ const inputType = input?.type || "text";
212
+ if (inputType === "file" || value instanceof File) {
213
+ continue;
214
+ }
215
+ const stringValue = String(value);
216
+ if (this.isEmailField(name, stringValue)) {
217
+ emailSubmitted = stringValue.substring(0, 254);
218
+ }
219
+ const truncatedValue = stringValue.length > this.config.maxFieldValueLength ? stringValue.substring(0, this.config.maxFieldValueLength) + "..." : stringValue;
220
+ fields.push({
221
+ name: this.sanitizeFieldName(name),
222
+ type: inputType,
223
+ value: truncatedValue
224
+ });
225
+ }
226
+ } catch (err) {
227
+ console.warn("[Loamly] Failed to capture form fields:", err);
416
228
  }
417
- return this.result;
229
+ return { fields, emailSubmitted };
418
230
  }
419
231
  /**
420
- * Extract behavioral signals from collected data
232
+ * Check if a field contains an email
421
233
  */
422
- extractSignals() {
423
- const signals = [];
424
- if (this.data.firstClickTime !== null) {
425
- const timeToClick = this.data.firstClickTime - this.data.startTime;
426
- if (timeToClick < 500) {
427
- signals.push("time_to_first_click_immediate");
428
- } else if (timeToClick < 2e3) {
429
- signals.push("time_to_first_click_fast");
430
- } else if (timeToClick < 1e4) {
431
- signals.push("time_to_first_click_normal");
432
- } else {
433
- signals.push("time_to_first_click_delayed");
234
+ isEmailField(fieldName, value) {
235
+ const lowerName = fieldName.toLowerCase();
236
+ const isEmailName = lowerName.includes("email") || lowerName === "e-mail";
237
+ const isEmailValue = /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value);
238
+ return isEmailName && isEmailValue;
239
+ }
240
+ startMutationObserver() {
241
+ this.mutationObserver = new MutationObserver((mutations) => {
242
+ for (const mutation of mutations) {
243
+ for (const node of mutation.addedNodes) {
244
+ if (node instanceof HTMLElement) {
245
+ if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
246
+ this.trackEmbeddedForm(node, "hubspot");
247
+ }
248
+ if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
249
+ this.trackEmbeddedForm(node, "typeform");
250
+ }
251
+ if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
252
+ this.trackEmbeddedForm(node, "jotform");
253
+ }
254
+ if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
255
+ this.trackEmbeddedForm(node, "gravity");
256
+ }
257
+ }
258
+ }
434
259
  }
435
- }
436
- if (this.data.scrollEvents.length === 0) {
437
- signals.push("scroll_speed_none");
438
- } else if (this.data.scrollEvents.length >= 3) {
439
- const scrollDeltas = [];
440
- for (let i = 1; i < this.data.scrollEvents.length; i++) {
441
- const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
442
- scrollDeltas.push(delta);
260
+ });
261
+ this.mutationObserver.observe(document.body, {
262
+ childList: true,
263
+ subtree: true
264
+ });
265
+ }
266
+ scanForEmbeddedForms() {
267
+ document.querySelectorAll(".hs-form").forEach((form) => {
268
+ this.trackEmbeddedForm(form, "hubspot");
269
+ });
270
+ document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
271
+ this.trackEmbeddedForm(form, "typeform");
272
+ });
273
+ document.querySelectorAll(".jotform-form").forEach((form) => {
274
+ this.trackEmbeddedForm(form, "jotform");
275
+ });
276
+ document.querySelectorAll(".gform_wrapper").forEach((form) => {
277
+ this.trackEmbeddedForm(form, "gravity");
278
+ });
279
+ }
280
+ trackEmbeddedForm(element, type) {
281
+ const formId = `${type}_${this.getFormId(element)}`;
282
+ element.addEventListener("focusin", () => {
283
+ if (!this.formStartTimes.has(formId)) {
284
+ this.formStartTimes.set(formId, Date.now());
285
+ this.interactedForms.add(formId);
286
+ this.emitEvent({
287
+ event_type: "form_start",
288
+ form_id: formId,
289
+ form_type: type
290
+ });
443
291
  }
444
- const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
445
- const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
446
- const stdDev = Math.sqrt(variance);
447
- const cv = mean > 0 ? stdDev / mean : 0;
448
- if (cv < 0.2) {
449
- signals.push("scroll_speed_uniform");
450
- } else if (cv < 0.6) {
451
- signals.push("scroll_speed_variable");
452
- } else {
453
- signals.push("scroll_speed_erratic");
292
+ }, { passive: true });
293
+ }
294
+ checkThankYouPage() {
295
+ const url = window.location.href.toLowerCase();
296
+ const title = document.title.toLowerCase();
297
+ for (const pattern of this.config.thankYouPatterns) {
298
+ if (pattern.test(url) || pattern.test(title)) {
299
+ this.emitEvent({
300
+ event_type: "form_success",
301
+ form_id: "page_conversion",
302
+ form_type: "unknown",
303
+ is_conversion: true,
304
+ submit_source: "thank_you"
305
+ });
306
+ break;
454
307
  }
455
308
  }
456
- if (this.data.mouseEvents.length === 0) {
457
- signals.push("mouse_movement_none");
458
- } else if (this.data.mouseEvents.length >= 10) {
459
- const n = Math.min(this.data.mouseEvents.length, 20);
460
- const recentMouse = this.data.mouseEvents.slice(-n);
461
- let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
462
- for (const event of recentMouse) {
463
- sumX += event.x;
464
- sumY += event.y;
465
- sumXY += event.x * event.y;
466
- sumX2 += event.x * event.x;
467
- }
468
- const denominator = n * sumX2 - sumX * sumX;
469
- const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
470
- const intercept = (sumY - slope * sumX) / n;
471
- let ssRes = 0, ssTot = 0;
472
- const yMean = sumY / n;
473
- for (const event of recentMouse) {
474
- const yPred = slope * event.x + intercept;
475
- ssRes += Math.pow(event.y - yPred, 2);
476
- ssTot += Math.pow(event.y - yMean, 2);
477
- }
478
- const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
479
- if (r2 > 0.95) {
480
- signals.push("mouse_movement_linear");
481
- } else {
482
- signals.push("mouse_movement_curved");
483
- }
309
+ }
310
+ isFormField(element) {
311
+ const tagName = element.tagName;
312
+ return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
313
+ }
314
+ getFormId(element) {
315
+ if (!element) return "unknown";
316
+ return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
317
+ }
318
+ getFieldName(input) {
319
+ return input.name || input.id || input.getAttribute("data-name") || "";
320
+ }
321
+ isSensitiveField(fieldName) {
322
+ const lowerName = fieldName.toLowerCase();
323
+ return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
324
+ }
325
+ sanitizeFieldName(fieldName) {
326
+ return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
327
+ }
328
+ detectFormType(element) {
329
+ if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
330
+ return "hubspot";
484
331
  }
485
- const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
486
- if (completedForms.length > 0) {
487
- const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
488
- if (avgFillTime < 100) {
489
- signals.push("form_fill_instant");
490
- } else if (avgFillTime < 500) {
491
- signals.push("form_fill_fast");
492
- } else {
493
- signals.push("form_fill_normal");
494
- }
332
+ if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
333
+ return "typeform";
495
334
  }
496
- if (this.data.focusBlurEvents.length >= 4) {
497
- const recentFB = this.data.focusBlurEvents.slice(-10);
498
- const intervals = [];
499
- for (let i = 1; i < recentFB.length; i++) {
500
- intervals.push(recentFB[i].time - recentFB[i - 1].time);
501
- }
502
- const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
503
- if (avgInterval < 1e3) {
504
- signals.push("focus_blur_rapid");
505
- } else {
506
- signals.push("focus_blur_normal");
507
- }
335
+ if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
336
+ return "jotform";
508
337
  }
509
- return signals;
338
+ if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
339
+ return "gravity";
340
+ }
341
+ if (element.tagName === "FORM") {
342
+ return "native";
343
+ }
344
+ return "unknown";
345
+ }
346
+ emitEvent(event) {
347
+ this.config.onFormEvent?.(event);
348
+ }
349
+ };
350
+
351
+ // src/behavioral/scroll-tracker.ts
352
+ var DEFAULT_CHUNKS = [30, 60, 90, 100];
353
+ var ScrollTracker = class {
354
+ constructor(config2 = {}) {
355
+ this.maxDepth = 0;
356
+ this.reportedChunks = /* @__PURE__ */ new Set();
357
+ this.ticking = false;
358
+ this.isVisible = true;
359
+ this.handleScroll = () => {
360
+ if (!this.ticking && this.isVisible) {
361
+ requestAnimationFrame(() => {
362
+ this.checkScrollDepth();
363
+ this.ticking = false;
364
+ });
365
+ this.ticking = true;
366
+ }
367
+ };
368
+ this.handleVisibility = () => {
369
+ this.isVisible = document.visibilityState === "visible";
370
+ };
371
+ this.config = {
372
+ chunks: DEFAULT_CHUNKS,
373
+ ...config2
374
+ };
375
+ this.startTime = Date.now();
510
376
  }
511
377
  /**
512
- * Add context signals (set by tracker from external data)
378
+ * Start tracking scroll depth
513
379
  */
514
- addContextSignal(_signal) {
380
+ start() {
381
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
382
+ document.addEventListener("visibilitychange", this.handleVisibility);
383
+ this.checkScrollDepth();
515
384
  }
516
385
  /**
517
- * Get current result (null if not yet classified)
386
+ * Stop tracking
518
387
  */
519
- getResult() {
520
- return this.result;
388
+ stop() {
389
+ window.removeEventListener("scroll", this.handleScroll);
390
+ document.removeEventListener("visibilitychange", this.handleVisibility);
521
391
  }
522
392
  /**
523
- * Check if classification has been performed
393
+ * Get current max scroll depth
524
394
  */
525
- hasClassified() {
526
- return this.classified;
527
- }
528
- };
529
-
530
- // src/detection/focus-blur.ts
531
- var FocusBlurAnalyzer = class {
532
- constructor() {
533
- this.sequence = [];
534
- this.firstInteractionTime = null;
535
- this.analyzed = false;
536
- this.result = null;
537
- this.pageLoadTime = performance.now();
395
+ getMaxDepth() {
396
+ return this.maxDepth;
538
397
  }
539
398
  /**
540
- * Initialize event tracking
541
- * Must be called after DOM is ready
399
+ * Get reported chunks
542
400
  */
543
- initTracking() {
544
- document.addEventListener("focus", (e) => {
545
- this.recordEvent("focus", e.target);
546
- }, true);
547
- document.addEventListener("blur", (e) => {
548
- this.recordEvent("blur", e.target);
549
- }, true);
550
- window.addEventListener("focus", () => {
551
- this.recordEvent("window_focus", null);
552
- });
553
- window.addEventListener("blur", () => {
554
- this.recordEvent("window_blur", null);
555
- });
556
- const recordFirstInteraction = () => {
557
- if (this.firstInteractionTime === null) {
558
- this.firstInteractionTime = performance.now();
559
- }
560
- };
561
- document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
562
- document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
401
+ getReportedChunks() {
402
+ return Array.from(this.reportedChunks).sort((a, b) => a - b);
563
403
  }
564
404
  /**
565
- * Record a focus/blur event
405
+ * Get final scroll event (for unload)
566
406
  */
567
- recordEvent(type, target) {
568
- const event = {
569
- type,
570
- target: target?.tagName || "WINDOW",
571
- timestamp: performance.now()
407
+ getFinalEvent() {
408
+ const docHeight = document.documentElement.scrollHeight;
409
+ const viewportHeight = window.innerHeight;
410
+ return {
411
+ depth: this.maxDepth,
412
+ chunk: this.getChunkForDepth(this.maxDepth),
413
+ time_to_reach_ms: Date.now() - this.startTime,
414
+ total_height: docHeight,
415
+ viewport_height: viewportHeight
572
416
  };
573
- this.sequence.push(event);
574
- if (this.sequence.length > 20) {
575
- this.sequence = this.sequence.slice(-20);
576
- }
577
417
  }
578
- /**
579
- * Analyze the focus/blur sequence for paste patterns
580
- */
581
- analyze() {
582
- if (this.analyzed && this.result) {
583
- return this.result;
584
- }
585
- const signals = [];
586
- let confidence = 0;
587
- const earlyEvents = this.sequence.filter((e) => e.timestamp < this.pageLoadTime + 500);
588
- const hasEarlyWindowFocus = earlyEvents.some((e) => e.type === "window_focus");
589
- if (hasEarlyWindowFocus) {
590
- signals.push("early_window_focus");
591
- confidence += 0.15;
592
- }
593
- const hasEarlyBodyFocus = earlyEvents.some(
594
- (e) => e.type === "focus" && e.target === "BODY"
595
- );
596
- if (hasEarlyBodyFocus) {
597
- signals.push("early_body_focus");
598
- confidence += 0.15;
599
- }
600
- const hasLinkFocus = this.sequence.some(
601
- (e) => e.type === "focus" && e.target === "A"
602
- );
603
- if (!hasLinkFocus) {
604
- signals.push("no_link_focus");
605
- confidence += 0.1;
606
- }
607
- const firstFocus = this.sequence.find((e) => e.type === "focus");
608
- if (firstFocus && (firstFocus.target === "BODY" || firstFocus.target === "HTML")) {
609
- signals.push("first_focus_body");
610
- confidence += 0.1;
611
- }
612
- const windowEvents = this.sequence.filter(
613
- (e) => e.type === "window_focus" || e.type === "window_blur"
614
- );
615
- if (windowEvents.length <= 2) {
616
- signals.push("minimal_window_switches");
617
- confidence += 0.05;
418
+ checkScrollDepth() {
419
+ const scrollY = window.scrollY;
420
+ const viewportHeight = window.innerHeight;
421
+ const docHeight = document.documentElement.scrollHeight;
422
+ if (docHeight <= viewportHeight) {
423
+ this.updateDepth(100);
424
+ return;
618
425
  }
619
- if (this.firstInteractionTime !== null) {
620
- const timeToInteraction = this.firstInteractionTime - this.pageLoadTime;
621
- if (timeToInteraction > 3e3) {
622
- signals.push("delayed_first_interaction");
623
- confidence += 0.1;
426
+ const scrollableHeight = docHeight - viewportHeight;
427
+ const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
428
+ this.updateDepth(currentDepth);
429
+ }
430
+ updateDepth(depth) {
431
+ if (depth <= this.maxDepth) return;
432
+ this.maxDepth = depth;
433
+ this.config.onDepthChange?.(depth);
434
+ for (const chunk of this.config.chunks) {
435
+ if (depth >= chunk && !this.reportedChunks.has(chunk)) {
436
+ this.reportedChunks.add(chunk);
437
+ this.reportChunk(chunk);
624
438
  }
625
439
  }
626
- confidence = Math.min(confidence, 0.65);
627
- let navType;
628
- if (confidence >= 0.35) {
629
- navType = "likely_paste";
630
- } else if (signals.length === 0) {
631
- navType = "unknown";
632
- } else {
633
- navType = "likely_click";
440
+ }
441
+ reportChunk(chunk) {
442
+ const docHeight = document.documentElement.scrollHeight;
443
+ const viewportHeight = window.innerHeight;
444
+ const event = {
445
+ depth: this.maxDepth,
446
+ chunk,
447
+ time_to_reach_ms: Date.now() - this.startTime,
448
+ total_height: docHeight,
449
+ viewport_height: viewportHeight
450
+ };
451
+ this.config.onChunkReached?.(event);
452
+ }
453
+ getChunkForDepth(depth) {
454
+ const chunks = this.config.chunks.sort((a, b) => b - a);
455
+ for (const chunk of chunks) {
456
+ if (depth >= chunk) return chunk;
634
457
  }
635
- this.result = {
636
- nav_type: navType,
637
- confidence,
638
- signals,
639
- sequence: this.sequence.slice(-10),
640
- time_to_first_interaction_ms: this.firstInteractionTime ? Math.round(this.firstInteractionTime - this.pageLoadTime) : null
458
+ return 0;
459
+ }
460
+ };
461
+
462
+ // src/behavioral/time-tracker.ts
463
+ var DEFAULT_CONFIG2 = {
464
+ idleThresholdMs: 3e4,
465
+ // 30 seconds
466
+ updateIntervalMs: 5e3
467
+ // 5 seconds
468
+ };
469
+ var TimeTracker = class {
470
+ constructor(config2 = {}) {
471
+ this.activeTime = 0;
472
+ this.idleTime = 0;
473
+ this.isVisible = true;
474
+ this.isIdle = false;
475
+ this.updateInterval = null;
476
+ this.idleCheckInterval = null;
477
+ this.handleVisibility = () => {
478
+ const wasVisible = this.isVisible;
479
+ this.isVisible = document.visibilityState === "visible";
480
+ if (wasVisible && !this.isVisible) {
481
+ this.updateTimes();
482
+ } else if (!wasVisible && this.isVisible) {
483
+ this.lastUpdateTime = Date.now();
484
+ this.lastActivityTime = Date.now();
485
+ }
486
+ };
487
+ this.handleActivity = () => {
488
+ const now = Date.now();
489
+ if (this.isIdle) {
490
+ this.isIdle = false;
491
+ }
492
+ this.lastActivityTime = now;
641
493
  };
642
- this.analyzed = true;
643
- return this.result;
494
+ this.config = { ...DEFAULT_CONFIG2, ...config2 };
495
+ this.startTime = Date.now();
496
+ this.lastActivityTime = this.startTime;
497
+ this.lastUpdateTime = this.startTime;
644
498
  }
645
499
  /**
646
- * Get current result (analyze if not done)
500
+ * Start tracking time
647
501
  */
648
- getResult() {
649
- return this.analyze();
502
+ start() {
503
+ document.addEventListener("visibilitychange", this.handleVisibility);
504
+ const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
505
+ activityEvents.forEach((event) => {
506
+ document.addEventListener(event, this.handleActivity, { passive: true });
507
+ });
508
+ this.updateInterval = setInterval(() => {
509
+ this.update();
510
+ }, this.config.updateIntervalMs);
511
+ this.idleCheckInterval = setInterval(() => {
512
+ this.checkIdle();
513
+ }, 1e3);
650
514
  }
651
515
  /**
652
- * Check if analysis has been performed
516
+ * Stop tracking
653
517
  */
654
- hasAnalyzed() {
655
- return this.analyzed;
518
+ stop() {
519
+ document.removeEventListener("visibilitychange", this.handleVisibility);
520
+ const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
521
+ activityEvents.forEach((event) => {
522
+ document.removeEventListener(event, this.handleActivity);
523
+ });
524
+ if (this.updateInterval) {
525
+ clearInterval(this.updateInterval);
526
+ this.updateInterval = null;
527
+ }
528
+ if (this.idleCheckInterval) {
529
+ clearInterval(this.idleCheckInterval);
530
+ this.idleCheckInterval = null;
531
+ }
656
532
  }
657
533
  /**
658
- * Get the raw sequence for debugging
534
+ * Get current time metrics
659
535
  */
660
- getSequence() {
661
- return [...this.sequence];
536
+ getMetrics() {
537
+ this.updateTimes();
538
+ return {
539
+ active_time_ms: this.activeTime,
540
+ total_time_ms: Date.now() - this.startTime,
541
+ idle_time_ms: this.idleTime,
542
+ is_engaged: !this.isIdle && this.isVisible
543
+ };
662
544
  }
663
545
  /**
664
- * Reset the analyzer
546
+ * Get final metrics (for unload)
665
547
  */
666
- reset() {
667
- this.sequence = [];
668
- this.pageLoadTime = performance.now();
669
- this.firstInteractionTime = null;
670
- this.analyzed = false;
671
- this.result = null;
548
+ getFinalMetrics() {
549
+ this.updateTimes();
550
+ return this.getMetrics();
551
+ }
552
+ checkIdle() {
553
+ const now = Date.now();
554
+ const timeSinceActivity = now - this.lastActivityTime;
555
+ if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
556
+ this.isIdle = true;
557
+ }
558
+ }
559
+ updateTimes() {
560
+ const now = Date.now();
561
+ const elapsed = now - this.lastUpdateTime;
562
+ if (this.isVisible) {
563
+ if (this.isIdle) {
564
+ this.idleTime += elapsed;
565
+ } else {
566
+ this.activeTime += elapsed;
567
+ }
568
+ }
569
+ this.lastUpdateTime = now;
570
+ }
571
+ update() {
572
+ if (!this.isVisible) return;
573
+ this.updateTimes();
574
+ this.config.onUpdate?.(this.getMetrics());
672
575
  }
673
576
  };
674
577
 
578
+ // src/config.ts
579
+ var VERSION = "2.4.0";
580
+ var DEFAULT_CONFIG3 = {
581
+ apiHost: "https://app.loamly.ai",
582
+ endpoints: {
583
+ visit: "/api/ingest/visit",
584
+ behavioral: "/api/ingest/behavioral",
585
+ session: "/api/ingest/session",
586
+ resolve: "/api/tracker/resolve",
587
+ health: "/api/tracker/health",
588
+ ping: "/api/tracker/ping"
589
+ },
590
+ pingInterval: 3e4,
591
+ // 30 seconds
592
+ batchSize: 10,
593
+ batchTimeout: 5e3,
594
+ sessionTimeout: 18e5,
595
+ // 30 minutes
596
+ maxTextLength: 100,
597
+ timeSpentThresholdMs: 5e3
598
+ // Only send time_spent when delta >= 5 seconds
599
+ };
600
+ var AI_PLATFORMS = {
601
+ "chatgpt.com": "chatgpt",
602
+ "chat.openai.com": "chatgpt",
603
+ "claude.ai": "claude",
604
+ "perplexity.ai": "perplexity",
605
+ "bard.google.com": "bard",
606
+ "gemini.google.com": "gemini",
607
+ "copilot.microsoft.com": "copilot",
608
+ "github.com/copilot": "github-copilot",
609
+ "you.com": "you",
610
+ "phind.com": "phind",
611
+ "poe.com": "poe"
612
+ };
613
+ var AI_BOT_PATTERNS = [
614
+ "GPTBot",
615
+ "ChatGPT-User",
616
+ "ClaudeBot",
617
+ "Claude-Web",
618
+ "PerplexityBot",
619
+ "Amazonbot",
620
+ "Google-Extended",
621
+ "CCBot",
622
+ "anthropic-ai",
623
+ "cohere-ai"
624
+ ];
625
+
675
626
  // src/detection/agentic-browser.ts
676
627
  var CometDetector = class {
677
628
  constructor() {
@@ -873,764 +824,877 @@ function createAgenticAnalyzer() {
873
824
  return analyzer;
874
825
  }
875
826
 
876
- // src/infrastructure/event-queue.ts
877
- var DEFAULT_QUEUE_CONFIG = {
878
- batchSize: DEFAULT_CONFIG.batchSize,
879
- batchTimeout: DEFAULT_CONFIG.batchTimeout,
880
- maxRetries: 3,
881
- retryDelayMs: 1e3,
882
- storageKey: "_loamly_queue"
827
+ // src/detection/behavioral-classifier.ts
828
+ var NAIVE_BAYES_WEIGHTS = {
829
+ human: {
830
+ time_to_first_click_delayed: 0.85,
831
+ time_to_first_click_normal: 0.75,
832
+ time_to_first_click_fast: 0.5,
833
+ time_to_first_click_immediate: 0.25,
834
+ scroll_speed_variable: 0.8,
835
+ scroll_speed_erratic: 0.7,
836
+ scroll_speed_uniform: 0.35,
837
+ scroll_speed_none: 0.45,
838
+ nav_timing_click: 0.75,
839
+ nav_timing_unknown: 0.55,
840
+ nav_timing_paste: 0.35,
841
+ has_referrer: 0.7,
842
+ no_referrer: 0.45,
843
+ homepage_landing: 0.65,
844
+ deep_landing: 0.5,
845
+ mouse_movement_curved: 0.9,
846
+ mouse_movement_linear: 0.3,
847
+ mouse_movement_none: 0.4,
848
+ form_fill_normal: 0.85,
849
+ form_fill_fast: 0.6,
850
+ form_fill_instant: 0.2,
851
+ focus_blur_normal: 0.75,
852
+ focus_blur_rapid: 0.45
853
+ },
854
+ ai_influenced: {
855
+ time_to_first_click_immediate: 0.75,
856
+ time_to_first_click_fast: 0.55,
857
+ time_to_first_click_normal: 0.4,
858
+ time_to_first_click_delayed: 0.35,
859
+ scroll_speed_none: 0.55,
860
+ scroll_speed_uniform: 0.7,
861
+ scroll_speed_variable: 0.35,
862
+ scroll_speed_erratic: 0.4,
863
+ nav_timing_paste: 0.75,
864
+ nav_timing_unknown: 0.5,
865
+ nav_timing_click: 0.35,
866
+ no_referrer: 0.65,
867
+ has_referrer: 0.4,
868
+ deep_landing: 0.6,
869
+ homepage_landing: 0.45,
870
+ mouse_movement_none: 0.6,
871
+ mouse_movement_linear: 0.75,
872
+ mouse_movement_curved: 0.25,
873
+ form_fill_instant: 0.8,
874
+ form_fill_fast: 0.55,
875
+ form_fill_normal: 0.3,
876
+ focus_blur_rapid: 0.6,
877
+ focus_blur_normal: 0.4
878
+ }
883
879
  };
884
- var EventQueue = class {
885
- constructor(endpoint2, config2 = {}) {
886
- this.queue = [];
887
- this.batchTimer = null;
888
- this.isFlushing = false;
889
- this.endpoint = endpoint2;
890
- this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
891
- this.loadFromStorage();
880
+ var PRIORS = {
881
+ human: 0.85,
882
+ ai_influenced: 0.15
883
+ };
884
+ var DEFAULT_WEIGHT = 0.5;
885
+ var BehavioralClassifier = class {
886
+ /**
887
+ * Create a new classifier
888
+ * @param minSessionTimeMs Minimum session time before classification (default: 10s)
889
+ */
890
+ constructor(minSessionTimeMs = 1e4) {
891
+ this.classified = false;
892
+ this.result = null;
893
+ this.onClassify = null;
894
+ this.minSessionTime = minSessionTimeMs;
895
+ this.data = {
896
+ firstClickTime: null,
897
+ scrollEvents: [],
898
+ mouseEvents: [],
899
+ formEvents: [],
900
+ focusBlurEvents: [],
901
+ startTime: Date.now()
902
+ };
903
+ }
904
+ /**
905
+ * Set callback for when classification completes
906
+ */
907
+ setOnClassify(callback) {
908
+ this.onClassify = callback;
909
+ }
910
+ /**
911
+ * Record a click event
912
+ */
913
+ recordClick() {
914
+ if (this.data.firstClickTime === null) {
915
+ this.data.firstClickTime = Date.now();
916
+ }
917
+ this.checkAndClassify();
918
+ }
919
+ /**
920
+ * Record a scroll event
921
+ */
922
+ recordScroll(position) {
923
+ this.data.scrollEvents.push({ time: Date.now(), position });
924
+ if (this.data.scrollEvents.length > 50) {
925
+ this.data.scrollEvents = this.data.scrollEvents.slice(-50);
926
+ }
927
+ this.checkAndClassify();
928
+ }
929
+ /**
930
+ * Record mouse movement
931
+ */
932
+ recordMouse(x, y) {
933
+ this.data.mouseEvents.push({ time: Date.now(), x, y });
934
+ if (this.data.mouseEvents.length > 100) {
935
+ this.data.mouseEvents = this.data.mouseEvents.slice(-100);
936
+ }
937
+ this.checkAndClassify();
892
938
  }
893
939
  /**
894
- * Add event to queue
940
+ * Record form field interaction start
895
941
  */
896
- push(type, payload, headers) {
897
- const event = {
898
- id: this.generateId(),
899
- type,
900
- payload,
901
- headers,
902
- timestamp: Date.now(),
903
- retries: 0
904
- };
905
- this.queue.push(event);
906
- this.saveToStorage();
907
- this.scheduleBatch();
942
+ recordFormStart(fieldId) {
943
+ const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
944
+ if (!existing) {
945
+ this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
946
+ }
908
947
  }
909
948
  /**
910
- * Force flush all events immediately
949
+ * Record form field interaction end
911
950
  */
912
- async flush() {
913
- if (this.isFlushing || this.queue.length === 0) return;
914
- this.isFlushing = true;
915
- this.clearBatchTimer();
916
- try {
917
- const events = [...this.queue];
918
- this.queue = [];
919
- await this.sendBatch(events);
920
- } finally {
921
- this.isFlushing = false;
922
- this.saveToStorage();
951
+ recordFormEnd(fieldId) {
952
+ const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
953
+ if (event) {
954
+ event.endTime = Date.now();
923
955
  }
956
+ this.checkAndClassify();
924
957
  }
925
958
  /**
926
- * Flush using sendBeacon (for unload events)
959
+ * Record focus/blur event
927
960
  */
928
- flushBeacon() {
929
- if (this.queue.length === 0) return true;
930
- const baseUrl = this.config.apiKey ? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}` : this.endpoint;
931
- let allSent = true;
932
- for (const event of this.queue) {
933
- const payload = {
934
- ...event.payload,
935
- _queue_id: event.id,
936
- _queue_timestamp: event.timestamp
937
- };
938
- const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false;
939
- if (!success) {
940
- allSent = false;
941
- break;
942
- }
943
- }
944
- if (allSent) {
945
- this.queue = [];
946
- this.clearStorage();
961
+ recordFocusBlur(type) {
962
+ this.data.focusBlurEvents.push({ type, time: Date.now() });
963
+ if (this.data.focusBlurEvents.length > 20) {
964
+ this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
947
965
  }
948
- return allSent;
949
966
  }
950
967
  /**
951
- * Get current queue length
968
+ * Check if we have enough data and classify
952
969
  */
953
- get length() {
954
- return this.queue.length;
970
+ checkAndClassify() {
971
+ if (this.classified) return;
972
+ const sessionDuration = Date.now() - this.data.startTime;
973
+ if (sessionDuration < this.minSessionTime) return;
974
+ const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
975
+ if (!hasData) return;
976
+ this.classify();
955
977
  }
956
- scheduleBatch() {
957
- if (this.batchTimer) return;
958
- if (this.queue.length >= this.config.batchSize) {
959
- this.flush();
960
- return;
961
- }
962
- this.batchTimer = setTimeout(() => {
963
- this.batchTimer = null;
964
- this.flush();
965
- }, this.config.batchTimeout);
978
+ /**
979
+ * Force classification (for beforeunload)
980
+ */
981
+ forceClassify() {
982
+ if (this.classified) return this.result;
983
+ return this.classify();
966
984
  }
967
- clearBatchTimer() {
968
- if (this.batchTimer) {
969
- clearTimeout(this.batchTimer);
970
- this.batchTimer = null;
985
+ /**
986
+ * Perform classification
987
+ */
988
+ classify() {
989
+ const sessionDuration = Date.now() - this.data.startTime;
990
+ const signals = this.extractSignals();
991
+ let humanLogProb = Math.log(PRIORS.human);
992
+ let aiLogProb = Math.log(PRIORS.ai_influenced);
993
+ for (const signal of signals) {
994
+ const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
995
+ const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
996
+ humanLogProb += Math.log(humanWeight);
997
+ aiLogProb += Math.log(aiWeight);
998
+ }
999
+ const maxLog = Math.max(humanLogProb, aiLogProb);
1000
+ const humanExp = Math.exp(humanLogProb - maxLog);
1001
+ const aiExp = Math.exp(aiLogProb - maxLog);
1002
+ const total = humanExp + aiExp;
1003
+ const humanProbability = humanExp / total;
1004
+ const aiProbability = aiExp / total;
1005
+ let classification;
1006
+ let confidence;
1007
+ if (humanProbability > 0.6) {
1008
+ classification = "human";
1009
+ confidence = humanProbability;
1010
+ } else if (aiProbability > 0.6) {
1011
+ classification = "ai_influenced";
1012
+ confidence = aiProbability;
1013
+ } else {
1014
+ classification = "uncertain";
1015
+ confidence = Math.max(humanProbability, aiProbability);
1016
+ }
1017
+ this.result = {
1018
+ classification,
1019
+ humanProbability,
1020
+ aiProbability,
1021
+ confidence,
1022
+ signals,
1023
+ timestamp: Date.now(),
1024
+ sessionDurationMs: sessionDuration
1025
+ };
1026
+ this.classified = true;
1027
+ if (this.onClassify) {
1028
+ this.onClassify(this.result);
971
1029
  }
1030
+ return this.result;
972
1031
  }
973
- async sendBatch(events) {
974
- if (events.length === 0) return;
975
- try {
976
- const results = await Promise.allSettled(
977
- events.map(async (event) => {
978
- const response = await fetch(this.endpoint, {
979
- method: "POST",
980
- headers: {
981
- "Content-Type": "application/json",
982
- ...event.headers || {}
983
- },
984
- body: JSON.stringify({
985
- ...event.payload,
986
- _queue_id: event.id,
987
- _queue_timestamp: event.timestamp
988
- })
989
- });
990
- if (!response.ok) {
991
- throw new Error(`HTTP ${response.status}`);
992
- }
993
- })
994
- );
995
- const failedEvents = events.filter((_, index) => results[index]?.status === "rejected");
996
- if (failedEvents.length > 0) {
997
- for (const event of failedEvents) {
998
- if (event.retries < this.config.maxRetries) {
999
- event.retries++;
1000
- this.queue.push(event);
1001
- }
1002
- }
1003
- const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1);
1004
- setTimeout(() => this.flush(), delay);
1032
+ /**
1033
+ * Extract behavioral signals from collected data
1034
+ */
1035
+ extractSignals() {
1036
+ const signals = [];
1037
+ if (this.data.firstClickTime !== null) {
1038
+ const timeToClick = this.data.firstClickTime - this.data.startTime;
1039
+ if (timeToClick < 500) {
1040
+ signals.push("time_to_first_click_immediate");
1041
+ } else if (timeToClick < 2e3) {
1042
+ signals.push("time_to_first_click_fast");
1043
+ } else if (timeToClick < 1e4) {
1044
+ signals.push("time_to_first_click_normal");
1045
+ } else {
1046
+ signals.push("time_to_first_click_delayed");
1005
1047
  }
1006
- return;
1007
- } catch (error) {
1008
- for (const event of events) {
1009
- if (event.retries < this.config.maxRetries) {
1010
- event.retries++;
1011
- this.queue.push(event);
1012
- }
1048
+ }
1049
+ if (this.data.scrollEvents.length === 0) {
1050
+ signals.push("scroll_speed_none");
1051
+ } else if (this.data.scrollEvents.length >= 3) {
1052
+ const scrollDeltas = [];
1053
+ for (let i = 1; i < this.data.scrollEvents.length; i++) {
1054
+ const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
1055
+ scrollDeltas.push(delta);
1013
1056
  }
1014
- if (this.queue.length > 0) {
1015
- const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
1016
- setTimeout(() => this.flush(), delay);
1057
+ const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
1058
+ const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
1059
+ const stdDev = Math.sqrt(variance);
1060
+ const cv = mean > 0 ? stdDev / mean : 0;
1061
+ if (cv < 0.2) {
1062
+ signals.push("scroll_speed_uniform");
1063
+ } else if (cv < 0.6) {
1064
+ signals.push("scroll_speed_variable");
1065
+ } else {
1066
+ signals.push("scroll_speed_erratic");
1067
+ }
1068
+ }
1069
+ if (this.data.mouseEvents.length === 0) {
1070
+ signals.push("mouse_movement_none");
1071
+ } else if (this.data.mouseEvents.length >= 10) {
1072
+ const n = Math.min(this.data.mouseEvents.length, 20);
1073
+ const recentMouse = this.data.mouseEvents.slice(-n);
1074
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
1075
+ for (const event of recentMouse) {
1076
+ sumX += event.x;
1077
+ sumY += event.y;
1078
+ sumXY += event.x * event.y;
1079
+ sumX2 += event.x * event.x;
1080
+ }
1081
+ const denominator = n * sumX2 - sumX * sumX;
1082
+ const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
1083
+ const intercept = (sumY - slope * sumX) / n;
1084
+ let ssRes = 0, ssTot = 0;
1085
+ const yMean = sumY / n;
1086
+ for (const event of recentMouse) {
1087
+ const yPred = slope * event.x + intercept;
1088
+ ssRes += Math.pow(event.y - yPred, 2);
1089
+ ssTot += Math.pow(event.y - yMean, 2);
1090
+ }
1091
+ const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
1092
+ if (r2 > 0.95) {
1093
+ signals.push("mouse_movement_linear");
1094
+ } else {
1095
+ signals.push("mouse_movement_curved");
1096
+ }
1097
+ }
1098
+ const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
1099
+ if (completedForms.length > 0) {
1100
+ const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
1101
+ if (avgFillTime < 100) {
1102
+ signals.push("form_fill_instant");
1103
+ } else if (avgFillTime < 500) {
1104
+ signals.push("form_fill_fast");
1105
+ } else {
1106
+ signals.push("form_fill_normal");
1017
1107
  }
1018
1108
  }
1019
- }
1020
- loadFromStorage() {
1021
- try {
1022
- const stored = localStorage.getItem(this.config.storageKey);
1023
- if (stored) {
1024
- const parsed = JSON.parse(stored);
1025
- if (Array.isArray(parsed)) {
1026
- const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
1027
- this.queue = parsed.filter((e) => e.timestamp > cutoff);
1028
- }
1109
+ if (this.data.focusBlurEvents.length >= 4) {
1110
+ const recentFB = this.data.focusBlurEvents.slice(-10);
1111
+ const intervals = [];
1112
+ for (let i = 1; i < recentFB.length; i++) {
1113
+ intervals.push(recentFB[i].time - recentFB[i - 1].time);
1029
1114
  }
1030
- } catch {
1031
- }
1032
- }
1033
- saveToStorage() {
1034
- try {
1035
- if (this.queue.length > 0) {
1036
- localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1115
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
1116
+ if (avgInterval < 1e3) {
1117
+ signals.push("focus_blur_rapid");
1037
1118
  } else {
1038
- this.clearStorage();
1119
+ signals.push("focus_blur_normal");
1039
1120
  }
1040
- } catch {
1041
1121
  }
1122
+ return signals;
1042
1123
  }
1043
- clearStorage() {
1044
- try {
1045
- localStorage.removeItem(this.config.storageKey);
1046
- } catch {
1047
- }
1124
+ /**
1125
+ * Add context signals (set by tracker from external data)
1126
+ */
1127
+ addContextSignal(_signal) {
1048
1128
  }
1049
- generateId() {
1050
- return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1129
+ /**
1130
+ * Get current result (null if not yet classified)
1131
+ */
1132
+ getResult() {
1133
+ return this.result;
1134
+ }
1135
+ /**
1136
+ * Check if classification has been performed
1137
+ */
1138
+ hasClassified() {
1139
+ return this.classified;
1051
1140
  }
1052
1141
  };
1053
1142
 
1054
- // src/infrastructure/ping.ts
1055
- var PingService = class {
1056
- constructor(sessionId2, visitorId2, version, config2 = {}) {
1057
- this.intervalId = null;
1058
- this.isVisible = true;
1059
- this.isFocused = true;
1060
- this.currentScrollDepth = 0;
1061
- this.ping = async () => {
1062
- const data = this.getData();
1063
- this.config.onPing?.(data);
1064
- if (this.config.endpoint) {
1065
- try {
1066
- await fetch(this.config.endpoint, {
1067
- method: "POST",
1068
- headers: { "Content-Type": "application/json" },
1069
- body: JSON.stringify(data)
1070
- });
1071
- } catch {
1072
- }
1073
- }
1074
- };
1075
- this.handleVisibilityChange = () => {
1076
- this.isVisible = document.visibilityState === "visible";
1077
- };
1078
- this.handleFocusChange = () => {
1079
- this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1080
- if (this.intervalId && this.isVisible && this.isFocused) {
1081
- this.ping();
1082
- }
1083
- };
1084
- this.handleScroll = () => {
1085
- const scrollPercent = Math.round(
1086
- (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1087
- );
1088
- if (scrollPercent > this.currentScrollDepth) {
1089
- this.currentScrollDepth = Math.min(scrollPercent, 100);
1090
- }
1091
- };
1092
- this.sessionId = sessionId2;
1093
- this.visitorId = visitorId2;
1094
- this.version = version;
1095
- this.pageLoadTime = Date.now();
1096
- this.isVisible = document.visibilityState === "visible";
1097
- this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1098
- this.config = {
1099
- interval: DEFAULT_CONFIG.pingInterval,
1100
- endpoint: "",
1101
- ...config2
1102
- };
1103
- document.addEventListener("visibilitychange", this.handleVisibilityChange);
1104
- window.addEventListener("focus", this.handleFocusChange);
1105
- window.addEventListener("blur", this.handleFocusChange);
1106
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1143
+ // src/detection/focus-blur.ts
1144
+ var FocusBlurAnalyzer = class {
1145
+ constructor() {
1146
+ this.sequence = [];
1147
+ this.firstInteractionTime = null;
1148
+ this.analyzed = false;
1149
+ this.result = null;
1150
+ this.pageLoadTime = performance.now();
1107
1151
  }
1108
1152
  /**
1109
- * Start the ping service
1153
+ * Initialize event tracking
1154
+ * Must be called after DOM is ready
1110
1155
  */
1111
- start() {
1112
- if (this.intervalId) return;
1113
- this.intervalId = setInterval(() => {
1114
- if (this.isVisible && this.isFocused) {
1115
- this.ping();
1156
+ initTracking() {
1157
+ document.addEventListener("focus", (e) => {
1158
+ this.recordEvent("focus", e.target);
1159
+ }, true);
1160
+ document.addEventListener("blur", (e) => {
1161
+ this.recordEvent("blur", e.target);
1162
+ }, true);
1163
+ window.addEventListener("focus", () => {
1164
+ this.recordEvent("window_focus", null);
1165
+ });
1166
+ window.addEventListener("blur", () => {
1167
+ this.recordEvent("window_blur", null);
1168
+ });
1169
+ const recordFirstInteraction = () => {
1170
+ if (this.firstInteractionTime === null) {
1171
+ this.firstInteractionTime = performance.now();
1116
1172
  }
1117
- }, this.config.interval);
1118
- if (this.isVisible && this.isFocused) {
1119
- this.ping();
1120
- }
1173
+ };
1174
+ document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
1175
+ document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
1121
1176
  }
1122
1177
  /**
1123
- * Stop the ping service
1178
+ * Record a focus/blur event
1124
1179
  */
1125
- stop() {
1126
- if (this.intervalId) {
1127
- clearInterval(this.intervalId);
1128
- this.intervalId = null;
1180
+ recordEvent(type, target) {
1181
+ const event = {
1182
+ type,
1183
+ target: target?.tagName || "WINDOW",
1184
+ timestamp: performance.now()
1185
+ };
1186
+ this.sequence.push(event);
1187
+ if (this.sequence.length > 20) {
1188
+ this.sequence = this.sequence.slice(-20);
1129
1189
  }
1130
- document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1131
- window.removeEventListener("focus", this.handleFocusChange);
1132
- window.removeEventListener("blur", this.handleFocusChange);
1133
- window.removeEventListener("scroll", this.handleScroll);
1134
1190
  }
1135
1191
  /**
1136
- * Update scroll depth (called by external scroll tracker)
1192
+ * Analyze the focus/blur sequence for paste patterns
1137
1193
  */
1138
- updateScrollDepth(depth) {
1139
- if (depth > this.currentScrollDepth) {
1140
- this.currentScrollDepth = depth;
1194
+ analyze() {
1195
+ if (this.analyzed && this.result) {
1196
+ return this.result;
1141
1197
  }
1142
- }
1143
- /**
1144
- * Get current ping data
1145
- */
1146
- getData() {
1147
- return {
1148
- session_id: this.sessionId,
1149
- visitor_id: this.visitorId,
1150
- url: window.location.href,
1151
- time_on_page_ms: Date.now() - this.pageLoadTime,
1152
- scroll_depth: this.currentScrollDepth,
1153
- is_active: this.isVisible && this.isFocused,
1154
- tracker_version: this.version
1155
- };
1156
- }
1157
- };
1158
-
1159
- // src/behavioral/scroll-tracker.ts
1160
- var DEFAULT_CHUNKS = [30, 60, 90, 100];
1161
- var ScrollTracker = class {
1162
- constructor(config2 = {}) {
1163
- this.maxDepth = 0;
1164
- this.reportedChunks = /* @__PURE__ */ new Set();
1165
- this.ticking = false;
1166
- this.isVisible = true;
1167
- this.handleScroll = () => {
1168
- if (!this.ticking && this.isVisible) {
1169
- requestAnimationFrame(() => {
1170
- this.checkScrollDepth();
1171
- this.ticking = false;
1172
- });
1173
- this.ticking = true;
1198
+ const signals = [];
1199
+ let confidence = 0;
1200
+ const earlyEvents = this.sequence.filter((e) => e.timestamp < this.pageLoadTime + 500);
1201
+ const hasEarlyWindowFocus = earlyEvents.some((e) => e.type === "window_focus");
1202
+ if (hasEarlyWindowFocus) {
1203
+ signals.push("early_window_focus");
1204
+ confidence += 0.15;
1205
+ }
1206
+ const hasEarlyBodyFocus = earlyEvents.some(
1207
+ (e) => e.type === "focus" && e.target === "BODY"
1208
+ );
1209
+ if (hasEarlyBodyFocus) {
1210
+ signals.push("early_body_focus");
1211
+ confidence += 0.15;
1212
+ }
1213
+ const hasLinkFocus = this.sequence.some(
1214
+ (e) => e.type === "focus" && e.target === "A"
1215
+ );
1216
+ if (!hasLinkFocus) {
1217
+ signals.push("no_link_focus");
1218
+ confidence += 0.1;
1219
+ }
1220
+ const firstFocus = this.sequence.find((e) => e.type === "focus");
1221
+ if (firstFocus && (firstFocus.target === "BODY" || firstFocus.target === "HTML")) {
1222
+ signals.push("first_focus_body");
1223
+ confidence += 0.1;
1224
+ }
1225
+ const windowEvents = this.sequence.filter(
1226
+ (e) => e.type === "window_focus" || e.type === "window_blur"
1227
+ );
1228
+ if (windowEvents.length <= 2) {
1229
+ signals.push("minimal_window_switches");
1230
+ confidence += 0.05;
1231
+ }
1232
+ if (this.firstInteractionTime !== null) {
1233
+ const timeToInteraction = this.firstInteractionTime - this.pageLoadTime;
1234
+ if (timeToInteraction > 3e3) {
1235
+ signals.push("delayed_first_interaction");
1236
+ confidence += 0.1;
1174
1237
  }
1238
+ }
1239
+ confidence = Math.min(confidence, 0.65);
1240
+ let navType;
1241
+ if (confidence >= 0.35) {
1242
+ navType = "likely_paste";
1243
+ } else if (signals.length === 0) {
1244
+ navType = "unknown";
1245
+ } else {
1246
+ navType = "likely_click";
1247
+ }
1248
+ this.result = {
1249
+ nav_type: navType,
1250
+ confidence,
1251
+ signals,
1252
+ sequence: this.sequence.slice(-10),
1253
+ time_to_first_interaction_ms: this.firstInteractionTime ? Math.round(this.firstInteractionTime - this.pageLoadTime) : null
1175
1254
  };
1176
- this.handleVisibility = () => {
1177
- this.isVisible = document.visibilityState === "visible";
1178
- };
1179
- this.config = {
1180
- chunks: DEFAULT_CHUNKS,
1181
- ...config2
1182
- };
1183
- this.startTime = Date.now();
1255
+ this.analyzed = true;
1256
+ return this.result;
1184
1257
  }
1185
1258
  /**
1186
- * Start tracking scroll depth
1259
+ * Get current result (analyze if not done)
1187
1260
  */
1188
- start() {
1189
- window.addEventListener("scroll", this.handleScroll, { passive: true });
1190
- document.addEventListener("visibilitychange", this.handleVisibility);
1191
- this.checkScrollDepth();
1261
+ getResult() {
1262
+ return this.analyze();
1192
1263
  }
1193
1264
  /**
1194
- * Stop tracking
1265
+ * Check if analysis has been performed
1195
1266
  */
1196
- stop() {
1197
- window.removeEventListener("scroll", this.handleScroll);
1198
- document.removeEventListener("visibilitychange", this.handleVisibility);
1267
+ hasAnalyzed() {
1268
+ return this.analyzed;
1199
1269
  }
1200
1270
  /**
1201
- * Get current max scroll depth
1271
+ * Get the raw sequence for debugging
1202
1272
  */
1203
- getMaxDepth() {
1204
- return this.maxDepth;
1273
+ getSequence() {
1274
+ return [...this.sequence];
1205
1275
  }
1206
1276
  /**
1207
- * Get reported chunks
1277
+ * Reset the analyzer
1208
1278
  */
1209
- getReportedChunks() {
1210
- return Array.from(this.reportedChunks).sort((a, b) => a - b);
1279
+ reset() {
1280
+ this.sequence = [];
1281
+ this.pageLoadTime = performance.now();
1282
+ this.firstInteractionTime = null;
1283
+ this.analyzed = false;
1284
+ this.result = null;
1211
1285
  }
1212
- /**
1213
- * Get final scroll event (for unload)
1214
- */
1215
- getFinalEvent() {
1216
- const docHeight = document.documentElement.scrollHeight;
1217
- const viewportHeight = window.innerHeight;
1286
+ };
1287
+
1288
+ // src/detection/navigation-timing.ts
1289
+ function detectNavigationType() {
1290
+ try {
1291
+ const entries = performance.getEntriesByType("navigation");
1292
+ if (!entries || entries.length === 0) {
1293
+ return { nav_type: "unknown", confidence: 0, signals: ["no_timing_data"] };
1294
+ }
1295
+ const nav = entries[0];
1296
+ const signals = [];
1297
+ let pasteScore = 0;
1298
+ const fetchStartDelta = nav.fetchStart - nav.startTime;
1299
+ if (fetchStartDelta < 5) {
1300
+ pasteScore += 0.25;
1301
+ signals.push("instant_fetch_start");
1302
+ } else if (fetchStartDelta < 20) {
1303
+ pasteScore += 0.15;
1304
+ signals.push("fast_fetch_start");
1305
+ }
1306
+ const dnsTime = nav.domainLookupEnd - nav.domainLookupStart;
1307
+ if (dnsTime === 0) {
1308
+ pasteScore += 0.15;
1309
+ signals.push("no_dns_lookup");
1310
+ }
1311
+ const connectTime = nav.connectEnd - nav.connectStart;
1312
+ if (connectTime === 0) {
1313
+ pasteScore += 0.15;
1314
+ signals.push("no_tcp_connect");
1315
+ }
1316
+ if (nav.redirectCount === 0) {
1317
+ pasteScore += 0.1;
1318
+ signals.push("no_redirects");
1319
+ }
1320
+ const timingVariance = calculateTimingVariance(nav);
1321
+ if (timingVariance < 10) {
1322
+ pasteScore += 0.15;
1323
+ signals.push("uniform_timing");
1324
+ }
1325
+ if (!document.referrer || document.referrer === "") {
1326
+ pasteScore += 0.1;
1327
+ signals.push("no_referrer");
1328
+ }
1329
+ const confidence = Math.min(pasteScore, 1);
1330
+ const nav_type = pasteScore >= 0.5 ? "likely_paste" : "likely_click";
1218
1331
  return {
1219
- depth: this.maxDepth,
1220
- chunk: this.getChunkForDepth(this.maxDepth),
1221
- time_to_reach_ms: Date.now() - this.startTime,
1222
- total_height: docHeight,
1223
- viewport_height: viewportHeight
1332
+ nav_type,
1333
+ confidence: Math.round(confidence * 1e3) / 1e3,
1334
+ signals
1224
1335
  };
1336
+ } catch {
1337
+ return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
1225
1338
  }
1226
- checkScrollDepth() {
1227
- const scrollY = window.scrollY;
1228
- const viewportHeight = window.innerHeight;
1229
- const docHeight = document.documentElement.scrollHeight;
1230
- if (docHeight <= viewportHeight) {
1231
- this.updateDepth(100);
1232
- return;
1233
- }
1234
- const scrollableHeight = docHeight - viewportHeight;
1235
- const currentDepth = Math.min(100, Math.round(scrollY / scrollableHeight * 100));
1236
- this.updateDepth(currentDepth);
1339
+ }
1340
+ function calculateTimingVariance(nav) {
1341
+ const timings = [
1342
+ nav.fetchStart - nav.startTime,
1343
+ nav.domainLookupEnd - nav.domainLookupStart,
1344
+ nav.connectEnd - nav.connectStart,
1345
+ nav.responseStart - nav.requestStart
1346
+ ].filter((t) => t >= 0);
1347
+ if (timings.length === 0) return 100;
1348
+ const mean = timings.reduce((a, b) => a + b, 0) / timings.length;
1349
+ const variance = timings.reduce((sum, t) => sum + Math.pow(t - mean, 2), 0) / timings.length;
1350
+ return Math.sqrt(variance);
1351
+ }
1352
+
1353
+ // src/detection/referrer.ts
1354
+ function detectAIFromReferrer(referrer) {
1355
+ if (!referrer) {
1356
+ return null;
1237
1357
  }
1238
- updateDepth(depth) {
1239
- if (depth <= this.maxDepth) return;
1240
- this.maxDepth = depth;
1241
- this.config.onDepthChange?.(depth);
1242
- for (const chunk of this.config.chunks) {
1243
- if (depth >= chunk && !this.reportedChunks.has(chunk)) {
1244
- this.reportedChunks.add(chunk);
1245
- this.reportChunk(chunk);
1358
+ try {
1359
+ const url = new URL(referrer);
1360
+ const hostname = url.hostname.toLowerCase();
1361
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1362
+ if (hostname.includes(pattern) || referrer.includes(pattern)) {
1363
+ return {
1364
+ isAI: true,
1365
+ platform,
1366
+ confidence: 0.95,
1367
+ // High confidence when referrer matches
1368
+ method: "referrer"
1369
+ };
1246
1370
  }
1247
1371
  }
1372
+ return null;
1373
+ } catch {
1374
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1375
+ if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
1376
+ return {
1377
+ isAI: true,
1378
+ platform,
1379
+ confidence: 0.85,
1380
+ method: "referrer"
1381
+ };
1382
+ }
1383
+ }
1384
+ return null;
1248
1385
  }
1249
- reportChunk(chunk) {
1250
- const docHeight = document.documentElement.scrollHeight;
1251
- const viewportHeight = window.innerHeight;
1252
- const event = {
1253
- depth: this.maxDepth,
1254
- chunk,
1255
- time_to_reach_ms: Date.now() - this.startTime,
1256
- total_height: docHeight,
1257
- viewport_height: viewportHeight
1258
- };
1259
- this.config.onChunkReached?.(event);
1260
- }
1261
- getChunkForDepth(depth) {
1262
- const chunks = this.config.chunks.sort((a, b) => b - a);
1263
- for (const chunk of chunks) {
1264
- if (depth >= chunk) return chunk;
1386
+ }
1387
+ function detectAIFromUTM(url) {
1388
+ try {
1389
+ const params = new URL(url).searchParams;
1390
+ const utmSource = params.get("utm_source")?.toLowerCase();
1391
+ if (utmSource) {
1392
+ for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
1393
+ if (utmSource.includes(pattern.split(".")[0])) {
1394
+ return {
1395
+ isAI: true,
1396
+ platform,
1397
+ confidence: 0.99,
1398
+ // Very high confidence from explicit UTM
1399
+ method: "referrer"
1400
+ };
1401
+ }
1402
+ }
1403
+ if (utmSource.includes("ai") || utmSource.includes("llm") || utmSource.includes("chatbot")) {
1404
+ return {
1405
+ isAI: true,
1406
+ platform: utmSource,
1407
+ confidence: 0.9,
1408
+ method: "referrer"
1409
+ };
1410
+ }
1265
1411
  }
1266
- return 0;
1412
+ return null;
1413
+ } catch {
1414
+ return null;
1267
1415
  }
1268
- };
1416
+ }
1269
1417
 
1270
- // src/behavioral/time-tracker.ts
1271
- var DEFAULT_CONFIG2 = {
1272
- idleThresholdMs: 3e4,
1273
- // 30 seconds
1274
- updateIntervalMs: 5e3
1275
- // 5 seconds
1418
+ // src/infrastructure/event-queue.ts
1419
+ var DEFAULT_QUEUE_CONFIG = {
1420
+ batchSize: DEFAULT_CONFIG3.batchSize,
1421
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1422
+ maxRetries: 3,
1423
+ retryDelayMs: 1e3,
1424
+ storageKey: "_loamly_queue"
1276
1425
  };
1277
- var TimeTracker = class {
1278
- constructor(config2 = {}) {
1279
- this.activeTime = 0;
1280
- this.idleTime = 0;
1281
- this.isVisible = true;
1282
- this.isIdle = false;
1283
- this.updateInterval = null;
1284
- this.idleCheckInterval = null;
1285
- this.handleVisibility = () => {
1286
- const wasVisible = this.isVisible;
1287
- this.isVisible = document.visibilityState === "visible";
1288
- if (wasVisible && !this.isVisible) {
1289
- this.updateTimes();
1290
- } else if (!wasVisible && this.isVisible) {
1291
- this.lastUpdateTime = Date.now();
1292
- this.lastActivityTime = Date.now();
1293
- }
1294
- };
1295
- this.handleActivity = () => {
1296
- const now = Date.now();
1297
- if (this.isIdle) {
1298
- this.isIdle = false;
1299
- }
1300
- this.lastActivityTime = now;
1301
- };
1302
- this.config = { ...DEFAULT_CONFIG2, ...config2 };
1303
- this.startTime = Date.now();
1304
- this.lastActivityTime = this.startTime;
1305
- this.lastUpdateTime = this.startTime;
1426
+ var EventQueue = class {
1427
+ constructor(endpoint2, config2 = {}) {
1428
+ this.queue = [];
1429
+ this.batchTimer = null;
1430
+ this.isFlushing = false;
1431
+ this.endpoint = endpoint2;
1432
+ this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
1433
+ this.loadFromStorage();
1306
1434
  }
1307
1435
  /**
1308
- * Start tracking time
1436
+ * Add event to queue
1309
1437
  */
1310
- start() {
1311
- document.addEventListener("visibilitychange", this.handleVisibility);
1312
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1313
- activityEvents.forEach((event) => {
1314
- document.addEventListener(event, this.handleActivity, { passive: true });
1315
- });
1316
- this.updateInterval = setInterval(() => {
1317
- this.update();
1318
- }, this.config.updateIntervalMs);
1319
- this.idleCheckInterval = setInterval(() => {
1320
- this.checkIdle();
1321
- }, 1e3);
1438
+ push(type, payload, headers) {
1439
+ const event = {
1440
+ id: this.generateId(),
1441
+ type,
1442
+ payload,
1443
+ headers,
1444
+ timestamp: Date.now(),
1445
+ retries: 0
1446
+ };
1447
+ this.queue.push(event);
1448
+ this.saveToStorage();
1449
+ this.scheduleBatch();
1322
1450
  }
1323
1451
  /**
1324
- * Stop tracking
1452
+ * Force flush all events immediately
1325
1453
  */
1326
- stop() {
1327
- document.removeEventListener("visibilitychange", this.handleVisibility);
1328
- const activityEvents = ["mousemove", "keydown", "scroll", "click", "touchstart"];
1329
- activityEvents.forEach((event) => {
1330
- document.removeEventListener(event, this.handleActivity);
1331
- });
1332
- if (this.updateInterval) {
1333
- clearInterval(this.updateInterval);
1334
- this.updateInterval = null;
1335
- }
1336
- if (this.idleCheckInterval) {
1337
- clearInterval(this.idleCheckInterval);
1338
- this.idleCheckInterval = null;
1454
+ async flush() {
1455
+ if (this.isFlushing || this.queue.length === 0) return;
1456
+ this.isFlushing = true;
1457
+ this.clearBatchTimer();
1458
+ try {
1459
+ const events = [...this.queue];
1460
+ this.queue = [];
1461
+ await this.sendBatch(events);
1462
+ } finally {
1463
+ this.isFlushing = false;
1464
+ this.saveToStorage();
1339
1465
  }
1340
1466
  }
1341
1467
  /**
1342
- * Get current time metrics
1468
+ * Flush using sendBeacon (for unload events)
1343
1469
  */
1344
- getMetrics() {
1345
- this.updateTimes();
1346
- return {
1347
- active_time_ms: this.activeTime,
1348
- total_time_ms: Date.now() - this.startTime,
1349
- idle_time_ms: this.idleTime,
1350
- is_engaged: !this.isIdle && this.isVisible
1351
- };
1470
+ flushBeacon() {
1471
+ if (this.queue.length === 0) return true;
1472
+ const baseUrl = this.config.apiKey ? `${this.endpoint}?api_key=${encodeURIComponent(this.config.apiKey)}` : this.endpoint;
1473
+ let allSent = true;
1474
+ for (const event of this.queue) {
1475
+ const payload = {
1476
+ ...event.payload,
1477
+ _queue_id: event.id,
1478
+ _queue_timestamp: event.timestamp
1479
+ };
1480
+ const success = navigator.sendBeacon?.(baseUrl, JSON.stringify(payload)) ?? false;
1481
+ if (!success) {
1482
+ allSent = false;
1483
+ break;
1484
+ }
1485
+ }
1486
+ if (allSent) {
1487
+ this.queue = [];
1488
+ this.clearStorage();
1489
+ }
1490
+ return allSent;
1352
1491
  }
1353
1492
  /**
1354
- * Get final metrics (for unload)
1493
+ * Get current queue length
1355
1494
  */
1356
- getFinalMetrics() {
1357
- this.updateTimes();
1358
- return this.getMetrics();
1495
+ get length() {
1496
+ return this.queue.length;
1359
1497
  }
1360
- checkIdle() {
1361
- const now = Date.now();
1362
- const timeSinceActivity = now - this.lastActivityTime;
1363
- if (!this.isIdle && timeSinceActivity >= this.config.idleThresholdMs) {
1364
- this.isIdle = true;
1498
+ scheduleBatch() {
1499
+ if (this.batchTimer) return;
1500
+ if (this.queue.length >= this.config.batchSize) {
1501
+ this.flush();
1502
+ return;
1365
1503
  }
1504
+ this.batchTimer = setTimeout(() => {
1505
+ this.batchTimer = null;
1506
+ this.flush();
1507
+ }, this.config.batchTimeout);
1366
1508
  }
1367
- updateTimes() {
1368
- const now = Date.now();
1369
- const elapsed = now - this.lastUpdateTime;
1370
- if (this.isVisible) {
1371
- if (this.isIdle) {
1372
- this.idleTime += elapsed;
1373
- } else {
1374
- this.activeTime += elapsed;
1509
+ clearBatchTimer() {
1510
+ if (this.batchTimer) {
1511
+ clearTimeout(this.batchTimer);
1512
+ this.batchTimer = null;
1513
+ }
1514
+ }
1515
+ async sendBatch(events) {
1516
+ if (events.length === 0) return;
1517
+ try {
1518
+ const results = await Promise.allSettled(
1519
+ events.map(async (event) => {
1520
+ const response = await fetch(this.endpoint, {
1521
+ method: "POST",
1522
+ headers: {
1523
+ "Content-Type": "application/json",
1524
+ ...event.headers || {}
1525
+ },
1526
+ body: JSON.stringify({
1527
+ ...event.payload,
1528
+ _queue_id: event.id,
1529
+ _queue_timestamp: event.timestamp
1530
+ })
1531
+ });
1532
+ if (!response.ok) {
1533
+ throw new Error(`HTTP ${response.status}`);
1534
+ }
1535
+ })
1536
+ );
1537
+ const failedEvents = events.filter((_, index) => results[index]?.status === "rejected");
1538
+ if (failedEvents.length > 0) {
1539
+ for (const event of failedEvents) {
1540
+ if (event.retries < this.config.maxRetries) {
1541
+ event.retries++;
1542
+ this.queue.push(event);
1543
+ }
1544
+ }
1545
+ const delay = this.config.retryDelayMs * Math.pow(2, failedEvents[0].retries - 1);
1546
+ setTimeout(() => this.flush(), delay);
1547
+ }
1548
+ return;
1549
+ } catch (error) {
1550
+ for (const event of events) {
1551
+ if (event.retries < this.config.maxRetries) {
1552
+ event.retries++;
1553
+ this.queue.push(event);
1554
+ }
1555
+ }
1556
+ if (this.queue.length > 0) {
1557
+ const delay = this.config.retryDelayMs * Math.pow(2, events[0].retries - 1);
1558
+ setTimeout(() => this.flush(), delay);
1375
1559
  }
1376
1560
  }
1377
- this.lastUpdateTime = now;
1378
1561
  }
1379
- update() {
1380
- if (!this.isVisible) return;
1381
- this.updateTimes();
1382
- this.config.onUpdate?.(this.getMetrics());
1562
+ loadFromStorage() {
1563
+ try {
1564
+ const stored = localStorage.getItem(this.config.storageKey);
1565
+ if (stored) {
1566
+ const parsed = JSON.parse(stored);
1567
+ if (Array.isArray(parsed)) {
1568
+ const cutoff = Date.now() - 24 * 60 * 60 * 1e3;
1569
+ this.queue = parsed.filter((e) => e.timestamp > cutoff);
1570
+ }
1571
+ }
1572
+ } catch {
1573
+ }
1383
1574
  }
1384
- };
1385
-
1386
- // src/behavioral/form-tracker.ts
1387
- var DEFAULT_CONFIG3 = {
1388
- sensitiveFields: [
1389
- "password",
1390
- "pwd",
1391
- "pass",
1392
- "credit",
1393
- "card",
1394
- "cvv",
1395
- "cvc",
1396
- "ssn",
1397
- "social",
1398
- "secret",
1399
- "token",
1400
- "key"
1401
- ],
1402
- trackableFields: [
1403
- "email",
1404
- "name",
1405
- "phone",
1406
- "company",
1407
- "first",
1408
- "last",
1409
- "city",
1410
- "country"
1411
- ],
1412
- thankYouPatterns: [
1413
- /thank[-_]?you/i,
1414
- /success/i,
1415
- /confirmation/i,
1416
- /submitted/i,
1417
- /complete/i
1418
- ]
1419
- };
1420
- var FormTracker = class {
1421
- constructor(config2 = {}) {
1422
- this.formStartTimes = /* @__PURE__ */ new Map();
1423
- this.interactedForms = /* @__PURE__ */ new Set();
1424
- this.mutationObserver = null;
1425
- this.handleFocusIn = (e) => {
1426
- const target = e.target;
1427
- if (!this.isFormField(target)) return;
1428
- const form = target.closest("form");
1429
- const formId = this.getFormId(form || target);
1430
- if (!this.formStartTimes.has(formId)) {
1431
- this.formStartTimes.set(formId, Date.now());
1432
- this.interactedForms.add(formId);
1433
- this.emitEvent({
1434
- event_type: "form_start",
1435
- form_id: formId,
1436
- form_type: this.detectFormType(form || target)
1437
- });
1575
+ saveToStorage() {
1576
+ try {
1577
+ if (this.queue.length > 0) {
1578
+ localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
1579
+ } else {
1580
+ this.clearStorage();
1438
1581
  }
1439
- const fieldName = this.getFieldName(target);
1440
- if (fieldName && !this.isSensitiveField(fieldName)) {
1441
- this.emitEvent({
1442
- event_type: "form_field",
1443
- form_id: formId,
1444
- form_type: this.detectFormType(form || target),
1445
- field_name: this.sanitizeFieldName(fieldName),
1446
- field_type: target.type || target.tagName.toLowerCase()
1447
- });
1582
+ } catch {
1583
+ }
1584
+ }
1585
+ clearStorage() {
1586
+ try {
1587
+ localStorage.removeItem(this.config.storageKey);
1588
+ } catch {
1589
+ }
1590
+ }
1591
+ generateId() {
1592
+ return `${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
1593
+ }
1594
+ };
1595
+
1596
+ // src/infrastructure/ping.ts
1597
+ var PingService = class {
1598
+ constructor(sessionId2, visitorId2, version, config2 = {}) {
1599
+ this.intervalId = null;
1600
+ this.isVisible = true;
1601
+ this.isFocused = true;
1602
+ this.currentScrollDepth = 0;
1603
+ this.ping = async () => {
1604
+ const data = this.getData();
1605
+ this.config.onPing?.(data);
1606
+ if (this.config.endpoint) {
1607
+ try {
1608
+ await fetch(this.config.endpoint, {
1609
+ method: "POST",
1610
+ headers: { "Content-Type": "application/json" },
1611
+ body: JSON.stringify(data)
1612
+ });
1613
+ } catch {
1614
+ }
1448
1615
  }
1449
1616
  };
1450
- this.handleSubmit = (e) => {
1451
- const form = e.target;
1452
- if (!form || form.tagName !== "FORM") return;
1453
- const formId = this.getFormId(form);
1454
- const startTime = this.formStartTimes.get(formId);
1455
- this.emitEvent({
1456
- event_type: "form_submit",
1457
- form_id: formId,
1458
- form_type: this.detectFormType(form),
1459
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1460
- is_conversion: true,
1461
- submit_source: "submit"
1462
- });
1617
+ this.handleVisibilityChange = () => {
1618
+ this.isVisible = document.visibilityState === "visible";
1463
1619
  };
1464
- this.handleClick = (e) => {
1465
- const target = e.target;
1466
- if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
1467
- const form = target.closest("form");
1468
- if (form && form.classList.contains("hs-form")) {
1469
- const formId = this.getFormId(form);
1470
- const startTime = this.formStartTimes.get(formId);
1471
- this.emitEvent({
1472
- event_type: "form_submit",
1473
- form_id: formId,
1474
- form_type: "hubspot",
1475
- time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
1476
- is_conversion: true,
1477
- submit_source: "click"
1478
- });
1479
- }
1620
+ this.handleFocusChange = () => {
1621
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1622
+ if (this.intervalId && this.isVisible && this.isFocused) {
1623
+ this.ping();
1480
1624
  }
1481
- if (target.closest('[data-qa="submit-button"]')) {
1482
- this.emitEvent({
1483
- event_type: "form_submit",
1484
- form_id: "typeform_embed",
1485
- form_type: "typeform",
1486
- is_conversion: true,
1487
- submit_source: "click"
1488
- });
1625
+ };
1626
+ this.handleScroll = () => {
1627
+ const scrollPercent = Math.round(
1628
+ (window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
1629
+ );
1630
+ if (scrollPercent > this.currentScrollDepth) {
1631
+ this.currentScrollDepth = Math.min(scrollPercent, 100);
1489
1632
  }
1490
1633
  };
1634
+ this.sessionId = sessionId2;
1635
+ this.visitorId = visitorId2;
1636
+ this.version = version;
1637
+ this.pageLoadTime = Date.now();
1638
+ this.isVisible = document.visibilityState === "visible";
1639
+ this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
1491
1640
  this.config = {
1492
- ...DEFAULT_CONFIG3,
1493
- ...config2,
1494
- sensitiveFields: [
1495
- ...DEFAULT_CONFIG3.sensitiveFields,
1496
- ...config2.sensitiveFields || []
1497
- ]
1641
+ interval: DEFAULT_CONFIG3.pingInterval,
1642
+ endpoint: "",
1643
+ ...config2
1498
1644
  };
1645
+ document.addEventListener("visibilitychange", this.handleVisibilityChange);
1646
+ window.addEventListener("focus", this.handleFocusChange);
1647
+ window.addEventListener("blur", this.handleFocusChange);
1648
+ window.addEventListener("scroll", this.handleScroll, { passive: true });
1499
1649
  }
1500
1650
  /**
1501
- * Start tracking forms
1651
+ * Start the ping service
1502
1652
  */
1503
1653
  start() {
1504
- document.addEventListener("focusin", this.handleFocusIn, { passive: true });
1505
- document.addEventListener("submit", this.handleSubmit);
1506
- document.addEventListener("click", this.handleClick, { passive: true });
1507
- this.startMutationObserver();
1508
- this.checkThankYouPage();
1509
- this.scanForEmbeddedForms();
1654
+ if (this.intervalId) return;
1655
+ this.intervalId = setInterval(() => {
1656
+ if (this.isVisible && this.isFocused) {
1657
+ this.ping();
1658
+ }
1659
+ }, this.config.interval);
1660
+ if (this.isVisible && this.isFocused) {
1661
+ this.ping();
1662
+ }
1510
1663
  }
1511
1664
  /**
1512
- * Stop tracking
1665
+ * Stop the ping service
1513
1666
  */
1514
1667
  stop() {
1515
- document.removeEventListener("focusin", this.handleFocusIn);
1516
- document.removeEventListener("submit", this.handleSubmit);
1517
- document.removeEventListener("click", this.handleClick);
1518
- this.mutationObserver?.disconnect();
1668
+ if (this.intervalId) {
1669
+ clearInterval(this.intervalId);
1670
+ this.intervalId = null;
1671
+ }
1672
+ document.removeEventListener("visibilitychange", this.handleVisibilityChange);
1673
+ window.removeEventListener("focus", this.handleFocusChange);
1674
+ window.removeEventListener("blur", this.handleFocusChange);
1675
+ window.removeEventListener("scroll", this.handleScroll);
1519
1676
  }
1520
1677
  /**
1521
- * Get forms that had interaction
1678
+ * Update scroll depth (called by external scroll tracker)
1522
1679
  */
1523
- getInteractedForms() {
1524
- return Array.from(this.interactedForms);
1525
- }
1526
- startMutationObserver() {
1527
- this.mutationObserver = new MutationObserver((mutations) => {
1528
- for (const mutation of mutations) {
1529
- for (const node of mutation.addedNodes) {
1530
- if (node instanceof HTMLElement) {
1531
- if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
1532
- this.trackEmbeddedForm(node, "hubspot");
1533
- }
1534
- if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
1535
- this.trackEmbeddedForm(node, "typeform");
1536
- }
1537
- if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
1538
- this.trackEmbeddedForm(node, "jotform");
1539
- }
1540
- if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
1541
- this.trackEmbeddedForm(node, "gravity");
1542
- }
1543
- }
1544
- }
1545
- }
1546
- });
1547
- this.mutationObserver.observe(document.body, {
1548
- childList: true,
1549
- subtree: true
1550
- });
1551
- }
1552
- scanForEmbeddedForms() {
1553
- document.querySelectorAll(".hs-form").forEach((form) => {
1554
- this.trackEmbeddedForm(form, "hubspot");
1555
- });
1556
- document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
1557
- this.trackEmbeddedForm(form, "typeform");
1558
- });
1559
- document.querySelectorAll(".jotform-form").forEach((form) => {
1560
- this.trackEmbeddedForm(form, "jotform");
1561
- });
1562
- document.querySelectorAll(".gform_wrapper").forEach((form) => {
1563
- this.trackEmbeddedForm(form, "gravity");
1564
- });
1565
- }
1566
- trackEmbeddedForm(element, type) {
1567
- const formId = `${type}_${this.getFormId(element)}`;
1568
- element.addEventListener("focusin", () => {
1569
- if (!this.formStartTimes.has(formId)) {
1570
- this.formStartTimes.set(formId, Date.now());
1571
- this.interactedForms.add(formId);
1572
- this.emitEvent({
1573
- event_type: "form_start",
1574
- form_id: formId,
1575
- form_type: type
1576
- });
1577
- }
1578
- }, { passive: true });
1579
- }
1580
- checkThankYouPage() {
1581
- const url = window.location.href.toLowerCase();
1582
- const title = document.title.toLowerCase();
1583
- for (const pattern of this.config.thankYouPatterns) {
1584
- if (pattern.test(url) || pattern.test(title)) {
1585
- this.emitEvent({
1586
- event_type: "form_success",
1587
- form_id: "page_conversion",
1588
- form_type: "unknown",
1589
- is_conversion: true,
1590
- submit_source: "thank_you"
1591
- });
1592
- break;
1593
- }
1594
- }
1595
- }
1596
- isFormField(element) {
1597
- const tagName = element.tagName;
1598
- return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
1599
- }
1600
- getFormId(element) {
1601
- if (!element) return "unknown";
1602
- return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
1603
- }
1604
- getFieldName(input) {
1605
- return input.name || input.id || input.getAttribute("data-name") || "";
1606
- }
1607
- isSensitiveField(fieldName) {
1608
- const lowerName = fieldName.toLowerCase();
1609
- return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
1610
- }
1611
- sanitizeFieldName(fieldName) {
1612
- return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
1613
- }
1614
- detectFormType(element) {
1615
- if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
1616
- return "hubspot";
1617
- }
1618
- if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
1619
- return "typeform";
1620
- }
1621
- if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
1622
- return "jotform";
1623
- }
1624
- if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
1625
- return "gravity";
1626
- }
1627
- if (element.tagName === "FORM") {
1628
- return "native";
1680
+ updateScrollDepth(depth) {
1681
+ if (depth > this.currentScrollDepth) {
1682
+ this.currentScrollDepth = depth;
1629
1683
  }
1630
- return "unknown";
1631
1684
  }
1632
- emitEvent(event) {
1633
- this.config.onFormEvent?.(event);
1685
+ /**
1686
+ * Get current ping data
1687
+ */
1688
+ getData() {
1689
+ return {
1690
+ session_id: this.sessionId,
1691
+ visitor_id: this.visitorId,
1692
+ url: window.location.href,
1693
+ time_on_page_ms: Date.now() - this.pageLoadTime,
1694
+ scroll_depth: this.currentScrollDepth,
1695
+ is_active: this.isVisible && this.isFocused,
1696
+ tracker_version: this.version
1697
+ };
1634
1698
  }
1635
1699
  };
1636
1700
 
@@ -1806,7 +1870,7 @@ function sendBeacon(url, data) {
1806
1870
  }
1807
1871
 
1808
1872
  // src/core.ts
1809
- var config = { apiHost: DEFAULT_CONFIG.apiHost };
1873
+ var config = { apiHost: DEFAULT_CONFIG3.apiHost };
1810
1874
  var initialized = false;
1811
1875
  var debugMode = false;
1812
1876
  var visitorId = null;
@@ -1864,7 +1928,7 @@ function init(userConfig = {}) {
1864
1928
  config = {
1865
1929
  ...config,
1866
1930
  ...userConfig,
1867
- apiHost: userConfig.apiHost || DEFAULT_CONFIG.apiHost
1931
+ apiHost: userConfig.apiHost || DEFAULT_CONFIG3.apiHost
1868
1932
  };
1869
1933
  workspaceId = userConfig.workspaceId ?? null;
1870
1934
  debugMode = userConfig.debug ?? false;
@@ -1889,9 +1953,9 @@ function init(userConfig = {}) {
1889
1953
  visitorId = getVisitorId();
1890
1954
  log("Visitor ID:", visitorId);
1891
1955
  if (features.eventQueue) {
1892
- eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG.endpoints.behavioral), {
1893
- batchSize: DEFAULT_CONFIG.batchSize,
1894
- batchTimeout: DEFAULT_CONFIG.batchTimeout,
1956
+ eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG3.endpoints.behavioral), {
1957
+ batchSize: DEFAULT_CONFIG3.batchSize,
1958
+ batchTimeout: DEFAULT_CONFIG3.batchTimeout,
1895
1959
  apiKey: config.apiKey
1896
1960
  });
1897
1961
  }
@@ -1930,8 +1994,8 @@ function init(userConfig = {}) {
1930
1994
  }
1931
1995
  if (features.ping && visitorId && sessionId) {
1932
1996
  pingService = new PingService(sessionId, visitorId, VERSION, {
1933
- interval: DEFAULT_CONFIG.pingInterval,
1934
- endpoint: endpoint(DEFAULT_CONFIG.endpoints.ping)
1997
+ interval: DEFAULT_CONFIG3.pingInterval,
1998
+ endpoint: endpoint(DEFAULT_CONFIG3.endpoints.ping)
1935
1999
  });
1936
2000
  pingService.start();
1937
2001
  }
@@ -1973,7 +2037,7 @@ async function initializeSession() {
1973
2037
  try {
1974
2038
  const storedSession = sessionStorage.getItem("loamly_session");
1975
2039
  const storedStart = sessionStorage.getItem("loamly_start");
1976
- const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG.sessionTimeout;
2040
+ const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG3.sessionTimeout;
1977
2041
  if (storedSession && storedStart) {
1978
2042
  const startTime = parseInt(storedStart, 10);
1979
2043
  const elapsed = now - startTime;
@@ -1987,7 +2051,7 @@ async function initializeSession() {
1987
2051
  }
1988
2052
  if (config.apiKey && workspaceId && visitorId) {
1989
2053
  try {
1990
- const response = await safeFetch(endpoint(DEFAULT_CONFIG.endpoints.session), {
2054
+ const response = await safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.session), {
1991
2055
  method: "POST",
1992
2056
  headers: buildHeaders(),
1993
2057
  body: JSON.stringify({
@@ -2036,7 +2100,7 @@ function setupAdvancedBehavioralTracking(features) {
2036
2100
  updateIntervalMs: 1e4,
2037
2101
  // Report every 10 seconds
2038
2102
  onUpdate: (event) => {
2039
- if (event.active_time_ms >= DEFAULT_CONFIG.timeSpentThresholdMs) {
2103
+ if (event.active_time_ms >= DEFAULT_CONFIG3.timeSpentThresholdMs) {
2040
2104
  queueEvent("time_spent", {
2041
2105
  visible_time_ms: event.total_time_ms,
2042
2106
  page_start_time: pageStartTime || Date.now()
@@ -2062,7 +2126,10 @@ function setupAdvancedBehavioralTracking(features) {
2062
2126
  form_event_type: event.event_type,
2063
2127
  submit_source: submitSource,
2064
2128
  is_inferred: isSuccessEvent,
2065
- time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1e3) : null
2129
+ time_to_submit_seconds: event.time_to_submit_ms ? Math.round(event.time_to_submit_ms / 1e3) : null,
2130
+ // LOA-482: Include captured form field values
2131
+ fields: event.fields || null,
2132
+ email_submitted: event.email_submitted || null
2066
2133
  });
2067
2134
  }
2068
2135
  });
@@ -2149,7 +2216,7 @@ function setupUnloadHandlers() {
2149
2216
  if (!workspaceId || !config.apiKey || !sessionId) return;
2150
2217
  const scrollEvent = scrollTracker?.getFinalEvent();
2151
2218
  if (scrollEvent) {
2152
- sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
2219
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2153
2220
  workspace_id: workspaceId,
2154
2221
  visitor_id: visitorId,
2155
2222
  session_id: sessionId,
@@ -2168,7 +2235,7 @@ function setupUnloadHandlers() {
2168
2235
  }
2169
2236
  const timeEvent = timeTracker?.getFinalMetrics();
2170
2237
  if (timeEvent) {
2171
- sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG.endpoints.behavioral)), {
2238
+ sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
2172
2239
  workspace_id: workspaceId,
2173
2240
  visitor_id: visitorId,
2174
2241
  session_id: sessionId,
@@ -2250,7 +2317,7 @@ function pageview(customUrl) {
2250
2317
  payload.workspace_id = workspaceId;
2251
2318
  }
2252
2319
  log("Pageview:", payload);
2253
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.visit), {
2320
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.visit), {
2254
2321
  method: "POST",
2255
2322
  headers: buildHeaders(idempotencyKey),
2256
2323
  body: JSON.stringify(payload)
@@ -2471,7 +2538,7 @@ function reportHealth(status, errorMessage) {
2471
2538
  event_queue: !!eventQueue
2472
2539
  }
2473
2540
  };
2474
- safeFetch(endpoint(DEFAULT_CONFIG.endpoints.health), {
2541
+ safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.health), {
2475
2542
  method: "POST",
2476
2543
  headers: { "Content-Type": "application/json" },
2477
2544
  body: JSON.stringify(healthData)
@@ -2515,14 +2582,28 @@ function setDebug(enabled) {
2515
2582
  debugMode = enabled;
2516
2583
  log("Debug mode:", enabled ? "enabled" : "disabled");
2517
2584
  }
2585
+ function trackBehavioral(eventType, eventData) {
2586
+ if (!initialized) {
2587
+ log("Not initialized, trackBehavioral skipped:", eventType);
2588
+ return;
2589
+ }
2590
+ queueEvent(eventType, eventData);
2591
+ }
2592
+ function getCurrentWorkspaceId() {
2593
+ return workspaceId;
2594
+ }
2518
2595
  var loamly = {
2519
2596
  init,
2520
2597
  pageview,
2521
2598
  track,
2599
+ trackBehavioral,
2600
+ // NEW: For secondary modules/plugins
2522
2601
  conversion,
2523
2602
  identify,
2524
2603
  getSessionId: getCurrentSessionId,
2525
2604
  getVisitorId: getCurrentVisitorId,
2605
+ getWorkspaceId: getCurrentWorkspaceId,
2606
+ // NEW: For debugging/introspection
2526
2607
  getAIDetection: getAIDetectionResult,
2527
2608
  getNavigationTiming: getNavigationTimingResult,
2528
2609
  getBehavioralML: getBehavioralMLResult,