@loamly/tracker 2.1.0 → 2.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -3
- package/dist/index.cjs +1580 -1286
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.mts +23 -1
- package/dist/index.d.ts +23 -1
- package/dist/index.mjs +1580 -1286
- package/dist/index.mjs.map +1 -1
- package/dist/loamly.iife.global.js +1578 -1270
- package/dist/loamly.iife.global.js.map +1 -1
- package/dist/loamly.iife.min.global.js +1 -1
- package/dist/loamly.iife.min.global.js.map +1 -1
- package/package.json +1 -1
- package/src/behavioral/form-tracker.ts +107 -1
- package/src/browser.ts +25 -6
- package/src/config.ts +1 -1
- package/src/core.ts +386 -132
- package/src/index.ts +1 -1
- package/src/infrastructure/event-queue.ts +58 -32
- package/src/infrastructure/ping.ts +19 -3
- package/src/types.ts +23 -0
package/dist/index.mjs
CHANGED
|
@@ -1,642 +1,593 @@
|
|
|
1
|
-
// src/
|
|
2
|
-
var VERSION = "2.1.0";
|
|
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
|
-
};
|
|
131
|
-
}
|
|
132
|
-
}
|
|
133
|
-
return null;
|
|
134
|
-
} catch {
|
|
135
|
-
for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
|
|
136
|
-
if (referrer.toLowerCase().includes(pattern.toLowerCase())) {
|
|
137
|
-
return {
|
|
138
|
-
isAI: true,
|
|
139
|
-
platform,
|
|
140
|
-
confidence: 0.85,
|
|
141
|
-
method: "referrer"
|
|
142
|
-
};
|
|
143
|
-
}
|
|
144
|
-
}
|
|
145
|
-
return null;
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
function detectAIFromUTM(url) {
|
|
149
|
-
try {
|
|
150
|
-
const params = new URL(url).searchParams;
|
|
151
|
-
const utmSource = params.get("utm_source")?.toLowerCase();
|
|
152
|
-
if (utmSource) {
|
|
153
|
-
for (const [pattern, platform] of Object.entries(AI_PLATFORMS)) {
|
|
154
|
-
if (utmSource.includes(pattern.split(".")[0])) {
|
|
155
|
-
return {
|
|
156
|
-
isAI: true,
|
|
157
|
-
platform,
|
|
158
|
-
confidence: 0.99,
|
|
159
|
-
// Very high confidence from explicit UTM
|
|
160
|
-
method: "referrer"
|
|
161
|
-
};
|
|
162
|
-
}
|
|
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
|
+
});
|
|
163
63
|
}
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
+
});
|
|
171
73
|
}
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
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()
|
|
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
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
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
|
+
]
|
|
254
129
|
};
|
|
255
130
|
}
|
|
256
131
|
/**
|
|
257
|
-
*
|
|
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();
|
|
309
|
-
}
|
|
310
|
-
/**
|
|
311
|
-
* Record focus/blur event
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
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";
|
|
296
|
+
}
|
|
297
|
+
if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
|
|
298
|
+
return "typeform";
|
|
299
|
+
}
|
|
300
|
+
if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
|
|
301
|
+
return "jotform";
|
|
449
302
|
}
|
|
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
|
-
}
|
|
303
|
+
if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
|
|
304
|
+
return "gravity";
|
|
460
305
|
}
|
|
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
|
-
}
|
|
306
|
+
if (element.tagName === "FORM") {
|
|
307
|
+
return "native";
|
|
473
308
|
}
|
|
474
|
-
return
|
|
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
|
+
}
|
|
606
451
|
};
|
|
607
|
-
this.
|
|
608
|
-
|
|
452
|
+
this.handleActivity = () => {
|
|
453
|
+
const now = Date.now();
|
|
454
|
+
if (this.isIdle) {
|
|
455
|
+
this.isIdle = false;
|
|
456
|
+
}
|
|
457
|
+
this.lastActivityTime = now;
|
|
458
|
+
};
|
|
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() {
|
|
@@ -819,745 +770,896 @@ var AgenticBrowserAnalyzer = class {
|
|
|
819
770
|
};
|
|
820
771
|
}
|
|
821
772
|
/**
|
|
822
|
-
* Cleanup resources
|
|
773
|
+
* Cleanup resources
|
|
774
|
+
*/
|
|
775
|
+
destroy() {
|
|
776
|
+
this.cometDetector.destroy();
|
|
777
|
+
this.mouseAnalyzer.destroy();
|
|
778
|
+
}
|
|
779
|
+
};
|
|
780
|
+
function createAgenticAnalyzer() {
|
|
781
|
+
const analyzer = new AgenticBrowserAnalyzer();
|
|
782
|
+
if (typeof document !== "undefined") {
|
|
783
|
+
if (document.readyState === "loading") {
|
|
784
|
+
document.addEventListener("DOMContentLoaded", () => analyzer.init());
|
|
785
|
+
} else {
|
|
786
|
+
analyzer.init();
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return analyzer;
|
|
790
|
+
}
|
|
791
|
+
|
|
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
|
+
}
|
|
844
|
+
};
|
|
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
|
|
823
886
|
*/
|
|
824
|
-
|
|
825
|
-
this.
|
|
826
|
-
this.
|
|
827
|
-
|
|
828
|
-
};
|
|
829
|
-
function createAgenticAnalyzer() {
|
|
830
|
-
const analyzer = new AgenticBrowserAnalyzer();
|
|
831
|
-
if (typeof document !== "undefined") {
|
|
832
|
-
if (document.readyState === "loading") {
|
|
833
|
-
document.addEventListener("DOMContentLoaded", () => analyzer.init());
|
|
834
|
-
} else {
|
|
835
|
-
analyzer.init();
|
|
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);
|
|
836
891
|
}
|
|
892
|
+
this.checkAndClassify();
|
|
837
893
|
}
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
storageKey: "_loamly_queue"
|
|
848
|
-
};
|
|
849
|
-
var EventQueue = class {
|
|
850
|
-
constructor(endpoint2, config2 = {}) {
|
|
851
|
-
this.queue = [];
|
|
852
|
-
this.batchTimer = null;
|
|
853
|
-
this.isFlushing = false;
|
|
854
|
-
this.endpoint = endpoint2;
|
|
855
|
-
this.config = { ...DEFAULT_QUEUE_CONFIG, ...config2 };
|
|
856
|
-
this.loadFromStorage();
|
|
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
|
-
timestamp: Date.now(),
|
|
867
|
-
retries: 0
|
|
868
|
-
};
|
|
869
|
-
this.queue.push(event);
|
|
870
|
-
this.saveToStorage();
|
|
871
|
-
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
|
+
}
|
|
872
912
|
}
|
|
873
913
|
/**
|
|
874
|
-
*
|
|
914
|
+
* Record form field interaction end
|
|
875
915
|
*/
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
try {
|
|
881
|
-
const events = [...this.queue];
|
|
882
|
-
this.queue = [];
|
|
883
|
-
await this.sendBatch(events);
|
|
884
|
-
} finally {
|
|
885
|
-
this.isFlushing = false;
|
|
886
|
-
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();
|
|
887
920
|
}
|
|
921
|
+
this.checkAndClassify();
|
|
888
922
|
}
|
|
889
923
|
/**
|
|
890
|
-
*
|
|
924
|
+
* Record focus/blur event
|
|
891
925
|
*/
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
...e.payload,
|
|
897
|
-
_queue_id: e.id,
|
|
898
|
-
_queue_timestamp: e.timestamp
|
|
899
|
-
}));
|
|
900
|
-
const success = navigator.sendBeacon?.(
|
|
901
|
-
this.endpoint,
|
|
902
|
-
JSON.stringify({ events, beacon: true })
|
|
903
|
-
) ?? false;
|
|
904
|
-
if (success) {
|
|
905
|
-
this.queue = [];
|
|
906
|
-
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);
|
|
907
930
|
}
|
|
908
|
-
return success;
|
|
909
931
|
}
|
|
910
932
|
/**
|
|
911
|
-
*
|
|
933
|
+
* Check if we have enough data and classify
|
|
912
934
|
*/
|
|
913
|
-
|
|
914
|
-
|
|
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();
|
|
915
942
|
}
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
this.batchTimer = setTimeout(() => {
|
|
923
|
-
this.batchTimer = null;
|
|
924
|
-
this.flush();
|
|
925
|
-
}, this.config.batchTimeout);
|
|
943
|
+
/**
|
|
944
|
+
* Force classification (for beforeunload)
|
|
945
|
+
*/
|
|
946
|
+
forceClassify() {
|
|
947
|
+
if (this.classified) return this.result;
|
|
948
|
+
return this.classify();
|
|
926
949
|
}
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
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);
|
|
931
963
|
}
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
const
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
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
|
|
943
990
|
};
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
991
|
+
this.classified = true;
|
|
992
|
+
if (this.onClassify) {
|
|
993
|
+
this.onClassify(this.result);
|
|
994
|
+
}
|
|
995
|
+
return this.result;
|
|
996
|
+
}
|
|
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");
|
|
952
1012
|
}
|
|
953
|
-
}
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
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);
|
|
959
1021
|
}
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
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");
|
|
963
1032
|
}
|
|
964
1033
|
}
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
const
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
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;
|
|
975
1045
|
}
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
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");
|
|
983
1059
|
} else {
|
|
984
|
-
|
|
1060
|
+
signals.push("mouse_movement_curved");
|
|
985
1061
|
}
|
|
986
|
-
} catch {
|
|
987
|
-
}
|
|
988
|
-
}
|
|
989
|
-
clearStorage() {
|
|
990
|
-
try {
|
|
991
|
-
localStorage.removeItem(this.config.storageKey);
|
|
992
|
-
} catch {
|
|
993
1062
|
}
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
this.intervalId = null;
|
|
1004
|
-
this.isVisible = true;
|
|
1005
|
-
this.currentScrollDepth = 0;
|
|
1006
|
-
this.ping = async () => {
|
|
1007
|
-
const data = this.getData();
|
|
1008
|
-
this.config.onPing?.(data);
|
|
1009
|
-
if (this.config.endpoint) {
|
|
1010
|
-
try {
|
|
1011
|
-
await fetch(this.config.endpoint, {
|
|
1012
|
-
method: "POST",
|
|
1013
|
-
headers: { "Content-Type": "application/json" },
|
|
1014
|
-
body: JSON.stringify(data)
|
|
1015
|
-
});
|
|
1016
|
-
} catch {
|
|
1017
|
-
}
|
|
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");
|
|
1018
1072
|
}
|
|
1019
|
-
}
|
|
1020
|
-
this.
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
(window.scrollY + window.innerHeight) / document.documentElement.scrollHeight * 100
|
|
1026
|
-
);
|
|
1027
|
-
if (scrollPercent > this.currentScrollDepth) {
|
|
1028
|
-
this.currentScrollDepth = Math.min(scrollPercent, 100);
|
|
1073
|
+
}
|
|
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);
|
|
1029
1079
|
}
|
|
1030
|
-
|
|
1031
|
-
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
this.config = {
|
|
1036
|
-
interval: DEFAULT_CONFIG.pingInterval,
|
|
1037
|
-
endpoint: "",
|
|
1038
|
-
...config2
|
|
1039
|
-
};
|
|
1040
|
-
document.addEventListener("visibilitychange", this.handleVisibilityChange);
|
|
1041
|
-
window.addEventListener("scroll", this.handleScroll, { passive: true });
|
|
1042
|
-
}
|
|
1043
|
-
/**
|
|
1044
|
-
* Start the ping service
|
|
1045
|
-
*/
|
|
1046
|
-
start() {
|
|
1047
|
-
if (this.intervalId) return;
|
|
1048
|
-
this.intervalId = setInterval(() => {
|
|
1049
|
-
if (this.isVisible) {
|
|
1050
|
-
this.ping();
|
|
1080
|
+
const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
|
|
1081
|
+
if (avgInterval < 1e3) {
|
|
1082
|
+
signals.push("focus_blur_rapid");
|
|
1083
|
+
} else {
|
|
1084
|
+
signals.push("focus_blur_normal");
|
|
1051
1085
|
}
|
|
1052
|
-
}
|
|
1053
|
-
|
|
1086
|
+
}
|
|
1087
|
+
return signals;
|
|
1054
1088
|
}
|
|
1055
1089
|
/**
|
|
1056
|
-
*
|
|
1090
|
+
* Add context signals (set by tracker from external data)
|
|
1057
1091
|
*/
|
|
1058
|
-
|
|
1059
|
-
if (this.intervalId) {
|
|
1060
|
-
clearInterval(this.intervalId);
|
|
1061
|
-
this.intervalId = null;
|
|
1062
|
-
}
|
|
1063
|
-
document.removeEventListener("visibilitychange", this.handleVisibilityChange);
|
|
1064
|
-
window.removeEventListener("scroll", this.handleScroll);
|
|
1092
|
+
addContextSignal(_signal) {
|
|
1065
1093
|
}
|
|
1066
1094
|
/**
|
|
1067
|
-
*
|
|
1095
|
+
* Get current result (null if not yet classified)
|
|
1068
1096
|
*/
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
this.currentScrollDepth = depth;
|
|
1072
|
-
}
|
|
1097
|
+
getResult() {
|
|
1098
|
+
return this.result;
|
|
1073
1099
|
}
|
|
1074
1100
|
/**
|
|
1075
|
-
*
|
|
1101
|
+
* Check if classification has been performed
|
|
1076
1102
|
*/
|
|
1077
|
-
|
|
1078
|
-
return
|
|
1079
|
-
session_id: this.sessionId,
|
|
1080
|
-
visitor_id: this.visitorId,
|
|
1081
|
-
url: window.location.href,
|
|
1082
|
-
time_on_page_ms: Date.now() - this.pageLoadTime,
|
|
1083
|
-
scroll_depth: this.currentScrollDepth,
|
|
1084
|
-
is_active: this.isVisible,
|
|
1085
|
-
tracker_version: this.version
|
|
1086
|
-
};
|
|
1103
|
+
hasClassified() {
|
|
1104
|
+
return this.classified;
|
|
1087
1105
|
}
|
|
1088
1106
|
};
|
|
1089
1107
|
|
|
1090
|
-
// src/
|
|
1091
|
-
var
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
this.
|
|
1095
|
-
this.
|
|
1096
|
-
this.
|
|
1097
|
-
this.
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
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();
|
|
1116
|
+
}
|
|
1117
|
+
/**
|
|
1118
|
+
* Initialize event tracking
|
|
1119
|
+
* Must be called after DOM is ready
|
|
1120
|
+
*/
|
|
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();
|
|
1105
1137
|
}
|
|
1106
1138
|
};
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
};
|
|
1110
|
-
this.config = {
|
|
1111
|
-
chunks: DEFAULT_CHUNKS,
|
|
1112
|
-
...config2
|
|
1113
|
-
};
|
|
1114
|
-
this.startTime = Date.now();
|
|
1139
|
+
document.addEventListener("click", recordFirstInteraction, { once: true, passive: true });
|
|
1140
|
+
document.addEventListener("keydown", recordFirstInteraction, { once: true, passive: true });
|
|
1115
1141
|
}
|
|
1116
1142
|
/**
|
|
1117
|
-
*
|
|
1143
|
+
* Record a focus/blur event
|
|
1118
1144
|
*/
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
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);
|
|
1154
|
+
}
|
|
1123
1155
|
}
|
|
1124
1156
|
/**
|
|
1125
|
-
*
|
|
1157
|
+
* Analyze the focus/blur sequence for paste patterns
|
|
1126
1158
|
*/
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1159
|
+
analyze() {
|
|
1160
|
+
if (this.analyzed && this.result) {
|
|
1161
|
+
return this.result;
|
|
1162
|
+
}
|
|
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;
|
|
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
|
|
1219
|
+
};
|
|
1220
|
+
this.analyzed = true;
|
|
1221
|
+
return this.result;
|
|
1222
|
+
}
|
|
1223
|
+
/**
|
|
1224
|
+
* Get current result (analyze if not done)
|
|
1225
|
+
*/
|
|
1226
|
+
getResult() {
|
|
1227
|
+
return this.analyze();
|
|
1130
1228
|
}
|
|
1131
1229
|
/**
|
|
1132
|
-
*
|
|
1230
|
+
* Check if analysis has been performed
|
|
1133
1231
|
*/
|
|
1134
|
-
|
|
1135
|
-
return this.
|
|
1232
|
+
hasAnalyzed() {
|
|
1233
|
+
return this.analyzed;
|
|
1136
1234
|
}
|
|
1137
1235
|
/**
|
|
1138
|
-
* Get
|
|
1236
|
+
* Get the raw sequence for debugging
|
|
1139
1237
|
*/
|
|
1140
|
-
|
|
1141
|
-
return
|
|
1238
|
+
getSequence() {
|
|
1239
|
+
return [...this.sequence];
|
|
1142
1240
|
}
|
|
1143
1241
|
/**
|
|
1144
|
-
*
|
|
1242
|
+
* Reset the analyzer
|
|
1145
1243
|
*/
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1244
|
+
reset() {
|
|
1245
|
+
this.sequence = [];
|
|
1246
|
+
this.pageLoadTime = performance.now();
|
|
1247
|
+
this.firstInteractionTime = null;
|
|
1248
|
+
this.analyzed = false;
|
|
1249
|
+
this.result = null;
|
|
1250
|
+
}
|
|
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";
|
|
1149
1296
|
return {
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
total_height: docHeight,
|
|
1154
|
-
viewport_height: viewportHeight
|
|
1297
|
+
nav_type,
|
|
1298
|
+
confidence: Math.round(confidence * 1e3) / 1e3,
|
|
1299
|
+
signals
|
|
1155
1300
|
};
|
|
1301
|
+
} catch {
|
|
1302
|
+
return { nav_type: "unknown", confidence: 0, signals: ["detection_error"] };
|
|
1156
1303
|
}
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
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;
|
|
1168
1322
|
}
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
+
};
|
|
1177
1335
|
}
|
|
1178
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;
|
|
1179
1350
|
}
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
const
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
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
|
+
}
|
|
1196
1376
|
}
|
|
1197
|
-
return
|
|
1377
|
+
return null;
|
|
1378
|
+
} catch {
|
|
1379
|
+
return null;
|
|
1198
1380
|
}
|
|
1199
|
-
}
|
|
1381
|
+
}
|
|
1200
1382
|
|
|
1201
|
-
// src/
|
|
1202
|
-
var
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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"
|
|
1207
1390
|
};
|
|
1208
|
-
var
|
|
1209
|
-
constructor(config2 = {}) {
|
|
1210
|
-
this.
|
|
1211
|
-
this.
|
|
1212
|
-
this.
|
|
1213
|
-
this.
|
|
1214
|
-
this.
|
|
1215
|
-
this.
|
|
1216
|
-
this.handleVisibility = () => {
|
|
1217
|
-
const wasVisible = this.isVisible;
|
|
1218
|
-
this.isVisible = document.visibilityState === "visible";
|
|
1219
|
-
if (wasVisible && !this.isVisible) {
|
|
1220
|
-
this.updateTimes();
|
|
1221
|
-
} else if (!wasVisible && this.isVisible) {
|
|
1222
|
-
this.lastUpdateTime = Date.now();
|
|
1223
|
-
this.lastActivityTime = Date.now();
|
|
1224
|
-
}
|
|
1225
|
-
};
|
|
1226
|
-
this.handleActivity = () => {
|
|
1227
|
-
const now = Date.now();
|
|
1228
|
-
if (this.isIdle) {
|
|
1229
|
-
this.isIdle = false;
|
|
1230
|
-
}
|
|
1231
|
-
this.lastActivityTime = now;
|
|
1232
|
-
};
|
|
1233
|
-
this.config = { ...DEFAULT_CONFIG2, ...config2 };
|
|
1234
|
-
this.startTime = Date.now();
|
|
1235
|
-
this.lastActivityTime = this.startTime;
|
|
1236
|
-
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();
|
|
1237
1399
|
}
|
|
1238
1400
|
/**
|
|
1239
|
-
*
|
|
1401
|
+
* Add event to queue
|
|
1240
1402
|
*/
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
}
|
|
1250
|
-
this.
|
|
1251
|
-
|
|
1252
|
-
|
|
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();
|
|
1253
1415
|
}
|
|
1254
1416
|
/**
|
|
1255
|
-
*
|
|
1417
|
+
* Force flush all events immediately
|
|
1256
1418
|
*/
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
clearInterval(this.idleCheckInterval);
|
|
1269
|
-
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();
|
|
1270
1430
|
}
|
|
1271
1431
|
}
|
|
1272
1432
|
/**
|
|
1273
|
-
*
|
|
1433
|
+
* Flush using sendBeacon (for unload events)
|
|
1274
1434
|
*/
|
|
1275
|
-
|
|
1276
|
-
this.
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
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;
|
|
1283
1456
|
}
|
|
1284
1457
|
/**
|
|
1285
|
-
* Get
|
|
1458
|
+
* Get current queue length
|
|
1286
1459
|
*/
|
|
1287
|
-
|
|
1288
|
-
this.
|
|
1289
|
-
return this.getMetrics();
|
|
1460
|
+
get length() {
|
|
1461
|
+
return this.queue.length;
|
|
1290
1462
|
}
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1463
|
+
scheduleBatch() {
|
|
1464
|
+
if (this.batchTimer) return;
|
|
1465
|
+
if (this.queue.length >= this.config.batchSize) {
|
|
1466
|
+
this.flush();
|
|
1467
|
+
return;
|
|
1296
1468
|
}
|
|
1469
|
+
this.batchTimer = setTimeout(() => {
|
|
1470
|
+
this.batchTimer = null;
|
|
1471
|
+
this.flush();
|
|
1472
|
+
}, this.config.batchTimeout);
|
|
1297
1473
|
}
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
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);
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
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
|
+
}
|
|
1539
|
+
}
|
|
1540
|
+
saveToStorage() {
|
|
1541
|
+
try {
|
|
1542
|
+
if (this.queue.length > 0) {
|
|
1543
|
+
localStorage.setItem(this.config.storageKey, JSON.stringify(this.queue));
|
|
1304
1544
|
} else {
|
|
1305
|
-
this.
|
|
1545
|
+
this.clearStorage();
|
|
1306
1546
|
}
|
|
1547
|
+
} catch {
|
|
1307
1548
|
}
|
|
1308
|
-
this.lastUpdateTime = now;
|
|
1309
1549
|
}
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
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)}`;
|
|
1314
1558
|
}
|
|
1315
1559
|
};
|
|
1316
1560
|
|
|
1317
|
-
// src/
|
|
1318
|
-
var
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
trackableFields: [
|
|
1334
|
-
"email",
|
|
1335
|
-
"name",
|
|
1336
|
-
"phone",
|
|
1337
|
-
"company",
|
|
1338
|
-
"first",
|
|
1339
|
-
"last",
|
|
1340
|
-
"city",
|
|
1341
|
-
"country"
|
|
1342
|
-
],
|
|
1343
|
-
thankYouPatterns: [
|
|
1344
|
-
/thank[-_]?you/i,
|
|
1345
|
-
/success/i,
|
|
1346
|
-
/confirmation/i,
|
|
1347
|
-
/submitted/i,
|
|
1348
|
-
/complete/i
|
|
1349
|
-
]
|
|
1350
|
-
};
|
|
1351
|
-
var FormTracker = class {
|
|
1352
|
-
constructor(config2 = {}) {
|
|
1353
|
-
this.formStartTimes = /* @__PURE__ */ new Map();
|
|
1354
|
-
this.interactedForms = /* @__PURE__ */ new Set();
|
|
1355
|
-
this.mutationObserver = null;
|
|
1356
|
-
this.handleFocusIn = (e) => {
|
|
1357
|
-
const target = e.target;
|
|
1358
|
-
if (!this.isFormField(target)) return;
|
|
1359
|
-
const form = target.closest("form");
|
|
1360
|
-
const formId = this.getFormId(form || target);
|
|
1361
|
-
if (!this.formStartTimes.has(formId)) {
|
|
1362
|
-
this.formStartTimes.set(formId, Date.now());
|
|
1363
|
-
this.interactedForms.add(formId);
|
|
1364
|
-
this.emitEvent({
|
|
1365
|
-
event_type: "form_start",
|
|
1366
|
-
form_id: formId,
|
|
1367
|
-
form_type: this.detectFormType(form || target)
|
|
1368
|
-
});
|
|
1369
|
-
}
|
|
1370
|
-
const fieldName = this.getFieldName(target);
|
|
1371
|
-
if (fieldName && !this.isSensitiveField(fieldName)) {
|
|
1372
|
-
this.emitEvent({
|
|
1373
|
-
event_type: "form_field",
|
|
1374
|
-
form_id: formId,
|
|
1375
|
-
form_type: this.detectFormType(form || target),
|
|
1376
|
-
field_name: this.sanitizeFieldName(fieldName),
|
|
1377
|
-
field_type: target.type || target.tagName.toLowerCase()
|
|
1378
|
-
});
|
|
1379
|
-
}
|
|
1380
|
-
};
|
|
1381
|
-
this.handleSubmit = (e) => {
|
|
1382
|
-
const form = e.target;
|
|
1383
|
-
if (!form || form.tagName !== "FORM") return;
|
|
1384
|
-
const formId = this.getFormId(form);
|
|
1385
|
-
const startTime = this.formStartTimes.get(formId);
|
|
1386
|
-
this.emitEvent({
|
|
1387
|
-
event_type: "form_submit",
|
|
1388
|
-
form_id: formId,
|
|
1389
|
-
form_type: this.detectFormType(form),
|
|
1390
|
-
time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
|
|
1391
|
-
is_conversion: true
|
|
1392
|
-
});
|
|
1393
|
-
};
|
|
1394
|
-
this.handleClick = (e) => {
|
|
1395
|
-
const target = e.target;
|
|
1396
|
-
if (target.closest(".hs-button") || target.closest('[type="submit"]')) {
|
|
1397
|
-
const form = target.closest("form");
|
|
1398
|
-
if (form && form.classList.contains("hs-form")) {
|
|
1399
|
-
const formId = this.getFormId(form);
|
|
1400
|
-
const startTime = this.formStartTimes.get(formId);
|
|
1401
|
-
this.emitEvent({
|
|
1402
|
-
event_type: "form_submit",
|
|
1403
|
-
form_id: formId,
|
|
1404
|
-
form_type: "hubspot",
|
|
1405
|
-
time_to_submit_ms: startTime ? Date.now() - startTime : void 0,
|
|
1406
|
-
is_conversion: true
|
|
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)
|
|
1407
1577
|
});
|
|
1578
|
+
} catch {
|
|
1408
1579
|
}
|
|
1409
1580
|
}
|
|
1410
|
-
|
|
1411
|
-
|
|
1412
|
-
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1581
|
+
};
|
|
1582
|
+
this.handleVisibilityChange = () => {
|
|
1583
|
+
this.isVisible = document.visibilityState === "visible";
|
|
1584
|
+
};
|
|
1585
|
+
this.handleFocusChange = () => {
|
|
1586
|
+
this.isFocused = typeof document.hasFocus === "function" ? document.hasFocus() : true;
|
|
1587
|
+
if (this.intervalId && this.isVisible && this.isFocused) {
|
|
1588
|
+
this.ping();
|
|
1417
1589
|
}
|
|
1418
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);
|
|
1597
|
+
}
|
|
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;
|
|
1419
1605
|
this.config = {
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
...DEFAULT_CONFIG3.sensitiveFields,
|
|
1424
|
-
...config2.sensitiveFields || []
|
|
1425
|
-
]
|
|
1606
|
+
interval: DEFAULT_CONFIG3.pingInterval,
|
|
1607
|
+
endpoint: "",
|
|
1608
|
+
...config2
|
|
1426
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 });
|
|
1427
1614
|
}
|
|
1428
1615
|
/**
|
|
1429
|
-
* Start
|
|
1616
|
+
* Start the ping service
|
|
1430
1617
|
*/
|
|
1431
1618
|
start() {
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
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
|
+
}
|
|
1438
1628
|
}
|
|
1439
1629
|
/**
|
|
1440
|
-
* Stop
|
|
1630
|
+
* Stop the ping service
|
|
1441
1631
|
*/
|
|
1442
1632
|
stop() {
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
|
|
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);
|
|
1447
1641
|
}
|
|
1448
1642
|
/**
|
|
1449
|
-
*
|
|
1643
|
+
* Update scroll depth (called by external scroll tracker)
|
|
1450
1644
|
*/
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
startMutationObserver() {
|
|
1455
|
-
this.mutationObserver = new MutationObserver((mutations) => {
|
|
1456
|
-
for (const mutation of mutations) {
|
|
1457
|
-
for (const node of mutation.addedNodes) {
|
|
1458
|
-
if (node instanceof HTMLElement) {
|
|
1459
|
-
if (node.classList?.contains("hs-form") || node.querySelector?.(".hs-form")) {
|
|
1460
|
-
this.trackEmbeddedForm(node, "hubspot");
|
|
1461
|
-
}
|
|
1462
|
-
if (node.classList?.contains("typeform-widget") || node.querySelector?.("[data-tf-widget]")) {
|
|
1463
|
-
this.trackEmbeddedForm(node, "typeform");
|
|
1464
|
-
}
|
|
1465
|
-
if (node.classList?.contains("jotform-form") || node.querySelector?.(".jotform-form")) {
|
|
1466
|
-
this.trackEmbeddedForm(node, "jotform");
|
|
1467
|
-
}
|
|
1468
|
-
if (node.classList?.contains("gform_wrapper") || node.querySelector?.(".gform_wrapper")) {
|
|
1469
|
-
this.trackEmbeddedForm(node, "gravity");
|
|
1470
|
-
}
|
|
1471
|
-
}
|
|
1472
|
-
}
|
|
1473
|
-
}
|
|
1474
|
-
});
|
|
1475
|
-
this.mutationObserver.observe(document.body, {
|
|
1476
|
-
childList: true,
|
|
1477
|
-
subtree: true
|
|
1478
|
-
});
|
|
1479
|
-
}
|
|
1480
|
-
scanForEmbeddedForms() {
|
|
1481
|
-
document.querySelectorAll(".hs-form").forEach((form) => {
|
|
1482
|
-
this.trackEmbeddedForm(form, "hubspot");
|
|
1483
|
-
});
|
|
1484
|
-
document.querySelectorAll("[data-tf-widget], .typeform-widget").forEach((form) => {
|
|
1485
|
-
this.trackEmbeddedForm(form, "typeform");
|
|
1486
|
-
});
|
|
1487
|
-
document.querySelectorAll(".jotform-form").forEach((form) => {
|
|
1488
|
-
this.trackEmbeddedForm(form, "jotform");
|
|
1489
|
-
});
|
|
1490
|
-
document.querySelectorAll(".gform_wrapper").forEach((form) => {
|
|
1491
|
-
this.trackEmbeddedForm(form, "gravity");
|
|
1492
|
-
});
|
|
1493
|
-
}
|
|
1494
|
-
trackEmbeddedForm(element, type) {
|
|
1495
|
-
const formId = `${type}_${this.getFormId(element)}`;
|
|
1496
|
-
element.addEventListener("focusin", () => {
|
|
1497
|
-
if (!this.formStartTimes.has(formId)) {
|
|
1498
|
-
this.formStartTimes.set(formId, Date.now());
|
|
1499
|
-
this.interactedForms.add(formId);
|
|
1500
|
-
this.emitEvent({
|
|
1501
|
-
event_type: "form_start",
|
|
1502
|
-
form_id: formId,
|
|
1503
|
-
form_type: type
|
|
1504
|
-
});
|
|
1505
|
-
}
|
|
1506
|
-
}, { passive: true });
|
|
1507
|
-
}
|
|
1508
|
-
checkThankYouPage() {
|
|
1509
|
-
const url = window.location.href.toLowerCase();
|
|
1510
|
-
const title = document.title.toLowerCase();
|
|
1511
|
-
for (const pattern of this.config.thankYouPatterns) {
|
|
1512
|
-
if (pattern.test(url) || pattern.test(title)) {
|
|
1513
|
-
this.emitEvent({
|
|
1514
|
-
event_type: "form_success",
|
|
1515
|
-
form_id: "page_conversion",
|
|
1516
|
-
form_type: "unknown",
|
|
1517
|
-
is_conversion: true
|
|
1518
|
-
});
|
|
1519
|
-
break;
|
|
1520
|
-
}
|
|
1521
|
-
}
|
|
1522
|
-
}
|
|
1523
|
-
isFormField(element) {
|
|
1524
|
-
const tagName = element.tagName;
|
|
1525
|
-
return tagName === "INPUT" || tagName === "TEXTAREA" || tagName === "SELECT";
|
|
1526
|
-
}
|
|
1527
|
-
getFormId(element) {
|
|
1528
|
-
if (!element) return "unknown";
|
|
1529
|
-
return element.id || element.getAttribute("name") || element.getAttribute("data-form-id") || "form_" + Math.random().toString(36).substring(2, 8);
|
|
1530
|
-
}
|
|
1531
|
-
getFieldName(input) {
|
|
1532
|
-
return input.name || input.id || input.getAttribute("data-name") || "";
|
|
1533
|
-
}
|
|
1534
|
-
isSensitiveField(fieldName) {
|
|
1535
|
-
const lowerName = fieldName.toLowerCase();
|
|
1536
|
-
return this.config.sensitiveFields.some((sensitive) => lowerName.includes(sensitive));
|
|
1537
|
-
}
|
|
1538
|
-
sanitizeFieldName(fieldName) {
|
|
1539
|
-
return fieldName.replace(/[0-9]+/g, "*").substring(0, 50);
|
|
1540
|
-
}
|
|
1541
|
-
detectFormType(element) {
|
|
1542
|
-
if (element.classList.contains("hs-form") || element.closest(".hs-form")) {
|
|
1543
|
-
return "hubspot";
|
|
1544
|
-
}
|
|
1545
|
-
if (element.classList.contains("typeform-widget") || element.closest("[data-tf-widget]")) {
|
|
1546
|
-
return "typeform";
|
|
1547
|
-
}
|
|
1548
|
-
if (element.classList.contains("jotform-form") || element.closest(".jotform-form")) {
|
|
1549
|
-
return "jotform";
|
|
1550
|
-
}
|
|
1551
|
-
if (element.classList.contains("gform_wrapper") || element.closest(".gform_wrapper")) {
|
|
1552
|
-
return "gravity";
|
|
1553
|
-
}
|
|
1554
|
-
if (element.tagName === "FORM") {
|
|
1555
|
-
return "native";
|
|
1645
|
+
updateScrollDepth(depth) {
|
|
1646
|
+
if (depth > this.currentScrollDepth) {
|
|
1647
|
+
this.currentScrollDepth = depth;
|
|
1556
1648
|
}
|
|
1557
|
-
return "unknown";
|
|
1558
1649
|
}
|
|
1559
|
-
|
|
1560
|
-
|
|
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
|
+
};
|
|
1561
1663
|
}
|
|
1562
1664
|
};
|
|
1563
1665
|
|
|
@@ -1733,13 +1835,15 @@ function sendBeacon(url, data) {
|
|
|
1733
1835
|
}
|
|
1734
1836
|
|
|
1735
1837
|
// src/core.ts
|
|
1736
|
-
var config = { apiHost:
|
|
1838
|
+
var config = { apiHost: DEFAULT_CONFIG3.apiHost };
|
|
1737
1839
|
var initialized = false;
|
|
1738
1840
|
var debugMode = false;
|
|
1739
1841
|
var visitorId = null;
|
|
1740
1842
|
var sessionId = null;
|
|
1843
|
+
var workspaceId = null;
|
|
1741
1844
|
var navigationTiming = null;
|
|
1742
1845
|
var aiDetection = null;
|
|
1846
|
+
var pageStartTime = null;
|
|
1743
1847
|
var behavioralClassifier = null;
|
|
1744
1848
|
var behavioralMLResult = null;
|
|
1745
1849
|
var focusBlurAnalyzer = null;
|
|
@@ -1759,6 +1863,28 @@ function log(...args) {
|
|
|
1759
1863
|
function endpoint(path) {
|
|
1760
1864
|
return `${config.apiHost}${path}`;
|
|
1761
1865
|
}
|
|
1866
|
+
function buildHeaders(idempotencyKey) {
|
|
1867
|
+
const headers = {
|
|
1868
|
+
"Content-Type": "application/json"
|
|
1869
|
+
};
|
|
1870
|
+
if (config.apiKey) {
|
|
1871
|
+
headers["X-Loamly-Api-Key"] = config.apiKey;
|
|
1872
|
+
}
|
|
1873
|
+
if (idempotencyKey) {
|
|
1874
|
+
headers["X-Idempotency-Key"] = idempotencyKey;
|
|
1875
|
+
}
|
|
1876
|
+
return headers;
|
|
1877
|
+
}
|
|
1878
|
+
function buildBeaconUrl(path) {
|
|
1879
|
+
if (!config.apiKey) return path;
|
|
1880
|
+
const url = new URL(path, config.apiHost);
|
|
1881
|
+
url.searchParams.set("api_key", config.apiKey);
|
|
1882
|
+
return url.toString();
|
|
1883
|
+
}
|
|
1884
|
+
function buildIdempotencyKey(prefix) {
|
|
1885
|
+
const base = sessionId || visitorId || "unknown";
|
|
1886
|
+
return `${prefix}:${base}:${Date.now()}`;
|
|
1887
|
+
}
|
|
1762
1888
|
function init(userConfig = {}) {
|
|
1763
1889
|
if (initialized) {
|
|
1764
1890
|
log("Already initialized");
|
|
@@ -1767,9 +1893,13 @@ function init(userConfig = {}) {
|
|
|
1767
1893
|
config = {
|
|
1768
1894
|
...config,
|
|
1769
1895
|
...userConfig,
|
|
1770
|
-
apiHost: userConfig.apiHost ||
|
|
1896
|
+
apiHost: userConfig.apiHost || DEFAULT_CONFIG3.apiHost
|
|
1771
1897
|
};
|
|
1898
|
+
workspaceId = userConfig.workspaceId ?? null;
|
|
1772
1899
|
debugMode = userConfig.debug ?? false;
|
|
1900
|
+
if (config.apiKey && !workspaceId) {
|
|
1901
|
+
log("Workspace ID missing. Behavioral events require workspaceId.");
|
|
1902
|
+
}
|
|
1773
1903
|
const features = {
|
|
1774
1904
|
scroll: true,
|
|
1775
1905
|
time: true,
|
|
@@ -1787,13 +1917,11 @@ function init(userConfig = {}) {
|
|
|
1787
1917
|
log("Features:", features);
|
|
1788
1918
|
visitorId = getVisitorId();
|
|
1789
1919
|
log("Visitor ID:", visitorId);
|
|
1790
|
-
const session = getSessionId();
|
|
1791
|
-
sessionId = session.sessionId;
|
|
1792
|
-
log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
|
|
1793
1920
|
if (features.eventQueue) {
|
|
1794
|
-
eventQueue = new EventQueue(endpoint(
|
|
1795
|
-
batchSize:
|
|
1796
|
-
batchTimeout:
|
|
1921
|
+
eventQueue = new EventQueue(endpoint(DEFAULT_CONFIG3.endpoints.behavioral), {
|
|
1922
|
+
batchSize: DEFAULT_CONFIG3.batchSize,
|
|
1923
|
+
batchTimeout: DEFAULT_CONFIG3.batchTimeout,
|
|
1924
|
+
apiKey: config.apiKey
|
|
1797
1925
|
});
|
|
1798
1926
|
}
|
|
1799
1927
|
navigationTiming = detectNavigationType();
|
|
@@ -1803,37 +1931,40 @@ function init(userConfig = {}) {
|
|
|
1803
1931
|
log("AI detected:", aiDetection);
|
|
1804
1932
|
}
|
|
1805
1933
|
initialized = true;
|
|
1806
|
-
if (!userConfig.disableAutoPageview) {
|
|
1807
|
-
pageview();
|
|
1808
|
-
}
|
|
1809
|
-
if (!userConfig.disableBehavioral) {
|
|
1810
|
-
setupAdvancedBehavioralTracking(features);
|
|
1811
|
-
}
|
|
1812
|
-
if (features.behavioralML) {
|
|
1813
|
-
behavioralClassifier = new BehavioralClassifier(1e4);
|
|
1814
|
-
behavioralClassifier.setOnClassify(handleBehavioralClassification);
|
|
1815
|
-
setupBehavioralMLTracking();
|
|
1816
|
-
}
|
|
1817
|
-
if (features.focusBlur) {
|
|
1818
|
-
focusBlurAnalyzer = new FocusBlurAnalyzer();
|
|
1819
|
-
focusBlurAnalyzer.initTracking();
|
|
1820
|
-
setTimeout(() => {
|
|
1821
|
-
if (focusBlurAnalyzer) {
|
|
1822
|
-
handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
|
|
1823
|
-
}
|
|
1824
|
-
}, 5e3);
|
|
1825
|
-
}
|
|
1826
1934
|
if (features.agentic) {
|
|
1827
1935
|
agenticAnalyzer = new AgenticBrowserAnalyzer();
|
|
1828
1936
|
agenticAnalyzer.init();
|
|
1829
1937
|
}
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
}
|
|
1835
|
-
|
|
1836
|
-
|
|
1938
|
+
void initializeSession().finally(() => {
|
|
1939
|
+
void registerServiceWorker();
|
|
1940
|
+
if (!userConfig.disableAutoPageview) {
|
|
1941
|
+
pageview();
|
|
1942
|
+
}
|
|
1943
|
+
if (!userConfig.disableBehavioral) {
|
|
1944
|
+
setupAdvancedBehavioralTracking(features);
|
|
1945
|
+
}
|
|
1946
|
+
if (features.behavioralML) {
|
|
1947
|
+
behavioralClassifier = new BehavioralClassifier(1e4);
|
|
1948
|
+
behavioralClassifier.setOnClassify(handleBehavioralClassification);
|
|
1949
|
+
setupBehavioralMLTracking();
|
|
1950
|
+
}
|
|
1951
|
+
if (features.focusBlur) {
|
|
1952
|
+
focusBlurAnalyzer = new FocusBlurAnalyzer();
|
|
1953
|
+
focusBlurAnalyzer.initTracking();
|
|
1954
|
+
setTimeout(() => {
|
|
1955
|
+
if (focusBlurAnalyzer) {
|
|
1956
|
+
handleFocusBlurAnalysis(focusBlurAnalyzer.analyze());
|
|
1957
|
+
}
|
|
1958
|
+
}, 5e3);
|
|
1959
|
+
}
|
|
1960
|
+
if (features.ping && visitorId && sessionId) {
|
|
1961
|
+
pingService = new PingService(sessionId, visitorId, VERSION, {
|
|
1962
|
+
interval: DEFAULT_CONFIG3.pingInterval,
|
|
1963
|
+
endpoint: endpoint(DEFAULT_CONFIG3.endpoints.ping)
|
|
1964
|
+
});
|
|
1965
|
+
pingService.start();
|
|
1966
|
+
}
|
|
1967
|
+
});
|
|
1837
1968
|
spaRouter = new SPARouter({
|
|
1838
1969
|
onNavigate: handleSPANavigation
|
|
1839
1970
|
});
|
|
@@ -1842,6 +1973,78 @@ function init(userConfig = {}) {
|
|
|
1842
1973
|
reportHealth("initialized");
|
|
1843
1974
|
log("Initialization complete");
|
|
1844
1975
|
}
|
|
1976
|
+
async function registerServiceWorker() {
|
|
1977
|
+
if (typeof navigator === "undefined" || !("serviceWorker" in navigator)) return;
|
|
1978
|
+
if (!config.apiKey || !workspaceId) return;
|
|
1979
|
+
try {
|
|
1980
|
+
const swUrl = new URL("/tracker/loamly-sw.js", window.location.origin);
|
|
1981
|
+
swUrl.searchParams.set("workspace_id", workspaceId);
|
|
1982
|
+
swUrl.searchParams.set("api_key", config.apiKey);
|
|
1983
|
+
const registration = await navigator.serviceWorker.register(swUrl.toString(), { scope: "/" });
|
|
1984
|
+
registration.addEventListener("updatefound", () => {
|
|
1985
|
+
const installing = registration.installing;
|
|
1986
|
+
installing?.addEventListener("statechange", () => {
|
|
1987
|
+
if (installing.state === "activated") {
|
|
1988
|
+
installing.postMessage({ type: "SKIP_WAITING" });
|
|
1989
|
+
}
|
|
1990
|
+
});
|
|
1991
|
+
});
|
|
1992
|
+
setInterval(() => {
|
|
1993
|
+
registration.update().catch(() => {
|
|
1994
|
+
});
|
|
1995
|
+
}, 24 * 60 * 60 * 1e3);
|
|
1996
|
+
} catch {
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
async function initializeSession() {
|
|
2000
|
+
const now = Date.now();
|
|
2001
|
+
pageStartTime = now;
|
|
2002
|
+
try {
|
|
2003
|
+
const storedSession = sessionStorage.getItem("loamly_session");
|
|
2004
|
+
const storedStart = sessionStorage.getItem("loamly_start");
|
|
2005
|
+
const sessionTimeout = config.sessionTimeout ?? DEFAULT_CONFIG3.sessionTimeout;
|
|
2006
|
+
if (storedSession && storedStart) {
|
|
2007
|
+
const startTime = parseInt(storedStart, 10);
|
|
2008
|
+
const elapsed = now - startTime;
|
|
2009
|
+
if (elapsed > 0 && elapsed < sessionTimeout) {
|
|
2010
|
+
sessionId = storedSession;
|
|
2011
|
+
log("Session ID:", sessionId, "(existing)");
|
|
2012
|
+
return;
|
|
2013
|
+
}
|
|
2014
|
+
}
|
|
2015
|
+
} catch {
|
|
2016
|
+
}
|
|
2017
|
+
if (config.apiKey && workspaceId && visitorId) {
|
|
2018
|
+
try {
|
|
2019
|
+
const response = await safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.session), {
|
|
2020
|
+
method: "POST",
|
|
2021
|
+
headers: buildHeaders(),
|
|
2022
|
+
body: JSON.stringify({
|
|
2023
|
+
workspace_id: workspaceId,
|
|
2024
|
+
visitor_id: visitorId
|
|
2025
|
+
})
|
|
2026
|
+
});
|
|
2027
|
+
if (response?.ok) {
|
|
2028
|
+
const data = await response.json();
|
|
2029
|
+
sessionId = data.session_id || sessionId;
|
|
2030
|
+
const startTime = data.start_time || now;
|
|
2031
|
+
if (sessionId) {
|
|
2032
|
+
try {
|
|
2033
|
+
sessionStorage.setItem("loamly_session", sessionId);
|
|
2034
|
+
sessionStorage.setItem("loamly_start", String(startTime));
|
|
2035
|
+
} catch {
|
|
2036
|
+
}
|
|
2037
|
+
log("Session ID:", sessionId, "(server)");
|
|
2038
|
+
return;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
} catch {
|
|
2042
|
+
}
|
|
2043
|
+
}
|
|
2044
|
+
const session = getSessionId();
|
|
2045
|
+
sessionId = session.sessionId;
|
|
2046
|
+
log("Session ID:", sessionId, session.isNew ? "(new)" : "(existing)");
|
|
2047
|
+
}
|
|
1845
2048
|
function setupAdvancedBehavioralTracking(features) {
|
|
1846
2049
|
if (features.scroll) {
|
|
1847
2050
|
scrollTracker = new ScrollTracker({
|
|
@@ -1849,8 +2052,8 @@ function setupAdvancedBehavioralTracking(features) {
|
|
|
1849
2052
|
onChunkReached: (event) => {
|
|
1850
2053
|
log("Scroll chunk:", event.chunk);
|
|
1851
2054
|
queueEvent("scroll_depth", {
|
|
1852
|
-
|
|
1853
|
-
|
|
2055
|
+
scroll_depth: Math.round(event.depth / 100 * 100) / 100,
|
|
2056
|
+
milestone: Math.round(event.chunk / 100 * 100) / 100,
|
|
1854
2057
|
time_to_reach_ms: event.time_to_reach_ms
|
|
1855
2058
|
});
|
|
1856
2059
|
}
|
|
@@ -1862,12 +2065,10 @@ function setupAdvancedBehavioralTracking(features) {
|
|
|
1862
2065
|
updateIntervalMs: 1e4,
|
|
1863
2066
|
// Report every 10 seconds
|
|
1864
2067
|
onUpdate: (event) => {
|
|
1865
|
-
if (event.active_time_ms >=
|
|
2068
|
+
if (event.active_time_ms >= DEFAULT_CONFIG3.timeSpentThresholdMs) {
|
|
1866
2069
|
queueEvent("time_spent", {
|
|
1867
|
-
|
|
1868
|
-
|
|
1869
|
-
idle_time_ms: event.idle_time_ms,
|
|
1870
|
-
is_engaged: event.is_engaged
|
|
2070
|
+
visible_time_ms: event.total_time_ms,
|
|
2071
|
+
page_start_time: pageStartTime || Date.now()
|
|
1871
2072
|
});
|
|
1872
2073
|
}
|
|
1873
2074
|
}
|
|
@@ -1878,13 +2079,22 @@ function setupAdvancedBehavioralTracking(features) {
|
|
|
1878
2079
|
formTracker = new FormTracker({
|
|
1879
2080
|
onFormEvent: (event) => {
|
|
1880
2081
|
log("Form event:", event.event_type, event.form_id);
|
|
1881
|
-
|
|
2082
|
+
const isSubmitEvent = event.event_type === "form_submit";
|
|
2083
|
+
const isSuccessEvent = event.event_type === "form_success";
|
|
2084
|
+
const normalizedEventType = isSubmitEvent || isSuccessEvent ? "form_submit" : "form_focus";
|
|
2085
|
+
const submitSource = event.submit_source || (isSuccessEvent ? "thank_you" : isSubmitEvent ? "submit" : null);
|
|
2086
|
+
queueEvent(normalizedEventType, {
|
|
1882
2087
|
form_id: event.form_id,
|
|
1883
|
-
|
|
1884
|
-
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
|
|
2088
|
+
form_provider: event.form_type || "unknown",
|
|
2089
|
+
form_field_type: event.field_type || null,
|
|
2090
|
+
form_field_name: event.field_name || null,
|
|
2091
|
+
form_event_type: event.event_type,
|
|
2092
|
+
submit_source: submitSource,
|
|
2093
|
+
is_inferred: isSuccessEvent,
|
|
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
|
|
1888
2098
|
});
|
|
1889
2099
|
}
|
|
1890
2100
|
});
|
|
@@ -1905,7 +2115,7 @@ function setupAdvancedBehavioralTracking(features) {
|
|
|
1905
2115
|
if (link && link.href) {
|
|
1906
2116
|
const isExternal = link.hostname !== window.location.hostname;
|
|
1907
2117
|
queueEvent("click", {
|
|
1908
|
-
|
|
2118
|
+
element_type: "link",
|
|
1909
2119
|
href: truncateText(link.href, 200),
|
|
1910
2120
|
text: truncateText(link.textContent || "", 100),
|
|
1911
2121
|
is_external: isExternal
|
|
@@ -1915,15 +2125,32 @@ function setupAdvancedBehavioralTracking(features) {
|
|
|
1915
2125
|
}
|
|
1916
2126
|
function queueEvent(eventType, data) {
|
|
1917
2127
|
if (!eventQueue) return;
|
|
1918
|
-
|
|
2128
|
+
if (!config.apiKey) {
|
|
2129
|
+
log("Missing apiKey, behavioral event skipped:", eventType);
|
|
2130
|
+
return;
|
|
2131
|
+
}
|
|
2132
|
+
if (!workspaceId) {
|
|
2133
|
+
log("Missing workspaceId, behavioral event skipped:", eventType);
|
|
2134
|
+
return;
|
|
2135
|
+
}
|
|
2136
|
+
if (!sessionId) {
|
|
2137
|
+
log("Missing sessionId, behavioral event skipped:", eventType);
|
|
2138
|
+
return;
|
|
2139
|
+
}
|
|
2140
|
+
const idempotencyKey = buildIdempotencyKey(eventType);
|
|
2141
|
+
const payload = {
|
|
1919
2142
|
visitor_id: visitorId,
|
|
1920
2143
|
session_id: sessionId,
|
|
1921
2144
|
event_type: eventType,
|
|
1922
|
-
|
|
1923
|
-
|
|
2145
|
+
event_data: data,
|
|
2146
|
+
page_url: window.location.href,
|
|
2147
|
+
page_path: window.location.pathname,
|
|
1924
2148
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
1925
|
-
tracker_version: VERSION
|
|
1926
|
-
|
|
2149
|
+
tracker_version: VERSION,
|
|
2150
|
+
idempotency_key: idempotencyKey
|
|
2151
|
+
};
|
|
2152
|
+
payload.workspace_id = workspaceId;
|
|
2153
|
+
eventQueue.push(eventType, payload, buildHeaders(idempotencyKey));
|
|
1927
2154
|
}
|
|
1928
2155
|
function handleSPANavigation(event) {
|
|
1929
2156
|
log("SPA navigation:", event.navigation_type, event.to_url);
|
|
@@ -1951,34 +2178,42 @@ function handleSPANavigation(event) {
|
|
|
1951
2178
|
}
|
|
1952
2179
|
function setupUnloadHandlers() {
|
|
1953
2180
|
const handleUnload = () => {
|
|
2181
|
+
if (!workspaceId || !config.apiKey || !sessionId) return;
|
|
1954
2182
|
const scrollEvent = scrollTracker?.getFinalEvent();
|
|
1955
2183
|
if (scrollEvent) {
|
|
1956
|
-
sendBeacon(endpoint(
|
|
2184
|
+
sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
|
|
2185
|
+
workspace_id: workspaceId,
|
|
1957
2186
|
visitor_id: visitorId,
|
|
1958
2187
|
session_id: sessionId,
|
|
1959
2188
|
event_type: "scroll_depth_final",
|
|
1960
|
-
|
|
1961
|
-
|
|
2189
|
+
event_data: {
|
|
2190
|
+
scroll_depth: Math.round(scrollEvent.depth / 100 * 100) / 100,
|
|
2191
|
+
milestone: Math.round(scrollEvent.chunk / 100 * 100) / 100,
|
|
2192
|
+
time_to_reach_ms: scrollEvent.time_to_reach_ms
|
|
2193
|
+
},
|
|
2194
|
+
page_url: window.location.href,
|
|
2195
|
+
page_path: window.location.pathname,
|
|
2196
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2197
|
+
tracker_version: VERSION,
|
|
2198
|
+
idempotency_key: buildIdempotencyKey("scroll_depth_final")
|
|
1962
2199
|
});
|
|
1963
2200
|
}
|
|
1964
2201
|
const timeEvent = timeTracker?.getFinalMetrics();
|
|
1965
2202
|
if (timeEvent) {
|
|
1966
|
-
sendBeacon(endpoint(
|
|
2203
|
+
sendBeacon(buildBeaconUrl(endpoint(DEFAULT_CONFIG3.endpoints.behavioral)), {
|
|
2204
|
+
workspace_id: workspaceId,
|
|
1967
2205
|
visitor_id: visitorId,
|
|
1968
2206
|
session_id: sessionId,
|
|
1969
|
-
event_type: "
|
|
1970
|
-
|
|
1971
|
-
|
|
1972
|
-
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
event_type: "agentic_detection",
|
|
1980
|
-
data: agenticResult,
|
|
1981
|
-
url: window.location.href
|
|
2207
|
+
event_type: "time_spent",
|
|
2208
|
+
event_data: {
|
|
2209
|
+
visible_time_ms: timeEvent.total_time_ms,
|
|
2210
|
+
page_start_time: pageStartTime || Date.now()
|
|
2211
|
+
},
|
|
2212
|
+
page_url: window.location.href,
|
|
2213
|
+
page_path: window.location.pathname,
|
|
2214
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2215
|
+
tracker_version: VERSION,
|
|
2216
|
+
idempotency_key: buildIdempotencyKey("time_spent")
|
|
1982
2217
|
});
|
|
1983
2218
|
}
|
|
1984
2219
|
eventQueue?.flushBeacon();
|
|
@@ -2001,30 +2236,55 @@ function pageview(customUrl) {
|
|
|
2001
2236
|
log("Not initialized, call init() first");
|
|
2002
2237
|
return;
|
|
2003
2238
|
}
|
|
2239
|
+
if (!config.apiKey) {
|
|
2240
|
+
log("Missing apiKey, pageview skipped");
|
|
2241
|
+
return;
|
|
2242
|
+
}
|
|
2004
2243
|
const url = customUrl || window.location.href;
|
|
2244
|
+
const utmParams = extractUTMParams(url);
|
|
2245
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
2246
|
+
const idempotencyKey = buildIdempotencyKey("visit");
|
|
2247
|
+
const agenticResult = agenticAnalyzer?.getResult();
|
|
2248
|
+
const pagePath = (() => {
|
|
2249
|
+
try {
|
|
2250
|
+
return new URL(url).pathname;
|
|
2251
|
+
} catch {
|
|
2252
|
+
return window.location.pathname;
|
|
2253
|
+
}
|
|
2254
|
+
})();
|
|
2005
2255
|
const payload = {
|
|
2006
2256
|
visitor_id: visitorId,
|
|
2007
2257
|
session_id: sessionId,
|
|
2008
|
-
url,
|
|
2258
|
+
page_url: url,
|
|
2259
|
+
page_path: pagePath,
|
|
2009
2260
|
referrer: document.referrer || null,
|
|
2010
2261
|
title: document.title || null,
|
|
2011
|
-
utm_source:
|
|
2012
|
-
utm_medium:
|
|
2013
|
-
utm_campaign:
|
|
2262
|
+
utm_source: utmParams.utm_source || null,
|
|
2263
|
+
utm_medium: utmParams.utm_medium || null,
|
|
2264
|
+
utm_campaign: utmParams.utm_campaign || null,
|
|
2265
|
+
utm_term: utmParams.utm_term || null,
|
|
2266
|
+
utm_content: utmParams.utm_content || null,
|
|
2014
2267
|
user_agent: navigator.userAgent,
|
|
2015
2268
|
screen_width: window.screen?.width,
|
|
2016
2269
|
screen_height: window.screen?.height,
|
|
2017
2270
|
language: navigator.language,
|
|
2018
2271
|
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
|
|
2019
2272
|
tracker_version: VERSION,
|
|
2273
|
+
event_type: "pageview",
|
|
2274
|
+
event_data: null,
|
|
2275
|
+
timestamp,
|
|
2020
2276
|
navigation_timing: navigationTiming,
|
|
2021
2277
|
ai_platform: aiDetection?.platform || null,
|
|
2022
|
-
is_ai_referrer: aiDetection?.isAI || false
|
|
2278
|
+
is_ai_referrer: aiDetection?.isAI || false,
|
|
2279
|
+
agentic_detection: agenticResult || null
|
|
2023
2280
|
};
|
|
2281
|
+
if (workspaceId) {
|
|
2282
|
+
payload.workspace_id = workspaceId;
|
|
2283
|
+
}
|
|
2024
2284
|
log("Pageview:", payload);
|
|
2025
|
-
safeFetch(endpoint(
|
|
2285
|
+
safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.visit), {
|
|
2026
2286
|
method: "POST",
|
|
2027
|
-
headers:
|
|
2287
|
+
headers: buildHeaders(idempotencyKey),
|
|
2028
2288
|
body: JSON.stringify(payload)
|
|
2029
2289
|
});
|
|
2030
2290
|
}
|
|
@@ -2033,6 +2293,11 @@ function track(eventName, options = {}) {
|
|
|
2033
2293
|
log("Not initialized, call init() first");
|
|
2034
2294
|
return;
|
|
2035
2295
|
}
|
|
2296
|
+
if (!config.apiKey) {
|
|
2297
|
+
log("Missing apiKey, event skipped:", eventName);
|
|
2298
|
+
return;
|
|
2299
|
+
}
|
|
2300
|
+
const idempotencyKey = buildIdempotencyKey(`event:${eventName}`);
|
|
2036
2301
|
const payload = {
|
|
2037
2302
|
visitor_id: visitorId,
|
|
2038
2303
|
session_id: sessionId,
|
|
@@ -2041,14 +2306,19 @@ function track(eventName, options = {}) {
|
|
|
2041
2306
|
properties: options.properties || {},
|
|
2042
2307
|
revenue: options.revenue,
|
|
2043
2308
|
currency: options.currency || "USD",
|
|
2044
|
-
|
|
2309
|
+
page_url: window.location.href,
|
|
2310
|
+
referrer: document.referrer || null,
|
|
2045
2311
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2046
|
-
tracker_version: VERSION
|
|
2312
|
+
tracker_version: VERSION,
|
|
2313
|
+
idempotency_key: idempotencyKey
|
|
2047
2314
|
};
|
|
2315
|
+
if (workspaceId) {
|
|
2316
|
+
payload.workspace_id = workspaceId;
|
|
2317
|
+
}
|
|
2048
2318
|
log("Event:", eventName, payload);
|
|
2049
2319
|
safeFetch(endpoint("/api/ingest/event"), {
|
|
2050
2320
|
method: "POST",
|
|
2051
|
-
headers:
|
|
2321
|
+
headers: buildHeaders(idempotencyKey),
|
|
2052
2322
|
body: JSON.stringify(payload)
|
|
2053
2323
|
});
|
|
2054
2324
|
}
|
|
@@ -2060,17 +2330,26 @@ function identify(userId, traits = {}) {
|
|
|
2060
2330
|
log("Not initialized, call init() first");
|
|
2061
2331
|
return;
|
|
2062
2332
|
}
|
|
2333
|
+
if (!config.apiKey) {
|
|
2334
|
+
log("Missing apiKey, identify skipped");
|
|
2335
|
+
return;
|
|
2336
|
+
}
|
|
2063
2337
|
log("Identify:", userId, traits);
|
|
2338
|
+
const idempotencyKey = buildIdempotencyKey("identify");
|
|
2064
2339
|
const payload = {
|
|
2065
2340
|
visitor_id: visitorId,
|
|
2066
2341
|
session_id: sessionId,
|
|
2067
2342
|
user_id: userId,
|
|
2068
2343
|
traits,
|
|
2069
|
-
timestamp: (/* @__PURE__ */ new Date()).toISOString()
|
|
2344
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2345
|
+
idempotency_key: idempotencyKey
|
|
2070
2346
|
};
|
|
2347
|
+
if (workspaceId) {
|
|
2348
|
+
payload.workspace_id = workspaceId;
|
|
2349
|
+
}
|
|
2071
2350
|
safeFetch(endpoint("/api/ingest/identify"), {
|
|
2072
2351
|
method: "POST",
|
|
2073
|
-
headers:
|
|
2352
|
+
headers: buildHeaders(idempotencyKey),
|
|
2074
2353
|
body: JSON.stringify(payload)
|
|
2075
2354
|
});
|
|
2076
2355
|
}
|
|
@@ -2201,14 +2480,15 @@ function isTrackerInitialized() {
|
|
|
2201
2480
|
return initialized;
|
|
2202
2481
|
}
|
|
2203
2482
|
function reportHealth(status, errorMessage) {
|
|
2204
|
-
if (!config.apiKey) return;
|
|
2205
2483
|
try {
|
|
2206
2484
|
const healthData = {
|
|
2207
|
-
workspace_id:
|
|
2485
|
+
workspace_id: workspaceId,
|
|
2486
|
+
visitor_id: visitorId,
|
|
2487
|
+
session_id: sessionId,
|
|
2208
2488
|
status,
|
|
2209
2489
|
error_message: errorMessage || null,
|
|
2210
|
-
|
|
2211
|
-
|
|
2490
|
+
tracker_version: VERSION,
|
|
2491
|
+
page_url: typeof window !== "undefined" ? window.location.href : null,
|
|
2212
2492
|
user_agent: typeof navigator !== "undefined" ? navigator.userAgent : null,
|
|
2213
2493
|
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2214
2494
|
features: {
|
|
@@ -2223,7 +2503,7 @@ function reportHealth(status, errorMessage) {
|
|
|
2223
2503
|
event_queue: !!eventQueue
|
|
2224
2504
|
}
|
|
2225
2505
|
};
|
|
2226
|
-
safeFetch(endpoint(
|
|
2506
|
+
safeFetch(endpoint(DEFAULT_CONFIG3.endpoints.health), {
|
|
2227
2507
|
method: "POST",
|
|
2228
2508
|
headers: { "Content-Type": "application/json" },
|
|
2229
2509
|
body: JSON.stringify(healthData)
|
|
@@ -2267,14 +2547,28 @@ function setDebug(enabled) {
|
|
|
2267
2547
|
debugMode = enabled;
|
|
2268
2548
|
log("Debug mode:", enabled ? "enabled" : "disabled");
|
|
2269
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
|
+
}
|
|
2270
2560
|
var loamly = {
|
|
2271
2561
|
init,
|
|
2272
2562
|
pageview,
|
|
2273
2563
|
track,
|
|
2564
|
+
trackBehavioral,
|
|
2565
|
+
// NEW: For secondary modules/plugins
|
|
2274
2566
|
conversion,
|
|
2275
2567
|
identify,
|
|
2276
2568
|
getSessionId: getCurrentSessionId,
|
|
2277
2569
|
getVisitorId: getCurrentVisitorId,
|
|
2570
|
+
getWorkspaceId: getCurrentWorkspaceId,
|
|
2571
|
+
// NEW: For debugging/introspection
|
|
2278
2572
|
getAIDetection: getAIDetectionResult,
|
|
2279
2573
|
getNavigationTiming: getNavigationTimingResult,
|
|
2280
2574
|
getBehavioralML: getBehavioralMLResult,
|
|
@@ -2326,7 +2620,7 @@ export {
|
|
|
2326
2620
|
* See what AI tells your customers — and track when they click.
|
|
2327
2621
|
*
|
|
2328
2622
|
* @module @loamly/tracker
|
|
2329
|
-
* @version 1.
|
|
2623
|
+
* @version 2.1.0
|
|
2330
2624
|
* @license MIT
|
|
2331
2625
|
* @see https://github.com/loamly/loamly
|
|
2332
2626
|
* @see https://loamly.ai
|