@analitikisler/ai-tag-debugger 0.15.0 → 0.16.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 +7 -0
- package/README.md +8 -1
- package/README.tr.md +8 -1
- package/package.json +1 -1
- package/src/audit.js +19 -7
- package/src/checks.js +54 -2
- package/src/gtm.js +92 -0
- package/src/i18n.js +22 -0
- package/src/report.js +33 -8
- package/src/whitelabel.js +76 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.16.0 (2026-10-17)
|
|
4
|
+
|
|
5
|
+
- Campaign checks (`campaign_checks`, on by default): each run opens the first page like an ad click, with UTM parameters, `gclid` or `fbclid` (or the ones already in the start address), and reports when a redirect drops them or the hits show a page address without them. Set `campaign_checks` to `false` to open the plain address.
|
|
6
|
+
- Server-side checks: one event ID reused on two different events is flagged, and so is a Meta event ID that doesn't match the `event_id` on the GA4 hit a server container forwards. A server container is noted as an info finding.
|
|
7
|
+
- GTM container awareness: with `gtm_container`, each broken event names the tag that sends it and the trigger it fires on, without an AI model.
|
|
8
|
+
- White label: the `report` setting takes a `name`, `logo`, `color` and `pdf`. A name or logo replaces the Analitik İşler branding in the HTML report, and a small AI Tag Debugger logo and credit stay. `pdf: true` also writes `report.pdf`.
|
|
9
|
+
|
|
3
10
|
## 0.15.0 (2026-10-17)
|
|
4
11
|
|
|
5
12
|
- The first run learns the plan. When a site has no saved plan, `aitd run` lists the events it already sends and asks whether to save them as its plan. Add `--yes` to save without asking; in a script without `--yes`, nothing is saved.
|
package/README.md
CHANGED
|
@@ -49,9 +49,15 @@ Manual tag QA catches this, but it's tedious, so it rarely happens after every r
|
|
|
49
49
|
| Personal data | An email address sent to an ad platform in plain text |
|
|
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
|
+
| Campaign parameters | `utm_campaign` or `gclid` on the landing address dropped by a redirect, or missing from the page address the hits report. The visit would be credited to direct traffic |
|
|
53
|
+
| Server-side tracking | One event ID used for two different events, or a Meta event ID that doesn't match the `event_id` on the GA4 hit a server container turns into a Conversions API event |
|
|
52
54
|
|
|
53
55
|
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
56
|
|
|
57
|
+
When GA4 hits go to a server container on another host, or its loader is served from one, the report adds an info finding: the events it forwards to ad platforms never pass through the browser, so they can't be seen here.
|
|
58
|
+
|
|
59
|
+
With a `gtm_container` export, each broken event also names the GTM tag that sends it and the trigger it fires on, or says that no tag sends it. This needs no AI model.
|
|
60
|
+
|
|
55
61
|
### Choose the platforms to check
|
|
56
62
|
|
|
57
63
|
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.
|
|
@@ -403,7 +409,7 @@ When a journey ran on both viewports, only the first viewport's steps are kept.
|
|
|
403
409
|
|---|---|
|
|
404
410
|
| `site` | Base URL. Use staging when you can. |
|
|
405
411
|
| `plan` | Measurement plan: a file path or an inline object (below). Optional with ready-made journeys. A run with `--against` uses that plan instead. |
|
|
406
|
-
| `gtm_container` | Optional GTM container export (Admin → Export container)
|
|
412
|
+
| `gtm_container` | Optional GTM container export (Admin → Export container). Each broken event then names the tag that sends it and the trigger it fires on (or says no tag sends it), even without an AI model. |
|
|
407
413
|
| `viewports` | `desktop` and/or `mobile`. |
|
|
408
414
|
| `journeys` | Optional when you pick ready-made journeys with `--journey`, describe one with `--goal`, or check against a plan with `--against`. |
|
|
409
415
|
| `journeys[].goal` | What the AI should do, in plain language. |
|
|
@@ -417,6 +423,7 @@ When a journey ran on both viewports, only the first viewport's steps are kept.
|
|
|
417
423
|
| `language` | Report language, `en` (default) or `tr`. Same as `--lang`. |
|
|
418
424
|
| `keep_bodies` | Default `false`. Set to `true` to store tracking requests as sent, without redacting user data. Same as `--keep-bodies`. |
|
|
419
425
|
| `branding` | Default `true`. Set to `false` to leave the Analitik İşler logo and the "Generated by AI Tag Debugger · analitikisler.com" line out of the reports. Same as `--no-branding`, or `branding: false` on the MCP `finish_audit` and `run_audit` tools. |
|
|
426
|
+
| `report` | Optional white label: `{ "name": "Acme", "logo": "logo.png", "color": "#0a7d5a", "pdf": true }`. `logo` (PNG, JPG, WebP, GIF or SVG, up to 512 KB, relative to the config file) and `name` replace the Analitik İşler branding in the HTML report (a small "by AI Tag Debugger" logo and credit stay); `color` sets the accent color; `pdf: true` also writes `report.pdf` next to `report.html`. |
|
|
420
427
|
| `provider` | Optional: `anthropic`, `openai`, `gemini`, `vercel`, `cloudflare` or `openai-compatible`. Same as `--provider`. |
|
|
421
428
|
| `base_url`, `model` | Optional: the API address and model id, e.g. for `openai-compatible`. Same as `--base-url` and `--model`. |
|
|
422
429
|
| `notify` | Optional alerts after each saved run: `slack` (a webhook URL, or `true` to read `SLACK_WEBHOOK_URL`) and `email` (an address or a list). See [Alerts](#alerts). |
|
package/README.tr.md
CHANGED
|
@@ -49,9 +49,15 @@ Manuel etiket kontrolü bunu yakalar, ama zahmetlidir; bu yüzden her sürümden
|
|
|
49
49
|
| Kişisel veri | Bir reklam platformuna düz metin olarak gönderilen e-posta adresi |
|
|
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
|
+
| Kampanya parametreleri | Açılış adresindeki `utm_campaign` ya da `gclid`'in bir yönlendirmede düşmesi, ya da isteklerin bildirdiği sayfa adresinde eksik olması. Ziyaret doğrudan trafiğe yazılır |
|
|
53
|
+
| Sunucu tarafı takip | Aynı event kimliğinin iki farklı event'te kullanılması, ya da Meta event kimliğinin sunucu konteynerinin Conversions API olayına dönüştürdüğü GA4 isteğindeki `event_id` ile eşleşmemesi |
|
|
52
54
|
|
|
53
55
|
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
56
|
|
|
57
|
+
GA4 istekleri başka bir ana makinedeki bir sunucu konteynerine gidiyorsa ya da yükleyicisi oradan sunuluyorsa, rapor bir bilgi bulgusu ekler: konteynerin ad platformlarına ilettiği event'ler tarayıcıdan geçmez, bu yüzden burada görülemez.
|
|
58
|
+
|
|
59
|
+
`gtm_container` dışa aktarımı verildiğinde, her hatalı event onu gönderen GTM etiketini ve tetiklendiği tetikleyiciyi adlandırır (ya da hiçbir etiketin göndermediğini söyler). Yapay zekâ modeli gerekmez.
|
|
60
|
+
|
|
55
61
|
### Kontrol edilecek platformları seçin
|
|
56
62
|
|
|
57
63
|
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.
|
|
@@ -403,7 +409,7 @@ Bir yolculuk her iki görünümde de çalıştıysa yalnızca ilk görünümün
|
|
|
403
409
|
|---|---|
|
|
404
410
|
| `site` | Temel URL. Mümkünse staging kullanın. |
|
|
405
411
|
| `plan` | Ölçümleme planı: bir dosya yolu ya da satır içi bir nesne (aşağıya bakın). Hazır yolculuklarla isteğe bağlıdır. `--against` ile çalışan bir çalıştırma bunun yerine o planı kullanır. |
|
|
406
|
-
| `gtm_container` | İsteğe bağlı GTM konteyner dışa aktarımı (Yönetici → Konteyneri dışa aktar)
|
|
412
|
+
| `gtm_container` | İsteğe bağlı GTM konteyner dışa aktarımı (Yönetici → Konteyneri dışa aktar). Her hatalı event, onu gönderen etiketi ve tetikleyicisini adlandırır (ya da hiçbir etiketin göndermediğini söyler), yapay zekâ modeli olmadan da. |
|
|
407
413
|
| `viewports` | `desktop` ve/veya `mobile`. |
|
|
408
414
|
| `journeys` | `--journey` ile hazır yolculuk seçtiğinizde, `--goal` ile bir yolculuk tarif ettiğinizde ya da `--against` ile bir plana karşı kontrol ettiğinizde isteğe bağlıdır. |
|
|
409
415
|
| `journeys[].goal` | Yapay zekânın sade bir dille ne yapacağı. |
|
|
@@ -417,6 +423,7 @@ Bir yolculuk her iki görünümde de çalıştıysa yalnızca ilk görünümün
|
|
|
417
423
|
| `language` | Rapor dili, `en` (varsayılan) ya da `tr`. `--lang` ile aynıdır. |
|
|
418
424
|
| `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. |
|
|
419
425
|
| `branding` | Varsayılan `true`. Analitik İşler logosunu ve "Generated by AI Tag Debugger · analitikisler.com" satırını raporlardan çıkarmak için `false` yapın. `--no-branding` ile aynıdır, ya da MCP `finish_audit` ve `run_audit` araçlarında `branding: false`. |
|
|
426
|
+
| `report` | İsteğe bağlı beyaz etiket: `{ "name": "Acme", "logo": "logo.png", "color": "#0a7d5a", "pdf": true }`. `logo` (PNG, JPG, WebP, GIF veya SVG; en fazla 512 KB; ayar dosyasına göre bağıl) ve `name`, HTML raporundaki Analitik İşler markasının yerine geçer (küçük bir AI Tag Debugger logosu ve kredisi kalır). `color` vurgu rengini ayarlar; `pdf: true` ise `report.html` yanına `report.pdf` de yazar. |
|
|
420
427
|
| `provider` | İsteğe bağlı: `anthropic`, `openai`, `gemini`, `vercel`, `cloudflare` ya da `openai-compatible`. `--provider` ile aynıdır. |
|
|
421
428
|
| `base_url`, `model` | İsteğe bağlı: API adresi ve model kimliği, örneğin `openai-compatible` için. `--base-url` ve `--model` ile aynıdır. |
|
|
422
429
|
| `notify` | İsteğe bağlı uyarılar, kayıtlı her çalıştırmadan sonra: `slack` (bir webhook adresi ya da `SLACK_WEBHOOK_URL` değişkenini okumak için `true`) ve `email` (bir adres ya da liste). Bkz. [Uyarılar](#uyarılar). |
|
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.16.0",
|
|
5
5
|
"description": "Open-source AI agent that QA-tests your website's analytics, ad and consent tracking (GA4, GTM, Google Ads, Meta, TikTok). Bring your own model (Claude, OpenAI, Gemini) or use it from your chat app over MCP.",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
package/src/audit.js
CHANGED
|
@@ -10,9 +10,11 @@ import { VIEWPORTS } from "./browser.js";
|
|
|
10
10
|
import { checkRun, groupFindings, planCheck, summarize } from "./checks.js";
|
|
11
11
|
import { explainFindings, renderCsv, renderHtml, renderMarkdown } from "./report.js";
|
|
12
12
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
13
|
+
import { loadReportBrand, writePdf } from "./whitelabel.js";
|
|
13
14
|
import { createProvider, alsoSet } from "./providers.js";
|
|
14
15
|
import { filterFindings, JOURNEY_PRESETS, planRun } from "./journeys.js";
|
|
15
16
|
import { resolveProviders } from "./parsers.js";
|
|
17
|
+
import { annotateFindings } from "./gtm.js";
|
|
16
18
|
import { SAVED_SITE_FILE, writeSavedSite } from "./replay.js";
|
|
17
19
|
import { runConsentScenarios } from "./consent.js";
|
|
18
20
|
|
|
@@ -33,7 +35,8 @@ export async function loadConfig(configFile) {
|
|
|
33
35
|
const plan = typeof config.plan === "string" ? await readJson(config.plan, configDir) : config.plan;
|
|
34
36
|
if (plan && !Array.isArray(plan.events)) throw new Error('The "plan" needs an "events" list.');
|
|
35
37
|
const gtmContainer = config.gtm_container ? await readJson(config.gtm_container, configDir) : null;
|
|
36
|
-
|
|
38
|
+
const brand = await loadReportBrand(config.report, configDir);
|
|
39
|
+
return { config, plan, gtmContainer, brand };
|
|
37
40
|
}
|
|
38
41
|
|
|
39
42
|
|
|
@@ -42,26 +45,35 @@ export async function loadConfig(configFile) {
|
|
|
42
45
|
* previous: the site's last saved run (see previousRun), null when there is none, or undefined to skip
|
|
43
46
|
* the "Changed since last run" section. Returns { reportPath, changes }.
|
|
44
47
|
*/
|
|
45
|
-
export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, ai = null, against = null, branding = config.branding !== false, previous = undefined, planChanges = null, consentScenarios = null }) {
|
|
48
|
+
export async function writeReports({ outDir, config, plan, runs, findings, explained, lang, ai = null, against = null, branding = config.branding !== false, previous = undefined, planChanges = null, consentScenarios = null, brand = null }) {
|
|
46
49
|
await mkdir(outDir, { recursive: true });
|
|
47
50
|
const site = new URL(config.site).hostname;
|
|
48
51
|
const title = messages(lang).ui.title(site);
|
|
49
52
|
const generatedAt = new Date().toISOString().slice(0, 16).replace("T", " ") + " UTC";
|
|
50
53
|
const check = against ? { plan_id: against.id ?? null, ...planCheck(plan, runs, findings) } : null;
|
|
51
54
|
const changes = previous === undefined ? null : compareRuns(previous, { findings, planCheck: check, runs });
|
|
52
|
-
const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes, planChanges, consentScenarios };
|
|
55
|
+
const report = { title, site, ai, generatedAt, findings, explained, runs, plan, lang, branding, against: against?.label ?? null, planCheck: check, changes, planChanges, consentScenarios, brand };
|
|
53
56
|
await writeFile(path.join(outDir, "report.md"), renderMarkdown(report));
|
|
54
57
|
await writeFile(path.join(outDir, "report.html"), renderHtml(report));
|
|
55
58
|
await writeFile(path.join(outDir, "timeline.csv"), renderCsv(report));
|
|
56
59
|
if (check) await writeFile(path.join(outDir, "plan-check.json"), JSON.stringify(check, null, 2));
|
|
57
60
|
await writeFile(path.join(outDir, "capture.json"), JSON.stringify({ config: { ...config, plan }, ...(against ? { against: { plan_id: against.id, file: against.file } } : {}), runs, findings, explained, ...(changes ? { changes } : {}), ...(planChanges ? { planChanges } : {}), ...(consentScenarios ? { consentScenarios } : {}) }, null, 2));
|
|
58
|
-
|
|
61
|
+
const reportPath = path.join(outDir, "report.html");
|
|
62
|
+
if (brand?.pdf) {
|
|
63
|
+
try {
|
|
64
|
+
await writePdf(reportPath, path.join(outDir, "report.pdf"));
|
|
65
|
+
} catch (err) {
|
|
66
|
+
// The HTML report is the main output; a PDF that can't be made is a warning, not a failed run.
|
|
67
|
+
console.error(`Could not write report.pdf: ${err.message}`);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return { reportPath, changes };
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
/**
|
|
62
74
|
* @param {{ config: any, plan?: any, gtmContainer?: any, journeys?: string[], goals?: string[], events?: string[], viewports?: string[],
|
|
63
75
|
* scripted?: boolean, ai?: boolean, provider?: string, model?: string, baseUrl?: string, maxSteps?: number, lang?: string, headed?: boolean,
|
|
64
|
-
* outDir?: string, dataDir?: string, branding?: boolean, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
|
|
76
|
+
* outDir?: string, dataDir?: string, branding?: boolean, brand?: Awaited<ReturnType<typeof loadReportBrand>>, providers?: string[], saveSite?: string, replaySite?: string, log?: (m: string) => void }} opts
|
|
65
77
|
* journeys: config journey ids or ready-made journeys (purchase, add_to_cart, sign_up, lead, search).
|
|
66
78
|
* goals: extra journeys described in plain language. events: only check these events.
|
|
67
79
|
* providers: tracking providers to check (default: config "providers", else GA4, Google Ads, Meta, TikTok plus
|
|
@@ -133,7 +145,7 @@ export async function runAudit(opts) {
|
|
|
133
145
|
? null
|
|
134
146
|
: await runConsentScenarios(config, { headed: opts.headed, log }).catch((err) => (log(` Consent scenarios failed: ${err.message}`), null));
|
|
135
147
|
|
|
136
|
-
const findings = filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events);
|
|
148
|
+
const findings = annotateFindings(filterFindings(runs.flatMap((run) => checkRun(plan, run, { lang })), events), gtmContainer);
|
|
137
149
|
let saveError = null;
|
|
138
150
|
if (opts.saveSite) {
|
|
139
151
|
try {
|
|
@@ -155,7 +167,7 @@ export async function runAudit(opts) {
|
|
|
155
167
|
// Runs saved in the site's runs folder are compared with the one before; a run sent to --out is not.
|
|
156
168
|
const previous = opts.outDir ? undefined : await previousRun(opts.dataDir, config.site);
|
|
157
169
|
const outDir = path.resolve(opts.outDir ?? (await newRunDir(opts.dataDir, config.site)).dir);
|
|
158
|
-
const { reportPath, changes } = await writeReports({ outDir, config, plan, runs, findings, explained, lang, ai: provider ? { name: provider.name, model: provider.model } : null, against, branding: opts.branding ?? config.branding !== false, previous, planChanges: against ? await previousPlanChanges(opts.dataDir, config.site, against.id, plan).catch(() => null) : null, consentScenarios });
|
|
170
|
+
const { reportPath, changes } = await writeReports({ outDir, config, plan, runs, findings, explained, lang, ai: provider ? { name: provider.name, model: provider.model } : null, against, branding: opts.branding ?? config.branding !== false, previous, planChanges: against ? await previousPlanChanges(opts.dataDir, config.site, against.id, plan).catch(() => null) : null, consentScenarios, brand: opts.brand ?? null });
|
|
159
171
|
if (config.notify && changes) {
|
|
160
172
|
const { sent, errors } = await sendAlerts({ notify: config.notify, site: new URL(config.site).hostname, changes: { run: path.basename(outDir), ...changes }, lang, reportPath });
|
|
161
173
|
if (sent.length) log(`\nSent the changes since the last run to ${sent.join(" and ")}.`);
|
package/src/checks.js
CHANGED
|
@@ -24,6 +24,20 @@ const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
|
24
24
|
* @typedef {{ severity: "broken"|"risk"|"warning"|"info"|"ok", check: string, journey: string, viewport: string, platform?: string, event?: string, step?: string, detail: string, evidence?: string }} Finding
|
|
25
25
|
*/
|
|
26
26
|
|
|
27
|
+
/** Hosts of server containers seen in the run's network calls, the site's own or Stape's. */
|
|
28
|
+
export function serverEndpoints(run) {
|
|
29
|
+
const hosts = new Set();
|
|
30
|
+
for (const c of run.network ?? []) {
|
|
31
|
+
let u;
|
|
32
|
+
try { u = new URL(c.url); } catch { continue; }
|
|
33
|
+
if (/(^|\.)(google-analytics\.com|analytics\.google\.com|googletagmanager\.com|google\.[a-z.]+|doubleclick\.net)$/.test(u.hostname)) continue;
|
|
34
|
+
const ga4Server = c.provider === "ga4" && u.pathname.endsWith("/g/collect");
|
|
35
|
+
const loader = (c.provider === "gtm" || c.provider === "ga4") && /\/(gtm\.js|gtag\/js)$/.test(u.pathname);
|
|
36
|
+
if (ga4Server || loader) hosts.add(u.hostname);
|
|
37
|
+
}
|
|
38
|
+
return [...hosts];
|
|
39
|
+
}
|
|
40
|
+
|
|
27
41
|
const key = (platform, name) => `${platform}:${name}`;
|
|
28
42
|
|
|
29
43
|
// Sites name events in their own way: Elevar's dl_add_to_cart, UA-style addToCart, Add To Cart.
|
|
@@ -287,7 +301,12 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
287
301
|
for (const p of byPlatform) findings.push({ ...base, severity: "info", check: "server_side", platform: p.platform, detail: d.serverSide(p.platform, p.server, p.browser, unseenServer) });
|
|
288
302
|
}
|
|
289
303
|
const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
|
|
290
|
-
|
|
304
|
+
// Server containers the browser talked to: GA4 hits on a non-Google host, and GTM or gtag.js
|
|
305
|
+
// loaded from one (a custom loader, which only a server container serves).
|
|
306
|
+
const endpoints = serverEndpoints(run);
|
|
307
|
+
if (endpoints.length && !serverHits.length) findings.push({ ...base, severity: "info", check: "server_endpoint", detail: d.serverEndpoint(endpoints) });
|
|
308
|
+
const serverSeen = serverVendors.length || serverHits.length || endpoints.length;
|
|
309
|
+
if (serverSeen) {
|
|
291
310
|
const noId = new Map();
|
|
292
311
|
for (const hit of run.hits) {
|
|
293
312
|
if ((hit.platform === "meta" || hit.platform === "tiktok") && !hit.eventId && !noId.has(key(hit.platform, hit.name))) noId.set(key(hit.platform, hit.name), hit);
|
|
@@ -297,6 +316,39 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
297
316
|
}
|
|
298
317
|
}
|
|
299
318
|
|
|
319
|
+
// One event ID on two different events: the platform keeps the first and drops the other as its
|
|
320
|
+
// duplicate, so a real event is lost (usually an ID set once per page instead of once per event).
|
|
321
|
+
const byId = new Map();
|
|
322
|
+
for (const hit of run.hits) {
|
|
323
|
+
if (!hit.eventId) continue;
|
|
324
|
+
const k = `${hit.platform}|${hit.eventId}`;
|
|
325
|
+
byId.set(k, [...(byId.get(k) ?? []), hit]);
|
|
326
|
+
}
|
|
327
|
+
for (const [, hits] of byId) {
|
|
328
|
+
const events = [...new Set(hits.map((h) => `${h.step}|${canonicalEvent(h.name)}`))];
|
|
329
|
+
if (events.length < 2) continue;
|
|
330
|
+
const names = [...new Set(hits.map((h) => h.name))];
|
|
331
|
+
findings.push({ ...base, severity: "warning", check: "event_id_reused", platform: hits[0].platform, event: names.join(", "), step: stepLabel(hits[1].step), detail: d.eventIdReused(platformName(hits[0].platform), hits[0].eventId, names, events.length), evidence: hits.map((h) => h.url).join("\n") });
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
// With a server container, the Conversions API copy is usually built from the GA4 hit, so the
|
|
335
|
+
// GA4 hit has to carry the same event_id as the Meta pixel event for Meta to deduplicate them.
|
|
336
|
+
if (serverSeen) {
|
|
337
|
+
const ga4ToServer = run.hits.filter((h) => h.platform === "ga4" && h.delivery === "server");
|
|
338
|
+
const reported = new Set();
|
|
339
|
+
for (const m of run.hits.filter((h) => h.platform === "meta" && h.eventId)) {
|
|
340
|
+
const g = ga4ToServer.find((h) => h.step === m.step && canonicalEvent(h.name) === canonicalEvent(m.name));
|
|
341
|
+
if (!g) continue;
|
|
342
|
+
const gid = g.params?.event_id;
|
|
343
|
+
const k = key("meta", m.name);
|
|
344
|
+
if (reported.has(k) || (gid !== undefined && String(gid) === m.eventId)) continue;
|
|
345
|
+
reported.add(k);
|
|
346
|
+
findings.push(gid === undefined
|
|
347
|
+
? { ...base, severity: "info", check: "event_id_not_forwarded", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.eventIdNotForwarded(m.name, g.name) }
|
|
348
|
+
: { ...base, severity: "warning", check: "event_id_mismatch", platform: "meta", event: m.name, step: stepLabel(m.step), detail: d.eventIdMismatch(m.name, m.eventId, g.name, gid), evidence: JSON.stringify({ meta: m.eventId, ga4: gid }) });
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
300
352
|
// Campaign parameters (gclid, fbclid, UTM) on the landing address: they must survive redirects and reach the hits.
|
|
301
353
|
const landing = run.landing;
|
|
302
354
|
const sent = landing ? campaignParamsIn(landing.requested) : {};
|
|
@@ -472,7 +524,7 @@ export function groupFindings(findings) {
|
|
|
472
524
|
const k = f.severity === "ok" ? `ok|${f.journey}|${f.viewport}` : [f.severity, f.check, f.platform, f.event, f.detail].join("|");
|
|
473
525
|
const where = { journey: f.journey, viewport: f.viewport, step: f.step };
|
|
474
526
|
if (groups.has(k)) groups.get(k).where.push(where);
|
|
475
|
-
else groups.set(k, { severity: f.severity, check: f.check, platform: f.platform, event: f.event, detail: f.detail, evidence: f.evidence, where: [where] });
|
|
527
|
+
else groups.set(k, { severity: f.severity, check: f.check, platform: f.platform, event: f.event, detail: f.detail, evidence: f.evidence, ...(f.gtm ? { gtm: f.gtm } : {}), where: [where] });
|
|
476
528
|
}
|
|
477
529
|
return [...groups.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
478
530
|
}
|
package/src/gtm.js
ADDED
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
// Reads a GTM container export (Admin → Export container) to name the tag and trigger
|
|
2
|
+
// behind a hit. Matching is deterministic: no API call and no AI model.
|
|
3
|
+
|
|
4
|
+
import { canonicalEvent } from "./checks.js";
|
|
5
|
+
|
|
6
|
+
// GTM's built-in "All Pages" trigger has no entry in the export's trigger list.
|
|
7
|
+
const BUILT_IN_TRIGGERS = { 2147479553: { name: "All Pages", type: "PAGEVIEW" } };
|
|
8
|
+
const EVENT_PARAMS = ["eventName", "event_name", "event", "eventType", "standardEventName", "customEventName", "eventTypeStandard", "eventTypeCustom"];
|
|
9
|
+
|
|
10
|
+
/** A tag parameter list as a flat { key: value } map; nested lists and maps are kept as JSON text. */
|
|
11
|
+
function params(list = []) {
|
|
12
|
+
const out = {};
|
|
13
|
+
for (const p of list) out[p.key] = p.value ?? (p.list || p.map ? JSON.stringify(p.list ?? p.map) : undefined);
|
|
14
|
+
return out;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Which platform a tag sends to, from its type or, for custom HTML and templates, from its code. */
|
|
18
|
+
function tagPlatform(tag, p) {
|
|
19
|
+
if (["gaawe", "gaawc", "googtag"].includes(tag.type)) return "ga4";
|
|
20
|
+
if (["awct", "sp", "gclidw"].includes(tag.type)) return "google_ads";
|
|
21
|
+
const text = `${tag.type} ${tag.name ?? ""} ${p.html ?? ""}`.toLowerCase();
|
|
22
|
+
if (/fbq\(|facebook|meta pixel|\bmeta\b/.test(text)) return "meta";
|
|
23
|
+
if (/ttq\.|tiktok/.test(text)) return "tiktok";
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The event names a tag sends: its event parameter, a page_view for a GA4 config tag, or the names in fbq/ttq calls. */
|
|
28
|
+
function tagEvents(tag, p) {
|
|
29
|
+
if (tag.type === "gaawc" || tag.type === "googtag") return ["page_view"];
|
|
30
|
+
const names = EVENT_PARAMS.map((k) => p[k]).filter((v) => typeof v === "string" && v);
|
|
31
|
+
for (const m of String(p.html ?? "").matchAll(/(?:fbq|ttq)\s*(?:\(\s*['"](?:track|trackCustom)['"]\s*,|\.track\s*\()\s*['"]([^'"]+)['"]/g)) names.push(m[1]);
|
|
32
|
+
if (/fbq\s*\(\s*['"]init['"]/.test(p.html ?? "") || /ttq\.page\s*\(/.test(p.html ?? "")) names.push("PageView", "Pageview");
|
|
33
|
+
return names;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** The custom event names a trigger fires on, from its "Event name equals …" filter. */
|
|
37
|
+
function triggerEvents(trigger) {
|
|
38
|
+
return (trigger?.customEventFilter ?? []).map((f) => params(f.parameter).arg1).filter(Boolean);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function describeTrigger(id, byId) {
|
|
42
|
+
const t = byId.get(String(id)) ?? BUILT_IN_TRIGGERS[id];
|
|
43
|
+
if (!t) return { id: String(id), name: `#${id}`, type: "unknown" };
|
|
44
|
+
const events = triggerEvents(t);
|
|
45
|
+
return { id: String(id), name: t.name, type: t.type, ...(events.length ? { event: events.join(", ") } : {}) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* The tags in a container export with their platform, events and triggers.
|
|
50
|
+
* @returns {{ id: string, name: string, type: string, platform: string|null, events: string[], paused: boolean, consent: string[], triggers: any[], blocking: any[] }[]}
|
|
51
|
+
*/
|
|
52
|
+
export function containerTags(container) {
|
|
53
|
+
const v = container?.containerVersion ?? container ?? {};
|
|
54
|
+
const byId = new Map((v.trigger ?? []).map((t) => [String(t.triggerId), t]));
|
|
55
|
+
return (v.tag ?? []).map((tag) => {
|
|
56
|
+
const p = params(tag.parameter);
|
|
57
|
+
const triggers = (tag.firingTriggerId ?? []).map((id) => describeTrigger(id, byId));
|
|
58
|
+
let events = tagEvents(tag, p);
|
|
59
|
+
// An event name taken from a variable such as {{Event}} is whatever the trigger's custom event is.
|
|
60
|
+
if (events.some((e) => /^\{\{.+\}\}$/.test(e))) events = [...events.filter((e) => !/^\{\{.+\}\}$/.test(e)), ...triggers.flatMap((t) => (t.event ? t.event.split(", ") : []))];
|
|
61
|
+
const cs = tag.consentSettings;
|
|
62
|
+
return {
|
|
63
|
+
id: String(tag.tagId ?? ""),
|
|
64
|
+
name: tag.name,
|
|
65
|
+
type: tag.type,
|
|
66
|
+
platform: tagPlatform(tag, p),
|
|
67
|
+
events,
|
|
68
|
+
paused: tag.paused === true,
|
|
69
|
+
consent: cs?.consentStatus === "NEEDED" ? (cs.consentType?.list ?? []).map((x) => x.value).filter(Boolean) : [],
|
|
70
|
+
triggers,
|
|
71
|
+
blocking: (tag.blockingTriggerId ?? []).map((id) => describeTrigger(id, byId)),
|
|
72
|
+
};
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** The tags in the container that send an event to a platform. */
|
|
77
|
+
export function tagsForEvent(tags, platform, event) {
|
|
78
|
+
const want = canonicalEvent(event);
|
|
79
|
+
return tags.filter((t) => t.platform === platform && t.events.some((e) => canonicalEvent(e) === want));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Adds `gtm` to each finding about one platform event: the tags that send it, or an empty list when none does. */
|
|
83
|
+
export function annotateFindings(findings, container) {
|
|
84
|
+
if (!container) return findings;
|
|
85
|
+
const tags = containerTags(container);
|
|
86
|
+
if (!tags.length) return findings;
|
|
87
|
+
for (const f of findings) {
|
|
88
|
+
if (!f.platform || !f.event || f.severity === "ok") continue;
|
|
89
|
+
f.gtm = tagsForEvent(tags, f.platform, f.event).map(({ name, type, paused, consent, triggers, blocking }) => ({ name, type, paused, consent, triggers, blocking }));
|
|
90
|
+
}
|
|
91
|
+
return findings;
|
|
92
|
+
}
|
package/src/i18n.js
CHANGED
|
@@ -25,6 +25,13 @@ const en = {
|
|
|
25
25
|
where: "Where",
|
|
26
26
|
causeAndFix: "Cause and fix",
|
|
27
27
|
likelyCause: "Likely cause",
|
|
28
|
+
gtmTags: "In your GTM container",
|
|
29
|
+
gtmNoTag: "No tag in the container export sends this event.",
|
|
30
|
+
gtmFiresOn: "Fires on",
|
|
31
|
+
gtmNoTrigger: "No firing trigger",
|
|
32
|
+
gtmBlockedBy: "Blocked by",
|
|
33
|
+
gtmPaused: "Paused",
|
|
34
|
+
gtmConsent: (types) => `Needs consent: ${types}`,
|
|
28
35
|
fix: "Fix",
|
|
29
36
|
captured: "Captured",
|
|
30
37
|
noProblems: "No problems found.",
|
|
@@ -183,6 +190,10 @@ const en = {
|
|
|
183
190
|
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
|
|
184
191
|
purchaseMismatch: (id, diffs) => `GA4 and Meta reported ${id ? `order ${q(id)}` : "the purchase"} differently: ${diffs.map(([f, g, m]) => `${f} ${q(g)} in GA4 and ${q(m)} in Meta`).join("; ")}. One of the tags reads the wrong data.`,
|
|
185
192
|
serverSide: (platform, server, browser, unseen = []) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}.${unseen.length ? ` Server-side copies of ${unseen.join(" and ")} events never pass through the browser, so they can't be seen here.` : ""}`,
|
|
193
|
+
serverEndpoint: (hosts) => `A server container answers on ${hosts.map(q).join(", ")} (server-side GTM or a similar setup). Events it forwards to ad platforms never pass through the browser, so they can't be seen here.`,
|
|
194
|
+
eventIdReused: (platform, id, names, n) => `${platform} event ID ${q(id)} was used for ${n} different events (${names.map(q).join(", ")}). ${platform} keeps the first and drops the rest as duplicates. Give each event its own ID.`,
|
|
195
|
+
eventIdNotForwarded: (name, ga4Name) => `Meta ${q(name)} has an event ID, but the GA4 ${q(ga4Name)} hit sent to the server container has no event_id. If the server container builds the Conversions API event from that hit, Meta can't match it with the pixel event and counts it twice. Send the same ID as an event_id parameter on the GA4 event.`,
|
|
196
|
+
eventIdMismatch: (name, metaId, ga4Name, ga4Id) => `Meta ${q(name)} has event ID ${q(metaId)}, but the GA4 ${q(ga4Name)} hit sent to the server container carries event_id ${q(ga4Id)}. A Conversions API event built from that hit won't match the pixel event, so Meta counts it twice. Use one ID for both.`,
|
|
186
197
|
dedupIdMissing: (platform, name) => `${platform} ${q(name)} was sent from the browser without an event ID, while a server-side setup was seen on this site. If the server also sends this event, ${platform} can't match the two and counts it twice.`,
|
|
187
198
|
campaignLost: (names, finalUrl) => `The landing address carried ${names.join(", ")}, but the page ended up on ${finalUrl} without ${names.length === 1 ? "it" : "them"}. A redirect dropped ${names.length === 1 ? "the parameter" : "the parameters"}, so ad clicks lose their attribution.`,
|
|
188
199
|
campaignMissingInHits: (platform, name, names) => `${platform} ${q(name)} on the landing page reports a page address without ${names.join(", ")}, though the page still had ${names.length === 1 ? "it" : "them"}. The visit can be credited to direct traffic instead of the campaign.`,
|
|
@@ -231,6 +242,13 @@ const tr = {
|
|
|
231
242
|
where: "Nerede",
|
|
232
243
|
causeAndFix: "Neden ve çözüm",
|
|
233
244
|
likelyCause: "Olası neden",
|
|
245
|
+
gtmTags: "GTM container'ınızda",
|
|
246
|
+
gtmNoTag: "Container dışa aktarımındaki hiçbir etiket bu olayı göndermiyor.",
|
|
247
|
+
gtmFiresOn: "Tetikleyici:",
|
|
248
|
+
gtmNoTrigger: "Tetikleyici yok",
|
|
249
|
+
gtmBlockedBy: "Engelleyen:",
|
|
250
|
+
gtmPaused: "Duraklatıldı",
|
|
251
|
+
gtmConsent: (types) => `İzin gerekiyor: ${types}`,
|
|
234
252
|
fix: "Çözüm",
|
|
235
253
|
captured: "Yakalanan",
|
|
236
254
|
noProblems: "Sorun bulunamadı.",
|
|
@@ -389,6 +407,10 @@ const tr = {
|
|
|
389
407
|
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
|
|
390
408
|
purchaseMismatch: (id, diffs) => `GA4 ve Meta ${id ? `${q(id)} siparişini` : "satın almayı"} farklı bildirdi: ${diffs.map(([f, g, m]) => `${f === "value" ? "değer" : "para birimi"} GA4'te ${q(g)}, Meta'da ${q(m)}`).join("; ")}. Etiketlerden biri yanlış veriyi okuyor.`,
|
|
391
409
|
serverSide: (platform, server, browser, unseen = []) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}.${unseen.length ? ` ${unseen.join(" ve ")} olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.` : ""}`,
|
|
410
|
+
serverEndpoint: (hosts) => `${hosts.map(q).join(", ")} adresinde bir sunucu konteyneri yanıt veriyor (sunucu tarafı GTM veya benzeri bir kurulum). Reklam platformlarına ilettiği olaylar tarayıcıdan geçmez, bu yüzden burada görünmez.`,
|
|
411
|
+
eventIdReused: (platform, id, names, n) => `${platform} olay kimliği ${q(id)} ${n} farklı olay için kullanıldı (${names.map(q).join(", ")}). ${platform} ilkini tutar, diğerlerini kopya sayıp atar. Her olaya kendi kimliğini verin.`,
|
|
412
|
+
eventIdNotForwarded: (name, ga4Name) => `Meta ${q(name)} olay kimliği taşıyor, ancak sunucu konteynerine giden GA4 ${q(ga4Name)} isteğinde event_id yok. Sunucu konteyneri Conversions API olayını bu istekten üretiyorsa Meta onu piksel olayıyla eşleştiremez ve iki kez sayar. Aynı kimliği GA4 olayına event_id parametresi olarak ekleyin.`,
|
|
413
|
+
eventIdMismatch: (name, metaId, ga4Name, ga4Id) => `Meta ${q(name)} olay kimliği ${q(metaId)}, ancak sunucu konteynerine giden GA4 ${q(ga4Name)} isteği event_id ${q(ga4Id)} taşıyor. Bu istekten üretilen Conversions API olayı piksel olayıyla eşleşmez, Meta onu iki kez sayar. İkisi için tek bir kimlik kullanın.`,
|
|
392
414
|
dedupIdMissing: (platform, name) => `${platform} ${q(name)} tarayıcıdan olay kimliği olmadan gönderildi; sitede sunucu tarafı bir kurulum görüldü. Sunucu da bu olayı gönderiyorsa ${platform} ikisini eşleştiremez ve iki kez sayar.`,
|
|
393
415
|
campaignLost: (names, finalUrl) => `Açılış adresi ${names.join(", ")} taşıyordu, ancak sayfa ${finalUrl} adresinde ${names.length === 1 ? "onsuz" : "onlarsız"} sonlandı. Bir yönlendirme ${names.length === 1 ? "parametreyi" : "parametreleri"} düşürdü, bu yüzden reklam tıklamalarının atfı kaybolur.`,
|
|
394
416
|
campaignMissingInHits: (platform, name, names) => `Açılış sayfasındaki ${platform} ${q(name)} isteği ${names.join(", ")} olmadan bir sayfa adresi bildiriyor, oysa sayfada ${names.length === 1 ? "o" : "onlar"} hâlâ vardı. Ziyaret kampanyaya değil doğrudan trafiğe yazılabilir.`,
|
package/src/report.js
CHANGED
|
@@ -7,6 +7,7 @@ import { parseTrackingRequest, providerOf, PROVIDERS } from "./parsers.js";
|
|
|
7
7
|
import { decodeGa4Item, isEventParam, paramTip } from "./glossary.js";
|
|
8
8
|
import { buildInventory } from "./inventory.js";
|
|
9
9
|
import { LOGOS } from "./brand.js";
|
|
10
|
+
import { brandCss } from "./whitelabel.js";
|
|
10
11
|
import { readFileSync } from "node:fs";
|
|
11
12
|
|
|
12
13
|
export const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf8")).version;
|
|
@@ -154,7 +155,7 @@ const mdCode = (v) => String(v ?? "").replace(/`/g, "'").replace(/\n/g, " ");
|
|
|
154
155
|
|
|
155
156
|
const routeList = (ch) => (ch?.routes ?? []).map((r) => `${r.journeyId} (${r.viewport})`).join(", ");
|
|
156
157
|
|
|
157
|
-
export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
|
|
158
|
+
export function renderMarkdown({ title, generatedAt, findings, explained, runs, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null }) {
|
|
158
159
|
const m = messages(lang);
|
|
159
160
|
const rows = attachExplanations(findings, explained);
|
|
160
161
|
const counts = summarize(rows);
|
|
@@ -183,6 +184,7 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
|
|
|
183
184
|
const vl = verifyLinks(f);
|
|
184
185
|
if (vl.length) lines.push(`- ${m.ui.verify}: ${vl.map((l) => `[${l.name}](${l.url})`).join(", ")}`);
|
|
185
186
|
if (f.evidence) lines.push(`- ${m.ui.captured}: \`${mdCode(f.evidence)}\``);
|
|
187
|
+
if (f.gtm) lines.push(`- ${m.ui.gtmTags}: ${f.gtm.length ? f.gtm.map((t) => `${t.name}${t.paused ? ` (${m.ui.gtmPaused})` : ""}, ${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map((x) => x.name).join(", ")}` : m.ui.gtmNoTrigger}`).join("; ") : m.ui.gtmNoTag}`);
|
|
186
188
|
if (f.explanation) {
|
|
187
189
|
if (f.explanation.impact) lines.push(`- ${m.ui.impact}: ${mdText(f.explanation.impact)}`);
|
|
188
190
|
lines.push(`- ${m.ui.likelyCause}: ${mdText(f.explanation.likely_cause)}`, `- ${m.ui.fix}:`, ...fixSteps(f.explanation).map((t, i) => ` ${i + 1}. ${mdText(t)}`));
|
|
@@ -206,7 +208,9 @@ export function renderMarkdown({ title, generatedAt, findings, explained, runs,
|
|
|
206
208
|
}
|
|
207
209
|
lines.push("");
|
|
208
210
|
}
|
|
209
|
-
|
|
211
|
+
// A report with its own name or logo carries no Analitik İşler credit; the AI Tag Debugger one stays.
|
|
212
|
+
if (brand?.name || brand?.logo) lines.push("---", "", `_${brand.name ? `${brand.name} · ` : ""}${m.ui.generatedBy}_`, "");
|
|
213
|
+
else if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
|
|
210
214
|
return lines.join("\n");
|
|
211
215
|
}
|
|
212
216
|
|
|
@@ -466,6 +470,7 @@ const ICON = {
|
|
|
466
470
|
info: '<circle cx="12" cy="12" r="10"/><path d="M12 16v-4"/><path d="M12 8h.01"/>',
|
|
467
471
|
impact: '<polyline points="22 17 13.5 8.5 8.5 13.5 2 7"/><polyline points="16 17 22 17 22 11"/>',
|
|
468
472
|
cause: '<circle cx="11" cy="11" r="8"/><path d="m21 21-4.3-4.3"/>',
|
|
473
|
+
gtm: '<rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/>',
|
|
469
474
|
evidence: '<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z"/><path d="M14 2v4a2 2 0 0 0 2 2h4"/><path d="M10 9H8"/><path d="M16 13H8"/><path d="M16 17H8"/>',
|
|
470
475
|
fix: '<path d="M14.7 6.3a1 1 0 0 0 0 1.4l1.6 1.6a1 1 0 0 0 1.4 0l3.77-3.77a6 6 0 0 1-7.94 7.94l-6.91 6.91a2.12 2.12 0 0 1-3-3l6.91-6.91a6 6 0 0 1 7.94-7.94l-3.76 3.76z"/>',
|
|
471
476
|
where: '<path d="M20 10c0 4.993-5.539 10.193-7.399 11.799a1 1 0 0 1-1.202 0C9.539 20.193 4 14.993 4 10a8 8 0 0 1 16 0"/><circle cx="12" cy="10" r="3"/>',
|
|
@@ -593,6 +598,18 @@ function evidenceHtml(evidence) {
|
|
|
593
598
|
return `<pre>${escapeHtml(evidence)}</pre>`;
|
|
594
599
|
}
|
|
595
600
|
|
|
601
|
+
/** The tags in the GTM container export that send this finding's event, with their triggers. */
|
|
602
|
+
function gtmHtml(m, tags) {
|
|
603
|
+
if (!tags) return "";
|
|
604
|
+
if (!tags.length) return `${sec("gtm", m.ui.gtmTags)}<p class="muted">${m.ui.gtmNoTag}</p>`;
|
|
605
|
+
const trig = (t) => `${escapeHtml(t.name)}${t.event ? ` <span class="muted">(${escapeHtml(t.event)})</span>` : ""}`;
|
|
606
|
+
const rows = tags.map((t) => {
|
|
607
|
+
const notes = [t.paused ? m.ui.gtmPaused : "", t.consent.length ? m.ui.gtmConsent(t.consent.join(", ")) : ""].filter(Boolean);
|
|
608
|
+
return `<tr><th>${escapeHtml(t.name)}${notes.length ? `<div class="muted xs">${escapeHtml(notes.join(" · "))}</div>` : ""}</th><td>${t.triggers.length ? `${m.ui.gtmFiresOn} ${t.triggers.map(trig).join(", ")}` : `<span class="missing">${m.ui.gtmNoTrigger}</span>`}${t.blocking.length ? `<div class="muted xs">${m.ui.gtmBlockedBy} ${t.blocking.map(trig).join(", ")}</div>` : ""}</td></tr>`;
|
|
609
|
+
});
|
|
610
|
+
return `${sec("gtm", m.ui.gtmTags)}<table class="ptable ev-table gtm-tags"><tbody>${rows.join("")}</tbody></table>`;
|
|
611
|
+
}
|
|
612
|
+
|
|
596
613
|
function verifyHtml(m, f) {
|
|
597
614
|
const links = verifyLinks(f);
|
|
598
615
|
if (!links.length) return "";
|
|
@@ -621,7 +638,7 @@ function findingCard(m, runs, f, open, aiRan) {
|
|
|
621
638
|
<span class="fhead"><span class="ftitle">${detailHtml(f.detail)}</span><span class="fmeta">${f.platform ? `<span class="ptag p-${safeKey(f.platform)}">${escapeHtml(platformLabel(f.platform))}</span>` : ""}<span>${escapeHtml(m.ui.spread(journeysN, stepsN))}</span></span></span>
|
|
622
639
|
${badge(f.severity, m.severity[f.severity])}${icon.chevron}</summary>
|
|
623
640
|
<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>` : ""}
|
|
624
|
-
<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>
|
|
641
|
+
<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}` : ""}${gtmHtml(m, f.gtm)}</div>
|
|
625
642
|
<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>`;
|
|
626
643
|
}
|
|
627
644
|
|
|
@@ -889,6 +906,7 @@ code{font-size:12px;background:var(--muted-bg);padding:1px 5px;border-radius:4px
|
|
|
889
906
|
.topbar{background:var(--bg);border-bottom:1px solid var(--border)}
|
|
890
907
|
.topbar .wrap{display:flex;align-items:center;justify-content:space-between;gap:16px;height:56px}
|
|
891
908
|
.product-logo img{display:block;height:26px;width:auto}
|
|
909
|
+
.brand-name{font:600 16px var(--font-sans);color:var(--fg)}
|
|
892
910
|
.byline{display:inline-flex;align-items:center;gap:8px;font-size:12px;color:var(--muted)}.byline:hover{opacity:.85}
|
|
893
911
|
.logo-h img{display:block;height:18px;width:auto}
|
|
894
912
|
.title{margin-top:20px;display:flex;flex-wrap:wrap;align-items:center;gap:6px 16px}
|
|
@@ -1126,25 +1144,32 @@ pre{margin:8px 0 0;font-size:12px;line-height:1.5;background:var(--muted-bg);bor
|
|
|
1126
1144
|
@media print{.toptabs,.jtabs,.stepnav,.filter>label,.filter>input,.tabs.seg>label,.sevfilter{display:none}.tabpanel,.jpanel,.sdetail,.tabs.seg>.panel{display:block!important}.jpanel{margin-bottom:32px}.sdetail{margin-bottom:16px;break-inside:avoid-page}details>*{display:block}}
|
|
1127
1145
|
`;
|
|
1128
1146
|
|
|
1129
|
-
export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null }) {
|
|
1147
|
+
export function renderHtml({ title, site = null, ai = null, generatedAt, findings, explained, runs, plan = null, lang = "en", branding = true, against = null, planCheck = null, changes = null, planChanges = null, consentScenarios = null, brand = null }) {
|
|
1130
1148
|
const m = messages(lang);
|
|
1131
1149
|
const rows = attachExplanations(findings, explained);
|
|
1132
1150
|
const counts = summarize(rows);
|
|
1133
1151
|
const problems = rows.filter((f) => f.severity !== "ok").length;
|
|
1134
1152
|
const flagged = flaggedSteps(runs, rows);
|
|
1135
1153
|
const atd = `<a class="f-atd" href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer"><img src="${LOGOS["atd-icon"]}" alt="" height="16">AI Tag Debugger</a>`;
|
|
1154
|
+
// A custom name or logo takes the place of the Analitik İşler branding. The AI Tag Debugger logo and credit stay, smaller, as with branding false.
|
|
1155
|
+
const whiteLabel = Boolean(brand?.logo || brand?.name);
|
|
1156
|
+
if (whiteLabel) branding = false;
|
|
1157
|
+
const brandName = brand?.name ? escapeHtml(brand.name) : "";
|
|
1158
|
+
const brandLogo = brand?.logo ? `<img src="${brand.logo}" alt="${brandName}" height="26">` : "";
|
|
1159
|
+
const productLogo = whiteLabel ? (brandLogo || `<span class="brand-name">${brandName}</span>`) : logo("atd-lockup", "AI Tag Debugger", 26, "logo-atd");
|
|
1160
|
+
const atdByline = whiteLabel ? `<a href="https://github.com/analitikisler/ai-tag-debugger" class="byline" rel="noopener noreferrer"><span>${m.ui.by}</span>${logo("atd-lockup", "AI Tag Debugger", 18, "logo-atd")}</a>` : "";
|
|
1136
1161
|
const footer = branding
|
|
1137
1162
|
? `<div class="wrap"><a href="https://analitikisler.com" class="logo-link" aria-label="analitikisler.com" rel="noopener noreferrer">${logo("stacked", "Analitik İşler", 56, "logo-s")}</a>
|
|
1138
1163
|
<div><p>${m.ui.credit(atd)}</p><div class="f-links"><a href="https://analitikisler.com" rel="noopener noreferrer">analitikisler.com</a><a href="https://github.com/analitikisler/ai-tag-debugger" rel="noopener noreferrer">GitHub</a></div></div>
|
|
1139
1164
|
<div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`
|
|
1140
|
-
: `<div class="wrap plain"><div class="f-meta"
|
|
1165
|
+
: `<div class="wrap plain"><div class="f-meta">${whiteLabel ? `${brandName}${brandName ? " · " : ""}${m.ui.credit(atd)}` : "AI Tag Debugger"}</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
|
|
1141
1166
|
|
|
1142
1167
|
return `<!doctype html>
|
|
1143
1168
|
<html lang="${lang}"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">
|
|
1144
|
-
<title>${escapeHtml(title)}</title><link rel="icon" type="image/png" href="${LOGOS.favicon}">
|
|
1169
|
+
<title>${escapeHtml(title)}</title><link rel="icon"${brand?.logo ? "" : ' type="image/png"'} href="${brand?.logo ?? LOGOS.favicon}">
|
|
1145
1170
|
<style>${CSS}${filterCss(runs)}
|
|
1146
|
-
${navCss(runs)}</style></head><body>
|
|
1147
|
-
<div class="topbar"><div class="wrap"><span class="product-logo">${
|
|
1171
|
+
${navCss(runs)}${brandCss(brand?.color)}</style></head><body>
|
|
1172
|
+
<div class="topbar"><div class="wrap"><span class="product-logo">${productLogo}</span>${atdByline}${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>
|
|
1148
1173
|
<header class="wrap title">
|
|
1149
1174
|
<h1 class="site"><span class="k">${m.ui.site}</span><span class="v">${escapeHtml(site ?? title)}</span></h1>
|
|
1150
1175
|
<div class="meta">${escapeHtml(generatedAt)} · ${escapeHtml(m.ui.headerMeta(runs.length, checkedProviders(runs).length))} · ai-tag-debugger ${VERSION}</div>${against ? `\n<div class="meta">${escapeHtml(m.ui.checkedAgainst(against))}</div>` : ""}${planChanges ? `\n<div class="meta plan-changes">${escapeHtml(m.ui.planChangesLine(planChanges))}${planChangeItems(planChanges).length ? `<ul>${planChangeItems(planChanges).map((i) => `<li>${escapeHtml(i)}</li>`).join("")}</ul>` : ""}</div>` : ""}${changes?.routes?.length ? `\n<div class="meta"><a href="#changes">${escapeHtml(m.ui.routeChanged(routeList(changes)))}</a></div>` : ""}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { chromium } from "playwright";
|
|
4
|
+
import { browserEnv, installBrowser } from "./browser.js";
|
|
5
|
+
|
|
6
|
+
const LOGO_TYPES = { ".png": "image/png", ".jpg": "image/jpeg", ".jpeg": "image/jpeg", ".webp": "image/webp", ".gif": "image/gif", ".svg": "image/svg+xml" };
|
|
7
|
+
const MAX_LOGO_BYTES = 512 * 1024;
|
|
8
|
+
const HEX = /^#(?:[0-9a-f]{3}|[0-9a-f]{6})$/i;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Reads the "report" setting: { name?, logo?, color?, pdf? }. A logo file is embedded as a data URI, so the
|
|
12
|
+
* report stays one file. Relative paths are read from the config file's folder.
|
|
13
|
+
* @returns {Promise<{ name: string | null, logo: string | null, color: string | null, pdf: boolean } | null>} null when nothing is set
|
|
14
|
+
*/
|
|
15
|
+
export async function loadReportBrand(report, baseDir = process.cwd()) {
|
|
16
|
+
if (report === undefined || report === null) return null;
|
|
17
|
+
if (typeof report !== "object" || Array.isArray(report)) throw new Error('"report" must be an object, e.g. { "name": "Acme", "logo": "logo.png", "color": "#0a7d5a", "pdf": true }.');
|
|
18
|
+
const { name = null, logo = null, color = null, pdf = false } = report;
|
|
19
|
+
if (name !== null && (typeof name !== "string" || name.length > 80)) throw new Error('"report.name" must be a short text.');
|
|
20
|
+
if (color !== null && (typeof color !== "string" || !HEX.test(color))) throw new Error('"report.color" must be a hex color like "#0a7d5a".');
|
|
21
|
+
if (typeof pdf !== "boolean") throw new Error('"report.pdf" must be true or false.');
|
|
22
|
+
let dataUri = null;
|
|
23
|
+
if (logo !== null) {
|
|
24
|
+
if (typeof logo !== "string") throw new Error('"report.logo" must be a file path.');
|
|
25
|
+
const full = path.resolve(baseDir, logo);
|
|
26
|
+
const type = LOGO_TYPES[path.extname(full).toLowerCase()];
|
|
27
|
+
if (!type) throw new Error(`"report.logo" must be a ${Object.keys(LOGO_TYPES).join(", ")} file.`);
|
|
28
|
+
let bytes;
|
|
29
|
+
try { bytes = await readFile(full); } catch (err) { throw new Error(`Could not read the report logo ${full}: ${err.message}`); }
|
|
30
|
+
if (bytes.length > MAX_LOGO_BYTES) throw new Error(`"report.logo" is ${Math.round(bytes.length / 1024)} KB; keep it under ${MAX_LOGO_BYTES / 1024} KB.`);
|
|
31
|
+
dataUri = `data:${type};base64,${bytes.toString("base64")}`;
|
|
32
|
+
}
|
|
33
|
+
if (!name && !dataUri && !color && !pdf) return null;
|
|
34
|
+
return { name: name || null, logo: dataUri, color: color ? normalizeHex(color) : null, pdf };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function normalizeHex(hex) {
|
|
38
|
+
const h = hex.slice(1);
|
|
39
|
+
return `#${(h.length === 3 ? [...h].map((c) => c + c).join("") : h).toLowerCase()}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const rgb = (hex) => [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16));
|
|
43
|
+
const toHex = (c) => `#${c.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0")).join("")}`;
|
|
44
|
+
const mix = (hex, other, amount) => toHex(rgb(hex).map((v, i) => v + (other[i] - v) * amount));
|
|
45
|
+
|
|
46
|
+
/** CSS that recolors the report's accent. The light theme gets a darker text color and a pale tint; the dark theme a lighter one. */
|
|
47
|
+
export function brandCss(color) {
|
|
48
|
+
if (!color) return "";
|
|
49
|
+
const black = [0, 0, 0], white = [255, 255, 255];
|
|
50
|
+
const light = `--accent:${color};--accent-fg:${mix(color, black, 0.2)};--accent-bg:${mix(color, white, 0.9)}`;
|
|
51
|
+
const dark = `--accent:${mix(color, white, 0.2)};--accent-fg:${mix(color, white, 0.5)};--accent-bg:${mix(color, [10, 14, 26], 0.75)}`;
|
|
52
|
+
return `:root{${light}}@media (prefers-color-scheme:dark){:root{${dark}}}`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Prints an HTML report to a PDF with Chromium. The print styles open every tab, so the PDF holds the whole report. */
|
|
56
|
+
export async function writePdf(htmlPath, pdfPath) {
|
|
57
|
+
const launch = { headless: true, env: browserEnv() };
|
|
58
|
+
let browser;
|
|
59
|
+
try {
|
|
60
|
+
browser = await chromium.launch(launch);
|
|
61
|
+
} catch (err) {
|
|
62
|
+
if (!/Executable doesn't exist|playwright install/i.test(String(err?.message))) throw err;
|
|
63
|
+
await installBrowser();
|
|
64
|
+
browser = await chromium.launch(launch);
|
|
65
|
+
}
|
|
66
|
+
try {
|
|
67
|
+
// JavaScript is off: the report needs none, and the page is only ever our own file.
|
|
68
|
+
const context = await browser.newContext({ javaScriptEnabled: false, colorScheme: "light" });
|
|
69
|
+
const page = await context.newPage();
|
|
70
|
+
await page.goto(new URL(`file://${path.resolve(htmlPath)}`).href);
|
|
71
|
+
await page.emulateMedia({ media: "print", colorScheme: "light" });
|
|
72
|
+
await page.pdf({ path: pdfPath, format: "A4", printBackground: true, margin: { top: "14mm", bottom: "14mm", left: "10mm", right: "10mm" } });
|
|
73
|
+
} finally {
|
|
74
|
+
await browser.close();
|
|
75
|
+
}
|
|
76
|
+
}
|