PageSourceSearch

https://app.mexum.online/static/js/tracking.js?v=4

js mexum.online collected 2026-10-08 14:36:51 UTC 9,601 bytes, 163 lines download raw bytes

1/**
2 * Свой встроенный счётчик посещений — шлёт реальные заходы в /track,
3 * данные видны в Админ → Посещения. Никаких сторонних сервисов, никакого PII.
4 *
5 * Область охвата: ТОЛЬКО маркетинговые страницы (/, /private, /business).
6 * Личный кабинет (/cabinet/*) не считается — это счётчик привлечения трафика,
7 * а не продуктовая аналитика уже залогиненных клиентов (сервер тоже это
8 * проверяет, см. tracking_routes.py — так что даже если этот файл поменяют
9 * неаккуратно, кабинет всё равно не попадёт в отчёт).
10 *
11 * session_id живёт в sessionStorage (одна вкладка = одна сессия, не переживает
12 * закрытие браузера) — этого достаточно для «уникальных посетителей», но не
13 * позволяет опознать конкретного человека.
14 */
15// Базовый адрес API: пусто — тот же домен; на отдельном сайте (mexum.online)
16// скрипт подключается с data-api="https://app.mexum.online".
17const _TRACK_BASE = (document.currentScript && document.currentScript.getAttribute('data-api')) || '';
18
19const Tracking = {
20    _trackedPathPrefixes: ['/', '/private', '/business'],
21    _pending: null, // { viewId, sessionId, enteredAt } — для расчёта времени на странице
22    _depth: { pct: 0, idx: -1, id: null, bound: false }, // глубина просмотра текущей страницы
23
24    _isTrackedPath(path) {
25        // '/' — только точное совпадение (иначе перехватит вообще все пути,
26        // включая /cabinet). Остальные — по префиксу для вложенных путей типа /private#pricing.
27        if (path === '/') return true;
28        return this._trackedPathPrefixes.slice(1).some(p => path === p || path.startsWith(p + '/') || path.startsWith(p + '#'));
29    },
30
31    _sessionId() {
32        let id = sessionStorage.getItem('mx_sid');
33        if (!id) {
34            id = (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random().toString(36).slice(2)}`);
35            sessionStorage.setItem('mx_sid', id);
36        }
37        return id;
38    },
39
40    /** UTM/клик-иды запоминаются на всю сессию — иначе внутренняя SPA-навигация
41     *  после захода по рекламной ссылке «теряла» бы источник трафика. */
42    _campaignParams() {
43        const params = new URLSearchParams(window.location.search);
44        const fresh = {
45            utm_source: params.get('utm_source'),
46            utm_medium: params.get('utm_medium'),
47            utm_campaign: params.get('utm_campaign'),
48            gclid: params.get('gclid'),
49            yclid: params.get('yclid'),
50        };
51        if (Object.values(fresh).some(Boolean)) {
52            sessionStorage.setItem('mx_campaign', JSON.stringify(fresh));
53            return fresh;
54        }
55        try {
56            return JSON.parse(sessionStorage.getItem('mx_campaign') || '{}');
57        } catch (e) {
58            return {};
59        }
60    },
61
62    /** Глубина просмотра: до какого % высоты и до какого раздела (section[id]) долистал
63     *  посетитель. Уходит вместе со временем на странице; в админке — «Посещения → Глубина». */
64    _depthUpdate() {
65        const de = document.documentElement;
66        const total = Math.max(de.scrollHeight, document.body ? document.body.scrollHeight : 0);
67        const seen = (window.scrollY || de.scrollTop || 0) + window.innerHeight;
68        const pct = total <= window.innerHeight + 4 ? 100 : Math.min(100, Math.round((seen / total) * 100));
69        if (pct > this._depth.pct) this._depth.pct = pct;
70        const secs = document.querySelectorAll('section[id]');
71        for (let i = secs.length - 1; i > this._depth.idx; i--) {
72            if (secs[i].getBoundingClientRect().top < window.innerHeight * 0.8) {
73                this._depth.idx = i;
74                this._depth.id = secs[i].id;
75                break;
76            }
77        }
78    },
79
80    _depthStart() {
81        this._depth.pct = 0; this._depth.idx = -1; this._depth.id = null;
82        if (!this._depth.bound) {
83            this._depth.bound = true;
84            let ticking = false;
85            const on = () => { if (ticking) return; ticking = true; requestAnimationFrame(() => { ticking = false; this._depthUpdate(); }); };
86            window.addEventListener('scroll', on, { passive: true });
87            window.addEventListener('resize', on, { passive: true });
88        }
89        this._depthUpdate();
90    },
91
92    /** Досчитывает и отправляет время, проведённое на ПРЕДЫДУЩЕЙ странице,
93     *  перед тем как перейти к новой (или когда вкладка скрывается/закрывается). */
94    _flushDuration() {
95        if (!this._pending) return;
96        const { viewId, sessionId, enteredAt } = this._pending;
97        this._pending = null;
98        const durationMs = Date.now() - enteredAt;
99        if (durationMs < 500) return; // мгновенный переход — не показатель, шум
100
101        this._depthUpdate();
102        const furthest = this._depth.id ? String(this._depth.idx).padStart(2, '0') + ':' + this._depth.id : null;
103        const payload = JSON.stringify({ view_id: viewId, session_id: sessionId, duration_ms: durationMs, scroll_pct: this._depth.pct, furthest });
104        try {
105            // sendBeacon с application/json на другой домен браузеры не отправляют (нужен preflight) —
106            // там используем fetch с keepalive, он проходит через CORS.
107            if (navigator.sendBeacon && !_TRACK_BASE) {
108                navigator.sendBeacon('/track/duration', new Blob([payload], { type: 'application/json' }));
109            } else {
110                fetch(_TRACK_BASE + '/track/duration', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: payload, keepalive: true }).catch(() => {});
111            }
112        } catch (e) { /* аналитика не должна ничего ломать */ }
113    },
114
115    pageview(path) {
116        const targetPath = path || window.location.pathname;
117
118        // Не считаем собственные визиты админа — иначе цифры в отчёте будут врать
119        if (localStorage.getItem('is_admin') === '1') return;
120        // На тесте/локальной разработке трекать нечего
121        if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') return;
122        // Личный кабинет — вне охвата этого счётчика (см. комментарий вверху файла)
123        if (!this._isTrackedPath(targetPath)) { this._flushDuration(); return; }
124
125        this._flushDuration();
125 // закрываем предыдущую страницу перед открытием новой
126        this._depthStart();
127
128        // referrer браузера валиден только для первого просмотра сессии — иначе
129        // внутренняя SPA-навигация выглядела бы новым переходом с той же ссылки.
130        const referrerSent = sessionStorage.getItem('mx_ref_sent') === '1';
131        const c = this._campaignParams();
132        const sessionId = this._sessionId();
133        const body = JSON.stringify({
134            session_id: sessionId,
135            path: targetPath,
136            referrer: referrerSent ? '' : document.referrer,
137            utm_source: c.utm_source || null,
138            utm_medium: c.utm_medium || null,
139            utm_campaign: c.utm_campaign || null,
140            gclid: c.gclid || null,
141            yclid: c.yclid || null,
142        });
143        sessionStorage.setItem('mx_ref_sent', '1');
144
145        // Обычный fetch (не sendBeacon) — нужен ответ с view_id для последующего
146        // /track/duration. sendBeacon ответа не даёт.
147        fetch(_TRACK_BASE + '/track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body, keepalive: true })
148            .then(r => r.ok ? r.json() : null)
149            .then(data => {
150                if (data && data.ok && data.view_id) {
151                    this._pending = { viewId: data.view_id, sessionId, enteredAt: Date.now() };
152                }
153            })
154            .catch(() => {});
155    },
156};
157
158// Досчитываем время на странице при уходе — сворачивание вкладки, закрытие,
159// переход на другой сайт. visibilitychange надёжнее beforeunload для sendBeacon.
160document.addEventListener('visibilitychange', () => {
161    if (document.visibilityState === 'hidden') Tracking._flushDuration();
162});
163window.addEventListener('pagehide', () => Tracking._flushDuration());

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.