@analitikisler/ai-tag-debugger 0.6.2 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +16 -1
- package/README.tr.md +16 -1
- package/package.json +1 -1
- package/src/agent.js +29 -6
- package/src/audit.js +10 -2
- package/src/browser.js +87 -5
- package/src/checks.js +21 -2
- package/src/cli.js +28 -0
- package/src/mcp.js +1 -1
- package/src/parsers.js +9 -4
- package/src/redact.js +58 -19
- package/src/replay.js +97 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.7.0 (2026-10-12)
|
|
4
|
+
|
|
5
|
+
Fixes from the first audit run across 18 real shops.
|
|
6
|
+
|
|
7
|
+
- **GA4 hits with a `gaf` parameter were missed and sent to the real GA4 property** (since 0.6.2). Some sites send `gaf=2` on every normal hit, so `view_item`, `add_to_cart` and others were reported as missing, and the test visits reached the site's analytics. Only copies to `www.google.com` and `doubleclick.net` are now left out, whatever their parameters, and every GA4 request is answered locally again.
|
|
8
|
+
- More Google tag pings are answered locally instead of reaching the real accounts: Google Ads remarketing (`www.google.com/rmkt/collect`), Merchant Center (`merchant-center-analytics.goog/mc/collect`), the Google tag's `/g/s/collect`, and consent mode pings on any host, including Google tag gateway paths on the site's own domain (`/<id>/gs/ccm/collect`).
|
|
9
|
+
- One browsing step can no longer stall a run: a model call that doesn't answer within 3 minutes, or a browser action that doesn't finish within 2, ends the journey as incomplete with a note.
|
|
10
|
+
- **Saved sites and `replay`.** `run --save-site <dir>` also saves every response the site sent (as a Playwright HAR file per journey and viewport, without tracking requests, cookies sent or set, authorization headers, form bodies, or requests with user data in their address) and the steps taken. `replay <dir>...` audits those pages again offline, with no network and no AI model, and lists findings that are new or gone. It exits with code 1 when any changed; `--update` makes the new findings the expected ones. Steps the AI took by ref are saved as CSS paths, or as visible text inside iframes. Images, fonts and video are left out of the recording; when the browser closed early, there is no recording to clean instead of an error.
|
|
11
|
+
|
|
12
|
+
## 0.6.3 (2026-10-12)
|
|
13
|
+
|
|
14
|
+
- The audited site's own email address in a `mailto:` link or a link field (`link_url`, `link_text`, `gtm.elementUrl`, `gtm.elementText`), such as the contact link in a GTM `gtm.linkClick` push, is no longer reported as unhashed user data or redacted. An own-domain address anywhere else, and every named user data field (`email`, `user_data`, `em`, …), is still redacted and reported.
|
|
15
|
+
|
|
3
16
|
## 0.6.2 (2026-10-12)
|
|
4
17
|
|
|
5
18
|
Fixes from Bahadır's audit of analitikisler.com.
|
package/README.md
CHANGED
|
@@ -169,6 +169,19 @@ Each browsing step is one model call, so usage grows with **journeys × viewport
|
|
|
169
169
|
- **Replay without AI.** After one AI run, copy the steps into `journeys[].steps` and use `--scripted` (with `--no-ai` for zero model calls) on every deploy.
|
|
170
170
|
- **Use a chat app.** Over MCP, the chat app's own model does the browsing, so no API key is used at all.
|
|
171
171
|
|
|
172
|
+
### Check a new version against saved sites
|
|
173
|
+
|
|
174
|
+
`--save-site <dir>` also saves every response the site sent and the steps taken. `replay` then audits the same pages again offline, with no network and no AI model, so it costs nothing. It lists findings that are new or gone compared with the saved run:
|
|
175
|
+
|
|
176
|
+
```bash
|
|
177
|
+
npx @analitikisler/ai-tag-debugger run --site https://www.yourshop.com --journey purchase --save-site saved/yourshop
|
|
178
|
+
npx @analitikisler/ai-tag-debugger replay saved/*
|
|
179
|
+
✗ https://www.yourshop.com (saved/yourshop): 0 new, 1 gone
|
|
180
|
+
- warning duplicate purchase desktop ga4 page_view
|
|
181
|
+
```
|
|
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.
|
|
184
|
+
|
|
172
185
|
## Configure your site
|
|
173
186
|
|
|
174
187
|
`ai-tag-debugger.config.json`:
|
|
@@ -229,6 +242,8 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
|
|
|
229
242
|
[--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
|
|
230
243
|
[--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
|
|
231
244
|
[--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
|
|
245
|
+
[--save-site <dir>]
|
|
246
|
+
ai-tag-debugger replay <dir>... [--update] [--out <dir>]
|
|
232
247
|
ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
|
|
233
248
|
[--allow-private-network]
|
|
234
249
|
```
|
|
@@ -316,7 +331,7 @@ A hosted server, so you don't need a tunnel, is part of the [Analitik İşler](h
|
|
|
316
331
|
- The browser stays on the audited site and its subdomains, plus any `allowed_hosts`. It opens only `http` and `https` pages.
|
|
317
332
|
- The model is told that page content is data, never instructions. A prompt rule can't guarantee a model is never misled, so the navigation limits above contain what a misled model can do.
|
|
318
333
|
- The browser runs without your API keys and other secrets in its environment.
|
|
319
|
-
- **User data sent for ad matching is redacted** before it is stored in `capture.json` or shown in the report: emails, phone numbers, names, addresses and user ids in GA4 and Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user`, and the `user_data` the site pushes to the dataLayer for enhanced conversions. Any value that looks like an email address is redacted whatever its field is called. Each value becomes a marker such as `[redacted: 64 chars, looks hashed]`, and a value that wasn't hashed is reported as a personal data risk. `--keep-bodies` (`"keep_bodies": true`) stores requests as sent, for debugging.
|
|
334
|
+
- **User data sent for ad matching is redacted** before it is stored in `capture.json` or shown in the report: emails, phone numbers, names, addresses and user ids in GA4 and Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user`, and the `user_data` the site pushes to the dataLayer for enhanced conversions. Any value that looks like an email address is redacted whatever its field is called, except the audited site's own address in a `mailto:` link or a link field (`link_url`, `link_text`, `gtm.elementUrl`, …), such as a contact link in a GTM click event, which is not visitor data. Each value becomes a marker such as `[redacted: 64 chars, looks hashed]`, and a value that wasn't hashed is reported as a personal data risk. `--keep-bodies` (`"keep_bodies": true`) stores requests as sent, for debugging.
|
|
320
335
|
- Stored request bodies are capped at 32 KB per request and 2 MB per journey. Past that, the report says the body wasn't stored.
|
|
321
336
|
- Only run it against sites you own or are authorized to test.
|
|
322
337
|
- Hosting the HTTP server for other people? Also block private and cloud metadata address ranges at the network level (an egress firewall or an isolated network), as defense in depth.
|
package/README.tr.md
CHANGED
|
@@ -171,6 +171,19 @@ Her gezinme adımı bir model çağrısıdır. Yani kullanım **senaryo × ekran
|
|
|
171
171
|
- **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra adımları `journeys[].steps` içine kopyalayın ve her deploy'da `--scripted` (hiç model çağrısı olmaması için `--no-ai` ile birlikte) kullanın.
|
|
172
172
|
- **Bir yapay zekâ asistanı kullanın.** MCP üzerinden gezinmeyi asistanın kendi modeli yapar, hiç API anahtarı kullanılmaz.
|
|
173
173
|
|
|
174
|
+
### Yeni bir sürümü kaydedilmiş sitelerle karşılaştırın
|
|
175
|
+
|
|
176
|
+
`--save-site <klasör>`, sitenin gönderdiği her yanıtı ve atılan adımları da kaydeder. `replay` aynı sayfaları ağ ve yapay zekâ modeli olmadan, çevrimdışı olarak yeniden denetler; bu yüzden hiçbir maliyeti yoktur. Kaydedilen çalıştırmaya göre yeni çıkan ya da kaybolan bulguları listeler:
|
|
177
|
+
|
|
178
|
+
```bash
|
|
179
|
+
npx @analitikisler/ai-tag-debugger run --site https://www.magazaniz.com --journey purchase --save-site saved/magazaniz
|
|
180
|
+
npx @analitikisler/ai-tag-debugger replay saved/*
|
|
181
|
+
✗ https://www.magazaniz.com (saved/magazaniz): 0 new, 1 gone
|
|
182
|
+
- warning duplicate purchase desktop ga4 page_view
|
|
183
|
+
```
|
|
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.
|
|
186
|
+
|
|
174
187
|
## Sitenizi ayarlayın
|
|
175
188
|
|
|
176
189
|
`ai-tag-debugger.config.json`:
|
|
@@ -231,6 +244,8 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
|
|
|
231
244
|
[--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
|
|
232
245
|
[--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
|
|
233
246
|
[--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
|
|
247
|
+
[--save-site <dir>]
|
|
248
|
+
ai-tag-debugger replay <dir>... [--update] [--out <dir>]
|
|
234
249
|
ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
|
|
235
250
|
[--allow-private-network]
|
|
236
251
|
```
|
|
@@ -318,7 +333,7 @@ Tünele gerek kalmayan, hazır bir adresle barındırılan sunucu, [Analitik İ
|
|
|
318
333
|
- Tarayıcı test edilen sitede ve alt alan adlarında, ayrıca `allowed_hosts` içindeki adreslerde kalır. Sadece `http` ve `https` sayfalarını açar.
|
|
319
334
|
- Modele, sayfa içeriğinin talimat değil veri olduğu söylenir. Bir prompt kuralı, modelin hiçbir zaman yanıltılmayacağını garanti edemez; bu yüzden yukarıdaki gezinme sınırları, yanıltılmış bir modelin yapabileceklerini kısıtlar.
|
|
320
335
|
- Tarayıcı, ortamında API anahtarlarınız ve diğer gizli bilgiler olmadan çalışır.
|
|
321
|
-
- **Reklam eşleştirmesi için gönderilen kullanıcı verileri**, `capture.json` dosyasına kaydedilmeden ya da raporda gösterilmeden önce **gizlenir**: GA4 ve Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user` içindeki e-postalar, telefon numaraları, adlar, adresler ve kullanıcı kimlikleri ile sitenin enhanced conversions için dataLayer'a gönderdiği `user_data`. E-posta adresine benzeyen her değer, alanın adı ne olursa olsun gizlenir. Her değer `[redacted: 64 chars, looks hashed]` gibi bir işarete dönüşür; hash'lenmemiş bir değer kişisel veri riski olarak raporlanır. `--keep-bodies` (`"keep_bodies": true`) hata ayıklama için istekleri gönderildiği haliyle saklar.
|
|
336
|
+
- **Reklam eşleştirmesi için gönderilen kullanıcı verileri**, `capture.json` dosyasına kaydedilmeden ya da raporda gösterilmeden önce **gizlenir**: GA4 ve Google Ads enhanced conversions (`em`, `ph`, `uid`, …), Meta advanced matching (`ud[*]`, `udff[*]`), TikTok `context.user` içindeki e-postalar, telefon numaraları, adlar, adresler ve kullanıcı kimlikleri ile sitenin enhanced conversions için dataLayer'a gönderdiği `user_data`. E-posta adresine benzeyen her değer, alanın adı ne olursa olsun gizlenir. Denetlenen sitenin kendi adresi bir `mailto:` bağlantısında ya da bir bağlantı alanında (`link_url`, `link_text`, `gtm.elementUrl`, …) geçiyorsa, örneğin bir GTM tıklama olayındaki iletişim bağlantısında, ziyaretçi verisi olmadığı için bu kuralın dışında kalır. Her değer `[redacted: 64 chars, looks hashed]` gibi bir işarete dönüşür; hash'lenmemiş bir değer kişisel veri riski olarak raporlanır. `--keep-bodies` (`"keep_bodies": true`) hata ayıklama için istekleri gönderildiği haliyle saklar.
|
|
322
337
|
- Saklanan istek gövdeleri istek başına 32 KB ve senaryo başına 2 MB ile sınırlıdır. Bu sınırın üstünde rapor, gövdenin saklanmadığını belirtir.
|
|
323
338
|
- Sadece size ait ya da test etme yetkiniz olan sitelerde çalıştırın.
|
|
324
339
|
- HTTP sunucusunu başkaları için mi barındırıyorsunuz? Ek bir güvenlik katmanı olarak özel ağ ve bulut metadata adres aralıklarını ağ seviyesinde de engelleyin (bir egress firewall ya da izole bir ağ ile).
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@analitikisler/ai-tag-debugger",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.7.0",
|
|
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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import path from "node:path";
|
|
1
2
|
import { AuditSession } from "./browser.js";
|
|
2
3
|
import { messages } from "./i18n.js";
|
|
3
4
|
import { createProvider, PROVIDERS } from "./providers.js";
|
|
@@ -87,6 +88,19 @@ export function validateAction(input) {
|
|
|
87
88
|
return null;
|
|
88
89
|
}
|
|
89
90
|
|
|
91
|
+
// One hung page or model request must not stall a whole run.
|
|
92
|
+
const MODEL_TIMEOUT_MS = 180_000;
|
|
93
|
+
const ACTION_TIMEOUT_MS = 120_000;
|
|
94
|
+
|
|
95
|
+
class StepTimeout extends Error {}
|
|
96
|
+
|
|
97
|
+
/** The promise's value, or a StepTimeout with this message once ms have passed. */
|
|
98
|
+
function withTimeout(promise, ms, message) {
|
|
99
|
+
let timer;
|
|
100
|
+
const timeout = new Promise((_, reject) => (timer = setTimeout(() => reject(new StepTimeout(message)), ms)));
|
|
101
|
+
return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
|
|
102
|
+
}
|
|
103
|
+
|
|
90
104
|
/**
|
|
91
105
|
* Lets the AI model drive the browser through one journey.
|
|
92
106
|
* @param {AuditSession} session
|
|
@@ -106,7 +120,7 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
106
120
|
});
|
|
107
121
|
|
|
108
122
|
for (let turn = 0; turn < maxSteps; turn++) {
|
|
109
|
-
const { toolCalls, stop } = await chat.next();
|
|
123
|
+
const { toolCalls, stop } = await withTimeout(chat.next(), MODEL_TIMEOUT_MS, "The model did not answer within 3 minutes.");
|
|
110
124
|
if (stop === "refusal") return { completed: false, note: "The model declined to continue this journey." };
|
|
111
125
|
if (toolCalls.length === 0) {
|
|
112
126
|
if (stop === "max_tokens") return { completed: false, note: "The model response was cut off." };
|
|
@@ -132,7 +146,7 @@ export async function runAgentJourney(session, journey, opts = {}) {
|
|
|
132
146
|
continue;
|
|
133
147
|
}
|
|
134
148
|
log(` ${call.input.reason}`);
|
|
135
|
-
const error = await session.act(call.input);
|
|
149
|
+
const error = await withTimeout(session.act(call.input), ACTION_TIMEOUT_MS, "The browser action did not finish within 2 minutes.");
|
|
136
150
|
results.push({ id: call.id, content: pageView(await session.snapshot(), error) });
|
|
137
151
|
}
|
|
138
152
|
if (finished) return finished;
|
|
@@ -153,20 +167,29 @@ export async function runScriptedJourney(session, journey, opts = {}) {
|
|
|
153
167
|
return { completed: true, note: "All scripted steps completed." };
|
|
154
168
|
}
|
|
155
169
|
|
|
170
|
+
/** The file a saved site keeps one journey's recorded responses in. */
|
|
171
|
+
export const harName = (journeyId, viewport) => `${String(journeyId).replace(/[^\w.-]+/g, "_")}-${viewport}.har`;
|
|
172
|
+
|
|
156
173
|
/**
|
|
157
174
|
* Runs one journey on one viewport and returns everything the checks need.
|
|
175
|
+
* opts.saveSite records the site's responses into that folder; opts.replaySite answers from one.
|
|
158
176
|
*/
|
|
159
177
|
export async function runJourney(config, journey, viewport, opts = {}) {
|
|
160
|
-
const
|
|
178
|
+
const har = (dir) => (dir ? path.join(dir, harName(journey.id, viewport)) : undefined);
|
|
179
|
+
const session = new AuditSession({
|
|
180
|
+
viewport, site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers, keepBodies: config.keep_bodies === true,
|
|
181
|
+
blockHits: config.block_hits !== false, headless: !opts.headed, recordHar: har(opts.saveSite), replayHar: har(opts.replaySite),
|
|
182
|
+
});
|
|
161
183
|
try {
|
|
162
184
|
await session.start();
|
|
163
185
|
const startUrl = new URL(journey.start ?? "/", config.site).href;
|
|
164
186
|
const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
|
|
165
187
|
if (startError) return { ...collect(session, journey, viewport), completed: false, note: `Could not open the start page: ${startError}` };
|
|
166
188
|
|
|
167
|
-
const outcome = opts.scripted
|
|
168
|
-
|
|
169
|
-
|
|
189
|
+
const outcome = await (opts.scripted ? runScriptedJourney(session, journey, opts) : runAgentJourney(session, journey, opts)).catch((err) => {
|
|
190
|
+
if (err instanceof StepTimeout) return { completed: false, note: err.message };
|
|
191
|
+
throw err;
|
|
192
|
+
});
|
|
170
193
|
return { ...collect(session, journey, viewport), ...outcome, cookies: await session.cookies() };
|
|
171
194
|
} finally {
|
|
172
195
|
await session.close();
|
package/src/audit.js
CHANGED
|
@@ -9,6 +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
13
|
|
|
13
14
|
export async function readJson(file, baseDir = ".") {
|
|
14
15
|
const full = path.resolve(baseDir, file);
|
|
@@ -48,10 +49,12 @@ export async function writeReports({ outDir, config, plan, runs, findings, expla
|
|
|
48
49
|
/**
|
|
49
50
|
* @param {{ config: any, plan?: any, gtmContainer?: any, journeys?: string[], goals?: string[], events?: string[], viewports?: string[],
|
|
50
51
|
* scripted?: boolean, ai?: boolean, provider?: string, model?: string, baseUrl?: string, maxSteps?: number, lang?: string, headed?: boolean,
|
|
51
|
-
* outDir?: string, branding?: boolean, providers?: string[], log?: (m: string) => void }} opts
|
|
52
|
+
* outDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
|
|
52
53
|
* journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
|
|
53
54
|
* goals: extra journeys described in plain language. events: only check these events.
|
|
54
55
|
* providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok).
|
|
56
|
+
* saveSite: record the site's responses and the steps taken into this folder, for replaySite (see replay.js).
|
|
57
|
+
* replaySite: answer every request from a folder saved with saveSite, without the network.
|
|
55
58
|
*/
|
|
56
59
|
export async function runAudit(opts) {
|
|
57
60
|
const { gtmContainer } = opts;
|
|
@@ -76,17 +79,22 @@ export async function runAudit(opts) {
|
|
|
76
79
|
const provider = useAi ? await createProvider({ name: opts.provider ?? config.provider, model: opts.model ?? config.model, baseUrl: opts.baseUrl ?? config.base_url }) : null;
|
|
77
80
|
if (provider) log(`Using ${provider.name} (${provider.model})`);
|
|
78
81
|
|
|
82
|
+
if (opts.saveSite) await mkdir(opts.saveSite, { recursive: true });
|
|
79
83
|
const runs = [];
|
|
80
84
|
for (const journey of journeys) {
|
|
81
85
|
for (const viewport of viewports) {
|
|
82
86
|
log(`\n▶ ${journey.id} (${viewport})`);
|
|
83
|
-
const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, maxSteps: opts.maxSteps, headed: opts.headed, lang, log });
|
|
87
|
+
const run = await runJourney(config, journey, viewport, { scripted: opts.scripted, provider, maxSteps: opts.maxSteps, headed: opts.headed, lang, log, saveSite: opts.saveSite, replaySite: opts.replaySite });
|
|
84
88
|
log(` ${run.completed ? "✓" : "✗"} ${run.note} · ${run.hits.length} tracking hits captured`);
|
|
85
89
|
runs.push(run);
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
89
93
|
const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
|
|
94
|
+
if (opts.saveSite) {
|
|
95
|
+
await writeSavedSite(opts.saveSite, { config, plan, runs, findings });
|
|
96
|
+
log(`\nSaved the site for offline replay in ${opts.saveSite}`);
|
|
97
|
+
}
|
|
90
98
|
let explained = null;
|
|
91
99
|
if (provider) {
|
|
92
100
|
log("\nAsking the model to explain the findings…");
|
package/src/browser.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { readFile, rm, writeFile } from "node:fs/promises";
|
|
1
2
|
import { chromium, devices } from "playwright";
|
|
2
3
|
import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders } from "./parsers.js";
|
|
3
4
|
import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
|
|
@@ -22,6 +23,14 @@ export const VIEWPORTS = {
|
|
|
22
23
|
// log survives page navigations.
|
|
23
24
|
/** A page address without its hash, for matching page_location to navigations. */
|
|
24
25
|
const pageKey = (url) => String(url ?? "").split("#")[0];
|
|
26
|
+
/** The host of a URL, or null. */
|
|
27
|
+
const hostOf = (url) => {
|
|
28
|
+
try {
|
|
29
|
+
return new URL(url).hostname || null;
|
|
30
|
+
} catch {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
};
|
|
25
34
|
|
|
26
35
|
const INIT_SCRIPT = `(() => {
|
|
27
36
|
const record = (entry) => {
|
|
@@ -188,6 +197,62 @@ function trackingBody(request) {
|
|
|
188
197
|
return body ? (body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body) : null;
|
|
189
198
|
}
|
|
190
199
|
|
|
200
|
+
/**
|
|
201
|
+
* How a scripted run can find the element an action used again: the selector it was given,
|
|
202
|
+
* else a CSS path to the element, else its visible text. Refs only mean something in one run.
|
|
203
|
+
*/
|
|
204
|
+
async function replayTarget(a, locator) {
|
|
205
|
+
if (a.selector) return { selector: a.selector };
|
|
206
|
+
const found = await locator
|
|
207
|
+
.evaluate((el) => {
|
|
208
|
+
const text = (el.innerText || el.value || el.getAttribute("aria-label") || "").replace(/\s+/g, " ").trim().slice(0, 80);
|
|
209
|
+
if (window !== window.top) return { text };
|
|
210
|
+
const parts = [];
|
|
211
|
+
for (let n = el; n && n !== document.documentElement; n = n.parentElement) {
|
|
212
|
+
if (n.id && document.querySelectorAll(`#${CSS.escape(n.id)}`).length === 1) {
|
|
213
|
+
parts.unshift(`#${CSS.escape(n.id)}`);
|
|
214
|
+
break;
|
|
215
|
+
}
|
|
216
|
+
let i = 1;
|
|
217
|
+
for (let s = n.previousElementSibling; s; s = s.previousElementSibling) if (s.tagName === n.tagName) i++;
|
|
218
|
+
parts.unshift(`${n.tagName.toLowerCase()}:nth-of-type(${i})`);
|
|
219
|
+
}
|
|
220
|
+
return { selector: parts.join(" > "), text };
|
|
221
|
+
}, null, { timeout: 5000 })
|
|
222
|
+
.catch(() => null);
|
|
223
|
+
if (found?.selector) return { selector: found.selector };
|
|
224
|
+
return found?.text ? { text: found.text } : a.text ? { text: a.text } : {};
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Images, fonts and video don't change what tracking sends, and are most of a recording's size.
|
|
228
|
+
const MEDIA = /^(image|font|video|audio)\//;
|
|
229
|
+
|
|
230
|
+
const SECRET_REQUEST_HEADER = /^(cookie|authorization|proxy-authorization)$/i;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* 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 both ways and authorization headers.
|
|
236
|
+
*/
|
|
237
|
+
|
|
238
|
+
async function cleanRecording(file) {
|
|
239
|
+
const har = JSON.parse(await readFile(file, "utf8"));
|
|
240
|
+
har.log.entries = har.log.entries
|
|
241
|
+
.filter((e) => !isCollectionEndpoint(e.request.url) && !MEDIA.test(e.response?.content?.mimeType ?? ""))
|
|
242
|
+
// A request whose address holds user data (an email in a form GET, for example) is left out whole.
|
|
243
|
+
.filter((e) => redactUrl(e.request.url).url === e.request.url)
|
|
244
|
+
.map((e) => {
|
|
245
|
+
// Form bodies are dropped; replay then matches POSTs by address and method.
|
|
246
|
+
const { postData: _postData, ...request } = e.request;
|
|
247
|
+
return {
|
|
248
|
+
...e,
|
|
249
|
+
request: { ...request, cookies: [], headers: request.headers.filter((h) => !SECRET_REQUEST_HEADER.test(h.name)) },
|
|
250
|
+
response: { ...e.response, cookies: [], headers: e.response.headers.filter((h) => !/^set-cookie$/i.test(h.name)) },
|
|
251
|
+
};
|
|
252
|
+
});
|
|
253
|
+
await writeFile(file, JSON.stringify(har));
|
|
254
|
+
}
|
|
255
|
+
|
|
191
256
|
export class AuditSession {
|
|
192
257
|
/**
|
|
193
258
|
* @param {{ viewport: string, site?: string, allowedHosts?: string[], providers?: string[], keepBodies?: boolean, blockPrivate?: boolean | ((ip: string) => boolean), blockHits?: boolean, headless?: boolean }} opts
|
|
@@ -196,6 +261,8 @@ export class AuditSession {
|
|
|
196
261
|
* providers: which tracking providers to record (default GA4, Google Ads, Meta, TikTok). Requests to the
|
|
197
262
|
* others are still answered locally, but only counted, per step, in this.skipped.
|
|
198
263
|
* site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
|
|
264
|
+
* recordHar: save every response the site sends into this .har file, so the journey can be replayed offline later.
|
|
265
|
+
* replayHar: answer requests from a .har file saved with recordHar instead of the network; requests it lacks fail.
|
|
199
266
|
* blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
|
|
200
267
|
* All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
|
|
201
268
|
*/
|
|
@@ -232,7 +299,8 @@ export class AuditSession {
|
|
|
232
299
|
launch.args = ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"];
|
|
233
300
|
}
|
|
234
301
|
this.browser = await chromium.launch(launch);
|
|
235
|
-
this.
|
|
302
|
+
const record = this.opts.recordHar ? { recordHar: { path: this.opts.recordHar, mode: "minimal" } } : {};
|
|
303
|
+
this.context = await this.browser.newContext({ ...device, locale: "en-US", ...record });
|
|
236
304
|
await this.context.exposeBinding("__atdRecord", (_source, entry) => {
|
|
237
305
|
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
238
306
|
});
|
|
@@ -307,6 +375,8 @@ export class AuditSession {
|
|
|
307
375
|
// the page navigates away (a page_view on the next load, a beacon on unload) can be
|
|
308
376
|
// cancelled before its route handler runs, but its request event has already fired.
|
|
309
377
|
this.context.on("request", onRequest);
|
|
378
|
+
// Registered first, so the routes below run before it and tracking requests are still answered locally.
|
|
379
|
+
if (this.opts.replayHar) await this.context.routeFromHAR(this.opts.replayHar, { notFound: "abort" });
|
|
310
380
|
if (this.opts.blockHits) {
|
|
311
381
|
await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
|
|
312
382
|
await route.fulfill({ status: 204, body: "" });
|
|
@@ -354,9 +424,10 @@ export class AuditSession {
|
|
|
354
424
|
/** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
|
|
355
425
|
redactHit({ userFields, ...hit }) {
|
|
356
426
|
const found = { fields: [], plain: [] };
|
|
357
|
-
const
|
|
358
|
-
const
|
|
359
|
-
redactParams(
|
|
427
|
+
const site = hostOf(hit.params?.page_location) ?? hostOf(this.page.url());
|
|
428
|
+
const url = redactUrl(hit.url, found, site).url;
|
|
429
|
+
const params = redactParams(hit.params, found, site);
|
|
430
|
+
redactParams(userFields, found, site); // fields from the POST body that the hit doesn't keep
|
|
360
431
|
if (!found.fields.length) return hit;
|
|
361
432
|
const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
|
|
362
433
|
return this.opts.keepBodies ? { ...hit, userData } : { ...hit, url, params, userData };
|
|
@@ -364,7 +435,7 @@ export class AuditSession {
|
|
|
364
435
|
|
|
365
436
|
/** A dataLayer entry with user data redacted, and userData: { fields, plain } when there was any. */
|
|
366
437
|
redactEntry(entry) {
|
|
367
|
-
const found = redactPush(entry.value);
|
|
438
|
+
const found = redactPush(entry.value, undefined, hostOf(this.page.url()));
|
|
368
439
|
if (!found.fields.length) return entry;
|
|
369
440
|
const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
|
|
370
441
|
return this.opts.keepBodies ? { ...entry, userData } : { ...entry, value: found.value, userData };
|
|
@@ -589,12 +660,14 @@ export class AuditSession {
|
|
|
589
660
|
async act(a) {
|
|
590
661
|
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
591
662
|
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
663
|
+
const step = this.steps[this.currentStep];
|
|
592
664
|
try {
|
|
593
665
|
switch (a.action) {
|
|
594
666
|
case "goto": {
|
|
595
667
|
const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
|
|
596
668
|
const refused = await this.checkUrl(target);
|
|
597
669
|
if (refused) return refused;
|
|
670
|
+
step.replay = { action: "goto", url: target };
|
|
598
671
|
await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
599
672
|
break;
|
|
600
673
|
}
|
|
@@ -608,12 +681,14 @@ export class AuditSession {
|
|
|
608
681
|
if (found.error) return found.error;
|
|
609
682
|
target = found.locator;
|
|
610
683
|
} else return `${a.action} needs a ref or the element's visible text.`;
|
|
684
|
+
step.replay = { action: a.action, ...(await replayTarget(a, target)), ...(a.value !== undefined ? { value: a.value } : {}) };
|
|
611
685
|
if (a.action === "click") await this.click(target);
|
|
612
686
|
else if (a.action === "fill") await target.fill(a.value ?? "", { timeout: 10000 });
|
|
613
687
|
else await target.selectOption(a.value ?? "", { timeout: 10000 });
|
|
614
688
|
break;
|
|
615
689
|
}
|
|
616
690
|
case "scroll": {
|
|
691
|
+
step.replay = { action: "scroll", ...(a.value !== undefined ? { value: a.value } : {}) };
|
|
617
692
|
const dy = /^up$/i.test(a.value ?? "") ? -900 : 900;
|
|
618
693
|
const size = this.page.viewportSize() ?? { width: 800, height: 600 };
|
|
619
694
|
const before = await this.page.evaluate(() => window.scrollY);
|
|
@@ -625,6 +700,7 @@ export class AuditSession {
|
|
|
625
700
|
break;
|
|
626
701
|
}
|
|
627
702
|
case "wait":
|
|
703
|
+
step.replay = { action: "wait", ...(a.value !== undefined ? { value: a.value } : {}) };
|
|
628
704
|
await this.page.waitForTimeout(Math.min(Number(a.value) || 1000, 10000));
|
|
629
705
|
break;
|
|
630
706
|
default:
|
|
@@ -645,6 +721,12 @@ export class AuditSession {
|
|
|
645
721
|
}
|
|
646
722
|
|
|
647
723
|
async close() {
|
|
724
|
+
if (this.opts.recordHar && this.context) {
|
|
725
|
+
// Writes the recording. If the browser already went away, there is none to clean.
|
|
726
|
+
const written = await this.context.close().then(() => true, () => false);
|
|
727
|
+
// A recording that could not be cleaned may hold cookies or form data, so it is deleted.
|
|
728
|
+
if (written) await cleanRecording(this.opts.recordHar).catch(() => rm(this.opts.recordHar, { force: true }));
|
|
729
|
+
}
|
|
648
730
|
await this.browser?.close();
|
|
649
731
|
await this.egress?.close();
|
|
650
732
|
}
|
package/src/checks.js
CHANGED
|
@@ -2,9 +2,10 @@
|
|
|
2
2
|
// They run without any API call; the AI model only explains the results.
|
|
3
3
|
|
|
4
4
|
import { messages } from "./i18n.js";
|
|
5
|
+
import { isOwnAddress } from "./redact.js";
|
|
5
6
|
|
|
6
7
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
7
|
-
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/
|
|
8
|
+
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/gi;
|
|
8
9
|
const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
9
10
|
|
|
10
11
|
/**
|
|
@@ -24,6 +25,24 @@ function pageOf(hit) {
|
|
|
24
25
|
return hit.params?.page_location ?? hit.pageUrl ?? "";
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
// Just before an address: a mailto: link or a link field (link_url=..., "link_text":"...").
|
|
29
|
+
const LINK_BEFORE = /(mailto:|\b(link_url|link_text|click_url|click_text)(=|":"))$/i;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Whether text holds an email address that could be a visitor's. The site's own address in a
|
|
33
|
+
* mailto: link or a link field is a contact link it shows; anywhere else it still counts.
|
|
34
|
+
*/
|
|
35
|
+
function visitorEmailIn(text, hit) {
|
|
36
|
+
let site = null;
|
|
37
|
+
try {
|
|
38
|
+
site = new URL(hit.params?.page_location ?? hit.pageUrl).hostname;
|
|
39
|
+
} catch { /* no page address */ }
|
|
40
|
+
return [...text.matchAll(EMAIL)].some((m) => {
|
|
41
|
+
const link = LINK_BEFORE.test(text.slice(Math.max(0, m.index - 16), m.index));
|
|
42
|
+
return !(link && isOwnAddress(m[0].replace(/%40/i, "@"), site));
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
|
|
27
46
|
function expectedIn(planEvent, journeyId) {
|
|
28
47
|
return !planEvent.journeys || planEvent.journeys.includes(journeyId);
|
|
29
48
|
}
|
|
@@ -172,7 +191,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
172
191
|
if (hit.userData?.plain?.length) {
|
|
173
192
|
piiSeen.add(key(hit.platform, hit.name));
|
|
174
193
|
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.piiUnhashed(hit.platform, hit.name, hit.userData.plain) });
|
|
175
|
-
} else if (
|
|
194
|
+
} else if (visitorEmailIn(haystack, hit)) {
|
|
176
195
|
piiSeen.add(key(hit.platform, hit.name));
|
|
177
196
|
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.pii(hit.platform, hit.name) });
|
|
178
197
|
}
|
package/src/cli.js
CHANGED
|
@@ -6,6 +6,7 @@ import { LANGUAGES, messages } from "./i18n.js";
|
|
|
6
6
|
import { PROVIDERS } from "./providers.js";
|
|
7
7
|
import { JOURNEY_PRESETS } from "./journeys.js";
|
|
8
8
|
import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
|
|
9
|
+
import { replaySavedSite } from "./replay.js";
|
|
9
10
|
|
|
10
11
|
const USAGE = `ai-tag-debugger: an open-source AI agent that QA-tests your analytics and ad tracking.
|
|
11
12
|
|
|
@@ -13,6 +14,8 @@ Usage:
|
|
|
13
14
|
ai-tag-debugger run --site <url> --journey purchase [options] Audit ready-made journeys, no config needed.
|
|
14
15
|
ai-tag-debugger run --site <url> --goal "<what to do>" Audit a journey you describe in plain language.
|
|
15
16
|
ai-tag-debugger run --config <file> [options] Run an audit from a config file.
|
|
17
|
+
ai-tag-debugger replay <dir>... [--update] Audit sites saved with --save-site again, offline and without AI,
|
|
18
|
+
and list findings that changed. Exits with code 1 when any did.
|
|
16
19
|
ai-tag-debugger mcp [--http] [--port <n>] Start the MCP server for Claude, ChatGPT, Gemini and other chat apps.
|
|
17
20
|
|
|
18
21
|
Run options:
|
|
@@ -41,6 +44,12 @@ Run options:
|
|
|
41
44
|
--keep-bodies Store tracking requests as sent. By default, emails, phone numbers and user ids sent
|
|
42
45
|
for ad matching are redacted in the report and capture.json ("keep_bodies" in the config).
|
|
43
46
|
--no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
|
|
47
|
+
--save-site <dir> Also save the site's responses and the steps taken in this folder, so "replay" can audit
|
|
48
|
+
the same pages again offline. Its findings become the expected ones.
|
|
49
|
+
|
|
50
|
+
Replay options:
|
|
51
|
+
--update Make this replay's findings the expected ones (after checking that the changes are right).
|
|
52
|
+
--out <dir> Where to write the replay's reports (default: <dir>/last-replay).
|
|
44
53
|
|
|
45
54
|
MCP options:
|
|
46
55
|
--http Serve over Streamable HTTP instead of stdio (for apps that connect to a URL).
|
|
@@ -87,6 +96,8 @@ async function main() {
|
|
|
87
96
|
token: { type: "string" },
|
|
88
97
|
reports: { type: "string" },
|
|
89
98
|
"public-url": { type: "string" },
|
|
99
|
+
"save-site": { type: "string" },
|
|
100
|
+
update: { type: "boolean", default: false },
|
|
90
101
|
help: { type: "boolean", short: "h", default: false },
|
|
91
102
|
},
|
|
92
103
|
});
|
|
@@ -97,6 +108,22 @@ async function main() {
|
|
|
97
108
|
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"] });
|
|
98
109
|
return;
|
|
99
110
|
}
|
|
111
|
+
if (command === "replay" && !values.help) {
|
|
112
|
+
const dirs = positionals.slice(1);
|
|
113
|
+
if (!dirs.length) throw new Error("replay needs the folder of at least one site saved with --save-site.");
|
|
114
|
+
let changed = false;
|
|
115
|
+
for (const dir of dirs) {
|
|
116
|
+
const r = await replaySavedSite(dir, { update: values.update, outDir: dirs.length === 1 ? values.out : undefined });
|
|
117
|
+
const same = !r.missing.length && !r.extra.length;
|
|
118
|
+
changed ||= !same;
|
|
119
|
+
console.log(`${same ? "✓" : values.update ? "↻" : "✗"} ${r.site} (${dir}): ${same ? "same findings" : `${r.extra.length} new, ${r.missing.length} gone${values.update ? ", saved as expected" : ""}`}`);
|
|
120
|
+
for (const k of r.extra) console.log(` + ${k}`);
|
|
121
|
+
for (const k of r.missing) console.log(` - ${k}`);
|
|
122
|
+
for (const note of r.incomplete) console.log(` ! ${note}`);
|
|
123
|
+
}
|
|
124
|
+
if (changed && !values.update) process.exitCode = 1;
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
100
127
|
if (values.help || command !== "run" || (!values.config && !values.site)) {
|
|
101
128
|
console.log(USAGE);
|
|
102
129
|
process.exit(values.help ? 0 : 2);
|
|
@@ -136,6 +163,7 @@ async function main() {
|
|
|
136
163
|
headed: values.headed,
|
|
137
164
|
branding: values["no-branding"] ? false : undefined,
|
|
138
165
|
outDir: values.out,
|
|
166
|
+
saveSite: values["save-site"],
|
|
139
167
|
log: (msg) => console.log(msg),
|
|
140
168
|
});
|
|
141
169
|
|
package/src/mcp.js
CHANGED
|
@@ -22,7 +22,7 @@ import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
|
22
22
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
23
23
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
24
24
|
|
|
25
|
-
const VERSION = "0.6.
|
|
25
|
+
const VERSION = "0.6.3";
|
|
26
26
|
const MAX_OPEN_AUDITS = 4;
|
|
27
27
|
const IDLE_MS = 15 * 60 * 1000; // an audit untouched this long is closed
|
|
28
28
|
const MAX_BODY_BYTES = 1024 * 1024;
|
package/src/parsers.js
CHANGED
|
@@ -35,9 +35,10 @@ export function parseGa4(url, postData) {
|
|
|
35
35
|
// Matched on the GA4 protocol rather than the host, so server-side GTM
|
|
36
36
|
// endpoints on first-party domains are captured too.
|
|
37
37
|
if (!u.pathname.endsWith("/g/collect") || u.searchParams.get("v") !== "2") return null;
|
|
38
|
-
// gtag.js resends a hit to www.google.com/g/collect
|
|
39
|
-
//
|
|
40
|
-
|
|
38
|
+
// gtag.js resends a hit to www.google.com/g/collect when the first send seems lost, and
|
|
39
|
+
// copies some to doubleclick.net. GA4 doesn't count these twice, so they aren't hits.
|
|
40
|
+
// (Primary hits can carry gaf too, e.g. gaf=2, so the host decides, not the parameter.)
|
|
41
|
+
if (GOOGLE_COMPANION_HOST.test(u.hostname)) return null;
|
|
41
42
|
const base = new URLSearchParams(u.search);
|
|
42
43
|
const lines = postData ? postData.split("\n").filter(Boolean) : [""];
|
|
43
44
|
return lines.map((line) => {
|
|
@@ -135,8 +136,12 @@ export function parseTrackingRequest(url, postData) {
|
|
|
135
136
|
// hits: Google Ads remarketing and consent pings, and the other common ad pixels.
|
|
136
137
|
const OTHER_BEACONS = [
|
|
137
138
|
[/(^|\.)(doubleclick\.net|googleadservices\.com|google\.[a-z.]+)$/, /^\/pagead\/(viewthroughconversion|1p-user-list|landing)\//],
|
|
138
|
-
[/(^|\.)google\.[a-z.]+$/, /^\/
|
|
139
|
+
[/(^|\.)google\.[a-z.]+$/, /^\/rmkt\/collect\//],
|
|
139
140
|
[GOOGLE_COMPANION_HOST, /\/g\/collect$/],
|
|
141
|
+
[/(^|\.)merchant-center-analytics\.goog$/, /^\/mc\/collect/],
|
|
142
|
+
// Google tag pings on any host: consent mode (ccm), the Google tag's own /g/s/collect, and
|
|
143
|
+
// Google tag gateway paths served from the site's own domain (/<id>/gs/ccm/collect).
|
|
144
|
+
[/./, /\/(ccm|ccm\/s|g\/s)\/collect$/],
|
|
140
145
|
[/^td\.doubleclick\.net$/, /^\/td\//],
|
|
141
146
|
[/(^|\.)facebook\.com$/, /^\/privacy_sandbox\/pixel\//],
|
|
142
147
|
[/^bat\.bing\.com$/, /^\/action\//],
|
package/src/redact.js
CHANGED
|
@@ -14,9 +14,48 @@ const USER_KEYS = new Set([
|
|
|
14
14
|
const PUSH_KEYS = new Set(["email", "email_address", "phone", "phone_number", "sha256_email_address", "sha256_phone_number", "sha256_email", "sha256_phone"]);
|
|
15
15
|
const USER_OBJECTS = /(^|\.)(user_data|user_provided_data|enhanced_conversion_data)(\.|$)/;
|
|
16
16
|
|
|
17
|
-
const EMAIL = /^[^\s@]+@[^\s
|
|
18
|
-
|
|
19
|
-
|
|
17
|
+
const EMAIL = /^(mailto:)?[^\s@]+@([^\s@?]+\.[^\s@?]+)(\?.*)?$/i;
|
|
18
|
+
|
|
19
|
+
// Second levels that are shared under a country code (shop.com.tr, shop.co.uk), and hosting
|
|
20
|
+
// suffixes whose subdomains belong to different owners (shop.myshopify.com).
|
|
21
|
+
const SHARED_SECOND_LEVELS = new Set(["com", "co", "org", "net", "gov", "edu", "ac", "gen", "web", "bel", "k12", "or", "ne", "go"]);
|
|
22
|
+
const SHARED_SUFFIXES = ["myshopify.com", "github.io", "vercel.app", "pages.dev", "netlify.app", "herokuapp.com", "web.app", "firebaseapp.com", "appspot.com", "wixsite.com", "blogspot.com", "azurewebsites.net", "cloudfront.net", "workers.dev"];
|
|
23
|
+
|
|
24
|
+
/** "www.shop.example.com.tr" -> "example.com.tr", "a.shop.myshopify.com" -> "shop.myshopify.com": the part one owner controls. */
|
|
25
|
+
function siteOf(host) {
|
|
26
|
+
const labels = String(host).toLowerCase().replace(/\.$/, "").split(".");
|
|
27
|
+
const name = labels.join(".");
|
|
28
|
+
const shared = SHARED_SUFFIXES.find((s) => name === s || name.endsWith(`.${s}`));
|
|
29
|
+
let n = 2;
|
|
30
|
+
if (shared) n = shared.split(".").length + 1;
|
|
31
|
+
else if (labels.length >= 3 && labels.at(-1).length === 2 && SHARED_SECOND_LEVELS.has(labels.at(-2))) n = 3;
|
|
32
|
+
return labels.slice(-n).join(".");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Whether an email address is on the audited site's own domain, like hello@shop.com on a link at shop.com. */
|
|
36
|
+
export function isOwnAddress(email, siteHost) {
|
|
37
|
+
if (!siteHost) return false;
|
|
38
|
+
const domain = String(email).trim().match(EMAIL)?.[2];
|
|
39
|
+
return Boolean(domain) && siteOf(domain) === siteOf(siteHost);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
// Fields that hold a link or its text. The site's own address there is a contact link it shows.
|
|
43
|
+
const LINK_KEYS = new Set(["link_url", "link_text", "click_url", "click_text"]);
|
|
44
|
+
const isLinkKey = (key) => {
|
|
45
|
+
const k = String(key ?? "").toLowerCase();
|
|
46
|
+
return LINK_KEYS.has(k.split(".").pop()) || /(^|\.)gtm\.element(url|text)$/.test(k);
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* A value that is an email address (or a mailto: link to one), whatever its field is called.
|
|
51
|
+
* The site's own address in a mailto: link or a link field is a contact link it shows, not
|
|
52
|
+
* visitor data. Anywhere else even an own-domain address could be a visitor's (staff, B2B).
|
|
53
|
+
*/
|
|
54
|
+
const isEmail = (v, site, key) => {
|
|
55
|
+
if (typeof v !== "string" || !EMAIL.test(v.trim())) return false;
|
|
56
|
+
const link = /^mailto:/i.test(v.trim()) || isLinkKey(key);
|
|
57
|
+
return !(link && isOwnAddress(v, site));
|
|
58
|
+
};
|
|
20
59
|
|
|
21
60
|
/** Whether a request parameter (or dotted JSON path) carries user data for ad matching. */
|
|
22
61
|
export function isUserDataKey(key) {
|
|
@@ -49,34 +88,34 @@ function note(found, key, value) {
|
|
|
49
88
|
}
|
|
50
89
|
|
|
51
90
|
/** Redacts form-encoded parameters (a URL query or a GA4 body line). Returns null when nothing needed it. */
|
|
52
|
-
function redactParamString(text, found) {
|
|
91
|
+
function redactParamString(text, found, site) {
|
|
53
92
|
const params = new URLSearchParams(text);
|
|
54
|
-
if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v))) return null;
|
|
93
|
+
if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v, site, k))) return null;
|
|
55
94
|
const out = new URLSearchParams();
|
|
56
|
-
for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v) ? note(found, k, v) : v);
|
|
95
|
+
for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v, site, k) ? note(found, k, v) : v);
|
|
57
96
|
return out.toString();
|
|
58
97
|
}
|
|
59
98
|
|
|
60
|
-
/** Redacts user data in a URL's query. Returns the URL unchanged when there is none. */
|
|
61
|
-
export function redactUrl(url, found = emptyFound()) {
|
|
99
|
+
/** Redacts user data in a URL's query. Returns the URL unchanged when there is none. site: see isOwnAddress. */
|
|
100
|
+
export function redactUrl(url, found = emptyFound(), site = null) {
|
|
62
101
|
let u;
|
|
63
102
|
try {
|
|
64
103
|
u = new URL(url);
|
|
65
104
|
} catch {
|
|
66
105
|
return { url, ...found };
|
|
67
106
|
}
|
|
68
|
-
const query = redactParamString(u.search, found);
|
|
107
|
+
const query = redactParamString(u.search, found, site);
|
|
69
108
|
if (query === null) return { url, ...found };
|
|
70
109
|
u.search = query;
|
|
71
110
|
return { url: u.href, ...found };
|
|
72
111
|
}
|
|
73
112
|
|
|
74
|
-
function redactJson(value, path, found, isKey) {
|
|
75
|
-
if (Array.isArray(value)) return value.map((v) => redactJson(v, path, found, isKey));
|
|
113
|
+
function redactJson(value, path, found, isKey, site = null) {
|
|
114
|
+
if (Array.isArray(value)) return value.map((v) => redactJson(v, path, found, isKey, site));
|
|
76
115
|
if (value && typeof value === "object") {
|
|
77
|
-
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactJson(v, path ? `${path}.${k}` : k, found, isKey)]));
|
|
116
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactJson(v, path ? `${path}.${k}` : k, found, isKey, site)]));
|
|
78
117
|
}
|
|
79
|
-
if ((typeof value === "string" || typeof value === "number") && ((path && isKey(path)) || isEmail(value))) return note(found, path || "(value)", value);
|
|
118
|
+
if ((typeof value === "string" || typeof value === "number") && ((path && isKey(path)) || isEmail(value, site, path))) return note(found, path || "(value)", value);
|
|
80
119
|
return value;
|
|
81
120
|
}
|
|
82
121
|
|
|
@@ -110,21 +149,21 @@ export function redactBody(body, found = emptyFound()) {
|
|
|
110
149
|
return { text, ...found };
|
|
111
150
|
}
|
|
112
151
|
|
|
113
|
-
/** Redacts user-data entries in parsed hit parameters. */
|
|
114
|
-
export function redactParams(params, found = emptyFound()) {
|
|
152
|
+
/** Redacts user-data entries in parsed hit parameters. site: see isOwnAddress. */
|
|
153
|
+
export function redactParams(params, found = emptyFound(), site = null) {
|
|
115
154
|
const out = {};
|
|
116
|
-
for (const [k, v] of Object.entries(params ?? {})) out[k] = isUserDataKey(k) || isEmail(v) ? note(found, k, v) : v;
|
|
155
|
+
for (const [k, v] of Object.entries(params ?? {})) out[k] = isUserDataKey(k) || isEmail(v, site, k) ? note(found, k, v) : v;
|
|
117
156
|
return out;
|
|
118
157
|
}
|
|
119
158
|
|
|
120
159
|
/**
|
|
121
160
|
* Redacts user data in a dataLayer push (GTM objects or gtag.js arguments):
|
|
122
|
-
* email and phone fields, anything under user_data, and email-like values.
|
|
161
|
+
* email and phone fields, anything under user_data, and email-like values not on the site's own domain.
|
|
123
162
|
*/
|
|
124
|
-
export function redactPush(value, found = emptyFound()) {
|
|
163
|
+
export function redactPush(value, found = emptyFound(), site = null) {
|
|
125
164
|
// gtag("set", "user_data", {...}) has no user_data key: the objects after the name are the user data.
|
|
126
165
|
if (Array.isArray(value) && value.includes("user_data")) {
|
|
127
166
|
return { value: value.map((v) => (v && typeof v === "object" ? redactJson(v, "user_data", found, isPushUserKey) : v)), ...found };
|
|
128
167
|
}
|
|
129
|
-
return { value: redactJson(value, "", found, isPushUserKey), ...found };
|
|
168
|
+
return { value: redactJson(value, "", found, isPushUserKey, site), ...found };
|
|
130
169
|
}
|
package/src/replay.js
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
// Saved sites: the responses a site sent during an audit and the steps taken, so a later
|
|
2
|
+
// version of the tool can audit the same pages offline and show what it now reports
|
|
3
|
+
// differently. Replays need no network and no AI model, so they cost nothing.
|
|
4
|
+
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
5
|
+
import path from "node:path";
|
|
6
|
+
import { runAudit } from "./audit.js";
|
|
7
|
+
|
|
8
|
+
export const SAVED_SITE_FILE = "site.json";
|
|
9
|
+
|
|
10
|
+
/** The steps a run took, as fixed steps a scripted run can repeat. */
|
|
11
|
+
export function replayableJourney(run) {
|
|
12
|
+
const steps = run.steps.filter((s) => s.index > 0);
|
|
13
|
+
const start = steps[0]?.replay?.action === "goto" ? steps[0].replay.url : undefined;
|
|
14
|
+
return {
|
|
15
|
+
id: run.journeyId,
|
|
16
|
+
viewport: run.viewport,
|
|
17
|
+
...(start ? { start } : {}),
|
|
18
|
+
steps: (start ? steps.slice(1) : steps)
|
|
19
|
+
.filter((s) => s.replay)
|
|
20
|
+
.map((s) => ({ ...s.replay, reason: s.label, ...(s.acceptsConsent ? { accepts_consent: true } : {}) })),
|
|
21
|
+
};
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** One line per finding, sorted: what a replay is compared on. */
|
|
25
|
+
export function findingKeys(findings) {
|
|
26
|
+
return findings.map((f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ")).sort();
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Lines in one list and not the other, counting repeats. */
|
|
30
|
+
export function compareKeys(expected, actual) {
|
|
31
|
+
const left = [...actual];
|
|
32
|
+
const missing = [];
|
|
33
|
+
for (const k of expected) {
|
|
34
|
+
const i = left.indexOf(k);
|
|
35
|
+
if (i === -1) missing.push(k);
|
|
36
|
+
else left.splice(i, 1);
|
|
37
|
+
}
|
|
38
|
+
return { missing, extra: left };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** Writes site.json next to the recorded responses. The findings of this run become the expected ones. */
|
|
42
|
+
export async function writeSavedSite(dir, { config, plan, runs, findings }) {
|
|
43
|
+
await mkdir(dir, { recursive: true });
|
|
44
|
+
const { journeys: _journeys, plan: _plan, gtm_container: _gtm, ...site } = config;
|
|
45
|
+
const saved = {
|
|
46
|
+
version: 1,
|
|
47
|
+
saved_at: new Date().toISOString(),
|
|
48
|
+
config: site,
|
|
49
|
+
plan,
|
|
50
|
+
journeys: runs.map(replayableJourney),
|
|
51
|
+
expected: findingKeys(findings),
|
|
52
|
+
};
|
|
53
|
+
await writeFile(path.join(dir, SAVED_SITE_FILE), JSON.stringify(saved, null, 2));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Audits a saved site again, offline and without AI, and compares the findings with the expected ones.
|
|
58
|
+
* update: make this replay's findings the expected ones.
|
|
59
|
+
* @returns {Promise<{ site: string, missing: string[], extra: string[], reportPaths: string[], incomplete: string[] }>}
|
|
60
|
+
*/
|
|
61
|
+
export async function replaySavedSite(dir, { update = false, outDir, log = () => {} } = {}) {
|
|
62
|
+
const file = path.join(dir, SAVED_SITE_FILE);
|
|
63
|
+
let saved;
|
|
64
|
+
try {
|
|
65
|
+
saved = JSON.parse(await readFile(file, "utf8"));
|
|
66
|
+
} catch (err) {
|
|
67
|
+
throw new Error(`Could not read ${file}: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
const viewports = [...new Set(saved.journeys.map((j) => j.viewport))];
|
|
70
|
+
const findings = [];
|
|
71
|
+
const reportPaths = [];
|
|
72
|
+
const incomplete = [];
|
|
73
|
+
for (const viewport of viewports) {
|
|
74
|
+
const journeys = saved.journeys.filter((j) => j.viewport === viewport).map(({ viewport: _v, ...j }) => j);
|
|
75
|
+
const out = path.resolve(outDir ?? path.join(dir, "last-replay"), viewports.length > 1 ? viewport : "");
|
|
76
|
+
const result = await runAudit({
|
|
77
|
+
config: { ...saved.config, journeys },
|
|
78
|
+
plan: saved.plan,
|
|
79
|
+
viewports: [viewport],
|
|
80
|
+
scripted: true,
|
|
81
|
+
ai: false,
|
|
82
|
+
replaySite: dir,
|
|
83
|
+
lang: "en",
|
|
84
|
+
outDir: out,
|
|
85
|
+
log,
|
|
86
|
+
});
|
|
87
|
+
findings.push(...result.findings);
|
|
88
|
+
reportPaths.push(result.reportPath);
|
|
89
|
+
incomplete.push(...result.runs.filter((r) => !r.completed).map((r) => `${r.journeyId} (${r.viewport}): ${r.note}`));
|
|
90
|
+
}
|
|
91
|
+
const actual = findingKeys(findings);
|
|
92
|
+
const { missing, extra } = compareKeys(saved.expected ?? [], actual);
|
|
93
|
+
if (update && (missing.length || extra.length)) {
|
|
94
|
+
await writeFile(file, JSON.stringify({ ...saved, expected: actual }, null, 2));
|
|
95
|
+
}
|
|
96
|
+
return { site: saved.config.site, missing, extra, reportPaths, incomplete };
|
|
97
|
+
}
|