@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 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), so explanations can name tags and triggers. |
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); açıklamalar etiket ve tetikleyicileri adlandırabilsin diye. |
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.15.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
- return { config, plan, gtmContainer };
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
- return { reportPath: path.join(outDir, "report.html"), changes };
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
- if (serverVendors.length || serverHits.length) {
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
- if (branding) lines.push("---", "", `_${m.ui.generatedBy} · [analitikisler.com](https://analitikisler.com)_`, "");
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">AI Tag Debugger</div><div class="f-meta">${escapeHtml(generatedAt)} · v${VERSION}</div></div>`;
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">${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>
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
+ }