@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/CHANGELOG.md +30 -0
- package/README.md +20 -9
- package/README.tr.md +20 -9
- package/package.json +1 -1
- package/src/agent.js +5 -1
- package/src/browser.js +168 -16
- package/src/campaign.js +7 -4
- package/src/checks.js +74 -20
- package/src/cli.js +2 -1
- package/src/cmp.js +166 -0
- package/src/consent.js +200 -26
- package/src/help.js +1 -0
- package/src/hit-params.js +70 -0
- package/src/html/consent.js +34 -9
- package/src/html/journeys.js +32 -70
- package/src/html/summary.js +1 -1
- package/src/i18n.js +152 -4
- package/src/mcp.js +280 -21
- package/src/metrics.js +13 -7
- package/src/parsers.js +62 -8
- package/src/report.js +9 -3
- package/src/verify.js +2 -0
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 =
|
|
21
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
425
|
-
|
|
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
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
+
}
|