@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/browser.js
ADDED
|
@@ -0,0 +1,579 @@
|
|
|
1
|
+
import { chromium, devices } from "playwright";
|
|
2
|
+
import { parseTrackingRequest, isCollectionEndpoint, providerOf, vendorOf, resolveProviders } from "./parsers.js";
|
|
3
|
+
import { redactBody, redactParams, redactPush, redactUrl } from "./redact.js";
|
|
4
|
+
import { hostScope, isPrivateAddress, navigationError, privateHostChecker } from "./guard.js";
|
|
5
|
+
import { startEgressProxy } from "./egress.js";
|
|
6
|
+
|
|
7
|
+
// Caps on what one audit records, so a looping page can't grow memory without limit.
|
|
8
|
+
const LOG_LIMITS = { hits: 5000, dataLayerLog: 5000, network: 10000 };
|
|
9
|
+
|
|
10
|
+
// Secrets the browser process has no use for. It gets the rest of the environment
|
|
11
|
+
// (PATH, HOME, DISPLAY...) so Chromium still starts normally.
|
|
12
|
+
const SECRET_ENV = /(API_?KEY|TOKEN|SECRET|PASSWORD|PASSWD|CREDENTIAL|PRIVATE_KEY|_KEY$|^AWS_|^AZURE_|^GOOGLE_APPLICATION)/i;
|
|
13
|
+
export const browserEnv = (env = process.env) => Object.fromEntries(Object.entries(env).filter(([k]) => !SECRET_ENV.test(k)));
|
|
14
|
+
|
|
15
|
+
export const VIEWPORTS = {
|
|
16
|
+
desktop: { viewport: { width: 1366, height: 900 } },
|
|
17
|
+
mobile: devices["Pixel 7"],
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
// Runs in the page before any site script: reports every dataLayer push
|
|
21
|
+
// (including gtag consent commands) to Node through an exposed binding, so the
|
|
22
|
+
// log survives page navigations.
|
|
23
|
+
const INIT_SCRIPT = `(() => {
|
|
24
|
+
const record = (entry) => {
|
|
25
|
+
let value = entry;
|
|
26
|
+
try {
|
|
27
|
+
// gtag() pushes an arguments object; store it as a plain array.
|
|
28
|
+
if (entry && typeof entry === "object" && typeof entry.length === "number" && !Array.isArray(entry)) value = Array.from(entry);
|
|
29
|
+
value = JSON.parse(JSON.stringify(value));
|
|
30
|
+
} catch { value = String(entry); }
|
|
31
|
+
if (window.__atdRecord) window.__atdRecord({ url: location.href, value });
|
|
32
|
+
};
|
|
33
|
+
const wrap = (dl) => {
|
|
34
|
+
if (!dl || dl.__atdWrapped) return dl;
|
|
35
|
+
dl.forEach(record);
|
|
36
|
+
const push = dl.push.bind(dl);
|
|
37
|
+
dl.push = (...items) => { items.forEach(record); return push(...items); };
|
|
38
|
+
dl.__atdWrapped = true;
|
|
39
|
+
return dl;
|
|
40
|
+
};
|
|
41
|
+
let current = wrap(window.dataLayer || []);
|
|
42
|
+
Object.defineProperty(window, "dataLayer", {
|
|
43
|
+
configurable: true,
|
|
44
|
+
get: () => current,
|
|
45
|
+
set: (v) => { current = wrap(v); },
|
|
46
|
+
});
|
|
47
|
+
})();`;
|
|
48
|
+
|
|
49
|
+
// Labels the interactive elements of one frame with numeric refs, starting at
|
|
50
|
+
// `first`, and returns a compact text view for the agent. Besides links, buttons
|
|
51
|
+
// and form fields, it finds custom controls that sites build from divs and spans
|
|
52
|
+
// (role attributes, tabindex, or a pointer cursor). Elements on screen come first,
|
|
53
|
+
// so a long menu or footer can't push the button the agent needs out of the list.
|
|
54
|
+
function snapshotInPage({ first, limit, textLimit, frameBox }) {
|
|
55
|
+
const visible = (el) => {
|
|
56
|
+
const r = el.getBoundingClientRect();
|
|
57
|
+
if (r.width <= 0 || r.height <= 0) return false;
|
|
58
|
+
const s = getComputedStyle(el);
|
|
59
|
+
return s.visibility !== "hidden" && s.display !== "none" && Number(s.opacity) > 0;
|
|
60
|
+
};
|
|
61
|
+
const clean = (t) => (t || "").replace(/\s+/g, " ").trim();
|
|
62
|
+
const label = (el) => {
|
|
63
|
+
const labelledBy = el.getAttribute("aria-labelledby");
|
|
64
|
+
const byId = labelledBy ? labelledBy.split(/\s+/).map((id) => document.getElementById(id)?.innerText || "").join(" ") : "";
|
|
65
|
+
const forLabel = el.id ? document.querySelector(`label[for="${CSS.escape(el.id)}"]`)?.innerText : "";
|
|
66
|
+
const img = el.querySelector?.("img[alt], svg title");
|
|
67
|
+
return clean(
|
|
68
|
+
el.getAttribute("aria-label") || byId || el.innerText || el.value || forLabel || el.getAttribute("placeholder") ||
|
|
69
|
+
el.getAttribute("title") || (img ? img.getAttribute("alt") || img.textContent : "") || el.getAttribute("name") || "",
|
|
70
|
+
).slice(0, 80);
|
|
71
|
+
};
|
|
72
|
+
const NATIVE = "a[href], button, input:not([type=hidden]), select, textarea, summary, [contenteditable=true]";
|
|
73
|
+
const ROLES = "[role=button], [role=link], [role=tab], [role=option], [role=menuitem], [role=checkbox], [role=radio], [role=switch], [role=combobox], [role=gridcell], [onclick], [tabindex]:not([tabindex='-1'])";
|
|
74
|
+
// Every element in the document, including open shadow roots.
|
|
75
|
+
const all = [];
|
|
76
|
+
const walk = (root) => {
|
|
77
|
+
for (const el of root.querySelectorAll("*")) {
|
|
78
|
+
all.push(el);
|
|
79
|
+
if (el.shadowRoot) walk(el.shadowRoot);
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
walk(document);
|
|
83
|
+
// Refs from the previous page view, including inside shadow roots.
|
|
84
|
+
for (const el of all) if (el.hasAttribute("data-atd-ref")) el.removeAttribute("data-atd-ref");
|
|
85
|
+
const vh = window.innerHeight;
|
|
86
|
+
const vw = window.innerWidth;
|
|
87
|
+
const found = new Set();
|
|
88
|
+
for (const el of all) {
|
|
89
|
+
if (el.matches(NATIVE) || el.matches(ROLES)) found.add(el);
|
|
90
|
+
}
|
|
91
|
+
// Custom controls: a pointer cursor where the parent has none, with text or a label.
|
|
92
|
+
// Only for elements within a screen or so of the visible area, to keep this fast on big pages.
|
|
93
|
+
const parentOf = (el) => el.parentElement ?? el.getRootNode().host ?? null;
|
|
94
|
+
for (const el of all) {
|
|
95
|
+
if (found.has(el) || el === document.body || el === document.documentElement) continue;
|
|
96
|
+
const r = el.getBoundingClientRect();
|
|
97
|
+
if (r.width <= 0 || r.height <= 0 || r.bottom < -vh || r.top > 2 * vh) continue;
|
|
98
|
+
if (getComputedStyle(el).cursor !== "pointer") continue;
|
|
99
|
+
const parent = parentOf(el);
|
|
100
|
+
if (parent && parent.nodeType === 1 && getComputedStyle(parent).cursor === "pointer") continue;
|
|
101
|
+
if (el.closest(NATIVE) || el.closest(ROLES)) continue;
|
|
102
|
+
if (!visible(el)) continue;
|
|
103
|
+
if (label(el)) found.add(el);
|
|
104
|
+
}
|
|
105
|
+
// Tier 0: on screen and actually on top at its center. Tier 1: above or below the
|
|
106
|
+
// visible area. Tier 2: on screen but covered or clipped (collapsed menus, overlays).
|
|
107
|
+
// In an iframe, "on screen" also means inside the frame's visible box in the main page.
|
|
108
|
+
const inView = (r) => {
|
|
109
|
+
if (r.bottom <= 0 || r.top >= vh) return false;
|
|
110
|
+
if (!frameBox) return true;
|
|
111
|
+
const top = frameBox.y + r.top;
|
|
112
|
+
return top + r.height > 0 && top < frameBox.viewportHeight;
|
|
113
|
+
};
|
|
114
|
+
const tierOf = (el) => {
|
|
115
|
+
const r = el.getBoundingClientRect();
|
|
116
|
+
if (!inView(r)) return 1;
|
|
117
|
+
const x = Math.min(Math.max(r.left + r.width / 2, 0), vw - 1);
|
|
118
|
+
const y = Math.min(Math.max(r.top + r.height / 2, 0), vh - 1);
|
|
119
|
+
let hit = document.elementFromPoint(x, y);
|
|
120
|
+
while (hit?.shadowRoot && hit.shadowRoot.elementFromPoint(x, y) && hit.shadowRoot.elementFromPoint(x, y) !== hit) hit = hit.shadowRoot.elementFromPoint(x, y);
|
|
121
|
+
return hit && (hit === el || el.contains(hit)) ? 0 : 2;
|
|
122
|
+
};
|
|
123
|
+
const ranked = [...found]
|
|
124
|
+
.filter(visible)
|
|
125
|
+
.map((el, order) => ({ el, order, tier: tierOf(el) }))
|
|
126
|
+
.sort((a, b) => a.tier - b.tier || a.order - b.order);
|
|
127
|
+
const firstCovered = ranked.findIndex((x) => x.tier === 2);
|
|
128
|
+
const items = ranked
|
|
129
|
+
.filter((item, i) => item.tier < 2 || i - firstCovered < 30) // only a few covered or clipped ones
|
|
130
|
+
.slice(0, limit)
|
|
131
|
+
.map((item) => ({ ...item, onScreen: item.tier === 0 }));
|
|
132
|
+
const lines = [];
|
|
133
|
+
let ref = first;
|
|
134
|
+
for (const { el, onScreen } of items) {
|
|
135
|
+
el.setAttribute("data-atd-ref", String(ref));
|
|
136
|
+
const tag = el.tagName.toLowerCase();
|
|
137
|
+
let desc = el.getAttribute("role") || tag;
|
|
138
|
+
if (tag === "input") desc = `input[type=${el.type}]${el.name ? ` name=${el.name}` : ""}${el.readOnly ? " readonly" : ""}`;
|
|
139
|
+
if (tag === "a") desc = `link -> ${el.getAttribute("href")}`;
|
|
140
|
+
if (tag === "select") desc = `select${el.name ? ` name=${el.name}` : ""} options=${[...el.options].map((o) => o.value).join("|").slice(0, 200)}`;
|
|
141
|
+
if (!["a", "button", "input", "select", "textarea"].includes(tag) && !el.getAttribute("role")) desc = `clickable ${tag}`;
|
|
142
|
+
if (el.disabled || el.getAttribute("aria-disabled") === "true") desc += " disabled";
|
|
143
|
+
lines.push(`[${ref}] ${desc} "${label(el)}"${onScreen ? "" : " (not in view)"}`);
|
|
144
|
+
ref += 1;
|
|
145
|
+
}
|
|
146
|
+
const text = (document.body?.innerText || "").replace(/\s+\n/g, "\n").slice(0, textLimit);
|
|
147
|
+
return { url: location.href, title: document.title, lines, next: ref, text, more: ranked.length > items.length };
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const MAX_ELEMENTS = 250;
|
|
151
|
+
|
|
152
|
+
const MAX_BODY = 32 * 1024;
|
|
153
|
+
export const MAX_TOTAL_BODIES = 2 * 1024 * 1024;
|
|
154
|
+
/** A tracking request's POST body (GA4 batches, TikTok JSON), so the report can show every event in it. */
|
|
155
|
+
function trackingBody(request) {
|
|
156
|
+
if (request.method() !== "POST" || !parseTrackingRequest(request.url(), null)) return null;
|
|
157
|
+
const body = request.postData();
|
|
158
|
+
return body ? (body.length > MAX_BODY ? body.slice(0, MAX_BODY) : body) : null;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export class AuditSession {
|
|
162
|
+
/**
|
|
163
|
+
* @param {{ viewport: string, site?: string, allowedHosts?: string[], providers?: string[], keepBodies?: boolean, blockPrivate?: boolean | ((ip: string) => boolean), blockHits?: boolean, headless?: boolean }} opts
|
|
164
|
+
* keepBodies: store tracking URLs and bodies as sent. By default, user data for ad matching
|
|
165
|
+
* (emails, phone numbers, user ids, hashed or not) is replaced with a marker first.
|
|
166
|
+
* providers: which tracking providers to record (default GA4, Google Ads, Meta, TikTok). Requests to the
|
|
167
|
+
* others are still answered locally, but only counted, per step, in this.skipped.
|
|
168
|
+
* site: navigation is limited to this site's host and its subdomains, plus allowedHosts.
|
|
169
|
+
* blockPrivate: refuse loopback, private and link-local addresses (used when the MCP server is reachable over HTTP).
|
|
170
|
+
* All browser traffic then goes through the egress proxy. A function decides which addresses count as private (for tests).
|
|
171
|
+
*/
|
|
172
|
+
constructor(opts) {
|
|
173
|
+
this.opts = { blockHits: true, headless: true, ...opts };
|
|
174
|
+
this.scope = this.opts.site ? hostScope(this.opts.site, this.opts.allowedHosts) : null;
|
|
175
|
+
this.isBlockedAddress = typeof this.opts.blockPrivate === "function" ? this.opts.blockPrivate : isPrivateAddress;
|
|
176
|
+
this.isPrivate = this.opts.blockPrivate ? privateHostChecker(this.isBlockedAddress) : null;
|
|
177
|
+
this.blocked = null;
|
|
178
|
+
this.hits = [];
|
|
179
|
+
this.steps = [];
|
|
180
|
+
this.consentStep = null;
|
|
181
|
+
this.errors = [];
|
|
182
|
+
this.dataLayerLog = [];
|
|
183
|
+
this.network = [];
|
|
184
|
+
this.providers = resolveProviders(this.opts.providers);
|
|
185
|
+
this.skipped = {}; // step index -> { vendor label: requests }
|
|
186
|
+
this.bodyBytes = 0;
|
|
187
|
+
this.bodyBudgetStep = null; // the step at which POST bodies stopped being stored
|
|
188
|
+
this.startedAt = Date.now();
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
async start() {
|
|
192
|
+
const device = VIEWPORTS[this.opts.viewport];
|
|
193
|
+
if (!device) throw new Error(`Unknown viewport "${this.opts.viewport}". Use: ${Object.keys(VIEWPORTS).join(", ")}`);
|
|
194
|
+
const launch = { headless: this.opts.headless, env: browserEnv() };
|
|
195
|
+
if (this.opts.blockPrivate) {
|
|
196
|
+
// Every connection, WebSockets included, goes to an address the proxy checked.
|
|
197
|
+
// "<-loopback>" stops Chromium from sending localhost around the proxy.
|
|
198
|
+
this.egress = await startEgressProxy({ isBlocked: this.isBlockedAddress });
|
|
199
|
+
launch.proxy = { server: this.egress.url, bypass: "<-loopback>" };
|
|
200
|
+
launch.args = ["--force-webrtc-ip-handling-policy=disable_non_proxied_udp"];
|
|
201
|
+
}
|
|
202
|
+
this.browser = await chromium.launch(launch);
|
|
203
|
+
this.context = await this.browser.newContext({ ...device, locale: "en-US" });
|
|
204
|
+
await this.context.exposeBinding("__atdRecord", (_source, entry) => {
|
|
205
|
+
this.record("dataLayerLog", { ...this.redactEntry(entry), step: this.currentStep, t: Date.now() });
|
|
206
|
+
});
|
|
207
|
+
await this.context.addInitScript(INIT_SCRIPT);
|
|
208
|
+
this.page = await this.context.newPage();
|
|
209
|
+
this.page.on("pageerror", (err) => this.errors.push({ step: this.currentStep, message: err.message }));
|
|
210
|
+
|
|
211
|
+
const onRequest = (request) => {
|
|
212
|
+
const hits = parseTrackingRequest(request.url(), request.postData());
|
|
213
|
+
if (!hits) return;
|
|
214
|
+
for (const hit of hits) {
|
|
215
|
+
if (!this.providers.includes(hit.platform)) continue;
|
|
216
|
+
this.record("hits", { ...this.redactHit(hit), step: this.currentStep, pageUrl: this.page.url(), t: Date.now() });
|
|
217
|
+
}
|
|
218
|
+
};
|
|
219
|
+
// Every call to an analytics, ad or consent vendor, for the timeline.
|
|
220
|
+
const calls = new Map();
|
|
221
|
+
this.context.on("request", (request) => {
|
|
222
|
+
const provider = providerOf(request.url());
|
|
223
|
+
if (!provider) return;
|
|
224
|
+
if (!provider.always && !this.providers.includes(provider.key)) {
|
|
225
|
+
const counts = (this.skipped[this.currentStep] ??= {});
|
|
226
|
+
counts[provider.label] = (counts[provider.label] ?? 0) + 1;
|
|
227
|
+
return;
|
|
228
|
+
}
|
|
229
|
+
const vendor = vendorOf(request.url());
|
|
230
|
+
const call = {
|
|
231
|
+
step: this.currentStep,
|
|
232
|
+
t: Date.now(),
|
|
233
|
+
method: request.method(),
|
|
234
|
+
url: this.opts.keepBodies ? request.url() : redactUrl(request.url()).url,
|
|
235
|
+
type: request.resourceType(),
|
|
236
|
+
vendor,
|
|
237
|
+
provider: provider.key,
|
|
238
|
+
...this.storeBody(request),
|
|
239
|
+
blocked: this.opts.blockHits && isCollectionEndpoint(request.url()),
|
|
240
|
+
status: null,
|
|
241
|
+
};
|
|
242
|
+
if (this.record("network", call)) calls.set(request, call);
|
|
243
|
+
});
|
|
244
|
+
this.context.on("response", (response) => {
|
|
245
|
+
const call = calls.get(response.request());
|
|
246
|
+
if (call) call.status = response.status();
|
|
247
|
+
});
|
|
248
|
+
this.context.on("requestfailed", (request) => {
|
|
249
|
+
const call = calls.get(request);
|
|
250
|
+
if (call) call.status = "failed";
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
if (this.opts.blockHits) {
|
|
254
|
+
await this.context.route((url) => isCollectionEndpoint(url.href), async (route) => {
|
|
255
|
+
onRequest(route.request());
|
|
256
|
+
await route.fulfill({ status: 204, body: "" });
|
|
257
|
+
});
|
|
258
|
+
} else {
|
|
259
|
+
this.context.on("request", onRequest);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// Registered last, so it runs before the route above. Route handlers only see the
|
|
263
|
+
// first hop of a redirect, so enforce() checks where each step actually landed too.
|
|
264
|
+
await this.context.route("**/*", async (route) => {
|
|
265
|
+
const request = route.request();
|
|
266
|
+
let error = null;
|
|
267
|
+
let isMainNavigation = false;
|
|
268
|
+
try {
|
|
269
|
+
isMainNavigation = request.isNavigationRequest() && request.frame() === this.page?.mainFrame();
|
|
270
|
+
} catch { /* service worker requests have no frame */ }
|
|
271
|
+
if (isMainNavigation) error = await this.checkUrl(request.url());
|
|
272
|
+
else if (this.isPrivate) {
|
|
273
|
+
const u = new URL(request.url());
|
|
274
|
+
if (/^https?:$/.test(u.protocol) && (await this.isPrivate(u.hostname))) error = `${u.hostname} is a private network address.`;
|
|
275
|
+
}
|
|
276
|
+
if (!error) return route.fallback();
|
|
277
|
+
if (isMainNavigation) this.blocked = { step: this.currentStep, url: request.url(), error };
|
|
278
|
+
return route.abort("blockedbyclient");
|
|
279
|
+
});
|
|
280
|
+
this.beginStep("start");
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/** The hit with user data for ad matching redacted, and userData: { fields, plain } when there was any. */
|
|
284
|
+
redactHit({ userFields, ...hit }) {
|
|
285
|
+
const found = { fields: [], plain: [] };
|
|
286
|
+
const url = redactUrl(hit.url, found).url;
|
|
287
|
+
const params = redactParams(hit.params, found);
|
|
288
|
+
redactParams(userFields, found); // fields from the POST body that the hit doesn't keep
|
|
289
|
+
if (!found.fields.length) return hit;
|
|
290
|
+
const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
|
|
291
|
+
return this.opts.keepBodies ? { ...hit, userData } : { ...hit, url, params, userData };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/** A dataLayer entry with user data redacted, and userData: { fields, plain } when there was any. */
|
|
295
|
+
redactEntry(entry) {
|
|
296
|
+
const found = redactPush(entry.value);
|
|
297
|
+
if (!found.fields.length) return entry;
|
|
298
|
+
const userData = { fields: [...new Set(found.fields)], plain: [...new Set(found.plain)] };
|
|
299
|
+
return this.opts.keepBodies ? { ...entry, userData } : { ...entry, value: found.value, userData };
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
/** { postData } for a tracking POST, redacted, within the per-audit budget; { bodyDropped } past it. */
|
|
303
|
+
storeBody(request) {
|
|
304
|
+
const body = trackingBody(request);
|
|
305
|
+
if (body === null) return {};
|
|
306
|
+
if (this.bodyBytes + body.length > MAX_TOTAL_BODIES) {
|
|
307
|
+
this.bodyBudgetStep ??= this.currentStep;
|
|
308
|
+
return { bodyDropped: true };
|
|
309
|
+
}
|
|
310
|
+
this.bodyBytes += body.length;
|
|
311
|
+
return { postData: this.opts.keepBodies ? body : redactBody(body).text };
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
/** Adds an entry to one of the logs, up to its limit. Returns false once the limit is reached. */
|
|
315
|
+
record(log, entry) {
|
|
316
|
+
if (this[log].length >= LOG_LIMITS[log]) {
|
|
317
|
+
if (!this.logFull) this.errors.push({ step: this.currentStep, message: `Recording limit reached (${LOG_LIMITS[log]} ${log} entries); later entries were dropped.` });
|
|
318
|
+
this.logFull = true;
|
|
319
|
+
return false;
|
|
320
|
+
}
|
|
321
|
+
this[log].push(entry);
|
|
322
|
+
return true;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** Error message when the browser may not be on this URL, or null. */
|
|
326
|
+
checkUrl(url) {
|
|
327
|
+
if (url.startsWith("chrome-error:")) return null; // Chromium's own error page, e.g. after a blocked navigation
|
|
328
|
+
return navigationError(url, { scope: this.scope, isPrivate: this.isPrivate });
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Makes sure the page is somewhere it may be (a redirect or script can move it).
|
|
333
|
+
* If not, goes back, or to a blank page. Returns the error, or null.
|
|
334
|
+
*/
|
|
335
|
+
async enforce() {
|
|
336
|
+
let error = await this.checkUrl(this.page.url());
|
|
337
|
+
if (!error && this.blocked?.step === this.currentStep) error = this.blocked.error;
|
|
338
|
+
if (!error) return null;
|
|
339
|
+
if (this.page.url().startsWith("chrome-error:") || (await this.checkUrl(this.page.url()))) {
|
|
340
|
+
await this.page.goBack({ timeout: 10000 }).catch(() => {});
|
|
341
|
+
if (this.page.url().startsWith("chrome-error:") || (await this.checkUrl(this.page.url()))) {
|
|
342
|
+
await this.page.goto("about:blank").catch(() => {});
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
return `Navigation blocked: ${error}`;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
get currentStep() {
|
|
349
|
+
return this.steps.length - 1;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
beginStep(label, extra = {}) {
|
|
353
|
+
this.steps.push({ index: this.steps.length, label, t: Date.now(), ...extra });
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
async settle() {
|
|
357
|
+
await this.page.waitForLoadState("load", { timeout: 15000 }).catch(() => {});
|
|
358
|
+
await this.page.waitForLoadState("networkidle", { timeout: 4000 }).catch(() => {});
|
|
359
|
+
await this.page.waitForTimeout(400);
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
async snapshot() {
|
|
363
|
+
const blocked = await this.enforce();
|
|
364
|
+
if (blocked && this.page.url() === "about:blank") return { url: "about:blank", title: "", elements: "", text: `(${blocked})` };
|
|
365
|
+
try {
|
|
366
|
+
// The main page first, then iframes (booking and payment widgets often live in one).
|
|
367
|
+
const frames = await this.visibleFrames();
|
|
368
|
+
const main = await this.page.evaluate(snapshotInPage, { first: 1, limit: frames.length ? MAX_ELEMENTS - 60 : MAX_ELEMENTS, textLimit: 3000 });
|
|
369
|
+
this.refFrames = new Map();
|
|
370
|
+
const lines = [...main.lines];
|
|
371
|
+
let next = main.next;
|
|
372
|
+
let more = main.more;
|
|
373
|
+
for (const { frame, frameBox } of frames) {
|
|
374
|
+
if (next > MAX_ELEMENTS) break;
|
|
375
|
+
const sub = await frame.evaluate(snapshotInPage, { first: next, limit: MAX_ELEMENTS - next + 1, textLimit: 0, frameBox }).catch(() => null);
|
|
376
|
+
if (!sub?.lines.length) continue;
|
|
377
|
+
lines.push(`(inside an embedded frame from ${new URL(sub.url).host})`, ...sub.lines);
|
|
378
|
+
for (let r = next; r < sub.next; r++) this.refFrames.set(r, frame);
|
|
379
|
+
next = sub.next;
|
|
380
|
+
more ||= sub.more;
|
|
381
|
+
}
|
|
382
|
+
if (more) lines.push(`(more elements not listed: scroll to bring them on screen, or use text to click something by its visible text)`);
|
|
383
|
+
const note = this.steps[this.currentStep]?.note;
|
|
384
|
+
return { url: main.url, title: main.title, elements: lines.join("\n"), text: main.text, note };
|
|
385
|
+
} catch (err) {
|
|
386
|
+
return { url: this.page.url(), title: "", elements: "", text: `(snapshot failed: ${err.message})` };
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Iframes worth showing the agent: visible, with a real size, on the main page's
|
|
392
|
+
* screen or below it, and not an ad, analytics or consent vendor's frame.
|
|
393
|
+
* @returns {Promise<{ frame: import("playwright").Frame, frameBox: { y: number, viewportHeight: number } }[]>}
|
|
394
|
+
*/
|
|
395
|
+
async visibleFrames() {
|
|
396
|
+
const out = [];
|
|
397
|
+
const viewportHeight = this.page.viewportSize()?.height ?? 900;
|
|
398
|
+
for (const frame of this.page.frames()) {
|
|
399
|
+
if (frame === this.page.mainFrame() || frame.isDetached()) continue;
|
|
400
|
+
if (vendorOf(frame.url())) continue;
|
|
401
|
+
if (/^(google_ads_iframe|aswift_|ad_iframe)/i.test(frame.name())) continue; // ad slots, often about:blank
|
|
402
|
+
try {
|
|
403
|
+
const element = await frame.frameElement();
|
|
404
|
+
if (/^(google_ads_iframe|aswift_)/i.test((await element.getAttribute("id")) ?? "")) continue;
|
|
405
|
+
if (!(await element.isVisible())) continue;
|
|
406
|
+
const box = await element.boundingBox();
|
|
407
|
+
if (!box || box.width < 20 || box.height < 20) continue;
|
|
408
|
+
out.push({ frame, frameBox: { y: box.y, viewportHeight } });
|
|
409
|
+
} catch {
|
|
410
|
+
// detached while we looked
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
locatorFor({ ref, selector }) {
|
|
417
|
+
if (selector) return this.page.locator(selector).first();
|
|
418
|
+
return (this.refFrames?.get(ref) ?? this.page).locator(`[data-atd-ref="${ref}"]`).first();
|
|
419
|
+
}
|
|
420
|
+
|
|
421
|
+
/**
|
|
422
|
+
* Finds one visible element by its text: exact matches before partial ones, buttons
|
|
423
|
+
* and links before labels, placeholders and other text, the main page before iframes.
|
|
424
|
+
* Returns { locator } or { error } when nothing or several elements match.
|
|
425
|
+
*/
|
|
426
|
+
async locateByText(text) {
|
|
427
|
+
const roots = [this.page, ...(await this.visibleFrames()).map((f) => f.frame)];
|
|
428
|
+
const kinds = (root, exact) => [
|
|
429
|
+
root.getByRole("button", { name: text, exact }),
|
|
430
|
+
root.getByRole("link", { name: text, exact }),
|
|
431
|
+
root.getByLabel(text, { exact }),
|
|
432
|
+
root.getByPlaceholder(text, { exact }),
|
|
433
|
+
root.getByText(text, { exact }),
|
|
434
|
+
];
|
|
435
|
+
for (const exact of [true, false]) {
|
|
436
|
+
for (const root of roots) {
|
|
437
|
+
for (const candidate of kinds(root, exact)) {
|
|
438
|
+
const visible = candidate.filter({ visible: true });
|
|
439
|
+
const n = await visible.count().catch(() => 0);
|
|
440
|
+
if (n === 1) return { locator: visible.first() };
|
|
441
|
+
if (n > 1) return { error: `${n} elements match "${text}"${exact ? "" : " partly"}. Use the ref of the one you mean.` };
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
return { error: `No visible element with the text "${text}". Scroll, or use a ref from the page view.` };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* Clicks like a visitor. When something covers the element: refuses if it's a dialog,
|
|
450
|
+
* a modal or most of the screen (a consent wall the journey has to deal with first);
|
|
451
|
+
* otherwise (a sticky header, a small badge) clicks a part of the element that is free,
|
|
452
|
+
* with real mouse events, or as a last resort sends a synthetic click, and notes that on the step.
|
|
453
|
+
*/
|
|
454
|
+
async click(locator) {
|
|
455
|
+
try {
|
|
456
|
+
await locator.click({ timeout: 8000 });
|
|
457
|
+
return null;
|
|
458
|
+
} catch (err) {
|
|
459
|
+
if (!/intercepts pointer events|not stable|outside of the viewport/.test(err.message)) throw err;
|
|
460
|
+
}
|
|
461
|
+
const cover = await locator
|
|
462
|
+
.evaluate((el) => {
|
|
463
|
+
const r = el.getBoundingClientRect();
|
|
464
|
+
const x = Math.min(Math.max(r.left + r.width / 2, 0), window.innerWidth - 1);
|
|
465
|
+
const y = Math.min(Math.max(r.top + r.height / 2, 0), window.innerHeight - 1);
|
|
466
|
+
const hit = document.elementFromPoint(x, y);
|
|
467
|
+
if (!hit || hit === el || el.contains(hit)) return null;
|
|
468
|
+
// A part of the element nothing covers, to click there with real mouse events.
|
|
469
|
+
let free = null;
|
|
470
|
+
for (const fy of [0.5, 0.25, 0.75, 0.1, 0.9]) {
|
|
471
|
+
for (const fx of [0.5, 0.25, 0.75, 0.1, 0.9]) {
|
|
472
|
+
const px = r.left + r.width * fx;
|
|
473
|
+
const py = r.top + r.height * fy;
|
|
474
|
+
if (px < 0 || py < 0 || px >= window.innerWidth || py >= window.innerHeight) continue;
|
|
475
|
+
const h = document.elementFromPoint(px, py);
|
|
476
|
+
if (h && (h === el || el.contains(h))) { free = { x: r.width * fx, y: r.height * fy }; break; }
|
|
477
|
+
}
|
|
478
|
+
if (free) break;
|
|
479
|
+
}
|
|
480
|
+
const describe = (n) => `${n.tagName.toLowerCase()}${n.id ? `#${n.id}` : ""}${n.getAttribute("role") ? ` role=${n.getAttribute("role")}` : ""} "${(n.innerText || "").replace(/\s+/g, " ").trim().slice(0, 60)}"`;
|
|
481
|
+
const screen = window.innerWidth * window.innerHeight;
|
|
482
|
+
for (let n = hit; n && n !== document.body && n !== document.documentElement && !n.contains(el); n = n.parentElement) {
|
|
483
|
+
const b = n.getBoundingClientRect();
|
|
484
|
+
const modal = n.matches("dialog[open], [role=dialog], [role=alertdialog], [aria-modal=true]");
|
|
485
|
+
// Cookie and consent banners block even when they cover only part of the screen.
|
|
486
|
+
const consent =
|
|
487
|
+
n.matches("#onetrust-banner-sdk, #onetrust-consent-sdk, #CybotCookiebotDialog, #usercentrics-root, #didomi-host, #cookiescript_injected, .cky-consent-container, .cc-window, #cmpbox, .qc-cmp2-container") ||
|
|
488
|
+
(/(cookie|consent|gdpr|kvkk)/i.test(`${n.id} ${typeof n.className === "string" ? n.className : ""}`) && ["fixed", "sticky"].includes(getComputedStyle(n).position));
|
|
489
|
+
if (consent) return { blocking: true, what: describe(n) };
|
|
490
|
+
if (modal || b.width * b.height >= screen * 0.5) return { blocking: true, what: describe(n) };
|
|
491
|
+
}
|
|
492
|
+
return { blocking: false, what: describe(hit), free };
|
|
493
|
+
})
|
|
494
|
+
.catch(() => null);
|
|
495
|
+
if (cover?.blocking) {
|
|
496
|
+
throw new Error(`The element is covered by ${cover.what}. Deal with that first (for example accept or close it), as a visitor would.`);
|
|
497
|
+
}
|
|
498
|
+
let note;
|
|
499
|
+
if (cover?.free) {
|
|
500
|
+
await locator.click({ position: cover.free, timeout: 8000 });
|
|
501
|
+
note = `Clicked the part of the element not covered by ${cover.what}.`;
|
|
502
|
+
} else {
|
|
503
|
+
// Fully covered by something small: a synthetic click is the only way, and some widgets ignore it.
|
|
504
|
+
await locator.dispatchEvent("click");
|
|
505
|
+
note = `Clicked through ${cover?.what ?? "an overlapping element"} with a synthetic click; the page may not have reacted.`;
|
|
506
|
+
}
|
|
507
|
+
this.steps[this.currentStep].note = note;
|
|
508
|
+
this.steps[this.currentStep].forced = true;
|
|
509
|
+
return note;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* Performs one action and records it as a step. Returns an error message
|
|
514
|
+
* instead of throwing, so the agent can recover.
|
|
515
|
+
* @param {{ action: string, ref?: number, selector?: string, text?: string, value?: string, url?: string, reason?: string, accepts_consent?: boolean }} a
|
|
516
|
+
*/
|
|
517
|
+
async act(a) {
|
|
518
|
+
const describe = a.reason || `${a.action} ${a.selector ?? (a.ref != null ? `#${a.ref}` : a.text != null ? `"${a.text}"` : a.url ?? "")}`.trim();
|
|
519
|
+
this.beginStep(describe, { action: a.action, acceptsConsent: !!a.accepts_consent });
|
|
520
|
+
try {
|
|
521
|
+
switch (a.action) {
|
|
522
|
+
case "goto": {
|
|
523
|
+
const target = new URL(a.url, this.page.url() === "about:blank" ? undefined : this.page.url()).href;
|
|
524
|
+
const refused = await this.checkUrl(target);
|
|
525
|
+
if (refused) return refused;
|
|
526
|
+
await this.page.goto(target, { waitUntil: "domcontentloaded", timeout: 30000 });
|
|
527
|
+
break;
|
|
528
|
+
}
|
|
529
|
+
case "click":
|
|
530
|
+
case "fill":
|
|
531
|
+
case "select": {
|
|
532
|
+
let target;
|
|
533
|
+
if (a.selector || Number.isInteger(a.ref)) target = this.locatorFor(a);
|
|
534
|
+
else if (typeof a.text === "string" && a.text.trim()) {
|
|
535
|
+
const found = await this.locateByText(a.text.trim());
|
|
536
|
+
if (found.error) return found.error;
|
|
537
|
+
target = found.locator;
|
|
538
|
+
} else return `${a.action} needs a ref or the element's visible text.`;
|
|
539
|
+
if (a.action === "click") await this.click(target);
|
|
540
|
+
else if (a.action === "fill") await target.fill(a.value ?? "", { timeout: 10000 });
|
|
541
|
+
else await target.selectOption(a.value ?? "", { timeout: 10000 });
|
|
542
|
+
break;
|
|
543
|
+
}
|
|
544
|
+
case "scroll": {
|
|
545
|
+
const dy = /^up$/i.test(a.value ?? "") ? -900 : 900;
|
|
546
|
+
const size = this.page.viewportSize() ?? { width: 800, height: 600 };
|
|
547
|
+
const before = await this.page.evaluate(() => window.scrollY);
|
|
548
|
+
await this.page.mouse.move(size.width / 2, size.height / 2);
|
|
549
|
+
await this.page.mouse.wheel(0, dy);
|
|
550
|
+
await this.page.waitForTimeout(300);
|
|
551
|
+
// Some pages ignore the wheel over their content; scroll the window directly then.
|
|
552
|
+
if ((await this.page.evaluate(() => window.scrollY)) === before) await this.page.evaluate((d) => window.scrollBy(0, d), dy);
|
|
553
|
+
break;
|
|
554
|
+
}
|
|
555
|
+
case "wait":
|
|
556
|
+
await this.page.waitForTimeout(Math.min(Number(a.value) || 1000, 10000));
|
|
557
|
+
break;
|
|
558
|
+
default:
|
|
559
|
+
throw new Error(`Unknown action "${a.action}"`);
|
|
560
|
+
}
|
|
561
|
+
await this.settle();
|
|
562
|
+
const blocked = await this.enforce();
|
|
563
|
+
if (blocked) return blocked;
|
|
564
|
+
if (a.accepts_consent && this.consentStep === null) this.consentStep = this.currentStep;
|
|
565
|
+
return null;
|
|
566
|
+
} catch (err) {
|
|
567
|
+
return (await this.enforce().catch(() => null)) ?? err.message.split("\n")[0];
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
async cookies() {
|
|
572
|
+
return (await this.context.cookies()).map((c) => ({ name: c.name, domain: c.domain }));
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
async close() {
|
|
576
|
+
await this.browser?.close();
|
|
577
|
+
await this.egress?.close();
|
|
578
|
+
}
|
|
579
|
+
}
|