PageSourceSearch

https://www.brasil247.com/wp-content/themes/tiebreak/assets/js/b247-same-day-beacon.js?m=1790815749g

js brasil247.com collected 2026-10-01 08:41:25 UTC 7,599 bytes, 251 lines download raw bytes

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.