@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/consent.js CHANGED
@@ -1,10 +1,19 @@
1
- // Consent scenarios: the start page opened in a fresh browser once per consent choice
2
- // (accept all, reject all, analytics only), to show which tags each choice lets through.
1
+ // Consent scenarios: the start page opened like an ad click in a fresh browser once per consent choice (no choice
2
+ // and on to the next page, accept all, reject all, analytics only), to show side by side which tags each choice lets
3
+ // through and what they carry: the consent on the first hit, the campaign parameters, the client id, the cookies.
3
4
  // Runs on its own with every audit when the page shows a consent banner.
4
5
  import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
5
6
  import { runPool } from "./pool.js";
7
+ import { CONSENT_SIGNALS, providerLabel } from "./parsers.js";
8
+ import { TRACKING_COOKIE } from "./checks.js";
9
+ import { campaignParamsIn, withCampaignParams } from "./campaign.js";
6
10
 
7
11
  export const CONSENT_STATES = ["accept", "reject", "analytics"];
12
+ // The visitor who leaves the banner unanswered and opens the next page. Browsing on is no consent under the GDPR or
13
+ // KVKK, so a tag that sends with cookies then is as wrong as after "reject all".
14
+ export const IGNORE = "ignore";
15
+ /** The scenario columns, in the order the reports show them. */
16
+ export const SCENARIO_COLUMNS = ["before", IGNORE, ...CONSENT_STATES];
8
17
  // Tags that are advertising, so an "analytics only" choice should keep them quiet.
9
18
  export const AD_PLATFORMS = new Set(["google_ads", "meta", "tiktok", "pinterest", "snapchat", "x"]);
10
19
 
@@ -14,19 +23,61 @@ const applyInPage = ({ state, selectors }) => {
14
23
  const w = window;
15
24
  try {
16
25
  if (w.OneTrust?.AllowAll) {
26
+ // Analytics only turns on the group OneTrust's Consent Mode setting gives analytics_storage (sites number their
27
+ // groups as they like), or C0002, the usual performance group, without one; not when ads share that group.
28
+ const gc = w.OneTrust.GetDomainData?.()?.GoogleConsent;
29
+ const analytics = gc?.GCEnable && gc.GCAnalyticsStorage ? String(gc.GCAnalyticsStorage) : "C0002";
30
+ if (state === "analytics" && gc?.GCEnable && analytics === String(gc.GCAdStorage)) return null;
17
31
  if (state === "accept") w.OneTrust.AllowAll();
18
32
  else {
19
33
  w.OneTrust.RejectAll();
20
- // C0002 is OneTrust's performance (analytics) group.
21
- if (state === "analytics") w.OneTrust.UpdateConsent?.("Category", "C0002:1");
34
+ if (state === "analytics") w.OneTrust.UpdateConsent?.("Category", `${analytics}:1`);
22
35
  }
23
36
  return "OneTrust";
24
37
  }
25
- if (w.Cookiebot?.submitCustomConsent) {
38
+ // Cookiebot's newer script names its object CookieConsent only.
39
+ const cookiebot = w.Cookiebot ?? w.CookieConsent;
40
+ if (cookiebot?.submitCustomConsent) {
26
41
  // preferences, statistics, marketing
27
- w.Cookiebot.submitCustomConsent(state === "accept", state !== "reject", state === "accept");
42
+ cookiebot.submitCustomConsent(state === "accept", state !== "reject", state === "accept");
28
43
  return "Cookiebot";
29
44
  }
45
+ // CookieYes has no public choice function, but its buttons and preference center switches carry fixed names.
46
+ if (w.cookieyes?._ckyStore) {
47
+ const tag = (name) => document.querySelector(`[data-cky-tag="${name}"]`);
48
+ if (state === "accept" || state === "reject") {
49
+ const button = tag(`${state}-button`);
50
+ if (button) {
51
+ button.click();
52
+ return "CookieYes";
53
+ }
54
+ } else {
55
+ tag("settings-button")?.click();
56
+ const switches = [...document.querySelectorAll('input[id^="ckySwitch"]')];
57
+ const save = tag("detail-save-button");
58
+ if (switches.length && save) {
59
+ for (const box of switches) if (box.checked !== (box.id === "ckySwitchanalytics")) box.click();
60
+ save.click();
61
+ return "CookieYes";
62
+ }
63
+ return null;
64
+ }
65
+ }
66
+ const efilli = w.efilliSdk?.consentManager;
67
+ if (efilli?.acceptAll) {
68
+ if (state === "accept") efilli.acceptAll();
69
+ else if (state === "reject") efilli.rejectAll();
70
+ else {
71
+ // Analytics only: the category Efilli's own Consent Mode mapping gives analytics_storage, when that mapping is
72
+ // the one in force and puts ads elsewhere. With Consent Mode run from a GTM template instead, it can't be known.
73
+ const mode = w.efilliSdk.configManager?.getLanguage?.()?.consentMode;
74
+ const analytics = mode?.active ? mode.mapping?.analytics_storage : null;
75
+ if (!analytics || analytics === mode.mapping?.ad_storage) return null;
76
+ for (const category of Object.keys(efilli.getCategories?.() ?? {})) efilli.setCategoryConsent(category, category === "essential" || category === analytics);
77
+ efilli.saveConsent();
78
+ }
79
+ return "Efilli";
80
+ }
30
81
  if (state !== "analytics") {
31
82
  if (w.UC_UI?.acceptAllConsents) {
32
83
  state === "accept" ? w.UC_UI.acceptAllConsents() : w.UC_UI.denyAllConsents();
@@ -43,9 +94,12 @@ const applyInPage = ({ state, selectors }) => {
43
94
  if (state === "analytics") return null;
44
95
  const words = state === "accept"
45
96
  ? /^(accept|allow|agree|ok|got it|kabul|tümünü kabul|izin ver|onayla|alle akzeptieren|tout accepter)/i
46
- : /^(reject|decline|deny|refuse|necessary only|only necessary|reddet|kabul etme|ablehnen|tout refuser)/i;
97
+ : /^(reject|decline|deny|refuse|necessary only|only necessary|reddet|tümünü reddet|kabul etme|ablehnen|tout refuser)/i;
47
98
  const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
48
- const banners = [...document.querySelectorAll(selectors)].filter(visible);
99
+ // Banners drawn inside open shadow roots (Efilli's) count too.
100
+ const roots = [document];
101
+ for (let i = 0; i < roots.length; i++) for (const n of roots[i].querySelectorAll("*")) if (n.shadowRoot) roots.push(n.shadowRoot);
102
+ const banners = roots.flatMap((r) => [...r.querySelectorAll(selectors)]).filter(visible);
49
103
  for (const n of document.querySelectorAll("body *")) {
50
104
  const label = `${n.id} ${typeof n.className === "string" ? n.className : ""}`;
51
105
  if (/(cookie|consent|gdpr|kvkk)/i.test(label) && visible(n) && !banners.some((b) => b.contains(n))) banners.push(n);
@@ -62,9 +116,10 @@ const applyInPage = ({ state, selectors }) => {
62
116
  return null;
63
117
  };
64
118
 
65
- // A Google hit that says storage is denied is a cookieless Consent Mode ping, which a "no" allows.
66
- const cookieless = (h) => !!h.consent && h.consent.ad_storage !== "granted" && h.consent.analytics_storage !== "granted";
67
- const isAnalyticsOnlyOk = (h) => !!h.consent && h.consent.ad_storage !== "granted";
119
+ // A Google hit that says every tracking signal is denied is a cookieless Consent Mode ping, which a "no"
120
+ // allows; after "analytics only" the ad signals must be denied.
121
+ const cookieless = (h) => !!h.consent && CONSENT_SIGNALS.every((k) => h.consent[k] !== "granted");
122
+ const isAnalyticsOnlyOk = (h) => !!h.consent && CONSENT_SIGNALS.filter((k) => k !== "analytics_storage").every((k) => h.consent[k] !== "granted");
68
123
 
69
124
  /** Hits per platform and event: { n, quiet } where quiet counts the ones sent with storage denied. */
70
125
  const countBy = (hits) => {
@@ -78,26 +133,83 @@ const countBy = (hits) => {
78
133
  return out;
79
134
  };
80
135
 
81
- /** Opens the start page, makes one consent choice, opens the page again, and returns the hits before and after. */
136
+ const GOOGLE = new Set(["ga4", "google_ads"]);
137
+
138
+ /**
139
+ * What one scenario shows beyond the hit counts, from the steps from..to of its own browser: the first Google hit
140
+ * with its consent and the campaign parameters its page address kept or lost, the GA4 client id on it and in _ga,
141
+ * whether _gcl_aw holds the landing gclid, the cookies added, how long after the steps began each provider was
142
+ * first called, and the Google tag's own consent state at the end.
143
+ */
144
+ function factsOf(session, from, to, landing) {
145
+ const inRange = (step) => step >= from && step <= to;
146
+ const google = session.hits.filter((h) => inRange(h.step) && GOOGLE.has(h.platform)).sort((a, b) => a.t - b.t);
147
+ const first = google.find((h) => h.platform === "ga4") ?? google[0] ?? null;
148
+ const sent = campaignParamsIn(landing);
149
+ let campaign = null;
150
+ if (first?.params?.page_location && Object.keys(sent).length) {
151
+ const got = campaignParamsIn(first.params.page_location);
152
+ const names = Object.keys(sent);
153
+ campaign = { kept: names.filter((n) => got[n] === sent[n]), lost: names.filter((n) => got[n] !== sent[n]) };
154
+ }
155
+ const identity = session.steps.filter((st) => inRange(st.index)).map((st) => st.identity).filter(Boolean).at(-1) ?? null;
156
+ const tag = session.steps.filter((st) => st.index <= to).map((st) => st.tagConsent).filter(Boolean).at(-1) ?? null;
157
+ const startedAt = session.steps[from]?.t ?? null;
158
+ const loaded = {};
159
+ for (const c of session.network) {
160
+ if (!inRange(c.step) || !c.provider || c.provider in loaded || startedAt === null) continue;
161
+ loaded[c.provider] = Math.max(0, c.t - startedAt);
162
+ }
163
+ return {
164
+ firstHit: first ? { platform: first.platform, event: first.name, ...(first.consent ? { consent: first.consent } : {}) } : null,
165
+ campaign,
166
+ clientId: first?.protocol?.cid ?? null,
167
+ cookieClientId: identity?.ga_client_id ?? null,
168
+ gclAw: identity?.gcl_aw ?? null,
169
+ cookies: [...new Set(session.cookieChanges.filter((c) => inRange(c.step) && c.change === "added").map((c) => c.name))],
170
+ loaded,
171
+ tagConsent: tag ? Object.fromEntries(Object.entries(tag.entries).map(([k, e]) => [k, e.update ?? e.default ?? null])) : null,
172
+ };
173
+ }
174
+
175
+ /** Where the scenarios start: the start page opened like an ad click (test campaign parameters, or the caller's own), or as it is. */
176
+ export function scenarioLanding(config, campaign) {
177
+ if (campaign === false || (campaign === undefined && config.campaign_checks === false)) return config.site;
178
+ return withCampaignParams(config.site, campaign && typeof campaign === "object" ? campaign : null);
179
+ }
180
+
181
+ /**
182
+ * Opens the start page, makes one consent choice (none for IGNORE), opens the page again, and returns the hits
183
+ * before and after the choice with the facts of each part.
184
+ */
82
185
  async function runState(config, state, opts) {
83
186
  const session = new AuditSession({
84
187
  viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
85
- blockHits: config.block_hits !== false, headless: !opts.headed,
188
+ blockHits: config.block_hits !== false, headless: !opts.headed, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}),
86
189
  });
190
+ const landing = scenarioLanding(config, opts.campaign);
87
191
  try {
88
192
  await session.start();
89
- const startError = await session.act({ action: "goto", url: config.site, reason: "Open the start page" });
193
+ const startError = await session.act({ action: "goto", url: landing, reason: "Open the start page" });
90
194
  if (startError) return { state, error: startError };
91
195
  session.beginStep(`Choose: ${state}`);
92
- const via = await session.page.evaluate(applyInPage, { state, selectors: CMP_BANNER_SELECTORS }).catch(() => null);
93
- if (!via) return { state, via: null, before: countBy(session.hits) };
196
+ const via = state === IGNORE ? "none" : await session.page.evaluate(applyInPage, { state, selectors: CMP_BANNER_SELECTORS }).catch(() => null);
197
+ if (!via) return { state, via: null, landing, before: countBy(session.hits), beforeFacts: factsOf(session, 0, session.currentStep, landing) };
94
198
  await session.settle();
199
+ await session.observe();
95
200
  session.beginStep("Open the start page again");
96
201
  await session.page.reload().catch(() => {});
97
202
  await session.settle();
203
+ await session.observe();
98
204
  const choice = session.steps.find((s) => s.label.startsWith("Choose:")).index;
205
+ const beforeFacts = factsOf(session, 0, choice - 1, landing);
206
+ // What a choice leads to is what the next page sends: a hit fired by the click itself can go out before the
207
+ // consent update applies. The cookies count from the click on. The client id this same visitor had before
208
+ // choosing tells a kept id from a new one.
209
+ const last = session.currentStep;
210
+ const facts = { ...factsOf(session, last, last, landing), cookies: factsOf(session, choice, last, landing).cookies, clientIdBefore: beforeFacts.clientId };
99
211
  return {
100
- state, via,
212
+ state, via, landing, beforeFacts, facts,
101
213
  before: countBy(session.hits.filter((h) => h.step < choice)),
102
214
  after: countBy(session.hits.filter((h) => h.step >= choice)),
103
215
  };
@@ -112,7 +224,9 @@ async function runState(config, state, opts) {
112
224
  * Runs every consent scenario on the site's start page. Returns null when the page has no consent
113
225
  * banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
114
226
  * opts.concurrency runs the other choices side by side, each in its own browser; default 1.
115
- * @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
227
+ * opts.campaign: the campaign parameters to open the start page with, or false for none (see scenarioLanding).
228
+ * opts.blockPrivate: as for AuditSession, for an MCP server reachable over HTTP.
229
+ * @returns {Promise<null | { cmp: string, landing: string, before: object, beforeFacts: object, states: Record<string, { via: string|null, after?: object, facts?: object, error?: string }> }>}
116
230
  */
117
231
  export async function runConsentScenarios(config, opts = {}) {
118
232
  const log = opts.log ?? (() => {});
@@ -120,19 +234,20 @@ export async function runConsentScenarios(config, opts = {}) {
120
234
  if (!first.via) return null;
121
235
  log(`\n▶ consent scenarios (${first.via})`);
122
236
  // The first choice shows whether there is a banner to answer at all; the others don't depend on each other.
123
- const results = [first, ...(await runPool(CONSENT_STATES.slice(1), opts.concurrency ?? 1, (state) => runState(config, state, opts)))];
237
+ const results = [first, ...(await runPool([IGNORE, ...CONSENT_STATES.slice(1)], opts.concurrency ?? 1, (state) => runState(config, state, opts)))];
124
238
  const states = {};
125
- for (const r of results) {
126
- states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
127
- log(` ${r.state}: ${r.error ? `✗ ${r.error}` : r.via ? `${Object.values(r.after ?? {}).reduce((s, e) => s + Object.values(e).reduce((a, b) => a + b.n, 0), 0)} tracking hits` : "not supported here"}`);
239
+ for (const state of [IGNORE, ...CONSENT_STATES]) {
240
+ const r = results.find((x) => x.state === state);
241
+ states[state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}), ...(r.facts ? { facts: r.facts } : {}) };
242
+ log(` ${state}: ${r.error ? `✗ ${r.error}` : r.via ? `${Object.values(r.after ?? {}).reduce((s, e) => s + Object.values(e).reduce((a, b) => a + b.n, 0), 0)} tracking hits` : "not supported here"}`);
128
243
  }
129
- return { cmp: first.via, before: first.before, states };
244
+ return { cmp: first.via, landing: first.landing, before: first.before, beforeFacts: first.beforeFacts, states };
130
245
  }
131
246
 
132
247
  /**
133
248
  * The per-state tag table: one row per platform and event, one cell per state (hit count, or null when
134
249
  * the state did not run). A cell is a problem when a tag sent hits a choice should have kept quiet:
135
- * anything after "reject all" except cookieless Consent Mode pings, and ad tags after "analytics only"
250
+ * anything after "reject all" or no choice except cookieless Consent Mode pings, and ad tags after "analytics only"
136
251
  * unless they were sent with ad storage denied.
137
252
  */
138
253
  export function consentTable(scenarios) {
@@ -140,17 +255,76 @@ export function consentTable(scenarios) {
140
255
  const add = (counts) => Object.entries(counts ?? {}).forEach(([p, events]) => Object.keys(events).forEach((e) => keys.add(`${p}\u0000${e}`)));
141
256
  add(scenarios.before);
142
257
  for (const s of Object.values(scenarios.states)) add(s.after);
143
- const cols = ["before", ...CONSENT_STATES];
258
+ // Captures from before the no-choice scenario existed have no column for it.
259
+ const cols = SCENARIO_COLUMNS.filter((col) => col !== IGNORE || scenarios.states?.[IGNORE]);
144
260
  const rows = [...keys].sort().map((k) => {
145
261
  const [platform, event] = k.split("\u0000");
146
262
  const cells = cols.map((col) => {
147
263
  const s = col === "before" ? { after: scenarios.before } : scenarios.states[col];
148
264
  if (!s?.after) return { state: col, count: null, problem: false };
149
265
  const c = s.after[platform]?.[event] ?? { n: 0, cookieless: 0, adDenied: 0 };
150
- const problem = (col === "reject" && c.n > c.cookieless) || (col === "analytics" && AD_PLATFORMS.has(platform) && c.n > c.adDenied);
266
+ const problem = ((col === "reject" || col === IGNORE) && c.n > c.cookieless) || (col === "analytics" && AD_PLATFORMS.has(platform) && c.n > c.adDenied);
151
267
  return { state: col, count: c.n, problem };
152
268
  });
153
269
  return { platform, event, cells };
154
270
  });
155
271
  return { cols, rows };
156
272
  }
273
+
274
+ /**
275
+ * The consent matrix: one row per fact, one cell per scenario column, from the facts each scenario recorded.
276
+ * Every cell is { text, problem }, where problem marks what should not happen with that choice: tracking cookies
277
+ * set before a choice, without one or after "reject all", a client id that changes when the visitor accepts, and a
278
+ * UTM parameter lost from the first hit. Null for captures from before the scenarios recorded facts.
279
+ * @returns {null | { landing: string|null, cols: string[], rows: { key: string, label: string, cells: { text: string, problem: boolean }[] }[] }}
280
+ */
281
+ export function consentMatrix(m, scenarios) {
282
+ const t = m.ui.matrix;
283
+ const factsFor = (col) => (col === "before" ? scenarios.beforeFacts : scenarios.states?.[col]?.facts) ?? null;
284
+ const cols = SCENARIO_COLUMNS.filter((col) => col === "before" || scenarios.states?.[col]);
285
+ if (!cols.some(factsFor)) return null;
286
+ const signals = (c) => CONSENT_SIGNALS.filter((k) => c?.[k]).map((k) => `${t.signals[k]} ${c[k] === "granted" ? "✓" : c[k] === "denied" ? "✗" : "?"}`).join(" ");
287
+ // No choice yet, no choice and on, and "reject all" may not leave tracking cookies; accepting must not lose the visitor.
288
+ const strict = (col) => col === "before" || col === IGNORE || col === "reject";
289
+ const cell = (text, problem = false) => ({ text, problem });
290
+ const clientId = (f, col) => {
291
+ if (col === "before") return cell(f.cookieClientId ? t.cid.cookie : f.clientId ? t.cid.cookieless : t.cid.none, Boolean(f.cookieClientId));
292
+ const after = f.cookieClientId ?? f.clientId;
293
+ if (!after) return cell(t.cid.none);
294
+ if (!f.clientIdBefore) return cell(t.cid.new);
295
+ if (after === f.clientIdBefore) return cell(t.cid.kept);
296
+ // Without a cookie every page load gets a new id, which is what declining asks for.
297
+ return cell(t.cid.changed, !strict(col) && Boolean(f.cookieClientId));
298
+ };
299
+ const rows = {
300
+ firstHit: (f) => cell(f.firstHit ? `${f.firstHit.event}${f.firstHit.consent ? ` · ${signals(f.firstHit.consent)}` : ""}` : t.none),
301
+ campaign: (f) => (!f.campaign ? cell(t.notRun) : f.campaign.lost.length
302
+ ? cell(t.lost(f.campaign.lost), f.campaign.lost.some((n) => n.startsWith("utm_")) || f.firstHit?.consent?.ad_storage === "granted")
303
+ : cell(t.kept)),
304
+ clientId,
305
+ gclAw: (f) => cell(t.gcl[f.gclAw ?? "none"]),
306
+ cookies: (f, col) => cell(t.cookies(f.cookies.length, f.cookies.slice(0, 10)), strict(col) && f.cookies.some((n) => TRACKING_COOKIE.test(n))),
307
+ loaded: (f) => cell(Object.entries(f.loaded).sort((a, b) => a[1] - b[1]).slice(0, 8).map(([p, ms]) => `${providerLabel(p)} ${t.ms(ms)}`).join(" · ") || t.none),
308
+ tagConsent: (f) => cell(f.tagConsent ? signals(f.tagConsent) || t.none : t.notRun),
309
+ };
310
+ return {
311
+ landing: scenarios.landing ?? null,
312
+ cols,
313
+ rows: Object.entries(rows).map(([key, fn]) => ({ key, label: t.rows[key], cells: cols.map((col) => (factsFor(col) ? fn(factsFor(col), col) : cell(t.notRun))) })),
314
+ };
315
+ }
316
+
317
+ /** The consent matrix as a Markdown table with its legend, for report.md and MCP. */
318
+ export function consentMatrixMarkdown(m, scenarios) {
319
+ const matrix = consentMatrix(m, scenarios);
320
+ if (!matrix) return [];
321
+ const esc = (x) => String(x).replace(/\|/g, "\\|");
322
+ return [
323
+ ...(matrix.landing ? [m.ui.matrix.intro(matrix.landing), ""] : []),
324
+ `| | ${matrix.cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`,
325
+ `|${" --- |".repeat(matrix.cols.length + 1)}`,
326
+ ...matrix.rows.map((r) => `| ${r.label} | ${r.cells.map((c) => `${esc(c.text)}${c.problem ? " ⚠" : ""}`).join(" | ")} |`),
327
+ "",
328
+ m.ui.matrix.legend,
329
+ ];
330
+ }
package/src/help.js CHANGED
@@ -102,6 +102,7 @@ Advanced:
102
102
  --host <addr> HTTP host (default 127.0.0.1)
103
103
  --token <secret> Secret URL path segment, at least 16 characters (default: random)
104
104
  --allow-private-network Over HTTP, let audits open localhost and private addresses
105
+ --allow-live Over HTTP, let start_audit send live (tracking requests reach the real accounts)
105
106
  --reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
106
107
  --public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
107
108
 
@@ -0,0 +1,70 @@
1
+ // The parameters a hit's request carried, split into the event's own and the session, device, page and
2
+ // consent ones. Shared by the HTML report's hit cards and the MCP server's get_hit, so both show the same table.
3
+ import { parseTrackingRequest } from "./parsers.js";
4
+ import { isEventParam } from "./glossary.js";
5
+
6
+ const cell = (v) => (v === null || v === undefined ? "" : typeof v === "object" ? JSON.stringify(v) : String(v));
7
+
8
+ /** The network request that carried the hit. Identical hits (duplicates) are paired with identical requests in order. */
9
+ export function callsForHit(run, hit) {
10
+ // A late hit was sent during a later step, so its request is recorded there.
11
+ const sentIn = hit.late?.during ?? hit.step;
12
+ const calls = (run.network ?? []).filter((c) => c.step === sentIn && c.url === hit.url);
13
+ const twins = run.hits.filter((h) => (h.late?.during ?? h.step) === sentIn && h.url === hit.url);
14
+ return twins.length > 1 && calls.length === twins.length ? [calls[twins.indexOf(hit)]] : calls;
15
+ }
16
+
17
+ /** Nested JSON as dotted keys (properties.value); arrays stay JSON text. */
18
+ function flatten(obj, prefix = "", out = []) {
19
+ for (const [k, v] of Object.entries(obj ?? {})) {
20
+ const key = prefix ? `${prefix}.${k}` : k;
21
+ if (v && typeof v === "object" && !Array.isArray(v) && prefix.split(".").length < 3) flatten(v, key, out);
22
+ else out.push([key, cell(v)]);
23
+ }
24
+ return out;
25
+ }
26
+
27
+ /** The parameter sets a request carried: one per event in a GA4 batch (query plus each body line), the query plus a flattened JSON body, or just the query. */
28
+ export function paramSets(call) {
29
+ let u;
30
+ try {
31
+ u = new URL(call.url);
32
+ } catch {
33
+ return [[]];
34
+ }
35
+ const query = [...u.searchParams];
36
+ const body = call.postData;
37
+ if (!body) return [query];
38
+ const trimmed = body.trim();
39
+ if (trimmed.startsWith("{")) {
40
+ try {
41
+ return [[...query, ...flatten(JSON.parse(trimmed))]];
42
+ } catch { /* not JSON, or cut at the size limit */ }
43
+ }
44
+ if (/^[^\s{]*=/.test(trimmed)) {
45
+ return trimmed.split("\n").filter(Boolean).map((line) => {
46
+ const merged = new Map(query);
47
+ for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
48
+ return [...merged];
49
+ });
50
+ }
51
+ return [query];
52
+ }
53
+
54
+ /**
55
+ * What a hit card shows as its parameters: the request's own parameter set (raw keys, with glossary tips), split
56
+ * into the event's parameters and the session, device, page and consent ones. A GA4 batch carries one set per
57
+ * event; the hit takes the set with its name. Without a captured request, the hit's parsed parameters.
58
+ */
59
+ export function hitParams(run, hit) {
60
+ const call = callsForHit(run, hit)[0] ?? null;
61
+ if (!call) return { call, batch: null, event: Object.entries(hit.params ?? {}).filter(([k]) => !(k === "items" && hit.items?.length)).map(([k, v]) => [k, cell(v)]), context: [] };
62
+ const sets = paramSets(call);
63
+ let i = 0;
64
+ if (sets.length > 1) {
65
+ const names = (parseTrackingRequest(call.url, call.postData ?? null) ?? []).map((h) => h.name);
66
+ i = Math.max(0, names.indexOf(hit.name));
67
+ }
68
+ const entries = sets[i] ?? [];
69
+ return { call, batch: sets.length > 1 ? [i + 1, sets.length] : null, event: entries.filter(([k]) => isEventParam(k)), context: entries.filter(([k]) => !isEventParam(k)) };
70
+ }
@@ -1,14 +1,17 @@
1
1
  // The Consent section: one status line per platform, the Consent Mode card and the scenarios card. Denied is red,
2
2
  // granted green and "no signal" grey, all by token through chip("consent", ...).
3
- import { consentTable } from "../consent.js";
3
+ import { consentMatrix, consentTable, IGNORE, SCENARIO_COLUMNS } from "../consent.js";
4
+ import { cmpFacts } from "../cmp.js";
4
5
  import { isConsentEntry } from "../report-shared.js";
6
+ import { tagConsentStates } from "../checks.js";
5
7
  import { PLATFORM_ART, chip, esc, platformLabel, safeKey, sectionBlock, sortPlatforms, stepTitle, secs, use, badge } from "./lib.js";
6
8
 
7
9
  // Google tags carry Consent Mode signals on the hit; every other platform sends none, so its state is "none".
8
10
  const GOOGLE = new Set(["ga4", "google_ads"]);
9
- const SCENARIOS = ["before", "accept", "reject", "analytics"];
11
+ // The status line per platform keeps to no choice, accept and reject; the card lists every column that ran.
12
+ const STATUS_SCENARIOS = ["before", "accept", "reject"];
10
13
  const LINE_CHECKS = new Set(["before_consent", "after_reject", "consent_default_missing"]);
11
- const MODE_CHECKS = new Set(["consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
14
+ const MODE_CHECKS = new Set(["consent_default_missing", "consent_default_late", "consent_default_not_applied", "consent_v2_missing", "consent_v2_update"]);
12
15
  const SEVERITY_RANK = { broken: 0, risk: 1, warning: 2, info: 3 };
13
16
 
14
17
  /** A platform's artwork, or its initials when it has none. Decorative: the chip's text names it once for screen readers. */
@@ -73,12 +76,18 @@ export function render(ctx) {
73
76
  // The scenarios can show a tag sent after Reject with no finding row for it; with no row at all (not even an
74
77
  // accepted one) the metric decides, so the line agrees with the flagged cell below.
75
78
  const sev = row?.severity ?? (!own.length && metrics.consent.platforms.find((p) => p.platform === platform)?.status !== "pass" ? "risk" : "ok");
76
- const cells = SCENARIOS.slice(0, 3).filter((s) => sc && afterOf(sc, s))
79
+ const cells = STATUS_SCENARIOS.filter((s) => sc && afterOf(sc, s))
77
80
  .map((s) => `<span class="cs-p"><span class="cs-k">${esc(t.cols[s])}</span>${total(platform, s)}</span>`).join("");
78
81
  return `<li class="card sev-${sev} cs-row" id="consent-${safeKey(platform)}">${platChip(platform)}${sevChip(sev, m.severity[sev])}${cells ? `<span class="cs-cells">${cells}</span>` : ""}${findingLink(row, "cs-go")}</li>`;
79
82
  }).join("");
80
83
  const list = lines ? `<ul class="cs-list" aria-label="${esc(t.statusLabel)}">${lines}</ul>` : "";
81
84
 
85
+ /* ---- consent platform ---- */
86
+ const platform = cmpFacts(m, runs);
87
+ const platformCard = platform
88
+ ? `<details class="card" id="consent-platform"><summary><span class="t">${esc(m.ui.cmp.title)}</span>${plain(esc(platform.vendor))}</summary><div class="body"><ul class="xs cm-timing">${platform.facts.map((f) => `<li>${esc(f)}</li>`).join("")}</ul></div></details>\n`
89
+ : "";
90
+
82
91
  /* ---- Consent Mode ---- */
83
92
  const mode = metrics.consent.consentMode;
84
93
  const perRun = runs.map((run, ri) => {
@@ -116,13 +125,24 @@ export function render(ctx) {
116
125
  const resent = Math.max(0, ...withDefault.map((p) => p.resent));
117
126
  const modeFindings = rows.filter((f) => MODE_CHECKS.has(f.check) && !f.accepted && ids.get(f)).map((f) => findingLink(f)).join(" ");
118
127
  const none = !runs.length ? m.ui.md.consentNotChecked : m.ui.md.consentModeNone;
128
+ // The Google tag's last recorded state per run; runs that ended in the same state share one line.
129
+ const tagGroups = new Map();
130
+ for (const run of runs) {
131
+ const last = tagConsentStates(run).at(-1);
132
+ if (!last) continue;
133
+ const k = JSON.stringify({ ...last, step: undefined });
134
+ if (!tagGroups.has(k)) tagGroups.set(k, { last, runs: [] });
135
+ tagGroups.get(k).runs.push(run);
136
+ }
137
+ const viaTag = withDefault.some((p) => p.info.source === "tag") ? `<p class="xs muted">${esc(t.defaultViaTag)}</p>` : "";
138
+ const tagHtml = [...tagGroups.values()].map(({ last, runs: rs }) => `<div class="cm-tag">${tagGroups.size > 1 ? `<div class="chips cm-runs">${rs.map(runChip).join("")}</div>` : ""}<p class="xs"><b>${esc(t.tagState)}</b> · ${esc(t.tagFlags(last.usedDefault, last.usedUpdate, last.wasSetLate, last.waitPeriodTimedOut))}</p><div class="chips">${signalChips(m, Object.fromEntries(Object.entries(last.entries).map(([k, e]) => [k, e.update ?? e.default ?? "none"])))}</div></div>`).join("");
119
139
  const modeBody = withDefault.length
120
- ? `${timing ? `<ul class="cm-timing">${timing}</ul>` : ""}${groupHtml}${resent ? `<p class="xs muted cm-rep">${use("ui-clock", 12)} ${esc(t.resent(resent))}</p>` : ""}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`
121
- : `<p class="muted xs">${esc(none)}</p>${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`;
140
+ ? `${timing ? `<ul class="cm-timing">${timing}</ul>` : ""}${viaTag}${groupHtml}${tagHtml}${resent ? `<p class="xs muted cm-rep">${use("ui-clock", 12)} ${esc(t.resent(resent))}</p>` : ""}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`
141
+ : `<p class="muted xs">${esc(none)}</p>${tagHtml}${modeFindings ? `<p class="xs cm-find">${modeFindings}</p>` : ""}`;
122
142
  const modeCard = `<details class="card" id="consent-mode"><summary><span class="t">${esc(m.html.consentMode)}</span>${modeHead}${modeChips}<span class="end muted xs">${esc(t.runsWith(withDefault.length, runs.length))}</span></summary><div class="body">${modeBody}</div></details>`;
123
143
 
124
144
  /* ---- scenarios ---- */
125
- if (!sc) return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${modeCard}\n<p class="muted xs cs-none" id="consent-scenarios">${esc(m.ui.md.noScenarios)}</p>`);
145
+ if (!sc) return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${platformCard}${modeCard}\n<p class="muted xs cs-none" id="consent-scenarios">${esc(m.ui.md.noScenarios)}</p>`);
126
146
  const table = consentTable(sc);
127
147
  const cols = table.cols.filter((s) => s !== "analytics" || sc.states?.analytics?.via);
128
148
  const before = rows.filter((f) => f.check === "before_consent" && !f.accepted);
@@ -145,8 +165,13 @@ export function render(ctx) {
145
165
  };
146
166
  const body = table.rows.map((r) => `<tr><th scope="row"><span class="cs-tag">${platChip(r.platform)}<code>${esc(r.event)}</code></span></th>${cols.map((s) => cellHtml(r, r.cells.find((c) => c.state === s))).join("")}</tr>`).join("");
147
167
  const supported = (s) => (s === "before" ? true : Boolean(sc.states?.[s]?.after));
148
- const summaryChips = SCENARIOS.map((s) => (supported(s) ? plain(esc(t.cols[s])) : chip("consent", "none", esc(`${t.cols[s]} · ${t.notSupported}`)))).join("");
168
+ // Captures from before the no-choice scenario existed have no column for it.
169
+ const summaryChips = SCENARIO_COLUMNS.filter((s) => s !== IGNORE || sc.states?.[IGNORE]).map((s) => (supported(s) ? plain(esc(t.cols[s])) : chip("consent", "none", esc(`${t.cols[s]} · ${t.notSupported}`)))).join("");
149
170
  const probChip = problemCount ? sevChip("risk", t.problems(problemCount)) : sevChip("ok", t.noProblems);
150
171
  const scenarios = `<details class="card" id="consent-scenarios"><summary><span class="t">${esc(t.scenarios)}</span>${summaryChips}<span class="end">${probChip}</span></summary><div class="body"><div class="tscroll"><table class="tbl cs-tbl"><caption class="sr">${esc(m.ui.scenarios)}</caption><thead><tr><th scope="col">${esc(m.ui.scenarioTag)}</th>${cols.map((s) => `<th scope="col">${esc(t.cols[s])}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div></div></details>`;
151
- return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${modeCard}\n${scenarios}`);
172
+ const matrix = consentMatrix(m, sc);
173
+ const matrixCard = matrix
174
+ ? `\n<details class="card" id="consent-matrix"><summary><span class="t">${esc(m.ui.matrix.title)}</span>${matrix.rows.some((r) => r.cells.some((c) => c.problem)) ? sevChip("risk", t.problems(matrix.rows.reduce((n, r) => n + r.cells.filter((c) => c.problem).length, 0))) : sevChip("ok", t.noProblems)}</summary><div class="body">${matrix.landing ? `<p class="xs muted">${esc(m.ui.matrix.intro(matrix.landing))}</p>` : ""}<div class="tscroll"><table class="tbl cs-tbl"><caption class="sr">${esc(m.ui.matrix.title)}</caption><thead><tr><th scope="col"></th>${matrix.cols.map((c) => `<th scope="col">${esc(t.cols[c])}</th>`).join("")}</tr></thead><tbody>${matrix.rows.map((r) => `<tr><th scope="row">${esc(r.label)}</th>${r.cells.map((c) => (c.problem ? `<td class="cs-bad"><span class="cs-c">${esc(c.text)}${badge("risk", "⚠")}<span class="sr"> ${esc(m.ui.matrix.problem)}</span></span></td>` : `<td class="xs">${esc(c.text)}</td>`)).join("")}</tr>`).join("")}</tbody></table></div><p class="xs muted">${esc(m.ui.matrix.legend)}</p></div></details>`
175
+ : "";
176
+ return sectionBlock("consent", esc(m.html.shell.sections.consent), `${list}\n${platformCard}${modeCard}\n${scenarios}${matrixCard}`);
152
177
  }