PageSourceSearch

https://theanswerportland.com/lib/ov.js

js theanswerportland.com collected 2026-09-29 02:46:14 UTC 19,662 bytes, 442 lines download raw bytes

1(async function () {
2
3  // ── 0. Identity Hash Helpers (optional integration surface) ──
4  function toHex(buffer) {
5    var bytes = new Uint8Array(buffer);
6    var hex = '';
7    for (var i = 0; i < bytes.length; i++) {
8      hex += bytes[i].toString(16).padStart(2, '0');
9    }
10    return hex;
11  }
12
13  function normalizeEmail(email) {
14    if (typeof email !== 'string') return null;
15
16    var trimmed = email.trim().toLowerCase();
17    if (!trimmed) return null;
18
19    var at = trimmed.indexOf('@');
20    if (at <= 0 || at === trimmed.length - 1) return null;
21
22    var local = trimmed.slice(0, at);
23    var domain = trimmed.slice(at + 1);
24    if (!local || !domain) return null;
25
26    // Gmail-style canonicalisation to reduce duplicate identities.
27    if (domain === 'gmail.com' || domain === 'googlemail.com') {
28      domain = 'gmail.com';
29      var plus = local.indexOf('+');
30      if (plus >= 0) local = local.slice(0, plus);
31      local = local.replace(/\./g, '');
32    }
33
34    return local + '@' + domain;
35  }
36
37  function normalizePhoneE164(phone, defaultCountryCode) {
38    if (typeof phone !== 'string') return null;
39
40    var trimmed = phone.trim();
41    if (!trimmed) return null;
42
43    // Drop common extension suffixes (x123, ext. 123).
44    trimmed = trimmed.replace(/(?:ext\.?|x)\s*\d+$/i, '').trim();
45    if (!trimmed) return null;
46
47    // Convert international prefix (00...) to +...
48    if (trimmed.indexOf('00') === 0) trimmed = '+' + trimmed.slice(2);
49
50    var hasPlus = trimmed.charAt(0) === '+';
51    var digits = trimmed.replace(/\D/g, '');
52    if (!digits) return null;
53
54    if (hasPlus) return '+' + digits;
55
56    var cc = typeof defaultCountryCode === 'string'
57      ? defaultCountryCode.replace(/\D/g, '')
58      : '';
59    if (cc) return '+' + cc + digits;
60
61    // Conservative fallback for NANP-only numbers when no country is provided.
62    if (digits.length === 11 && digits.charAt(0) === '1') return '+' + digits;
63    if (digits.length === 10) return '+1' + digits;
64
65    return null;
66  }
67
68  async function sha256Hex(input) {
69    if (typeof input !== 'string' || !input) return null;
70    if (!window.crypto || !window.crypto.subtle) return null;
71
72    var data = new TextEncoder().encode(input);
73    var digest = await window.crypto.subtle.digest('SHA-256', data);
74    return toHex(digest);
75  }
76
77  async function hashEmailSha256(email) {
78    var normalized = normalizeEmail(email);
79    if (!normalized) return null;
80    return sha256Hex(normalized);
81  }
82
83  async function hashPhoneSha256(phone, defaultCountryCode) {
84    var normalized = normalizePhoneE164(phone, defaultCountryCode);
85    if (!normalized) return null;
86    return sha256Hex(normalized);
87  }
88
89  function getOvid() {
90    try {
91      return localStorage.getItem('ovid') || null;
92    } catch (_) {
93      return null;
94    }
95  }
96
97  // Public helper surface for login providers (Audience.io, etc.).
98  // Example: await window.OneView.hashEmailSha256('[email protected]')
99  window.OneView = {
100    getOvid: getOvid,
101    normalizeEmail: normalizeEmail,
102    normalizePhoneE164: normalizePhoneE164,
103    sha256Hex: sha256Hex,
104    hashEmailSha256: hashEmailSha256,
105    hashPhoneSha256: hashPhoneSha256
106  };
107
108  // ── 1. Consent Detection ──────────────────────────────────────
109  function parseAdmiralCookie() {
110    var val = (document.cookie.match(/(?:^|;\s*)__adm_consent=([^;]*)/) || [])[1];
111    if (!val) return null;
112    if (val === '1' || val === 'granted') return 'granted';
113    if (val === '0' || val === 'denied' || val === 'opt_out') return 'opt_out';
114    return null;
115  }
116
117  function getConsentStatus() {
118    // Prefer standards-based CMP signals when available.
119    // GPP API
120    try {
121      if (typeof window.__gppapi === 'function') {
122        return new Promise(function (resolve) {
123          window.__gppapi('ping', 1, function (pingData, success) {
124            if (!success || !pingData) { resolve(null); return; }
125            // Wait until the CMP has finished loading and captured a decision.
126            if (pingData.signalStatus !== 'ready') { resolve(null); return; }
127            // Signal is ready — consent has been expressed. Treat as granted;
128            // callers needing section-level opt-out should parse pingData.gppString.
129            resolve('granted');
130          });
131        });
132      }
133    } catch (_) {}
134
135    // TCF API (EU/UK)
136    try {
137      if (typeof window.__tcfapi === 'function') {
138        return new Promise(function (resolve) {
139          window.__tcfapi('getTCData', 2, function (tcData, success) {
140            if (!success || !tcData) { resolve(null); return; }
141            // Conservative mapping: if CMP UI is shown and no positive signal, treat as opt-out.
142            resolve(tcData.gdprApplies && !tcData.tcString ? 'opt_out' : 'granted');
143          });
144        });
145      }
146    } catch (_) {}
147
148    // USP API (legacy US Privacy)
149    try {
150      if (typeof window.__uspapi === 'function') {
151        return new Promise(function (resolve) {
152          window.__uspapi('getUSPData', 1, function (uspData, success) {
153            if (!success || !uspData || !uspData.uspString) { resolve(null); return; }
154            // CCPA string example: 1YNN => user opted out of sale/sharing.
155            resolve(uspData.uspString.charAt(2) === 'Y' ? 'opt_out' : 'granted');
156          });
157        });
158      }
159    } catch (_) {}
160
161    // Fallback for current Admiral deployments.
162    return Promise.resolve(parseAdmiralCookie());
163  }
164
165  // ── 2. Privacy Gate ───────────────────────────────────────────
166  // Global Privacy Control (browser-level)
167  if (navigator.globalPrivacyControl) {
168    localStorage.removeItem('ovid');   // clear any stale identity
169    return;
170  }
171
172  // CMP consent from standards APIs (fallback to Admiral cookie)
173  var cmpConsent = await getConsentStatus();
174  if (cmpConsent === 'opt_out' || cmpConsent === 'denied') {
175    localStorage.removeItem('ovid');   // clear any stale identity
176    return;
177  }
178
179  // ── 3. Link Decoration (Linker Utility — see §6) ──────────────
180  // Only runs after consent is confirmed. Passing a cross-site identifier
181  // (?ov_id=) to another domain without consent would be cross-site tracking
182  // against the user's expressed preference.
183  // OV_NETWORK is generated at startup from Api/config/network_sites.yaml by
184  // ClientScriptCache. The fallback list inside the sentinel is only used in
185  // unit tests / when the script is loaded without server substitution.
186  var OV_NETWORK = new Set(/*<OV_NETWORK_HOSTS>*/["ibelieve.com","wava.com","560theanswer.com","hotair.com","kdow.biz","twincitiesbusinessradio.com","money1055.com","983fmtheword.com","faithtalk995.com","kfax.com","thewordsacramento.com","thewordfm1007.com","thewordseattle.com","kkht.com","kkla.com","am980themission.com","truetalk800.com","kpdq.com","kprz.com","faithtalk1360.com","990amtheword.com","947fmtheword.com","am630theword.com","thewordfm.com","familyvaluesradio1010.com","wavaam.com","wezeradio.com","wfil.com","thewordcleveland.com","faithtalkdetroit.com","wmca.com","faithtalk970.com","wordfm.com","wpitradio.com","thewordcolumbus.com","wrolradio.com","letstalkfaith.com","thewordorlando.com","1160hope.com","familytalktoday.com","theanswersandiego.com","1011fmtheanswer.com","960thepatriot.com","theanswerseattle.com","930amtheanswer.com","am1070theanswer.com","710knus.com","am870theanswer.com","660amtheanswer.com","am590theanswer.com","am1460theanswer.com","theanswerdetroit.com","am920theanswer.com","theanswertampa.com","whkradio.com","theanswersarasota.com","990theanswer.com","am970theanswer.com","theanswerorlando.com","theanswerpgh.com","am570theanswer.com","989theanswer.com","am1280thepatriot.com","860amtheanswer.com","959columbus.com","faithtalk590.com","am1380theanswer.com","freedom1570.com","931elrey.com","theanswerportland.com","halfofftuitions.com","salempodcastnetwork.com","gospel1190.com","kdia.com","lapoderosa.com","lapatronaseattle.com","thewordmiami.com","oneplace.com","godtube.com","biblestudytools.com","crosswalk.com","christianity.com"]/*</OV_NETWORK_HOSTS>*/);
187
188  function normalizeHost(hostname) {
189    if (!hostname) return '';
190    var h = hostname.toLowerCase();
191    return h.indexOf('www.') === 0 ? h.slice(4) : h;
192  }
193
194  function decorateLink(e) {
195    var link = e.target.closest('a');
196    if (!link || !link.href) return;
197    try {
198      var url = new URL(link.href);
199      var currentOvid = localStorage.getItem('ovid');
200      var targetHost = normalizeHost(url.hostname);
201      var pageHost = normalizeHost(location.hostname);
202      // Only decorate cross-site links to other network sites. Same-site links
203      // are identified via the HttpOnly cookie the Nexus already set on this
204      // domain, so adding ?ov_id= is redundant and pollutes the URL bar,
205      // browser history, and server access logs. Mirrors GA4 linker behavior.
206      if (targetHost !== pageHost && OV_NETWORK.has(targetHost) && currentOvid) {
207        url.searchParams.set('ov_id', currentOvid);
208        link.href = url.toString();
209      }
210    } catch (_) {
211      // Ignore invalid URLs
212    }
213  }
214
215  document.addEventListener('mousedown', decorateLink);
216  document.addEventListener('touchstart', decorateLink);
217
218  // ── 4. Sync ───────────────────────────────────────────────────
219  // Build headers — forward CMP consent and linker ID (if present) to the Nexus.
220  var headers = {};
221  if (cmpConsent) headers['X-Consent-Status'] = cmpConsent;
222  var localStorageOvid = localStorage.getItem('ovid');
223
224  // If arriving via a decorated link from another network site, the ?ov_id=
225  // parameter carries the visitor's OVID from the originating site. Forward it
226  // as X-OV-ID so the Nexus can recognise this as a known visitor rather than
227  // generating a new OVID. The Nexus validates it is a well-formed UUID before
228  // trusting it. The cookie (set by the Nexus response) then takes over for all
229  // future visits on this domain.
230  var urlOvId = new URLSearchParams(location.search).get('ov_id');
231  var syncUrl = '/lib/ov-sync';
232  if (urlOvId) {
233    headers['X-OV-ID'] = urlOvId;
234    // Capture the source site hostname from the browser's referrer so the
235    // Nexus can record the full A→B linker flow without requiring a proxy
236    // contract change (query params are forwarded transparently by reverse proxies).
237    var ref = document.referrer;
238    if (ref) {
239      try {
240        var fromHost = new URL(ref).hostname;
241        // Only record cross-site arrivals — skip if the referrer is the same
242        // domain (e.g. a visitor who navigates internally while ov_id is still
243        // in the URL from a prior cross-site click).
244        if (fromHost && fromHost !== location.hostname)
245          syncUrl = '/lib/ov-sync?ov_from_host=' + encodeURIComponent(fromHost);
246      } catch (_) {}
247    }
248  }
249  if (!urlOvId && localStorageOvid) headers['X-OV-LS-ID'] = localStorageOvid;
250
251  // Early-exit if this tab already synced in the current browser session.
252  // Do not rely on reading the ovid cookie from document.cookie because
253  // production sets it as HttpOnly, which is not visible to client JS.
254  var alreadySynced = sessionStorage.getItem('ov_session_sent');
255  if (alreadySynced) return;
256
257  // Cross-tab dedupe: prevent immediate duplicate session_start events when
258  // users open multiple same-site tabs in a short window.
259  // Uses a fixed GA-style window of 30 minutes.
260  var SESSION_START_DEDUPE_MS = 30 * 60 * 1000;
261  var SESSION_START_STATE_KEY = 'ov_session_state_v1';
262
263  function readSessionState() {
264    try {
265      return JSON.parse(localStorage.getItem(SESSION_START_STATE_KEY) || '{}');
266    } catch (_) {
267      return {};
268    }
269  }
270
271  function writeSessionState(state) {
272    try {
273      localStorage.setItem(SESSION_START_STATE_KEY, JSON.stringify(state));
274    } catch (_) {
275      // Ignore storage quota or access errors.
276    }
277  }
278
279  function shouldSendSessionStart(host, ovid, nowMs) {
280    var state = readSessionState();
281    var last = state[host];
282    if (!last || typeof last.lastStartMs !== 'number') return true;
283
284    // New OVID on the same host should create a fresh session immediately.
285    if (last.lastOvid && ovid && last.lastOvid !== ovid) return true;
286
287    return nowMs - last.lastStartMs >= SESSION_START_DEDUPE_MS;
288  }
289
290  function markSessionStart(host, ovid, nowMs) {
291    var state = readSessionState();
292    state[host] = {
293      lastStartMs: nowMs,
294      lastOvid: ovid || null
295    };
296    writeSessionState(state);
297  }
298
299  function getSameSiteLinkFromEvent(e) {
300    var link = e.target && e.target.closest ? e.target.closest('a') : null;
301    if (!link || !link.href) return null;
302
303    try {
304      var url = new URL(link.href, location.href);
305      if (url.hostname !== location.hostname) return null;
306      return { link: link, url: url };
307    } catch (_) {
308      return null;
309    }
310  }
311
312  function isLikelyNewTabIntent(e, link) {
313    if (!e || !link) return false;
314
315    if ((link.target || '').toLowerCase() === '_blank') return true;
316    if (e.metaKey || e.ctrlKey) return true;
317    if (e.shiftKey) return true;
318    if (e.type === 'auxclick' && e.button === 1) return true;
319
320    return false;
321  }
322
323  function handleLikelySameSiteNewTab(e) {
324    var linkInfo = getSameSiteLinkFromEvent(e);
325    if (!linkInfo) return;
326    if (!isLikelyNewTabIntent(e, linkInfo.link)) return;
327
328    var nowMs = Date.now();
329    var host = location.hostname;
330    var sessionOvid = localStorage.getItem('ovid') || null;
331    markSessionStart(host, sessionOvid, nowMs);
332  }
333
334  // Supplemental signal: when users intentionally open same-site links in a
335  // new tab/window, refresh the shared timer so the landing tab does not emit
336  // an immediate duplicate session_start.
337  document.addEventListener('click', handleLikelySameSiteNewTab, true);
338  document.addEventListener('auxclick', handleLikelySameSiteNewTab, true);
339
340  // ── 4b. GAM Audience Targeting ────────────────────────────────
341  // Apply the server-resolved anonymous-audience key-values to Google Ad
342  // Manager. The values originate from BigQuery segment views, are published
343  // into Firestore/Redis, and returned on the sync response. We push onto the
344  // GPT command queue so this is safe whether GPT has loaded yet or not — the
345  // commands run as soon as googletag is ready, before the first ad request
346  // when ov.js is placed in the page head ahead of slot definitions.
347  function applyGamTargeting(gamKeyValues) {
348    if (!gamKeyValues || typeof gamKeyValues !== 'object') return;
349    var keys = Object.keys(gamKeyValues);
350    if (keys.length === 0) return;
351    window.googletag = window.googletag || {};
352    window.googletag.cmd = window.googletag.cmd || [];
353    window.googletag.cmd.push(function () {
354      try {
355        var pubads = window.googletag.pubads();
356        for (var i = 0; i < keys.length; i++) {
357          var key = keys[i];
358          var value = gamKeyValues[key];
359          // GPT accepts a string or an array of strings.
360          pubads.setTargeting(key, value);
361        }
362      } catch (e) { /* GPT unavailable or slots already rendered — ignore */ }
363    });
364  }
365
366  // ── 5. Engagement Gate ────────────────────────────────────────
367  // Defer the sync until the visitor shows engagement: scroll, click, or 5s
368  // dwell. Headless bots and instant bounces never trigger — no API call,
369  // no Firestore write, no Cloud Run billing for non-human traffic.
370  // Note: the server-side unconfirmed-OVID deferral still applies on top of
371  // this — genuine first-time visitors won't write to Firestore until visit 2.
372  var syncFired = false;
373  function doSync() {
374    if (syncFired) return;
375    syncFired = true;
376    // ENG-051: a unique URL + no-store defeats partner-side caches that would
377    // hand one visitor's OVID to everyone in the cache window, and IIS URL
378    // Rewrite's per-URL rule cache that replays one visitor's forwarded headers.
379    var bustedUrl = syncUrl + (syncUrl.indexOf('?') < 0 ? '?' : '&') + '_ovr=' +
380      Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
381    fetch(bustedUrl, { credentials: 'include', headers: headers, cache: 'no-store' })
382      .then(function (r) { return r.json(); })
383      .then(function (data) {
384        if (data.status === 'opted_out') return;        // server-side backstop
385        if (data.ovid) localStorage.setItem('ovid', data.ovid);
386        sessionStorage.setItem('ov_session_sent', '1');
387
388        // Apply anonymous-audience targeting to GAM (no-op when absent).
389        applyGamTargeting(data.gamKeyValues);
390
391        // ── 5. Session Start ─────────────────────────────────────
392        // Fire only when the host-level dedupe window has elapsed.
393        // This avoids counting immediate same-site new-tab opens as new sessions.
394        var nowMs = Date.now();
395        var host = location.hostname;
396        var sessionOvid = data.ovid || localStorage.getItem('ovid') || null;
397        if (shouldSendSessionStart(host, sessionOvid, nowMs)) {
398          var payload = JSON.stringify({
399            event: 'session_start',
400            ovid: sessionOvid,
401            ts: nowMs,
402            sessionProof: data.sessionProof || null
403          });
404
405          // ENG-051: unique URL per call; IIS URL Rewrite caches rule results (incl. forwarded
406          // header values) per URL, which leaked one visitor's headers to the next.
407          var sessionStartUrl = '/lib/ov-session-start?_ovr=' +
408            Date.now().toString(36) + Math.random().toString(36).slice(2, 10);
409
410          if (navigator.sendBeacon) {
411            // Wrap in Blob to set Content-Type: application/json.
412            // sendBeacon with a plain string sends text/plain, which ASP.NET Core
413            // minimal APIs won't bind to the SessionStartRequest model (returns 415).
414            var beacon = new Blob([payload], { type: 'application/json' });
415            var sent = navigator.sendBeacon(sessionStartUrl, beacon);
416            if (!sent) {
417              // UA queue was full; fall back to fetch so the event is not silently dropped.
418              fetch(sessionStartUrl, {
419                method: 'POST',
420                headers: { 'Content-Type': 'application/json' },
421                body: payload,
422                keepalive: true
423              });
424            }
425          } else {
426            fetch(sessionStartUrl, {
427              method: 'POST',
428              headers: { 'Content-Type': 'application/json' },
429              body: payload,
430              keepalive: true
431            });
432          }
433
434          markSessionStart(host, sessionOvid, nowMs);
435        }
436      });
437  }
438
439  document.addEventListener('scroll', doSync, { once: true, passive: true });
440  document.addEventListener('click',  doSync, { once: true });
441  setTimeout(doSync, 5000);
442})();

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.