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