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/README.md
CHANGED
|
@@ -1,13 +1,15 @@
|
|
|
1
1
|
<h2 align="center">🦞 BrowserClaw — Standalone OpenClaw browser module</h2>
|
|
2
2
|
|
|
3
3
|
<p align="center">
|
|
4
|
-
<a href="https://browserclaw.
|
|
4
|
+
<a href="https://browserclaw.org"><img src="https://img.shields.io/badge/Live-browserclaw.org-orange" alt="Live" /></a>
|
|
5
5
|
<a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/v/browserclaw.svg" alt="npm version" /></a>
|
|
6
6
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
|
|
7
7
|
<a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/dw/browserclaw" alt="npm downloads" /></a>
|
|
8
8
|
<a href="https://github.com/idan-rubin/browserclaw/stargazers"><img src="https://img.shields.io/github/stars/idan-rubin/browserclaw" alt="GitHub stars" /></a>
|
|
9
9
|
</p>
|
|
10
10
|
|
|
11
|
+
> **DISCLAIMER: This project is NOT affiliated with browserclaw.com in any form. We have no connection to that site and recommend treating it with caution.**
|
|
12
|
+
|
|
11
13
|
Extracted and refined from [OpenClaw](https://github.com/openclaw/openclaw)'s browser automation module. A standalone, typed library for AI-friendly browser control with **snapshot + ref targeting** — no CSS selectors, no XPath, no vision, just numbered refs that map to interactive elements.
|
|
12
14
|
|
|
13
15
|
```typescript
|
package/dist/index.cjs
CHANGED
|
@@ -1198,8 +1198,13 @@ function isWebSocketUrl(url) {
|
|
|
1198
1198
|
function isLoopbackHost(hostname) {
|
|
1199
1199
|
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
1200
1200
|
}
|
|
1201
|
-
|
|
1202
|
-
|
|
1201
|
+
var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
1202
|
+
function hasProxyEnvConfigured(env = process.env) {
|
|
1203
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
1204
|
+
const value = env[key];
|
|
1205
|
+
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
1206
|
+
}
|
|
1207
|
+
return false;
|
|
1203
1208
|
}
|
|
1204
1209
|
function normalizeCdpWsUrl(wsUrl, cdpUrl) {
|
|
1205
1210
|
const ws = new URL(wsUrl);
|
|
@@ -1292,6 +1297,16 @@ async function fetchChromeVersion(cdpUrl, timeoutMs = 500, authToken) {
|
|
|
1292
1297
|
clearTimeout(t);
|
|
1293
1298
|
}
|
|
1294
1299
|
}
|
|
1300
|
+
var COMMON_CDP_PORTS = [9222, 9223, 9224, 9225, 9226, 9229];
|
|
1301
|
+
async function discoverChromeCdpUrl(timeoutMs = 500) {
|
|
1302
|
+
const results = await Promise.all(
|
|
1303
|
+
COMMON_CDP_PORTS.map(async (port) => {
|
|
1304
|
+
const url = `http://127.0.0.1:${String(port)}`;
|
|
1305
|
+
return await isChromeReachable(url, timeoutMs) ? url : null;
|
|
1306
|
+
})
|
|
1307
|
+
);
|
|
1308
|
+
return results.find((url) => url !== null) ?? null;
|
|
1309
|
+
}
|
|
1295
1310
|
async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
|
|
1296
1311
|
if (isWebSocketUrl(cdpUrl)) return await canOpenWebSocket(cdpUrl, timeoutMs);
|
|
1297
1312
|
const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
@@ -1912,6 +1927,12 @@ function ensurePageState(page) {
|
|
|
1912
1927
|
rec.ok = false;
|
|
1913
1928
|
}
|
|
1914
1929
|
});
|
|
1930
|
+
page.on("dialog", (dialog) => {
|
|
1931
|
+
if (state.armIdDialog > 0) return;
|
|
1932
|
+
dialog.dismiss().catch((err) => {
|
|
1933
|
+
console.warn(`[browserclaw] Failed to dismiss dialog: ${err instanceof Error ? err.message : String(err)}`);
|
|
1934
|
+
});
|
|
1935
|
+
});
|
|
1915
1936
|
page.on("close", () => {
|
|
1916
1937
|
pageStates.delete(page);
|
|
1917
1938
|
observedPages.delete(page);
|
|
@@ -2351,48 +2372,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
|
|
|
2351
2372
|
}
|
|
2352
2373
|
var BROWSER_EVALUATOR = new Function(
|
|
2353
2374
|
"args",
|
|
2354
|
-
|
|
2355
|
-
|
|
2356
|
-
|
|
2357
|
-
|
|
2358
|
-
var candidate
|
|
2359
|
-
|
|
2360
|
-
|
|
2361
|
-
|
|
2362
|
-
|
|
2363
|
-
|
|
2364
|
-
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
}
|
|
2368
|
-
|
|
2369
|
-
}
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2375
|
+
[
|
|
2376
|
+
'"use strict";',
|
|
2377
|
+
"var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
|
|
2378
|
+
"try {",
|
|
2379
|
+
" var candidate;",
|
|
2380
|
+
' try { candidate = eval("(" + fnBody + ")"); }',
|
|
2381
|
+
" catch (_) { candidate = (0, eval)(fnBody); }",
|
|
2382
|
+
' var result = typeof candidate === "function" ? candidate() : candidate;',
|
|
2383
|
+
' if (result && typeof result.then === "function") {',
|
|
2384
|
+
" return Promise.race([",
|
|
2385
|
+
" result,",
|
|
2386
|
+
" new Promise(function(_, reject) {",
|
|
2387
|
+
' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
|
|
2388
|
+
" })",
|
|
2389
|
+
" ]);",
|
|
2390
|
+
" }",
|
|
2391
|
+
" return result;",
|
|
2392
|
+
"} catch (err) {",
|
|
2393
|
+
' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
|
|
2394
|
+
"}"
|
|
2395
|
+
].join("\n")
|
|
2373
2396
|
);
|
|
2374
2397
|
var ELEMENT_EVALUATOR = new Function(
|
|
2375
2398
|
"el",
|
|
2376
2399
|
"args",
|
|
2377
|
-
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
var candidate
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
}
|
|
2391
|
-
|
|
2392
|
-
}
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2400
|
+
[
|
|
2401
|
+
'"use strict";',
|
|
2402
|
+
"var fnBody = args.fnBody, timeoutMs = args.timeoutMs;",
|
|
2403
|
+
"try {",
|
|
2404
|
+
" var candidate;",
|
|
2405
|
+
' try { candidate = eval("(" + fnBody + ")"); }',
|
|
2406
|
+
" catch (_) { candidate = (0, eval)(fnBody); }",
|
|
2407
|
+
' var result = typeof candidate === "function" ? candidate(el) : candidate;',
|
|
2408
|
+
' if (result && typeof result.then === "function") {',
|
|
2409
|
+
" return Promise.race([",
|
|
2410
|
+
" result,",
|
|
2411
|
+
" new Promise(function(_, reject) {",
|
|
2412
|
+
' setTimeout(function() { reject(new Error("evaluate timed out after " + timeoutMs + "ms")); }, timeoutMs);',
|
|
2413
|
+
" })",
|
|
2414
|
+
" ]);",
|
|
2415
|
+
" }",
|
|
2416
|
+
" return result;",
|
|
2417
|
+
"} catch (err) {",
|
|
2418
|
+
' throw new Error("Invalid evaluate function: " + (err && err.message ? err.message : String(err)));',
|
|
2419
|
+
"}"
|
|
2420
|
+
].join("\n")
|
|
2396
2421
|
);
|
|
2397
2422
|
async function evaluateViaPlaywright(opts) {
|
|
2398
2423
|
const fnText = opts.fn.trim();
|
|
@@ -2460,6 +2485,18 @@ async function evaluateViaPlaywright(opts) {
|
|
|
2460
2485
|
|
|
2461
2486
|
// src/security.ts
|
|
2462
2487
|
var ipaddr = __toESM(require_ipaddr());
|
|
2488
|
+
function resolveDefaultBrowserTmpDir() {
|
|
2489
|
+
try {
|
|
2490
|
+
if (process.platform === "linux" || process.platform === "darwin") {
|
|
2491
|
+
return "/tmp/browserclaw";
|
|
2492
|
+
}
|
|
2493
|
+
} catch {
|
|
2494
|
+
}
|
|
2495
|
+
return path.join(os.tmpdir(), "browserclaw");
|
|
2496
|
+
}
|
|
2497
|
+
var DEFAULT_BROWSER_TMP_DIR = resolveDefaultBrowserTmpDir();
|
|
2498
|
+
path.join(DEFAULT_BROWSER_TMP_DIR, "downloads");
|
|
2499
|
+
var DEFAULT_UPLOAD_DIR = path.join(DEFAULT_BROWSER_TMP_DIR, "uploads");
|
|
2463
2500
|
var InvalidBrowserNavigationUrlError = class extends Error {
|
|
2464
2501
|
constructor(message) {
|
|
2465
2502
|
super(message);
|
|
@@ -2471,7 +2508,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
|
|
|
2471
2508
|
}
|
|
2472
2509
|
var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
2473
2510
|
var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
|
|
2474
|
-
var
|
|
2511
|
+
var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
2475
2512
|
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
|
|
2476
2513
|
function isAllowedNonNetworkNavigationUrl(parsed) {
|
|
2477
2514
|
return SAFE_NON_NETWORK_URLS.has(parsed.href);
|
|
@@ -2480,7 +2517,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
|
|
|
2480
2517
|
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
|
2481
2518
|
}
|
|
2482
2519
|
function hasProxyEnvConfigured2(env = process.env) {
|
|
2483
|
-
for (const key of
|
|
2520
|
+
for (const key of PROXY_ENV_KEYS2) {
|
|
2484
2521
|
const value = env[key];
|
|
2485
2522
|
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
2486
2523
|
}
|
|
@@ -2685,6 +2722,8 @@ function dedupeAndPreferIpv4(results) {
|
|
|
2685
2722
|
}
|
|
2686
2723
|
function createPinnedLookup(params) {
|
|
2687
2724
|
const normalizedHost = normalizeHostname(params.hostname);
|
|
2725
|
+
if (params.addresses.length === 0)
|
|
2726
|
+
throw new Error(`Pinned lookup requires at least one address for ${params.hostname}`);
|
|
2688
2727
|
const fallback = params.fallback ?? dns.lookup;
|
|
2689
2728
|
const records = params.addresses.map((address) => ({
|
|
2690
2729
|
address,
|
|
@@ -2859,6 +2898,48 @@ async function resolveStrictExistingUploadPaths(params) {
|
|
|
2859
2898
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2860
2899
|
}
|
|
2861
2900
|
}
|
|
2901
|
+
function resolvePathWithinRoot(params) {
|
|
2902
|
+
const root = path.resolve(params.rootDir);
|
|
2903
|
+
const raw = params.requestedPath.trim();
|
|
2904
|
+
const effectivePath = raw === "" && params.defaultFileName != null && params.defaultFileName !== "" ? params.defaultFileName : raw;
|
|
2905
|
+
if (effectivePath === "") return { ok: false, error: `Empty path is not allowed (${params.scopeLabel}).` };
|
|
2906
|
+
const resolved = path.resolve(root, effectivePath);
|
|
2907
|
+
const rel = path.relative(root, resolved);
|
|
2908
|
+
if (!rel || rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
2909
|
+
return { ok: false, error: `Path escapes ${params.scopeLabel}: "${params.requestedPath}".` };
|
|
2910
|
+
}
|
|
2911
|
+
return { ok: true, path: resolved };
|
|
2912
|
+
}
|
|
2913
|
+
async function resolveStrictExistingPathsWithinRoot(params) {
|
|
2914
|
+
const root = path.resolve(params.rootDir);
|
|
2915
|
+
const resolved = [];
|
|
2916
|
+
for (const raw of params.requestedPaths) {
|
|
2917
|
+
const lexical = resolvePathWithinRoot({ rootDir: root, requestedPath: raw, scopeLabel: params.scopeLabel });
|
|
2918
|
+
if (!lexical.ok) return lexical;
|
|
2919
|
+
let real;
|
|
2920
|
+
try {
|
|
2921
|
+
real = await promises$1.realpath(lexical.path);
|
|
2922
|
+
} catch (e) {
|
|
2923
|
+
if (e.code === "ENOENT") {
|
|
2924
|
+
return { ok: false, error: `Path does not exist (${params.scopeLabel}): "${raw}".` };
|
|
2925
|
+
}
|
|
2926
|
+
return { ok: false, error: `Cannot resolve "${raw}" (${params.scopeLabel}): ${e.message}` };
|
|
2927
|
+
}
|
|
2928
|
+
const rel = path.relative(root, real);
|
|
2929
|
+
if (rel === ".." || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) {
|
|
2930
|
+
return { ok: false, error: `Path escapes ${params.scopeLabel} via symlink: "${raw}".` };
|
|
2931
|
+
}
|
|
2932
|
+
const stat = await promises$1.lstat(real);
|
|
2933
|
+
if (stat.isSymbolicLink()) {
|
|
2934
|
+
return { ok: false, error: `Path is a symbolic link (${params.scopeLabel}): "${raw}".` };
|
|
2935
|
+
}
|
|
2936
|
+
if (!stat.isFile()) {
|
|
2937
|
+
return { ok: false, error: `Path is not a regular file (${params.scopeLabel}): "${raw}".` };
|
|
2938
|
+
}
|
|
2939
|
+
resolved.push(real);
|
|
2940
|
+
}
|
|
2941
|
+
return { ok: true, paths: resolved };
|
|
2942
|
+
}
|
|
2862
2943
|
function sanitizeUntrustedFileName(fileName, fallbackName) {
|
|
2863
2944
|
const trimmed = fileName.trim();
|
|
2864
2945
|
if (trimmed === "") return fallbackName;
|
|
@@ -2939,6 +3020,35 @@ function resolveLocator(page, resolved) {
|
|
|
2939
3020
|
const sel = resolved.selector ?? "";
|
|
2940
3021
|
return page.locator(sel);
|
|
2941
3022
|
}
|
|
3023
|
+
async function mouseClickViaPlaywright(opts) {
|
|
3024
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3025
|
+
await page.mouse.click(opts.x, opts.y, {
|
|
3026
|
+
button: opts.button,
|
|
3027
|
+
clickCount: opts.clickCount,
|
|
3028
|
+
delay: opts.delayMs
|
|
3029
|
+
});
|
|
3030
|
+
}
|
|
3031
|
+
async function clickByTextViaPlaywright(opts) {
|
|
3032
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3033
|
+
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
3034
|
+
try {
|
|
3035
|
+
await page.getByText(opts.text, { exact: opts.exact }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
|
|
3036
|
+
} catch (err) {
|
|
3037
|
+
throw toAIFriendlyError(err, `text="${opts.text}"`);
|
|
3038
|
+
}
|
|
3039
|
+
}
|
|
3040
|
+
async function clickByRoleViaPlaywright(opts) {
|
|
3041
|
+
const page = await getRestoredPageForTarget(opts);
|
|
3042
|
+
const timeout = resolveInteractionTimeoutMs(opts.timeoutMs);
|
|
3043
|
+
try {
|
|
3044
|
+
await page.getByRole(opts.role, { name: opts.name }).click({ timeout, button: opts.button, modifiers: opts.modifiers });
|
|
3045
|
+
} catch (err) {
|
|
3046
|
+
throw toAIFriendlyError(
|
|
3047
|
+
err,
|
|
3048
|
+
`role=${opts.role}${opts.name !== void 0 && opts.name !== "" ? ` name="${opts.name}"` : ""}`
|
|
3049
|
+
);
|
|
3050
|
+
}
|
|
3051
|
+
}
|
|
2942
3052
|
async function clickViaPlaywright(opts) {
|
|
2943
3053
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
2944
3054
|
const page = await getRestoredPageForTarget(opts);
|
|
@@ -3102,9 +3212,10 @@ async function setInputFilesViaPlaywright(opts) {
|
|
|
3102
3212
|
if (inputRef && element) throw new Error("ref and element are mutually exclusive");
|
|
3103
3213
|
if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
|
|
3104
3214
|
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
|
|
3105
|
-
const uploadPathsResult = await
|
|
3215
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3216
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3106
3217
|
requestedPaths: opts.paths,
|
|
3107
|
-
scopeLabel:
|
|
3218
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3108
3219
|
});
|
|
3109
3220
|
if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
|
|
3110
3221
|
const resolvedPaths = uploadPathsResult.paths;
|
|
@@ -3132,9 +3243,14 @@ async function armDialogViaPlaywright(opts) {
|
|
|
3132
3243
|
const armId = state.armIdDialog;
|
|
3133
3244
|
page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
|
|
3134
3245
|
if (state.armIdDialog !== armId) return;
|
|
3135
|
-
|
|
3136
|
-
|
|
3246
|
+
try {
|
|
3247
|
+
if (opts.accept) await dialog.accept(opts.promptText);
|
|
3248
|
+
else await dialog.dismiss();
|
|
3249
|
+
} finally {
|
|
3250
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3251
|
+
}
|
|
3137
3252
|
}).catch(() => {
|
|
3253
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3138
3254
|
});
|
|
3139
3255
|
}
|
|
3140
3256
|
async function armFileUploadViaPlaywright(opts) {
|
|
@@ -3152,9 +3268,10 @@ async function armFileUploadViaPlaywright(opts) {
|
|
|
3152
3268
|
}
|
|
3153
3269
|
return;
|
|
3154
3270
|
}
|
|
3155
|
-
const uploadPathsResult = await
|
|
3271
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3272
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3156
3273
|
requestedPaths: opts.paths,
|
|
3157
|
-
scopeLabel:
|
|
3274
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3158
3275
|
});
|
|
3159
3276
|
if (!uploadPathsResult.ok) {
|
|
3160
3277
|
try {
|
|
@@ -3188,6 +3305,17 @@ async function pressKeyViaPlaywright(opts) {
|
|
|
3188
3305
|
}
|
|
3189
3306
|
|
|
3190
3307
|
// src/actions/navigation.ts
|
|
3308
|
+
var recordingContexts = /* @__PURE__ */ new Map();
|
|
3309
|
+
function clearRecordingContext(cdpUrl) {
|
|
3310
|
+
recordingContexts.delete(cdpUrl);
|
|
3311
|
+
}
|
|
3312
|
+
async function createRecordingContext(browser, cdpUrl, recordVideo) {
|
|
3313
|
+
const context = await browser.newContext({ recordVideo });
|
|
3314
|
+
observeContext(context);
|
|
3315
|
+
recordingContexts.set(cdpUrl, context);
|
|
3316
|
+
context.on("close", () => recordingContexts.delete(cdpUrl));
|
|
3317
|
+
return context;
|
|
3318
|
+
}
|
|
3191
3319
|
function isRetryableNavigateError(err) {
|
|
3192
3320
|
const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
|
|
3193
3321
|
return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
|
|
@@ -3240,7 +3368,7 @@ async function listPagesViaPlaywright(opts) {
|
|
|
3240
3368
|
}
|
|
3241
3369
|
async function createPageViaPlaywright(opts) {
|
|
3242
3370
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
3243
|
-
const context = browser.contexts()[0] ?? await browser.newContext();
|
|
3371
|
+
const context = opts.recordVideo ? recordingContexts.get(opts.cdpUrl) ?? await createRecordingContext(browser, opts.cdpUrl, opts.recordVideo) : browser.contexts()[0] ?? await browser.newContext();
|
|
3244
3372
|
ensureContextState(context);
|
|
3245
3373
|
const page = await context.newPage();
|
|
3246
3374
|
ensurePageState(page);
|
|
@@ -3317,10 +3445,10 @@ async function waitForViaPlaywright(opts) {
|
|
|
3317
3445
|
await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
|
3318
3446
|
}
|
|
3319
3447
|
if (opts.text !== void 0 && opts.text !== "") {
|
|
3320
|
-
await page.
|
|
3448
|
+
await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
|
|
3321
3449
|
}
|
|
3322
3450
|
if (opts.textGone !== void 0 && opts.textGone !== "") {
|
|
3323
|
-
await page.
|
|
3451
|
+
await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
|
|
3324
3452
|
}
|
|
3325
3453
|
if (opts.selector !== void 0 && opts.selector !== "") {
|
|
3326
3454
|
const selector = opts.selector.trim();
|
|
@@ -3592,7 +3720,7 @@ async function waitForDownloadViaPlaywright(opts) {
|
|
|
3592
3720
|
try {
|
|
3593
3721
|
const download = await waiter.promise;
|
|
3594
3722
|
if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
|
|
3595
|
-
const savePath = opts.path ?? download.suggestedFilename();
|
|
3723
|
+
const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
|
|
3596
3724
|
await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
|
|
3597
3725
|
return await saveDownloadPayload(download, savePath);
|
|
3598
3726
|
} catch (err) {
|
|
@@ -4259,6 +4387,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4259
4387
|
return match ? match[1] : null;
|
|
4260
4388
|
}
|
|
4261
4389
|
if (options.interactive === true) {
|
|
4390
|
+
let interactiveMaxRef = 0;
|
|
4391
|
+
for (const line of lines) {
|
|
4392
|
+
const refMatch = /\[ref=e(\d+)\]/.exec(line);
|
|
4393
|
+
if (refMatch) interactiveMaxRef = Math.max(interactiveMaxRef, Number.parseInt(refMatch[1], 10));
|
|
4394
|
+
}
|
|
4395
|
+
let interactiveCounter = interactiveMaxRef;
|
|
4396
|
+
const nextInteractiveRef = () => {
|
|
4397
|
+
interactiveCounter++;
|
|
4398
|
+
return `e${String(interactiveCounter)}`;
|
|
4399
|
+
};
|
|
4262
4400
|
const out2 = [];
|
|
4263
4401
|
for (const line of lines) {
|
|
4264
4402
|
const parsed = matchInteractiveSnapshotLine(line, options);
|
|
@@ -4266,13 +4404,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4266
4404
|
const { roleRaw, role, name, suffix } = parsed;
|
|
4267
4405
|
if (!INTERACTIVE_ROLES.has(role)) continue;
|
|
4268
4406
|
const ref = parseAiSnapshotRef(suffix);
|
|
4269
|
-
if (ref === null) continue;
|
|
4270
4407
|
const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
|
|
4271
|
-
|
|
4272
|
-
|
|
4408
|
+
if (ref !== null) {
|
|
4409
|
+
refs[ref] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
|
|
4410
|
+
out2.push(`${prefix}${roleRaw}${name !== void 0 && name !== "" ? ` "${name}"` : ""}${suffix}`);
|
|
4411
|
+
} else {
|
|
4412
|
+
const generatedRef = nextInteractiveRef();
|
|
4413
|
+
refs[generatedRef] = { role, ...name !== void 0 && name !== "" ? { name } : {} };
|
|
4414
|
+
let enhanced = `${prefix}${roleRaw}`;
|
|
4415
|
+
if (name !== void 0 && name !== "") enhanced += ` "${name}"`;
|
|
4416
|
+
enhanced += ` [ref=${generatedRef}]`;
|
|
4417
|
+
if (suffix.trim() !== "") enhanced += suffix;
|
|
4418
|
+
out2.push(enhanced);
|
|
4419
|
+
}
|
|
4273
4420
|
}
|
|
4274
4421
|
return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
|
|
4275
4422
|
}
|
|
4423
|
+
let maxRef = 0;
|
|
4424
|
+
for (const line of lines) {
|
|
4425
|
+
const refMatch = /\[ref=e(\d+)\]/.exec(line);
|
|
4426
|
+
if (refMatch) maxRef = Math.max(maxRef, Number.parseInt(refMatch[1], 10));
|
|
4427
|
+
}
|
|
4428
|
+
let generatedCounter = maxRef;
|
|
4429
|
+
const nextGeneratedRef = () => {
|
|
4430
|
+
generatedCounter++;
|
|
4431
|
+
return `e${String(generatedCounter)}`;
|
|
4432
|
+
};
|
|
4276
4433
|
const out = [];
|
|
4277
4434
|
for (const line of lines) {
|
|
4278
4435
|
const depth = getIndentLevel(line);
|
|
@@ -4282,7 +4439,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4282
4439
|
out.push(line);
|
|
4283
4440
|
continue;
|
|
4284
4441
|
}
|
|
4285
|
-
const [, , roleRaw, name, suffix] = match;
|
|
4442
|
+
const [, prefix, roleRaw, name, suffix] = match;
|
|
4286
4443
|
if (roleRaw.startsWith("/")) {
|
|
4287
4444
|
out.push(line);
|
|
4288
4445
|
continue;
|
|
@@ -4291,8 +4448,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4291
4448
|
const isStructural = STRUCTURAL_ROLES.has(role);
|
|
4292
4449
|
if (options.compact === true && isStructural && name === "") continue;
|
|
4293
4450
|
const ref = parseAiSnapshotRef(suffix);
|
|
4294
|
-
if (ref !== null)
|
|
4295
|
-
|
|
4451
|
+
if (ref !== null) {
|
|
4452
|
+
refs[ref] = { role, ...name !== "" ? { name } : {} };
|
|
4453
|
+
out.push(line);
|
|
4454
|
+
} else if (INTERACTIVE_ROLES.has(role)) {
|
|
4455
|
+
const generatedRef = nextGeneratedRef();
|
|
4456
|
+
refs[generatedRef] = { role, ...name !== "" ? { name } : {} };
|
|
4457
|
+
let enhanced = `${prefix}${roleRaw}`;
|
|
4458
|
+
if (name !== "") enhanced += ` "${name}"`;
|
|
4459
|
+
enhanced += ` [ref=${generatedRef}]`;
|
|
4460
|
+
if (suffix.trim() !== "") enhanced += suffix;
|
|
4461
|
+
out.push(enhanced);
|
|
4462
|
+
} else {
|
|
4463
|
+
out.push(line);
|
|
4464
|
+
}
|
|
4296
4465
|
}
|
|
4297
4466
|
const tree = out.join("\n") || "(empty)";
|
|
4298
4467
|
return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
|
|
@@ -4653,6 +4822,85 @@ var CrawlPage = class {
|
|
|
4653
4822
|
timeoutMs: opts?.timeoutMs
|
|
4654
4823
|
});
|
|
4655
4824
|
}
|
|
4825
|
+
/**
|
|
4826
|
+
* Click at specific page coordinates.
|
|
4827
|
+
*
|
|
4828
|
+
* Useful for canvas elements, custom widgets, or elements without ARIA roles.
|
|
4829
|
+
*
|
|
4830
|
+
* @param x - X coordinate in pixels
|
|
4831
|
+
* @param y - Y coordinate in pixels
|
|
4832
|
+
* @param opts - Click options (button, clickCount, delayMs)
|
|
4833
|
+
*
|
|
4834
|
+
* @example
|
|
4835
|
+
* ```ts
|
|
4836
|
+
* await page.mouseClick(100, 200);
|
|
4837
|
+
* await page.mouseClick(100, 200, { button: 'right' });
|
|
4838
|
+
* await page.mouseClick(100, 200, { clickCount: 2 }); // double-click
|
|
4839
|
+
* ```
|
|
4840
|
+
*/
|
|
4841
|
+
async mouseClick(x, y, opts) {
|
|
4842
|
+
return mouseClickViaPlaywright({
|
|
4843
|
+
cdpUrl: this.cdpUrl,
|
|
4844
|
+
targetId: this.targetId,
|
|
4845
|
+
x,
|
|
4846
|
+
y,
|
|
4847
|
+
button: opts?.button,
|
|
4848
|
+
clickCount: opts?.clickCount,
|
|
4849
|
+
delayMs: opts?.delayMs
|
|
4850
|
+
});
|
|
4851
|
+
}
|
|
4852
|
+
/**
|
|
4853
|
+
* Click an element by its visible text content (no snapshot/ref needed).
|
|
4854
|
+
*
|
|
4855
|
+
* Finds and clicks atomically — no stale ref problem.
|
|
4856
|
+
*
|
|
4857
|
+
* @param text - Text content to match
|
|
4858
|
+
* @param opts - Options (exact: require full match, button, modifiers)
|
|
4859
|
+
*
|
|
4860
|
+
* @example
|
|
4861
|
+
* ```ts
|
|
4862
|
+
* await page.clickByText('Submit');
|
|
4863
|
+
* await page.clickByText('Save Changes', { exact: true });
|
|
4864
|
+
* ```
|
|
4865
|
+
*/
|
|
4866
|
+
async clickByText(text, opts) {
|
|
4867
|
+
return clickByTextViaPlaywright({
|
|
4868
|
+
cdpUrl: this.cdpUrl,
|
|
4869
|
+
targetId: this.targetId,
|
|
4870
|
+
text,
|
|
4871
|
+
exact: opts?.exact,
|
|
4872
|
+
button: opts?.button,
|
|
4873
|
+
modifiers: opts?.modifiers,
|
|
4874
|
+
timeoutMs: opts?.timeoutMs
|
|
4875
|
+
});
|
|
4876
|
+
}
|
|
4877
|
+
/**
|
|
4878
|
+
* Click an element by its ARIA role and accessible name (no snapshot/ref needed).
|
|
4879
|
+
*
|
|
4880
|
+
* Finds and clicks atomically — no stale ref problem.
|
|
4881
|
+
*
|
|
4882
|
+
* @param role - ARIA role (e.g. `'button'`, `'link'`, `'menuitem'`)
|
|
4883
|
+
* @param name - Accessible name to match (optional)
|
|
4884
|
+
* @param opts - Click options
|
|
4885
|
+
*
|
|
4886
|
+
* @example
|
|
4887
|
+
* ```ts
|
|
4888
|
+
* await page.clickByRole('button', 'Save');
|
|
4889
|
+
* await page.clickByRole('link', 'Settings');
|
|
4890
|
+
* await page.clickByRole('menuitem', 'Delete');
|
|
4891
|
+
* ```
|
|
4892
|
+
*/
|
|
4893
|
+
async clickByRole(role, name, opts) {
|
|
4894
|
+
return clickByRoleViaPlaywright({
|
|
4895
|
+
cdpUrl: this.cdpUrl,
|
|
4896
|
+
targetId: this.targetId,
|
|
4897
|
+
role,
|
|
4898
|
+
name,
|
|
4899
|
+
button: opts?.button,
|
|
4900
|
+
modifiers: opts?.modifiers,
|
|
4901
|
+
timeoutMs: opts?.timeoutMs
|
|
4902
|
+
});
|
|
4903
|
+
}
|
|
4656
4904
|
/**
|
|
4657
4905
|
* Type text into an input element by ref.
|
|
4658
4906
|
*
|
|
@@ -5488,11 +5736,13 @@ var CrawlPage = class {
|
|
|
5488
5736
|
var BrowserClaw = class _BrowserClaw {
|
|
5489
5737
|
cdpUrl;
|
|
5490
5738
|
ssrfPolicy;
|
|
5739
|
+
recordVideo;
|
|
5491
5740
|
chrome;
|
|
5492
|
-
constructor(cdpUrl, chrome, ssrfPolicy) {
|
|
5741
|
+
constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
|
|
5493
5742
|
this.cdpUrl = cdpUrl;
|
|
5494
5743
|
this.chrome = chrome;
|
|
5495
5744
|
this.ssrfPolicy = ssrfPolicy;
|
|
5745
|
+
this.recordVideo = recordVideo;
|
|
5496
5746
|
}
|
|
5497
5747
|
/**
|
|
5498
5748
|
* Launch a new Chrome instance and connect to it.
|
|
@@ -5521,7 +5771,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5521
5771
|
const chrome = await launchChrome(opts);
|
|
5522
5772
|
const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
|
|
5523
5773
|
const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
|
|
5524
|
-
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
|
|
5774
|
+
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
|
|
5525
5775
|
}
|
|
5526
5776
|
/**
|
|
5527
5777
|
* Connect to an already-running Chrome instance via its CDP endpoint.
|
|
@@ -5538,12 +5788,22 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5538
5788
|
* ```
|
|
5539
5789
|
*/
|
|
5540
5790
|
static async connect(cdpUrl, opts) {
|
|
5541
|
-
|
|
5542
|
-
|
|
5791
|
+
let resolvedUrl = cdpUrl;
|
|
5792
|
+
if (resolvedUrl === void 0 || resolvedUrl === "") {
|
|
5793
|
+
const discovered = await discoverChromeCdpUrl();
|
|
5794
|
+
if (discovered === null) {
|
|
5795
|
+
throw new Error(
|
|
5796
|
+
"No Chrome instance found on common CDP ports (9222-9226, 9229). Start Chrome with --remote-debugging-port=9222, or pass a CDP URL."
|
|
5797
|
+
);
|
|
5798
|
+
}
|
|
5799
|
+
resolvedUrl = discovered;
|
|
5800
|
+
}
|
|
5801
|
+
if (!await isChromeReachable(resolvedUrl, 3e3, opts?.authToken)) {
|
|
5802
|
+
throw new Error(`Cannot connect to Chrome at ${resolvedUrl}. Is Chrome running with --remote-debugging-port?`);
|
|
5543
5803
|
}
|
|
5544
|
-
await connectBrowser(
|
|
5804
|
+
await connectBrowser(resolvedUrl, opts?.authToken);
|
|
5545
5805
|
const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
|
|
5546
|
-
return new _BrowserClaw(
|
|
5806
|
+
return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
|
|
5547
5807
|
}
|
|
5548
5808
|
/**
|
|
5549
5809
|
* Open a URL in a new tab and return the page handle.
|
|
@@ -5558,7 +5818,12 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5558
5818
|
* ```
|
|
5559
5819
|
*/
|
|
5560
5820
|
async open(url) {
|
|
5561
|
-
const tab = await createPageViaPlaywright({
|
|
5821
|
+
const tab = await createPageViaPlaywright({
|
|
5822
|
+
cdpUrl: this.cdpUrl,
|
|
5823
|
+
url,
|
|
5824
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
5825
|
+
recordVideo: this.recordVideo
|
|
5826
|
+
});
|
|
5562
5827
|
return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
|
|
5563
5828
|
}
|
|
5564
5829
|
/**
|
|
@@ -5621,6 +5886,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5621
5886
|
* Playwright connection is closed.
|
|
5622
5887
|
*/
|
|
5623
5888
|
async stop() {
|
|
5889
|
+
clearRecordingContext(this.cdpUrl);
|
|
5624
5890
|
await disconnectBrowser();
|
|
5625
5891
|
if (this.chrome) {
|
|
5626
5892
|
await stopChrome(this.chrome);
|