@agent-native/creative-context 0.5.12 → 0.6.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/dist/actions/list-context-connections.js +1 -1
- package/dist/client/CreativeContextPanel.js +1 -1
- package/dist/connectors/index.d.ts +1 -0
- package/dist/connectors/index.d.ts.map +1 -1
- package/dist/connectors/index.js +1 -0
- package/dist/connectors/index.js.map +1 -1
- package/dist/connectors/rendered-design.d.ts +68 -0
- package/dist/connectors/rendered-design.d.ts.map +1 -0
- package/dist/connectors/rendered-design.js +362 -0
- package/dist/connectors/rendered-design.js.map +1 -0
- package/dist/connectors/rendered-page.d.ts +15 -1
- package/dist/connectors/rendered-page.d.ts.map +1 -1
- package/dist/connectors/rendered-page.js +602 -85
- package/dist/connectors/rendered-page.js.map +1 -1
- package/dist/server/index.d.ts +1 -0
- package/dist/server/index.d.ts.map +1 -1
- package/dist/server/index.js +1 -0
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -2
- package/src/actions/list-context-connections.ts +1 -1
- package/src/client/CreativeContextPanel.tsx +1 -1
- package/src/connectors/index.ts +9 -0
- package/src/connectors/rendered-design.spec.ts +171 -0
- package/src/connectors/rendered-design.ts +498 -0
- package/src/connectors/rendered-page.spec.ts +380 -0
- package/src/connectors/rendered-page.ts +775 -108
- package/src/connectors/website.spec.ts +27 -71
- package/src/server/index.ts +9 -0
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
2
3
|
import { isBlockedExtensionUrlWithDns, ssrfSafeFetch, } from "@agent-native/core/extensions/url-safety";
|
|
3
4
|
import { extractStaticWebsiteContext, rankColorSamples, readBoundedResponseBytes, } from "@agent-native/core/ingestion";
|
|
4
5
|
import { normalizeWhitespace } from "./normalize.js";
|
|
@@ -12,6 +13,36 @@ const MAX_TYPOGRAPHY = 100;
|
|
|
12
13
|
const MAX_SPACING = 100;
|
|
13
14
|
const MAX_RADII = 64;
|
|
14
15
|
const MAX_CSS_VARIABLES = 128;
|
|
16
|
+
const MAX_COMPONENT_STYLES = 24;
|
|
17
|
+
const FONT_READY_TIMEOUT_MS = 4_000;
|
|
18
|
+
const MAX_BROWSER_RESOURCE_BYTES = 12 * 1024 * 1024;
|
|
19
|
+
const MAX_BROWSER_RESOURCE_COUNT = 400;
|
|
20
|
+
const MAX_BROWSER_RESOURCE_BYTES_TOTAL = 64 * 1024 * 1024;
|
|
21
|
+
const BROWSER_RESOURCE_TIMEOUT_MS = 15_000;
|
|
22
|
+
const BROWSER_REQUEST_HEADERS = new Set([
|
|
23
|
+
"accept",
|
|
24
|
+
"accept-language",
|
|
25
|
+
"if-modified-since",
|
|
26
|
+
"if-none-match",
|
|
27
|
+
"origin",
|
|
28
|
+
"range",
|
|
29
|
+
"referer",
|
|
30
|
+
"user-agent",
|
|
31
|
+
]);
|
|
32
|
+
const BROWSER_RESPONSE_HEADERS = new Set([
|
|
33
|
+
"connection",
|
|
34
|
+
"content-encoding",
|
|
35
|
+
"content-length",
|
|
36
|
+
"keep-alive",
|
|
37
|
+
"proxy-authenticate",
|
|
38
|
+
"proxy-authorization",
|
|
39
|
+
"set-cookie",
|
|
40
|
+
"set-cookie2",
|
|
41
|
+
"te",
|
|
42
|
+
"trailer",
|
|
43
|
+
"transfer-encoding",
|
|
44
|
+
"upgrade",
|
|
45
|
+
]);
|
|
15
46
|
export class LayeredRenderedPageProvider {
|
|
16
47
|
#requestBuilderBrowserConnection;
|
|
17
48
|
#loadPlaywright;
|
|
@@ -37,7 +68,7 @@ export class LayeredRenderedPageProvider {
|
|
|
37
68
|
const connection = await this.#requestBuilderBrowserConnection({
|
|
38
69
|
sessionId: `creative-context-${randomUUID()}`,
|
|
39
70
|
});
|
|
40
|
-
const wsUrl =
|
|
71
|
+
const wsUrl = typeof connection.wsUrl === "string" ? connection.wsUrl.trim() : "";
|
|
41
72
|
if (!wsUrl)
|
|
42
73
|
throw new Error("Builder Browser did not return wsUrl.");
|
|
43
74
|
return await renderWithPlaywright(playwright, request, warnings, "builder-browser", wsUrl);
|
|
@@ -75,40 +106,80 @@ export class LayeredRenderedPageProvider {
|
|
|
75
106
|
return renderStatic(request, warnings, this.#staticFetch);
|
|
76
107
|
}
|
|
77
108
|
}
|
|
78
|
-
async function renderWithPlaywright(playwright, request, warnings, method, wsUrl) {
|
|
109
|
+
export async function renderWithPlaywright(playwright, request, warnings, method, wsUrl) {
|
|
79
110
|
const browser = wsUrl
|
|
80
111
|
? await playwright.chromium.connectOverCDP(wsUrl)
|
|
81
|
-
: await playwright.chromium
|
|
112
|
+
: await launchChromium(playwright.chromium);
|
|
113
|
+
let isolatedContext;
|
|
82
114
|
try {
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
115
|
+
// Never reuse a connected browser's ambient context: it can carry cookies,
|
|
116
|
+
// extensions, or tabs from another workflow. The safe proxy below is only
|
|
117
|
+
// useful when the page itself is isolated from that state.
|
|
118
|
+
if (!browser.newContext) {
|
|
119
|
+
throw new Error("Browser did not provide isolated context support.");
|
|
120
|
+
}
|
|
121
|
+
isolatedContext = await browser.newContext();
|
|
122
|
+
const page = await isolatedContext.newPage();
|
|
123
|
+
const getFinalNavigationUrl = await installNavigationGuard(page, request, warnings);
|
|
86
124
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
87
125
|
await page.goto(request.url, {
|
|
88
126
|
timeout: boundedTimeout(request.timeoutMs),
|
|
89
127
|
waitUntil: request.waitUntil ?? "domcontentloaded",
|
|
90
128
|
});
|
|
91
|
-
|
|
129
|
+
await page
|
|
130
|
+
.waitForLoadState?.("load", {
|
|
131
|
+
timeout: Math.min(8_000, boundedTimeout(request.timeoutMs)),
|
|
132
|
+
})
|
|
133
|
+
.catch((error) => {
|
|
134
|
+
warnings.push(`Browser load stabilization unavailable: ${errorMessage(error)}`);
|
|
135
|
+
});
|
|
136
|
+
// React hydration, CSS-in-JS insertion, and web fonts commonly finish just
|
|
137
|
+
// after `load`. Give those layers a bounded chance to settle, then capture
|
|
138
|
+
// the computed cascade rather than the server HTML.
|
|
139
|
+
await page
|
|
140
|
+
.waitForLoadState?.("networkidle", { timeout: 4_000 })
|
|
141
|
+
.catch((error) => {
|
|
142
|
+
warnings.push(`Browser network-idle stabilization unavailable: ${errorMessage(error)}`);
|
|
143
|
+
});
|
|
144
|
+
await waitForFontReadiness(page, Math.min(FONT_READY_TIMEOUT_MS, boundedTimeout(request.timeoutMs))).catch((error) => {
|
|
145
|
+
warnings.push(`Browser font readiness unavailable: ${errorMessage(error)}`);
|
|
146
|
+
});
|
|
147
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
148
|
+
await page.evaluate(dismissConsentOverlays).catch(() => undefined);
|
|
149
|
+
const finalUrl = getFinalNavigationUrl() ?? page.url();
|
|
92
150
|
await assertPublicBrowserUrl(finalUrl);
|
|
93
151
|
const [title, text, desktopScreenshot, extraction] = await Promise.all([
|
|
94
|
-
page.title().catch(() =>
|
|
152
|
+
page.title().catch((error) => {
|
|
153
|
+
warnings.push(`Browser title extraction unavailable: ${errorMessage(error)}`);
|
|
154
|
+
return "";
|
|
155
|
+
}),
|
|
95
156
|
page
|
|
96
157
|
.locator("body")
|
|
97
158
|
.innerText()
|
|
98
|
-
.catch(() =>
|
|
159
|
+
.catch((error) => {
|
|
160
|
+
warnings.push(`Browser text extraction unavailable: ${errorMessage(error)}`);
|
|
161
|
+
return "";
|
|
162
|
+
}),
|
|
99
163
|
page
|
|
100
164
|
.screenshot({ type: "png", fullPage: false })
|
|
101
165
|
.then(boundedScreenshot)
|
|
102
|
-
.catch(() =>
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
166
|
+
.catch((error) => {
|
|
167
|
+
warnings.push(`Desktop screenshot unavailable: ${errorMessage(error)}`);
|
|
168
|
+
return undefined;
|
|
169
|
+
}),
|
|
170
|
+
page.evaluate(captureRenderedWebsiteContext).catch((error) => {
|
|
171
|
+
warnings.push(`Browser style extraction unavailable: ${errorMessage(error)}`);
|
|
172
|
+
return emptyExtraction();
|
|
173
|
+
}),
|
|
106
174
|
]);
|
|
107
175
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
108
176
|
const mobileScreenshot = await page
|
|
109
177
|
.screenshot({ type: "png", fullPage: false })
|
|
110
178
|
.then(boundedScreenshot)
|
|
111
|
-
.catch(() =>
|
|
179
|
+
.catch((error) => {
|
|
180
|
+
warnings.push(`Mobile screenshot unavailable: ${errorMessage(error)}`);
|
|
181
|
+
return undefined;
|
|
182
|
+
});
|
|
112
183
|
const unboundedText = normalizeWhitespace(text);
|
|
113
184
|
const textTruncated = unboundedText.length > MAX_RENDERED_TEXT_CHARS;
|
|
114
185
|
const normalizedText = unboundedText.slice(0, MAX_RENDERED_TEXT_CHARS);
|
|
@@ -173,21 +244,76 @@ async function renderWithPlaywright(playwright, request, warnings, method, wsUrl
|
|
|
173
244
|
};
|
|
174
245
|
}
|
|
175
246
|
finally {
|
|
247
|
+
await isolatedContext?.close?.().catch((error) => {
|
|
248
|
+
warnings.push(`Browser context cleanup unavailable: ${errorMessage(error)}`);
|
|
249
|
+
});
|
|
176
250
|
await browser.close().catch(() => undefined);
|
|
177
251
|
}
|
|
178
252
|
}
|
|
179
|
-
async function
|
|
253
|
+
async function waitForFontReadiness(page, timeoutMs) {
|
|
254
|
+
let timeout;
|
|
255
|
+
try {
|
|
256
|
+
await Promise.race([
|
|
257
|
+
page.evaluate(async () => {
|
|
258
|
+
if (document.fonts?.ready)
|
|
259
|
+
await document.fonts.ready;
|
|
260
|
+
}),
|
|
261
|
+
new Promise((_, reject) => {
|
|
262
|
+
timeout = setTimeout(() => {
|
|
263
|
+
reject(new Error(`font readiness timed out after ${timeoutMs}ms`));
|
|
264
|
+
}, timeoutMs);
|
|
265
|
+
}),
|
|
266
|
+
]);
|
|
267
|
+
}
|
|
268
|
+
finally {
|
|
269
|
+
if (timeout !== undefined)
|
|
270
|
+
clearTimeout(timeout);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
async function installNavigationGuard(page, renderRequest, warnings) {
|
|
274
|
+
let finalNavigationUrl;
|
|
275
|
+
let resourceCount = 0;
|
|
276
|
+
let resourceBytes = 0;
|
|
277
|
+
let reservedResourceBytes = 0;
|
|
278
|
+
const bodyBudgetWaiters = [];
|
|
279
|
+
let resourceLimitWarningAdded = false;
|
|
280
|
+
let blockedResourceWarningAdded = false;
|
|
281
|
+
let failedResourceWarningAdded = false;
|
|
282
|
+
const reserveBodyBudget = async () => {
|
|
283
|
+
while (reservedResourceBytes + MAX_BROWSER_RESOURCE_BYTES >
|
|
284
|
+
MAX_BROWSER_RESOURCE_BYTES_TOTAL) {
|
|
285
|
+
await new Promise((resolve) => {
|
|
286
|
+
bodyBudgetWaiters.push(resolve);
|
|
287
|
+
});
|
|
288
|
+
}
|
|
289
|
+
reservedResourceBytes += MAX_BROWSER_RESOURCE_BYTES;
|
|
290
|
+
let released = false;
|
|
291
|
+
return () => {
|
|
292
|
+
if (released)
|
|
293
|
+
return;
|
|
294
|
+
released = true;
|
|
295
|
+
reservedResourceBytes -= MAX_BROWSER_RESOURCE_BYTES;
|
|
296
|
+
bodyBudgetWaiters.shift()?.();
|
|
297
|
+
};
|
|
298
|
+
};
|
|
299
|
+
const addWarning = (value) => {
|
|
300
|
+
if (!warnings.includes(value))
|
|
301
|
+
warnings.push(value);
|
|
302
|
+
};
|
|
180
303
|
await page.route("**/*", async (route) => {
|
|
181
|
-
const
|
|
304
|
+
const browserRequest = route.request();
|
|
182
305
|
let parsed;
|
|
183
306
|
try {
|
|
184
|
-
parsed = new URL(
|
|
307
|
+
parsed = new URL(browserRequest.url());
|
|
185
308
|
}
|
|
186
309
|
catch {
|
|
310
|
+
// coercion-ok: an absent optional package means the next compatible probe should run.
|
|
187
311
|
await route.abort("blockedbyclient");
|
|
188
312
|
return;
|
|
189
313
|
}
|
|
190
|
-
if (parsed.protocol === "
|
|
314
|
+
if (parsed.protocol === "about:" ||
|
|
315
|
+
parsed.protocol === "data:" ||
|
|
316
|
+
parsed.protocol === "blob:") {
|
|
191
317
|
await route.continue();
|
|
192
318
|
return;
|
|
193
319
|
}
|
|
@@ -195,15 +321,237 @@ async function installNavigationGuard(page) {
|
|
|
195
321
|
await route.abort("blockedbyclient");
|
|
196
322
|
return;
|
|
197
323
|
}
|
|
324
|
+
const method = (browserRequest.method?.() ?? "GET").toUpperCase();
|
|
325
|
+
if (method !== "GET" && method !== "HEAD") {
|
|
326
|
+
await route.abort("blockedbyclient");
|
|
327
|
+
if (!resourceLimitWarningAdded) {
|
|
328
|
+
resourceLimitWarningAdded = true;
|
|
329
|
+
addWarning("Browser blocked a non-read-only resource request during extraction.");
|
|
330
|
+
}
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (resourceCount >= MAX_BROWSER_RESOURCE_COUNT) {
|
|
334
|
+
await route.abort("blockedbyclient");
|
|
335
|
+
if (!resourceLimitWarningAdded) {
|
|
336
|
+
resourceLimitWarningAdded = true;
|
|
337
|
+
addWarning(`Browser resource budget reached (${MAX_BROWSER_RESOURCE_COUNT} requests).`);
|
|
338
|
+
}
|
|
339
|
+
return;
|
|
340
|
+
}
|
|
341
|
+
// Reserve the request slot before the first await. Browser route handlers
|
|
342
|
+
// overlap, so incrementing only after the proxy response arrives lets a
|
|
343
|
+
// burst of requests all pass the limit check.
|
|
344
|
+
resourceCount += 1;
|
|
345
|
+
let bodyBudgetRelease;
|
|
346
|
+
let committedBytes = 0;
|
|
347
|
+
let fulfilled = false;
|
|
198
348
|
try {
|
|
199
349
|
await assertPublicBrowserUrl(parsed.href);
|
|
350
|
+
const response = await ssrfSafeFetch(parsed.href, {
|
|
351
|
+
method,
|
|
352
|
+
headers: browserRequestHeaders(browserRequest),
|
|
353
|
+
signal: AbortSignal.timeout(Math.min(BROWSER_RESOURCE_TIMEOUT_MS, boundedTimeout(renderRequest.timeoutMs))),
|
|
354
|
+
}, { maxRedirects: 5 });
|
|
355
|
+
bodyBudgetRelease = await reserveBodyBudget();
|
|
356
|
+
const body = await readBoundedResponseBytes(response, MAX_BROWSER_RESOURCE_BYTES);
|
|
357
|
+
bodyBudgetRelease();
|
|
358
|
+
bodyBudgetRelease = undefined;
|
|
359
|
+
if (resourceBytes + body.byteLength > MAX_BROWSER_RESOURCE_BYTES_TOTAL) {
|
|
360
|
+
await route.abort("blockedbyclient");
|
|
361
|
+
if (!resourceLimitWarningAdded) {
|
|
362
|
+
resourceLimitWarningAdded = true;
|
|
363
|
+
addWarning(`Browser resource budget reached (${MAX_BROWSER_RESOURCE_BYTES_TOTAL} bytes).`);
|
|
364
|
+
}
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
resourceBytes += body.byteLength;
|
|
368
|
+
committedBytes = body.byteLength;
|
|
369
|
+
if (browserRequest.isNavigationRequest()) {
|
|
370
|
+
finalNavigationUrl = response.url || parsed.href;
|
|
371
|
+
}
|
|
372
|
+
await route.fulfill({
|
|
373
|
+
status: response.status,
|
|
374
|
+
headers: browserResponseHeaders(response),
|
|
375
|
+
body: Buffer.from(body),
|
|
376
|
+
});
|
|
377
|
+
fulfilled = true;
|
|
200
378
|
}
|
|
201
|
-
catch {
|
|
379
|
+
catch (error) {
|
|
380
|
+
if (committedBytes > 0) {
|
|
381
|
+
resourceBytes -= committedBytes;
|
|
382
|
+
committedBytes = 0;
|
|
383
|
+
}
|
|
202
384
|
await route.abort("blockedbyclient");
|
|
203
|
-
|
|
385
|
+
if (isSsrfError(error)) {
|
|
386
|
+
if (!blockedResourceWarningAdded) {
|
|
387
|
+
blockedResourceWarningAdded = true;
|
|
388
|
+
addWarning("Some browser resources were blocked by the SSRF safety policy.");
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
else if (!failedResourceWarningAdded) {
|
|
392
|
+
failedResourceWarningAdded = true;
|
|
393
|
+
addWarning("Some browser resources could not be fetched through the safe network proxy.");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
finally {
|
|
397
|
+
bodyBudgetRelease?.();
|
|
398
|
+
if (!fulfilled)
|
|
399
|
+
resourceCount -= 1;
|
|
400
|
+
}
|
|
401
|
+
});
|
|
402
|
+
return () => finalNavigationUrl;
|
|
403
|
+
}
|
|
404
|
+
function browserRequestHeaders(request) {
|
|
405
|
+
const source = request.headers?.() ?? {};
|
|
406
|
+
return Object.fromEntries(Object.entries(source).filter(([name, value]) => {
|
|
407
|
+
return BROWSER_REQUEST_HEADERS.has(name.toLowerCase()) && Boolean(value);
|
|
408
|
+
}));
|
|
409
|
+
}
|
|
410
|
+
function browserResponseHeaders(response) {
|
|
411
|
+
const headers = {};
|
|
412
|
+
response.headers.forEach((value, name) => {
|
|
413
|
+
if (!BROWSER_RESPONSE_HEADERS.has(name.toLowerCase())) {
|
|
414
|
+
headers[name] = value;
|
|
204
415
|
}
|
|
205
|
-
await route.continue();
|
|
206
416
|
});
|
|
417
|
+
return headers;
|
|
418
|
+
}
|
|
419
|
+
function isSsrfError(error) {
|
|
420
|
+
return /ssrf blocked|private\/internal|connect blocked/i.test(errorMessage(error));
|
|
421
|
+
}
|
|
422
|
+
const SYSTEM_CHROME_EXECUTABLES = [
|
|
423
|
+
"/usr/bin/google-chrome-stable",
|
|
424
|
+
"/usr/bin/google-chrome",
|
|
425
|
+
"/usr/bin/chromium",
|
|
426
|
+
"/usr/bin/chromium-browser",
|
|
427
|
+
];
|
|
428
|
+
async function launchChromium(chromium) {
|
|
429
|
+
const launchOptions = {
|
|
430
|
+
headless: true,
|
|
431
|
+
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
432
|
+
};
|
|
433
|
+
let missingBrowserError;
|
|
434
|
+
try {
|
|
435
|
+
return await chromium.launch(launchOptions);
|
|
436
|
+
}
|
|
437
|
+
catch (error) {
|
|
438
|
+
if (!isMissingBrowserError(error))
|
|
439
|
+
throw error;
|
|
440
|
+
missingBrowserError = error;
|
|
441
|
+
}
|
|
442
|
+
const serverlessChromium = await loadOptionalServerlessChromium();
|
|
443
|
+
if (serverlessChromium) {
|
|
444
|
+
try {
|
|
445
|
+
const executablePath = await serverlessChromium.executablePath();
|
|
446
|
+
if (executablePath) {
|
|
447
|
+
return await chromium.launch({
|
|
448
|
+
...launchOptions,
|
|
449
|
+
args: [...launchOptions.args, ...(serverlessChromium.args ?? [])],
|
|
450
|
+
executablePath,
|
|
451
|
+
});
|
|
452
|
+
}
|
|
453
|
+
}
|
|
454
|
+
catch (error) {
|
|
455
|
+
if (!isMissingBrowserError(error))
|
|
456
|
+
throw error;
|
|
457
|
+
missingBrowserError = error;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
|
|
461
|
+
if (!existsSync(executablePath))
|
|
462
|
+
continue;
|
|
463
|
+
try {
|
|
464
|
+
return await chromium.launch({ ...launchOptions, executablePath });
|
|
465
|
+
}
|
|
466
|
+
catch {
|
|
467
|
+
continue;
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
if (missingBrowserError) {
|
|
471
|
+
throw missingBrowserError;
|
|
472
|
+
}
|
|
473
|
+
throw new Error("No Chromium executable is available for browser extraction.");
|
|
474
|
+
}
|
|
475
|
+
async function loadOptionalServerlessChromium() {
|
|
476
|
+
const specifier = "@sparticuz/chromium";
|
|
477
|
+
try {
|
|
478
|
+
const module = (await import(/* @vite-ignore */ specifier));
|
|
479
|
+
const chromium = module.default ?? module;
|
|
480
|
+
return typeof chromium.executablePath === "function"
|
|
481
|
+
? chromium
|
|
482
|
+
: null;
|
|
483
|
+
}
|
|
484
|
+
catch {
|
|
485
|
+
// coercion-ok: this optional capability is absent in non-serverless installs.
|
|
486
|
+
return null;
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
/*
|
|
490
|
+
* Kept separate from Playwright loading so a deployment can omit the large
|
|
491
|
+
* serverless Chromium package and still use Builder Browser or system Chrome.
|
|
492
|
+
*/
|
|
493
|
+
async function loadOptionalPlaywright() {
|
|
494
|
+
for (const specifier of [
|
|
495
|
+
"playwright",
|
|
496
|
+
"@playwright/test",
|
|
497
|
+
"playwright-core",
|
|
498
|
+
]) {
|
|
499
|
+
try {
|
|
500
|
+
const module = (await import(
|
|
501
|
+
/* @vite-ignore */ specifier));
|
|
502
|
+
for (const candidate of [module.default, module]) {
|
|
503
|
+
if (typeof candidate?.chromium?.launch === "function") {
|
|
504
|
+
return candidate;
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
// coercion-ok: an absent optional package means the next compatible probe should run.
|
|
508
|
+
}
|
|
509
|
+
catch {
|
|
510
|
+
// Try the next compatible browser package before falling back to HTML.
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
// coercion-ok: browser packages are optional in non-Node runtimes.
|
|
514
|
+
return null;
|
|
515
|
+
}
|
|
516
|
+
async function defaultBuilderBrowserRequest(input) {
|
|
517
|
+
const server = (await import("@agent-native/core/server"));
|
|
518
|
+
if (!server.requestBuilderBrowserConnection) {
|
|
519
|
+
throw new Error("@agent-native/core/server does not export requestBuilderBrowserConnection.");
|
|
520
|
+
}
|
|
521
|
+
return server.requestBuilderBrowserConnection(input);
|
|
522
|
+
}
|
|
523
|
+
function isMissingBrowserError(error) {
|
|
524
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
525
|
+
return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(message);
|
|
526
|
+
}
|
|
527
|
+
/** Close common consent banners without accepting tracking or changing page data. */
|
|
528
|
+
function dismissConsentOverlays() {
|
|
529
|
+
const selectors = [
|
|
530
|
+
'[aria-label*="reject" i]',
|
|
531
|
+
'[aria-label*="decline" i]',
|
|
532
|
+
'[aria-label*="close" i]',
|
|
533
|
+
'[data-testid*="reject" i]',
|
|
534
|
+
'[data-testid*="decline" i]',
|
|
535
|
+
'[data-testid*="close" i]',
|
|
536
|
+
'button[id*="reject" i]',
|
|
537
|
+
'button[id*="decline" i]',
|
|
538
|
+
'button[class*="reject" i]',
|
|
539
|
+
'button[class*="decline" i]',
|
|
540
|
+
];
|
|
541
|
+
for (const selector of selectors) {
|
|
542
|
+
const element = document.querySelector(selector);
|
|
543
|
+
if (!element)
|
|
544
|
+
continue;
|
|
545
|
+
const rect = element.getBoundingClientRect();
|
|
546
|
+
const style = getComputedStyle(element);
|
|
547
|
+
if (rect.width > 0 &&
|
|
548
|
+
rect.height > 0 &&
|
|
549
|
+
style.visibility !== "hidden" &&
|
|
550
|
+
style.display !== "none") {
|
|
551
|
+
element.click();
|
|
552
|
+
return;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
207
555
|
}
|
|
208
556
|
async function renderStatic(request, warnings, fetcher) {
|
|
209
557
|
const response = await fetcher(request.url, {
|
|
@@ -264,22 +612,6 @@ async function assertPublicBrowserUrl(value) {
|
|
|
264
612
|
throw new Error("SSRF blocked: website resolved to a private/internal host.");
|
|
265
613
|
}
|
|
266
614
|
}
|
|
267
|
-
async function defaultBuilderBrowserRequest(input) {
|
|
268
|
-
const server = (await import("@agent-native/core/server"));
|
|
269
|
-
if (!server.requestBuilderBrowserConnection) {
|
|
270
|
-
throw new Error("@agent-native/core/server does not export requestBuilderBrowserConnection.");
|
|
271
|
-
}
|
|
272
|
-
return server.requestBuilderBrowserConnection(input);
|
|
273
|
-
}
|
|
274
|
-
async function loadOptionalPlaywright() {
|
|
275
|
-
const specifier = "playwright";
|
|
276
|
-
try {
|
|
277
|
-
return (await import(specifier));
|
|
278
|
-
}
|
|
279
|
-
catch {
|
|
280
|
-
return null;
|
|
281
|
-
}
|
|
282
|
-
}
|
|
283
615
|
function captureRenderedWebsiteContext() {
|
|
284
616
|
const MAX_ASSETS = 500;
|
|
285
617
|
const MAX_LINKS = 500;
|
|
@@ -287,15 +619,148 @@ function captureRenderedWebsiteContext() {
|
|
|
287
619
|
const MAX_TYPE_STYLES = 100;
|
|
288
620
|
const MAX_SPACING_VALUES = 100;
|
|
289
621
|
const MAX_RADIUS_VALUES = 64;
|
|
622
|
+
const MAX_SHADOWS = 32;
|
|
623
|
+
const MAX_BACKGROUNDS = 32;
|
|
290
624
|
const MAX_VARIABLES = 128;
|
|
291
625
|
const MAX_TEXT = 2_000_000;
|
|
626
|
+
const MAX_COMPONENT_STYLES = 24;
|
|
292
627
|
const assets = new Map();
|
|
293
628
|
const links = new Set();
|
|
294
629
|
const colors = [];
|
|
295
630
|
const typography = new Map();
|
|
296
631
|
const spacing = new Set();
|
|
297
632
|
const radii = new Set();
|
|
633
|
+
const shadows = new Set();
|
|
634
|
+
const backgrounds = new Set();
|
|
635
|
+
const components = [];
|
|
298
636
|
const cssVariables = {};
|
|
637
|
+
const semanticColors = {};
|
|
638
|
+
const isOpaque = (value) => {
|
|
639
|
+
const normalized = value.trim().toLowerCase();
|
|
640
|
+
if (!normalized || normalized === "transparent")
|
|
641
|
+
return false;
|
|
642
|
+
const functionBody = normalized.match(/^[a-z-]+\((.*)\)$/)?.[1];
|
|
643
|
+
if (!functionBody)
|
|
644
|
+
return true;
|
|
645
|
+
const alphaValue = functionBody.includes("/")
|
|
646
|
+
? functionBody.split("/").at(-1)?.trim()
|
|
647
|
+
: functionBody.split(",").length === 4
|
|
648
|
+
? functionBody.split(",").at(-1)?.trim()
|
|
649
|
+
: undefined;
|
|
650
|
+
if (!alphaValue)
|
|
651
|
+
return true;
|
|
652
|
+
const alpha = alphaValue.endsWith("%")
|
|
653
|
+
? Number.parseFloat(alphaValue) / 100
|
|
654
|
+
: Number.parseFloat(alphaValue);
|
|
655
|
+
return Number.isNaN(alpha) || alpha > 0.02;
|
|
656
|
+
};
|
|
657
|
+
const addColor = (value) => {
|
|
658
|
+
if (colors.length >= MAX_COLOR_VALUES || !isOpaque(value))
|
|
659
|
+
return;
|
|
660
|
+
const normalized = value.trim();
|
|
661
|
+
if (!colors.includes(normalized))
|
|
662
|
+
colors.push(normalized);
|
|
663
|
+
};
|
|
664
|
+
const visible = (element) => {
|
|
665
|
+
const style = getComputedStyle(element);
|
|
666
|
+
const rect = element.getBoundingClientRect();
|
|
667
|
+
return (rect.width > 0 &&
|
|
668
|
+
rect.height > 0 &&
|
|
669
|
+
style.display !== "none" &&
|
|
670
|
+
style.visibility !== "hidden" &&
|
|
671
|
+
Number(style.opacity || 1) > 0.02);
|
|
672
|
+
};
|
|
673
|
+
const firstVisible = (selector) => Array.from(document.querySelectorAll(selector)).find(visible);
|
|
674
|
+
const opaqueValue = (value) => isOpaque(value) ? value.trim() : undefined;
|
|
675
|
+
const recordComputedStyle = (element, role) => {
|
|
676
|
+
const style = getComputedStyle(element);
|
|
677
|
+
const values = [
|
|
678
|
+
style.color,
|
|
679
|
+
style.backgroundColor,
|
|
680
|
+
style.borderTopColor,
|
|
681
|
+
style.borderRightColor,
|
|
682
|
+
style.borderBottomColor,
|
|
683
|
+
style.borderLeftColor,
|
|
684
|
+
];
|
|
685
|
+
values.forEach(addColor);
|
|
686
|
+
if (style.boxShadow && style.boxShadow !== "none") {
|
|
687
|
+
if (shadows.size < MAX_SHADOWS)
|
|
688
|
+
shadows.add(style.boxShadow);
|
|
689
|
+
}
|
|
690
|
+
if (style.backgroundImage && style.backgroundImage !== "none") {
|
|
691
|
+
if (backgrounds.size < MAX_BACKGROUNDS) {
|
|
692
|
+
backgrounds.add(style.backgroundImage);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
for (const value of [
|
|
696
|
+
style.marginTop,
|
|
697
|
+
style.marginRight,
|
|
698
|
+
style.marginBottom,
|
|
699
|
+
style.marginLeft,
|
|
700
|
+
style.paddingTop,
|
|
701
|
+
style.paddingRight,
|
|
702
|
+
style.paddingBottom,
|
|
703
|
+
style.paddingLeft,
|
|
704
|
+
style.gap,
|
|
705
|
+
style.rowGap,
|
|
706
|
+
style.columnGap,
|
|
707
|
+
]) {
|
|
708
|
+
if (spacing.size < MAX_SPACING_VALUES &&
|
|
709
|
+
value &&
|
|
710
|
+
value !== "0px" &&
|
|
711
|
+
value !== "normal") {
|
|
712
|
+
spacing.add(value);
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
for (const value of [
|
|
716
|
+
style.borderTopLeftRadius,
|
|
717
|
+
style.borderTopRightRadius,
|
|
718
|
+
style.borderBottomRightRadius,
|
|
719
|
+
style.borderBottomLeftRadius,
|
|
720
|
+
]) {
|
|
721
|
+
if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
|
|
722
|
+
radii.add(value);
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
const type = {
|
|
726
|
+
family: style.fontFamily,
|
|
727
|
+
size: style.fontSize,
|
|
728
|
+
weight: style.fontWeight,
|
|
729
|
+
lineHeight: style.lineHeight,
|
|
730
|
+
letterSpacing: style.letterSpacing,
|
|
731
|
+
};
|
|
732
|
+
if (typography.size < MAX_TYPE_STYLES && type.family) {
|
|
733
|
+
typography.set(JSON.stringify(type), type);
|
|
734
|
+
}
|
|
735
|
+
if (!role || components.length >= MAX_COMPONENT_STYLES)
|
|
736
|
+
return style;
|
|
737
|
+
components.push({
|
|
738
|
+
role,
|
|
739
|
+
fontFamily: style.fontFamily,
|
|
740
|
+
fontSize: style.fontSize,
|
|
741
|
+
fontWeight: style.fontWeight,
|
|
742
|
+
lineHeight: style.lineHeight,
|
|
743
|
+
letterSpacing: style.letterSpacing,
|
|
744
|
+
color: opaqueValue(style.color),
|
|
745
|
+
backgroundColor: opaqueValue(style.backgroundColor),
|
|
746
|
+
backgroundImage: style.backgroundImage !== "none" ? style.backgroundImage : undefined,
|
|
747
|
+
border: style.borderStyle !== "none" && style.borderWidth !== "0px"
|
|
748
|
+
? style.border
|
|
749
|
+
: undefined,
|
|
750
|
+
borderRadius: style.borderTopLeftRadius !== "0px" ? style.borderRadius : undefined,
|
|
751
|
+
boxShadow: style.boxShadow !== "none" ? style.boxShadow : undefined,
|
|
752
|
+
padding: style.padding !== "0px" ? style.padding : undefined,
|
|
753
|
+
gap: style.gap !== "normal" ? style.gap : undefined,
|
|
754
|
+
textTransform: style.textTransform !== "none" ? style.textTransform : undefined,
|
|
755
|
+
});
|
|
756
|
+
return style;
|
|
757
|
+
};
|
|
758
|
+
const styleFor = (selector, role) => {
|
|
759
|
+
const element = firstVisible(selector);
|
|
760
|
+
if (!element)
|
|
761
|
+
return undefined;
|
|
762
|
+
return recordComputedStyle(element, role);
|
|
763
|
+
};
|
|
299
764
|
const addAsset = (raw, kind, role) => {
|
|
300
765
|
if (!raw || assets.size >= MAX_ASSETS)
|
|
301
766
|
return;
|
|
@@ -374,52 +839,67 @@ function captureRenderedWebsiteContext() {
|
|
|
374
839
|
}
|
|
375
840
|
}
|
|
376
841
|
}
|
|
377
|
-
const
|
|
842
|
+
const bodyStyle = document.body
|
|
843
|
+
? recordComputedStyle(document.body)
|
|
844
|
+
: undefined;
|
|
845
|
+
const rootBackground = opaqueValue(rootStyle.backgroundColor);
|
|
846
|
+
const bodyBackground = bodyStyle
|
|
847
|
+
? opaqueValue(bodyStyle.backgroundColor)
|
|
848
|
+
: undefined;
|
|
849
|
+
const headingStyle = styleFor("h1, h2, h3", "heading");
|
|
850
|
+
const textStyle = styleFor("p, li, label, body", "body");
|
|
851
|
+
const buttonStyle = styleFor('button, [role="button"], input[type="submit"], a[class*="button" i], a[class*="cta" i]', "button");
|
|
852
|
+
const linkStyle = styleFor("a[href]", "link");
|
|
853
|
+
const cardStyle = styleFor('article, [class*="card" i], [class*="panel" i], [class*="surface" i], section', "card");
|
|
854
|
+
styleFor("input, textarea, select", "input");
|
|
855
|
+
styleFor("nav, header", "nav");
|
|
856
|
+
styleFor('main, [class*="hero" i]', "hero");
|
|
857
|
+
const setSemantic = (role, value) => {
|
|
858
|
+
if (value)
|
|
859
|
+
semanticColors[role] = value;
|
|
860
|
+
};
|
|
861
|
+
setSemantic("background", bodyBackground ?? rootBackground);
|
|
862
|
+
setSemantic("surface", cardStyle ? opaqueValue(cardStyle.backgroundColor) : undefined);
|
|
863
|
+
setSemantic("text", textStyle ? opaqueValue(textStyle.color) : undefined);
|
|
864
|
+
const mutedElement = firstVisible("small, figcaption, [class*='muted' i]");
|
|
865
|
+
setSemantic("textMuted", mutedElement
|
|
866
|
+
? opaqueValue(getComputedStyle(mutedElement).color)
|
|
867
|
+
: undefined);
|
|
868
|
+
setSemantic("accent", linkStyle
|
|
869
|
+
? opaqueValue(linkStyle.color)
|
|
870
|
+
: buttonStyle
|
|
871
|
+
? opaqueValue(buttonStyle.backgroundColor)
|
|
872
|
+
: undefined);
|
|
873
|
+
setSemantic("primary", buttonStyle
|
|
874
|
+
? (opaqueValue(buttonStyle.backgroundColor) ??
|
|
875
|
+
opaqueValue(buttonStyle.color))
|
|
876
|
+
: colors[0]);
|
|
877
|
+
setSemantic("secondary", semanticColors.surface ?? colors[1] ?? semanticColors.background);
|
|
878
|
+
const layoutElement = firstVisible("main, [role='main'], body > div");
|
|
879
|
+
const layoutStyle = layoutElement
|
|
880
|
+
? getComputedStyle(layoutElement)
|
|
881
|
+
: undefined;
|
|
882
|
+
const layoutRect = layoutElement?.getBoundingClientRect();
|
|
883
|
+
const layout = {
|
|
884
|
+
contentWidth: layoutStyle?.maxWidth && layoutStyle.maxWidth !== "none"
|
|
885
|
+
? layoutStyle.maxWidth
|
|
886
|
+
: layoutRect && layoutRect.width > 0
|
|
887
|
+
? `${Math.round(layoutRect.width)}px`
|
|
888
|
+
: undefined,
|
|
889
|
+
pagePadding: bodyStyle?.padding && bodyStyle.padding !== "0px"
|
|
890
|
+
? bodyStyle.padding
|
|
891
|
+
: undefined,
|
|
892
|
+
sectionGap: firstVisible("section, article") &&
|
|
893
|
+
getComputedStyle(firstVisible("section, article")).gap !== "normal" &&
|
|
894
|
+
getComputedStyle(firstVisible("section, article")).gap !== "0px"
|
|
895
|
+
? getComputedStyle(firstVisible("section, article")).gap
|
|
896
|
+
: undefined,
|
|
897
|
+
};
|
|
898
|
+
const elements = Array.from(document.querySelectorAll("body *"))
|
|
899
|
+
.filter(visible)
|
|
900
|
+
.slice(0, 700);
|
|
378
901
|
for (const element of elements) {
|
|
379
|
-
|
|
380
|
-
if (colors.length < MAX_COLOR_VALUES) {
|
|
381
|
-
colors.push(style.color, style.backgroundColor, style.borderTopColor, style.borderRightColor, style.borderBottomColor, style.borderLeftColor);
|
|
382
|
-
}
|
|
383
|
-
const type = {
|
|
384
|
-
family: style.fontFamily,
|
|
385
|
-
size: style.fontSize,
|
|
386
|
-
weight: style.fontWeight,
|
|
387
|
-
lineHeight: style.lineHeight,
|
|
388
|
-
letterSpacing: style.letterSpacing,
|
|
389
|
-
};
|
|
390
|
-
if (typography.size < MAX_TYPE_STYLES) {
|
|
391
|
-
typography.set(JSON.stringify(type), type);
|
|
392
|
-
}
|
|
393
|
-
for (const value of [
|
|
394
|
-
style.marginTop,
|
|
395
|
-
style.marginRight,
|
|
396
|
-
style.marginBottom,
|
|
397
|
-
style.marginLeft,
|
|
398
|
-
style.paddingTop,
|
|
399
|
-
style.paddingRight,
|
|
400
|
-
style.paddingBottom,
|
|
401
|
-
style.paddingLeft,
|
|
402
|
-
style.gap,
|
|
403
|
-
style.rowGap,
|
|
404
|
-
style.columnGap,
|
|
405
|
-
]) {
|
|
406
|
-
if (spacing.size < MAX_SPACING_VALUES &&
|
|
407
|
-
value &&
|
|
408
|
-
value !== "0px" &&
|
|
409
|
-
value !== "normal") {
|
|
410
|
-
spacing.add(value);
|
|
411
|
-
}
|
|
412
|
-
}
|
|
413
|
-
for (const value of [
|
|
414
|
-
style.borderTopLeftRadius,
|
|
415
|
-
style.borderTopRightRadius,
|
|
416
|
-
style.borderBottomRightRadius,
|
|
417
|
-
style.borderBottomLeftRadius,
|
|
418
|
-
]) {
|
|
419
|
-
if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
|
|
420
|
-
radii.add(value);
|
|
421
|
-
}
|
|
422
|
-
}
|
|
902
|
+
recordComputedStyle(element);
|
|
423
903
|
}
|
|
424
904
|
return {
|
|
425
905
|
title: document.title,
|
|
@@ -432,10 +912,21 @@ function captureRenderedWebsiteContext() {
|
|
|
432
912
|
spacing: [...spacing],
|
|
433
913
|
radii: [...radii],
|
|
434
914
|
cssVariables,
|
|
915
|
+
semanticColors,
|
|
916
|
+
shadows: [...shadows],
|
|
917
|
+
backgrounds: [...backgrounds],
|
|
918
|
+
components,
|
|
919
|
+
layout,
|
|
435
920
|
},
|
|
436
921
|
};
|
|
437
922
|
}
|
|
438
923
|
export function boundWebsiteExtraction(extraction) {
|
|
924
|
+
const boundedComponents = extraction.designTokens.components
|
|
925
|
+
?.slice(0, MAX_COMPONENT_STYLES)
|
|
926
|
+
.map((component) => Object.fromEntries(Object.entries(component).map(([key, value]) => [
|
|
927
|
+
key,
|
|
928
|
+
typeof value === "string" ? value.slice(0, 500) : value,
|
|
929
|
+
])));
|
|
439
930
|
return {
|
|
440
931
|
title: normalizeWhitespace(extraction.title).slice(0, 500),
|
|
441
932
|
text: normalizeWhitespace(extraction.text).slice(0, MAX_RENDERED_TEXT_CHARS),
|
|
@@ -454,6 +945,35 @@ export function boundWebsiteExtraction(extraction) {
|
|
|
454
945
|
cssVariables: Object.fromEntries(Object.entries(extraction.designTokens.cssVariables)
|
|
455
946
|
.slice(0, MAX_CSS_VARIABLES)
|
|
456
947
|
.map(([name, value]) => [name.slice(0, 500), value.slice(0, 4_096)])),
|
|
948
|
+
...(extraction.designTokens.semanticColors
|
|
949
|
+
? {
|
|
950
|
+
semanticColors: Object.fromEntries(Object.entries(extraction.designTokens.semanticColors)
|
|
951
|
+
.filter(([, value]) => typeof value === "string" && value)
|
|
952
|
+
.map(([name, value]) => [name, value.slice(0, 200)])),
|
|
953
|
+
}
|
|
954
|
+
: {}),
|
|
955
|
+
...(extraction.designTokens.shadows
|
|
956
|
+
? {
|
|
957
|
+
shadows: extraction.designTokens.shadows
|
|
958
|
+
.slice(0, 32)
|
|
959
|
+
.map((value) => value.slice(0, 500)),
|
|
960
|
+
}
|
|
961
|
+
: {}),
|
|
962
|
+
...(extraction.designTokens.backgrounds
|
|
963
|
+
? {
|
|
964
|
+
backgrounds: extraction.designTokens.backgrounds
|
|
965
|
+
.slice(0, 32)
|
|
966
|
+
.map((value) => value.slice(0, 500)),
|
|
967
|
+
}
|
|
968
|
+
: {}),
|
|
969
|
+
...(boundedComponents ? { components: boundedComponents } : {}),
|
|
970
|
+
...(extraction.designTokens.layout
|
|
971
|
+
? {
|
|
972
|
+
layout: Object.fromEntries(Object.entries(extraction.designTokens.layout)
|
|
973
|
+
.filter(([, value]) => typeof value === "string" && value)
|
|
974
|
+
.map(([name, value]) => [name, value.slice(0, 200)])),
|
|
975
|
+
}
|
|
976
|
+
: {}),
|
|
457
977
|
},
|
|
458
978
|
};
|
|
459
979
|
}
|
|
@@ -495,9 +1015,6 @@ function boundedTimeout(value) {
|
|
|
495
1015
|
function boundedScreenshot(value) {
|
|
496
1016
|
return value.byteLength <= MAX_SCREENSHOT_BYTES ? value : undefined;
|
|
497
1017
|
}
|
|
498
|
-
function stringValue(value) {
|
|
499
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
500
|
-
}
|
|
501
1018
|
function errorMessage(error) {
|
|
502
1019
|
return error instanceof Error ? error.message : String(error);
|
|
503
1020
|
}
|