@analitikisler/ai-tag-debugger 0.7.1 → 0.7.3
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 +2 -2
- package/README.tr.md +2 -2
- package/package.json +1 -1
- package/src/agent.js +6 -4
- package/src/audit.js +14 -7
- package/src/browser.js +74 -8
- package/src/cli.js +1 -0
- package/src/parsers.js +20 -6
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.3 (2026-10-13)
|
|
4
|
+
|
|
5
|
+
- `replay` no longer crashes with `NavigationAbortedError: net::ERR_BLOCKED_BY_CLIENT` when a saved page redirects to another site. The redirect is refused again, so the journey stays unfinished as it was saved, and the other sites are still replayed.
|
|
6
|
+
- Tracking hits sent while the browser closes are answered locally again on normal runs. In 0.7.2 the routes were removed before closing, so a hit sent at that moment could reach the real account. They are now removed only on `replay`, which never goes online.
|
|
7
|
+
|
|
8
|
+
## 0.7.2 (2026-10-13)
|
|
9
|
+
|
|
10
|
+
- **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-…`).
|
|
11
|
+
- Google Tag Manager and gtag.js loaded from the site's own domain are labelled as such in the network timeline.
|
|
12
|
+
- Two more Google pings are answered locally instead of reaching Google: `www.google.com/measurement/conversion` and `google.<tld>/ads/ga-audiences`.
|
|
13
|
+
- 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.
|
|
14
|
+
- 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.
|
|
15
|
+
- `replay` no longer crashes with "Target page, context or browser has been closed" when a request is still being answered as the browser closes.
|
|
16
|
+
- 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.
|
|
17
|
+
- 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.
|
|
18
|
+
|
|
3
19
|
## 0.7.1 (2026-10-13)
|
|
4
20
|
|
|
5
21
|
- 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.
|
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,
|
|
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ı, oturum ç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.
|
|
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.3",
|
|
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
|
@@ -164,15 +164,15 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
164
164
|
/** Replays fixed steps from the config, without calling an AI model. */
|
|
165
165
|
export async function runScriptedJourney(session, journey, opts = {}) {
|
|
166
166
|
const log = opts.log ?? (() => {});
|
|
167
|
-
if (!journey.steps?.length) throw new Error(`Journey "${journey.id}" has no "steps" for --scripted mode.`);
|
|
168
|
-
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 ?? []) {
|
|
169
169
|
log(` ${step.reason ?? `${step.action} ${step.selector ?? step.url ?? ""}`}`);
|
|
170
170
|
const error = await session.act(step);
|
|
171
171
|
if (error && !step.optional) return { completed: false, note: `Step "${step.reason ?? step.action}" failed: ${error}` };
|
|
172
172
|
}
|
|
173
173
|
// Tags often send a moment after the last click (GA4 batches events). An AI run gives them that time
|
|
174
174
|
// while the model answers; a scripted run would otherwise close the browser first.
|
|
175
|
-
await session.page.waitForTimeout(LAST_STEP_WAIT_MS);
|
|
175
|
+
await session.page.waitForTimeout(LAST_STEP_WAIT_MS).catch(() => {});
|
|
176
176
|
if (journey.incomplete) return { completed: false, note: journey.incomplete };
|
|
177
177
|
return { completed: true, note: "All scripted steps completed." };
|
|
178
178
|
}
|
|
@@ -194,7 +194,8 @@ export async function runJourney(config, journey, viewport, opts = {}) {
|
|
|
194
194
|
await session.start();
|
|
195
195
|
const startUrl = new URL(journey.start ?? "/", config.site).href;
|
|
196
196
|
const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
|
|
197
|
-
|
|
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}` };
|
|
198
199
|
|
|
199
200
|
const outcome = await (opts.scripted ? runScriptedJourney(session, journey, opts) : runAgentJourney(session, journey, opts)).catch((err) => {
|
|
200
201
|
if (err instanceof StepTimeout) return { completed: false, note: err.message };
|
|
@@ -202,6 +203,7 @@ export async function runJourney(config, journey, viewport, opts = {}) {
|
|
|
202
203
|
});
|
|
203
204
|
return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
|
|
204
205
|
} finally {
|
|
206
|
+
if (opts.saveSite) opts.log?.(" Writing the recording…");
|
|
205
207
|
await session.close();
|
|
206
208
|
}
|
|
207
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,10 +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
|
-
|
|
97
|
-
|
|
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
|
+
}
|
|
98
105
|
}
|
|
99
106
|
let explained = null;
|
|
100
107
|
if (provider) {
|
|
@@ -105,5 +112,5 @@ export async function runAudit(opts) {
|
|
|
105
112
|
const outDir = path.resolve(opts.outDir ?? defaultOutDir());
|
|
106
113
|
const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
|
|
107
114
|
const counts = summarize(groupFindings(findings));
|
|
108
|
-
return { runs, findings, explained, counts, reportPath, outDir, lang };
|
|
115
|
+
return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
|
|
109
116
|
}
|
package/src/browser.js
CHANGED
|
@@ -232,14 +232,23 @@ const isDroppedMedia = (request) =>
|
|
|
232
232
|
["image", "font", "media"].includes(request.resourceType()) || MEDIA_FILE.test(new URL(request.url()).pathname);
|
|
233
233
|
|
|
234
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
|
|
236
|
-
//
|
|
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;
|
|
237
239
|
function keptHeaders(headers) {
|
|
238
240
|
const isSetCookie = (h) => /^set-cookie$/i.test(h.name);
|
|
239
|
-
const
|
|
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));
|
|
240
247
|
return [...headers.filter((h) => !isSetCookie(h)), ...(cookies.length ? [{ name: "Set-Cookie", value: cookies.join("\n") }] : [])];
|
|
241
248
|
}
|
|
242
249
|
|
|
250
|
+
const CLOSE_TIMEOUT_MS = 30_000;
|
|
251
|
+
|
|
243
252
|
const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
|
|
244
253
|
|
|
245
254
|
/**
|
|
@@ -276,6 +285,7 @@ export class AuditSession {
|
|
|
276
285
|
* others are still answered locally, but only counted, per step, in this.skipped.
|
|
277
286
|
* site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
|
|
278
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.
|
|
279
289
|
* replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
|
|
280
290
|
* blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
|
|
281
291
|
* All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
|
|
@@ -312,6 +322,11 @@ export class AuditSession {
|
|
|
312
322
|
launch.proxy = { server: this.egress.url, bypass: "<-loopback>" };
|
|
313
323
|
launch.args = ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"];
|
|
314
324
|
}
|
|
325
|
+
if (this.opts.replayHar) {
|
|
326
|
+
// A replay never goes online: requests the routes below don't answer, including any still in
|
|
327
|
+
// flight once they are removed at the end, go to a proxy that isn't there.
|
|
328
|
+
launch.proxy = { server: "http://127.0.0.1:9", bypass: "<-loopback>" };
|
|
329
|
+
}
|
|
315
330
|
this.browser = await chromium.launch(launch);
|
|
316
331
|
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
317
332
|
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
@@ -394,9 +409,15 @@ export class AuditSession {
|
|
|
394
409
|
// Requests the recording lacks fail, except the media left out of it: those get an empty answer,
|
|
395
410
|
// since a failed image or icon can stop a script (a consent banner waiting for its logo, for example).
|
|
396
411
|
await this.context.route("**/*", (route) =>
|
|
397
|
-
isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort(),
|
|
412
|
+
(isDroppedMedia(route.request()) ? route.fulfill({ status: 200, body: "" }) : route.abort()).catch(() => {}),
|
|
398
413
|
);
|
|
399
414
|
await this.context.routeFromHAR(this.opts.replayHar, { notFound: "fallback" });
|
|
415
|
+
// A saved redirect to a page the browser may not open fails here, as it did when saved. Left to the
|
|
416
|
+
// recording, the redirect would be followed outside any handler, and its failure would end the run.
|
|
417
|
+
const refusedRedirects = await this.refusedRedirects(this.opts.replayHar);
|
|
418
|
+
if (refusedRedirects.size) {
|
|
419
|
+
await this.context.route((url) => refusedRedirects.has(url.href), (route) => route.abort("blockedbyclient").catch(() => {}));
|
|
420
|
+
}
|
|
400
421
|
}
|
|
401
422
|
if (this.opts.blockHits) {
|
|
402
423
|
await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
|
|
@@ -485,6 +506,21 @@ export class AuditSession {
|
|
|
485
506
|
return true;
|
|
486
507
|
}
|
|
487
508
|
|
|
509
|
+
/** Addresses whose saved answer redirects somewhere the browser may not go. */
|
|
510
|
+
async refusedRedirects(file) {
|
|
511
|
+
const refused = new Set();
|
|
512
|
+
const har = JSON.parse(await readFile(file, "utf8"));
|
|
513
|
+
for (const e of har.log.entries) {
|
|
514
|
+
const status = e.response?.status;
|
|
515
|
+
if (status < 300 || status >= 400) continue;
|
|
516
|
+
const location = e.response.redirectURL || e.response.headers?.find((h) => /^location$/i.test(h.name))?.value;
|
|
517
|
+
if (!location) continue;
|
|
518
|
+
const target = new URL(location, e.request.url).href;
|
|
519
|
+
if (await this.checkUrl(target)) refused.add(e.request.url);
|
|
520
|
+
}
|
|
521
|
+
return refused;
|
|
522
|
+
}
|
|
523
|
+
|
|
488
524
|
/** Error message when the browser may not be on this URL, or null. */
|
|
489
525
|
checkUrl(url) {
|
|
490
526
|
if (url.startsWith("chrome-error:")) return null; // Chromium's own error page, e.g. after a blocked navigation
|
|
@@ -693,9 +729,9 @@ export class AuditSession {
|
|
|
693
729
|
switch (a.action) {
|
|
694
730
|
case "goto": {
|
|
695
731
|
const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
|
|
732
|
+
step.replay = { action: "goto", url: target };
|
|
696
733
|
const refused = await this.checkUrl(target);
|
|
697
734
|
if (refused) return refused;
|
|
698
|
-
step.replay = { action: "goto", url: target };
|
|
699
735
|
await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
700
736
|
break;
|
|
701
737
|
}
|
|
@@ -745,13 +781,43 @@ export class AuditSession {
|
|
|
745
781
|
}
|
|
746
782
|
|
|
747
783
|
async cookies() {
|
|
748
|
-
|
|
784
|
+
let timer;
|
|
785
|
+
const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve([]), 10_000)));
|
|
786
|
+
const cookies = await Promise.race([this.context.cookies().catch(() => []), tooLong]);
|
|
787
|
+
clearTimeout(timer);
|
|
788
|
+
return cookies.map((c) => ({ name: c.name, domain: c.domain }));
|
|
749
789
|
}
|
|
750
790
|
|
|
751
791
|
async close() {
|
|
792
|
+
// Leaving the page first cancels what it is still loading (the recording would otherwise wait for it),
|
|
793
|
+
// while tracking requests sent on the way out are still answered locally.
|
|
794
|
+
if (this.opts.recordHar) await this.page?.goto("about:blank", { timeout: 5000 }).catch(() => {});
|
|
795
|
+
if (this.opts.replayHar) {
|
|
796
|
+
// The recording's own route callbacks would otherwise throw for requests still in flight once the
|
|
797
|
+
// browser is gone. Without routes, those go nowhere (see start). Elsewhere the routes stay, so a
|
|
798
|
+
// hit sent while the browser closes is still answered locally instead of reaching the real account.
|
|
799
|
+
// A page stuck in a script never answers, so this waits at most 5 seconds.
|
|
800
|
+
let unrouteTimer;
|
|
801
|
+
await Promise.race([
|
|
802
|
+
this.context?.unrouteAll({ behavior: "ignoreErrors" }).catch(() => {}),
|
|
803
|
+
new Promise((resolve) => (unrouteTimer = setTimeout(resolve, 5000))),
|
|
804
|
+
]);
|
|
805
|
+
clearTimeout(unrouteTimer);
|
|
806
|
+
}
|
|
752
807
|
if (this.opts.recordHar && this.context) {
|
|
753
|
-
// Writes the recording. If the browser already went away,
|
|
754
|
-
|
|
808
|
+
// Writes the recording. If the browser already went away, or writing it takes too long, there is
|
|
809
|
+
// none to clean: closing the browser below then ends the run's wait.
|
|
810
|
+
let timer;
|
|
811
|
+
const limit = this.opts.closeTimeoutMs ?? CLOSE_TIMEOUT_MS;
|
|
812
|
+
const tooLong = new Promise((resolve) => (timer = setTimeout(() => resolve("timeout"), limit)));
|
|
813
|
+
const closed = await Promise.race([this.context.close().then(() => "closed", () => "gone"), tooLong]);
|
|
814
|
+
clearTimeout(timer);
|
|
815
|
+
if (closed === "timeout") {
|
|
816
|
+
console.warn(`Writing the recording ${this.opts.recordHar} took over ${Math.round(limit / 1000)} seconds, so it was skipped.`);
|
|
817
|
+
await this.browser?.close().catch(() => {});
|
|
818
|
+
await rm(this.opts.recordHar, { force: true }); // a partly written file
|
|
819
|
+
}
|
|
820
|
+
const written = closed === "closed";
|
|
755
821
|
// A recording that could not be cleaned may hold cookies or form data, so it is deleted.
|
|
756
822
|
if (written) {
|
|
757
823
|
await cleanRecording(this.opts.recordHar).catch(async (err) => {
|
package/src/cli.js
CHANGED
|
@@ -180,6 +180,7 @@ async function main() {
|
|
|
180
180
|
const threshold = levels.indexOf(failOn);
|
|
181
181
|
if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
|
|
182
182
|
}
|
|
183
|
+
if (result.saveError) process.exitCode = 1;
|
|
183
184
|
}
|
|
184
185
|
|
|
185
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
|
|