@analitikisler/ai-tag-debugger 0.11.3 → 0.13.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +19 -0
- package/README.md +9 -8
- package/README.tr.md +9 -8
- package/package.json +1 -1
- package/src/agent.js +1 -1
- package/src/audit.js +11 -7
- package/src/brand.js +3 -3
- package/src/browser.js +27 -5
- package/src/changes.js +29 -1
- package/src/checks.js +7 -3
- package/src/i18n.js +56 -2
- package/src/mcp.js +3 -3
- package/src/parsers.js +184 -33
- package/src/redact.js +20 -3
- package/src/report.js +144 -35
- package/src/verify.js +48 -0
package/src/checks.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import { messages } from "./i18n.js";
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
6
|
|
|
7
|
+
const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
|
|
7
8
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
8
9
|
const GOOGLE_PLATFORMS = new Set(["ga4", "google_ads"]);
|
|
9
10
|
|
|
@@ -136,6 +137,7 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
136
137
|
// Platforms are compared in lower case ("GA4" in a plan is ga4). Planned events for
|
|
137
138
|
// providers the user chose not to check are left out, and listed in one info line.
|
|
138
139
|
plan = { ...plan, events: plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() })) };
|
|
140
|
+
const hasPlan = plan.events.length > 0;
|
|
139
141
|
if (run.providers) {
|
|
140
142
|
const skipped = plan.events.filter((e) => !run.providers.includes(e.platform) && expectedIn(e, run.journeyId));
|
|
141
143
|
plan = { ...plan, events: plan.events.filter((e) => run.providers.includes(e.platform)) };
|
|
@@ -279,7 +281,9 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
279
281
|
const own = run.hits.filter((h) => h.platform === platform);
|
|
280
282
|
return { platform, server: names(own.filter((h) => h.delivery === "server")), browser: names(own.filter((h) => h.delivery !== "server")) };
|
|
281
283
|
}).filter((p) => p.server.length);
|
|
282
|
-
|
|
284
|
+
// Only name the platforms whose server-side copies this run would have checked.
|
|
285
|
+
const unseenServer = ["meta", "tiktok"].filter((p) => !run.providers || run.providers.includes(p)).map(platformName);
|
|
286
|
+
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) });
|
|
283
287
|
}
|
|
284
288
|
const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
|
|
285
289
|
if (serverVendors.length || serverHits.length) {
|
|
@@ -408,9 +412,9 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
408
412
|
findings.push({ ...base, severity: "warning", check: "pii_datalayer", step: stepLabel(plainPush.step), detail: d.piiDataLayer(plainPush.userData.plain) });
|
|
409
413
|
}
|
|
410
414
|
|
|
411
|
-
// Events that are not in the plan.
|
|
415
|
+
// Events that are not in the plan. Without a plan every event would be listed, which says nothing.
|
|
412
416
|
const unplanned = [...new Set(run.hits.filter((h) => !planEventFor(h)).map((h) => key(h.platform, h.name)))];
|
|
413
|
-
if (unplanned.length) {
|
|
417
|
+
if (hasPlan && unplanned.length) {
|
|
414
418
|
findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
|
|
415
419
|
}
|
|
416
420
|
|
package/src/i18n.js
CHANGED
|
@@ -53,6 +53,12 @@ const en = {
|
|
|
53
53
|
whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, step "${step}"` : ""}`,
|
|
54
54
|
allGood: "All planned events fired correctly on every journey tested.",
|
|
55
55
|
tabEvents: "Events",
|
|
56
|
+
tabTimeline: "Timeline",
|
|
57
|
+
timelineGtmEvent: "GTM event",
|
|
58
|
+
timelineConsent: "Consent command",
|
|
59
|
+
timelineNoEvent: "No GTM event (data only)",
|
|
60
|
+
timelineNoHits: "No platform hit followed this push.",
|
|
61
|
+
timelineOther: "Hits without a dataLayer push",
|
|
56
62
|
bodyDropped: "Request body not stored: this audit reached its 2 MB limit for stored request bodies.",
|
|
57
63
|
tabNetwork: "Network requests",
|
|
58
64
|
filterBy: "Filter by source",
|
|
@@ -75,6 +81,9 @@ const en = {
|
|
|
75
81
|
impact: "Impact",
|
|
76
82
|
evidence: "Evidence",
|
|
77
83
|
fixSteps: "Fix",
|
|
84
|
+
verify: "How to verify",
|
|
85
|
+
owners: { developer: "Developer", gtm: "Tag Manager", marketing: "Marketing" },
|
|
86
|
+
ownerHint: { developer: "Site code and dataLayer", gtm: "Tags, triggers and consent setup", marketing: "The measurement plan" },
|
|
78
87
|
places: (n) => `${n} ${n === 1 ? "place" : "places"}`,
|
|
79
88
|
alsoCheck: "Not in the findings list, but worth checking",
|
|
80
89
|
providersChecked: "Providers checked:",
|
|
@@ -92,6 +101,7 @@ const en = {
|
|
|
92
101
|
changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? (ch.fixed.length === 1 ? "event" : "events") : (ch.fixed.length === 1 ? "problem" : "problems")} fixed` : "", ch.new.length ? `${ch.new.length} new ${ch.new.length === 1 ? "problem" : "problems"}` : ""].filter(Boolean).join(", ") : "No changes since the last run",
|
|
93
102
|
changeFixed: "Fixed",
|
|
94
103
|
changeNew: "New",
|
|
104
|
+
routeChanged: (list) => `The AI took a different route through the site than in the last run: ${list}`,
|
|
95
105
|
noPreviousRun: "This is the first saved run for this site, so there is nothing to compare yet.",
|
|
96
106
|
planVsSiteLead: "Each planned event, and what the site actually sent",
|
|
97
107
|
coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
|
|
@@ -104,6 +114,23 @@ const en = {
|
|
|
104
114
|
stepsCol: "Steps",
|
|
105
115
|
problemsCol: "Problems",
|
|
106
116
|
scopeCard: "Scope",
|
|
117
|
+
site: "Site",
|
|
118
|
+
runCard: "This run",
|
|
119
|
+
runPlatforms: "Platforms",
|
|
120
|
+
runAi: "AI",
|
|
121
|
+
runAiOn: (name, model) => `On · ${name}${model ? ` · ${model}` : ""}`,
|
|
122
|
+
runAiOff: "Off",
|
|
123
|
+
runPlan: "Plan",
|
|
124
|
+
runPlanEvents: (n) => `${n} ${n === 1 ? "event" : "events"}`,
|
|
125
|
+
runNoPlan: "None",
|
|
126
|
+
runJourneys: "Journeys",
|
|
127
|
+
runViewports: "Viewports",
|
|
128
|
+
runLanguage: "Language",
|
|
129
|
+
runVersion: "Version",
|
|
130
|
+
consentDefault: "Default",
|
|
131
|
+
consentUpdate: "Update",
|
|
132
|
+
consentLead: "Consent Mode commands the site sent, in order.",
|
|
133
|
+
consentNone: "The site sent no Consent Mode commands on this journey.",
|
|
107
134
|
skippedLine: (list, n) => `${n} out-of-scope ${n === 1 ? "request was" : "requests were"} skipped: ${list}.`,
|
|
108
135
|
findingHint: "Open a finding to see its likely cause, evidence and fix steps.",
|
|
109
136
|
spread: (j, s) => `${j} ${j === 1 ? "journey" : "journeys"}${s ? ` · ${s} ${s === 1 ? "step" : "steps"}` : ""}`,
|
|
@@ -138,7 +165,7 @@ const en = {
|
|
|
138
165
|
purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} was sent with currency ${q(c)}, which is not a 3-letter ISO 4217 code in capitals (such as USD, EUR or TRY). Platforms may drop the revenue or convert it wrongly.`,
|
|
139
166
|
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
|
|
140
167
|
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.`,
|
|
141
|
-
serverSide: (platform, server, browser) => `${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` : ""}. Server-side copies of
|
|
168
|
+
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.` : ""}`,
|
|
142
169
|
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.`,
|
|
143
170
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
144
171
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
@@ -212,6 +239,12 @@ const tr = {
|
|
|
212
239
|
whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, "${step}" adımı` : ""}`,
|
|
213
240
|
allGood: "Test edilen tüm yolculuklarda plandaki tüm olaylar doğru çalıştı.",
|
|
214
241
|
tabEvents: "Olaylar",
|
|
242
|
+
tabTimeline: "Zaman çizelgesi",
|
|
243
|
+
timelineGtmEvent: "GTM olayı",
|
|
244
|
+
timelineConsent: "İzin komutu",
|
|
245
|
+
timelineNoEvent: "GTM olayı yok (yalnızca veri)",
|
|
246
|
+
timelineNoHits: "Bu gönderimin ardından platform isteği gelmedi.",
|
|
247
|
+
timelineOther: "dataLayer gönderimi olmayan istekler",
|
|
215
248
|
bodyDropped: "İstek gövdesi saklanmadı: bu denetim, saklanan istek gövdeleri için 2 MB sınırına ulaştı.",
|
|
216
249
|
tabNetwork: "Ağ istekleri",
|
|
217
250
|
filterBy: "Kaynağa göre filtrele",
|
|
@@ -234,6 +267,9 @@ const tr = {
|
|
|
234
267
|
impact: "Etkisi",
|
|
235
268
|
evidence: "Kanıt",
|
|
236
269
|
fixSteps: "Çözüm",
|
|
270
|
+
verify: "Nasıl doğrulanır",
|
|
271
|
+
owners: { developer: "Yazılımcı", gtm: "Tag Manager", marketing: "Pazarlama" },
|
|
272
|
+
ownerHint: { developer: "Site kodu ve dataLayer", gtm: "Etiketler, tetikleyiciler ve onay ayarları", marketing: "Ölçüm planı" },
|
|
237
273
|
places: (n) => `${n} yer`,
|
|
238
274
|
alsoCheck: "Bulgu listesinde olmayan, ama kontrol edilmesi gerekenler",
|
|
239
275
|
providersChecked: "Kontrol edilen sağlayıcılar:",
|
|
@@ -251,6 +287,7 @@ const tr = {
|
|
|
251
287
|
changesLine: (ch) => ch.fixed.length || ch.new.length ? [ch.fixed.length ? `${ch.fixed.length} ${ch.mode === "plan" ? "olay" : "sorun"} düzeldi` : "", ch.new.length ? `${ch.new.length} yeni sorun` : ""].filter(Boolean).join(", ") : "Son çalıştırmadan bu yana değişiklik yok",
|
|
252
288
|
changeFixed: "Düzeldi",
|
|
253
289
|
changeNew: "Yeni",
|
|
290
|
+
routeChanged: (list) => `Yapay zeka siteyi son çalıştırmadan farklı bir yoldan gezdi: ${list}`,
|
|
254
291
|
noPreviousRun: "Bu site için kayıtlı ilk çalıştırma bu, karşılaştırılacak bir şey henüz yok.",
|
|
255
292
|
planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
|
|
256
293
|
coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
|
|
@@ -263,6 +300,23 @@ const tr = {
|
|
|
263
300
|
stepsCol: "Adım",
|
|
264
301
|
problemsCol: "Sorun",
|
|
265
302
|
scopeCard: "Kapsam",
|
|
303
|
+
site: "Site",
|
|
304
|
+
runCard: "Bu çalıştırma",
|
|
305
|
+
runPlatforms: "Platformlar",
|
|
306
|
+
runAi: "Yapay zekâ",
|
|
307
|
+
runAiOn: (name, model) => `Açık · ${name}${model ? ` · ${model}` : ""}`,
|
|
308
|
+
runAiOff: "Kapalı",
|
|
309
|
+
runPlan: "Plan",
|
|
310
|
+
runPlanEvents: (n) => `${n} olay`,
|
|
311
|
+
runNoPlan: "Yok",
|
|
312
|
+
runJourneys: "Yolculuklar",
|
|
313
|
+
runViewports: "Ekranlar",
|
|
314
|
+
runLanguage: "Dil",
|
|
315
|
+
runVersion: "Sürüm",
|
|
316
|
+
consentDefault: "Varsayılan",
|
|
317
|
+
consentUpdate: "Güncelleme",
|
|
318
|
+
consentLead: "Sitenin gönderdiği Consent Mode komutları, sırasıyla.",
|
|
319
|
+
consentNone: "Site bu yolculukta Consent Mode komutu göndermedi.",
|
|
266
320
|
skippedLine: (list, n) => `Kapsam dışı ${n} istek atlandı: ${list}.`,
|
|
267
321
|
findingHint: "Bir bulguyu açınca olası neden, kanıt ve çözüm adımları görünür.",
|
|
268
322
|
spread: (j, s) => `${j} yolculuk${s ? ` · ${s} adım` : ""}`,
|
|
@@ -297,7 +351,7 @@ const tr = {
|
|
|
297
351
|
purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} ${q(c)} para birimiyle gönderildi; bu, büyük harfli 3 harfli bir ISO 4217 kodu değil (USD, EUR veya TRY gibi). Platformlar geliri atabilir veya yanlış çevirebilir.`,
|
|
298
352
|
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
|
|
299
353
|
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.`,
|
|
300
|
-
serverSide: (platform, server, browser) => `${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` : ""}.
|
|
354
|
+
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.` : ""}`,
|
|
301
355
|
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.`,
|
|
302
356
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
303
357
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
package/src/mcp.js
CHANGED
|
@@ -37,7 +37,7 @@ const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracki
|
|
|
37
37
|
|
|
38
38
|
Two kinds of request: a PLAN is what should be tracked, an AUDIT is what the site actually sends. If the user wants to know what to track, which events a site needs, or asks for a tracking or measurement plan, call make_plan (with topic when they only care about one kind of event: ${Object.keys(TOPICS).join(", ")}). If they want to know whether tracking works, what a site sends, or what changed, run an audit. To audit against the plan afterwards, pass against to finish_audit.
|
|
39
39
|
|
|
40
|
-
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
|
|
40
|
+
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}, plus any other platform the site turns out to use); pass their choice as providers, or leave it out for the default.
|
|
41
41
|
|
|
42
42
|
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
|
|
43
43
|
|
|
@@ -153,7 +153,7 @@ export function buildServer(opts) {
|
|
|
153
153
|
providers: z
|
|
154
154
|
.array(z.enum(Object.keys(TRACKING_PROVIDERS)))
|
|
155
155
|
.optional()
|
|
156
|
-
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}.
|
|
156
|
+
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
157
157
|
},
|
|
158
158
|
},
|
|
159
159
|
async ({ site, journey, viewport, allowed_hosts, providers }) => {
|
|
@@ -367,7 +367,7 @@ export function buildServer(opts) {
|
|
|
367
367
|
providers: z
|
|
368
368
|
.array(z.enum(Object.keys(TRACKING_PROVIDERS)))
|
|
369
369
|
.optional()
|
|
370
|
-
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}.
|
|
370
|
+
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")} plus any other known platform the site sends to. When given, requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
371
371
|
},
|
|
372
372
|
},
|
|
373
373
|
async ({ config: configFile, against, scripted, language, branding, providers }) => {
|
package/src/parsers.js
CHANGED
|
@@ -196,16 +196,162 @@ export function parseKlaviyo(url, postData) {
|
|
|
196
196
|
}];
|
|
197
197
|
}
|
|
198
198
|
|
|
199
|
-
/**
|
|
199
|
+
/**
|
|
200
|
+
* LinkedIn Insight Tag: px.ads.linkedin.com/collect with the partner id (pid), and conversionId
|
|
201
|
+
* for a conversion; without one the hit is the page view. eventId pairs it with a Conversions API copy.
|
|
202
|
+
* @returns {Hit[] | null}
|
|
203
|
+
*/
|
|
204
|
+
export function parseLinkedIn(url) {
|
|
205
|
+
const u = new URL(url);
|
|
206
|
+
const q = u.searchParams;
|
|
207
|
+
if (u.hostname !== "px.ads.linkedin.com" || !/^\/(collect|wa)\/?$/.test(u.pathname) || !q.has("pid")) return null;
|
|
208
|
+
const params = {};
|
|
209
|
+
for (const [from, to] of [["value", "value"], ["currency", "currency"], ["url", "page_location"]]) if (q.has(from)) params[to] = q.get(from);
|
|
210
|
+
const eventId = q.get("eventId") || undefined;
|
|
211
|
+
if (eventId) params.event_id = eventId;
|
|
212
|
+
const conversion = q.get("conversionId");
|
|
213
|
+
return [{ platform: "linkedin", name: conversion ? `conversion:${conversion}` : "pageview", id: q.get("pid"), ...(eventId ? { eventId } : {}), params, url }];
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Microsoft Advertising UET: bat.bing.com/action/0 with the tag id (ti) and evt: "pageLoad" for page
|
|
218
|
+
* views, "custom" for events, named by their action (ea). gv and gc are the revenue and currency.
|
|
219
|
+
* @returns {Hit[] | null}
|
|
220
|
+
*/
|
|
221
|
+
export function parseBing(url) {
|
|
222
|
+
const u = new URL(url);
|
|
223
|
+
const q = u.searchParams;
|
|
224
|
+
if (u.hostname !== "bat.bing.com" || !/^\/action\/\d+$/.test(u.pathname) || !q.has("ti")) return null;
|
|
225
|
+
const params = {};
|
|
226
|
+
for (const [from, to] of [["ec", "event_category"], ["el", "event_label"], ["ev", "event_value"], ["gv", "value"], ["gc", "currency"], ["p", "page_location"]]) {
|
|
227
|
+
if (q.has(from)) params[to] = q.get(from);
|
|
228
|
+
}
|
|
229
|
+
const evt = q.get("evt") || "";
|
|
230
|
+
const name = evt === "custom" ? q.get("ea") || "custom" : evt || "(unnamed)";
|
|
231
|
+
return [{ platform: "microsoft_ads", name, id: q.get("ti"), params, url }];
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Criteo OneTag event codes. Setup entries (site type, email, …) ride along in the same request.
|
|
235
|
+
const CRITEO_EVENTS = { vh: "viewHome", vl: "viewList", vp: "viewItem", vb: "viewBasket", vc: "trackTransaction", vs: "viewSearch" };
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Criteo OneTag: sslwidget.criteo.com/event with the account (a) and one entry per pushed event
|
|
239
|
+
* in p0, p1, … (each itself a query string with e = the event code). Hashed emails are user data.
|
|
240
|
+
* @returns {Hit[] | null}
|
|
241
|
+
*/
|
|
242
|
+
export function parseCriteo(url, postData) {
|
|
243
|
+
const u = new URL(url);
|
|
244
|
+
if (!/(^|\.)criteo\.(com|net)$/.test(u.hostname) || !/^\/(event|m\/event)\/?$/.test(u.pathname)) return null;
|
|
245
|
+
const q = new URLSearchParams(u.search);
|
|
246
|
+
if (postData && !postData.trim().startsWith("{")) for (const [k, v] of new URLSearchParams(postData)) q.set(k, v);
|
|
247
|
+
const entries = [...q].filter(([k]) => /^p\d+$/.test(k)).map(([, v]) => new URLSearchParams(v));
|
|
248
|
+
if (!q.has("a") || !entries.length) return null;
|
|
249
|
+
const params = {};
|
|
250
|
+
const user = {};
|
|
251
|
+
let name = "";
|
|
252
|
+
for (const e of entries) {
|
|
253
|
+
const code = e.get("e");
|
|
254
|
+
if (CRITEO_EVENTS[code] && !name) name = CRITEO_EVENTS[code];
|
|
255
|
+
for (const [k, v] of e) {
|
|
256
|
+
if (k === "e") continue;
|
|
257
|
+
if (/^m(\[\d*\])?$/.test(k) || isUserDataKey(k)) user[`${code}.${k}`] = v;
|
|
258
|
+
else if (k === "p") params.content_ids = v;
|
|
259
|
+
else if (k === "id" && code === "vc") params.transaction_id = v;
|
|
260
|
+
else if (k === "site_type") params.site_type = v;
|
|
261
|
+
else if (k === "c") params.currency = v;
|
|
262
|
+
else if (k === "item" || k === "i") params.items = v;
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
return [{ platform: "criteo", name: name || "(unnamed)", id: q.get("a"), params, ...(Object.keys(user).length ? { userFields: user } : {}), url }];
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* Adobe Analytics image request: /b/ss/<report suite>/<version>/<code>/s<random> on omtrdc.net,
|
|
270
|
+
* 2o7.net or a first-party host. A link (pe: lnk_o, lnk_d, lnk_e) is named by its link name (pev2),
|
|
271
|
+
* anything else is the page view. events, products, purchase id, currency and the page are kept.
|
|
272
|
+
* @returns {Hit[] | null}
|
|
273
|
+
*/
|
|
274
|
+
export function parseAdobe(url) {
|
|
275
|
+
const u = new URL(url);
|
|
276
|
+
const m = u.pathname.match(/^\/b\/ss\/([^/]+)\/[^/]+\//);
|
|
277
|
+
if (!m) return null;
|
|
278
|
+
const q = u.searchParams;
|
|
279
|
+
const params = {};
|
|
280
|
+
for (const [from, to] of [["pageName", "page_title"], ["events", "events"], ["products", "items"], ["purchaseID", "transaction_id"], ["xact", "transaction_id"], ["cc", "currency"], ["g", "page_location"]]) {
|
|
281
|
+
if (q.has(from)) params[to] = q.get(from);
|
|
282
|
+
}
|
|
283
|
+
const pe = q.get("pe");
|
|
284
|
+
return [{ platform: "adobe", name: pe ? `link:${q.get("pev2") || pe}` : "pageView", id: decodeURIComponent(m[1]), params, url }];
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
// Matomo and Piwik PRO share one tracking protocol: an idsite and either an event (e_c, e_a, e_n),
|
|
288
|
+
// a goal (idgoal) or an ecommerce order (idgoal=0 with ec_id), otherwise a page view.
|
|
289
|
+
function matomoHit(platform, q, url) {
|
|
290
|
+
const params = {};
|
|
291
|
+
for (const [from, to] of [["revenue", "value"], ["ec_id", "transaction_id"], ["ec_items", "items"], ["ec_tx", "tax"], ["ec_sh", "shipping"], ["e_v", "event_value"], ["action_name", "page_title"], ["url", "page_location"], ["search", "search_term"]]) {
|
|
292
|
+
if (q.has(from)) params[to] = q.get(from);
|
|
293
|
+
}
|
|
294
|
+
const goal = q.get("idgoal");
|
|
295
|
+
let name = "pageview";
|
|
296
|
+
if (q.has("e_c")) name = q.get("e_a") ? `${q.get("e_c")}:${q.get("e_a")}` : q.get("e_c");
|
|
297
|
+
else if (goal === "0") name = q.has("ec_id") ? "ecommerce_order" : "ecommerce_cart_update";
|
|
298
|
+
else if (goal) name = `goal:${goal}`;
|
|
299
|
+
else if (q.has("search")) name = "site_search";
|
|
300
|
+
else if (q.has("link")) name = "outlink";
|
|
301
|
+
else if (q.has("download")) name = "download";
|
|
302
|
+
const user = [...q].filter(([k, v]) => isUserDataKey(k) && v);
|
|
303
|
+
return { platform, name, id: q.get("idsite") || undefined, params, ...(user.length ? { userFields: Object.fromEntries(user) } : {}), url };
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// Matomo's tracker can batch hits: a JSON POST of { requests: ["?idsite=1&…", …] }.
|
|
307
|
+
function matomoHits(platform, u, postData) {
|
|
308
|
+
const requests = jsonOr(postData ?? "{}", {})?.requests;
|
|
309
|
+
const batched = Array.isArray(requests) ? requests.filter((r) => typeof r === "string").map((r) => new URLSearchParams(r.replace(/^\?/, ""))) : [];
|
|
310
|
+
const queries = batched.length ? batched.map((b) => { for (const [k, v] of u.searchParams) if (!b.has(k)) b.set(k, v); return b; }) : [u.searchParams];
|
|
311
|
+
return queries.filter((q) => q.has("idsite")).map((q) => matomoHit(platform, q, u.href));
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Matomo (self-hosted or cloud): /matomo.php or /piwik.php with idsite, on any host. @returns {Hit[] | null} */
|
|
315
|
+
export function parseMatomo(url, postData) {
|
|
316
|
+
const u = new URL(url);
|
|
317
|
+
if (!/(^|\/)(matomo|piwik)\.php$/.test(u.pathname)) return null;
|
|
318
|
+
const hits = matomoHits("matomo", u, postData);
|
|
319
|
+
return hits.length ? hits : null;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/** Piwik PRO: /ppms.php with idsite (a UUID), on its cloud or a first-party host. @returns {Hit[] | null} */
|
|
323
|
+
export function parsePiwikPro(url, postData) {
|
|
324
|
+
const u = new URL(url);
|
|
325
|
+
if (!/(^|\/)ppms\.php$/.test(u.pathname)) return null;
|
|
326
|
+
const hits = matomoHits("piwik_pro", u, postData);
|
|
327
|
+
return hits.length ? hits : null;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Microsoft Clarity: the tag load (www.clarity.ms/tag/<project>) names the project; the data
|
|
332
|
+
* itself goes to <letter>.clarity.ms/collect as a compressed payload, so only the call is shown.
|
|
333
|
+
* @returns {Hit[] | null}
|
|
334
|
+
*/
|
|
335
|
+
export function parseClarity(url) {
|
|
336
|
+
const u = new URL(url);
|
|
337
|
+
if (!/(^|\.)clarity\.ms$/.test(u.hostname)) return null;
|
|
338
|
+
const tag = u.pathname.match(/^\/tag\/([a-z0-9]+)\/?$/i);
|
|
339
|
+
if (tag) return [{ platform: "clarity", name: "script_load", id: tag[1], params: {}, url }];
|
|
340
|
+
if (/^\/collect\/?$/.test(u.pathname)) return [{ platform: "clarity", name: "collect", params: {}, url }];
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
/** Hotjar: the tag load (static.hotjar.com/c/hotjar-<id>.js) and calls to its API carry the site id in the path. Only the id is read. @returns {Hit[] | null} */
|
|
200
345
|
export function parseHotjar(url) {
|
|
201
346
|
const u = new URL(url);
|
|
202
347
|
if (!/(^|\.)hotjar\.(com|io)$/.test(u.hostname)) return null;
|
|
348
|
+
const script = u.pathname.match(/^\/c\/hotjar-(\d+)\.js$/);
|
|
349
|
+
if (script) return [{ platform: "hotjar", name: "script_load", id: script[1], params: {}, url }];
|
|
203
350
|
const m = u.pathname.match(/^\/api\/v\d+\/client\/sites\/(\d+)\/([a-z-]+)/i);
|
|
204
351
|
if (!m) return null;
|
|
205
352
|
return [{ platform: "hotjar", name: m[2], id: m[1], params: {}, url }];
|
|
206
353
|
}
|
|
207
354
|
|
|
208
|
-
const PARSERS = [parseGa4, parseMeta, parseGoogleAds, parseTikTok, parseX, parsePinterest, parseSnapchat, parseKlaviyo, parseHotjar];
|
|
209
355
|
|
|
210
356
|
/** Returns normalized hits for a request, or null when it is not a tracking request. */
|
|
211
357
|
export function parseTrackingRequest(url, postData) {
|
|
@@ -231,7 +377,7 @@ const OTHER_BEACONS = [
|
|
|
231
377
|
[/./, /\/(ccm|ccm\/s|g\/s)\/collect$/],
|
|
232
378
|
[/^td\.doubleclick\.net$/, /^\/td\//],
|
|
233
379
|
[/(^|\.)facebook\.com$/, /^\/privacy_sandbox\/pixel\//],
|
|
234
|
-
[/^bat\.bing\.com$/, /^\/action\//],
|
|
380
|
+
[/^bat\.bing\.com$/, /^\/action\//], // UET requests without a tag id
|
|
235
381
|
[/^px\.ads\.linkedin\.com$/, /^\/(collect|wa)/],
|
|
236
382
|
[/^ct\.pinterest\.com$/, /^\/(v3|user)/],
|
|
237
383
|
[/^tr\.snapchat\.com$/, /^\//],
|
|
@@ -249,24 +395,39 @@ export function isCollectionEndpoint(url) {
|
|
|
249
395
|
return OTHER_BEACONS.some(([host, pathname]) => host.test(u.hostname) && pathname.test(u.pathname));
|
|
250
396
|
}
|
|
251
397
|
|
|
252
|
-
|
|
398
|
+
/**
|
|
399
|
+
* Every tracking vendor: its key, display name, the hosts its requests go to (path: how its
|
|
400
|
+
* tracker is told apart on a first-party host) and, when its events are read, a parser. Adding a vendor is one entry here plus its icon in report.js.
|
|
401
|
+
*/
|
|
402
|
+
export const VENDORS = [
|
|
403
|
+
{ key: "ga4", label: "GA4", hosts: /(^|\.)(google-analytics|analytics\.google)\.com$/, parse: parseGa4 },
|
|
404
|
+
{ key: "google_ads", label: "Google Ads", hosts: /(^|\.)googleadservices\.com$/, parse: parseGoogleAds },
|
|
405
|
+
{ key: "meta", label: "Meta", hosts: /(^|\.)(facebook\.com|facebook\.net)$/, parse: parseMeta },
|
|
406
|
+
{ key: "tiktok", label: "TikTok", hosts: /(^|\.)tiktok\.com$/, parse: parseTikTok },
|
|
407
|
+
{ key: "x", label: "X", hosts: /(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, parse: parseX },
|
|
408
|
+
{ key: "linkedin", label: "LinkedIn", hosts: /(^|\.)(licdn\.com|ads\.linkedin\.com)$/, parse: parseLinkedIn },
|
|
409
|
+
{ key: "microsoft_ads", label: "Microsoft Ads", hosts: /(^|\.)bing\.com$/, parse: parseBing },
|
|
410
|
+
{ key: "pinterest", label: "Pinterest", hosts: /(^|\.)(pinterest\.com|pinimg\.com)$/, parse: parsePinterest },
|
|
411
|
+
{ key: "snapchat", label: "Snapchat", hosts: /(^|\.)(snapchat\.com|sc-static\.net)$/, parse: parseSnapchat },
|
|
412
|
+
{ key: "criteo", label: "Criteo", hosts: /(^|\.)(criteo\.com|criteo\.net)$/, parse: parseCriteo },
|
|
413
|
+
{ key: "klaviyo", label: "Klaviyo", hosts: /(^|\.)klaviyo\.com$/, parse: parseKlaviyo },
|
|
414
|
+
{ key: "adobe", label: "Adobe Analytics", hosts: /(^|\.)(omtrdc|2o7)\.net$/, path: /^\/b\/ss\//, parse: parseAdobe },
|
|
415
|
+
{ key: "matomo", label: "Matomo", hosts: /(^|\.)(matomo\.cloud|innocraft\.cloud)$/, path: /(^|\/)(matomo|piwik)\.php$/, parse: parseMatomo },
|
|
416
|
+
// No host: piwik.pro also serves consent management, so its tracker is recognized by path.
|
|
417
|
+
{ key: "piwik_pro", label: "Piwik PRO", path: /(^|\/)ppms\.php$/, parse: parsePiwikPro },
|
|
418
|
+
{ key: "clarity", label: "Microsoft Clarity", hosts: /(^|\.)clarity\.ms$/, parse: parseClarity },
|
|
419
|
+
{ key: "hotjar", label: "Hotjar", hosts: /(^|\.)hotjar\.(com|io)$/, parse: parseHotjar },
|
|
420
|
+
{ key: "doubleclick", label: "DoubleClick" },
|
|
421
|
+
{ key: "adsense", label: "Google AdSense", hosts: /(^|\.)googlesyndication\.com$/ },
|
|
422
|
+
];
|
|
423
|
+
|
|
424
|
+
const PARSERS = VENDORS.filter((v) => v.parse).map((v) => v.parse);
|
|
425
|
+
|
|
426
|
+
// Providers the user can choose to check. Requests to the ones not checked are still
|
|
253
427
|
// answered locally, but they are left out of the timeline and only counted.
|
|
254
|
-
export const PROVIDERS =
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
meta: "Meta",
|
|
258
|
-
tiktok: "TikTok",
|
|
259
|
-
x: "X",
|
|
260
|
-
linkedin: "LinkedIn",
|
|
261
|
-
microsoft_ads: "Microsoft Ads",
|
|
262
|
-
pinterest: "Pinterest",
|
|
263
|
-
snapchat: "Snapchat",
|
|
264
|
-
klaviyo: "Klaviyo",
|
|
265
|
-
clarity: "Microsoft Clarity",
|
|
266
|
-
hotjar: "Hotjar",
|
|
267
|
-
doubleclick: "DoubleClick",
|
|
268
|
-
adsense: "Google AdSense",
|
|
269
|
-
};
|
|
428
|
+
export const PROVIDERS = Object.fromEntries(VENDORS.map((v) => [v.key, v.label]));
|
|
429
|
+
// Always checked. Any other vendor the site sends to is added on its first request,
|
|
430
|
+
// unless the platforms to check were chosen.
|
|
270
431
|
export const DEFAULT_PROVIDERS = ["ga4", "google_ads", "meta", "tiktok"];
|
|
271
432
|
// Tag managers and consent platforms are always shown: they explain how the tags loaded.
|
|
272
433
|
// Server-side tagging providers are too: their requests show where events are sent from.
|
|
@@ -275,19 +436,7 @@ const ALWAYS_SHOWN = { gtm: "Google Tag Manager", consent: "Consent platform", s
|
|
|
275
436
|
const HOSTS = [
|
|
276
437
|
[/(^|\.)stape\.(io|jp)$/, "stape"],
|
|
277
438
|
[/(^|\.)getelevar\.com$/, "elevar"],
|
|
278
|
-
|
|
279
|
-
[/(^|\.)googleadservices\.com$/, "google_ads"],
|
|
280
|
-
[/(^|\.)googlesyndication\.com$/, "adsense"],
|
|
281
|
-
[/(^|\.)(facebook\.com|facebook\.net)$/, "meta"],
|
|
282
|
-
[/(^|\.)tiktok\.com$/, "tiktok"],
|
|
283
|
-
[/(^|\.)(x\.com|twitter\.com|t\.co|ads-twitter\.com)$/, "x"],
|
|
284
|
-
[/(^|\.)(licdn\.com|ads\.linkedin\.com)$/, "linkedin"],
|
|
285
|
-
[/(^|\.)bing\.com$/, "microsoft_ads"],
|
|
286
|
-
[/(^|\.)clarity\.ms$/, "clarity"],
|
|
287
|
-
[/(^|\.)hotjar\.(com|io)$/, "hotjar"],
|
|
288
|
-
[/(^|\.)(pinterest\.com|pinimg\.com)$/, "pinterest"],
|
|
289
|
-
[/(^|\.)(snapchat\.com|sc-static\.net)$/, "snapchat"],
|
|
290
|
-
[/(^|\.)klaviyo\.com$/, "klaviyo"],
|
|
439
|
+
...VENDORS.filter((v) => v.hosts).map((v) => [v.hosts, v.key]),
|
|
291
440
|
[/(^|\.)(cookiebot\.com|cookielaw\.org|onetrust\.com|usercentrics\.(eu|com)|cookieyes\.com|didomi\.io|privacy-center\.org|trustarc\.com|truste\.com|consensu\.org|quantcast\.com|osano\.com|iubenda\.com|termly\.io|cookiefirst\.com|axeptio\.eu|sourcepoint\.com|sp-prod\.net|consentmanager\.net|cookiescript\.com|cookie-script\.com|cookieinformation\.com|civiccomputing\.com|piwik\.pro|complianz\.io|cdn\.privacy-mgmt\.com)$/, "consent"],
|
|
292
441
|
];
|
|
293
442
|
|
|
@@ -310,6 +459,8 @@ export function providerOf(url) {
|
|
|
310
459
|
if (/(^|\.)doubleclick\.net$/.test(host)) return of("doubleclick");
|
|
311
460
|
// Before the vendor hosts: a GA4 hit to a Stape or Elevar endpoint is still a GA4 hit.
|
|
312
461
|
if (isGa4Path(u) && !GOOGLE_COMPANION_HOST.test(host)) return of("ga4");
|
|
462
|
+
// Before the hosts too: trackers on a first-party or consent vendor host are told apart by path.
|
|
463
|
+
for (const v of VENDORS) if (v.path?.test(u.pathname)) return of(v.key);
|
|
313
464
|
for (const [pattern, key] of HOSTS) if (pattern.test(host)) return of(key);
|
|
314
465
|
if (isGa4Path(u)) return of("ga4");
|
|
315
466
|
if (u.pathname.startsWith("/pagead/") || /\/as\/p\/c\/\d+\/?$/.test(u.pathname)) return of("google_ads");
|
package/src/redact.js
CHANGED
|
@@ -88,12 +88,29 @@ function note(found, key, value) {
|
|
|
88
88
|
}
|
|
89
89
|
|
|
90
90
|
/** Redacts form-encoded parameters (a URL query or a GA4 body line). Returns null when nothing needed it. */
|
|
91
|
+
// Criteo OneTag nests each event as a query string in p0, p1, …; its setEmail entry (e=ce) carries the email hash in m.
|
|
92
|
+
const isNestedEvent = (k, v) => /^p\d+$/.test(k) && /^e=/.test(v);
|
|
93
|
+
const nestedUserKey = (k) => /^m(\[\d*\])?$/.test(k) || isUserDataKey(k);
|
|
94
|
+
|
|
91
95
|
function redactParamString(text, found, site) {
|
|
92
96
|
const params = new URLSearchParams(text);
|
|
93
|
-
|
|
97
|
+
let changed = false;
|
|
94
98
|
const out = new URLSearchParams();
|
|
95
|
-
for (const [k, v] of params)
|
|
96
|
-
|
|
99
|
+
for (const [k, v] of params) {
|
|
100
|
+
if (isNestedEvent(k, v)) {
|
|
101
|
+
const inner = new URLSearchParams();
|
|
102
|
+
for (const [ik, iv] of new URLSearchParams(v)) {
|
|
103
|
+
const hide = nestedUserKey(ik) || isEmail(iv, site, ik);
|
|
104
|
+
if (hide) changed = true;
|
|
105
|
+
inner.append(ik, hide ? note(found, ik, iv) : iv);
|
|
106
|
+
}
|
|
107
|
+
out.append(k, inner.toString());
|
|
108
|
+
} else if (isUserDataKey(k) || isEmail(v, site, k)) {
|
|
109
|
+
changed = true;
|
|
110
|
+
out.append(k, note(found, k, v));
|
|
111
|
+
} else out.append(k, v);
|
|
112
|
+
}
|
|
113
|
+
return changed ? out.toString() : null;
|
|
97
114
|
}
|
|
98
115
|
|
|
99
116
|
/** Redacts user data in a URL's query. Returns the URL unchanged when there is none. site: see isOwnAddress. */
|