@analitikisler/ai-tag-debugger 0.12.0 → 0.13.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.13.0 (2026-10-17)
4
+
5
+ - Each journey step has a **Timeline** tab: every dataLayer push, the GTM event it raised (or the consent command), then the platform hits it caused. Hits with no push are listed last.
6
+ - Findings are grouped by who fixes them (Developer, Tag Manager, Marketing). Each one has a **How to verify** list linking to the tool that shows the fix: Tag Assistant, GA4 DebugView, Meta Test Events, TikTok Pixel Helper or the Consent Mode guide.
7
+ - When the AI took a different route through the site than in the last run, the report header names the journeys, and the Changed since last run section lists the pages before and after.
8
+ - Platforms are detected automatically: a platform the site sends to is checked from its first request. No flag needed. Choosing platforms with `--platforms` or `providers` turns detection off.
9
+ - New platforms: LinkedIn Insight Tag, Microsoft Advertising UET and Criteo OneTag (events are read); Adobe Analytics, Matomo and Piwik PRO (page views, links, goals, site search and ecommerce orders); Microsoft Clarity and Hotjar tag loads, which name the project or site id.
10
+
3
11
  ## 0.12.0 (2026-10-16)
4
12
 
5
13
  - The HTML report uses the Analitik İşler design system and the new ai tag debugger logo. The large title is now a compact `Site: example.com` line.
package/README.md CHANGED
@@ -4,7 +4,7 @@ English | [Türkçe](README.tr.md)
4
4
 
5
5
  **An open-source AI agent that QA-tests your website's analytics and ad tracking.**
6
6
 
7
- An AI model drives a real browser through your key journeys (product → cart → checkout, lead forms) like a real visitor. While it browses, `ai-tag-debugger` records every GA4, Google Ads, Meta Pixel and TikTok hit and every dataLayer push. It then checks them against your measurement plan and Consent Mode setup and writes a plain-language report of what broke and how to fix it, in English or Turkish.
7
+ An AI model drives a real browser through your key journeys (product → cart → checkout, lead forms) like a real visitor. While it browses, `ai-tag-debugger` records every tracking hit (GA4, Google Ads, Meta Pixel, TikTok and about a dozen more platforms) and every dataLayer push. It then checks them against your measurement plan and Consent Mode setup and writes a plain-language report of what broke and how to fix it, in English or Turkish.
8
8
 
9
9
  Use it in two ways:
10
10
 
@@ -50,13 +50,13 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
50
50
  | Unplanned events | Events firing that aren't in your measurement plan |
51
51
  | Plan coverage | Each planned event sorted into implemented, missing, missing parameters, wrong values or not checked (see [Plan vs. site](#plan-vs-site)) |
52
52
 
53
- Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain; hits to a server container are marked server-side, and Stape and Elevar requests are recognised), Google Ads conversions, Meta Pixel, TikTok Pixel, and basic X Pixel events.
53
+ Platforms: GA4 (including server-side GTM endpoints and Google tag gateway paths on the site's own domain; hits to a server container are marked server-side, and Stape and Elevar requests are recognised), Google Ads conversions, Meta Pixel, TikTok Pixel, basic X Pixel events, LinkedIn Insight Tag, Microsoft Advertising UET, Criteo OneTag, and Pinterest, Snapchat and Klaviyo events. Adobe Analytics, Matomo and Piwik PRO events are read too (page views, links, goals, site search and ecommerce orders), and Microsoft Clarity and Hotjar tag loads name the project or site id.
54
54
 
55
55
  ### Choose the platforms to check
56
56
 
57
- By default an audit checks GA4, Google Ads, Meta and TikTok. Pick others with `--platforms ga4,meta,x`, `"providers"` in the config, or `providers` on the MCP `start_audit` and `run_audit` tools. You can choose from `ga4`, `google_ads`, `meta`, `tiktok`, `x`, `linkedin`, `microsoft_ads`, `pinterest`, `snapchat`, `clarity`, `hotjar`, `doubleclick` and `adsense`. Events are parsed for GA4, Google Ads, Meta, TikTok and X; the others show their network requests. `--providers` still works as the old name of `--platforms` for one more minor version and prints a note.
57
+ By default an audit checks GA4, Google Ads, Meta and TikTok, and also any other platform the site turns out to use. That platform is checked from its first request, with no setting needed. Choosing platforms turns this detection off. Pick a fixed list with `--platforms ga4,meta,x`, `"providers"` in the config, or `providers` on the MCP `start_audit` and `run_audit` tools. You can choose from `ga4`, `google_ads`, `meta`, `tiktok`, `x`, `linkedin`, `microsoft_ads`, `pinterest`, `snapchat`, `criteo`, `klaviyo`, `adobe`, `matomo`, `piwik_pro`, `clarity`, `hotjar`, `doubleclick` and `adsense`. Events are parsed for GA4, Google Ads, Meta, TikTok, X, LinkedIn, Microsoft Ads, Pinterest, Snapchat, Criteo, Klaviyo, Adobe Analytics, Matomo and Piwik PRO. Clarity and Hotjar show their tag loads, and the others show their network requests. `--providers` still works as the old name of `--platforms` for one more minor version and prints a note.
58
58
 
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 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.
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
61
  ## How it works
62
62
 
@@ -377,7 +377,7 @@ When a journey ran on both viewports, only the first viewport's steps are kept.
377
377
  | `journeys[].start` | Optional start path (default `/`). |
378
378
  | `journeys[].steps` | Optional fixed steps for `--scripted` runs: `{ "action": "click", "selector": "#add-to-cart" }`. |
379
379
  | `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`. |
380
- | `providers` | Tracking platforms to check, e.g. `["ga4", "meta"]`. Default `["ga4", "google_ads", "meta", "tiktok"]`. Same as `--platforms`. |
380
+ | `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`. |
381
381
  | `block_hits` | Default `true`. Set to `false` to let hits reach your real endpoints. |
382
382
  | `language` | Report language, `en` (default) or `tr`. Same as `--lang`. |
383
383
  | `keep_bodies` | Default `false`. Set to `true` to store tracking requests as sent, without redacting user data. Same as `--keep-bodies`. |
@@ -442,9 +442,10 @@ The exit code is `1` when findings reach `--fail-on` (default `broken`), so it c
442
442
  Output:
443
443
 
444
444
  - `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.
445
- - **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.
446
- - **Findings**: grouped by severity and filterable. Each one has its impact, likely cause, evidence, numbered fix steps and the steps where it happened.
447
- - **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 three tabs:
445
+ - **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.
446
+ - **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.
447
+ - **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:
448
+ - **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.
448
449
  - **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.
449
450
  - **dataLayer**: every push in the step.
450
451
  - **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.
package/README.tr.md CHANGED
@@ -4,7 +4,7 @@
4
4
 
5
5
  **Web sitenizin analitik ve reklam takibini test eden, açık kaynaklı bir yapay zekâ ajanı.**
6
6
 
7
- Bir yapay zekâ modeli gerçek bir tarayıcıyı, sitenizdeki temel yolculukları (ürün → sepet → ödeme, form gönderimleri) gerçek bir ziyaretçi gibi gezdirir. Gezerken `ai-tag-debugger` her GA4, Google Ads, Meta Pixel ve TikTok isteğini ve her dataLayer gönderimini kaydeder. Ardından bunları ölçümleme planınız ve Consent Mode kurulumunuzla karşılaştırır ve neyin bozulduğunu ve nasıl düzeltileceğini sade bir dille, Türkçe ya da İngilizce olarak yazılmış bir raporla anlatır.
7
+ Bir yapay zekâ modeli gerçek bir tarayıcıyı, sitenizdeki temel yolculukları (ürün → sepet → ödeme, form gönderimleri) gerçek bir ziyaretçi gibi gezdirir. Gezerken `ai-tag-debugger` her takip isteğini (GA4, Google Ads, Meta Pixel, TikTok ve bir düzine kadar başka platform) ve her dataLayer gönderimini kaydeder. Ardından bunları ölçümleme planınız ve Consent Mode kurulumunuzla karşılaştırır ve neyin bozulduğunu ve nasıl düzeltileceğini sade bir dille, Türkçe ya da İngilizce olarak yazılmış bir raporla anlatır.
8
8
 
9
9
  İki şekilde kullanabilirsiniz:
10
10
 
@@ -50,13 +50,13 @@ Manuel etiket kontrolü bunu yakalar, ama zahmetlidir; bu yüzden her sürümden
50
50
  | Planda olmayan event'ler | Ölçümleme planınızda bulunmayan, yine de fire olan event'ler |
51
51
  | Plan kapsamı | Planlanan her event'in uygulandı, eksik, parametresi eksik, değeri hatalı ya da kontrol edilmedi olarak sınıflandırılması (bkz. [Plan ve site](#plan-ve-site)) |
52
52
 
53
- Platformlar: GA4 (sitenin kendi alan adındaki sunucu tarafı GTM uç noktaları ve Google etiket geçidi yolları dahil; bir sunucu konteynerine giden istekler sunucu tarafı olarak işaretlenir, Stape ve Elevar istekleri tanınır), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel ve temel X Pixel event'leri.
53
+ Platformlar: GA4 (sitenin kendi alan adındaki sunucu tarafı GTM uç noktaları ve Google etiket geçidi yolları dahil; bir sunucu konteynerine giden istekler sunucu tarafı olarak işaretlenir, Stape ve Elevar istekleri tanınır), Google Ads dönüşümleri, Meta Pixel, TikTok Pixel, temel X Pixel event'leri, LinkedIn Insight Tag, Microsoft Advertising UET, Criteo OneTag ile Pinterest, Snapchat ve Klaviyo event'leri. Adobe Analytics, Matomo ve Piwik PRO event'leri de okunur (sayfa görüntülemeleri, bağlantılar, hedefler, site içi arama ve e-ticaret siparişleri); Microsoft Clarity ve Hotjar etiket yüklemeleri ise proje ya da site kimliğini gösterir.
54
54
 
55
55
  ### Kontrol edilecek platformları seçin
56
56
 
57
- Varsayılan olarak bir denetim GA4, Google Ads, Meta ve TikTok'u kontrol eder. Başka platformları `--platforms ga4,meta,x` ile, ayar dosyasındaki `"providers"` ile ya da MCP `start_audit` ve `run_audit` araçlarındaki `providers` ile seçebilirsiniz. Seçenekler: `ga4`, `google_ads`, `meta`, `tiktok`, `x`, `linkedin`, `microsoft_ads`, `pinterest`, `snapchat`, `clarity`, `hotjar`, `doubleclick` ve `adsense`. GA4, Google Ads, Meta, TikTok ve X için event'ler ayrıştırılır; diğerlerinin ağ istekleri gösterilir. `--providers` eski adı olarak bir minör sürüm daha çalışır ve bir not yazdırır.
57
+ Varsayılan olarak bir denetim GA4, Google Ads, Meta ve TikTok'u kontrol eder; ayrıca sitenin kullandığı başka her platformu, ilk isteğinden itibaren kontrol eder. Bunun için bir ayar gerekmez. Platform seçmek bu otomatik algılamayı kapatır. Sabit bir liste için `--platforms ga4,meta,x` kullanın, ayar dosyasında `"providers"` ya da MCP `start_audit` ve `run_audit` araçlarında `providers` verin. Seçenekler: `ga4`, `google_ads`, `meta`, `tiktok`, `x`, `linkedin`, `microsoft_ads`, `pinterest`, `snapchat`, `criteo`, `klaviyo`, `adobe`, `matomo`, `piwik_pro`, `clarity`, `hotjar`, `doubleclick` ve `adsense`. GA4, Google Ads, Meta, TikTok, X, LinkedIn, Microsoft Ads, Pinterest, Snapchat, Criteo, Klaviyo, Adobe Analytics, Matomo ve Piwik PRO için event'ler ayrıştırılır. Clarity ve Hotjar etiket yüklemelerini gösterir; diğerlerinin ağ istekleri gösterilir. `--providers` eski adı olarak bir minör sürüm daha çalışır ve bir not yazdırır.
58
58
 
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 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.
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
61
  ## Nasıl çalışır
62
62
 
@@ -377,7 +377,7 @@ Bir yolculuk her iki görünümde de çalıştıysa yalnızca ilk görünümün
377
377
  | `journeys[].start` | İsteğe bağlı başlangıç yolu (varsayılan `/`). |
378
378
  | `journeys[].steps` | `--scripted` çalıştırmaları için isteğe bağlı sabit adımlar: `{ "action": "click", "selector": "#add-to-cart" }`. |
379
379
  | `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. |
380
- | `providers` | Kontrol edilecek takip platformları, örneğin `["ga4", "meta"]`. Varsayılan `["ga4", "google_ads", "meta", "tiktok"]`. `--platforms` ile aynıdır. |
380
+ | `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. |
381
381
  | `block_hits` | Varsayılan `true`. Takip isteklerinin gerçek uç noktalarınıza ulaşmasına izin vermek için `false` yapın. |
382
382
  | `language` | Rapor dili, `en` (varsayılan) ya da `tr`. `--lang` ile aynıdır. |
383
383
  | `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. |
@@ -442,9 +442,10 @@ aitd mcp [--http] [--port <n>] [--host <adres>] [--token <gizli>] [--public-url
442
442
  Çıktı:
443
443
 
444
444
  - `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.
445
- - **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) ve `--against` ile [Plan ve site](#plan-ve-site) kapsamı.
446
- - **Bulgular**: önem düzeyine göre gruplu ve filtrelenebilir. Her birinde etkisi, olası nedeni, kanıtı, numaralı düzeltme adımları ve olayın yaşandığı adımlar bulunur.
447
- - **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 üç sekmesi vardır:
445
+ - **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.
446
+ - **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).
447
+ - **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:
448
+ - **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.
448
449
  - **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.
449
450
  - **dataLayer**: adımdaki her gönderim.
450
451
  - **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.
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.12.0",
4
+ "version": "0.13.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
@@ -188,7 +188,7 @@ export const harName = (journeyId, viewport) => `${String(journeyId).replace(/[^
188
188
  export async function runJourney(config, journey, viewport, opts = {}) {
189
189
  const har = (dir) => (dir ? path.join(dir, harName(journey.id, viewport)) : undefined);
190
190
  const session = new AuditSession({
191
- viewport, site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers, keepBodies: config.keep_bodies === true,
191
+ viewport, site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers, detect: config.detect_providers, keepBodies: config.keep_bodies === true,
192
192
  blockHits: config.block_hits !== false, headless: !opts.headed, recordHar: har(opts.saveSite), replayHar: har(opts.replaySite),
193
193
  });
194
194
  try {
package/src/audit.js CHANGED
@@ -46,7 +46,7 @@ export async function writeReports({ outDir, config, plan, runs, findings, expla
46
46
  const title = messages(lang).ui.title(site);
47
47
  const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
48
48
  const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
49
- const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check });
49
+ const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check, runs });
50
50
  const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes };
51
51
  await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
52
52
  await writeFile(path.join(outDir, "report.html"), renderHtml(report));
@@ -62,7 +62,8 @@ export async function writeReports({ outDir, config, plan, runs, findings, expla
62
62
  * outDir?: string, dataDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
63
63
  * journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
64
64
  * goals: extra journeys described in plain language. events: only check these events.
65
- * providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok).
65
+ * providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok plus
66
+ * any other known platform the site sends to).
66
67
  * saveSite: record the site's responses and the steps taken into this folder, for replaySite (see replay.js).
67
68
  * replaySite: answer every request from a folder saved with saveSite, without the network.
68
69
  * against: { id, file } of the plan given with --against; opts.plan is then that plan. Used alone, the
@@ -81,7 +82,9 @@ export async function runAudit(opts) {
81
82
 
82
83
  const against = opts.against ? { ...opts.against, label: opts.against.id ?? path.relative(process.cwd(), opts.against.file) } : null;
83
84
  const { journeys, plan } = planRun({ config, plan: opts.plan, picks: opts.journeys, goals: opts.goals, events, against: Boolean(against) });
84
- config = { ...config, providers: resolveProviders(opts.providers ?? config.providers, plan) };
85
+ const chosen = opts.providers ?? config.providers;
86
+ // Without a choice, any other known platform the site sends to is checked too (see AuditSession).
87
+ config = { ...config, providers: resolveProviders(chosen, plan), detect_providers: !(Array.isArray(chosen) ? chosen.length : chosen) };
85
88
  const viewports = opts.viewports?.length ? opts.viewports : config.viewports ?? ["desktop"];
86
89
  const allowed = Object.keys(VIEWPORTS).join(", ");
87
90
  if (!Array.isArray(viewports)) throw new Error(`"viewports" must be a list, e.g. ["desktop", "mobile"]. Allowed values: ${allowed}.`);
package/src/browser.js CHANGED
@@ -3,7 +3,7 @@ import { createRequire } from "node:module";
3
3
  import { readFile, rm, writeFile } from "node:fs/promises";
4
4
  import path from "node:path";
5
5
  import { chromium, devices } from "playwright";
6
- import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders } from "./parsers.js";
6
+ import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders, PROVIDERS } from "./parsers.js";
7
7
  import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
8
8
  import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
9
9
  import { startEgressProxy } from "./egress.js";
@@ -341,13 +341,25 @@ async function cleanRecording(file) {
341
341
  await writeFile(file, JSON.stringify(har));
342
342
  }
343
343
 
344
+ /** Where the step left the browser, without query or fragment, so the route can be compared across runs. */
345
+ function pagePath(href) {
346
+ try {
347
+ const u = new URL(href);
348
+ return /^https?:$/.test(u.protocol) ? u.origin + u.pathname : null;
349
+ } catch {
350
+ return null;
351
+ }
352
+ }
353
+
344
354
  export class AuditSession {
345
355
  /**
346
356
  * @param {{ viewport: string, site?: string, allowedHosts?: string[], providers?: string[], keepBodies?: boolean, blockPrivate?: boolean | ((ip: string) => boolean), blockHits?: boolean, headless?: boolean }} opts
347
357
  * keepBodies: store tracking URLs and bodies as sent. By default, user data for ad matching
348
358
  * (emails, phone numbers, user ids, hashed or not) is replaced with a marker first.
349
- * providers: which tracking providers to record (default GA4, Google Ads, Meta, TikTok). Requests to the
350
- * others are still answered locally, but only counted, per step, in this.skipped.
359
+ * providers: which tracking providers to record. Requests to the others are still answered locally,
360
+ * but only counted, per step, in this.skipped.
361
+ * detect: also record any other known provider the site sends to, from its first request
362
+ * (default: when no providers were chosen). They join this.providers.
351
363
  * site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
352
364
  * recordHar: save every response the site sends into this .har file, so the journey can be replayed offline later.
353
365
  * closeTimeoutMs: how long close() waits for the recording to be written (default 30 s) before skipping it.
@@ -370,6 +382,7 @@ export class AuditSession {
370
382
  this.dataLayerLog = [];
371
383
  this.network = [];
372
384
  this.providers = resolveProviders(this.opts.providers);
385
+ this.detect = this.opts.detect ?? !this.opts.providers?.length;
373
386
  this.skipped = {}; // step index -> { vendor label: requests }
374
387
  this.loadedIn = new Map(); // page address -> the last step whose main-frame navigation reached it
375
388
  this.leaving = null; // the page address being navigated away from, until the next page commits
@@ -424,7 +437,7 @@ export class AuditSession {
424
437
  if (!hits) return;
425
438
  const recorded = [];
426
439
  for (const hit of hits) {
427
- if (!this.providers.includes(hit.platform)) continue;
440
+ if (!this.checks(hit.platform)) continue;
428
441
  const entry = { ...this.redactHit(hit), step: this.stepForHit(hit), pageUrl: this.page.url(), t: Date.now() };
429
442
  if (this.record("hits", entry)) recorded.push(entry);
430
443
  }
@@ -443,7 +456,7 @@ export class AuditSession {
443
456
  this.context.on("request", (request) => {
444
457
  const provider = providerOf(request.url());
445
458
  if (!provider) return;
446
- if (!provider.always && !this.providers.includes(provider.key)) {
459
+ if (!provider.always && !this.checks(provider.key)) {
447
460
  const counts = (this.skipped[this.currentStep] ??= {});
448
461
  counts[provider.label] = (counts[provider.label] ?? 0) + 1;
449
462
  return;
@@ -553,6 +566,14 @@ export class AuditSession {
553
566
  }
554
567
 
555
568
  /** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
569
+ /** Whether requests of this provider are recorded; with detection on, a known provider seen for the first time joins the list. */
570
+ checks(key) {
571
+ if (this.providers.includes(key)) return true;
572
+ if (!this.detect || !(key in PROVIDERS)) return false;
573
+ this.providers.push(key);
574
+ return true;
575
+ }
576
+
556
577
  redactHit({ userFields, ...hit }) {
557
578
  const found = { fields: [], plain: [] };
558
579
  const site = hostOf(hit.params?.page_location) ?? hostOf(this.page.url());
@@ -808,6 +829,7 @@ export class AuditSession {
808
829
  await this.trackCookies();
809
830
  // A scripted replay of this step may fail the same way, which must not end the journey there.
810
831
  if (error) this.steps[this.currentStep].error = error;
832
+ this.steps[this.currentStep].pagePath = pagePath(this.page.url());
811
833
  return error;
812
834
  }
813
835
 
package/src/changes.js CHANGED
@@ -35,14 +35,42 @@ function planProblems(pc) {
35
35
  return out;
36
36
  }
37
37
 
38
+ /** The pages one run visited, in order, one entry per journey and viewport. Null when it recorded none (older runs). */
39
+ function routes(runs = []) {
40
+ const out = new Map();
41
+ for (const r of runs) {
42
+ const pages = [];
43
+ for (const s of r.steps ?? []) if (s.pagePath && pages.at(-1) !== s.pagePath) pages.push(s.pagePath);
44
+ if (pages.length) out.set(`${r.journeyId}|${r.viewport}`, { journeyId: r.journeyId, viewport: r.viewport, pages });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ /** Journeys whose route through the site differs from the previous run: [{ journeyId, viewport, before, after }]. */
50
+ export function compareRoutes(prevRuns, runs) {
51
+ const before = routes(prevRuns);
52
+ const out = [];
53
+ for (const [k, now] of routes(runs)) {
54
+ const was = before.get(k);
55
+ if (was && was.pages.join("\n") !== now.pages.join("\n")) out.push({ journeyId: now.journeyId, viewport: now.viewport, before: was.pages, after: now.pages });
56
+ }
57
+ return out;
58
+ }
59
+
38
60
  /**
39
61
  * What changed since the previous run of the same site. Compares plan coverage when both runs were
40
62
  * checked against a plan, and the health findings otherwise.
41
63
  * Returns { previous: null } when there is no earlier run, else
42
64
  * { previous, mode: "plan" | "findings", fixed: [...], new: [...] }.
43
65
  */
44
- export function compareRuns(prev, { findings, planCheck = null }) {
66
+ export function compareRuns(prev, { findings, planCheck = null, runs = [] }) {
45
67
  if (!prev) return { previous: null };
68
+ const routeChanges = compareRoutes(prev.capture?.runs, runs);
69
+ const base = compareFindings(prev, { findings, planCheck });
70
+ return routeChanges.length ? { ...base, routes: routeChanges } : base;
71
+ }
72
+
73
+ function compareFindings(prev, { findings, planCheck }) {
46
74
  if (planCheck && prev.planCheck) {
47
75
  const before = planProblems(prev.planCheck);
48
76
  const now = planProblems(planCheck);
package/src/i18n.js CHANGED
@@ -53,6 +53,12 @@ const en = {
53
53
  whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, step "${step}"` : ""}`,
54
54
  allGood: "All planned events fired correctly on every journey tested.",
55
55
  tabEvents: "Events",
56
+ tabTimeline: "Timeline",
57
+ timelineGtmEvent: "GTM event",
58
+ timelineConsent: "Consent command",
59
+ timelineNoEvent: "No GTM event (data only)",
60
+ timelineNoHits: "No platform hit followed this push.",
61
+ timelineOther: "Hits without a dataLayer push",
56
62
  bodyDropped: "Request body not stored: this audit reached its 2 MB limit for stored request bodies.",
57
63
  tabNetwork: "Network requests",
58
64
  filterBy: "Filter by source",
@@ -75,6 +81,9 @@ const en = {
75
81
  impact: "Impact",
76
82
  evidence: "Evidence",
77
83
  fixSteps: "Fix",
84
+ verify: "How to verify",
85
+ owners: { developer: "Developer", gtm: "Tag Manager", marketing: "Marketing" },
86
+ ownerHint: { developer: "Site code and dataLayer", gtm: "Tags, triggers and consent setup", marketing: "The measurement plan" },
78
87
  places: (n) => `${n} ${n === 1 ? "place" : "places"}`,
79
88
  alsoCheck: "Not in the findings list, but worth checking",
80
89
  providersChecked: "Providers checked:",
@@ -92,6 +101,7 @@ const en = {
92
101
  changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
93
102
  changeFixed: "Fixed",
94
103
  changeNew: "New",
104
+ routeChanged: (list) => `The AI took a different route through the site than in the last run: ${list}`,
95
105
  noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
96
106
  planVsSiteLead: "Each planned event, and what the site actually sent",
97
107
  coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
@@ -229,6 +239,12 @@ const tr = {
229
239
  whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, "${step}" adımı` : ""}`,
230
240
  allGood: "Test edilen tüm yolculuklarda plandaki tüm olaylar doğru çalıştı.",
231
241
  tabEvents: "Olaylar",
242
+ tabTimeline: "Zaman çizelgesi",
243
+ timelineGtmEvent: "GTM olayı",
244
+ timelineConsent: "İzin komutu",
245
+ timelineNoEvent: "GTM olayı yok (yalnızca veri)",
246
+ timelineNoHits: "Bu gönderimin ardından platform isteği gelmedi.",
247
+ timelineOther: "dataLayer gönderimi olmayan istekler",
232
248
  bodyDropped: "İstek gövdesi saklanmadı: bu denetim, saklanan istek gövdeleri için 2 MB sınırına ulaştı.",
233
249
  tabNetwork: "Ağ istekleri",
234
250
  filterBy: "Kaynağa göre filtrele",
@@ -251,6 +267,9 @@ const tr = {
251
267
  impact: "Etkisi",
252
268
  evidence: "Kanıt",
253
269
  fixSteps: "Çözüm",
270
+ verify: "Nasıl doğrulanır",
271
+ owners: { developer: "Yazılımcı", gtm: "Tag Manager", marketing: "Pazarlama" },
272
+ ownerHint: { developer: "Site kodu ve dataLayer", gtm: "Etiketler, tetikleyiciler ve onay ayarları", marketing: "Ölçüm planı" },
254
273
  places: (n) => `${n} yer`,
255
274
  alsoCheck: "Bulgu listesinde olmayan, ama kontrol edilmesi gerekenler",
256
275
  providersChecked: "Kontrol edilen sağlayıcılar:",
@@ -268,6 +287,7 @@ const tr = {
268
287
  changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
269
288
  changeFixed: "Düzeldi",
270
289
  changeNew: "Yeni",
290
+ routeChanged: (list) => `Yapay zeka siteyi son çalıştırmadan farklı bir yoldan gezdi: ${list}`,
271
291
  noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
272
292
  planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
273
293
  coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
package/src/mcp.js CHANGED
@@ -37,7 +37,7 @@ const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracki
37
37
 
38
38
  Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the site actually sends. If the user wants to know what to track, which events a site needs, or asks for a tracking or measurement plan, call make_plan (with topic when they only care about one kind of event: ${Object.keys(TOPICS).join(", ")}). If they want to know whether tracking works, what a site sends, or what changed, run an audit. To audit against the plan afterwards, pass against to finish_audit.
39
39
 
40
- Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
40
+ Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}, plus any other platform the site turns out to use); pass their choice as providers, or leave it out for the default.
41
41
 
42
42
  To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
43
43
 
@@ -153,7 +153,7 @@ export function buildServer(opts) {
153
153
  providers: z
154
154
  .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
155
155
  .optional()
156
- .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
156
+ .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
157
157
  },
158
158
  },
159
159
  async ({ site, journey, viewport, allowed_hosts, providers }) => {
@@ -367,7 +367,7 @@ export function buildServer(opts) {
367
367
  providers: z
368
368
  .array(z.enum(Object.keys(TRACKING_PROVIDERS)))
369
369
  .optional()
370
- .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
370
+ .describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
371
371
  },
372
372
  },
373
373
  async ({ config: configFile, against, scripted, language, branding, providers }) => {
package/src/parsers.js CHANGED
@@ -196,16 +196,162 @@ export function parseKlaviyo(url, postData) {
196
196
  }];
197
197
  }
198
198
 
199
- /** Hotjar: calls to its in.hotjar.com / .hotjar.io API carry the site id in the path. Only the id is read. @returns {Hit[] | null} */
199
+ /**
200
+ * LinkedIn Insight Tag: px.ads.linkedin.com/collect with the partner id (pid), and conversionId
201
+ * for a conversion; without one the hit is the page view. eventId pairs it with a Conversions API copy.
202
+ * @returns {Hit[] | null}
203
+ */
204
+ export function parseLinkedIn(url) {
205
+ const u = new URL(url);
206
+ const q = u.searchParams;
207
+ if (u.hostname !== "px.ads.linkedin.com" || !/^\/(collect|wa)\/?$/.test(u.pathname) || !q.has("pid")) return null;
208
+ const params = {};
209
+ for (const [from, to] of [["value", "value"], ["currency", "currency"], ["url", "page_location"]]) if (q.has(from)) params[to] = q.get(from);
210
+ const eventId = q.get("eventId") || undefined;
211
+ if (eventId) params.event_id = eventId;
212
+ const conversion = q.get("conversionId");
213
+ return [{ platform: "linkedin", name: conversion ? `conversion:${conversion}` : "pageview", id: q.get("pid"), ...(eventId ? { eventId } : {}), params, url }];
214
+ }
215
+
216
+ /**
217
+ * Microsoft Advertising UET: bat.bing.com/action/0 with the tag id (ti) and evt: "pageLoad" for page
218
+ * views, "custom" for events, named by their action (ea). gv and gc are the revenue and currency.
219
+ * @returns {Hit[] | null}
220
+ */
221
+ export function parseBing(url) {
222
+ const u = new URL(url);
223
+ const q = u.searchParams;
224
+ if (u.hostname !== "bat.bing.com" || !/^\/action\/\d+$/.test(u.pathname) || !q.has("ti")) return null;
225
+ const params = {};
226
+ for (const [from, to] of [["ec", "event_category"], ["el", "event_label"], ["ev", "event_value"], ["gv", "value"], ["gc", "currency"], ["p", "page_location"]]) {
227
+ if (q.has(from)) params[to] = q.get(from);
228
+ }
229
+ const evt = q.get("evt") || "";
230
+ const name = evt === "custom" ? q.get("ea") || "custom" : evt || "(unnamed)";
231
+ return [{ platform: "microsoft_ads", name, id: q.get("ti"), params, url }];
232
+ }
233
+
234
+ // Criteo OneTag event codes. Setup entries (site type, email, …) ride along in the same request.
235
+ const CRITEO_EVENTS = { vh: "viewHome", vl: "viewList", vp: "viewItem", vb: "viewBasket", vc: "trackTransaction", vs: "viewSearch" };
236
+
237
+ /**
238
+ * Criteo OneTag: sslwidget.criteo.com/event with the account (a) and one entry per pushed event
239
+ * in p0, p1, … (each itself a query string with e = the event code). Hashed emails are user data.
240
+ * @returns {Hit[] | null}
241
+ */
242
+ export function parseCriteo(url, postData) {
243
+ const u = new URL(url);
244
+ if (!/(^|\.)criteo\.(com|net)$/.test(u.hostname) || !/^\/(event|m\/event)\/?$/.test(u.pathname)) return null;
245
+ const q = new URLSearchParams(u.search);
246
+ if (postData && !postData.trim().startsWith("{")) for (const [k, v] of new URLSearchParams(postData)) q.set(k, v);
247
+ const entries = [...q].filter(([k]) => /^p\d+$/.test(k)).map(([, v]) => new URLSearchParams(v));
248
+ if (!q.has("a") || !entries.length) return null;
249
+ const params = {};
250
+ const user = {};
251
+ let name = "";
252
+ for (const e of entries) {
253
+ const code = e.get("e");
254
+ if (CRITEO_EVENTS[code] && !name) name = CRITEO_EVENTS[code];
255
+ for (const [k, v] of e) {
256
+ if (k === "e") continue;
257
+ if (/^m(\[\d*\])?$/.test(k) || isUserDataKey(k)) user[`${code}.${k}`] = v;
258
+ else if (k === "p") params.content_ids = v;
259
+ else if (k === "id" && code === "vc") params.transaction_id = v;
260
+ else if (k === "site_type") params.site_type = v;
261
+ else if (k === "c") params.currency = v;
262
+ else if (k === "item" || k === "i") params.items = v;
263
+ }
264
+ }
265
+ return [{ platform: "criteo", name: name || "(unnamed)", id: q.get("a"), params, ...(Object.keys(user).length ? { userFields: user } : {}), url }];
266
+ }
267
+
268
+ /**
269
+ * Adobe Analytics image request: /b/ss/<report suite>/<version>/<code>/s<random> on omtrdc.net,
270
+ * 2o7.net or a first-party host. A link (pe: lnk_o, lnk_d, lnk_e) is named by its link name (pev2),
271
+ * anything else is the page view. events, products, purchase id, currency and the page are kept.
272
+ * @returns {Hit[] | null}
273
+ */
274
+ export function parseAdobe(url) {
275
+ const u = new URL(url);
276
+ const m = u.pathname.match(/^\/b\/ss\/([^/]+)\/[^/]+\//);
277
+ if (!m) return null;
278
+ const q = u.searchParams;
279
+ const params = {};
280
+ for (const [from, to] of [["pageName", "page_title"], ["events", "events"], ["products", "items"], ["purchaseID", "transaction_id"], ["xact", "transaction_id"], ["cc", "currency"], ["g", "page_location"]]) {
281
+ if (q.has(from)) params[to] = q.get(from);
282
+ }
283
+ const pe = q.get("pe");
284
+ return [{ platform: "adobe", name: pe ? `link:${q.get("pev2") || pe}` : "pageView", id: decodeURIComponent(m[1]), params, url }];
285
+ }
286
+
287
+ // Matomo and Piwik PRO share one tracking protocol: an idsite and either an event (e_c, e_a, e_n),
288
+ // a goal (idgoal) or an ecommerce order (idgoal=0 with ec_id), otherwise a page view.
289
+ function matomoHit(platform, q, url) {
290
+ const params = {};
291
+ for (const [from, to] of [["revenue", "value"], ["ec_id", "transaction_id"], ["ec_items", "items"], ["ec_tx", "tax"], ["ec_sh", "shipping"], ["e_v", "event_value"], ["action_name", "page_title"], ["url", "page_location"], ["search", "search_term"]]) {
292
+ if (q.has(from)) params[to] = q.get(from);
293
+ }
294
+ const goal = q.get("idgoal");
295
+ let name = "pageview";
296
+ if (q.has("e_c")) name = q.get("e_a") ? `${q.get("e_c")}:${q.get("e_a")}` : q.get("e_c");
297
+ else if (goal === "0") name = q.has("ec_id") ? "ecommerce_order" : "ecommerce_cart_update";
298
+ else if (goal) name = `goal:${goal}`;
299
+ else if (q.has("search")) name = "site_search";
300
+ else if (q.has("link")) name = "outlink";
301
+ else if (q.has("download")) name = "download";
302
+ const user = [...q].filter(([k, v]) => isUserDataKey(k) && v);
303
+ return { platform, name, id: q.get("idsite") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url };
304
+ }
305
+
306
+ // Matomo's tracker can batch hits: a JSON POST of { requests: ["?idsite=1&…", …] }.
307
+ function matomoHits(platform, u, postData) {
308
+ const requests = jsonOr(postData ?? "{}", {})?.requests;
309
+ const batched = Array.isArray(requests) ? requests.filter((r) => typeof r === "string").map((r) => new URLSearchParams(r.replace(/^\?/, ""))) : [];
310
+ const queries = batched.length ? batched.map((b) => { for (const [k, v] of u.searchParams) if (!b.has(k)) b.set(k, v); return b; }) : [u.searchParams];
311
+ return queries.filter((q) => q.has("idsite")).map((q) => matomoHit(platform, q, u.href));
312
+ }
313
+
314
+ /** Matomo (self-hosted or cloud): /matomo.php or /piwik.php with idsite, on any host. @returns {Hit[] | null} */
315
+ export function parseMatomo(url, postData) {
316
+ const u = new URL(url);
317
+ if (!/(^|\/)(matomo|piwik)\.php$/.test(u.pathname)) return null;
318
+ const hits = matomoHits("matomo", u, postData);
319
+ return hits.length ? hits : null;
320
+ }
321
+
322
+ /** Piwik PRO: /ppms.php with idsite (a UUID), on its cloud or a first-party host. @returns {Hit[] | null} */
323
+ export function parsePiwikPro(url, postData) {
324
+ const u = new URL(url);
325
+ if (!/(^|\/)ppms\.php$/.test(u.pathname)) return null;
326
+ const hits = matomoHits("piwik_pro", u, postData);
327
+ return hits.length ? hits : null;
328
+ }
329
+
330
+ /**
331
+ * Microsoft Clarity: the tag load (www.clarity.ms/tag/<project>) names the project; the data
332
+ * itself goes to <letter>.clarity.ms/collect as a compressed payload, so only the call is shown.
333
+ * @returns {Hit[] | null}
334
+ */
335
+ export function parseClarity(url) {
336
+ const u = new URL(url);
337
+ if (!/(^|\.)clarity\.ms$/.test(u.hostname)) return null;
338
+ const tag = u.pathname.match(/^\/tag\/([a-z0-9]+)\/?$/i);
339
+ if (tag) return [{ platform: "clarity", name: "script_load", id: tag[1], params: {}, url }];
340
+ if (/^\/collect\/?$/.test(u.pathname)) return [{ platform: "clarity", name: "collect", params: {}, url }];
341
+ return null;
342
+ }
343
+
344
+ /** Hotjar: the tag load (static.hotjar.com/c/hotjar-<id>.js) and calls to its API carry the site id in the path. Only the id is read. @returns {Hit[] | null} */
200
345
  export function parseHotjar(url) {
201
346
  const u = new URL(url);
202
347
  if (!/(^|\.)hotjar\.(com|io)$/.test(u.hostname)) return null;
348
+ const script = u.pathname.match(/^\/c\/hotjar-(\d+)\.js$/);
349
+ if (script) return [{ platform: "hotjar", name: "script_load", id: script[1], params: {}, url }];
203
350
  const m = u.pathname.match(/^\/api\/v\d+\/client\/sites\/(\d+)\/([a-z-]+)/i);
204
351
  if (!m) return null;
205
352
  return [{ platform: "hotjar", name: m[2], id: m[1], params: {}, url }];
206
353
  }
207
354
 
208
- const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX, parsePinterest, parseSnapchat, parseKlaviyo, parseHotjar];
209
355
 
210
356
  /** Returns normalized hits for a request, or null when it is not a tracking request. */
211
357
  export function parseTrackingRequest(url, postData) {
@@ -231,7 +377,7 @@ const OTHER_BEACONS = [
231
377
  [/./, /\/(ccm|ccm\/s|g\/s)\/collect$/],
232
378
  [/^td\.doubleclick\.net$/, /^\/td\//],
233
379
  [/(^|\.)facebook\.com$/, /^\/privacy_sandbox\/pixel\//],
234
- [/^bat\.bing\.com$/, /^\/action\//],
380
+ [/^bat\.bing\.com$/, /^\/action\//], // UET requests without a tag id
235
381
  [/^px\.ads\.linkedin\.com$/, /^\/(collect|wa)/],
236
382
  [/^ct\.pinterest\.com$/, /^\/(v3|user)/],
237
383
  [/^tr\.snapchat\.com$/, /^\//],
@@ -249,24 +395,39 @@ export function isCollectionEndpoint(url) {
249
395
  return OTHER_BEACONS.some(([host, pathname]) => host.test(u.hostname) && pathname.test(u.pathname));
250
396
  }
251
397
 
252
- // Providers the user can choose to check. Requests to the ones not chosen are still
398
+ /**
399
+ * Every tracking vendor: its key, display name, the hosts its requests go to (path: how its
400
+ * tracker is told apart on a first-party host) and, when its events are read, a parser. Adding a vendor is one entry here plus its icon in report.js.
401
+ */
402
+ export const VENDORS = [
403
+ { key: "ga4", label: "GA4", hosts: /(^|\.)(google-analytics|analytics\.google)\.com$/, parse: parseGa4 },
404
+ { key: "google_ads", label: "Google Ads", hosts: /(^|\.)googleadservices\.com$/, parse: parseGoogleAds },
405
+ { key: "meta", label: "Meta", hosts: /(^|\.)(facebook\.com|facebook\.net)$/, parse: parseMeta },
406
+ { key: "tiktok", label: "TikTok", hosts: /(^|\.)tiktok\.com$/, parse: parseTikTok },
407
+ { key: "x", label: "X", hosts: /(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, parse: parseX },
408
+ { key: "linkedin", label: "LinkedIn", hosts: /(^|\.)(licdn\.com|ads\.linkedin\.com)$/, parse: parseLinkedIn },
409
+ { key: "microsoft_ads", label: "Microsoft Ads", hosts: /(^|\.)bing\.com$/, parse: parseBing },
410
+ { key: "pinterest", label: "Pinterest", hosts: /(^|\.)(pinterest\.com|pinimg\.com)$/, parse: parsePinterest },
411
+ { key: "snapchat", label: "Snapchat", hosts: /(^|\.)(snapchat\.com|sc-static\.net)$/, parse: parseSnapchat },
412
+ { key: "criteo", label: "Criteo", hosts: /(^|\.)(criteo\.com|criteo\.net)$/, parse: parseCriteo },
413
+ { key: "klaviyo", label: "Klaviyo", hosts: /(^|\.)klaviyo\.com$/, parse: parseKlaviyo },
414
+ { key: "adobe", label: "Adobe Analytics", hosts: /(^|\.)(omtrdc|2o7)\.net$/, path: /^\/b\/ss\//, parse: parseAdobe },
415
+ { key: "matomo", label: "Matomo", hosts: /(^|\.)(matomo\.cloud|innocraft\.cloud)$/, path: /(^|\/)(matomo|piwik)\.php$/, parse: parseMatomo },
416
+ // No host: piwik.pro also serves consent management, so its tracker is recognized by path.
417
+ { key: "piwik_pro", label: "Piwik PRO", path: /(^|\/)ppms\.php$/, parse: parsePiwikPro },
418
+ { key: "clarity", label: "Microsoft Clarity", hosts: /(^|\.)clarity\.ms$/, parse: parseClarity },
419
+ { key: "hotjar", label: "Hotjar", hosts: /(^|\.)hotjar\.(com|io)$/, parse: parseHotjar },
420
+ { key: "doubleclick", label: "DoubleClick" },
421
+ { key: "adsense", label: "Google AdSense", hosts: /(^|\.)googlesyndication\.com$/ },
422
+ ];
423
+
424
+ const PARSERS = VENDORS.filter((v) => v.parse).map((v) => v.parse);
425
+
426
+ // Providers the user can choose to check. Requests to the ones not checked are still
253
427
  // answered locally, but they are left out of the timeline and only counted.
254
- export const PROVIDERS = {
255
- ga4: "GA4",
256
- google_ads: "Google Ads",
257
- meta: "Meta",
258
- tiktok: "TikTok",
259
- x: "X",
260
- linkedin: "LinkedIn",
261
- microsoft_ads: "Microsoft Ads",
262
- pinterest: "Pinterest",
263
- snapchat: "Snapchat",
264
- klaviyo: "Klaviyo",
265
- clarity: "Microsoft Clarity",
266
- hotjar: "Hotjar",
267
- doubleclick: "DoubleClick",
268
- adsense: "Google AdSense",
269
- };
428
+ export const PROVIDERS = Object.fromEntries(VENDORS.map((v) => [v.key, v.label]));
429
+ // Always checked. Any other vendor the site sends to is added on its first request,
430
+ // unless the platforms to check were chosen.
270
431
  export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
271
432
  // Tag managers and consent platforms are always shown: they explain how the tags loaded.
272
433
  // Server-side tagging providers are too: their requests show where events are sent from.
@@ -275,19 +436,7 @@ const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform", s
275
436
  const HOSTS = [
276
437
  [/(^|\.)stape\.(io|jp)$/, "stape"],
277
438
  [/(^|\.)getelevar\.com$/, "elevar"],
278
- [/(^|\.)(google-analytics|analytics\.google)\.com$/, "ga4"],
279
- [/(^|\.)googleadservices\.com$/, "google_ads"],
280
- [/(^|\.)googlesyndication\.com$/, "adsense"],
281
- [/(^|\.)(facebook\.com|facebook\.net)$/, "meta"],
282
- [/(^|\.)tiktok\.com$/, "tiktok"],
283
- [/(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, "x"],
284
- [/(^|\.)(licdn\.com|ads\.linkedin\.com)$/, "linkedin"],
285
- [/(^|\.)bing\.com$/, "microsoft_ads"],
286
- [/(^|\.)clarity\.ms$/, "clarity"],
287
- [/(^|\.)hotjar\.(com|io)$/, "hotjar"],
288
- [/(^|\.)(pinterest\.com|pinimg\.com)$/, "pinterest"],
289
- [/(^|\.)(snapchat\.com|sc-static\.net)$/, "snapchat"],
290
- [/(^|\.)klaviyo\.com$/, "klaviyo"],
439
+ ...VENDORS.filter((v) => v.hosts).map((v) => [v.hosts, v.key]),
291
440
  [/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.(eu|com)|cookieyes\.com|didomi\.io|privacy-center\.org|trustarc\.com|truste\.com|consensu\.org|quantcast\.com|osano\.com|iubenda\.com|termly\.io|cookiefirst\.com|axeptio\.eu|sourcepoint\.com|sp-prod\.net|consentmanager\.net|cookiescript\.com|cookie-script\.com|cookieinformation\.com|civiccomputing\.com|piwik\.pro|complianz\.io|cdn\.privacy-mgmt\.com)$/, "consent"],
292
441
  ];
293
442
 
@@ -310,6 +459,8 @@ export function providerOf(url) {
310
459
  if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
311
460
  // Before the vendor hosts: a GA4 hit to a Stape or Elevar endpoint is still a GA4 hit.
312
461
  if (isGa4Path(u) && !GOOGLE_COMPANION_HOST.test(host)) return of("ga4");
462
+ // Before the hosts too: trackers on a first-party or consent vendor host are told apart by path.
463
+ for (const v of VENDORS) if (v.path?.test(u.pathname)) return of(v.key);
313
464
  for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
314
465
  if (isGa4Path(u)) return of("ga4");
315
466
  if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
package/src/redact.js CHANGED
@@ -88,12 +88,29 @@ function note(found, key, value) {
88
88
  }
89
89
 
90
90
  /** Redacts form-encoded parameters (a URL query or a GA4 body line). Returns null when nothing needed it. */
91
+ // Criteo OneTag nests each event as a query string in p0, p1, …; its setEmail entry (e=ce) carries the email hash in m.
92
+ const isNestedEvent = (k, v) => /^p\d+$/.test(k) && /^e=/.test(v);
93
+ const nestedUserKey = (k) => /^m(\[\d*\])?$/.test(k) || isUserDataKey(k);
94
+
91
95
  function redactParamString(text, found, site) {
92
96
  const params = new URLSearchParams(text);
93
- if (![...params].some(([k, v]) => isUserDataKey(k) || isEmail(v, site, k))) return null;
97
+ let changed = false;
94
98
  const out = new URLSearchParams();
95
- for (const [k, v] of params) out.append(k, isUserDataKey(k) || isEmail(v, site, k) ? note(found, k, v) : v);
96
- return out.toString();
99
+ for (const [k, v] of params) {
100
+ if (isNestedEvent(k, v)) {
101
+ const inner = new URLSearchParams();
102
+ for (const [ik, iv] of new URLSearchParams(v)) {
103
+ const hide = nestedUserKey(ik) || isEmail(iv, site, ik);
104
+ if (hide) changed = true;
105
+ inner.append(ik, hide ? note(found, ik, iv) : iv);
106
+ }
107
+ out.append(k, inner.toString());
108
+ } else if (isUserDataKey(k) || isEmail(v, site, k)) {
109
+ changed = true;
110
+ out.append(k, note(found, k, v));
111
+ } else out.append(k, v);
112
+ }
113
+ return changed ? out.toString() : null;
97
114
  }
98
115
 
99
116
  /** Redacts user data in a URL's query. Returns the URL unchanged when there is none. site: see isOwnAddress. */
package/src/report.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { OWNERS, ownerOf, verifyLinks } from "./verify.js";
1
2
  import { createProvider } from "./providers.js";
2
3
  import { groupFindings, hitMatchesEvent, summarize } from "./checks.js";
3
4
  import { messages } from "./i18n.js";
@@ -149,11 +150,13 @@ const formatPush = (value) => (isConsentEntry(value) ? `gtag(${value.map((v) =>
149
150
  const mdText = (v) => String(v ?? "").replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
150
151
  const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
151
152
 
153
+ const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
154
+
152
155
  export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null }) {
153
156
  const m = messages(lang);
154
157
  const rows = attachExplanations(findings, explained);
155
158
  const counts = summarize(rows);
156
- const lines = [`# ${mdText(title)}`, "", m.ui.generated(generatedAt), "", ...(against ? [mdText(m.ui.checkedAgainst(against)), ""] : []), `**${m.ui.counts(counts)}**`, ""];
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)}**`, ""];
157
160
  if (changes) lines.push(...changesMd(m, changes));
158
161
  if (planCheck) lines.push(...planVsSiteMd(m, planCheck));
159
162
  const sum = summaryParts(explained?.summary);
@@ -166,6 +169,9 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
166
169
  for (const f of rows.filter((r) => r.severity !== "ok")) {
167
170
  lines.push(`### ${m.severity[f.severity]}: ${mdText(f.detail)}`, "");
168
171
  lines.push(`- ${m.ui.where}: ${mdText(f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; "))}`);
172
+ lines.push(`- ${m.ui.owners[ownerOf(f)]}`);
173
+ const vl = verifyLinks(f);
174
+ if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
169
175
  if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
170
176
  if (f.explanation) {
171
177
  if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
@@ -194,7 +200,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
194
200
  return lines.join("\n");
195
201
  }
196
202
 
197
- const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "pinterest", "snapchat", "klaviyo", "hotjar"];
203
+ const PLATFORM_ORDER = ["ga4", "google_ads", "meta", "tiktok", "x", "linkedin", "microsoft_ads", "pinterest", "snapchat", "criteo", "klaviyo", "adobe", "matomo", "piwik_pro", "clarity", "hotjar"];
198
204
  export const platformLabel = (p) => PROVIDERS[p] ?? String(p).replace(/[_-]+/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
199
205
  const platformRank = (p) => (PLATFORM_ORDER.includes(p) ? PLATFORM_ORDER.indexOf(p) : PLATFORM_ORDER.length);
200
206
 
@@ -203,6 +209,7 @@ const EVENT_ALIASES = {
203
209
  pageview: "pageview", viewcontent: "viewitem", addtocart: "addtocart", addtowishlist: "addtowishlist", initiatecheckout: "begincheckout",
204
210
  addpaymentinfo: "addpaymentinfo", purchase: "purchase", completepayment: "purchase", placeanorder: "purchase", lead: "generatelead",
205
211
  completeregistration: "signup", search: "search", contact: "generatelead", submitform: "generatelead", subscribe: "subscribe", starttrial: "starttrial",
212
+ viewbasket: "viewcart", tracktransaction: "purchase",
206
213
  };
207
214
  const normName = (s) => String(s ?? "").toLowerCase().replace(/[^a-z0-9]/g, "");
208
215
  const eventKey = (s) => EVENT_ALIASES[normName(s)] ?? normName(s);
@@ -468,6 +475,37 @@ function whereTarget(runs, w, f) {
468
475
  return { ri, step };
469
476
  }
470
477
 
478
+ /** The push a hit came from: the latest matching push at or before the hit, else the first match. */
479
+ function sourcePush(run, hit) {
480
+ const pushes = pushesForHit(run, hit);
481
+ return pushes.filter((e) => e.t <= hit.t).at(-1) ?? pushes[0] ?? null;
482
+ }
483
+
484
+ /** The step in order: each dataLayer push, the GTM event it raised, then the platform hits it caused. Hits with no push come last. */
485
+ function timelinePanel(m, findings, run, step, pushes, hits) {
486
+ const bySource = new Map();
487
+ const orphans = [];
488
+ for (const h of [...hits].sort((a, b) => a.t - b.t)) {
489
+ const src = sourcePush(run, h);
490
+ if (src && src.step === step.index) {
491
+ if (!bySource.has(src)) bySource.set(src, []);
492
+ bySource.get(src).push(h);
493
+ } else orphans.push(h);
494
+ }
495
+ const hitList = (list) => (list.length ? `<ul class="evlist tl-hits">${list.map((h) => eventRow(m, findings, run, step, h)).join("")}</ul>` : `<p class="muted xs tl-none">${m.ui.timelineNoHits}</p>`);
496
+ const nodes = [...pushes].sort((a, b) => a.t - b.t).map((e) => {
497
+ const name = pushEvent(e.value);
498
+ const gtm = isConsentEntry(e.value)
499
+ ? `<span class="tl-k">${m.ui.timelineConsent}</span><code>${escapeHtml(`consent ${e.value[1] ?? ""}`.trim())}</code>`
500
+ : typeof name === "string" && name
501
+ ? `<span class="tl-k">${m.ui.timelineGtmEvent}</span><code>${escapeHtml(name)}</code>`
502
+ : `<span class="tl-k muted">${m.ui.timelineNoEvent}</span>`;
503
+ return `<li class="tl-node"><div class="tl-lbl">dataLayer</div><ul class="evlist">${pushRow(m, run, e)}</ul><div class="tl-gtm">${gtm}</div>${hitList(bySource.get(e) ?? [])}</li>`;
504
+ });
505
+ if (orphans.length) nodes.push(`<li class="tl-node tl-orphan"><div class="tl-lbl">${m.ui.timelineOther}</div>${hitList(orphans)}</li>`);
506
+ return nodes.length ? `<ol class="timeline">${nodes.join("")}</ol>` : `<p class="empty">${m.ui.noHits}</p>`;
507
+ }
508
+
471
509
  function stepDetail(m, lang, findings, run, ri, step, isDefault) {
472
510
  const hits = run.hits.filter((h) => h.step === step.index);
473
511
  const id = `s${ri}-${step.index}`;
@@ -480,7 +518,8 @@ function stepDetail(m, lang, findings, run, ri, step, isDefault) {
480
518
  const tab = (n, label, count) => `<input type="radio" name="${id}" id="${id}-${n}"${n === 1 ? " checked" : ""}><label for="${id}-${n}">${label} <span class="n">${count}</span></label>`;
481
519
  return `<section class="sdetail${isDefault ? " default" : ""}" id="${stepAnchor(ri, step.index)}" aria-label="${escapeHtml(step.label)}">
482
520
  <header class="shead"><span class="sidx">${step.index}</span><div><h3>${escapeHtml(step.label)}</h3><div class="muted xs">${escapeHtml(m.ui.stepMeta(seconds(run, step.t), hits.length, pushes.length, skippedN))}${step.forced ? ` · ${m.ui.clickedThrough}` : ""}</div></div></header>
483
- <div class="tabs seg">${tab(1, m.ui.tabEvents, eventRows.length)}${tab(2, "dataLayer", pushes.length)}${tab(3, m.ui.tabNetwork, reqRows.length)}
521
+ <div class="tabs seg">${tab(1, m.ui.tabTimeline, pushes.length + hits.length)}${tab(2, m.ui.tabEvents, eventRows.length)}${tab(3, "dataLayer", pushes.length)}${tab(4, m.ui.tabNetwork, reqRows.length)}
522
+ <div class="panel">${timelinePanel(m, findings, run, step, pushes, hits)}</div>
484
523
  <div class="panel">${eventRows.length ? filtered(m, `${id}e`, eventRows, eventRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noHits}</p>`}</div>
485
524
  <div class="panel">${pushes.length ? `<ul class="evlist">${pushes.map((e) => pushRow(m, run, e)).join("")}</ul>` : `<p class="empty">${m.ui.noPushes}</p>`}</div>
486
525
  <div class="panel">${reqRows.length ? filtered(m, `${id}n`, reqRows, reqRows.map((r) => r.html).join("")) : `<p class="empty">${m.ui.noRequests}</p>`}${skippedN ? `<p class="muted xs scope-note">${escapeHtml(m.ui.skippedInStep(skipped.map(([v, n]) => `${v} ${n}`).join(", "), skippedN))}</p>` : ""}</div>
@@ -534,6 +573,12 @@ function evidenceHtml(evidence) {
534
573
  return `<pre>${escapeHtml(evidence)}</pre>`;
535
574
  }
536
575
 
576
+ function verifyHtml(m, f) {
577
+ const links = verifyLinks(f);
578
+ if (!links.length) return "";
579
+ return `${sec("cause", m.ui.verify)}<ul class="verify">${links.map((l) => `<li><a href="${l.url}" target="_blank" rel="noopener noreferrer">${escapeHtml(l.name)}</a></li>`).join("")}</ul>`;
580
+ }
581
+
537
582
  function findingCard(m, runs, f, open, aiRan) {
538
583
  const e = f.explanation;
539
584
  const steps = fixSteps(e);
@@ -557,7 +602,7 @@ function findingCard(m, runs, f, open, aiRan) {
557
602
  ${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
558
603
  <div class="fbody">${e?.impact ? `<div class="impact">${ic(ICON.impact, 16)}<div><span class="impact-l">${m.ui.impact}</span>${fmt(e.impact)}</div></div>` : ""}
559
604
  <div class="fcols"><div class="fcol">${e?.likely_cause ? `${sec("cause", m.ui.likelyCause)}<p>${fmt(e.likely_cause)}</p>` : aiRan || f.severity === "info" ? "" : `${sec("cause", m.ui.likelyCause)}<p class="muted">${m.ui.noExplanation}</p>`}${evidence ? `${sec("evidence", m.ui.evidence)}${evidence}` : ""}</div>
560
- <div class="fcol">${steps.length ? `${sec("fix", m.ui.fixSteps)}<ol class="fix-steps">${steps.map((t) => `<li><span>${fmt(t)}</span></li>`).join("")}</ol>` : ""}${sec("where", m.ui.where)}<div class="where">${chips}</div></div></div></div></details></li>`;
605
+ <div class="fcol">${steps.length ? `${sec("fix", m.ui.fixSteps)}<ol class="fix-steps">${steps.map((t) => `<li><span>${fmt(t)}</span></li>`).join("")}</ol>` : ""}${sec("where", m.ui.where)}<div class="where">${chips}</div>${verifyHtml(m, f)}</div></div></div></details></li>`;
561
606
  }
562
607
 
563
608
  const FINDING_SEVERITIES = ["broken", "risk", "warning", "info"];
@@ -572,11 +617,21 @@ function findingsTab(m, runs, rows, aiRan) {
572
617
  const bar = present.map((s) => `<span style="flex:${n[s]};background:${SEV_COLOR[s]}"></span>`).join("");
573
618
  const legend = present.map((s) => `<span><i style="background:${SEV_COLOR[s]}"></i>${m.severity[s]} <strong>${n[s]}</strong></span>`).join("");
574
619
  const labels = [`<label for="fs-all">${m.ui.all} <span class="n">${problems.length}</span></label>`, ...present.map((s) => `<label for="fs-${s}">${m.severity[s]} <span class="n">${n[s]}</span></label>`)].join("");
575
- const sorted = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s));
620
+ let first = true;
621
+ const groups = OWNERS.map((o) => {
622
+ const mine = FINDING_SEVERITIES.flatMap((s) => problems.filter((f) => f.severity === s && ownerOf(f) === o));
623
+ if (!mine.length) return "";
624
+ const cards = mine.map((f) => {
625
+ const open = first && f.severity !== "info";
626
+ if (open) first = false;
627
+ return findingCard(m, runs, f, open, aiRan);
628
+ }).join("");
629
+ return `<section class="ogroup" data-owner="${o}"><h3 class="ogroup-h">${m.ui.owners[o]} <span class="n">${mine.length}</span><span class="muted xs">${m.ui.ownerHint[o]}</span></h3><ul class="flist">${cards}</ul></section>`;
630
+ }).join("");
576
631
  return `<div class="filter-host">${radios}
577
632
  <div class="sevbar" aria-hidden="true">${bar}</div><div class="sevlegend">${legend}</div>
578
633
  <div class="toolbar"><div class="sevfilter" role="group" aria-label="${m.ui.filterBy}">${labels}</div><span class="muted xs">${m.ui.findingHint}</span></div>
579
- <ul class="flist">${sorted.map((f, i) => findingCard(m, runs, f, i === 0 && f.severity !== "info", aiRan)).join("")}</ul></div>`;
634
+ ${groups}</div>`;
580
635
  }
581
636
 
582
637
  function summaryCard(m, explained, problems) {
@@ -662,6 +717,7 @@ function changesMd(m, ch) {
662
717
  lines.push(`**${mdText(m.ui.changesLine(ch))}**`, "");
663
718
  const items = changeItems(m, ch);
664
719
  if (items.length) lines.push(...items.map((i) => `- ${i.tag}: ${mdText(i.text)}`), "");
720
+ if (ch.routes?.length) lines.push(...ch.routes.map((r) => `- ${mdText(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}`), "");
665
721
  return lines;
666
722
  }
667
723
 
@@ -669,7 +725,7 @@ function changesCard(m, ch) {
669
725
  const head = `<div class="card-h"><div class="card-t">${m.ui.changesTitle}</div>${ch.previous ? `<div class="card-d">${escapeHtml(m.ui.changesLead(ch.previous, ch.mode))}</div>` : ""}</div>`;
670
726
  if (!ch.previous) return `<div class="card" id="changes">${head}<div class="card-c"><p class="muted">${m.ui.noPreviousRun}</p></div></div>`;
671
727
  const items = changeItems(m, ch);
672
- return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}</div></div>`;
728
+ return `<div class="card" id="changes">${head}<div class="card-c"><p><strong>${escapeHtml(m.ui.changesLine(ch))}</strong></p>${items.length ? `<ul class="changes">${items.map((i) => `<li>${badge(i.sev, i.tag)} ${escapeHtml(i.text)}</li>`).join("")}</ul>` : ""}${ch.routes?.length ? `<ul class="changes">${ch.routes.map((r) => `<li>${escapeHtml(`${r.journeyId} (${r.viewport}): ${r.before.join(" → ")} ⇒ ${r.after.join(" → ")}`)}</li>`).join("")}</ul>` : ""}</div></div>`;
673
729
  }
674
730
 
675
731
  function planVsSiteMd(m, pc) {
@@ -752,7 +808,8 @@ function navCss(runs) {
752
808
  const sev = ["all", ...FINDING_SEVERITIES];
753
809
  out.push(`${sev.map((s) => `#fs-${s}:checked~.toolbar label[for=fs-${s}]`).join(",")}{background:var(--bg);color:var(--fg);box-shadow:var(--shadow-sm)}`);
754
810
  out.push(`${sev.map((s) => `#fs-${s}:focus-visible~.toolbar label[for=fs-${s}]`).join(",")}{outline:2px solid var(--accent)}`);
755
- out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.flist>li:not([data-sev=${s}])`).join(",")}{display:none}`);
811
+ out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.ogroup>.flist>li:not([data-sev=${s}])`).join(",")}{display:none}`);
812
+ out.push(`${FINDING_SEVERITIES.map((s) => `#fs-${s}:checked~.ogroup:not(:has(li[data-sev=${s}]))`).join(",")}{display:none}`);
756
813
  return out.join("\n");
757
814
  }
758
815
 
@@ -872,6 +929,8 @@ table.dt a{color:var(--fg)}table.dt a:hover{color:var(--accent-fg)}
872
929
  .fix-steps{list-style:none;margin:0;padding:0;display:grid;gap:10px;counter-reset:fx}
873
930
  .fix-steps li{display:grid;grid-template-columns:22px 1fr;gap:10px;counter-increment:fx}
874
931
  .fix-steps li::before{content:counter(fx);display:inline-grid;place-items:center;width:22px;height:22px;border-radius:4px;background:var(--accent-bg);color:var(--accent-fg);font:500 12px var(--font-mono)}
932
+ .ogroup{margin-top:20px}.ogroup-h{display:flex;align-items:baseline;gap:8px;font-size:14px;margin:0 0 10px}.ogroup-h .n{font-size:12px;color:var(--muted)}
933
+ .verify{display:flex;flex-wrap:wrap;gap:6px 14px;margin:0;padding:0;list-style:none;font-size:13px}.verify a{color:var(--accent-fg)}
875
934
  .where{display:flex;flex-wrap:wrap;gap:6px}
876
935
  .chip{display:inline-flex;align-items:center;gap:8px;padding:4px 10px 4px 4px;border:1px solid var(--border);border-radius:var(--radius);color:var(--fg);font-size:12px;background:var(--bg)}
877
936
  span.chip{padding-left:10px}
@@ -915,15 +974,23 @@ body>main{flex:1 0 auto;width:100%}
915
974
  .shead h3{overflow-wrap:anywhere}
916
975
  .shead .sidx{background:var(--accent);border-color:var(--accent);color:#fff;width:26px;height:26px;flex:none}
917
976
  .empty{color:var(--muted);padding:8px 0}
918
- .tabs.seg{display:grid;grid-template-columns:repeat(3,auto) 1fr;padding:0 16px 16px}
977
+ .tabs.seg{display:grid;grid-template-columns:repeat(4,auto) 1fr;padding:0 16px 16px}
919
978
  .tabs.seg>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
920
979
  .tabs.seg>label{padding:6px 12px;font-weight:500;color:var(--muted);cursor:pointer;background:var(--muted-bg);white-space:nowrap;display:inline-flex;gap:6px;align-items:center}
921
980
  .tabs.seg>label:nth-of-type(1){border-radius:var(--radius) 0 0 var(--radius)}
922
- .tabs.seg>label:nth-of-type(3){border-radius:0 var(--radius) var(--radius) 0}
981
+ .tabs.seg>label:nth-of-type(4){border-radius:0 var(--radius) var(--radius) 0}
923
982
  .tabs.seg>input:checked+label{background:var(--bg);color:var(--fg);box-shadow:inset 0 0 0 1px var(--border)}
924
983
  .tabs.seg>input:focus-visible+label{outline:2px solid var(--accent)}
925
984
  .tabs.seg>.panel{display:none;grid-column:1/-1;padding-top:12px;min-width:0}
926
- .tabs.seg>input:nth-of-type(1):checked~.panel:nth-of-type(1),.tabs.seg>input:nth-of-type(2):checked~.panel:nth-of-type(2),.tabs.seg>input:nth-of-type(3):checked~.panel:nth-of-type(3){display:block}
985
+ .tabs.seg>input:nth-of-type(1):checked~.panel:nth-of-type(1),.tabs.seg>input:nth-of-type(2):checked~.panel:nth-of-type(2),.tabs.seg>input:nth-of-type(3):checked~.panel:nth-of-type(3),.tabs.seg>input:nth-of-type(4):checked~.panel:nth-of-type(4){display:block}
986
+ .timeline{list-style:none;margin:0;padding:0 0 0 18px;border-left:2px solid var(--border);display:grid;gap:14px}
987
+ .tl-node{position:relative;display:grid;gap:6px;min-width:0}
988
+ .tl-node::before{content:"";position:absolute;left:-24px;top:4px;width:10px;height:10px;border-radius:50%;background:var(--bg);border:2px solid var(--accent)}
989
+ .tl-orphan::before{border-color:var(--muted)}
990
+ .tl-lbl{font-size:12px;font-weight:500;color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
991
+ .tl-gtm{display:flex;gap:8px;align-items:center;font-size:13px;padding-left:12px}
992
+ .tl-k{color:var(--muted)}
993
+ .tl-hits,.tl-none{margin-left:12px}
927
994
  .filter{display:flex;flex-wrap:wrap;gap:6px;position:relative}
928
995
  .filter>input{position:absolute;opacity:0;width:1px;height:1px;pointer-events:none}
929
996
  .filter>label{display:inline-flex;align-items:center;gap:6px;padding:3px 10px;border:1px solid var(--input);border-radius:var(--radius);font-size:12px;cursor:pointer;color:var(--muted);background:var(--bg)}
@@ -933,7 +1000,7 @@ body>main{flex:1 0 auto;width:100%}
933
1000
  .filter>input:focus-visible+label{outline:2px solid var(--accent);outline-offset:2px}
934
1001
  .filter>label:last-of-type{margin-bottom:4px}
935
1002
  .dot{display:inline-block;width:12px;height:12px;border-radius:2px;background:var(--muted);vertical-align:-2px}
936
- .dot.p-ga4,.ptag.p-ga4::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSI2IiBoZWlnaHQ9IjIwIiByeD0iMyIgZmlsbD0iI0Y5QUIwMCIvPjxyZWN0IHg9IjkiIHk9IjkiIHdpZHRoPSI2IiBoZWlnaHQ9IjEzIiByeD0iMyIgZmlsbD0iI0UzNzQwMCIvPjxjaXJjbGUgY3g9IjYiIGN5PSIxOSIgcj0iMyIgZmlsbD0iI0UzNzQwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-google_ads,.ptag.p-google_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iOS41IiB5PSIxLjUiIHdpZHRoPSI2IiBoZWlnaHQ9IjIxIiByeD0iMyIgdHJhbnNmb3JtPSJyb3RhdGUoMzAgMTIuNSAxMikiIGZpbGw9IiNGQkJDMDQiLz48cmVjdCB4PSI4LjUiIHk9IjEuNSIgd2lkdGg9IjYiIGhlaWdodD0iMjEiIHJ4PSIzIiB0cmFuc2Zvcm09InJvdGF0ZSgtMzAgMTEuNSAxMikiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSI1LjUiIGN5PSIxOC41IiByPSIzIiBmaWxsPSIjMzRBODUzIi8+PC9zdmc+") center/contain no-repeat}.dot.p-meta,.ptag.p-meta::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIuNSAxNS41YzAtNC40IDIuMy05IDUtOSAzLjQgMCA2IDExIDkuNSAxMSAyLjIgMCA0LjUtMi42IDQuNS02LjVTMTkuOCA2LjUgMTcuNSA2LjVjLTMuNCAwLTYgMTEtOS44IDExLTIuOSAwLTUuMi0uNy01LjItMnoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzA4NjZGRiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-tiktok,.ptag.p-tiktok::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIyLjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iIzI1RjRFRSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iI0ZFMkM1NSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIvPjxwYXRoIGQ9Ik0xMyAzdjExLjVhMy41IDMuNSAwIDEgMS0zLTMuNDZNMTMgM2MuNSAyLjUgMi41IDQgNSA0IiBzdHJva2U9IiMxMTEiLz48L2c+PC9zdmc+") center/contain no-repeat}
1003
+ .dot.p-ga4,.ptag.p-ga4::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMTUiIHk9IjIiIHdpZHRoPSI2IiBoZWlnaHQ9IjIwIiByeD0iMyIgZmlsbD0iI0Y5QUIwMCIvPjxyZWN0IHg9IjkiIHk9IjkiIHdpZHRoPSI2IiBoZWlnaHQ9IjEzIiByeD0iMyIgZmlsbD0iI0UzNzQwMCIvPjxjaXJjbGUgY3g9IjYiIGN5PSIxOSIgcj0iMyIgZmlsbD0iI0UzNzQwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-google_ads,.ptag.p-google_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iOS41IiB5PSIxLjUiIHdpZHRoPSI2IiBoZWlnaHQ9IjIxIiByeD0iMyIgdHJhbnNmb3JtPSJyb3RhdGUoMzAgMTIuNSAxMikiIGZpbGw9IiNGQkJDMDQiLz48cmVjdCB4PSI4LjUiIHk9IjEuNSIgd2lkdGg9IjYiIGhlaWdodD0iMjEiIHJ4PSIzIiB0cmFuc2Zvcm09InJvdGF0ZSgtMzAgMTEuNSAxMikiIGZpbGw9IiM0Mjg1RjQiLz48Y2lyY2xlIGN4PSI1LjUiIGN5PSIxOC41IiByPSIzIiBmaWxsPSIjMzRBODUzIi8+PC9zdmc+") center/contain no-repeat}.dot.p-meta,.ptag.p-meta::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIuNSAxNS41YzAtNC40IDIuMy05IDUtOSAzLjQgMCA2IDExIDkuNSAxMSAyLjIgMCA0LjUtMi42IDQuNS02LjVTMTkuOCA2LjUgMTcuNSA2LjVjLTMuNCAwLTYgMTEtOS44IDExLTIuOSAwLTUuMi0uNy01LjItMnoiIGZpbGw9Im5vbmUiIHN0cm9rZT0iIzA4NjZGRiIgc3Ryb2tlLXdpZHRoPSIyLjQiIHN0cm9rZS1saW5lam9pbj0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-tiktok,.ptag.p-tiktok::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGcgZmlsbD0ibm9uZSIgc3Ryb2tlLXdpZHRoPSIyLjYiIHN0cm9rZS1saW5lY2FwPSJyb3VuZCI+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iIzI1RjRFRSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoLTEgLTEpIi8+PHBhdGggZD0iTTEzIDN2MTEuNWEzLjUgMy41IDAgMSAxLTMtMy40Nk0xMyAzYy41IDIuNSAyLjUgNCA1IDQiIHN0cm9rZT0iI0ZFMkM1NSIgdHJhbnNmb3JtPSJ0cmFuc2xhdGUoMSAxKSIvPjxwYXRoIGQ9Ik0xMyAzdjExLjVhMy41IDMuNSAwIDEgMS0zLTMuNDZNMTMgM2MuNSAyLjUgMi41IDQgNSA0IiBzdHJva2U9IiMxMTEiLz48L2c+PC9zdmc+") center/contain no-repeat}.dot.p-x,.ptag.p-x::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTE4LjkwMSAxLjE1M2gzLjY4bC04LjA0IDkuMTlMMjQgMjIuODQ2aC03LjQwNmwtNS44LTcuNTg0LTYuNjM4IDcuNTg0SC40NzRsOC42LTkuODNMMCAxLjE1NGg3LjU5NGw1LjI0MyA2LjkzMlpNMTcuNjEgMjAuNjQ0aDIuMDM5TDYuNDg2IDMuMjRINC4yOThaIiBmaWxsPSIjMDAwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-linkedin,.ptag.p-linkedin::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTIwLjQ0NyAyMC40NTJoLTMuNTU0di01LjU2OWMwLTEuMzI4LS4wMjctMy4wMzctMS44NTItMy4wMzctMS44NTMgMC0yLjEzNiAxLjQ0NS0yLjEzNiAyLjkzOXY1LjY2N0g5LjM1MVY5aDMuNDE0djEuNTYxaC4wNDZjLjQ3Ny0uOSAxLjYzNy0xLjg1IDMuMzctMS44NSAzLjYwMSAwIDQuMjY3IDIuMzcgNC4yNjcgNS40NTV2Ni4yODZ6TTUuMzM3IDcuNDMzYy0xLjE0NCAwLTIuMDYzLS45MjYtMi4wNjMtMi4wNjUgMC0xLjEzOC45Mi0yLjA2MyAyLjA2My0yLjA2MyAxLjE0IDAgMi4wNjQuOTI1IDIuMDY0IDIuMDYzIDAgMS4xMzktLjkyNSAyLjA2NS0yLjA2NCAyLjA2NXptMS43ODIgMTMuMDE5SDMuNTU1VjloMy41NjR2MTEuNDUyek0yMi4yMjUgMEgxLjc3MUMuNzkyIDAgMCAuNzc0IDAgMS43Mjl2MjAuNTQyQzAgMjMuMjI3Ljc5MiAyNCAxLjc3MSAyNGgyMC40NTFDMjMuMiAyNCAyNCAyMy4yMjcgMjQgMjIuMjcxVjEuNzI5QzI0IC43NzQgMjMuMiAwIDIyLjIyMiAwaC4wMDN6IiBmaWxsPSIjMEE2NkMyIi8+PC9zdmc+") center/contain no-repeat}.dot.p-microsoft_ads,.ptag.p-microsoft_ads::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3QgeD0iMSIgeT0iMSIgd2lkdGg9IjEwLjUiIGhlaWdodD0iMTAuNSIgZmlsbD0iI0YyNTAyMiIvPjxyZWN0IHg9IjEyLjUiIHk9IjEiIHdpZHRoPSIxMC41IiBoZWlnaHQ9IjEwLjUiIGZpbGw9IiM3RkJBMDAiLz48cmVjdCB4PSIxIiB5PSIxMi41IiB3aWR0aD0iMTAuNSIgaGVpZ2h0PSIxMC41IiBmaWxsPSIjMDBBNEVGIi8+PHJlY3QgeD0iMTIuNSIgeT0iMTIuNSIgd2lkdGg9IjEwLjUiIGhlaWdodD0iMTAuNSIgZmlsbD0iI0ZGQjkwMCIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-pinterest,.ptag.p-pinterest::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEyLjAxNyAwQzUuMzk2IDAgLjAyOSA1LjM2Ny4wMjkgMTEuOTg3YzAgNS4wNzkgMy4xNTggOS40MTcgNy42MTggMTEuMTYyLS4xMDUtLjk0OS0uMTk5LTIuNDAzLjA0MS0zLjQzOS4yMTktLjkzNyAxLjQwNi01Ljk1NyAxLjQwNi01Ljk1N3MtLjM1OS0uNzItLjM1OS0xLjc4MWMwLTEuNjYzLjk2Ny0yLjkxMSAyLjE2OC0yLjkxMSAxLjAyNCAwIDEuNTE4Ljc2OSAxLjUxOCAxLjY4OCAwIDEuMDI5LS42NTMgMi41NjctLjk5MiAzLjk5Mi0uMjg1IDEuMTkzLjYgMi4xNjUgMS43NzUgMi4xNjUgMi4xMjggMCAzLjc2OC0yLjI0NSAzLjc2OC01LjQ4NyAwLTIuODYxLTIuMDYzLTQuODY5LTUuMDA4LTQuODY5LTMuNDEgMC01LjQwOSAyLjU2Mi01LjQwOSA1LjE5OSAwIDEuMDMzLjM5NCAyLjE0My44ODkgMi43NDEuMDk5LjEyLjExMi4yMjUuMDg1LjM0NS0uMDkuMzc1LS4yOTMgMS4xOTktLjMzNCAxLjM2My0uMDUzLjIyNS0uMTcyLjI3MS0uNDAxLjE2NS0xLjQ5NS0uNjktMi40MzMtMi44NzgtMi40MzMtNC42NDYgMC0zLjc3NiAyLjc0OC03LjI1MiA3LjkyLTcuMjUyIDQuMTU4IDAgNy4zOTIgMi45NjcgNy4zOTIgNi45MjMgMCA0LjEzNS0yLjYwNyA3LjQ2Mi02LjIzMyA3LjQ2Mi0xLjIxNCAwLTIuMzU0LS42MjktMi43NTgtMS4zNzlsLS43NDkgMi44NDhjLS4yNjkgMS4wNDUtMS4wMDQgMi4zNTItMS40OTggMy4xNDYgMS4xMjMuMzQ1IDIuMzA2LjUzNSAzLjU1LjUzNSA2LjYwNyAwIDExLjk4NS01LjM2NSAxMS45ODUtMTEuOTg3QzIzLjk3IDUuMzkgMTguNTkyLjAyNiAxMS45ODUuMDI2TDEyLjAxNyAweiIgZmlsbD0iI0JEMDgxQyIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-snapchat,.ptag.p-snapchat::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iI0ZGRkMwMCIvPjxwYXRoIGQ9Ik0xMi4yMDYuNzkzYy45OSAwIDQuMzQ3LjI3NiA1LjkzIDMuODIxLjUyOSAxLjE5My40MDMgMy4yMTkuMjk5IDQuODQ3bC0uMDAzLjA2Yy0uMDEyLjE4LS4wMjIuMzQ1LS4wMy41MS4wNzUuMDQ1LjIwMy4wOS40MDEuMDkuMy0uMDE2LjY1OS0uMTIgMS4wMzMtLjMwMS4xNjUtLjA4OC4zNDQtLjEwNC40NjQtLjEwNC4xODIgMCAuMzU5LjAyOS41MDkuMDkuNDUuMTQ5LjczNC40NzkuNzM0LjgzOC4wMTUuNDQ5LS4zOS44MzktMS4yMTMgMS4xNjgtLjA4OS4wMjktLjIwOS4wNzUtLjM0NC4xMTktLjQ1LjEzNS0xLjEzOS4zNi0xLjMzMy44MS0uMDkuMjI0LS4wNjEuNTI0LjEyLjg2OGwuMDE1LjAxNWMuMDYuMTM2IDEuNTI2IDMuNDc1IDQuNzkxIDQuMDE0LjI1NS4wNDQuNDM1LjI3LjQyLjUwOSAwIC4wNzUtLjAxNS4xNDktLjA0NS4yMjUtLjI0LjU2OS0xLjI3My45ODgtMy4xNDYgMS4yNzEtLjA1OS4wOTEtLjEyLjM3NS0uMTY0LjU3LS4wMjkuMTc5LS4wNzQuMzYtLjEzNC41NTMtLjA3Ni4yNzEtLjI3LjQwNS0uNTU1LjQwNWgtLjAzYy0uMTM1IDAtLjMxMy0uMDMxLS41MzgtLjA3NC0uMzYtLjA3NS0uNzY1LS4xMzUtMS4yNzMtLjEzNS0uMyAwLS41OTkuMDE1LS45MTMuMDc0LS42LjEwNC0xLjEyMy40NjQtMS43MjMuODg0LS44NTMuNTk5LTEuODI2IDEuMjg4LTMuMjk0IDEuMjg4LS4wNiAwLS4xMTktLjAxNS0uMTgtLjAxNWgtLjE0OWMtMS40NjggMC0yLjQyNy0uNjc1LTMuMjc5LTEuMjg4LS41OTktLjQyLTEuMTA3LS43NzktMS43MDctLjg4NC0uMzE0LS4wNDUtLjYyOS0uMDc0LS45MjgtLjA3NC0uNTQgMC0uOTU4LjA4OS0xLjI3Mi4xNDktLjIxMS4wNDMtLjM5MS4wNzQtLjU0LjA3NC0uMzc0IDAtLjUyMy0uMjI0LS41ODMtLjQyLS4wNjEtLjE5Mi0uMDktLjM4OS0uMTM1LS41NjctLjA0Ni0uMTgxLS4xMDUtLjQ5NC0uMTY2LS41Ny0xLjkxOC0uMjIyLTIuOTUtLjY0Mi0zLjE4OS0xLjIyNi0uMDMxLS4wNjMtLjA1Mi0uMTUtLjA1NS0uMjI1LS4wMTUtLjI0My4xNjUtLjQ2NS40Mi0uNTA5IDMuMjY0LS41NCA0LjczLTMuODc5IDQuNzkxLTQuMDJsLjAxNi0uMDI5Yy4xOC0uMzQ1LjIyNC0uNjQ1LjExOS0uODY5LS4xOTUtLjQzNC0uODg0LS42NTgtMS4zMzItLjgwOS0uMTIxLS4wMjktLjI0LS4wNzQtLjM0Ni0uMTE5LTEuMTA3LS40MzUtMS4yNTctLjkzLTEuMTk3LTEuMjczLjA5LS40NzkuNjc0LS43OTMgMS4xNjgtLjc5My4xNDYgMCAuMjcuMDI5LjM4My4wNzQuNDIuMTk0Ljc4OS4zIDEuMTA0LjMuMjM0IDAgLjM4NC0uMDYuNDY1LS4xMDVsLS4wNDYtLjU2OWMtLjA5OC0xLjYyNi0uMjI1LTMuNjUxLjMwNy00LjgzN0M3LjM5MiAxLjA3NyAxMC43MzkuODA3IDExLjcyNy44MDdsLjQxOS0uMDE1aC4wNnoiIHRyYW5zZm9ybT0idHJhbnNsYXRlKDMgMykgc2NhbGUoLjc1KSIgZmlsbD0iI2ZmZiIgc3Ryb2tlPSIjMDAwIiBzdHJva2Utd2lkdGg9IjEuMyIvPjwvc3ZnPg==") center/contain no-repeat}.dot.p-criteo,.ptag.p-criteo::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PGNpcmNsZSBjeD0iMTIiIGN5PSIxMiIgcj0iMTEiIGZpbGw9IiNGRTUwMDAiLz48cGF0aCBkPSJNMTYuMiA4LjZhNSA1IDAgMSAwIDAgNi44IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi42IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-hotjar,.ptag.p-hotjar::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTEwLjExOSA5LjgxNEMxMi44OTkgOC4yNyAxNi43MDQgNi4xNTUgMTYuNzA0IDBoLTQuNjA5YzAgMy40NDQtMS42NzYgNC4zNzUtNC4yMTQgNS43ODZDNS4xIDcuMzMgMS4yOTUgOS40NDQgMS4yOTUgMTUuNmg0LjYxYzAtMy40NDQgMS42NzYtNC4zNzYgNC4yMTQtNS43ODZ6TTE4LjA5NiA4LjRjMCAzLjQ0NC0xLjY3NyA0LjM3Ni00LjIxNSA1Ljc4NS0yLjc3OCAxLjU0NC02LjU4NSAzLjY2LTYuNTg1IDkuODE1aDQuNjA5YzAtMy40NDQgMS42NzYtNC4zNzYgNC4yMTQtNS43ODYgMi43OC0xLjU0NCA2LjU4Ni0zLjY1OCA2LjU4Ni05LjgxNGgtNC42MDl6IiBmaWxsPSIjRkYzQzAwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-adobe,.ptag.p-adobe::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTAgMmg4LjVMMCAyMnpNMjQgMmgtOC41TDI0IDIyek0xMiA5bDUuNSAxM2gtMy42bC0xLjctNC4ySDguOXoiIGZpbGw9IiNFQjEwMDAiLz48L3N2Zz4=") center/contain no-repeat}.dot.p-matomo,.ptag.p-matomo::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZD0iTTYgMThsNi05IDYgOSIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjMzE1MkEwIiBzdHJva2Utd2lkdGg9IjIiLz48Y2lyY2xlIGN4PSI2IiBjeT0iMTgiIHI9IjMiIGZpbGw9IiM5NUM3NDgiLz48Y2lyY2xlIGN4PSIxMiIgY3k9IjkiIHI9IjMiIGZpbGw9IiMzMTUyQTAiLz48Y2lyY2xlIGN4PSIxOCIgY3k9IjE4IiByPSIzIiBmaWxsPSIjMzE1MkEwIi8+PC9zdmc+") center/contain no-repeat}.dot.p-piwik_pro,.ptag.p-piwik_pro::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iIzBDMkQ1QiIvPjxwYXRoIGQ9Ik04IDE4VjZoNWEzLjUgMy41IDAgMDEwIDdIOCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjZmZmIiBzdHJva2Utd2lkdGg9IjIuNCIgc3Ryb2tlLWxpbmVjYXA9InJvdW5kIiBzdHJva2UtbGluZWpvaW49InJvdW5kIi8+PC9zdmc+") center/contain no-repeat}.dot.p-clarity,.ptag.p-clarity::before{background:url("data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHJlY3Qgd2lkdGg9IjI0IiBoZWlnaHQ9IjI0IiByeD0iNSIgZmlsbD0iIzAwNzhENCIvPjxwYXRoIGQ9Ik0xMiA1YTcgNyAwIDEwMCAxNE0xMiA5YTMgMyAwIDEwMCA2IiBmaWxsPSJub25lIiBzdHJva2U9IiNmZmYiIHN0cm9rZS13aWR0aD0iMi40IiBzdHJva2UtbGluZWNhcD0icm91bmQiLz48L3N2Zz4=") center/contain no-repeat}
937
1004
  .evlist{list-style:none;margin:0;padding:0;border:1px solid var(--border);border-radius:var(--radius);flex-basis:100%;min-width:0}
938
1005
  .evlist>li+li{border-top:1px solid var(--border)}
939
1006
  summary{cursor:pointer;list-style:none}summary::-webkit-details-marker{display:none}
@@ -1029,7 +1096,7 @@ ${navCss(runs)}</style></head><body>
1029
1096
  <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>
1030
1097
  <header class="wrap title">
1031
1098
  <h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
1032
- <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>` : ""}
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>` : ""}
1033
1100
  </header>
1034
1101
  <nav class="toptabs" aria-label="${m.ui.sections}"><div class="wrap">
1035
1102
  <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>
package/src/verify.js ADDED
@@ -0,0 +1,48 @@
1
+ // Who fixes a finding, and where to check the fix. Both are fixed tables over
2
+ // the check names, so they work with or without the AI explanation.
3
+
4
+ export const OWNERS = ["developer", "gtm", "marketing"];
5
+
6
+ const OWNER_BY_CHECK = {
7
+ missing_params: "developer",
8
+ purchase_value_invalid: "developer",
9
+ purchase_currency_invalid: "developer",
10
+ purchase_items_empty: "developer",
11
+ purchase_mismatch: "developer",
12
+ pii_datalayer: "developer",
13
+ journey_incomplete: "developer",
14
+ server_side: "developer",
15
+ unplanned_events: "marketing",
16
+ planned_out_of_scope: "marketing",
17
+ };
18
+
19
+ /** Who most likely fixes this finding: the site's developer, whoever runs Tag Manager, or marketing (the plan itself). Tag and consent setup is the default. */
20
+ export function ownerOf(finding) {
21
+ return OWNER_BY_CHECK[finding.check] ?? "gtm";
22
+ }
23
+
24
+ const LINKS = {
25
+ tagAssistant: { name: "Tag Assistant", url: "https://tagassistant.google.com/" },
26
+ ga4Debug: { name: "GA4 DebugView", url: "https://support.google.com/analytics/answer/7201382" },
27
+ metaTest: { name: "Meta Test Events", url: "https://www.facebook.com/business/help/2040882565969969" },
28
+ tiktokHelper: { name: "TikTok Pixel Helper", url: "https://chromewebstore.google.com/detail/tiktok-pixel-helper/aelgobmabdmlfmiblddjfnjodalhidnn" },
29
+ consentMode: { name: "Consent Mode", url: "https://developers.google.com/tag-platform/security/guides/consent" },
30
+ };
31
+
32
+ const PLATFORM_LINKS = {
33
+ ga4: ["tagAssistant", "ga4Debug"],
34
+ google_ads: ["tagAssistant"],
35
+ meta: ["metaTest"],
36
+ tiktok: ["tiktokHelper"],
37
+ };
38
+
39
+ /** Tools where the person fixing this finding can see the result: a list of { key, name, url }. Empty when no tool fits. */
40
+ export function verifyLinks(finding) {
41
+ const keys = new Set(PLATFORM_LINKS[finding.platform] ?? []);
42
+ if (/^(consent_|cookies_|reject_|before_consent|after_reject)/.test(finding.check)) {
43
+ keys.add("tagAssistant");
44
+ keys.add("consentMode");
45
+ }
46
+ if (!keys.size && ["unplanned_events", "journey_incomplete"].includes(finding.check)) keys.add("tagAssistant");
47
+ return [...keys].map((key) => ({ key, ...LINKS[key] }));
48
+ }