@analitikisler/ai-tag-debugger 0.8.2 → 0.9.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 CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0 (2026-10-15)
4
+
5
+ - `aitd run ... --save-steps <file>` writes the steps each journey took into `journeys[].steps` of that config file (creating it if needed, and adding the run's plan when the config has none). A later `aitd run --config <file> --scripted --no-ai` repeats them with no AI model or API key. A journey run on several viewports keeps the steps of its first viewport.
6
+ - New purchase checks: a purchase sent with a value that is 0, negative or not a number (`purchase_value_invalid`), a currency that isn't a 3-letter ISO code such as USD (`purchase_currency_invalid`), or no items (`purchase_items_empty`). GA4 and Meta reporting the same order with a different value or currency is flagged as `purchase_mismatch`; purchases are paired by order ID, or taken as one order when the journey has a single purchase on each.
7
+
3
8
  ## 0.8.2 (2026-10-15)
4
9
 
5
10
  - A journey that fails with an error, such as the AI provider's API refusing a request or the browser crashing, no longer ends the whole audit. That journey is marked unfinished with the error as its note, what it captured until then is kept, the other journeys still run, and the report is written. This applies to the CLI and to `run_audit` over MCP.
package/README.md CHANGED
@@ -40,6 +40,10 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
40
40
  |---|---|
41
41
  | Missing events | `purchase` never fired on the checkout journey |
42
42
  | Missing parameters | `purchase` sent without `value` and `currency` on mobile |
43
+ | Invalid purchase value | `purchase` sent with `value` `0`, a negative number or text such as `"free"` |
44
+ | Invalid purchase currency | `purchase` sent with currency `usd` or `TL` instead of a 3-letter ISO code such as `USD` or `TRY` |
45
+ | Empty purchase items | `purchase` sent with an empty `items` list |
46
+ | Purchase mismatch | GA4 sends `value` 120 and Meta sends 115 for the same order ID |
43
47
  | Duplicates | `add_to_cart` sent twice by one click |
44
48
  | Consent | Meta `PageView` fired before the visitor accepted cookies, Consent Mode default missing or set too late, Consent Mode v2 signals (`ad_user_data`, `ad_personalization`) missing, tracking cookies set before consent, tags or cookies still active after the `decline_cookies` journey rejects the banner, a reject button that does nothing |
45
49
  | Personal data | An email address sent to an ad platform in plain text |
@@ -170,7 +174,7 @@ Each browsing step is one model call, so usage grows with **journeys × viewport
170
174
  - **Cap the steps.** `--max-steps 15` stops a journey that wanders.
171
175
  - **Expect bigger pages to cost more.** Each step sends the page view (up to 250 elements and 3,000 characters of text) to the model, so busy pages use more tokens per step than simple ones.
172
176
  - **Use `--events`** to keep the report focused. It doesn't shorten browsing, so it barely changes usage.
173
- - **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.
177
+ - **Replay without AI.** After one AI run, add `--save-steps <file>` to save its steps, then run that config with `--scripted --no-ai` on every deploy. See [Save the steps and replay them without AI](#save-the-steps-and-replay-them-without-ai).
174
178
  - **Use a chat app.** Over MCP, the chat app's own model does the browsing, so no API key is used at all.
175
179
 
176
180
  ### Check a new version against saved sites
@@ -186,6 +190,17 @@ npx @analitikisler/ai-tag-debugger replay saved/*
186
190
 
187
191
  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.
188
192
 
193
+ ### Save the steps and replay them without AI
194
+
195
+ `--save-steps <file>` writes the steps each journey took into `journeys[].steps` of a config file. It creates the file if it's missing, and it also saves the run's plan when the config has none. The same config then repeats the journey with no AI model and no API key:
196
+
197
+ ```bash
198
+ ai-tag-debugger run --site https://www.yourshop.com --journey purchase --save-steps ai-tag-debugger.config.json
199
+ ai-tag-debugger run --config ai-tag-debugger.config.json --scripted --no-ai
200
+ ```
201
+
202
+ When a journey ran on both viewports, only the first viewport's steps are kept. Keep the config in your repository and run the second command on every deploy to check the same journey without any model calls.
203
+
189
204
  ## Configure your site
190
205
 
191
206
  `ai-tag-debugger.config.json`:
@@ -248,7 +263,7 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
248
263
  [--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
249
264
  [--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
250
265
  [--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
251
- [--save-site <dir>]
266
+ [--save-site <dir>] [--save-steps <file>]
252
267
  ai-tag-debugger replay <dir>... [--update] [--out <dir>]
253
268
  ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
254
269
  [--allow-private-network]
package/README.tr.md CHANGED
@@ -40,6 +40,10 @@ Tag'leri elle test etmek bunu yakalar, ama zahmetli olduğu için her sürümden
40
40
  |---|---|
41
41
  | Eksik event'ler | `purchase`, ödeme senaryosunda hiç gönderilmedi |
42
42
  | Eksik parametreler | `purchase`, mobilde `value` ve `currency` olmadan gönderildi |
43
+ | Geçersiz satın alma değeri | `purchase`, `value` olarak `0`, negatif bir sayı ya da `"ücretsiz"` gibi bir metinle gönderildi |
44
+ | Geçersiz satın alma para birimi | `purchase`, para birimi `usd` ya da `TL` olarak gönderildi; `USD` ya da `TRY` gibi 3 harfli ISO kodu bekleniyor |
45
+ | Boş satın alma ürünleri | `purchase`, boş bir `items` listesiyle gönderildi |
46
+ | Satın alma uyuşmazlığı | Aynı sipariş numarası için GA4 `value` 120, Meta 115 gönderdi |
43
47
  | Tekrar eden event'ler | `add_to_cart` tek tıklamada iki kez gönderildi |
44
48
  | Çerez onayı | Meta `PageView`, ziyaretçi çerezleri kabul etmeden gönderildi; Consent Mode varsayılanı yok ya da çok geç ayarlanmış, Consent Mode v2 sinyalleri (`ad_user_data`, `ad_personalization`) eksik, onaydan önce yazılan izleme çerezleri, `decline_cookies` yolculuğu bandı reddettikten sonra hâlâ çalışan etiketler ya da çerezler, işe yaramayan reddet düğmesi |
45
49
  | Kişisel veri | Bir e-posta adresi reklam platformuna açık metin olarak gönderildi |
@@ -172,7 +176,7 @@ Her gezinme adımı bir model çağrısıdır. Yani kullanım **senaryo × ekran
172
176
  - **Adım sayısını sınırlayın.** `--max-steps 15`, yolunu kaybeden bir senaryoyu durdurur.
173
177
  - **Büyük sayfaların daha pahalı olduğunu unutmayın.** Her adım modele sayfanın görünümünü (en fazla 250 öğe ve 3.000 karakter metin) gönderir. Bu yüzden kalabalık sayfalar adım başına daha fazla token kullanır.
174
178
  - **Raporu odaklı tutmak için `--events` kullanın.** Gezinmeyi kısaltmadığı için kullanımı pek değiştirmez.
175
- - **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.
179
+ - **Yapay zekâ olmadan tekrar oynatın.** Bir yapay zekâ çalıştırmasından sonra `--save-steps <dosya>` ile adımları kaydedin, ardından her deploy'da o ayar dosyasıyla `--scripted --no-ai` kullanın. Ayrıntılar için [Adımları kaydedin ve yapay zekâ olmadan tekrar oynatın](#adımları-kaydedin-ve-yapay-zekâ-olmadan-tekrar-oynatın) bölümüne bakın.
176
180
  - **Bir yapay zekâ asistanı kullanın.** MCP üzerinden gezinmeyi asistanın kendi modeli yapar, hiç API anahtarı kullanılmaz.
177
181
 
178
182
  ### Yeni bir sürümü kaydedilmiş sitelerle karşılaştırın
@@ -188,6 +192,17 @@ npx @analitikisler/ai-tag-debugger replay saved/*
188
192
 
189
193
  İ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.
190
194
 
195
+ ### Adımları kaydedin ve yapay zekâ olmadan tekrar oynatın
196
+
197
+ `--save-steps <dosya>`, her senaryonun izlediği adımları bir ayar dosyasındaki `journeys[].steps` alanına yazar. Dosya yoksa oluşturulur; ayar dosyasında plan yoksa çalıştırmanın planı da kaydedilir. Aynı ayar dosyası, hiçbir yapay zekâ modeli ve API anahtarı olmadan senaryoyu tekrar çalıştırır:
198
+
199
+ ```bash
200
+ ai-tag-debugger run --site https://www.magazaniz.com --journey purchase --save-steps ai-tag-debugger.config.json
201
+ ai-tag-debugger run --config ai-tag-debugger.config.json --scripted --no-ai
202
+ ```
203
+
204
+ Bir senaryo iki ekran boyutunda çalıştıysa yalnızca ilk ekran boyutunun adımları tutulur. Ayar dosyasını depoda saklayın ve ikinci komutu her deploy'da çalıştırarak aynı senaryoyu hiçbir model çağrısı yapmadan kontrol edin.
205
+
191
206
  ## Sitenizi ayarlayın
192
207
 
193
208
  `ai-tag-debugger.config.json`:
@@ -248,7 +263,7 @@ ai-tag-debugger run [--site <url>] [--config <file>] [--journey <id>] [--goal "<
248
263
  [--max-steps <n>] [--out <dir>] [--viewport desktop|mobile] [--providers <list>]
249
264
  [--provider anthropic|openai|gemini|openai-compatible] [--model <id>] [--base-url <url>]
250
265
  [--lang en|tr] [--allow-host <host>] [--keep-bodies] [--scripted] [--no-ai] [--fail-on broken|risk|warning|never] [--headed]
251
- [--save-site <dir>]
266
+ [--save-site <dir>] [--save-steps <file>]
252
267
  ai-tag-debugger replay <dir>... [--update] [--out <dir>]
253
268
  ai-tag-debugger mcp [--http] [--port <n>] [--host <addr>] [--token <secret>] [--public-url <https url>] [--reports <dir>]
254
269
  [--allow-private-network]
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@analitikisler/ai-tag-debugger",
3
3
  "mcpName": "io.github.analitikisler/ai-tag-debugger",
4
- "version": "0.8.2",
4
+ "version": "0.9.0",
5
5
  "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.",
6
6
  "type": "module",
7
7
  "bin": {
package/src/audit.js CHANGED
@@ -119,5 +119,5 @@ export async function runAudit(opts) {
119
119
  const outDir = path.resolve(opts.outDir ?? defaultOutDir());
120
120
  const reportPath = await writeReports({ outDir, config, plan, runs, findings, explained, lang, branding: opts.branding ?? config.branding !== false });
121
121
  const counts = summarize(groupFindings(findings));
122
- return { runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
122
+ return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(saveError ? { saveError } : {}) };
123
123
  }
package/src/checks.js CHANGED
@@ -68,6 +68,17 @@ export function hasParam(hit, name) {
68
68
  return [name, ...(PARAM_ALIASES[name] ?? [])].some((n) => hit.params?.[n] !== undefined && hit.params[n] !== "");
69
69
  }
70
70
 
71
+ /** A hit's parameter under its own name or the first common alias it carries. */
72
+ function paramOf(hit, name) {
73
+ for (const n of [name, ...(PARAM_ALIASES[name] ?? [])]) {
74
+ if (hit.params?.[n] !== undefined && hit.params[n] !== "") return hit.params[n];
75
+ }
76
+ return undefined;
77
+ }
78
+
79
+ const isPurchase = (hit) => canonicalEvent(hit.name) === "purchase";
80
+ const EMPTY_LIST = /^\s*(\[\s*\])?\s*$/;
81
+
71
82
  /** The page load a hit came from: GA4's page load id (_p) when it has one, else the page address. */
72
83
  function pageOf(hit) {
73
84
  try {
@@ -203,6 +214,62 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
203
214
  });
204
215
  }
205
216
 
217
+ // Purchase values: a value that is 0, negative or not a number, a currency that isn't an ISO
218
+ // code, and an empty items list (reported once per platform and event).
219
+ const purchaseReported = new Set();
220
+ const reportPurchase = (hit, check, severity, detail) => {
221
+ const k = `${check}|${key(hit.platform, hit.name)}`;
222
+ if (purchaseReported.has(k)) return;
223
+ purchaseReported.add(k);
224
+ findings.push({ ...base, severity, check, platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail, evidence: JSON.stringify(hit.params) });
225
+ };
226
+ const purchases = run.hits.filter(isPurchase);
227
+ for (const hit of purchases) {
228
+ const value = paramOf(hit, "value");
229
+ if (value !== undefined) {
230
+ const n = Number(value);
231
+ if (!Number.isFinite(n) || String(value).trim() === "") reportPurchase(hit, "purchase_value_invalid", "broken", d.purchaseValueNaN(hit.platform, hit.name, value));
232
+ else if (n <= 0) reportPurchase(hit, "purchase_value_invalid", "broken", d.purchaseValueZero(hit.platform, hit.name, value));
233
+ }
234
+ const currency = paramOf(hit, "currency");
235
+ if (currency !== undefined && !/^[A-Z]{3}$/.test(currency)) reportPurchase(hit, "purchase_currency_invalid", "broken", d.purchaseCurrency(hit.platform, hit.name, currency));
236
+ // GA4 sends items as pr1, pr2...; Meta as contents or content_ids. Items left out of a GA4
237
+ // purchase count as empty, unless the plan requires them and missing_params already says so.
238
+ const listed = ["items", "contents", "content_ids"].filter((n) => hit.params?.[n] !== undefined);
239
+ const requiresItems = planEventFor(hit)?.required_params?.includes("items");
240
+ const empty = listed.length
241
+ ? listed.every((n) => EMPTY_LIST.test(hit.params[n]))
242
+ : hit.platform === "ga4" && !requiresItems && !hasParam(hit, "items");
243
+ if (empty) reportPurchase(hit, "purchase_items_empty", "warning", d.purchaseItemsEmpty(hit.platform, hit.name));
244
+ }
245
+
246
+ // GA4 and Meta reporting the same order with a different value or currency. Purchases are paired
247
+ // by order ID; without IDs, a run with one purchase on each platform is taken as one order.
248
+ const ga4Buys = purchases.filter((h) => h.platform === "ga4");
249
+ const metaBuys = purchases.filter((h) => h.platform === "meta");
250
+ const orderId = (h) => paramOf(h, "transaction_id");
251
+ const pairs = [];
252
+ for (const g of ga4Buys) {
253
+ const m = orderId(g) !== undefined && metaBuys.find((h) => orderId(h) === orderId(g));
254
+ if (m) pairs.push([g, m]);
255
+ }
256
+ if (!pairs.length && ga4Buys.length === 1 && metaBuys.length === 1) {
257
+ const [g, m] = [ga4Buys[0], metaBuys[0]];
258
+ if (orderId(g) === undefined || orderId(m) === undefined) pairs.push([g, m]);
259
+ }
260
+ const mismatchSeen = new Set();
261
+ for (const [g, m] of pairs) {
262
+ const [gv, mv] = [paramOf(g, "value"), paramOf(m, "value")];
263
+ const [gc, mc] = [paramOf(g, "currency"), paramOf(m, "currency")];
264
+ const diffs = [];
265
+ if (gv !== undefined && mv !== undefined && Number.isFinite(Number(gv)) && Number.isFinite(Number(mv)) && Math.abs(Number(gv) - Number(mv)) > 0.005) diffs.push(["value", gv, mv]);
266
+ if (gc !== undefined && mc !== undefined && gc.toUpperCase() !== mc.toUpperCase()) diffs.push(["currency", gc, mc]);
267
+ const k = `${orderId(g) ?? ""}|${diffs.map((x) => x.join("=")).join(",")}`;
268
+ if (!diffs.length || mismatchSeen.has(k)) continue;
269
+ mismatchSeen.add(k);
270
+ findings.push({ ...base, severity: "warning", check: "purchase_mismatch", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.purchaseMismatch(orderId(g) ?? orderId(m), diffs), evidence: JSON.stringify({ ga4: g.params, meta: m.params }) });
271
+ }
272
+
206
273
  // Server-side tagging. Only what the browser can see: a GA4 hit to a server container, and requests
207
274
  // to Stape or Elevar. Meta's and TikTok's server-side copies never pass through the browser.
208
275
  const serverHits = run.hits.filter((h) => h.delivery === "server");
package/src/cli.js CHANGED
@@ -7,7 +7,7 @@ import { LANGUAGES, messages } from "./i18n.js";
7
7
  import { PROVIDERS } from "./providers.js";
8
8
  import { JOURNEY_PRESETS } from "./journeys.js";
9
9
  import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
10
- import { replaySavedSite } from "./replay.js";
10
+ import { replaySavedSite, writeSavedSteps } from "./replay.js";
11
11
 
12
12
  const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
13
13
 
@@ -49,6 +49,9 @@ Run options:
49
49
  --no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
50
50
  --save-site <dir> Also save the site's responses and the steps taken in this folder, so "replay" can audit
51
51
  the same pages again offline. Its findings become the expected ones.
52
+ --save-steps <file> Write the steps each journey took into "journeys[].steps" of this config file (created
53
+ if missing), so "run --config <file> --scripted --no-ai" repeats them with no AI model
54
+ or API key. A journey run on several viewports keeps the steps of the first.
52
55
 
53
56
  Replay options:
54
57
  --update Make this replay's findings the expected ones (after checking that the changes are right).
@@ -103,6 +106,7 @@ async function main() {
103
106
  reports: { type: "string" },
104
107
  "public-url": { type: "string" },
105
108
  "save-site": { type: "string" },
109
+ "save-steps": { type: "string" },
106
110
  update: { type: "boolean", default: false },
107
111
  help: { type: "boolean", short: "h", default: false },
108
112
  version: { type: "boolean", short: "v", default: false },
@@ -193,6 +197,16 @@ async function main() {
193
197
  if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
194
198
  }
195
199
  if (result.saveError) process.exitCode = 1;
200
+ if (values["save-steps"]) {
201
+ try {
202
+ const { saved, skipped } = await writeSavedSteps(values["save-steps"], { site: loaded.config.site, plan: result.plan, journeys: result.journeys, runs: result.runs });
203
+ if (saved.length) console.log(`Saved the steps of ${saved.join(", ")} in ${values["save-steps"]}. Repeat them with: aitd run --config ${values["save-steps"]} --scripted --no-ai`);
204
+ if (skipped.length) console.log(`No steps to save for ${skipped.join(", ")}.`);
205
+ } catch (err) {
206
+ console.log(`Could not save the steps in ${values["save-steps"]}: ${err.message}`);
207
+ process.exitCode = 1;
208
+ }
209
+ }
196
210
  }
197
211
 
198
212
  main().catch((err) => {
package/src/i18n.js CHANGED
@@ -121,6 +121,11 @@ const en = {
121
121
  missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
122
122
  missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
123
123
  duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
124
+ purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, and ad platforms can't optimize bids on it.`,
125
+ purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}, which is not a number, so the revenue is lost. Send a plain number such as 49.90, with no currency sign or thousands separator.`,
126
+ purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} was sent with currency ${q(c)}, which is not a 3-letter ISO 4217 code in capitals (such as USD, EUR or TRY). Platforms may drop the revenue or convert it wrongly.`,
127
+ purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
128
+ purchaseMismatch: (id, diffs) => `GA4 and Meta reported ${id ? `order ${q(id)}` : "the purchase"} differently: ${diffs.map(([f, g, m]) => `${f} ${q(g)} in GA4 and ${q(m)} in Meta`).join("; ")}. One of the tags reads the wrong data.`,
124
129
  serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}. Server-side copies of Meta and TikTok events never pass through the browser, so they can't be seen here.`,
125
130
  dedupIdMissing: (platform, name) => `${platform} ${q(name)} was sent from the browser without an event ID, while a server-side setup was seen on this site. If the server also sends this event, ${platform} can't match the two and counts it twice.`,
126
131
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
@@ -263,6 +268,11 @@ const tr = {
263
268
  missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
264
269
  missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
265
270
  duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
271
+ purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez ve reklam platformları teklifleri buna göre optimize edemez.`,
272
+ purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} sayı olmayan ${q(v)} değeriyle gönderildi, bu yüzden gelir kayboluyor. Para birimi işareti veya binlik ayırıcı olmadan 49.90 gibi düz bir sayı gönderin.`,
273
+ purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} ${q(c)} para birimiyle gönderildi; bu, büyük harfli 3 harfli bir ISO 4217 kodu değil (USD, EUR veya TRY gibi). Platformlar geliri atabilir veya yanlış çevirebilir.`,
274
+ purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
275
+ purchaseMismatch: (id, diffs) => `GA4 ve Meta ${id ? `${q(id)} siparişini` : "satın almayı"} farklı bildirdi: ${diffs.map(([f, g, m]) => `${f === "value" ? "değer" : "para birimi"} GA4'te ${q(g)}, Meta'da ${q(m)}`).join("; ")}. Etiketlerden biri yanlış veriyi okuyor.`,
266
276
  serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}. Meta ve TikTok olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.`,
267
277
  dedupIdMissing: (platform, name) => `${platform} ${q(name)} tarayıcıdan olay kimliği olmadan gönderildi; sitede sunucu tarafı bir kurulum görüldü. Sunucu da bu olayı gönderiyorsa ${platform} ikisini eşleştiremez ve iki kez sayar.`,
268
278
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
package/src/replay.js CHANGED
@@ -24,6 +24,53 @@ export function replayableJourney(run) {
24
24
  };
25
25
  }
26
26
 
27
+ /**
28
+ * Writes the steps each journey took into journeys[].steps of a config file, so a later
29
+ * --scripted --no-ai run repeats them without an AI model. Creates the file when it doesn't exist.
30
+ * A journey run on several viewports keeps the steps of its first one. Journeys that took no
31
+ * repeatable steps are left as they were. A config with no plan gets the run's, so ready-made
32
+ * journeys keep their expected events.
33
+ * @returns {Promise<{ saved: string[], skipped: string[] }>}
34
+ */
35
+ export async function writeSavedSteps(file, { site, plan, journeys, runs }) {
36
+ let config = {};
37
+ const text = await readFile(file, "utf8").catch((err) => {
38
+ if (err.code === "ENOENT") return null;
39
+ throw new Error(`Could not read ${file}: ${err.message}`);
40
+ });
41
+ if (text !== null) {
42
+ try {
43
+ config = JSON.parse(text);
44
+ } catch (err) {
45
+ throw new Error(`Could not read ${file}: ${err.message}`);
46
+ }
47
+ }
48
+ config.site ??= site;
49
+ if (config.plan === undefined && plan?.events?.length) config.plan = plan;
50
+ const list = [...(config.journeys ?? [])];
51
+ const saved = [];
52
+ const skipped = [];
53
+ for (const journey of journeys) {
54
+ const run = runs.find((r) => r.journeyId === journey.id);
55
+ const { viewport: _v, ...steps } = run ? replayableJourney(run) : { steps: [] };
56
+ if (!steps.steps.length && !steps.start) {
57
+ skipped.push(journey.id);
58
+ continue;
59
+ }
60
+ const i = list.findIndex((j) => j.id === journey.id);
61
+ const { start: _s, steps: _st, incomplete: _in, ...kept } = i === -1 ? { id: journey.id, ...(journey.goal ? { goal: journey.goal } : {}) } : list[i];
62
+ const entry = { ...kept, ...steps, id: journey.id };
63
+ if (i === -1) list.push(entry);
64
+ else list[i] = entry;
65
+ saved.push(journey.id);
66
+ }
67
+ if (saved.length) {
68
+ await mkdir(path.dirname(path.resolve(file)), { recursive: true });
69
+ await writeFile(file, `${JSON.stringify({ ...config, journeys: list }, null, 2)}\n`);
70
+ }
71
+ return { saved, skipped };
72
+ }
73
+
27
74
  /** One line per finding, sorted: what a replay is compared on. */
28
75
  export function findingKeys(findings) {
29
76
  return findings.map((f) => [f.severity, f.check, f.journey, f.viewport, f.platform ?? "-", f.event ?? "-"].join(" ")).sort();