browserclaw 0.7.0 → 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 +334 -79
- 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 +336 -81
- 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,23 +1297,20 @@ 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);
|
|
1298
|
-
|
|
1299
|
-
let isLoopback = false;
|
|
1300
|
-
try {
|
|
1301
|
-
const u = new URL(cdpUrl.startsWith("http") ? cdpUrl : `http://${cdpUrl}`);
|
|
1302
|
-
isLoopback = isLoopbackHost(u.hostname);
|
|
1303
|
-
} catch {
|
|
1304
|
-
}
|
|
1305
|
-
if (!isLoopback) return false;
|
|
1306
|
-
for (let i = 0; i < 2; i++) {
|
|
1307
|
-
await new Promise((r) => setTimeout(r, 150));
|
|
1308
|
-
const retry = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
1309
|
-
if (retry !== null) return true;
|
|
1310
|
-
}
|
|
1311
|
-
return false;
|
|
1313
|
+
return version !== null;
|
|
1312
1314
|
}
|
|
1313
1315
|
async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
|
|
1314
1316
|
if (isWebSocketUrl(cdpUrl)) return cdpUrl;
|
|
@@ -1925,6 +1927,12 @@ function ensurePageState(page) {
|
|
|
1925
1927
|
rec.ok = false;
|
|
1926
1928
|
}
|
|
1927
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
|
+
});
|
|
1928
1936
|
page.on("close", () => {
|
|
1929
1937
|
pageStates.delete(page);
|
|
1930
1938
|
observedPages.delete(page);
|
|
@@ -2053,6 +2061,8 @@ async function disconnectBrowser() {
|
|
|
2053
2061
|
}
|
|
2054
2062
|
}
|
|
2055
2063
|
for (const cur of cachedByCdpUrl.values()) {
|
|
2064
|
+
if (cur.onDisconnected && typeof cur.browser.off === "function")
|
|
2065
|
+
cur.browser.off("disconnected", cur.onDisconnected);
|
|
2056
2066
|
await cur.browser.close().catch(() => {
|
|
2057
2067
|
});
|
|
2058
2068
|
}
|
|
@@ -2362,48 +2372,52 @@ async function awaitEvalWithAbort(evalPromise, abortPromise) {
|
|
|
2362
2372
|
}
|
|
2363
2373
|
var BROWSER_EVALUATOR = new Function(
|
|
2364
2374
|
"args",
|
|
2365
|
-
|
|
2366
|
-
|
|
2367
|
-
|
|
2368
|
-
|
|
2369
|
-
var candidate
|
|
2370
|
-
|
|
2371
|
-
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
2377
|
-
|
|
2378
|
-
}
|
|
2379
|
-
|
|
2380
|
-
}
|
|
2381
|
-
|
|
2382
|
-
|
|
2383
|
-
|
|
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")
|
|
2384
2396
|
);
|
|
2385
2397
|
var ELEMENT_EVALUATOR = new Function(
|
|
2386
2398
|
"el",
|
|
2387
2399
|
"args",
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
var candidate
|
|
2393
|
-
|
|
2394
|
-
|
|
2395
|
-
|
|
2396
|
-
|
|
2397
|
-
|
|
2398
|
-
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
}
|
|
2402
|
-
|
|
2403
|
-
}
|
|
2404
|
-
|
|
2405
|
-
|
|
2406
|
-
|
|
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")
|
|
2407
2421
|
);
|
|
2408
2422
|
async function evaluateViaPlaywright(opts) {
|
|
2409
2423
|
const fnText = opts.fn.trim();
|
|
@@ -2471,6 +2485,18 @@ async function evaluateViaPlaywright(opts) {
|
|
|
2471
2485
|
|
|
2472
2486
|
// src/security.ts
|
|
2473
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");
|
|
2474
2500
|
var InvalidBrowserNavigationUrlError = class extends Error {
|
|
2475
2501
|
constructor(message) {
|
|
2476
2502
|
super(message);
|
|
@@ -2482,7 +2508,7 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
|
|
|
2482
2508
|
}
|
|
2483
2509
|
var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
2484
2510
|
var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
|
|
2485
|
-
var
|
|
2511
|
+
var PROXY_ENV_KEYS2 = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
2486
2512
|
var BLOCKED_HOSTNAMES = /* @__PURE__ */ new Set(["localhost", "localhost.localdomain", "metadata.google.internal"]);
|
|
2487
2513
|
function isAllowedNonNetworkNavigationUrl(parsed) {
|
|
2488
2514
|
return SAFE_NON_NETWORK_URLS.has(parsed.href);
|
|
@@ -2491,7 +2517,7 @@ function isPrivateNetworkAllowedByPolicy(policy) {
|
|
|
2491
2517
|
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
|
2492
2518
|
}
|
|
2493
2519
|
function hasProxyEnvConfigured2(env = process.env) {
|
|
2494
|
-
for (const key of
|
|
2520
|
+
for (const key of PROXY_ENV_KEYS2) {
|
|
2495
2521
|
const value = env[key];
|
|
2496
2522
|
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
2497
2523
|
}
|
|
@@ -2696,6 +2722,8 @@ function dedupeAndPreferIpv4(results) {
|
|
|
2696
2722
|
}
|
|
2697
2723
|
function createPinnedLookup(params) {
|
|
2698
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}`);
|
|
2699
2727
|
const fallback = params.fallback ?? dns.lookup;
|
|
2700
2728
|
const records = params.addresses.map((address) => ({
|
|
2701
2729
|
address,
|
|
@@ -2870,6 +2898,48 @@ async function resolveStrictExistingUploadPaths(params) {
|
|
|
2870
2898
|
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
2871
2899
|
}
|
|
2872
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
|
+
}
|
|
2873
2943
|
function sanitizeUntrustedFileName(fileName, fallbackName) {
|
|
2874
2944
|
const trimmed = fileName.trim();
|
|
2875
2945
|
if (trimmed === "") return fallbackName;
|
|
@@ -2950,6 +3020,35 @@ function resolveLocator(page, resolved) {
|
|
|
2950
3020
|
const sel = resolved.selector ?? "";
|
|
2951
3021
|
return page.locator(sel);
|
|
2952
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
|
+
}
|
|
2953
3052
|
async function clickViaPlaywright(opts) {
|
|
2954
3053
|
const resolved = requireRefOrSelector(opts.ref, opts.selector);
|
|
2955
3054
|
const page = await getRestoredPageForTarget(opts);
|
|
@@ -3113,9 +3212,10 @@ async function setInputFilesViaPlaywright(opts) {
|
|
|
3113
3212
|
if (inputRef && element) throw new Error("ref and element are mutually exclusive");
|
|
3114
3213
|
if (!inputRef && !element) throw new Error("Either ref or element is required for setInputFiles");
|
|
3115
3214
|
const locator = inputRef ? refLocator(page, inputRef) : page.locator(element).first();
|
|
3116
|
-
const uploadPathsResult = await
|
|
3215
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3216
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3117
3217
|
requestedPaths: opts.paths,
|
|
3118
|
-
scopeLabel:
|
|
3218
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3119
3219
|
});
|
|
3120
3220
|
if (!uploadPathsResult.ok) throw new Error(uploadPathsResult.error);
|
|
3121
3221
|
const resolvedPaths = uploadPathsResult.paths;
|
|
@@ -3143,9 +3243,14 @@ async function armDialogViaPlaywright(opts) {
|
|
|
3143
3243
|
const armId = state.armIdDialog;
|
|
3144
3244
|
page.waitForEvent("dialog", { timeout }).then(async (dialog) => {
|
|
3145
3245
|
if (state.armIdDialog !== armId) return;
|
|
3146
|
-
|
|
3147
|
-
|
|
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
|
+
}
|
|
3148
3252
|
}).catch(() => {
|
|
3253
|
+
if (state.armIdDialog === armId) state.armIdDialog = 0;
|
|
3149
3254
|
});
|
|
3150
3255
|
}
|
|
3151
3256
|
async function armFileUploadViaPlaywright(opts) {
|
|
@@ -3163,9 +3268,10 @@ async function armFileUploadViaPlaywright(opts) {
|
|
|
3163
3268
|
}
|
|
3164
3269
|
return;
|
|
3165
3270
|
}
|
|
3166
|
-
const uploadPathsResult = await
|
|
3271
|
+
const uploadPathsResult = await resolveStrictExistingPathsWithinRoot({
|
|
3272
|
+
rootDir: DEFAULT_UPLOAD_DIR,
|
|
3167
3273
|
requestedPaths: opts.paths,
|
|
3168
|
-
scopeLabel:
|
|
3274
|
+
scopeLabel: `uploads directory (${DEFAULT_UPLOAD_DIR})`
|
|
3169
3275
|
});
|
|
3170
3276
|
if (!uploadPathsResult.ok) {
|
|
3171
3277
|
try {
|
|
@@ -3199,6 +3305,17 @@ async function pressKeyViaPlaywright(opts) {
|
|
|
3199
3305
|
}
|
|
3200
3306
|
|
|
3201
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
|
+
}
|
|
3202
3319
|
function isRetryableNavigateError(err) {
|
|
3203
3320
|
const msg = typeof err === "string" ? err.toLowerCase() : err instanceof Error ? err.message.toLowerCase() : "";
|
|
3204
3321
|
return msg.includes("frame has been detached") || msg.includes("target page, context or browser has been closed");
|
|
@@ -3251,7 +3368,7 @@ async function listPagesViaPlaywright(opts) {
|
|
|
3251
3368
|
}
|
|
3252
3369
|
async function createPageViaPlaywright(opts) {
|
|
3253
3370
|
const { browser } = await connectBrowser(opts.cdpUrl);
|
|
3254
|
-
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();
|
|
3255
3372
|
ensureContextState(context);
|
|
3256
3373
|
const page = await context.newPage();
|
|
3257
3374
|
ensurePageState(page);
|
|
@@ -3328,10 +3445,10 @@ async function waitForViaPlaywright(opts) {
|
|
|
3328
3445
|
await page.waitForTimeout(resolveBoundedDelayMs2(opts.timeMs, "wait timeMs", MAX_WAIT_TIME_MS));
|
|
3329
3446
|
}
|
|
3330
3447
|
if (opts.text !== void 0 && opts.text !== "") {
|
|
3331
|
-
await page.
|
|
3448
|
+
await page.waitForFunction((text) => (document.body?.innerText ?? "").includes(text), opts.text, { timeout });
|
|
3332
3449
|
}
|
|
3333
3450
|
if (opts.textGone !== void 0 && opts.textGone !== "") {
|
|
3334
|
-
await page.
|
|
3451
|
+
await page.waitForFunction((text) => !(document.body?.innerText ?? "").includes(text), opts.textGone, { timeout });
|
|
3335
3452
|
}
|
|
3336
3453
|
if (opts.selector !== void 0 && opts.selector !== "") {
|
|
3337
3454
|
const selector = opts.selector.trim();
|
|
@@ -3603,7 +3720,7 @@ async function waitForDownloadViaPlaywright(opts) {
|
|
|
3603
3720
|
try {
|
|
3604
3721
|
const download = await waiter.promise;
|
|
3605
3722
|
if (state.armIdDownload !== armId) throw new Error("Download was superseded by another waiter");
|
|
3606
|
-
const savePath = opts.path ?? download.suggestedFilename();
|
|
3723
|
+
const savePath = opts.path ?? sanitizeUntrustedFileName(download.suggestedFilename() || "download.bin", "download.bin");
|
|
3607
3724
|
await assertSafeOutputPath(savePath, opts.allowedOutputRoots);
|
|
3608
3725
|
return await saveDownloadPayload(download, savePath);
|
|
3609
3726
|
} catch (err) {
|
|
@@ -4270,6 +4387,16 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4270
4387
|
return match ? match[1] : null;
|
|
4271
4388
|
}
|
|
4272
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
|
+
};
|
|
4273
4400
|
const out2 = [];
|
|
4274
4401
|
for (const line of lines) {
|
|
4275
4402
|
const parsed = matchInteractiveSnapshotLine(line, options);
|
|
@@ -4277,13 +4404,32 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4277
4404
|
const { roleRaw, role, name, suffix } = parsed;
|
|
4278
4405
|
if (!INTERACTIVE_ROLES.has(role)) continue;
|
|
4279
4406
|
const ref = parseAiSnapshotRef(suffix);
|
|
4280
|
-
if (ref === null) continue;
|
|
4281
4407
|
const prefix = /^(\s*-\s*)/.exec(line)?.[1] ?? "";
|
|
4282
|
-
|
|
4283
|
-
|
|
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
|
+
}
|
|
4284
4420
|
}
|
|
4285
4421
|
return { snapshot: out2.join("\n") || "(no interactive elements)", refs };
|
|
4286
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
|
+
};
|
|
4287
4433
|
const out = [];
|
|
4288
4434
|
for (const line of lines) {
|
|
4289
4435
|
const depth = getIndentLevel(line);
|
|
@@ -4293,7 +4439,7 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4293
4439
|
out.push(line);
|
|
4294
4440
|
continue;
|
|
4295
4441
|
}
|
|
4296
|
-
const [, , roleRaw, name, suffix] = match;
|
|
4442
|
+
const [, prefix, roleRaw, name, suffix] = match;
|
|
4297
4443
|
if (roleRaw.startsWith("/")) {
|
|
4298
4444
|
out.push(line);
|
|
4299
4445
|
continue;
|
|
@@ -4302,8 +4448,20 @@ function buildRoleSnapshotFromAiSnapshot(aiSnapshot, options = {}) {
|
|
|
4302
4448
|
const isStructural = STRUCTURAL_ROLES.has(role);
|
|
4303
4449
|
if (options.compact === true && isStructural && name === "") continue;
|
|
4304
4450
|
const ref = parseAiSnapshotRef(suffix);
|
|
4305
|
-
if (ref !== null)
|
|
4306
|
-
|
|
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
|
+
}
|
|
4307
4465
|
}
|
|
4308
4466
|
const tree = out.join("\n") || "(empty)";
|
|
4309
4467
|
return { snapshot: options.compact === true ? compactTree(tree) : tree, refs };
|
|
@@ -4664,6 +4822,85 @@ var CrawlPage = class {
|
|
|
4664
4822
|
timeoutMs: opts?.timeoutMs
|
|
4665
4823
|
});
|
|
4666
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
|
+
}
|
|
4667
4904
|
/**
|
|
4668
4905
|
* Type text into an input element by ref.
|
|
4669
4906
|
*
|
|
@@ -5499,11 +5736,13 @@ var CrawlPage = class {
|
|
|
5499
5736
|
var BrowserClaw = class _BrowserClaw {
|
|
5500
5737
|
cdpUrl;
|
|
5501
5738
|
ssrfPolicy;
|
|
5739
|
+
recordVideo;
|
|
5502
5740
|
chrome;
|
|
5503
|
-
constructor(cdpUrl, chrome, ssrfPolicy) {
|
|
5741
|
+
constructor(cdpUrl, chrome, ssrfPolicy, recordVideo) {
|
|
5504
5742
|
this.cdpUrl = cdpUrl;
|
|
5505
5743
|
this.chrome = chrome;
|
|
5506
5744
|
this.ssrfPolicy = ssrfPolicy;
|
|
5745
|
+
this.recordVideo = recordVideo;
|
|
5507
5746
|
}
|
|
5508
5747
|
/**
|
|
5509
5748
|
* Launch a new Chrome instance and connect to it.
|
|
@@ -5532,7 +5771,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5532
5771
|
const chrome = await launchChrome(opts);
|
|
5533
5772
|
const cdpUrl = `http://127.0.0.1:${String(chrome.cdpPort)}`;
|
|
5534
5773
|
const ssrfPolicy = opts.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts.ssrfPolicy;
|
|
5535
|
-
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy);
|
|
5774
|
+
return new _BrowserClaw(cdpUrl, chrome, ssrfPolicy, opts.recordVideo);
|
|
5536
5775
|
}
|
|
5537
5776
|
/**
|
|
5538
5777
|
* Connect to an already-running Chrome instance via its CDP endpoint.
|
|
@@ -5549,12 +5788,22 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5549
5788
|
* ```
|
|
5550
5789
|
*/
|
|
5551
5790
|
static async connect(cdpUrl, opts) {
|
|
5552
|
-
|
|
5553
|
-
|
|
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?`);
|
|
5554
5803
|
}
|
|
5555
|
-
await connectBrowser(
|
|
5804
|
+
await connectBrowser(resolvedUrl, opts?.authToken);
|
|
5556
5805
|
const ssrfPolicy = opts?.allowInternal === true ? { ...opts.ssrfPolicy, dangerouslyAllowPrivateNetwork: true } : opts?.ssrfPolicy;
|
|
5557
|
-
return new _BrowserClaw(
|
|
5806
|
+
return new _BrowserClaw(resolvedUrl, null, ssrfPolicy, opts?.recordVideo);
|
|
5558
5807
|
}
|
|
5559
5808
|
/**
|
|
5560
5809
|
* Open a URL in a new tab and return the page handle.
|
|
@@ -5569,7 +5818,12 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5569
5818
|
* ```
|
|
5570
5819
|
*/
|
|
5571
5820
|
async open(url) {
|
|
5572
|
-
const tab = await createPageViaPlaywright({
|
|
5821
|
+
const tab = await createPageViaPlaywright({
|
|
5822
|
+
cdpUrl: this.cdpUrl,
|
|
5823
|
+
url,
|
|
5824
|
+
ssrfPolicy: this.ssrfPolicy,
|
|
5825
|
+
recordVideo: this.recordVideo
|
|
5826
|
+
});
|
|
5573
5827
|
return new CrawlPage(this.cdpUrl, tab.targetId, this.ssrfPolicy);
|
|
5574
5828
|
}
|
|
5575
5829
|
/**
|
|
@@ -5632,6 +5886,7 @@ var BrowserClaw = class _BrowserClaw {
|
|
|
5632
5886
|
* Playwright connection is closed.
|
|
5633
5887
|
*/
|
|
5634
5888
|
async stop() {
|
|
5889
|
+
clearRecordingContext(this.cdpUrl);
|
|
5635
5890
|
await disconnectBrowser();
|
|
5636
5891
|
if (this.chrome) {
|
|
5637
5892
|
await stopChrome(this.chrome);
|