@analitikisler/ai-tag-debugger 0.22.4 → 0.22.6
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 +16 -0
- package/README.md +26 -3
- package/README.tr.md +26 -3
- package/package.json +1 -1
- package/src/agent.js +7 -2
- package/src/audit.js +4 -1
- package/src/browser.js +84 -17
- package/src/checks.js +18 -1
- package/src/cli.js +48 -0
- package/src/consent.js +1 -1
- package/src/containers.js +60 -3
- package/src/help.js +20 -1
- package/src/html/scope.js +1 -1
- package/src/i18n.js +30 -0
- package/src/journeys.js +8 -1
- package/src/login.js +91 -0
- package/src/mcp.js +29 -9
- package/src/parsers.js +2 -0
- package/src/report.js +4 -4
- package/src/schema.js +1 -0
package/src/checks.js
CHANGED
|
@@ -5,7 +5,7 @@ import { messages } from "./i18n.js";
|
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
6
|
import { campaignParamsIn } from "./campaign.js";
|
|
7
7
|
import { CONSENT_SIGNALS } from "./parsers.js";
|
|
8
|
-
import { duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
|
|
8
|
+
import { containersNotRun, duplicateLoads, loadSources, scriptFailed, splitLayers } from "./containers.js";
|
|
9
9
|
import { messageOutcomes, widgetSummaries, widgetsOf } from "./frames.js";
|
|
10
10
|
|
|
11
11
|
const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
|
|
@@ -394,6 +394,23 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
394
394
|
findings.push({ ...base, severity: "warning", check: "container_failed", step: stepLabel(sc.step), detail: d.containerFailed(sc.id, sc.host, String(sc.status)) });
|
|
395
395
|
}
|
|
396
396
|
|
|
397
|
+
// A GTM container downloaded or asked for that never ran. Expected while a consent platform holds it back and the
|
|
398
|
+
// run never consented; otherwise none of its tags fired.
|
|
399
|
+
const consented = consentGrantedStep(run) !== null;
|
|
400
|
+
for (const c of containersNotRun(run)) {
|
|
401
|
+
findings.push(run.cmp?.vendor && !consented
|
|
402
|
+
? { ...base, severity: "info", check: "container_held", step: stepLabel(c.step), detail: d.containerHeld(c.id, run.cmp.vendor) }
|
|
403
|
+
: { ...base, severity: "warning", check: "container_not_run", step: stepLabel(c.step), detail: d.containerNotRun(c.id, c.kind) });
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
// Logged in with the test account: GA4 hits from then on should carry the site's user ID.
|
|
407
|
+
if (Number.isInteger(run.loginStep)) {
|
|
408
|
+
const after = run.hits.filter((h) => h.platform === "ga4" && h.step > run.loginStep);
|
|
409
|
+
if (after.length && !after.some((h) => h.hasUserId)) {
|
|
410
|
+
findings.push({ ...base, severity: "info", check: "user_id_missing", platform: "ga4", step: stepLabel(after[0].step), detail: d.userIdMissing(after.length) });
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
397
414
|
// Widgets that tracked on their own, left out of the plan checks: said once per widget, so nothing goes missing silently.
|
|
398
415
|
for (const w of widgetsOf(run.widgetHits ?? [])) {
|
|
399
416
|
const first = (run.widgetHits ?? []).find((h) => h.frame.origin === w.origin);
|
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { chmod, mkdir } from "node:fs/promises";
|
|
3
4
|
import path from "node:path";
|
|
4
5
|
import { parseArgs } from "node:util";
|
|
5
6
|
import { loadConfig, runAudit } from "./audit.js";
|
|
@@ -10,10 +11,48 @@ import { replaySavedSite, writeSavedSteps } from "./replay.js";
|
|
|
10
11
|
import { readBaseline, writeBaseline } from "./baseline.js";
|
|
11
12
|
import { savePlan } from "./plan.js";
|
|
12
13
|
import { DEFAULT_DATA_DIR } from "./workspace.js";
|
|
14
|
+
import { resolveSessionFile, sessionFileFor } from "./login.js";
|
|
13
15
|
import { commandText, intentToArgs, isSentence, needsWhatToTest, translate } from "./ask.js";
|
|
14
16
|
|
|
15
17
|
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
16
18
|
|
|
19
|
+
/**
|
|
20
|
+
* `aitd login`: opens the site in a visible browser for the user to log in by hand (a CAPTCHA, two-factor login or
|
|
21
|
+
* single sign-on are fine), then saves the session (cookies and storage) for runs to start from with --session saved.
|
|
22
|
+
* The file holds a live login, so only its owner may read it.
|
|
23
|
+
*/
|
|
24
|
+
async function saveLoginSession(values) {
|
|
25
|
+
if (!values.site) throw new Error("login needs --site <url>: aitd login --site https://www.example.com");
|
|
26
|
+
try {
|
|
27
|
+
new URL(values.site);
|
|
28
|
+
} catch {
|
|
29
|
+
throw new Error(`"${values.site}" is not a full URL. Use e.g. https://www.example.com`);
|
|
30
|
+
}
|
|
31
|
+
const file = path.resolve(values.save ?? sessionFileFor(values.site, values["data-dir"] ?? DEFAULT_DATA_DIR));
|
|
32
|
+
const { AuditSession } = await import("./browser.js");
|
|
33
|
+
const session = new AuditSession({ viewport: "desktop", site: values.site, allowedHosts: values["allow-host"], headless: false, login: null });
|
|
34
|
+
await session.start();
|
|
35
|
+
try {
|
|
36
|
+
await session.act({ action: "goto", url: values.site });
|
|
37
|
+
console.log("Log in to the site in the browser window. When you are logged in, come back here and press Enter (Ctrl+C cancels).");
|
|
38
|
+
console.log(`The browser stays on ${session.scope.hosts.join(", ")}; for a login on another host (single sign-on), add --allow-host <host>.`);
|
|
39
|
+
await new Promise((resolve) => {
|
|
40
|
+
process.stdin.resume();
|
|
41
|
+
process.stdin.once("data", () => {
|
|
42
|
+
process.stdin.pause();
|
|
43
|
+
resolve();
|
|
44
|
+
});
|
|
45
|
+
});
|
|
46
|
+
await mkdir(path.dirname(file), { recursive: true });
|
|
47
|
+
await session.context.storageState({ path: file });
|
|
48
|
+
await chmod(file, 0o600);
|
|
49
|
+
console.log(`Saved the logged-in session to ${file}. It holds a live login: keep it private.`);
|
|
50
|
+
console.log(`Start a run from it with: aitd run --site ${values.site} --session ${values.save ? file : "saved"} --journey <id>`);
|
|
51
|
+
} finally {
|
|
52
|
+
await session.close();
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
17
56
|
async function main(args = process.argv.slice(2)) {
|
|
18
57
|
const { values, positionals } = parseArgs({
|
|
19
58
|
args,
|
|
@@ -31,6 +70,8 @@ async function main(args = process.argv.slice(2)) {
|
|
|
31
70
|
platforms: { type: "string", multiple: true },
|
|
32
71
|
providers: { type: "string", multiple: true },
|
|
33
72
|
"allow-host": { type: "string", multiple: true },
|
|
73
|
+
session: { type: "string" },
|
|
74
|
+
save: { type: "string" },
|
|
34
75
|
"allow-private-network": { type: "boolean", default: false },
|
|
35
76
|
"allow-live": { type: "boolean", default: false },
|
|
36
77
|
scripted: { type: "boolean", default: false },
|
|
@@ -83,6 +124,10 @@ async function main(args = process.argv.slice(2)) {
|
|
|
83
124
|
await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"], allowLive: values["allow-live"] });
|
|
84
125
|
return;
|
|
85
126
|
}
|
|
127
|
+
if (command === "login" && !values.help) {
|
|
128
|
+
await saveLoginSession(values);
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
86
131
|
if (command === "replay" && !values.help) {
|
|
87
132
|
const dirs = positionals.slice(1);
|
|
88
133
|
if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
|
|
@@ -191,6 +236,9 @@ async function main(args = process.argv.slice(2)) {
|
|
|
191
236
|
}
|
|
192
237
|
if (values["keep-bodies"]) loaded.config = { ...loaded.config, keep_bodies: true };
|
|
193
238
|
if (values["allow-host"]?.length) loaded.config = { ...loaded.config, allowed_hosts: [...(loaded.config.allowed_hosts ?? []), ...values["allow-host"]] };
|
|
239
|
+
// A session saved with `aitd login` ("saved") or a file: every journey starts logged in.
|
|
240
|
+
const sessionArg = values.session ?? loaded.config.session_file;
|
|
241
|
+
if (sessionArg) loaded.config = { ...loaded.config, session_file: resolveSessionFile(sessionArg, { site: loaded.config.site, dataDir: values["data-dir"] ?? DEFAULT_DATA_DIR }) };
|
|
194
242
|
try {
|
|
195
243
|
new URL(loaded.config.site);
|
|
196
244
|
} catch {
|
package/src/consent.js
CHANGED
|
@@ -184,7 +184,7 @@ export function scenarioLanding(config, campaign) {
|
|
|
184
184
|
*/
|
|
185
185
|
async function runState(config, state, opts) {
|
|
186
186
|
const session = new AuditSession({
|
|
187
|
-
viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
|
|
187
|
+
viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, storageState: config.session_file, providers: config.providers,
|
|
188
188
|
blockHits: config.block_hits !== false, headless: !opts.headed, ...(opts.blockPrivate ? { blockPrivate: opts.blockPrivate } : {}),
|
|
189
189
|
});
|
|
190
190
|
const landing = scenarioLanding(config, opts.campaign);
|
package/src/containers.js
CHANGED
|
@@ -7,6 +7,9 @@
|
|
|
7
7
|
// Container and destination IDs gtm.js and gtag/js are loaded with.
|
|
8
8
|
const CONTAINER_ID = /^(GTM|G|GT|AW|DC|MC|HA)-[A-Z0-9]+$/;
|
|
9
9
|
|
|
10
|
+
/** Whether a string is a container or Google tag ID: GTM-…, G-…, GT-…, AW-…, DC-… and the like. */
|
|
11
|
+
export const isContainerId = (id) => CONTAINER_ID.test(String(id));
|
|
12
|
+
|
|
10
13
|
/**
|
|
11
14
|
* The container a script URL loads, or null: its ID, gtm or gtag, the dataLayer it reads (`l`), the host it
|
|
12
15
|
* came from (Google's, or the site's own through the Google tag gateway or a server container) and the GTM
|
|
@@ -148,13 +151,16 @@ export function loadSources(run) {
|
|
|
148
151
|
|
|
149
152
|
/**
|
|
150
153
|
* One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
|
|
151
|
-
* environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, pages, journeys }. frame is
|
|
152
|
-
* iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the
|
|
154
|
+
* environment?, kind, by?, frame?, version?, bytes?, transferBytes?, ms?, failed?, ran?, pages, journeys }. frame is
|
|
155
|
+
* the iframe it loaded in; version to failed are its script's answer (see scriptFacts), from the first load in the
|
|
156
|
+
* row; ran says whether Google's registry held it on some step (left out for captures that didn't record it).
|
|
157
|
+
* Containers the page asked for without the tool seeing a script added are rows too (see unseenContainers).
|
|
153
158
|
*/
|
|
154
159
|
export function containerRows(runs) {
|
|
155
160
|
const rows = new Map();
|
|
156
161
|
for (const run of runs) {
|
|
157
|
-
|
|
162
|
+
const ran = Array.isArray(run.containersRunning) ? new Set(run.containersRunning.map((c) => c.id)) : null;
|
|
163
|
+
for (const l of [...loadSources(run), ...unseenContainers(run)]) {
|
|
158
164
|
const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? "", l.frame?.origin ?? ""].join("|");
|
|
159
165
|
const row = rows.get(k) ?? {
|
|
160
166
|
id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
|
|
@@ -165,12 +171,63 @@ export function containerRows(runs) {
|
|
|
165
171
|
};
|
|
166
172
|
if (l.page) row.pages.add(l.page);
|
|
167
173
|
row.journeys.add(`${run.journeyId} (${run.viewport})`);
|
|
174
|
+
// Whether it ran (Google's registry held it) in any run that recorded the registry; unknown for older captures.
|
|
175
|
+
if (ran) row.ran = Boolean(row.ran) || ran.has(l.id);
|
|
168
176
|
rows.set(k, row);
|
|
169
177
|
}
|
|
170
178
|
}
|
|
171
179
|
return [...rows.values()].map((r) => ({ ...r, pages: [...r.pages].sort(), journeys: [...r.journeys] }));
|
|
172
180
|
}
|
|
173
181
|
|
|
182
|
+
/**
|
|
183
|
+
* Containers a run met without seeing a script added for them: a script the page requested or preloaded, or an ID
|
|
184
|
+
* that ran (Google's registry held it), with no load recorded. kind "preload": only a <link rel="preload"> asked for
|
|
185
|
+
* it, which downloads a script without running it; "unseen": something added it in a way the tool couldn't follow,
|
|
186
|
+
* as when a consent platform's auto-blocking replaces the browser's own functions. One per container and host.
|
|
187
|
+
*/
|
|
188
|
+
export function unseenContainers(run) {
|
|
189
|
+
const loads = run.containerLoads ?? [];
|
|
190
|
+
const loaded = (id, host) => loads.some((l) => l.id === id && (!host || l.host === host));
|
|
191
|
+
const preloads = run.containerPreloads ?? [];
|
|
192
|
+
const out = new Map();
|
|
193
|
+
// The page a step was on: the preload's own, else the step's (see pagePath in browser.js).
|
|
194
|
+
const pageAt = (step, page) => page ?? run.steps?.[step]?.pagePath ?? undefined;
|
|
195
|
+
const add = (id, host, kind, step, facts = {}, page) => {
|
|
196
|
+
const k = `${id}|${host ?? ""}`;
|
|
197
|
+
if (out.has(k) || loaded(id, host)) return;
|
|
198
|
+
const at = pageAt(step, page);
|
|
199
|
+
out.set(k, {
|
|
200
|
+
id, script: id.startsWith("GTM-") ? "gtm" : "gtag", layer: null,
|
|
201
|
+
...(host ? { host, served: /(^|\.)googletagmanager\.com$/.test(host) ? "google" : "site" } : {}),
|
|
202
|
+
kind, step, ...(at ? { page: at } : {}), ...facts,
|
|
203
|
+
});
|
|
204
|
+
};
|
|
205
|
+
for (const sc of run.containerScripts ?? []) {
|
|
206
|
+
const pre = preloads.find((p) => p.id === sc.id && p.host === sc.host);
|
|
207
|
+
add(sc.id, sc.host, pre ? "preload" : "unseen", pre?.step ?? sc.step, scriptFacts(sc), pre?.page);
|
|
208
|
+
}
|
|
209
|
+
for (const p of preloads) add(p.id, p.host, "preload", p.step, {}, p.page);
|
|
210
|
+
for (const c of run.containersRunning ?? []) {
|
|
211
|
+
if (!loaded(c.id) && ![...out.values()].some((x) => x.id === c.id)) add(c.id, null, "unseen", c.step);
|
|
212
|
+
}
|
|
213
|
+
return [...out.values()];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* GTM containers a run downloaded or asked for that never ran: Google's registry never held them on any step. Only
|
|
218
|
+
* GTM containers, whose registry entry is certain; [] for captures that didn't record the registry.
|
|
219
|
+
*/
|
|
220
|
+
export function containersNotRun(run) {
|
|
221
|
+
if (!Array.isArray(run.containersRunning)) return [];
|
|
222
|
+
const ran = new Set(run.containersRunning.map((c) => c.id));
|
|
223
|
+
const seen = new Map();
|
|
224
|
+
for (const l of [...loadSources(run), ...unseenContainers(run)]) {
|
|
225
|
+
if (!l.id.startsWith("GTM-") || ran.has(l.id) || l.failed || l.frame?.party === "third" || seen.has(l.id)) continue;
|
|
226
|
+
seen.set(l.id, { id: l.id, kind: l.kind, step: l.step ?? 0 });
|
|
227
|
+
}
|
|
228
|
+
return [...seen.values()];
|
|
229
|
+
}
|
|
230
|
+
|
|
174
231
|
/** The containers loaded more than once into one page, as [{ id, loads }] with the loads in order. */
|
|
175
232
|
export function duplicateLoads(run) {
|
|
176
233
|
const byDoc = new Map();
|
package/src/help.js
CHANGED
|
@@ -13,6 +13,7 @@ Commands:
|
|
|
13
13
|
run Audit what a site actually sends, and compare it to a plan
|
|
14
14
|
replay Re-check saved sites offline, without AI
|
|
15
15
|
mcp Start the MCP server for Claude, ChatGPT and other chat apps
|
|
16
|
+
login Log in to a site by hand once and save the session for runs to start from
|
|
16
17
|
doctor Check that Node, Chromium, an AI key and the data folder are ready
|
|
17
18
|
history Show how a site's saved runs trend: counts, open problems, flapping, journeys
|
|
18
19
|
|
|
@@ -41,6 +42,7 @@ Options:
|
|
|
41
42
|
--events <names> Only check these events, e.g. purchase,add_to_cart
|
|
42
43
|
--lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
|
|
43
44
|
--headed Show the browser window
|
|
45
|
+
--session <file> Start logged in: saved (the session aitd login saved) or a session file
|
|
44
46
|
--fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
|
|
45
47
|
--baseline <file> Known findings to accept; they don't count toward --fail-on
|
|
46
48
|
--update-baseline Write the baseline file after the run (creates it)
|
|
@@ -106,6 +108,23 @@ Advanced:
|
|
|
106
108
|
--reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
|
|
107
109
|
--public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
|
|
108
110
|
|
|
111
|
+
const LOGIN = `Usage: aitd login --site <url> [--save <file>] [--allow-host <host>] [--data-dir <dir>]
|
|
112
|
+
|
|
113
|
+
Open the site in a browser window, log in by hand (a CAPTCHA, a code sent to your phone or
|
|
114
|
+
single sign-on are fine), then press Enter in the terminal: the logged-in session (cookies and
|
|
115
|
+
storage) is saved to aitd/<domain>/session.json. Runs start from it with --session saved.
|
|
116
|
+
The file holds a live login: keep it private, and save it again when the login expires.
|
|
117
|
+
|
|
118
|
+
For journeys that can log in by themselves, set a test account instead:
|
|
119
|
+
AITD_LOGIN_EMAIL (or AITD_LOGIN_USERNAME) and AITD_LOGIN_PASSWORD. The model sees only
|
|
120
|
+
the placeholders {{login.email}} and {{login.password}}; the browser types the real values.
|
|
121
|
+
|
|
122
|
+
Options:
|
|
123
|
+
--site <url> The site to log in to (required)
|
|
124
|
+
--save <file> Where to save the session (default: aitd/<domain>/session.json)
|
|
125
|
+
--allow-host <host> Another host the login passes through, such as a single sign-on page (repeatable)
|
|
126
|
+
--data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
|
|
127
|
+
|
|
109
128
|
const DOCTOR = `Usage: aitd doctor [--site <url>] [--data-dir <dir>]
|
|
110
129
|
|
|
111
130
|
Check that this machine can run an audit: the Node version, Playwright's Chromium, whether
|
|
@@ -130,7 +149,7 @@ Options:
|
|
|
130
149
|
--lang <code> Language of the lines: ${Object.keys(LANGUAGES).join(" or ")}
|
|
131
150
|
--data-dir <dir> The folder plans and runs are saved in (default: aitd)`;
|
|
132
151
|
|
|
133
|
-
const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, doctor: DOCTOR, history: HISTORY };
|
|
152
|
+
const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP, login: LOGIN, doctor: DOCTOR, history: HISTORY };
|
|
134
153
|
|
|
135
154
|
/** Help text for a command, or the top-level help when the command is unknown or missing. */
|
|
136
155
|
export function helpText(command) {
|
package/src/html/scope.js
CHANGED
|
@@ -202,7 +202,7 @@ function containersCard(ctx) {
|
|
|
202
202
|
const rows = containerRows(runs);
|
|
203
203
|
if (!rows.length) return null;
|
|
204
204
|
const c = m.ui.containers;
|
|
205
|
-
const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
|
|
205
|
+
const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${r.ran === false ? badge("broken", c.neverRan) : ""}${r.layer ? plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`) : ""}${r.served ? plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`)) : ""}${r.version ? plain(esc(c.version(r.version)), "mono") : ""}${r.bytes ? plain(esc(c.size(r.bytes)), "mono") : ""}${r.ms != null ? plain(esc(c.loadTime(r.ms))) : ""}${r.failed ? badge("broken", c.failed(r.failed)) : ""}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
|
|
206
206
|
const ids = uniq(rows.map((r) => r.id));
|
|
207
207
|
return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
|
|
208
208
|
}
|
package/src/i18n.js
CHANGED
|
@@ -244,6 +244,7 @@ const en = {
|
|
|
244
244
|
size: (bytes) => `${(bytes / 1024).toFixed(1)} KB`,
|
|
245
245
|
loadTime: (ms) => `loaded in ${ms} ms`,
|
|
246
246
|
failed: (status) => (status === "failed" ? "failed to load" : `failed to load (HTTP ${status})`),
|
|
247
|
+
neverRan: "never ran",
|
|
247
248
|
kinds: {
|
|
248
249
|
html: () => "A script tag in the page's HTML",
|
|
249
250
|
inline: () => "Inline code in the page (the snippet)",
|
|
@@ -254,6 +255,8 @@ const en = {
|
|
|
254
255
|
config: (by) => `A gtag('config') command, loaded by ${by}`,
|
|
255
256
|
send_to: (by) => `An event's send_to with no config, loaded by ${by}`,
|
|
256
257
|
destination: (by) => `A destination of the Google tag ${by}`,
|
|
258
|
+
preload: () => "Only a preload hint in the page (<link rel=\"preload\">): downloaded, never added as a script",
|
|
259
|
+
unseen: () => "Not seen being added: a script on the page may have replaced the browser's own functions, as consent platforms that block scripts do",
|
|
257
260
|
},
|
|
258
261
|
},
|
|
259
262
|
provider: "Provider",
|
|
@@ -385,6 +388,9 @@ const en = {
|
|
|
385
388
|
widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
|
|
386
389
|
iframeContainer: (id, origin, parent) => `${id} runs in an iframe from ${origin} embedded in ${parent}. The iframe has its own dataLayer: events and consent commands pushed in the page never reach it, so it needs its own.`,
|
|
387
390
|
containerFailed: (id, host, status) => `The ${id} script from ${host} ${status === "failed" ? "failed to load" : `was answered with HTTP ${status}`}, so none of its tags ran on that page.`,
|
|
391
|
+
userIdMissing: (n) => `After logging in with the test account, ${n} GA4 ${n === 1 ? "hit went" : "hits went"} out without a user ID (uid). GA4 can't tie the visit to the account, so reports by user and journeys across devices miss it.`,
|
|
392
|
+
containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "was downloaded because of a preload hint in the page, but nothing added it as a script, and it" : "was loaded but"} never ran on any page of this run: Google's own registry never held it, so none of its tags fired.`,
|
|
393
|
+
containerHeld: (id, vendor) => `${id} never ran, which is expected while ${vendor} holds it back until the visitor consents. This run never consented, so its tags weren't checked.`,
|
|
388
394
|
widgetEventsUnshared: (origin, names) => `An embedded widget from ${origin} pushed ${names.map(q).join(", ")} to its own dataLayer, but none of its hits to the site's accounts carried ${names.length === 1 ? "it" : "them"}. The page's tags can't read a widget's dataLayer, so ${names.length === 1 ? "this event reaches" : "these events reach"} only the widget's own tracking.`,
|
|
389
395
|
iframeMessageUnhandled: (origin, name, party) => `${party === "site" ? "An iframe on the site" : "An embedded widget"} from ${origin} posted ${q(name)} to the page, but the page pushed nothing to its dataLayer and sent no hit in the 3 seconds after, nor ${q(name)} later. If it is a conversion, the page isn't relaying it to its tags.`,
|
|
390
396
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
@@ -449,6 +455,9 @@ const en = {
|
|
|
449
455
|
widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
|
|
450
456
|
iframe_container: "Tags in the iframe miss what the page pushes, so conversions completed in it can go untracked or be sent without the visitor's consent.",
|
|
451
457
|
container_failed: "None of the container's tags ran: page views, conversions and consent signals it sends were all lost on that page.",
|
|
458
|
+
user_id_missing: "Logged-in visits can't be tied to their accounts in GA4, so user-level reports and cross-device journeys undercount them.",
|
|
459
|
+
container_not_run: "None of the container's tags fired: page views, conversions and remarketing were all lost wherever it never ran.",
|
|
460
|
+
container_held: "The container's tags weren't checked on this run, since it waited for a consent the run never gave.",
|
|
452
461
|
widget_events_unshared: "Conversions completed in the widget, such as a booking, a payment or a form, can be missing from the site's analytics and ad accounts.",
|
|
453
462
|
iframe_message_unhandled: "A conversion completed in the iframe, such as a form sent or a booking made, can go untracked although the iframe told the page about it.",
|
|
454
463
|
campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
|
|
@@ -500,6 +509,9 @@ const en = {
|
|
|
500
509
|
widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
|
|
501
510
|
iframe_container: "Push the events and the consent state inside the iframe as well (or pass them in with postMessage), or track the iframe's steps from the page.",
|
|
502
511
|
container_failed: "Check the container ID and the address in the snippet; for a server container or the Google tag gateway, check that it serves gtm.js and gtag/js.",
|
|
512
|
+
user_id_missing: "Once the visitor is logged in, set user_id on the Google tag (the user_id field of the GA4 Google tag in GTM, or gtag('config', ID, { user_id })) to your own account ID, never an email.",
|
|
513
|
+
container_not_run: "Check that something actually adds the container's script, the snippet or the framework component that loads it (a preload alone runs nothing); look for script errors that stop the page's own code, and for a consent platform that never releases it.",
|
|
514
|
+
container_held: "Run the journey with consent accepted, or the consent scenarios, to check the container's tags.",
|
|
503
515
|
widget_events_unshared: "If the site needs these events, have the widget send them to the site's accounts (most booking and form widgets take a GA4 or GTM ID), or pass them to the page with postMessage and push them to the page's dataLayer.",
|
|
504
516
|
iframe_message_unhandled: "Add a message listener on the page that checks event.origin and pushes the event to the dataLayer, so a GTM trigger can fire on it; or have the iframe send the event to the site's accounts itself.",
|
|
505
517
|
campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
|
|
@@ -551,6 +563,9 @@ const en = {
|
|
|
551
563
|
widget_tracking: "hygiene",
|
|
552
564
|
iframe_container: "hygiene",
|
|
553
565
|
container_failed: "money",
|
|
566
|
+
user_id_missing: "hygiene",
|
|
567
|
+
container_not_run: "money",
|
|
568
|
+
container_held: "hygiene",
|
|
554
569
|
widget_events_unshared: "money",
|
|
555
570
|
iframe_message_unhandled: "money",
|
|
556
571
|
campaign_params: "money",
|
|
@@ -1113,6 +1128,7 @@ const tr = {
|
|
|
1113
1128
|
size: (bytes) => `${(bytes / 1024).toFixed(1).replace(".", ",")} KB`,
|
|
1114
1129
|
loadTime: (ms) => `${ms} ms'de yüklendi`,
|
|
1115
1130
|
failed: (status) => (status === "failed" ? "yüklenemedi" : `yüklenemedi (HTTP ${status})`),
|
|
1131
|
+
neverRan: "hiç çalışmadı",
|
|
1116
1132
|
kinds: {
|
|
1117
1133
|
html: () => "Sayfanın HTML'indeki bir script etiketi",
|
|
1118
1134
|
inline: () => "Sayfadaki satır içi kod (snippet)",
|
|
@@ -1123,6 +1139,8 @@ const tr = {
|
|
|
1123
1139
|
config: (by) => `Bir gtag('config') komutu, ${by} yükledi`,
|
|
1124
1140
|
send_to: (by) => `config olmadan bir olayın send_to değeri, ${by} yükledi`,
|
|
1125
1141
|
destination: (by) => `${by} Google etiketinin bir hedefi`,
|
|
1142
|
+
preload: () => "Sayfada yalnızca bir ön yükleme ipucu (<link rel=\"preload\">): indirildi, hiç betik olarak eklenmedi",
|
|
1143
|
+
unseen: () => "Eklendiği görülmedi: sayfadaki bir betik tarayıcının kendi işlevlerini değiştirmiş olabilir; betikleri engelleyen onay platformlarının yaptığı gibi",
|
|
1126
1144
|
},
|
|
1127
1145
|
},
|
|
1128
1146
|
provider: "Sağlayıcı",
|
|
@@ -1250,6 +1268,9 @@ const tr = {
|
|
|
1250
1268
|
widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
|
|
1251
1269
|
iframeContainer: (id, origin, parent) => `${id}, ${parent} içine gömülü ${origin} kaynaklı bir iframe içinde çalışıyor. iframe'in kendi dataLayer'ı var: sayfada gönderilen olaylar ve onay komutları ona hiç ulaşmaz, bu yüzden kendi olaylarına ve onayına ihtiyacı var.`,
|
|
1252
1270
|
containerFailed: (id, host, status) => `${host} adresinden gelen ${id} betiği ${status === "failed" ? "yüklenemedi" : `HTTP ${status} ile yanıtlandı`}; bu yüzden o sayfada etiketlerinin hiçbiri çalışmadı.`,
|
|
1271
|
+
userIdMissing: (n) => `Test hesabıyla giriş yapıldıktan sonra ${n} GA4 isteği kullanıcı kimliği (uid) olmadan gönderildi. GA4 ziyareti hesaba bağlayamıyor; bu yüzden kullanıcıya göre raporlar ve cihazlar arası yolculuklar onu kaçırıyor.`,
|
|
1272
|
+
containerNotRun: (id, kind) => `${id} ${kind === "preload" ? "sayfadaki bir ön yükleme ipucu yüzünden indirildi, ama hiçbir şey onu betik olarak eklemedi ve" : "yüklendi ama"} bu çalıştırmanın hiçbir sayfasında çalışmadı: Google'ın kendi kaydı onu hiç tutmadı, bu yüzden etiketlerinin hiçbiri çalışmadı.`,
|
|
1273
|
+
containerHeld: (id, vendor) => `${id} hiç çalışmadı; ${vendor} onu ziyaretçi onay verene kadar beklettiği sürece bu beklenen bir durum. Bu çalıştırmada onay hiç verilmedi, bu yüzden etiketleri kontrol edilmedi.`,
|
|
1253
1274
|
widgetEventsUnshared: (origin, names) => `${origin} kaynaklı gömülü bir widget kendi dataLayer'ına ${names.map(q).join(", ")} gönderdi, ancak sitenin hesaplarına giden isteklerinin hiçbiri ${names.length === 1 ? "onu" : "onları"} taşımadı. Sayfanın etiketleri bir widget'ın dataLayer'ını okuyamaz; bu yüzden ${names.length === 1 ? "bu olay" : "bu olaylar"} yalnızca widget'ın kendi izlemesine ulaşıyor.`,
|
|
1254
1275
|
iframeMessageUnhandled: (origin, name, party) => `${origin} kaynaklı ${party === "site" ? "sitedeki bir iframe" : "gömülü bir widget"} sayfaya ${q(name)} gönderdi, ancak sayfa ardından 3 saniye içinde dataLayer'a hiçbir şey göndermedi ve istek yollamadı, sonradan ${q(name)} da göndermedi. Bu bir dönüşümse sayfa onu etiketlerine iletmiyor.`,
|
|
1255
1276
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
@@ -1313,6 +1334,9 @@ const tr = {
|
|
|
1313
1334
|
widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
|
|
1314
1335
|
iframe_container: "iframe içindeki etiketler sayfanın gönderdiklerini görmez; içinde tamamlanan dönüşümler izlenmeyebilir ya da ziyaretçinin onayı olmadan gönderilebilir.",
|
|
1315
1336
|
container_failed: "Container'ın etiketlerinin hiçbiri çalışmadı: gönderdiği sayfa görüntülemeleri, dönüşümler ve onay sinyalleri o sayfada kayboldu.",
|
|
1337
|
+
user_id_missing: "Giriş yapılmış ziyaretler GA4'te hesaplarına bağlanamıyor; kullanıcı düzeyindeki raporlar ve cihazlar arası yolculuklar onları eksik sayıyor.",
|
|
1338
|
+
container_not_run: "Container'ın etiketlerinin hiçbiri çalışmadı: hiç çalışmadığı her yerde sayfa görüntülemeleri, dönüşümler ve yeniden pazarlama kayboldu.",
|
|
1339
|
+
container_held: "Container onay beklediği ve bu çalıştırma onay vermediği için etiketleri kontrol edilmedi.",
|
|
1316
1340
|
widget_events_unshared: "Widget içinde tamamlanan dönüşümler, örneğin bir rezervasyon, ödeme ya da form, sitenin analitik ve reklam hesaplarında eksik kalabilir.",
|
|
1317
1341
|
iframe_message_unhandled: "iframe içinde tamamlanan bir dönüşüm (gönderilen bir form, yapılan bir rezervasyon), iframe sayfaya bildirdiği hâlde izlenmeyebilir.",
|
|
1318
1342
|
campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
|
|
@@ -1362,6 +1386,9 @@ const tr = {
|
|
|
1362
1386
|
widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
|
|
1363
1387
|
iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
|
|
1364
1388
|
container_failed: "Snippet'teki container kimliğini ve adresi kontrol edin; sunucu container'ı ya da Google etiket ağ geçidi için gtm.js ve gtag/js sunduğunu kontrol edin.",
|
|
1389
|
+
user_id_missing: "Ziyaretçi giriş yaptıktan sonra Google etiketinde user_id'yi kendi hesap kimliğinize ayarlayın (GTM'deki GA4 Google etiketinin user_id alanı ya da gtag('config', ID, { user_id })); asla bir e-posta kullanmayın.",
|
|
1390
|
+
container_not_run: "Container betiğini gerçekten bir şeyin eklediğini kontrol edin: snippet ya da onu yükleyen çatı bileşeni (yalnızca ön yükleme hiçbir şeyi çalıştırmaz); sayfanın kendi kodunu durduran betik hatalarına ve onu hiç serbest bırakmayan bir onay platformuna bakın.",
|
|
1391
|
+
container_held: "Container'ın etiketlerini kontrol etmek için yolculuğu onay kabul edilmiş olarak ya da onay senaryolarıyla çalıştırın.",
|
|
1365
1392
|
widget_events_unshared: "Site bu olaylara ihtiyaç duyuyorsa widget'ın onları sitenin hesaplarına göndermesini sağlayın (çoğu rezervasyon ve form widget'ı bir GA4 ya da GTM kimliği kabul eder) ya da onları postMessage ile sayfaya iletip sayfanın dataLayer'ına gönderin.",
|
|
1366
1393
|
iframe_message_unhandled: "Sayfaya event.origin'i kontrol eden ve olayı dataLayer'a gönderen bir message dinleyicisi ekleyin, böylece bir GTM tetikleyicisi onunla çalışabilir; ya da iframe'in olayı sitenin hesaplarına kendisi göndermesini sağlayın.",
|
|
1367
1394
|
campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
|
|
@@ -1412,6 +1439,9 @@ const tr = {
|
|
|
1412
1439
|
widget_tracking: "hygiene",
|
|
1413
1440
|
iframe_container: "hygiene",
|
|
1414
1441
|
container_failed: "money",
|
|
1442
|
+
user_id_missing: "hygiene",
|
|
1443
|
+
container_not_run: "money",
|
|
1444
|
+
container_held: "hygiene",
|
|
1415
1445
|
widget_events_unshared: "money",
|
|
1416
1446
|
iframe_message_unhandled: "money",
|
|
1417
1447
|
campaign_params: "money",
|
package/src/journeys.js
CHANGED
|
@@ -39,6 +39,12 @@ export const JOURNEY_PRESETS = {
|
|
|
39
39
|
{ platform: "meta", name: "Lead" },
|
|
40
40
|
],
|
|
41
41
|
},
|
|
42
|
+
login: {
|
|
43
|
+
goal: "Accept cookies, open the login or sign-in page, and log in with the test account: fill {{login.email}} (or {{login.username}} where the site asks for a username) and {{login.password}}, then submit. Stop when the site shows you are logged in.",
|
|
44
|
+
events: [
|
|
45
|
+
{ platform: "ga4", name: "login" },
|
|
46
|
+
],
|
|
47
|
+
},
|
|
42
48
|
search: {
|
|
43
49
|
goal: "Accept cookies, use the site search to search for a common product or topic, and open the first result.",
|
|
44
50
|
events: [
|
|
@@ -52,7 +58,7 @@ export const JOURNEY_PRESETS = {
|
|
|
52
58
|
},
|
|
53
59
|
};
|
|
54
60
|
|
|
55
|
-
const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
|
|
61
|
+
const ALIASES = { signin: "login", sign_in: "login", log_in: "login", checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", decline: "decline_cookies", reject: "decline_cookies", reject_cookies: "decline_cookies", consent: "decline_cookies", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
|
|
56
62
|
|
|
57
63
|
/** The preset for a name like "purchase" or "signup", or null. */
|
|
58
64
|
export function presetFor(name) {
|
|
@@ -168,6 +174,7 @@ const PRESET_BY_EVENT = [
|
|
|
168
174
|
["purchase", ["purchase", "add_payment_info", "add_shipping_info", "begin_checkout"]],
|
|
169
175
|
["add_to_cart", ["add_to_cart", "view_cart"]],
|
|
170
176
|
["sign_up", ["sign_up"]],
|
|
177
|
+
["login", ["login"]],
|
|
171
178
|
["lead", ["generate_lead"]],
|
|
172
179
|
["search", ["search", "view_search_results"]],
|
|
173
180
|
];
|
package/src/login.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// A test account for journeys that log in. The email (or username) and password come from environment variables on
|
|
2
|
+
// the computer running the tool; the model only ever sees placeholders such as {{login.password}}, the browser fills
|
|
3
|
+
// in the real values (on the audited site's pages only, see AuditSession.perform), and every text the tool records
|
|
4
|
+
// or shows has them turned back into their placeholders.
|
|
5
|
+
|
|
6
|
+
import { existsSync } from "node:fs";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { domainOf } from "./workspace.js";
|
|
9
|
+
|
|
10
|
+
export const LOGIN_ENV = { email: "AITD_LOGIN_EMAIL", username: "AITD_LOGIN_USERNAME", password: "AITD_LOGIN_PASSWORD" };
|
|
11
|
+
|
|
12
|
+
const PLACEHOLDER = /\{\{\s*login\.(email|username|password)\s*\}\}/g;
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The test account the environment sets, as { email, username, password }, or null when none is set. username is
|
|
16
|
+
* AITD_LOGIN_USERNAME, else the email, for sites that ask for a username; email is the same the other way round.
|
|
17
|
+
*/
|
|
18
|
+
export function loginFromEnv(env = process.env) {
|
|
19
|
+
const email = env[LOGIN_ENV.email]?.trim() || "";
|
|
20
|
+
const username = env[LOGIN_ENV.username]?.trim() || "";
|
|
21
|
+
const password = env[LOGIN_ENV.password] ?? "";
|
|
22
|
+
if (!email && !username && !password) return null;
|
|
23
|
+
return { email: email || username, username: username || email, password };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Whether a value asks for the test account: it holds {{login.email}}, {{login.username}} or {{login.password}}. */
|
|
27
|
+
export const hasLoginPlaceholder = (value) => typeof value === "string" && new RegExp(PLACEHOLDER.source).test(value);
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* The value with its placeholders replaced by the test account's. Throws an error with code NO_LOGIN, naming the
|
|
31
|
+
* environment variables to set, when a placeholder asks for something the environment doesn't hold.
|
|
32
|
+
*/
|
|
33
|
+
export function fillLoginValue(value, login) {
|
|
34
|
+
return String(value).replace(PLACEHOLDER, (_, field) => {
|
|
35
|
+
const v = login?.[field];
|
|
36
|
+
if (!v) {
|
|
37
|
+
const name = field === "password" ? LOGIN_ENV.password : `${LOGIN_ENV.email} (or ${LOGIN_ENV.username})`;
|
|
38
|
+
const err = new Error(`No test account is set up for {{login.${field}}}: set ${name} on the computer running ai-tag-debugger. Without one, stop and report that the journey needs a login.`);
|
|
39
|
+
err.code = "NO_LOGIN";
|
|
40
|
+
throw err;
|
|
41
|
+
}
|
|
42
|
+
return v;
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const escape = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* A function that turns the test account's values back into their placeholders wherever they appear in a text, as
|
|
50
|
+
* typed or URL-encoded. Values shorter than 3 characters are left alone, since they would match ordinary text.
|
|
51
|
+
* Identity when there is no test account.
|
|
52
|
+
*/
|
|
53
|
+
export function loginScrubber(login) {
|
|
54
|
+
if (!login) return (text) => text;
|
|
55
|
+
const pairs = [];
|
|
56
|
+
const taken = new Set();
|
|
57
|
+
// The password first: an email inside a password must not leave the rest of the password behind. A username that is
|
|
58
|
+
// the email (the default) reads as {{login.email}}.
|
|
59
|
+
for (const [field, value] of [["password", login.password], ["email", login.email], ["username", login.username]]) {
|
|
60
|
+
if (!value || value.length < 3 || taken.has(value)) continue;
|
|
61
|
+
taken.add(value);
|
|
62
|
+
for (const form of new Set([value, encodeURIComponent(value), encodeURIComponent(value).replace(/%20/g, "+")])) pairs.push([form, `{{login.${field}}}`]);
|
|
63
|
+
}
|
|
64
|
+
if (!pairs.length) return (text) => text;
|
|
65
|
+
const re = new RegExp(pairs.map(([v]) => escape(v)).sort((a, b) => b.length - a.length).join("|"), "g");
|
|
66
|
+
const to = new Map(pairs);
|
|
67
|
+
return (text) => (typeof text === "string" && text ? text.replace(re, (m) => to.get(m) ?? m) : text);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** A copy of a value with every string in it scrubbed: for hits' parameters, dataLayer pushes and the like. */
|
|
71
|
+
export function scrubDeep(value, scrub) {
|
|
72
|
+
if (typeof value === "string") return scrub(value);
|
|
73
|
+
if (Array.isArray(value)) return value.map((v) => scrubDeep(v, scrub));
|
|
74
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, scrubDeep(v, scrub)]));
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Where `aitd login` saves a site's logged-in session by default: aitd/<domain>/session.json, a gitignored folder. */
|
|
79
|
+
export function sessionFileFor(site, dataDir) {
|
|
80
|
+
return path.join(dataDir, domainOf(site), "session.json");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The session file a run should start from: "saved" for the one `aitd login` saved for the site, else a path.
|
|
85
|
+
* Throws when the file doesn't exist, with the command that saves one.
|
|
86
|
+
*/
|
|
87
|
+
export function resolveSessionFile(value, { site, dataDir }) {
|
|
88
|
+
const file = value === "saved" ? sessionFileFor(site, dataDir) : path.resolve(String(value));
|
|
89
|
+
if (!existsSync(file)) throw new Error(`No saved login session at ${file}. Save one with: aitd login --site ${site}`);
|
|
90
|
+
return file;
|
|
91
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -33,7 +33,8 @@ import { decodeGcd, DEFAULT_PROVIDERS, googleConsent, isCollectionEndpoint, pars
|
|
|
33
33
|
import { hitParams } from "./hit-params.js";
|
|
34
34
|
import { CAMPAIGN_PARAMS, campaignParamsIn, withCampaignParams } from "./campaign.js";
|
|
35
35
|
import { cmpFacts } from "./cmp.js";
|
|
36
|
-
import {
|
|
36
|
+
import { resolveSessionFile } from "./login.js";
|
|
37
|
+
import { containerRows, containersNotRun, loadSources, unseenContainers } from "./containers.js";
|
|
37
38
|
import { isEventMessage, isWidgetHit, messageOutcomes, pushedEvent, siteIds, splitRun, widgetSummaries } from "./frames.js";
|
|
38
39
|
import { consentMatrixMarkdown, consentTable, runConsentScenarios } from "./consent.js";
|
|
39
40
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
@@ -244,7 +245,7 @@ export function hitDetail(audit, step, index) {
|
|
|
244
245
|
function containerJson(l) {
|
|
245
246
|
return {
|
|
246
247
|
id: l.id, source: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
247
|
-
...(l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
|
|
248
|
+
...(l.layer && l.layer !== "dataLayer" ? { data_layer: l.layer } : {}),
|
|
248
249
|
...(l.served === "site" ? { served_from: l.host } : {}),
|
|
249
250
|
...(l.environment ? { gtm_environment: l.environment } : {}),
|
|
250
251
|
...(l.frame ? { iframe: { origin: l.frame.origin, party: l.frame.party } } : {}),
|
|
@@ -253,6 +254,7 @@ function containerJson(l) {
|
|
|
253
254
|
...(l.transferBytes ? { transfer_bytes: l.transferBytes } : {}),
|
|
254
255
|
...(l.ms != null ? { load_ms: l.ms } : {}),
|
|
255
256
|
...(l.failed ? { failed: l.failed } : {}),
|
|
257
|
+
...(typeof l.ran === "boolean" ? { ran: l.ran } : {}),
|
|
256
258
|
...(l.pages ? { pages: l.pages } : {}),
|
|
257
259
|
};
|
|
258
260
|
}
|
|
@@ -265,7 +267,7 @@ export function containerLines(runs) {
|
|
|
265
267
|
return [
|
|
266
268
|
"Google containers (how each reached the page):",
|
|
267
269
|
...rows.map((r) => {
|
|
268
|
-
const extra = [scriptCell(c, r), r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
|
|
270
|
+
const extra = [scriptCell(c, r), r.layer && r.layer !== "dataLayer" && `dataLayer ${r.layer}`, r.served === "site" && `served from ${r.host}`, r.environment && c.environment(r.environment), r.frame && c.iframe(r.frame.origin, r.frame.party), `${r.pages.length} ${r.pages.length === 1 ? "page" : "pages"}`].filter(Boolean);
|
|
269
271
|
return ` ${r.id} (${c.script[r.script]}): ${c.kinds[r.kind](r.by)}; ${extra.join(", ")}`;
|
|
270
272
|
}),
|
|
271
273
|
];
|
|
@@ -440,10 +442,14 @@ export function buildServer(opts) {
|
|
|
440
442
|
.optional()
|
|
441
443
|
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
442
444
|
campaign: campaignSchema().describe(`Open the first page the way an ad click does, so finish_audit checks that the campaign parameters survive redirects and reach the hits. ${CAMPAIGN_DOC}`),
|
|
445
|
+
session: z
|
|
446
|
+
.enum(["saved"])
|
|
447
|
+
.optional()
|
|
448
|
+
.describe("Start logged in, from the session saved for this site with `aitd login --site <url>` on the computer running this server. For sites whose login needs a person (a CAPTCHA or a code). Local servers only."),
|
|
443
449
|
live: z.boolean().optional().describe("Default false: tracking requests are answered locally and never reach the site's accounts. true lets them through to the real analytics and ad accounts, to check them in GA4 Realtime or DebugView, Meta Events Manager or Tag Assistant: the test traffic then shows in the site's reports. Only when the user asks for it."),
|
|
444
450
|
},
|
|
445
451
|
},
|
|
446
|
-
async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false }) => {
|
|
452
|
+
async ({ site, journey, viewport, allowed_hosts, providers, campaign = true, live = false, session: savedSession }) => {
|
|
447
453
|
let url;
|
|
448
454
|
try {
|
|
449
455
|
url = new URL(site);
|
|
@@ -460,7 +466,10 @@ export function buildServer(opts) {
|
|
|
460
466
|
let session;
|
|
461
467
|
try {
|
|
462
468
|
if (live && !opts.allowLive) return fail("Live audits are off on this server: it was started without --allow-live. Leave live out to audit with tracking requests answered locally.");
|
|
463
|
-
|
|
469
|
+
// A saved login lives on the computer running the server; over HTTP, a chat elsewhere must not use it.
|
|
470
|
+
if (savedSession && !opts.allowFiles) return fail("A saved login session can only be used on a local server. Leave session out, or log in with the test account during the journey.");
|
|
471
|
+
const storageState = savedSession ? resolveSessionFile("saved", { site: url.href, dataDir: opts.dataDir ?? DEFAULT_DATA_DIR }) : undefined;
|
|
472
|
+
session = new AuditSession({ viewport: viewport ?? "desktop", site: url.href, allowedHosts: allowed_hosts, providers, blockPrivate: opts.blockPrivate, blockHits: !live, storageState });
|
|
464
473
|
} catch (err) {
|
|
465
474
|
return fail(err.message);
|
|
466
475
|
}
|
|
@@ -482,7 +491,8 @@ export function buildServer(opts) {
|
|
|
482
491
|
const sent = campaignParamsIn(start);
|
|
483
492
|
const opened = Object.keys(sent).length ? `Opened like an ad click with ${Object.entries(sent).map(([k, v]) => `${k}=${v}`).join(", ")}; finish_audit checks that they survive redirects and reach the hits.\n` : "";
|
|
484
493
|
const liveLine = live ? `${LIVE_NOTE}\n` : "";
|
|
485
|
-
|
|
494
|
+
const accountLine = `${session.login ? "A test account is set up: to log in, fill {{login.email}} (or {{login.username}}) and {{login.password}}, written exactly like that; the browser types the real values." : "No test account is set up on this computer (AITD_LOGIN_EMAIL and AITD_LOGIN_PASSWORD), so a journey that needs a login stops at it."}${savedSession ? " The browser started from the saved login session." : ""}\n`;
|
|
495
|
+
return text(`audit_id: ${id}\n${liveLine}${accountLine}${opened}${goal}\n${page}\n\n${stepCapture(audit, session.currentStep)}`);
|
|
486
496
|
},
|
|
487
497
|
);
|
|
488
498
|
|
|
@@ -575,10 +585,20 @@ export function buildServer(opts) {
|
|
|
575
585
|
},
|
|
576
586
|
async ({ audit_id }) => {
|
|
577
587
|
const { session } = getAudit(audit_id);
|
|
578
|
-
const
|
|
579
|
-
|
|
588
|
+
const run = {
|
|
589
|
+
containerLoads: session.containerLoads ?? [], containerScripts: session.containerScripts, dataLayerLog: session.dataLayerLog,
|
|
590
|
+
containerPreloads: [...(session.containerPreloads?.values() ?? [])],
|
|
591
|
+
containersRunning: [...(session.containersRunning ?? [])].map(([id, step]) => ({ id, step })),
|
|
592
|
+
};
|
|
593
|
+
const loads = loadSources(run);
|
|
594
|
+
const unseen = unseenContainers(run);
|
|
595
|
+
const notRun = containersNotRun(run);
|
|
596
|
+
if (!loads.length && !unseen.length) return text("No GTM container or Google tag script has loaded on this audit's pages yet.");
|
|
597
|
+
const c = messages("en").ui.containers;
|
|
580
598
|
const lines = loads.map((l) => `Step ${l.step} ("${session.steps[l.step]?.label ?? ""}"), ${l.page ?? "?"}:\n${describeLoad(l)}`);
|
|
581
|
-
|
|
599
|
+
if (unseen.length) lines.push("Asked for without a script being seen added:", ...unseen.map((u) => ` ${u.id} (${c.script[u.script]}), step ${u.step}: ${c.kinds[u.kind]()}`));
|
|
600
|
+
if (notRun.length) lines.push(`Never ran so far (Google's registry never held ${notRun.length === 1 ? "it" : "them"}): ${notRun.map((x) => x.id).join(", ")}. If a consent platform holds GTM back, accept consent to see it run.`);
|
|
601
|
+
return text(`${loads.length} container ${loads.length === 1 ? "load" : "loads"}:\n${lines.join("\n")}\n\nAs JSON: ${JSON.stringify([...loads, ...unseen].map((l) => ({ step: l.step, page: l.page, ...containerJson(l), ...(l.id.startsWith("GTM-") ? { ran: run.containersRunning.some((r) => r.id === l.id) } : {}) })))}`);
|
|
582
602
|
},
|
|
583
603
|
);
|
|
584
604
|
|