/* Groupe KA — ka-id.js : SDK client KA ID v2 (personnalisation). * SOURCE CANONIQUE : ka-ui.git/kaid/ka-id.js — copié dans frontend/public/ * (ou public/) de chaque app par sync-kaid.sh. Corriger ICI puis redistribuer. * * Rôle : signaux navigateur que le serveur ne voit pas — position des clics * dans les résultats, temps passé sur une fiche (dwell), visites de retour — * plus les aides d'interface : KAID.hide() (« Pas pour moi »), * KAID.saveSearch() (recherche sauvegardée), badge « Recommandé pour vous ». * * Intégration (index.html, AVANT le bundle) : * * * * Vie privée : ne fait RIEN si l'utilisateur n'est pas connecté via KA ID * (vérifié auprès de /api/kaid/status) ; le hub respecte ensuite ses * réglages Historique/Personnalisation (voir groupe-ka.com/mon-ka). */ (function () { "use strict"; var CONF = window.KAID_CONF || {}; var API = CONF.api || "/api/kaid"; var connected = null; // null = inconnu, sinon bool var queue = []; var flushTimer = null; /* ---------- transport (lots de 10, sendBeacon à la fermeture) ---------- */ function post(events, useBeacon) { if (!events.length) return; var body = JSON.stringify({ events: events }); if (useBeacon && navigator.sendBeacon) { navigator.sendBeacon(API + "/events", new Blob([body], { type: "application/json" })); return; } fetch(API + "/events", { method: "POST", headers: { "Content-Type": "application/json" }, body: body, keepalive: true, credentials: "same-origin", }).catch(function () {}); } function flush(useBeacon) { if (!queue.length) return; var batch = queue.splice(0, queue.length); post(batch, useBeacon); } function track(type, data) { if (connected === false) return; var e = { type: type }; if (data) for (var k in data) if (data[k] != null) e[k] = data[k]; queue.push(e); if (queue.length >= 10) flush(false); else if (!flushTimer) flushTimer = setTimeout(function () { flushTimer = null; flush(false); }, 4000); } /* ---------- connexion ---------- */ function init(cb) { try { var cached = sessionStorage.getItem("kaid_status"); if (cached) { var c = JSON.parse(cached); if (Date.now() - c.t < 600000) { connected = c.on; cb(); return; } } } catch (err) { /* stockage indisponible */ } fetch(API + "/status", { credentials: "same-origin" }) .then(function (r) { return r.json(); }) .then(function (d) { connected = !!d.connected; try { sessionStorage.setItem("kaid_status", JSON.stringify({ on: connected, t: Date.now() })); } catch (err) { /* stockage indisponible */ } cb(); }) .catch(function () { connected = false; }); } /* ---------- clics sur les cartes résultat ---------- */ function uidFromHref(el) { if (!CONF.detail) return null; var href = el.getAttribute("href") || ""; var path = href.replace(/^https?:\/\/[^/]+/, "").split("?")[0]; var m = path.match(new RegExp(CONF.detail)); return m ? decodeURIComponent(m[1]) : null; } function watchClicks() { if (!CONF.card) return; document.addEventListener("click", function (ev) { var el = ev.target && ev.target.closest && ev.target.closest(CONF.card); if (!el) return; var uid = (CONF.uid ? CONF.uid(el) : null) || uidFromHref(el); if (!uid) return; var cards = document.querySelectorAll(CONF.card); var pos = Array.prototype.indexOf.call(cards, el); track("click", { entity_type: CONF.entity, entity_id: uid, position: pos >= 0 ? pos : null, }); }, true); } /* ---------- temps passé sur une fiche (dwell) ---------- */ var dwell = { uid: null, t0: 0 }; function detailUid() { if (!CONF.detail) return null; var m = location.pathname.match(new RegExp(CONF.detail)); return m ? decodeURIComponent(m[1]) : null; } function endDwell(useBeacon) { if (!dwell.uid) return; var ms = Date.now() - dwell.t0; if (ms >= 8000) track("detail_dwell", { entity_type: CONF.entity, entity_id: dwell.uid, dwell_ms: ms }); dwell.uid = null; if (useBeacon) flush(true); } function checkRoute() { var uid = detailUid(); if (uid === dwell.uid) return; endDwell(false); if (uid) dwell = { uid: uid, t0: Date.now() }; } function watchRoutes() { ["pushState", "replaceState"].forEach(function (fn) { var orig = history[fn]; history[fn] = function () { var out = orig.apply(this, arguments); setTimeout(checkRoute, 50); return out; }; }); window.addEventListener("popstate", function () { setTimeout(checkRoute, 50); }); document.addEventListener("visibilitychange", function () { if (document.visibilityState === "hidden") { endDwell(true); } else checkRoute(); }); window.addEventListener("pagehide", function () { endDwell(true); }); checkRoute(); } /* ---------- visite de retour ---------- */ function returnVisit() { try { var last = Number(localStorage.getItem("kaid_last") || 0); var now = Date.now(); if (last && now - last > 72e6) track("return_visit", {}); // > 20 h localStorage.setItem("kaid_last", String(now)); } catch (err) { /* stockage indisponible */ } } /* ---------- API publique ---------- */ window.KAID = { track: track, connected: function () { return connected; }, /** « Pas pour moi » — masque l'annonce partout et nourrit le signal négatif. */ hide: function (itemId, features) { return fetch(API + "/hide", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ item_id: itemId, on: true, features: features || null }), }).then(function (r) { return r.json(); }); }, /** Sauvegarde la recherche courante (par défaut : URL + titre du document). */ saveSearch: function (opts) { opts = opts || {}; return fetch(API + "/saved-searches", { method: "POST", headers: { "Content-Type": "application/json" }, credentials: "same-origin", body: JSON.stringify({ action: "add", label: opts.label || document.title.split("|")[0].split("—")[0].trim(), query: opts.query || null, filters: opts.filters || null, url: opts.url || (location.origin + location.pathname + location.search), alert: !!opts.alert, frequency: opts.frequency || null, }), }).then(function (r) { return r.json(); }); }, external: function (itemId, features) { track("external_click", { entity_type: CONF.entity, entity_id: itemId, features: features }); flush(true); }, }; /* ---------- démarrage ---------- */ function start() { if (!connected) return; watchClicks(); watchRoutes(); returnVisit(); } if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", function () { init(start); }); else init(start); })();