browserclaw 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -0
- package/dist/index.cjs +131 -19
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +38 -1
- package/dist/index.d.ts +38 -1
- package/dist/index.js +126 -20
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -3,6 +3,8 @@
|
|
|
3
3
|
<p align="center">
|
|
4
4
|
<a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/v/browserclaw.svg" alt="npm version" /></a>
|
|
5
5
|
<a href="./LICENSE"><img src="https://img.shields.io/badge/License-MIT-blue.svg" alt="License: MIT" /></a>
|
|
6
|
+
<a href="https://www.npmjs.com/package/browserclaw"><img src="https://img.shields.io/npm/dw/browserclaw" alt="npm downloads" /></a>
|
|
7
|
+
<a href="https://github.com/idan-rubin/browserclaw/stargazers"><img src="https://img.shields.io/github/stars/idan-rubin/browserclaw" alt="GitHub stars" /></a>
|
|
6
8
|
</p>
|
|
7
9
|
|
|
8
10
|
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.
|
package/dist/index.cjs
CHANGED
|
@@ -349,39 +349,109 @@ function resolveUserDataDir(profileName) {
|
|
|
349
349
|
const configDir = process.env.XDG_CONFIG_HOME ?? path__default.default.join(os__default.default.homedir(), ".config");
|
|
350
350
|
return path__default.default.join(configDir, "browserclaw", "profiles", profileName, "user-data");
|
|
351
351
|
}
|
|
352
|
-
|
|
353
|
-
const ctrl = new AbortController();
|
|
354
|
-
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
352
|
+
function isWebSocketUrl(url) {
|
|
355
353
|
try {
|
|
356
|
-
const
|
|
357
|
-
|
|
358
|
-
const res = await fetch(`${cdpUrl.replace(/\/+$/, "")}/json/version`, { signal: ctrl.signal, headers });
|
|
359
|
-
if (!res.ok) return false;
|
|
360
|
-
const data = await res.json();
|
|
361
|
-
return data != null && typeof data === "object";
|
|
354
|
+
const parsed = new URL(url);
|
|
355
|
+
return parsed.protocol === "ws:" || parsed.protocol === "wss:";
|
|
362
356
|
} catch {
|
|
363
357
|
return false;
|
|
364
|
-
} finally {
|
|
365
|
-
clearTimeout(t);
|
|
366
358
|
}
|
|
367
359
|
}
|
|
368
|
-
|
|
360
|
+
function isLoopbackHost(hostname) {
|
|
361
|
+
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1" || hostname === "[::1]";
|
|
362
|
+
}
|
|
363
|
+
function normalizeCdpWsUrl(wsUrl, cdpUrl) {
|
|
364
|
+
const ws = new URL(wsUrl);
|
|
365
|
+
const cdp = new URL(cdpUrl);
|
|
366
|
+
const isWildcardBind = ws.hostname === "0.0.0.0" || ws.hostname === "[::]";
|
|
367
|
+
if ((isLoopbackHost(ws.hostname) || isWildcardBind) && !isLoopbackHost(cdp.hostname)) {
|
|
368
|
+
ws.hostname = cdp.hostname;
|
|
369
|
+
const cdpPort = cdp.port || (cdp.protocol === "https:" ? "443" : "80");
|
|
370
|
+
ws.port = cdpPort;
|
|
371
|
+
ws.protocol = cdp.protocol === "https:" ? "wss:" : "ws:";
|
|
372
|
+
}
|
|
373
|
+
if (cdp.protocol === "https:" && ws.protocol === "ws:") ws.protocol = "wss:";
|
|
374
|
+
if (!ws.username && !ws.password && (cdp.username || cdp.password)) {
|
|
375
|
+
ws.username = cdp.username;
|
|
376
|
+
ws.password = cdp.password;
|
|
377
|
+
}
|
|
378
|
+
for (const [key, value] of cdp.searchParams.entries()) {
|
|
379
|
+
if (!ws.searchParams.has(key)) ws.searchParams.append(key, value);
|
|
380
|
+
}
|
|
381
|
+
return ws.toString();
|
|
382
|
+
}
|
|
383
|
+
function normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) {
|
|
384
|
+
try {
|
|
385
|
+
const url = new URL(cdpUrl);
|
|
386
|
+
if (url.protocol === "ws:") url.protocol = "http:";
|
|
387
|
+
else if (url.protocol === "wss:") url.protocol = "https:";
|
|
388
|
+
url.pathname = url.pathname.replace(/\/devtools\/browser\/.*$/, "");
|
|
389
|
+
url.pathname = url.pathname.replace(/\/cdp$/, "");
|
|
390
|
+
return url.toString().replace(/\/$/, "");
|
|
391
|
+
} catch {
|
|
392
|
+
return cdpUrl.replace(/^ws:/, "http:").replace(/^wss:/, "https:").replace(/\/devtools\/browser\/.*$/, "").replace(/\/cdp$/, "").replace(/\/$/, "");
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
function appendCdpPath(cdpUrl, cdpPath) {
|
|
396
|
+
const url = new URL(cdpUrl);
|
|
397
|
+
url.pathname = `${url.pathname.replace(/\/$/, "")}${cdpPath.startsWith("/") ? cdpPath : `/${cdpPath}`}`;
|
|
398
|
+
return url.toString();
|
|
399
|
+
}
|
|
400
|
+
async function canOpenWebSocket(url, timeoutMs) {
|
|
401
|
+
return new Promise((resolve2) => {
|
|
402
|
+
let settled = false;
|
|
403
|
+
const finish = (value) => {
|
|
404
|
+
if (settled) return;
|
|
405
|
+
settled = true;
|
|
406
|
+
clearTimeout(timer);
|
|
407
|
+
try {
|
|
408
|
+
ws.close();
|
|
409
|
+
} catch {
|
|
410
|
+
}
|
|
411
|
+
resolve2(value);
|
|
412
|
+
};
|
|
413
|
+
const timer = setTimeout(() => finish(false), Math.max(50, timeoutMs + 25));
|
|
414
|
+
let ws;
|
|
415
|
+
try {
|
|
416
|
+
ws = new WebSocket(url);
|
|
417
|
+
} catch {
|
|
418
|
+
finish(false);
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
ws.onopen = () => finish(true);
|
|
422
|
+
ws.onerror = () => finish(false);
|
|
423
|
+
});
|
|
424
|
+
}
|
|
425
|
+
async function fetchChromeVersion(cdpUrl, timeoutMs = 500, authToken) {
|
|
369
426
|
const ctrl = new AbortController();
|
|
370
427
|
const t = setTimeout(() => ctrl.abort(), timeoutMs);
|
|
371
428
|
try {
|
|
429
|
+
const httpBase = isWebSocketUrl(cdpUrl) ? normalizeCdpHttpBaseForJsonEndpoints(cdpUrl) : cdpUrl;
|
|
372
430
|
const headers = {};
|
|
373
431
|
if (authToken) headers["Authorization"] = `Bearer ${authToken}`;
|
|
374
|
-
const res = await fetch(
|
|
432
|
+
const res = await fetch(appendCdpPath(httpBase, "/json/version"), { signal: ctrl.signal, headers });
|
|
375
433
|
if (!res.ok) return null;
|
|
376
434
|
const data = await res.json();
|
|
377
435
|
if (!data || typeof data !== "object") return null;
|
|
378
|
-
return
|
|
436
|
+
return data;
|
|
379
437
|
} catch {
|
|
380
438
|
return null;
|
|
381
439
|
} finally {
|
|
382
440
|
clearTimeout(t);
|
|
383
441
|
}
|
|
384
442
|
}
|
|
443
|
+
async function isChromeReachable(cdpUrl, timeoutMs = 500, authToken) {
|
|
444
|
+
if (isWebSocketUrl(cdpUrl)) return await canOpenWebSocket(cdpUrl, timeoutMs);
|
|
445
|
+
const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
446
|
+
return Boolean(version);
|
|
447
|
+
}
|
|
448
|
+
async function getChromeWebSocketUrl(cdpUrl, timeoutMs = 500, authToken) {
|
|
449
|
+
if (isWebSocketUrl(cdpUrl)) return cdpUrl;
|
|
450
|
+
const version = await fetchChromeVersion(cdpUrl, timeoutMs, authToken);
|
|
451
|
+
const wsUrl = String(version?.webSocketDebuggerUrl ?? "").trim();
|
|
452
|
+
if (!wsUrl) return null;
|
|
453
|
+
return normalizeCdpWsUrl(wsUrl, cdpUrl);
|
|
454
|
+
}
|
|
385
455
|
async function isChromeCdpReady(cdpUrl, timeoutMs = 500, handshakeTimeoutMs = 800) {
|
|
386
456
|
const wsUrl = await getChromeWebSocketUrl(cdpUrl, timeoutMs);
|
|
387
457
|
if (!wsUrl) return false;
|
|
@@ -779,7 +849,7 @@ async function findPageByTargetId(browser, targetId, cdpUrl) {
|
|
|
779
849
|
}
|
|
780
850
|
if (cdpUrl) {
|
|
781
851
|
try {
|
|
782
|
-
const listUrl = `${cdpUrl
|
|
852
|
+
const listUrl = `${normalizeCdpHttpBaseForJsonEndpoints(cdpUrl)}/json/list`;
|
|
783
853
|
const headers = {};
|
|
784
854
|
if (cached?.authToken) headers["Authorization"] = `Bearer ${cached.authToken}`;
|
|
785
855
|
const response = await fetch(listUrl, { headers });
|
|
@@ -1267,6 +1337,20 @@ function withBrowserNavigationPolicy(ssrfPolicy) {
|
|
|
1267
1337
|
}
|
|
1268
1338
|
var NETWORK_NAVIGATION_PROTOCOLS = /* @__PURE__ */ new Set(["http:", "https:"]);
|
|
1269
1339
|
var SAFE_NON_NETWORK_URLS = /* @__PURE__ */ new Set(["about:blank"]);
|
|
1340
|
+
var PROXY_ENV_KEYS = ["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "http_proxy", "https_proxy", "all_proxy"];
|
|
1341
|
+
function isAllowedNonNetworkNavigationUrl(parsed) {
|
|
1342
|
+
return SAFE_NON_NETWORK_URLS.has(parsed.href);
|
|
1343
|
+
}
|
|
1344
|
+
function isPrivateNetworkAllowedByPolicy(policy) {
|
|
1345
|
+
return policy?.dangerouslyAllowPrivateNetwork === true || policy?.allowPrivateNetwork === true;
|
|
1346
|
+
}
|
|
1347
|
+
function hasProxyEnvConfigured(env = process.env) {
|
|
1348
|
+
for (const key of PROXY_ENV_KEYS) {
|
|
1349
|
+
const value = env[key];
|
|
1350
|
+
if (typeof value === "string" && value.trim().length > 0) return true;
|
|
1351
|
+
}
|
|
1352
|
+
return false;
|
|
1353
|
+
}
|
|
1270
1354
|
async function assertBrowserNavigationAllowed(opts) {
|
|
1271
1355
|
const rawUrl = String(opts.url ?? "").trim();
|
|
1272
1356
|
let parsed;
|
|
@@ -1276,9 +1360,14 @@ async function assertBrowserNavigationAllowed(opts) {
|
|
|
1276
1360
|
throw new InvalidBrowserNavigationUrlError(`Invalid URL: "${rawUrl}"`);
|
|
1277
1361
|
}
|
|
1278
1362
|
if (!NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol)) {
|
|
1279
|
-
if (
|
|
1363
|
+
if (isAllowedNonNetworkNavigationUrl(parsed)) return;
|
|
1280
1364
|
throw new InvalidBrowserNavigationUrlError(`Navigation blocked: unsupported protocol "${parsed.protocol}"`);
|
|
1281
1365
|
}
|
|
1366
|
+
if (hasProxyEnvConfigured() && !isPrivateNetworkAllowedByPolicy(opts.ssrfPolicy)) {
|
|
1367
|
+
throw new InvalidBrowserNavigationUrlError(
|
|
1368
|
+
"Navigation blocked: strict browser SSRF policy cannot be enforced while env proxy variables are set"
|
|
1369
|
+
);
|
|
1370
|
+
}
|
|
1282
1371
|
const policy = opts.ssrfPolicy;
|
|
1283
1372
|
if (policy?.dangerouslyAllowPrivateNetwork ?? policy?.allowPrivateNetwork ?? true) return;
|
|
1284
1373
|
const allowedHostnames = [
|
|
@@ -1483,10 +1572,24 @@ async function assertBrowserNavigationResultAllowed(opts) {
|
|
|
1483
1572
|
} catch {
|
|
1484
1573
|
return;
|
|
1485
1574
|
}
|
|
1486
|
-
if (NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) ||
|
|
1575
|
+
if (NETWORK_NAVIGATION_PROTOCOLS.has(parsed.protocol) || isAllowedNonNetworkNavigationUrl(parsed)) {
|
|
1487
1576
|
await assertBrowserNavigationAllowed(opts);
|
|
1488
1577
|
}
|
|
1489
1578
|
}
|
|
1579
|
+
async function assertBrowserNavigationRedirectChainAllowed(opts) {
|
|
1580
|
+
const chain = [];
|
|
1581
|
+
let current = opts.request ?? null;
|
|
1582
|
+
while (current) {
|
|
1583
|
+
chain.push(current.url());
|
|
1584
|
+
current = current.redirectedFrom();
|
|
1585
|
+
}
|
|
1586
|
+
for (const url of [...chain].reverse()) {
|
|
1587
|
+
await assertBrowserNavigationAllowed({ url, lookupFn: opts.lookupFn, ssrfPolicy: opts.ssrfPolicy });
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function requiresInspectableBrowserNavigationRedirects(ssrfPolicy) {
|
|
1591
|
+
return !isPrivateNetworkAllowedByPolicy(ssrfPolicy);
|
|
1592
|
+
}
|
|
1490
1593
|
|
|
1491
1594
|
// src/actions/interaction.ts
|
|
1492
1595
|
async function clickViaPlaywright(opts) {
|
|
@@ -1691,7 +1794,8 @@ async function navigateViaPlaywright(opts) {
|
|
|
1691
1794
|
await assertBrowserNavigationAllowed({ url, ssrfPolicy: policy });
|
|
1692
1795
|
const page = await getPageForTargetId({ cdpUrl: opts.cdpUrl, targetId: opts.targetId });
|
|
1693
1796
|
ensurePageState(page);
|
|
1694
|
-
await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
|
|
1797
|
+
const response = await page.goto(url, { timeout: normalizeTimeoutMs(opts.timeoutMs, 2e4) });
|
|
1798
|
+
await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...withBrowserNavigationPolicy(policy) });
|
|
1695
1799
|
const finalUrl = page.url();
|
|
1696
1800
|
await assertBrowserNavigationResultAllowed({ url: finalUrl, ssrfPolicy: policy });
|
|
1697
1801
|
return { url: finalUrl };
|
|
@@ -1722,7 +1826,9 @@ async function createPageViaPlaywright(opts) {
|
|
|
1722
1826
|
const page = await context.newPage();
|
|
1723
1827
|
ensurePageState(page);
|
|
1724
1828
|
if (targetUrl !== "about:blank") {
|
|
1725
|
-
|
|
1829
|
+
const navigationPolicy = withBrowserNavigationPolicy(policy);
|
|
1830
|
+
const response = await page.goto(targetUrl, { timeout: normalizeTimeoutMs(void 0, 2e4) });
|
|
1831
|
+
await assertBrowserNavigationRedirectChainAllowed({ request: response?.request(), ...navigationPolicy });
|
|
1726
1832
|
await assertBrowserNavigationResultAllowed({ url: page.url(), ssrfPolicy: policy });
|
|
1727
1833
|
}
|
|
1728
1834
|
const tid = await pageTargetId(page).catch(() => null);
|
|
@@ -3252,7 +3358,13 @@ exports.BrowserClaw = BrowserClaw;
|
|
|
3252
3358
|
exports.CrawlPage = CrawlPage;
|
|
3253
3359
|
exports.InvalidBrowserNavigationUrlError = InvalidBrowserNavigationUrlError;
|
|
3254
3360
|
exports.assertBrowserNavigationAllowed = assertBrowserNavigationAllowed;
|
|
3361
|
+
exports.assertBrowserNavigationRedirectChainAllowed = assertBrowserNavigationRedirectChainAllowed;
|
|
3362
|
+
exports.assertBrowserNavigationResultAllowed = assertBrowserNavigationResultAllowed;
|
|
3363
|
+
exports.getChromeWebSocketUrl = getChromeWebSocketUrl;
|
|
3255
3364
|
exports.isChromeCdpReady = isChromeCdpReady;
|
|
3365
|
+
exports.isChromeReachable = isChromeReachable;
|
|
3366
|
+
exports.normalizeCdpHttpBaseForJsonEndpoints = normalizeCdpHttpBaseForJsonEndpoints;
|
|
3367
|
+
exports.requiresInspectableBrowserNavigationRedirects = requiresInspectableBrowserNavigationRedirects;
|
|
3256
3368
|
exports.withBrowserNavigationPolicy = withBrowserNavigationPolicy;
|
|
3257
3369
|
//# sourceMappingURL=index.cjs.map
|
|
3258
3370
|
//# sourceMappingURL=index.cjs.map
|