browserclaw 0.7.1 → 0.8.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/README.md +3 -1
- package/dist/index.cjs +331 -65
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +86 -1
- package/dist/index.d.ts +86 -1
- package/dist/index.js +333 -67
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -4,8 +4,8 @@ import { devices, chromium } from 'playwright-core';
|
|
|
4
4
|
import { spawn, execFileSync } from 'child_process';
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import net from 'net';
|
|
7
|
-
import os from 'os';
|
|
8
|
-
import path, { posix, win32, resolve, dirname,
|
|
7
|
+
import os, { tmpdir } from 'os';
|
|
8
|
+
import path, { join, posix, win32, resolve, dirname, basename, relative, sep, isAbsolute as isAbsolute$1, normalize } from 'path';
|
|
9
9
|
import { randomUUID } from 'crypto';
|
|
10
10
|
import { lookup } from 'dns';
|
|
11
11
|
import { lookup as lookup$1 } from 'dns/promises';
|
|
@@ -1187,8 +1187,13 @@ function isWebSocketUrl(url) {
|
|
|
1187
1187
|
function isLoopbackHost(hostname) {
|
|
1188
1188
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
1189
1189
|
}
|
|
1190
|
-
|
|
1191
|
-
|
|
1190
|
+
var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
1191
|
+
function hasProxyEnvConfigured(env = process.env) {
|
|
1192
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
1193
|
+
const value = env[key];
|
|
1194
|
+
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
1195
|
+
}
|
|
1196
|
+
return false;
|
|
1192
1197
|
}
|
|
1193
1198
|
function normalizeCdpWsUrl(wsUrl, cdpUrl) {
|
|
1194
1199
|
const ws = new URL(wsUrl);
|
|
@@ -1281,6 +1286,16 @@ async function fetchChromeVersion(cdpUrl, timeoutMs = 500, authToken) {
|
|
|
1281
1286
|
clearTimeout(t);
|
|
1282
1287
|
}
|
|
1283
1288
|
}
|
|
1289
|
+
var COMMON_CDP_PORTS = [9222, 9223, 9224, 9225, 9226, 9229];
|
|
1290
|
+
async function discoverChromeCdpUrl(timeoutMs = 500) {
|
|
1291
|
+
const results = await Promise.all(
|
|
1292
|
+
COMMON_CDP_PORTS.map(async (port) => {
|
|
1293
|
+
const url = `http://127.0.0.1:${String(port)}`;
|
|
1294
|
+
return await isChromeReachable(url, timeoutMs) ? url : null;
|
|
1295
|
+
})
|
|
1296
|
+
);
|
|
1297
|
+
return results.find((url) => url !== null) ?? null;
|
|
1298
|
+
}
|
|
1284
1299
|
async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
|
|
1285
1300
|
if (isWebSocketUrl(cdpUrl)) return await canOpenWebSocket(cdpUrl, timeoutMs);
|
|
1286
1301
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
@@ -1901,6 +1916,12 @@ function ensurePageState(page) {
|
|
|
1901
1916
|
rec.ok = false;
|
|
1902
1917
|
}
|
|
1903
1918
|
});
|
|
1919
|
+
page.on("dialog", (dialog) => {
|
|
1920
|
+
if (state.armIdDialog > 0) return;
|
|
1921
|
+
dialog.dismiss().catch((err) => {
|
|
1922
|
+
console.warn(`[browserclaw] Failed to dismiss dialog: ${err instanceof Error ? err.message : String(err)}`);
|
|
1923
|
+
});
|
|
1924
|
+
});
|
|
1904
1925
|
page.on("close", () => {
|
|
1905
1926
|
pageStates.delete(page);
|
|
1906
1927
|
observedPages.delete(page);
|
|
@@ -2340,48 +2361,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
|
|
|
2340
2361
|
}
|
|
2341
2362
|
var BROWSER_EVALUATOR = new Function(
|
|
2342
2363
|
"args",
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
2346
|
-
|
|
2347
|
-
var candidate
|
|
2348
|
-
|
|
2349
|
-
|
|
2350
|
-
|
|
2351
|
-
|
|
2352
|
-
|
|
2353
|
-
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
}
|
|
2357
|
-
|
|
2358
|
-
}
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2364
|
+
[
|
|
2365
|
+
'"use strict";',
|
|
2366
|
+
"var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
|
|
2367
|
+
"try {",
|
|
2368
|
+
" var candidate;",
|
|
2369
|
+
' try { candidate = eval("(" + fnBody + ")"); }',
|
|
2370
|
+
" catch (_) { candidate = (0, eval)(fnBody); }",
|
|
2371
|
+
' var result = typeof candidate === "function" ? candidate() : candidate;',
|
|
2372
|
+
' if (result && typeof result.then === "function") {',
|
|
2373
|
+
" return Promise.race([",
|
|
2374
|
+
" result,",
|
|
2375
|
+
" new Promise(function(_, reject) {",
|
|
2376
|
+
' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
|
|
2377
|
+
" })",
|
|
2378
|
+
" ]);",
|
|
2379
|
+
" }",
|
|
2380
|
+
" return result;",
|
|
2381
|
+
"} catch (err) {",
|
|
2382
|
+
' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
|
|
2383
|
+
"}"
|
|
2384
|
+
].join("\n")
|
|
2362
2385
|
);
|
|
2363
2386
|
var ELEMENT_EVALUATOR = new Function(
|
|
2364
2387
|
"el",
|
|
2365
2388
|
"args",
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
|
|
2370
|
-
var candidate
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
}
|
|
2380
|
-
|
|
2381
|
-
}
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2389
|
+
[
|
|
2390
|
+
'"use strict";',
|
|
2391
|
+
"var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
|
|
2392
|
+
"try {",
|
|
2393
|
+
" var candidate;",
|
|
2394
|
+
' try { candidate = eval("(" + fnBody + ")"); }',
|
|
2395
|
+
" catch (_) { candidate = (0, eval)(fnBody); }",
|
|
2396
|
+
' var result = typeof candidate === "function" ? candidate(el) : candidate;',
|
|
2397
|
+
' if (result && typeof result.then === "function") {',
|
|
2398
|
+
" return Promise.race([",
|
|
2399
|
+
" result,",
|
|
2400
|
+
" new Promise(function(_, reject) {",
|
|
2401
|
+
' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
|
|
2402
|
+
" })",
|
|
2403
|
+
" ]);",
|
|
2404
|
+
" }",
|
|
2405
|
+
" return result;",
|
|
2406
|
+
"} catch (err) {",
|
|
2407
|
+
' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
|
|
2408
|
+
"}"
|
|
2409
|
+
].join("\n")
|
|
2385
2410
|
);
|
|
2386
2411
|
async function evaluateViaPlaywright(opts) {
|
|
2387
2412
|
const fnText = opts.fn.trim();
|
|
@@ -2449,6 +2474,18 @@ async function evaluateViaPlaywright(opts) {
|
|
|
2449
2474
|
|
|
2450
2475
|
// src/security.ts
|
|
2451
2476
|
var ipaddr = __toESM(require_ipaddr());
|
|
2477
|
+
function resolveDefaultBrowserTmpDir() {
|
|
2478
|
+
try {
|
|
2479
|
+
if (process.platform === "linux" || process.platform === "darwin") {
|
|
2480
|
+
return "/tmp/browserclaw";
|
|
2481
|
+
}
|
|
2482
|
+
} catch {
|
|
2483
|
+
}
|
|
2484
|
+
return join(tmpdir(), "browserclaw");
|
|
2485
|
+
}
|
|
2486
|
+
var DEFAULT_BROWSER_TMP_DIR = resolveDefaultBrowserTmpDir();
|
|
2487
|
+
join(DEFAULT_BROWSER_TMP_DIR, "downloads");
|
|
2488
|
+
var DEFAULT_UPLOAD_DIR = join(DEFAULT_BROWSER_TMP_DIR, "uploads");
|
|
2452
2489
|
var InvalidBrowserNavigationUrlError = class extends Error {
|
|
2453
2490
|
constructor(message) {
|
|
2454
2491
|
super(message);
|
|
@@ -2460,7 +2497,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
|
|
|
2460
2497
|
}
|
|
2461
2498
|
var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
2462
2499
|
var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
|
|
2463
|
-
var
|
|
2500
|
+
var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
2464
2501
|
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
|
|
2465
2502
|
function isAllowedNonNetworkNavigationUrl(parsed) {
|
|
2466
2503
|
return SAFE_NON_NETWORK_URLS.has(parsed.href);
|
|
@@ -2469,7 +2506,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
|
|
|
2469
2506
|
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
|
2470
2507
|
}
|
|
2471
2508
|
function hasProxyEnvConfigured2(env = process.env) {
|
|
2472
|
-
for (const key of
|
|
2509
|
+
for (const key of PROXY_ENV_KEYS2) {
|
|
2473
2510
|
const value = env[key];
|
|
2474
2511
|
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
2475
2512
|
}
|
|
@@ -2674,6 +2711,8 @@ function dedupeAndPreferIpv4(results) {
|
|
|
2674
2711
|
}
|
|
2675
2712
|
function createPinnedLookup(params) {
|
|
2676
2713
|
const normalizedHost = normalizeHostname(params.hostname);
|
|
2714
|
+
if (params.addresses.length === 0)
|
|
2715
|
+
throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`);
|
|
2677
2716
|
const fallback = params.fallback ?? lookup;
|
|
2678
2717
|
const records = params.addresses.map((address) => ({
|
|
2679
2718
|
address,
|
|
@@ -2848,6 +2887,48 @@ async function resolveStrictExistingUploadPaths(params) {
|
|
|
2848
2887
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2849
2888
|
}
|
|
2850
2889
|
}
|
|
2890
|
+
function resolvePathWithinRoot(params) {
|
|
2891
|
+
const root = resolve(params.rootDir);
|
|
2892
|
+
const raw = params.requestedPath.trim();
|
|
2893
|
+
const effectivePath = raw === "" && params.defaultFileName != null && params.defaultFileName !== "" ? params.defaultFileName : raw;
|
|
2894
|
+
if (effectivePath === "") return { ok: false, error: `Empty path is not allowed (${params.scopeLabel}).` };
|
|
2895
|
+
const resolved = resolve(root, effectivePath);
|
|
2896
|
+
const rel = relative(root, resolved);
|
|
2897
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute$1(rel)) {
|
|
2898
|
+
return { ok: false, error: `Path escapes ${params.scopeLabel}: "${params.requestedPath}".` };
|
|
2899
|
+
}
|
|
2900
|
+
return { ok: true, path: resolved };
|
|
2901
|
+
}
|
|
2902
|
+
async function resolveStrictExistingPathsWithinRoot(params) {
|
|
2903
|
+
const root = resolve(params.rootDir);
|
|
2904
|
+
const resolved = [];
|
|
2905
|
+
for (const raw of params.requestedPaths) {
|
|
2906
|
+
const lexical = resolvePathWithinRoot({ rootDir: root, requestedPath: raw, scopeLabel: params.scopeLabel });
|
|
2907
|
+
if (!lexical.ok) return lexical;
|
|
2908
|
+
let real;
|
|
2909
|
+
try {
|
|
2910
|
+
real = await realpath(lexical.path);
|
|
2911
|
+
} catch (e) {
|
|
2912
|
+
if (e.code === "ENOENT") {
|
|
2913
|
+
return { ok: false, error: `Path does not exist (${params.scopeLabel}): "${raw}".` };
|
|
2914
|
+
}
|
|
2915
|
+
return { ok: false, error: `Cannot resolve "${raw}" (${params.scopeLabel}): ${e.message}` };
|
|
2916
|
+
}
|
|
2917
|
+
const rel = relative(root, real);
|
|
2918
|
+
if (rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute$1(rel)) {
|
|
2919
|
+
return { ok: false, error: `Path escapes ${params.scopeLabel} via symlink: "${raw}".` };
|
|
2920
|
+
}
|
|
2921
|
+
const stat = await lstat(real);
|
|
2922
|
+
if (stat.isSymbolicLink()) {
|
|
2923
|
+
return { ok: false, error: `Path is a symbolic link (${params.scopeLabel}): "${raw}".` };
|
|
2924
|
+
}
|
|
2925
|
+
if (!stat.isFile()) {
|
|
2926
|
+
return { ok: false, error: `Path is not a regular file (${params.scopeLabel}): "${raw}".` };
|
|
2927
|
+
}
|
|
2928
|
+
resolved.push(real);
|
|
2929
|
+
}
|
|
2930
|
+
return { ok: true, paths: resolved };
|
|
2931
|
+
}
|
|
2851
2932
|
function sanitizeUntrustedFileName(fileName, fallbackName) {
|
|
2852
2933
|
const trimmed = fileName.trim();
|
|
2853
2934
|
if (trimmed === "") return fallbackName;
|
|
@@ -2928,6 +3009,35 @@ function resolveLocator(page, resolved) {
|
|
|
2928
3009
|
const sel = resolved.selector ?? "";
|
|
2929
3010
|
return page.locator(sel);
|
|
2930
3011
|
}
|
|
3012
|
+
async function mouseClickViaPlaywright(opts) {
|
|
3013
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3014
|
+
await page.mouse.click(opts.x, opts.y, {
|
|
3015
|
+
button: opts.button,
|
|
3016
|
+
clickCount: opts.clickCount,
|
|
3017
|
+
delay: opts.delayMs
|
|
3018
|
+
});
|
|
3019
|
+
}
|
|
3020
|
+
async function clickByTextViaPlaywright(opts) {
|
|
3021
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3022
|
+
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
3023
|
+
try {
|
|
3024
|
+
await page.getByText(opts.text, { exact: opts.exact }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
|
|
3025
|
+
} catch (err) {
|
|
3026
|
+
throw toAIFriendlyError(err, `text="${opts.text}"`);
|
|
3027
|
+
}
|
|
3028
|
+
}
|
|
3029
|
+
async function clickByRoleViaPlaywright(opts) {
|
|
3030
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3031
|
+
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
3032
|
+
try {
|
|
3033
|
+
await page.getByRole(opts.role, { name: opts.name }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
|
|
3034
|
+
} catch (err) {
|
|
3035
|
+
throw toAIFriendlyError(
|
|
3036
|
+
err,
|
|
3037
|
+
`role=${opts.role}${opts.name !== void 0 && opts.name !== "" ? ` name="${opts.name}"` : ""}`
|
|
3038
|
+
);
|
|
3039
|
+
}
|
|
3040
|
+
}
|
|
2931
3041
|
async function clickViaPlaywright(opts) {
|
|
2932
3042
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
2933
3043
|
const page = await getRestoredPageForTarget(opts);
|
|
@@ -3091,9 +3201,10 @@ async function setInputFilesViaPlaywright(opts) {
|
|
|
3091
3201
|
if (inputRef && element) throw new Error("ref and element are mutually exclusive");
|
|
3092
3202
|
if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
|
|
3093
3203
|
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
|
|
3094
|
-
const uploadPathsResult = await
|
|
3204
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3205
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3095
3206
|
requestedPaths: opts.paths,
|
|
3096
|
-
scopeLabel:
|
|
3207
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3097
3208
|
});
|
|
3098
3209
|
if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
|
|
3099
3210
|
const resolvedPaths = uploadPathsResult.paths;
|
|
@@ -3121,9 +3232,14 @@ async function armDialogViaPlaywright(opts) {
|
|
|
3121
3232
|
const armId = state.armIdDialog;
|
|
3122
3233
|
page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
|
|
3123
3234
|
if (state.armIdDialog !== armId) return;
|
|
3124
|
-
|
|
3125
|
-
|
|
3235
|
+
try {
|
|
3236
|
+
if (opts.accept) await dialog.accept(opts.promptText);
|
|
3237
|
+
else await dialog.dismiss();
|
|
3238
|
+
} finally {
|
|
3239
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3240
|
+
}
|
|
3126
3241
|
}).catch(() => {
|
|
3242
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3127
3243
|
});
|
|
3128
3244
|
}
|
|
3129
3245
|
async function armFileUploadViaPlaywright(opts) {
|
|
@@ -3141,9 +3257,10 @@ async function armFileUploadViaPlaywright(opts) {
|
|
|
3141
3257
|
}
|
|
3142
3258
|
return;
|
|
3143
3259
|
}
|
|
3144
|
-
const uploadPathsResult = await
|
|
3260
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3261
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3145
3262
|
requestedPaths: opts.paths,
|
|
3146
|
-
scopeLabel:
|
|
3263
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3147
3264
|
});
|
|
3148
3265
|
if (!uploadPathsResult.ok) {
|
|
3149
3266
|
try {
|
|
@@ -3177,6 +3294,17 @@ async function pressKeyViaPlaywright(opts) {
|
|
|
3177
3294
|
}
|
|
3178
3295
|
|
|
3179
3296
|
// src/actions/navigation.ts
|
|
3297
|
+
var recordingContexts = /* @__PURE__ */ new Map();
|
|
3298
|
+
function clearRecordingContext(cdpUrl) {
|
|
3299
|
+
recordingContexts.delete(cdpUrl);
|
|
3300
|
+
}
|
|
3301
|
+
async function createRecordingContext(browser, cdpUrl, recordVideo) {
|
|
3302
|
+
const context = await browser.newContext({ recordVideo });
|
|
3303
|
+
observeContext(context);
|
|
3304
|
+
recordingContexts.set(cdpUrl, context);
|
|
3305
|
+
context.on("close", () => recordingContexts.delete(cdpUrl));
|
|
3306
|
+
return context;
|
|
3307
|
+
}
|
|
3180
3308
|
function isRetryableNavigateError(err) {
|
|
3181
3309
|
const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
|
|
3182
3310
|
return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
|
|
@@ -3229,7 +3357,7 @@ async function listPagesViaPlaywright(opts) {
|
|
|
3229
3357
|
}
|
|
3230
3358
|
async function createPageViaPlaywright(opts) {
|
|
3231
3359
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
3232
|
-
const context = browser.contexts()[0] ?? await browser.newContext();
|
|
3360
|
+
const context = opts.recordVideo ? recordingContexts.get(opts.cdpUrl) ?? await createRecordingContext(browser, opts.cdpUrl, opts.recordVideo) : browser.contexts()[0] ?? await browser.newContext();
|
|
3233
3361
|
ensureContextState(context);
|
|
3234
3362
|
const page = await context.newPage();
|
|
3235
3363
|
ensurePageState(page);
|
|
@@ -3306,10 +3434,10 @@ async function waitForViaPlaywright(opts) {
|
|
|
3306
3434
|
await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
|
3307
3435
|
}
|
|
3308
3436
|
if (opts.text !== void 0 && opts.text !== "") {
|
|
3309
|
-
await page.
|
|
3437
|
+
await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
|
|
3310
3438
|
}
|
|
3311
3439
|
if (opts.textGone !== void 0 && opts.textGone !== "") {
|
|
3312
|
-
await page.
|
|
3440
|
+
await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
|
|
3313
3441
|
}
|
|
3314
3442
|
if (opts.selector !== void 0 && opts.selector !== "") {
|
|
3315
3443
|
const selector = opts.selector.trim();
|
|
@@ -3581,7 +3709,7 @@ async function waitForDownloadViaPlaywright(opts) {
|
|
|
3581
3709
|
try {
|
|
3582
3710
|
const download = await waiter.promise;
|
|
3583
3711
|
if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
|
|
3584
|
-
const savePath = opts.path ?? download.suggestedFilename();
|
|
3712
|
+
const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
|
|
3585
3713
|
await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
|
|
3586
3714
|
return await saveDownloadPayload(download, savePath);
|
|
3587
3715
|
} catch (err) {
|
|
@@ -4248,6 +4376,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4248
4376
|
return match ? match[1] : null;
|
|
4249
4377
|
}
|
|
4250
4378
|
if (options.interactive === true) {
|
|
4379
|
+
let interactiveMaxRef = 0;
|
|
4380
|
+
for (const line of lines) {
|
|
4381
|
+
const refMatch = /\[ref=e(\d+)\]/.exec(line);
|
|
4382
|
+
if (refMatch) interactiveMaxRef = Math.max(interactiveMaxRef, Number.parseInt(refMatch[1], 10));
|
|
4383
|
+
}
|
|
4384
|
+
let interactiveCounter = interactiveMaxRef;
|
|
4385
|
+
const nextInteractiveRef = () => {
|
|
4386
|
+
interactiveCounter++;
|
|
4387
|
+
return `e${String(interactiveCounter)}`;
|
|
4388
|
+
};
|
|
4251
4389
|
const out2 = [];
|
|
4252
4390
|
for (const line of lines) {
|
|
4253
4391
|
const parsed = matchInteractiveSnapshotLine(line, options);
|
|
@@ -4255,13 +4393,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4255
4393
|
const { roleRaw, role, name, suffix } = parsed;
|
|
4256
4394
|
if (!INTERACTIVE_ROLES.has(role)) continue;
|
|
4257
4395
|
const ref = parseAiSnapshotRef(suffix);
|
|
4258
|
-
if (ref === null) continue;
|
|
4259
4396
|
const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
|
|
4260
|
-
|
|
4261
|
-
|
|
4397
|
+
if (ref !== null) {
|
|
4398
|
+
refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
|
|
4399
|
+
out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
|
|
4400
|
+
} else {
|
|
4401
|
+
const generatedRef = nextInteractiveRef();
|
|
4402
|
+
refs[generatedRef] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
|
|
4403
|
+
let enhanced = `${prefix}${roleRaw}`;
|
|
4404
|
+
if (name !== void 0 && name !== "") enhanced += ` "${name}"`;
|
|
4405
|
+
enhanced += ` [ref=${generatedRef}]`;
|
|
4406
|
+
if (suffix.trim() !== "") enhanced += suffix;
|
|
4407
|
+
out2.push(enhanced);
|
|
4408
|
+
}
|
|
4262
4409
|
}
|
|
4263
4410
|
return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
|
|
4264
4411
|
}
|
|
4412
|
+
let maxRef = 0;
|
|
4413
|
+
for (const line of lines) {
|
|
4414
|
+
const refMatch = /\[ref=e(\d+)\]/.exec(line);
|
|
4415
|
+
if (refMatch) maxRef = Math.max(maxRef, Number.parseInt(refMatch[1], 10));
|
|
4416
|
+
}
|
|
4417
|
+
let generatedCounter = maxRef;
|
|
4418
|
+
const nextGeneratedRef = () => {
|
|
4419
|
+
generatedCounter++;
|
|
4420
|
+
return `e${String(generatedCounter)}`;
|
|
4421
|
+
};
|
|
4265
4422
|
const out = [];
|
|
4266
4423
|
for (const line of lines) {
|
|
4267
4424
|
const depth = getIndentLevel(line);
|
|
@@ -4271,7 +4428,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4271
4428
|
out.push(line);
|
|
4272
4429
|
continue;
|
|
4273
4430
|
}
|
|
4274
|
-
const [, , roleRaw, name, suffix] = match;
|
|
4431
|
+
const [, prefix, roleRaw, name, suffix] = match;
|
|
4275
4432
|
if (roleRaw.startsWith("/")) {
|
|
4276
4433
|
out.push(line);
|
|
4277
4434
|
continue;
|
|
@@ -4280,8 +4437,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4280
4437
|
const isStructural = STRUCTURAL_ROLES.has(role);
|
|
4281
4438
|
if (options.compact === true && isStructural && name === "") continue;
|
|
4282
4439
|
const ref = parseAiSnapshotRef(suffix);
|
|
4283
|
-
if (ref !== null)
|
|
4284
|
-
|
|
4440
|
+
if (ref !== null) {
|
|
4441
|
+
refs[ref] = { role, ...name !== "" ? { name } : {} };
|
|
4442
|
+
out.push(line);
|
|
4443
|
+
} else if (INTERACTIVE_ROLES.has(role)) {
|
|
4444
|
+
const generatedRef = nextGeneratedRef();
|
|
4445
|
+
refs[generatedRef] = { role, ...name !== "" ? { name } : {} };
|
|
4446
|
+
let enhanced = `${prefix}${roleRaw}`;
|
|
4447
|
+
if (name !== "") enhanced += ` "${name}"`;
|
|
4448
|
+
enhanced += ` [ref=${generatedRef}]`;
|
|
4449
|
+
if (suffix.trim() !== "") enhanced += suffix;
|
|
4450
|
+
out.push(enhanced);
|
|
4451
|
+
} else {
|
|
4452
|
+
out.push(line);
|
|
4453
|
+
}
|
|
4285
4454
|
}
|
|
4286
4455
|
const tree = out.join("\n") || "(empty)";
|
|
4287
4456
|
return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
|
|
@@ -4642,6 +4811,85 @@ var CrawlPage = class {
|
|
|
4642
4811
|
timeoutMs: opts?.timeoutMs
|
|
4643
4812
|
});
|
|
4644
4813
|
}
|
|
4814
|
+
/**
|
|
4815
|
+
* Click at specific page coordinates.
|
|
4816
|
+
*
|
|
4817
|
+
* Useful for canvas elements, custom widgets, or elements without ARIA roles.
|
|
4818
|
+
*
|
|
4819
|
+
* @param x - X coordinate in pixels
|
|
4820
|
+
* @param y - Y coordinate in pixels
|
|
4821
|
+
* @param opts - Click options (button, clickCount, delayMs)
|
|
4822
|
+
*
|
|
4823
|
+
* @example
|
|
4824
|
+
* ```ts
|
|
4825
|
+
* await page.mouseClick(100, 200);
|
|
4826
|
+
* await page.mouseClick(100, 200, { button: 'right' });
|
|
4827
|
+
* await page.mouseClick(100, 200, { clickCount: 2 }); // double-click
|
|
4828
|
+
* ```
|
|
4829
|
+
*/
|
|
4830
|
+
async mouseClick(x, y, opts) {
|
|
4831
|
+
return mouseClickViaPlaywright({
|
|
4832
|
+
cdpUrl: this.cdpUrl,
|
|
4833
|
+
targetId: this.targetId,
|
|
4834
|
+
x,
|
|
4835
|
+
y,
|
|
4836
|
+
button: opts?.button,
|
|
4837
|
+
clickCount: opts?.clickCount,
|
|
4838
|
+
delayMs: opts?.delayMs
|
|
4839
|
+
});
|
|
4840
|
+
}
|
|
4841
|
+
/**
|
|
4842
|
+
* Click an element by its visible text content (no snapshot/ref needed).
|
|
4843
|
+
*
|
|
4844
|
+
* Finds and clicks atomically — no stale ref problem.
|
|
4845
|
+
*
|
|
4846
|
+
* @param text - Text content to match
|
|
4847
|
+
* @param opts - Options (exact: require full match, button, modifiers)
|
|
4848
|
+
*
|
|
4849
|
+
* @example
|
|
4850
|
+
* ```ts
|
|
4851
|
+
* await page.clickByText('Submit');
|
|
4852
|
+
* await page.clickByText('Save Changes', { exact: true });
|
|
4853
|
+
* ```
|
|
4854
|
+
*/
|
|
4855
|
+
async clickByText(text, opts) {
|
|
4856
|
+
return clickByTextViaPlaywright({
|
|
4857
|
+
cdpUrl: this.cdpUrl,
|
|
4858
|
+
targetId: this.targetId,
|
|
4859
|
+
text,
|
|
4860
|
+
exact: opts?.exact,
|
|
4861
|
+
button: opts?.button,
|
|
4862
|
+
modifiers: opts?.modifiers,
|
|
4863
|
+
timeoutMs: opts?.timeoutMs
|
|
4864
|
+
});
|
|
4865
|
+
}
|
|
4866
|
+
/**
|
|
4867
|
+
* Click an element by its ARIA role and accessible name (no snapshot/ref needed).
|
|
4868
|
+
*
|
|
4869
|
+
* Finds and clicks atomically — no stale ref problem.
|
|
4870
|
+
*
|
|
4871
|
+
* @param role - ARIA role (e.g. `'button'`, `'link'`, `'menuitem'`)
|
|
4872
|
+
* @param name - Accessible name to match (optional)
|
|
4873
|
+
* @param opts - Click options
|
|
4874
|
+
*
|
|
4875
|
+
* @example
|
|
4876
|
+
* ```ts
|
|
4877
|
+
* await page.clickByRole('button', 'Save');
|
|
4878
|
+
* await page.clickByRole('link', 'Settings');
|
|
4879
|
+
* await page.clickByRole('menuitem', 'Delete');
|
|
4880
|
+
* ```
|
|
4881
|
+
*/
|
|
4882
|
+
async clickByRole(role, name, opts) {
|
|
4883
|
+
return clickByRoleViaPlaywright({
|
|
4884
|
+
cdpUrl: this.cdpUrl,
|
|
4885
|
+
targetId: this.targetId,
|
|
4886
|
+
role,
|
|
4887
|
+
name,
|
|
4888
|
+
button: opts?.button,
|
|
4889
|
+
modifiers: opts?.modifiers,
|
|
4890
|
+
timeoutMs: opts?.timeoutMs
|
|
4891
|
+
});
|
|
4892
|
+
}
|
|
4645
4893
|
/**
|
|
4646
4894
|
* Type text into an input element by ref.
|
|
4647
4895
|
*
|
|
@@ -5477,11 +5725,13 @@ var CrawlPage = class {
|
|
|
5477
5725
|
var BrowserClaw = class _BrowserClaw {
|
|
5478
5726
|
cdpUrl;
|
|
5479
5727
|
ssrfPolicy;
|
|
5728
|
+
recordVideo;
|
|
5480
5729
|
chrome;
|
|
5481
|
-
constructor(cdpUrl, chrome, ssrfPolicy) {
|
|
5730
|
+
constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
|
|
5482
5731
|
this.cdpUrl = cdpUrl;
|
|
5483
5732
|
this.chrome = chrome;
|
|
5484
5733
|
this.ssrfPolicy = ssrfPolicy;
|
|
5734
|
+
this.recordVideo = recordVideo;
|
|
5485
5735
|
}
|
|
5486
5736
|
/**
|
|
5487
5737
|
* Launch a new Chrome instance and connect to it.
|
|
@@ -5510,7 +5760,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5510
5760
|
const chrome = await launchChrome(opts);
|
|
5511
5761
|
const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
|
|
5512
5762
|
const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
|
|
5513
|
-
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
|
|
5763
|
+
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
|
|
5514
5764
|
}
|
|
5515
5765
|
/**
|
|
5516
5766
|
* Connect to an already-running Chrome instance via its CDP endpoint.
|
|
@@ -5527,12 +5777,22 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5527
5777
|
* ```
|
|
5528
5778
|
*/
|
|
5529
5779
|
static async connect(cdpUrl, opts) {
|
|
5530
|
-
|
|
5531
|
-
|
|
5780
|
+
let resolvedUrl = cdpUrl;
|
|
5781
|
+
if (resolvedUrl === void 0 || resolvedUrl === "") {
|
|
5782
|
+
const discovered = await discoverChromeCdpUrl();
|
|
5783
|
+
if (discovered === null) {
|
|
5784
|
+
throw new Error(
|
|
5785
|
+
"No Chrome instance found on common CDP ports (9222-9226, 9229). Start Chrome with --remote-debugging-port=9222, or pass a CDP URL."
|
|
5786
|
+
);
|
|
5787
|
+
}
|
|
5788
|
+
resolvedUrl = discovered;
|
|
5789
|
+
}
|
|
5790
|
+
if (!await isChromeReachable(resolvedUrl, 3e3, opts?.authToken)) {
|
|
5791
|
+
throw new Error(`Cannot connect to Chrome at ${resolvedUrl}. Is Chrome running with --remote-debugging-port?`);
|
|
5532
5792
|
}
|
|
5533
|
-
await connectBrowser(
|
|
5793
|
+
await connectBrowser(resolvedUrl, opts?.authToken);
|
|
5534
5794
|
const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
|
|
5535
|
-
return new _BrowserClaw(
|
|
5795
|
+
return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
|
|
5536
5796
|
}
|
|
5537
5797
|
/**
|
|
5538
5798
|
* Open a URL in a new tab and return the page handle.
|
|
@@ -5547,7 +5807,12 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5547
5807
|
* ```
|
|
5548
5808
|
*/
|
|
5549
5809
|
async open(url) {
|
|
5550
|
-
const tab = await createPageViaPlaywright({
|
|
5810
|
+
const tab = await createPageViaPlaywright({
|
|
5811
|
+
cdpUrl: this.cdpUrl,
|
|
5812
|
+
url,
|
|
5813
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
5814
|
+
recordVideo: this.recordVideo
|
|
5815
|
+
});
|
|
5551
5816
|
return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
|
|
5552
5817
|
}
|
|
5553
5818
|
/**
|
|
@@ -5610,6 +5875,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5610
5875
|
* Playwright connection is closed.
|
|
5611
5876
|
*/
|
|
5612
5877
|
async stop() {
|
|
5878
|
+
clearRecordingContext(this.cdpUrl);
|
|
5613
5879
|
await disconnectBrowser();
|
|
5614
5880
|
if (this.chrome) {
|
|
5615
5881
|
await stopChrome(this.chrome);
|