@analitikisler/ai-tag-debugger 0.20.0 → 0.21.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,7 @@
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";
7
8
 
8
9
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
9
10
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
@@ -15,7 +16,12 @@ const sentBefore = (a, b) => a.step < b.step || (a.step === b.step && a.t != nul
15
16
  const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
16
17
  // Consent Mode v2 added two signals to ad_storage and analytics_storage. Google requires them for EEA/UK traffic.
17
18
  const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
18
- 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_.+)$/;
19
+ // The signals a "reject all" must keep denied. functionality_storage and security_storage cover what
20
+ // the site needs to work, and personalization_storage its own features, so CMPs grant them on reject too.
21
+ const TRACKING_SIGNALS = CONSENT_SIGNALS;
22
+ /** Whether a Google hit says it was sent with any of the tracking signals granted (from gcs, and gcd for the v2 ones). */
23
+ const claimsGranted = (hit) => TRACKING_SIGNALS.some((k) => hit.consent?.[k] === "granted");
24
+ 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_.+)$/;
19
25
  const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
20
26
 
21
27
  /**
@@ -63,6 +69,28 @@ export function canonicalEvent(name) {
63
69
  return CANONICAL.get(n) ?? n;
64
70
  }
65
71
 
72
+ // GA4 makes these itself: first_visit and session_start from flags on its other hits, user_engagement
73
+ // when the page is hidden or left. No tag sends them, so they are never unplanned, and a run can end
74
+ // before user_engagement goes out. Keyed by canonicalEvent, like every comparison of event names here.
75
+ const GA4_AUTOMATIC = new Set(["first_visit", "session_start", "user_engagement"].map(canonicalEvent));
76
+ // Enhanced measurement events, which GA4 sends with no tag when the web stream's setting is on.
77
+ // The value names the setting that sends it.
78
+ const GA4_ENHANCED = new Map(Object.entries({
79
+ page_view: "pageViews", scroll: "scrolls", click: "outboundClicks", view_search_results: "siteSearch",
80
+ video_start: "videoEngagement", video_progress: "videoEngagement", video_complete: "videoEngagement",
81
+ file_download: "fileDownloads", form_start: "formInteractions", form_submit: "formInteractions",
82
+ }).map(([name, setting]) => [canonicalEvent(name), setting]));
83
+ const isGa4Automatic = (platform, name) => platform === "ga4" && GA4_AUTOMATIC.has(canonicalEvent(name));
84
+ /** Whether GA4 sends this event by itself, so a plan need not list it. */
85
+ const isGa4Builtin = (platform, name) => isGa4Automatic(platform, name) || (platform === "ga4" && GA4_ENHANCED.has(canonicalEvent(name)));
86
+
87
+ /** Whether a planned event was sent on a run: by name, or for GA4's first_visit and session_start, as a flag on another hit. */
88
+ function eventFired(ev, hits) {
89
+ if (hits.some((h) => hitMatchesEvent(ev, h))) return true;
90
+ const name = canonicalEvent(ev.name);
91
+ return ev.platform === "ga4" && hits.some((h) => h.platform === "ga4" && (h.autoEvents ?? []).some((a) => canonicalEvent(a) === name));
92
+ }
93
+
66
94
  /** Whether a captured hit is the planned event, by its exact name, an alias or a common variant. */
67
95
  export function hitMatchesEvent(ev, hit) {
68
96
  if (hit.platform !== ev.platform) return false;
@@ -135,10 +163,17 @@ export function consentGrantedStep(run) {
135
163
  const fromUpdate = run.dataLayerLog.find(
136
164
  (e) => isConsentCommand(e, "update") && e.value[2]?.ad_storage === "granted",
137
165
  );
138
- const candidates = [run.consentStep, fromUpdate?.step].filter((s) => s !== null && s !== undefined);
166
+ // A consent platform's GTM template updates the Google tag without a dataLayer command; the tag's own state shows it.
167
+ const fromTag = tagConsentStates(run).find((st) => st.entries.ad_storage?.update === "granted");
168
+ const candidates = [run.consentStep, fromUpdate?.step, fromTag?.step].filter((s) => s !== null && s !== undefined);
139
169
  return candidates.length ? Math.min(...candidates) : null;
140
170
  }
141
171
 
172
+ /** The Google tag's own consent states a run recorded (google_tag_data.ics, kept on the steps where it changed), as { step, ...state }. */
173
+ export function tagConsentStates(run) {
174
+ return (run.steps ?? []).filter((st) => st.tagConsent).map((st) => ({ step: st.index, ...st.tagConsent }));
175
+ }
176
+
142
177
  /**
143
178
  * @param {Plan} plan
144
179
  * @param {{ journeyId: string, viewport: string, completed: boolean, note?: string, hits: any[], steps: any[], consentStep: number|null, dataLayerLog: any[] }} run
@@ -166,20 +201,33 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
166
201
  findings.push({ ...base, severity: "warning", check: "journey_incomplete", detail: d.journeyIncomplete(run.note) });
167
202
  }
168
203
 
204
+ // A page that was a bot challenge or crashed in the browser: the tags there may never have run, so what the run
205
+ // did not see there is unverified rather than missing.
206
+ const troubled = (run.steps ?? []).filter((st) => st.pageProblem);
207
+ const challenge = troubled.find((st) => st.pageProblem.kind === "challenge");
208
+ if (challenge) findings.push({ ...base, severity: "warning", check: "page_blocked", step: stepLabel(challenge.index), detail: d.pageBlocked(challenge.pageProblem.vendor) });
209
+ const crash = troubled.find((st) => st.pageProblem.kind === "crash");
210
+ if (crash) findings.push({ ...base, severity: "warning", check: "page_crashed", step: stepLabel(crash.index), detail: d.pageCrashed(crash.pageProblem.vendor, crash.pageProblem.refusedScripts ?? 0) });
211
+
169
212
  // Expected events that never fired.
170
213
  for (const ev of plan.events) {
171
214
  if (!expectedIn(ev, run.journeyId)) continue;
172
- const fired = run.hits.some((h) => hitMatchesEvent(ev, h));
173
- if (!fired) {
174
- findings.push({
175
- ...base,
176
- severity: run.completed ? "broken" : "info",
177
- check: "missing_event",
178
- platform: ev.platform,
179
- event: ev.name,
180
- detail: run.completed ? d.missingEvent(ev.platform, ev.name) : d.missingEventEarly(ev.name),
181
- });
215
+ if (eventFired(ev, run.hits)) continue;
216
+ if (isGa4Automatic(ev.platform, ev.name)) {
217
+ findings.push({ ...base, severity: "info", check: "ga4_automatic_event", platform: ev.platform, event: ev.name, detail: d.missingAutomatic(ev.name) });
218
+ continue;
182
219
  }
220
+ // page_view is enhanced measurement too, but a missing one means the GA4 tag itself did not send.
221
+ const name = canonicalEvent(ev.name);
222
+ const setting = ev.platform === "ga4" && name !== canonicalEvent("page_view") ? GA4_ENHANCED.get(name) : undefined;
223
+ findings.push({
224
+ ...base,
225
+ severity: run.completed && !troubled.length ? "broken" : "info",
226
+ check: "missing_event",
227
+ platform: ev.platform,
228
+ event: ev.name,
229
+ 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),
230
+ });
183
231
  }
184
232
 
185
233
  // Required parameters missing on events that did fire (reported once per event).
@@ -378,31 +426,69 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
378
426
  }
379
427
  }
380
428
 
429
+ // Cookies that carry the visit from page to page. Captures from before cookies were tracked per step have no cookieChanges.
430
+ if (run.cookieChanges) {
431
+ // A Google Ads tag keeps the landing gclid in _gcl_aw, so a later conversion can be tied to the click. GA4 alone
432
+ // never writes it, and with ad storage denied the cookie is rightly not written: only Ads, with storage granted.
433
+ const gclid = landing ? campaignParamsIn(landing.final ?? landing.requested).gclid : undefined;
434
+ const adsTag = run.hits.some((h) => h.platform === "google_ads") || (run.network ?? []).some((c) => c.provider === "google_ads");
435
+ const googleHits = run.hits.filter((h) => GOOGLE_PLATFORMS.has(h.platform));
436
+ // Without Consent Mode, no hit carries ad_storage at all, and nothing holds the cookie back.
437
+ const adStorage = googleHits.some((h) => h.consent?.ad_storage === "granted") || (googleHits.length > 0 && googleHits.every((h) => !h.consent?.ad_storage));
438
+ if (gclid && adsTag && adStorage && !run.steps.some((st) => st.identity?.gcl_aw === "landing")) {
439
+ findings.push({ ...base, severity: "warning", check: "gclid_not_stored", platform: "google_ads", step: stepLabel(landing.step), detail: d.gclidNotStored() });
440
+ }
441
+ // A GA4 client id that changes mid-visit splits it into two users. A reject in between may rightly clear the cookie.
442
+ let seen = null;
443
+ for (const st of run.steps) {
444
+ const cid = st.identity?.ga_client_id;
445
+ if (!cid) continue;
446
+ const rejectedBetween = run.rejectStep != null && run.rejectStep > seen?.step && run.rejectStep <= st.index;
447
+ if (seen && cid !== seen.cid && !rejectedBetween) {
448
+ 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}` });
449
+ break;
450
+ }
451
+ seen = { cid, step: st.index };
452
+ }
453
+ }
454
+
381
455
  // Consent.
382
456
  const granted = consentGrantedStep(run);
383
457
  // Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
384
458
  const firstGoogleHit = run.hits.filter((h) => GOOGLE_PLATFORMS.has(h.platform)).reduce((a, h) => (!a || sentBefore(h, a) ? h : a), null);
459
+ // The Google tag's own state also counts: a consent platform's GTM template sets the default and the update inside
460
+ // GTM, where the dataLayer never shows them.
461
+ const tagStates = tagConsentStates(run);
462
+ const tagDefault = tagStates.find((st) => st.usedDefault) ?? null;
463
+ const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
385
464
  if (plan.consent_mode) {
386
- const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
387
- if (!defaultEntry) {
465
+ if (!defaultEntry && !tagDefault) {
388
466
  findings.push({ ...base, severity: "risk", check: "consent_default_missing", detail: d.consentDefaultMissing() });
389
- } else if (firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) {
467
+ } else if ((defaultEntry && firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) || tagStates.some((st) => st.wasSetLate)) {
390
468
  findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
391
469
  }
392
470
  }
471
+ // A default the page pushed that the Google tag never applied: pushed as a plain array instead of through gtag(), to
472
+ // another dataLayer, or from a frame. Tag Assistant then says Consent Mode is not configured.
473
+ if (defaultEntry && tagStates.length && !tagDefault) {
474
+ findings.push({ ...base, severity: "warning", check: "consent_default_not_applied", step: stepLabel(defaultEntry.step), detail: d.consentDefaultNotApplied() });
475
+ }
393
476
  const defaults = run.dataLayerLog.filter((e) => isConsentCommand(e, "default"));
394
- if (plan.consent_mode && defaults.length) {
395
- const declared = new Set(defaults.flatMap((e) => Object.keys(e.value[2] ?? {})));
477
+ const declared = defaults.length
478
+ ? new Set(defaults.flatMap((e) => Object.keys(e.value[2] ?? {})))
479
+ : tagDefault ? new Set(Object.entries(tagDefault.entries).filter(([, e]) => e.default).map(([k]) => k)) : null;
480
+ if (plan.consent_mode && declared) {
396
481
  const missing = V2_SIGNALS.filter((k) => !declared.has(k));
397
482
  if (missing.length) findings.push({ ...base, severity: "risk", check: "consent_v2_missing", detail: d.consentV2Missing(missing) });
398
483
  }
399
484
  if (granted !== null) {
400
485
  const updates = run.dataLayerLog.filter((e) => isConsentCommand(e, "update"));
401
- if (updates.length) {
402
- const grantedKeys = new Set(updates.flatMap((e) => Object.entries(e.value[2] ?? {}).filter(([, v]) => v === "granted").map(([k]) => k)));
403
- const missing = V2_SIGNALS.filter((k) => !grantedKeys.has(k));
404
- if (missing.length) findings.push({ ...base, severity: "warning", check: "consent_v2_update", detail: d.consentV2Update(missing) });
405
- }
486
+ const tagGrant = tagStates.find((st) => st.entries.ad_storage?.update === "granted");
487
+ const grantedKeys = updates.length
488
+ ? new Set(updates.flatMap((e) => Object.entries(e.value[2] ?? {}).filter(([, v]) => v === "granted").map(([k]) => k)))
489
+ : tagGrant ? new Set(Object.entries(tagGrant.entries).filter(([, e]) => e.update === "granted").map(([k]) => k)) : null;
490
+ const missing = grantedKeys ? V2_SIGNALS.filter((k) => !grantedKeys.has(k)) : [];
491
+ if (missing.length) findings.push({ ...base, severity: "warning", check: "consent_v2_update", detail: d.consentV2Update(missing) });
406
492
  }
407
493
 
408
494
  // Declining: nothing may be sent or stored for ads after the visitor said no.
@@ -410,15 +496,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
410
496
  if (rejectStep !== null) {
411
497
  const reject = run.steps.find((s) => s.index === rejectStep);
412
498
  const regranted = run.dataLayerLog.find(
413
- (e) => e.step >= rejectStep && isConsentCommand(e, "update") && Object.values(e.value[2] ?? {}).includes("granted"),
414
- );
499
+ (e) => e.step >= rejectStep && isConsentCommand(e, "update") && TRACKING_SIGNALS.some((k) => e.value[2]?.[k] === "granted"),
500
+ ) ?? tagStates.find((st) => st.step >= rejectStep && TRACKING_SIGNALS.some((k) => st.entries[k]?.update === "granted"));
415
501
  if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
416
502
  else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
417
503
  const afterReject = new Map();
418
504
  for (const hit of run.hits) {
419
505
  if (hit.step < rejectStep) continue;
420
- const claimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
421
- if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted) {
506
+ if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted(hit)) {
422
507
  const k = key(hit.platform, hit.name);
423
508
  if (!afterReject.has(k)) afterReject.set(k, hit);
424
509
  }
@@ -450,8 +535,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
450
535
  const early = granted === null || hit.step < granted;
451
536
  if (!early) continue;
452
537
  const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
453
- const googleClaimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
454
- if (adPlatform || googleClaimsGranted) {
538
+ if (adPlatform || claimsGranted(hit)) {
455
539
  const k = key(hit.platform, hit.name);
456
540
  if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
457
541
  }
@@ -495,7 +579,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
495
579
  }
496
580
 
497
581
  // Events that are not in the plan. Without a plan every event would be listed, which says nothing.
498
- const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h)).map((h) => key(h.platform, h.name)))];
582
+ const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h) && !isGa4Builtin(h.platform, h.name)).map((h) => key(h.platform, h.name)))];
499
583
  if (hasPlan && unplanned.length) {
500
584
  findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
501
585
  }
@@ -557,10 +641,12 @@ export function planCheck(plan, runs, findings) {
557
641
  const own = findings.filter((f) => f.event && walked.some((r) => r.journeyId === f.journey && r.viewport === f.viewport) && hitMatchesEvent(ev, { platform: f.platform, name: f.event }));
558
642
  const params = own.filter((f) => f.check === "missing_params");
559
643
  const values = own.filter((f) => WRONG_VALUE_CHECKS.has(f.check));
560
- const fired = walked.some((r) => r.hits.some((h) => hitMatchesEvent(ev, h)));
644
+ const fired = walked.some((r) => eventFired(ev, r.hits));
561
645
  if (params.length) entry.bucket = "missing_params";
562
646
  else if (values.length) entry.bucket = "wrong_values";
563
- else entry.bucket = fired ? "implemented" : "missing";
647
+ else if (fired) entry.bucket = "implemented";
648
+ // GA4 makes these without a tag, so not seeing one says nothing about the site's setup.
649
+ else entry.bucket = isGa4Automatic(ev.platform, ev.name) ? "not_checked" : "missing";
564
650
  const details = [...new Set([...params, ...values].map((f) => f.detail))];
565
651
  if (details.length) entry.details = details;
566
652
  }
@@ -571,7 +657,7 @@ export function planCheck(plan, runs, findings) {
571
657
  for (const r of runs) {
572
658
  if (!findings.some((f) => f.check === "unplanned_events" && f.journey === r.journeyId && f.viewport === r.viewport)) continue;
573
659
  for (const h of r.hits) {
574
- if (events.some((e) => hitMatchesEvent(e, h)) || seen.has(key(h.platform, h.name))) continue;
660
+ if (events.some((e) => hitMatchesEvent(e, h)) || isGa4Builtin(h.platform, h.name) || seen.has(key(h.platform, h.name))) continue;
575
661
  seen.add(key(h.platform, h.name));
576
662
  entries.push({ platform: h.platform, event: h.name, bucket: "unplanned", journeys: [r.journeyId] });
577
663
  }
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
+ }