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