@analitikisler/ai-tag-debugger 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +30 -0
- package/README.md +20 -9
- package/README.tr.md +20 -9
- package/package.json +1 -1
- package/src/agent.js +5 -1
- package/src/browser.js +168 -16
- package/src/campaign.js +7 -4
- package/src/checks.js +74 -20
- package/src/cli.js +2 -1
- package/src/cmp.js +166 -0
- package/src/consent.js +200 -26
- package/src/help.js +1 -0
- package/src/hit-params.js +70 -0
- package/src/html/consent.js +34 -9
- package/src/html/journeys.js +32 -70
- package/src/html/summary.js +1 -1
- package/src/i18n.js +152 -4
- package/src/mcp.js +280 -21
- package/src/metrics.js +13 -7
- package/src/parsers.js +62 -8
- package/src/report.js +9 -3
- package/src/verify.js +2 -0
package/src/mcp.js
CHANGED
|
@@ -29,7 +29,11 @@ import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
|
|
|
29
29
|
import { createProvider } from "./providers.js";
|
|
30
30
|
import { savePlan } from "./plan.js";
|
|
31
31
|
import { changesLines, sinceLastRun } from "./changes.js";
|
|
32
|
-
import { DEFAULT_PROVIDERS, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
32
|
+
import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, parseTrackingRequest, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
33
|
+
import { hitParams } from "./hit-params.js";
|
|
34
|
+
import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
|
|
35
|
+
import { cmpFacts } from "./cmp.js";
|
|
36
|
+
import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
|
|
33
37
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
34
38
|
|
|
35
39
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -44,7 +48,7 @@ Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the sit
|
|
|
44
48
|
|
|
45
49
|
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}, plus any other platform the site turns out to use); pass their choice as providers, or leave it out for the default.
|
|
46
50
|
|
|
47
|
-
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
|
|
51
|
+
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language. When a question needs a hit's raw parameters (client id, gcs, gcd decoded per consent signal, session flags), call get_hit with the step number and the hit's [h] number; get_step shows an earlier step again.
|
|
48
52
|
|
|
49
53
|
While browsing:
|
|
50
54
|
${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
|
|
@@ -82,14 +86,18 @@ function wholeFieldsIn(value, depth = 0, out = []) {
|
|
|
82
86
|
return out;
|
|
83
87
|
}
|
|
84
88
|
|
|
85
|
-
|
|
89
|
+
const consentText = (c) => Object.entries(c).map(([k, v]) => `${k}=${v}`).join(" ");
|
|
90
|
+
/** The Google protocol fields of a hit, short: values for the ids and consent strings, bare names for the 1-flags. */
|
|
91
|
+
const protocolText = (p) => Object.entries(p).map(([k, v]) => (["_fv", "_ss", "_nsi"].includes(k) && v === "1" ? k : `${k}=${v}`)).join(" ");
|
|
92
|
+
|
|
93
|
+
function describeHit(h, steps, index) {
|
|
86
94
|
const entries = Object.entries(h.params ?? {}).filter(([k]) => !["v", "tid", "gtm", "_p", "cid", "sid", "_s", "sct", "seg", "dl", "dr", "dt", "ul", "sr", "_et", "en"].includes(k));
|
|
87
95
|
const params = [
|
|
88
96
|
...entries.filter(([k]) => !WHOLE_FIELDS.has(k)).slice(0, 12).map(([k, v]) => cut(`${k}=${show(v)}`, VALUE_LIMIT)),
|
|
89
97
|
...entries.filter(([k]) => WHOLE_FIELDS.has(k)).map(([k, v]) => `${k}=${show(v)}`),
|
|
90
98
|
].join(", ");
|
|
91
99
|
const late = h.late ? ` (arrived ${(h.late.ms / 1000).toFixed(1)} s late, after the step that loaded its page: "${steps[h.step]?.label ?? `step ${h.step}`}")` : "";
|
|
92
|
-
return `- ${h.platform} ${h.name}${h.consent ? ` [consent ${
|
|
100
|
+
return `- [h${index}] ${h.platform} ${h.name}${h.consent ? ` [consent ${consentText(h.consent)}]` : ""}${h.protocol ? ` {${protocolText(h.protocol)}}` : ""}${params ? `: ${params}` : ""}${late}`;
|
|
93
101
|
}
|
|
94
102
|
|
|
95
103
|
/** A consent command in full, as key=value; any other push as JSON, cut, with its page address fields whole. */
|
|
@@ -103,7 +111,7 @@ function describePush(p) {
|
|
|
103
111
|
return `- ${cut(json, PUSH_LIMIT)}${whole.length ? ` (${whole.map(([k, v]) => `${k}=${v}`).join(", ")})` : ""}`;
|
|
104
112
|
}
|
|
105
113
|
|
|
106
|
-
/** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent
|
|
114
|
+
/** A call to a tracking endpoint that carries no event the tool reads, such as a Consent Mode ping, with its consent. */
|
|
107
115
|
function describeCall(c) {
|
|
108
116
|
let u;
|
|
109
117
|
try {
|
|
@@ -111,8 +119,35 @@ function describeCall(c) {
|
|
|
111
119
|
} catch {
|
|
112
120
|
return `- ${c.vendor ?? c.provider} ${cut(c.url, VALUE_LIMIT)}`;
|
|
113
121
|
}
|
|
114
|
-
const
|
|
115
|
-
|
|
122
|
+
const consent = googleConsent(u.searchParams);
|
|
123
|
+
// gclid is the landing click id the ping reports (0 when ads_data_redaction blanks it), gclaw the one _gcl_aw holds.
|
|
124
|
+
const signals = ["gcs", "gcd", "dma", "npa", "gclid", "gclaw"].filter((k) => u.searchParams.has(k))
|
|
125
|
+
.map((k) => `${k}=${u.searchParams.get(k)}${k === "gclid" && u.searchParams.get(k) === "0" ? " (redacted)" : ""}`).join(" ");
|
|
126
|
+
return `- ${c.vendor ?? c.provider}: ${u.hostname}${u.pathname}${signals ? ` [${signals}]` : ""}${consent ? ` [consent ${consentText(consent)}]` : ""}`;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const NAMES_SHOWN = 15;
|
|
130
|
+
/** The cookies a step added, changed or removed, by name, and what the analytics and ad cookies say when that changed. */
|
|
131
|
+
function cookieLines(session, step) {
|
|
132
|
+
const lines = [];
|
|
133
|
+
const changes = (session.cookieChanges ?? []).filter((c) => c.step === step);
|
|
134
|
+
const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
|
|
135
|
+
const part = (label, list) => (list.length ? `${label} ${list.slice(0, NAMES_SHOWN).join(", ")}${list.length > NAMES_SHOWN ? ` and ${list.length - NAMES_SHOWN} more` : ""}` : null);
|
|
136
|
+
const parts = [part("added", names("added")), part("changed", names("changed")), part("removed", names("removed"))].filter(Boolean);
|
|
137
|
+
if (parts.length) lines.push(`Cookies (names only): ${parts.join("; ")}`);
|
|
138
|
+
const id = session.steps[step]?.identity;
|
|
139
|
+
const before = session.steps.slice(0, step).map((st) => st.identity).filter(Boolean).at(-1);
|
|
140
|
+
if (!id || (!parts.length && JSON.stringify(id) === JSON.stringify(before))) return lines;
|
|
141
|
+
const facts = [];
|
|
142
|
+
if (id.ga_client_id) {
|
|
143
|
+
const prev = before?.ga_client_id;
|
|
144
|
+
const since = session.steps.findIndex((st) => st.identity?.ga_client_id === id.ga_client_id);
|
|
145
|
+
facts.push(`GA4 client id (_ga) ${id.ga_client_id}, ${!prev ? "new at this step" : prev === id.ga_client_id ? `the same since step ${since}` : `changed from ${prev}`}`);
|
|
146
|
+
}
|
|
147
|
+
if (id.gcl_aw) facts.push(id.gcl_aw === "landing" ? "_gcl_aw holds the landing gclid" : "_gcl_aw holds a gclid other than the landing one");
|
|
148
|
+
if (id.fbc) facts.push(id.fbc === "landing" ? "_fbc holds the landing fbclid" : "_fbc holds an fbclid other than the landing one");
|
|
149
|
+
if (facts.length) lines.push(`${facts.join("; ")}.`);
|
|
150
|
+
return lines;
|
|
116
151
|
}
|
|
117
152
|
|
|
118
153
|
export function stepCapture(audit, step) {
|
|
@@ -122,13 +157,108 @@ export function stepCapture(audit, step) {
|
|
|
122
157
|
// Hits that arrived during this step for a page an earlier step loaded are listed there, and noted here.
|
|
123
158
|
const lateHere = session.hits.filter((h) => h.late?.during === step);
|
|
124
159
|
const pings = session.network.filter((c) => c.step === step && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
|
|
125
|
-
const
|
|
160
|
+
const problem = session.steps[step]?.pageProblem;
|
|
161
|
+
const lines = [
|
|
162
|
+
...(problem?.kind === "challenge" ? [`WARNING: the page is a bot challenge from ${problem.vendor}, not the site. Nothing captured here says anything about the site's tracking; run the audit from a network the site trusts.`] : []),
|
|
163
|
+
...(problem?.kind === "crash" ? [`WARNING: the page crashed in the browser (the ${problem.vendor} error screen)${problem.refusedScripts ? `; ${problem.refusedScripts} of its own scripts came back as HTML pages, which bot protection or a mismatched deployment does` : ""}. Its tags may never have run.`] : []),
|
|
164
|
+
`Step ${step}: "${session.steps[step]?.label ?? ""}". Read a hit's full parameters with get_hit (audit_id, step ${step}, its [h] number).`,
|
|
165
|
+
`Tracking hits sent by this step: ${hits.length}`,
|
|
166
|
+
...hits.map((h, i) => describeHit(h, session.steps, i)),
|
|
167
|
+
];
|
|
126
168
|
if (lateHere.length) lines.push(`Arrived during this step but belong to the step that loaded their page: ${lateHere.map((h) => `${h.platform} ${h.name} (step "${session.steps[h.step]?.label ?? h.step}")`).join(", ")}`);
|
|
127
169
|
if (pings.length) lines.push("Other calls to analytics and ad endpoints, not read as events (Consent Mode pings, resends, remarketing):", ...pings.map(describeCall));
|
|
128
170
|
if (pushes.length) lines.push("dataLayer pushes:", ...pushes.map(describePush));
|
|
171
|
+
lines.push(...cookieLines(session, step));
|
|
172
|
+
const tag = session.steps[step]?.tagConsent;
|
|
173
|
+
if (tag) {
|
|
174
|
+
const state = (e) => (e.default && e.update ? `${e.default} → ${e.update}` : e.default ? `${e.default} (default)` : e.update ? `${e.update} (update)` : "not set");
|
|
175
|
+
const flags = [tag.usedDefault ? "default applied" : "no default applied", tag.usedUpdate && "update applied", tag.wasSetLate && "default set late", tag.waitPeriodTimedOut && "wait_for_update ran out"].filter(Boolean);
|
|
176
|
+
lines.push(`Google tag consent state (google_tag_data.ics), changed at this step: ${Object.entries(tag.entries).map(([k, e]) => `${k} ${state(e)}`).join(", ")}; ${flags.join(", ")}.`);
|
|
177
|
+
}
|
|
178
|
+
// The consent platform once, on the step it was first seen at.
|
|
179
|
+
const platform = session.cmp?.step === step ? cmpFacts(messages("en"), [{ cmp: session.cmp, network: session.network }]) : null;
|
|
180
|
+
if (platform) lines.push(`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`);
|
|
129
181
|
return lines.join("\n");
|
|
130
182
|
}
|
|
131
183
|
|
|
184
|
+
const MAX_PARAM_VALUE = 2000;
|
|
185
|
+
/** What a gcd letter says about one signal, in words. */
|
|
186
|
+
function gcdWords({ letter, default: d, update: u, known }) {
|
|
187
|
+
if (!known) return `${letter}: a letter this tool doesn't know`;
|
|
188
|
+
if (!d && !u) return `${letter}: consent mode not used`;
|
|
189
|
+
return `${letter}: ${d ? `${d} by default` : "no default"}, ${u ? `${u} by update` : "no update"}`;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/** One hit in full: where it was sent, its consent and every parameter its request carried. */
|
|
193
|
+
export function hitDetail(audit, step, index) {
|
|
194
|
+
const { session } = audit;
|
|
195
|
+
const hits = session.hits.filter((h) => h.step === step);
|
|
196
|
+
const h = hits[index];
|
|
197
|
+
if (!h) return null;
|
|
198
|
+
const { call, batch, event, context } = hitParams(session, h);
|
|
199
|
+
const label = session.steps[step]?.label ?? "";
|
|
200
|
+
const rows = (entries) => entries.map(([k, v]) => ` ${k} = ${cut(String(v), MAX_PARAM_VALUE)}`);
|
|
201
|
+
const lines = [`[h${index}] ${h.platform} ${h.name}, step ${step} "${label}"${h.late ? `, sent ${(h.late.ms / 1000).toFixed(1)} s late during step ${h.late.during} "${session.steps[h.late.during]?.label ?? ""}"` : ""}`];
|
|
202
|
+
lines.push(`Request: ${call ? `${call.method ?? ""} ${call.url}`.trim() : h.url}${call?.blocked ? " (answered locally, never reached the platform)" : ""}`);
|
|
203
|
+
if (h.consent) lines.push(`Consent: ${consentText(h.consent)}`);
|
|
204
|
+
const gcd = decodeGcd(h.protocol?.gcd);
|
|
205
|
+
if (gcd) lines.push(`gcd ${h.protocol.gcd}: ${Object.entries(gcd).map(([signal, state]) => `${signal} ${gcdWords(state)}`).join("; ")}`);
|
|
206
|
+
if (h.userData?.fields?.length) lines.push(`User data redacted from this hit: ${h.userData.fields.join(", ")}`);
|
|
207
|
+
lines.push(`Event parameters${batch ? ` (event ${batch[0]} of ${batch[1]} in one request)` : ""}:`, ...(event.length ? rows(event) : [" (none)"]));
|
|
208
|
+
if (context.length) lines.push("Other parameters (ids, page, device, consent):", ...rows(context));
|
|
209
|
+
return lines.join("\n");
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/**
|
|
213
|
+
* One entry per step, short enough to return with finish_audit and read without the report: what the step did, the
|
|
214
|
+
* hits it sent with their consent, its consent commands and other tracking calls, the Google tag's consent when it
|
|
215
|
+
* changed, and the cookies and ids that changed.
|
|
216
|
+
*/
|
|
217
|
+
export function timelineOf(run) {
|
|
218
|
+
let lastIds = null;
|
|
219
|
+
return (run.steps ?? []).map((st) => {
|
|
220
|
+
const hits = (run.hits ?? []).filter((h) => h.step === st.index);
|
|
221
|
+
const commands = (run.dataLayerLog ?? []).filter((e) => e.step === st.index && (isConsentCommand(e, "default") || isConsentCommand(e, "update")));
|
|
222
|
+
const others = (run.network ?? []).filter((c) => c.step === st.index && isCollectionEndpoint(c.url) && !parseTrackingRequest(c.url, c.postData ?? null));
|
|
223
|
+
const changes = (run.cookieChanges ?? []).filter((c) => c.step === st.index);
|
|
224
|
+
const names = (kind) => [...new Set(changes.filter((c) => c.change === kind).map((c) => c.name))];
|
|
225
|
+
const entry = { step: st.index, action: st.label, ...(st.pagePath ? { page: st.pagePath } : {}) };
|
|
226
|
+
if (st.error) entry.error = st.error;
|
|
227
|
+
if (hits.length) entry.hits = hits.map((h) => ({ platform: h.platform, event: h.name, ...(h.consent ? { consent: h.consent } : {}), ...(h.late ? { late_ms: h.late.ms } : {}) }));
|
|
228
|
+
if (others.length) entry.other_tracking_calls = others.length;
|
|
229
|
+
if (commands.length) entry.consent_commands = commands.map((e) => ({ [e.value[1]]: e.value[2] }));
|
|
230
|
+
if (st.tagConsent) entry.tag_consent = Object.fromEntries(Object.entries(st.tagConsent.entries).map(([k, e]) => [k, e.update ?? e.default ?? null]));
|
|
231
|
+
const cookies = Object.fromEntries(["added", "changed", "removed"].map((k) => [k, names(k)]).filter(([, list]) => list.length));
|
|
232
|
+
if (Object.keys(cookies).length) entry.cookies = cookies;
|
|
233
|
+
if (st.identity && JSON.stringify(st.identity) !== JSON.stringify(lastIds)) entry.ids = st.identity;
|
|
234
|
+
if (st.identity) lastIds = st.identity;
|
|
235
|
+
return entry;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const campaignSchema = () => z.union([z.boolean(), z.object(Object.fromEntries(CAMPAIGN_PARAMS.map((k) => [k, z.string().min(1).optional()]))).strict()]).optional();
|
|
240
|
+
const CAMPAIGN_DOC = `Default true: test values (utm_source=aitd, gclid=aitd-gclid, fbclid=aitd-fbclid...) unless the site address already carries campaign parameters. false opens the site as given. Or your own values, e.g. {"utm_source": "newsletter", "utm_campaign": "autumn", "gclid": "test123"}; allowed keys: ${CAMPAIGN_PARAMS.join(", ")}.`;
|
|
241
|
+
|
|
242
|
+
const LIVE_NOTE = "LIVE: tracking requests go to the real analytics and ad accounts, so this test traffic shows in their reports (GA4 Realtime and DebugView, Meta Events Manager).";
|
|
243
|
+
|
|
244
|
+
const OFFSCREEN_SHOWN = 40;
|
|
245
|
+
const UNCHANGED_TEXT = "(unchanged since the last page view)";
|
|
246
|
+
const UNCHANGED_ELEMENTS = "(unchanged since the last page view: the same refs still apply)";
|
|
247
|
+
const DETAIL_DOC = "compact (default for browser_action): up to 40 elements above or below the screen, and the page text or element list replaced by a note when unchanged since the last view. full: every element and the whole text again.";
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* The page view for the chat app. Long sessions lose their oldest results from the context, so a compact view
|
|
251
|
+
* leaves out what the last one already showed: the same text, the same elements (and so the same refs).
|
|
252
|
+
*/
|
|
253
|
+
async function viewFor(audit, error, detail = "compact") {
|
|
254
|
+
const full = detail === "full";
|
|
255
|
+
const snap = await audit.session.snapshot({ offscreenLimit: full ? null : OFFSCREEN_SHOWN });
|
|
256
|
+
const last = audit.lastView;
|
|
257
|
+
audit.lastView = { url: snap.url, text: snap.text, elements: snap.elements };
|
|
258
|
+
if (full || !last || last.url !== snap.url) return pageView(snap, error);
|
|
259
|
+
return pageView({ ...snap, text: snap.text === last.text ? UNCHANGED_TEXT : snap.text, elements: snap.elements === last.elements ? UNCHANGED_ELEMENTS : snap.elements }, error);
|
|
260
|
+
}
|
|
261
|
+
|
|
132
262
|
function getAudit(id) {
|
|
133
263
|
const audit = audits.get(id);
|
|
134
264
|
if (!audit) throw new Error(`No open audit "${id}". Start one with start_audit. Audits close after ${IDLE_MS / 60000} minutes without activity.`);
|
|
@@ -187,6 +317,8 @@ async function loadAgainst(ref, site, opts) {
|
|
|
187
317
|
*/
|
|
188
318
|
export function buildServer(opts) {
|
|
189
319
|
const server = new McpServer({ name: "ai-tag-debugger", version: VERSION }, { instructions: INSTRUCTIONS });
|
|
320
|
+
// The reports this server wrote, by folder name: get_report reads these and nothing else, over HTTP too.
|
|
321
|
+
const written = new Map();
|
|
190
322
|
|
|
191
323
|
server.registerTool(
|
|
192
324
|
"start_audit",
|
|
@@ -209,9 +341,11 @@ export function buildServer(opts) {
|
|
|
209
341
|
.array(z.enum(Object.keys(TRACKING_PROVIDERS)))
|
|
210
342
|
.optional()
|
|
211
343
|
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
344
|
+
campaign: campaignSchema().describe(`Open the first page the way an ad click does, so finish_audit checks that the campaign parameters survive redirects and reach the hits. ${CAMPAIGN_DOC}`),
|
|
345
|
+
live: z.boolean().optional().describe("Default false: tracking requests are answered locally and never reach the site's accounts. true lets them through to the real analytics and ad accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant: the test traffic then shows in the site's reports. Only when the user asks for it."),
|
|
212
346
|
},
|
|
213
347
|
},
|
|
214
|
-
async ({ site, journey, viewport, allowed_hosts, providers }) => {
|
|
348
|
+
async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false }) => {
|
|
215
349
|
let url;
|
|
216
350
|
try {
|
|
217
351
|
url = new URL(site);
|
|
@@ -227,7 +361,8 @@ export function buildServer(opts) {
|
|
|
227
361
|
if (audits.size >= MAX_OPEN_AUDITS) return fail(`${audits.size} audits are already open. Finish one with finish_audit first.`);
|
|
228
362
|
let session;
|
|
229
363
|
try {
|
|
230
|
-
|
|
364
|
+
if (live && !opts.allowLive) return fail("Live audits are off on this server: it was started without --allow-live. Leave live out to audit with tracking requests answered locally.");
|
|
365
|
+
session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live });
|
|
231
366
|
} catch (err) {
|
|
232
367
|
return fail(err.message);
|
|
233
368
|
}
|
|
@@ -242,10 +377,14 @@ export function buildServer(opts) {
|
|
|
242
377
|
const name = preset?.id ?? ((journey || "journey").toLowerCase().replace(/[^\w-]+/g, "-").slice(0, 40) || "journey");
|
|
243
378
|
const audit = { id, session, site: url.href, journey: name, preset, viewport: viewport ?? "desktop", lastUsed: Date.now() };
|
|
244
379
|
audits.set(id, audit);
|
|
245
|
-
const
|
|
246
|
-
const
|
|
380
|
+
const start = campaign === false ? url.href : withCampaignParams(url.href, typeof campaign === "object" ? campaign : null);
|
|
381
|
+
const error = await session.act({ action: "goto", url: start, reason: `Open ${start}` });
|
|
382
|
+
const page = await viewFor(audit, error);
|
|
247
383
|
const goal = preset ? `Journey goal: ${preset.goal}\nEvents expected on the way: ${preset.events.map((e) => `${e.platform} ${e.name}`).join(", ")}\n\n` : "";
|
|
248
|
-
|
|
384
|
+
const sent = campaignParamsIn(start);
|
|
385
|
+
const opened = Object.keys(sent).length ? `Opened like an ad click with ${Object.entries(sent).map(([k, v]) => `${k}=${v}`).join(", ")}; finish_audit checks that they survive redirects and reach the hits.\n` : "";
|
|
386
|
+
const liveLine = live ? `${LIVE_NOTE}\n` : "";
|
|
387
|
+
return text(`audit_id: ${id}\n${liveLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
|
|
249
388
|
},
|
|
250
389
|
);
|
|
251
390
|
|
|
@@ -265,6 +404,7 @@ export function buildServer(opts) {
|
|
|
265
404
|
reason: z.string().describe("Short description of the step, e.g. 'Add the first product to the cart'. Shown in the report."),
|
|
266
405
|
accepts_consent: z.boolean().optional().describe("True only when this click accepts the cookie or consent banner."),
|
|
267
406
|
rejects_consent: z.boolean().optional().describe("True only when this click rejects or declines all cookies on the consent banner."),
|
|
407
|
+
detail: z.enum(["compact", "full"]).optional().describe(DETAIL_DOC),
|
|
268
408
|
},
|
|
269
409
|
},
|
|
270
410
|
async (input) => {
|
|
@@ -272,7 +412,7 @@ export function buildServer(opts) {
|
|
|
272
412
|
if (["click", "fill", "select"].includes(input.action) && !Number.isInteger(input.ref) && !input.text?.trim()) return fail(`${input.action} needs a ref or text.`);
|
|
273
413
|
if (input.action === "goto" && !input.url) return fail("goto needs a url.");
|
|
274
414
|
const error = await audit.session.act(input);
|
|
275
|
-
const page =
|
|
415
|
+
const page = await viewFor(audit, error, input.detail);
|
|
276
416
|
return text(`${page}\n\n${stepCapture(audit, audit.session.currentStep)}`);
|
|
277
417
|
},
|
|
278
418
|
);
|
|
@@ -281,11 +421,115 @@ export function buildServer(opts) {
|
|
|
281
421
|
"get_page",
|
|
282
422
|
{
|
|
283
423
|
title: "Show the current page",
|
|
284
|
-
description: "Returns the current page view of an open audit without doing anything.",
|
|
285
|
-
inputSchema: { audit_id: z.string() },
|
|
424
|
+
description: "Returns the current page view of an open audit without doing anything. By default in full: every element and the whole page text.",
|
|
425
|
+
inputSchema: { audit_id: z.string(), detail: z.enum(["compact", "full"]).optional().describe(`Default full here. ${DETAIL_DOC}`) },
|
|
426
|
+
annotations: { readOnlyHint: true },
|
|
427
|
+
},
|
|
428
|
+
async ({ audit_id, detail = "full" }) => text(await viewFor(getAudit(audit_id), null, detail)),
|
|
429
|
+
);
|
|
430
|
+
|
|
431
|
+
server.registerTool(
|
|
432
|
+
"get_hit",
|
|
433
|
+
{
|
|
434
|
+
title: "Show one tracking hit in full",
|
|
435
|
+
description:
|
|
436
|
+
"Returns every parameter one tracking hit's request carried (client and session ids, gcs and gcd with gcd decoded per consent signal, page, device and event parameters), where it was sent, and its consent. Step results list each hit with an [h] number; pass that and the step number.",
|
|
437
|
+
inputSchema: {
|
|
438
|
+
audit_id: z.string(),
|
|
439
|
+
step: z.number().int().min(0).describe("The step number from the step result's first line."),
|
|
440
|
+
hit: z.number().int().min(0).describe("The hit's [h] number in that step."),
|
|
441
|
+
},
|
|
286
442
|
annotations: { readOnlyHint: true },
|
|
287
443
|
},
|
|
288
|
-
async ({ audit_id }) =>
|
|
444
|
+
async ({ audit_id, step, hit }) => {
|
|
445
|
+
const audit = getAudit(audit_id);
|
|
446
|
+
const detail = hitDetail(audit, step, hit);
|
|
447
|
+
if (detail !== null) return text(detail);
|
|
448
|
+
const count = audit.session.hits.filter((h) => h.step === step).length;
|
|
449
|
+
return fail(step >= audit.session.steps.length ? `There is no step ${step}; the last is ${audit.session.steps.length - 1}.` : `Step ${step} has ${count} hit${count === 1 ? "" : "s"}${count ? ` (h0 to h${count - 1})` : ""}.`);
|
|
450
|
+
},
|
|
451
|
+
);
|
|
452
|
+
|
|
453
|
+
server.registerTool(
|
|
454
|
+
"get_step",
|
|
455
|
+
{
|
|
456
|
+
title: "Show an earlier step's capture again",
|
|
457
|
+
description:
|
|
458
|
+
"Returns what an earlier step captured, as browser_action showed it: its hits, other tracking calls and dataLayer pushes. Use it to see a step again, for example after a hit that arrived late was added to the step that loaded its page.",
|
|
459
|
+
inputSchema: { audit_id: z.string(), step: z.number().int().min(0).describe("The step number from the step result's first line.") },
|
|
460
|
+
annotations: { readOnlyHint: true },
|
|
461
|
+
},
|
|
462
|
+
async ({ audit_id, step }) => {
|
|
463
|
+
const audit = getAudit(audit_id);
|
|
464
|
+
if (step >= audit.session.steps.length) return fail(`There is no step ${step}; the last is ${audit.session.steps.length - 1}.`);
|
|
465
|
+
return text(stepCapture(audit, step));
|
|
466
|
+
},
|
|
467
|
+
);
|
|
468
|
+
|
|
469
|
+
server.registerTool(
|
|
470
|
+
"get_report",
|
|
471
|
+
{
|
|
472
|
+
title: "Read a report this server wrote",
|
|
473
|
+
description:
|
|
474
|
+
"Returns a report finish_audit or run_audit wrote, so it can be read without access to the computer running this server. part markdown (default): report.md, the verdict, findings, consent and journeys in about 120 lines. summary: summary.json, the counts, journeys and findings as JSON. timeline: one JSON entry per step of each journey (hits with their consent, consent commands, the Google tag's consent, cookies and ids).",
|
|
475
|
+
inputSchema: {
|
|
476
|
+
report: z.string().describe("The report name finish_audit or run_audit gave."),
|
|
477
|
+
part: z.enum(["markdown", "summary", "timeline"]).optional().describe("Default markdown."),
|
|
478
|
+
},
|
|
479
|
+
annotations: { readOnlyHint: true },
|
|
480
|
+
},
|
|
481
|
+
async ({ report, part = "markdown" }) => {
|
|
482
|
+
const dir = written.get(report);
|
|
483
|
+
if (!dir) return fail(written.size ? `No report "${report}" from this server. Its reports: ${[...written.keys()].join(", ")}.` : "This server has written no report yet.");
|
|
484
|
+
try {
|
|
485
|
+
if (part === "markdown") return text(await readFile(path.join(dir, "report.md"), "utf8"));
|
|
486
|
+
if (part === "summary") return text(await readFile(path.join(dir, "summary.json"), "utf8"));
|
|
487
|
+
const capture = JSON.parse(await readFile(path.join(dir, "capture.json"), "utf8"));
|
|
488
|
+
return text(JSON.stringify((capture.runs ?? []).map((r) => ({ journey: r.journeyId, viewport: r.viewport, steps: timelineOf(r) }))));
|
|
489
|
+
} catch (err) {
|
|
490
|
+
return fail(`Could not read the report: ${err.message}`);
|
|
491
|
+
}
|
|
492
|
+
},
|
|
493
|
+
);
|
|
494
|
+
|
|
495
|
+
server.registerTool(
|
|
496
|
+
"consent_matrix",
|
|
497
|
+
{
|
|
498
|
+
title: "Compare every consent choice side by side",
|
|
499
|
+
description:
|
|
500
|
+
"Opens the site's start page like an ad click in a fresh browser once per consent choice: no choice and on to the next page, accept all, reject all and analytics only. Returns which tags sent after each choice, and a matrix of the first Google hit's consent, whether the campaign parameters reached it, whether the GA4 client id was kept, the _gcl_aw cookie, the cookies set, when each tag was called and the Google tag's own consent, with what should not happen marked. Needs a consent banner the tool can answer. Takes about half a minute per choice.",
|
|
501
|
+
inputSchema: {
|
|
502
|
+
site: z.string().describe("The page to start from, e.g. https://www.example.com/"),
|
|
503
|
+
campaign: campaignSchema().describe(`The campaign parameters to open the start page with, as the UTM check. ${CAMPAIGN_DOC}`),
|
|
504
|
+
providers: z.array(z.enum(Object.keys(TRACKING_PROVIDERS))).optional().describe(`Tracking providers to count. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to.`),
|
|
505
|
+
allowed_hosts: z.array(z.string()).optional().describe("Other hosts the pages may load from or redirect to."),
|
|
506
|
+
concurrency: z.number().int().min(1).max(4).optional().describe("How many choices to try at once, each in its own browser. Default 2."),
|
|
507
|
+
language: z.enum(Object.keys(LANGUAGES)).optional().describe("Language of the answer. Default en."),
|
|
508
|
+
},
|
|
509
|
+
annotations: { readOnlyHint: true },
|
|
510
|
+
},
|
|
511
|
+
async ({ site, campaign, providers, allowed_hosts, concurrency = 2, language = "en" }) => {
|
|
512
|
+
let url;
|
|
513
|
+
try {
|
|
514
|
+
url = new URL(site);
|
|
515
|
+
if (!["http:", "https:"].includes(url.protocol)) throw new Error();
|
|
516
|
+
} catch {
|
|
517
|
+
return fail("site must be an http or https URL.");
|
|
518
|
+
}
|
|
519
|
+
if (opts.blockPrivate) {
|
|
520
|
+
const refused = await navigationError(url.href, { isPrivate: privateHostChecker() });
|
|
521
|
+
if (refused) return fail(`${refused} Over HTTP, audits are limited to public websites.`);
|
|
522
|
+
}
|
|
523
|
+
const config = { site: url.href, providers, allowed_hosts };
|
|
524
|
+
const sc = await runConsentScenarios(config, { campaign, concurrency, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}) });
|
|
525
|
+
if (!sc) return text(`No consent banner the tool can answer was found on ${url.href}, so the choices could not be compared. Walk the journey with start_audit and browser_action instead.`);
|
|
526
|
+
const m = messages(language);
|
|
527
|
+
const table = consentTable(sc);
|
|
528
|
+
const lines = [`Consent banner answered through: ${sc.cmp}.`, "", `| | ${table.cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(table.cols.length + 1)}`];
|
|
529
|
+
for (const r of table.rows) lines.push(`| ${TRACKING_PROVIDERS[r.platform] ?? r.platform} ${r.event} | ${r.cells.map((c) => (c.count === null ? "–" : `${c.count}${c.problem ? " ⚠" : ""}`)).join(" | ")} |`);
|
|
530
|
+
lines.push("", "Hits per tag and event after each choice; ⚠: sent with cookies where the choice says no.", "", ...consentMatrixMarkdown(m, sc));
|
|
531
|
+
return text(lines.join("\n"));
|
|
532
|
+
},
|
|
289
533
|
);
|
|
290
534
|
|
|
291
535
|
server.registerTool(
|
|
@@ -338,8 +582,10 @@ export function buildServer(opts) {
|
|
|
338
582
|
const counts = summarize(grouped);
|
|
339
583
|
const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
|
|
340
584
|
const { reportPath } = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
|
|
585
|
+
written.set(path.basename(outDir), outDir);
|
|
341
586
|
const m = messages(lang);
|
|
342
587
|
const lines = [
|
|
588
|
+
...(run.live ? [LIVE_NOTE] : []),
|
|
343
589
|
m.ui.counts(counts),
|
|
344
590
|
...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
|
|
345
591
|
"",
|
|
@@ -347,15 +593,22 @@ export function buildServer(opts) {
|
|
|
347
593
|
"",
|
|
348
594
|
`${run.hits.length} tracking hits, ${run.dataLayerLog.length} dataLayer pushes and ${run.network.length} analytics or ad network calls captured over ${run.steps.length} steps.`,
|
|
349
595
|
skippedLine(run),
|
|
596
|
+
...((platform) => (platform ? [`Consent platform: ${platform.vendor}. ${platform.facts.join(" ")}`] : []))(cmpFacts(messages("en"), [run])),
|
|
350
597
|
opts.allowFiles
|
|
351
598
|
? `Full report with the event timeline: ${reportPath}`
|
|
352
599
|
: opts.reportsUrl
|
|
353
600
|
? `Full report with the event timeline: ${opts.reportsUrl}/${path.basename(outDir)}/report.html`
|
|
354
601
|
: "",
|
|
355
602
|
"",
|
|
603
|
+
`To read the report here: get_report with report "${path.basename(outDir)}" (markdown, summary or timeline).`,
|
|
356
604
|
"Explain each problem to the user with its likely cause and fix.",
|
|
357
605
|
];
|
|
358
|
-
return
|
|
606
|
+
return {
|
|
607
|
+
content: [
|
|
608
|
+
{ type: "text", text: lines.filter((l, i, a) => l !== "" || a[i - 1] !== "").join("\n") },
|
|
609
|
+
{ type: "text", text: `Timeline (JSON, one entry per step):\n${JSON.stringify(timelineOf(run))}` },
|
|
610
|
+
],
|
|
611
|
+
};
|
|
359
612
|
},
|
|
360
613
|
);
|
|
361
614
|
|
|
@@ -484,6 +737,7 @@ export function buildServer(opts) {
|
|
|
484
737
|
"No AI provider key (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY) is set on the computer running this server, so scripted false can't browse with a model. Call run_audit again with scripted true (or leave it out) to replay the journeys' fixed steps, or browse the site yourself with start_audit, browser_action and finish_audit. Neither needs a key.",
|
|
485
738
|
);
|
|
486
739
|
}
|
|
740
|
+
written.set(path.basename(result.outDir), result.outDir);
|
|
487
741
|
const m = messages(result.lang);
|
|
488
742
|
const grouped = groupFindings(result.findings).filter((f) => f.severity !== "ok");
|
|
489
743
|
return text(
|
|
@@ -493,6 +747,7 @@ export function buildServer(opts) {
|
|
|
493
747
|
...result.runs.map((r) => `${r.journeyId} (${r.viewport}): ${r.completed ? "completed" : "not completed"}, ${r.note}`),
|
|
494
748
|
...grouped.map((f) => `- ${m.severity[f.severity]}${f.accepted ? ` (${m.ui.accepted})` : ""}: ${f.detail}`),
|
|
495
749
|
`Report: ${result.reportPath}`,
|
|
750
|
+
`To read the report here: get_report with report "${path.basename(result.outDir)}" (markdown, summary or timeline).`,
|
|
496
751
|
].join("\n"),
|
|
497
752
|
);
|
|
498
753
|
},
|
|
@@ -503,7 +758,7 @@ export function buildServer(opts) {
|
|
|
503
758
|
}
|
|
504
759
|
|
|
505
760
|
/** Starts the MCP server on stdio, or on HTTP with --http. */
|
|
506
|
-
export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false } = {}) {
|
|
761
|
+
export async function startMcp({ http = false, port = 8787, host = "127.0.0.1", token, reportsDir, publicUrl, allowPrivateNetwork = false, allowLive = false } = {}) {
|
|
507
762
|
const dir = path.resolve(reportsDir ?? path.join(homedir(), "ai-tag-debugger-reports"));
|
|
508
763
|
const shutdown = async () => {
|
|
509
764
|
await Promise.all([...audits.keys()].map(closeAudit));
|
|
@@ -513,7 +768,8 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
|
|
|
513
768
|
process.on("SIGTERM", shutdown);
|
|
514
769
|
|
|
515
770
|
if (!http) {
|
|
516
|
-
|
|
771
|
+
// On your own computer live runs are yours to start; a server others reach needs --allow-live.
|
|
772
|
+
const server = buildServer({ allowFiles: true, reportsDir: dir, allowLive: true });
|
|
517
773
|
await server.connect(new StdioServerTransport());
|
|
518
774
|
// stdout carries the protocol, so status goes to stderr.
|
|
519
775
|
console.error(`ai-tag-debugger MCP server ready on stdio. Reports go to ${dir}`);
|
|
@@ -530,6 +786,9 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
|
|
|
530
786
|
if (allowPrivateNetwork) {
|
|
531
787
|
console.error("Warning: --allow-private-network lets audits open localhost and private network addresses. Use it only for a local tunnel you control.");
|
|
532
788
|
}
|
|
789
|
+
if (allowLive) {
|
|
790
|
+
console.error("Warning: --allow-live lets start_audit send tracking requests to the real analytics and ad accounts of the sites it audits.");
|
|
791
|
+
}
|
|
533
792
|
const secret = token ?? randomBytes(18).toString("base64url");
|
|
534
793
|
const endpoint = `/mcp/${secret}`;
|
|
535
794
|
const digest = (s) => createHash("sha256").update(s).digest();
|
|
@@ -597,7 +856,7 @@ export async function startMcp({ http = false, port = 8787, host = "127.0.0.1",
|
|
|
597
856
|
return;
|
|
598
857
|
}
|
|
599
858
|
// Stateless: a new server and transport per request. Open audits live in the module, keyed by audit_id.
|
|
600
|
-
const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl });
|
|
859
|
+
const server = buildServer({ allowFiles: false, blockPrivate: !allowPrivateNetwork, reportsDir: dir, reportsUrl, allowLive });
|
|
601
860
|
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined, enableJsonResponse: true });
|
|
602
861
|
res.on("close", () => {
|
|
603
862
|
transport.close();
|
package/src/metrics.js
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// { plan, runs, findings, consentScenarios } (no I/O, no AI, no HTML) and returns plain data; the
|
|
5
5
|
// report decides how to show it. The rules are documented in docs/design/metrics.md.
|
|
6
6
|
|
|
7
|
-
import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize } from "./checks.js";
|
|
7
|
+
import { canonicalEvent, groupFindings, hasParam, hitMatchesEvent, isConsentCommand, PLAN_BUCKETS, planCheck, summarize, tagConsentStates } from "./checks.js";
|
|
8
8
|
import { AD_PLATFORMS, CONSENT_STATES, consentTable } from "./consent.js";
|
|
9
9
|
import { presetFor } from "./journeys.js";
|
|
10
10
|
import { OWNERS, ownerOf } from "./verify.js";
|
|
@@ -32,7 +32,7 @@ const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
|
|
|
32
32
|
const V2_SIGNALS = ["ad_user_data", "ad_personalization"];
|
|
33
33
|
// Consent findings that say the visitor's "no" was not honoured, against the ones that say the setup is risky.
|
|
34
34
|
const CONSENT_FAIL_CHECKS = new Set(["after_reject", "cookies_after_reject", "reject_ignored", "reject_button_failed"]);
|
|
35
|
-
const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_v2_missing", "consent_v2_update"]);
|
|
35
|
+
const CONSENT_ATTENTION_CHECKS = new Set(["before_consent", "cookies_before_consent", "consent_default_missing", "consent_default_late", "consent_default_not_applied", "consent_v2_missing", "consent_v2_update"]);
|
|
36
36
|
const CONSENT_CHECK = /^(consent_|cookies_|reject_|before_consent|after_reject)/;
|
|
37
37
|
|
|
38
38
|
// Ecommerce parameters sites send under another name (UA-style products/revenue, order ids), as checks.js reads them.
|
|
@@ -243,15 +243,21 @@ export function platformParity({ runs = [] } = {}) {
|
|
|
243
243
|
function consentDefaultIn(run) {
|
|
244
244
|
const log = run.dataLayerLog ?? [];
|
|
245
245
|
const entry = log.find((e) => isConsentCommand(e, "default")) ?? null;
|
|
246
|
+
// Without a dataLayer command, the Google tag's own state can still show a default (a consent platform's GTM template).
|
|
247
|
+
const tagStates = entry ? [] : tagConsentStates({ steps: run.steps ?? [] });
|
|
248
|
+
const tagDefault = tagStates.find((st) => st.usedDefault) ?? null;
|
|
249
|
+
const set = Boolean(entry || tagDefault);
|
|
246
250
|
const firstGoogleHit = (run.hits ?? []).filter((h) => GOOGLE_PLATFORMS.has(h.platform)).sort(byTime)[0] ?? null;
|
|
247
251
|
const step = entry ? stepOf(run, entry.step) : null;
|
|
248
|
-
const declared =
|
|
252
|
+
const declared = entry
|
|
253
|
+
? uniq(log.filter((e) => isConsentCommand(e, "default")).flatMap((e) => Object.keys(e.value[2] ?? {})))
|
|
254
|
+
: tagDefault ? Object.entries(tagDefault.entries).filter(([, e]) => e.default).map(([k]) => k) : [];
|
|
249
255
|
const missing = V2_SIGNALS.filter((k) => !declared.includes(k));
|
|
250
256
|
return {
|
|
251
|
-
journey: run.journeyId, viewport: run.viewport, present:
|
|
252
|
-
step: entry?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
|
|
253
|
-
beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : null,
|
|
254
|
-
v2Signals: { present:
|
|
257
|
+
journey: run.journeyId, viewport: run.viewport, present: set, source: entry ? "dataLayer" : tagDefault ? "tag" : null,
|
|
258
|
+
step: entry?.step ?? tagDefault?.step ?? null, stepLabel: step?.label ?? null, ms: entry && step && entry.t != null && step.t != null ? entry.t - step.t : null,
|
|
259
|
+
beforeFirstGoogleHit: entry && firstGoogleHit ? !sentBefore(firstGoogleHit, entry) : tagDefault ? !tagStates.some((st) => st.wasSetLate) : null,
|
|
260
|
+
v2Signals: { present: set && !missing.length, missing: set ? missing : V2_SIGNALS },
|
|
255
261
|
};
|
|
256
262
|
}
|
|
257
263
|
|
package/src/parsers.js
CHANGED
|
@@ -18,14 +18,63 @@ function ga4ParamsFrom(searchParams) {
|
|
|
18
18
|
return params;
|
|
19
19
|
}
|
|
20
20
|
|
|
21
|
-
|
|
22
|
-
|
|
21
|
+
export const CONSENT_SIGNALS = ["ad_storage", "analytics_storage", "ad_user_data", "ad_personalization"];
|
|
22
|
+
|
|
23
|
+
// gcd=13r3r3r2r5l1: one letter per signal, in CONSENT_SIGNALS order, each saying the default and the update
|
|
24
|
+
// the Google tag holds. Google publishes no key for the letters. Each one below was read off gtag.js in
|
|
25
|
+
// November 2026 by sending that consent sequence (test/parsers.test.js keeps the strings it sent); a letter
|
|
26
|
+
// not listed is kept as it is and never decides a check.
|
|
27
|
+
const GCD_STATES = {
|
|
28
|
+
l: {}, // consent mode not used for this signal
|
|
29
|
+
p: { default: "denied" },
|
|
30
|
+
q: { default: "denied", update: "denied" },
|
|
31
|
+
r: { default: "denied", update: "granted" },
|
|
32
|
+
t: { default: "granted" },
|
|
33
|
+
u: { default: "granted", update: "denied" },
|
|
34
|
+
v: { default: "granted", update: "granted" },
|
|
35
|
+
m: { update: "denied" },
|
|
36
|
+
n: { update: "granted" },
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** The gcd parameter as { signal: { letter, default?, update?, known } }, or null when it isn't one. */
|
|
40
|
+
export function decodeGcd(gcd) {
|
|
41
|
+
const m = /^\d{2}([a-z])\d([a-z])\d([a-z])\d([a-z])/i.exec(gcd ?? "");
|
|
42
|
+
if (!m) return null;
|
|
43
|
+
return Object.fromEntries(CONSENT_SIGNALS.map((signal, i) => {
|
|
44
|
+
const letter = m[i + 1].toLowerCase();
|
|
45
|
+
return [signal, { letter, ...(GCD_STATES[letter] ?? {}), known: Object.hasOwn(GCD_STATES, letter) }];
|
|
46
|
+
}));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* The consent a Google hit says it was sent with. gcs=G1xy gives ad_storage (x) and analytics_storage (y),
|
|
51
|
+
* 1 granted and 0 denied; gcd adds ad_user_data and ad_personalization, and stands in for gcs when it is absent.
|
|
52
|
+
*/
|
|
53
|
+
export function googleConsent(searchParams) {
|
|
54
|
+
const out = {};
|
|
23
55
|
const gcs = searchParams.get("gcs");
|
|
24
|
-
if (
|
|
25
|
-
|
|
26
|
-
|
|
56
|
+
if (gcs && /^G1[01-]{2}$/.test(gcs)) {
|
|
57
|
+
const state = (c) => (c === "1" ? "granted" : c === "0" ? "denied" : "unknown");
|
|
58
|
+
out.ad_storage = state(gcs[2]);
|
|
59
|
+
out.analytics_storage = state(gcs[3]);
|
|
60
|
+
}
|
|
61
|
+
const gcd = decodeGcd(searchParams.get("gcd"));
|
|
62
|
+
for (const signal of CONSENT_SIGNALS) {
|
|
63
|
+
const state = gcd?.[signal].update ?? gcd?.[signal].default;
|
|
64
|
+
if (!(signal in out) && state) out[signal] = state;
|
|
65
|
+
}
|
|
66
|
+
return Object.keys(out).length ? out : undefined;
|
|
27
67
|
}
|
|
28
68
|
|
|
69
|
+
// Fields of the Google protocol that are no event parameter but answer consent and session questions:
|
|
70
|
+
// the client and session ids, the session count and engagement, consent state, and the first visit,
|
|
71
|
+
// session start and new session id flags.
|
|
72
|
+
const GOOGLE_PROTOCOL = ["cid", "sid", "sct", "seg", "gcs", "gcd", "dma", "npa", "_fv", "_ss", "_nsi"];
|
|
73
|
+
const protocolOf = (searchParams) => {
|
|
74
|
+
const out = Object.fromEntries(GOOGLE_PROTOCOL.filter((k) => searchParams.has(k)).map((k) => [k, searchParams.get(k)]));
|
|
75
|
+
return Object.keys(out).length ? out : undefined;
|
|
76
|
+
};
|
|
77
|
+
|
|
29
78
|
// Only www.google.<tld> and doubleclick.net: analytics.google.com is a primary GA4 endpoint.
|
|
30
79
|
const GOOGLE_COMPANION_HOST = /^(www\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$|(^|\.)doubleclick\.net$/;
|
|
31
80
|
|
|
@@ -72,7 +121,8 @@ export function parseGa4(url, postData) {
|
|
|
72
121
|
name: merged.get("en") || "(unnamed)",
|
|
73
122
|
id: merged.get("tid") || undefined,
|
|
74
123
|
params: ga4ParamsFrom(merged),
|
|
75
|
-
consent:
|
|
124
|
+
consent: googleConsent(merged),
|
|
125
|
+
protocol: protocolOf(merged),
|
|
76
126
|
...(items.length ? { items } : {}),
|
|
77
127
|
...(autoEvents.length ? { autoEvents } : {}),
|
|
78
128
|
...(user.length ? { userFields: Object.fromEntries(user) } : {}),
|
|
@@ -112,7 +162,9 @@ export function parseGoogleAds(url) {
|
|
|
112
162
|
for (const key of ["label", "value", "currency_code", "oid"]) {
|
|
113
163
|
if (u.searchParams.has(key)) params[key === "currency_code" ? "currency" : key === "oid" ? "transaction_id" : key] = u.searchParams.get(key);
|
|
114
164
|
}
|
|
115
|
-
|
|
165
|
+
const consent = googleConsent(u.searchParams);
|
|
166
|
+
const protocol = protocolOf(u.searchParams);
|
|
167
|
+
return [{ platform: "google_ads", name: params.label ? `conversion:${params.label}` : "conversion", id: conversionId, params, ...(consent ? { consent } : {}), ...(protocol ? { protocol } : {}), url }];
|
|
116
168
|
}
|
|
117
169
|
|
|
118
170
|
/** @returns {Hit[] | null} */
|
|
@@ -435,12 +487,14 @@ export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
|
|
|
435
487
|
// Tag managers and consent platforms are always shown: they explain how the tags loaded.
|
|
436
488
|
// Server-side tagging providers are too: their requests show where events are sent from.
|
|
437
489
|
const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform", stape: "Stape", elevar: "Elevar" };
|
|
490
|
+
/** The display name of any provider key a network call can carry: a tracking vendor or one that is always shown. */
|
|
491
|
+
export const providerLabel = (key) => PROVIDERS[key] ?? ALWAYS_SHOWN[key] ?? key;
|
|
438
492
|
|
|
439
493
|
const HOSTS = [
|
|
440
494
|
[/(^|\.)stape\.(io|jp)$/, "stape"],
|
|
441
495
|
[/(^|\.)getelevar\.com$/, "elevar"],
|
|
442
496
|
...VENDORS.filter((v) => v.hosts).map((v) => [v.hosts, v.key]),
|
|
443
|
-
[/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.(eu|com)|cookieyes\.com|didomi\.io|privacy-center\.org|trustarc\.com|truste\.com|consensu\.org|quantcast\.com|osano\.com|iubenda\.com|termly\.io|cookiefirst\.com|axeptio\.eu|sourcepoint\.com|sp-prod\.net|consentmanager\.net|cookiescript\.com|cookie-script\.com|cookieinformation\.com|civiccomputing\.com|piwik\.pro|complianz\.io|cdn\.privacy-mgmt\.com)$/, "consent"],
|
|
497
|
+
[/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.(eu|com)|cookieyes\.com|didomi\.io|privacy-center\.org|trustarc\.com|truste\.com|consensu\.org|quantcast\.com|osano\.com|iubenda\.com|termly\.io|cookiefirst\.com|axeptio\.eu|sourcepoint\.com|sp-prod\.net|consentmanager\.net|cookiescript\.com|cookie-script\.com|cookieinformation\.com|civiccomputing\.com|piwik\.pro|complianz\.io|cdn\.privacy-mgmt\.com|efilli\.com|cdn-cookieyes\.com)$/, "consent"],
|
|
444
498
|
];
|
|
445
499
|
|
|
446
500
|
/**
|