@analitikisler/ai-tag-debugger 0.14.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 +15 -0
- package/README.md +32 -4
- package/README.tr.md +32 -4
- package/package.json +1 -1
- package/src/agent.js +6 -2
- package/src/audit.js +28 -10
- package/src/browser.js +11 -1
- package/src/campaign.js +28 -0
- package/src/changes.js +38 -1
- package/src/checks.js +82 -2
- package/src/cli.js +22 -0
- package/src/consent.js +154 -0
- package/src/gtm.js +92 -0
- package/src/i18n.js +60 -0
- package/src/inventory.js +69 -0
- package/src/learn.js +34 -0
- package/src/redact.js +1 -1
- package/src/report.js +91 -15
- package/src/sheet.js +8 -5
- package/src/whitelabel.js +76 -0
package/src/checks.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
|
|
4
4
|
import { messages } from "./i18n.js";
|
|
5
5
|
import { isOwnAddress } from "./redact.js";
|
|
6
|
+
import { campaignParamsIn } from "./campaign.js";
|
|
6
7
|
|
|
7
8
|
const platformName = (p) => ({ meta: "Meta", tiktok: "TikTok" })[p] ?? p;
|
|
8
9
|
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
@@ -23,6 +24,20 @@ const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
|
23
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
|
|
24
25
|
*/
|
|
25
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
|
+
|
|
26
41
|
const key = (platform, name) => `${platform}:${name}`;
|
|
27
42
|
|
|
28
43
|
// Sites name events in their own way: Elevar's dl_add_to_cart, UA-style addToCart, Add To Cart.
|
|
@@ -286,7 +301,12 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
286
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) });
|
|
287
302
|
}
|
|
288
303
|
const serverVendors = [...new Set((run.network ?? []).map((c) => c.provider).filter((p) => p === "stape" || p === "elevar"))];
|
|
289
|
-
|
|
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) {
|
|
290
310
|
const noId = new Map();
|
|
291
311
|
for (const hit of run.hits) {
|
|
292
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);
|
|
@@ -296,6 +316,66 @@ export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
|
296
316
|
}
|
|
297
317
|
}
|
|
298
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
|
+
|
|
352
|
+
// Campaign parameters (gclid, fbclid, UTM) on the landing address: they must survive redirects and reach the hits.
|
|
353
|
+
const landing = run.landing;
|
|
354
|
+
const sent = landing ? campaignParamsIn(landing.requested) : {};
|
|
355
|
+
if (landing && Object.keys(sent).length) {
|
|
356
|
+
const kept = campaignParamsIn(landing.final);
|
|
357
|
+
const lost = Object.keys(sent).filter((n) => kept[n] !== sent[n]);
|
|
358
|
+
const hop = landing.chain.length > 1 ? landing.chain : null;
|
|
359
|
+
if (lost.length) {
|
|
360
|
+
findings.push({ ...base, severity: "broken", check: "campaign_lost_redirect", step: stepLabel(landing.step), detail: d.campaignLost(lost, landing.final), evidence: hop ? hop.join(" -> ") : undefined });
|
|
361
|
+
}
|
|
362
|
+
// Hits the landing page sent whose page address leaves out a parameter the page still had.
|
|
363
|
+
const missing = new Map();
|
|
364
|
+
for (const hit of run.hits) {
|
|
365
|
+
const loc = hit.params?.page_location;
|
|
366
|
+
if (hit.step !== landing.step || !loc || hit.platform === "google_ads") continue;
|
|
367
|
+
const here = campaignParamsIn(loc);
|
|
368
|
+
const gone = Object.keys(kept).filter((n) => kept[n] === sent[n] && here[n] !== sent[n] && hit.params?.[n] !== sent[n]);
|
|
369
|
+
if (gone.length && !missing.has(hit.platform)) missing.set(hit.platform, { hit, gone });
|
|
370
|
+
}
|
|
371
|
+
for (const [platform, { hit, gone }] of missing) {
|
|
372
|
+
findings.push({ ...base, severity: "broken", check: "campaign_missing_in_hits", platform, event: hit.name, step: stepLabel(hit.step), detail: d.campaignMissingInHits(platform, hit.name, gone), evidence: hit.params.page_location });
|
|
373
|
+
}
|
|
374
|
+
if (!lost.length && !missing.size) {
|
|
375
|
+
findings.push({ ...base, severity: "ok", check: "campaign_params", step: stepLabel(landing.step), detail: d.campaignOk(Object.keys(sent)) });
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
|
|
299
379
|
// Consent.
|
|
300
380
|
const granted = consentGrantedStep(run);
|
|
301
381
|
// Consent Mode only governs Google tags. Within one step, the push and hit times decide which came first.
|
|
@@ -444,7 +524,7 @@ export function groupFindings(findings) {
|
|
|
444
524
|
const k = f.severity === "ok" ? `ok|${f.journey}|${f.viewport}` : [f.severity, f.check, f.platform, f.event, f.detail].join("|");
|
|
445
525
|
const where = { journey: f.journey, viewport: f.viewport, step: f.step };
|
|
446
526
|
if (groups.has(k)) groups.get(k).where.push(where);
|
|
447
|
-
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] });
|
|
448
528
|
}
|
|
449
529
|
return [...groups.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
450
530
|
}
|
package/src/cli.js
CHANGED
|
@@ -205,6 +205,8 @@ async function main(args = process.argv.slice(2)) {
|
|
|
205
205
|
const rel = path.relative(process.cwd(), result.reportPath);
|
|
206
206
|
console.log(`Report: ${rel.startsWith("..") ? result.reportPath : rel}`);
|
|
207
207
|
|
|
208
|
+
if (!against && !values.out) await offerLearnedPlan(result, loaded.config.site, values);
|
|
209
|
+
|
|
208
210
|
if (failOn !== "never") {
|
|
209
211
|
const threshold = levels.indexOf(failOn);
|
|
210
212
|
if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
|
|
@@ -222,6 +224,26 @@ async function main(args = process.argv.slice(2)) {
|
|
|
222
224
|
}
|
|
223
225
|
}
|
|
224
226
|
|
|
227
|
+
// The first run of a site with no plan: offer what it already tracks as its plan.
|
|
228
|
+
async function offerLearnedPlan(result, site, values) {
|
|
229
|
+
const dataDir = values["data-dir"] ?? DEFAULT_DATA_DIR;
|
|
230
|
+
const { listPlanIds } = await import("./workspace.js");
|
|
231
|
+
if ((await listPlanIds(dataDir, site).catch(() => [])).length) return;
|
|
232
|
+
const { learnPlan } = await import("./learn.js");
|
|
233
|
+
const plan = learnPlan(site, result.runs);
|
|
234
|
+
if (!plan) return;
|
|
235
|
+
console.log(`\nNo plan yet for this site. It already sends ${plan.events.length} ${plan.events.length === 1 ? "event" : "events"}: ${plan.events.map((e) => `${e.platform}:${e.name}`).join(", ")}`);
|
|
236
|
+
let save = values.yes;
|
|
237
|
+
if (!save && process.stdin.isTTY) save = ["", "y", "yes"].includes((await ask("Save them as the site's plan? (Y/n) ")).toLowerCase());
|
|
238
|
+
if (!save) {
|
|
239
|
+
if (!process.stdin.isTTY && !values.yes) console.log("Add --yes to save them as the plan, or draft a fuller one with: aitd plan --site " + site);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
const { id, dir } = await savePlan(plan, { dataDir, site, branding: !values["no-branding"] });
|
|
243
|
+
const rel = path.relative(process.cwd(), dir);
|
|
244
|
+
console.log(`✓ Saved plan ${id} in ${rel.startsWith("..") ? dir : rel}. Next runs can check against it with --against latest.`);
|
|
245
|
+
}
|
|
246
|
+
|
|
225
247
|
// aitd "check my GA tags": shows the matching command and runs it once confirmed.
|
|
226
248
|
async function fromSentence(sentence, values, extra) {
|
|
227
249
|
const { createProvider } = await import("./providers.js");
|
package/src/consent.js
ADDED
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// Consent scenarios: the start page opened in a fresh browser once per consent choice
|
|
2
|
+
// (accept all, reject all, analytics only), to show which tags each choice lets through.
|
|
3
|
+
// Runs on its own with every audit when the page shows a consent banner.
|
|
4
|
+
import { AuditSession, CMP_BANNER_SELECTORS } from "./browser.js";
|
|
5
|
+
|
|
6
|
+
export const CONSENT_STATES = ["accept", "reject", "analytics"];
|
|
7
|
+
// Tags that are advertising, so an "analytics only" choice should keep them quiet.
|
|
8
|
+
export const AD_PLATFORMS = new Set(["google_ads", "meta", "tiktok", "pinterest", "snapchat", "x"]);
|
|
9
|
+
|
|
10
|
+
// Runs in the page: makes the choice through the consent platform's own API when it has one,
|
|
11
|
+
// otherwise by clicking the banner's accept or reject button. Returns how, or null when it could not.
|
|
12
|
+
const applyInPage = ({ state, selectors }) => {
|
|
13
|
+
const w = window;
|
|
14
|
+
try {
|
|
15
|
+
if (w.OneTrust?.AllowAll) {
|
|
16
|
+
if (state === "accept") w.OneTrust.AllowAll();
|
|
17
|
+
else {
|
|
18
|
+
w.OneTrust.RejectAll();
|
|
19
|
+
// C0002 is OneTrust's performance (analytics) group.
|
|
20
|
+
if (state === "analytics") w.OneTrust.UpdateConsent?.("Category", "C0002:1");
|
|
21
|
+
}
|
|
22
|
+
return "OneTrust";
|
|
23
|
+
}
|
|
24
|
+
if (w.Cookiebot?.submitCustomConsent) {
|
|
25
|
+
// preferences, statistics, marketing
|
|
26
|
+
w.Cookiebot.submitCustomConsent(state === "accept", state !== "reject", state === "accept");
|
|
27
|
+
return "Cookiebot";
|
|
28
|
+
}
|
|
29
|
+
if (state !== "analytics") {
|
|
30
|
+
if (w.UC_UI?.acceptAllConsents) {
|
|
31
|
+
state === "accept" ? w.UC_UI.acceptAllConsents() : w.UC_UI.denyAllConsents();
|
|
32
|
+
return "Usercentrics";
|
|
33
|
+
}
|
|
34
|
+
if (w.Didomi?.setUserAgreeToAll) {
|
|
35
|
+
state === "accept" ? w.Didomi.setUserAgreeToAll() : w.Didomi.setUserDisagreeToAll();
|
|
36
|
+
return "Didomi";
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
} catch {
|
|
40
|
+
// An API that throws falls back to the banner's buttons.
|
|
41
|
+
}
|
|
42
|
+
if (state === "analytics") return null;
|
|
43
|
+
const words = state === "accept"
|
|
44
|
+
? /^(accept|allow|agree|ok|got it|kabul|tümünü kabul|izin ver|onayla|alle akzeptieren|tout accepter)/i
|
|
45
|
+
: /^(reject|decline|deny|refuse|necessary only|only necessary|reddet|kabul etme|ablehnen|tout refuser)/i;
|
|
46
|
+
const visible = (el) => !!(el.offsetWidth || el.offsetHeight || el.getClientRects().length);
|
|
47
|
+
const banners = [...document.querySelectorAll(selectors)].filter(visible);
|
|
48
|
+
for (const n of document.querySelectorAll("body *")) {
|
|
49
|
+
const label = `${n.id} ${typeof n.className === "string" ? n.className : ""}`;
|
|
50
|
+
if (/(cookie|consent|gdpr|kvkk)/i.test(label) && visible(n) && !banners.some((b) => b.contains(n))) banners.push(n);
|
|
51
|
+
}
|
|
52
|
+
for (const banner of banners) {
|
|
53
|
+
const button = [...banner.querySelectorAll("button, a, [role=button], input[type=button], input[type=submit]")]
|
|
54
|
+
.filter(visible)
|
|
55
|
+
.find((b) => words.test((b.innerText || b.value || b.getAttribute("aria-label") || "").trim()));
|
|
56
|
+
if (button) {
|
|
57
|
+
button.click();
|
|
58
|
+
return "banner";
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
return null;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// A Google hit that says storage is denied is a cookieless Consent Mode ping, which a "no" allows.
|
|
65
|
+
const cookieless = (h) => !!h.consent && h.consent.ad_storage !== "granted" && h.consent.analytics_storage !== "granted";
|
|
66
|
+
const isAnalyticsOnlyOk = (h) => !!h.consent && h.consent.ad_storage !== "granted";
|
|
67
|
+
|
|
68
|
+
/** Hits per platform and event: { n, quiet } where quiet counts the ones sent with storage denied. */
|
|
69
|
+
const countBy = (hits) => {
|
|
70
|
+
const out = {};
|
|
71
|
+
for (const h of hits) {
|
|
72
|
+
const c = ((out[h.platform] ??= {})[h.name] ??= { n: 0, cookieless: 0, adDenied: 0 });
|
|
73
|
+
c.n++;
|
|
74
|
+
if (cookieless(h)) c.cookieless++;
|
|
75
|
+
if (isAnalyticsOnlyOk(h)) c.adDenied++;
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/** Opens the start page, makes one consent choice, opens the page again, and returns the hits before and after. */
|
|
81
|
+
async function runState(config, state, opts) {
|
|
82
|
+
const session = new AuditSession({
|
|
83
|
+
viewport: "desktop", site: config.site, allowedHosts: config.allowed_hosts, providers: config.providers,
|
|
84
|
+
blockHits: config.block_hits !== false, headless: !opts.headed,
|
|
85
|
+
});
|
|
86
|
+
try {
|
|
87
|
+
await session.start();
|
|
88
|
+
const startError = await session.act({ action: "goto", url: config.site, reason: "Open the start page" });
|
|
89
|
+
if (startError) return { state, error: startError };
|
|
90
|
+
session.beginStep(`Choose: ${state}`);
|
|
91
|
+
const via = await session.page.evaluate(applyInPage, { state, selectors: CMP_BANNER_SELECTORS }).catch(() => null);
|
|
92
|
+
if (!via) return { state, via: null, before: countBy(session.hits) };
|
|
93
|
+
await session.settle();
|
|
94
|
+
session.beginStep("Open the start page again");
|
|
95
|
+
await session.page.reload().catch(() => {});
|
|
96
|
+
await session.settle();
|
|
97
|
+
const choice = session.steps.find((s) => s.label.startsWith("Choose:")).index;
|
|
98
|
+
return {
|
|
99
|
+
state, via,
|
|
100
|
+
before: countBy(session.hits.filter((h) => h.step < choice)),
|
|
101
|
+
after: countBy(session.hits.filter((h) => h.step >= choice)),
|
|
102
|
+
};
|
|
103
|
+
} catch (err) {
|
|
104
|
+
return { state, error: err?.message ?? String(err) };
|
|
105
|
+
} finally {
|
|
106
|
+
await session.close().catch(() => {});
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Runs every consent scenario on the site's start page. Returns null when the page has no consent
|
|
112
|
+
* banner the tool can answer (the first choice found nothing to click), so the report leaves the section out.
|
|
113
|
+
* @returns {Promise<null | { cmp: string, before: object, states: Record<string, { via: string|null, after?: object, error?: string }> }>}
|
|
114
|
+
*/
|
|
115
|
+
export async function runConsentScenarios(config, opts = {}) {
|
|
116
|
+
const log = opts.log ?? (() => {});
|
|
117
|
+
const first = await runState(config, "accept", opts);
|
|
118
|
+
if (!first.via) return null;
|
|
119
|
+
log(`\n▶ consent scenarios (${first.via})`);
|
|
120
|
+
const results = [first];
|
|
121
|
+
for (const state of CONSENT_STATES.slice(1)) results.push(await runState(config, state, opts));
|
|
122
|
+
const states = {};
|
|
123
|
+
for (const r of results) {
|
|
124
|
+
states[r.state] = r.error ? { via: null, error: r.error } : { via: r.via, ...(r.after ? { after: r.after } : {}) };
|
|
125
|
+
log(` ${r.state}: ${r.error ? `✗ ${r.error}` : r.via ? `${Object.values(r.after ?? {}).reduce((s, e) => s + Object.values(e).reduce((a, b) => a + b.n, 0), 0)} tracking hits` : "not supported here"}`);
|
|
126
|
+
}
|
|
127
|
+
return { cmp: first.via, before: first.before, states };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The per-state tag table: one row per platform and event, one cell per state (hit count, or null when
|
|
132
|
+
* the state did not run). A cell is a problem when a tag sent hits a choice should have kept quiet:
|
|
133
|
+
* anything after "reject all" except cookieless Consent Mode pings, and ad tags after "analytics only"
|
|
134
|
+
* unless they were sent with ad storage denied.
|
|
135
|
+
*/
|
|
136
|
+
export function consentTable(scenarios) {
|
|
137
|
+
const keys = new Set();
|
|
138
|
+
const add = (counts) => Object.entries(counts ?? {}).forEach(([p, events]) => Object.keys(events).forEach((e) => keys.add(`${p}\u0000${e}`)));
|
|
139
|
+
add(scenarios.before);
|
|
140
|
+
for (const s of Object.values(scenarios.states)) add(s.after);
|
|
141
|
+
const cols = ["before", ...CONSENT_STATES];
|
|
142
|
+
const rows = [...keys].sort().map((k) => {
|
|
143
|
+
const [platform, event] = k.split("\u0000");
|
|
144
|
+
const cells = cols.map((col) => {
|
|
145
|
+
const s = col === "before" ? { after: scenarios.before } : scenarios.states[col];
|
|
146
|
+
if (!s?.after) return { state: col, count: null, problem: false };
|
|
147
|
+
const c = s.after[platform]?.[event] ?? { n: 0, cookieless: 0, adDenied: 0 };
|
|
148
|
+
const problem = (col === "reject" && c.n > c.cookieless) || (col === "analytics" && AD_PLATFORMS.has(platform) && c.n > c.adDenied);
|
|
149
|
+
return { state: col, count: c.n, problem };
|
|
150
|
+
});
|
|
151
|
+
return { platform, event, cells };
|
|
152
|
+
});
|
|
153
|
+
return { cols, rows };
|
|
154
|
+
}
|
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.",
|
|
@@ -95,6 +102,7 @@ const en = {
|
|
|
95
102
|
sections: "Report sections",
|
|
96
103
|
headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
|
|
97
104
|
checkedAgainst: (id) => `Checked against plan ${id}`,
|
|
105
|
+
planChangesLine: (pc) => (pc.same ? `Plan unchanged since ${pc.previous}` : `Plan changed since ${pc.previous}: ${pc.added.length} added, ${pc.removed.length} removed, ${pc.changed.length} changed`),
|
|
98
106
|
planVsSite: "Plan vs. site",
|
|
99
107
|
changesTitle: "Changed since last run",
|
|
100
108
|
changesLead: (id, mode) => `Compared with run ${id}, by ${mode === "plan" ? "plan coverage" : "health findings"}`,
|
|
@@ -132,6 +140,11 @@ const en = {
|
|
|
132
140
|
consentUpdate: "Update",
|
|
133
141
|
consentLead: "Consent Mode commands the site sent, in order.",
|
|
134
142
|
consentNone: "The site sent no Consent Mode commands on this journey.",
|
|
143
|
+
scenarios: "Consent scenarios",
|
|
144
|
+
scenariosLead: (cmp) => `The start page opened once per consent choice (answered through ${cmp === "banner" ? "the banner's buttons" : cmp}). Hits per tag after the choice; red ones should not have been sent.`,
|
|
145
|
+
scenarioCols: { before: "No choice yet", accept: "Accept all", reject: "Reject all", analytics: "Analytics only" },
|
|
146
|
+
scenarioNotRun: "Not supported here",
|
|
147
|
+
scenarioTag: "Tag",
|
|
135
148
|
skippedLine: (list, n) => `${n} out-of-scope ${n === 1 ? "request was" : "requests were"} skipped: ${list}.`,
|
|
136
149
|
findingHint: "Open a finding to see its likely cause, evidence and fix steps.",
|
|
137
150
|
spread: (j, s) => `${j} ${j === 1 ? "journey" : "journeys"}${s ? ` · ${s} ${s === 1 ? "step" : "steps"}` : ""}`,
|
|
@@ -145,6 +158,16 @@ const en = {
|
|
|
145
158
|
notChecked: "not checked",
|
|
146
159
|
noPlan: "No measurement plan was given, so there is nothing to compare against.",
|
|
147
160
|
providers: "Providers",
|
|
161
|
+
inventoryTitle: "Tag inventory",
|
|
162
|
+
inventoryLead: "Every vendor the site sent requests to, and where",
|
|
163
|
+
vendorCol: "Vendor",
|
|
164
|
+
partyCol: "Party",
|
|
165
|
+
pagesCol: "Pages",
|
|
166
|
+
cookiesCol: "Cookies",
|
|
167
|
+
party: { first: "First party", third: "Third party", both: "Both" },
|
|
168
|
+
morePages: (n) => `+${n} more`,
|
|
169
|
+
otherCookies: "Other cookies",
|
|
170
|
+
noInventory: "No vendor requests were recorded.",
|
|
148
171
|
provider: "Provider",
|
|
149
172
|
requestsCol: "Requests",
|
|
150
173
|
checked: "Checked",
|
|
@@ -167,7 +190,14 @@ const en = {
|
|
|
167
190
|
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
|
|
168
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.`,
|
|
169
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.`,
|
|
170
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.`,
|
|
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.`,
|
|
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.`,
|
|
200
|
+
campaignOk: (names) => `${names.join(", ")} survived the landing page's redirects and reached the hits.`,
|
|
171
201
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
172
202
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
173
203
|
beforeConsentNever: (platform, name) => `${platform} ${q(name)} fired although consent was never given in this journey.`,
|
|
@@ -212,6 +242,13 @@ const tr = {
|
|
|
212
242
|
where: "Nerede",
|
|
213
243
|
causeAndFix: "Neden ve çözüm",
|
|
214
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}`,
|
|
215
252
|
fix: "Çözüm",
|
|
216
253
|
captured: "Yakalanan",
|
|
217
254
|
noProblems: "Sorun bulunamadı.",
|
|
@@ -282,6 +319,7 @@ const tr = {
|
|
|
282
319
|
sections: "Rapor bölümleri",
|
|
283
320
|
headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
|
|
284
321
|
checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
|
|
322
|
+
planChangesLine: (pc) => (pc.same ? `Plan ${pc.previous} sürümünden bu yana değişmedi` : `Plan ${pc.previous} sürümünden bu yana değişti: ${pc.added.length} eklendi, ${pc.removed.length} çıkarıldı, ${pc.changed.length} değişti`),
|
|
285
323
|
planVsSite: "Plan ve site",
|
|
286
324
|
changesTitle: "Son çalıştırmadan bu yana değişenler",
|
|
287
325
|
changesLead: (id, mode) => `${id} çalıştırmasıyla ${mode === "plan" ? "plan kapsamına" : "sağlık bulgularına"} göre karşılaştırıldı`,
|
|
@@ -319,6 +357,11 @@ const tr = {
|
|
|
319
357
|
consentUpdate: "Güncelleme",
|
|
320
358
|
consentLead: "Sitenin gönderdiği Consent Mode komutları, sırasıyla.",
|
|
321
359
|
consentNone: "Site bu yolculukta Consent Mode komutu göndermedi.",
|
|
360
|
+
scenarios: "Onay senaryoları",
|
|
361
|
+
scenariosLead: (cmp) => `Başlangıç sayfası her onay seçimi için ayrı açıldı (${cmp === "banner" ? "banner butonlarıyla" : cmp} üzerinden yanıtlandı). Seçimden sonra etiket başına istek sayısı; kırmızılar gönderilmemeliydi.`,
|
|
362
|
+
scenarioCols: { before: "Henüz seçim yok", accept: "Tümünü kabul", reject: "Tümünü reddet", analytics: "Yalnızca analitik" },
|
|
363
|
+
scenarioNotRun: "Burada desteklenmiyor",
|
|
364
|
+
scenarioTag: "Etiket",
|
|
322
365
|
skippedLine: (list, n) => `Kapsam dışı ${n} istek atlandı: ${list}.`,
|
|
323
366
|
findingHint: "Bir bulguyu açınca olası neden, kanıt ve çözüm adımları görünür.",
|
|
324
367
|
spread: (j, s) => `${j} yolculuk${s ? ` · ${s} adım` : ""}`,
|
|
@@ -332,6 +375,16 @@ const tr = {
|
|
|
332
375
|
notChecked: "kontrol edilmedi",
|
|
333
376
|
noPlan: "Ölçüm planı verilmediği için karşılaştırılacak bir şey yok.",
|
|
334
377
|
providers: "Sağlayıcılar",
|
|
378
|
+
inventoryTitle: "Etiket envanteri",
|
|
379
|
+
inventoryLead: "Sitenin istek gönderdiği tüm sağlayıcılar ve hangi sayfalarda",
|
|
380
|
+
vendorCol: "Sağlayıcı",
|
|
381
|
+
partyCol: "Taraf",
|
|
382
|
+
pagesCol: "Sayfalar",
|
|
383
|
+
cookiesCol: "Çerezler",
|
|
384
|
+
party: { first: "Birinci taraf", third: "Üçüncü taraf", both: "İkisi de" },
|
|
385
|
+
morePages: (n) => `+${n} daha`,
|
|
386
|
+
otherCookies: "Diğer çerezler",
|
|
387
|
+
noInventory: "Sağlayıcıya giden istek kaydedilmedi.",
|
|
335
388
|
provider: "Sağlayıcı",
|
|
336
389
|
requestsCol: "İstek",
|
|
337
390
|
checked: "Kontrol edildi",
|
|
@@ -354,7 +407,14 @@ const tr = {
|
|
|
354
407
|
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
|
|
355
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.`,
|
|
356
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.`,
|
|
357
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.`,
|
|
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.`,
|
|
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.`,
|
|
417
|
+
campaignOk: (names) => `${names.join(", ")} açılış sayfasının yönlendirmelerinden geçti ve isteklere ulaştı.`,
|
|
358
418
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
359
419
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
360
420
|
beforeConsentNever: (platform, name) => `${platform} ${q(name)}, bu yolculukta hiç onay verilmemesine rağmen tetiklendi.`,
|
package/src/inventory.js
ADDED
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { providerOf } from "./parsers.js";
|
|
2
|
+
import { siteOf } from "./redact.js";
|
|
3
|
+
|
|
4
|
+
// Cookies the vendors' own scripts set on the site's domain.
|
|
5
|
+
const COOKIE_VENDORS = [
|
|
6
|
+
[/^(_ga|_gid|_gat)(_|$)/, "ga4"],
|
|
7
|
+
[/^(_gcl_|_gac_|IDE$|test_cookie$)/, "google_ads"],
|
|
8
|
+
[/^_fb[pc]$/, "meta"],
|
|
9
|
+
[/^(_ttp|ttclid|_tt_enable_cookie)$/, "tiktok"],
|
|
10
|
+
[/^(_li_|li_|bcookie|lidc|UserMatchHistory)/, "linkedin"],
|
|
11
|
+
[/^(_uet|MUID)/, "microsoft_ads"],
|
|
12
|
+
[/^(_pin_|_pinterest_)/, "pinterest"],
|
|
13
|
+
[/^(_scid|sc_at)/, "snapchat"],
|
|
14
|
+
[/^(_cto_|cto_)/, "criteo"],
|
|
15
|
+
[/^__kla_id$/, "klaviyo"],
|
|
16
|
+
[/^_hj/, "hotjar"],
|
|
17
|
+
[/^_clck|^_clsk/, "clarity"],
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const hostOf = (url) => {
|
|
21
|
+
try { return new URL(url).hostname; } catch { return ""; }
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const pathOf = (url) => {
|
|
25
|
+
try { const u = new URL(url); return `${u.hostname}${u.pathname}`; } catch { return ""; }
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Every vendor the site talked to: the pages it loaded or sent from, whether its requests went to the
|
|
30
|
+
* site's own domain (first party) or to the vendor (third party), and the cookie names tied to it.
|
|
31
|
+
* Cookies no vendor owns are listed apart. Only cookie names are kept, never values.
|
|
32
|
+
*/
|
|
33
|
+
export function buildInventory(runs) {
|
|
34
|
+
const vendors = new Map();
|
|
35
|
+
for (const r of runs) {
|
|
36
|
+
for (const c of r.network ?? []) {
|
|
37
|
+
if (!c.vendor || !c.pageUrl) continue;
|
|
38
|
+
const v = vendors.get(c.vendor) ?? { vendor: c.vendor, keys: new Set(), pages: new Set(), first: 0, third: 0, requests: 0, cookies: new Set() };
|
|
39
|
+
v.keys.add(c.provider);
|
|
40
|
+
v.pages.add(pathOf(c.pageUrl));
|
|
41
|
+
v.requests++;
|
|
42
|
+
v[siteOf(hostOf(c.url)) === siteOf(hostOf(c.pageUrl)) ? "first" : "third"]++;
|
|
43
|
+
vendors.set(c.vendor, v);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
const other = new Map();
|
|
47
|
+
for (const r of runs) {
|
|
48
|
+
for (const ck of r.cookieLog ?? []) {
|
|
49
|
+
const domain = String(ck.domain ?? "").replace(/^\./, "");
|
|
50
|
+
const key = COOKIE_VENDORS.find(([re]) => re.test(ck.name))?.[1] ?? providerOf(`https://${domain}/`)?.key;
|
|
51
|
+
const owners = [...vendors.values()].filter((v) => key && v.keys.has(key));
|
|
52
|
+
if (owners.length) for (const v of owners) v.cookies.add(ck.name);
|
|
53
|
+
else other.set(`${domain}|${ck.name}`, { name: ck.name, domain });
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
const sites = new Set(runs.flatMap((r) => (r.network ?? []).map((c) => c.pageUrl)).filter(Boolean).map((u) => siteOf(hostOf(u))));
|
|
57
|
+
return {
|
|
58
|
+
vendors: [...vendors.values()]
|
|
59
|
+
.map((v) => ({
|
|
60
|
+
vendor: v.vendor,
|
|
61
|
+
party: v.first && v.third ? "both" : v.first ? "first" : "third",
|
|
62
|
+
pages: [...v.pages].sort(),
|
|
63
|
+
requests: v.requests,
|
|
64
|
+
cookies: [...v.cookies].sort(),
|
|
65
|
+
}))
|
|
66
|
+
.sort((a, b) => b.pages.length - a.pages.length || a.vendor.localeCompare(b.vendor)),
|
|
67
|
+
otherCookies: [...other.values()].map((c) => ({ name: c.name, party: sites.has(siteOf(c.domain)) ? "first" : "third" })).sort((a, b) => a.name.localeCompare(b.name)),
|
|
68
|
+
};
|
|
69
|
+
}
|
package/src/learn.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { PROVIDERS } from "./parsers.js";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A plan learned from what a site already sends: one event per platform and event name seen in
|
|
5
|
+
* the runs, the parameters it carried,. Parameters sent with every
|
|
6
|
+
* hit of an event become its required parameters. Values are not copied, so nothing a visitor typed
|
|
7
|
+
* ends up in the plan. Returns null when the runs captured no named events.
|
|
8
|
+
*/
|
|
9
|
+
export function learnPlan(site, runs) {
|
|
10
|
+
const byKey = new Map();
|
|
11
|
+
for (const run of runs) {
|
|
12
|
+
for (const hit of run.hits ?? []) {
|
|
13
|
+
if (!hit?.platform || !hit.name || hit.name === "(unnamed)") continue;
|
|
14
|
+
const k = `${hit.platform}:${hit.name}`;
|
|
15
|
+
let ev = byKey.get(k);
|
|
16
|
+
if (!ev) byKey.set(k, (ev = { platform: hit.platform, name: hit.name, hits: 0, params: new Map() }));
|
|
17
|
+
ev.hits++;
|
|
18
|
+
for (const p of Object.keys(hit.params ?? {})) ev.params.set(p, (ev.params.get(p) ?? 0) + 1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
if (!byKey.size) return null;
|
|
22
|
+
const events = [...byKey.values()].map((ev) => {
|
|
23
|
+
const required = [...ev.params].filter(([, n]) => n === ev.hits).map(([p]) => p);
|
|
24
|
+
return {
|
|
25
|
+
platform: ev.platform,
|
|
26
|
+
name: ev.name,
|
|
27
|
+
description: `Learned from what ${PROVIDERS[ev.platform] ?? ev.platform} received on the site.`,
|
|
28
|
+
...(required.length ? { required_params: required } : {}),
|
|
29
|
+
parameters: Object.fromEntries([...ev.params.keys()].map((p) => [p, {}])),
|
|
30
|
+
seen: true,
|
|
31
|
+
};
|
|
32
|
+
});
|
|
33
|
+
return { site, learned: true, consent_mode: true, pages: [], events, user_properties: [] };
|
|
34
|
+
}
|
package/src/redact.js
CHANGED
|
@@ -22,7 +22,7 @@ const SHARED_SECOND_LEVELS = new Set(["com", "co", "org", "net", "gov", "edu", "
|
|
|
22
22
|
const SHARED_SUFFIXES = ["myshopify.com", "github.io", "vercel.app", "pages.dev", "netlify.app", "herokuapp.com", "web.app", "firebaseapp.com", "appspot.com", "wixsite.com", "blogspot.com", "azurewebsites.net", "cloudfront.net", "workers.dev"];
|
|
23
23
|
|
|
24
24
|
/** "www.shop.example.com.tr" -> "example.com.tr", "a.shop.myshopify.com" -> "shop.myshopify.com": the part one owner controls. */
|
|
25
|
-
function siteOf(host) {
|
|
25
|
+
export function siteOf(host) {
|
|
26
26
|
const labels = String(host).toLowerCase().replace(/\.$/, "").split(".");
|
|
27
27
|
const name = labels.join(".");
|
|
28
28
|
const shared = SHARED_SUFFIXES.find((s) => name === s || name.endsWith(`.${s}`));
|