@analitikisler/ai-tag-debugger 0.20.1 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/checks.js CHANGED
@@ -4,6 +4,8 @@
4
4
  import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
6
  import { campaignParamsIn } from "./campaign.js";
7
+ import { CONSENT_SIGNALS } from "./parsers.js";
8
+ import { duplicateLoads, loadSources, splitLayers } from "./containers.js";
7
9
 
8
10
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
9
11
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
@@ -17,8 +19,10 @@ const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
17
19
  const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
18
20
  // The signals a "reject all" must keep denied. functionality_storage and security_storage cover what
19
21
  // the site needs to work, and personalization_storage its own features, so CMPs grant them on reject too.
20
- const TRACKING_SIGNALS = ["ad_storage", "analytics_storage", ...V2_SIGNALS];
21
- const TRACKING_COOKIE = /^(_ga(_.+)?|_gid|_gat(_.+)?|_gcl_.+|_fbp|_fbc|_ttp|_tt_.+|_uet(sid|vid)|_clck|_clsk|_hj.+|_pin_unauth|_scid|IDE|test_cookie|fr|li_.+|_dc_gtm_.+)$/;
22
+ const TRACKING_SIGNALS = CONSENT_SIGNALS;
23
+ /** Whether a Google hit says it was sent with any of the tracking signals granted (from gcs, and gcd for the v2 ones). */
24
+ const claimsGranted = (hit) => TRACKING_SIGNALS.some((k) => hit.consent?.[k] === "granted");
25
+ export const TRACKING_COOKIE = /^(_ga(_.+)?|_gid|_gat(_.+)?|_gcl_.+|_fbp|_fbc|_ttp|_tt_.+|_uet(sid|vid)|_clck|_clsk|_hj.+|_pin_unauth|_scid|IDE|test_cookie|fr|li_.+|_dc_gtm_.+)$/;
22
26
  const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
23
27
 
24
28
  /**
@@ -160,10 +164,17 @@ export function consentGrantedStep(run) {
160
164
  const fromUpdate = run.dataLayerLog.find(
161
165
  (e) => isConsentCommand(e, "update") && e.value[2]?.ad_storage === "granted",
162
166
  );
163
- const candidates = [run.consentStep, fromUpdate?.step].filter((s) => s !== null && s !== undefined);
167
+ // A consent platform's GTM template updates the Google tag without a dataLayer command; the tag's own state shows it.
168
+ const fromTag = tagConsentStates(run).find((st) => st.entries.ad_storage?.update === "granted");
169
+ const candidates = [run.consentStep, fromUpdate?.step, fromTag?.step].filter((s) => s !== null && s !== undefined);
164
170
  return candidates.length ? Math.min(...candidates) : null;
165
171
  }
166
172
 
173
+ /** The Google tag's own consent states a run recorded (google_tag_data.ics, kept on the steps where it changed), as { step, ...state }. */
174
+ export function tagConsentStates(run) {
175
+ return (run.steps ?? []).filter((st) => st.tagConsent).map((st) => ({ step: st.index, ...st.tagConsent }));
176
+ }
177
+
167
178
  /**
168
179
  * @param {Plan} plan
169
180
  * @param {{ journeyId: string, viewport: string, completed: boolean, note?: string, hits: any[], steps: any[], consentStep: number|null, dataLayerLog: any[] }} run
@@ -191,6 +202,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
191
202
  findings.push({ ...base, severity: "warning", check: "journey_incomplete", detail: d.journeyIncomplete(run.note) });
192
203
  }
193
204
 
205
+ // A page that was a bot challenge or crashed in the browser: the tags there may never have run, so what the run
206
+ // did not see there is unverified rather than missing.
207
+ const troubled = (run.steps ?? []).filter((st) => st.pageProblem);
208
+ const challenge = troubled.find((st) => st.pageProblem.kind === "challenge");
209
+ if (challenge) findings.push({ ...base, severity: "warning", check: "page_blocked", step: stepLabel(challenge.index), detail: d.pageBlocked(challenge.pageProblem.vendor) });
210
+ const crash = troubled.find((st) => st.pageProblem.kind === "crash");
211
+ if (crash) findings.push({ ...base, severity: "warning", check: "page_crashed", step: stepLabel(crash.index), detail: d.pageCrashed(crash.pageProblem.vendor, crash.pageProblem.refusedScripts ?? 0) });
212
+
194
213
  // Expected events that never fired.
195
214
  for (const ev of plan.events) {
196
215
  if (!expectedIn(ev, run.journeyId)) continue;
@@ -204,11 +223,11 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
204
223
  const setting = ev.platform === "ga4" && name !== canonicalEvent("page_view") ? GA4_ENHANCED.get(name) : undefined;
205
224
  findings.push({
206
225
  ...base,
207
- severity: run.completed ? "broken" : "info",
226
+ severity: run.completed && !troubled.length ? "broken" : "info",
208
227
  check: "missing_event",
209
228
  platform: ev.platform,
210
229
  event: ev.name,
211
- detail: !run.completed ? d.missingEventEarly(ev.name) : setting ? d.missingEnhanced(ev.name, setting) : d.missingEvent(ev.platform, ev.name),
230
+ detail: troubled.length ? d.missingEventUnverified(ev.name) : !run.completed ? d.missingEventEarly(ev.name) : setting ? d.missingEnhanced(ev.name, setting) : d.missingEvent(ev.platform, ev.name),
212
231
  });
213
232
  }
214
233
 
@@ -348,6 +367,22 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
348
367
  }
349
368
  }
350
369
 
370
+ // Google containers and how each got onto the page (src/containers.js). Runs saved by older versions have none.
371
+ const ui = messages(lang).ui.containers;
372
+ const source = (l) => ui.kinds[l.kind](l.by);
373
+ for (const dup of duplicateLoads(run).filter((x, i, all) => all.findIndex((y) => y.id === x.id) === i)) {
374
+ findings.push({ ...base, severity: "warning", check: "container_duplicate", step: stepLabel(dup.loads[1].step), detail: d.containerDuplicate(dup.id, dup.loads.map(source), stepLabel(dup.loads[1].step)), evidence: dup.loads.map((l) => `${l.id} ${l.kind}${l.by ? ` ${l.by}` : ""} ${l.page ?? ""}`).join("\n") });
375
+ }
376
+ const loads = loadSources(run);
377
+ for (const l of loads.filter((x, i, all) => x.environment && all.findIndex((y) => y.environment && y.id === x.id) === i)) {
378
+ findings.push({ ...base, severity: "warning", check: "container_environment", step: stepLabel(l.step), detail: d.containerEnvironment(l.id, l.environment) });
379
+ }
380
+ const split = splitLayers(run)[0];
381
+ if (split) findings.push({ ...base, severity: "info", check: "datalayer_split", step: stepLabel(split.step), detail: d.dataLayerSplit(split.layers.map((x) => `${x.id} → ${x.layer}`), stepLabel(split.step)) });
382
+ for (const l of loads.filter((x, i, all) => x.kind === "send_to" && !all.some((y) => y.id === x.id && y.kind !== "send_to") && all.findIndex((y) => y.kind === "send_to" && y.id === x.id) === i)) {
383
+ findings.push({ ...base, severity: "info", check: "container_send_to_only", platform: l.id.startsWith("G-") ? "ga4" : "google_ads", step: stepLabel(l.step), detail: d.sendToOnly(l.id, l.by) });
384
+ }
385
+
351
386
  // One event ID on two different events: the platform keeps the first and drops the other as its
352
387
  // duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
353
388
  const byId = new Map();
@@ -408,31 +443,69 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
408
443
  }
409
444
  }
410
445
 
446
+ // Cookies that carry the visit from page to page. Captures from before cookies were tracked per step have no cookieChanges.
447
+ if (run.cookieChanges) {
448
+ // A Google Ads tag keeps the landing gclid in _gcl_aw, so a later conversion can be tied to the click. GA4 alone
449
+ // never writes it, and with ad storage denied the cookie is rightly not written: only Ads, with storage granted.
450
+ const gclid = landing ? campaignParamsIn(landing.final ?? landing.requested).gclid : undefined;
451
+ const adsTag = run.hits.some((h) => h.platform === "google_ads") || (run.network ?? []).some((c) => c.provider === "google_ads");
452
+ const googleHits = run.hits.filter((h) => GOOGLE_PLATFORMS.has(h.platform));
453
+ // Without Consent Mode, no hit carries ad_storage at all, and nothing holds the cookie back.
454
+ const adStorage = googleHits.some((h) => h.consent?.ad_storage === "granted") || (googleHits.length > 0 && googleHits.every((h) => !h.consent?.ad_storage));
455
+ if (gclid && adsTag && adStorage && !run.steps.some((st) => st.identity?.gcl_aw === "landing")) {
456
+ findings.push({ ...base, severity: "warning", check: "gclid_not_stored", platform: "google_ads", step: stepLabel(landing.step), detail: d.gclidNotStored() });
457
+ }
458
+ // A GA4 client id that changes mid-visit splits it into two users. A reject in between may rightly clear the cookie.
459
+ let seen = null;
460
+ for (const st of run.steps) {
461
+ const cid = st.identity?.ga_client_id;
462
+ if (!cid) continue;
463
+ const rejectedBetween = run.rejectStep != null && run.rejectStep > seen?.step && run.rejectStep <= st.index;
464
+ if (seen && cid !== seen.cid && !rejectedBetween) {
465
+ findings.push({ ...base, severity: "warning", check: "client_id_changed", platform: "ga4", step: stepLabel(st.index), detail: d.clientIdChanged(stepLabel(seen.step), stepLabel(st.index)), evidence: `_ga ${seen.cid} -> ${cid}` });
466
+ break;
467
+ }
468
+ seen = { cid, step: st.index };
469
+ }
470
+ }
471
+
411
472
  // Consent.
412
473
  const granted = consentGrantedStep(run);
413
474
  // Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
414
475
  const firstGoogleHit = run.hits.filter((h) => GOOGLE_PLATFORMS.has(h.platform)).reduce((a, h) => (!a || sentBefore(h, a) ? h : a), null);
476
+ // The Google tag's own state also counts: a consent platform's GTM template sets the default and the update inside
477
+ // GTM, where the dataLayer never shows them.
478
+ const tagStates = tagConsentStates(run);
479
+ const tagDefault = tagStates.find((st) => st.usedDefault) ?? null;
480
+ const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
415
481
  if (plan.consent_mode) {
416
- const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
417
- if (!defaultEntry) {
482
+ if (!defaultEntry && !tagDefault) {
418
483
  findings.push({ ...base, severity: "risk", check: "consent_default_missing", detail: d.consentDefaultMissing() });
419
- } else if (firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) {
484
+ } else if ((defaultEntry && firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) || tagStates.some((st) => st.wasSetLate)) {
420
485
  findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
421
486
  }
422
487
  }
488
+ // A default the page pushed that the Google tag never applied: pushed as a plain array instead of through gtag(), to
489
+ // another dataLayer, or from a frame. Tag Assistant then says Consent Mode is not configured.
490
+ if (defaultEntry && tagStates.length && !tagDefault) {
491
+ findings.push({ ...base, severity: "warning", check: "consent_default_not_applied", step: stepLabel(defaultEntry.step), detail: d.consentDefaultNotApplied() });
492
+ }
423
493
  const defaults = run.dataLayerLog.filter((e) => isConsentCommand(e, "default"));
424
- if (plan.consent_mode && defaults.length) {
425
- const declared = new Set(defaults.flatMap((e) => Object.keys(e.value[2] ?? {})));
494
+ const declared = defaults.length
495
+ ? new Set(defaults.flatMap((e) => Object.keys(e.value[2] ?? {})))
496
+ : tagDefault ? new Set(Object.entries(tagDefault.entries).filter(([, e]) => e.default).map(([k]) => k)) : null;
497
+ if (plan.consent_mode && declared) {
426
498
  const missing = V2_SIGNALS.filter((k) => !declared.has(k));
427
499
  if (missing.length) findings.push({ ...base, severity: "risk", check: "consent_v2_missing", detail: d.consentV2Missing(missing) });
428
500
  }
429
501
  if (granted !== null) {
430
502
  const updates = run.dataLayerLog.filter((e) => isConsentCommand(e, "update"));
431
- if (updates.length) {
432
- const grantedKeys = new Set(updates.flatMap((e) => Object.entries(e.value[2] ?? {}).filter(([, v]) => v === "granted").map(([k]) => k)));
433
- const missing = V2_SIGNALS.filter((k) => !grantedKeys.has(k));
434
- if (missing.length) findings.push({ ...base, severity: "warning", check: "consent_v2_update", detail: d.consentV2Update(missing) });
435
- }
503
+ const tagGrant = tagStates.find((st) => st.entries.ad_storage?.update === "granted");
504
+ const grantedKeys = updates.length
505
+ ? new Set(updates.flatMap((e) => Object.entries(e.value[2] ?? {}).filter(([, v]) => v === "granted").map(([k]) => k)))
506
+ : tagGrant ? new Set(Object.entries(tagGrant.entries).filter(([, e]) => e.update === "granted").map(([k]) => k)) : null;
507
+ const missing = grantedKeys ? V2_SIGNALS.filter((k) => !grantedKeys.has(k)) : [];
508
+ if (missing.length) findings.push({ ...base, severity: "warning", check: "consent_v2_update", detail: d.consentV2Update(missing) });
436
509
  }
437
510
 
438
511
  // Declining: nothing may be sent or stored for ads after the visitor said no.
@@ -441,14 +514,13 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
441
514
  const reject = run.steps.find((s) => s.index === rejectStep);
442
515
  const regranted = run.dataLayerLog.find(
443
516
  (e) => e.step >= rejectStep && isConsentCommand(e, "update") && TRACKING_SIGNALS.some((k) => e.value[2]?.[k] === "granted"),
444
- );
517
+ ) ?? tagStates.find((st) => st.step >= rejectStep && TRACKING_SIGNALS.some((k) => st.entries[k]?.update === "granted"));
445
518
  if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
446
519
  else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
447
520
  const afterReject = new Map();
448
521
  for (const hit of run.hits) {
449
522
  if (hit.step < rejectStep) continue;
450
- const claimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
451
- if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted) {
523
+ if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted(hit)) {
452
524
  const k = key(hit.platform, hit.name);
453
525
  if (!afterReject.has(k)) afterReject.set(k, hit);
454
526
  }
@@ -480,8 +552,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
480
552
  const early = granted === null || hit.step < granted;
481
553
  if (!early) continue;
482
554
  const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
483
- const googleClaimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
484
- if (adPlatform || googleClaimsGranted) {
555
+ if (adPlatform || claimsGranted(hit)) {
485
556
  const k = key(hit.platform, hit.name);
486
557
  if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
487
558
  }
package/src/cli.js CHANGED
@@ -32,6 +32,7 @@ async function main(args = process.argv.slice(2)) {
32
32
  providers: { type: "string", multiple: true },
33
33
  "allow-host": { type: "string", multiple: true },
34
34
  "allow-private-network": { type: "boolean", default: false },
35
+ "allow-live": { type: "boolean", default: false },
35
36
  scripted: { type: "boolean", default: false },
36
37
  "no-ai": { type: "boolean", default: false },
37
38
  provider: { type: "string" },
@@ -79,7 +80,7 @@ async function main(args = process.argv.slice(2)) {
79
80
  }
80
81
  if (command === "mcp" && !values.help) {
81
82
  const { startMcp } = await import("./mcp.js");
82
- await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"] });
83
+ await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"], allowLive: values["allow-live"] });
83
84
  return;
84
85
  }
85
86
  if (command === "replay" && !values.help) {
package/src/cmp.js ADDED
@@ -0,0 +1,166 @@
1
+ // Which consent platform a page runs and how it is set up, read from the platform's own objects in the page,
2
+ // so a report can say what the agent otherwise has to dig out of the platform's bundle by hand.
3
+
4
+ /**
5
+ * Runs in the page. Efilli in full: its site bundle and the date it was compiled, the language in force,
6
+ * whether its own Consent Mode integration is on with the category each signal maps to, auto-blocking and TCF.
7
+ * The other platforms by name only. Null when none is found.
8
+ * @returns {null | { vendor: string, built?: string|null, script?: string|null, language?: string|null, categories?: string[],
9
+ * consentMode?: { active: boolean, mapping: Record<string,string>|null, urlPassthrough: boolean, adsDataRedaction: boolean },
10
+ * autoBlocking?: boolean, blockingRules?: number|null, tcf?: boolean }}
11
+ */
12
+ export function detectCmpInPage() {
13
+ const w = window;
14
+ try {
15
+ const efilli = w.efilliSdk;
16
+ if (efilli?.consentManager) {
17
+ // getLanguage() is the language the page matched: Efilli keeps a whole setup, mapping included, per language.
18
+ const lang = efilli.configManager?.getLanguage?.() ?? {};
19
+ const mode = lang.consentMode ?? {};
20
+ return {
21
+ vendor: "Efilli",
22
+ built: typeof w.efilliBundleCompileDate === "string" ? w.efilliBundleCompileDate : null,
23
+ script: [...document.scripts].map((s) => s.src).find((src) => /^https:\/\/bundles\.efilli\.com\//.test(src)) ?? null,
24
+ language: typeof lang.name === "string" ? lang.name : null,
25
+ categories: Object.keys(efilli.consentManager.getCategories?.() ?? {}),
26
+ consentMode: {
27
+ active: Boolean(mode.active),
28
+ mapping: mode.mapping && typeof mode.mapping === "object" ? { ...mode.mapping } : null,
29
+ urlPassthrough: Boolean(mode.url_passthrough),
30
+ adsDataRedaction: Boolean(mode.ads_data_redaction),
31
+ },
32
+ autoBlocking: Boolean(lang.autoBlocking ?? efilli.configManager?.isAutoBlockingEnabled?.()),
33
+ blockingRules: Array.isArray(lang.blockingRules) ? lang.blockingRules.length : null,
34
+ tcf: Boolean(lang.tcf?.active),
35
+ };
36
+ }
37
+ const cky = w.cookieyes?._ckyStore;
38
+ if (cky && Array.isArray(cky._categories)) {
39
+ return {
40
+ vendor: "CookieYes",
41
+ script: [...document.scripts].map((s) => s.src).find((src) => /^https:\/\/cdn-cookieyes\.com\/client_data\/[^/]+\/script\.js/.test(src)) ?? null,
42
+ law: typeof cky._bannerConfig?.activeLaw === "string" ? cky._bannerConfig.activeLaw : null,
43
+ categories: cky._categories.map((c) => c.slug).filter((s) => typeof s === "string"),
44
+ // CookieYes fixes the mapping in its code rather than its setup; read off its consent updates in November 2026.
45
+ consentMode: { fixed: true, mapping: { ad_storage: "advertisement", ad_user_data: "advertisement", ad_personalization: "advertisement", analytics_storage: "analytics", functionality_storage: "functional", personalization_storage: "functional", security_storage: "necessary" } },
46
+ autoBlocking: Array.isArray(cky._providersToBlock) && cky._providersToBlock.length > 0,
47
+ blockingRules: Array.isArray(cky._providersToBlock) ? cky._providersToBlock.length : null,
48
+ };
49
+ }
50
+ const domain = w.OneTrust?.GetDomainData?.();
51
+ if (domain && typeof domain === "object") {
52
+ // GoogleConsent names, per signal, the consent group that grants it; the groups carry their names.
53
+ const gc = domain.GoogleConsent ?? {};
54
+ const groups = Object.fromEntries((domain.Groups ?? []).map((g) => [String(g.OptanonGroupId), g.GroupName]));
55
+ const bySignal = { ad_storage: gc.GCAdStorage, analytics_storage: gc.GCAnalyticsStorage, ad_user_data: gc.GCAdUserData, ad_personalization: gc.GCAdPersonalization, functionality_storage: gc.GCFunctionalityStorage, personalization_storage: gc.GCPersonalizationStorage, security_storage: gc.GCSecurityStorage };
56
+ const mapping = Object.fromEntries(Object.entries(bySignal).filter(([, id]) => id !== undefined && id !== null && id !== "").map(([k, id]) => [k, groups[id] ? `${groups[id]} (${id})` : String(id)]));
57
+ const scripts = [...document.scripts];
58
+ // The site's own setup file, /consent/<id>/<id>.js: its date is when the setup was last published.
59
+ const id = scripts.find((s) => /\/otSDKStub\.js/.test(s.src))?.getAttribute("data-domain-script");
60
+ const geo = w.OneTrust.getGeolocationData?.();
61
+ const model = domain.ConsentModel?.Name ?? domain.ConsentModel;
62
+ return {
63
+ vendor: "OneTrust",
64
+ version: scripts.map((s) => s.src.match(/\/scripttemplates\/(\d+\.\d+\.\d+)\/otBannerSdk\.js/)?.[1]).find(Boolean) ?? null,
65
+ script: id ? scripts.map((s) => s.src).find((src) => src.endsWith(`/consent/${id}/${id}.js`)) ?? `https://cdn.cookielaw.org/consent/${id}/${id}.js` : null,
66
+ categories: Object.values(groups),
67
+ consentModel: typeof model === "string" ? model : null,
68
+ // Opt-out, implied and notice-only models let tags run before the visitor chooses.
69
+ impliedConsent: typeof model === "string" && /opt-?out|implied|notice/i.test(model),
70
+ region: geo?.country ? [geo.country, geo.state].filter(Boolean).join("-") : null,
71
+ consentMode: { active: Boolean(gc.GCEnable), mapping: Object.keys(mapping).length ? mapping : null, urlPassthrough: false, adsDataRedaction: Boolean(gc.GCRedactEnable) },
72
+ tcf: Boolean(domain.IsIabEnabled),
73
+ };
74
+ }
75
+ // Cookiebot's newer script names its object CookieConsent only.
76
+ const cb = w.Cookiebot ?? w.CookieConsent;
77
+ if (cb && typeof cb.submitCustomConsent === "function") {
78
+ return {
79
+ vendor: "Cookiebot",
80
+ version: cb.version !== undefined && cb.version !== null ? String(cb.version) : null,
81
+ // The banner setup, /consentconfig/<cbid>/settings.json: its date is when the setup was last published.
82
+ script: typeof cb.serial === "string" ? `https://consentcdn.cookiebot.com/consentconfig/${cb.serial}/settings.json` : null,
83
+ categories: ["necessary", "preferences", "statistics", "marketing"],
84
+ consentModel: typeof cb.responseMode === "string" ? cb.responseMode : null,
85
+ region: typeof cb.userCountry === "string" ? cb.userCountry : null,
86
+ impliedConsent: cb.consent?.method === "implied",
87
+ // Cookiebot fixes the mapping in its code; read off its consent updates in November 2026.
88
+ consentMode: {
89
+ fixed: true, active: !cb.consentModeDisabled,
90
+ mapping: { ad_storage: "marketing", ad_user_data: "marketing", ad_personalization: "marketing", analytics_storage: "statistics", functionality_storage: "preferences", personalization_storage: "preferences", security_storage: "necessary" },
91
+ redaction: typeof cb.consentModeDataRedaction === "string" ? cb.consentModeDataRedaction : cb.consentModeDataRedaction ? "true" : "false",
92
+ },
93
+ autoBlocking: Boolean(cb.autoblock),
94
+ tcf: Boolean(cb.framework),
95
+ };
96
+ }
97
+ if (w.OneTrust || w.OptanonWrapper) return { vendor: "OneTrust" };
98
+ if (w.UC_UI || w.UC_UI_IS_RENDERED !== undefined) return { vendor: "Usercentrics" };
99
+ if (w.Didomi) return { vendor: "Didomi" };
100
+ } catch {
101
+ // A platform object that throws when read is the same as none.
102
+ }
103
+ return null;
104
+ }
105
+
106
+ /** The consent platform's script dates from the run's network calls: each consent script's Last-Modified header, once per address. */
107
+ export function cmpScriptDates(run) {
108
+ return (run.network ?? [])
109
+ .filter((c) => c.provider === "consent" && c.lastModified)
110
+ .map((c) => ({ url: c.url, lastModified: c.lastModified }))
111
+ .filter((c, i, list) => list.findIndex((x) => x.url === c.url) === i);
112
+ }
113
+
114
+ const day = (d) => {
115
+ const t = Date.parse(d);
116
+ return Number.isNaN(t) ? String(d) : new Date(t).toISOString().slice(0, 10);
117
+ };
118
+ const hostPath = (url) => {
119
+ try {
120
+ const u = new URL(url);
121
+ return `${u.hostname}${u.pathname}`;
122
+ } catch {
123
+ return String(url);
124
+ }
125
+ };
126
+
127
+ /**
128
+ * The consent platform of a set of runs as { vendor, facts }: one sentence per fact, in the report's language.
129
+ * Null when no run saw a consent platform. The same sentences go to the HTML report, report.md and MCP.
130
+ */
131
+ export function cmpFacts(m, runs) {
132
+ const cmp = runs.map((r) => r.cmp).find(Boolean) ?? null;
133
+ const dates = runs.flatMap(cmpScriptDates);
134
+ if (!cmp && !dates.length) return null;
135
+ const c = m.ui.cmp;
136
+ const vendor = cmp?.vendor ?? c.unknown;
137
+ const facts = [];
138
+ // The platform's own script or setup file when it is known; on the vendor's own site every first-party file looks
139
+ // like the platform's. OneTrust and Cookiebot name a setup file, whose date is when the site last published its setup.
140
+ const setupFile = ["OneTrust", "Cookiebot"].includes(cmp?.vendor);
141
+ // OneTrust serves its setup as <id>.js or <id>.json, whichever its script asks for.
142
+ const base = (url) => String(url).replace(/\.(js|json)$/, "");
143
+ const own = cmp?.script ? dates.find((d) => base(d.url) === base(cmp.script)) : null;
144
+ // A known setup file without a date gets none, rather than the date of some other file.
145
+ const dated = own ?? (setupFile && cmp?.script ? null : dates[0]);
146
+ if (cmp?.built) facts.push(c.built(day(cmp.built)));
147
+ else if (dated) facts.push(own && setupFile ? c.published(day(dated.lastModified), hostPath(dated.url)) : c.scriptDated(day(dated.lastModified), hostPath(dated.url)));
148
+ if (cmp?.version) facts.push(c.version(cmp.version));
149
+ if (cmp?.script) facts.push(setupFile ? c.setupFile(cmp.script) : c.script(cmp.script));
150
+ if (cmp?.consentModel) facts.push(c.model(cmp.consentModel, cmp.region ?? null));
151
+ if (cmp?.impliedConsent) facts.push(c.implied);
152
+ if (cmp?.language) facts.push(c.language(cmp.language));
153
+ if (cmp?.law) facts.push(c.law(cmp.law));
154
+ if (cmp?.categories?.length) facts.push(c.categories(cmp.categories));
155
+ if (cmp?.consentMode) {
156
+ const mapping = cmp.consentMode.mapping ? c.mapping(Object.entries(cmp.consentMode.mapping)) : "";
157
+ if (cmp.consentMode.active === false) facts.push(c.modeOff(vendor, mapping));
158
+ else if (cmp.consentMode.fixed) facts.push(c.modeFixed(vendor, mapping));
159
+ else if (cmp.vendor === "OneTrust") facts.push(c.modeOn(vendor, mapping), c.redaction(cmp.consentMode.adsDataRedaction ? "on" : "off"));
160
+ else facts.push(c.modeOn(vendor, mapping), c.passthrough(cmp.consentMode.urlPassthrough, cmp.consentMode.adsDataRedaction));
161
+ if (cmp.consentMode.redaction && cmp.consentMode.active !== false) facts.push(c.redaction(cmp.consentMode.redaction));
162
+ }
163
+ if (typeof cmp?.autoBlocking === "boolean") facts.push(cmp.vendor === "CookieYes" ? c.providerBlocking(cmp.blockingRules ?? 0) : c.autoBlocking(cmp.autoBlocking, cmp.blockingRules ?? 0));
164
+ if (typeof cmp?.tcf === "boolean") facts.push(c.tcf(cmp.tcf));
165
+ return { vendor, facts };
166
+ }