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