@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/CHANGELOG.md +17 -0
- package/README.md +61 -6
- package/README.tr.md +63 -6
- package/package.json +1 -1
- package/src/agent.js +16 -4
- package/src/audit.js +26 -9
- package/src/browser.js +11 -1
- package/src/campaign.js +28 -0
- package/src/changes.js +91 -3
- package/src/checks.js +28 -0
- package/src/cli.js +22 -0
- package/src/consent.js +154 -0
- package/src/help.js +2 -2
- package/src/i18n.js +40 -0
- package/src/inventory.js +69 -0
- package/src/learn.js +34 -0
- package/src/mcp.js +24 -1
- package/src/notify.js +108 -0
- package/src/providers.js +35 -12
- package/src/redact.js +1 -1
- package/src/report.js +60 -9
- package/src/sheet.js +8 -5
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 {
|
|
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.`,
|
package/src/inventory.js
ADDED
|
@@ -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",
|