1/** 2 * Attribution Script 3 * 4 * Captures paid campaign parameters (UTM, gclid, fbclid, etc.) from landing URLs, 5 * stores them in localStorage, and decorates outbound links to configured target 6 * domains with a base64-encoded query parameter. 7 * 8 * No internal consent logic â consent gating is handled externally via Cookiebot 9 * (type="text/plain" data-cookieconsent="marketing") or omitted for non-GDPR regions. 10 * 11 * Configuration via data attributes on the script tag: 12 * data-target-domains â comma-separated list of domains to decorate (required) 13 * data-storage-key â localStorage key name (default: "orgsource") 14 * data-param-name â query parameter name appended to links (default: "orgsource") 15 * data-tracked-params â comma-separated override of tracked params allowlist 16 * data-ttl-days â days before stored data expires (default: 90) 17 * data-allowed-domains â comma-separated list of domains where the script should run; 18 * if omitted, the script runs on all domains 19 * 20 * For GTM deployment (where document.currentScript is unavailable), use attribution-gtm.js 21 * which reads config from window.__attribution instead. 22 */ 23(function () { 24 'use strict'; 25 26 // --- Configuration ----------------------------------------------------------- 27 28 var scriptEl = document.currentScript; 29 30 var DEFAULT_TRACKED_PARAMS = [ 31 'utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content', 32 'gclid', 'fbclid' 33 ]; 34 35 function attr(name) { 36 return scriptEl && scriptEl.getAttribute(name); 37 } 38 39 // Domain allowlist â if set, only run on matching domains 40 var allowedDomainsAttr = attr('data-allowed-domains'); 41 if (allowedDomainsAttr) { 42 var allowedDomains = allowedDomainsAttr.split(',').map(function (d) { return d.trim().toLowerCase(); }).filter(Boolean); 43 var currentHost = window.location.hostname.toLowerCase(); 44 var allowed = false; 45 for (var a = 0; a < allowedDomains.length; a++) { 46 if (currentHost === allowedDomains[a] || currentHost.endsWith('.' + allowedDomains[a])) { 47 allowed = true; 48 break; 49 } 50 } 51 if (!allowed) return; 52 } 53 54 var targetDomains = (attr('data-target-domains') || '') 55 .split(',') 56 .map(function (d) { return d.trim().toLowerCase(); }) 57 .filter(Boolean); 58 59 var storageKey = attr('data-storage-key') || 'orgsource'; 60 var paramName = attr('data-param-name') || 'orgsource'; 61 62 var trackedParams = attr('data-tracked-params') 63 ? attr('data-tracked-params').split(',').map(function (p) { return p.trim(); }).filter(Boolean) 64 : DEFAULT_TRACKED_PARAMS; 65 66 var ttlDays = parseInt(attr('data-ttl-days'), 10) || 90; 67 var ttlMs = ttlDays * 24 * 60 * 60 * 1000; 68 69 // --- Helpers ----------------------------------------------------------------- 70 71 /** 72 * Encode a string to standard base64. 73 * Uses TextEncoder for UTF-8 safety, falls back to unescape/encodeURIComponent. 74 */ 75 function toUrlSafeBase64(str) { 76 var bytes; 77 if (typeof TextEncoder !== 'undefined') { 78 bytes = new TextEncoder().encode(str); 79 } else { 80 bytes = unescape(encodeURIComponent(str)); 81 } 82 83 var binary = ''; 84 for (var i = 0; i < bytes.length; i++) { 85 binary += String.fromCharCode(typeof bytes === 'string' ? bytes.charCodeAt(i) : bytes[i]); 86 } 87 88 return btoa(binary); 89 } 90 91 /** 92 * Check whether a hostname matches one of the configured target domains. 93 * Matches exactly or as a subdomain (e.g. "pay.wise.com" matches "wise.com"). 94 */ 95 function isTargetDomain(hostname) { 96 hostname = hostname.toLowerCase(); 97 for (var i = 0; i < targetDomains.length; i++) { 98 var target = targetDomains[i]; 99 if (hostname === target || hostname.endsWith('.' + target)) { 100 return true; 101 } 102 } 103 return false; 104 } 105 106 /** 107 * Append the orgsource parameter to a URL string. Returns the decorated URL, 108 * or the original URL if it already contains the parameter or has no hostname match. 109 */ 110 function decorateUrl(url) { 111 try { 112 var parsed = new URL(url, window.location.origin); 113 if (!isTargetDomain(parsed.hostname)) return url; 114 if (parsed.searchParams.has(paramName)) return url; 115 parsed.searchParams.set(paramName, encodedValue); 116 return parsed.toString(); 117 } catch (_e) { 118 return url; 119 } 120 } 121 122 /** 123 * Decorate a single anchor element if it points to a target domain. 124 */ 125 function decorateAnchor(anchor) { 126 var href = anchor.getAttribute('href'); 127 if (!href) return; 128 var decorated = decorateUrl(href); 129 if (decorated !== href) { 130 anchor.setAttribute('href', decorated); 131 } 132 } 133 134 // --- Main logic -------------------------------------------------------------- 135 136 // 1. Capture tracked params from the current URL 137 var params = {}; 138 var searchParams = new URLSearchParams(window.location.search); 139 for (var i = 0; i < trackedParams.length; i++) { 140 var key = trackedParams[i]; 141 var val = searchParams.get(key); 142 if (val) { 143 params[key] = val; 144 } 145 } 146 147 // 2. Persist to localStorage 148 var stored = {}; 149 try { 150 var raw = localStorage.getItem(storageKey); 151 if (raw) { 152 var parsed2 = JSON.parse(raw); 153 var storedTs = parsed2._ts || 0; 154 // Expire if older than TTL 155 if (storedTs && (Date.now() - storedTs > ttlMs)) { 156 localStorage.removeItem(storageKey); 157 } else { 158 stored = parsed2; 159 } 160 } 161 } catch (_e) { /* ignore parse errors */ } 162 163 // Only store params if we captured new ones AND no valid first-touch data exists
163yet. 164 // This preserves first-touch attribution â subsequent visits with different UTM params 165 // (e.g. returning via social) will not overwrite the original source. 166 var hasExistingData = Object.keys(stored).some(function (k) { return k !== '_ts'; }); 167 if (Object.keys(params).length > 0 && !hasExistingData) { 168 var merged = {}; 169 for (var k2 in params) { 170 if (params.hasOwnProperty(k2)) { 171 merged[k2] = params[k2]; 172 } 173 } 174 merged._ts = Date.now(); 175 stored = merged; 176 try { 177 localStorage.setItem(storageKey, JSON.stringify(stored)); 178 } catch (_e) { /* storage full or disabled */ } 179 } 180 181 // 3. If there's nothing stored (besides _ts), nothing to decorate â exit early 182 var payloadKeys = Object.keys(stored).filter(function (k) { return k !== '_ts'; }); 183 if (!payloadKeys.length || !targetDomains.length) return; 184 185 // 4. Encode the stored data once (exclude internal _ts from payload) 186 var payload = {}; 187 for (var p = 0; p < payloadKeys.length; p++) { 188 payload[payloadKeys[p]] = stored[payloadKeys[p]]; 189 } 190 var encodedValue = toUrlSafeBase64(JSON.stringify(payload)); 191 192 // 5. Initial scan â decorate all existing <a> elements 193 var anchors = document.querySelectorAll('a[href]'); 194 for (var j = 0; j < anchors.length; j++) { 195 decorateAnchor(anchors[j]); 196 } 197 198 // 6. Click-time delegation â catch dynamically rendered links (SPA navigation) 199 document.addEventListener('click', function (e) { 200 var target = e.target; 201 // Walk up the DOM to find the closest <a> element 202 while (target && target.tagName !== 'A') { 203 target = target.parentElement; 204 } 205 if (target && target.tagName === 'A') { 206 decorateAnchor(target); 207 } 208 }, true); // capture phase â runs before navigation 209})();
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.