PageSourceSearch

https://psmmag.com/erisim-analytics.js

js psmmag.com collected 2026-10-02 21:26:37 UTC 23,889 bytes, 904 lines download raw bytes

1(function () {
2  // ---- SINGLETON GUARD (dev'de script 2 kez yüklenmesin) ----
3  if (window.__EA_INSTALLED__) {
4    console.warn("[EA] already installed, skipping duplicate load");
5    return;
6  }
7  window.__EA_INSTALLED__ = true;
8
9  const BACKOFFS = [1000, 2000, 5000, 10000, 30000];
10  const DEFAULTS = {
11    wsUrl: "ws://localhost:8080/ws/ingest",
12    tokenEndpoint: "/api/analyz/token",
13    batchMax: 20,
14    batchInterval: 250,
15    queueMax: 1500,
16    heartbeatInterval: 12000,
17    hoverThrottleMs: 2500,
18    sessionTimeoutMs: 30 * 60 * 1000,
19    sourceKeyInQuery: true,
20  };
21
22  const STATE = {
23    ws: null,
24    cfg: null,
25    ready: false,
26    connecting: false,
27    reconnectTimer: null,
28    reconnectAttempt: 0,
29    queue: [],
30    flushTimer: null,
31    pingTimer: null,
32    heartbeatTimer: null,
33    lastHeartbeatAt: null,
34    baseContext: null,
35    hoverLast: new WeakMap(),
36    adState: new WeakMap(),
37    adObserver: null,
38    mutationObserver: null,
39    adImpressionKeys: new Set(),
40    adViewableKeys: new Set(),
41    maxScrollDepth: 0,
42    scrollTicking: false,
43    pendingPageMeta: null,
44  };
45
46  let inMemoryVisitorId = null;
47  let inMemorySessionId = null;
48
49  // ---- UUID v4 (backend uuid.Parse bekliyor) ----
50  function uuidv4() {
51    if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
52
53    const buf = new Uint8Array(16);
54    crypto.getRandomValues(buf);
55    buf[6] = (buf[6] & 0x0f) | 0x40;
56    buf[8] = (buf[8] & 0x3f) | 0x80;
57
58    const hex = Array.from(buf)
59      .map((b) => b.toString(16).padStart(2, "0"))
60      .join("");
61
62    return (
63      hex.slice(0, 8) +
64      "-" +
65      hex.slice(8, 12) +
66      "-" +
67      hex.slice(12, 16) +
68      "-" +
69      hex.slice(16, 20) +
70      "-" +
71      hex.slice(20)
72    );
73  }
74
75  function nowIso() {
76    return new Date().toISOString();
77  }
78
79  function isObject(val) {
80    return !!val && typeof val === "object" && !Array.isArray(val);
81  }
82
83  function mergeDeep(target, source) {
84    const out = Object.assign({}, target || {});
85    Object.keys(source || {}).forEach((key) => {
86      const srcVal = source[key];
87      const dstVal = out[key];
88      if (isObject(srcVal) && isObject(dstVal)) {
89        out[key] = mergeDeep(dstVal, srcVal);
90      } else {
91        out[key] = srcVal;
92      }
93    });
94    return out;
95  }
96
97  function safeGet(storage, key) {
98    try {
99      return storage.getItem(key);
100    } catch (_) {
101      return null;
102    }
103  }
104
105  function safeSet(storage, key, val) {
106    try {
107      storage.setItem(key, val);
108    } catch (_) {
109      // ignore
110    }
111  }
112
113  function getVisitorId() {
114    const key = "analyz_vid";
115    let v = null;
116
117    if (typeof localStorage !== "undefined") {
118      v = safeGet(localStorage, key);
119      if (!v) {
120        v = uuidv4();
121        safeSet(localStorage, key, v);
122      }
123      return v;
124    }
125
126    if (!inMemoryVisitorId) inMemoryVisitorId = uuidv4();
127    return inMemoryVisitorId;
128  }
129
130  function getSessionId() {
131    const sidKey = "analyz_sid";
132    const lastKey = "analyz_sid_last";
133    const now = Date.now();
134    const timeoutMs =
135      (STATE.cfg && STATE.cfg.sessionTimeoutMs) || DEFAULTS.sessionTimeoutMs;
136
137    if (typeof sessionStorage !== "undefined") {
138      let sid = safeGet(sessionStorage, sidKey);
139      const lastRaw = safeGet(sessionStorage, lastKey);
140      const last = lastRaw ? Number(lastRaw) : 0;
141
142      if (!sid || !last || now - last > timeoutMs) {
143        sid = uuidv4();
144        safeSet(sessionStorage, sidKey, sid);
145      }
146
147      safeSet(sessionStorage, lastKey, String(now));
148      return sid;
149    }
150
151    if (!inMemorySessionId) inMemorySessionId = uuidv4();
152    return inMemorySessionId;
153  }
154
155  function touchSession() {
156    if (typeof sessionStorage === "undefined") return;
157    safeSet(sessionStorage, "analyz_sid_last", String(Date.now()));
158  }
159
160  function getCanonicalUrl() {
161    const link = document.querySelector('link[rel="canonical"]');
162    if (link && link.href) return link.href;
163    return window.location.href;
164  }
165
166  function getPage() {
167    return {
168      url: getCanonicalUrl(),
169      hostname: window.location.hostname,
170      path: window.location.pathname,
171      title: document.title || "",
172      referrer: document.referrer || "",
173      search: window.location.search || "",
174    };
175  }
176
177  function detectDevice(ua) {
178    if (/iPad|Tablet/i.test(ua)) return "tablet";
179    if (/Mobi|Android/i.test(ua)) return "mobile";
180    return "desktop";
181  }
182
183  function detectOS(ua) {
184    if (/Windows NT/i.test(ua)) return "Windows";
185    if (/Mac OS X/i.test(ua) && !/iPhone|iPad|iPod/i.test(ua)) return "macOS";
186    if (/Android/i.test(ua)) return "Android";
187    if (/iPhone|iPad|iPod/i.test(ua)) return "iOS";
188    if (/Linux/i.test(ua)) return "Linux";
189    return "Other";
190  }
191
192  function detectBrowser(ua) {
193    if (/Edg\//i.test(ua)) return "Edge";
194    if (/Chrome\//i.test(ua) && !/Edg\//i.test(ua)) return "Chrome";
195    if (/Firefox\//i.test(ua)) return "Firefox";
196    if (/Safari\//i.test(ua) && !/Chrome\//i.test(ua)) return "Safari";
197    return "Other";
198  }
199
200  function getBaseContext() {
201    if (STATE.baseContext) return STATE.baseContext;
202
203    const ua = navigator.userAgent || "";
204    STATE.baseContext = {
205      device: detectDevice(ua),
206      os: detectOS(ua),
207      browser: detectBrowser(ua),
208      userAgent: ua,
209      language: navigator.language || "",
210      timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "",
211    };
212
213    return STATE.baseContext;
214  }
215
216  function getContext() {
217    const base = getBaseContext();
218    const screenSize =
219      typeof screen !== "undefined" && screen.width && screen.height
220        ? `${screen.width}x${screen.height}`
221        : "";
222    const viewportSize = `${window.innerWidth || 0}x${window.innerHeight || 0}`;
223
224    return Object.assign({}, base, {
225      screen: screenSize,
226      viewport: viewportSize,
227    });
228  }
229
230  function resolvePageMeta(overrides) {
231    let meta = {};
232
233    if (STATE.cfg && typeof STATE.cfg.pageMeta === "function") {
234      try {
235        const res = STATE.cfg.pageMeta(getPage());
236        if (isObject(res)) meta = mergeDeep(meta, res);
237      } catch (err) {
238        console.warn("[EA] pageMeta function failed", err);
239      }
240    } else if (STATE.cfg && isObject(STATE.cfg.pageMeta)) {
241      meta = mergeDeep(meta, STATE.cfg.pageMeta);
242    }
243
244    if (isObject(overrides)) {
245      meta = mergeDeep(meta, overrides);
246    }
247
248    if (!meta.pageType) meta.pageType = "static";
249    if (!meta.routeKey) meta.routeKey = window.location.pathname || "/";
250
251    return meta;
252  }
253
254  function buildEvent(typeKey, actionKey, meta) {
255    if (!STATE.cfg || !STATE.cfg.sourceKey) return null;
256    const pageMeta = resolvePageMeta();
257    const mergedMeta = isObject(meta) ? mergeDeep(pageMeta, meta) : pageMeta;
258
259    const base = {
260      sourceKey: STATE?.cfg?.sourceKey,
261      secret: STATE?.cfg?.secret,
262      token: STATE?.cfg?.token,
263      visitorId: getVisitorId(),
264      sessionId: getSessionId(),
265      eventId: uuidv4(),
266      timestamp: nowIso(),
267      typeKey,
268      actionKey,
269      page: getPage(),
270      context: getContext(),
271      meta: mergedMeta,
272    };
273
274    touchSession();
275    return base;
276  }
277
278  function sendPayload(payload) {
279    if (!STATE.ws || STATE.ws.readyState !== 1) return false;
280    STATE.ws.send(JSON.stringify(payload));
281    return true;
282  }
283
284  function queueEvent(event) {
285    if (!event) return;
286    if (!STATE.cfg) return;
287
288    STATE.queue.push(event);
289
290    if (STATE.queue.length > STATE.cfg.queueMax) {
291      STATE.queue.shift();
292    }
293
294    scheduleFlush();
295  }
296
297  function scheduleFlush() {
298    if (STATE.flushTimer) return;
299    STATE.flushTimer = setTimeout(flushQueue, STATE.cfg.batchInterval);
300  }
301
302  function flushQueue() {
303    STATE.flushTimer = null;
304
305    if (!STATE.ready) return;
306    if (!STATE.queue.length) return;
307
308    const maxBatch = STATE.cfg.batchMax;
309
310    while (STATE.queue.length) {
311      const batch = STATE.queue.splice(0, maxBatch);
312      const payload =
313        batch.length === 1
314          ? Object.assign({ op: "event" }, batch[0])
315          : { op: "batch", events: batch };
316
317      if (!sendPayload(payload)) {
318        STATE.queue = batch.concat(STATE.queue);
319        break;
320      }
321    }
322
323    if (STATE.queue.length) scheduleFlush();
324  }
325
326  function clearReconnect() {
327    if (STATE.reconnectTimer) {
328      clearTimeout(STATE.reconnectTimer);
329      STATE.reconnectTimer = null;
330    }
331  }
332
333  function scheduleReconnect(reason) {
334    if (reason && reason.code === 1008) {
335      console.warn("[EA] stop reconnect on 1008 (policy violation)", reason);
336      return;
337    }
338
339    if (STATE.reconnectTimer) return;
340
341    STATE.reconnectAttempt = Math.min(
342      STATE.reconnectAttempt + 1,
343      BACKOFFS.length
344    );
345    const delay = BACKOFFS[STATE.reconnectAttempt - 1] || BACKOFFS[BACKOFFS.length - 1];
346
347    STATE.reconnectTimer = setTimeout(() => {
348      STATE.reconnectTimer = null;
349      connect().catch((e) => console.error("[EA] reconnect failed", e));
350    }, delay);
351  }
352
353  async function fetchToken() {
354    if (!STATE.cfg.tokenEndpoint) return null;
355    const res = await fetch(STATE.cfg.tokenEndpoint, { credentials: "include" });
356    const text = await res.text();
357
358    if (!res.ok) {
359      throw new Error("token endpoint failed: " + res.status + " " + text);
360    }
361
362    const data = JSON.parse(text);
363    if (!data.token) throw new Error("token missing in response");
364    return data.token;
365  }
366
367  function buildWsUrl() {
368    let wsUrl = STATE.cfg.wsUrl || DEFAULTS.wsUrl;
369
370    if (window.location.protocol === "https:" && wsUrl.startsWith("ws://")) {
371      wsUrl = wsUrl.replace("ws://", "wss://");
372    }
373
374    if (STATE.cfg.sourceKeyInQuery && STATE.cfg.sourceKey) {
375      const hasQuery = wsUrl.includes("?");
376      const joiner = hasQuery ? "&" : "?";
377      wsUrl = `${wsUrl}${joiner}sourceKey=${encodeURIComponent(STATE.cfg.sourceKey)}`;
378    }
379
380    return wsUrl;
381  }
382
383  function handleMessage(evt) {
384    let msg = null;
385    try {
386      msg = JSON.parse(evt.data);
387    } catch (_) {
388      return;
389    }
390
391    if (!msg || !msg.op) return;
392
393    if (msg.op === "assign") {
394      if (msg.visitorId) {
395        if (typeof localStorage !== "undefined") {
396          safeSet(localStorage, "analyz_vid", msg.visitorId);
397        } else {
398          inMemoryVisitorId = msg.visitorId;
399        }
400      }
401      if (msg.sessionId) {
402        if (typeof sessionStorage !== "undefined") {
403          safeSet(sessionStorage, "analyz_sid", msg.sessionId);
404        } else {
405          inMemorySessionId = msg.sessionId;
406        }
407      }
408      return;
409    }
410
411    if (msg.op === "error") {
412      console.warn("[EA] server error", msg);
413      return;
414    }
415  }
416
417  function startPing() {
418    stopPing();
419    STATE.pingTimer = setInterval(() => {
420      if (!STATE.ready) return;
421      sendPayload({ op: "ping" });
422    }, 30000);
423  }
424
425  function stopPing() {
426    if (STATE.pingTimer) {
427      clearInterval(STATE.pingTimer);
428      STATE.pingTimer = null;
429    }
430  }
431
432  function heartbeatTick(reason) {
433    if (!STATE.ready) return;
434
435    const now = Date.now();
436    const last = STATE.lastHeartbeatAt || now;
437    const delta = Math.max(1, Math.round((now - last) / 1000));
438    STATE.lastHeartbeatAt = now;
439
440    const meta = {
441      deltaSeconds: delta,
442      visible: document.visibilityState === "visible",
443    };
444
445    const depth =
446      STATE.maxScrollDepth > 0 ? STATE.maxScrollDepth : getScrollDepthPercent();
447    if (depth !== null) meta.scrollDepth = depth;
448    if (reason) meta.reason = reason;
449
450    queueEvent(buildEvent("engagement", "duration", meta));
451    STATE.maxScrollDepth = 0;
452  }
453
454  function startHeartbeat() {
455    stopHeartbeat();
456    STATE.lastHeartbeatAt = Date.now();
457    STATE.heartbeatTimer = setInterval(heartbeatTick, STATE.cfg.heartbeatInterval);
458  }
459
460  function stopHeartbeat() {
461    if (STATE.heartbeatTimer) {
462      clearInterval(STATE.heartbeatTimer);
463      STATE.heartbeatTimer = null;
464    }
465  }
466
467  async function connect() {
468    if (!STATE.cfg) {
469      console.warn("[EA] connect called but cfg is empty");
470      return;
471    }
472    if (STATE.connecting) return;
473    if (STATE.ws && (STATE.ws.readyState === 0 || STATE.ws.readyState === 1)) return;
474
475    STATE.connecting = true;
476    STATE.ready = false;
477
478    try {
479      if (!STATE.cfg.secret && STATE.cfg.tokenEndpoint) {
480        STATE.cfg.token = await fetchToken();
481      }
482
483      const wsUrl = buildWsUrl();
484
485      const ws = new WebSocket(wsUrl);
486      STATE.ws = ws;
487
488      ws.onopen = () => {
489        const hello = {
490          op: "hello",
491          sourceKey: STATE?.cfg?.sourceKey,
492          secret: STATE?.cfg?.secret,
493          token: STATE?.cfg?.token,
494          visitorId: getVisitorId(),
495          sessionId: getSessionId(),
496          context: getContext(),
497        };
498
499        sendPayload(hello);
500
501        STATE.ready = true;
502        STATE.connecting = false;
503        STATE.reconnectAttempt = 0;
504        clearReconnect();
505
506        startPing();
507        startHeartbeat();
508        flushQueue();
509      };
510
511      ws.onmessage = handleMessage;
512
513      ws.onerror = (e) => {
514        console.error("[EA] ws error", e);
515      };
516
517      ws.onclose = (e) => {
518        console.warn("[EA] ws closed", {
519          code: e.code,
520          reason: e.reason,
521          wasClean: e.wasClean,
522        });
523
524        STATE.ready = false;
525        STATE.connecting = false;
526        STATE.ws = null;
527        stopPing();
528        stopHeartbeat();
529
530        scheduleReconnect(e);
531      };
532    } catch (err) {
533      console.error("[EA] connect failed", err);
534      STATE.connecting = false;
535      STATE.ready = false;
536      scheduleReconnect();
537    }
538  }
539
540  function getScrollDepthPercent() {
541    const doc = document.documentElement;
542    if (!doc) return null;
543
544    const scrollTop = window.scrollY || doc.scrollTop || 0;
545    const viewport = window.innerHeight || 0;
546    const height = Math.max(doc.scrollHeight || 0, doc.offsetHeight || 0);
547
548    if (!height) return 0;
549
550    const depth = Math.min(1, (scrollTop + viewport) / height);
551    return Math.round(depth * 100);
552  }
553
554  function updateScrollDepth() {
555    const depth = getScrollDepthPercent();
556    if (depth === null) return;
557    STATE.maxScrollDepth = Math.max(STATE.maxScrollDepth, depth);
558  }
559
560  function parseAnalyzMeta(el) {
561    const raw = el.getAttribute("data-analyz-meta");
562    if (!raw) return null;
563
564    try {
565      const parsed = JSON.parse(raw);
566      return isObject(parsed) ? parsed : null;
567    } catch (_) {
568      return null;
569    }
570  }
571
572  function toNumberIfNumeric(val) {
573    if (val === null || val === undefined || val === "") return undefined;
574    if (/^\d+$/.test(String(val))) return Number(val);
575    return val;
576  }
577
578  function getAttr(el, name) {
579    return el.getAttribute(name) || "";
580  }
581
582  function buildElementMeta(el) {
583    const type = getAttr(el, "data-analyz-type");
584    const meta = {};
585
586    if (type === "ad") {
587      meta.ad = {
588        slot: getAttr(el, "data-analyz-slot") || undefined,
589        campaignId: getAttr(el, "data-analyz-campaign") || undefined,
590        creativeId: getAttr(el, "data-analyz-creative") || undefined,
591        advertiser: getAttr(el, "data-analyz-advertiser") || undefined,
592        label: getAttr(el, "data-analyz-label") || undefined,
593        url: getAttr(el, "data-analyz-url") || undefined,
594      };
595    }
596
597    if (type === "content") {
598      meta.content = {
599        id: toNumberIfNumeric(
600          getAttr(el, "data-analyz-content-id") || getAttr(el, "data-analyz-id")
601        ),
602        type: getAttr(el, "data-analyz-content-type") || undefined,
603        category: getAttr(el, "data-analyz-category") || undefined,
604        label: getAttr(el, "data-analyz-label") || undefined,
605        href: getAttr(el, "href") || undefined,
606      };
607    }
608
609    const elementId = getAttr(el, "data-analyz-id") || undefined;
610    const elementLabel = getAttr(el, "data-analyz-label") || undefined;
611    const elementClasses =
612      typeof el.className === "string" && el.className.trim()
613        ? el.className.trim().slice(0, 120)
614        : undefined;
615    const elementText =
616      typeof el.textContent === "string"
617        ? el.textContent.trim().slice(0, 80)
618        : undefined;
619
620    if (type === "ui") {
621      meta.element = {
622        type: "ui",
623        id: elementId,
624        label: elementLabel,
625        classes: elementClasses,
626        text: elementText,
627      };
628    } else if (elementId || elementLabel || elementClasses || elementText) {
629      meta.element = {
630        id: elementId,
631        label: elementLabel,
632        classes: elementClasses,
633        text: elementText,
634      };
635    }
636
637    const extra = parseAnalyzMeta(el);
638    if (extra) {
639      return mergeDeep(meta, extra);
640    }
641
642    return meta;
643  }
644
645  function emitElementEvent(el, actionKey) {
646    const type = getAttr(el, "data-analyz-type");
647    if (!type) return;
648
649    const meta = buildElementMeta(el);
650
651    if (type === "ad") {
652      queueEvent(buildEvent("ad_event", actionKey, meta));
653      return;
654    }
655
656    if (type === "content") {
657      queueEvent(buildEvent("content_event", actionKey, meta));
658      return;
659    }
660
661    if (type === "ui") {
662      queueEvent(buildEvent("ui_interaction", actionKey, meta));
663      return;
664    }
665
666    queueEvent(buildEvent("ui_interaction", actionKey, meta));
667  }
668
669  function resolveAnalyzElement(target) {
670    if (!target || !target.closest) return null;
671    return target.closest('[data-analyz="1"]');
672  }
673
674  function installClickHover() {
675    document.addEventListener(
676      "click",
677      (e) => {
678        const el = resolveAnalyzElement(e.target);
679        if (!el) return;
680        emitElementEvent(el, "click");
681      },
682      true
683    );
684
685    document.addEventListener(
686      "mouseover",
687      (e) => {
688        const el = resolveAnalyzElement(e.target);
689        if (!el) return;
690
691        const now = Date.now();
692        const last = STATE.hoverLast.get(el) || 0;
693        if (now - last < STATE.cfg.hoverThrottleMs) return;
694        STATE.hoverLast.set(el, now);
695
696        emitElementEvent(el, "hover");
697      },
698      true
699    );
700  }
701
702  function adDedupeKey(el) {
703    const slot = getAttr(el, "data-analyz-slot");
704    const creative = getAttr(el, "data-analyz-creative");
705    const campaign = getAttr(el, "data-analyz-campaign");
706    if (!slot && !creative && !campaign) return null;
707    return `${window.location.pathname}|${slot}|${creative}|${campaign}`;
708  }
709
710  function ensureAdState(el) {
711    let state = STATE.adState.get(el);
712    if (!state) {
713      state = {
714        impressionSent: false,
715        viewableSent: false,
716        viewableTimer: null,
717        lastRatio: 0,
718      };
719      STATE.adState.set(el, state);
720    }
721    return state;
722  }
723
724  function sendAdImpression(el) {
725    const key = adDedupeKey(el);
726    if (key && STATE.adImpressionKeys.has(key)) return;
727
728    emitElementEvent(el, "impression");
729
730    if (key) STATE.adImpressionKeys.add(key);
731  }
732
733  function sendAdViewable(el) {
734    const key = adDedupeKey(el);
735    if (key && STATE.adViewableKeys.has(key)) return;
736
737    emitElementEvent(el, "viewable");
738
739    if (key) STATE.adViewableKeys.add(key);
740  }
741
742  function handleAdIntersection(entries) {
743    entries.forEach((entry) => {
744      const el = entry.target;
745      if (!el || getAttr(el, "data-analyz-type") !== "ad") return;
746
747      const state = ensureAdState(el);
748      state.lastRatio = entry.intersectionRatio || 0;
749
750      if (entry.isIntersecting && state.lastRatio > 0 && !state.impressionSent) {
751        state.impressionSent = true;
752        sendAdImpression(el);
753      }
754
755      if (state.viewableSent) return;
756
757      if (state.lastRatio >= 0.5) {
758        if (!state.viewableTimer) {
759          state.viewableTimer = setTimeout(() => {
760            state.viewableTimer = null;
761            if (!state.viewableSent && state.lastRatio >= 0.5) {
762              state.viewableSent = true;
763              sendAdViewable(el);
764            }
765          }, 1000);
766        }
767      } else if (state.viewableTimer) {
768        clearTimeout(state.viewableTimer);
769        state.viewableTimer = null;
770      }
771    });
772  }
773
774  function registerAdElement(el) {
775    if (!el || getAttr(el, "data-analyz-type") !== "ad") return;
776    if (!STATE.adObserver) return;
777    STATE.adObserver.observe(el);
778  }
779
780  function scanForAds(root) {
781    if (!root || !root.querySelectorAll) return;
782    const nodes = root.querySelectorAll('[data-analyz="1"][data-analyz-type="ad"]');
783    nodes.forEach((el) => registerAdElement(el));
784  }
785
786  function installAdObservers() {
787    if (STATE.adObserver) return;
788    if (typeof IntersectionObserver === "undefined") return;
789
790    STATE.adObserver = new IntersectionObserver(handleAdIntersection, {
791      threshold: [0, 0.5],
792    });
793
794    scanForAds(document);
795
796    STATE.mutationObserver = new MutationObserver((mutations) => {
797      mutations.forEach((mutation) => {
798        mutation.addedNodes.forEach((node) => {
799          if (node.nodeType !== 1) return;
800          if (node.matches && node.matches('[data-analyz="1"][data-analyz-type="ad"]')) {
801            registerAdElement(node);
802          }
803          scanForAds(node);
804        });
805      });
806    });
807
808    if (document.body) {
809      STATE.mutationObserver.observe(document.body, {
810        childList: true,
811        subtree: true,
812      });
813    }
814  }
815
816  function installLifecycleHandlers() {
817    document.addEventListener("visibilitychange", () => {
818      if (document.visibilityState === "hidden") {
819        heartbeatTick("hidden");
820        stopHeartbeat();
821      } else {
822        startHeartbeat();
823      }
824    });
825
826    window.addEventListener("beforeunload", () => {
827      heartbeatTick("beforeunload");
828      flushQueue();
829    });
830
831    window.addEventListener("online", () => {
832      connect().catch((e) => console.error("[EA] reconnect on online failed", e));
833    });
834
835    window.addEventListener("scroll", () => {
836      if (STATE.scrollTicking) return;
837      STATE.scrollTicking = true;
838      window.requestAnimationFrame(() => {
839        updateScrollDepth();
840        STATE.scrollTicking = false;
841      });
842    });
843  }
844
845  window.ErisimAnalytics = {
846    init(cfg) {
847      const sourceKey = cfg?.sourceKey || cfg?.siteKey || "";
848      if (!sourceKey) {
849        console.warn("[EA] init missing sourceKey");
850      }
851
852      const wsUrl = cfg.wsUrl || DEFAULTS.wsUrl;
853
854      STATE.cfg = {
855        sourceKey,
856        secret: cfg.secret || undefined,
857        token: cfg.token || undefined,
858        tokenEndpoint:
859          cfg.tokenEndpoint === null ? null : cfg.tokenEndpoint || DEFAULTS.tokenEndpoint,
860        wsUrl,
861        batchMax: cfg.batchMax || DEFAULTS.batchMax,
862        batchInterval: cfg.batchInterval || DEFAULTS.batchInterval,
863        queueMax: cfg.queueMax || DEFAULTS.queueMax,
864        heartbeatInterval: cfg.heartbeatInterval || DEFAULTS.heartbeatInterval,
865        hoverThrottleMs: cfg.hoverThrottleMs || DEFAULTS.hoverThrottleMs,
866        sessionTimeoutMs: cfg.sessionTimeoutMs || DEFAULTS.sessionTimeoutMs,
867        pageMeta: cfg.pageMeta || null,
868        sourceKeyInQuery:
869          typeof cfg.sourceKeyInQuery === "boolean"
870            ? cfg.sourceKeyInQuery
871            : DEFAULTS.sourceKeyInQuery,
872      };
873
874      installClickHover();
875      installAdObservers();
876      installLifecycleHandlers();
877      updateScrollDepth();
878
879      connect().catch((e) => console.error("[EA] connect failed", e));
880
881      if (cfg.autoPageView) {
882        this.page();
883      } else if (STATE.pendingPageMeta) {
884        const pending = STATE.pendingPageMeta;
885        STATE.pendingPageMeta = null;
886        this.page(pending);
887      }
888    },
889
890    page(meta) {
891      if (!STATE.cfg) {
892        STATE.pendingPageMeta = meta || {};
893        return;
894      }
895      const pageMeta = resolvePageMeta(meta);
896      queueEvent(buildEvent("page_view", "view", pageMeta));
897    },
898
899    track(typeKey, actionKey, meta) {
900      if (!typeKey || !actionKey) return;
901      queueEvent(buildEvent(typeKey, actionKey, meta || {}));
902    },
903  };
904})();

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.