@analitikisler/ai-tag-debugger 0.14.0 → 0.15.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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.15.0 (2026-10-17)
4
+
5
+ - The first run learns the plan. When a site has no saved plan, `aitd run` lists the events it already sends and asks whether to save them as its plan. Add `--yes` to save without asking; in a script without `--yes`, nothing is saved.
6
+ - With `--against`, the report header says how the plan changed since the version before it: `Plan changed since <id>: 1 added, 0 removed, 2 changed`, or `Plan unchanged since <id>`. Each change is listed. `capture.json` has the same comparison under `planChanges`.
7
+ - `aitd plan import` reads Google Sheets downloads: the events CSV with its `<Doc> - user_properties.csv` beside it, and checkboxes as yes and no (TRUE and FALSE).
8
+ - Consent scenarios: each audit opens the start page once per consent choice (accept all, reject all, analytics only) and shows the hits each tag sent after each choice. Analytics only works with OneTrust and Cookiebot. Set `"consent_scenarios": false` in the config to turn them off.
9
+ - Tag inventory: the report lists every vendor the site sent requests to, first or third party, the pages it was seen on, and its cookie names (never values). Each network call in `capture.json` now has a `pageUrl`, redacted unless `keep_bodies` is on.
10
+
3
11
  ## 0.14.0 (2026-10-17)
4
12
 
5
13
  - The MCP server has a `what_changed` tool that returns only the "since last run" diff for a site: what was fixed and what is new, without browsing or an AI.
package/README.md CHANGED
@@ -58,6 +58,10 @@ By default an audit checks GA4, Google Ads, Meta and TikTok, and also any other
58
58
 
59
59
  Requests to platforms you didn't choose are still answered locally, so they never reach those accounts either. They are left out of the timeline, the CSV and the checks, and only counted: the report lists them with their request counts (for example "61 out-of-scope requests were skipped: Microsoft Clarity, DoubleClick"), and each step says how many it skipped. Without `--platforms`, any platform your measurement plan names or the site sends to is added to the default list. Planned events for platforms you did leave out are listed in one info finding instead of being reported as missing. Tag manager and consent platform requests are always shown.
60
60
 
61
+ ### Consent scenarios
62
+
63
+ Each audit also opens the start page once per consent choice, each time in a fresh browser: accept all, reject all and analytics only. The choice goes through the consent platform's own API (OneTrust, Cookiebot, Usercentrics or Didomi) when it has one, or else by clicking the banner's buttons. The report then shows how many hits each tag sent after each choice. Analytics only works with OneTrust and Cookiebot; with other platforms that column says "Not supported here". A tag that sends after reject all is flagged, except cookieless Consent Mode pings. An advertising tag that sends after analytics only is flagged unless it was sent with ad storage denied. The table is at the top of the **Journeys** tab and above the findings in `report.md`. It is skipped when the page shows no banner the tool can answer, and when a saved site is replayed. No AI model is called, but the start page is visited three more times. Set `"consent_scenarios": false` in the config to turn it off.
64
+
61
65
  ## How it works
62
66
 
63
67
  1. **Browse:** the AI model gets a goal in plain language ("add any product to the cart and complete checkout with the test payment") and a text view of the page. It acts through two tools, `browser_action` (goto, click, fill, select, scroll, wait) and `finish`, one step at a time with Playwright.
@@ -238,7 +242,7 @@ Events your site already sends are marked `"seen": true`.
238
242
 
239
243
  Review the plan before you use it. The examples and suggestions come from the pages the AI saw, so check that each event fits your business. The extra fields are ignored by the checks.
240
244
 
241
- **Spreadsheet import and export.** `aitd plan export <plan> --site <url>` writes `plan.xlsx` next to the plan, with one row per event parameter on an `events` tab (`event, platform, parameter, required, example, trigger, description`) and one row per user property on a `user_properties` tab. Add `--format csv` to get `events.csv` and `user_properties.csv` instead; CSV needs no extra package. `.xlsx` uses one small library, `fflate`, which is installed with the tool as an optional dependency. `aitd plan import <file> --site <url>` reads the spreadsheet back and saves it as a new plan folder. Import never edits an existing plan. Unknown columns are ignored with a warning, and an error names the tab, row and fix, for example `events, row 7: "required" must be yes or no (found "maybe").`
245
+ **Spreadsheet import and export.** `aitd plan export <plan> --site <url>` writes `plan.xlsx` next to the plan, with one row per event parameter on an `events` tab (`event, platform, parameter, required, example, trigger, description`) and one row per user property on a `user_properties` tab. Add `--format csv` to get `events.csv` and `user_properties.csv` instead; CSV needs no extra package. `.xlsx` uses one small library, `fflate`, which is installed with the tool as an optional dependency. `aitd plan import <file> --site <url>` reads the spreadsheet back (from Google Sheets, use File > Download > Microsoft Excel (.xlsx), or download each tab as CSV: `Shop plan - events.csv` is imported together with `Shop plan - user_properties.csv` beside it, and checkbox cells work as yes/no) and saves it as a new plan folder. Import never edits an existing plan. Unknown columns are ignored with a warning, and an error names the tab, row and fix, for example `events, row 7: "required" must be yes or no (found "maybe").`
242
246
 
243
247
  `measurement-plan.json` looks like this:
244
248
 
@@ -263,6 +267,15 @@ Event names are matched loosely, so `dl_add_to_cart` (Elevar), `addToCart` and `
263
267
 
264
268
  `aitd plan list --site <url>` lists the site's plan IDs, oldest first.
265
269
 
270
+ **Learn the plan from the first run.** When a site has no saved plan, a run without `--against` or `--out` lists the events the site already sends and asks whether to save them as its plan:
271
+
272
+ ```
273
+ No plan yet for this site. It already sends 3 events: ga4:page_view, ga4:purchase, meta:PageView
274
+ Save them as the site's plan? (Y/n)
275
+ ```
276
+
277
+ Say yes, and the next runs can check against it with `--against latest`. A parameter that every hit of an event carries becomes one of its required parameters. No values are copied, so nothing a visitor typed ends up in the plan. In a script or CI job nothing is saved unless you add `--yes`; the run then prints the `aitd plan --site` command for drafting a fuller plan.
278
+
266
279
  ### Plan vs. site
267
280
 
268
281
  When a run uses `--against`, the report starts with a **Plan vs. site** section, in `report.html` and `report.md`. Each planned event lands in one bucket:
@@ -278,6 +291,12 @@ When a run uses `--against`, the report starts with a **Plan vs. site** section,
278
291
 
279
292
  The coverage score is implemented divided by the planned events that were checkable (all except not checked), shown overall and per platform, for example `Coverage 7 of 11 (64%), 2 events not checked`. The same entries are in `plan-check.json`, one per planned event with its bucket. Exit codes don't change: `--fail-on` still looks at finding severity. Without `--against`, the report is exactly as before.
280
293
 
294
+ When the plan is not the site's first, the header compares it with the version saved before it: `Plan changed since 2026-10-01_0900: 1 added, 0 removed, 2 changed`, or `Plan unchanged since 2026-10-01_0900`. The header in `report.html` and `report.md` then lists each change: `+` for an added event, `−` for a removed one and `~` for a changed one, followed by what changed (required parameters, parameters or journeys). The first version has nothing to compare with, and `capture.json` keeps the same comparison under `planChanges`.
295
+
296
+ ### Tag inventory
297
+
298
+ Every report also has a **tag inventory**: each vendor the site sent requests to, marked first party when its requests went to the site's own domain, third party when they went to the vendor, or both. It shows how many pages the vendor was seen on and the cookies tied to it. Cookies that no vendor owns are listed apart, as first or third party. Only cookie names are recorded, never their values. It is in the **Scope and plan** tab of `report.html` and as a table in `report.md`.
299
+
281
300
  ### Folder layout and plan IDs
282
301
 
283
302
  Plans and runs are kept in `aitd/` in the folder you run the tool from:
@@ -392,7 +411,9 @@ When a journey ran on both viewports, only the first viewport's steps are kept.
392
411
  | `journeys[].steps` | Optional fixed steps for `--scripted` runs: `{ "action": "click", "selector": "#add-to-cart" }`. |
393
412
  | `allowed_hosts` | Other hosts journeys may visit, e.g. `["checkout.payments-provider.com"]` for a hosted checkout. The browser otherwise stays on the `site` host and its subdomains. Same as `--allow-host`. |
394
413
  | `providers` | Tracking platforms to check, e.g. `["ga4", "meta"]`. Default `["ga4", "google_ads", "meta", "tiktok"]`, plus any other platform the site sends to while this is unset. Same as `--platforms`. |
414
+ | `campaign_checks` | Default `true`. Each run opens the first page like an ad click (UTM parameters, `gclid`, `fbclid`, or the ones already in the start address) and checks they survive redirects and reach the hits. Set to `false` to open the plain address. |
395
415
  | `block_hits` | Default `true`. Set to `false` to let hits reach your real endpoints. |
416
+ | `consent_scenarios` | Default `true`. Set to `false` to skip the [consent scenarios](#consent-scenarios), which visit the start page once per consent choice. |
396
417
  | `language` | Report language, `en` (default) or `tr`. Same as `--lang`. |
397
418
  | `keep_bodies` | Default `false`. Set to `true` to store tracking requests as sent, without redacting user data. Same as `--keep-bodies`. |
398
419
  | `branding` | Default `true`. Set to `false` to leave the Analitik İşler logo and the "Generated by AI Tag Debugger · analitikisler.com" line out of the reports. Same as `--no-branding`, or `branding: false` on the MCP `finish_audit` and `run_audit` tools. |
@@ -477,12 +498,12 @@ Output:
477
498
  - `report.html`: one file with four tabs. Links work across tabs (a finding's "Where" chip opens that step), and every view has its own URL you can bookmark or share.
478
499
  - **Overview**: problem counts, the AI summary (most important problem first, plus things worth checking that the findings don't cover), the journeys with their problem counts, a **This run** card (platforms, AI provider and model, plan, journeys, viewports, language, version), and, with `--against`, the [Plan vs. site](#plan-vs-site) coverage. When the AI took a different route through the site than in the last run, the header names the journeys whose route changed, and the **Changed since last run** section shows the pages before and after.
479
500
  - **Findings**: grouped by who fixes them (Developer, Tag Manager, Marketing) and filterable by severity. Each one has its impact, likely cause, evidence, numbered fix steps, the steps where it happened, and a **How to verify** list of tools to check the fix in, such as Tag Assistant, GA4 DebugView, Meta Test Events or TikTok Pixel Helper.
480
- - **Journeys**: one journey at a time. A **Consent** card at the top lists the Consent Mode default and every update, one tile per signal. Below it are the steps on the left and the selected step on the right. It opens on the first step with a problem. Each step has four tabs:
501
+ - **Journeys**: one journey at a time. Above them, when the start page has a consent banner, the [consent scenarios](#consent-scenarios) table. A **Consent** card at the top lists the Consent Mode default and every update, one tile per signal. Below it are the steps on the left and the selected step on the right. It opens on the first step with a problem. Each step has four tabs:
481
502
  - **Timeline**: the step in order. Each dataLayer push comes first, then the GTM event it raised (or the consent command), then the platform hits it caused. Hits with no push are listed last.
482
503
  - **Events**: each tracking hit with its parameters, consent state, the dataLayer push and request behind it, and a note when a finding points at it.
483
504
  - **dataLayer**: every push in the step.
484
505
  - **Network requests**: every request to the chosen platforms, decoded into a parameter table with a `?` tip on each parameter. A batched GA4 request shows each of its events.
485
- - **Scope and plan**: whether each planned event was sent on each journey, and the platforms checked or left out, with request counts.
506
+ - **Scope and plan**: whether each planned event was sent on each journey, and the platforms checked or left out, with request counts. The [tag inventory](#tag-inventory) lists each vendor with its party, pages and cookies.
486
507
 
487
508
  Events and requests can be filtered by source. GA4 item strings (`pr1`…) and dataLayer `ecommerce.items` are shown as item cards, with custom item parameters in their own group. The report has no scripts: tabs and filters are plain HTML and CSS (they need Chrome 105, Safari 15.4 or Firefox 121 or newer), and printing shows every tab. The logos are embedded, so nothing is loaded from the internet. `--no-branding` leaves out the Analitik İşler logo and credit line.
488
509
  - `timeline.csv`: the same timeline as one row per hit, dataLayer push and network call, ready for Excel or Google Sheets.
package/README.tr.md CHANGED
@@ -58,6 +58,10 @@ Varsayılan olarak bir denetim GA4, Google Ads, Meta ve TikTok'u kontrol eder; a
58
58
 
59
59
  Seçmediğiniz platformlara giden istekler de yerelde yanıtlanır, yani o hesaplara da ulaşmaz. Bu istekler zaman akışına, CSV'ye ve kontrollere alınmaz, sadece sayılır: rapor bunları istek sayılarıyla listeler (örneğin "Kapsam dışı 61 istek atlandı: Microsoft Clarity, DoubleClick") ve her adım kaç istek atladığını gösterir. `--platforms` vermezseniz, ölçümleme planınızda geçen ya da sitenin gönderdiği her platform varsayılan listeye eklenir. Dışarıda bıraktığınız platformlar için planlanmış event'ler eksik olarak raporlanmaz, tek bir bilgi bulgusunda listelenir. Etiket yöneticisi ve çerez onayı platformu istekleri her zaman gösterilir.
60
60
 
61
+ ### Onay senaryoları
62
+
63
+ Her denetim ayrıca başlangıç sayfasını her çerez onay seçimi için bir kez açar; her seçim yeni bir tarayıcıda yapılır: tümünü kabul, tümünü reddet ve yalnızca analitik. Seçim, consent platformunun kendi API'siyle (OneTrust, Cookiebot, Usercentrics ya da Didomi) yapılır; API yoksa bannerın düğmelerine tıklanır. Rapor, her seçimden sonra her etiketin kaç istek gönderdiğini gösterir. Yalnızca analitik seçeneği OneTrust ve Cookiebot ile çalışır; diğer platformlarda o sütun "Burada desteklenmiyor" yazar. Tümünü reddet'ten sonra gönderilen bir etiket işaretlenir; Consent Mode'un çerezsiz ping'leri hariç. Yalnızca analitik'ten sonra gönderilen bir reklam etiketi, reklam depolaması reddedilmiş olarak gönderilmediği sürece işaretlenir. Tablo **Yolculuklar** sekmesinin en üstünde ve `report.md` içinde bulgulardan önce yer alır. Başlangıç sayfasında aracın yanıtlayabileceği bir banner yoksa ya da kayıtlı bir site tekrar oynatılıyorsa atlanır. Yapay zekâ modeli çağrılmaz, ama başlangıç sayfası üç kez daha ziyaret edilir. Kapatmak için ayar dosyasına `"consent_scenarios": false` yazın.
64
+
61
65
  ## Nasıl çalışır
62
66
 
63
67
  1. **Gezinme:** Yapay zekâ modeli, sade bir dille yazılmış bir hedef ("sepete herhangi bir ürün ekle ve test ödeme yöntemiyle siparişi tamamla") ve sayfanın metin görünümünü alır. İki araçla çalışır: `browser_action` (goto, click, fill, select, scroll, wait) ve `finish`. Playwright ile her seferinde bir adım atar.
@@ -238,7 +242,7 @@ Siteniz zaten gönderdiği event'ler `"seen": true` ile işaretlenir.
238
242
 
239
243
  Kullanmadan önce planı gözden geçirin. Örnekler ve öneriler yapay zekânın gördüğü sayfalardan gelir, bu yüzden her event'in işinize uyduğunu kontrol edin. Ek alanları kontroller yok sayar.
240
244
 
241
- **Elektronik tablo ile içe ve dışa aktarma.** `aitd plan export <plan> --site <url>`, planın yanına `plan.xlsx` yazar: `events` sekmesinde her event parametresi için bir satır (`event, platform, parameter, required, example, trigger, description`) ve `user_properties` sekmesinde her kullanıcı özelliği için bir satır. Bunun yerine `events.csv` ve `user_properties.csv` almak için `--format csv` ekleyin; CSV ek bir paket gerektirmez. `.xlsx`, araçla birlikte isteğe bağlı bir bağımlılık olarak gelen küçük bir kütüphane, `fflate` kullanır. `aitd plan import <dosya> --site <url>` elektronik tabloyu okuyup yeni bir plan klasörü olarak kaydeder. İçe aktarma mevcut bir planı asla düzenlemez. Tanınmayan sütunlar bir uyarıyla yok sayılır; bir hata, sekmeyi, satırı ve düzeltmeyi belirtir, örneğin `events, row 7: "required" must be yes or no (found "maybe").`
245
+ **Elektronik tablo ile içe ve dışa aktarma.** `aitd plan export <plan> --site <url>`, planın yanına `plan.xlsx` yazar: `events` sekmesinde her event parametresi için bir satır (`event, platform, parameter, required, example, trigger, description`) ve `user_properties` sekmesinde her kullanıcı özelliği için bir satır. Bunun yerine `events.csv` ve `user_properties.csv` almak için `--format csv` ekleyin; CSV ek bir paket gerektirmez. `.xlsx`, araçla birlikte isteğe bağlı bir bağımlılık olarak gelen küçük bir kütüphane, `fflate` kullanır. `aitd plan import <dosya> --site <url>` elektronik tabloyu okuyup yeni bir plan klasörü olarak kaydeder. Google E-Tablolar'dan indirirken Dosya > İndir > Microsoft Excel (.xlsx) seçin ya da her sekmeyi CSV olarak indirin: `Shop plan - events.csv` dosyası, yanındaki `Shop plan - user_properties.csv` ile birlikte okunur ve onay kutuları evet/hayır olarak tanınır. İçe aktarma mevcut bir planı asla düzenlemez. Tanınmayan sütunlar bir uyarıyla yok sayılır; bir hata, sekmeyi, satırı ve düzeltmeyi belirtir, örneğin `events, row 7: "required" must be yes or no (found "maybe").`
242
246
 
243
247
  `measurement-plan.json` şöyle görünür:
244
248
 
@@ -263,6 +267,15 @@ Event adları gevşek eşleştirilir: `dl_add_to_cart` (Elevar), `addToCart` ve
263
267
 
264
268
  `aitd plan list --site <url>`, sitenin plan kimliklerini en eskiden en yeniye listeler.
265
269
 
270
+ **Planı ilk çalıştırmadan öğrenin.** Sitenin kayıtlı bir planı yokken, `--against` ya da `--out` olmadan yapılan bir çalıştırma, sitenin zaten gönderdiği event'leri listeler ve bunları sitenin planı olarak kaydedip kaydetmeyeceğinizi sorar:
271
+
272
+ ```
273
+ No plan yet for this site. It already sends 3 events: ga4:page_view, ga4:purchase, meta:PageView
274
+ Save them as the site's plan? (Y/n)
275
+ ```
276
+
277
+ Evet derseniz sonraki çalıştırmalar `--against latest` ile bu plana karşı kontrol yapabilir. Bir event'in her isteğinde bulunan bir parametre, o event'in zorunlu parametrelerinden biri olur. Hiçbir değer kopyalanmaz, bu yüzden bir ziyaretçinin yazdıkları plana girmez. Bir betikte ya da CI işinde `--yes` eklemedikçe hiçbir şey kaydedilmez; çalıştırma bunun yerine daha ayrıntılı bir plan taslağı için `aitd plan --site` komutunu yazdırır.
278
+
266
279
  ### Plan ve site
267
280
 
268
281
  Bir çalıştırma `--against` kullandığında rapor, `report.html` ve `report.md` dosyalarında **Plan ve site** bölümüyle başlar. Planlanan her event bir kovaya düşer:
@@ -278,6 +291,12 @@ Bir çalıştırma `--against` kullandığında rapor, `report.html` ve `report.
278
291
 
279
292
  Kapsam puanı, uygulanmış olanların kontrol edilebilir planlanmış event'lere (kontrol edilmedi hariç hepsi) bölünmesiyle bulunur; genel ve platform başına gösterilir, örneğin `Kapsam 11 olaydan 7 (%64), 2 olay kontrol edilmedi`. Aynı kayıtlar `plan-check.json` dosyasında da bulunur: planlanan her event için kovasıyla birlikte bir kayıt. Çıkış kodları değişmez: `--fail-on` yine bulgu önemine bakar. `--against` olmadan rapor tam olarak eskisi gibidir.
280
293
 
294
+ Kontrol edilen plan sitenin ilk planı değilse, rapor başlığı onu bir önceki kayıtlı sürümle karşılaştırır: `Plan 2026-10-01_0900 sürümünden bu yana değişti: 1 eklendi, 0 çıkarıldı, 2 değişti` ya da `Plan 2026-10-01_0900 sürümünden bu yana değişmedi`. Başlık her değişikliği `report.html` ve `report.md` içinde listeler: eklenen event için `+`, çıkarılan için `−`, değişen için `~`, ardından neyin değiştiği (zorunlu parametreler, parametreler ya da yolculuklar). İlk sürümün karşılaştırılacak bir şeyi yoktur; `capture.json` aynı karşılaştırmayı `planChanges` altında da saklar.
295
+
296
+ ### Etiket envanteri
297
+
298
+ Her raporda ayrıca bir **etiket envanteri** bulunur: sitenin istek gönderdiği her sağlayıcı; istekleri sitenin kendi alan adına gittiyse birinci taraf, sağlayıcıya gittiyse üçüncü taraf, ikisine de gittiyse ikisi olarak işaretlenir. Sağlayıcının görüldüğü sayfa sayısını ve ona bağlı çerezleri gösterir. Hiçbir sağlayıcıya ait olmayan çerezler ayrıca birinci ya da üçüncü taraf olarak listelenir. Yalnızca çerez adları kaydedilir, değerleri asla. Envanter `report.html` içindeki **Kapsam ve plan** sekmesinde, `report.md` içinde ise bir tablo olarak yer alır.
299
+
281
300
  ### Klasör yapısı ve plan kimlikleri
282
301
 
283
302
  Planlar ve çalıştırmalar, aracı çalıştırdığınız klasördeki `aitd/` içinde tutulur:
@@ -392,7 +411,9 @@ Bir yolculuk her iki görünümde de çalıştıysa yalnızca ilk görünümün
392
411
  | `journeys[].steps` | `--scripted` çalıştırmaları için isteğe bağlı sabit adımlar: `{ "action": "click", "selector": "#add-to-cart" }`. |
393
412
  | `allowed_hosts` | Yolculukların ziyaret edebileceği başka ana makineler, örneğin barındırılan bir ödeme için `["checkout.payments-provider.com"]`. Tarayıcı aksi halde `site` ana makinesinde ve alt alan adlarında kalır. `--allow-host` ile aynıdır. |
394
413
  | `providers` | Kontrol edilecek takip platformları, örneğin `["ga4", "meta"]`. Varsayılan `["ga4", "google_ads", "meta", "tiktok"]`; bu ayar boşken sitenin gönderdiği diğer platformlar da eklenir. `--platforms` ile aynıdır. |
414
+ | `campaign_checks` | Varsayılan `true`. Her çalıştırma ilk sayfayı bir reklam tıklaması gibi açar (UTM parametreleri, `gclid`, `fbclid` ya da başlangıç adresinde zaten olanlar) ve bunların yönlendirmelerden geçip isteklere ulaştığını denetler. Düz adresi açmak için `false` yapın. |
395
415
  | `block_hits` | Varsayılan `true`. Takip isteklerinin gerçek uç noktalarınıza ulaşmasına izin vermek için `false` yapın. |
416
+ | `consent_scenarios` | Varsayılan `true`. [Onay senaryolarını](#onay-senaryoları) atlamak için `false` yapın; her onay seçimi için başlangıç sayfasını bir kez ziyaret ederler. |
396
417
  | `language` | Rapor dili, `en` (varsayılan) ya da `tr`. `--lang` ile aynıdır. |
397
418
  | `keep_bodies` | Varsayılan `false`. Takip isteklerini gönderildiği gibi, kullanıcı verisi gizlenmeden saklamak için `true` yapın. `--keep-bodies` ile aynıdır. |
398
419
  | `branding` | Varsayılan `true`. Analitik İşler logosunu ve "Generated by AI Tag Debugger · analitikisler.com" satırını raporlardan çıkarmak için `false` yapın. `--no-branding` ile aynıdır, ya da MCP `finish_audit` ve `run_audit` araçlarında `branding: false`. |
@@ -479,12 +500,12 @@ aitd mcp [--http] [--port <n>] [--host <adres>] [--token <gizli>] [--public-url
479
500
  - `report.html`: dört sekmeli tek bir dosya. Bağlantılar sekmeler arasında çalışır (bir bulgunun "Nerede" çipi o adımı açar) ve her görünümün paylaşılabilir ya da yer imine eklenebilir kendi URL'si vardır.
480
501
  - **Genel bakış**: sorun sayıları, yapay zekâ özeti (en önemli sorun önce, ve bulguların kapsamadığı kontrol edilmeye değer şeyler), yolculuklar ve sorun sayıları, bir **Bu çalıştırma** kartı (platformlar, yapay zekâ sağlayıcısı ve modeli, plan, yolculuklar, ekranlar, dil, sürüm) `--against` ile [Plan ve site](#plan-ve-site) kapsamı. Yapay zekâ siteyi son çalıştırmadan farklı bir yoldan gezdiyse, başlıkta yolu değişen yolculukları gösteren bir not ve **Son çalıştırmadan bu yana değişenler** bölümünde değişiklikten önceki ve sonraki sayfalar bulunur.
481
502
  - **Bulgular**: düzeltmesini kimin yapacağına göre gruplu (Yazılımcı, Tag Manager, Pazarlama) ve önem düzeyine göre filtrelenebilir. Her birinde etkisi, olası nedeni, kanıtı, numaralı düzeltme adımları, olayın yaşandığı adımlar ve **Nasıl doğrulanır** listesi bulunur; bu liste düzeltmeyi kontrol edebileceğiniz araçlara bağlantı verir (Tag Assistant, GA4 DebugView, Meta Test Events, TikTok Pixel Helper).
482
- - **Yolculuklar**: her seferinde bir yolculuk. Üstteki **Onay** kartı Consent Mode varsayılanını ve her güncellemeyi, sinyal başına bir kutuyla gösterir. Altında solda adımları, sağda seçili adım. Sorunlu ilk adımda açılır. Her adımın dört sekmesi vardır:
503
+ - **Yolculuklar**: her seferinde bir yolculuk. Başlangıç sayfasında bir onay bannerı varsa, yolculukların üstünde [onay senaryoları](#onay-senaryoları) tablosu yer alır. Üstteki **Onay** kartı Consent Mode varsayılanını ve her güncellemeyi, sinyal başına bir kutuyla gösterir. Altında solda adımları, sağda seçili adım. Sorunlu ilk adımda açılır. Her adımın dört sekmesi vardır:
483
504
  - **Zaman akışı**: adım sırasıyla. Önce her dataLayer gönderimi, ardından onu tetikleyen GTM event'i (ya da çerez onayı komutu), sonra da yol açtığı platform istekleri gelir. Gönderimi olmayan istekler en sonda listelenir.
484
505
  - **Event'ler**: her takip isteği, parametreleri, çerez onayı durumu, arkasındaki dataLayer gönderimi ve isteği ve bir bulgu buraya işaret ediyorsa bir not.
485
506
  - **dataLayer**: adımdaki her gönderim.
486
507
  - **Ağ istekleri**: seçilen platformlara giden her istek; her parametrede bir `?` ipucuyla bir parametre tablosuna çözülmüş. Toplu bir GA4 isteği, içindeki her event'i gösterir.
487
- - **Kapsam ve plan**: planlanan her event'in her yolculukta gönderilip gönderilmediği ve kontrol edilen ya da dışarıda bırakılan platformlar, istek sayılarıyla.
508
+ - **Kapsam ve plan**: planlanan her event'in her yolculukta gönderilip gönderilmediği ve kontrol edilen ya da dışarıda bırakılan platformlar, istek sayılarıyla. Ayrıca [etiket envanteri](#etiket-envanteri): her sağlayıcı, birinci ya da üçüncü taraf, sayfaları ve çerezleri.
488
509
 
489
510
  Event'ler ve istekler kaynağa göre filtrelenebilir. GA4 ürün dizgileri (`pr1`…) ve dataLayer `ecommerce.items` ürün kartları olarak gösterilir; özel ürün parametreleri kendi grubunda yer alır. Raporda betik yoktur: sekmeler ve filtreler düz HTML ve CSS'tir (Chrome 105, Safari 15.4 ya da Firefox 121 ve sonrası gerekir) ve yazdırırken her sekme görünür. Logolar gömülüdür, bu yüzden internetten hiçbir şey yüklenmez. `--no-branding`, Analitik İşler logosunu ve kredi satırını çıkarır.
490
511
  - `timeline.csv`: aynı zaman akışı, her isteğe, dataLayer gönderimine ve ağ çağrısına bir satır olarak; Excel ya da Google Sheets için hazır.
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.14.0",
4
+ "version": "0.15.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/agent.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import path from "node:path";
2
2
  import { AuditSession } from "./browser.js";
3
+ import { withCampaignParams } from "./campaign.js";
3
4
  import { messages } from "./i18n.js";
4
5
  import { createProvider, PROVIDERS } from "./providers.js";
5
6
 
@@ -200,8 +201,10 @@ export async function runJourney(config, journey, viewport, opts = {}) {
200
201
  });
201
202
  try {
202
203
  await session.start();
203
- const startUrl = new URL(journey.start ?? "/", config.site).href;
204
- const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${startUrl}` });
204
+ // The first page is opened the way an ad click opens it, so the campaign checks have something to follow.
205
+ const start = new URL(journey.start ?? "/", config.site).href;
206
+ const startUrl = config.campaign_checks === false ? start : withCampaignParams(start);
207
+ const startError = await session.act({ action: "goto", url: startUrl, reason: `Open ${start}` });
205
208
  // A replayed journey that was unfinished when saved keeps its saved note, however it fails this time.
206
209
  if (startError) return { ...collect(session, journey, viewport), completed: false, note: journey.incomplete ?? `Could not open the start page: ${startError}` };
207
210
 
@@ -248,5 +251,6 @@ export function collect(session, journey, viewport) {
248
251
  bodyBudgetStep: session.bodyBudgetStep,
249
252
  startedAt: session.startedAt,
250
253
  pageErrors: session.errors,
254
+ landing: session.landing,
251
255
  };
252
256
  }
package/src/audit.js CHANGED
@@ -1,7 +1,7 @@
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
3
  import { newRunDir } from "./workspace.js";
4
- import { compareRuns, previousRun, routeHints } from "./changes.js";
4
+ import { compareRuns, previousPlanChanges, previousRun, routeHints } from "./changes.js";
5
5
  import { sendAlerts } from "./notify.js";
6
6
  import { readFile, mkdir, rm, writeFile } from "node:fs/promises";
7
7
  import path from "node:path";
@@ -14,6 +14,7 @@ import { createProvider, alsoSet } from "./providers.js";
14
14
  import { filterFindings, JOURNEY_PRESETS, planRun } from "./journeys.js";
15
15
  import { resolveProviders } from "./parsers.js";
16
16
  import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
17
+ import { runConsentScenarios } from "./consent.js";
17
18
 
18
19
  export async function readJson(file, baseDir = ".") {
19
20
  const full = path.resolve(baseDir, file);
@@ -41,19 +42,19 @@ export async function loadConfig(configFile) {
41
42
  * previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
42
43
  * the "Changed since last run" section. Returns { reportPath, changes }.
43
44
  */
44
- export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, ai = null, against = null, branding = config.branding !== false, previous = undefined }) {
45
+ export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, ai = null, against = null, branding = config.branding !== false, previous = undefined, planChanges = null, consentScenarios = null }) {
45
46
  await mkdir(outDir, { recursive: true });
46
47
  const site = new URL(config.site).hostname;
47
48
  const title = messages(lang).ui.title(site);
48
49
  const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
49
50
  const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
50
51
  const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check, runs });
51
- const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes };
52
+ const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes, planChanges, consentScenarios };
52
53
  await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
53
54
  await writeFile(path.join(outDir, "report.html"), renderHtml(report));
54
55
  await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
55
56
  if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
56
- await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}) }, null, 2));
57
+ await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}), ...(planChanges ? { planChanges } : {}), ...(consentScenarios ? { consentScenarios } : {}) }, null, 2));
57
58
  return { reportPath: path.join(outDir, "report.html"), changes };
58
59
  }
59
60
 
@@ -127,6 +128,11 @@ export async function runAudit(opts) {
127
128
  }
128
129
  }
129
130
 
131
+ // Consent scenarios need the live site, so a replay from a saved site skips them. "consent_scenarios": false turns them off.
132
+ const consentScenarios = opts.replaySite || config.consent_scenarios === false
133
+ ? null
134
+ : await runConsentScenarios(config, { headed: opts.headed, log }).catch((err) => (log(` Consent scenarios failed: ${err.message}`), null));
135
+
130
136
  const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
131
137
  let saveError = null;
132
138
  if (opts.saveSite) {
@@ -149,12 +155,12 @@ export async function runAudit(opts) {
149
155
  // Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
150
156
  const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
151
157
  const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
152
- const { reportPath, changes } = await writeReports({ outDir, config, plan, runs, findings, explained, lang, ai: provider ? { name: provider.name, model: provider.model } : null, against, branding: opts.branding ?? config.branding !== false, previous });
158
+ const { reportPath, changes } = await writeReports({ outDir, config, plan, runs, findings, explained, lang, ai: provider ? { name: provider.name, model: provider.model } : null, against, branding: opts.branding ?? config.branding !== false, previous, planChanges: against ? await previousPlanChanges(opts.dataDir, config.site, against.id, plan).catch(() => null) : null, consentScenarios });
153
159
  if (config.notify && changes) {
154
160
  const { sent, errors } = await sendAlerts({ notify: config.notify, site: new URL(config.site).hostname, changes: { run: path.basename(outDir), ...changes }, lang, reportPath });
155
161
  if (sent.length) log(`\nSent the changes since the last run to ${sent.join(" and ")}.`);
156
162
  for (const e of errors) log(`\nCould not send an alert. ${e}`);
157
163
  }
158
164
  const counts = summarize(groupFindings(findings));
159
- return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, ...(against ? { againstId: against.id } : {}), ...(saveError ? { saveError } : {}) };
165
+ return { journeys, plan, runs, findings, explained, counts, reportPath, outDir, lang, consentScenarios, ...(against ? { againstId: against.id } : {}), ...(saveError ? { saveError } : {}) };
160
166
  }
package/src/browser.js CHANGED
@@ -382,6 +382,7 @@ export class AuditSession {
382
382
  this.dataLayerLog = [];
383
383
  this.network = [];
384
384
  this.providers = resolveProviders(this.opts.providers);
385
+ this.landing = null; // the first page: { step, requested, chain, final }
385
386
  this.detect = this.opts.detect ?? !this.opts.providers?.length;
386
387
  this.skipped = {}; // step index -> { vendor label: requests }
387
388
  this.loadedIn = new Map(); // page address -> the last step whose main-frame navigation reached it
@@ -426,6 +427,8 @@ export class AuditSession {
426
427
  if (frame !== this.page.mainFrame()) return;
427
428
  this.loadedIn.set(pageKey(frame.url()), this.currentStep);
428
429
  this.leaving = null;
430
+ // Script redirects on the first page move where it ends up.
431
+ if (this.landing && this.landing.step === this.currentStep) this.landing.final = frame.url();
429
432
  });
430
433
  // From the moment the main frame starts loading another document, the open page is leaving.
431
434
  this.page.on("request", (request) => {
@@ -464,6 +467,7 @@ export class AuditSession {
464
467
  const vendor = vendorOf(request.url());
465
468
  const call = {
466
469
  step: this.currentStep,
470
+ pageUrl: this.opts.keepBodies ? this.page.url() : redactUrl(this.page.url()).url,
467
471
  t: Date.now(),
468
472
  method: request.method(),
469
473
  url: this.opts.keepBodies ? request.url() : redactUrl(request.url()).url,
@@ -844,7 +848,13 @@ export class AuditSession {
844
848
  step.replay = { action: "goto", url: target };
845
849
  const refused = await this.checkUrl(target);
846
850
  if (refused) return refused;
847
- await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
851
+ const response = await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
852
+ if (!this.landing) {
853
+ // The first page: the address asked for, each redirect on the way, and where it ended up.
854
+ const chain = [];
855
+ for (let r = response?.request(); r; r = r.redirectedFrom()) chain.unshift(r.url());
856
+ this.landing = { step: this.currentStep, requested: target, chain, final: this.page.url() };
857
+ }
848
858
  break;
849
859
  }
850
860
  case "click":
@@ -0,0 +1,28 @@
1
+ // Campaign parameters an ad click puts on the landing address. They have to survive the site's
2
+ // redirects and show up in the page address its tags report, or the visit is credited to "direct".
3
+
4
+ export const CLICK_IDS = ["gclid", "wbraid", "gbraid", "fbclid", "ttclid", "msclkid"];
5
+ export const UTM_PARAMS = ["utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content"];
6
+ export const CAMPAIGN_PARAMS = [...CLICK_IDS, ...UTM_PARAMS];
7
+
8
+ // Values a run adds to a landing address that has none of its own. Hits are held back by default
9
+ // (block_hits), so these never reach a real analytics property.
10
+ const TEST_PARAMS = { utm_source: "aitd", utm_medium: "check", utm_campaign: "aitd-campaign-check", gclid: "aitd-gclid", fbclid: "aitd-fbclid" };
11
+
12
+ /** The campaign parameters in an address, as {name: value}. */
13
+ export function campaignParamsIn(url) {
14
+ try {
15
+ const u = new URL(url);
16
+ return Object.fromEntries(CAMPAIGN_PARAMS.filter((n) => u.searchParams.get(n)).map((n) => [n, u.searchParams.get(n)]));
17
+ } catch {
18
+ return {};
19
+ }
20
+ }
21
+
22
+ /** The address with test campaign parameters added, unless it already carries campaign parameters. */
23
+ export function withCampaignParams(url) {
24
+ if (Object.keys(campaignParamsIn(url)).length) return url;
25
+ const u = new URL(url);
26
+ for (const [k, v] of Object.entries(TEST_PARAMS)) u.searchParams.set(k, v);
27
+ return u.href;
28
+ }
package/src/changes.js CHANGED
@@ -2,7 +2,7 @@ import { readFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import { messages } from "./i18n.js";
4
4
  import { platformLabel } from "./report.js";
5
- import { listRunIds, runsDir } from "./workspace.js";
5
+ import { PLAN_FILE, listPlanIds, listRunIds, plansDir, runsDir } from "./workspace.js";
6
6
 
7
7
  const PROBLEMS = new Set(["broken", "risk", "warning"]);
8
8
  const PLAN_PROBLEMS = new Set(["missing", "missing_params", "wrong_values"]);
@@ -143,3 +143,40 @@ export function changesLines(ch, lang = "en") {
143
143
  ...(ch.routes?.length ? [m.ui.routeChanged(ch.routes.map((r) => `${r.journeyId} (${r.viewport})`).join(", "))] : []),
144
144
  ];
145
145
  }
146
+
147
+ const planKey = (e) => [e.platform ?? "ga4", e.name].join("|");
148
+ const sortedList = (v) => JSON.stringify([...(v ?? [])].sort());
149
+
150
+ /** What differs for an event kept in both plans: its required parameters, parameter list or journeys. */
151
+ function eventChanges(a, b) {
152
+ const out = [];
153
+ if (sortedList(a.required_params) !== sortedList(b.required_params)) out.push("required parameters");
154
+ if (sortedList(Object.keys(a.parameters ?? {})) !== sortedList(Object.keys(b.parameters ?? {}))) out.push("parameters");
155
+ if (sortedList(a.journeys) !== sortedList(b.journeys)) out.push("journeys");
156
+ return out;
157
+ }
158
+
159
+ /**
160
+ * How a plan differs from the version before it: events added, removed and changed.
161
+ * Returns null when there is no earlier version, else { previous, added, removed, changed, same }.
162
+ */
163
+ export function comparePlans(prevPlan, plan, previousId = null) {
164
+ if (!prevPlan || !plan) return null;
165
+ const before = new Map((prevPlan.events ?? []).map((e) => [planKey(e), e]));
166
+ const now = new Map((plan.events ?? []).map((e) => [planKey(e), e]));
167
+ const label = (e) => ({ platform: e.platform ?? "ga4", event: e.name });
168
+ const added = [...now].filter(([k]) => !before.has(k)).map(([, e]) => label(e));
169
+ const removed = [...before].filter(([k]) => !now.has(k)).map(([, e]) => label(e));
170
+ const changed = [...now].filter(([k]) => before.has(k)).map(([k, e]) => ({ ...label(e), what: eventChanges(before.get(k), e) })).filter((c) => c.what.length);
171
+ return { previous: previousId, added, removed, changed, same: !added.length && !removed.length && !changed.length };
172
+ }
173
+
174
+ /** The saved plan right before `planId` for the site, compared with `plan`; null for the first version or an unsaved plan. */
175
+ export async function previousPlanChanges(dataDir, site, planId, plan) {
176
+ if (!planId) return null;
177
+ const ids = await listPlanIds(dataDir, site);
178
+ const prevId = ids.filter((id) => id < planId).at(-1);
179
+ if (!prevId) return null;
180
+ const prevPlan = await readJson(path.join(plansDir(dataDir, site), prevId, PLAN_FILE));
181
+ return comparePlans(prevPlan, plan, prevId);
182
+ }
package/src/checks.js CHANGED
@@ -3,6 +3,7 @@
3
3
 
4
4
  import { messages } from "./i18n.js";
5
5
  import { isOwnAddress } from "./redact.js";
6
+ import { campaignParamsIn } from "./campaign.js";
6
7
 
7
8
  const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
8
9
  const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
@@ -296,6 +297,33 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
296
297
  }
297
298
  }
298
299
 
300
+ // Campaign parameters (gclid, fbclid, UTM) on the landing address: they must survive redirects and reach the hits.
301
+ const landing = run.landing;
302
+ const sent = landing ? campaignParamsIn(landing.requested) : {};
303
+ if (landing && Object.keys(sent).length) {
304
+ const kept = campaignParamsIn(landing.final);
305
+ const lost = Object.keys(sent).filter((n) => kept[n] !== sent[n]);
306
+ const hop = landing.chain.length > 1 ? landing.chain : null;
307
+ if (lost.length) {
308
+ findings.push({ ...base, severity: "broken", check: "campaign_lost_redirect", step: stepLabel(landing.step), detail: d.campaignLost(lost, landing.final), evidence: hop ? hop.join(" -> ") : undefined });
309
+ }
310
+ // Hits the landing page sent whose page address leaves out a parameter the page still had.
311
+ const missing = new Map();
312
+ for (const hit of run.hits) {
313
+ const loc = hit.params?.page_location;
314
+ if (hit.step !== landing.step || !loc || hit.platform === "google_ads") continue;
315
+ const here = campaignParamsIn(loc);
316
+ const gone = Object.keys(kept).filter((n) => kept[n] === sent[n] && here[n] !== sent[n] && hit.params?.[n] !== sent[n]);
317
+ if (gone.length && !missing.has(hit.platform)) missing.set(hit.platform, { hit, gone });
318
+ }
319
+ for (const [platform, { hit, gone }] of missing) {
320
+ findings.push({ ...base, severity: "broken", check: "campaign_missing_in_hits", platform, event: hit.name, step: stepLabel(hit.step), detail: d.campaignMissingInHits(platform, hit.name, gone), evidence: hit.params.page_location });
321
+ }
322
+ if (!lost.length && !missing.size) {
323
+ findings.push({ ...base, severity: "ok", check: "campaign_params", step: stepLabel(landing.step), detail: d.campaignOk(Object.keys(sent)) });
324
+ }
325
+ }
326
+
299
327
  // Consent.
300
328
  const granted = consentGrantedStep(run);
301
329
  // Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
package/src/cli.js CHANGED
@@ -205,6 +205,8 @@ async function main(args = process.argv.slice(2)) {
205
205
  const rel = path.relative(process.cwd(), result.reportPath);
206
206
  console.log(`Report: ${rel.startsWith("..") ? result.reportPath : rel}`);
207
207
 
208
+ if (!against && !values.out) await offerLearnedPlan(result, loaded.config.site, values);
209
+
208
210
  if (failOn !== "never") {
209
211
  const threshold = levels.indexOf(failOn);
210
212
  if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
@@ -222,6 +224,26 @@ async function main(args = process.argv.slice(2)) {
222
224
  }
223
225
  }
224
226
 
227
+ // The first run of a site with no plan: offer what it already tracks as its plan.
228
+ async function offerLearnedPlan(result, site, values) {
229
+ const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
230
+ const { listPlanIds } = await import("./workspace.js");
231
+ if ((await listPlanIds(dataDir, site).catch(() => [])).length) return;
232
+ const { learnPlan } = await import("./learn.js");
233
+ const plan = learnPlan(site, result.runs);
234
+ if (!plan) return;
235
+ console.log(`\nNo plan yet for this site. It already sends ${plan.events.length} ${plan.events.length === 1 ? "event" : "events"}: ${plan.events.map((e) => `${e.platform}:${e.name}`).join(", ")}`);
236
+ let save = values.yes;
237
+ if (!save && process.stdin.isTTY) save = ["", "y", "yes"].includes((await ask("Save them as the site's plan? (Y/n) ")).toLowerCase());
238
+ if (!save) {
239
+ if (!process.stdin.isTTY && !values.yes) console.log("Add --yes to save them as the plan, or draft a fuller one with: aitd plan --site " + site);
240
+ return;
241
+ }
242
+ const { id, dir } = await savePlan(plan, { dataDir, site, branding: !values["no-branding"] });
243
+ const rel = path.relative(process.cwd(), dir);
244
+ console.log(`✓ Saved plan ${id} in ${rel.startsWith("..") ? dir : rel}. Next runs can check against it with --against latest.`);
245
+ }
246
+
225
247
  // aitd "check my GA tags": shows the matching command and runs it once confirmed.
226
248
  async function fromSentence(sentence, values, extra) {
227
249
  const { createProvider } = await import("./providers.js");
package/src/consent.js ADDED
@@ -0,0 +1,154 @@
1
+ // Consent scenarios: the start page opened in a fresh browser once per consent choice
2
+ // (accept all, reject all, analytics only), to show which tags each choice lets through.
3
+ // Runs on its own with every audit when the page shows a consent banner.
4
+ import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
5
+
6
+ export const CONSENT_STATES = ["accept", "reject", "analytics"];
7
+ // Tags that are advertising, so an "analytics only" choice should keep them quiet.
8
+ export const AD_PLATFORMS = new Set(["google_ads", "meta", "tiktok", "pinterest", "snapchat", "x"]);
9
+
10
+ // Runs in the page: makes the choice through the consent platform's own API when it has one,
11
+ // otherwise by clicking the banner's accept or reject button. Returns how, or null when it could not.
12
+ const applyInPage = ({ state, selectors }) => {
13
+ const w = window;
14
+ try {
15
+ if (w.OneTrust?.AllowAll) {
16
+ if (state === "accept") w.OneTrust.AllowAll();
17
+ else {
18
+ w.OneTrust.RejectAll();
19
+ // C0002 is OneTrust's performance (analytics) group.
20
+ if (state === "analytics") w.OneTrust.UpdateConsent?.("Category", "C0002:1");
21
+ }
22
+ return "OneTrust";
23
+ }
24
+ if (w.Cookiebot?.submitCustomConsent) {
25
+ // preferences, statistics, marketing
26
+ w.Cookiebot.submitCustomConsent(state === "accept", state !== "reject", state === "accept");
27
+ return "Cookiebot";
28
+ }
29
+ if (state !== "analytics") {
30
+ if (w.UC_UI?.acceptAllConsents) {
31
+ state === "accept" ? w.UC_UI.acceptAllConsents() : w.UC_UI.denyAllConsents();
32
+ return "Usercentrics";
33
+ }
34
+ if (w.Didomi?.setUserAgreeToAll) {
35
+ state === "accept" ? w.Didomi.setUserAgreeToAll() : w.Didomi.setUserDisagreeToAll();
36
+ return "Didomi";
37
+ }
38
+ }
39
+ } catch {
40
+ // An API that throws falls back to the banner's buttons.
41
+ }
42
+ if (state === "analytics") return null;
43
+ const words = state === "accept"
44
+ ? /^(accept|allow|agree|ok|got it|kabul|tümünü kabul|izin ver|onayla|alle akzeptieren|tout accepter)/i
45
+ : /^(reject|decline|deny|refuse|necessary only|only necessary|reddet|kabul etme|ablehnen|tout refuser)/i;
46
+ const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
47
+ const banners = [...document.querySelectorAll(selectors)].filter(visible);
48
+ for (const n of document.querySelectorAll("body *")) {
49
+ const label = `${n.id} ${typeof n.className === "string" ? n.className : ""}`;
50
+ if (/(cookie|consent|gdpr|kvkk)/i.test(label) && visible(n) && !banners.some((b) => b.contains(n))) banners.push(n);
51
+ }
52
+ for (const banner of banners) {
53
+ const button = [...banner.querySelectorAll("button, a, [role=button], input[type=button], input[type=submit]")]
54
+ .filter(visible)
55
+ .find((b) => words.test((b.innerText || b.value || b.getAttribute("aria-label") || "").trim()));
56
+ if (button) {
57
+ button.click();
58
+ return "banner";
59
+ }
60
+ }
61
+ return null;
62
+ };
63
+
64
+ // A Google hit that says storage is denied is a cookieless Consent Mode ping, which a "no" allows.
65
+ const cookieless = (h) => !!h.consent && h.consent.ad_storage !== "granted" && h.consent.analytics_storage !== "granted";
66
+ const isAnalyticsOnlyOk = (h) => !!h.consent && h.consent.ad_storage !== "granted";
67
+
68
+ /** Hits per platform and event: { n, quiet } where quiet counts the ones sent with storage denied. */
69
+ const countBy = (hits) => {
70
+ const out = {};
71
+ for (const h of hits) {
72
+ const c = ((out[h.platform] ??= {})[h.name] ??= { n: 0, cookieless: 0, adDenied: 0 });
73
+ c.n++;
74
+ if (cookieless(h)) c.cookieless++;
75
+ if (isAnalyticsOnlyOk(h)) c.adDenied++;
76
+ }
77
+ return out;
78
+ };
79
+
80
+ /** Opens the start page, makes one consent choice, opens the page again, and returns the hits before and after. */
81
+ async function runState(config, state, opts) {
82
+ const session = new AuditSession({
83
+ viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
84
+ blockHits: config.block_hits !== false, headless: !opts.headed,
85
+ });
86
+ try {
87
+ await session.start();
88
+ const startError = await session.act({ action: "goto", url: config.site, reason: "Open the start page" });
89
+ if (startError) return { state, error: startError };
90
+ session.beginStep(`Choose: ${state}`);
91
+ const via = await session.page.evaluate(applyInPage, { state, selectors: CMP_BANNER_SELECTORS }).catch(() => null);
92
+ if (!via) return { state, via: null, before: countBy(session.hits) };
93
+ await session.settle();
94
+ session.beginStep("Open the start page again");
95
+ await session.page.reload().catch(() => {});
96
+ await session.settle();
97
+ const choice = session.steps.find((s) => s.label.startsWith("Choose:")).index;
98
+ return {
99
+ state, via,
100
+ before: countBy(session.hits.filter((h) => h.step < choice)),
101
+ after: countBy(session.hits.filter((h) => h.step >= choice)),
102
+ };
103
+ } catch (err) {
104
+ return { state, error: err?.message ?? String(err) };
105
+ } finally {
106
+ await session.close().catch(() => {});
107
+ }
108
+ }
109
+
110
+ /**
111
+ * Runs every consent scenario on the site's start page. Returns null when the page has no consent
112
+ * banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
113
+ * @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
114
+ */
115
+ export async function runConsentScenarios(config, opts = {}) {
116
+ const log = opts.log ?? (() => {});
117
+ const first = await runState(config, "accept", opts);
118
+ if (!first.via) return null;
119
+ log(`\n▶ consent scenarios (${first.via})`);
120
+ const results = [first];
121
+ for (const state of CONSENT_STATES.slice(1)) results.push(await runState(config, state, opts));
122
+ const states = {};
123
+ for (const r of results) {
124
+ states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
125
+ log(` ${r.state}: ${r.error ? `✗ ${r.error}` : r.via ? `${Object.values(r.after ?? {}).reduce((s, e) => s + Object.values(e).reduce((a, b) => a + b.n, 0), 0)} tracking hits` : "not supported here"}`);
126
+ }
127
+ return { cmp: first.via, before: first.before, states };
128
+ }
129
+
130
+ /**
131
+ * The per-state tag table: one row per platform and event, one cell per state (hit count, or null when
132
+ * the state did not run). A cell is a problem when a tag sent hits a choice should have kept quiet:
133
+ * anything after "reject all" except cookieless Consent Mode pings, and ad tags after "analytics only"
134
+ * unless they were sent with ad storage denied.
135
+ */
136
+ export function consentTable(scenarios) {
137
+ const keys = new Set();
138
+ const add = (counts) => Object.entries(counts ?? {}).forEach(([p, events]) => Object.keys(events).forEach((e) => keys.add(`${p}\u0000${e}`)));
139
+ add(scenarios.before);
140
+ for (const s of Object.values(scenarios.states)) add(s.after);
141
+ const cols = ["before", ...CONSENT_STATES];
142
+ const rows = [...keys].sort().map((k) => {
143
+ const [platform, event] = k.split("\u0000");
144
+ const cells = cols.map((col) => {
145
+ const s = col === "before" ? { after: scenarios.before } : scenarios.states[col];
146
+ if (!s?.after) return { state: col, count: null, problem: false };
147
+ const c = s.after[platform]?.[event] ?? { n: 0, cookieless: 0, adDenied: 0 };
148
+ const problem = (col === "reject" && c.n > c.cookieless) || (col === "analytics" && AD_PLATFORMS.has(platform) && c.n > c.adDenied);
149
+ return { state: col, count: c.n, problem };
150
+ });
151
+ return { platform, event, cells };
152
+ });
153
+ return { cols, rows };
154
+ }
package/src/i18n.js CHANGED
@@ -95,6 +95,7 @@ const en = {
95
95
  sections: "Report sections",
96
96
  headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
97
97
  checkedAgainst: (id) => `Checked against plan ${id}`,
98
+ planChangesLine: (pc) => (pc.same ? `Plan unchanged since ${pc.previous}` : `Plan changed since ${pc.previous}: ${pc.added.length} added, ${pc.removed.length} removed, ${pc.changed.length} changed`),
98
99
  planVsSite: "Plan vs. site",
99
100
  changesTitle: "Changed since last run",
100
101
  changesLead: (id, mode) => `Compared with run ${id}, by ${mode === "plan" ? "plan coverage" : "health findings"}`,
@@ -132,6 +133,11 @@ const en = {
132
133
  consentUpdate: "Update",
133
134
  consentLead: "Consent Mode commands the site sent, in order.",
134
135
  consentNone: "The site sent no Consent Mode commands on this journey.",
136
+ scenarios: "Consent scenarios",
137
+ scenariosLead: (cmp) => `The start page opened once per consent choice (answered through ${cmp === "banner" ? "the banner's buttons" : cmp}). Hits per tag after the choice; red ones should not have been sent.`,
138
+ scenarioCols: { before: "No choice yet", accept: "Accept all", reject: "Reject all", analytics: "Analytics only" },
139
+ scenarioNotRun: "Not supported here",
140
+ scenarioTag: "Tag",
135
141
  skippedLine: (list, n) => `${n} out-of-scope ${n === 1 ? "request was" : "requests were"} skipped: ${list}.`,
136
142
  findingHint: "Open a finding to see its likely cause, evidence and fix steps.",
137
143
  spread: (j, s) => `${j} ${j === 1 ? "journey" : "journeys"}${s ? ` · ${s} ${s === 1 ? "step" : "steps"}` : ""}`,
@@ -145,6 +151,16 @@ const en = {
145
151
  notChecked: "not checked",
146
152
  noPlan: "No measurement plan was given, so there is nothing to compare against.",
147
153
  providers: "Providers",
154
+ inventoryTitle: "Tag inventory",
155
+ inventoryLead: "Every vendor the site sent requests to, and where",
156
+ vendorCol: "Vendor",
157
+ partyCol: "Party",
158
+ pagesCol: "Pages",
159
+ cookiesCol: "Cookies",
160
+ party: { first: "First party", third: "Third party", both: "Both" },
161
+ morePages: (n) => `+${n} more`,
162
+ otherCookies: "Other cookies",
163
+ noInventory: "No vendor requests were recorded.",
148
164
  provider: "Provider",
149
165
  requestsCol: "Requests",
150
166
  checked: "Checked",
@@ -168,6 +184,9 @@ const en = {
168
184
  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.`,
169
185
  serverSide: (platform, server, browser, unseen = []) => `${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` : ""}.${unseen.length ? ` Server-side copies of ${unseen.join(" and ")} events never pass through the browser, so they can't be seen here.` : ""}`,
170
186
  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.`,
187
+ campaignLost: (names, finalUrl) => `The landing address carried ${names.join(", ")}, but the page ended up on ${finalUrl} without ${names.length === 1 ? "it" : "them"}. A redirect dropped ${names.length === 1 ? "the parameter" : "the parameters"}, so ad clicks lose their attribution.`,
188
+ campaignMissingInHits: (platform, name, names) => `${platform} ${q(name)} on the landing page reports a page address without ${names.join(", ")}, though the page still had ${names.length === 1 ? "it" : "them"}. The visit can be credited to direct traffic instead of the campaign.`,
189
+ campaignOk: (names) => `${names.join(", ")} survived the landing page's redirects and reached the hits.`,
171
190
  consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
172
191
  consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
173
192
  beforeConsentNever: (platform, name) => `${platform} ${q(name)} fired although consent was never given in this journey.`,
@@ -282,6 +301,7 @@ const tr = {
282
301
  sections: "Rapor bölümleri",
283
302
  headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
284
303
  checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
304
+ planChangesLine: (pc) => (pc.same ? `Plan ${pc.previous} sürümünden bu yana değişmedi` : `Plan ${pc.previous} sürümünden bu yana değişti: ${pc.added.length} eklendi, ${pc.removed.length} çıkarıldı, ${pc.changed.length} değişti`),
285
305
  planVsSite: "Plan ve site",
286
306
  changesTitle: "Son çalıştırmadan bu yana değişenler",
287
307
  changesLead: (id, mode) => `${id} çalıştırmasıyla ${mode === "plan" ? "plan kapsamına" : "sağlık bulgularına"} göre karşılaştırıldı`,
@@ -319,6 +339,11 @@ const tr = {
319
339
  consentUpdate: "Güncelleme",
320
340
  consentLead: "Sitenin gönderdiği Consent Mode komutları, sırasıyla.",
321
341
  consentNone: "Site bu yolculukta Consent Mode komutu göndermedi.",
342
+ scenarios: "Onay senaryoları",
343
+ scenariosLead: (cmp) => `Başlangıç sayfası her onay seçimi için ayrı açıldı (${cmp === "banner" ? "banner butonlarıyla" : cmp} üzerinden yanıtlandı). Seçimden sonra etiket başına istek sayısı; kırmızılar gönderilmemeliydi.`,
344
+ scenarioCols: { before: "Henüz seçim yok", accept: "Tümünü kabul", reject: "Tümünü reddet", analytics: "Yalnızca analitik" },
345
+ scenarioNotRun: "Burada desteklenmiyor",
346
+ scenarioTag: "Etiket",
322
347
  skippedLine: (list, n) => `Kapsam dışı ${n} istek atlandı: ${list}.`,
323
348
  findingHint: "Bir bulguyu açınca olası neden, kanıt ve çözüm adımları görünür.",
324
349
  spread: (j, s) => `${j} yolculuk${s ? ` · ${s} adım` : ""}`,
@@ -332,6 +357,16 @@ const tr = {
332
357
  notChecked: "kontrol edilmedi",
333
358
  noPlan: "Ölçüm planı verilmediği için karşılaştırılacak bir şey yok.",
334
359
  providers: "Sağlayıcılar",
360
+ inventoryTitle: "Etiket envanteri",
361
+ inventoryLead: "Sitenin istek gönderdiği tüm sağlayıcılar ve hangi sayfalarda",
362
+ vendorCol: "Sağlayıcı",
363
+ partyCol: "Taraf",
364
+ pagesCol: "Sayfalar",
365
+ cookiesCol: "Çerezler",
366
+ party: { first: "Birinci taraf", third: "Üçüncü taraf", both: "İkisi de" },
367
+ morePages: (n) => `+${n} daha`,
368
+ otherCookies: "Diğer çerezler",
369
+ noInventory: "Sağlayıcıya giden istek kaydedilmedi.",
335
370
  provider: "Sağlayıcı",
336
371
  requestsCol: "İstek",
337
372
  checked: "Kontrol edildi",
@@ -355,6 +390,9 @@ const tr = {
355
390
  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.`,
356
391
  serverSide: (platform, server, browser, unseen = []) => `${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` : ""}.${unseen.length ? ` ${unseen.join(" ve ")} olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.` : ""}`,
357
392
  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.`,
393
+ campaignLost: (names, finalUrl) => `Açılış adresi ${names.join(", ")} taşıyordu, ancak sayfa ${finalUrl} adresinde ${names.length === 1 ? "onsuz" : "onlarsız"} sonlandı. Bir yönlendirme ${names.length === 1 ? "parametreyi" : "parametreleri"} düşürdü, bu yüzden reklam tıklamalarının atfı kaybolur.`,
394
+ campaignMissingInHits: (platform, name, names) => `Açılış sayfasındaki ${platform} ${q(name)} isteği ${names.join(", ")} olmadan bir sayfa adresi bildiriyor, oysa sayfada ${names.length === 1 ? "o" : "onlar"} hâlâ vardı. Ziyaret kampanyaya değil doğrudan trafiğe yazılabilir.`,
395
+ campaignOk: (names) => `${names.join(", ")} açılış sayfasının yönlendirmelerinden geçti ve isteklere ulaştı.`,
358
396
  consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
359
397
  consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
360
398
  beforeConsentNever: (platform, name) => `${platform} ${q(name)}, bu yolculukta hiç onay verilmemesine rağmen tetiklendi.`,
@@ -0,0 +1,69 @@
1
+ import { providerOf } from "./parsers.js";
2
+ import { siteOf } from "./redact.js";
3
+
4
+ // Cookies the vendors' own scripts set on the site's domain.
5
+ const COOKIE_VENDORS = [
6
+ [/^(_ga|_gid|_gat)(_|$)/, "ga4"],
7
+ [/^(_gcl_|_gac_|IDE$|test_cookie$)/, "google_ads"],
8
+ [/^_fb[pc]$/, "meta"],
9
+ [/^(_ttp|ttclid|_tt_enable_cookie)$/, "tiktok"],
10
+ [/^(_li_|li_|bcookie|lidc|UserMatchHistory)/, "linkedin"],
11
+ [/^(_uet|MUID)/, "microsoft_ads"],
12
+ [/^(_pin_|_pinterest_)/, "pinterest"],
13
+ [/^(_scid|sc_at)/, "snapchat"],
14
+ [/^(_cto_|cto_)/, "criteo"],
15
+ [/^__kla_id$/, "klaviyo"],
16
+ [/^_hj/, "hotjar"],
17
+ [/^_clck|^_clsk/, "clarity"],
18
+ ];
19
+
20
+ const hostOf = (url) => {
21
+ try { return new URL(url).hostname; } catch { return ""; }
22
+ };
23
+
24
+ const pathOf = (url) => {
25
+ try { const u = new URL(url); return `${u.hostname}${u.pathname}`; } catch { return ""; }
26
+ };
27
+
28
+ /**
29
+ * Every vendor the site talked to: the pages it loaded or sent from, whether its requests went to the
30
+ * site's own domain (first party) or to the vendor (third party), and the cookie names tied to it.
31
+ * Cookies no vendor owns are listed apart. Only cookie names are kept, never values.
32
+ */
33
+ export function buildInventory(runs) {
34
+ const vendors = new Map();
35
+ for (const r of runs) {
36
+ for (const c of r.network ?? []) {
37
+ if (!c.vendor || !c.pageUrl) continue;
38
+ const v = vendors.get(c.vendor) ?? { vendor: c.vendor, keys: new Set(), pages: new Set(), first: 0, third: 0, requests: 0, cookies: new Set() };
39
+ v.keys.add(c.provider);
40
+ v.pages.add(pathOf(c.pageUrl));
41
+ v.requests++;
42
+ v[siteOf(hostOf(c.url)) === siteOf(hostOf(c.pageUrl)) ? "first" : "third"]++;
43
+ vendors.set(c.vendor, v);
44
+ }
45
+ }
46
+ const other = new Map();
47
+ for (const r of runs) {
48
+ for (const ck of r.cookieLog ?? []) {
49
+ const domain = String(ck.domain ?? "").replace(/^\./, "");
50
+ const key = COOKIE_VENDORS.find(([re]) => re.test(ck.name))?.[1] ?? providerOf(`https://${domain}/`)?.key;
51
+ const owners = [...vendors.values()].filter((v) => key && v.keys.has(key));
52
+ if (owners.length) for (const v of owners) v.cookies.add(ck.name);
53
+ else other.set(`${domain}|${ck.name}`, { name: ck.name, domain });
54
+ }
55
+ }
56
+ const sites = new Set(runs.flatMap((r) => (r.network ?? []).map((c) => c.pageUrl)).filter(Boolean).map((u) => siteOf(hostOf(u))));
57
+ return {
58
+ vendors: [...vendors.values()]
59
+ .map((v) => ({
60
+ vendor: v.vendor,
61
+ party: v.first && v.third ? "both" : v.first ? "first" : "third",
62
+ pages: [...v.pages].sort(),
63
+ requests: v.requests,
64
+ cookies: [...v.cookies].sort(),
65
+ }))
66
+ .sort((a, b) => b.pages.length - a.pages.length || a.vendor.localeCompare(b.vendor)),
67
+ otherCookies: [...other.values()].map((c) => ({ name: c.name, party: sites.has(siteOf(c.domain)) ? "first" : "third" })).sort((a, b) => a.name.localeCompare(b.name)),
68
+ };
69
+ }
package/src/learn.js ADDED
@@ -0,0 +1,34 @@
1
+ import { PROVIDERS } from "./parsers.js";
2
+
3
+ /**
4
+ * A plan learned from what a site already sends: one event per platform and event name seen in
5
+ * the runs, the parameters it carried,. Parameters sent with every
6
+ * hit of an event become its required parameters. Values are not copied, so nothing a visitor typed
7
+ * ends up in the plan. Returns null when the runs captured no named events.
8
+ */
9
+ export function learnPlan(site, runs) {
10
+ const byKey = new Map();
11
+ for (const run of runs) {
12
+ for (const hit of run.hits ?? []) {
13
+ if (!hit?.platform || !hit.name || hit.name === "(unnamed)") continue;
14
+ const k = `${hit.platform}:${hit.name}`;
15
+ let ev = byKey.get(k);
16
+ if (!ev) byKey.set(k, (ev = { platform: hit.platform, name: hit.name, hits: 0, params: new Map() }));
17
+ ev.hits++;
18
+ for (const p of Object.keys(hit.params ?? {})) ev.params.set(p, (ev.params.get(p) ?? 0) + 1);
19
+ }
20
+ }
21
+ if (!byKey.size) return null;
22
+ const events = [...byKey.values()].map((ev) => {
23
+ const required = [...ev.params].filter(([, n]) => n === ev.hits).map(([p]) => p);
24
+ return {
25
+ platform: ev.platform,
26
+ name: ev.name,
27
+ description: `Learned from what ${PROVIDERS[ev.platform] ?? ev.platform} received on the site.`,
28
+ ...(required.length ? { required_params: required } : {}),
29
+ parameters: Object.fromEntries([...ev.params.keys()].map((p) => [p, {}])),
30
+ seen: true,
31
+ };
32
+ });
33
+ return { site, learned: true, consent_mode: true, pages: [], events, user_properties: [] };
34
+ }
package/src/redact.js CHANGED
@@ -22,7 +22,7 @@ const SHARED_SECOND_LEVELS = new Set(["com", "co", "org", "net", "gov", "edu", "
22
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
23
 
24
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) {
25
+ export function siteOf(host) {
26
26
  const labels = String(host).toLowerCase().replace(/\.$/, "").split(".");
27
27
  const name = labels.join(".");
28
28
  const shared = SHARED_SUFFIXES.find((s) => name === s || name.endsWith(`.${s}`));
package/src/report.js CHANGED
@@ -1,9 +1,11 @@
1
+ import { consentTable } from "./consent.js";
1
2
  import { OWNERS, ownerOf, verifyLinks } from "./verify.js";
2
3
  import { createProvider } from "./providers.js";
3
4
  import { groupFindings, hitMatchesEvent, summarize } from "./checks.js";
4
5
  import { messages } from "./i18n.js";
5
6
  import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
6
7
  import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
8
+ import { buildInventory } from "./inventory.js";
7
9
  import { LOGOS } from "./brand.js";
8
10
  import { readFileSync } from "node:fs";
9
11
 
@@ -152,19 +154,27 @@ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
152
154
 
153
155
  const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
154
156
 
155
- export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
157
+ export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
156
158
  const m = messages(lang);
157
159
  const rows = attachExplanations(findings, explained);
158
160
  const counts = summarize(rows);
159
- const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), ...(changes?.routes?.length ? [mdText(m.ui.routeChanged(routeList(changes))), ""] : []), `**${m.ui.counts(counts)}**`, ""];
161
+ const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), ...(planChanges ? [mdText(m.ui.planChangesLine(planChanges)), "", ...planChangeItems(planChanges).map((i) => `- ${mdText(i)}`), ...(planChangeItems(planChanges).length ? [""] : [])] : []), ...(changes?.routes?.length ? [mdText(m.ui.routeChanged(routeList(changes))), ""] : []), `**${m.ui.counts(counts)}**`, ""];
160
162
  if (changes) lines.push(...changesMd(m, changes));
161
163
  if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
164
+ lines.push(...inventoryMd(m, runs));
162
165
  const sum = summaryParts(explained?.summary);
163
166
  if (sum) {
164
167
  lines.push(`## ${m.ui.summary}`, "", sum.text ? mdText(sum.text) : `**${mdText(sum.headline)}**`, "");
165
168
  if (sum.points.length) lines.push(...sum.points.map((p) => `- **${m.severity[p.severity]}**: ${mdText(p.text)}`), "");
166
169
  if (sum.also_check.length) lines.push(`### ${m.ui.alsoCheck}`, "", ...sum.also_check.map((t) => `- ${mdText(t)}`), "");
167
170
  }
171
+ if (consentScenarios) {
172
+ const { cols, rows: tagRows } = consentTable(consentScenarios);
173
+ lines.push(`## ${m.ui.scenarios}`, "", mdText(m.ui.scenariosLead(consentScenarios.cmp)), "");
174
+ lines.push(`| ${m.ui.scenarioTag} | ${cols.map((c) => m.ui.scenarioCols[c]).join(" | ")} |`, `|${" --- |".repeat(cols.length + 1)}`);
175
+ for (const r of tagRows) lines.push(`| ${mdText(platformLabel(r.platform))} \`${mdCode(r.event)}\` | ${r.cells.map((c) => (c.count === null ? "–" : c.problem ? `**${c.count}** ⚠` : String(c.count))).join(" | ")} |`);
176
+ lines.push("");
177
+ }
168
178
  lines.push(`## ${m.ui.findings}`, "");
169
179
  for (const f of rows.filter((r) => r.severity !== "ok")) {
170
180
  lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
@@ -558,10 +568,20 @@ function journeyPanel(m, lang, findings, run, ri, flagged) {
558
568
  ${consentCard(m, run)}${body}</article>`;
559
569
  }
560
570
 
561
- function journeysTab(m, lang, findings, runs, flagged) {
562
- if (!runs.length) return `<p class="empty">${m.ui.noSteps}</p>`;
571
+ /** Consent scenarios: one row per tag, one column per consent choice, hit counts after the choice. */
572
+ function scenariosCard(m, scenarios) {
573
+ if (!scenarios) return "";
574
+ const { cols, rows } = consentTable(scenarios);
575
+ const head = `<tr><th>${m.ui.scenarioTag}</th>${cols.map((c) => `<th>${m.ui.scenarioCols[c]}</th>`).join("")}</tr>`;
576
+ const body = rows.map((r) => `<tr><td><span class="ptag p-${safeKey(r.platform)}">${escapeHtml(platformLabel(r.platform))}</span> <code>${escapeHtml(r.event)}</code></td>${r.cells.map((c) => (c.count === null ? `<td class="muted xs">–</td>` : `<td class="num${c.problem ? " bad" : ""}">${c.count}</td>`)).join("")}</tr>`).join("");
577
+ const notRun = cols.filter((c) => c !== "before" && !scenarios.states[c]?.via).map((c) => m.ui.scenarioCols[c]);
578
+ return `<div class="card jconsent scenarios"><div class="card-h"><div class="card-t">${m.ui.scenarios}</div><div class="card-d">${escapeHtml(m.ui.scenariosLead(scenarios.cmp))}${notRun.length ? ` ${escapeHtml(m.ui.scenarioNotRun)}: ${escapeHtml(notRun.join(", "))}.` : ""}</div></div><div class="card-c tight"><table class="dt"><thead>${head}</thead><tbody>${body}</tbody></table></div></div>`;
579
+ }
580
+
581
+ function journeysTab(m, lang, findings, runs, flagged, scenarios = null) {
582
+ if (!runs.length) return `${scenariosCard(m, scenarios)}<p class="empty">${m.ui.noSteps}</p>`;
563
583
  const tabs = runs.map((r, ri) => `<a href="#j${ri}">${escapeHtml(r.journeyId)} <span class="muted">· ${escapeHtml(r.viewport)}</span><span class="n">${problemCount(findings, r)}</span></a>`).join("");
564
- return `<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
584
+ return `${scenariosCard(m, scenarios)}<nav class="jtabs" aria-label="${m.ui.chooseJourney}">${tabs}</nav>\n${runs.map((r, ri) => journeyPanel(m, lang, findings, r, ri, flagged)).join("\n")}`;
565
585
  }
566
586
 
567
587
  /** Evidence as a table when it is a JSON object (captured parameters), otherwise as text. */
@@ -704,6 +724,12 @@ const coverageLines = (m, pc) => [
704
724
  const pcSort = (pc) => [...pc.events].sort((a, b) => BUCKET_ORDER.indexOf(a.bucket) - BUCKET_ORDER.indexOf(b.bucket) || platformRank(a.platform) - platformRank(b.platform));
705
725
  const BUCKET_ORDER = ["missing", "missing_params", "wrong_values", "implemented", "unplanned", "not_checked"];
706
726
 
727
+ const planChangeItems = (pc) => [
728
+ ...pc.added.map((c) => `+ ${changeLabel(c)}`),
729
+ ...pc.removed.map((c) => `− ${changeLabel(c)}`),
730
+ ...pc.changed.map((c) => `~ ${changeLabel(c)}: ${c.what.join(", ")}`),
731
+ ];
732
+
707
733
  /** "Changed since last run": one headline, then what was fixed and what is new. */
708
734
  const changeLabel = (c) => `${platformLabel(c.platform ?? "")} ${c.event ?? ""}`.trim() || c.check;
709
735
  const changeItems = (m, ch) => [
@@ -761,6 +787,30 @@ function coverageCard(m, plan, runs) {
761
787
  return `<div class="card">${head}<div class="card-c"><div class="tscroll"><table class="dt cov"><thead><tr><th>${m.ui.event}</th>${runs.map((r) => `<th>${escapeHtml(r.journeyId)} · ${escapeHtml(r.viewport)}</th>`).join("")}</tr></thead><tbody>${body}</tbody></table></div><p class="muted xs legend">${m.ui.coverageLegend}</p></div></div>`;
762
788
  }
763
789
 
790
+ const PAGES_SHOWN = 5;
791
+
792
+ function inventoryMd(m, runs) {
793
+ const inv = buildInventory(runs);
794
+ const lines = [`## ${m.ui.inventoryTitle}`, ""];
795
+ if (!inv.vendors.length) return [...lines, m.ui.noInventory, ""];
796
+ lines.push(`| ${m.ui.vendorCol} | ${m.ui.partyCol} | ${m.ui.pagesCol} | ${m.ui.cookiesCol} |`, "|---|---|---|---|");
797
+ for (const v of inv.vendors) lines.push(`| ${mdText(v.vendor)} | ${m.ui.party[v.party]} | ${v.pages.length} | ${mdText(v.cookies.join(", "))} |`);
798
+ if (inv.otherCookies.length) lines.push("", `${m.ui.otherCookies}: ${mdText(inv.otherCookies.map((c) => c.name).join(", "))}`);
799
+ lines.push("");
800
+ return lines;
801
+ }
802
+
803
+ function inventoryCard(m, runs) {
804
+ const inv = buildInventory(runs);
805
+ const head = `<div class="card-h"><div class="card-t">${m.ui.inventoryTitle}</div><div class="card-d">${m.ui.inventoryLead}</div></div>`;
806
+ if (!inv.vendors.length) return `<div class="card" id="inventory">${head}<div class="card-c"><p class="muted">${m.ui.noInventory}</p></div></div>`;
807
+ const names = (xs) => xs.map((x) => `<code>${escapeHtml(x)}</code>`).join(" ");
808
+ const pages = (v) => `${names(v.pages.slice(0, PAGES_SHOWN))}${v.pages.length > PAGES_SHOWN ? ` <span class="muted">${m.ui.morePages(v.pages.length - PAGES_SHOWN)}</span>` : ""}`;
809
+ const rows = inv.vendors.map((v) => `<tr><td>${escapeHtml(v.vendor)}</td><td>${escapeHtml(m.ui.party[v.party])}</td><td class="xs">${pages(v)}</td><td class="xs">${v.cookies.length ? names(v.cookies) : '<span class="muted">–</span>'}</td></tr>`).join("");
810
+ const other = inv.otherCookies.length ? `<p class="muted xs legend">${m.ui.otherCookies}: ${inv.otherCookies.map((c) => `${escapeHtml(c.name)} (${escapeHtml(m.ui.party[c.party])})`).join(", ")}</p>` : "";
811
+ return `<div class="card" id="inventory">${head}<div class="card-c tight"><div class="tscroll"><table class="dt"><thead><tr><th>${m.ui.vendorCol}</th><th>${m.ui.partyCol}</th><th>${m.ui.pagesCol}</th><th>${m.ui.cookiesCol}</th></tr></thead><tbody>${rows}</tbody></table></div></div>${other ? `<div class="card-c">${other}</div>` : ""}</div>`;
812
+ }
813
+
764
814
  function providersCard(m, runs) {
765
815
  const provs = checkedProviders(runs);
766
816
  const skipped = skippedTotals(runs);
@@ -858,6 +908,7 @@ code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px
858
908
  .jconsent{margin-bottom:16px}
859
909
  .eyebrow{font:500 12px var(--font-mono);color:var(--accent-fg);text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px}
860
910
  .title .meta{margin-top:4px;font-size:12px;color:var(--muted)}
911
+ .title .plan-changes ul{margin:4px 0 0;padding-left:18px}
861
912
  .toptabs{position:sticky;top:0;z-index:10;background:var(--page);border-bottom:1px solid var(--border);margin-top:16px}
862
913
  .toptabs .wrap{display:flex;gap:4px;overflow-x:auto;scrollbar-width:none}
863
914
  .toptabs a{display:inline-flex;align-items:center;gap:6px;padding:12px 10px;color:var(--muted);font-weight:500;white-space:nowrap;border-bottom:2px solid transparent;margin-bottom:-1px}
@@ -1075,7 +1126,7 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
1075
1126
  @media print{.toptabs,.jtabs,.stepnav,.filter>label,.filter>input,.tabs.seg>label,.sevfilter{display:none}.tabpanel,.jpanel,.sdetail,.tabs.seg>.panel{display:block!important}.jpanel{margin-bottom:32px}.sdetail{margin-bottom:16px;break-inside:avoid-page}details>*{display:block}}
1076
1127
  `;
1077
1128
 
1078
- export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
1129
+ export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
1079
1130
  const m = messages(lang);
1080
1131
  const rows = attachExplanations(findings, explained);
1081
1132
  const counts = summarize(rows);
@@ -1096,15 +1147,15 @@ ${navCss(runs)}</style></head><body>
1096
1147
  <div class="topbar"><div class="wrap"><span class="product-logo">${logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd")}</span>${branding ? `<a href="https://analitikisler.com" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("horizontal", "Analitik İşler", 18, "logo-h")}</a>` : ""}</div></div>
1097
1148
  <header class="wrap title">
1098
1149
  <h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
1099
- <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>${against ? `\n<div class="meta">${escapeHtml(m.ui.checkedAgainst(against))}</div>` : ""}${changes?.routes?.length ? `\n<div class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></div>` : ""}
1150
+ <div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>${against ? `\n<div class="meta">${escapeHtml(m.ui.checkedAgainst(against))}</div>` : ""}${planChanges ? `\n<div class="meta plan-changes">${escapeHtml(m.ui.planChangesLine(planChanges))}${planChangeItems(planChanges).length ? `<ul>${planChangeItems(planChanges).map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>` : ""}</div>` : ""}${changes?.routes?.length ? `\n<div class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></div>` : ""}
1100
1151
  </header>
1101
1152
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
1102
1153
  <a href="#overview">${m.ui.tabOverview}</a><a href="#findings">${m.ui.findings} <span class="n">${problems}</span></a><a href="#journeys">${m.ui.tabJourneys} <span class="n">${runs.length}</span></a><a href="#scope">${m.ui.tabScope}</a></div></nav>
1103
1154
  <main class="wrap">
1104
1155
  <section class="tabpanel" id="overview" aria-label="${m.ui.tabOverview}">${changes ? changesCard(m, changes) : ""}${planCheck ? planVsSiteCard(m, planCheck) : ""}${overviewTab(m, findings, rows, counts, explained, runs, { ai, plan, lang })}</section>
1105
1156
  <section class="tabpanel" id="findings" aria-label="${m.ui.findings}">${findingsTab(m, runs, rows, Boolean(explained))}</section>
1106
- <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged)}</section>
1107
- <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${providersCard(m, runs)}</div></section>
1157
+ <section class="tabpanel" id="journeys" aria-label="${m.ui.tabJourneys}">${journeysTab(m, lang, findings, runs, flagged, consentScenarios)}</section>
1158
+ <section class="tabpanel" id="scope" aria-label="${m.ui.tabScope}"><div class="stack">${coverageCard(m, plan, runs)}${inventoryCard(m, runs)}${providersCard(m, runs)}</div></section>
1108
1159
  </main>
1109
1160
  <footer class="footer">${footer}</footer>
1110
1161
  </body></html>`;
package/src/sheet.js CHANGED
@@ -84,7 +84,8 @@ export function rowsToPlan({ events: eventRows = [], user_properties: upRows = n
84
84
  if (!r.event) throw fail(`${at}: "event" is empty. Give the event name, e.g. purchase.`);
85
85
  const platform = (r.platform || "ga4").toLowerCase();
86
86
  if (!PLATFORMS[platform]) throw fail(`${at}: "platform" must be one of ${Object.keys(PLATFORMS).join(", ")} (found "${r.platform}").`);
87
- const required = (r.required ?? "").toLowerCase();
87
+ // Google Sheets checkboxes export as TRUE/FALSE.
88
+ const required = { true: "yes", false: "no" }[(r.required ?? "").toLowerCase()] ?? (r.required ?? "").toLowerCase();
88
89
  if (!["yes", "no", ""].includes(required)) throw fail(`${at}: "required" must be yes or no (found "${r.required}").`);
89
90
  if (required && !r.parameter) throw fail(`${at}: "required" is set but "parameter" is empty. Name the parameter, or leave "required" empty on the event's own row.`);
90
91
  const custom = (r.custom ?? "").toLowerCase().split(",").map((s) => s.trim()).filter(Boolean);
@@ -290,15 +291,17 @@ export async function readPlanSheet(file, { site } = {}) {
290
291
  if (!events) throw new Error(`${path.basename(file)} has no tab named "events" (found ${Object.keys(sheets).map((k) => `"${k}"`).join(", ") || "none"}). Rename the tab with the events to "events".`);
291
292
  tabs = { events, user_properties: find("user_properties") ?? null };
292
293
  } else if (ext === ".csv") {
293
- const upFile = path.join(path.dirname(file), "user_properties.csv");
294
- const isUp = path.basename(file).toLowerCase() === "user_properties.csv";
295
- if (isUp) throw new Error(`Import the events file (events.csv); ${path.basename(file)} beside it is read too.`);
294
+ // Google Sheets downloads each tab as "<Document> - <tab>.csv"; the sibling file keeps that prefix.
295
+ const base = path.basename(file);
296
+ const prefix = base.match(/^(.*?)events\.csv$/i)?.[1] ?? "";
297
+ const upFile = path.join(path.dirname(file), `${prefix}user_properties.csv`);
298
+ if (/user_properties\.csv$/i.test(base)) throw new Error(`Import the events file (${base.replace(/user_properties\.csv$/i, "events.csv")}); ${base} beside it is read too.`);
296
299
  tabs = { events: parseCsv(await readFile(file, "utf8")), user_properties: (await exists(upFile)) ? parseCsv(await readFile(upFile, "utf8")) : null };
297
300
  } else {
298
301
  throw new Error(`Import a .xlsx or .csv file (got ${path.basename(file)}).`);
299
302
  }
300
303
  const result = rowsToPlan(tabs, { site });
301
- if (!tabs.user_properties) result.warnings.push(ext === ".csv" ? "No user_properties.csv beside the file, so the plan has no user properties." : 'No "user_properties" tab, so the plan has no user properties.');
304
+ if (!tabs.user_properties) result.warnings.push(ext === ".csv" ? `No ${path.basename(upFile)} beside the file, so the plan has no user properties.` : 'No "user_properties" tab, so the plan has no user properties.');
302
305
  return result;
303
306
  }
304
307