@analitikisler/ai-tag-debugger 0.7.0 → 0.7.2
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 +26 -0
- package/README.md +2 -2
- package/README.tr.md +2 -2
- package/package.json +1 -1
- package/src/agent.js +23 -11
- package/src/audit.js +14 -6
- package/src/browser.js +79 -10
- package/src/cli.js +7 -1
- package/src/parsers.js +20 -6
- package/src/replay.js +14 -3
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,31 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.2 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
- **GA4 and Google Ads hits sent through Google tag gateway are captured.** Sites using the gateway send GA4 to a short path on their own domain (`/<prefix>/ag/g/c`, `/<prefix>/ga/g/c`) and Google Ads to `/<prefix>/as/p/c/<id>/`. These were neither recognized nor answered locally, so their events were reported as missing and the audit's visits reached the site's real accounts. A `/g/c` path counts as GA4 only with a GA4 measurement id (`tid=G-…`).
|
|
6
|
+
- Google Tag Manager and gtag.js loaded from the site's own domain are labelled as such in the network timeline.
|
|
7
|
+
- Two more Google pings are answered locally instead of reaching Google: `www.google.com/measurement/conversion` and `google.<tld>/ads/ga-audiences`.
|
|
8
|
+
- Saved sites also leave out cookies named like a login or token (`auth_token`, `sid`, `csrftoken`, …) and cookies holding an email address, even when they aren't HttpOnly.
|
|
9
|
+
- Writing a saved site's recording can no longer stall a run: the page is left first, so what it is still loading is cancelled, and after 30 seconds the recording is skipped (the site is then not saved). The log says when the recording is being written. Reading the browser's cookies at the end of a journey has a time limit too.
|
|
10
|
+
- `replay` no longer crashes with "Target page, context or browser has been closed" when a request is still being answered as the browser closes.
|
|
11
|
+
- A saved journey whose start page could not be opened (a redirect to another site, a bot check) can be replayed; it stays unfinished with its saved note.
|
|
12
|
+
- When a site can't be saved, a `site.json` from an earlier save is removed so it can't be replayed against the new recordings, and `run` exits with code 1.
|
|
13
|
+
|
|
14
|
+
## 0.7.1 (2026-10-13)
|
|
15
|
+
|
|
16
|
+
- A page whose script stops responding after a step can no longer stall a run. Reading the page now has a 1 minute limit too, and when it runs out the journey ends as incomplete with a note. Before, only the model call and the browser action itself had a limit.
|
|
17
|
+
- When a saved site's recording can't be cleaned, a warning now says so before the file is deleted.
|
|
18
|
+
|
|
19
|
+
Replays of saved sites are closer to the saved run, after the first save of 18 real shops:
|
|
20
|
+
|
|
21
|
+
- Scripted runs, and so replays, wait 3 seconds after the last step, so tags that send a moment after the last click (GA4 batches events) are captured as in an AI run.
|
|
22
|
+
- A step that failed in the saved run is tried again on replay without ending the journey there.
|
|
23
|
+
- A journey the AI could not finish (no cart, a bot check) stays unfinished on replay, so its missing events are not reported as broken.
|
|
24
|
+
- Cookies the server sets for the page's scripts, such as a country, are kept in the recording; HttpOnly cookies, where sessions and logins live, are still left out. Some sites don't render without them, for example a cookie banner that never appears.
|
|
25
|
+
- Images, fonts and video left out of the recording get an empty answer on replay instead of failing, since a failed image can stop a script.
|
|
26
|
+
- Requests that had no answer yet when the browser closed are left out of the recording. On replay they made the page that asked for them again never finish loading.
|
|
27
|
+
- A site whose recording is missing (the browser closed early) is not saved, and `replay` reports a saved site it can't replay and goes on with the next one instead of stopping.
|
|
28
|
+
|
|
3
29
|
## 0.7.0 (2026-10-12)
|
|
4
30
|
|
|
5
31
|
Fixes from the first audit run across 18 real shops.
|
package/README.md
CHANGED
|
@@ -45,7 +45,7 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
|
|
|
45
45
|
| Personal data | An email address sent to an ad platform in plain text |
|
|
46
46
|
| Unplanned events | Events firing that aren't in your measurement plan |
|
|
47
47
|
|
|
48
|
-
Platforms: GA4 (including server-side GTM endpoints), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
|
|
48
|
+
Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
|
|
49
49
|
|
|
50
50
|
### Choose the providers to check
|
|
51
51
|
|
|
@@ -180,7 +180,7 @@ npx @analitikisler/ai-tag-debugger replay saved/*
|
|
|
180
180
|
- warning duplicate purchase desktop ga4 page_view
|
|
181
181
|
```
|
|
182
182
|
|
|
183
|
-
The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, cookies, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
|
|
183
|
+
The first run's findings become the expected ones. When a change is right, for example a false alarm that is fixed, `replay --update` makes the new findings the expected ones. Use it to check a new version of the tool against sites you know, or a site change against its last good run. A saved site is usually a few MB. It leaves out tracking requests, images and video, HttpOnly and login cookies and the cookies the browser sent, authorization headers, form data, and requests with an email or other user data in their address. It still holds the site's pages and scripts, so keep sites you don't own out of public repositories.
|
|
184
184
|
|
|
185
185
|
## Configure your site
|
|
186
186
|
|
package/README.tr.md
CHANGED
|
@@ -45,7 +45,7 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
|
|
|
45
45
|
| Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
|
|
46
46
|
| Planda olmayan event'ler | Ölçümleme planınızda bulunmayan event'ler gönderiliyor |
|
|
47
47
|
|
|
48
|
-
Platformlar: GA4 (server-side GTM adresleri dahil), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
|
|
48
|
+
Platformlar: GA4 (server-side GTM adresleri ve sitenin kendi alan adındaki Google tag gateway yolları dahil), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
|
|
49
49
|
|
|
50
50
|
### Kontrol edilecek platformları seçin
|
|
51
51
|
|
|
@@ -182,7 +182,7 @@ npx @analitikisler/ai-tag-debugger replay saved/*
|
|
|
182
182
|
- warning duplicate purchase desktop ga4 page_view
|
|
183
183
|
```
|
|
184
184
|
|
|
185
|
-
İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
|
|
185
|
+
İlk çalıştırmanın bulguları beklenen bulgular olur. Bir değişiklik doğruysa (örneğin düzeltilmiş bir yanlış alarm), `replay --update` yeni bulguları beklenen bulgular yapar. Aracın yeni bir sürümünü bildiğiniz sitelerle ya da bir site değişikliğini son sağlıklı çalıştırmayla karşılaştırmak için kullanın. Kaydedilmiş bir site genellikle birkaç MB'tır. İzleme isteklerini, görselleri ve videoları, HttpOnly ve oturum açma çerezlerini ve tarayıcının gönderdiği çerezleri, yetkilendirme başlıklarını, form verilerini ve adresinde e-posta ya da başka kullanıcı verisi olan istekleri içermez. Yine de sitenin sayfalarını ve betiklerini içerdiği için size ait olmayan siteleri herkese açık depolara koymayın.
|
|
186
186
|
|
|
187
187
|
## Sitenizi ayarlayın
|
|
188
188
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.2",
|
|
4
4
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/agent.js
CHANGED
|
@@ -88,9 +88,11 @@ export function validateAction(input) {
|
|
|
88
88
|
return null;
|
|
89
89
|
}
|
|
90
90
|
|
|
91
|
-
// One hung page or model request must not stall a whole run.
|
|
92
|
-
|
|
93
|
-
const
|
|
91
|
+
// One hung page or model request must not stall a whole run. A page whose script never
|
|
92
|
+
// stops (an endless loop after a click, for example) can't be read, so reading it has a limit too.
|
|
93
|
+
const TIMEOUTS = { model: 180_000, action: 120_000, page: 60_000 };
|
|
94
|
+
|
|
95
|
+
const LAST_STEP_WAIT_MS = 3000;
|
|
94
96
|
|
|
95
97
|
class StepTimeout extends Error {}
|
|
96
98
|
|
|
@@ -105,14 +107,18 @@ function withTimeout(promise, ms, message) {
|
|
|
105
107
|
* Lets the AI model drive the browser through one journey.
|
|
106
108
|
* @param {AuditSession} session
|
|
107
109
|
* @param {{ id: string, goal: string }} journey
|
|
108
|
-
* @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void }} opts
|
|
110
|
+
* @param {{ provider?: any, providerName?: string, client?: any, model?: string, maxSteps?: number, lang?: string, log?: (msg: string) => void, timeouts?: { model?: number, action?: number, page?: number } }} opts
|
|
111
|
+
* timeouts: milliseconds for one model answer, one browser action and one read of the page.
|
|
109
112
|
*/
|
|
110
113
|
export async function runAgentJourney(session, journey, opts = {}) {
|
|
111
114
|
const provider = opts.provider ?? (await createProvider({ name: opts.providerName, model: opts.model, client: opts.client }));
|
|
112
115
|
const maxSteps = opts.maxSteps ?? 30;
|
|
113
116
|
const log = opts.log ?? (() => {});
|
|
117
|
+
const limit = { ...TIMEOUTS, ...opts.timeouts };
|
|
118
|
+
const seconds = (ms) => (ms % 60_000 ? `${Math.round(ms / 1000)} seconds` : ms === 60_000 ? "a minute" : `${ms / 60_000} minutes`);
|
|
119
|
+
const readPage = () => withTimeout(session.snapshot(), limit.page, `The page stopped responding: it could not be read for ${seconds(limit.page)}.`);
|
|
114
120
|
|
|
115
|
-
const first = await
|
|
121
|
+
const first = await readPage();
|
|
116
122
|
const chat = provider.conversation({
|
|
117
123
|
system: systemPrompt(opts.lang ?? "en"),
|
|
118
124
|
tools: TOOLS,
|
|
@@ -120,7 +126,7 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
120
126
|
});
|
|
121
127
|
|
|
122
128
|
for (let turn = 0; turn < maxSteps; turn++) {
|
|
123
|
-
const { toolCalls, stop } = await withTimeout(chat.next(),
|
|
129
|
+
const { toolCalls, stop } = await withTimeout(chat.next(), limit.model, `The model did not answer within ${seconds(limit.model)}.`);
|
|
124
130
|
if (stop === "refusal") return { completed: false, note: "The model declined to continue this journey." };
|
|
125
131
|
if (toolCalls.length === 0) {
|
|
126
132
|
if (stop === "max_tokens") return { completed: false, note: "The model response was cut off." };
|
|
@@ -146,8 +152,8 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
146
152
|
continue;
|
|
147
153
|
}
|
|
148
154
|
log(` ${call.input.reason}`);
|
|
149
|
-
const error = await withTimeout(session.act(call.input),
|
|
150
|
-
results.push({ id: call.id, content: pageView(await
|
|
155
|
+
const error = await withTimeout(session.act(call.input), limit.action, `The browser action did not finish within ${seconds(limit.action)}.`);
|
|
156
|
+
results.push({ id: call.id, content: pageView(await readPage(), error) });
|
|
151
157
|
}
|
|
152
158
|
if (finished) return finished;
|
|
153
159
|
chat.addToolResults(results);
|
|
@@ -158,12 +164,16 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
158
164
|
/** Replays fixed steps from the config, without calling an AI model. */
|
|
159
165
|
export async function runScriptedJourney(session, journey, opts = {}) {
|
|
160
166
|
const log = opts.log ?? (() => {});
|
|
161
|
-
if (!journey.steps?.length) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
|
|
162
|
-
for (const step of journey.steps) {
|
|
167
|
+
if (!journey.steps?.length && !journey.incomplete) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
|
|
168
|
+
for (const step of journey.steps ?? []) {
|
|
163
169
|
log(` ${step.reason ?? `${step.action} ${step.selector ?? step.url ?? ""}`}`);
|
|
164
170
|
const error = await session.act(step);
|
|
165
171
|
if (error && !step.optional) return { completed: false, note: `Step "${step.reason ?? step.action}" failed: ${error}` };
|
|
166
172
|
}
|
|
173
|
+
// Tags often send a moment after the last click (GA4 batches events). An AI run gives them that time
|
|
174
|
+
// while the model answers; a scripted run would otherwise close the browser first.
|
|
175
|
+
await session.page.waitForTimeout(LAST_STEP_WAIT_MS).catch(() => {});
|
|
176
|
+
if (journey.incomplete) return { completed: false, note: journey.incomplete };
|
|
167
177
|
return { completed: true, note: "All scripted steps completed." };
|
|
168
178
|
}
|
|
169
179
|
|
|
@@ -184,7 +194,8 @@ export async function runJourney(config, journey, viewport, opts = {}) {
|
|
|
184
194
|
await session.start();
|
|
185
195
|
const startUrl = new URL(journey.start ?? "/", config.site).href;
|
|
186
196
|
const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
|
|
187
|
-
|
|
197
|
+
// A replayed journey that was unfinished when saved keeps its saved note, however it fails this time.
|
|
198
|
+
if (startError) return { ...collect(session, journey, viewport), completed: false, note: journey.incomplete ?? `Could not open the start page: ${startError}` };
|
|
188
199
|
|
|
189
200
|
const outcome = await (opts.scripted ? runScriptedJourney(session, journey, opts) : runAgentJourney(session, journey, opts)).catch((err) => {
|
|
190
201
|
if (err instanceof StepTimeout) return { completed: false, note: err.message };
|
|
@@ -192,6 +203,7 @@ export async function runJourney(config, journey, viewport, opts = {}) {
|
|
|
192
203
|
});
|
|
193
204
|
return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
|
|
194
205
|
} finally {
|
|
206
|
+
if (opts.saveSite) opts.log?.(" Writing the recording…");
|
|
195
207
|
await session.close();
|
|
196
208
|
}
|
|
197
209
|
}
|
package/src/audit.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// A full audit from a config file: run every journey, check, explain, write the reports.
|
|
2
2
|
// Shared by the CLI and the MCP server's run_audit tool.
|
|
3
|
-
import { readFile, mkdir, writeFile } from "node:fs/promises";
|
|
3
|
+
import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { runJourney } from "./agent.js";
|
|
6
6
|
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
@@ -9,7 +9,7 @@ import { LANGUAGES, messages } from "./i18n.js";
|
|
|
9
9
|
import { createProvider } from "./providers.js";
|
|
10
10
|
import { filterFindings, planRun } from "./journeys.js";
|
|
11
11
|
import { resolveProviders } from "./parsers.js";
|
|
12
|
-
import { writeSavedSite } from "./replay.js";
|
|
12
|
+
import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
|
|
13
13
|
|
|
14
14
|
export async function readJson(file, baseDir = ".") {
|
|
15
15
|
const full = path.resolve(baseDir, file);
|
|
@@ -72,7 +72,7 @@ export async function runAudit(opts) {
|
|
|
72
72
|
const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
|
|
73
73
|
if (!journeys.length) throw new Error("No journeys to run. Pick one with --journey (e.g. purchase) or describe one with --goal.");
|
|
74
74
|
if (opts.scripted) {
|
|
75
|
-
const unscripted = journeys.filter((j) => !j.steps?.length).map((j) => j.id);
|
|
75
|
+
const unscripted = journeys.filter((j) => !j.steps?.length && !j.incomplete).map((j) => j.id);
|
|
76
76
|
if (unscripted.length) throw new Error(`Journeys without fixed steps can't be replayed with --scripted: ${unscripted.join(", ")}.`);
|
|
77
77
|
}
|
|
78
78
|
|
|
@@ -91,9 +91,17 @@ export async function runAudit(opts) {
|
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
|
|
94
|
+
let saveError = null;
|
|
94
95
|
if (opts.saveSite) {
|
|
95
|
-
|
|
96
|
-
|
|
96
|
+
try {
|
|
97
|
+
await writeSavedSite(opts.saveSite, { config, plan, runs, findings });
|
|
98
|
+
log(`\nSaved the site for offline replay in ${opts.saveSite}`);
|
|
99
|
+
} catch (err) {
|
|
100
|
+
// A site.json from an earlier save would no longer match the recordings next to it.
|
|
101
|
+
await rm(path.join(opts.saveSite, SAVED_SITE_FILE), { force: true });
|
|
102
|
+
saveError = err.message;
|
|
103
|
+
log(`\nCould not save the site in ${opts.saveSite}: ${err.message}`);
|
|
104
|
+
}
|
|
97
105
|
}
|
|
98
106
|
let explained = null;
|
|
99
107
|
if (provider) {
|
|
@@ -104,5 +112,5 @@ export async function runAudit(opts) {
|
|
|
104
112
|
const outDir = path.resolve(opts.outDir ?? defaultOutDir());
|
|
105
113
|
const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
|
|
106
114
|
const counts = summarize(groupFindings(findings));
|
|
107
|
-
return { runs, findings, explained, counts, reportPath, outDir, lang };
|
|
115
|
+
return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
|
|
108
116
|
}
|
package/src/browser.js
CHANGED
|
@@ -227,18 +227,41 @@ async function replayTarget(a, locator) {
|
|
|
227
227
|
// Images, fonts and video don't change what tracking sends, and are most of a recording's size.
|
|
228
228
|
const MEDIA = /^(image|font|video|audio)\//;
|
|
229
229
|
|
|
230
|
+
const MEDIA_FILE = /\.(png|jpe?g|gif|webp|avif|svg|ico|bmp|woff2?|ttf|otf|eot|mp4|webm|mov|mp3|m4a|ogg|wav)$/i;
|
|
231
|
+
const isDroppedMedia = (request) =>
|
|
232
|
+
["image", "font", "media"].includes(request.resourceType()) || MEDIA_FILE.test(new URL(request.url()).pathname);
|
|
233
|
+
|
|
234
|
+
// Cookies the server sets for the page's scripts (a country or language, for example) are kept, since
|
|
235
|
+
// sites often need them to render. HttpOnly ones, where sessions and logins usually live, are dropped,
|
|
236
|
+
// and so are ones named or holding like a login. Replay applies only one Set-Cookie header, so the
|
|
237
|
+
// kept ones are joined into one, one cookie per line.
|
|
238
|
+
const LOGIN_COOKIE = /sess|sid|token|auth|jwt|login|csrf|xsrf|remember/i;
|
|
239
|
+
function keptHeaders(headers) {
|
|
240
|
+
const isSetCookie = (h) => /^set-cookie$/i.test(h.name);
|
|
241
|
+
const isPrivate = (cookie) => {
|
|
242
|
+
const [pair] = cookie.split(";");
|
|
243
|
+
const name = pair.slice(0, pair.indexOf("=")).trim();
|
|
244
|
+
return /;\s*httponly\s*(;|$)/i.test(cookie) || LOGIN_COOKIE.test(name) || /@|%40/.test(pair);
|
|
245
|
+
};
|
|
246
|
+
const cookies = headers.filter(isSetCookie).flatMap((h) => h.value.split("\n")).filter((c) => !isPrivate(c));
|
|
247
|
+
return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const CLOSE_TIMEOUT_MS = 30_000;
|
|
251
|
+
|
|
230
252
|
const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
|
|
231
253
|
|
|
232
254
|
/**
|
|
233
255
|
* Drops images, fonts, video and tracking requests from a recording (they are answered locally on replay, and their
|
|
234
|
-
* URLs can hold the user data redacted everywhere else), requests with user data in their address, form bodies,
|
|
235
|
-
* cookies
|
|
256
|
+
* URLs can hold the user data redacted everywhere else), unanswered requests, requests with user data in their address, form bodies,
|
|
257
|
+
* the cookies the browser sent, HttpOnly cookies the server set, and authorization headers.
|
|
236
258
|
*/
|
|
237
|
-
|
|
238
259
|
async function cleanRecording(file) {
|
|
239
260
|
const har = JSON.parse(await readFile(file, "utf8"));
|
|
240
261
|
har.log.entries = har.log.entries
|
|
241
262
|
.filter((e) => !isCollectionEndpoint(e.request.url) && !MEDIA.test(e.response?.content?.mimeType ?? ""))
|
|
263
|
+
// A request that had no answer yet when the browser closed would never finish on replay either.
|
|
264
|
+
.filter((e) => e.response?.status > 0)
|
|
242
265
|
// A request whose address holds user data (an email in a form GET, for example) is left out whole.
|
|
243
266
|
.filter((e) => redactUrl(e.request.url).url === e.request.url)
|
|
244
267
|
.map((e) => {
|
|
@@ -247,7 +270,7 @@ async function cleanRecording(file) {
|
|
|
247
270
|
return {
|
|
248
271
|
...e,
|
|
249
272
|
request: { ...request, cookies: [], headers: request.headers.filter((h) => !SECRET_REQUEST_HEADER.test(h.name)) },
|
|
250
|
-
response: { ...e.response, cookies: [], headers: e.response.headers
|
|
273
|
+
response: { ...e.response, cookies: [], headers: keptHeaders(e.response.headers) },
|
|
251
274
|
};
|
|
252
275
|
});
|
|
253
276
|
await writeFile(file, JSON.stringify(har));
|
|
@@ -262,6 +285,7 @@ export class AuditSession {
|
|
|
262
285
|
* others are still answered locally, but only counted, per step, in this.skipped.
|
|
263
286
|
* site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
|
|
264
287
|
* recordHar: save every response the site sends into this .har file, so the journey can be replayed offline later.
|
|
288
|
+
* closeTimeoutMs: how long close() waits for the recording to be written (default 30 s) before skipping it.
|
|
265
289
|
* replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
|
|
266
290
|
* blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
|
|
267
291
|
* All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
|
|
@@ -376,7 +400,14 @@ export class AuditSession {
|
|
|
376
400
|
// cancelled before its route handler runs, but its request event has already fired.
|
|
377
401
|
this.context.on("request", onRequest);
|
|
378
402
|
// Registered first, so the routes below run before it and tracking requests are still answered locally.
|
|
379
|
-
if (this.opts.replayHar)
|
|
403
|
+
if (this.opts.replayHar) {
|
|
404
|
+
// Requests the recording lacks fail, except the media left out of it: those get an empty answer,
|
|
405
|
+
// since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
|
|
406
|
+
await this.context.route("**/*", (route) =>
|
|
407
|
+
(isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
|
|
408
|
+
);
|
|
409
|
+
await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
|
|
410
|
+
}
|
|
380
411
|
if (this.opts.blockHits) {
|
|
381
412
|
await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
|
|
382
413
|
await route.fulfill({ status: 204, body: "" });
|
|
@@ -658,6 +689,13 @@ export class AuditSession {
|
|
|
658
689
|
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
|
|
659
690
|
*/
|
|
660
691
|
async act(a) {
|
|
692
|
+
const error = await this.perform(a);
|
|
693
|
+
// A scripted replay of this step may fail the same way, which must not end the journey there.
|
|
694
|
+
if (error) this.steps[this.currentStep].error = error;
|
|
695
|
+
return error;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
async perform(a) {
|
|
661
699
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
662
700
|
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
663
701
|
const step = this.steps[this.currentStep];
|
|
@@ -665,9 +703,9 @@ export class AuditSession {
|
|
|
665
703
|
switch (a.action) {
|
|
666
704
|
case "goto": {
|
|
667
705
|
const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
|
|
706
|
+
step.replay = { action: "goto", url: target };
|
|
668
707
|
const refused = await this.checkUrl(target);
|
|
669
708
|
if (refused) return refused;
|
|
670
|
-
step.replay = { action: "goto", url: target };
|
|
671
709
|
await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
672
710
|
break;
|
|
673
711
|
}
|
|
@@ -717,15 +755,46 @@ export class AuditSession {
|
|
|
717
755
|
}
|
|
718
756
|
|
|
719
757
|
async cookies() {
|
|
720
|
-
|
|
758
|
+
let timer;
|
|
759
|
+
const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
|
|
760
|
+
const cookies = await Promise.race([this.context.cookies().catch(() => []), tooLong]);
|
|
761
|
+
clearTimeout(timer);
|
|
762
|
+
return cookies.map((c) => ({ name: c.name, domain: c.domain }));
|
|
721
763
|
}
|
|
722
764
|
|
|
723
765
|
async close() {
|
|
766
|
+
// Leaving the page first cancels what it is still loading (the recording would otherwise wait for it),
|
|
767
|
+
// while tracking requests sent on the way out are still answered locally.
|
|
768
|
+
if (this.opts.recordHar) await this.page?.goto("about:blank", { timeout: 5000 }).catch(() => {});
|
|
769
|
+
// Requests still in flight then get no route callback that would throw once the browser is gone.
|
|
770
|
+
// A page stuck in a script never answers, so this waits at most 5 seconds.
|
|
771
|
+
let unrouteTimer;
|
|
772
|
+
await Promise.race([
|
|
773
|
+
this.context?.unrouteAll({ behavior: "ignoreErrors" }).catch(() => {}),
|
|
774
|
+
new Promise((resolve) => (unrouteTimer = setTimeout(resolve, 5000))),
|
|
775
|
+
]);
|
|
776
|
+
clearTimeout(unrouteTimer);
|
|
724
777
|
if (this.opts.recordHar && this.context) {
|
|
725
|
-
// Writes the recording. If the browser already went away,
|
|
726
|
-
|
|
778
|
+
// Writes the recording. If the browser already went away, or writing it takes too long, there is
|
|
779
|
+
// none to clean: closing the browser below then ends the run's wait.
|
|
780
|
+
let timer;
|
|
781
|
+
const limit = this.opts.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
|
|
782
|
+
const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve("timeout"), limit)));
|
|
783
|
+
const closed = await Promise.race([this.context.close().then(() => "closed", () => "gone"), tooLong]);
|
|
784
|
+
clearTimeout(timer);
|
|
785
|
+
if (closed === "timeout") {
|
|
786
|
+
console.warn(`Writing the recording ${this.opts.recordHar} took over ${Math.round(limit / 1000)} seconds, so it was skipped.`);
|
|
787
|
+
await this.browser?.close().catch(() => {});
|
|
788
|
+
await rm(this.opts.recordHar, { force: true }); // a partly written file
|
|
789
|
+
}
|
|
790
|
+
const written = closed === "closed";
|
|
727
791
|
// A recording that could not be cleaned may hold cookies or form data, so it is deleted.
|
|
728
|
-
if (written)
|
|
792
|
+
if (written) {
|
|
793
|
+
await cleanRecording(this.opts.recordHar).catch(async (err) => {
|
|
794
|
+
console.warn(`Could not clean the recording ${this.opts.recordHar}, so it was deleted: ${err.message}`);
|
|
795
|
+
await rm(this.opts.recordHar, { force: true });
|
|
796
|
+
});
|
|
797
|
+
}
|
|
729
798
|
}
|
|
730
799
|
await this.browser?.close();
|
|
731
800
|
await this.egress?.close();
|
package/src/cli.js
CHANGED
|
@@ -113,7 +113,12 @@ async function main() {
|
|
|
113
113
|
if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
|
|
114
114
|
let changed = false;
|
|
115
115
|
for (const dir of dirs) {
|
|
116
|
-
const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined })
|
|
116
|
+
const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined }).catch((err) => {
|
|
117
|
+
console.log(`✗ ${dir}: could not replay: ${err.message.split("\n")[0]}`);
|
|
118
|
+
changed = true;
|
|
119
|
+
return null;
|
|
120
|
+
});
|
|
121
|
+
if (!r) continue;
|
|
117
122
|
const same = !r.missing.length && !r.extra.length;
|
|
118
123
|
changed ||= !same;
|
|
119
124
|
console.log(`${same ? "✓" : values.update ? "↻" : "✗"} ${r.site} (${dir}): ${same ? "same findings" : `${r.extra.length} new, ${r.missing.length} gone${values.update ? ", saved as expected" : ""}`}`);
|
|
@@ -175,6 +180,7 @@ async function main() {
|
|
|
175
180
|
const threshold = levels.indexOf(failOn);
|
|
176
181
|
if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
|
|
177
182
|
}
|
|
183
|
+
if (result.saveError) process.exitCode = 1;
|
|
178
184
|
}
|
|
179
185
|
|
|
180
186
|
main().catch((err) => {
|
package/src/parsers.js
CHANGED
|
@@ -29,12 +29,19 @@ function ga4Consent(searchParams) {
|
|
|
29
29
|
// Only www.google.<tld> and doubleclick.net: analytics.google.com is a primary GA4 endpoint.
|
|
30
30
|
const GOOGLE_COMPANION_HOST = /^(www\.)?google\.[a-z]{2,3}(\.[a-z]{2})?$|(^|\.)doubleclick\.net$/;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Google tag gateway serves Google's endpoints from a path on the site's own domain, with
|
|
34
|
+
* short names: /<prefix>/ag/g/c or /<prefix>/ga/g/c for GA4's /g/collect. Only accepted with a
|
|
35
|
+
* GA4 measurement id, since "/g/c" alone is too common a path.
|
|
36
|
+
*/
|
|
37
|
+
const isGa4Path = (u) => u.pathname.endsWith("/g/collect") || (/\/g\/c$/.test(u.pathname) && /^G-[A-Z0-9]+$/.test(u.searchParams.get("tid") ?? ""));
|
|
38
|
+
|
|
32
39
|
/** @returns {Hit[] | null} */
|
|
33
40
|
export function parseGa4(url, postData) {
|
|
34
41
|
const u = new URL(url);
|
|
35
42
|
// Matched on the GA4 protocol rather than the host, so server-side GTM
|
|
36
43
|
// endpoints on first-party domains are captured too.
|
|
37
|
-
if (!u
|
|
44
|
+
if (!isGa4Path(u) || u.searchParams.get("v") !== "2") return null;
|
|
38
45
|
// gtag.js resends a hit to www.google.com/g/collect when the first send seems lost, and
|
|
39
46
|
// copies some to doubleclick.net. GA4 doesn't count these twice, so they aren't hits.
|
|
40
47
|
// (Primary hits can carry gaf too, e.g. gaf=2, so the host decides, not the parameter.)
|
|
@@ -74,11 +81,14 @@ export function parseMeta(url) {
|
|
|
74
81
|
/** @returns {Hit[] | null} */
|
|
75
82
|
export function parseGoogleAds(url) {
|
|
76
83
|
const u = new URL(url);
|
|
84
|
+
// Google tag gateway serves /pagead/conversion/<id>/ as /<prefix>/as/p/c/<id>/ on the site's own domain.
|
|
85
|
+
const gateway = u.pathname.match(/\/as\/p\/c\/(\d+)\/?$/);
|
|
77
86
|
const isAds =
|
|
78
87
|
(u.hostname === "www.googleadservices.com" && u.pathname.startsWith("/pagead/conversion/")) ||
|
|
79
|
-
(/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/"))
|
|
88
|
+
(/(^|\.)google\.[a-z.]+$/.test(u.hostname) && u.pathname.startsWith("/pagead/1p-conversion/")) ||
|
|
89
|
+
(gateway && u.searchParams.has("cv"));
|
|
80
90
|
if (!isAds) return null;
|
|
81
|
-
const conversionId = u.pathname.split("/").filter(Boolean)[2];
|
|
91
|
+
const conversionId = gateway ? gateway[1] : u.pathname.split("/").filter(Boolean)[2];
|
|
82
92
|
const params = {};
|
|
83
93
|
for (const key of ["label", "value", "currency_code", "oid"]) {
|
|
84
94
|
if (u.searchParams.has(key)) params[key === "currency_code" ? "currency" : key === "oid" ? "transaction_id" : key] = u.searchParams.get(key);
|
|
@@ -136,7 +146,7 @@ export function parseTrackingRequest(url, postData) {
|
|
|
136
146
|
// hits: Google Ads remarketing and consent pings, and the other common ad pixels.
|
|
137
147
|
const OTHER_BEACONS = [
|
|
138
148
|
[/(^|\.)(doubleclick\.net|googleadservices\.com|google\.[a-z.]+)$/, /^\/pagead\/(viewthroughconversion|1p-user-list|landing)\//],
|
|
139
|
-
[/(^|\.)google\.[a-z.]+$/, /^\/rmkt\/collect
|
|
149
|
+
[/(^|\.)google\.[a-z.]+$/, /^\/(rmkt\/collect\/|measurement\/conversion|ads\/ga-audiences)/],
|
|
140
150
|
[GOOGLE_COMPANION_HOST, /\/g\/collect$/],
|
|
141
151
|
[/(^|\.)merchant-center-analytics\.goog$/, /^\/mc\/collect/],
|
|
142
152
|
// Google tag pings on any host: consent mode (ccm), the Google tag's own /g/s/collect, and
|
|
@@ -217,8 +227,12 @@ export function providerOf(url) {
|
|
|
217
227
|
if (/(^|\.)(doubleclick\.net|google\.[a-z.]+)$/.test(host) && /^\/(pagead|ccm)\//.test(u.pathname)) return of("google_ads");
|
|
218
228
|
if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
|
|
219
229
|
for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
|
|
220
|
-
if (u
|
|
221
|
-
if (u.pathname.startsWith("/pagead/")) return of("google_ads");
|
|
230
|
+
if (isGa4Path(u)) return of("ga4");
|
|
231
|
+
if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
|
|
232
|
+
// Google Tag Manager and gtag.js served from the site's own domain (server-side GTM, Google tag gateway).
|
|
233
|
+
const id = u.searchParams.get("id") ?? "";
|
|
234
|
+
if (/^GTM-[A-Z0-9]+$/.test(id)) return of("gtm");
|
|
235
|
+
if (/\/gtag\/js$/.test(u.pathname) && id.startsWith("G-")) return of("ga4");
|
|
222
236
|
return null;
|
|
223
237
|
}
|
|
224
238
|
|
package/src/replay.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Saved sites: the responses a site sent during an audit and the steps taken, so a later
|
|
2
2
|
// version of the tool can audit the same pages offline and show what it now reports
|
|
3
3
|
// differently. Replays need no network and no AI model, so they cost nothing.
|
|
4
|
-
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
4
|
+
import { mkdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { harName } from "./agent.js";
|
|
6
7
|
import { runAudit } from "./audit.js";
|
|
7
8
|
|
|
8
9
|
export const SAVED_SITE_FILE = "site.json";
|
|
@@ -15,9 +16,11 @@ export function replayableJourney(run) {
|
|
|
15
16
|
id: run.journeyId,
|
|
16
17
|
viewport: run.viewport,
|
|
17
18
|
...(start ? { start } : {}),
|
|
19
|
+
// A journey the AI could not finish (no cart, a bot check) is still incomplete when its steps replay.
|
|
20
|
+
...(run.completed ? {} : { incomplete: run.note }),
|
|
18
21
|
steps: (start ? steps.slice(1) : steps)
|
|
19
22
|
.filter((s) => s.replay)
|
|
20
|
-
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}) })),
|
|
23
|
+
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}), ...(s.error ? { optional: true } : {}) })),
|
|
21
24
|
};
|
|
22
25
|
}
|
|
23
26
|
|
|
@@ -41,6 +44,12 @@ export function compareKeys(expected, actual) {
|
|
|
41
44
|
/** Writes site.json next to the recorded responses. The findings of this run become the expected ones. */
|
|
42
45
|
export async function writeSavedSite(dir, { config, plan, runs, findings }) {
|
|
43
46
|
await mkdir(dir, { recursive: true });
|
|
47
|
+
for (const run of runs) {
|
|
48
|
+
const har = path.join(dir, harName(run.journeyId, run.viewport));
|
|
49
|
+
if (!(await stat(har).catch(() => null))) {
|
|
50
|
+
throw new Error(`The recording of ${run.journeyId} (${run.viewport}) is missing, probably because the browser closed early. Save the site again.`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
44
53
|
const { journeys: _journeys, plan: _plan, gtm_container: _gtm, ...site } = config;
|
|
45
54
|
const saved = {
|
|
46
55
|
version: 1,
|
|
@@ -86,7 +95,9 @@ export async function replaySavedSite(dir, { update = false, outDir, log = () =>
|
|
|
86
95
|
});
|
|
87
96
|
findings.push(...result.findings);
|
|
88
97
|
reportPaths.push(result.reportPath);
|
|
89
|
-
|
|
98
|
+
// Journeys that were already unfinished when saved are compared through their findings only.
|
|
99
|
+
const wasIncomplete = (r) => journeys.find((j) => j.id === r.journeyId)?.incomplete === r.note;
|
|
100
|
+
incomplete.push(...result.runs.filter((r) => !r.completed && !wasIncomplete(r)).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
|
|
90
101
|
}
|
|
91
102
|
const actual = findingKeys(findings);
|
|
92
103
|
const { missing, extra } = compareKeys(saved.expected ?? [], actual);
|