@agent-native/creative-context 0.5.11 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +19 -1
- package/dist/connectors/rendered-page.d.ts.map +1 -1
- package/dist/connectors/rendered-page.js +385 -129
- package/dist/connectors/rendered-page.js.map +1 -1
- package/dist/jobs/server-worker.d.ts.map +1 -1
- package/dist/jobs/server-worker.js +5 -3
- package/dist/jobs/server-worker.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 +5 -2
- package/dist/server/index.js.map +1 -1
- package/package.json +3 -3
- 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 +94 -0
- package/src/connectors/rendered-page.ts +494 -174
- package/src/connectors/website.spec.ts +27 -71
- package/src/jobs/server-worker.spec.ts +32 -10
- package/src/jobs/server-worker.ts +5 -2
- package/src/server/index.spec.ts +15 -0
- package/src/server/index.ts +13 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
2
|
|
|
3
3
|
import {
|
|
4
4
|
isBlockedExtensionUrlWithDns,
|
|
@@ -29,6 +29,10 @@ const MAX_TYPOGRAPHY = 100;
|
|
|
29
29
|
const MAX_SPACING = 100;
|
|
30
30
|
const MAX_RADII = 64;
|
|
31
31
|
const MAX_CSS_VARIABLES = 128;
|
|
32
|
+
const MAX_COMPONENT_STYLES = 24;
|
|
33
|
+
const FONT_READY_TIMEOUT_MS = 4_000;
|
|
34
|
+
const BROWSER_NAVIGATION_DISABLED_WARNING =
|
|
35
|
+
"Browser rendering is disabled until Chromium navigation has a connect-time SSRF guard.";
|
|
32
36
|
|
|
33
37
|
export interface RenderedPageRequest {
|
|
34
38
|
url: string;
|
|
@@ -88,6 +92,10 @@ interface PlaywrightPageLike {
|
|
|
88
92
|
url: string,
|
|
89
93
|
options: { timeout: number; waitUntil: string },
|
|
90
94
|
): Promise<unknown>;
|
|
95
|
+
waitForLoadState?(
|
|
96
|
+
state: "load" | "domcontentloaded" | "networkidle",
|
|
97
|
+
options?: { timeout: number },
|
|
98
|
+
): Promise<void>;
|
|
91
99
|
title(): Promise<string>;
|
|
92
100
|
url(): string;
|
|
93
101
|
locator(selector: string): { innerText(): Promise<string> };
|
|
@@ -99,26 +107,37 @@ interface PlaywrightPageLike {
|
|
|
99
107
|
interface PlaywrightContextLike {
|
|
100
108
|
pages(): PlaywrightPageLike[];
|
|
101
109
|
newPage(): Promise<PlaywrightPageLike>;
|
|
110
|
+
close?(): Promise<void>;
|
|
102
111
|
}
|
|
103
112
|
|
|
104
113
|
interface PlaywrightBrowserLike {
|
|
105
114
|
contexts(): PlaywrightContextLike[];
|
|
106
|
-
newContext(): Promise<PlaywrightContextLike>;
|
|
115
|
+
newContext?(): Promise<PlaywrightContextLike>;
|
|
107
116
|
close(): Promise<void>;
|
|
108
117
|
}
|
|
109
118
|
|
|
110
119
|
interface PlaywrightLike {
|
|
111
120
|
chromium: {
|
|
112
121
|
connectOverCDP(endpoint: string): Promise<PlaywrightBrowserLike>;
|
|
113
|
-
launch(options: {
|
|
122
|
+
launch(options: {
|
|
123
|
+
headless: boolean;
|
|
124
|
+
args?: string[];
|
|
125
|
+
executablePath?: string;
|
|
126
|
+
}): Promise<PlaywrightBrowserLike>;
|
|
114
127
|
};
|
|
115
128
|
}
|
|
116
129
|
|
|
117
130
|
export interface LayeredRenderedPageProviderOptions {
|
|
131
|
+
/**
|
|
132
|
+
* Retained for compatibility with the previous browser renderer. Arbitrary
|
|
133
|
+
* Chromium navigation is disabled until a connect-time SSRF guard exists.
|
|
134
|
+
*/
|
|
118
135
|
requestBuilderBrowserConnection?: (input: {
|
|
119
136
|
sessionId: string;
|
|
120
137
|
}) => Promise<Record<string, unknown>>;
|
|
138
|
+
/** @deprecated Browser navigation is disabled until it is connect-time guarded. */
|
|
121
139
|
loadPlaywright?: () => Promise<PlaywrightLike | null>;
|
|
140
|
+
/** @deprecated Browser navigation is disabled until it is connect-time guarded. */
|
|
122
141
|
requestAttachedBrowserConnection?: (input: {
|
|
123
142
|
sessionId: string;
|
|
124
143
|
url: string;
|
|
@@ -127,94 +146,28 @@ export interface LayeredRenderedPageProviderOptions {
|
|
|
127
146
|
}
|
|
128
147
|
|
|
129
148
|
export class LayeredRenderedPageProvider implements RenderedPageProvider {
|
|
130
|
-
readonly #requestBuilderBrowserConnection: (input: {
|
|
131
|
-
sessionId: string;
|
|
132
|
-
}) => Promise<Record<string, unknown>>;
|
|
133
|
-
readonly #loadPlaywright: () => Promise<PlaywrightLike | null>;
|
|
134
|
-
readonly #requestAttachedBrowserConnection?: NonNullable<
|
|
135
|
-
LayeredRenderedPageProviderOptions["requestAttachedBrowserConnection"]
|
|
136
|
-
>;
|
|
137
149
|
readonly #staticFetch: typeof ssrfSafeFetch;
|
|
138
150
|
|
|
139
151
|
constructor(options: LayeredRenderedPageProviderOptions = {}) {
|
|
140
|
-
this.#requestBuilderBrowserConnection =
|
|
141
|
-
options.requestBuilderBrowserConnection ?? defaultBuilderBrowserRequest;
|
|
142
|
-
this.#loadPlaywright = options.loadPlaywright ?? loadOptionalPlaywright;
|
|
143
|
-
this.#requestAttachedBrowserConnection =
|
|
144
|
-
options.requestAttachedBrowserConnection;
|
|
145
152
|
this.#staticFetch = options.staticFetch ?? ssrfSafeFetch;
|
|
146
153
|
}
|
|
147
154
|
|
|
148
155
|
async render(request: RenderedPageRequest): Promise<RenderedPageResult> {
|
|
149
156
|
await assertPublicBrowserUrl(request.url);
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
if (request.preferHosted !== false && playwright) {
|
|
157
|
-
try {
|
|
158
|
-
const connection = await this.#requestBuilderBrowserConnection({
|
|
159
|
-
sessionId: `creative-context-${randomUUID()}`,
|
|
160
|
-
});
|
|
161
|
-
const wsUrl = stringValue(connection.wsUrl);
|
|
162
|
-
if (!wsUrl) throw new Error("Builder Browser did not return wsUrl.");
|
|
163
|
-
return await renderWithPlaywright(
|
|
164
|
-
playwright,
|
|
165
|
-
request,
|
|
166
|
-
warnings,
|
|
167
|
-
"builder-browser",
|
|
168
|
-
wsUrl,
|
|
169
|
-
);
|
|
170
|
-
} catch (error) {
|
|
171
|
-
warnings.push(`Builder Browser unavailable: ${errorMessage(error)}`);
|
|
172
|
-
}
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
if (playwright) {
|
|
176
|
-
try {
|
|
177
|
-
return await renderWithPlaywright(
|
|
178
|
-
playwright,
|
|
179
|
-
request,
|
|
180
|
-
warnings,
|
|
181
|
-
"local-playwright",
|
|
182
|
-
);
|
|
183
|
-
} catch (error) {
|
|
184
|
-
warnings.push(`Local Playwright unavailable: ${errorMessage(error)}`);
|
|
185
|
-
}
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
if (playwright && this.#requestAttachedBrowserConnection) {
|
|
189
|
-
try {
|
|
190
|
-
const connection = await this.#requestAttachedBrowserConnection({
|
|
191
|
-
sessionId: `creative-context-attached-${randomUUID()}`,
|
|
192
|
-
url: request.url,
|
|
193
|
-
});
|
|
194
|
-
if (!connection.wsUrl?.trim()) {
|
|
195
|
-
throw new Error("Approved attached browser did not return wsUrl.");
|
|
196
|
-
}
|
|
197
|
-
return await renderWithPlaywright(
|
|
198
|
-
playwright,
|
|
199
|
-
request,
|
|
200
|
-
warnings,
|
|
201
|
-
"attached-chrome",
|
|
202
|
-
connection.wsUrl,
|
|
203
|
-
);
|
|
204
|
-
} catch (error) {
|
|
205
|
-
warnings.push(`Attached Chrome unavailable: ${errorMessage(error)}`);
|
|
206
|
-
}
|
|
207
|
-
} else {
|
|
208
|
-
warnings.push(
|
|
209
|
-
"Attached Chrome unavailable: no approved browser connection adapter is configured.",
|
|
210
|
-
);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
return renderStatic(request, warnings, this.#staticFetch);
|
|
157
|
+
return renderStatic(
|
|
158
|
+
request,
|
|
159
|
+
[BROWSER_NAVIGATION_DISABLED_WARNING],
|
|
160
|
+
this.#staticFetch,
|
|
161
|
+
);
|
|
214
162
|
}
|
|
215
163
|
}
|
|
216
164
|
|
|
217
|
-
|
|
165
|
+
/**
|
|
166
|
+
* Internal browser renderer retained for a future connect-time-guarded
|
|
167
|
+
* adapter. `LayeredRenderedPageProvider` must not call this for arbitrary
|
|
168
|
+
* caller URLs until Chromium's socket connection is guarded as well.
|
|
169
|
+
*/
|
|
170
|
+
export async function renderWithPlaywright(
|
|
218
171
|
playwright: PlaywrightLike,
|
|
219
172
|
request: RenderedPageRequest,
|
|
220
173
|
warnings: string[],
|
|
@@ -223,9 +176,21 @@ async function renderWithPlaywright(
|
|
|
223
176
|
): Promise<RenderedPageResult> {
|
|
224
177
|
const browser = wsUrl
|
|
225
178
|
? await playwright.chromium.connectOverCDP(wsUrl)
|
|
226
|
-
: await playwright.chromium
|
|
179
|
+
: await launchChromium(playwright.chromium);
|
|
180
|
+
let isolatedContext: PlaywrightContextLike | undefined;
|
|
227
181
|
try {
|
|
228
|
-
|
|
182
|
+
// A hosted CDP browser can already contain the user's other tabs. Prefer a
|
|
183
|
+
// fresh context so extraction never reads ambient browser state, cookies,
|
|
184
|
+
// or a page left behind by another workflow. Older browser adapters may not
|
|
185
|
+
// expose newContext, so retain the existing-context fallback.
|
|
186
|
+
try {
|
|
187
|
+
isolatedContext = await browser.newContext?.();
|
|
188
|
+
} catch {
|
|
189
|
+
isolatedContext = undefined;
|
|
190
|
+
}
|
|
191
|
+
const context = isolatedContext ?? browser.contexts()[0];
|
|
192
|
+
if (!context)
|
|
193
|
+
throw new Error("Browser did not provide an isolated context.");
|
|
229
194
|
const page = context.pages()[0] ?? (await context.newPage());
|
|
230
195
|
await installNavigationGuard(page);
|
|
231
196
|
await page.setViewportSize({ width: 1440, height: 900 });
|
|
@@ -233,27 +198,77 @@ async function renderWithPlaywright(
|
|
|
233
198
|
timeout: boundedTimeout(request.timeoutMs),
|
|
234
199
|
waitUntil: request.waitUntil ?? "domcontentloaded",
|
|
235
200
|
});
|
|
201
|
+
await page
|
|
202
|
+
.waitForLoadState?.("load", {
|
|
203
|
+
timeout: Math.min(8_000, boundedTimeout(request.timeoutMs)),
|
|
204
|
+
})
|
|
205
|
+
.catch((error) => {
|
|
206
|
+
warnings.push(
|
|
207
|
+
`Browser load stabilization unavailable: ${errorMessage(error)}`,
|
|
208
|
+
);
|
|
209
|
+
});
|
|
210
|
+
// React hydration, CSS-in-JS insertion, and web fonts commonly finish just
|
|
211
|
+
// after `load`. Give those layers a bounded chance to settle, then capture
|
|
212
|
+
// the computed cascade rather than the server HTML.
|
|
213
|
+
await page
|
|
214
|
+
.waitForLoadState?.("networkidle", { timeout: 4_000 })
|
|
215
|
+
.catch((error) => {
|
|
216
|
+
warnings.push(
|
|
217
|
+
`Browser network-idle stabilization unavailable: ${errorMessage(error)}`,
|
|
218
|
+
);
|
|
219
|
+
});
|
|
220
|
+
await waitForFontReadiness(
|
|
221
|
+
page,
|
|
222
|
+
Math.min(FONT_READY_TIMEOUT_MS, boundedTimeout(request.timeoutMs)),
|
|
223
|
+
).catch((error) => {
|
|
224
|
+
warnings.push(
|
|
225
|
+
`Browser font readiness unavailable: ${errorMessage(error)}`,
|
|
226
|
+
);
|
|
227
|
+
});
|
|
228
|
+
await new Promise((resolve) => setTimeout(resolve, 150));
|
|
229
|
+
await page.evaluate(dismissConsentOverlays).catch(() => undefined);
|
|
236
230
|
const finalUrl = page.url();
|
|
237
231
|
await assertPublicBrowserUrl(finalUrl);
|
|
238
232
|
const [title, text, desktopScreenshot, extraction] = await Promise.all([
|
|
239
|
-
page.title().catch(() =>
|
|
233
|
+
page.title().catch((error) => {
|
|
234
|
+
warnings.push(
|
|
235
|
+
`Browser title extraction unavailable: ${errorMessage(error)}`,
|
|
236
|
+
);
|
|
237
|
+
return "";
|
|
238
|
+
}),
|
|
240
239
|
page
|
|
241
240
|
.locator("body")
|
|
242
241
|
.innerText()
|
|
243
|
-
.catch(() =>
|
|
242
|
+
.catch((error) => {
|
|
243
|
+
warnings.push(
|
|
244
|
+
`Browser text extraction unavailable: ${errorMessage(error)}`,
|
|
245
|
+
);
|
|
246
|
+
return "";
|
|
247
|
+
}),
|
|
244
248
|
page
|
|
245
249
|
.screenshot({ type: "png", fullPage: false })
|
|
246
250
|
.then(boundedScreenshot)
|
|
247
|
-
.catch(() =>
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
+
.catch((error) => {
|
|
252
|
+
warnings.push(
|
|
253
|
+
`Desktop screenshot unavailable: ${errorMessage(error)}`,
|
|
254
|
+
);
|
|
255
|
+
return undefined;
|
|
256
|
+
}),
|
|
257
|
+
page.evaluate(captureRenderedWebsiteContext).catch((error) => {
|
|
258
|
+
warnings.push(
|
|
259
|
+
`Browser style extraction unavailable: ${errorMessage(error)}`,
|
|
260
|
+
);
|
|
261
|
+
return emptyExtraction();
|
|
262
|
+
}),
|
|
251
263
|
]);
|
|
252
264
|
await page.setViewportSize({ width: 390, height: 844 });
|
|
253
265
|
const mobileScreenshot = await page
|
|
254
266
|
.screenshot({ type: "png", fullPage: false })
|
|
255
267
|
.then(boundedScreenshot)
|
|
256
|
-
.catch(() =>
|
|
268
|
+
.catch((error) => {
|
|
269
|
+
warnings.push(`Mobile screenshot unavailable: ${errorMessage(error)}`);
|
|
270
|
+
return undefined;
|
|
271
|
+
});
|
|
257
272
|
const unboundedText = normalizeWhitespace(text);
|
|
258
273
|
const textTruncated = unboundedText.length > MAX_RENDERED_TEXT_CHARS;
|
|
259
274
|
const normalizedText = unboundedText.slice(0, MAX_RENDERED_TEXT_CHARS);
|
|
@@ -317,10 +332,36 @@ async function renderWithPlaywright(
|
|
|
317
332
|
},
|
|
318
333
|
};
|
|
319
334
|
} finally {
|
|
335
|
+
await isolatedContext?.close?.().catch((error) => {
|
|
336
|
+
warnings.push(
|
|
337
|
+
`Browser context cleanup unavailable: ${errorMessage(error)}`,
|
|
338
|
+
);
|
|
339
|
+
});
|
|
320
340
|
await browser.close().catch(() => undefined);
|
|
321
341
|
}
|
|
322
342
|
}
|
|
323
343
|
|
|
344
|
+
async function waitForFontReadiness(
|
|
345
|
+
page: PlaywrightPageLike,
|
|
346
|
+
timeoutMs: number,
|
|
347
|
+
): Promise<void> {
|
|
348
|
+
let timeout: ReturnType<typeof setTimeout> | undefined;
|
|
349
|
+
try {
|
|
350
|
+
await Promise.race([
|
|
351
|
+
page.evaluate(async () => {
|
|
352
|
+
if (document.fonts?.ready) await document.fonts.ready;
|
|
353
|
+
}),
|
|
354
|
+
new Promise<never>((_, reject) => {
|
|
355
|
+
timeout = setTimeout(() => {
|
|
356
|
+
reject(new Error(`font readiness timed out after ${timeoutMs}ms`));
|
|
357
|
+
}, timeoutMs);
|
|
358
|
+
}),
|
|
359
|
+
]);
|
|
360
|
+
} finally {
|
|
361
|
+
if (timeout !== undefined) clearTimeout(timeout);
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
|
|
324
365
|
async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
|
|
325
366
|
await page.route("**/*", async (route) => {
|
|
326
367
|
const request = route.request();
|
|
@@ -349,6 +390,74 @@ async function installNavigationGuard(page: PlaywrightPageLike): Promise<void> {
|
|
|
349
390
|
});
|
|
350
391
|
}
|
|
351
392
|
|
|
393
|
+
const SYSTEM_CHROME_EXECUTABLES = [
|
|
394
|
+
"/usr/bin/google-chrome-stable",
|
|
395
|
+
"/usr/bin/google-chrome",
|
|
396
|
+
"/usr/bin/chromium",
|
|
397
|
+
"/usr/bin/chromium-browser",
|
|
398
|
+
];
|
|
399
|
+
|
|
400
|
+
async function launchChromium(
|
|
401
|
+
chromium: PlaywrightLike["chromium"],
|
|
402
|
+
): Promise<PlaywrightBrowserLike> {
|
|
403
|
+
const launchOptions = {
|
|
404
|
+
headless: true,
|
|
405
|
+
args: ["--no-sandbox", "--disable-dev-shm-usage"],
|
|
406
|
+
};
|
|
407
|
+
try {
|
|
408
|
+
return await chromium.launch(launchOptions);
|
|
409
|
+
} catch (error) {
|
|
410
|
+
if (!isMissingBrowserError(error)) throw error;
|
|
411
|
+
for (const executablePath of SYSTEM_CHROME_EXECUTABLES) {
|
|
412
|
+
if (!existsSync(executablePath)) continue;
|
|
413
|
+
try {
|
|
414
|
+
return await chromium.launch({ ...launchOptions, executablePath });
|
|
415
|
+
} catch {
|
|
416
|
+
continue;
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
throw error;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
function isMissingBrowserError(error: unknown): boolean {
|
|
424
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
425
|
+
return /Executable doesn't exist|playwright install|browser.*not found|chromium.*not found/i.test(
|
|
426
|
+
message,
|
|
427
|
+
);
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
/** Close common consent banners without accepting tracking or changing page data. */
|
|
431
|
+
function dismissConsentOverlays(): void {
|
|
432
|
+
const selectors = [
|
|
433
|
+
'[aria-label*="reject" i]',
|
|
434
|
+
'[aria-label*="decline" i]',
|
|
435
|
+
'[aria-label*="close" i]',
|
|
436
|
+
'[data-testid*="reject" i]',
|
|
437
|
+
'[data-testid*="decline" i]',
|
|
438
|
+
'[data-testid*="close" i]',
|
|
439
|
+
'button[id*="reject" i]',
|
|
440
|
+
'button[id*="decline" i]',
|
|
441
|
+
'button[class*="reject" i]',
|
|
442
|
+
'button[class*="decline" i]',
|
|
443
|
+
];
|
|
444
|
+
for (const selector of selectors) {
|
|
445
|
+
const element = document.querySelector<HTMLElement>(selector);
|
|
446
|
+
if (!element) continue;
|
|
447
|
+
const rect = element.getBoundingClientRect();
|
|
448
|
+
const style = getComputedStyle(element);
|
|
449
|
+
if (
|
|
450
|
+
rect.width > 0 &&
|
|
451
|
+
rect.height > 0 &&
|
|
452
|
+
style.visibility !== "hidden" &&
|
|
453
|
+
style.display !== "none"
|
|
454
|
+
) {
|
|
455
|
+
element.click();
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
352
461
|
async function renderStatic(
|
|
353
462
|
request: RenderedPageRequest,
|
|
354
463
|
warnings: string[],
|
|
@@ -423,31 +532,6 @@ async function assertPublicBrowserUrl(value: string): Promise<void> {
|
|
|
423
532
|
}
|
|
424
533
|
}
|
|
425
534
|
|
|
426
|
-
async function defaultBuilderBrowserRequest(input: {
|
|
427
|
-
sessionId: string;
|
|
428
|
-
}): Promise<Record<string, unknown>> {
|
|
429
|
-
const server = (await import("@agent-native/core/server")) as unknown as {
|
|
430
|
-
requestBuilderBrowserConnection?: (value: {
|
|
431
|
-
sessionId: string;
|
|
432
|
-
}) => Promise<Record<string, unknown>>;
|
|
433
|
-
};
|
|
434
|
-
if (!server.requestBuilderBrowserConnection) {
|
|
435
|
-
throw new Error(
|
|
436
|
-
"@agent-native/core/server does not export requestBuilderBrowserConnection.",
|
|
437
|
-
);
|
|
438
|
-
}
|
|
439
|
-
return server.requestBuilderBrowserConnection(input);
|
|
440
|
-
}
|
|
441
|
-
|
|
442
|
-
async function loadOptionalPlaywright(): Promise<PlaywrightLike | null> {
|
|
443
|
-
const specifier = "playwright";
|
|
444
|
-
try {
|
|
445
|
-
return (await import(specifier)) as PlaywrightLike;
|
|
446
|
-
} catch {
|
|
447
|
-
return null;
|
|
448
|
-
}
|
|
449
|
-
}
|
|
450
|
-
|
|
451
535
|
function captureRenderedWebsiteContext(): WebsiteExtraction {
|
|
452
536
|
const MAX_ASSETS = 500;
|
|
453
537
|
const MAX_LINKS = 500;
|
|
@@ -455,8 +539,11 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
|
|
|
455
539
|
const MAX_TYPE_STYLES = 100;
|
|
456
540
|
const MAX_SPACING_VALUES = 100;
|
|
457
541
|
const MAX_RADIUS_VALUES = 64;
|
|
542
|
+
const MAX_SHADOWS = 32;
|
|
543
|
+
const MAX_BACKGROUNDS = 32;
|
|
458
544
|
const MAX_VARIABLES = 128;
|
|
459
545
|
const MAX_TEXT = 2_000_000;
|
|
546
|
+
const MAX_COMPONENT_STYLES = 24;
|
|
460
547
|
const assets = new Map<string, WebsiteExtraction["assets"][number]>();
|
|
461
548
|
const links = new Set<string>();
|
|
462
549
|
const colors: string[] = [];
|
|
@@ -466,7 +553,161 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
|
|
|
466
553
|
>();
|
|
467
554
|
const spacing = new Set<string>();
|
|
468
555
|
const radii = new Set<string>();
|
|
556
|
+
const shadows = new Set<string>();
|
|
557
|
+
const backgrounds = new Set<string>();
|
|
558
|
+
const components: NonNullable<
|
|
559
|
+
WebsiteExtraction["designTokens"]["components"]
|
|
560
|
+
> = [];
|
|
469
561
|
const cssVariables: Record<string, string> = {};
|
|
562
|
+
const semanticColors: NonNullable<
|
|
563
|
+
WebsiteExtraction["designTokens"]["semanticColors"]
|
|
564
|
+
> = {};
|
|
565
|
+
|
|
566
|
+
const isOpaque = (value: string): boolean => {
|
|
567
|
+
const normalized = value.trim().toLowerCase();
|
|
568
|
+
if (!normalized || normalized === "transparent") return false;
|
|
569
|
+
const functionBody = normalized.match(/^[a-z-]+\((.*)\)$/)?.[1];
|
|
570
|
+
if (!functionBody) return true;
|
|
571
|
+
const alphaValue = functionBody.includes("/")
|
|
572
|
+
? functionBody.split("/").at(-1)?.trim()
|
|
573
|
+
: functionBody.split(",").length === 4
|
|
574
|
+
? functionBody.split(",").at(-1)?.trim()
|
|
575
|
+
: undefined;
|
|
576
|
+
if (!alphaValue) return true;
|
|
577
|
+
const alpha = alphaValue.endsWith("%")
|
|
578
|
+
? Number.parseFloat(alphaValue) / 100
|
|
579
|
+
: Number.parseFloat(alphaValue);
|
|
580
|
+
return Number.isNaN(alpha) || alpha > 0.02;
|
|
581
|
+
};
|
|
582
|
+
|
|
583
|
+
const addColor = (value: string): void => {
|
|
584
|
+
if (colors.length >= MAX_COLOR_VALUES || !isOpaque(value)) return;
|
|
585
|
+
const normalized = value.trim();
|
|
586
|
+
if (!colors.includes(normalized)) colors.push(normalized);
|
|
587
|
+
};
|
|
588
|
+
|
|
589
|
+
const visible = (element: Element): boolean => {
|
|
590
|
+
const style = getComputedStyle(element);
|
|
591
|
+
const rect = element.getBoundingClientRect();
|
|
592
|
+
return (
|
|
593
|
+
rect.width > 0 &&
|
|
594
|
+
rect.height > 0 &&
|
|
595
|
+
style.display !== "none" &&
|
|
596
|
+
style.visibility !== "hidden" &&
|
|
597
|
+
Number(style.opacity || 1) > 0.02
|
|
598
|
+
);
|
|
599
|
+
};
|
|
600
|
+
|
|
601
|
+
const firstVisible = (selector: string): Element | undefined =>
|
|
602
|
+
Array.from(document.querySelectorAll(selector)).find(visible);
|
|
603
|
+
|
|
604
|
+
const opaqueValue = (value: string): string | undefined =>
|
|
605
|
+
isOpaque(value) ? value.trim() : undefined;
|
|
606
|
+
|
|
607
|
+
const recordComputedStyle = (
|
|
608
|
+
element: Element,
|
|
609
|
+
role?: NonNullable<
|
|
610
|
+
WebsiteExtraction["designTokens"]["components"]
|
|
611
|
+
>[number]["role"],
|
|
612
|
+
) => {
|
|
613
|
+
const style = getComputedStyle(element);
|
|
614
|
+
const values = [
|
|
615
|
+
style.color,
|
|
616
|
+
style.backgroundColor,
|
|
617
|
+
style.borderTopColor,
|
|
618
|
+
style.borderRightColor,
|
|
619
|
+
style.borderBottomColor,
|
|
620
|
+
style.borderLeftColor,
|
|
621
|
+
];
|
|
622
|
+
values.forEach(addColor);
|
|
623
|
+
if (style.boxShadow && style.boxShadow !== "none") {
|
|
624
|
+
if (shadows.size < MAX_SHADOWS) shadows.add(style.boxShadow);
|
|
625
|
+
}
|
|
626
|
+
if (style.backgroundImage && style.backgroundImage !== "none") {
|
|
627
|
+
if (backgrounds.size < MAX_BACKGROUNDS) {
|
|
628
|
+
backgrounds.add(style.backgroundImage);
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
for (const value of [
|
|
632
|
+
style.marginTop,
|
|
633
|
+
style.marginRight,
|
|
634
|
+
style.marginBottom,
|
|
635
|
+
style.marginLeft,
|
|
636
|
+
style.paddingTop,
|
|
637
|
+
style.paddingRight,
|
|
638
|
+
style.paddingBottom,
|
|
639
|
+
style.paddingLeft,
|
|
640
|
+
style.gap,
|
|
641
|
+
style.rowGap,
|
|
642
|
+
style.columnGap,
|
|
643
|
+
]) {
|
|
644
|
+
if (
|
|
645
|
+
spacing.size < MAX_SPACING_VALUES &&
|
|
646
|
+
value &&
|
|
647
|
+
value !== "0px" &&
|
|
648
|
+
value !== "normal"
|
|
649
|
+
) {
|
|
650
|
+
spacing.add(value);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
for (const value of [
|
|
654
|
+
style.borderTopLeftRadius,
|
|
655
|
+
style.borderTopRightRadius,
|
|
656
|
+
style.borderBottomRightRadius,
|
|
657
|
+
style.borderBottomLeftRadius,
|
|
658
|
+
]) {
|
|
659
|
+
if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
|
|
660
|
+
radii.add(value);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
const type = {
|
|
664
|
+
family: style.fontFamily,
|
|
665
|
+
size: style.fontSize,
|
|
666
|
+
weight: style.fontWeight,
|
|
667
|
+
lineHeight: style.lineHeight,
|
|
668
|
+
letterSpacing: style.letterSpacing,
|
|
669
|
+
};
|
|
670
|
+
if (typography.size < MAX_TYPE_STYLES && type.family) {
|
|
671
|
+
typography.set(JSON.stringify(type), type);
|
|
672
|
+
}
|
|
673
|
+
if (!role || components.length >= MAX_COMPONENT_STYLES) return style;
|
|
674
|
+
components.push({
|
|
675
|
+
role,
|
|
676
|
+
fontFamily: style.fontFamily,
|
|
677
|
+
fontSize: style.fontSize,
|
|
678
|
+
fontWeight: style.fontWeight,
|
|
679
|
+
lineHeight: style.lineHeight,
|
|
680
|
+
letterSpacing: style.letterSpacing,
|
|
681
|
+
color: opaqueValue(style.color),
|
|
682
|
+
backgroundColor: opaqueValue(style.backgroundColor),
|
|
683
|
+
backgroundImage:
|
|
684
|
+
style.backgroundImage !== "none" ? style.backgroundImage : undefined,
|
|
685
|
+
border:
|
|
686
|
+
style.borderStyle !== "none" && style.borderWidth !== "0px"
|
|
687
|
+
? style.border
|
|
688
|
+
: undefined,
|
|
689
|
+
borderRadius:
|
|
690
|
+
style.borderTopLeftRadius !== "0px" ? style.borderRadius : undefined,
|
|
691
|
+
boxShadow: style.boxShadow !== "none" ? style.boxShadow : undefined,
|
|
692
|
+
padding: style.padding !== "0px" ? style.padding : undefined,
|
|
693
|
+
gap: style.gap !== "normal" ? style.gap : undefined,
|
|
694
|
+
textTransform:
|
|
695
|
+
style.textTransform !== "none" ? style.textTransform : undefined,
|
|
696
|
+
});
|
|
697
|
+
return style;
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
const styleFor = (
|
|
701
|
+
selector: string,
|
|
702
|
+
role?: NonNullable<
|
|
703
|
+
WebsiteExtraction["designTokens"]["components"]
|
|
704
|
+
>[number]["role"],
|
|
705
|
+
): CSSStyleDeclaration | undefined => {
|
|
706
|
+
const element = firstVisible(selector);
|
|
707
|
+
if (!element) return undefined;
|
|
708
|
+
return recordComputedStyle(element, role);
|
|
709
|
+
};
|
|
710
|
+
|
|
470
711
|
const addAsset = (
|
|
471
712
|
raw: string | null | undefined,
|
|
472
713
|
kind: WebsiteExtraction["assets"][number]["kind"],
|
|
@@ -548,64 +789,99 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
|
|
|
548
789
|
}
|
|
549
790
|
}
|
|
550
791
|
}
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
792
|
+
|
|
793
|
+
const bodyStyle = document.body
|
|
794
|
+
? recordComputedStyle(document.body)
|
|
795
|
+
: undefined;
|
|
796
|
+
const rootBackground = opaqueValue(rootStyle.backgroundColor);
|
|
797
|
+
const bodyBackground = bodyStyle
|
|
798
|
+
? opaqueValue(bodyStyle.backgroundColor)
|
|
799
|
+
: undefined;
|
|
800
|
+
const headingStyle = styleFor("h1, h2, h3", "heading");
|
|
801
|
+
const textStyle = styleFor("p, li, label, body", "body");
|
|
802
|
+
const buttonStyle = styleFor(
|
|
803
|
+
'button, [role="button"], input[type="submit"], a[class*="button" i], a[class*="cta" i]',
|
|
804
|
+
"button",
|
|
805
|
+
);
|
|
806
|
+
const linkStyle = styleFor("a[href]", "link");
|
|
807
|
+
const cardStyle = styleFor(
|
|
808
|
+
'article, [class*="card" i], [class*="panel" i], [class*="surface" i], section',
|
|
809
|
+
"card",
|
|
810
|
+
);
|
|
811
|
+
styleFor("input, textarea, select", "input");
|
|
812
|
+
styleFor("nav, header", "nav");
|
|
813
|
+
styleFor('main, [class*="hero" i]', "hero");
|
|
814
|
+
|
|
815
|
+
const setSemantic = (
|
|
816
|
+
role: keyof NonNullable<
|
|
817
|
+
WebsiteExtraction["designTokens"]["semanticColors"]
|
|
818
|
+
>,
|
|
819
|
+
value: string | undefined,
|
|
820
|
+
): void => {
|
|
821
|
+
if (value) semanticColors[role] = value;
|
|
822
|
+
};
|
|
823
|
+
setSemantic("background", bodyBackground ?? rootBackground);
|
|
824
|
+
setSemantic(
|
|
825
|
+
"surface",
|
|
826
|
+
cardStyle ? opaqueValue(cardStyle.backgroundColor) : undefined,
|
|
827
|
+
);
|
|
828
|
+
setSemantic("text", textStyle ? opaqueValue(textStyle.color) : undefined);
|
|
829
|
+
const mutedElement = firstVisible("small, figcaption, [class*='muted' i]");
|
|
830
|
+
setSemantic(
|
|
831
|
+
"textMuted",
|
|
832
|
+
mutedElement
|
|
833
|
+
? opaqueValue(getComputedStyle(mutedElement).color)
|
|
834
|
+
: undefined,
|
|
835
|
+
);
|
|
836
|
+
setSemantic(
|
|
837
|
+
"accent",
|
|
838
|
+
linkStyle
|
|
839
|
+
? opaqueValue(linkStyle.color)
|
|
840
|
+
: buttonStyle
|
|
841
|
+
? opaqueValue(buttonStyle.backgroundColor)
|
|
842
|
+
: undefined,
|
|
554
843
|
);
|
|
844
|
+
setSemantic(
|
|
845
|
+
"primary",
|
|
846
|
+
buttonStyle
|
|
847
|
+
? (opaqueValue(buttonStyle.backgroundColor) ??
|
|
848
|
+
opaqueValue(buttonStyle.color))
|
|
849
|
+
: colors[0],
|
|
850
|
+
);
|
|
851
|
+
setSemantic(
|
|
852
|
+
"secondary",
|
|
853
|
+
semanticColors.surface ?? colors[1] ?? semanticColors.background,
|
|
854
|
+
);
|
|
855
|
+
|
|
856
|
+
const layoutElement = firstVisible("main, [role='main'], body > div");
|
|
857
|
+
const layoutStyle = layoutElement
|
|
858
|
+
? getComputedStyle(layoutElement)
|
|
859
|
+
: undefined;
|
|
860
|
+
const layoutRect = layoutElement?.getBoundingClientRect();
|
|
861
|
+
const layout = {
|
|
862
|
+
contentWidth:
|
|
863
|
+
layoutStyle?.maxWidth && layoutStyle.maxWidth !== "none"
|
|
864
|
+
? layoutStyle.maxWidth
|
|
865
|
+
: layoutRect && layoutRect.width > 0
|
|
866
|
+
? `${Math.round(layoutRect.width)}px`
|
|
867
|
+
: undefined,
|
|
868
|
+
pagePadding:
|
|
869
|
+
bodyStyle?.padding && bodyStyle.padding !== "0px"
|
|
870
|
+
? bodyStyle.padding
|
|
871
|
+
: undefined,
|
|
872
|
+
sectionGap:
|
|
873
|
+
firstVisible("section, article") &&
|
|
874
|
+
getComputedStyle(firstVisible("section, article")!).gap !== "normal" &&
|
|
875
|
+
getComputedStyle(firstVisible("section, article")!).gap !== "0px"
|
|
876
|
+
? getComputedStyle(firstVisible("section, article")!).gap
|
|
877
|
+
: undefined,
|
|
878
|
+
};
|
|
879
|
+
|
|
880
|
+
const elements = Array.from(document.querySelectorAll("body *"))
|
|
881
|
+
.filter(visible)
|
|
882
|
+
.slice(0, 700);
|
|
555
883
|
for (const element of elements) {
|
|
556
|
-
|
|
557
|
-
if (colors.length < MAX_COLOR_VALUES) {
|
|
558
|
-
colors.push(
|
|
559
|
-
style.color,
|
|
560
|
-
style.backgroundColor,
|
|
561
|
-
style.borderTopColor,
|
|
562
|
-
style.borderRightColor,
|
|
563
|
-
style.borderBottomColor,
|
|
564
|
-
style.borderLeftColor,
|
|
565
|
-
);
|
|
566
|
-
}
|
|
567
|
-
const type = {
|
|
568
|
-
family: style.fontFamily,
|
|
569
|
-
size: style.fontSize,
|
|
570
|
-
weight: style.fontWeight,
|
|
571
|
-
lineHeight: style.lineHeight,
|
|
572
|
-
letterSpacing: style.letterSpacing,
|
|
573
|
-
};
|
|
574
|
-
if (typography.size < MAX_TYPE_STYLES) {
|
|
575
|
-
typography.set(JSON.stringify(type), type);
|
|
576
|
-
}
|
|
577
|
-
for (const value of [
|
|
578
|
-
style.marginTop,
|
|
579
|
-
style.marginRight,
|
|
580
|
-
style.marginBottom,
|
|
581
|
-
style.marginLeft,
|
|
582
|
-
style.paddingTop,
|
|
583
|
-
style.paddingRight,
|
|
584
|
-
style.paddingBottom,
|
|
585
|
-
style.paddingLeft,
|
|
586
|
-
style.gap,
|
|
587
|
-
style.rowGap,
|
|
588
|
-
style.columnGap,
|
|
589
|
-
]) {
|
|
590
|
-
if (
|
|
591
|
-
spacing.size < MAX_SPACING_VALUES &&
|
|
592
|
-
value &&
|
|
593
|
-
value !== "0px" &&
|
|
594
|
-
value !== "normal"
|
|
595
|
-
) {
|
|
596
|
-
spacing.add(value);
|
|
597
|
-
}
|
|
598
|
-
}
|
|
599
|
-
for (const value of [
|
|
600
|
-
style.borderTopLeftRadius,
|
|
601
|
-
style.borderTopRightRadius,
|
|
602
|
-
style.borderBottomRightRadius,
|
|
603
|
-
style.borderBottomLeftRadius,
|
|
604
|
-
]) {
|
|
605
|
-
if (radii.size < MAX_RADIUS_VALUES && value && value !== "0px") {
|
|
606
|
-
radii.add(value);
|
|
607
|
-
}
|
|
608
|
-
}
|
|
884
|
+
recordComputedStyle(element);
|
|
609
885
|
}
|
|
610
886
|
return {
|
|
611
887
|
title: document.title,
|
|
@@ -618,6 +894,11 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
|
|
|
618
894
|
spacing: [...spacing],
|
|
619
895
|
radii: [...radii],
|
|
620
896
|
cssVariables,
|
|
897
|
+
semanticColors,
|
|
898
|
+
shadows: [...shadows],
|
|
899
|
+
backgrounds: [...backgrounds],
|
|
900
|
+
components,
|
|
901
|
+
layout,
|
|
621
902
|
},
|
|
622
903
|
};
|
|
623
904
|
}
|
|
@@ -625,6 +906,16 @@ function captureRenderedWebsiteContext(): WebsiteExtraction {
|
|
|
625
906
|
export function boundWebsiteExtraction(
|
|
626
907
|
extraction: WebsiteExtraction,
|
|
627
908
|
): WebsiteExtraction {
|
|
909
|
+
const boundedComponents = extraction.designTokens.components
|
|
910
|
+
?.slice(0, MAX_COMPONENT_STYLES)
|
|
911
|
+
.map((component) =>
|
|
912
|
+
Object.fromEntries(
|
|
913
|
+
Object.entries(component).map(([key, value]) => [
|
|
914
|
+
key,
|
|
915
|
+
typeof value === "string" ? value.slice(0, 500) : value,
|
|
916
|
+
]),
|
|
917
|
+
),
|
|
918
|
+
) as WebsiteExtraction["designTokens"]["components"];
|
|
628
919
|
return {
|
|
629
920
|
title: normalizeWhitespace(extraction.title).slice(0, 500),
|
|
630
921
|
text: normalizeWhitespace(extraction.text).slice(
|
|
@@ -648,6 +939,39 @@ export function boundWebsiteExtraction(
|
|
|
648
939
|
.slice(0, MAX_CSS_VARIABLES)
|
|
649
940
|
.map(([name, value]) => [name.slice(0, 500), value.slice(0, 4_096)]),
|
|
650
941
|
),
|
|
942
|
+
...(extraction.designTokens.semanticColors
|
|
943
|
+
? {
|
|
944
|
+
semanticColors: Object.fromEntries(
|
|
945
|
+
Object.entries(extraction.designTokens.semanticColors)
|
|
946
|
+
.filter(([, value]) => typeof value === "string" && value)
|
|
947
|
+
.map(([name, value]) => [name, value.slice(0, 200)]),
|
|
948
|
+
),
|
|
949
|
+
}
|
|
950
|
+
: {}),
|
|
951
|
+
...(extraction.designTokens.shadows
|
|
952
|
+
? {
|
|
953
|
+
shadows: extraction.designTokens.shadows
|
|
954
|
+
.slice(0, 32)
|
|
955
|
+
.map((value) => value.slice(0, 500)),
|
|
956
|
+
}
|
|
957
|
+
: {}),
|
|
958
|
+
...(extraction.designTokens.backgrounds
|
|
959
|
+
? {
|
|
960
|
+
backgrounds: extraction.designTokens.backgrounds
|
|
961
|
+
.slice(0, 32)
|
|
962
|
+
.map((value) => value.slice(0, 500)),
|
|
963
|
+
}
|
|
964
|
+
: {}),
|
|
965
|
+
...(boundedComponents ? { components: boundedComponents } : {}),
|
|
966
|
+
...(extraction.designTokens.layout
|
|
967
|
+
? {
|
|
968
|
+
layout: Object.fromEntries(
|
|
969
|
+
Object.entries(extraction.designTokens.layout)
|
|
970
|
+
.filter(([, value]) => typeof value === "string" && value)
|
|
971
|
+
.map(([name, value]) => [name, value.slice(0, 200)]),
|
|
972
|
+
),
|
|
973
|
+
}
|
|
974
|
+
: {}),
|
|
651
975
|
},
|
|
652
976
|
};
|
|
653
977
|
}
|
|
@@ -696,10 +1020,6 @@ function boundedScreenshot(value: Uint8Array): Uint8Array | undefined {
|
|
|
696
1020
|
return value.byteLength <= MAX_SCREENSHOT_BYTES ? value : undefined;
|
|
697
1021
|
}
|
|
698
1022
|
|
|
699
|
-
function stringValue(value: unknown): string | undefined {
|
|
700
|
-
return typeof value === "string" && value.trim() ? value.trim() : undefined;
|
|
701
|
-
}
|
|
702
|
-
|
|
703
1023
|
function errorMessage(error: unknown): string {
|
|
704
1024
|
return error instanceof Error ? error.message : String(error);
|
|
705
1025
|
}
|