added on local at 2026-07-01 12:34:06
| 1 | /* ===================================================================== | |
| 2 | TaskForge -- Visitor tracking client. | |
| 3 | Loaded on every page (marketing + in-app). No third-party deps. | |
| 4 | ||
| 5 | What it does: | |
| 6 | 1. Generates / reads a UUID cookie (taskforge_track) | |
| 7 | 2. POSTs an "init" event with full client fingerprint | |
| 8 | (OS, browser, screen, viewport, language, timezone, touch, ...) | |
| 9 | 3. Tracks page views, clicks (with x/y%), max scroll depth | |
| 10 | ||
| 11 | Adapted from MODS::WebSTLs/track.js -- support chat + push-link | |
| 12 | widget removed. | |
| 13 | ===================================================================== */ | |
| 14 | (function () { | |
| 15 | 'use strict'; | |
| 16 | ||
| 17 | /* ---- Admin / ingest-loop guard ---------------------------------- */ | |
| 18 | // Don't track admin/track endpoints (would create infinite recursion | |
| 19 | // on /track.cgi itself if its 200 response somehow rendered HTML). | |
| 20 | var __path = (window.location && window.location.pathname) || ''; | |
| 21 | if (/^\/(?:admin|track|_)/.test(__path)) return; | |
| 22 | ||
| 23 | /* ---- Configuration ---------------------------------------------- */ | |
| 24 | var INGEST_URL = '/track.cgi'; | |
| 25 | var HEATMAP_SAMPLE_RATE = 1.0; // 1.0 = log every click | |
| 26 | ||
| 27 | /* ---- UUID + cookie ---------------------------------------------- */ | |
| 28 | function uuid4() { | |
| 29 | return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { | |
| 30 | var r = Math.random() * 16 | 0; | |
| 31 | var v = c === 'x' ? r : (r & 0x3 | 0x8); | |
| 32 | return v.toString(16); | |
| 33 | }); | |
| 34 | } | |
| 35 | function getCookie(name) { | |
| 36 | var m = document.cookie.match(new RegExp('(?:^|; )' + name + '=([^;]*)')); | |
| 37 | return m ? decodeURIComponent(m[1]) : ''; | |
| 38 | } | |
| 39 | function setCookie(name, value, days) { | |
| 40 | var expires = ''; | |
| 41 | if (days) { | |
| 42 | var d = new Date(); | |
| 43 | d.setTime(d.getTime() + days * 86400000); | |
| 44 | expires = '; expires=' + d.toUTCString(); | |
| 45 | } | |
| 46 | document.cookie = name + '=' + encodeURIComponent(value) + expires + '; path=/; SameSite=Lax'; | |
| 47 | } | |
| 48 | var TOKEN = getCookie('taskforge_track'); | |
| 49 | if (!/^[a-f0-9]{8}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{4}-[a-f0-9]{12}$/.test(TOKEN)) { | |
| 50 | TOKEN = uuid4(); | |
| 51 | setCookie('taskforge_track', TOKEN, 365); | |
| 52 | } | |
| 53 | ||
| 54 | /* ---- Client fingerprint ----------------------------------------- */ | |
| 55 | function detectDevice() { | |
| 56 | var ua = navigator.userAgent || ''; | |
| 57 | if (/iPad|Tablet/i.test(ua)) return 'tablet'; | |
| 58 | if (/Mobi/i.test(ua) || /iPhone|Android/i.test(ua)) return 'mobile'; | |
| 59 | return 'desktop'; | |
| 60 | } | |
| 61 | function detectOS() { | |
| 62 | var ua = navigator.userAgent || ''; | |
| 63 | if (/Windows NT 10/i.test(ua)) return { name: 'Windows', version: '10/11' }; | |
| 64 | if (/Windows NT ([\d.]+)/i.test(ua)) return { name: 'Windows', version: RegExp.$1 }; | |
| 65 | if (/Mac OS X ([\d_]+)/i.test(ua)) return { name: 'macOS', version: RegExp.$1.replace(/_/g, '.') }; | |
| 66 | if (/iPhone|iPad|iPod/i.test(ua)) return { name: 'iOS', version: (ua.match(/OS ([\d_]+)/) || [,''])[1].replace(/_/g, '.') }; | |
| 67 | if (/Android ([\d.]+)/i.test(ua)) return { name: 'Android', version: RegExp.$1 }; | |
| 68 | if (/Linux/i.test(ua)) return { name: 'Linux', version: '' }; | |
| 69 | return { name: '', version: '' }; | |
| 70 | } | |
| 71 | function detectBrowser() { | |
| 72 | var ua = navigator.userAgent || ''; | |
| 73 | if (/Edg\/([\d.]+)/.test(ua)) return { name: 'Edge', version: RegExp.$1 }; | |
| 74 | if (/OPR\/([\d.]+)/.test(ua)) return { name: 'Opera', version: RegExp.$1 }; | |
| 75 | if (/Firefox\/([\d.]+)/.test(ua)) return { name: 'Firefox', version: RegExp.$1 }; | |
| 76 | if (/Chrome\/([\d.]+)/.test(ua)) return { name: 'Chrome', version: RegExp.$1 }; | |
| 77 | if (/Safari\/.*Version\/([\d.]+)/.test(ua)) return { name: 'Safari', version: RegExp.$1 }; | |
| 78 | if (/Safari/.test(ua)) return { name: 'Safari', version: '' }; | |
| 79 | return { name: '', version: '' }; | |
| 80 | } | |
| 81 | ||
| 82 | function buildFingerprint() { | |
| 83 | var os = detectOS(); | |
| 84 | var br = detectBrowser(); | |
| 85 | var tz = ''; | |
| 86 | try { tz = Intl.DateTimeFormat().resolvedOptions().timeZone || ''; } catch (e) {} | |
| 87 | return { | |
| 88 | os_name: os.name, | |
| 89 | os_version: os.version, | |
| 90 | browser_name: br.name, | |
| 91 | browser_version: br.version, | |
| 92 | device_type: detectDevice(), | |
| 93 | screen_w: (window.screen && window.screen.width) || 0, | |
| 94 | screen_h: (window.screen && window.screen.height) || 0, | |
| 95 | viewport_w: window.innerWidth || 0, | |
| 96 | viewport_h: window.innerHeight || 0, | |
| 97 | color_depth: (window.screen && window.screen.colorDepth) || 0, | |
| 98 | pixel_ratio: window.devicePixelRatio || 1, | |
| 99 | touch_capable: ('ontouchstart' in window || (navigator.maxTouchPoints > 0)) ? 1 : 0, | |
| 100 | language: navigator.language || '', | |
| 101 | timezone: tz, | |
| 102 | referrer: document.referrer || '', | |
| 103 | current_page_path: location.pathname + location.search | |
| 104 | }; | |
| 105 | } | |
| 106 | ||
| 107 | /* ---- Tiny JSON POST helper -------------------------------------- */ | |
| 108 | function post(url, obj) { | |
| 109 | return fetch(url, { | |
| 110 | method: 'POST', | |
| 111 | headers: { 'Content-Type': 'application/json' }, | |
| 112 | body: JSON.stringify(obj), | |
| 113 | credentials: 'same-origin', | |
| 114 | keepalive: true | |
| 115 | }).then(function (r) { return r.json(); }).catch(function () { return null; }); | |
| 116 | } | |
| 117 | ||
| 118 | /* ---- 1. Init ---------------------------------------------------- */ | |
| 119 | var fp = buildFingerprint(); | |
| 120 | fp.kind = 'init'; | |
| 121 | fp.token = TOKEN; | |
| 122 | fp.page = location.pathname + location.search; | |
| 123 | post(INGEST_URL, fp).then(function () { | |
| 124 | sendEvent('page_view', {}); | |
| 125 | }); | |
| 126 | ||
| 127 | /* ---- 2. Event helper ------------------------------------------- */ | |
| 128 | function sendEvent(type, extra) { | |
| 129 | var body = { | |
| 130 | kind: 'event', | |
| 131 | token: TOKEN, | |
| 132 | type: type, | |
| 133 | page: location.pathname + location.search | |
| 134 | }; | |
| 135 | if (extra) for (var k in extra) if (extra.hasOwnProperty(k)) body[k] = extra[k]; | |
| 136 | post(INGEST_URL, body); | |
| 137 | } | |
| 138 | ||
| 139 | /* ---- 3. Click tracking ----------------------------------------- */ | |
| 140 | function selectorPath(el) { | |
| 141 | if (!el || el.nodeType !== 1) return ''; | |
| 142 | var parts = []; | |
| 143 | var depth = 0; | |
| 144 | while (el && el.nodeType === 1 && depth < 8) { | |
| 145 | var tag = (el.tagName || '').toLowerCase(); | |
| 146 | if (!tag || tag === 'html') break; | |
| 147 | if (el.id && /^[A-Za-z][\w\-:.]*$/.test(el.id)) { | |
| 148 | parts.unshift('#' + el.id); | |
| 149 | break; | |
| 150 | } | |
| 151 | var part = tag; | |
| 152 | var parent = el.parentNode; | |
| 153 | if (parent && parent.children && parent.children.length > 1) { | |
| 154 | var n = 1; | |
| 155 | for (var i = 0; i < parent.children.length; i++) { | |
| 156 | var sib = parent.children[i]; | |
| 157 | if (sib === el) break; | |
| 158 | if (sib.tagName === el.tagName) n++; | |
| 159 | } | |
| 160 | part += ':nth-of-type(' + n + ')'; | |
| 161 | } | |
| 162 | parts.unshift(part); | |
| 163 | if (tag === 'body') break; | |
| 164 | el = parent; | |
| 165 | depth++; | |
| 166 | } | |
| 167 | return parts.join('>').slice(0, 500); | |
| 168 | } | |
| 169 | ||
| 170 | document.addEventListener('click', function (e) { | |
| 171 | if (Math.random() > HEATMAP_SAMPLE_RATE) return; | |
| 172 | var t = e.target; | |
| 173 | var sel = selectorPath(t); | |
| 174 | ||
| 175 | var ox = 0.5, oy = 0.5, haveOffsets = 0; | |
| 176 | try { | |
| 177 | var r = t.getBoundingClientRect(); | |
| 178 | if (r && r.width > 0 && r.height > 0) { | |
| 179 | ox = (e.clientX - r.left) / r.width; | |
| 180 | oy = (e.clientY - r.top) / r.height; | |
| 181 | if (ox < 0) ox = 0; else if (ox > 1) ox = 1; | |
| 182 | if (oy < 0) oy = 0; else if (oy > 1) oy = 1; | |
| 183 | haveOffsets = 1; | |
| 184 | } | |
| 185 | } catch (err) {} | |
| 186 | ||
| 187 | sendEvent('click', { | |
| 188 | selector: sel, | |
| 189 | x_pct: ((e.clientX / Math.max(window.innerWidth, 1)) * 100).toFixed(2), | |
| 190 | y_pct: (((e.clientY + window.scrollY) / Math.max(document.body.scrollHeight, 1)) * 100).toFixed(2), | |
| 191 | offset_x_pct: haveOffsets ? ox.toFixed(4) : '', | |
| 192 | offset_y_pct: haveOffsets ? oy.toFixed(4) : '' | |
| 193 | }); | |
| 194 | }, true); | |
| 195 | ||
| 196 | /* ---- 4. Scroll depth tracking ---------------------------------- */ | |
| 197 | var maxScrollPct = 0; | |
| 198 | var scrollTimer = null; | |
| 199 | window.addEventListener('scroll', function () { | |
| 200 | var h = Math.max(document.body.scrollHeight - window.innerHeight, 1); | |
| 201 | var pct = Math.min(100, Math.round((window.scrollY / h) * 100)); | |
| 202 | if (pct > maxScrollPct) maxScrollPct = pct; | |
| 203 | clearTimeout(scrollTimer); | |
| 204 | scrollTimer = setTimeout(function () { | |
| 205 | sendEvent('scroll', { scroll_pct: maxScrollPct }); | |
| 206 | }, 1500); | |
| 207 | }, { passive: true }); | |
| 208 | ||
| 209 | })(); |