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