@loamly/tracker 1.6.0 → 1.7.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.
@@ -26,7 +26,7 @@ var Loamly = (() => {
26
26
  });
27
27
 
28
28
  // src/config.ts
29
- var VERSION = "1.6.0";
29
+ var VERSION = "1.7.0";
30
30
  var DEFAULT_CONFIG = {
31
31
  apiHost: "https://app.loamly.ai",
32
32
  endpoints: {
@@ -191,6 +191,322 @@ var Loamly = (() => {
191
191
  }
192
192
  }
193
193
 
194
+ // src/detection/behavioral-classifier.ts
195
+ var NAIVE_BAYES_WEIGHTS = {
196
+ human: {
197
+ time_to_first_click_delayed: 0.85,
198
+ time_to_first_click_normal: 0.75,
199
+ time_to_first_click_fast: 0.5,
200
+ time_to_first_click_immediate: 0.25,
201
+ scroll_speed_variable: 0.8,
202
+ scroll_speed_erratic: 0.7,
203
+ scroll_speed_uniform: 0.35,
204
+ scroll_speed_none: 0.45,
205
+ nav_timing_click: 0.75,
206
+ nav_timing_unknown: 0.55,
207
+ nav_timing_paste: 0.35,
208
+ has_referrer: 0.7,
209
+ no_referrer: 0.45,
210
+ homepage_landing: 0.65,
211
+ deep_landing: 0.5,
212
+ mouse_movement_curved: 0.9,
213
+ mouse_movement_linear: 0.3,
214
+ mouse_movement_none: 0.4,
215
+ form_fill_normal: 0.85,
216
+ form_fill_fast: 0.6,
217
+ form_fill_instant: 0.2,
218
+ focus_blur_normal: 0.75,
219
+ focus_blur_rapid: 0.45
220
+ },
221
+ ai_influenced: {
222
+ time_to_first_click_immediate: 0.75,
223
+ time_to_first_click_fast: 0.55,
224
+ time_to_first_click_normal: 0.4,
225
+ time_to_first_click_delayed: 0.35,
226
+ scroll_speed_none: 0.55,
227
+ scroll_speed_uniform: 0.7,
228
+ scroll_speed_variable: 0.35,
229
+ scroll_speed_erratic: 0.4,
230
+ nav_timing_paste: 0.75,
231
+ nav_timing_unknown: 0.5,
232
+ nav_timing_click: 0.35,
233
+ no_referrer: 0.65,
234
+ has_referrer: 0.4,
235
+ deep_landing: 0.6,
236
+ homepage_landing: 0.45,
237
+ mouse_movement_none: 0.6,
238
+ mouse_movement_linear: 0.75,
239
+ mouse_movement_curved: 0.25,
240
+ form_fill_instant: 0.8,
241
+ form_fill_fast: 0.55,
242
+ form_fill_normal: 0.3,
243
+ focus_blur_rapid: 0.6,
244
+ focus_blur_normal: 0.4
245
+ }
246
+ };
247
+ var PRIORS = {
248
+ human: 0.85,
249
+ ai_influenced: 0.15
250
+ };
251
+ var DEFAULT_WEIGHT = 0.5;
252
+ var BehavioralClassifier = class {
253
+ /**
254
+ * Create a new classifier
255
+ * @param minSessionTimeMs Minimum session time before classification (default: 10s)
256
+ */
257
+ constructor(minSessionTimeMs = 1e4) {
258
+ this.classified = false;
259
+ this.result = null;
260
+ this.onClassify = null;
261
+ this.minSessionTime = minSessionTimeMs;
262
+ this.data = {
263
+ firstClickTime: null,
264
+ scrollEvents: [],
265
+ mouseEvents: [],
266
+ formEvents: [],
267
+ focusBlurEvents: [],
268
+ startTime: Date.now()
269
+ };
270
+ }
271
+ /**
272
+ * Set callback for when classification completes
273
+ */
274
+ setOnClassify(callback) {
275
+ this.onClassify = callback;
276
+ }
277
+ /**
278
+ * Record a click event
279
+ */
280
+ recordClick() {
281
+ if (this.data.firstClickTime === null) {
282
+ this.data.firstClickTime = Date.now();
283
+ }
284
+ this.checkAndClassify();
285
+ }
286
+ /**
287
+ * Record a scroll event
288
+ */
289
+ recordScroll(position) {
290
+ this.data.scrollEvents.push({ time: Date.now(), position });
291
+ if (this.data.scrollEvents.length > 50) {
292
+ this.data.scrollEvents = this.data.scrollEvents.slice(-50);
293
+ }
294
+ this.checkAndClassify();
295
+ }
296
+ /**
297
+ * Record mouse movement
298
+ */
299
+ recordMouse(x, y) {
300
+ this.data.mouseEvents.push({ time: Date.now(), x, y });
301
+ if (this.data.mouseEvents.length > 100) {
302
+ this.data.mouseEvents = this.data.mouseEvents.slice(-100);
303
+ }
304
+ this.checkAndClassify();
305
+ }
306
+ /**
307
+ * Record form field interaction start
308
+ */
309
+ recordFormStart(fieldId) {
310
+ const existing = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
311
+ if (!existing) {
312
+ this.data.formEvents.push({ fieldId, startTime: Date.now(), endTime: 0 });
313
+ }
314
+ }
315
+ /**
316
+ * Record form field interaction end
317
+ */
318
+ recordFormEnd(fieldId) {
319
+ const event = this.data.formEvents.find((e) => e.fieldId === fieldId && e.endTime === 0);
320
+ if (event) {
321
+ event.endTime = Date.now();
322
+ }
323
+ this.checkAndClassify();
324
+ }
325
+ /**
326
+ * Record focus/blur event
327
+ */
328
+ recordFocusBlur(type) {
329
+ this.data.focusBlurEvents.push({ type, time: Date.now() });
330
+ if (this.data.focusBlurEvents.length > 20) {
331
+ this.data.focusBlurEvents = this.data.focusBlurEvents.slice(-20);
332
+ }
333
+ }
334
+ /**
335
+ * Check if we have enough data and classify
336
+ */
337
+ checkAndClassify() {
338
+ if (this.classified) return;
339
+ const sessionDuration = Date.now() - this.data.startTime;
340
+ if (sessionDuration < this.minSessionTime) return;
341
+ const hasData = this.data.scrollEvents.length >= 2 || this.data.mouseEvents.length >= 5 || this.data.firstClickTime !== null;
342
+ if (!hasData) return;
343
+ this.classify();
344
+ }
345
+ /**
346
+ * Force classification (for beforeunload)
347
+ */
348
+ forceClassify() {
349
+ if (this.classified) return this.result;
350
+ return this.classify();
351
+ }
352
+ /**
353
+ * Perform classification
354
+ */
355
+ classify() {
356
+ const sessionDuration = Date.now() - this.data.startTime;
357
+ const signals = this.extractSignals();
358
+ let humanLogProb = Math.log(PRIORS.human);
359
+ let aiLogProb = Math.log(PRIORS.ai_influenced);
360
+ for (const signal of signals) {
361
+ const humanWeight = NAIVE_BAYES_WEIGHTS.human[signal] ?? DEFAULT_WEIGHT;
362
+ const aiWeight = NAIVE_BAYES_WEIGHTS.ai_influenced[signal] ?? DEFAULT_WEIGHT;
363
+ humanLogProb += Math.log(humanWeight);
364
+ aiLogProb += Math.log(aiWeight);
365
+ }
366
+ const maxLog = Math.max(humanLogProb, aiLogProb);
367
+ const humanExp = Math.exp(humanLogProb - maxLog);
368
+ const aiExp = Math.exp(aiLogProb - maxLog);
369
+ const total = humanExp + aiExp;
370
+ const humanProbability = humanExp / total;
371
+ const aiProbability = aiExp / total;
372
+ let classification;
373
+ let confidence;
374
+ if (humanProbability > 0.6) {
375
+ classification = "human";
376
+ confidence = humanProbability;
377
+ } else if (aiProbability > 0.6) {
378
+ classification = "ai_influenced";
379
+ confidence = aiProbability;
380
+ } else {
381
+ classification = "uncertain";
382
+ confidence = Math.max(humanProbability, aiProbability);
383
+ }
384
+ this.result = {
385
+ classification,
386
+ humanProbability,
387
+ aiProbability,
388
+ confidence,
389
+ signals,
390
+ timestamp: Date.now(),
391
+ sessionDurationMs: sessionDuration
392
+ };
393
+ this.classified = true;
394
+ if (this.onClassify) {
395
+ this.onClassify(this.result);
396
+ }
397
+ return this.result;
398
+ }
399
+ /**
400
+ * Extract behavioral signals from collected data
401
+ */
402
+ extractSignals() {
403
+ const signals = [];
404
+ if (this.data.firstClickTime !== null) {
405
+ const timeToClick = this.data.firstClickTime - this.data.startTime;
406
+ if (timeToClick < 500) {
407
+ signals.push("time_to_first_click_immediate");
408
+ } else if (timeToClick < 2e3) {
409
+ signals.push("time_to_first_click_fast");
410
+ } else if (timeToClick < 1e4) {
411
+ signals.push("time_to_first_click_normal");
412
+ } else {
413
+ signals.push("time_to_first_click_delayed");
414
+ }
415
+ }
416
+ if (this.data.scrollEvents.length === 0) {
417
+ signals.push("scroll_speed_none");
418
+ } else if (this.data.scrollEvents.length >= 3) {
419
+ const scrollDeltas = [];
420
+ for (let i = 1; i < this.data.scrollEvents.length; i++) {
421
+ const delta = this.data.scrollEvents[i].time - this.data.scrollEvents[i - 1].time;
422
+ scrollDeltas.push(delta);
423
+ }
424
+ const mean = scrollDeltas.reduce((a, b) => a + b, 0) / scrollDeltas.length;
425
+ const variance = scrollDeltas.reduce((sum, d) => sum + Math.pow(d - mean, 2), 0) / scrollDeltas.length;
426
+ const stdDev = Math.sqrt(variance);
427
+ const cv = mean > 0 ? stdDev / mean : 0;
428
+ if (cv < 0.2) {
429
+ signals.push("scroll_speed_uniform");
430
+ } else if (cv < 0.6) {
431
+ signals.push("scroll_speed_variable");
432
+ } else {
433
+ signals.push("scroll_speed_erratic");
434
+ }
435
+ }
436
+ if (this.data.mouseEvents.length === 0) {
437
+ signals.push("mouse_movement_none");
438
+ } else if (this.data.mouseEvents.length >= 10) {
439
+ const n = Math.min(this.data.mouseEvents.length, 20);
440
+ const recentMouse = this.data.mouseEvents.slice(-n);
441
+ let sumX = 0, sumY = 0, sumXY = 0, sumX2 = 0;
442
+ for (const event of recentMouse) {
443
+ sumX += event.x;
444
+ sumY += event.y;
445
+ sumXY += event.x * event.y;
446
+ sumX2 += event.x * event.x;
447
+ }
448
+ const denominator = n * sumX2 - sumX * sumX;
449
+ const slope = denominator !== 0 ? (n * sumXY - sumX * sumY) / denominator : 0;
450
+ const intercept = (sumY - slope * sumX) / n;
451
+ let ssRes = 0, ssTot = 0;
452
+ const yMean = sumY / n;
453
+ for (const event of recentMouse) {
454
+ const yPred = slope * event.x + intercept;
455
+ ssRes += Math.pow(event.y - yPred, 2);
456
+ ssTot += Math.pow(event.y - yMean, 2);
457
+ }
458
+ const r2 = ssTot !== 0 ? 1 - ssRes / ssTot : 0;
459
+ if (r2 > 0.95) {
460
+ signals.push("mouse_movement_linear");
461
+ } else {
462
+ signals.push("mouse_movement_curved");
463
+ }
464
+ }
465
+ const completedForms = this.data.formEvents.filter((e) => e.endTime > 0);
466
+ if (completedForms.length > 0) {
467
+ const avgFillTime = completedForms.reduce((sum, e) => sum + (e.endTime - e.startTime), 0) / completedForms.length;
468
+ if (avgFillTime < 100) {
469
+ signals.push("form_fill_instant");
470
+ } else if (avgFillTime < 500) {
471
+ signals.push("form_fill_fast");
472
+ } else {
473
+ signals.push("form_fill_normal");
474
+ }
475
+ }
476
+ if (this.data.focusBlurEvents.length >= 4) {
477
+ const recentFB = this.data.focusBlurEvents.slice(-10);
478
+ const intervals = [];
479
+ for (let i = 1; i < recentFB.length; i++) {
480
+ intervals.push(recentFB[i].time - recentFB[i - 1].time);
481
+ }
482
+ const avgInterval = intervals.reduce((a, b) => a + b, 0) / intervals.length;
483
+ if (avgInterval < 1e3) {
484
+ signals.push("focus_blur_rapid");
485
+ } else {
486
+ signals.push("focus_blur_normal");
487
+ }
488
+ }
489
+ return signals;
490
+ }
491
+ /**
492
+ * Add context signals (set by tracker from external data)
493
+ */
494
+ addContextSignal(_signal) {
495
+ }
496
+ /**
497
+ * Get current result (null if not yet classified)
498
+ */
499
+ getResult() {
500
+ return this.result;
501
+ }
502
+ /**
503
+ * Check if classification has been performed
504
+ */
505
+ hasClassified() {
506
+ return this.classified;
507
+ }
508
+ };
509
+
194
510
  // src/utils.ts
195
511
  function generateUUID() {
196
512
  if (typeof crypto !== "undefined" && crypto.randomUUID) {
@@ -276,6 +592,8 @@ var Loamly = (() => {
276
592
  var sessionStartTime = null;
277
593
  var navigationTiming = null;
278
594
  var aiDetection = null;
595
+ var behavioralClassifier = null;
596
+ var behavioralMLResult = null;
279
597
  function log(...args) {
280
598
  if (debugMode) {
281
599
  console.log("[Loamly]", ...args);
@@ -315,6 +633,9 @@ var Loamly = (() => {
315
633
  if (!userConfig.disableBehavioral) {
316
634
  setupBehavioralTracking();
317
635
  }
636
+ behavioralClassifier = new BehavioralClassifier(1e4);
637
+ behavioralClassifier.setOnClassify(handleBehavioralClassification);
638
+ setupBehavioralMLTracking();
318
639
  log("Initialization complete");
319
640
  }
320
641
  function pageview(customUrl) {
@@ -496,6 +817,89 @@ var Loamly = (() => {
496
817
  body: JSON.stringify(payload)
497
818
  });
498
819
  }
820
+ function setupBehavioralMLTracking() {
821
+ if (!behavioralClassifier) return;
822
+ let mouseSampleCount = 0;
823
+ document.addEventListener("mousemove", (e) => {
824
+ mouseSampleCount++;
825
+ if (mouseSampleCount % 10 === 0 && behavioralClassifier) {
826
+ behavioralClassifier.recordMouse(e.clientX, e.clientY);
827
+ }
828
+ }, { passive: true });
829
+ document.addEventListener("click", () => {
830
+ if (behavioralClassifier) {
831
+ behavioralClassifier.recordClick();
832
+ }
833
+ }, { passive: true });
834
+ let lastScrollY = 0;
835
+ document.addEventListener("scroll", () => {
836
+ const currentY = window.scrollY;
837
+ if (Math.abs(currentY - lastScrollY) > 50 && behavioralClassifier) {
838
+ lastScrollY = currentY;
839
+ behavioralClassifier.recordScroll(currentY);
840
+ }
841
+ }, { passive: true });
842
+ document.addEventListener("focusin", (e) => {
843
+ if (behavioralClassifier) {
844
+ behavioralClassifier.recordFocusBlur("focus");
845
+ const target = e.target;
846
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
847
+ behavioralClassifier.recordFormStart(target.id || target.getAttribute("name") || "unknown");
848
+ }
849
+ }
850
+ }, { passive: true });
851
+ document.addEventListener("focusout", (e) => {
852
+ if (behavioralClassifier) {
853
+ behavioralClassifier.recordFocusBlur("blur");
854
+ const target = e.target;
855
+ if (target.tagName === "INPUT" || target.tagName === "TEXTAREA") {
856
+ behavioralClassifier.recordFormEnd(target.id || target.getAttribute("name") || "unknown");
857
+ }
858
+ }
859
+ }, { passive: true });
860
+ window.addEventListener("beforeunload", () => {
861
+ if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
862
+ const result = behavioralClassifier.forceClassify();
863
+ if (result) {
864
+ handleBehavioralClassification(result);
865
+ }
866
+ }
867
+ });
868
+ setTimeout(() => {
869
+ if (behavioralClassifier && !behavioralClassifier.hasClassified()) {
870
+ behavioralClassifier.forceClassify();
871
+ }
872
+ }, 3e4);
873
+ }
874
+ function handleBehavioralClassification(result) {
875
+ log("Behavioral ML classification:", result);
876
+ behavioralMLResult = {
877
+ classification: result.classification,
878
+ humanProbability: result.humanProbability,
879
+ aiProbability: result.aiProbability,
880
+ confidence: result.confidence,
881
+ signals: result.signals,
882
+ sessionDurationMs: result.sessionDurationMs
883
+ };
884
+ sendBehavioralEvent("ml_classification", {
885
+ classification: result.classification,
886
+ human_probability: result.humanProbability,
887
+ ai_probability: result.aiProbability,
888
+ confidence: result.confidence,
889
+ signals: result.signals,
890
+ session_duration_ms: result.sessionDurationMs,
891
+ navigation_timing: navigationTiming,
892
+ ai_detection: aiDetection
893
+ });
894
+ if (result.classification === "ai_influenced" && result.confidence >= 0.7) {
895
+ aiDetection = {
896
+ isAI: true,
897
+ confidence: result.confidence,
898
+ method: "behavioral"
899
+ };
900
+ log("AI detection updated from behavioral ML:", aiDetection);
901
+ }
902
+ }
499
903
  function getCurrentSessionId() {
500
904
  return sessionId;
501
905
  }
@@ -508,6 +912,9 @@ var Loamly = (() => {
508
912
  function getNavigationTimingResult() {
509
913
  return navigationTiming;
510
914
  }
915
+ function getBehavioralMLResult() {
916
+ return behavioralMLResult;
917
+ }
511
918
  function isTrackerInitialized() {
512
919
  return initialized;
513
920
  }
@@ -519,6 +926,8 @@ var Loamly = (() => {
519
926
  sessionStartTime = null;
520
927
  navigationTiming = null;
521
928
  aiDetection = null;
929
+ behavioralClassifier = null;
930
+ behavioralMLResult = null;
522
931
  try {
523
932
  sessionStorage.removeItem("loamly_session");
524
933
  sessionStorage.removeItem("loamly_start");
@@ -539,6 +948,7 @@ var Loamly = (() => {
539
948
  getVisitorId: getCurrentVisitorId,
540
949
  getAIDetection: getAIDetectionResult,
541
950
  getNavigationTiming: getNavigationTimingResult,
951
+ getBehavioralML: getBehavioralMLResult,
542
952
  isInitialized: isTrackerInitialized,
543
953
  reset,
544
954
  debug: setDebug