@analitikisler/ai-tag-debugger 0.20.1 → 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"]);
@@ -17,8 +18,10 @@ const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
17
18
  const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
18
19
  // The signals a "reject all" must keep denied. functionality_storage and security_storage cover what
19
20
  // 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_.+)$/;
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_.+)$/;
22
25
  const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
23
26
 
24
27
  /**
@@ -160,10 +163,17 @@ export function consentGrantedStep(run) {
160
163
  const fromUpdate = run.dataLayerLog.find(
161
164
  (e) => isConsentCommand(e, "update") && e.value[2]?.ad_storage === "granted",
162
165
  );
163
- 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);
164
169
  return candidates.length ? Math.min(...candidates) : null;
165
170
  }
166
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
+
167
177
  /**
168
178
  * @param {Plan} plan
169
179
  * @param {{ journeyId: string, viewport: string, completed: boolean, note?: string, hits: any[], steps: any[], consentStep: number|null, dataLayerLog: any[] }} run
@@ -191,6 +201,14 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
191
201
  findings.push({ ...base, severity: "warning", check: "journey_incomplete", detail: d.journeyIncomplete(run.note) });
192
202
  }
193
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
+
194
212
  // Expected events that never fired.
195
213
  for (const ev of plan.events) {
196
214
  if (!expectedIn(ev, run.journeyId)) continue;
@@ -204,11 +222,11 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
204
222
  const setting = ev.platform === "ga4" && name !== canonicalEvent("page_view") ? GA4_ENHANCED.get(name) : undefined;
205
223
  findings.push({
206
224
  ...base,
207
- severity: run.completed ? "broken" : "info",
225
+ severity: run.completed && !troubled.length ? "broken" : "info",
208
226
  check: "missing_event",
209
227
  platform: ev.platform,
210
228
  event: ev.name,
211
- detail: !run.completed ? d.missingEventEarly(ev.name) : setting ? d.missingEnhanced(ev.name, setting) : d.missingEvent(ev.platform, 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),
212
230
  });
213
231
  }
214
232
 
@@ -408,31 +426,69 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
408
426
  }
409
427
  }
410
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
+
411
455
  // Consent.
412
456
  const granted = consentGrantedStep(run);
413
457
  // Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
414
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"));
415
464
  if (plan.consent_mode) {
416
- const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
417
- if (!defaultEntry) {
465
+ if (!defaultEntry && !tagDefault) {
418
466
  findings.push({ ...base, severity: "risk", check: "consent_default_missing", detail: d.consentDefaultMissing() });
419
- } else if (firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) {
467
+ } else if ((defaultEntry && firstGoogleHit && sentBefore(firstGoogleHit, defaultEntry)) || tagStates.some((st) => st.wasSetLate)) {
420
468
  findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
421
469
  }
422
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
+ }
423
476
  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] ?? {})));
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) {
426
481
  const missing = V2_SIGNALS.filter((k) => !declared.has(k));
427
482
  if (missing.length) findings.push({ ...base, severity: "risk", check: "consent_v2_missing", detail: d.consentV2Missing(missing) });
428
483
  }
429
484
  if (granted !== null) {
430
485
  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
- }
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) });
436
492
  }
437
493
 
438
494
  // Declining: nothing may be sent or stored for ads after the visitor said no.
@@ -441,14 +497,13 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
441
497
  const reject = run.steps.find((s) => s.index === rejectStep);
442
498
  const regranted = run.dataLayerLog.find(
443
499
  (e) => e.step >= rejectStep && isConsentCommand(e, "update") && TRACKING_SIGNALS.some((k) => e.value[2]?.[k] === "granted"),
444
- );
500
+ ) ?? tagStates.find((st) => st.step >= rejectStep && TRACKING_SIGNALS.some((k) => st.entries[k]?.update === "granted"));
445
501
  if (regranted) findings.push({ ...base, severity: "broken", check: "reject_ignored", step: stepLabel(regranted.step), detail: d.rejectIgnored() });
446
502
  else if (reject?.bannerOpenAfter) findings.push({ ...base, severity: "broken", check: "reject_button_failed", step: stepLabel(rejectStep), detail: d.rejectButtonFailed() });
447
503
  const afterReject = new Map();
448
504
  for (const hit of run.hits) {
449
505
  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) {
506
+ if (NON_GOOGLE_AD_PLATFORMS.has(hit.platform) || claimsGranted(hit)) {
452
507
  const k = key(hit.platform, hit.name);
453
508
  if (!afterReject.has(k)) afterReject.set(k, hit);
454
509
  }
@@ -480,8 +535,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
480
535
  const early = granted === null || hit.step < granted;
481
536
  if (!early) continue;
482
537
  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) {
538
+ if (adPlatform || claimsGranted(hit)) {
485
539
  const k = key(hit.platform, hit.name);
486
540
  if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
487
541
  }
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
+ }