@analitikisler/ai-tag-debugger 0.13.0 → 0.15.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/changes.js CHANGED
@@ -1,6 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
- import { listRunIds, runsDir } from "./workspace.js";
3
+ import { messages } from "./i18n.js";
4
+ import { platformLabel } from "./report.js";
5
+ import { PLAN_FILE, listPlanIds, listRunIds, plansDir, runsDir } from "./workspace.js";
4
6
 
5
7
  const PROBLEMS = new Set(["broken", "risk", "warning"]);
6
8
  const PLAN_PROBLEMS = new Set(["missing", "missing_params", "wrong_values"]);
@@ -36,16 +38,36 @@ function planProblems(pc) {
36
38
  }
37
39
 
38
40
  /** The pages one run visited, in order, one entry per journey and viewport. Null when it recorded none (older runs). */
39
- function routes(runs = []) {
41
+ function routes(runs = [], completedOnly = false) {
40
42
  const out = new Map();
41
43
  for (const r of runs) {
44
+ if (completedOnly && (!r.completed || !r.goal)) continue;
42
45
  const pages = [];
43
46
  for (const s of r.steps ?? []) if (s.pagePath && pages.at(-1) !== s.pagePath) pages.push(s.pagePath);
44
- if (pages.length) out.set(`${r.journeyId}|${r.viewport}`, { journeyId: r.journeyId, viewport: r.viewport, pages });
47
+ if (pages.length) out.set(`${r.journeyId}|${r.viewport}`, { journeyId: r.journeyId, viewport: r.viewport, goal: r.goal, pages });
45
48
  }
46
49
  return out;
47
50
  }
48
51
 
52
+ const HINT_SCAN = 10;
53
+
54
+ /**
55
+ * The pages each journey visited the last time it completed on this site, so a repeat run can start from a
56
+ * known route: Map of "journeyId|viewport" to { goal, pages }. A run only counts for the same goal, since
57
+ * "custom" and the like are reused for different goals, and only if it recorded its goal (older runs didn't).
58
+ */
59
+ export async function routeHints(dataDir, site) {
60
+ const hints = new Map();
61
+ let before = null;
62
+ for (let i = 0; i < HINT_SCAN; i++) {
63
+ const prev = await previousRun(dataDir, site, before);
64
+ if (!prev) break;
65
+ for (const [k, r] of routes(prev.capture?.runs, true)) if (!hints.has(k)) hints.set(k, { goal: r.goal, pages: r.pages });
66
+ before = prev.id;
67
+ }
68
+ return hints;
69
+ }
70
+
49
71
  /** Journeys whose route through the site differs from the previous run: [{ journeyId, viewport, before, after }]. */
50
72
  export function compareRoutes(prevRuns, runs) {
51
73
  const before = routes(prevRuns);
@@ -92,3 +114,69 @@ function compareFindings(prev, { findings, planCheck }) {
92
114
  new: [...now].filter(([k]) => !before.has(k)).map(([, f]) => f),
93
115
  };
94
116
  }
117
+
118
+ /**
119
+ * The "since last run" diff for a site's newest saved run, as the report showed it: that run compared
120
+ * with the one before. Returns { run: null } when nothing is saved, else { run, ...compareRuns() }.
121
+ * Shared by anything that reports the diff outside the report (MCP, alerts).
122
+ */
123
+ export async function sinceLastRun(dataDir, site) {
124
+ const latest = await previousRun(dataDir, site);
125
+ if (!latest) return { run: null, previous: null };
126
+ const saved = latest.capture.changes;
127
+ if (saved) return { run: latest.id, ...saved };
128
+ const prev = await previousRun(dataDir, site, latest.id);
129
+ return { run: latest.id, ...compareRuns(prev, { findings: latest.capture.findings ?? [], planCheck: latest.planCheck, runs: latest.capture.runs }) };
130
+ }
131
+
132
+ /** The diff as plain lines: a headline, then one line per fixed and new problem. */
133
+ export function changesLines(ch, lang = "en") {
134
+ const m = messages(lang);
135
+ if (!ch.run) return [m.ui.noSavedRun];
136
+ if (!ch.previous) return [m.ui.noPreviousRun];
137
+ const label = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
138
+ const why = (c, bucket) => (ch.mode === "findings" ? c.detail : m.ui.bucket[bucket]);
139
+ return [
140
+ `${m.ui.changesLine(ch)}. ${m.ui.changesLead(ch.previous, ch.mode)} (${ch.run}).`,
141
+ ...ch.fixed.map((c) => `- ${m.ui.changeFixed}: ${label(c)} (${why(c, c.was)})`),
142
+ ...ch.new.map((c) => `- ${m.ui.changeNew}: ${label(c)} (${why(c, c.bucket)})`),
143
+ ...(ch.routes?.length ? [m.ui.routeChanged(ch.routes.map((r) => `${r.journeyId} (${r.viewport})`).join(", "))] : []),
144
+ ];
145
+ }
146
+
147
+ const planKey = (e) => [e.platform ?? "ga4", e.name].join("|");
148
+ const sortedList = (v) => JSON.stringify([...(v ?? [])].sort());
149
+
150
+ /** What differs for an event kept in both plans: its required parameters, parameter list or journeys. */
151
+ function eventChanges(a, b) {
152
+ const out = [];
153
+ if (sortedList(a.required_params) !== sortedList(b.required_params)) out.push("required parameters");
154
+ if (sortedList(Object.keys(a.parameters ?? {})) !== sortedList(Object.keys(b.parameters ?? {}))) out.push("parameters");
155
+ if (sortedList(a.journeys) !== sortedList(b.journeys)) out.push("journeys");
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * How a plan differs from the version before it: events added, removed and changed.
161
+ * Returns null when there is no earlier version, else { previous, added, removed, changed, same }.
162
+ */
163
+ export function comparePlans(prevPlan, plan, previousId = null) {
164
+ if (!prevPlan || !plan) return null;
165
+ const before = new Map((prevPlan.events ?? []).map((e) => [planKey(e), e]));
166
+ const now = new Map((plan.events ?? []).map((e) => [planKey(e), e]));
167
+ const label = (e) => ({ platform: e.platform ?? "ga4", event: e.name });
168
+ const added = [...now].filter(([k]) => !before.has(k)).map(([, e]) => label(e));
169
+ const removed = [...before].filter(([k]) => !now.has(k)).map(([, e]) => label(e));
170
+ const changed = [...now].filter(([k]) => before.has(k)).map(([k, e]) => ({ ...label(e), what: eventChanges(before.get(k), e) })).filter((c) => c.what.length);
171
+ return { previous: previousId, added, removed, changed, same: !added.length && !removed.length && !changed.length };
172
+ }
173
+
174
+ /** The saved plan right before `planId` for the site, compared with `plan`; null for the first version or an unsaved plan. */
175
+ export async function previousPlanChanges(dataDir, site, planId, plan) {
176
+ if (!planId) return null;
177
+ const ids = await listPlanIds(dataDir, site);
178
+ const prevId = ids.filter((id) => id < planId).at(-1);
179
+ if (!prevId) return null;
180
+ const prevPlan = await readJson(path.join(plansDir(dataDir, site), prevId, PLAN_FILE));
181
+ return comparePlans(prevPlan, plan, prevId);
182
+ }
package/src/checks.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
+ import { campaignParamsIn } from "./campaign.js";
6
7
 
7
8
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
8
9
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
@@ -296,6 +297,33 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
296
297
  }
297
298
  }
298
299
 
300
+ // Campaign parameters (gclid, fbclid, UTM) on the landing address: they must survive redirects and reach the hits.
301
+ const landing = run.landing;
302
+ const sent = landing ? campaignParamsIn(landing.requested) : {};
303
+ if (landing && Object.keys(sent).length) {
304
+ const kept = campaignParamsIn(landing.final);
305
+ const lost = Object.keys(sent).filter((n) => kept[n] !== sent[n]);
306
+ const hop = landing.chain.length > 1 ? landing.chain : null;
307
+ if (lost.length) {
308
+ findings.push({ ...base, severity: "broken", check: "campaign_lost_redirect", step: stepLabel(landing.step), detail: d.campaignLost(lost, landing.final), evidence: hop ? hop.join(" -> ") : undefined });
309
+ }
310
+ // Hits the landing page sent whose page address leaves out a parameter the page still had.
311
+ const missing = new Map();
312
+ for (const hit of run.hits) {
313
+ const loc = hit.params?.page_location;
314
+ if (hit.step !== landing.step || !loc || hit.platform === "google_ads") continue;
315
+ const here = campaignParamsIn(loc);
316
+ const gone = Object.keys(kept).filter((n) => kept[n] === sent[n] && here[n] !== sent[n] && hit.params?.[n] !== sent[n]);
317
+ if (gone.length && !missing.has(hit.platform)) missing.set(hit.platform, { hit, gone });
318
+ }
319
+ for (const [platform, { hit, gone }] of missing) {
320
+ findings.push({ ...base, severity: "broken", check: "campaign_missing_in_hits", platform, event: hit.name, step: stepLabel(hit.step), detail: d.campaignMissingInHits(platform, hit.name, gone), evidence: hit.params.page_location });
321
+ }
322
+ if (!lost.length && !missing.size) {
323
+ findings.push({ ...base, severity: "ok", check: "campaign_params", step: stepLabel(landing.step), detail: d.campaignOk(Object.keys(sent)) });
324
+ }
325
+ }
326
+
299
327
  // Consent.
300
328
  const granted = consentGrantedStep(run);
301
329
  // Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
package/src/cli.js CHANGED
@@ -205,6 +205,8 @@ async function main(args = process.argv.slice(2)) {
205
205
  const rel = path.relative(process.cwd(), result.reportPath);
206
206
  console.log(`Report: ${rel.startsWith("..") ? result.reportPath : rel}`);
207
207
 
208
+ if (!against && !values.out) await offerLearnedPlan(result, loaded.config.site, values);
209
+
208
210
  if (failOn !== "never") {
209
211
  const threshold = levels.indexOf(failOn);
210
212
  if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
@@ -222,6 +224,26 @@ async function main(args = process.argv.slice(2)) {
222
224
  }
223
225
  }
224
226
 
227
+ // The first run of a site with no plan: offer what it already tracks as its plan.
228
+ async function offerLearnedPlan(result, site, values) {
229
+ const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
230
+ const { listPlanIds } = await import("./workspace.js");
231
+ if ((await listPlanIds(dataDir, site).catch(() => [])).length) return;
232
+ const { learnPlan } = await import("./learn.js");
233
+ const plan = learnPlan(site, result.runs);
234
+ if (!plan) return;
235
+ console.log(`\nNo plan yet for this site. It already sends ${plan.events.length} ${plan.events.length === 1 ? "event" : "events"}: ${plan.events.map((e) => `${e.platform}:${e.name}`).join(", ")}`);
236
+ let save = values.yes;
237
+ if (!save && process.stdin.isTTY) save = ["", "y", "yes"].includes((await ask("Save them as the site's plan? (Y/n) ")).toLowerCase());
238
+ if (!save) {
239
+ if (!process.stdin.isTTY && !values.yes) console.log("Add --yes to save them as the plan, or draft a fuller one with: aitd plan --site " + site);
240
+ return;
241
+ }
242
+ const { id, dir } = await savePlan(plan, { dataDir, site, branding: !values["no-branding"] });
243
+ const rel = path.relative(process.cwd(), dir);
244
+ console.log(`✓ Saved plan ${id} in ${rel.startsWith("..") ? dir : rel}. Next runs can check against it with --against latest.`);
245
+ }
246
+
225
247
  // aitd "check my GA tags": shows the matching command and runs it once confirmed.
226
248
  async function fromSentence(sentence, values, extra) {
227
249
  const { createProvider } = await import("./providers.js");
package/src/consent.js ADDED
@@ -0,0 +1,154 @@
1
+ // Consent scenarios: the start page opened in a fresh browser once per consent choice
2
+ // (accept all, reject all, analytics only), to show which tags each choice lets through.
3
+ // Runs on its own with every audit when the page shows a consent banner.
4
+ import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
5
+
6
+ export const CONSENT_STATES = ["accept", "reject", "analytics"];
7
+ // Tags that are advertising, so an "analytics only" choice should keep them quiet.
8
+ export const AD_PLATFORMS = new Set(["google_ads", "meta", "tiktok", "pinterest", "snapchat", "x"]);
9
+
10
+ // Runs in the page: makes the choice through the consent platform's own API when it has one,
11
+ // otherwise by clicking the banner's accept or reject button. Returns how, or null when it could not.
12
+ const applyInPage = ({ state, selectors }) => {
13
+ const w = window;
14
+ try {
15
+ if (w.OneTrust?.AllowAll) {
16
+ if (state === "accept") w.OneTrust.AllowAll();
17
+ else {
18
+ w.OneTrust.RejectAll();
19
+ // C0002 is OneTrust's performance (analytics) group.
20
+ if (state === "analytics") w.OneTrust.UpdateConsent?.("Category", "C0002:1");
21
+ }
22
+ return "OneTrust";
23
+ }
24
+ if (w.Cookiebot?.submitCustomConsent) {
25
+ // preferences, statistics, marketing
26
+ w.Cookiebot.submitCustomConsent(state === "accept", state !== "reject", state === "accept");
27
+ return "Cookiebot";
28
+ }
29
+ if (state !== "analytics") {
30
+ if (w.UC_UI?.acceptAllConsents) {
31
+ state === "accept" ? w.UC_UI.acceptAllConsents() : w.UC_UI.denyAllConsents();
32
+ return "Usercentrics";
33
+ }
34
+ if (w.Didomi?.setUserAgreeToAll) {
35
+ state === "accept" ? w.Didomi.setUserAgreeToAll() : w.Didomi.setUserDisagreeToAll();
36
+ return "Didomi";
37
+ }
38
+ }
39
+ } catch {
40
+ // An API that throws falls back to the banner's buttons.
41
+ }
42
+ if (state === "analytics") return null;
43
+ const words = state === "accept"
44
+ ? /^(accept|allow|agree|ok|got it|kabul|tümünü kabul|izin ver|onayla|alle akzeptieren|tout accepter)/i
45
+ : /^(reject|decline|deny|refuse|necessary only|only necessary|reddet|kabul etme|ablehnen|tout refuser)/i;
46
+ const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
47
+ const banners = [...document.querySelectorAll(selectors)].filter(visible);
48
+ for (const n of document.querySelectorAll("body *")) {
49
+ const label = `${n.id} ${typeof n.className === "string" ? n.className : ""}`;
50
+ if (/(cookie|consent|gdpr|kvkk)/i.test(label) && visible(n) && !banners.some((b) => b.contains(n))) banners.push(n);
51
+ }
52
+ for (const banner of banners) {
53
+ const button = [...banner.querySelectorAll("button, a, [role=button], input[type=button], input[type=submit]")]
54
+ .filter(visible)
55
+ .find((b) => words.test((b.innerText || b.value || b.getAttribute("aria-label") || "").trim()));
56
+ if (button) {
57
+ button.click();
58
+ return "banner";
59
+ }
60
+ }
61
+ return null;
62
+ };
63
+
64
+ // A Google hit that says storage is denied is a cookieless Consent Mode ping, which a "no" allows.
65
+ const cookieless = (h) => !!h.consent && h.consent.ad_storage !== "granted" && h.consent.analytics_storage !== "granted";
66
+ const isAnalyticsOnlyOk = (h) => !!h.consent && h.consent.ad_storage !== "granted";
67
+
68
+ /** Hits per platform and event: { n, quiet } where quiet counts the ones sent with storage denied. */
69
+ const countBy = (hits) => {
70
+ const out = {};
71
+ for (const h of hits) {
72
+ const c = ((out[h.platform] ??= {})[h.name] ??= { n: 0, cookieless: 0, adDenied: 0 });
73
+ c.n++;
74
+ if (cookieless(h)) c.cookieless++;
75
+ if (isAnalyticsOnlyOk(h)) c.adDenied++;
76
+ }
77
+ return out;
78
+ };
79
+
80
+ /** Opens the start page, makes one consent choice, opens the page again, and returns the hits before and after. */
81
+ async function runState(config, state, opts) {
82
+ const session = new AuditSession({
83
+ viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
84
+ blockHits: config.block_hits !== false, headless: !opts.headed,
85
+ });
86
+ try {
87
+ await session.start();
88
+ const startError = await session.act({ action: "goto", url: config.site, reason: "Open the start page" });
89
+ if (startError) return { state, error: startError };
90
+ session.beginStep(`Choose: ${state}`);
91
+ const via = await session.page.evaluate(applyInPage, { state, selectors: CMP_BANNER_SELECTORS }).catch(() => null);
92
+ if (!via) return { state, via: null, before: countBy(session.hits) };
93
+ await session.settle();
94
+ session.beginStep("Open the start page again");
95
+ await session.page.reload().catch(() => {});
96
+ await session.settle();
97
+ const choice = session.steps.find((s) => s.label.startsWith("Choose:")).index;
98
+ return {
99
+ state, via,
100
+ before: countBy(session.hits.filter((h) => h.step < choice)),
101
+ after: countBy(session.hits.filter((h) => h.step >= choice)),
102
+ };
103
+ } catch (err) {
104
+ return { state, error: err?.message ?? String(err) };
105
+ } finally {
106
+ await session.close().catch(() => {});
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Runs every consent scenario on the site's start page. Returns null when the page has no consent
112
+ * banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
113
+ * @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
114
+ */
115
+ export async function runConsentScenarios(config, opts = {}) {
116
+ const log = opts.log ?? (() => {});
117
+ const first = await runState(config, "accept", opts);
118
+ if (!first.via) return null;
119
+ log(`\n▶ consent scenarios (${first.via})`);
120
+ const results = [first];
121
+ for (const state of CONSENT_STATES.slice(1)) results.push(await runState(config, state, opts));
122
+ const states = {};
123
+ for (const r of results) {
124
+ states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
125
+ log(` ${r.state}: ${r.error ? `✗ ${r.error}` : r.via ? `${Object.values(r.after ?? {}).reduce((s, e) => s + Object.values(e).reduce((a, b) => a + b.n, 0), 0)} tracking hits` : "not supported here"}`);
126
+ }
127
+ return { cmp: first.via, before: first.before, states };
128
+ }
129
+
130
+ /**
131
+ * The per-state tag table: one row per platform and event, one cell per state (hit count, or null when
132
+ * the state did not run). A cell is a problem when a tag sent hits a choice should have kept quiet:
133
+ * anything after "reject all" except cookieless Consent Mode pings, and ad tags after "analytics only"
134
+ * unless they were sent with ad storage denied.
135
+ */
136
+ export function consentTable(scenarios) {
137
+ const keys = new Set();
138
+ const add = (counts) => Object.entries(counts ?? {}).forEach(([p, events]) => Object.keys(events).forEach((e) => keys.add(`${p}\u0000${e}`)));
139
+ add(scenarios.before);
140
+ for (const s of Object.values(scenarios.states)) add(s.after);
141
+ const cols = ["before", ...CONSENT_STATES];
142
+ const rows = [...keys].sort().map((k) => {
143
+ const [platform, event] = k.split("\u0000");
144
+ const cells = cols.map((col) => {
145
+ const s = col === "before" ? { after: scenarios.before } : scenarios.states[col];
146
+ if (!s?.after) return { state: col, count: null, problem: false };
147
+ const c = s.after[platform]?.[event] ?? { n: 0, cookieless: 0, adDenied: 0 };
148
+ const problem = (col === "reject" && c.n > c.cookieless) || (col === "analytics" && AD_PLATFORMS.has(platform) && c.n > c.adDenied);
149
+ return { state: col, count: c.n, problem };
150
+ });
151
+ return { platform, event, cells };
152
+ });
153
+ return { cols, rows };
154
+ }
package/src/help.js CHANGED
@@ -41,7 +41,7 @@ Options:
41
41
  --headed Show the browser window
42
42
  --fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
43
43
 
44
- AI: --provider <name> --model <id> (default: whichever API key is set)
44
+ AI: --provider <name> --model <id|cheap> (default: whichever API key is set)
45
45
 
46
46
  Advanced:
47
47
  --max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
@@ -67,7 +67,7 @@ Options:
67
67
  --topic <name> Only plan one kind of event: custom-events, ecommerce, forms or consent
68
68
  --headed Show the browser window
69
69
 
70
- AI: --provider <name> --model <id> (default: whichever API key is set)
70
+ AI: --provider <name> --model <id|cheap> (default: whichever API key is set)
71
71
 
72
72
  Advanced:
73
73
  --max-steps <n> --data-dir <dir> --format <xlsx|csv> (see the README)`;
package/src/i18n.js CHANGED
@@ -95,12 +95,14 @@ const en = {
95
95
  sections: "Report sections",
96
96
  headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
97
97
  checkedAgainst: (id) => `Checked against plan ${id}`,
98
+ planChangesLine: (pc) => (pc.same ? `Plan unchanged since ${pc.previous}` : `Plan changed since ${pc.previous}: ${pc.added.length} added, ${pc.removed.length} removed, ${pc.changed.length} changed`),
98
99
  planVsSite: "Plan vs. site",
99
100
  changesTitle: "Changed since last run",
100
101
  changesLead: (id, mode) => `Compared with run ${id}, by ${mode === "plan" ? "plan coverage" : "health findings"}`,
101
102
  changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
102
103
  changeFixed: "Fixed",
103
104
  changeNew: "New",
105
+ noSavedRun: "No saved run for this site yet. Run `aitd run` first; each run is compared with the one before it.",
104
106
  routeChanged: (list) => `The AI took a different route through the site than in the last run: ${list}`,
105
107
  noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
106
108
  planVsSiteLead: "Each planned event, and what the site actually sent",
@@ -131,6 +133,11 @@ const en = {
131
133
  consentUpdate: "Update",
132
134
  consentLead: "Consent Mode commands the site sent, in order.",
133
135
  consentNone: "The site sent no Consent Mode commands on this journey.",
136
+ scenarios: "Consent scenarios",
137
+ scenariosLead: (cmp) => `The start page opened once per consent choice (answered through ${cmp === "banner" ? "the banner's buttons" : cmp}). Hits per tag after the choice; red ones should not have been sent.`,
138
+ scenarioCols: { before: "No choice yet", accept: "Accept all", reject: "Reject all", analytics: "Analytics only" },
139
+ scenarioNotRun: "Not supported here",
140
+ scenarioTag: "Tag",
134
141
  skippedLine: (list, n) => `${n} out-of-scope ${n === 1 ? "request was" : "requests were"} skipped: ${list}.`,
135
142
  findingHint: "Open a finding to see its likely cause, evidence and fix steps.",
136
143
  spread: (j, s) => `${j} ${j === 1 ? "journey" : "journeys"}${s ? ` · ${s} ${s === 1 ? "step" : "steps"}` : ""}`,
@@ -144,6 +151,16 @@ const en = {
144
151
  notChecked: "not checked",
145
152
  noPlan: "No measurement plan was given, so there is nothing to compare against.",
146
153
  providers: "Providers",
154
+ inventoryTitle: "Tag inventory",
155
+ inventoryLead: "Every vendor the site sent requests to, and where",
156
+ vendorCol: "Vendor",
157
+ partyCol: "Party",
158
+ pagesCol: "Pages",
159
+ cookiesCol: "Cookies",
160
+ party: { first: "First party", third: "Third party", both: "Both" },
161
+ morePages: (n) => `+${n} more`,
162
+ otherCookies: "Other cookies",
163
+ noInventory: "No vendor requests were recorded.",
147
164
  provider: "Provider",
148
165
  requestsCol: "Requests",
149
166
  checked: "Checked",
@@ -167,6 +184,9 @@ const en = {
167
184
  purchaseMismatch: (id, diffs) => `GA4 and Meta reported ${id ? `order ${q(id)}` : "the purchase"} differently: ${diffs.map(([f, g, m]) => `${f} ${q(g)} in GA4 and ${q(m)} in Meta`).join("; ")}. One of the tags reads the wrong data.`,
168
185
  serverSide: (platform, server, browser, unseen = []) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}.${unseen.length ? ` Server-side copies of ${unseen.join(" and ")} events never pass through the browser, so they can't be seen here.` : ""}`,
169
186
  dedupIdMissing: (platform, name) => `${platform} ${q(name)} was sent from the browser without an event ID, while a server-side setup was seen on this site. If the server also sends this event, ${platform} can't match the two and counts it twice.`,
187
+ campaignLost: (names, finalUrl) => `The landing address carried ${names.join(", ")}, but the page ended up on ${finalUrl} without ${names.length === 1 ? "it" : "them"}. A redirect dropped ${names.length === 1 ? "the parameter" : "the parameters"}, so ad clicks lose their attribution.`,
188
+ campaignMissingInHits: (platform, name, names) => `${platform} ${q(name)} on the landing page reports a page address without ${names.join(", ")}, though the page still had ${names.length === 1 ? "it" : "them"}. The visit can be credited to direct traffic instead of the campaign.`,
189
+ campaignOk: (names) => `${names.join(", ")} survived the landing page's redirects and reached the hits.`,
170
190
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
171
191
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
172
192
  beforeConsentNever: (platform, name) => `${platform} ${q(name)} fired although consent was never given in this journey.`,
@@ -281,12 +301,14 @@ const tr = {
281
301
  sections: "Rapor bölümleri",
282
302
  headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
283
303
  checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
304
+ planChangesLine: (pc) => (pc.same ? `Plan ${pc.previous} sürümünden bu yana değişmedi` : `Plan ${pc.previous} sürümünden bu yana değişti: ${pc.added.length} eklendi, ${pc.removed.length} çıkarıldı, ${pc.changed.length} değişti`),
284
305
  planVsSite: "Plan ve site",
285
306
  changesTitle: "Son çalıştırmadan bu yana değişenler",
286
307
  changesLead: (id, mode) => `${id} çalıştırmasıyla ${mode === "plan" ? "plan kapsamına" : "sağlık bulgularına"} göre karşılaştırıldı`,
287
308
  changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
288
309
  changeFixed: "Düzeldi",
289
310
  changeNew: "Yeni",
311
+ noSavedRun: "Bu site için henüz kayıtlı çalıştırma yok. Önce `aitd run` çalıştırın; her çalıştırma bir öncekiyle karşılaştırılır.",
290
312
  routeChanged: (list) => `Yapay zeka siteyi son çalıştırmadan farklı bir yoldan gezdi: ${list}`,
291
313
  noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
292
314
  planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
@@ -317,6 +339,11 @@ const tr = {
317
339
  consentUpdate: "Güncelleme",
318
340
  consentLead: "Sitenin gönderdiği Consent Mode komutları, sırasıyla.",
319
341
  consentNone: "Site bu yolculukta Consent Mode komutu göndermedi.",
342
+ scenarios: "Onay senaryoları",
343
+ scenariosLead: (cmp) => `Başlangıç sayfası her onay seçimi için ayrı açıldı (${cmp === "banner" ? "banner butonlarıyla" : cmp} üzerinden yanıtlandı). Seçimden sonra etiket başına istek sayısı; kırmızılar gönderilmemeliydi.`,
344
+ scenarioCols: { before: "Henüz seçim yok", accept: "Tümünü kabul", reject: "Tümünü reddet", analytics: "Yalnızca analitik" },
345
+ scenarioNotRun: "Burada desteklenmiyor",
346
+ scenarioTag: "Etiket",
320
347
  skippedLine: (list, n) => `Kapsam dışı ${n} istek atlandı: ${list}.`,
321
348
  findingHint: "Bir bulguyu açınca olası neden, kanıt ve çözüm adımları görünür.",
322
349
  spread: (j, s) => `${j} yolculuk${s ? ` · ${s} adım` : ""}`,
@@ -330,6 +357,16 @@ const tr = {
330
357
  notChecked: "kontrol edilmedi",
331
358
  noPlan: "Ölçüm planı verilmediği için karşılaştırılacak bir şey yok.",
332
359
  providers: "Sağlayıcılar",
360
+ inventoryTitle: "Etiket envanteri",
361
+ inventoryLead: "Sitenin istek gönderdiği tüm sağlayıcılar ve hangi sayfalarda",
362
+ vendorCol: "Sağlayıcı",
363
+ partyCol: "Taraf",
364
+ pagesCol: "Sayfalar",
365
+ cookiesCol: "Çerezler",
366
+ party: { first: "Birinci taraf", third: "Üçüncü taraf", both: "İkisi de" },
367
+ morePages: (n) => `+${n} daha`,
368
+ otherCookies: "Diğer çerezler",
369
+ noInventory: "Sağlayıcıya giden istek kaydedilmedi.",
333
370
  provider: "Sağlayıcı",
334
371
  requestsCol: "İstek",
335
372
  checked: "Kontrol edildi",
@@ -353,6 +390,9 @@ const tr = {
353
390
  purchaseMismatch: (id, diffs) => `GA4 ve Meta ${id ? `${q(id)} siparişini` : "satın almayı"} farklı bildirdi: ${diffs.map(([f, g, m]) => `${f === "value" ? "değer" : "para birimi"} GA4'te ${q(g)}, Meta'da ${q(m)}`).join("; ")}. Etiketlerden biri yanlış veriyi okuyor.`,
354
391
  serverSide: (platform, server, browser, unseen = []) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}.${unseen.length ? ` ${unseen.join(" ve ")} olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.` : ""}`,
355
392
  dedupIdMissing: (platform, name) => `${platform} ${q(name)} tarayıcıdan olay kimliği olmadan gönderildi; sitede sunucu tarafı bir kurulum görüldü. Sunucu da bu olayı gönderiyorsa ${platform} ikisini eşleştiremez ve iki kez sayar.`,
393
+ campaignLost: (names, finalUrl) => `Açılış adresi ${names.join(", ")} taşıyordu, ancak sayfa ${finalUrl} adresinde ${names.length === 1 ? "onsuz" : "onlarsız"} sonlandı. Bir yönlendirme ${names.length === 1 ? "parametreyi" : "parametreleri"} düşürdü, bu yüzden reklam tıklamalarının atfı kaybolur.`,
394
+ campaignMissingInHits: (platform, name, names) => `Açılış sayfasındaki ${platform} ${q(name)} isteği ${names.join(", ")} olmadan bir sayfa adresi bildiriyor, oysa sayfada ${names.length === 1 ? "o" : "onlar"} hâlâ vardı. Ziyaret kampanyaya değil doğrudan trafiğe yazılabilir.`,
395
+ campaignOk: (names) => `${names.join(", ")} açılış sayfasının yönlendirmelerinden geçti ve isteklere ulaştı.`,
356
396
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
357
397
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
358
398
  beforeConsentNever: (platform, name) => `${platform} ${q(name)}, bu yolculukta hiç onay verilmemesine rağmen tetiklendi.`,
@@ -0,0 +1,69 @@
1
+ import { providerOf } from "./parsers.js";
2
+ import { siteOf } from "./redact.js";
3
+
4
+ // Cookies the vendors' own scripts set on the site's domain.
5
+ const COOKIE_VENDORS = [
6
+ [/^(_ga|_gid|_gat)(_|$)/, "ga4"],
7
+ [/^(_gcl_|_gac_|IDE$|test_cookie$)/, "google_ads"],
8
+ [/^_fb[pc]$/, "meta"],
9
+ [/^(_ttp|ttclid|_tt_enable_cookie)$/, "tiktok"],
10
+ [/^(_li_|li_|bcookie|lidc|UserMatchHistory)/, "linkedin"],
11
+ [/^(_uet|MUID)/, "microsoft_ads"],
12
+ [/^(_pin_|_pinterest_)/, "pinterest"],
13
+ [/^(_scid|sc_at)/, "snapchat"],
14
+ [/^(_cto_|cto_)/, "criteo"],
15
+ [/^__kla_id$/, "klaviyo"],
16
+ [/^_hj/, "hotjar"],
17
+ [/^_clck|^_clsk/, "clarity"],
18
+ ];
19
+
20
+ const hostOf = (url) => {
21
+ try { return new URL(url).hostname; } catch { return ""; }
22
+ };
23
+
24
+ const pathOf = (url) => {
25
+ try { const u = new URL(url); return `${u.hostname}${u.pathname}`; } catch { return ""; }
26
+ };
27
+
28
+ /**
29
+ * Every vendor the site talked to: the pages it loaded or sent from, whether its requests went to the
30
+ * site's own domain (first party) or to the vendor (third party), and the cookie names tied to it.
31
+ * Cookies no vendor owns are listed apart. Only cookie names are kept, never values.
32
+ */
33
+ export function buildInventory(runs) {
34
+ const vendors = new Map();
35
+ for (const r of runs) {
36
+ for (const c of r.network ?? []) {
37
+ if (!c.vendor || !c.pageUrl) continue;
38
+ const v = vendors.get(c.vendor) ?? { vendor: c.vendor, keys: new Set(), pages: new Set(), first: 0, third: 0, requests: 0, cookies: new Set() };
39
+ v.keys.add(c.provider);
40
+ v.pages.add(pathOf(c.pageUrl));
41
+ v.requests++;
42
+ v[siteOf(hostOf(c.url)) === siteOf(hostOf(c.pageUrl)) ? "first" : "third"]++;
43
+ vendors.set(c.vendor, v);
44
+ }
45
+ }
46
+ const other = new Map();
47
+ for (const r of runs) {
48
+ for (const ck of r.cookieLog ?? []) {
49
+ const domain = String(ck.domain ?? "").replace(/^\./, "");
50
+ const key = COOKIE_VENDORS.find(([re]) => re.test(ck.name))?.[1] ?? providerOf(`https://${domain}/`)?.key;
51
+ const owners = [...vendors.values()].filter((v) => key && v.keys.has(key));
52
+ if (owners.length) for (const v of owners) v.cookies.add(ck.name);
53
+ else other.set(`${domain}|${ck.name}`, { name: ck.name, domain });
54
+ }
55
+ }
56
+ const sites = new Set(runs.flatMap((r) => (r.network ?? []).map((c) => c.pageUrl)).filter(Boolean).map((u) => siteOf(hostOf(u))));
57
+ return {
58
+ vendors: [...vendors.values()]
59
+ .map((v) => ({
60
+ vendor: v.vendor,
61
+ party: v.first && v.third ? "both" : v.first ? "first" : "third",
62
+ pages: [...v.pages].sort(),
63
+ requests: v.requests,
64
+ cookies: [...v.cookies].sort(),
65
+ }))
66
+ .sort((a, b) => b.pages.length - a.pages.length || a.vendor.localeCompare(b.vendor)),
67
+ otherCookies: [...other.values()].map((c) => ({ name: c.name, party: sites.has(siteOf(c.domain)) ? "first" : "third" })).sort((a, b) => a.name.localeCompare(b.name)),
68
+ };
69
+ }
package/src/learn.js ADDED
@@ -0,0 +1,34 @@
1
+ import { PROVIDERS } from "./parsers.js";
2
+
3
+ /**
4
+ * A plan learned from what a site already sends: one event per platform and event name seen in
5
+ * the runs, the parameters it carried,. Parameters sent with every
6
+ * hit of an event become its required parameters. Values are not copied, so nothing a visitor typed
7
+ * ends up in the plan. Returns null when the runs captured no named events.
8
+ */
9
+ export function learnPlan(site, runs) {
10
+ const byKey = new Map();
11
+ for (const run of runs) {
12
+ for (const hit of run.hits ?? []) {
13
+ if (!hit?.platform || !hit.name || hit.name === "(unnamed)") continue;
14
+ const k = `${hit.platform}:${hit.name}`;
15
+ let ev = byKey.get(k);
16
+ if (!ev) byKey.set(k, (ev = { platform: hit.platform, name: hit.name, hits: 0, params: new Map() }));
17
+ ev.hits++;
18
+ for (const p of Object.keys(hit.params ?? {})) ev.params.set(p, (ev.params.get(p) ?? 0) + 1);
19
+ }
20
+ }
21
+ if (!byKey.size) return null;
22
+ const events = [...byKey.values()].map((ev) => {
23
+ const required = [...ev.params].filter(([, n]) => n === ev.hits).map(([p]) => p);
24
+ return {
25
+ platform: ev.platform,
26
+ name: ev.name,
27
+ description: `Learned from what ${PROVIDERS[ev.platform] ?? ev.platform} received on the site.`,
28
+ ...(required.length ? { required_params: required } : {}),
29
+ parameters: Object.fromEntries([...ev.params.keys()].map((p) => [p, {}])),
30
+ seen: true,
31
+ };
32
+ });
33
+ return { site, learned: true, consent_mode: true, pages: [], events, user_properties: [] };
34
+ }
package/src/mcp.js CHANGED
@@ -24,6 +24,7 @@ import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
24
24
  import { checkTopic, discoverPlan, TOPICS } from "./discover.js";
25
25
  import { createProvider } from "./providers.js";
26
26
  import { savePlan } from "./plan.js";
27
+ import { changesLines, sinceLastRun } from "./changes.js";
27
28
  import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
28
29
  import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
29
30
 
@@ -282,7 +283,7 @@ export function buildServer(opts) {
282
283
  const grouped = groupFindings(findings);
283
284
  const counts = summarize(grouped);
284
285
  const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
285
- const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
286
+ const { reportPath } = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
286
287
  const m = messages(lang);
287
288
  const lines = [
288
289
  m.ui.counts(counts),
@@ -351,6 +352,28 @@ export function buildServer(opts) {
351
352
  },
352
353
  );
353
354
 
355
+ server.registerTool(
356
+ "what_changed",
357
+ {
358
+ title: "What changed since the last run",
359
+ description:
360
+ "Returns only the \"since last run\" diff for a site: which problems were fixed and which are new in its newest saved run (`aitd run`) compared with the run before it. Compares plan coverage when both runs were checked against a plan, health findings otherwise. Does not browse or use an AI. Use it to answer \"what changed on this site?\" without re-reading a whole report.",
361
+ inputSchema: {
362
+ site: z.string().describe("The site, e.g. https://www.example.com/. Its saved runs are the ones in the data folder of the computer running this server."),
363
+ language: z.enum(Object.keys(LANGUAGES)).optional().describe("Language of the answer. Default en."),
364
+ },
365
+ annotations: { readOnlyHint: true },
366
+ },
367
+ async ({ site, language }) => {
368
+ try {
369
+ const diff = await sinceLastRun(opts.dataDir ?? DEFAULT_DATA_DIR, site);
370
+ return text(changesLines(diff, language ?? "en").join("\n"));
371
+ } catch (err) {
372
+ return fail(err.message);
373
+ }
374
+ },
375
+ );
376
+
354
377
  if (opts.allowFiles) {
355
378
  server.registerTool(
356
379
  "run_audit",