PageSourceSearch

https://courstore.com/shared/landing-beacon.js

js courstore.com collected 2026-09-28 09:01:03 UTC 25,489 bytes, 473 lines download raw bytes

1/*!
2 * Courstore Landing Analytics beacon (первосторонняя аналитика лендинга).
3 * Самодостаточный, без внешних либ. Подключать <script src=".../landing-beacon.js" defer>.
4 *
5 * Что делает:
6 *  - visitor_id: постоянный UUID в localStorage (cs_vid), не сбрасывается;
7 *  - session_id: UUID в localStorage (cs_sid) + 30-мин таймаут бездействия;
8 *  - на загрузке страницы шлёт pageview на /v1/landing/track (endpoint по хосту);
9 *  - CourstoreAnalytics.visitorId()/sessionId() — геттеры для quiz-POST;
10 *  - CourstoreAnalytics.lead(extra) — опциональное событие конверсии.
11 *
12 * ВНИМАНИЕ: beacon работает на ВСЕХ хостах (в отличие от GA/GTM-гарда).
13 * Тест-хосты уходят в devapi (тест-БД), боевые — в api (бой-БД). Изолированно, так и надо.
14 */
15(function () {
16  "use strict";
17
18  var SESSION_TTL_MS = 30 * 60 * 1000; // 30 минут бездействия
19  var VKEY = "cs_vid";                 // persistent visitor_id (localStorage)
20  var SKEY = "cs_sid";                 // session_id + last_activity (localStorage, CRS-3322)
21  var UKEY = "cs_utm";                 // ПОСЛЕДНЕЕ известное касание (localStorage — переживают вкладки/сессию,
22                                        // но перезаписывается новым кликом — несмотря на имя переменной ниже,
23                                        // это НЕ first-touch; см. FKEY)
24  // CRS-3666: ПЕРВОЕ касание с рекламой — пишется РОВНО ОДИН РАЗ (или если
25  // предыдущая запись протухла — старше 90 дней) и живёт 90 дней. Без него
26  // fbclid терялся: активная UKEY-метка выше перезатирается каждым новым
27  // кликом, а квиз мог открыться спустя дни/недели на «чистом» URL без
28  // единого параметра рекламы — тогда единственным источником атрибуции
29  // остаётся именно это самое первое касание.
30  var FKEY = "cs_ft";
31  var FIRST_TOUCH_MAX_AGE_MS = 90 * 24 * 60 * 60 * 1000; // 90 дней
32  var LKEY = "cs_lang";                // выбранный язык лендинга (localStorage + cookie), site-wide
33  var LANG_MAX_AGE = 60 * 60 * 24 * 365; // 1 год для cookie языка
34
35  // Ключи трекинга: utm_* + клик-id рекламных систем.
36  var TRACK_KEYS = [
37    "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term",
38    "gclid", "fbclid", "yclid", "ttclid"
39  ];
40
41  // API-хост: testapp/test/localhost в hostname → devapi, иначе прод api.
42  function apiBase() {
43    var h = (location.hostname || "").toLowerCase();
44    if (/testapp|(^|\.)test|localhost|127\.0\.0\.1/.test(h)) {
45      return "https://devapi.courstore.com";
46    }
47    return "https://api.courstore.com";
48  }
49
50  function uuid() {
51    try {
52      if (window.crypto && crypto.randomUUID) return crypto.randomUUID();
53    } catch (e) {}
54    return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) {
55      var r = (Math.random() * 16) | 0;
56      var v = c === "x" ? r : (r & 0x3) | 0x8;
57      return v.toString(16);
58    });
59  }
60
61  // visitor_id — постоянный, живёт в localStorage.
62  function getVisitorId() {
63    try {
64      var v = localStorage.getItem(VKEY);
65      if (!v) { v = uuid(); localStorage.setItem(VKEY, v); }
66      return v;
67    } catch (e) { return uuid(); }
68  }
69
70  // session_id — в localStorage; продлевается при активности,
71  // пересоздаётся после 30 мин бездействия (по last_activity ts).
72  //
73  // CRS-3322: раньше лежал в sessionStorage, а он изолирован по вкладке.
74  // In-app браузер Facebook/Instagram открывает каждый переход новым webview →
75  // один и тот же человек давал новую «сессию» на каждый заход (замерено:
76  // 25 сессий одного visitor_id за 3 минуты, 18% всех сессий — такие двойники).
77  // Заодно начинал работать 30-минутный TTL: он читал sessionStorage, которого
78  // в новой вкладке просто нет, поэтому никогда ничего не продлевал.
79  function readSession() {
80    var raw = null;
81    try { raw = localStorage.getItem(SKEY); } catch (e) {}
82    // Обратная совместимость: сессия, начатая прошлой версией beacon, лежит
83    // в sessionStorage. Подхватываем её, чтобы визит не разрывался на выкате.
84    if (!raw) { try { raw = sessionStorage.getItem(SKEY); } catch (e) {} }
85    try { var o = JSON.parse(raw || "null"); return (o && o.id) ? o : null; } catch (e) { return null; }
86  }
87  function getSessionId() {
88    var now = Date.now(), s = readSession();
89    if (!s || !s.id || (now - s.t) > SESSION_TTL_MS) {
90      s = { id: uuid(), t: now };
91    } else {
92      s.t = now; // продлеваем last_activity
93    }
94    var body = JSON.stringify(s);
95    // localStorage может быть недоступен (приватный режим, отключённые куки) —
96    // тогда деградируем до прежнего поведения, а не теряем session_id вовсе.
97    try { localStorage.setItem(SKEY, body); }
98    catch (e) { try { sessionStorage.setItem(SKEY, body); } catch (e2) {} }
99    return s.id;
100  }
101
102  function params() {
103    try { return (new URL(location.href)).searchParams; } catch (e) { return null; }
104  }
105  function qp(p, k) { return (p && p.get(k)) || null; }
106
107  // ── Трекинг-метки (LAST-touch: приоритет — текущий URL) ─────────────────
108  // Владелец: ВСЕГДА брать актуальную UTM из текущего URL. Если в URL есть метки —
109  // они «истина» (last-touch): используются в ссылках/лиде И перезаписывают хранилище.
110  // Если в URL меток нет — НЕ подставлять старые залипшие (не тащим stale в ссылки/лид).
111  // Хранилище остаётся лишь как «последнее известное касание» для тела pageview.
112
113  // Прочитать трекинг-ключи из текущего URL → объект (только непустые).
114  function urlTracking() {
115    var out = {}, p = params();
116    if (!p) return out;
117    for (var i = 0; i < TRACK_KEYS.length; i++) {
118      var v = p.get(TRACK_KEYS[i]);
119      if (v != null && v !== "") out[TRACK_KEYS[i]] = v;
120    }
121    return out;
122  }
123
124  function isEmptyObj(o) {
125    for (var k in o) if (o.hasOwnProperty(k)) return false;
126    return true;
127  }
128
129  // last-touch: если в текущем URL есть метки — ПЕРЕЗАПИСАТЬ ими хранилище
130  // (актуальная кампания побеждает старую). Если меток в URL нет — хранилище не трогаем.
131  function captureLastTouch() {
132    try {
133      var cur = urlTracking();
134      if (!isEmptyObj(cur)) {
135        localStorage.setItem(UKEY, JSON.stringify(cur));
136        try { sessionStorage.removeItem(UKEY); } catch (e) {} // чистим старый sessionStorage-ключ
137      }
138    } catch (e) {}
139  }
140
141  // ── ПЕРВОЕ касание (CRS-3666) — 90 дней, пишется один раз ───────────────
142  // fb.1.<ts_ms>.<fbclid> — тот же формат cookie _fbc, который ждёт Meta CAPI
143  // (сервер строит его так же в MetaCapiPayload.fbcFromClickId).
144  function buildFbc(fbclid, tsMs) {
145    return fbclid ? ("fb.1." + tsMs + "." + fbclid) : null;
146  }
147  function readFirstTouch() {
148    try {
149      var raw = localStorage.getItem(FKEY);
150      if (!raw) return null;
151      var o = JSON.parse(raw);
152      if (!o || !o.ts) return null;
153      if ((Date.now() - o.ts) > FIRST_TOUCH_MAX_AGE_MS) return null; // протухло
154      return o;
155    } catch (e) { return null; }
156  }
157  // Пишем ТОЛЬКО если ещё нет валидной записи (нет или протухла) И в текущем
158  // URL реально есть хоть одна метка — иначе первым «касанием» станет случайный
159  // прямой заход без рекламы, и настоящий клик по объявлению эту пустую
160  // заглушку уже не перезапишет (что и было бы неправильно — 90 дней истина одна).
161  function captureFirstTouch() {
162    try {
163      if (readFirstTouch()) return;
164      var cur = urlTracking();
165      if (isEmptyObj(cur)) return;
166      var now = Date.now();
167      var rec = {
168        ts: now,
169        utm_source: cur.utm_source || null,
170        utm_medium: cur.utm_medium || null,
171        utm_campaign: cur.utm_campaign || null,
172        utm_content: cur.utm_content || null,
173        utm_term: cur.utm_term || null,
174        fbclid: cur.fbclid || null,
175        fbc: buildFbc(cur.fbclid, now)
176      };
177      localStorage.setItem(FKEY, JSON.stringify(rec));
178    } catch (e) {}
179  }
180  // Публичный вид (для beacon-тела и quiz-POST): first_touch_at как ISO-8601.
181  function firstTouchPublic() {
182    var f = readFirstTouch();
183    if (!f) return {};
184    return {
185      utm_source: f.utm_source || null,
186      utm_medium: f.utm_medium || null,
187      utm_campaign: f.utm_campaign || null,
188      utm_content: f.utm_content || null,
189      utm_term: f.utm_term || null,
190      fbclid: f.fbclid || null,
191      fbc: f.fbc || null,
192      first_touch_at: f.ts ? new Date(f.ts).toISOString() : null
193    };
194  }
195
196  // Сохранённые метки (объект; {} если ничего нет). Fallback на старый sessionStorage-ключ.
197  function storedParams() {
198    try {
199      var s = localStorage.getItem(UKEY) || sessionStorage.getItem(UKEY);
200      if (s) { var o = JSON.parse(s); if (o && typeof o === "object") return o; }
201    } catch (e) {}
202    return {};
203  }
204
205  // АКТУАЛЬНЫЕ метки для ссылок/лида: ТОЛЬКО из текущего URL (last-touch).
206  // На «чистом» URL (без utm_) — {} , старые залипшие НЕ подставляем.
207  function activeTracking() {
208    return urlTracking();
209  }
210
211  // Для тела pageview: актуальные из URL, иначе последнее известное касание (хранилище).
212  function trackingForBeacon() {
213    var cur = urlTracking();
214    return isEmptyObj(cur) ? storedParams() : cur;
215  }
216
217  // ── Язык лендинга (site-wide персистентность) ───────────────────────────
218  // Хранение: localStorage (cs_lang) + cookie (cs_lang) — чтобы читалось и на
219  // других страницах, и при SSR. Приоритет чтения: ?lang= → cookie → localStorage.
220  function readCookie(name) {
221    try {
222      var m = document.cookie.match(new RegExp("(?:^|; )" + name + "=([^;]*)"));
223      return m ? decodeURIComponent(m[1]) : null;
224    } catch (e) { return null; }
225  }
226  function writeCookie(name, val) {
227    try {
228      document.cookie = name + "=" + encodeURIComponent(val) +
229        "; path=/; max-age=" + LANG_MAX_AGE + "; SameSite=Lax";
230    } catch (e) {}
231  }
232  function normLang(l) {
233    l = (l || "").toLowerCase().trim();
234    return /^(ru|kk|en|uz)$/.test(l) ? l : null;
235  }
236  // Сохранённый язык (без ?lang=): cookie → localStorage.
237  function savedLang() {
238    var l = normLang(readCookie(LKEY));
239    if (l) return l;
240    try { l = normLang(localStorage.getItem(LKEY)); } catch (e) {}
241    return l || null;
242  }
243  // Язык из ПУТИ (префиксная схема): маркер __CS_PATH_LANG, иначе первый сегмент
244  // пути /<lang>/… . Это главный источник в полностью-префиксной схеме.
245  function pathLang() {
246    try {
247      var pl = normLang(window.__CS_PATH_LANG);
248      if (pl) return pl;
249    } catch (e) {}
250    var m = (location.pathname || "").match(/\/(ru|kk|en|uz)(?:\/|$)/i);
251    return m ? normLang(m[1]) : null;
252  }
253  // Текущий язык страницы: ПУТЬ /<lang>/ → ?lang= (back-compat) → сохранённый.
254  // В префиксной схеме язык несёт сам путь, поэтому он в приоритете.
255  function currentLang() {
256    var l = pathLang();
257    if (l) return l;
258    var p = params();
259    l = p ? normLang(p.get("lang")) : null;
260    return l || savedLang();
261  }
262  // Сохранить выбранный язык (localStorage + cookie).
263  function persistLang(l) {
264    l = normLang(l); if (!l) return;
265    try { localStorage.setItem(LKEY, l); } catch (e) {}
266    writeCookie(LKEY, l);
267  }
268
269  // Публичный API языка (используется страницами лендинга для чтения на load).
270  window.CourstoreLang = {
271    get: currentLang,       // ПУТЬ /<lang>/ → ?lang= → cookie → localStorage → null
272    path: pathLang,         // язык из пути /<lang>/ (или __CS_PATH_LANG) → null
273    saved: savedLang,       // cookie → localStorage → null (без учёта URL/пути)
274    save: persistLang,      // записать выбранный язык в cookie+localStorage
275    norm: normLang
276  };
277
278  // ── Префиксная перелинковка внутренних ссылок ───────────────────────────
279  // Полностью-префиксная i18n: внутренняя nav/footer/CTA-ссылка должна нести
280  // ТЕКУЩИЙ языковой префикс пути (/<lang>/…), а не голый путь и не ?lang=.
281  // langifyPath(pathname, lang): вернуть pathname с первым сегментом = /<lang>/.
282  // Учитывает «базу» деплоя (…/landing/, …/pro/): язык вставляется ПОСЛЕ базы,
283  // определяемой по текущему пути страницы (родитель языкового сегмента).
284  function deployBase() {
285    // База = текущий путь до языкового сегмента. На /landing/kk/prices/ база = /landing/.
286    // Если мы на голом пути (без /<lang>/) — база = каталог текущей страницы.
287    var pn = location.pathname || "/";
288    var m = pn.match(/^(.*?\/)(?:ru|kk|en|uz)(?:\/|$)/i);
289    if (m) return m[1];
290    // голый путь: до последнего '/'
291    return pn.replace(/[^\/]*$/, "");
292  }
293  function langifyPath(pathname, lang, base) {
294    // Абсолютный pathname → тот же путь под /<lang>/. Снимаем существующий языковой
295    // сегмент (если есть), затем вставляем нужный сразу после базы деплоя.
296    var rest = pathname;
297    // отрезать базу (если путь начинается с неё)
298    if (base && pathname.toLowerCase().indexOf(base.toLowerCase()) === 0) {
299      rest = pathname.slice(base.length);
300    } else {
301      base = "/";
302      rest = pathname.replace(/^\//, "");
303    }
304    // снять ведущий языковой сегмент из остатка
305    rest = rest.replace(/^(ru|kk|en|uz)(\/|$)/i, function (m, l, sl) { return sl === "/" ? "" : ""; });
306    return base + lang + "/" + rest;
307  }
308  // Перелинковать ВСЕ внутренние ссылки под язык `lang` + актуальную UTM.
309  // Идемпотентно: absolute-path хрефы с уже верным префиксом остаются как есть.
310  var NO_LANG_PATHS = /(^|\/)mobile-app(\/|$)/i;
311  function relinkAll(lang) {
312    lang = normLang(lang) || pathLang(); if (!lang) return;
313    var host = (location.hostname || "").toLowerCase();
314    var base = deployBase();
315    // Актуальные (last-touch) UTM-параметры из текущего URL — в query.
316    var utm = activeTracking(), utmParts = [];
317    for (var uk in utm) if (utm.hasOwnProperty(uk)) {
318      utmParts.push(encodeURIComponent(uk) + "=" + encodeURIComponent(utm[uk]));
319    }
320    var links = document.querySelectorAll("a[href]");
321    for (var i = 0; i < links.length; i++) {
322      var a = links[i], href = a.getAttribute("href");
323      if (!href) continue;
324      var low = href.trim().toLowerCase();
325      if (low.charAt(0) === "#") continue;                       // in-page якорь
326      if (/^(mailto:|tel:|javascript:)/.test(low)) continue;     // не-навигация
327      var abs;
328      try { abs = new URL(href, location.href); } catch (e) { continue; }
329      if (abs.hostname.toLowerCase() !== host) continue;         // внешний домен — не трогаем
330      // Только страницы под базой деплоя (nav/footer/CTA лендинга). Ресурсы
331      // (shared/*.css, /og/*, favicon, .png/.js/.css) — не языковые, пропускаем.
332      if (/\.(css|js|png|jpg|jpeg|svg|webp|ico|gif|woff2?|mp4|webm|pdf)$/i.test(abs.pathname)) continue;
333      // Standalone-лендинги живут вне языковой схемы (/mobile-app/ не имеет /ru/-версии).
334      if (NO_LANG_PATHS.test(abs.pathname)) continue;
335      // Вставляем языковой префикс в путь.
336      var newPath = langifyPath(abs.pathname, lang, base);
337      // Чистим legacy ?lang= из query; наносим актуальную UTM (перезаписываем старую).
338      abs.searchParams.delete("lang");
339      for (var t = 0; t < TRACK_KEYS.length; t++) abs.searchParams.delete(TRACK_KEYS[t]);
340      var qs = abs.searchParams.toString();
341      if (utmParts.length) qs = (qs ? qs + "&" : "") + utmParts.join("&");
342      a.setAttribute("href", newPath + (qs ? "?" + qs : "") + (abs.hash || ""));
343    }
344  }
345
346  // Персист языка при любом переключении: делегированный клик по [data-lang].
347  // (Страничный switcher сам ведёт на /<lang>/-путь через csLangHref; здесь только
348  // сохраняем выбор и на всякий случай перелинковываем видимые ссылки под новый язык.)
349  document.addEventListener("click", function (e) {
350    var a = e.target && e.target.closest ? e.target.closest("[data-lang]") : null;
351    if (a) {
352      var l = normLang(a.getAttribute("data-lang"));
353      if (l) { persistLang(l); relinkAll(l); }
354    }
355  }, true);
356
357  // Страховка: если страница меняет <html lang> своим applyLang — сохранить его.
358  try {
359    var htmlEl = document.documentElement;
360    new MutationObserver(function () {
361      var l = normLang(htmlEl.getAttribute("lang"));
362      if (l && l !== savedLang()) persistLang(l);
363    }).observe(htmlEl, { attributes: true, attributeFilter: ["lang"] });
364  } catch (e) {}
365
366  // Декорация внутренних ссылок: языковой ПРЕФИКС пути (/<lang>/…) + АКТУАЛЬНАЯ UTM.
367  // Заменяет старую query-based (?lang=) схему: язык теперь несёт путь.
368  function decorateLinks() {
369    var lang = currentLang();                 // язык из пути (в приоритете) → …
370    if (!lang) return;                        // нет языкового контекста — ничего не префиксим
371    relinkAll(lang);
372  }
373
374  // lang: ?lang= → сохранённый (cookie/localStorage) → <html lang>
375  function getLang() {
376    var l = currentLang();
377    if (!l) l = document.documentElement.getAttribute("lang") || "";
378    return (l || "").toLowerCase().slice(0, 16);
379  }
380
381  function deviceHint() {
382    var w = window.innerWidth || (screen && screen.width) || 0;
383    if (w > 0 && w < 768) return "mobile";
384    if (w >= 768 && w < 1024) return "tablet";
385    return "desktop";
386  }
387
388  function send(eventType, extra) {
389    // Тело pageview несёт ПЕРВОКАСАТЕЛЬНЫЕ метки (stored), фолбэк — текущий URL.
390    var tp = trackingForBeacon();
391    var payload = {
392      event_type: eventType,
393      visitor_id: getVisitorId(),
394      session_id: getSessionId(),
395      page_path: location.pathname,
396      page_url: location.href,
397      referrer: document.referrer || null,
398      utm_source: tp.utm_source || null,
399      utm_medium: tp.utm_medium || null,
400      utm_campaign: tp.utm_campaign || null,
401      utm_content: tp.utm_content || null,
402      utm_term: tp.utm_term || null,
403      lang: getLang(),
404      screen_w: window.innerWidth || 0,
405      device_hint: deviceHint()
406    };
407    // CRS-3666: первое касание с рекламой — явными полями, чтобы оÐ
407½Ð¾ писалось
408    // в wp_landing_sessions/wp_landing_events независимо от того, несёт ли
409    // ТЕКУЩИЙ URL fbclid (обычно уже не несёт — реклама была кликнута раньше).
410    var ft = firstTouchPublic();
411    if (ft.fbclid) payload.fbclid = ft.fbclid;
412    if (ft.fbc) payload.fbc = ft.fbc;
413    if (ft.first_touch_at) payload.first_touch_at = ft.first_touch_at;
414    if (extra) for (var k in extra) if (extra.hasOwnProperty(k)) payload[k] = extra[k];
415
416    var url = apiBase() + "/v1/landing/track";
417    var body = JSON.stringify(payload);
418    // Аналитика никогда не роняет страницу — любые ошибки глушим.
419    try {
420      if (navigator.sendBeacon) {
421        navigator.sendBeacon(url, new Blob([body], { type: "text/plain;charset=UTF-8" }));
422        return;
423      }
424    } catch (e) {}
425    try {
426      fetch(url, {
427        method: "POST",
428        headers: { "Content-Type": "text/plain;charset=UTF-8" },
429        body: body,
430        keepalive: true,
431        mode: "cors"
432      }).catch(function () {});
433    } catch (e) {}
434  }
435
436  // Публичный API: геттеры id'шников для quiz-POST + опциональное lead-событие.
437  window.CourstoreAnalytics = {
438    visitorId: getVisitorId,
439    sessionId: getSessionId,
440    trackingParams: activeTracking, // АКТУАЛЬНЫЕ метки из текущего URL (last-touch); {} на чистом URL
441    // CRS-3666: ПЕРВОЕ касание с рекламой (90 дней, пишется один раз) —
442    // {utm_source, utm_medium, utm_campaign, utm_content, utm_term, fbclid, fbc,
443    // first_touch_at} или {} если ещё не зафиксировано/протухло. Источник
444    // атрибуции для формы квиза, не зависит от того, что несёт текущий URL.
445    firstTouch: firstTouchPublic,
446    lead: function (extra) { send("lead", extra || {}); },
447    // Универсальный трек произвольного события (promo_impression/close/click и пр.).
448    // Летит тем же beacon-каналом на /v1/landing/track с event_type + доп.поля.
449    track: function (eventType, extra) { send(eventType, extra || {}); }
450  };
451
452  // Порядок: сначала last-touch capture, затем first-touch (если ещё не
453  // зафиксировано) — оба синхронно, до pageview и декорации; затем — по
454  // готовности DOM — декорация ссылок и pageview.
455  captureLastTouch();
456  captureFirstTouch();
457
458  function onReady() {
459    // На SEO-пререндере (__CS_NO_REDIRECT) НЕ префиксим ссылки: страница грузится
460    // по голому/виртуальному URL, префикс из location был бы неверным — оставляем
461    // исходные относительные ссылки, prerender сам правит глубину, а на реальной
462    // загрузке beacon перелинкует по фактическому /<lang>/-пути.
463    var skip = false;
464    try { skip = !!window.__CS_NO_REDIRECT; } catch (e) {}
465    if (!skip) decorateLinks();  // ссылки получают язык-префикс + актуальную UTM
466    send("pageview");
467  }
468  if (document.readyState === "complete" || document.readyState === "interactive") {
469    setTimeout(onReady, 0);
470  } else {
471    window.addEventListener("DOMContentLoaded", onReady);
472  }
473})();

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.