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.