@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 +8 -0
- package/README.md +9 -8
- package/README.tr.md +9 -8
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/audit.js +6 -3
- package/src/browser.js +27 -5
- package/src/changes.js +29 -1
- package/src/i18n.js +20 -0
- package/src/mcp.js +3 -3
- package/src/parsers.js +184 -33
- package/src/redact.js +20 -3
- package/src/report.js +80 -13
- package/src/verify.js +48 -0
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
|
|
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,
|
|
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
|
|
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"]
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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"]
|
|
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)
|
|
446
|
-
- **Bulgular**:
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
350
|
-
*
|
|
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.
|
|
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.
|
|
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(", ")}.
|
|
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(", ")}.
|
|
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
|
-
/**
|
|
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
|
-
|
|
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
|
-
|
|
256
|
-
|
|
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
|
-
|
|
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
|
-
|
|
97
|
+
let changed = false;
|
|
94
98
|
const out = new URLSearchParams();
|
|
95
|
-
for (const [k, v] of params)
|
|
96
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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(
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
+
}
|