@analitikisler/ai-tag-debugger 0.8.2 → 0.10.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 +25 -0
- package/README.md +174 -47
- package/README.tr.md +305 -178
- package/package.json +5 -2
- package/src/agent.js +1 -1
- package/src/audit.js +27 -13
- package/src/checks.js +123 -1
- package/src/cli.js +116 -71
- package/src/discover.js +148 -0
- package/src/help.js +104 -0
- package/src/i18n.js +22 -0
- package/src/journeys.js +87 -8
- package/src/mcp.js +52 -11
- package/src/plan-html.js +76 -0
- package/src/plan.js +14 -0
- package/src/providers.js +1 -1
- package/src/replay.js +47 -0
- package/src/report.js +34 -10
- package/src/sheet.js +321 -0
- package/src/workspace.js +77 -0
package/src/discover.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { AuditSession } from "./browser.js";
|
|
2
|
+
import { runAgentJourney, StepTimeout } from "./agent.js";
|
|
3
|
+
|
|
4
|
+
/** The goal of the browsing part: one page of each type, nothing submitted. */
|
|
5
|
+
export const DISCOVER_GOAL = `Explore this site to draft its analytics measurement plan. You are not completing a purchase or any other task: visit ONE example of each kind of page that exists here and look at what a visitor can do on it, then call finish with success true.
|
|
6
|
+
Visit, where the site has them (a small site may have only a few; that is fine, don't look for pages that aren't linked): the home page, one category or listing page, one product, item or article page, the cart (add one item first if there is an add-to-cart button), the first checkout step, the search results (search for something the site sells), and the sign-up, login, contact or newsletter forms.
|
|
7
|
+
Rules: never pay, never submit a form with personal details, never open more than one page of the same kind, and accept the cookie banner when it appears. Stop as soon as every kind of page above has been seen once.`;
|
|
8
|
+
|
|
9
|
+
const PLAN_SYSTEM = `You are a senior digital analytics consultant writing a GA4 measurement plan for a website that may have no tracking yet. You get the pages an explorer visited (their type, address, visible text and interactive elements) and any tracking hits the site already sent.
|
|
10
|
+
|
|
11
|
+
Propose the events this site should track:
|
|
12
|
+
- Recommended GA4 events where they fit (page_view, view_item_list, view_item, select_item, add_to_cart, view_cart, begin_checkout, purchase, search, sign_up, login, generate_lead, ...), with GA4's recommended parameters.
|
|
13
|
+
- Custom events for interactions specific to this site that a business would want to measure (for example a size guide, a store locator, a video, a configurator, a filter, a download). Name them in snake_case and mark them custom.
|
|
14
|
+
- For each event: what the visitor does, the page type and element that triggers it (quote the element's visible label), its parameters (standard and custom) with one realistic example value taken from the pages when possible, and which parameters are required.
|
|
15
|
+
- Suggested user properties (for example customer_type, logged_in, membership_level), with a description and example.
|
|
16
|
+
Only propose what the pages show is possible. Prefer fewer, useful events over many trivial ones.`;
|
|
17
|
+
|
|
18
|
+
const str = { type: "string" };
|
|
19
|
+
const PLAN_SCHEMA = {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
pages: { type: "array", items: { type: "object", properties: { type: str, url: str }, required: ["type", "url"], additionalProperties: false } },
|
|
23
|
+
events: {
|
|
24
|
+
type: "array",
|
|
25
|
+
items: {
|
|
26
|
+
type: "object",
|
|
27
|
+
properties: {
|
|
28
|
+
name: str,
|
|
29
|
+
custom: { type: "boolean" },
|
|
30
|
+
description: str,
|
|
31
|
+
trigger_page: str,
|
|
32
|
+
trigger_element: str,
|
|
33
|
+
parameters: {
|
|
34
|
+
type: "array",
|
|
35
|
+
items: {
|
|
36
|
+
type: "object",
|
|
37
|
+
properties: { name: str, custom: { type: "boolean" }, required: { type: "boolean" }, description: str, example: str },
|
|
38
|
+
required: ["name", "custom", "required", "description", "example"],
|
|
39
|
+
additionalProperties: false,
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
required: ["name", "custom", "description", "trigger_page", "trigger_element", "parameters"],
|
|
44
|
+
additionalProperties: false,
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
user_properties: {
|
|
48
|
+
type: "array",
|
|
49
|
+
items: { type: "object", properties: { name: str, description: str, example: str }, required: ["name", "description", "example"], additionalProperties: false },
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
required: ["pages", "events", "user_properties"],
|
|
53
|
+
additionalProperties: false,
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
/** Page types the plan reports on; a site that has fewer of them (a two-page site) is normal, not a failure. */
|
|
57
|
+
export const PAGE_TYPES = ["home", "listing", "detail", "search", "cart", "checkout", "signup", "login", "contact", "newsletter"];
|
|
58
|
+
|
|
59
|
+
/** Which of PAGE_TYPES the plan's pages cover, and which are absent. Unrecognised type names are ignored. */
|
|
60
|
+
export function pageTypeCoverage(pages = []) {
|
|
61
|
+
const norm = (t) => String(t ?? "").toLowerCase().replace(/[\s_-]+/g, "");
|
|
62
|
+
const aliases = { homepage: "home", category: "listing", product: "detail", item: "detail", article: "detail", register: "signup", registration: "signup", basket: "cart", lead: "contact", form: "contact" };
|
|
63
|
+
const found = new Set(pages.map((p) => aliases[norm(p.type)] ?? norm(p.type)));
|
|
64
|
+
return { found: PAGE_TYPES.filter((t) => found.has(t)), absent: PAGE_TYPES.filter((t) => !found.has(t)) };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const MAX_PAGES = 15;
|
|
68
|
+
const cut = (s, n) => (s && s.length > n ? `${s.slice(0, n)}…` : s ?? "");
|
|
69
|
+
|
|
70
|
+
/** One entry per address the explorer saw, keeping the last (fullest) view of each. */
|
|
71
|
+
export function pagesSeen(snapshots) {
|
|
72
|
+
const byUrl = new Map();
|
|
73
|
+
for (const s of snapshots) {
|
|
74
|
+
if (!s?.url || s.url === "about:blank") continue;
|
|
75
|
+
const key = s.url.split("#")[0];
|
|
76
|
+
byUrl.delete(key);
|
|
77
|
+
byUrl.set(key, { url: key, title: s.title, elements: cut(s.elements, 4000), text: cut(s.text, 1500) });
|
|
78
|
+
}
|
|
79
|
+
return [...byUrl.values()].slice(-MAX_PAGES);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Turns the model's answer into the plan file: the audit's fields plus descriptions, triggers, parameters and user properties. */
|
|
83
|
+
export function toPlan(site, answer, hits = []) {
|
|
84
|
+
const seen = new Set(hits.filter((h) => h.platform === "ga4").map((h) => h.name));
|
|
85
|
+
return {
|
|
86
|
+
site,
|
|
87
|
+
consent_mode: true,
|
|
88
|
+
pages: answer.pages ?? [],
|
|
89
|
+
events: (answer.events ?? []).map((ev) => {
|
|
90
|
+
const params = ev.parameters ?? [];
|
|
91
|
+
const required = params.filter((p) => p.required).map((p) => p.name);
|
|
92
|
+
return {
|
|
93
|
+
platform: "ga4",
|
|
94
|
+
name: ev.name,
|
|
95
|
+
...(ev.custom ? { custom: true } : {}),
|
|
96
|
+
description: ev.description,
|
|
97
|
+
trigger: { page: ev.trigger_page, element: ev.trigger_element },
|
|
98
|
+
...(required.length ? { required_params: required } : {}),
|
|
99
|
+
parameters: Object.fromEntries(params.map((p) => [p.name, { ...(p.custom ? { custom: true } : {}), description: p.description, example: p.example }])),
|
|
100
|
+
...(seen.has(ev.name) ? { seen: true } : {}),
|
|
101
|
+
};
|
|
102
|
+
}),
|
|
103
|
+
user_properties: answer.user_properties ?? [],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Lets the AI explore the site (one page per type), then asks it for a measurement plan.
|
|
109
|
+
* @param {{ site: string, provider: any, maxSteps?: number, headed?: boolean, allowedHosts?: string[], viewport?: string, log?: (msg: string) => void }} opts
|
|
110
|
+
*/
|
|
111
|
+
export async function discoverPlan(opts) {
|
|
112
|
+
const log = opts.log ?? (() => {});
|
|
113
|
+
const session = new AuditSession({ viewport: opts.viewport ?? "desktop", site: opts.site, allowedHosts: opts.allowedHosts, headless: !opts.headed });
|
|
114
|
+
const snapshots = [];
|
|
115
|
+
let outcome;
|
|
116
|
+
try {
|
|
117
|
+
await session.start();
|
|
118
|
+
const startError = await session.act({ action: "goto", url: opts.site, reason: `Open ${opts.site}` });
|
|
119
|
+
if (startError) throw new Error(`Could not open ${opts.site}: ${startError}`);
|
|
120
|
+
const snapshot = session.snapshot.bind(session);
|
|
121
|
+
session.snapshot = async () => {
|
|
122
|
+
const s = await snapshot();
|
|
123
|
+
snapshots.push(s);
|
|
124
|
+
return s;
|
|
125
|
+
};
|
|
126
|
+
outcome = await runAgentJourney(session, { id: "discover", goal: DISCOVER_GOAL }, { provider: opts.provider, maxSteps: opts.maxSteps ?? 25, log }).catch((err) => {
|
|
127
|
+
if (err instanceof StepTimeout) return { completed: false, note: err.message };
|
|
128
|
+
throw err;
|
|
129
|
+
});
|
|
130
|
+
} finally {
|
|
131
|
+
await session.close();
|
|
132
|
+
}
|
|
133
|
+
const pages = pagesSeen(snapshots);
|
|
134
|
+
if (!pages.length) throw new Error("The explorer saw no pages, so there is nothing to plan from.");
|
|
135
|
+
log(`\nExplored ${pages.length} pages${outcome.completed ? "" : ` (${outcome.note})`}. Drafting the plan…`);
|
|
136
|
+
const existing = [...new Set(session.hits.map((h) => `${h.platform}:${h.name}`))];
|
|
137
|
+
const result = await opts.provider.json({
|
|
138
|
+
system: PLAN_SYSTEM,
|
|
139
|
+
texts: [`Site: ${opts.site}`, `Pages visited:\n${JSON.stringify(pages, null, 1)}`, `Tracking hits the site already sends: ${existing.length ? existing.join(", ") : "none"}`],
|
|
140
|
+
schema: PLAN_SCHEMA,
|
|
141
|
+
});
|
|
142
|
+
if (result.error) throw new Error(`The model did not return a plan (${result.error}).`);
|
|
143
|
+
const plan = toPlan(opts.site, result.value, session.hits);
|
|
144
|
+
const coverage = pageTypeCoverage(plan.pages);
|
|
145
|
+
log(`Page types found: ${coverage.found.join(", ") || "none"}`);
|
|
146
|
+
if (coverage.absent.length) log(`Not present on this site: ${coverage.absent.join(", ")}`);
|
|
147
|
+
return { plan, outcome, coverage };
|
|
148
|
+
}
|
package/src/help.js
ADDED
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { LANGUAGES } from "./i18n.js";
|
|
2
|
+
import { PROVIDERS } from "./providers.js";
|
|
3
|
+
import { JOURNEY_PRESETS } from "./journeys.js";
|
|
4
|
+
|
|
5
|
+
const journeys = Object.keys(JOURNEY_PRESETS).join(", ");
|
|
6
|
+
|
|
7
|
+
const MAIN = `aitd: an open-source AI agent that QA-tests your analytics and ad tracking.
|
|
8
|
+
|
|
9
|
+
Usage: aitd <command> [options]
|
|
10
|
+
|
|
11
|
+
Commands:
|
|
12
|
+
plan Draft a measurement plan: what should be tracked
|
|
13
|
+
run Audit what a site actually sends, and compare it to a plan
|
|
14
|
+
replay Re-check saved sites offline, without AI
|
|
15
|
+
mcp Start the MCP server for Claude, ChatGPT and other chat apps
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
aitd plan --site https://example.com
|
|
19
|
+
aitd run --site https://example.com --against latest
|
|
20
|
+
aitd run --site https://example.com --journey purchase
|
|
21
|
+
|
|
22
|
+
Run "aitd <command> --help" for that command's options. -h, --help -v, --version`;
|
|
23
|
+
|
|
24
|
+
const RUN = `Usage: aitd run --site <url> (--against <plan> | --journey <id> | --goal "<text>")
|
|
25
|
+
|
|
26
|
+
Audit what a site actually sends while an AI agent walks its journeys. Needs an AI key
|
|
27
|
+
(${Object.values(PROVIDERS).map((p) => p.env).join(", ")}) unless --no-ai.
|
|
28
|
+
|
|
29
|
+
What to test (at least one):
|
|
30
|
+
--against <plan> Check against a plan: latest, first, a plan ID or a plan file or folder
|
|
31
|
+
--journey <id> A ready-made journey: ${journeys} (repeatable)
|
|
32
|
+
--goal "<text>" A journey in plain language (repeatable)
|
|
33
|
+
|
|
34
|
+
Options:
|
|
35
|
+
--site <url> The site to audit
|
|
36
|
+
--config <file> Take the site and journeys from a config file
|
|
37
|
+
--events <names> Only check these events, e.g. purchase,add_to_cart
|
|
38
|
+
--lang <code> Report language: ${Object.keys(LANGUAGES).join(" or ")}
|
|
39
|
+
--headed Show the browser window
|
|
40
|
+
--fail-on <level> Exit 1 at this level: broken (default), risk, warning, never
|
|
41
|
+
|
|
42
|
+
AI: --provider <name> --model <id> (default: whichever API key is set)
|
|
43
|
+
|
|
44
|
+
Advanced:
|
|
45
|
+
--max-steps <n> --platforms <list> --viewport <name> --data-dir <dir>
|
|
46
|
+
--scripted --no-ai --save-site <dir> --save-steps <file> (see the README)`;
|
|
47
|
+
|
|
48
|
+
const PLAN = `Usage: aitd plan --site <url>
|
|
49
|
+
aitd plan list --site <url>
|
|
50
|
+
aitd plan import <file> --site <url>
|
|
51
|
+
aitd plan export <plan> --site <url>
|
|
52
|
+
|
|
53
|
+
Draft a measurement plan: what should be tracked. The AI explores one page of each type
|
|
54
|
+
and proposes events, parameters and user properties. Plans are saved in
|
|
55
|
+
aitd/<domain>/plans/<id>/. Needs an AI key (${Object.values(PROVIDERS).map((p) => p.env).join(", ")}).
|
|
56
|
+
|
|
57
|
+
Commands:
|
|
58
|
+
(none) Draft a new plan from the site
|
|
59
|
+
list List the site's plan IDs, oldest first
|
|
60
|
+
import <file> Save a plan from a spreadsheet (xlsx or csv) as a new plan
|
|
61
|
+
export <plan> Write a plan to a spreadsheet to edit; <plan> is latest, first, an ID or a path
|
|
62
|
+
|
|
63
|
+
Options:
|
|
64
|
+
--site <url> The site (required)
|
|
65
|
+
--headed Show the browser window
|
|
66
|
+
|
|
67
|
+
AI: --provider <name> --model <id> (default: whichever API key is set)
|
|
68
|
+
|
|
69
|
+
Advanced:
|
|
70
|
+
--max-steps <n> --data-dir <dir> --format <xlsx|csv> (see the README)`;
|
|
71
|
+
|
|
72
|
+
const REPLAY = `Usage: aitd replay <dir>... [--update]
|
|
73
|
+
|
|
74
|
+
Audit sites saved with "aitd run --save-site <dir>" again, offline and without AI, and
|
|
75
|
+
list findings that changed. Exits with code 1 when any did.
|
|
76
|
+
|
|
77
|
+
Options:
|
|
78
|
+
--update Make this replay's findings the expected ones (check the changes first)
|
|
79
|
+
|
|
80
|
+
Advanced:
|
|
81
|
+
--out <dir> Where to write the reports (default: <dir>/last-replay)`;
|
|
82
|
+
|
|
83
|
+
const MCP = `Usage: aitd mcp [--http] [--port <n>]
|
|
84
|
+
|
|
85
|
+
Start the MCP server for Claude, ChatGPT, Gemini and other chat apps. It talks over
|
|
86
|
+
stdio unless you pass --http.
|
|
87
|
+
|
|
88
|
+
Options:
|
|
89
|
+
--http Serve over Streamable HTTP, for apps that connect to a URL
|
|
90
|
+
--port <n> HTTP port (default 8787)
|
|
91
|
+
|
|
92
|
+
Advanced:
|
|
93
|
+
--host <addr> HTTP host (default 127.0.0.1)
|
|
94
|
+
--token <secret> Secret URL path segment, at least 16 characters (default: random)
|
|
95
|
+
--allow-private-network Over HTTP, let audits open localhost and private addresses
|
|
96
|
+
--reports <dir> Where finished audits write reports (default: ~/ai-tag-debugger-reports)
|
|
97
|
+
--public-url <url> The HTTPS address of your tunnel or host, so audits can link to reports`;
|
|
98
|
+
|
|
99
|
+
const HELP = { run: RUN, plan: PLAN, replay: REPLAY, mcp: MCP };
|
|
100
|
+
|
|
101
|
+
/** Help text for a command, or the top-level help when the command is unknown or missing. */
|
|
102
|
+
export function helpText(command) {
|
|
103
|
+
return HELP[command] ?? MAIN;
|
|
104
|
+
}
|
package/src/i18n.js
CHANGED
|
@@ -85,6 +85,12 @@ const en = {
|
|
|
85
85
|
tabScope: "Scope and plan",
|
|
86
86
|
sections: "Report sections",
|
|
87
87
|
headerMeta: (journeys, providers) => `${journeys} ${journeys === 1 ? "journey" : "journeys"} · ${providers} ${providers === 1 ? "provider" : "providers"}`,
|
|
88
|
+
checkedAgainst: (id) => `Checked against plan ${id}`,
|
|
89
|
+
planVsSite: "Plan vs. site",
|
|
90
|
+
planVsSiteLead: "Each planned event, and what the site actually sent",
|
|
91
|
+
coverageLine: (c) => `Coverage ${c.implemented} of ${c.checkable}${c.percent === null ? "" : ` (${c.percent}%)`}${c.not_checked ? `, ${c.not_checked} ${c.not_checked === 1 ? "event" : "events"} not checked` : ""}`,
|
|
92
|
+
bucket: { implemented: "Implemented", missing: "Missing", missing_params: "Missing params", wrong_values: "Wrong values", unplanned: "Unplanned", not_checked: "Not checked" },
|
|
93
|
+
bucketCol: "Result",
|
|
88
94
|
summaryLead: "AI summary, most important first",
|
|
89
95
|
noSummary: "Run without --no-ai for an AI summary of the findings.",
|
|
90
96
|
seeAllFindings: "See all findings →",
|
|
@@ -121,6 +127,11 @@ const en = {
|
|
|
121
127
|
missingEventEarly: (name) => `${q(name)} was not seen, but the journey stopped early, so this may not be a tracking bug.`,
|
|
122
128
|
missingParams: (name, params) => `${q(name)} was sent without ${params.map(q).join(", ")}.`,
|
|
123
129
|
duplicate: (name, n) => `${q(name)} was sent ${n} times by one action, which inflates counts and conversions.`,
|
|
130
|
+
purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}. A purchase worth 0 or less adds no revenue, and ad platforms can't optimize bids on it.`,
|
|
131
|
+
purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} was sent with value ${q(v)}, which is not a number, so the revenue is lost. Send a plain number such as 49.90, with no currency sign or thousands separator.`,
|
|
132
|
+
purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} was sent with currency ${q(c)}, which is not a 3-letter ISO 4217 code in capitals (such as USD, EUR or TRY). Platforms may drop the revenue or convert it wrongly.`,
|
|
133
|
+
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} was sent with no items, so product and item reports get nothing for this order.`,
|
|
134
|
+
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.`,
|
|
124
135
|
serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} ${server.length === 1 ? "was" : "were"} sent to a server container on the site's own domain (server-side tagging)${browser.length ? `; ${browser.map(q).join(", ")} went directly from the browser` : ""}. Server-side copies of Meta and TikTok events never pass through the browser, so they can't be seen here.`,
|
|
125
136
|
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.`,
|
|
126
137
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
@@ -227,6 +238,12 @@ const tr = {
|
|
|
227
238
|
tabScope: "Kapsam ve plan",
|
|
228
239
|
sections: "Rapor bölümleri",
|
|
229
240
|
headerMeta: (journeys, providers) => `${journeys} yolculuk · ${providers} sağlayıcı`,
|
|
241
|
+
checkedAgainst: (id) => `${id} planına göre kontrol edildi`,
|
|
242
|
+
planVsSite: "Plan ve site",
|
|
243
|
+
planVsSiteLead: "Plandaki her olay ve sitenin gerçekte gönderdiği",
|
|
244
|
+
coverageLine: (c) => `Kapsam ${c.checkable} olaydan ${c.implemented}${c.percent === null ? "" : ` (%${c.percent})`}${c.not_checked ? `, ${c.not_checked} olay kontrol edilmedi` : ""}`,
|
|
245
|
+
bucket: { implemented: "Uygulanmış", missing: "Eksik", missing_params: "Eksik parametre", wrong_values: "Hatalı değer", unplanned: "Planda yok", not_checked: "Kontrol edilmedi" },
|
|
246
|
+
bucketCol: "Sonuç",
|
|
230
247
|
summaryLead: "Yapay zekâ özeti, öncelik sırasıyla",
|
|
231
248
|
noSummary: "Bulguların yapay zekâ özeti için --no-ai olmadan çalıştırın.",
|
|
232
249
|
seeAllFindings: "Tüm bulguları gör →",
|
|
@@ -263,6 +280,11 @@ const tr = {
|
|
|
263
280
|
missingEventEarly: (name) => `${q(name)} görülmedi, ancak yolculuk erken durduğu için bu bir izleme hatası olmayabilir.`,
|
|
264
281
|
missingParams: (name, params) => `${q(name)} olayı ${params.map(q).join(", ")} ${params.length > 1 ? "parametreleri" : "parametresi"} olmadan gönderildi.`,
|
|
265
282
|
duplicate: (name, n) => `${q(name)} tek bir işlemde ${n} kez gönderildi; bu da sayıları ve dönüşümleri şişirir.`,
|
|
283
|
+
purchaseValueZero: (platform, name, v) => `${platform} ${q(name)} ${q(v)} değeriyle gönderildi. 0 veya daha düşük değerli bir satın alma gelire eklenmez ve reklam platformları teklifleri buna göre optimize edemez.`,
|
|
284
|
+
purchaseValueNaN: (platform, name, v) => `${platform} ${q(name)} sayı olmayan ${q(v)} değeriyle gönderildi, bu yüzden gelir kayboluyor. Para birimi işareti veya binlik ayırıcı olmadan 49.90 gibi düz bir sayı gönderin.`,
|
|
285
|
+
purchaseCurrency: (platform, name, c) => `${platform} ${q(name)} ${q(c)} para birimiyle gönderildi; bu, büyük harfli 3 harfli bir ISO 4217 kodu değil (USD, EUR veya TRY gibi). Platformlar geliri atabilir veya yanlış çevirebilir.`,
|
|
286
|
+
purchaseItemsEmpty: (platform, name) => `${platform} ${q(name)} ürün listesi boş gönderildi, bu yüzden ürün raporları bu siparişten veri alamaz.`,
|
|
287
|
+
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.`,
|
|
266
288
|
serverSide: (platform, server, browser) => `${platform}: ${server.map(q).join(", ")} sitenin kendi alan adındaki bir sunucu konteynerine gönderildi (sunucu tarafı etiketleme)${browser.length ? `; ${browser.map(q).join(", ")} doğrudan tarayıcıdan gitti` : ""}. Meta ve TikTok olaylarının sunucu tarafı kopyaları tarayıcıdan geçmez, bu yüzden burada görünmez.`,
|
|
267
289
|
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.`,
|
|
268
290
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
package/src/journeys.js
CHANGED
|
@@ -64,11 +64,13 @@ export function presetFor(name) {
|
|
|
64
64
|
* Adds a journey's preset events to a plan (a copy): events the plan already
|
|
65
65
|
* has are expected on this journey too; missing ones are added for this journey.
|
|
66
66
|
*/
|
|
67
|
-
export function withPresetEvents(plan, journeyId, preset) {
|
|
67
|
+
export function withPresetEvents(plan, journeyId, preset, { addMissing = true } = {}) {
|
|
68
68
|
const events = plan.events.map((e) => ({ ...e }));
|
|
69
69
|
for (const pe of preset.events) {
|
|
70
|
-
const existing = events.find((e) => e.platform === pe.platform && e.name === pe.name);
|
|
71
|
-
if (!existing)
|
|
70
|
+
const existing = events.find((e) => String(e.platform).toLowerCase() === pe.platform && e.name === pe.name);
|
|
71
|
+
if (!existing) {
|
|
72
|
+
if (addMissing) events.push({ ...pe, journeys: [journeyId] });
|
|
73
|
+
}
|
|
72
74
|
else if (existing.journeys && !existing.journeys.includes(journeyId)) existing.journeys = [...existing.journeys, journeyId];
|
|
73
75
|
}
|
|
74
76
|
return { ...plan, events };
|
|
@@ -80,10 +82,20 @@ export function withPresetEvents(plan, journeyId, preset) {
|
|
|
80
82
|
* picks: journey ids from the config or preset names. goals: journeys described in plain language.
|
|
81
83
|
* events: only check these event names.
|
|
82
84
|
*/
|
|
83
|
-
export function planRun({ config = {}, plan, picks = [], goals = [], events = [] }) {
|
|
84
|
-
// Copied, because the loops below add journeys to events.
|
|
85
|
-
let runPlan = { ...(plan ?? { events: [] }), events: (plan?.events ?? []).map((e) => ({ ...e })) };
|
|
85
|
+
export function planRun({ config = {}, plan, picks = [], goals = [], events = [], against = false }) {
|
|
86
86
|
const configJourneys = config.journeys ?? [];
|
|
87
|
+
// --against alone: the plan says what to walk.
|
|
88
|
+
if (against && !picks.length && !goals.length && !configJourneys.length) {
|
|
89
|
+
const derived = journeysFromPlan(plan);
|
|
90
|
+
return { journeys: derived.journeys, plan: onlyEvents(derived.plan, events) };
|
|
91
|
+
}
|
|
92
|
+
// --against with --journey or --goal: only plan events expected on those flows are checked;
|
|
93
|
+
// the rest are expected nowhere, so they are not checked rather than reported missing.
|
|
94
|
+
const scoped = against && (picks.length || goals.length);
|
|
95
|
+
let runPlan = {
|
|
96
|
+
...(plan ?? { events: [] }),
|
|
97
|
+
events: (plan?.events ?? []).map((e) => (scoped ? { ...e, journeys: (e.journeys ?? []).filter((j) => picks.includes(j)) } : { ...e })),
|
|
98
|
+
};
|
|
87
99
|
const journeys = [];
|
|
88
100
|
|
|
89
101
|
for (const pick of picks) {
|
|
@@ -99,13 +111,13 @@ export function planRun({ config = {}, plan, picks = [], goals = [], events = []
|
|
|
99
111
|
}
|
|
100
112
|
if (journeys.some((j) => j.id === preset.id)) continue;
|
|
101
113
|
journeys.push({ id: preset.id, goal: preset.goal });
|
|
102
|
-
runPlan = withPresetEvents(runPlan, preset.id, preset);
|
|
114
|
+
runPlan = withPresetEvents(runPlan, preset.id, preset, { addMissing: !scoped });
|
|
103
115
|
}
|
|
104
116
|
|
|
105
117
|
goals.forEach((goal, i) => {
|
|
106
118
|
const id = goals.length > 1 ? `custom-${i + 1}` : "custom";
|
|
107
119
|
journeys.push({ id, goal });
|
|
108
|
-
runPlan = expectEvents(runPlan, id, events);
|
|
120
|
+
runPlan = expectEvents(runPlan, id, scoped ? [...events, ...eventsNamedIn(goal, runPlan)] : events);
|
|
109
121
|
});
|
|
110
122
|
|
|
111
123
|
if (!picks.length && !goals.length) journeys.push(...configJourneys);
|
|
@@ -140,3 +152,70 @@ export function filterFindings(findings, events = []) {
|
|
|
140
152
|
const wanted = new Set(events.map((e) => e.toLowerCase()));
|
|
141
153
|
return findings.filter((f) => (f.event ? wanted.has(f.event.toLowerCase()) : f.check !== "unplanned_events"));
|
|
142
154
|
}
|
|
155
|
+
|
|
156
|
+
const words = (text) => String(text ?? "").toLowerCase().replace(/[_-]+/g, " ");
|
|
157
|
+
|
|
158
|
+
/** Plan events a plain-language goal mentions, by event name ("add to cart" finds add_to_cart) or "<trigger page> page". */
|
|
159
|
+
export function eventsNamedIn(goal, plan) {
|
|
160
|
+
const text = words(goal);
|
|
161
|
+
return plan.events
|
|
162
|
+
.filter((e) => text.includes(words(e.name)) || (e.trigger?.page && text.includes(`${words(e.trigger.page)} page`)))
|
|
163
|
+
.map((e) => e.name);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** Which ready-made journey covers a group of plan events, from the standard GA4 shop and lead events in it. */
|
|
167
|
+
const PRESET_BY_EVENT = [
|
|
168
|
+
["purchase", ["purchase", "add_payment_info", "add_shipping_info", "begin_checkout"]],
|
|
169
|
+
["add_to_cart", ["add_to_cart", "view_cart"]],
|
|
170
|
+
["sign_up", ["sign_up"]],
|
|
171
|
+
["lead", ["generate_lead"]],
|
|
172
|
+
["search", ["search", "view_search_results"]],
|
|
173
|
+
];
|
|
174
|
+
|
|
175
|
+
export const MAX_PLAN_JOURNEYS = 6;
|
|
176
|
+
|
|
177
|
+
const slug = (text) => words(text).trim().replace(/[^a-z0-9]+/g, "_").replace(/^_|_$/g, "") || "page";
|
|
178
|
+
|
|
179
|
+
function goalFor(page, events) {
|
|
180
|
+
const actions = [...new Set(events.map((e) => e.trigger?.element).filter(Boolean))];
|
|
181
|
+
const where = page ? `go to a ${page} page` : "browse the site";
|
|
182
|
+
const doing = actions.length ? ` and use ${actions.map((a) => `"${a}"`).join(", ")}` : "";
|
|
183
|
+
return `Accept cookies, ${where}${doing}. Never enter real payment details.`;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Journeys for `run --against` used alone: the plan's events grouped by the page they are triggered on
|
|
188
|
+
* (trigger.page), at most six, each with one goal built from the plan's trigger text (standard shop events
|
|
189
|
+
* reuse the ready-made journeys) and the group's events expected on it. Returns the journeys and the plan
|
|
190
|
+
* with each event's "journeys" set.
|
|
191
|
+
*/
|
|
192
|
+
export function journeysFromPlan(plan, max = MAX_PLAN_JOURNEYS) {
|
|
193
|
+
const groups = new Map();
|
|
194
|
+
for (const e of plan?.events ?? []) {
|
|
195
|
+
const page = e.trigger?.page ? String(e.trigger.page).trim() : "";
|
|
196
|
+
if (!groups.has(page)) groups.set(page, []);
|
|
197
|
+
groups.get(page).push(e);
|
|
198
|
+
}
|
|
199
|
+
// The biggest groups get their own journey; the rest share the last one.
|
|
200
|
+
let list = [...groups].map(([page, evs]) => ({ page, events: evs })).sort((a, b) => b.events.length - a.events.length);
|
|
201
|
+
if (list.length > max) {
|
|
202
|
+
const rest = list.slice(max - 1);
|
|
203
|
+
list = [...list.slice(0, max - 1), { page: rest.map((g) => g.page).filter(Boolean).join(", "), events: rest.flatMap((g) => g.events), merged: rest }];
|
|
204
|
+
}
|
|
205
|
+
const journeys = [];
|
|
206
|
+
const ids = new Map();
|
|
207
|
+
for (const group of list) {
|
|
208
|
+
const names = new Set(group.events.map((e) => e.name));
|
|
209
|
+
const presetId = PRESET_BY_EVENT.find(([, evs]) => evs.some((n) => names.has(n)))?.[0];
|
|
210
|
+
const base = presetId && !journeys.some((j) => j.id === presetId) ? presetId : slug(group.page);
|
|
211
|
+
let id = base;
|
|
212
|
+
for (let n = 2; journeys.some((j) => j.id === id); n++) id = `${base}_${n}`;
|
|
213
|
+
const goal = presetId === id ? JOURNEY_PRESETS[presetId].goal : group.merged ? group.merged.map((g) => goalFor(g.page, g.events)).join(" Then ") : goalFor(group.page, group.events);
|
|
214
|
+
journeys.push({ id, goal, planEvents: group.events.map((e) => e.name) });
|
|
215
|
+
for (const e of group.events) ids.set(e, id);
|
|
216
|
+
}
|
|
217
|
+
return {
|
|
218
|
+
journeys,
|
|
219
|
+
plan: { ...plan, events: (plan?.events ?? []).map((e) => ({ ...e, journeys: [ids.get(e)] })) },
|
|
220
|
+
};
|
|
221
|
+
}
|
package/src/mcp.js
CHANGED
|
@@ -20,6 +20,7 @@ import { collect, pageView, SYSTEM_PROMPT } from "./agent.js";
|
|
|
20
20
|
import { checkRun, groupFindings, summarize } from "./checks.js";
|
|
21
21
|
import { LANGUAGES, messages } from "./i18n.js";
|
|
22
22
|
import { loadConfig, readJson, runAudit, writeReports } from "./audit.js";
|
|
23
|
+
import { DEFAULT_DATA_DIR, resolvePlan } from "./workspace.js";
|
|
23
24
|
import { DEFAULT_PROVIDERS, PROVIDERS as TRACKING_PROVIDERS } from "./parsers.js";
|
|
24
25
|
import { expectEvents, filterFindings, JOURNEY_PRESETS, onlyEvents, presetFor, withPresetEvents } from "./journeys.js";
|
|
25
26
|
|
|
@@ -33,7 +34,7 @@ const INSTRUCTIONS = `ai-tag-debugger checks a website's analytics and ad tracki
|
|
|
33
34
|
|
|
34
35
|
Before starting, ask the user which platforms to check and suggest the default (${DEFAULT_PROVIDERS.map((k) => TRACKING_PROVIDERS[k]).join(", ")}); pass their choice as providers.
|
|
35
36
|
|
|
36
|
-
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit with the
|
|
37
|
+
To audit a journey yourself: call start_audit with the site URL and a journey (a ready-made one: ${Object.keys(JOURNEY_PRESETS).join(", ")}, or the user's own description), then browser_action one step at a time until the journey is done (each result shows the page and the tracking hits that step sent), then finish_audit (with against to check the run against a saved plan) to get the findings and the report. Explain the findings to the user in plain language.
|
|
37
38
|
|
|
38
39
|
While browsing:
|
|
39
40
|
${SYSTEM_PROMPT.split("How to work:\n")[1]}`;
|
|
@@ -100,10 +101,27 @@ async function readPlan(plan, allowFiles) {
|
|
|
100
101
|
return readJson(trimmed);
|
|
101
102
|
}
|
|
102
103
|
|
|
104
|
+
/** finish_audit refuses a bad plan without closing the audit, so the captured run is not lost. */
|
|
105
|
+
const STILL_OPEN = "The audit is still open: call finish_audit again with a corrected plan (or none).";
|
|
106
|
+
|
|
107
|
+
const AGAINST_DOC = "A saved plan to check against: latest (or last), first, a plan ID such as 2026-10-16_0627, or on a local server a path to a plan file or folder. Plans are the ones saved by `aitd plan --site <url>`.";
|
|
108
|
+
|
|
109
|
+
/** Finds the saved plan named by `against`. Over HTTP only latest, first and plan IDs are allowed, never paths. */
|
|
110
|
+
async function loadAgainst(ref, site, opts) {
|
|
111
|
+
if (!opts.allowFiles && !/^(latest|last|first|\d{4}-\d{2}-\d{2}_\d{4}(\d{2})?)$/.test(ref.trim())) {
|
|
112
|
+
throw new Error("Over HTTP, against takes latest, first or a plan ID, not a path.");
|
|
113
|
+
}
|
|
114
|
+
const found = await resolvePlan(ref, { dataDir: opts.dataDir ?? DEFAULT_DATA_DIR, site });
|
|
115
|
+
const plan = await readJson(found.file);
|
|
116
|
+
if (!Array.isArray(plan?.events)) throw new Error(`${found.file} is not a plan: it needs an "events" list.`);
|
|
117
|
+
return { found, plan };
|
|
118
|
+
}
|
|
119
|
+
|
|
103
120
|
/**
|
|
104
|
-
* @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string }} opts
|
|
121
|
+
* @param {{ allowFiles: boolean, blockPrivate?: boolean, reportsDir: string, reportsUrl?: string, dataDir?: string }} opts
|
|
105
122
|
* allowFiles: local (stdio) servers may read config and plan files; HTTP servers may not.
|
|
106
123
|
* blockPrivate: the browser refuses loopback, private and link-local addresses (HTTP servers, by default).
|
|
124
|
+
* dataDir: where saved plans live (default ./aitd), used by `against`.
|
|
107
125
|
* reportsUrl: where an HTTP server serves finished reports, if it knows its public URL.
|
|
108
126
|
*/
|
|
109
127
|
export function buildServer(opts) {
|
|
@@ -214,7 +232,7 @@ export function buildServer(opts) {
|
|
|
214
232
|
{
|
|
215
233
|
title: "Finish and check the audit",
|
|
216
234
|
description:
|
|
217
|
-
"Closes the browser, checks every captured hit against the
|
|
235
|
+
"Closes the browser, checks every captured hit against the plan (missing events, missing parameters, duplicates, hits before consent, Consent Mode defaults, emails sent in plain text, unplanned events) and writes the report with the event timeline. Returns the findings. The plan is what should be tracked: pass a saved one with against, or write one inline with measurement_plan.",
|
|
218
236
|
inputSchema: {
|
|
219
237
|
audit_id: z.string(),
|
|
220
238
|
completed: z.boolean().describe("Whether the journey reached its goal."),
|
|
@@ -223,21 +241,28 @@ export function buildServer(opts) {
|
|
|
223
241
|
.string()
|
|
224
242
|
.optional()
|
|
225
243
|
.describe(
|
|
226
|
-
'The
|
|
244
|
+
'The plan (what should be tracked) as JSON text, e.g. {"consent_mode": true, "events": [{"platform": "ga4", "name": "purchase", "required_params": ["transaction_id", "value", "currency"]}]}. On a local server this can also be a file path. Without a plan, a ready-made journey checks its own events; otherwise only consent, duplicate and personal data checks run.',
|
|
227
245
|
),
|
|
246
|
+
against: z.string().optional().describe(`${AGAINST_DOC} The site comes from this audit. Use either against or measurement_plan, not both.`),
|
|
228
247
|
events: z.array(z.string()).optional().describe("Only check these events, e.g. [\"purchase\", \"add_to_cart\"]. On your own journey, these events are also expected to fire."),
|
|
229
248
|
language: z.enum(Object.keys(LANGUAGES)).optional().describe("Report language. Default en."),
|
|
230
249
|
branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
|
|
231
250
|
},
|
|
232
251
|
},
|
|
233
|
-
async ({ audit_id, completed, note, measurement_plan, events = [], language, branding }) => {
|
|
252
|
+
async ({ audit_id, completed, note, measurement_plan, against, events = [], language, branding }) => {
|
|
234
253
|
const audit = getAudit(audit_id);
|
|
254
|
+
if (against && measurement_plan) return fail(`Use either against or measurement_plan, not both. ${STILL_OPEN}`);
|
|
235
255
|
let plan;
|
|
256
|
+
let againstPlan = null;
|
|
236
257
|
try {
|
|
237
|
-
|
|
258
|
+
if (against) {
|
|
259
|
+
const loaded = await loadAgainst(against, audit.site, opts);
|
|
260
|
+
plan = loaded.plan;
|
|
261
|
+
againstPlan = { ...loaded.found, label: loaded.found.id ?? path.relative(process.cwd(), loaded.found.file) };
|
|
262
|
+
} else plan = await readPlan(measurement_plan, opts.allowFiles);
|
|
238
263
|
if (!Array.isArray(plan.events)) throw new Error('The plan needs an "events" list.');
|
|
239
264
|
} catch (err) {
|
|
240
|
-
return fail(`Could not read the
|
|
265
|
+
return fail(`Could not read the plan: ${err.message} ${STILL_OPEN}`);
|
|
241
266
|
}
|
|
242
267
|
if (audit.preset) plan = withPresetEvents(plan, audit.journey, audit.preset);
|
|
243
268
|
else plan = expectEvents(plan, audit.journey, events);
|
|
@@ -251,10 +276,11 @@ export function buildServer(opts) {
|
|
|
251
276
|
const grouped = groupFindings(findings);
|
|
252
277
|
const counts = summarize(grouped);
|
|
253
278
|
const outDir = path.join(opts.reportsDir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${audit.journey}`);
|
|
254
|
-
const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, branding: branding !== false });
|
|
279
|
+
const reportPath = await writeReports({ outDir, config: { site: audit.site }, plan, runs: [run], findings, explained: null, lang, against: againstPlan, branding: branding !== false });
|
|
255
280
|
const m = messages(lang);
|
|
256
281
|
const lines = [
|
|
257
282
|
m.ui.counts(counts),
|
|
283
|
+
...(againstPlan ? [m.ui.checkedAgainst(againstPlan.label)] : []),
|
|
258
284
|
"",
|
|
259
285
|
...grouped.filter((f) => f.severity !== "ok").map((f) => `- ${m.severity[f.severity]}: ${f.detail}${f.evidence ? ` (captured: ${f.evidence})` : ""} [${f.where.map((w) => m.ui.whereStep(w.journey, w.viewport, w.step)).join("; ")}]`),
|
|
260
286
|
"",
|
|
@@ -278,9 +304,10 @@ export function buildServer(opts) {
|
|
|
278
304
|
{
|
|
279
305
|
title: "Run a configured audit",
|
|
280
306
|
description:
|
|
281
|
-
"Runs every journey in a ai-tag-debugger config file. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY).
|
|
307
|
+
"Runs every journey in a ai-tag-debugger config file and writes the report. With against, the run is checked against a saved plan and, if the config has no journeys, walks the journeys derived from that plan. With scripted true, it replays each journey's fixed steps and needs no API key. Otherwise the journeys are browsed by the AI provider configured on this computer (ANTHROPIC_API_KEY, OPENAI_API_KEY or GEMINI_API_KEY). Returns the findings.",
|
|
282
308
|
inputSchema: {
|
|
283
309
|
config: z.string().describe("Path to the config file, e.g. ai-tag-debugger.config.json"),
|
|
310
|
+
against: z.string().optional().describe(`${AGAINST_DOC} The site comes from the config.`),
|
|
284
311
|
scripted: z.boolean().optional().describe("Replay fixed steps instead of browsing with an AI model. Default true."),
|
|
285
312
|
language: z.enum(Object.keys(LANGUAGES)).optional(),
|
|
286
313
|
branding: z.boolean().optional().describe("Set false to leave the Analitik İşler logo and credit line out of the report."),
|
|
@@ -290,12 +317,26 @@ export function buildServer(opts) {
|
|
|
290
317
|
.describe(`Tracking providers to check. Default ${DEFAULT_PROVIDERS.join(", ")}. Requests to the others are still answered locally, but they are only counted, not shown or checked.`),
|
|
291
318
|
},
|
|
292
319
|
},
|
|
293
|
-
async ({ config: configFile, scripted, language, branding, providers }) => {
|
|
320
|
+
async ({ config: configFile, against, scripted, language, branding, providers }) => {
|
|
294
321
|
const loaded = await loadConfig(configFile);
|
|
322
|
+
let againstPlan = null;
|
|
323
|
+
if (against) {
|
|
324
|
+
try {
|
|
325
|
+
const found = await loadAgainst(against, loaded.config.site, opts);
|
|
326
|
+
if (!loaded.config.site) {
|
|
327
|
+
if (!found.plan.site) throw new Error(`${found.found.file} has no "site" and the config has none either.`);
|
|
328
|
+
loaded.config = { ...loaded.config, site: found.plan.site };
|
|
329
|
+
}
|
|
330
|
+
loaded.plan = found.plan;
|
|
331
|
+
againstPlan = found.found;
|
|
332
|
+
} catch (err) {
|
|
333
|
+
return fail(`Could not read the plan: ${err.message}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
295
336
|
const replay = scripted !== false;
|
|
296
337
|
let result;
|
|
297
338
|
try {
|
|
298
|
-
result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
|
|
339
|
+
result = await runAudit({ ...loaded, scripted: replay, ai: !replay, lang: language, branding, providers, against: againstPlan, outDir: path.join(opts.reportsDir, new Date().toISOString().replace(/[:.]/g, "-")) });
|
|
299
340
|
} catch (err) {
|
|
300
341
|
// The CLI wording (--scripted --no-ai) means nothing to a chat app, so say it in this tool's terms.
|
|
301
342
|
if (err.code !== "NO_API_KEY") throw err;
|