@analitikisler/ai-tag-debugger 0.21.0 → 0.22.1
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 +17 -0
- package/README.md +32 -3
- package/README.tr.md +31 -2
- package/package.json +4 -1
- package/src/agent.js +5 -3
- package/src/browser.js +111 -15
- package/src/checks.js +44 -12
- package/src/containers.js +164 -0
- package/src/discover.js +5 -2
- package/src/frames.js +90 -0
- package/src/html/scope.js +15 -1
- package/src/i18n.js +98 -0
- package/src/mcp.js +85 -10
- package/src/report.js +17 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
// How each Google container got onto the page: GTM (gtm.js) and Google tag (gtag/js) scripts, with what
|
|
2
|
+
// inserted them. The browser records every container script as it is added (see CONTAINER_HOOK in browser.js):
|
|
3
|
+
// parser-inserted from the HTML, or set by a script, with the scripts on the call stack at that moment. This
|
|
4
|
+
// module turns that into a load source: the snippet, a script tag in the HTML, another script on the page,
|
|
5
|
+
// a tag or Zone in another container, or a gtag config or send_to command the Google tag acted on.
|
|
6
|
+
|
|
7
|
+
// Container and destination IDs gtm.js and gtag/js are loaded with.
|
|
8
|
+
const CONTAINER_ID = /^(GTM|G|GT|AW|DC|MC|HA)-[A-Z0-9]+$/;
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* The container a script URL loads, or null: its ID, gtm or gtag, the dataLayer it reads (`l`), the host it
|
|
12
|
+
* came from (Google's, or the site's own through the Google tag gateway or a server container) and the GTM
|
|
13
|
+
* environment when one is named (`gtm_preview`). gtm_auth, the environment's token, is never kept.
|
|
14
|
+
*/
|
|
15
|
+
export function containerOf(url) {
|
|
16
|
+
let u;
|
|
17
|
+
try { u = new URL(url); } catch { return null; }
|
|
18
|
+
if (!/^https?:$/.test(u.protocol)) return null;
|
|
19
|
+
const id = (u.searchParams.get("id") ?? "").toUpperCase();
|
|
20
|
+
if (!CONTAINER_ID.test(id)) return null;
|
|
21
|
+
const script = /\/gtm\.js$/.test(u.pathname) ? "gtm" : /\/gtag\/js$/.test(u.pathname) ? "gtag" : null;
|
|
22
|
+
if (!script) return null;
|
|
23
|
+
const environment = u.searchParams.get("gtm_preview") || (u.searchParams.has("gtm_auth") ? "unnamed" : null);
|
|
24
|
+
return {
|
|
25
|
+
id,
|
|
26
|
+
script,
|
|
27
|
+
layer: u.searchParams.get("l") || "dataLayer",
|
|
28
|
+
host: u.hostname,
|
|
29
|
+
served: /(^|\.)googletagmanager\.com$/.test(u.hostname) ? "google" : "site",
|
|
30
|
+
...(environment ? { environment } : {}),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** origin + path, so a caller's query string (which may carry user data) is never kept. */
|
|
35
|
+
export function scriptPath(url) {
|
|
36
|
+
try {
|
|
37
|
+
const u = new URL(url);
|
|
38
|
+
return /^https?:$/.test(u.protocol) ? u.origin + u.pathname : null;
|
|
39
|
+
} catch {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The call stack the browser captured, as callers from the innermost out: { inline: true } for script in the
|
|
46
|
+
* page itself (the page's address, or "inline" for code injected without one) (the snippet, or a Custom HTML tag GTM injected), { container: id } for a container script and
|
|
47
|
+
* { script: url } for any other script. Repeats in a row are folded.
|
|
48
|
+
*/
|
|
49
|
+
export function callersOf(frames, documentUrl) {
|
|
50
|
+
const doc = String(documentUrl ?? "").split("#")[0];
|
|
51
|
+
const out = [];
|
|
52
|
+
for (const f of frames ?? []) {
|
|
53
|
+
const url = String(f).split("#")[0];
|
|
54
|
+
const c = containerOf(url);
|
|
55
|
+
const caller = url === doc || url === "inline" ? { inline: true } : c ? { container: c.id } : scriptPath(url) ? { script: scriptPath(url) } : null;
|
|
56
|
+
if (!caller) continue;
|
|
57
|
+
const last = out.at(-1);
|
|
58
|
+
if (last && JSON.stringify(last) === JSON.stringify(caller)) continue;
|
|
59
|
+
out.push(caller);
|
|
60
|
+
}
|
|
61
|
+
return out;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const pathOf = (href) => scriptPath(href);
|
|
65
|
+
|
|
66
|
+
/** Whether a gtag command in the dataLayer names this ID: ["config", id] or ["event", name, { send_to }]. */
|
|
67
|
+
function commandFor(load, pushes) {
|
|
68
|
+
const earlier = pushes.filter((e) => Array.isArray(e.value) && (e.t ?? 0) <= (load.t ?? Infinity) && (!load.page || pathOf(e.url) === load.page));
|
|
69
|
+
if (earlier.some((e) => e.value[0] === "config" && String(e.value[1]).toUpperCase() === load.id)) return "config";
|
|
70
|
+
const sendsTo = (e) => {
|
|
71
|
+
const to = e.value[0] === "event" ? e.value[2]?.send_to : undefined;
|
|
72
|
+
const list = Array.isArray(to) ? to : String(to ?? "").split(",");
|
|
73
|
+
return list.some((s) => String(s).trim().toUpperCase().split("/")[0] === load.id);
|
|
74
|
+
};
|
|
75
|
+
return earlier.some(sendsTo) ? "send_to" : null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Where one recorded load came from, as { kind, by? }:
|
|
80
|
+
* - html: a script tag in the page's HTML (the gtag.js snippet, or gtm.js referenced directly)
|
|
81
|
+
* - inline: code in the page itself (the GTM snippet, or a hand-written loader)
|
|
82
|
+
* - script: another script on the page; by is its address (a consent platform, the site's bundle)
|
|
83
|
+
* - custom_html: a Custom HTML tag in the GTM container `by`
|
|
84
|
+
* - google_tag: a Google tag or template in the GTM container `by`, through GTM's load_google_tags
|
|
85
|
+
* - nested: GTM container `by` loaded another GTM container itself, as a Zone or a tag template does
|
|
86
|
+
* - config / send_to: a gtag('config') or an event's send_to naming the ID, acted on by `by`
|
|
87
|
+
* - destination: the Google tag `by` loaded it as one of its destinations, with no command in the page
|
|
88
|
+
*/
|
|
89
|
+
export function sourceOf(load, pushes = []) {
|
|
90
|
+
if (load.how === "parser") return { kind: "html" };
|
|
91
|
+
const callers = load.callers ?? [];
|
|
92
|
+
const at = callers.findIndex((c) => c.container && c.container !== load.id);
|
|
93
|
+
if (at >= 0) {
|
|
94
|
+
const by = callers[at].container;
|
|
95
|
+
const command = commandFor(load, pushes);
|
|
96
|
+
if (command) return { kind: command, by };
|
|
97
|
+
if (by.startsWith("GTM-")) {
|
|
98
|
+
// Code in the page between the call and the container is a Custom HTML tag the container injected.
|
|
99
|
+
if (callers.slice(0, at).some((c) => c.inline)) return { kind: "custom_html", by };
|
|
100
|
+
return { kind: load.id.startsWith("GTM-") ? "nested" : "google_tag", by };
|
|
101
|
+
}
|
|
102
|
+
return { kind: "destination", by };
|
|
103
|
+
}
|
|
104
|
+
const other = callers.find((c) => c.script);
|
|
105
|
+
if (other) return { kind: "script", by: other.script };
|
|
106
|
+
return { kind: "inline" };
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** A run's container loads with their sources, in the order they happened. Runs saved by older versions have none. */
|
|
110
|
+
export function loadSources(run) {
|
|
111
|
+
const pushes = run.dataLayerLog ?? [];
|
|
112
|
+
return (run.containerLoads ?? []).map((load) => ({ ...load, ...sourceOf(load, pushes) }));
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* One row per container and source across runs, in the order first seen: { id, script, layer, served, host,
|
|
117
|
+
* environment?, kind, by?, frame?, pages, journeys }, where frame is the iframe it loaded in, if any.
|
|
118
|
+
*/
|
|
119
|
+
export function containerRows(runs) {
|
|
120
|
+
const rows = new Map();
|
|
121
|
+
for (const run of runs) {
|
|
122
|
+
for (const l of loadSources(run)) {
|
|
123
|
+
const k = [l.id, l.kind, l.by ?? "", l.host, l.layer, l.environment ?? "", l.frame?.origin ?? ""].join("|");
|
|
124
|
+
const row = rows.get(k) ?? {
|
|
125
|
+
id: l.id, script: l.script, layer: l.layer, served: l.served, host: l.host,
|
|
126
|
+
...(l.environment ? { environment: l.environment } : {}), kind: l.kind, ...(l.by ? { by: l.by } : {}),
|
|
127
|
+
...(l.frame ? { frame: { origin: l.frame.origin, party: l.frame.party } } : {}),
|
|
128
|
+
pages: new Set(), journeys: new Set(),
|
|
129
|
+
};
|
|
130
|
+
if (l.page) row.pages.add(l.page);
|
|
131
|
+
row.journeys.add(`${run.journeyId} (${run.viewport})`);
|
|
132
|
+
rows.set(k, row);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return [...rows.values()].map((r) => ({ ...r, pages: [...r.pages].sort(), journeys: [...r.journeys] }));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** The containers loaded more than once into one page, as [{ id, loads }] with the loads in order. */
|
|
139
|
+
export function duplicateLoads(run) {
|
|
140
|
+
const byDoc = new Map();
|
|
141
|
+
for (const l of loadSources(run)) {
|
|
142
|
+
const k = `${l.doc ?? l.page}|${l.id}`;
|
|
143
|
+
byDoc.set(k, [...(byDoc.get(k) ?? []), l]);
|
|
144
|
+
}
|
|
145
|
+
return [...byDoc.values()].filter((list) => list.length > 1).map((loads) => ({ id: loads[0].id, loads }));
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Pages where containers read different dataLayers, as [{ page, step, layers: [{ id, layer }] }]. */
|
|
149
|
+
export function splitLayers(run) {
|
|
150
|
+
const byDoc = new Map();
|
|
151
|
+
for (const l of loadSources(run)) {
|
|
152
|
+
// A destination a Google tag loaded reads that tag's dataLayer, whatever its own URL says.
|
|
153
|
+
if (["config", "send_to", "destination"].includes(l.kind)) continue;
|
|
154
|
+
const k = l.doc ?? l.page;
|
|
155
|
+
byDoc.set(k, [...(byDoc.get(k) ?? []), l]);
|
|
156
|
+
}
|
|
157
|
+
const out = [];
|
|
158
|
+
for (const loads of byDoc.values()) {
|
|
159
|
+
if (new Set(loads.map((l) => l.layer)).size < 2) continue;
|
|
160
|
+
const seen = new Map(loads.map((l) => [l.id, { id: l.id, layer: l.layer }]));
|
|
161
|
+
out.push({ page: loads[0].page, step: loads[0].step, layers: [...seen.values()] });
|
|
162
|
+
}
|
|
163
|
+
return out;
|
|
164
|
+
}
|
package/src/discover.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { AuditSession } from "./browser.js";
|
|
2
2
|
import { runAgentJourney, StepTimeout } from "./agent.js";
|
|
3
|
+
import { splitRun } from "./frames.js";
|
|
3
4
|
|
|
4
5
|
/** The goal of the browsing part: one page of each type, nothing submitted. */
|
|
5
6
|
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.
|
|
@@ -169,14 +170,16 @@ export async function discoverPlan(opts) {
|
|
|
169
170
|
const pages = pagesSeen(snapshots);
|
|
170
171
|
if (!pages.length) throw new Error("The explorer saw no pages, so there is nothing to plan from.");
|
|
171
172
|
log(`\nExplored ${pages.length} pages${outcome.completed ? "" : ` (${outcome.note})`}. Drafting the plan…`);
|
|
172
|
-
|
|
173
|
+
// An embedded widget's hits are not the site's tracking, so the plan leaves them out.
|
|
174
|
+
const ownHits = splitRun(session).hits;
|
|
175
|
+
const existing = [...new Set(ownHits.map((h) => `${h.platform}:${h.name}`))];
|
|
173
176
|
const result = await opts.provider.json({
|
|
174
177
|
system: topic ? `${PLAN_SYSTEM}\nThis plan covers only ${opts.topic}. Propose only ${topic.propose}.` : PLAN_SYSTEM,
|
|
175
178
|
texts: [`Site: ${opts.site}`, `Pages visited:\n${JSON.stringify(pages, null, 1)}`, `Tracking hits the site already sends: ${existing.length ? existing.join(", ") : "none"}`],
|
|
176
179
|
schema: PLAN_SCHEMA,
|
|
177
180
|
});
|
|
178
181
|
if (result.error) throw new Error(`The model did not return a plan (${result.error}).`);
|
|
179
|
-
const plan = toPlan(opts.site, result.value,
|
|
182
|
+
const plan = toPlan(opts.site, result.value, ownHits);
|
|
180
183
|
if (topic) plan.events = plan.events.filter(topic.keep);
|
|
181
184
|
const coverage = pageTypeCoverage(plan.pages);
|
|
182
185
|
log(`Page types found: ${coverage.found.join(", ") || "none"}`);
|
package/src/frames.js
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Whose tracking a hit is: the site's own, or an embedded widget's. Hits, dataLayer pushes, vendor calls and
|
|
2
|
+
// container loads from an iframe carry { origin, party, parent }: party "site" when the iframe is on the page's
|
|
3
|
+
// own site (a checkout or form on a subdomain), "third" when it is someone else's (a booking or chat widget,
|
|
4
|
+
// a payment form, a video). A third-party iframe's hit is still the site's when it goes to an account the
|
|
5
|
+
// site's own tags use (a booking engine set up with the site's GA4 ID); otherwise it is the widget's.
|
|
6
|
+
import { siteOf } from "./redact.js";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* A Playwright frame as { origin, party, parent }, or null for a page's main frame and for frames without an
|
|
10
|
+
* http(s) address (about:blank, srcdoc), which the page's own scripts write. parent is the address of the
|
|
11
|
+
* page it is embedded in, without its query.
|
|
12
|
+
*/
|
|
13
|
+
export function frameInfo(frame) {
|
|
14
|
+
if (!frame) return null;
|
|
15
|
+
let top;
|
|
16
|
+
try {
|
|
17
|
+
top = frame.page()?.mainFrame();
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
if (!top || frame === top) return null;
|
|
22
|
+
let u;
|
|
23
|
+
let t;
|
|
24
|
+
try {
|
|
25
|
+
u = new URL(frame.url());
|
|
26
|
+
t = new URL(top.url());
|
|
27
|
+
} catch {
|
|
28
|
+
return null;
|
|
29
|
+
}
|
|
30
|
+
if (!/^https?:$/.test(u.protocol) || !/^https?:$/.test(t.protocol)) return null;
|
|
31
|
+
return { origin: u.origin, party: siteOf(u.hostname) === siteOf(t.hostname) ? "site" : "third", parent: t.origin + t.pathname };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Whether a hit, push or load came from the page itself or an iframe on the same site. */
|
|
35
|
+
export const isSiteFrame = (x) => !x?.frame || x.frame.party !== "third";
|
|
36
|
+
|
|
37
|
+
// Account IDs as the hits and the containers name them: GA4's G-…, Google Ads' AW-… (a numeric conversion ID in
|
|
38
|
+
// its hits), Meta's pixel ID and so on. Compared per platform family, without the AW- prefix.
|
|
39
|
+
const idKey = (id) => String(id).trim().toUpperCase().replace(/^AW-/, "");
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* The account IDs the site's own tags use: the containers loaded in the page or a same-site iframe, the IDs its
|
|
43
|
+
* hits went to, and the destinations its gtag('config') commands name.
|
|
44
|
+
*/
|
|
45
|
+
export function siteIds({ hits = [], containerLoads = [], dataLayerLog = [] }) {
|
|
46
|
+
const ids = new Set();
|
|
47
|
+
for (const l of containerLoads) if (isSiteFrame(l)) ids.add(idKey(l.id));
|
|
48
|
+
for (const h of hits) if (isSiteFrame(h) && h.id) ids.add(idKey(h.id));
|
|
49
|
+
for (const e of dataLayerLog) {
|
|
50
|
+
if (isSiteFrame(e) && Array.isArray(e.value) && e.value[0] === "config" && typeof e.value[1] === "string") ids.add(idKey(e.value[1]));
|
|
51
|
+
}
|
|
52
|
+
return ids;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Whether a hit is an embedded widget's: sent from a third-party iframe to an account the site's tags don't use. */
|
|
56
|
+
export function isWidgetHit(hit, ids) {
|
|
57
|
+
return !isSiteFrame(hit) && !(hit.id && ids.has(idKey(hit.id)));
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The run's hits and dataLayer pushes split into the site's own and the widgets': { hits, widgetHits,
|
|
62
|
+
* dataLayerLog, widgetDataLayerLog }. Plan, coverage and duplicate checks read only the site's; the consent and
|
|
63
|
+
* personal data checks read both, since the site answers for what the widgets it embeds collect.
|
|
64
|
+
*/
|
|
65
|
+
export function splitRun({ hits = [], containerLoads = [], dataLayerLog = [] }) {
|
|
66
|
+
const ids = siteIds({ hits, containerLoads, dataLayerLog });
|
|
67
|
+
return {
|
|
68
|
+
hits: hits.filter((h) => !isWidgetHit(h, ids)),
|
|
69
|
+
widgetHits: hits.filter((h) => isWidgetHit(h, ids)),
|
|
70
|
+
dataLayerLog: dataLayerLog.filter(isSiteFrame),
|
|
71
|
+
widgetDataLayerLog: dataLayerLog.filter((e) => !isSiteFrame(e)),
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The widgets that sent hits, by iframe origin: [{ origin, parent, platforms: [{ platform, ids, events }] }]. */
|
|
76
|
+
export function widgetsOf(widgetHits = []) {
|
|
77
|
+
const byOrigin = new Map();
|
|
78
|
+
for (const h of widgetHits) {
|
|
79
|
+
const w = byOrigin.get(h.frame.origin) ?? { origin: h.frame.origin, parent: h.frame.parent, platforms: new Map() };
|
|
80
|
+
const p = w.platforms.get(h.platform) ?? { platform: h.platform, ids: new Set(), events: new Set() };
|
|
81
|
+
if (h.id) p.ids.add(h.id);
|
|
82
|
+
p.events.add(h.name);
|
|
83
|
+
w.platforms.set(h.platform, p);
|
|
84
|
+
byOrigin.set(h.frame.origin, w);
|
|
85
|
+
}
|
|
86
|
+
return [...byOrigin.values()].map((w) => ({
|
|
87
|
+
origin: w.origin, parent: w.parent,
|
|
88
|
+
platforms: [...w.platforms.values()].map((p) => ({ platform: p.platform, ids: [...p.ids], events: [...p.events] })),
|
|
89
|
+
}));
|
|
90
|
+
}
|
package/src/html/scope.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// handling (which runs check which platform, the funnel cells, the inventory's parties and cookies, the skipped
|
|
4
4
|
// vendors, the trend) is the 0.17 renderer's, so the numbers match the Markdown report and the CLI.
|
|
5
5
|
import { buildInventory } from "../inventory.js";
|
|
6
|
+
import { containerRows } from "../containers.js";
|
|
6
7
|
import { providerOf } from "../parsers.js";
|
|
7
8
|
import { siteOf } from "../redact.js";
|
|
8
9
|
import { VERSION, BUCKET_ORDER, coverageLines, pcSort, planChangeItems, routeList } from "../report-shared.js";
|
|
@@ -193,12 +194,24 @@ function inventoryLines(ctx) {
|
|
|
193
194
|
return { lines, otherCookies: inv.otherCookies };
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/** The Google containers card: one line per container and the way it reached the page. Null when none was recorded. */
|
|
198
|
+
function containersCard(ctx) {
|
|
199
|
+
const { m, runs } = ctx;
|
|
200
|
+
const rows = containerRows(runs);
|
|
201
|
+
if (!rows.length) return null;
|
|
202
|
+
const c = m.ui.containers;
|
|
203
|
+
const items = rows.map((r) => `<li>${code(r.id)}${plain(esc(c.script[r.script]))}<span>${esc(c.kinds[r.kind](r.by))}</span>${plain(`${esc(m.ui.layerCol)} ${code(r.layer)}`)}${plain(esc(r.served === "google" ? c.served.google : `${c.served.site} (${r.host})`))}${r.frame ? plain(esc(c.iframe(r.frame.origin, r.frame.party))) : ""}${r.environment ? badge("warning", c.environment(r.environment)) : ""}<span class="inv-n">${m.html.scope.pages(r.pages.length, bold(r.pages.length))}</span></li>`).join("");
|
|
204
|
+
const ids = uniq(rows.map((r) => r.id));
|
|
205
|
+
return `<li><details class="card inv" id="inv-containers"><summary><span class="t">${esc(m.ui.containersTitle)}</span>${plain(n(ids.length))}<span class="inv-m">${esc(ids.slice(0, 3).join(", "))}${ids.length > 3 ? " …" : ""}</span></summary><div class="body"><p class="xs muted">${esc(m.ui.containersIntro)}</p><ul class="inv-list">${items}</ul></div></details></li>`;
|
|
206
|
+
}
|
|
207
|
+
|
|
196
208
|
function inventorySection(ctx) {
|
|
197
209
|
const { m, runs } = ctx;
|
|
198
210
|
const t = m.html.scope;
|
|
199
211
|
const { lines, otherCookies } = inventoryLines(ctx);
|
|
200
212
|
const skipped = skippedTotals(runs);
|
|
201
|
-
|
|
213
|
+
const containers = containersCard(ctx);
|
|
214
|
+
if (!lines.length && !skipped.length && !containers) return sectionBlock("inventory", esc(m.html.shell.sections.inventory), `<p class="muted">${esc(m.ui.noInventory)}</p>`);
|
|
202
215
|
const counts = (x) => `<span class="inv-m">${t.requests(x.requests, bold(x.requests))}</span><span class="inv-m">${t.pages(x.pages.size, bold(x.pages.size))}</span><span class="inv-m${x.cookies.length ? "" : " zero"}">${t.cookies(x.cookies.length, bold(x.cookies.length))}</span>`;
|
|
203
216
|
const out = lines.map((x) => {
|
|
204
217
|
const id = `inv-${safeKey(x.p)}`;
|
|
@@ -214,6 +227,7 @@ function inventorySection(ctx) {
|
|
|
214
227
|
<div role="tabpanel" id="${tid("pages")}" data-label="${esc(m.ui.pagesCol)}">${pages ? `<ul class="inv-list">${pages}</ul>` : none}</div>
|
|
215
228
|
<div role="tabpanel" id="${tid("cookies")}" data-label="${esc(m.ui.cookiesCol)}">${x.cookies.length ? `<ul class="inv-list">${x.cookies.map((ck) => `<li>${code(ck)}</li>`).join("")}</ul>` : none}</div></div></div></details></li>`;
|
|
216
229
|
});
|
|
230
|
+
if (containers) out.unshift(containers);
|
|
217
231
|
const skippedN = skipped.reduce((a, [, k]) => a + k, 0);
|
|
218
232
|
if (skipped.length) {
|
|
219
233
|
out.push(`<li><details class="card inv" id="inv-out-of-scope"><summary><span class="t">${esc(m.ui.outOfScopeStatus)}</span>${plain(t.requests(skippedN, bold(skippedN)))}<span class="inv-m">${esc(skipped.slice(0, 3).map(([v]) => v).join(", "))}${skipped.length > 3 ? " …" : ""}</span></summary><div class="body"><ul class="inv-list">${skipped.map(([v, k]) => `<li><span>${esc(v)}</span>${badge("muted", m.ui.outOfScopeStatus)}<span class="inv-n">${t.requests(k, bold(k))}</span></li>`).join("")}</ul></div></details></li>`);
|
package/src/i18n.js
CHANGED
|
@@ -202,6 +202,30 @@ const en = {
|
|
|
202
202
|
party: { first: "First party", third: "Third party", both: "Both" },
|
|
203
203
|
otherCookies: "Other cookies",
|
|
204
204
|
noInventory: "No vendor requests were recorded.",
|
|
205
|
+
containersTitle: "Google containers",
|
|
206
|
+
containersIntro: "How each GTM container and Google tag got onto the page: what inserted its script, the dataLayer it reads and where it was served from.",
|
|
207
|
+
noContainers: "No GTM container or Google tag script was loaded.",
|
|
208
|
+
containerCol: "Container",
|
|
209
|
+
sourceCol: "Loaded by",
|
|
210
|
+
layerCol: "dataLayer",
|
|
211
|
+
servedCol: "Served from",
|
|
212
|
+
containers: {
|
|
213
|
+
script: { gtm: "GTM container", gtag: "Google tag" },
|
|
214
|
+
served: { google: "Google", site: "The site's own domain" },
|
|
215
|
+
environment: (env) => (env === "unnamed" ? "GTM environment" : `GTM environment ${env}`),
|
|
216
|
+
iframe: (origin, party) => `In an iframe from ${origin} (${party === "site" ? "same site" : "third party"})`,
|
|
217
|
+
kinds: {
|
|
218
|
+
html: () => "A script tag in the page's HTML",
|
|
219
|
+
inline: () => "Inline code in the page (the snippet)",
|
|
220
|
+
script: (by) => `Another script on the page: ${by}`,
|
|
221
|
+
custom_html: (by) => `A Custom HTML tag in ${by}`,
|
|
222
|
+
google_tag: (by) => `A Google tag in ${by} (load_google_tags)`,
|
|
223
|
+
nested: (by) => `${by} itself: a Zone or a tag template`,
|
|
224
|
+
config: (by) => `A gtag('config') command, loaded by ${by}`,
|
|
225
|
+
send_to: (by) => `An event's send_to with no config, loaded by ${by}`,
|
|
226
|
+
destination: (by) => `A destination of the Google tag ${by}`,
|
|
227
|
+
},
|
|
228
|
+
},
|
|
205
229
|
provider: "Provider",
|
|
206
230
|
checked: "Checked",
|
|
207
231
|
outOfScopeStatus: "Out of scope",
|
|
@@ -323,6 +347,13 @@ const en = {
|
|
|
323
347
|
campaignOk: (names) => `${names.join(", ")} survived the landing page's redirects and reached the hits.`,
|
|
324
348
|
gclidNotStored: () => `The landing address carried a gclid and ad storage was granted, but the _gcl_aw cookie never held it. Google Ads cannot tie a conversion later in the visit, or on a later visit, to the ad click.`,
|
|
325
349
|
clientIdChanged: (from, at) => `The GA4 client id in the _ga cookie changed between "${from}" and "${at}". GA4 counts the visit before and after as two different users.`,
|
|
350
|
+
containerDuplicate: (id, sources, step) => `${id} was loaded ${sources.length} times into one page at "${step}": ${sources.join("; ")}. Each copy can run the container's tags again, so hits can go out twice; at best one install is dead weight.`,
|
|
351
|
+
containerEnvironment: (id, env) => `${id} is loaded from a GTM environment (${env === "unnamed" ? "gtm_auth" : `gtm_preview=${env}`}) instead of the Live version, so visitors run whatever was last published to that environment, often a staging or development version.`,
|
|
352
|
+
dataLayerSplit: (list, step) => `Containers on one page at "${step}" read different dataLayers: ${list.join(", ")}. A push to one is never seen by the containers reading another, so events and consent commands may reach only some of them.`,
|
|
353
|
+
sendToOnly: (id, by) => `${id} was loaded only because an event named it in send_to; no gtag('config', '${id}') ran. ${by} sends to it with default settings, so whatever a config command would set for it does not apply.`,
|
|
354
|
+
fromWidget: (origin, text) => `In an embedded widget from ${origin}: ${text}`,
|
|
355
|
+
widgetTracking: (origin, platforms) => `An embedded widget from ${origin} sent its own tracking: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. It goes to accounts the site's own tags don't use, so it was left out of the plan, coverage and duplicate checks; the consent and personal data checks still include it.`,
|
|
356
|
+
iframeContainer: (id, origin, parent) => `${id} runs in an iframe from ${origin} embedded in ${parent}. The iframe has its own dataLayer: events and consent commands pushed in the page never reach it, so it needs its own.`,
|
|
326
357
|
consentDefaultMissing: () => "No Google Consent Mode default command (gtag('consent', 'default', ...)) was found in the dataLayer.",
|
|
327
358
|
consentDefaultLate: () => "The Consent Mode default was set after the first tracking hit was sent.",
|
|
328
359
|
consentDefaultNotApplied: () => "The page pushed a Consent Mode default, but the Google tag never applied it, so its tags ran without one. Tag Assistant shows Consent Mode as not configured.",
|
|
@@ -378,6 +409,12 @@ const en = {
|
|
|
378
409
|
page_crashed: "Visitors who get the same error see a broken page, and the tags on it may not run.",
|
|
379
410
|
gclid_not_stored: "Conversions are not linked to the ad click that brought the visitor, so Google Ads under-reports them and bids on less data.",
|
|
380
411
|
client_id_changed: "One visitor becomes two users in GA4: the visit is split, the journey before the change loses its conversions, and attribution starts over.",
|
|
412
|
+
container_duplicate: "A container loaded twice can fire its tags twice, inflating page views and conversions; at best one install is redundant.",
|
|
413
|
+
container_environment: "Visitors run a non-Live version of the container, so tags still under test, or an old setup, collect production data.",
|
|
414
|
+
datalayer_split: "Events and consent commands pushed to one dataLayer never reach the containers reading another, so some tags miss them.",
|
|
415
|
+
container_send_to_only: "The destination runs without its own config, so the settings a config command would give it are missing.",
|
|
416
|
+
widget_tracking: "Nothing is wrong with the site's tracking: the widget's own hits were kept out of the plan checks so they can't pass for the site's.",
|
|
417
|
+
iframe_container: "Tags in the iframe miss what the page pushes, so conversions completed in it can go untracked or be sent without the visitor's consent.",
|
|
381
418
|
campaign_params: "Campaign parameters survived the landing redirect and reached the hits, so paid clicks are credited to their campaigns.",
|
|
382
419
|
event_id_reused: "Two different events share one event ID, so the platform keeps the first and silently drops the other: a real event is lost.",
|
|
383
420
|
event_id_mismatch: "The browser and server copies of this event carry different IDs, so Meta cannot pair them and counts the conversion twice.",
|
|
@@ -420,6 +457,12 @@ const en = {
|
|
|
420
457
|
page_crashed: "Open the page in an ordinary browser. If it works there, the site's bot protection refused its scripts to the audit's network; if not, a deployment serves pages that ask for script files it no longer has.",
|
|
421
458
|
gclid_not_stored: "Fire the Conversion Linker tag (or the Google tag with the Ads ID) on every page, on consent granted as well as on page load, and keep the gclid on the landing address until it has run.",
|
|
422
459
|
client_id_changed: "Look for what rewrites or deletes _ga at that step: a consent platform clearing cookies, a second GA4 setup with another cookie_domain or cookie_prefix, or a cross-domain link without the _gl parameter.",
|
|
460
|
+
container_duplicate: "Keep one install: remove the extra snippet, or the tag in the other container that loads it again.",
|
|
461
|
+
container_environment: "Use the Live snippet in production: take gtm_auth and gtm_preview out of the GTM snippet, or publish what the environment holds to Live.",
|
|
462
|
+
datalayer_split: "Point every container at the same dataLayer, or push events and consent commands to each dataLayer the containers read.",
|
|
463
|
+
container_send_to_only: "Add a gtag('config', ID) for the destination before the events that send to it, or a Google tag for it in GTM.",
|
|
464
|
+
widget_tracking: "Nothing to fix for the plan. If the widget sets cookies or sends data before consent, the consent findings name it; ask its vendor how to hold it back.",
|
|
465
|
+
iframe_container: "Push the events and the consent state inside the iframe as well (or pass them in with postMessage), or track the iframe's steps from the page.",
|
|
423
466
|
campaign_params: "Nothing to fix. Check the redirects again when the landing pages change.",
|
|
424
467
|
event_id_reused: "Generate a new event ID for every event, not once per page load.",
|
|
425
468
|
event_id_mismatch: "Use one event ID for the pixel event and the server copy: pass the same value to both tags.",
|
|
@@ -462,6 +505,12 @@ const en = {
|
|
|
462
505
|
page_crashed: "hygiene",
|
|
463
506
|
gclid_not_stored: "money",
|
|
464
507
|
client_id_changed: "money",
|
|
508
|
+
container_duplicate: "money",
|
|
509
|
+
container_environment: "hygiene",
|
|
510
|
+
datalayer_split: "hygiene",
|
|
511
|
+
container_send_to_only: "hygiene",
|
|
512
|
+
widget_tracking: "hygiene",
|
|
513
|
+
iframe_container: "hygiene",
|
|
465
514
|
campaign_params: "money",
|
|
466
515
|
event_id_reused: "money",
|
|
467
516
|
event_id_mismatch: "money",
|
|
@@ -980,6 +1029,30 @@ const tr = {
|
|
|
980
1029
|
party: { first: "Birinci taraf", third: "Üçüncü taraf", both: "İkisi de" },
|
|
981
1030
|
otherCookies: "Diğer çerezler",
|
|
982
1031
|
noInventory: "Sağlayıcıya giden istek kaydedilmedi.",
|
|
1032
|
+
containersTitle: "Google container'ları",
|
|
1033
|
+
containersIntro: "Her GTM container'ının ve Google etiketinin sayfaya nasıl geldiği: betiğini neyin eklediği, okuduğu dataLayer ve nereden sunulduğu.",
|
|
1034
|
+
noContainers: "Hiçbir GTM container'ı veya Google etiketi betiği yüklenmedi.",
|
|
1035
|
+
containerCol: "Container",
|
|
1036
|
+
sourceCol: "Yükleyen",
|
|
1037
|
+
layerCol: "dataLayer",
|
|
1038
|
+
servedCol: "Sunulduğu yer",
|
|
1039
|
+
containers: {
|
|
1040
|
+
script: { gtm: "GTM container'ı", gtag: "Google etiketi" },
|
|
1041
|
+
served: { google: "Google", site: "Sitenin kendi alan adı" },
|
|
1042
|
+
environment: (env) => (env === "unnamed" ? "GTM ortamı" : `GTM ortamı ${env}`),
|
|
1043
|
+
iframe: (origin, party) => `${origin} kaynaklı bir iframe içinde (${party === "site" ? "aynı site" : "üçüncü taraf"})`,
|
|
1044
|
+
kinds: {
|
|
1045
|
+
html: () => "Sayfanın HTML'indeki bir script etiketi",
|
|
1046
|
+
inline: () => "Sayfadaki satır içi kod (snippet)",
|
|
1047
|
+
script: (by) => `Sayfadaki başka bir betik: ${by}`,
|
|
1048
|
+
custom_html: (by) => `${by} içindeki bir Özel HTML etiketi`,
|
|
1049
|
+
google_tag: (by) => `${by} içindeki bir Google etiketi (load_google_tags)`,
|
|
1050
|
+
nested: (by) => `${by} kendisi: bir Zone veya etiket şablonu`,
|
|
1051
|
+
config: (by) => `Bir gtag('config') komutu, ${by} yükledi`,
|
|
1052
|
+
send_to: (by) => `config olmadan bir olayın send_to değeri, ${by} yükledi`,
|
|
1053
|
+
destination: (by) => `${by} Google etiketinin bir hedefi`,
|
|
1054
|
+
},
|
|
1055
|
+
},
|
|
983
1056
|
provider: "Sağlayıcı",
|
|
984
1057
|
checked: "Kontrol edildi",
|
|
985
1058
|
outOfScopeStatus: "Kapsam dışı",
|
|
@@ -1097,6 +1170,13 @@ const tr = {
|
|
|
1097
1170
|
campaignOk: (names) => `${names.join(", ")} açılış sayfasının yönlendirmelerinden geçti ve isteklere ulaştı.`,
|
|
1098
1171
|
gclidNotStored: () => `Açılış adresi bir gclid taşıyordu ve reklam depolamasına izin verildi, ancak _gcl_aw çerezi onu hiç tutmadı. Google Ads, ziyaretin ilerleyen adımlarındaki ya da sonraki bir ziyaretteki dönüşümü reklam tıklamasına bağlayamaz.`,
|
|
1099
1172
|
clientIdChanged: (from, at) => `_ga çerezindeki GA4 istemci kimliği "${from}" ile "${at}" arasında değişti. GA4 değişiklikten önceki ve sonraki ziyareti iki ayrı kullanıcı sayar.`,
|
|
1173
|
+
containerDuplicate: (id, sources, step) => `${id}, "${step}" adımında aynı sayfaya ${sources.length} kez yüklendi: ${sources.join("; ")}. Her kopya container'ın etiketlerini yeniden çalıştırabilir, bu yüzden istekler iki kez gidebilir; en iyi ihtimalle kurulumlardan biri boşuna yükleniyor.`,
|
|
1174
|
+
containerEnvironment: (id, env) => `${id}, Live sürüm yerine bir GTM ortamından (${env === "unnamed" ? "gtm_auth" : `gtm_preview=${env}`}) yükleniyor; ziyaretçiler o ortama en son ne yayımlandıysa onu, çoğu zaman bir test veya geliştirme sürümünü çalıştırıyor.`,
|
|
1175
|
+
dataLayerSplit: (list, step) => `"${step}" adımında aynı sayfadaki container'lar farklı dataLayer'ları okuyor: ${list.join(", ")}. Birine yapılan push diğerini okuyan container'lar tarafından hiç görülmez; olaylar ve onay komutları yalnızca bazılarına ulaşabilir.`,
|
|
1176
|
+
sendToOnly: (id, by) => `${id} yalnızca bir olay send_to ile onu adlandırdığı için yüklendi; gtag('config', '${id}') hiç çalışmadı. ${by} ona varsayılan ayarlarla gönderiyor, bu yüzden bir config komutunun onun için ayarlayacağı hiçbir şey uygulanmıyor.`,
|
|
1177
|
+
fromWidget: (origin, text) => `${origin} kaynaklı gömülü bir widget içinde: ${text}`,
|
|
1178
|
+
widgetTracking: (origin, platforms) => `${origin} kaynaklı gömülü bir widget kendi izlemesini gönderdi: ${platforms.map((p) => `${label(p.platform)}${p.ids.length ? ` (${p.ids.join(", ")})` : ""} ${p.events.map(q).join(", ")}`).join("; ")}. Bunlar sitenin kendi etiketlerinin kullanmadığı hesaplara gidiyor; bu yüzden plan, kapsam ve tekrar kontrollerinin dışında tutuldu, onay ve kişisel veri kontrolleri ise onları yine içeriyor.`,
|
|
1179
|
+
iframeContainer: (id, origin, parent) => `${id}, ${parent} içine gömülü ${origin} kaynaklı bir iframe içinde çalışıyor. iframe'in kendi dataLayer'ı var: sayfada gönderilen olaylar ve onay komutları ona hiç ulaşmaz, bu yüzden kendi olaylarına ve onayına ihtiyacı var.`,
|
|
1100
1180
|
consentDefaultMissing: () => "dataLayer'da Google Consent Mode varsayılan komutu (gtag('consent', 'default', ...)) bulunamadı.",
|
|
1101
1181
|
consentDefaultLate: () => "Consent Mode varsayılanı, ilk izleme isteği gönderildikten sonra ayarlandı.",
|
|
1102
1182
|
consentDefaultNotApplied: () => "Sayfa bir Consent Mode varsayılanı gönderdi, ancak Google etiketi onu hiç uygulamadı; etiketleri varsayılan olmadan çalıştı. Tag Assistant, Consent Mode'u yapılandırılmamış gösterir.",
|
|
@@ -1151,6 +1231,12 @@ const tr = {
|
|
|
1151
1231
|
page_crashed: "Aynı hatayı alan ziyaretçiler bozuk bir sayfa görür ve sayfadaki etiketler çalışmayabilir.",
|
|
1152
1232
|
gclid_not_stored: "Dönüşümler ziyaretçiyi getiren reklam tıklamasına bağlanmıyor; Google Ads onları eksik raporluyor ve daha az veriyle teklif veriyor.",
|
|
1153
1233
|
client_id_changed: "Bir ziyaretçi GA4'te iki kullanıcıya dönüşüyor: ziyaret bölünüyor, değişiklikten önceki yolculuk dönüşümlerini kaybediyor ve atıf baştan başlıyor.",
|
|
1234
|
+
container_duplicate: "İki kez yüklenen bir container etiketlerini iki kez çalıştırabilir; sayfa görüntülemeleri ve dönüşümler şişer, en iyi ihtimalle kurulumlardan biri gereksizdir.",
|
|
1235
|
+
container_environment: "Ziyaretçiler container'ın Live olmayan bir sürümünü çalıştırıyor; hâlâ test edilen etiketler ya da eski bir kurulum canlı veri topluyor.",
|
|
1236
|
+
datalayer_split: "Bir dataLayer'a gönderilen olaylar ve onay komutları diğerini okuyan container'lara hiç ulaşmaz; bazı etiketler onları kaçırır.",
|
|
1237
|
+
container_send_to_only: "Hedef kendi config'i olmadan çalışıyor; bir config komutunun ona vereceği ayarlar eksik.",
|
|
1238
|
+
widget_tracking: "Sitenin izlemesinde bir sorun yok: widget'ın kendi istekleri sitenin isteklerinin yerine geçmesin diye plan kontrollerinin dışında tutuldu.",
|
|
1239
|
+
iframe_container: "iframe içindeki etiketler sayfanın gönderdiklerini görmez; içinde tamamlanan dönüşümler izlenmeyebilir ya da ziyaretçinin onayı olmadan gönderilebilir.",
|
|
1154
1240
|
campaign_params: "Kampanya parametreleri açılış yönlendirmesinden geçip isteklere ulaştı; ücretli tıklamalar kendi kampanyalarına yazılıyor.",
|
|
1155
1241
|
event_id_reused: "İki farklı olay tek bir olay kimliğini paylaşıyor; platform ilkini tutup diğerini sessizce atıyor, gerçek bir olay kayboluyor.",
|
|
1156
1242
|
event_id_mismatch: "Bu olayın tarayıcı ve sunucu kopyaları farklı kimlik taşıyor; Meta ikisini eşleştiremiyor ve dönüşümü iki kez sayıyor.",
|
|
@@ -1191,6 +1277,12 @@ const tr = {
|
|
|
1191
1277
|
page_crashed: "Sayfayı sıradan bir tarayıcıda açın. Orada çalışıyorsa sitenin bot koruması betiklerini denetimin ağına vermedi; çalışmıyorsa bir dağıtım artık sahip olmadığı betik dosyalarını isteyen sayfalar sunuyor.",
|
|
1192
1278
|
gclid_not_stored: "Dönüşüm bağlayıcı etiketini (ya da Ads kimliğiyle Google etiketini) her sayfada, sayfa yüklenince olduğu kadar onay verildiğinde de çalıştırın ve gclid'i o etiket çalışana kadar açılış adresinde tutun.",
|
|
1193
1279
|
client_id_changed: "O adımda _ga'yı yeniden yazan ya da silen şeyi arayın: çerezleri temizleyen bir onay platformu, başka bir cookie_domain ya da cookie_prefix ile ikinci bir GA4 kurulumu, ya da _gl parametresi olmayan alan adları arası bir bağlantı.",
|
|
1280
|
+
container_duplicate: "Tek bir kurulum bırakın: fazladan snippet'i ya da onu diğer container'da yeniden yükleyen etiketi kaldırın.",
|
|
1281
|
+
container_environment: "Canlı sitede Live snippet'ini kullanın: GTM snippet'inden gtm_auth ve gtm_preview'ı çıkarın ya da ortamdaki sürümü Live'a yayımlayın.",
|
|
1282
|
+
datalayer_split: "Tüm container'ları aynı dataLayer'a yönlendirin ya da olayları ve onay komutlarını container'ların okuduğu her dataLayer'a gönderin.",
|
|
1283
|
+
container_send_to_only: "Hedefe gönderen olaylardan önce onun için bir gtag('config', ID) ya da GTM'de onun için bir Google etiketi ekleyin.",
|
|
1284
|
+
widget_tracking: "Plan için düzeltilecek bir şey yok. Widget onaydan önce çerez yazıyor ya da veri gönderiyorsa onay bulguları onu adlandırır; nasıl durdurulacağını sağlayıcısına sorun.",
|
|
1285
|
+
iframe_container: "Olayları ve onay durumunu iframe içinde de gönderin (ya da postMessage ile iletin) veya iframe'deki adımları sayfadan izleyin.",
|
|
1194
1286
|
campaign_params: "Düzeltilecek bir şey yok. Açılış sayfaları değiştiğinde yönlendirmeleri yeniden kontrol edin.",
|
|
1195
1287
|
event_id_reused: "Her olay için yeni bir olay kimliği üretin; sayfa yüklemesi başına bir kez değil.",
|
|
1196
1288
|
event_id_mismatch: "Piksel olayı ve sunucu kopyası için tek bir olay kimliği kullanın: aynı değeri iki etikete de verin.",
|
|
@@ -1232,6 +1324,12 @@ const tr = {
|
|
|
1232
1324
|
page_crashed: "hygiene",
|
|
1233
1325
|
gclid_not_stored: "money",
|
|
1234
1326
|
client_id_changed: "money",
|
|
1327
|
+
container_duplicate: "money",
|
|
1328
|
+
container_environment: "hygiene",
|
|
1329
|
+
datalayer_split: "hygiene",
|
|
1330
|
+
container_send_to_only: "hygiene",
|
|
1331
|
+
widget_tracking: "hygiene",
|
|
1332
|
+
iframe_container: "hygiene",
|
|
1235
1333
|
campaign_params: "money",
|
|
1236
1334
|
event_id_reused: "money",
|
|
1237
1335
|
event_id_mismatch: "money",
|