@analitikisler/ai-tag-debugger 0.0.0-stage → 0.5.2
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 +104 -0
- package/LICENSE +21 -0
- package/README.md +327 -2
- package/package.json +56 -4
- package/src/agent.js +191 -0
- package/src/audit.js +100 -0
- package/src/browser.js +579 -0
- package/src/checks.js +213 -0
- package/src/cli.js +155 -0
- package/src/egress.js +134 -0
- package/src/glossary.js +139 -0
- package/src/guard.js +129 -0
- package/src/i18n.js +207 -0
- package/src/journeys.js +138 -0
- package/src/mcp.js +425 -0
- package/src/parsers.js +234 -0
- package/src/providers.js +250 -0
- package/src/redact.js +130 -0
- package/src/report.js +827 -0
package/src/guard.js
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Limits where the audit browser may go: only http(s), only the audited site
|
|
2
|
+
// (plus any hosts the user allows), and, when asked, never private network addresses.
|
|
3
|
+
import { lookup } from "node:dns/promises";
|
|
4
|
+
import { isIP } from "node:net";
|
|
5
|
+
|
|
6
|
+
/** Lowercase host without a leading "www.", so www.example.com and example.com match each other. */
|
|
7
|
+
const baseHost = (host) => host.toLowerCase().replace(/^www\./, "");
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Hosts the browser may navigate to: the audited site and its subdomains, plus
|
|
11
|
+
* each allowed host and its subdomains. Entries may be hosts or full URLs.
|
|
12
|
+
* @param {string} site
|
|
13
|
+
* @param {string[]} [allowedHosts]
|
|
14
|
+
*/
|
|
15
|
+
export function hostScope(site, allowedHosts = []) {
|
|
16
|
+
const bases = [site, ...allowedHosts]
|
|
17
|
+
.map((entry) => String(entry).trim())
|
|
18
|
+
.filter(Boolean)
|
|
19
|
+
.map((entry) => {
|
|
20
|
+
try {
|
|
21
|
+
return baseHost(new URL(entry.includes("://") ? entry : `http://${entry}`).hostname);
|
|
22
|
+
} catch {
|
|
23
|
+
throw new Error(`"${entry}" in allowed_hosts is not a valid host.`);
|
|
24
|
+
}
|
|
25
|
+
})
|
|
26
|
+
.map((h) => h.replace(/^\*\./, ""));
|
|
27
|
+
return {
|
|
28
|
+
hosts: [...new Set(bases)],
|
|
29
|
+
allows(hostname) {
|
|
30
|
+
const h = hostname.toLowerCase();
|
|
31
|
+
return this.hosts.some((b) => h === b || h === `www.${b}` || h.endsWith(`.${b}`));
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function ipv4Private(ip) {
|
|
37
|
+
const [a, b] = ip.split(".").map(Number);
|
|
38
|
+
return (
|
|
39
|
+
a === 0 || a === 10 || a === 127 || a >= 224 ||
|
|
40
|
+
(a === 100 && b >= 64 && b <= 127) || // carrier-grade NAT
|
|
41
|
+
(a === 169 && b === 254) || // link-local, cloud metadata
|
|
42
|
+
(a === 172 && b >= 16 && b <= 31) ||
|
|
43
|
+
(a === 192 && b === 168) ||
|
|
44
|
+
(a === 192 && b === 0) ||
|
|
45
|
+
(a === 198 && (b === 18 || b === 19))
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The 8 groups of an IPv6 address as numbers, including a trailing dotted IPv4 part, or null. */
|
|
50
|
+
function ipv6Groups(ip) {
|
|
51
|
+
let a = ip.toLowerCase().replace(/^\[|\]$/g, "").split("%")[0];
|
|
52
|
+
const v4 = a.match(/(\d+\.\d+\.\d+\.\d+)$/);
|
|
53
|
+
if (v4) {
|
|
54
|
+
const [w, x, y, z] = v4[1].split(".").map(Number);
|
|
55
|
+
a = a.slice(0, -v4[1].length) + `${((w << 8) | x).toString(16)}:${((y << 8) | z).toString(16)}`;
|
|
56
|
+
}
|
|
57
|
+
const [head, tail] = a.includes("::") ? a.split("::") : [a, null];
|
|
58
|
+
const h = head ? head.split(":") : [];
|
|
59
|
+
const t = tail ? tail.split(":") : [];
|
|
60
|
+
const groups = tail === null ? h : [...h, ...Array(8 - h.length - t.length).fill("0"), ...t];
|
|
61
|
+
return groups.length === 8 ? groups.map((g) => parseInt(g || "0", 16)) : null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const v4From = (hi, lo) => `${hi >> 8}.${hi & 255}.${lo >> 8}.${lo & 255}`;
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* True for loopback, private, link-local, carrier-grade NAT and other non-public
|
|
68
|
+
* addresses, including IPv6 forms that carry an IPv4 address inside.
|
|
69
|
+
*/
|
|
70
|
+
export function isPrivateAddress(ip) {
|
|
71
|
+
const v = isIP(String(ip).replace(/^\[|\]$/g, "").split("%")[0]);
|
|
72
|
+
if (v === 4) return ipv4Private(ip);
|
|
73
|
+
if (v !== 6) return false;
|
|
74
|
+
const g = ipv6Groups(ip);
|
|
75
|
+
if (!g) return true;
|
|
76
|
+
const [g0, g1, g2, g3, , g5, g6, g7] = g;
|
|
77
|
+
const zeroTo = (n) => g.slice(0, n).every((x) => x === 0);
|
|
78
|
+
if (zeroTo(8) || (zeroTo(7) && g7 === 1)) return true; // :: and ::1
|
|
79
|
+
if (zeroTo(5) && g5 === 0xffff) return ipv4Private(v4From(g6, g7)); // ::ffff:a.b.c.d
|
|
80
|
+
if (zeroTo(6)) return ipv4Private(v4From(g6, g7)); // ::a.b.c.d (IPv4-compatible)
|
|
81
|
+
if (g0 === 0x64 && g1 === 0xff9b) return ipv4Private(v4From(g6, g7)); // NAT64 64:ff9b::/96 (and the local-use /48)
|
|
82
|
+
if (g0 === 0x2002) return ipv4Private(v4From(g1, g2)); // 6to4
|
|
83
|
+
if (g0 === 0x2001 && g1 === 0) return true; // Teredo: the IPv4 address is obscured, so refuse
|
|
84
|
+
return (g0 & 0xfe00) === 0xfc00 || (g0 & 0xffc0) === 0xfe80 || (g0 & 0xff00) === 0xff00 || (g0 === 0x100 && g1 === 0 && g2 === 0 && g3 === 0); // ULA, link-local, multicast, discard
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Resolves a hostname and reports whether it points at a private address.
|
|
89
|
+
* Results are cached per checker. This is the early, friendly check; the egress
|
|
90
|
+
* proxy (egress.js) is what makes sure the browser only connects to checked addresses.
|
|
91
|
+
*/
|
|
92
|
+
export function privateHostChecker(isBlocked = isPrivateAddress) {
|
|
93
|
+
const cache = new Map();
|
|
94
|
+
return async (hostname) => {
|
|
95
|
+
const host = hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
96
|
+
if (host === "localhost" || host.endsWith(".localhost")) return true;
|
|
97
|
+
if (isIP(host)) return isBlocked(host);
|
|
98
|
+
if (!cache.has(host)) {
|
|
99
|
+
cache.set(
|
|
100
|
+
host,
|
|
101
|
+
lookup(host, { all: true, verbatim: true })
|
|
102
|
+
.then((addrs) => addrs.some((a) => isBlocked(a.address)))
|
|
103
|
+
.catch(() => false), // unresolvable: the browser will fail to load it anyway
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
return cache.get(host);
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Checks a navigation target. Returns an error message, or null when allowed.
|
|
112
|
+
* @param {string} url
|
|
113
|
+
* @param {{ scope?: ReturnType<typeof hostScope>, isPrivate?: (host: string) => Promise<boolean> }} rules
|
|
114
|
+
*/
|
|
115
|
+
export async function navigationError(url, { scope, isPrivate } = {}) {
|
|
116
|
+
let u;
|
|
117
|
+
try {
|
|
118
|
+
u = new URL(url);
|
|
119
|
+
} catch {
|
|
120
|
+
return `"${url}" is not a valid URL.`;
|
|
121
|
+
}
|
|
122
|
+
if (u.protocol === "about:" && u.href === "about:blank") return null;
|
|
123
|
+
if (!["http:", "https:"].includes(u.protocol)) return `Only http and https pages can be opened, not ${u.protocol} URLs.`;
|
|
124
|
+
if (scope && !scope.allows(u.hostname)) {
|
|
125
|
+
return `${u.hostname} is outside the audited site (${scope.hosts.join(", ")}). Stay on the site. If the journey really needs this host (for example a hosted checkout), add it to allowed_hosts.`;
|
|
126
|
+
}
|
|
127
|
+
if (isPrivate && (await isPrivate(u.hostname))) return `${u.hostname} is a private network address, which this server doesn't open.`;
|
|
128
|
+
return null;
|
|
129
|
+
}
|
package/src/i18n.js
ADDED
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
// Report language strings. Add a language by copying the "en" block.
|
|
2
|
+
|
|
3
|
+
const q = (s) => `"${s}"`;
|
|
4
|
+
|
|
5
|
+
const en = {
|
|
6
|
+
name: "English",
|
|
7
|
+
severity: { broken: "Broken", risk: "Risk", warning: "Warning", info: "Info", ok: "OK" },
|
|
8
|
+
ui: {
|
|
9
|
+
title: (host) => `AI Tag Debugger report: ${host}`,
|
|
10
|
+
generated: (at) => `Generated ${at} by ai-tag-debugger · Analitik İşler`,
|
|
11
|
+
summary: "Summary",
|
|
12
|
+
eyebrow: "Tracking QA report",
|
|
13
|
+
paramsShort: "params",
|
|
14
|
+
noParameters: "No parameters.",
|
|
15
|
+
pushMatchHint: "dataLayer pushes in this step with the same event name.",
|
|
16
|
+
noMatchingPush: "No dataLayer push with this event name in this step. The tag may fire from a page view, a click trigger or a hardcoded pixel.",
|
|
17
|
+
otherInStep: "Other dataLayer pushes and network calls",
|
|
18
|
+
noHits: "No tracking events",
|
|
19
|
+
hasProblems: "Has problems",
|
|
20
|
+
stepsShort: "steps",
|
|
21
|
+
eventsShort: "events",
|
|
22
|
+
problemsShort: "problems",
|
|
23
|
+
findings: "Findings",
|
|
24
|
+
what: "What",
|
|
25
|
+
where: "Where",
|
|
26
|
+
causeAndFix: "Cause and fix",
|
|
27
|
+
likelyCause: "Likely cause",
|
|
28
|
+
fix: "Fix",
|
|
29
|
+
captured: "Captured",
|
|
30
|
+
noProblems: "No problems found.",
|
|
31
|
+
noExplanation: "Run without --no-ai for an explanation.",
|
|
32
|
+
matchingPlan: "Matching the plan",
|
|
33
|
+
timeline: "Journeys and event timeline",
|
|
34
|
+
timelineLead: "Every step of every journey. Events are grouped by platform; open one to see its parameters, the dataLayer push behind it and the network request that carried it. Steps with problems are open.",
|
|
35
|
+
completed: "Completed",
|
|
36
|
+
notCompleted: "Not completed",
|
|
37
|
+
step: "step",
|
|
38
|
+
trackingHits: "Tracking hits",
|
|
39
|
+
dataLayer: "dataLayer pushes",
|
|
40
|
+
network: "Network calls",
|
|
41
|
+
platform: "Platform",
|
|
42
|
+
event: "Event",
|
|
43
|
+
parameters: "Parameters",
|
|
44
|
+
consent: "Consent",
|
|
45
|
+
method: "Method",
|
|
46
|
+
status: "Status",
|
|
47
|
+
vendor: "Vendor",
|
|
48
|
+
blockedNote: "answered locally, not sent",
|
|
49
|
+
clickedThrough: "clicked through an overlapping element",
|
|
50
|
+
generatedBy: "Generated by AI Tag Debugger",
|
|
51
|
+
nothing: "None",
|
|
52
|
+
counts: (c) => `${c.broken} broken · ${c.risk} risk · ${c.warning} warning · ${c.info} info · ${c.ok} ok`,
|
|
53
|
+
whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, step "${step}"` : ""}`,
|
|
54
|
+
allGood: "All planned events fired correctly on every journey tested.",
|
|
55
|
+
tabEvents: "Events",
|
|
56
|
+
bodyDropped: "Request body not stored: this audit reached its 2 MB limit for stored request bodies.",
|
|
57
|
+
tabNetwork: "Network requests",
|
|
58
|
+
filterBy: "Filter by source",
|
|
59
|
+
all: "All",
|
|
60
|
+
items: "Items",
|
|
61
|
+
customItemParams: "Custom item parameters",
|
|
62
|
+
itemsCount: (n) => `${n} ${n === 1 ? "item" : "items"}`,
|
|
63
|
+
noItems: "no items",
|
|
64
|
+
source: "Source",
|
|
65
|
+
request: "Request",
|
|
66
|
+
noRequest: "No matching request",
|
|
67
|
+
noPushes: "No dataLayer pushes in this step.",
|
|
68
|
+
noRequests: "No requests to the chosen providers in this step.",
|
|
69
|
+
eventParams: "Event parameters",
|
|
70
|
+
contextParams: "Session, device, page and consent parameters",
|
|
71
|
+
rawRequest: "Raw request",
|
|
72
|
+
rawJson: "Raw JSON",
|
|
73
|
+
inBatch: (i, n) => `${i} of ${n} in one request`,
|
|
74
|
+
impact: "Impact",
|
|
75
|
+
evidence: "Evidence",
|
|
76
|
+
fixSteps: "Fix",
|
|
77
|
+
places: (n) => `${n} ${n === 1 ? "place" : "places"}`,
|
|
78
|
+
alsoCheck: "Not in the findings list, but worth checking",
|
|
79
|
+
providersChecked: "Providers checked:",
|
|
80
|
+
outOfScope: (list, n) => `Out of scope: ${list} (${n} ${n === 1 ? "request" : "requests"} skipped)`,
|
|
81
|
+
skippedInStep: (list, n) => `Only requests to the chosen providers are shown. ${n} out-of-scope ${n === 1 ? "request was" : "requests were"} skipped in this step: ${list}.`,
|
|
82
|
+
timelineLeadV2: "Every step has three tabs: the events it sent, its dataLayer pushes and its network requests. Events and requests can be filtered by source. Steps with problems are open.",
|
|
83
|
+
},
|
|
84
|
+
detail: {
|
|
85
|
+
journeyIncomplete: (note) => `The journey did not reach its goal${note ? `: ${note}` : "."} Events after that point could not be checked.`,
|
|
86
|
+
missingEvent: (platform, name) => `Expected ${platform} event ${q(name)} was never sent during this journey.`,
|
|
87
|
+
missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
|
|
88
|
+
missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
|
|
89
|
+
duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
|
|
90
|
+
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
91
|
+
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
92
|
+
beforeConsentNever: (platform, name) => `${platform} ${q(name)} fired although consent was never given in this journey.`,
|
|
93
|
+
beforeConsent: (platform, name) => `${platform} ${q(name)} fired before the visitor accepted cookies.`,
|
|
94
|
+
pii: (platform, name) => `An email address was sent in plain text with ${q(name)} to ${platform}.`,
|
|
95
|
+
unplanned: (list) => `Events sent that are not in the measurement plan: ${list.join(", ")}.`,
|
|
96
|
+
piiUnhashed: (platform, name, fields) => `${q(name)} sent user data to ${platform} without hashing it (${fields.join(", ")}). Ad platforms expect these values SHA-256 hashed.`,
|
|
97
|
+
piiDataLayer: (fields) => `The dataLayer holds user data that isn't hashed (${fields.join(", ")}). This is a valid setup for Google enhanced conversions, but every other tag and script on the page can read it too. Check that only the tags that should use it do.`,
|
|
98
|
+
plannedOutOfScope: (list) => `${list.length} planned ${list.length === 1 ? "event was" : "events were"} not checked because the provider wasn't selected: ${list.join(", ")}. Add the provider with --providers to check ${list.length === 1 ? "it" : "them"}.`,
|
|
99
|
+
matches: (list) => `${list.length} planned events matched the plan: ${list.join(", ")}.`,
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
const tr = {
|
|
104
|
+
name: "Turkish",
|
|
105
|
+
severity: { broken: "Bozuk", risk: "Risk", warning: "Uyarı", info: "Bilgi", ok: "Tamam" },
|
|
106
|
+
ui: {
|
|
107
|
+
title: (host) => `AI Tag Debugger raporu: ${host}`,
|
|
108
|
+
generated: (at) => `${at} tarihinde ai-tag-debugger ile oluşturuldu · Analitik İşler`,
|
|
109
|
+
summary: "Özet",
|
|
110
|
+
eyebrow: "İzleme QA raporu",
|
|
111
|
+
paramsShort: "parametre",
|
|
112
|
+
noParameters: "Parametre yok.",
|
|
113
|
+
pushMatchHint: "Bu adımda aynı olay adına sahip dataLayer gönderimleri.",
|
|
114
|
+
noMatchingPush: "Bu adımda bu olay adıyla bir dataLayer gönderimi yok. Etiket bir sayfa görüntüleme, tıklama tetikleyicisi ya da sayfaya gömülü bir pikselden tetikleniyor olabilir.",
|
|
115
|
+
otherInStep: "Diğer dataLayer gönderimleri ve ağ çağrıları",
|
|
116
|
+
noHits: "İzleme olayı yok",
|
|
117
|
+
hasProblems: "Sorun var",
|
|
118
|
+
stepsShort: "adım",
|
|
119
|
+
eventsShort: "olay",
|
|
120
|
+
problemsShort: "sorun",
|
|
121
|
+
findings: "Bulgular",
|
|
122
|
+
what: "Ne",
|
|
123
|
+
where: "Nerede",
|
|
124
|
+
causeAndFix: "Neden ve çözüm",
|
|
125
|
+
likelyCause: "Olası neden",
|
|
126
|
+
fix: "Çözüm",
|
|
127
|
+
captured: "Yakalanan",
|
|
128
|
+
noProblems: "Sorun bulunamadı.",
|
|
129
|
+
noExplanation: "Açıklama için --no-ai olmadan çalıştırın.",
|
|
130
|
+
matchingPlan: "Plana uygun olanlar",
|
|
131
|
+
timeline: "Yolculuklar ve olay zaman çizelgesi",
|
|
132
|
+
timelineLead: "Her yolculuğun her adımı. Olaylar platforma göre gruplanır; bir olayı açınca parametrelerini, arkasındaki dataLayer gönderimini ve onu taşıyan ağ isteğini görürsünüz. Sorunlu adımlar açık gösterilir.",
|
|
133
|
+
completed: "Tamamlandı",
|
|
134
|
+
notCompleted: "Tamamlanmadı",
|
|
135
|
+
step: "adım",
|
|
136
|
+
trackingHits: "İzleme istekleri",
|
|
137
|
+
dataLayer: "dataLayer gönderimleri",
|
|
138
|
+
network: "Ağ çağrıları",
|
|
139
|
+
platform: "Platform",
|
|
140
|
+
event: "Olay",
|
|
141
|
+
parameters: "Parametreler",
|
|
142
|
+
consent: "Onay",
|
|
143
|
+
method: "Metot",
|
|
144
|
+
status: "Durum",
|
|
145
|
+
vendor: "Sağlayıcı",
|
|
146
|
+
blockedNote: "yerel olarak yanıtlandı, gönderilmedi",
|
|
147
|
+
clickedThrough: "üstündeki bir öğeye rağmen tıklandı",
|
|
148
|
+
generatedBy: "AI Tag Debugger ile oluşturuldu",
|
|
149
|
+
nothing: "Yok",
|
|
150
|
+
counts: (c) => `${c.broken} bozuk · ${c.risk} risk · ${c.warning} uyarı · ${c.info} bilgi · ${c.ok} tamam`,
|
|
151
|
+
whereStep: (journey, viewport, step) => `${journey} (${viewport})${step ? `, "${step}" adımı` : ""}`,
|
|
152
|
+
allGood: "Test edilen tüm yolculuklarda plandaki tüm olaylar doğru çalıştı.",
|
|
153
|
+
tabEvents: "Olaylar",
|
|
154
|
+
bodyDropped: "İstek gövdesi saklanmadı: bu denetim, saklanan istek gövdeleri için 2 MB sınırına ulaştı.",
|
|
155
|
+
tabNetwork: "Ağ istekleri",
|
|
156
|
+
filterBy: "Kaynağa göre filtrele",
|
|
157
|
+
all: "Tümü",
|
|
158
|
+
items: "Ürünler",
|
|
159
|
+
customItemParams: "Özel ürün parametreleri",
|
|
160
|
+
itemsCount: (n) => `${n} ürün`,
|
|
161
|
+
noItems: "ürün yok",
|
|
162
|
+
source: "Kaynak",
|
|
163
|
+
request: "İstek",
|
|
164
|
+
noRequest: "Eşleşen istek yok",
|
|
165
|
+
noPushes: "Bu adımda dataLayer gönderimi yok.",
|
|
166
|
+
noRequests: "Bu adımda seçilen sağlayıcılara istek yok.",
|
|
167
|
+
eventParams: "Olay parametreleri",
|
|
168
|
+
contextParams: "Oturum, cihaz, sayfa ve onay parametreleri",
|
|
169
|
+
rawRequest: "Ham istek",
|
|
170
|
+
rawJson: "Ham JSON",
|
|
171
|
+
inBatch: (i, n) => `tek istekte ${n} olaydan ${i}.`,
|
|
172
|
+
impact: "Etkisi",
|
|
173
|
+
evidence: "Kanıt",
|
|
174
|
+
fixSteps: "Çözüm",
|
|
175
|
+
places: (n) => `${n} yer`,
|
|
176
|
+
alsoCheck: "Bulgu listesinde olmayan, ama kontrol edilmesi gerekenler",
|
|
177
|
+
providersChecked: "Kontrol edilen sağlayıcılar:",
|
|
178
|
+
outOfScope: (list, n) => `Kapsam dışı: ${list} (${n} istek atlandı)`,
|
|
179
|
+
skippedInStep: (list, n) => `Yalnızca seçilen sağlayıcıların istekleri gösterilir. Bu adımda kapsam dışı ${n} istek atlandı: ${list}.`,
|
|
180
|
+
timelineLeadV2: "Her adımda üç sekme var: o adımda gönderilen olaylar, dataLayer gönderimleri ve ağ istekleri. Olayları ve istekleri kaynağa göre filtreleyebilirsiniz. Sorunlu adımlar açık gösterilir.",
|
|
181
|
+
},
|
|
182
|
+
detail: {
|
|
183
|
+
journeyIncomplete: (note) => `Yolculuk hedefine ulaşmadı${note ? `: ${note}` : "."} Bu noktadan sonraki olaylar kontrol edilemedi.`,
|
|
184
|
+
missingEvent: (platform, name) => `Beklenen ${platform} olayı ${q(name)} bu yolculuk boyunca hiç gönderilmedi.`,
|
|
185
|
+
missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
|
|
186
|
+
missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
|
|
187
|
+
duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
|
|
188
|
+
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
189
|
+
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
190
|
+
beforeConsentNever: (platform, name) => `${platform} ${q(name)}, bu yolculukta hiç onay verilmemesine rağmen tetiklendi.`,
|
|
191
|
+
beforeConsent: (platform, name) => `${platform} ${q(name)}, ziyaretçi çerezleri kabul etmeden önce tetiklendi.`,
|
|
192
|
+
pii: (platform, name) => `${q(name)} ile birlikte ${platform} platformuna düz metin olarak bir e-posta adresi gönderildi.`,
|
|
193
|
+
unplanned: (list) => `Ölçüm planında olmayan olaylar gönderildi: ${list.join(", ")}.`,
|
|
194
|
+
piiUnhashed: (platform, name, fields) => `${q(name)}, ${platform} platformuna kullanıcı verisini karma (hash) yapmadan gönderdi (${fields.join(", ")}). Reklam platformları bu değerleri SHA-256 ile karma yapılmış bekler.`,
|
|
195
|
+
piiDataLayer: (fields) => `dataLayer karma (hash) yapılmamış kullanıcı verisi içeriyor (${fields.join(", ")}). Bu, Google gelişmiş dönüşümleri için geçerli bir kurulum, ancak sayfadaki diğer tüm etiketler ve betikler de bu veriyi okuyabilir. Yalnızca kullanması gereken etiketlerin kullandığını kontrol edin.`,
|
|
196
|
+
plannedOutOfScope: (list) => `Plandaki ${list.length} olay, sağlayıcısı seçilmediği için kontrol edilmedi: ${list.join(", ")}. Kontrol etmek için sağlayıcıyı --providers ile ekleyin.`,
|
|
197
|
+
matches: (list) => `Plandaki ${list.length} olay plana uygun çalıştı: ${list.join(", ")}.`,
|
|
198
|
+
},
|
|
199
|
+
};
|
|
200
|
+
|
|
201
|
+
export const LANGUAGES = { en, tr };
|
|
202
|
+
|
|
203
|
+
export function messages(lang = "en") {
|
|
204
|
+
const m = LANGUAGES[lang];
|
|
205
|
+
if (!m) throw new Error(`Unsupported language "${lang}". Use: ${Object.keys(LANGUAGES).join(", ")}`);
|
|
206
|
+
return m;
|
|
207
|
+
}
|
package/src/journeys.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// Ready-made journeys: a goal for the AI in plain language, plus the events
|
|
2
|
+
// (GA4 recommended events and Meta standard events) a correct setup sends on the way.
|
|
3
|
+
// They let you audit a site without writing a config or a measurement plan.
|
|
4
|
+
|
|
5
|
+
const ecommerce = ["currency", "value", "items"];
|
|
6
|
+
|
|
7
|
+
export const JOURNEY_PRESETS = {
|
|
8
|
+
purchase: {
|
|
9
|
+
goal: "Accept cookies, open any product, add it to the cart, go to checkout and place the order with the test or demo payment option until a confirmation page shows. Never enter real payment details.",
|
|
10
|
+
events: [
|
|
11
|
+
{ platform: "ga4", name: "view_item", required_params: ecommerce },
|
|
12
|
+
{ platform: "ga4", name: "add_to_cart", required_params: ecommerce },
|
|
13
|
+
{ platform: "ga4", name: "begin_checkout", required_params: ecommerce },
|
|
14
|
+
{ platform: "ga4", name: "purchase", required_params: ["transaction_id", ...ecommerce] },
|
|
15
|
+
{ platform: "meta", name: "AddToCart" },
|
|
16
|
+
{ platform: "meta", name: "InitiateCheckout" },
|
|
17
|
+
{ platform: "meta", name: "Purchase", required_params: ["value", "currency"] },
|
|
18
|
+
],
|
|
19
|
+
},
|
|
20
|
+
add_to_cart: {
|
|
21
|
+
goal: "Accept cookies, open any product and add it to the cart. Stop once the cart shows the product.",
|
|
22
|
+
events: [
|
|
23
|
+
{ platform: "ga4", name: "view_item", required_params: ecommerce },
|
|
24
|
+
{ platform: "ga4", name: "add_to_cart", required_params: ecommerce },
|
|
25
|
+
{ platform: "meta", name: "AddToCart" },
|
|
26
|
+
],
|
|
27
|
+
},
|
|
28
|
+
sign_up: {
|
|
29
|
+
goal: "Accept cookies, find the sign-up or registration form, and create an account with the test data. Stop when the site confirms the account or asks to verify the email.",
|
|
30
|
+
events: [
|
|
31
|
+
{ platform: "ga4", name: "sign_up" },
|
|
32
|
+
{ platform: "meta", name: "CompleteRegistration" },
|
|
33
|
+
],
|
|
34
|
+
},
|
|
35
|
+
lead: {
|
|
36
|
+
goal: "Accept cookies, find a contact, quote or demo request form, fill it in with the test data and submit it. Stop when the site confirms the submission.",
|
|
37
|
+
events: [
|
|
38
|
+
{ platform: "ga4", name: "generate_lead" },
|
|
39
|
+
{ platform: "meta", name: "Lead" },
|
|
40
|
+
],
|
|
41
|
+
},
|
|
42
|
+
search: {
|
|
43
|
+
goal: "Accept cookies, use the site search to search for a common product or topic, and open the first result.",
|
|
44
|
+
events: [
|
|
45
|
+
{ platform: "ga4", name: "search", required_params: ["search_term"] },
|
|
46
|
+
{ platform: "meta", name: "Search" },
|
|
47
|
+
],
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const ALIASES = { checkout: "purchase", order: "purchase", cart: "add_to_cart", signup: "sign_up", register: "sign_up", registration: "sign_up", contact: "lead", form: "lead" };
|
|
52
|
+
|
|
53
|
+
/** The preset for a name like "purchase" or "signup", or null. */
|
|
54
|
+
export function presetFor(name) {
|
|
55
|
+
const id = ALIASES[name?.toLowerCase()] ?? name?.toLowerCase();
|
|
56
|
+
return JOURNEY_PRESETS[id] ? { id, ...JOURNEY_PRESETS[id] } : null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Adds a journey's preset events to a plan (a copy): events the plan already
|
|
61
|
+
* has are expected on this journey too; missing ones are added for this journey.
|
|
62
|
+
*/
|
|
63
|
+
export function withPresetEvents(plan, journeyId, preset) {
|
|
64
|
+
const events = plan.events.map((e) => ({ ...e }));
|
|
65
|
+
for (const pe of preset.events) {
|
|
66
|
+
const existing = events.find((e) => e.platform === pe.platform && e.name === pe.name);
|
|
67
|
+
if (!existing) events.push({ ...pe, journeys: [journeyId] });
|
|
68
|
+
else if (existing.journeys && !existing.journeys.includes(journeyId)) existing.journeys = [...existing.journeys, journeyId];
|
|
69
|
+
}
|
|
70
|
+
return { ...plan, events };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds the journeys and plan for a run from the config plus command-line picks.
|
|
75
|
+
* @param {{ config?: any, plan?: any, picks?: string[], goals?: string[], events?: string[] }} opts
|
|
76
|
+
* picks: journey ids from the config or preset names. goals: journeys described in plain language.
|
|
77
|
+
* events: only check these event names.
|
|
78
|
+
*/
|
|
79
|
+
export function planRun({ config = {}, plan, picks = [], goals = [], events = [] }) {
|
|
80
|
+
// Copied, because the loops below add journeys to events.
|
|
81
|
+
let runPlan = { ...(plan ?? { events: [] }), events: (plan?.events ?? []).map((e) => ({ ...e })) };
|
|
82
|
+
const configJourneys = config.journeys ?? [];
|
|
83
|
+
const journeys = [];
|
|
84
|
+
|
|
85
|
+
for (const pick of picks) {
|
|
86
|
+
const fromConfig = configJourneys.find((j) => j.id === pick);
|
|
87
|
+
if (fromConfig) {
|
|
88
|
+
journeys.push(fromConfig);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
const preset = presetFor(pick);
|
|
92
|
+
if (!preset) {
|
|
93
|
+
const known = [...configJourneys.map((j) => j.id), ...Object.keys(JOURNEY_PRESETS)];
|
|
94
|
+
throw new Error(`Unknown journey "${pick}". Use one of: ${[...new Set(known)].join(", ")}, or describe it with --goal.`);
|
|
95
|
+
}
|
|
96
|
+
if (journeys.some((j) => j.id === preset.id)) continue;
|
|
97
|
+
journeys.push({ id: preset.id, goal: preset.goal });
|
|
98
|
+
runPlan = withPresetEvents(runPlan, preset.id, preset);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
goals.forEach((goal, i) => {
|
|
102
|
+
const id = goals.length > 1 ? `custom-${i + 1}` : "custom";
|
|
103
|
+
journeys.push({ id, goal });
|
|
104
|
+
runPlan = expectEvents(runPlan, id, events);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
if (!picks.length && !goals.length) journeys.push(...configJourneys);
|
|
108
|
+
|
|
109
|
+
return { journeys, plan: onlyEvents(runPlan, events) };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Keeps only the picked events in a plan (all of them when none are picked). Names match any platform, ignoring case. */
|
|
113
|
+
export function onlyEvents(plan, events = []) {
|
|
114
|
+
if (!events.length) return plan;
|
|
115
|
+
const wanted = new Set(events.map((e) => e.toLowerCase()));
|
|
116
|
+
return { ...plan, events: plan.events.filter((e) => wanted.has(e.name.toLowerCase())) };
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A journey you describe yourself has no known events, so the events you pick are
|
|
121
|
+
* expected on it: as GA4 events, unless the plan already lists them.
|
|
122
|
+
*/
|
|
123
|
+
export function expectEvents(plan, journeyId, names) {
|
|
124
|
+
const events = plan.events.map((e) => ({ ...e }));
|
|
125
|
+
for (const name of names) {
|
|
126
|
+
const existing = events.filter((e) => e.name.toLowerCase() === name.toLowerCase());
|
|
127
|
+
if (!existing.length) events.push({ platform: "ga4", name, journeys: [journeyId] });
|
|
128
|
+
for (const e of existing) if (e.journeys && !e.journeys.includes(journeyId)) e.journeys = [...e.journeys, journeyId];
|
|
129
|
+
}
|
|
130
|
+
return { ...plan, events };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/** With an events filter, keeps findings about those events plus the journey-wide ones (consent setup, incomplete journey). */
|
|
134
|
+
export function filterFindings(findings, events = []) {
|
|
135
|
+
if (!events.length) return findings;
|
|
136
|
+
const wanted = new Set(events.map((e) => e.toLowerCase()));
|
|
137
|
+
return findings.filter((f) => (f.event ? wanted.has(f.event.toLowerCase()) : f.check !== "unplanned_events"));
|
|
138
|
+
}
|