@analitikisler/ai-tag-debugger 0.20.1 → 0.22.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 +40 -0
- package/README.md +43 -11
- package/README.tr.md +42 -10
- package/package.json +4 -1
- package/src/agent.js +6 -1
- package/src/browser.js +262 -29
- package/src/campaign.js +7 -4
- package/src/checks.js +91 -20
- package/src/cli.js +2 -1
- package/src/cmp.js +166 -0
- package/src/consent.js +200 -26
- package/src/containers.js +163 -0
- 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/scope.js +15 -1
- package/src/html/summary.js +1 -1
- package/src/i18n.js +230 -4
- package/src/mcp.js +341 -22
- package/src/metrics.js +13 -7
- package/src/parsers.js +62 -8
- package/src/report.js +26 -3
- package/src/verify.js +2 -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
|
+
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
// How each Google container got onto the page: GTM (gtm.js) and Google tag (gtag/js) scripts, with what
|
|
2
|
+
// inserted them. The browser records every container script as it is added (see CONTAINER_HOOK in browser.js):
|
|
3
|
+
// parser-inserted from the HTML, or set by a script, with the scripts on the call stack at that moment. This
|
|
4
|
+
// module turns that into a load source: the snippet, a script tag in the HTML, another script on the page,
|
|
5
|
+
// a tag or Zone in another container, or a gtag config or send_to command the Google tag acted on.
|
|
6
|
+
|
|
7
|
+
// Container and destination IDs gtm.js and gtag/js are loaded with.
|
|
8
|
+
const CONTAINER_ID = /^(GTM|G|GT|AW|DC|MC|HA)-[A-Z0-9]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The container a script URL loads, or null: its ID, gtm or gtag, the dataLayer it reads (`l`), the host it
|
|
12
|
+
* came from (Google's, or the site's own through the Google tag gateway or a server container) and the GTM
|
|
13
|
+
* environment when one is named (`gtm_preview`). gtm_auth, the environment's token, is never kept.
|
|
14
|
+
*/
|
|
15
|
+
export function containerOf(url) {
|
|
16
|
+
let u;
|
|
17
|
+
try { u = new URL(url); } catch { return null; }
|
|
18
|
+
if (!/^https?:$/.test(u.protocol)) return null;
|
|
19
|
+
const id = (u.searchParams.get("id") ?? "").toUpperCase();
|
|
20
|
+
if (!CONTAINER_ID.test(id)) return null;
|
|
21
|
+
const script = /\/gtm\.js$/.test(u.pathname) ? "gtm" : /\/gtag\/js$/.test(u.pathname) ? "gtag" : null;
|
|
22
|
+
if (!script) return null;
|
|
23
|
+
const environment = u.searchParams.get("gtm_preview") || (u.searchParams.has("gtm_auth") ? "unnamed" : null);
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
script,
|
|
27
|
+
layer: u.searchParams.get("l") || "dataLayer",
|
|
28
|
+
host: u.hostname,
|
|
29
|
+
served: /(^|\.)googletagmanager\.com$/.test(u.hostname) ? "google" : "site",
|
|
30
|
+
...(environment ? { environment } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** origin + path, so a caller's query string (which may carry user data) is never kept. */
|
|
35
|
+
export function scriptPath(url) {
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(url);
|
|
38
|
+
return /^https?:$/.test(u.protocol) ? u.origin + u.pathname : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The call stack the browser captured, as callers from the innermost out: { inline: true } for script in the
|
|
46
|
+
* page itself (the page's address, or "inline" for code injected without one) (the snippet, or a Custom HTML tag GTM injected), { container: id } for a container script and
|
|
47
|
+
* { script: url } for any other script. Repeats in a row are folded.
|
|
48
|
+
*/
|
|
49
|
+
export function callersOf(frames, documentUrl) {
|
|
50
|
+
const doc = String(documentUrl ?? "").split("#")[0];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const f of frames ?? []) {
|
|
53
|
+
const url = String(f).split("#")[0];
|
|
54
|
+
const c = containerOf(url);
|
|
55
|
+
const caller = url === doc || url === "inline" ? { inline: true } : c ? { container: c.id } : scriptPath(url) ? { script: scriptPath(url) } : null;
|
|
56
|
+
if (!caller) continue;
|
|
57
|
+
const last = out.at(-1);
|
|
58
|
+
if (last && JSON.stringify(last) === JSON.stringify(caller)) continue;
|
|
59
|
+
out.push(caller);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pathOf = (href) => scriptPath(href);
|
|
65
|
+
|
|
66
|
+
/** Whether a gtag command in the dataLayer names this ID: ["config", id] or ["event", name, { send_to }]. */
|
|
67
|
+
function commandFor(load, pushes) {
|
|
68
|
+
const earlier = pushes.filter((e) => Array.isArray(e.value) && (e.t ?? 0) <= (load.t ?? Infinity) && (!load.page || pathOf(e.url) === load.page));
|
|
69
|
+
if (earlier.some((e) => e.value[0] === "config" && String(e.value[1]).toUpperCase() === load.id)) return "config";
|
|
70
|
+
const sendsTo = (e) => {
|
|
71
|
+
const to = e.value[0] === "event" ? e.value[2]?.send_to : undefined;
|
|
72
|
+
const list = Array.isArray(to) ? to : String(to ?? "").split(",");
|
|
73
|
+
return list.some((s) => String(s).trim().toUpperCase().split("/")[0] === load.id);
|
|
74
|
+
};
|
|
75
|
+
return earlier.some(sendsTo) ? "send_to" : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where one recorded load came from, as { kind, by? }:
|
|
80
|
+
* - html: a script tag in the page's HTML (the gtag.js snippet, or gtm.js referenced directly)
|
|
81
|
+
* - inline: code in the page itself (the GTM snippet, or a hand-written loader)
|
|
82
|
+
* - script: another script on the page; by is its address (a consent platform, the site's bundle)
|
|
83
|
+
* - custom_html: a Custom HTML tag in the GTM container `by`
|
|
84
|
+
* - google_tag: a Google tag or template in the GTM container `by`, through GTM's load_google_tags
|
|
85
|
+
* - nested: GTM container `by` loaded another GTM container itself, as a Zone or a tag template does
|
|
86
|
+
* - config / send_to: a gtag('config') or an event's send_to naming the ID, acted on by `by`
|
|
87
|
+
* - destination: the Google tag `by` loaded it as one of its destinations, with no command in the page
|
|
88
|
+
*/
|
|
89
|
+
export function sourceOf(load, pushes = []) {
|
|
90
|
+
if (load.how === "parser") return { kind: "html" };
|
|
91
|
+
const callers = load.callers ?? [];
|
|
92
|
+
const at = callers.findIndex((c) => c.container && c.container !== load.id);
|
|
93
|
+
if (at >= 0) {
|
|
94
|
+
const by = callers[at].container;
|
|
95
|
+
const command = commandFor(load, pushes);
|
|
96
|
+
if (command) return { kind: command, by };
|
|
97
|
+
if (by.startsWith("GTM-")) {
|
|
98
|
+
// Code in the page between the call and the container is a Custom HTML tag the container injected.
|
|
99
|
+
if (callers.slice(0, at).some((c) => c.inline)) return { kind: "custom_html", by };
|
|
100
|
+
return { kind: load.id.startsWith("GTM-") ? "nested" : "google_tag", by };
|
|
101
|
+
}
|
|
102
|
+
return { kind: "destination", by };
|
|
103
|
+
}
|
|
104
|
+
const other = callers.find((c) => c.script);
|
|
105
|
+
if (other) return { kind: "script", by: other.script };
|
|
106
|
+
return { kind: "inline" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A run's container loads with their sources, in the order they happened. Runs saved by older versions have none. */
|
|
110
|
+
export function loadSources(run) {
|
|
111
|
+
const pushes = run.dataLayerLog ?? [];
|
|
112
|
+
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
|
|
117
|
+
* environment?, kind, by?, pages, journeys }.
|
|
118
|
+
*/
|
|
119
|
+
export function containerRows(runs) {
|
|
120
|
+
const rows = new Map();
|
|
121
|
+
for (const run of runs) {
|
|
122
|
+
for (const l of loadSources(run)) {
|
|
123
|
+
const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? ""].join("|");
|
|
124
|
+
const row = rows.get(k) ?? {
|
|
125
|
+
id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
|
|
126
|
+
...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
127
|
+
pages: new Set(), journeys: new Set(),
|
|
128
|
+
};
|
|
129
|
+
if (l.page) row.pages.add(l.page);
|
|
130
|
+
row.journeys.add(`${run.journeyId} (${run.viewport})`);
|
|
131
|
+
rows.set(k, row);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return [...rows.values()].map((r) => ({ ...r, pages: [...r.pages].sort(), journeys: [...r.journeys] }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** The containers loaded more than once into one page, as [{ id, loads }] with the loads in order. */
|
|
138
|
+
export function duplicateLoads(run) {
|
|
139
|
+
const byDoc = new Map();
|
|
140
|
+
for (const l of loadSources(run)) {
|
|
141
|
+
const k = `${l.doc ?? l.page}|${l.id}`;
|
|
142
|
+
byDoc.set(k, [...(byDoc.get(k) ?? []), l]);
|
|
143
|
+
}
|
|
144
|
+
return [...byDoc.values()].filter((list) => list.length > 1).map((loads) => ({ id: loads[0].id, loads }));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Pages where containers read different dataLayers, as [{ page, step, layers: [{ id, layer }] }]. */
|
|
148
|
+
export function splitLayers(run) {
|
|
149
|
+
const byDoc = new Map();
|
|
150
|
+
for (const l of loadSources(run)) {
|
|
151
|
+
// A destination a Google tag loaded reads that tag's dataLayer, whatever its own URL says.
|
|
152
|
+
if (["config", "send_to", "destination"].includes(l.kind)) continue;
|
|
153
|
+
const k = l.doc ?? l.page;
|
|
154
|
+
byDoc.set(k, [...(byDoc.get(k) ?? []), l]);
|
|
155
|
+
}
|
|
156
|
+
const out = [];
|
|
157
|
+
for (const loads of byDoc.values()) {
|
|
158
|
+
if (new Set(loads.map((l) => l.layer)).size < 2) continue;
|
|
159
|
+
const seen = new Map(loads.map((l) => [l.id, { id: l.id, layer: l.layer }]));
|
|
160
|
+
out.push({ page: loads[0].page, step: loads[0].step, layers: [...seen.values()] });
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
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
|
+
}
|