https://www.brasil247.com/wp-content/themes/tiebreak/assets/js/b247-same-day-beacon.js?m=1790815749g
1/** 2 * b247-same-day-beacon.js 3 * 4 * First-party audience beacon for the Pauta.ai "Hoje" dashboard: one VIEW beacon 5 * per page load and ENGAGEMENT beacons carrying deltas (seconds engaged, scroll 6 * depth gained, completed) each time the page becomes hidden. Only for posts 7 * published in the last ~30 hours - older articles cannot appear in "Hoje", so 8 * their traffic is never sent at all. 9 * 10 * Design: GenAI repo, reports/metrics-asap-epic/00-plan.md (D9, D10, D15). 11 * 12 * - Transport: navigator.sendBeacon(url, JSON.stringify(payload)). A plain string 13 * body is sent as text/plain, a CORS "simple request": no preflight, and no 14 * fetch fallback (the response is never read). Without sendBeacon the script 15 * does nothing. 16 * - Consent: the script gates ITSELF on the consent bridge (inc/consent.php). 17 * It starts only when html[data-b247-consent] is "granted" or "na", waits for 18 * the bridge's "b247:consent" event while it is not settled, and never starts 19 * on "denied". It cannot rely on the bridge's script_loader_tag gate alone: 20 * VIP concatenates enqueued theme scripts into one shared bundle, which 21 * bypasses that gate. (The handle is still registered with 22 * b247_consent_gated_handles in case it is ever printed on its own.) 23 * - No cookies, no localStorage, no timers, no heartbeat. 24 * - Engagement is sent as DELTAS with a sequence number, so the server can add 25 * them up exactly per visit without keeping any per-visit state. visibilitychange 26 * -> hidden is the reliable signal on mobile (pagehide alone often never fires 27 * when a tab is backgrounded); pagehide is sent too. 28 * 29 * Reads window.b247SameDayBeacon = { endpoint, postId, publishedTs } (localized by 30 * b247_enqueue_same_day_beacon() in inc/same-day-beacon.php). wp_localize_script 31 * turns every scalar into a string, so the numbers are parsed here. 32 */ 33(function (w, d) { 34 'use strict'; 35 36 var MAX_AGE_SECONDS = 30 * 3600; // older posts cannot appear in "Hoje". 37 var COMPLETE_THRESHOLD = 0.9; // 90% scrolled counts as "read". 38 var MAX_SEQ = 20; // the server rejects a larger seq. 39 var MAX_SECONDS_PER_SEND = 1800; // the server rejects a larger value. 40 41 var config = w.b247SameDayBeacon; 42 if (!config || !config.endpoint) { 43 return; 44 } 45 // wp_localize_script delivers "10831", not 10831; the backend requires integers. 46 var postId = parseInt(config.postId, 10); 47 var publishedTs = parseInt(config.publishedTs, 10); 48 if (!(postId > 0) || !(publishedTs > 0)) { 49 return; 50 } 51 if (!w.navigator || typeof w.navigator.sendBeacon !== 'function') { 52 return; 53 } 54 if (Date.now() / 1000 - publishedTs > MAX_AGE_SECONDS) { 55 return; 56 } 57 58 function consentState() { 59 return d.documentElement.getAttribute('data-b247-consent') || ''; 60 } 61 62 function uuidV4() { 63 var c = w.crypto; 64 if (c && typeof c.randomUUID === 'function') { 65 return c.randomUUID(); 66 } 67 if (c && typeof c.getRandomValues === 'function') { 68 var b = new Uint8Array(16); 69 c.getRandomValues(b); 70 b[6] = (b[6] & 0x0f) | 0x40; // version 4. 71 b[8] = (b[8] & 0x3f) | 0x80; // variant 10xx. 72 var hex = []; 73 for (var i = 0; i < 16; i++) { 74 hex.push((b[i] + 0x100).toString(16).slice(1)); 75 } 76 return ( 77 hex.slice(0, 4).join('') + '-' + hex.slice(4, 6).join('') + '-' + 78 hex.slice(6, 8).join('') + '-' + hex.slice(8, 10).join('') + '-' + 79 hex.slice(10, 16).join('') 80 ); 81 } 82 return null; 83 } 84 85 /** 86 * Everything that sends. Runs once, and only after consent allows analytics. 87 */ 88 function start() { 89 var visitId = uuidV4(); 90 if (!visitId) { 91 return; 92 } 93 94 var send = function (payload) { 95 try { 96 return !!w.navigator.sendBeacon(config.endpoint, JSON.stringify(payload)); 97 } catch (e) { 98 return false; 99 } 100 }; 101 102 var base = function (type) { 103 return { 104 type: type, 105 visit_id: visitId, 106 post_id: postId, 107 published_ts: publishedTs 108 }; 109 }; 110 111 // The view: immediately, once. 112 send(base('view')); 113 114 // Engagement needs the article body to measure scroll against; without it 115 // the view beacon above is all this page sends. 116 var content = d.querySelector('.b247-article-standard .wp-block-post-content') 117 || d.querySelector('.wp-block-post-content'); 118 if (!content) { 119 return; 120 } 121 122 var maxScrollPct = 0; // 0..1 123 var completed = false;
124 var engagedMs = 0; 125 126 // What has already been reported, so each send carries only what was gained. 127 var sentSeconds = 0; 128 var sentScrollPct = 0; 129 var completedSent = false; 130 var seq = 0; 131 132 var visibleNow = function () { 133 return d.visibilityState === 'visible'; 134 }; 135 var lastTickAt = visibleNow() ? Date.now() : null; 136 137 var accrueEngagedTime = function () { 138 if (lastTickAt !== null) { 139 engagedMs += Date.now() - lastTickAt; 140 } 141 lastTickAt = visibleNow() ? Date.now() : null; 142 }; 143 144 var currentScrollPct = function () { 145 var rect = content.getBoundingClientRect(); 146 var startY = rect.top + w.scrollY; 147 var span = rect.height; 148 if (span <= 0) { 149 return maxScrollPct; 150 } 151 var pct = (w.scrollY + w.innerHeight - startY) / span; 152 if (pct < 0) { 153 return 0; 154 } 155 return pct > 1 ? 1 : pct; 156 }; 157 158 var measureScroll = function () { 159 var pct = currentScrollPct(); 160 if (pct > maxScrollPct) { 161 maxScrollPct = pct; 162 } 163 if (maxScrollPct >= COMPLETE_THRESHOLD) { 164 completed = true; 165 } 166 }; 167 168 var flush = function () { 169 accrueEngagedTime(); 170 measureScroll(); 171 if (seq > MAX_SEQ) { 172 return; 173 } 174 var seconds = Math.floor(engagedMs / 1000) - sentSeconds; 175 if (seconds > MAX_SECONDS_PER_SEND) { 176 seconds = MAX_SECONDS_PER_SEND; 177 } 178 var scrollPct = Math.round(maxScrollPct * 100); 179 var scrollDelta = scrollPct - sentScrollPct; 180 var completedNow = completed && !completedSent; 181 if (seconds < 1 && scrollDelta <= 0 && !completedNow) { 182 return; 183 } 184 var payload = base('engagement'); 185 payload.seq = seq; 186 payload.seconds = seconds > 0 ? seconds : 0; 187 payload.scroll_delta = scrollDelta > 0 ? scrollDelta : 0; 188 payload.completed_now = completedNow; 189 if (send(payload)) { 190 sentSeconds += payload.seconds; 191 sentScrollPct += payload.scroll_delta; 192 if (completedNow) { 193 completedSent = true; 194 } 195 seq += 1; 196 } 197 }; 198 199 var ticking = false; 200 var onScroll = function () { 201 if (ticking) { 202 return; 203 } 204 ticking = true; 205 w.requestAnimationFrame(function () { 206 ticking = false; 207 accrueEngagedTime(); 208 measureScroll(); 209 }); 210 }; 211 212 d.addEventListener('visibilitychange', function () { 213 if (d.visibilityState === 'hidden') { 214 flush(); 215 } else { 216 accrueEngagedTime(); // the reader came back: start counting again. 217 } 218 }); 219 220 // pagehide too: bfcache / tab close on the browsers that fire it. A second 221 // flush right after a hidden one has nothing new to say and sends nothing. 222 w.addEventListener('pagehide', flush); 223 224 w.addEventListener('scroll', onScroll, { passive: true }); 225 w.addEventListener('resize', onScroll, { passive: true }); 226 onScroll(); 227 } 228 229 // Consent first. "granted" (consented) and "na" (no consent regime applies, 230 // e.g. Brazil) start it; "denied" never does; anything else means the bridge 231 // has not settled yet, so wait for its event. 232 var state = consentState(); 233 if (state === 'denied') { 234 return; 235 } 236 if (state === 'granted' || state === 'na') { 237 start(); 238 return; 239 } 240 var onConsent = function () { 241 var settled = consentState(); 242 if (!settled) { 243 return; 244 } 245 d.removeEventListener('b247:consent', onConsent); 246 if (settled === 'granted' || settled === 'na') { 247 start(); 248 } 249 }; 250 d.addEventListener('b247:consent', onConsent); 251}(window, document));
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.