@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/checks.js
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Deterministic checks that compare captured hits with the measurement plan.
|
|
2
|
+
// They run without any API call; the AI model only explains the results.
|
|
3
|
+
|
|
4
|
+
import { messages } from "./i18n.js";
|
|
5
|
+
|
|
6
|
+
const NON_GOOGLE_AD_PLATFORMS = new Set(["meta", "tiktok"]);
|
|
7
|
+
const EMAIL = /[A-Z0-9._%+-]+(@|%40)[A-Z0-9.-]+\.[A-Z]{2,}/i;
|
|
8
|
+
const SEVERITY_ORDER = { broken: 0, risk: 1, warning: 2, info: 3, ok: 4 };
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {{ platform: string, name: string, required_params?: string[], journeys?: string[], allow_multiple?: boolean }} PlanEvent
|
|
12
|
+
* @typedef {{ consent_mode?: boolean, events: PlanEvent[] }} Plan
|
|
13
|
+
* @typedef {{ severity: "broken"|"risk"|"warning"|"info"|"ok", check: string, journey: string, viewport: string, platform?: string, event?: string, step?: string, detail: string, evidence?: string }} Finding
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const key = (platform, name) => `${platform}:${name}`;
|
|
17
|
+
|
|
18
|
+
function expectedIn(planEvent, journeyId) {
|
|
19
|
+
return !planEvent.journeys || planEvent.journeys.includes(journeyId);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isConsentCommand(entry, kind) {
|
|
23
|
+
return Array.isArray(entry.value) && entry.value[0] === "consent" && entry.value[1] === kind;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The step at which the visitor gave ad consent, from the run or from Consent Mode updates. */
|
|
27
|
+
export function consentGrantedStep(run) {
|
|
28
|
+
const fromUpdate = run.dataLayerLog.find(
|
|
29
|
+
(e) => isConsentCommand(e, "update") && e.value[2]?.ad_storage === "granted",
|
|
30
|
+
);
|
|
31
|
+
const candidates = [run.consentStep, fromUpdate?.step].filter((s) => s !== null && s !== undefined);
|
|
32
|
+
return candidates.length ? Math.min(...candidates) : null;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @param {Plan} plan
|
|
37
|
+
* @param {{ journeyId: string, viewport: string, completed: boolean, note?: string, hits: any[], steps: any[], consentStep: number|null, dataLayerLog: any[] }} run
|
|
38
|
+
* @param {{ lang?: string }} [opts] Report language for the finding texts.
|
|
39
|
+
* @returns {Finding[]}
|
|
40
|
+
*/
|
|
41
|
+
export function checkRun(plan, run, { lang = "en" } = {}) {
|
|
42
|
+
const d = messages(lang).detail;
|
|
43
|
+
const findings = [];
|
|
44
|
+
const base = { journey: run.journeyId, viewport: run.viewport };
|
|
45
|
+
// Platforms are compared in lower case ("GA4" in a plan is ga4). Planned events for
|
|
46
|
+
// providers the user chose not to check are left out, and listed in one info line.
|
|
47
|
+
plan = { ...plan, events: plan.events.map((e) => ({ ...e, platform: String(e.platform ?? "").toLowerCase() })) };
|
|
48
|
+
if (run.providers) {
|
|
49
|
+
const skipped = plan.events.filter((e) => !run.providers.includes(e.platform) && expectedIn(e, run.journeyId));
|
|
50
|
+
plan = { ...plan, events: plan.events.filter((e) => run.providers.includes(e.platform)) };
|
|
51
|
+
if (skipped.length) findings.push({ ...base, severity: "info", check: "planned_out_of_scope", detail: d.plannedOutOfScope(skipped.map((e) => key(e.platform, e.name))) });
|
|
52
|
+
}
|
|
53
|
+
const stepLabel = (i) => run.steps[i]?.label ?? `step ${i}`;
|
|
54
|
+
const planByKey = new Map(plan.events.map((e) => [key(e.platform, e.name), e]));
|
|
55
|
+
|
|
56
|
+
if (!run.completed) {
|
|
57
|
+
findings.push({ ...base, severity: "warning", check: "journey_incomplete", detail: d.journeyIncomplete(run.note) });
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Expected events that never fired.
|
|
61
|
+
for (const ev of plan.events) {
|
|
62
|
+
if (!expectedIn(ev, run.journeyId)) continue;
|
|
63
|
+
const fired = run.hits.some((h) => h.platform === ev.platform && h.name === ev.name);
|
|
64
|
+
if (!fired) {
|
|
65
|
+
findings.push({
|
|
66
|
+
...base,
|
|
67
|
+
severity: run.completed ? "broken" : "info",
|
|
68
|
+
check: "missing_event",
|
|
69
|
+
platform: ev.platform,
|
|
70
|
+
event: ev.name,
|
|
71
|
+
detail: run.completed ? d.missingEvent(ev.platform, ev.name) : d.missingEventEarly(ev.name),
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Required parameters missing on events that did fire (reported once per event).
|
|
77
|
+
const paramsReported = new Set();
|
|
78
|
+
for (const hit of run.hits) {
|
|
79
|
+
const ev = planByKey.get(key(hit.platform, hit.name));
|
|
80
|
+
if (!ev?.required_params?.length) continue;
|
|
81
|
+
const missing = ev.required_params.filter((p) => hit.params[p] === undefined || hit.params[p] === "");
|
|
82
|
+
const reportKey = `${key(hit.platform, hit.name)}|${missing.join(",")}`;
|
|
83
|
+
if (missing.length && !paramsReported.has(reportKey)) {
|
|
84
|
+
paramsReported.add(reportKey);
|
|
85
|
+
findings.push({
|
|
86
|
+
...base,
|
|
87
|
+
severity: "broken",
|
|
88
|
+
check: "missing_params",
|
|
89
|
+
platform: hit.platform,
|
|
90
|
+
event: hit.name,
|
|
91
|
+
step: stepLabel(hit.step),
|
|
92
|
+
detail: d.missingParams(hit.name, missing),
|
|
93
|
+
evidence: JSON.stringify(hit.params),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The same event sent more than once by a single action.
|
|
99
|
+
const perStep = new Map();
|
|
100
|
+
for (const hit of run.hits) {
|
|
101
|
+
const k = `${hit.step}|${key(hit.platform, hit.name)}`;
|
|
102
|
+
perStep.set(k, [...(perStep.get(k) ?? []), hit]);
|
|
103
|
+
}
|
|
104
|
+
for (const [, hits] of perStep) {
|
|
105
|
+
const ev = planByKey.get(key(hits[0].platform, hits[0].name));
|
|
106
|
+
if (hits.length < 2 || ev?.allow_multiple) continue;
|
|
107
|
+
findings.push({
|
|
108
|
+
...base,
|
|
109
|
+
severity: "warning",
|
|
110
|
+
check: "duplicate",
|
|
111
|
+
platform: hits[0].platform,
|
|
112
|
+
event: hits[0].name,
|
|
113
|
+
step: stepLabel(hits[0].step),
|
|
114
|
+
detail: d.duplicate(hits[0].name, hits.length),
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// Consent.
|
|
119
|
+
const granted = consentGrantedStep(run);
|
|
120
|
+
const firstHitStep = run.hits.length ? Math.min(...run.hits.map((h) => h.step)) : null;
|
|
121
|
+
if (plan.consent_mode) {
|
|
122
|
+
const defaultEntry = run.dataLayerLog.find((e) => isConsentCommand(e, "default"));
|
|
123
|
+
if (!defaultEntry) {
|
|
124
|
+
findings.push({ ...base, severity: "risk", check: "consent_default_missing", detail: d.consentDefaultMissing() });
|
|
125
|
+
} else if (firstHitStep !== null && defaultEntry.step > firstHitStep) {
|
|
126
|
+
findings.push({ ...base, severity: "risk", check: "consent_default_late", detail: d.consentDefaultLate() });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
const beforeConsent = new Map();
|
|
130
|
+
for (const hit of run.hits) {
|
|
131
|
+
const early = granted === null || hit.step < granted;
|
|
132
|
+
if (!early) continue;
|
|
133
|
+
const adPlatform = NON_GOOGLE_AD_PLATFORMS.has(hit.platform);
|
|
134
|
+
const googleClaimsGranted = hit.consent?.ad_storage === "granted" || hit.consent?.analytics_storage === "granted";
|
|
135
|
+
if (adPlatform || googleClaimsGranted) {
|
|
136
|
+
const k = key(hit.platform, hit.name);
|
|
137
|
+
if (!beforeConsent.has(k)) beforeConsent.set(k, hit);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
for (const [, hit] of beforeConsent) {
|
|
141
|
+
findings.push({
|
|
142
|
+
...base,
|
|
143
|
+
severity: "risk",
|
|
144
|
+
check: "before_consent",
|
|
145
|
+
platform: hit.platform,
|
|
146
|
+
event: hit.name,
|
|
147
|
+
step: stepLabel(hit.step),
|
|
148
|
+
detail: granted === null ? d.beforeConsentNever(hit.platform, hit.name) : d.beforeConsent(hit.platform, hit.name),
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// Personal data in hits.
|
|
153
|
+
const piiSeen = new Set();
|
|
154
|
+
for (const hit of run.hits) {
|
|
155
|
+
let url = hit.url;
|
|
156
|
+
try {
|
|
157
|
+
url = decodeURIComponent(hit.url);
|
|
158
|
+
} catch { /* malformed % sequence, e.g. "50%": search the raw URL */ }
|
|
159
|
+
const haystack = url + " " + JSON.stringify(hit.params);
|
|
160
|
+
if (piiSeen.has(key(hit.platform, hit.name))) continue;
|
|
161
|
+
// User data for ad matching is redacted at capture; userData.plain lists the fields that were not hashed.
|
|
162
|
+
if (hit.userData?.plain?.length) {
|
|
163
|
+
piiSeen.add(key(hit.platform, hit.name));
|
|
164
|
+
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.piiUnhashed(hit.platform, hit.name, hit.userData.plain) });
|
|
165
|
+
} else if (EMAIL.test(haystack)) {
|
|
166
|
+
piiSeen.add(key(hit.platform, hit.name));
|
|
167
|
+
findings.push({ ...base, severity: "risk", check: "pii", platform: hit.platform, event: hit.name, step: stepLabel(hit.step), detail: d.pii(hit.platform, hit.name) });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// Unhashed user data in the dataLayer. Valid for GTM enhanced conversions (Google hashes it
|
|
172
|
+
// before sending), but every other tag on the page can read it too.
|
|
173
|
+
const plainPush = run.dataLayerLog.find((e) => e.userData?.plain?.length);
|
|
174
|
+
if (plainPush) {
|
|
175
|
+
findings.push({ ...base, severity: "warning", check: "pii_datalayer", step: stepLabel(plainPush.step), detail: d.piiDataLayer(plainPush.userData.plain) });
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Events that are not in the plan.
|
|
179
|
+
const unplanned = [...new Set(run.hits.filter((h) => !planByKey.has(key(h.platform, h.name))).map((h) => key(h.platform, h.name)))];
|
|
180
|
+
if (unplanned.length) {
|
|
181
|
+
findings.push({ ...base, severity: "info", check: "unplanned_events", detail: d.unplanned(unplanned) });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Planned events that fired cleanly.
|
|
185
|
+
const problemKeys = new Set(findings.filter((f) => f.event).map((f) => key(f.platform, f.event)));
|
|
186
|
+
const okKeys = [...new Set(run.hits.map((h) => key(h.platform, h.name)))].filter((k) => planByKey.has(k) && !problemKeys.has(k));
|
|
187
|
+
if (okKeys.length) {
|
|
188
|
+
findings.push({ ...base, severity: "ok", check: "matches_plan", detail: d.matches(okKeys) });
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
return findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function summarize(findings) {
|
|
195
|
+
const counts = { broken: 0, risk: 0, warning: 0, info: 0, ok: 0 };
|
|
196
|
+
for (const f of findings) counts[f.severity] += 1;
|
|
197
|
+
return counts;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* Merges the same problem seen on several journeys or viewports into one
|
|
202
|
+
* finding with a list of places, so the report reads as a list of problems.
|
|
203
|
+
*/
|
|
204
|
+
export function groupFindings(findings) {
|
|
205
|
+
const groups = new Map();
|
|
206
|
+
for (const f of findings) {
|
|
207
|
+
const k = f.severity === "ok" ? `ok|${f.journey}|${f.viewport}` : [f.severity, f.check, f.platform, f.event, f.detail].join("|");
|
|
208
|
+
const where = { journey: f.journey, viewport: f.viewport, step: f.step };
|
|
209
|
+
if (groups.has(k)) groups.get(k).where.push(where);
|
|
210
|
+
else groups.set(k, { severity: f.severity, check: f.check, platform: f.platform, event: f.event, detail: f.detail, evidence: f.evidence, where: [where] });
|
|
211
|
+
}
|
|
212
|
+
return [...groups.values()].sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]);
|
|
213
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parseArgs } from "node:util";
|
|
4
|
+
import { loadConfig, runAudit } from "./audit.js";
|
|
5
|
+
import { LANGUAGES, messages } from "./i18n.js";
|
|
6
|
+
import { PROVIDERS } from "./providers.js";
|
|
7
|
+
import { JOURNEY_PRESETS } from "./journeys.js";
|
|
8
|
+
import { PROVIDERS as TRACKING_PROVIDERS, DEFAULT_PROVIDERS } from "./parsers.js";
|
|
9
|
+
|
|
10
|
+
const USAGE = `ai-tag-debugger: an open-source AI agent that QA-tests your analytics and ad tracking.
|
|
11
|
+
|
|
12
|
+
Usage:
|
|
13
|
+
ai-tag-debugger run --site <url> --journey purchase [options] Audit ready-made journeys, no config needed.
|
|
14
|
+
ai-tag-debugger run --site <url> --goal "<what to do>" Audit a journey you describe in plain language.
|
|
15
|
+
ai-tag-debugger run --config <file> [options] Run an audit from a config file.
|
|
16
|
+
ai-tag-debugger mcp [--http] [--port <n>] Start the MCP server for Claude, ChatGPT, Gemini and other chat apps.
|
|
17
|
+
|
|
18
|
+
Run options:
|
|
19
|
+
--site <url> The site to audit (overrides "site" in the config).
|
|
20
|
+
--config <file> Audit config (JSON). See examples/demo.config.json.
|
|
21
|
+
--journey <id> Run this journey (repeatable): a journey id from the config, or a ready-made
|
|
22
|
+
journey: ${Object.keys(JOURNEY_PRESETS).join(", ")}.
|
|
23
|
+
--goal "<text>" Run a journey you describe in plain language (repeatable).
|
|
24
|
+
--events <names> Only check these events, comma separated, e.g. purchase,add_to_cart.
|
|
25
|
+
--max-steps <n> Most AI steps per journey (default 30). Lower it to cap API usage.
|
|
26
|
+
--out <dir> Where to write report.html, report.md, timeline.csv and capture.json (default: reports/<timestamp>).
|
|
27
|
+
--provider <name> AI provider: ${Object.keys(PROVIDERS).join(", ")} (default: whichever API key is set).
|
|
28
|
+
--model <id> Model id (defaults: ${Object.entries(PROVIDERS).filter(([, p]) => p.defaultModel).map(([k, p]) => `${k} ${p.defaultModel}`).join(", ")}).
|
|
29
|
+
--base-url <url> API address for --provider openai-compatible (Kimi, GLM, DeepSeek, OpenRouter, Ollama...).
|
|
30
|
+
--lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")} (default: en, or "language" in the config).
|
|
31
|
+
--providers <list> Tracking providers to check, comma separated (default: ${DEFAULT_PROVIDERS.join(",")}).
|
|
32
|
+
Choose from ${Object.keys(TRACKING_PROVIDERS).join(", ")}. Requests to the
|
|
33
|
+
others are still answered locally, but only counted ("providers" in the config).
|
|
34
|
+
--viewport <name> Only run this viewport: desktop or mobile (repeatable).
|
|
35
|
+
--allow-host <host> Let journeys visit this host too, e.g. a hosted checkout (repeatable). By default the
|
|
36
|
+
browser stays on the audited site and its subdomains ("allowed_hosts" in the config).
|
|
37
|
+
--scripted Replay each journey's fixed "steps" instead of letting the AI browse.
|
|
38
|
+
--no-ai Use no AI model at all (requires --scripted). The report then has no explanations.
|
|
39
|
+
--fail-on <level> Exit with code 1 when findings reach this level: broken, risk, warning or never (default: broken).
|
|
40
|
+
--headed Show the browser window.
|
|
41
|
+
--keep-bodies Store tracking requests as sent. By default, emails, phone numbers and user ids sent
|
|
42
|
+
for ad matching are redacted in the report and capture.json ("keep_bodies" in the config).
|
|
43
|
+
--no-branding Leave the Analitik İşler logo and credit line out of the reports ("branding": false in the config).
|
|
44
|
+
|
|
45
|
+
MCP options:
|
|
46
|
+
--http Serve over Streamable HTTP instead of stdio (for apps that connect to a URL).
|
|
47
|
+
--port <n> HTTP port (default 8787).
|
|
48
|
+
--host <addr> HTTP host (default 127.0.0.1).
|
|
49
|
+
--token <secret> Secret URL path segment, at least 16 characters (default: random, printed at start).
|
|
50
|
+
--allow-private-network Over HTTP, let audits open localhost and private network addresses (blocked by default).
|
|
51
|
+
--reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports).
|
|
52
|
+
--public-url <url> The HTTPS address your tunnel or host gives this server, so audits can link to their reports.
|
|
53
|
+
|
|
54
|
+
-h, --help Show this help.
|
|
55
|
+
|
|
56
|
+
API keys: set one of ${Object.values(PROVIDERS).map((p) => p.env).join(", ")}.
|
|
57
|
+
Any OpenAI-compatible API: --provider openai-compatible --base-url <url> --model <id>, with the key in OPENAI_COMPATIBLE_API_KEY.`;
|
|
58
|
+
|
|
59
|
+
async function main() {
|
|
60
|
+
const { values, positionals } = parseArgs({
|
|
61
|
+
allowPositionals: true,
|
|
62
|
+
options: {
|
|
63
|
+
config: { type: "string" },
|
|
64
|
+
site: { type: "string" },
|
|
65
|
+
goal: { type: "string", multiple: true },
|
|
66
|
+
events: { type: "string", multiple: true },
|
|
67
|
+
"max-steps": { type: "string" },
|
|
68
|
+
out: { type: "string" },
|
|
69
|
+
journey: { type: "string", multiple: true },
|
|
70
|
+
viewport: { type: "string", multiple: true },
|
|
71
|
+
providers: { type: "string", multiple: true },
|
|
72
|
+
"allow-host": { type: "string", multiple: true },
|
|
73
|
+
"allow-private-network": { type: "boolean", default: false },
|
|
74
|
+
scripted: { type: "boolean", default: false },
|
|
75
|
+
"no-ai": { type: "boolean", default: false },
|
|
76
|
+
provider: { type: "string" },
|
|
77
|
+
model: { type: "string" },
|
|
78
|
+
"base-url": { type: "string" },
|
|
79
|
+
lang: { type: "string" },
|
|
80
|
+
"fail-on": { type: "string", default: "broken" },
|
|
81
|
+
headed: { type: "boolean", default: false },
|
|
82
|
+
"no-branding": { type: "boolean", default: false },
|
|
83
|
+
"keep-bodies": { type: "boolean", default: false },
|
|
84
|
+
http: { type: "boolean", default: false },
|
|
85
|
+
port: { type: "string" },
|
|
86
|
+
host: { type: "string" },
|
|
87
|
+
token: { type: "string" },
|
|
88
|
+
reports: { type: "string" },
|
|
89
|
+
"public-url": { type: "string" },
|
|
90
|
+
help: { type: "boolean", short: "h", default: false },
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
const command = positionals[0];
|
|
94
|
+
|
|
95
|
+
if (command === "mcp" && !values.help) {
|
|
96
|
+
const { startMcp } = await import("./mcp.js");
|
|
97
|
+
await startMcp({ http: values.http, port: values.port ? Number(values.port) : undefined, host: values.host, token: values.token, reportsDir: values.reports, publicUrl: values["public-url"], allowPrivateNetwork: values["allow-private-network"] });
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (values.help || command !== "run" || (!values.config && !values.site)) {
|
|
101
|
+
console.log(USAGE);
|
|
102
|
+
process.exit(values.help ? 0 : 2);
|
|
103
|
+
}
|
|
104
|
+
if (values["no-ai"] && !values.scripted) {
|
|
105
|
+
throw new Error("--no-ai needs --scripted: without an AI model, journeys must have fixed steps.");
|
|
106
|
+
}
|
|
107
|
+
const levels = ["broken", "risk", "warning"];
|
|
108
|
+
const failOn = values["fail-on"];
|
|
109
|
+
if (failOn !== "never" && !levels.includes(failOn)) throw new Error(`--fail-on must be one of ${levels.join(", ")}, never.`);
|
|
110
|
+
|
|
111
|
+
const loaded = values.config ? await loadConfig(values.config) : { config: {}, plan: null, gtmContainer: null };
|
|
112
|
+
if (values.site) loaded.config = { ...loaded.config, site: values.site };
|
|
113
|
+
if (values["keep-bodies"]) loaded.config = { ...loaded.config, keep_bodies: true };
|
|
114
|
+
if (values["allow-host"]?.length) loaded.config = { ...loaded.config, allowed_hosts: [...(loaded.config.allowed_hosts ?? []), ...values["allow-host"]] };
|
|
115
|
+
try {
|
|
116
|
+
new URL(loaded.config.site);
|
|
117
|
+
} catch {
|
|
118
|
+
throw new Error(`"${loaded.config.site}" is not a full URL. Use e.g. https://www.example.com`);
|
|
119
|
+
}
|
|
120
|
+
const maxSteps = values["max-steps"] ? Number(values["max-steps"]) : undefined;
|
|
121
|
+
if (maxSteps !== undefined && !(Number.isInteger(maxSteps) && maxSteps > 0)) throw new Error("--max-steps must be a positive whole number.");
|
|
122
|
+
const result = await runAudit({
|
|
123
|
+
...loaded,
|
|
124
|
+
journeys: values.journey,
|
|
125
|
+
goals: values.goal,
|
|
126
|
+
events: (values.events ?? []).flatMap((e) => e.split(",")).map((e) => e.trim()).filter(Boolean),
|
|
127
|
+
maxSteps,
|
|
128
|
+
viewports: values.viewport,
|
|
129
|
+
providers: values.providers?.length ? values.providers.flatMap((p) => p.split(",")) : undefined,
|
|
130
|
+
scripted: values.scripted,
|
|
131
|
+
ai: !values["no-ai"],
|
|
132
|
+
provider: values.provider,
|
|
133
|
+
model: values.model,
|
|
134
|
+
baseUrl: values["base-url"],
|
|
135
|
+
lang: values.lang,
|
|
136
|
+
headed: values.headed,
|
|
137
|
+
branding: values["no-branding"] ? false : undefined,
|
|
138
|
+
outDir: values.out,
|
|
139
|
+
log: (msg) => console.log(msg),
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
console.log(`\n${messages(result.lang).ui.counts(result.counts)}`);
|
|
143
|
+
const rel = path.relative(process.cwd(), result.reportPath);
|
|
144
|
+
console.log(`Report: ${rel.startsWith("..") ? result.reportPath : rel}`);
|
|
145
|
+
|
|
146
|
+
if (failOn !== "never") {
|
|
147
|
+
const threshold = levels.indexOf(failOn);
|
|
148
|
+
if (levels.slice(0, threshold + 1).some((l) => result.counts[l] > 0)) process.exitCode = 1;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
main().catch((err) => {
|
|
153
|
+
console.error(`Error: ${err.message}`);
|
|
154
|
+
process.exit(2);
|
|
155
|
+
});
|
package/src/egress.js
ADDED
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
// A small local proxy that all browser traffic goes through when private addresses
|
|
2
|
+
// are blocked (MCP over HTTP). It resolves each host once, refuses private
|
|
3
|
+
// addresses, and connects to exactly the address it checked. Chromium never
|
|
4
|
+
// resolves the host itself, so a DNS answer that changes between the check and
|
|
5
|
+
// the connection (DNS rebinding) can't reach a private address. It covers plain
|
|
6
|
+
// HTTP, HTTPS and WebSocket (ws:// and wss://) traffic alike.
|
|
7
|
+
import { createServer, request as httpRequest } from "node:http";
|
|
8
|
+
import { connect as netConnect, isIP } from "node:net";
|
|
9
|
+
import { lookup as dnsLookup } from "node:dns/promises";
|
|
10
|
+
import { isPrivateAddress } from "./guard.js";
|
|
11
|
+
|
|
12
|
+
const HOP_HEADERS = ["proxy-connection", "proxy-authorization", "connection", "keep-alive", "te", "trailer", "transfer-encoding", "upgrade"];
|
|
13
|
+
|
|
14
|
+
/** Splits "host:port" or "[v6]:port". */
|
|
15
|
+
function splitHostPort(value, defaultPort) {
|
|
16
|
+
const m = value.match(/^\[([^\]]+)\](?::(\d+))?$/) ?? value.match(/^([^:]+)(?::(\d+))?$/);
|
|
17
|
+
if (!m) return null;
|
|
18
|
+
return { host: m[1], port: Number(m[2] ?? defaultPort) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @param {{ isBlocked?: (ip: string) => boolean, lookup?: (host: string) => Promise<{ address: string, family: number }[]> }} [opts]
|
|
23
|
+
* isBlocked: which resolved addresses to refuse (default: private ones). lookup: injectable for tests.
|
|
24
|
+
* @returns {Promise<{ url: string, refused: string[], close: () => Promise<void> }>}
|
|
25
|
+
*/
|
|
26
|
+
export async function startEgressProxy(opts = {}) {
|
|
27
|
+
const isBlocked = opts.isBlocked ?? isPrivateAddress;
|
|
28
|
+
const lookup = opts.lookup ?? ((host) => dnsLookup(host, { all: true, verbatim: true }));
|
|
29
|
+
const refused = [];
|
|
30
|
+
|
|
31
|
+
/** The one address to connect to, or throws when the host is refused or unresolvable. */
|
|
32
|
+
async function resolve(host) {
|
|
33
|
+
const bare = host.replace(/^\[|\]$/g, "");
|
|
34
|
+
const addrs = isIP(bare) ? [{ address: bare, family: isIP(bare) }] : await lookup(bare);
|
|
35
|
+
if (!addrs.length) throw new Error(`${host} did not resolve`);
|
|
36
|
+
if (bare === "localhost" || bare.endsWith(".localhost") || addrs.some((a) => isBlocked(a.address))) {
|
|
37
|
+
refused.push(host);
|
|
38
|
+
throw Object.assign(new Error(`${host} is a private network address`), { refused: true });
|
|
39
|
+
}
|
|
40
|
+
return addrs[0];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const sockets = new Set();
|
|
44
|
+
const track = (s) => {
|
|
45
|
+
sockets.add(s);
|
|
46
|
+
s.on("close", () => sockets.delete(s));
|
|
47
|
+
s.on("error", () => s.destroy());
|
|
48
|
+
return s;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
const server = createServer(async (req, res) => {
|
|
52
|
+
// Plain http:// requests arrive with an absolute URL.
|
|
53
|
+
let target;
|
|
54
|
+
try {
|
|
55
|
+
target = new URL(req.url);
|
|
56
|
+
if (target.protocol !== "http:") throw new Error("only http");
|
|
57
|
+
} catch {
|
|
58
|
+
res.writeHead(400).end();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
let addr;
|
|
62
|
+
try {
|
|
63
|
+
addr = await resolve(target.hostname);
|
|
64
|
+
} catch (err) {
|
|
65
|
+
res.writeHead(err.refused ? 403 : 502).end();
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
const headers = { ...req.headers };
|
|
69
|
+
for (const h of HOP_HEADERS) delete headers[h];
|
|
70
|
+
const upstream = httpRequest(
|
|
71
|
+
{ host: addr.address, family: addr.family, port: Number(target.port) || 80, method: req.method, path: target.pathname + target.search, headers, setHost: false },
|
|
72
|
+
(up) => {
|
|
73
|
+
res.writeHead(up.statusCode ?? 502, up.headers);
|
|
74
|
+
up.pipe(res);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
upstream.on("error", () => (res.headersSent ? res.destroy() : res.writeHead(502).end()));
|
|
78
|
+
req.pipe(upstream);
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
// HTTPS and WebSockets: a CONNECT tunnel to the checked address.
|
|
82
|
+
server.on("connect", async (req, client, head) => {
|
|
83
|
+
track(client);
|
|
84
|
+
const target = splitHostPort(req.url, 443);
|
|
85
|
+
let addr;
|
|
86
|
+
try {
|
|
87
|
+
if (!target) throw new Error("bad target");
|
|
88
|
+
addr = await resolve(target.host);
|
|
89
|
+
} catch (err) {
|
|
90
|
+
client.end(`HTTP/1.1 ${err.refused ? "403 Forbidden" : "502 Bad Gateway"}\r\n\r\n`);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const upstream = track(netConnect({ host: addr.address, port: target.port, family: addr.family }, () => {
|
|
94
|
+
client.write("HTTP/1.1 200 Connection Established\r\n\r\n");
|
|
95
|
+
if (head?.length) upstream.write(head);
|
|
96
|
+
upstream.pipe(client);
|
|
97
|
+
client.pipe(upstream);
|
|
98
|
+
}));
|
|
99
|
+
upstream.on("error", () => client.destroy());
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
// A ws:// upgrade sent as a plain proxied request, in case the browser doesn't tunnel it.
|
|
103
|
+
server.on("upgrade", async (req, client, head) => {
|
|
104
|
+
track(client);
|
|
105
|
+
let target;
|
|
106
|
+
let addr;
|
|
107
|
+
try {
|
|
108
|
+
target = new URL(req.url);
|
|
109
|
+
addr = await resolve(target.hostname);
|
|
110
|
+
} catch {
|
|
111
|
+
client.end("HTTP/1.1 403 Forbidden\r\n\r\n");
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
const upstream = track(netConnect({ host: addr.address, port: Number(target.port) || 80, family: addr.family }, () => {
|
|
115
|
+
const headers = Object.entries(req.headers).filter(([k]) => !k.startsWith("proxy-")).map(([k, v]) => `${k}: ${v}`);
|
|
116
|
+
upstream.write(`${req.method} ${target.pathname}${target.search} HTTP/1.1\r\n${headers.join("\r\n")}\r\n\r\n`);
|
|
117
|
+
if (head?.length) upstream.write(head);
|
|
118
|
+
upstream.pipe(client);
|
|
119
|
+
client.pipe(upstream);
|
|
120
|
+
}));
|
|
121
|
+
upstream.on("error", () => client.destroy());
|
|
122
|
+
});
|
|
123
|
+
server.on("connection", track);
|
|
124
|
+
|
|
125
|
+
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
|
126
|
+
return {
|
|
127
|
+
url: `http://127.0.0.1:${server.address().port}`,
|
|
128
|
+
refused,
|
|
129
|
+
close: async () => {
|
|
130
|
+
for (const s of sockets) s.destroy();
|
|
131
|
+
await new Promise((r) => server.close(() => r()));
|
|
132
|
+
},
|
|
133
|
+
};
|
|
134
|
+
}
|
package/src/glossary.js
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// What each tracking request parameter means, for the "?" tips in the report's Network tab.
|
|
2
|
+
// Keys are the raw request parameter names; [en, tr].
|
|
3
|
+
|
|
4
|
+
const G = {
|
|
5
|
+
// GA4 (Measurement Protocol v2, as sent by gtag.js)
|
|
6
|
+
v: ["Protocol version. Always 2 for GA4.", "Protokol sürümü. GA4 için her zaman 2."],
|
|
7
|
+
tid: ["Measurement ID (G-…): which GA4 property the request goes to.", "Ölçüm kimliği (G-…). İsteğin hangi GA4 mülküne gittiğini gösterir."],
|
|
8
|
+
gtm: ["Hash of the Google tag or GTM container that sent it.", "Etiketi gönderen Google etiketi ya da GTM kapsayıcısının özeti."],
|
|
9
|
+
_p: ["Page load ID. Every request from the same page load has the same value.", "Sayfa yükleme kimliği. Aynı sayfadaki tüm istekler aynı değeri taşır."],
|
|
10
|
+
gcs: ["Consent Mode state. G1xy: x is ad_storage, y is analytics_storage (1 granted, 0 denied).", "Consent Mode durumu. G1xy: x reklam, y analitik depolama (1 izin var, 0 yok)."],
|
|
11
|
+
gcd: ["Consent Mode detail: the default and update value of each consent type.", "Consent Mode ayrıntısı: her izin türü için varsayılan ve güncelleme değerleri."],
|
|
12
|
+
npa: ["Non-personalized ads. 1 means personalization is off.", "Kişiselleştirilmemiş reklam. 1 ise kişiselleştirme kapalı."],
|
|
13
|
+
dma: ["EU Digital Markets Act flag.", "AB Dijital Pazarlar Yasası işareti."],
|
|
14
|
+
dma_cps: ["Consent purposes sent under the Digital Markets Act.", "Dijital Pazarlar Yasası kapsamında gönderilen izin amaçları."],
|
|
15
|
+
cid: ["Client ID: the anonymous ID of this browser (the _ga cookie).", "Client ID: tarayıcıyı tanımlayan anonim kimlik (_ga çerezi)."],
|
|
16
|
+
uid: ["User ID set by the site for a logged-in user.", "Site tarafından giriş yapmış kullanıcıya verilen kullanıcı kimliği."],
|
|
17
|
+
ul: ["Browser language.", "Tarayıcı dili."],
|
|
18
|
+
sr: ["Screen resolution.", "Ekran çözünürlüğü."],
|
|
19
|
+
_s: ["Sequence number of this request within the page load.", "Bu sayfa yüklemesindeki istek sıra numarası."],
|
|
20
|
+
sid: ["Session ID.", "Oturum kimliği."],
|
|
21
|
+
sct: ["Number of sessions for this user.", "Bu kullanıcının oturum sayısı."],
|
|
22
|
+
seg: ["Engaged session (1 = yes).", "Etkileşimli oturum (1 = evet)."],
|
|
23
|
+
dl: ["Page URL (page_location).", "Sayfa adresi (page_location)."],
|
|
24
|
+
dr: ["Previous page (page_referrer).", "Önceki sayfa (page_referrer)."],
|
|
25
|
+
dt: ["Page title (page_title).", "Sayfa başlığı (page_title)."],
|
|
26
|
+
en: ["Event name.", "Olay adı (event_name)."],
|
|
27
|
+
_et: ["Engagement time in milliseconds.", "Etkileşim süresi, milisaniye."],
|
|
28
|
+
cu: ["Currency.", "Para birimi (currency)."],
|
|
29
|
+
_ee: ["Enhanced conversions or user-provided data is enabled.", "Gelişmiş dönüşümler ya da kullanıcı verisi açık."],
|
|
30
|
+
_fv: ["First visit: this is the user's first session.", "İlk ziyaret: kullanıcının ilk oturumu."],
|
|
31
|
+
_ss: ["Session start: this request starts a new session.", "Oturum başlangıcı: bu istek yeni bir oturum başlatır."],
|
|
32
|
+
_nsi: ["New session ID was created.", "Yeni oturum kimliği oluşturuldu."],
|
|
33
|
+
_dbg: ["Debug mode is on (shows in GA4 DebugView).", "Hata ayıklama modu açık (GA4 DebugView'da görünür)."],
|
|
34
|
+
tfd: ["Time from page load to this request, in milliseconds.", "Sayfa yüklemesinden bu isteğe kadar geçen süre, milisaniye."],
|
|
35
|
+
are: ["Ads remarketing enabled.", "Reklam yeniden pazarlaması açık."],
|
|
36
|
+
frm: ["Request sent from inside a frame.", "İstek bir çerçeve içinden gönderildi."],
|
|
37
|
+
pscdl: ["Privacy sandbox cookie deprecation label.", "Privacy Sandbox çerez kullanımdan kaldırma etiketi."],
|
|
38
|
+
tag_exp: ["Google tag experiments this request is part of.", "Bu isteğin dahil olduğu Google etiketi deneyleri."],
|
|
39
|
+
// Meta Pixel
|
|
40
|
+
id: ["Meta Pixel ID.", "Meta piksel kimliği."],
|
|
41
|
+
ev: ["Meta event name.", "Meta olay adı."],
|
|
42
|
+
rl: ["Previous page (referrer).", "Önceki sayfa (referrer)."],
|
|
43
|
+
if: ["Whether the request came from inside an iframe.", "İstek bir iframe içinden mi geldi."],
|
|
44
|
+
ts: ["Time sent (Unix milliseconds).", "Gönderim zamanı (Unix ms)."],
|
|
45
|
+
sw: ["Screen width.", "Ekran genişliği."],
|
|
46
|
+
sh: ["Screen height.", "Ekran yüksekliği."],
|
|
47
|
+
ec: ["Number of events the pixel has sent on this page.", "Bu sayfada pikselin gönderdiği olay sayısı."],
|
|
48
|
+
o: ["Pixel setting flags.", "Piksel ayar bayrakları."],
|
|
49
|
+
fbp: ["Meta browser ID (the _fbp cookie).", "Meta tarayıcı kimliği (_fbp çerezi)."],
|
|
50
|
+
fbc: ["Meta click ID (the _fbc cookie, from fbclid).", "Meta tıklama kimliği (_fbc çerezi, fbclid'den)."],
|
|
51
|
+
eid: ["Event ID, used to deduplicate against the server-side (Conversions API) event.", "Olay kimliği. Sunucu tarafı (CAPI) olayla eşleştirmek için kullanılır."],
|
|
52
|
+
it: ["When the pixel loaded.", "Pikselin yüklendiği zaman."],
|
|
53
|
+
coo: ["Whether third-party cookies are off.", "Üçüncü taraf çerezler kapalı mı."],
|
|
54
|
+
rqm: ["Request method.", "İstek yöntemi."],
|
|
55
|
+
r: ["Pixel release channel.", "Piksel sürüm kanalı."],
|
|
56
|
+
a: ["Integration the pixel was installed with (e.g. tmgoogletagmanager).", "Pikselin kurulduğu entegrasyon (ör. tmgoogletagmanager)."],
|
|
57
|
+
udff: ["Hashed user data for advanced matching.", "Gelişmiş eşleştirme için karma (hash) kullanıcı verisi."],
|
|
58
|
+
// TikTok Pixel
|
|
59
|
+
event: ["TikTok event name.", "TikTok olay adı."],
|
|
60
|
+
pixel_code: ["TikTok Pixel ID.", "TikTok piksel kimliği."],
|
|
61
|
+
event_id: ["Event ID, used to deduplicate against the Events API.", "Olay kimliği. Events API ile eşleştirme için."],
|
|
62
|
+
"context.page.url": ["Page URL.", "Sayfa adresi."],
|
|
63
|
+
"context.page.referrer": ["Previous page.", "Önceki sayfa."],
|
|
64
|
+
"context.user.ttp": ["TikTok browser ID (the _ttp cookie).", "TikTok tarayıcı kimliği (_ttp çerezi)."],
|
|
65
|
+
"context.pixel.code": ["TikTok Pixel ID.", "TikTok piksel kimliği."],
|
|
66
|
+
"context.ad.callback": ["TikTok click ID (ttclid).", "TikTok tıklama kimliği (ttclid)."],
|
|
67
|
+
timestamp: ["Time sent.", "Gönderim zamanı."],
|
|
68
|
+
// Google Ads
|
|
69
|
+
label: ["Conversion label: which conversion action this is.", "Dönüşüm etiketi: hangi dönüşüm işlemi olduğunu gösterir."],
|
|
70
|
+
value: ["Conversion value.", "Dönüşüm değeri."],
|
|
71
|
+
currency_code: ["Currency.", "Para birimi."],
|
|
72
|
+
oid: ["Order (transaction) ID, used to deduplicate conversions.", "Sipariş (işlem) kimliği. Dönüşümleri tekilleştirmek için."],
|
|
73
|
+
gclaw: ["Google click ID (gclid) from the ad click.", "Reklam tıklamasından gelen Google tıklama kimliği (gclid)."],
|
|
74
|
+
gcl_ctr: ["Number of conversions counted for this click.", "Bu tıklama için sayılan dönüşüm sayısı."],
|
|
75
|
+
url: ["Page URL.", "Sayfa adresi."],
|
|
76
|
+
ref: ["Previous page.", "Önceki sayfa."],
|
|
77
|
+
guid: ["Whether Google user ID matching is on.", "Google kullanıcı kimliği eşleştirmesi açık mı."],
|
|
78
|
+
rnd: ["Random number to stop caching.", "Önbelleğe almayı önlemek için rastgele sayı."],
|
|
79
|
+
em: ["Hashed email for enhanced conversions.", "Gelişmiş dönüşümler için karma (hash) e-posta."],
|
|
80
|
+
// X Pixel
|
|
81
|
+
txn_id: ["X Pixel ID.", "X piksel kimliği."],
|
|
82
|
+
events: ["Events in this request.", "Bu istekteki olaylar."],
|
|
83
|
+
tw_sale_amount: ["Conversion value.", "Dönüşüm değeri."],
|
|
84
|
+
tw_order_quantity: ["Quantity.", "Adet."],
|
|
85
|
+
tw_document_href: ["Page URL.", "Sayfa adresi."],
|
|
86
|
+
p_id: ["Pixel type.", "Piksel türü."],
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const PATTERNS = [
|
|
90
|
+
[/^ep\.(.+)$/, (m) => [`Text event parameter: ${m[1]}.`, `Metin olay parametresi: ${m[1]}.`]],
|
|
91
|
+
[/^epn\.(.+)$/, (m) => [`Number event parameter: ${m[1]}.`, `Sayısal olay parametresi: ${m[1]}.`]],
|
|
92
|
+
[/^up\.(.+)$/, (m) => [`Text user property: ${m[1]}.`, `Metin kullanıcı özelliği: ${m[1]}.`]],
|
|
93
|
+
[/^upn\.(.+)$/, (m) => [`Number user property: ${m[1]}.`, `Sayısal kullanıcı özelliği: ${m[1]}.`]],
|
|
94
|
+
[/^pr(\d+)$/, (m) => [`Item ${m[1]}, fields separated by ~. Decoded below.`, `${m[1]}. ürün, ~ ile ayrılmış alanlar. Çözülmüş hali aşağıda.`]],
|
|
95
|
+
[/^cd\[(.+)\]$/, (m) => [`Custom data: ${m[1]}.`, `Özel veri: ${m[1]}.`]],
|
|
96
|
+
[/^ud\[(.+)\]$/, (m) => [`Advanced matching user data: ${m[1]} (should be hashed).`, `Gelişmiş eşleştirme kullanıcı verisi: ${m[1]} (karma olmalı).`]],
|
|
97
|
+
[/^properties\.(.+)$/, (m) => [`Event property: ${m[1]}.`, `Olay özelliği: ${m[1]}.`]],
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
/** The tip for a request parameter in the report language, or null. */
|
|
101
|
+
export function paramTip(key, lang = "en") {
|
|
102
|
+
const i = lang === "tr" ? 1 : 0;
|
|
103
|
+
if (Object.hasOwn(G, key)) return G[key][i];
|
|
104
|
+
for (const [re, fn] of PATTERNS) {
|
|
105
|
+
const m = key.match(re);
|
|
106
|
+
if (m) return fn(m)[i];
|
|
107
|
+
}
|
|
108
|
+
return null;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Parameters that describe the event itself; the rest (session, device, page, consent) are collapsed.
|
|
112
|
+
const EVENT_KEYS = /^(en|ev|event|label|value|currency_code|oid|cu|ep\..*|epn\..*|pr\d+|cd\[.*\]|properties\..*|eid|event_id|events|tw_sale_amount|tw_order_quantity)$/;
|
|
113
|
+
export const isEventParam = (key) => EVENT_KEYS.test(key);
|
|
114
|
+
|
|
115
|
+
// GA4 item string fields (pr1=…): two-letter keys, plus kN/vN custom item parameters.
|
|
116
|
+
const ITEM_FIELDS = {
|
|
117
|
+
id: "item_id", nm: "item_name", br: "item_brand", ca: "item_category", c2: "item_category2", c3: "item_category3", c4: "item_category4", c5: "item_category5",
|
|
118
|
+
va: "item_variant", pr: "price", qt: "quantity", cp: "coupon", ds: "discount", af: "affiliation", lp: "index", ln: "item_list_name", li: "item_list_id", lo: "location_id",
|
|
119
|
+
pi: "promotion_id", pn: "promotion_name", cn: "creative_name", cs: "creative_slot",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* Decodes a GA4 item string ("nmShoe~id42~pr10~qt1~k0size~v0XL") into
|
|
124
|
+
* { fields: [[item_name, "Shoe"], …], custom: [["size", "XL"]] }.
|
|
125
|
+
*/
|
|
126
|
+
export function decodeGa4Item(s) {
|
|
127
|
+
const fields = [];
|
|
128
|
+
const names = {};
|
|
129
|
+
const values = {};
|
|
130
|
+
for (const part of String(s).split("~")) {
|
|
131
|
+
const m = part.match(/^([a-z][a-z0-9])(.*)$/);
|
|
132
|
+
if (!m) continue;
|
|
133
|
+
const [, k, v] = m;
|
|
134
|
+
if (/^k\d$/.test(k)) names[k[1]] = v;
|
|
135
|
+
else if (/^v\d$/.test(k)) values[k[1]] = v;
|
|
136
|
+
else fields.push([ITEM_FIELDS[k] ?? k, v]);
|
|
137
|
+
}
|
|
138
|
+
return { fields, custom: Object.entries(values).map(([n, v]) => [names[n] ?? `custom_${n}`, v]) };
|
|
139
|
+
}
|