@xbrowser/cli 1.9.4 → 1.9.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/{browser-6WJHQDPV.js → browser-ALPLJNH2.js} +1 -1
- package/dist/{browser-4KRHALJ3.js → browser-JXIJF4VL.js} +1 -1
- package/dist/{browser-C63PFGPT.js → browser-ZPQP6VW7.js} +1 -1
- package/dist/{chunk-OOJ2H7IL.js → chunk-37SHZSUR.js} +1 -1
- package/dist/{chunk-MSQIPXKE.js → chunk-5JSOGINQ.js} +1 -1
- package/dist/{chunk-Z7WC4P2M.js → chunk-74S7UUI2.js} +1 -1
- package/dist/{chunk-TTZNR3QP.js → chunk-C4O73DQL.js} +165 -50
- package/dist/{chunk-YSCY52UJ.js → chunk-CH5ZQV64.js} +165 -50
- package/dist/{chunk-53LRNYPG.js → chunk-YAUG3NSI.js} +2 -2
- package/dist/cli.js +38 -13
- package/dist/{daemon-client-PZX2KOLQ.js → daemon-client-4HJENICO.js} +2 -2
- package/dist/{daemon-client-TOUDMIY5.js → daemon-client-CMRAWXTN.js} +1 -1
- package/dist/daemon-main.js +28 -8
- package/dist/index.d.ts +24 -3
- package/dist/index.js +37 -12
- package/dist/{session-recorder-UTULJFTE.js → session-recorder-7GXI42FO.js} +1 -1
- package/dist/{session-recorder-KPU4YQAE.js → session-recorder-RFOPX4SF.js} +1 -1
- package/dist/{session-replayer-RWFOWH3F.js → session-replayer-3HEIJBJ2.js} +65 -33
- package/package.json +1 -1
package/dist/index.d.ts
CHANGED
|
@@ -1812,6 +1812,13 @@ interface RecordingStep {
|
|
|
1812
1812
|
networkId: number;
|
|
1813
1813
|
paramName: string;
|
|
1814
1814
|
}>;
|
|
1815
|
+
signals: ActionSignal[];
|
|
1816
|
+
}
|
|
1817
|
+
/** Success / verification signal attached to a recording step */
|
|
1818
|
+
interface ActionSignal {
|
|
1819
|
+
type: 'network_success' | 'url_change' | 'dialog' | 'dom_change';
|
|
1820
|
+
value?: string;
|
|
1821
|
+
label: string;
|
|
1815
1822
|
}
|
|
1816
1823
|
interface RecordingSummary {
|
|
1817
1824
|
startUrl: string;
|
|
@@ -1871,12 +1878,17 @@ declare class SessionRecorder {
|
|
|
1871
1878
|
private lastActionTs;
|
|
1872
1879
|
private activePages;
|
|
1873
1880
|
private lastKnownUrl;
|
|
1874
|
-
/** Dedup
|
|
1875
|
-
|
|
1881
|
+
/** Dedup map: key = normalizedType|tag|value → expiration timestamp.
|
|
1882
|
+
* Replaces old single-entry cdpActionDedup for bidirectional dedup. */
|
|
1883
|
+
private dedupMap;
|
|
1884
|
+
private dedupActionCount;
|
|
1876
1885
|
/** Network dedup: last request key for short-window dedup */
|
|
1877
1886
|
private _lastRequestKey;
|
|
1878
1887
|
private _lastRequestTs;
|
|
1879
1888
|
private _isRecording;
|
|
1889
|
+
private streamMode;
|
|
1890
|
+
/** Ambient action types: filtered out in clean mode */
|
|
1891
|
+
private static readonly AMBIENT_TYPES;
|
|
1880
1892
|
constructor(context: XBContext, page: XBPage, sessionName: string);
|
|
1881
1893
|
get isRecording(): boolean;
|
|
1882
1894
|
get actionCount(): number;
|
|
@@ -1888,6 +1900,8 @@ declare class SessionRecorder {
|
|
|
1888
1900
|
url?: string;
|
|
1889
1901
|
element?: UserAction['element'];
|
|
1890
1902
|
}): Promise<void>;
|
|
1903
|
+
/** Generate dedup key for an action (normalized type + selector/tag + value). */
|
|
1904
|
+
private dedupKey;
|
|
1891
1905
|
get networkCount(): number;
|
|
1892
1906
|
getLiveData(): RecordingData;
|
|
1893
1907
|
addManualCheckpoint(type: string, hint: string, selector?: string): CheckpointEntry;
|
|
@@ -1898,7 +1912,9 @@ declare class SessionRecorder {
|
|
|
1898
1912
|
get controlFilePath(): string;
|
|
1899
1913
|
/** Path to the stop signal file (written by `record stop`). */
|
|
1900
1914
|
get stopSignalPath(): string;
|
|
1901
|
-
start(url?: string
|
|
1915
|
+
start(url?: string, options?: {
|
|
1916
|
+
stream?: 'clean' | 'raw';
|
|
1917
|
+
}): Promise<void>;
|
|
1902
1918
|
stop(): Promise<{
|
|
1903
1919
|
data: RecordingData;
|
|
1904
1920
|
summary: RecordingSummary;
|
|
@@ -1932,6 +1948,11 @@ declare class SessionRecorder {
|
|
|
1932
1948
|
*/
|
|
1933
1949
|
private captureClickContext;
|
|
1934
1950
|
private detectCheckpoints;
|
|
1951
|
+
/**
|
|
1952
|
+
* After a key action (click/submit/input), check for success signals:
|
|
1953
|
+
* URL changes, recent successful network responses, dialog appearances.
|
|
1954
|
+
*/
|
|
1955
|
+
private verifyAction;
|
|
1935
1956
|
private flushToDisk;
|
|
1936
1957
|
private writeFinalOutput;
|
|
1937
1958
|
private buildData;
|
package/dist/index.js
CHANGED
|
@@ -44,7 +44,7 @@ import {
|
|
|
44
44
|
startDaemonProcess,
|
|
45
45
|
stopDaemonProcess,
|
|
46
46
|
version
|
|
47
|
-
} from "./chunk-
|
|
47
|
+
} from "./chunk-YAUG3NSI.js";
|
|
48
48
|
import {
|
|
49
49
|
generateBashScript,
|
|
50
50
|
generateJSScript,
|
|
@@ -61,7 +61,7 @@ import {
|
|
|
61
61
|
} from "./chunk-ANVL2ID2.js";
|
|
62
62
|
import {
|
|
63
63
|
SessionRecorder
|
|
64
|
-
} from "./chunk-
|
|
64
|
+
} from "./chunk-CH5ZQV64.js";
|
|
65
65
|
import {
|
|
66
66
|
addKnownIssue,
|
|
67
67
|
getKnowledgePath,
|
|
@@ -86,7 +86,7 @@ import {
|
|
|
86
86
|
resolveLaunchOpts,
|
|
87
87
|
saveSessionDiskMeta,
|
|
88
88
|
setActivePage
|
|
89
|
-
} from "./chunk-
|
|
89
|
+
} from "./chunk-5JSOGINQ.js";
|
|
90
90
|
import "./chunk-VJNMAWPZ.js";
|
|
91
91
|
import "./chunk-TNEN6VQ2.js";
|
|
92
92
|
import {
|
|
@@ -6678,6 +6678,22 @@ async function checkPluginLoginRequired(options) {
|
|
|
6678
6678
|
const requiresLogin = command.requiresLogin === true || site.config?.requiresLogin === true || loginConfig?.requiresLogin === true || command.loginRequired === "required";
|
|
6679
6679
|
if (!requiresLogin) return { ok: true };
|
|
6680
6680
|
const pluginName = site.name || "plugin";
|
|
6681
|
+
const siteConfig = site.config;
|
|
6682
|
+
const configIsLogin = siteConfig?.isLogin;
|
|
6683
|
+
if (configIsLogin) {
|
|
6684
|
+
try {
|
|
6685
|
+
const loggedIn = await configIsLogin(ctx);
|
|
6686
|
+
if (loggedIn) return { ok: true };
|
|
6687
|
+
return buildLoginRequired({
|
|
6688
|
+
plugin: pluginName,
|
|
6689
|
+
command: commandName,
|
|
6690
|
+
reason: "config.isLogin returned false",
|
|
6691
|
+
sessionName,
|
|
6692
|
+
loginConfig
|
|
6693
|
+
});
|
|
6694
|
+
} catch {
|
|
6695
|
+
}
|
|
6696
|
+
}
|
|
6681
6697
|
if (typeof site.isLoggedIn === "function") {
|
|
6682
6698
|
try {
|
|
6683
6699
|
const loggedIn = await site.isLoggedIn(ctx);
|
|
@@ -7390,7 +7406,7 @@ async function executeCommand(commandName, params, sessionName = "default", extr
|
|
|
7390
7406
|
}
|
|
7391
7407
|
let targetPageOverride = null;
|
|
7392
7408
|
if (_target && extraOpts?.cdpEndpoint) {
|
|
7393
|
-
const { findTargetPage } = await import("./browser-
|
|
7409
|
+
const { findTargetPage } = await import("./browser-ZPQP6VW7.js");
|
|
7394
7410
|
targetPageOverride = await findTargetPage(extraOpts.cdpEndpoint, _target);
|
|
7395
7411
|
if (!targetPageOverride) {
|
|
7396
7412
|
return errorResult(`Target "${_target}" not found. Use 'xbrowser targets --cdp ${extraOpts.cdpEndpoint}' to list available pages.`);
|
|
@@ -7407,7 +7423,7 @@ async function executeCommand(commandName, params, sessionName = "default", extr
|
|
|
7407
7423
|
params = result.data;
|
|
7408
7424
|
}
|
|
7409
7425
|
if (command.scope !== "cli" && !process.env.XBROWSER_DAEMON_WORKER) {
|
|
7410
|
-
const { forwardExec } = await import("./daemon-client-
|
|
7426
|
+
const { forwardExec } = await import("./daemon-client-CMRAWXTN.js");
|
|
7411
7427
|
const result = await forwardExec(commandName, params, sessionName, extraOpts?.cdpEndpoint);
|
|
7412
7428
|
if (result) return result;
|
|
7413
7429
|
}
|
|
@@ -8852,7 +8868,7 @@ var PluginInstaller = class {
|
|
|
8852
8868
|
import { outputFormatter, OutputFormatter } from "@dyyz1993/xcli-core";
|
|
8853
8869
|
var formatter = new OutputFormatter();
|
|
8854
8870
|
function outputResult(result, mode = "text") {
|
|
8855
|
-
if (typeof result === "object" && result !== null) {
|
|
8871
|
+
if (mode !== "json" && mode !== "yaml" && typeof result === "object" && result !== null) {
|
|
8856
8872
|
const r = result;
|
|
8857
8873
|
if (r.success === false) {
|
|
8858
8874
|
outputError(r.message ? String(r.message) : "Unknown error");
|
|
@@ -10866,7 +10882,7 @@ async function handlePlugin(args, options, mode) {
|
|
|
10866
10882
|
} catch {
|
|
10867
10883
|
}
|
|
10868
10884
|
try {
|
|
10869
|
-
const { daemonPing } = await import("./daemon-client-
|
|
10885
|
+
const { daemonPing } = await import("./daemon-client-CMRAWXTN.js");
|
|
10870
10886
|
if (await daemonPing()) {
|
|
10871
10887
|
await fetch("http://localhost:9224/rpc", {
|
|
10872
10888
|
method: "POST",
|
|
@@ -11024,7 +11040,8 @@ async function handleRecord(args, options, mode) {
|
|
|
11024
11040
|
const url = options.url;
|
|
11025
11041
|
const sessionName = options.session || "default";
|
|
11026
11042
|
const cdpEndpoint = options.cdp;
|
|
11027
|
-
const
|
|
11043
|
+
const stream = options.stream || "clean";
|
|
11044
|
+
const result = await forwardRecordStart(sessionName, url, cdpEndpoint, stream);
|
|
11028
11045
|
if (!result.ok) {
|
|
11029
11046
|
outputError(String(result.error || "Failed to start recording"));
|
|
11030
11047
|
return;
|
|
@@ -11319,7 +11336,15 @@ async function handleReplay(args, options, mode) {
|
|
|
11319
11336
|
const absPath = await import("path").then((p) => p.resolve(filePath));
|
|
11320
11337
|
const result = await forwardReplay(absPath, sessionName, slowMo);
|
|
11321
11338
|
if (!result.ok) {
|
|
11322
|
-
|
|
11339
|
+
if (mode === "json" || mode === "yaml") {
|
|
11340
|
+
outputResult({
|
|
11341
|
+
...result,
|
|
11342
|
+
success: false,
|
|
11343
|
+
message: result.errors ? result.errors.map((e) => e.error).join("; ") : result.error || "Replay failed"
|
|
11344
|
+
}, mode);
|
|
11345
|
+
} else {
|
|
11346
|
+
outputError(String(result.errors ? result.errors.map((e) => e.error).join("; ") : result.error || "Replay failed"));
|
|
11347
|
+
}
|
|
11323
11348
|
return;
|
|
11324
11349
|
}
|
|
11325
11350
|
outputResult(result, mode);
|
|
@@ -11423,7 +11448,7 @@ async function handleFilter(args, _mode, options) {
|
|
|
11423
11448
|
}
|
|
11424
11449
|
}
|
|
11425
11450
|
async function handleGeneratePlugin(sessionName, pluginName, outputDir) {
|
|
11426
|
-
const { SessionRecorder: SessionRecorder2 } = await import("./session-recorder-
|
|
11451
|
+
const { SessionRecorder: SessionRecorder2 } = await import("./session-recorder-RFOPX4SF.js");
|
|
11427
11452
|
const { readSiteKnowledge: readSiteKnowledge2, toMarkdown } = await import("./site-knowledge-SYC6VCDB.js");
|
|
11428
11453
|
const { mkdirSync: mkdirSync11, writeFileSync: writeFileSync13 } = await import("fs");
|
|
11429
11454
|
const { join: join14 } = await import("path");
|
|
@@ -13360,7 +13385,7 @@ Run "xbrowser ${command} ${subCommand} --help" to see available parameters.`
|
|
|
13360
13385
|
}
|
|
13361
13386
|
const needsBrowser = cmdEntry.scope === "page" || cmdEntry.scope === "browser";
|
|
13362
13387
|
if (needsBrowser && !process.env.XBROWSER_DAEMON_WORKER) {
|
|
13363
|
-
const { forwardExec } = await import("./daemon-client-
|
|
13388
|
+
const { forwardExec } = await import("./daemon-client-CMRAWXTN.js");
|
|
13364
13389
|
const userTimeout = typeof params.timeout === "number" && params.timeout > 0 ? params.timeout * 1e3 + 3e4 : void 0;
|
|
13365
13390
|
const result = await forwardExec(`${command}.${subCommand}`, params, sessionName, cdpEndpoint, userTimeout);
|
|
13366
13391
|
const resultData = result && typeof result === "object" && "data" in result ? result.data : void 0;
|
|
@@ -13400,7 +13425,7 @@ Run "xbrowser ${command} ${subCommand} --help" to see available parameters.`
|
|
|
13400
13425
|
const targetPage = pages[cmdTabIndex];
|
|
13401
13426
|
await targetPage.bringToFront().catch(() => {
|
|
13402
13427
|
});
|
|
13403
|
-
const { setActivePage: setActivePage2 } = await import("./browser-
|
|
13428
|
+
const { setActivePage: setActivePage2 } = await import("./browser-ZPQP6VW7.js");
|
|
13404
13429
|
setActivePage2(session, targetPage);
|
|
13405
13430
|
}
|
|
13406
13431
|
}
|
|
@@ -56,15 +56,17 @@ var SessionReplayer = class {
|
|
|
56
56
|
await this.replayTrajectory(action.trajectory);
|
|
57
57
|
}
|
|
58
58
|
await this.replayAction(action);
|
|
59
|
+
if (action.type !== "resize" && action.type !== "clipboard" && action.type !== "visibility") {
|
|
60
|
+
try {
|
|
61
|
+
await this.page.waitForLoadState("domcontentloaded", this.opts.stepTimeout);
|
|
62
|
+
} catch {
|
|
63
|
+
}
|
|
64
|
+
}
|
|
59
65
|
success++;
|
|
60
66
|
} catch (e) {
|
|
61
67
|
const err = e instanceof Error ? e : new Error(String(e));
|
|
62
68
|
this.opts.onError?.(action, err);
|
|
63
|
-
|
|
64
|
-
skipped++;
|
|
65
|
-
} else {
|
|
66
|
-
failed++;
|
|
67
|
-
}
|
|
69
|
+
failed++;
|
|
68
70
|
}
|
|
69
71
|
if (i < actions.length - 1) {
|
|
70
72
|
await new Promise((r) => setTimeout(r, this.opts.stepDelay));
|
|
@@ -78,41 +80,36 @@ var SessionReplayer = class {
|
|
|
78
80
|
const timeout = this.opts.stepTimeout;
|
|
79
81
|
switch (action.type) {
|
|
80
82
|
case "navigation":
|
|
81
|
-
await page.goto(action.url, { waitUntil: "
|
|
83
|
+
await page.goto(action.url, { waitUntil: "load", timeout });
|
|
82
84
|
break;
|
|
83
85
|
case "goto":
|
|
84
|
-
await page.goto(action.url, { waitUntil: "
|
|
86
|
+
await page.goto(action.url, { waitUntil: "load", timeout });
|
|
85
87
|
break;
|
|
86
88
|
case "click":
|
|
87
89
|
case "cdp-click": {
|
|
88
|
-
const selector = this.
|
|
89
|
-
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
90
|
+
const selector = await this.resolveAndWait(action);
|
|
90
91
|
await page.click(selector, { timeout });
|
|
91
92
|
break;
|
|
92
93
|
}
|
|
93
94
|
case "input": {
|
|
94
|
-
const selector = this.
|
|
95
|
-
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
95
|
+
const selector = await this.resolveAndWait(action);
|
|
96
96
|
await page.fill(selector, action.value ?? "", { timeout });
|
|
97
97
|
break;
|
|
98
98
|
}
|
|
99
99
|
case "cdp-fill": {
|
|
100
|
-
const selector = this.
|
|
101
|
-
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
100
|
+
const selector = await this.resolveAndWait(action);
|
|
102
101
|
await page.fill(selector, action.value ?? "", { timeout });
|
|
103
102
|
break;
|
|
104
103
|
}
|
|
105
104
|
case "change": {
|
|
106
|
-
const selector = this.
|
|
107
|
-
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
105
|
+
const selector = await this.resolveAndWait(action);
|
|
108
106
|
if (action.value) {
|
|
109
107
|
await page.selectOption(selector, action.value);
|
|
110
108
|
}
|
|
111
109
|
break;
|
|
112
110
|
}
|
|
113
111
|
case "filechooser": {
|
|
114
|
-
const selector = this.
|
|
115
|
-
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
112
|
+
const selector = await this.resolveAndWait(action);
|
|
116
113
|
const files = this.resolveFiles(action);
|
|
117
114
|
if (files.length > 0) {
|
|
118
115
|
await page.setInputFiles(selector, files);
|
|
@@ -155,11 +152,8 @@ var SessionReplayer = class {
|
|
|
155
152
|
break;
|
|
156
153
|
}
|
|
157
154
|
case "hover": {
|
|
158
|
-
const selector = this.
|
|
159
|
-
|
|
160
|
-
await page.waitForSelector(selector, { state: "visible", timeout });
|
|
161
|
-
await page.hover(selector);
|
|
162
|
-
}
|
|
155
|
+
const selector = await this.resolveAndWait(action);
|
|
156
|
+
await page.hover(selector);
|
|
163
157
|
break;
|
|
164
158
|
}
|
|
165
159
|
case "drag": {
|
|
@@ -199,10 +193,9 @@ var SessionReplayer = class {
|
|
|
199
193
|
break;
|
|
200
194
|
}
|
|
201
195
|
case "focus": {
|
|
202
|
-
const selector = this.
|
|
203
|
-
if (
|
|
204
|
-
await page.
|
|
205
|
-
await page.click(selector, { timeout });
|
|
196
|
+
const selector = await this.resolveAndWait(action);
|
|
197
|
+
if (action.focus?.focusType === "focus") {
|
|
198
|
+
await page.locator(selector).focus();
|
|
206
199
|
}
|
|
207
200
|
break;
|
|
208
201
|
}
|
|
@@ -210,10 +203,16 @@ var SessionReplayer = class {
|
|
|
210
203
|
break;
|
|
211
204
|
}
|
|
212
205
|
case "submit": {
|
|
213
|
-
const selector = this.
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
206
|
+
const selector = await this.resolveAndWait(action);
|
|
207
|
+
await page.evaluate((sel) => {
|
|
208
|
+
const form = document.querySelector(sel);
|
|
209
|
+
if (!form) return;
|
|
210
|
+
if (typeof form.requestSubmit === "function") {
|
|
211
|
+
form.requestSubmit();
|
|
212
|
+
} else {
|
|
213
|
+
form.dispatchEvent(new Event("submit", { bubbles: true, cancelable: true }));
|
|
214
|
+
}
|
|
215
|
+
}, selector);
|
|
217
216
|
break;
|
|
218
217
|
}
|
|
219
218
|
case "scroll": {
|
|
@@ -239,12 +238,10 @@ var SessionReplayer = class {
|
|
|
239
238
|
await page.mouse.move(x, y);
|
|
240
239
|
}
|
|
241
240
|
}
|
|
242
|
-
/** Resolve the best selector for an action */
|
|
241
|
+
/** Resolve the best selector for an action (primary selector only) */
|
|
243
242
|
resolveSelector(action) {
|
|
244
243
|
const el = action.element;
|
|
245
244
|
if (!el) return "";
|
|
246
|
-
if (el.textFallback) {
|
|
247
|
-
}
|
|
248
245
|
if (el.selector) {
|
|
249
246
|
return el.selector;
|
|
250
247
|
}
|
|
@@ -253,6 +250,41 @@ var SessionReplayer = class {
|
|
|
253
250
|
}
|
|
254
251
|
return "";
|
|
255
252
|
}
|
|
253
|
+
/**
|
|
254
|
+
* X2: Wait for an element using the best available selector, with
|
|
255
|
+
* confidence-based fallback support.
|
|
256
|
+
*
|
|
257
|
+
* Returns the first matching selector, or throws if none match.
|
|
258
|
+
* Fallback order:
|
|
259
|
+
* 1. Primary CSS selector (always tried first)
|
|
260
|
+
* 2. textFallback selector (for low-confidence selectors when primary fails)
|
|
261
|
+
* 3. Tag-based fallback (last resort)
|
|
262
|
+
*/
|
|
263
|
+
async resolveAndWait(action) {
|
|
264
|
+
const el = action.element;
|
|
265
|
+
if (!el) throw new Error("No element metadata");
|
|
266
|
+
const page = this.page;
|
|
267
|
+
const timeout = this.opts.stepTimeout;
|
|
268
|
+
const candidates = [];
|
|
269
|
+
if (el.selector) candidates.push(el.selector);
|
|
270
|
+
if (el.confidence === "low" && el.textFallback?.selector) {
|
|
271
|
+
if (!candidates.includes(el.textFallback.selector)) {
|
|
272
|
+
candidates.push(el.textFallback.selector);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (el.tag && !candidates.includes(el.tag)) {
|
|
276
|
+
candidates.push(el.tag);
|
|
277
|
+
}
|
|
278
|
+
if (candidates.length === 0) throw new Error("No selector available for element");
|
|
279
|
+
for (const sel of candidates) {
|
|
280
|
+
try {
|
|
281
|
+
await page.waitForSelector(sel, { state: "visible", timeout });
|
|
282
|
+
return sel;
|
|
283
|
+
} catch {
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
throw new Error(`Element not found, tried: ${candidates.join(", ")}`);
|
|
287
|
+
}
|
|
256
288
|
/** Resolve file payloads from a filechooser action */
|
|
257
289
|
resolveFiles(action) {
|
|
258
290
|
if (!action.files?.fileData) return [];
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xbrowser/cli",
|
|
3
|
-
"version": "1.9.
|
|
3
|
+
"version": "1.9.6",
|
|
4
4
|
"description": "Browser automation CLI for web scraping, headless browsing, SEO analysis, and AI agent workflows. A command-line alternative to Playwright, Puppeteer, and Selenium.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|