@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/CHANGELOG.md +39 -0
- package/README.md +22 -11
- package/README.tr.md +22 -11
- package/package.json +1 -1
- package/src/agent.js +5 -1
- package/src/browser.js +211 -19
- package/src/campaign.js +7 -4
- package/src/checks.js +118 -32
- 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 +36 -71
- package/src/html/summary.js +1 -1
- package/src/i18n.js +169 -4
- package/src/mcp.js +335 -28
- package/src/metrics.js +13 -7
- package/src/parsers.js +65 -8
- package/src/report.js +9 -3
- package/src/verify.js +3 -0
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
66
|
-
|
|
67
|
-
const
|
|
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
|
-
|
|
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:
|
|
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
|
-
*
|
|
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
|
|
126
|
-
|
|
127
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/html/consent.js
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|