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