added on local at 2026-07-01 16:01:41
| 1 | /* ===================================================================== | |
| 2 | ABForge tracker -- embedded on any customer website with: | |
| 3 | <script async src="https://abforge.3dshawn.com/t.js?k=PUBLIC_KEY"></script> | |
| 4 | ||
| 5 | What it does: | |
| 6 | 1. Reads/creates abf_uid (long-lived visitor cookie) and abf_sid | |
| 7 | (session cookie). | |
| 8 | 2. Sends a pageview to /c.cgi on load (sendBeacon when possible). | |
| 9 | 3. Asks /x.cgi for any live A/B / MVT variant assignments for the | |
| 10 | current URL, then applies the variant's DOM ops client-side. | |
| 11 | 4. Tracks clicks (heatmap), max scroll depth, and form submits. | |
| 12 | 5. Sends a heartbeat every 15s with active time on page + scroll | |
| 13 | depth so we can compute time-on-page accurately. | |
| 14 | 6. Exposes a tiny global API: window.abforge.event(name, value) | |
| 15 | and window.abforge.goal(name, value) for the customer to fire | |
| 16 | custom events from their own code. | |
| 17 | ===================================================================== */ | |
| 18 | (function () { | |
| 19 | 'use strict'; | |
| 20 | ||
| 21 | if (window.__abforge_loaded) return; | |
| 22 | window.__abforge_loaded = true; | |
| 23 | ||
| 24 | /* ---- Resolve the public key from the script's own src ----------- */ | |
| 25 | function findKey() { | |
| 26 | var s = document.currentScript; | |
| 27 | if (!s) { | |
| 28 | var ss = document.getElementsByTagName('script'); | |
| 29 | for (var i = 0; i < ss.length; i++) { | |
| 30 | if ((ss[i].src || '').indexOf('/t.js') > 0) { s = ss[i]; break; } | |
| 31 | } | |
| 32 | } | |
| 33 | if (!s) return { key: '', host: '' }; | |
| 34 | var src = s.src || ''; | |
| 35 | var m = src.match(/[?&]k=([A-Za-z0-9]+)/); | |
| 36 | var hostM = src.match(/^(https?:\/\/[^\/]+)/); | |
| 37 | return { key: m ? m[1] : '', host: hostM ? hostM[1] : '' }; | |
| 38 | } | |
| 39 | var ctx = findKey(); | |
| 40 | if (!ctx.key) return; | |
| 41 | var KEY = ctx.key; | |
| 42 | var HOST = ctx.host; // e.g. https://abforge.3dshawn.com | |
| 43 | ||
| 44 | var COLLECT = HOST + '/c.cgi'; | |
| 45 | var EXPOSE = HOST + '/x.cgi'; | |
| 46 | var GOAL = HOST + '/g.cgi'; | |
| 47 | var HEAT = HOST + '/h.cgi'; | |
| 48 | ||
| 49 | /* ---- Cookie helpers --------------------------------------------- */ | |
| 50 | function getCookie(name) { | |
| 51 | var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); | |
| 52 | return m ? decodeURIComponent(m[1]) : ''; | |
| 53 | } | |
| 54 | function setCookie(name, value, days) { | |
| 55 | var exp = ''; | |
| 56 | if (days) { | |
| 57 | var d = new Date(); | |
| 58 | d.setTime(d.getTime() + days * 86400000); | |
| 59 | exp = '; expires=' + d.toUTCString(); | |
| 60 | } | |
| 61 | document.cookie = name + '=' + encodeURIComponent(value) + exp + '; path=/; SameSite=Lax'; | |
| 62 | } | |
| 63 | function uuid() { | |
| 64 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
| 65 | var r = Math.random() * 16 | 0; | |
| 66 | return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16); | |
| 67 | }); | |
| 68 | } | |
| 69 | var UID_COOKIE = 'abf_uid'; | |
| 70 | var SID_COOKIE = 'abf_sid'; | |
| 71 | var UID = getCookie(UID_COOKIE); | |
| 72 | if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(UID)) { | |
| 73 | UID = uuid(); setCookie(UID_COOKIE, UID, 365 * 2); | |
| 74 | } | |
| 75 | var SID = getCookie(SID_COOKIE); | |
| 76 | if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(SID)) { | |
| 77 | SID = uuid(); setCookie(SID_COOKIE, SID, 1); // 1 day; server enforces 30-min idle | |
| 78 | } | |
| 79 | ||
| 80 | /* ---- Fingerprint ------------------------------------------------- */ | |
| 81 | function detect() { | |
| 82 | var ua = navigator.userAgent || ''; | |
| 83 | var os = '', osv = ''; | |
| 84 | if (/Windows NT 10/i.test(ua)) { os = 'Windows'; osv = '10/11'; } | |
| 85 | else if (/Windows NT ([\d.]+)/i.test(ua)) { os = 'Windows'; osv = RegExp.$1; } | |
| 86 | else if (/Mac OS X ([\d_]+)/i.test(ua)) { os = 'macOS'; osv = RegExp.$1.replace(/_/g, '.'); } | |
| 87 | else if (/iPhone|iPad|iPod/i.test(ua)) { os = 'iOS'; osv = (ua.match(/OS ([\d_]+)/) || [,''])[1].replace(/_/g, '.'); } | |
| 88 | else if (/Android ([\d.]+)/i.test(ua)) { os = 'Android'; osv = RegExp.$1; } | |
| 89 | else if (/Linux/i.test(ua)) { os = 'Linux'; } | |
| 90 | var br = '', brv = ''; | |
| 91 | if (/Edg\/([\d.]+)/.test(ua)) { br = 'Edge'; brv = RegExp.$1; } | |
| 92 | else if (/OPR\/([\d.]+)/.test(ua)) { br = 'Opera'; brv = RegExp.$1; } | |
| 93 | else if (/Firefox\/([\d.]+)/.test(ua)){ br = 'Firefox'; brv = RegExp.$1; } | |
| 94 | else if (/Chrome\/([\d.]+)/.test(ua)) { br = 'Chrome'; brv = RegExp.$1; } | |
| 95 | else if (/Version\/([\d.]+).*Safari/.test(ua)) { br = 'Safari'; brv = RegExp.$1; } | |
| 96 | var dev = 'desktop'; | |
| 97 | if (/bot|crawl|spider|google|bing|yahoo|slurp|facebookexternalhit/i.test(ua)) dev = 'bot'; | |
| 98 | else if (/iPad|Tablet/i.test(ua)) dev = 'tablet'; | |
| 99 | else if (/Mobi|iPhone|Android.*Mobile/i.test(ua)) dev = 'mobile'; | |
| 100 | var tz = ''; | |
| 101 | try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {} | |
| 102 | return { | |
| 103 | os_name: os, os_version: osv, | |
| 104 | browser_name: br, browser_version: brv, | |
| 105 | device_type: dev, | |
| 106 | screen_width: (screen && screen.width) || 0, | |
| 107 | screen_height: (screen && screen.height) || 0, | |
| 108 | viewport_width: window.innerWidth || 0, | |
| 109 | viewport_height: window.innerHeight || 0, | |
| 110 | color_depth: (screen && screen.colorDepth) || 24, | |
| 111 | pixel_ratio: window.devicePixelRatio || 1, | |
| 112 | touch_capable: ('ontouchstart' in window || navigator.maxTouchPoints > 0) ? 1 : 0, | |
| 113 | language: navigator.language || '', | |
| 114 | timezone: tz | |
| 115 | }; | |
| 116 | } | |
| 117 | var FP = detect(); | |
| 118 | ||
| 119 | /* ---- UTM params ------------------------------------------------- */ | |
| 120 | function readUTM() { | |
| 121 | var qs = location.search || ''; | |
| 122 | var out = {}; | |
| 123 | qs.replace(/[?&]([^=&]+)=([^&]*)/g, function (_, k, v) { | |
| 124 | k = decodeURIComponent(k); v = decodeURIComponent(v.replace(/\+/g, ' ')); | |
| 125 | if (/^utm_/.test(k)) out[k] = v; | |
| 126 | }); | |
| 127 | return out; | |
| 128 | } | |
| 129 | var UTM = readUTM(); | |
| 130 | ||
| 131 | /* ---- Lightweight transport (sendBeacon or fetch keepalive) ------ */ | |
| 132 | function send(url, payload) { | |
| 133 | var body = JSON.stringify(payload); | |
| 134 | if (navigator.sendBeacon) { | |
| 135 | try { return navigator.sendBeacon(url, new Blob([body], { type: 'application/json' })); } catch (e) {} | |
| 136 | } | |
| 137 | try { | |
| 138 | fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, | |
| 139 | body: body, keepalive: true, credentials: 'omit', mode: 'cors' }); | |
| 140 | } catch (e) {} | |
| 141 | } | |
| 142 | function jget(url) { | |
| 143 | return fetch(url, { method: 'GET', credentials: 'omit', mode: 'cors' }) | |
| 144 | .then(function (r) { return r.json(); }) | |
| 145 | .catch(function () { return null; }); | |
| 146 | } | |
| 147 | ||
| 148 | /* ---- Selector path for heatmap clicks --------------------------- */ | |
| 149 | function selectorPath(el) { | |
| 150 | if (!el || el.nodeType !== 1) return ''; | |
| 151 | var parts = [], depth = 0; | |
| 152 | while (el && el.nodeType === 1 && depth < 8) { | |
| 153 | var tag = (el.tagName || '').toLowerCase(); | |
| 154 | if (!tag || tag === 'html') break; | |
| 155 | if (el.id && /^[A-Za-z][\w\-:.]*$/.test(el.id)) { | |
| 156 | parts.unshift('#' + el.id); break; | |
| 157 | } | |
| 158 | var part = tag; | |
| 159 | var parent = el.parentNode; | |
| 160 | if (parent && parent.children && parent.children.length > 1) { | |
| 161 | var n = 1; | |
| 162 | for (var i = 0; i < parent.children.length; i++) { | |
| 163 | var sib = parent.children[i]; | |
| 164 | if (sib === el) break; | |
| 165 | if (sib.tagName === el.tagName) n++; | |
| 166 | } | |
| 167 | part += ':nth-of-type(' + n + ')'; | |
| 168 | } | |
| 169 | parts.unshift(part); | |
| 170 | if (tag === 'body') break; | |
| 171 | el = parent; depth++; | |
| 172 | } | |
| 173 | return parts.join('>').slice(0, 500); | |
| 174 | } | |
| 175 | ||
| 176 | /* ---- Variant exposure ------------------------------------------- */ | |
| 177 | function applyOps(ops) { | |
| 178 | if (!ops || !ops.length) return; | |
| 179 | for (var i = 0; i < ops.length; i++) { | |
| 180 | var op = ops[i]; | |
| 181 | try { | |
| 182 | var els = document.querySelectorAll(op.selector); | |
| 183 | for (var j = 0; j < els.length; j++) { | |
| 184 | var el = els[j]; | |
| 185 | if (op.op === 'text') el.textContent = op.value || ''; | |
| 186 | else if (op.op === 'html') el.innerHTML = op.value || ''; | |
| 187 | else if (op.op === 'attr') el.setAttribute(op.attr, op.value || ''); | |
| 188 | else if (op.op === 'css') el.style.cssText += ';' + (op.value || ''); | |
| 189 | else if (op.op === 'class') el.classList.add(op.value || ''); | |
| 190 | else if (op.op === 'hide') el.style.display = 'none'; | |
| 191 | else if (op.op === 'show') el.style.display = ''; | |
| 192 | } | |
| 193 | } catch (e) {} | |
| 194 | } | |
| 195 | } | |
| 196 | function applyVariant(t, v) { | |
| 197 | var ch = {}; | |
| 198 | try { ch = typeof v.changes === 'string' ? JSON.parse(v.changes || '{}') : (v.changes || {}); } catch (e) {} | |
| 199 | if (ch.redirect_url) { try { location.replace(ch.redirect_url); } catch (e) {} return; } | |
| 200 | if (ch.custom_css) { | |
| 201 | var st = document.createElement('style'); st.textContent = ch.custom_css; document.head.appendChild(st); | |
| 202 | } | |
| 203 | applyOps(ch.ops); | |
| 204 | if (ch.custom_js) { try { new Function(ch.custom_js).call(window); } catch (e) {} } | |
| 205 | // Tag DOM so the customer can verify in DevTools | |
| 206 | if (document.body) document.body.setAttribute('data-abf-exposure', t + ':' + v.id); | |
| 207 | } | |
| 208 | ||
| 209 | var ACTIVE_EXPERIMENTS = []; // [{test_id, variant_id}] | |
| 210 | function fetchExperiments() { | |
| 211 | var u = EXPOSE + '?k=' + encodeURIComponent(KEY) | |
| 212 | + '&u=' + encodeURIComponent(UID) | |
| 213 | + '&s=' + encodeURIComponent(SID) | |
| 214 | + '&p=' + encodeURIComponent(location.pathname + location.search); | |
| 215 | return jget(u).then(function (res) { | |
| 216 | if (!res || !res.ok || !res.experiments) return; | |
| 217 | for (var i = 0; i < res.experiments.length; i++) { | |
| 218 | var ex = res.experiments[i]; | |
| 219 | if (ex.variant) { | |
| 220 | applyVariant(ex.test_id, ex.variant); | |
| 221 | ACTIVE_EXPERIMENTS.push({ test_id: ex.test_id, variant_id: ex.variant.id }); | |
| 222 | } | |
| 223 | } | |
| 224 | }); | |
| 225 | } | |
| 226 | ||
| 227 | /* ---- Pageview + lifecycle --------------------------------------- */ | |
| 228 | function basePayload(extras) { | |
| 229 | var p = { | |
| 230 | k: KEY, u: UID, s: SID, | |
| 231 | p: location.pathname + location.search, | |
| 232 | url: location.href, | |
| 233 | r: document.referrer || '' | |
| 234 | }; | |
| 235 | if (extras) for (var k in extras) if (extras.hasOwnProperty(k)) p[k] = extras[k]; | |
| 236 | return p; | |
| 237 | } | |
| 238 | function pickExperimentContext() { | |
| 239 | if (!ACTIVE_EXPERIMENTS.length) return {}; | |
| 240 | var e = ACTIVE_EXPERIMENTS[0]; // primary exposure -- multiple OK at write time | |
| 241 | return { xt: e.test_id, xv: e.variant_id }; | |
| 242 | } | |
| 243 | ||
| 244 | function sendPageview() { | |
| 245 | var payload = basePayload({ | |
| 246 | t: 'pv', title: document.title || '', | |
| 247 | referrer: document.referrer || '' | |
| 248 | }); | |
| 249 | for (var k in FP) if (FP.hasOwnProperty(k)) payload[k] = FP[k]; | |
| 250 | for (var u in UTM) if (UTM.hasOwnProperty(u)) payload[u] = UTM[u]; | |
| 251 | var ec = pickExperimentContext(); | |
| 252 | for (var x in ec) if (ec.hasOwnProperty(x)) payload[x] = ec[x]; | |
| 253 | send(COLLECT, payload); | |
| 254 | } | |
| 255 | ||
| 256 | /* ---- Heartbeat + scroll depth ----------------------------------- */ | |
| 257 | var ACTIVE_MS = 0, LAST_TICK = Date.now(), IS_ACTIVE = true, MAX_SCROLL = 0; | |
| 258 | function tick() { | |
| 259 | var now = Date.now(); | |
| 260 | if (IS_ACTIVE && document.visibilityState === 'visible') { | |
| 261 | ACTIVE_MS += (now - LAST_TICK); | |
| 262 | } | |
| 263 | LAST_TICK = now; | |
| 264 | } | |
| 265 | function fireHeartbeat() { | |
| 266 | tick(); | |
| 267 | var secs = Math.round(ACTIVE_MS / 1000); | |
| 268 | if (secs <= 0) return; | |
| 269 | var ec = pickExperimentContext(); | |
| 270 | send(COLLECT, basePayload(Object.assign({ t: 'hb', d: secs, sd: MAX_SCROLL }, ec))); | |
| 271 | ACTIVE_MS = 0; | |
| 272 | } | |
| 273 | document.addEventListener('visibilitychange', function () { | |
| 274 | IS_ACTIVE = (document.visibilityState === 'visible'); | |
| 275 | LAST_TICK = Date.now(); | |
| 276 | }); | |
| 277 | window.addEventListener('scroll', function () { | |
| 278 | var h = Math.max(document.body.scrollHeight - window.innerHeight, 1); | |
| 279 | var pct = Math.min(100, Math.round((window.scrollY / h) * 100)); | |
| 280 | if (pct > MAX_SCROLL) MAX_SCROLL = pct; | |
| 281 | }, { passive: true }); | |
| 282 | setInterval(fireHeartbeat, 15000); | |
| 283 | window.addEventListener('pagehide', fireHeartbeat); | |
| 284 | window.addEventListener('beforeunload', fireHeartbeat); | |
| 285 | ||
| 286 | /* ---- Click + form_submit + rage clicks -------------------------- */ | |
| 287 | var clickStamps = []; | |
| 288 | document.addEventListener('click', function (e) { | |
| 289 | var t = e.target; var sel = selectorPath(t); | |
| 290 | var ox = 0.5, oy = 0.5, have = 0; | |
| 291 | try { | |
| 292 | var r = t.getBoundingClientRect(); | |
| 293 | if (r && r.width > 0 && r.height > 0) { | |
| 294 | ox = (e.clientX - r.left) / r.width; | |
| 295 | oy = (e.clientY - r.top) / r.height; | |
| 296 | if (ox < 0) ox = 0; else if (ox > 1) ox = 1; | |
| 297 | if (oy < 0) oy = 0; else if (oy > 1) oy = 1; | |
| 298 | have = 1; | |
| 299 | } | |
| 300 | } catch (err) {} | |
| 301 | var xpct = (e.clientX / Math.max(window.innerWidth, 1)) * 100; | |
| 302 | var ypct = ((e.clientY + window.scrollY) / Math.max(document.body.scrollHeight, 1)) * 100; | |
| 303 | var ec = pickExperimentContext(); | |
| 304 | send(COLLECT, basePayload(Object.assign({ | |
| 305 | t: 'click', sel: sel, | |
| 306 | text: ((t.innerText || t.value || '') + '').slice(0, 80), | |
| 307 | x: xpct.toFixed(2), y: ypct.toFixed(2) | |
| 308 | }, ec))); | |
| 309 | // Heatmap row (anchored to element) | |
| 310 | send(HEAT, basePayload(Object.assign({ | |
| 311 | t: 'hm', sel: sel, | |
| 312 | ox: have ? ox.toFixed(4) : null, | |
| 313 | oy: have ? oy.toFixed(4) : null, | |
| 314 | x: xpct.toFixed(2), | |
| 315 | y: ypct.toFixed(2), | |
| 316 | vw: window.innerWidth || 0 | |
| 317 | }, ec))); | |
| 318 | // Rage-click detection: 3+ clicks on same element within 1s | |
| 319 | var now = Date.now(); | |
| 320 | clickStamps = clickStamps.filter(function (c) { return now - c.ts < 1000; }); | |
| 321 | clickStamps.push({ ts: now, sel: sel }); | |
| 322 | var same = 0; | |
| 323 | for (var i = 0; i < clickStamps.length; i++) { if (clickStamps[i].sel === sel) same++; } | |
| 324 | if (same >= 3) { | |
| 325 | send(COLLECT, basePayload(Object.assign({ t: 'rage_click', sel: sel }, ec))); | |
| 326 | clickStamps = []; | |
| 327 | } | |
| 328 | }, true); | |
| 329 | ||
| 330 | document.addEventListener('submit', function (e) { | |
| 331 | var f = e.target; | |
| 332 | var sel = selectorPath(f); | |
| 333 | var ec = pickExperimentContext(); | |
| 334 | send(COLLECT, basePayload(Object.assign({ | |
| 335 | t: 'form_submit', sel: sel, | |
| 336 | name: (f.getAttribute && f.getAttribute('name')) || '' | |
| 337 | }, ec))); | |
| 338 | }, true); | |
| 339 | ||
| 340 | /* ---- Public API for the customer site --------------------------- */ | |
| 341 | window.abforge = { | |
| 342 | event: function (name, value, meta) { | |
| 343 | var ec = pickExperimentContext(); | |
| 344 | send(COLLECT, basePayload(Object.assign({ | |
| 345 | t: 'custom', name: String(name).slice(0, 120), | |
| 346 | v: typeof value === 'number' ? value : null, | |
| 347 | meta: meta ? JSON.stringify(meta).slice(0, 480) : '' | |
| 348 | }, ec))); | |
| 349 | }, | |
| 350 | goal: function (name, value) { | |
| 351 | send(GOAL, basePayload({ | |
| 352 | name: String(name).slice(0, 120), | |
| 353 | v: typeof value === 'number' ? value : null | |
| 354 | })); | |
| 355 | }, | |
| 356 | identify: function (props) { | |
| 357 | // Best-effort: just attach as a custom event so it ends up on the timeline | |
| 358 | this.event('identify', null, props); | |
| 359 | }, | |
| 360 | isExposed: function () { return ACTIVE_EXPERIMENTS.slice(0); } | |
| 361 | }; | |
| 362 | ||
| 363 | /* ---- Boot ------------------------------------------------------- */ | |
| 364 | function boot() { | |
| 365 | fetchExperiments().then(sendPageview).catch(sendPageview); | |
| 366 | } | |
| 367 | if (document.readyState === 'loading') { | |
| 368 | document.addEventListener('DOMContentLoaded', boot); | |
| 369 | } else { | |
| 370 | boot(); | |
| 371 | } | |
| 372 | })(); |