@analitikisler/ai-tag-debugger 0.20.0 → 0.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/parsers.js CHANGED
@@ -18,14 +18,63 @@ function ga4ParamsFrom(searchParams) {
18
18
  return params;
19
19
  }
20
20
 
21
- // gcs=G1xy: x = ad_storage, y = analytics_storage (1 granted, 0 denied).
22
- function ga4Consent(searchParams) {
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 (!gcs || !/^G1[01-]{2}$/.test(gcs)) return undefined;
25
- const state = (c) => (c === "1" ? "granted" : c === "0" ? "denied" : "unknown");
26
- return { ad_storage: state(gcs[2]), analytics_storage: state(gcs[3]) };
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
 
@@ -64,14 +113,18 @@ export function parseGa4(url, postData) {
64
113
  for (const [k, v] of new URLSearchParams(line)) merged.set(k, v);
65
114
  const items = [...merged].filter(([k]) => /^pr\d+$/.test(k)).map(([, v]) => v);
66
115
  const user = [...merged].filter(([k]) => isUserDataKey(k));
116
+ // GA4 makes first_visit and session_start from these flags on another hit; they are never sent by name.
117
+ const autoEvents = [["_fv", "first_visit"], ["_ss", "session_start"]].filter(([flag]) => merged.has(flag)).map(([, name]) => name);
67
118
  return {
68
119
  platform: "ga4",
69
120
  delivery: ga4Delivery(u),
70
121
  name: merged.get("en") || "(unnamed)",
71
122
  id: merged.get("tid") || undefined,
72
123
  params: ga4ParamsFrom(merged),
73
- consent: ga4Consent(merged),
124
+ consent: googleConsent(merged),
125
+ protocol: protocolOf(merged),
74
126
  ...(items.length ? { items } : {}),
127
+ ...(autoEvents.length ? { autoEvents } : {}),
75
128
  ...(user.length ? { userFields: Object.fromEntries(user) } : {}),
76
129
  url,
77
130
  };
@@ -109,7 +162,9 @@ export function parseGoogleAds(url) {
109
162
  for (const key of ["label", "value", "currency_code", "oid"]) {
110
163
  if (u.searchParams.has(key)) params[key === "currency_code" ? "currency" : key === "oid" ? "transaction_id" : key] = u.searchParams.get(key);
111
164
  }
112
- return [{ platform: "google_ads", name: params.label ? `conversion:${params.label}` : "conversion", id: conversionId, params, url }];
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 }];
113
168
  }
114
169
 
115
170
  /** @returns {Hit[] | null} */
@@ -432,12 +487,14 @@ export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
432
487
  // Tag managers and consent platforms are always shown: they explain how the tags loaded.
433
488
  // Server-side tagging providers are too: their requests show where events are sent from.
434
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;
435
492
 
436
493
  const HOSTS = [
437
494
  [/(^|\.)stape\.(io|jp)$/, "stape"],
438
495
  [/(^|\.)getelevar\.com$/, "elevar"],
439
496
  ...VENDORS.filter((v) => v.hosts).map((v) => [v.hosts, v.key]),
440
- [/(^|\.)(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"],
441
498
  ];
442
499
 
443
500
  /**
package/src/report.js CHANGED
@@ -1,4 +1,5 @@
1
- import { AD_PLATFORMS, consentTable } from "./consent.js";
1
+ import { AD_PLATFORMS, consentMatrixMarkdown, consentTable } from "./consent.js";
2
+ import { cmpFacts } from "./cmp.js";
2
3
  import { ownerOf, verifyLinks } from "./verify.js";
3
4
  import { createProvider } from "./providers.js";
4
5
  import { canonicalEvent, groupFindings, summarize } from "./checks.js";
@@ -293,8 +294,10 @@ function mdCell(m, col, cell, raw, adPlatform) {
293
294
  }
294
295
 
295
296
  /** The consent section: the Consent Mode timing sentence, then the scenario table with a reason in every cell. */
296
- function mdConsent(m, metrics, scenarios) {
297
+ function mdConsent(m, metrics, scenarios, runs = []) {
297
298
  const lines = [`## ${m.ui.md.consent}`, ""];
299
+ const platform = cmpFacts(m, runs);
300
+ if (platform) lines.push(`${m.ui.cmp.title}: ${mdText(platform.vendor)}. ${platform.facts.map(mdText).join(" ")}`, "");
298
301
  const cm = metrics.consent?.consentMode;
299
302
  const tested = Boolean(cm?.runs?.length);
300
303
  if (tested) {
@@ -307,6 +310,8 @@ function mdConsent(m, metrics, scenarios) {
307
310
  lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
308
311
  for (const r of rows) lines.push(`| ${mdText(platformLabel(r.platform))} \`${mdCode(r.event)}\` | ${r.cells.map((c) => mdCell(m, c.state, c, rawOf(c.state, r.platform, r.event), AD_PLATFORMS.has(r.platform))).join(" | ")} |`);
309
312
  lines.push("", m.ui.md.consentLegend, "");
313
+ const matrix = consentMatrixMarkdown(m, scenarios);
314
+ if (matrix.length) lines.push(`### ${m.ui.matrix.title}`, "", ...matrix, "");
310
315
  } else if (!tested) lines.push(m.ui.md.consentNotChecked, "");
311
316
  return lines;
312
317
  }
@@ -417,6 +422,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
417
422
  const meta = [m.ui.md.meta(mdText(generatedAt), runs.length, m.ui.md.platformsSeen(platformsSeen.length, platformsSeen.map((p) => mdText(platformLabel(p))).join(", ")), VERSION), `[${m.ui.md.htmlReport}](report.html)`, ...(against ? [mdText(m.ui.checkedAgainst(against))] : [])];
418
423
  const status = `**${m.verdict.status[metrics.verdict.status]}**${metrics.score?.score === null || metrics.score?.score === undefined ? "" : ` · ${m.ui.md.score(metrics.score.score)}`}`;
419
424
  const lines = [`# ${mdText(m.ui.md.title(site ?? title, mdHeadline(m, runs, rows, metrics)))}`, "", meta.join(" · "), status, `**${m.ui.counts(counts)}**${changes && !changes.previous ? ` · ${m.ui.noPreviousRun}` : ""}`, ""];
425
+ if (runs.some((r) => r.live)) lines.push(`**${mdText(m.ui.liveRun)}**`, "");
420
426
  if (accepted) lines.push(mdText(m.ui.acceptedLine(accepted)), "");
421
427
  if (planChanges) lines.push(mdText(m.ui.planChangesLine(planChanges)), "", ...planChangeItems(planChanges).map((i) => `- ${mdText(i)}`), ...(planChangeItems(planChanges).length ? [""] : []));
422
428
  const sum = summaryParts(explained?.summary);
@@ -436,7 +442,7 @@ export function renderMarkdown({ title, site = null, generatedAt, findings, expl
436
442
  for (const f of problems.slice(0, MD_BLOCKS)) lines.push(...mdFinding(m, runs, f, ids));
437
443
  const rest = problems.slice(MD_BLOCKS);
438
444
  if (rest.length) lines.push(`### ${m.ui.md.also}`, ...rest.map((f) => `- **${m.severity[f.severity]}** ${mdDetail(f.detail).replace(/\.$/, "")} · ${mdWhere(m, runs, f.where, { full: false })}`), "");
439
- lines.push(...mdConsent(m, metrics, consentScenarios));
445
+ lines.push(...mdConsent(m, metrics, consentScenarios, runs));
440
446
  lines.push(...mdPassed(m, runs, findings, metrics, planCheck));
441
447
  lines.push(...mdJourneys(m, runs, rows));
442
448
  lines.push(...mdNotChecked(m, runs, rows, plan, consentScenarios));
package/src/verify.js CHANGED
@@ -14,6 +14,9 @@ const OWNER_BY_CHECK = {
14
14
  server_side: "developer",
15
15
  unplanned_events: "marketing",
16
16
  planned_out_of_scope: "marketing",
17
+ ga4_automatic_event: "marketing",
18
+ page_blocked: "developer",
19
+ page_crashed: "developer",
17
20
  };
18
21
 
19
22
  /** Who most likely fixes this finding: the site's developer, whoever runs Tag Manager, or marketing (the plan itself). Tag and consent setup is the default. */