@jackwener/opencli 1.7.2 → 1.7.4
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 +18 -15
- package/README.zh-CN.md +31 -15
- package/cli-manifest.json +1265 -101
- package/clis/barchart/flow.js +1 -1
- package/clis/barchart/greeks.js +2 -2
- package/clis/barchart/options.js +2 -2
- package/clis/barchart/quote.js +1 -1
- package/clis/bilibili/favorite.js +18 -13
- package/clis/bilibili/feed.js +202 -48
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -2
- package/clis/chatgpt/image.js +97 -0
- package/clis/chatgpt/utils.js +297 -0
- package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
- package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
- package/clis/discord-app/delete.js +114 -0
- package/clis/douban/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +279 -10
- package/clis/douban/utils.test.js +296 -1
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/ke/chengjiao.js +77 -0
- package/clis/ke/ershoufang.js +100 -0
- package/clis/ke/utils.js +104 -0
- package/clis/ke/xiaoqu.js +77 -0
- package/clis/ke/zufang.js +94 -0
- package/clis/maimai/search-talents.js +172 -0
- package/clis/mubu/doc.js +40 -0
- package/clis/mubu/docs.js +43 -0
- package/clis/mubu/notes.js +244 -0
- package/clis/mubu/recent.js +27 -0
- package/clis/mubu/search.js +62 -0
- package/clis/mubu/utils.js +304 -0
- package/clis/reuters/search.js +1 -1
- package/clis/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +20 -8
- package/clis/xiaohongshu/comments.test.js +69 -12
- package/clis/xiaohongshu/creator-note-detail.js +2 -0
- package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
- package/clis/xiaohongshu/creator-notes-summary.js +4 -0
- package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
- package/clis/xiaohongshu/creator-notes.js +1 -0
- package/clis/xiaohongshu/creator-profile.js +1 -0
- package/clis/xiaohongshu/creator-stats.js +1 -0
- package/clis/xiaohongshu/download.js +18 -7
- package/clis/xiaohongshu/download.test.js +42 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +17 -10
- package/clis/xiaohongshu/note.test.js +66 -11
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/daemon-client.d.ts +2 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -35
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +7 -0
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +82 -10
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +13 -0
- package/dist/src/execution.js +36 -9
- package/dist/src/execution.test.js +23 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -8
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +3 -1
- package/dist/src/update-check.d.ts +14 -0
- package/dist/src/update-check.js +48 -3
- package/dist/src/update-check.test.d.ts +1 -0
- package/dist/src/update-check.test.js +31 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +35 -8
|
@@ -18,6 +18,15 @@ export declare abstract class BasePage implements IPage {
|
|
|
18
18
|
settleMs?: number;
|
|
19
19
|
}): Promise<void>;
|
|
20
20
|
abstract evaluate(js: string): Promise<unknown>;
|
|
21
|
+
/**
|
|
22
|
+
* Safely evaluate JS with pre-serialized arguments.
|
|
23
|
+
* Each key in `args` becomes a `const` declaration with JSON-serialized value,
|
|
24
|
+
* prepended to the JS code. Prevents injection by design.
|
|
25
|
+
*
|
|
26
|
+
* Usage:
|
|
27
|
+
* page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
|
|
28
|
+
*/
|
|
29
|
+
evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown>;
|
|
21
30
|
abstract getCookies(opts?: {
|
|
22
31
|
domain?: string;
|
|
23
32
|
url?: string;
|
|
@@ -8,16 +8,43 @@
|
|
|
8
8
|
* Subclasses implement the transport-specific methods: goto, evaluate,
|
|
9
9
|
* getCookies, screenshot, tabs, etc.
|
|
10
10
|
*/
|
|
11
|
-
import { generateSnapshotJs,
|
|
12
|
-
import {
|
|
11
|
+
import { generateSnapshotJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
|
+
import { pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
13
|
+
import { resolveTargetJs, clickResolvedJs, typeResolvedJs, scrollResolvedJs } from './target-resolver.js';
|
|
14
|
+
import { TargetError } from './target-errors.js';
|
|
13
15
|
import { formatSnapshot } from '../snapshotFormatter.js';
|
|
14
16
|
export class BasePage {
|
|
15
17
|
_lastUrl = null;
|
|
16
18
|
/** Cached previous snapshot hashes for incremental diff marking */
|
|
17
19
|
_prevSnapshotHashes = null;
|
|
20
|
+
/**
|
|
21
|
+
* Safely evaluate JS with pre-serialized arguments.
|
|
22
|
+
* Each key in `args` becomes a `const` declaration with JSON-serialized value,
|
|
23
|
+
* prepended to the JS code. Prevents injection by design.
|
|
24
|
+
*
|
|
25
|
+
* Usage:
|
|
26
|
+
* page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
|
|
27
|
+
*/
|
|
28
|
+
async evaluateWithArgs(js, args) {
|
|
29
|
+
const declarations = Object.entries(args)
|
|
30
|
+
.map(([key, value]) => {
|
|
31
|
+
if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
|
|
32
|
+
throw new Error(`evaluateWithArgs: invalid key "${key}"`);
|
|
33
|
+
}
|
|
34
|
+
return `const ${key} = ${JSON.stringify(value)};`;
|
|
35
|
+
})
|
|
36
|
+
.join('\n');
|
|
37
|
+
return this.evaluate(`${declarations}\n${js}`);
|
|
38
|
+
}
|
|
18
39
|
// ── Shared DOM helper implementations ──
|
|
19
40
|
async click(ref) {
|
|
20
|
-
|
|
41
|
+
// Phase 1: Resolve target with fingerprint verification
|
|
42
|
+
const resolution = await this.evaluate(resolveTargetJs(ref));
|
|
43
|
+
if (!resolution.ok) {
|
|
44
|
+
throw new TargetError(resolution);
|
|
45
|
+
}
|
|
46
|
+
// Phase 2: Execute click on resolved element
|
|
47
|
+
const result = await this.evaluate(clickResolvedJs());
|
|
21
48
|
// Backwards compat: old format returned 'clicked' string
|
|
22
49
|
if (typeof result === 'string' || result == null)
|
|
23
50
|
return;
|
|
@@ -37,13 +64,25 @@ export class BasePage {
|
|
|
37
64
|
return false;
|
|
38
65
|
}
|
|
39
66
|
async typeText(ref, text) {
|
|
40
|
-
|
|
67
|
+
// Phase 1: Resolve target with fingerprint verification
|
|
68
|
+
const resolution = await this.evaluate(resolveTargetJs(ref));
|
|
69
|
+
if (!resolution.ok) {
|
|
70
|
+
throw new TargetError(resolution);
|
|
71
|
+
}
|
|
72
|
+
// Phase 2: Execute type on resolved element
|
|
73
|
+
await this.evaluate(typeResolvedJs(text));
|
|
41
74
|
}
|
|
42
75
|
async pressKey(key) {
|
|
43
76
|
await this.evaluate(pressKeyJs(key));
|
|
44
77
|
}
|
|
45
78
|
async scrollTo(ref) {
|
|
46
|
-
|
|
79
|
+
// Phase 1: Resolve target with fingerprint verification
|
|
80
|
+
const resolution = await this.evaluate(resolveTargetJs(ref));
|
|
81
|
+
if (!resolution.ok) {
|
|
82
|
+
throw new TargetError(resolution);
|
|
83
|
+
}
|
|
84
|
+
// Phase 2: Scroll to resolved element
|
|
85
|
+
return this.evaluate(scrollResolvedJs());
|
|
47
86
|
}
|
|
48
87
|
async getFormState() {
|
|
49
88
|
return (await this.evaluate(getFormStateJs()));
|
|
@@ -18,6 +18,8 @@ export declare class BrowserBridge implements IBrowserFactory {
|
|
|
18
18
|
}): Promise<IPage>;
|
|
19
19
|
close(): Promise<void>;
|
|
20
20
|
private _ensureDaemon;
|
|
21
|
+
/** Poll until daemon is fully stopped (port released). */
|
|
22
|
+
private _waitForDaemonStop;
|
|
21
23
|
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
|
|
22
24
|
private _pollUntilReady;
|
|
23
25
|
}
|
|
@@ -6,9 +6,10 @@ import { fileURLToPath } from 'node:url';
|
|
|
6
6
|
import * as path from 'node:path';
|
|
7
7
|
import * as fs from 'node:fs';
|
|
8
8
|
import { Page } from './page.js';
|
|
9
|
-
import { getDaemonHealth } from './daemon-client.js';
|
|
9
|
+
import { getDaemonHealth, requestDaemonShutdown } from './daemon-client.js';
|
|
10
10
|
import { DEFAULT_DAEMON_PORT } from '../constants.js';
|
|
11
11
|
import { BrowserConnectError } from '../errors.js';
|
|
12
|
+
import { PKG_VERSION } from '../version.js';
|
|
12
13
|
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
|
13
14
|
/**
|
|
14
15
|
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
|
@@ -57,18 +58,42 @@ export class BrowserBridge {
|
|
|
57
58
|
// Fast path: everything ready
|
|
58
59
|
if (health.state === 'ready')
|
|
59
60
|
return;
|
|
60
|
-
// Daemon running but no extension
|
|
61
|
+
// Daemon running but no extension
|
|
61
62
|
if (health.state === 'no-extension') {
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
63
|
+
// Detect stale daemon: version mismatch OR missing daemonVersion (pre-version daemon)
|
|
64
|
+
const daemonVersion = health.status?.daemonVersion;
|
|
65
|
+
const isStale = !daemonVersion || daemonVersion !== PKG_VERSION;
|
|
66
|
+
if (isStale) {
|
|
67
|
+
// Stale daemon — restart it so extension gets a fresh WebSocket endpoint
|
|
68
|
+
const reason = daemonVersion
|
|
69
|
+
? `v${daemonVersion} ≠ v${PKG_VERSION}`
|
|
70
|
+
: `pre-version daemon, CLI is v${PKG_VERSION}`;
|
|
71
|
+
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
72
|
+
process.stderr.write(`⚠️ Stale daemon detected (${reason}). Restarting...\n`);
|
|
73
|
+
}
|
|
74
|
+
const shutdownAccepted = await requestDaemonShutdown();
|
|
75
|
+
const portReleased = shutdownAccepted && await this._waitForDaemonStop(3000);
|
|
76
|
+
if (!portReleased) {
|
|
77
|
+
// Stale daemon replacement failed — don't blindly spawn on an occupied port
|
|
78
|
+
throw new BrowserConnectError('Stale daemon could not be replaced', `A stale daemon (${reason}) is running but did not shut down.\n` +
|
|
79
|
+
' Run manually: opencli daemon stop && opencli doctor', 'daemon-not-running');
|
|
80
|
+
}
|
|
81
|
+
// Port released — fall through to spawn a fresh daemon
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
// Same version — wait for extension to connect
|
|
85
|
+
if (process.env.OPENCLI_VERBOSE || process.stderr.isTTY) {
|
|
86
|
+
process.stderr.write('⏳ Waiting for Chrome/Chromium extension to connect...\n');
|
|
87
|
+
process.stderr.write(' Make sure Chrome or Chromium is open and the OpenCLI extension is enabled.\n');
|
|
88
|
+
}
|
|
89
|
+
if (await this._pollUntilReady(timeoutMs))
|
|
90
|
+
return;
|
|
91
|
+
throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
|
|
92
|
+
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
|
|
93
|
+
'If not installed:\n' +
|
|
94
|
+
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
95
|
+
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
65
96
|
}
|
|
66
|
-
if (await this._pollUntilReady(timeoutMs))
|
|
67
|
-
return;
|
|
68
|
-
throw new BrowserConnectError('Browser Bridge extension not connected', 'Install the Browser Bridge:\n' +
|
|
69
|
-
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
70
|
-
' 2. In Chrome or Chromium, open chrome://extensions → Developer Mode → Load unpacked\n' +
|
|
71
|
-
' Then run: opencli doctor', 'extension-not-connected');
|
|
72
97
|
}
|
|
73
98
|
// No daemon — spawn one
|
|
74
99
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -94,13 +119,25 @@ export class BrowserBridge {
|
|
|
94
119
|
return;
|
|
95
120
|
const finalHealth = await getDaemonHealth();
|
|
96
121
|
if (finalHealth.state === 'no-extension') {
|
|
97
|
-
throw new BrowserConnectError('Browser Bridge extension not connected', '
|
|
122
|
+
throw new BrowserConnectError('Browser Bridge extension not connected', 'Make sure Chrome/Chromium is open and the extension is enabled.\n' +
|
|
123
|
+
'If the extension is installed, try: opencli daemon stop && opencli doctor\n' +
|
|
124
|
+
'If not installed:\n' +
|
|
98
125
|
' 1. Download: https://github.com/jackwener/opencli/releases\n' +
|
|
99
|
-
' 2.
|
|
100
|
-
' Then run: opencli doctor', 'extension-not-connected');
|
|
126
|
+
' 2. Open chrome://extensions → Developer Mode → Load unpacked', 'extension-not-connected');
|
|
101
127
|
}
|
|
102
128
|
throw new BrowserConnectError('Failed to start opencli daemon', `Try running manually:\n node ${daemonPath}\nMake sure port ${DEFAULT_DAEMON_PORT} is available.`, 'daemon-not-running');
|
|
103
129
|
}
|
|
130
|
+
/** Poll until daemon is fully stopped (port released). */
|
|
131
|
+
async _waitForDaemonStop(timeoutMs) {
|
|
132
|
+
const deadline = Date.now() + timeoutMs;
|
|
133
|
+
while (Date.now() < deadline) {
|
|
134
|
+
await new Promise(resolve => setTimeout(resolve, 200));
|
|
135
|
+
const h = await getDaemonHealth();
|
|
136
|
+
if (h.state === 'stopped')
|
|
137
|
+
return true;
|
|
138
|
+
}
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
104
141
|
/** Poll getDaemonHealth() until state is 'ready' or deadline is reached. */
|
|
105
142
|
async _pollUntilReady(timeoutMs) {
|
|
106
143
|
const deadline = Date.now() + timeoutMs;
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -40,7 +40,11 @@ export class CDPBridge {
|
|
|
40
40
|
return new Promise((resolve, reject) => {
|
|
41
41
|
const ws = new WebSocket(wsUrl);
|
|
42
42
|
const timeoutMs = (opts?.timeout ?? 10) * 1000;
|
|
43
|
-
const timeout = setTimeout(() =>
|
|
43
|
+
const timeout = setTimeout(() => {
|
|
44
|
+
this._ws = null;
|
|
45
|
+
ws.close();
|
|
46
|
+
reject(new Error('CDP connect timeout'));
|
|
47
|
+
}, timeoutMs);
|
|
44
48
|
ws.on('open', async () => {
|
|
45
49
|
clearTimeout(timeout);
|
|
46
50
|
this._ws = ws;
|
|
@@ -48,7 +52,11 @@ export class CDPBridge {
|
|
|
48
52
|
await this.send('Page.enable');
|
|
49
53
|
await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
|
|
50
54
|
}
|
|
51
|
-
catch {
|
|
55
|
+
catch (err) {
|
|
56
|
+
ws.close();
|
|
57
|
+
reject(err instanceof Error ? err : new Error(String(err)));
|
|
58
|
+
return;
|
|
59
|
+
}
|
|
52
60
|
resolve(new CDPPage(this));
|
|
53
61
|
});
|
|
54
62
|
ws.on('error', (err) => {
|
|
@@ -247,6 +255,7 @@ class CDPPage extends BasePage {
|
|
|
247
255
|
});
|
|
248
256
|
this._networkCapturing = true;
|
|
249
257
|
}
|
|
258
|
+
return true;
|
|
250
259
|
}
|
|
251
260
|
async readNetworkCapture() {
|
|
252
261
|
// Await all in-flight body fetches so entries have responsePreview populated
|
|
@@ -47,8 +47,10 @@ export interface DaemonStatus {
|
|
|
47
47
|
ok: boolean;
|
|
48
48
|
pid: number;
|
|
49
49
|
uptime: number;
|
|
50
|
+
daemonVersion?: string;
|
|
50
51
|
extensionConnected: boolean;
|
|
51
52
|
extensionVersion?: string;
|
|
53
|
+
extensionCompatRange?: string;
|
|
52
54
|
pending: number;
|
|
53
55
|
memoryMB: number;
|
|
54
56
|
port: number;
|
|
@@ -575,6 +575,7 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
575
575
|
const lines = [];
|
|
576
576
|
const hiddenInteractives = [];
|
|
577
577
|
const currentHashes = [];
|
|
578
|
+
const refIdentity = {};
|
|
578
579
|
let iframeCount = 0;
|
|
579
580
|
|
|
580
581
|
function walk(el, depth, parentPropagatingRect) {
|
|
@@ -709,11 +710,20 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
709
710
|
// Scroll marker
|
|
710
711
|
if (isScrollable && !interactive) line += '|scroll|';
|
|
711
712
|
|
|
712
|
-
// Interactive index + data-ref
|
|
713
|
+
// Interactive index + data-ref + fingerprint
|
|
713
714
|
if (interactive) {
|
|
714
715
|
interactiveIndex++;
|
|
715
716
|
if (ANNOTATE_REFS) el.setAttribute('data-opencli-ref', '' + interactiveIndex);
|
|
716
717
|
line += isScrollable ? '|scroll[' + interactiveIndex + ']|' : '[' + interactiveIndex + ']';
|
|
718
|
+
// Store fingerprint for stale-ref detection
|
|
719
|
+
refIdentity['' + interactiveIndex] = {
|
|
720
|
+
tag: tag,
|
|
721
|
+
role: el.getAttribute('role') || '',
|
|
722
|
+
text: (el.textContent || '').trim().slice(0, 30),
|
|
723
|
+
ariaLabel: el.getAttribute('aria-label') || '',
|
|
724
|
+
id: el.id || '',
|
|
725
|
+
testId: el.getAttribute('data-testid') || el.getAttribute('data-test') || '',
|
|
726
|
+
};
|
|
717
727
|
}
|
|
718
728
|
|
|
719
729
|
// Tag + attributes
|
|
@@ -797,6 +807,8 @@ export function generateSnapshotJs(opts = {}) {
|
|
|
797
807
|
|
|
798
808
|
// Store hashes on window for next diff snapshot
|
|
799
809
|
try { window.__opencli_prev_hashes = JSON.stringify(currentHashes); } catch {}
|
|
810
|
+
// Store ref identity map for stale-ref detection by target resolver
|
|
811
|
+
try { window.__opencli_ref_identity = refIdentity; } catch {}
|
|
800
812
|
|
|
801
813
|
return lines.join('\\n');
|
|
802
814
|
})()
|
|
@@ -18,6 +18,8 @@ export declare class Page extends BasePage {
|
|
|
18
18
|
constructor(workspace?: string);
|
|
19
19
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
20
20
|
private _page;
|
|
21
|
+
private _networkCaptureUnsupported;
|
|
22
|
+
private _networkCaptureWarned;
|
|
21
23
|
/** Helper: spread workspace into command params */
|
|
22
24
|
private _wsOpt;
|
|
23
25
|
/** Helper: spread workspace + page identity into command params */
|
|
@@ -30,6 +32,7 @@ export declare class Page extends BasePage {
|
|
|
30
32
|
getActivePage(): string | undefined;
|
|
31
33
|
/** @deprecated Use getActivePage() instead */
|
|
32
34
|
getActiveTabId(): number | undefined;
|
|
35
|
+
private _markUnsupportedNetworkCapture;
|
|
33
36
|
evaluate(js: string): Promise<unknown>;
|
|
34
37
|
getCookies(opts?: {
|
|
35
38
|
domain?: string;
|
|
@@ -43,7 +46,7 @@ export declare class Page extends BasePage {
|
|
|
43
46
|
* Capture a screenshot via CDP Page.captureScreenshot.
|
|
44
47
|
*/
|
|
45
48
|
screenshot(options?: ScreenshotOptions): Promise<string>;
|
|
46
|
-
startNetworkCapture(pattern?: string): Promise<
|
|
49
|
+
startNetworkCapture(pattern?: string): Promise<boolean>;
|
|
47
50
|
readNetworkCapture(): Promise<unknown[]>;
|
|
48
51
|
/**
|
|
49
52
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
package/dist/src/browser/page.js
CHANGED
|
@@ -15,6 +15,13 @@ import { generateStealthJs } from './stealth.js';
|
|
|
15
15
|
import { waitForDomStableJs } from './dom-helpers.js';
|
|
16
16
|
import { BasePage } from './base-page.js';
|
|
17
17
|
import { classifyBrowserError } from './errors.js';
|
|
18
|
+
import { log } from '../logger.js';
|
|
19
|
+
function isUnsupportedNetworkCaptureError(err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
const normalized = message.toLowerCase();
|
|
22
|
+
return (normalized.includes('unknown action') && normalized.includes('network-capture'))
|
|
23
|
+
|| (normalized.includes('network capture') && normalized.includes('not supported'));
|
|
24
|
+
}
|
|
18
25
|
/**
|
|
19
26
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
20
27
|
*/
|
|
@@ -26,6 +33,8 @@ export class Page extends BasePage {
|
|
|
26
33
|
}
|
|
27
34
|
/** Active page identity (targetId), set after navigate and used in all subsequent commands */
|
|
28
35
|
_page;
|
|
36
|
+
_networkCaptureUnsupported = false;
|
|
37
|
+
_networkCaptureWarned = false;
|
|
29
38
|
/** Helper: spread workspace into command params */
|
|
30
39
|
_wsOpt() {
|
|
31
40
|
return { workspace: this.workspace };
|
|
@@ -97,6 +106,14 @@ export class Page extends BasePage {
|
|
|
97
106
|
getActiveTabId() {
|
|
98
107
|
return undefined;
|
|
99
108
|
}
|
|
109
|
+
_markUnsupportedNetworkCapture() {
|
|
110
|
+
this._networkCaptureUnsupported = true;
|
|
111
|
+
if (this._networkCaptureWarned)
|
|
112
|
+
return;
|
|
113
|
+
this._networkCaptureWarned = true;
|
|
114
|
+
log.warn('Browser Bridge extension does not support network capture; continuing without it. ' +
|
|
115
|
+
'Explore output may miss API endpoints until you reload or reinstall the extension.');
|
|
116
|
+
}
|
|
100
117
|
async evaluate(js) {
|
|
101
118
|
const code = wrapForEval(js);
|
|
102
119
|
try {
|
|
@@ -125,6 +142,8 @@ export class Page extends BasePage {
|
|
|
125
142
|
finally {
|
|
126
143
|
this._page = undefined;
|
|
127
144
|
this._lastUrl = null;
|
|
145
|
+
this._networkCaptureUnsupported = false;
|
|
146
|
+
this._networkCaptureWarned = false;
|
|
128
147
|
}
|
|
129
148
|
}
|
|
130
149
|
async tabs() {
|
|
@@ -152,16 +171,37 @@ export class Page extends BasePage {
|
|
|
152
171
|
return base64;
|
|
153
172
|
}
|
|
154
173
|
async startNetworkCapture(pattern = '') {
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
174
|
+
if (this._networkCaptureUnsupported)
|
|
175
|
+
return false;
|
|
176
|
+
try {
|
|
177
|
+
await sendCommand('network-capture-start', {
|
|
178
|
+
pattern,
|
|
179
|
+
...this._cmdOpts(),
|
|
180
|
+
});
|
|
181
|
+
return true;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
if (!isUnsupportedNetworkCaptureError(err))
|
|
185
|
+
throw err;
|
|
186
|
+
this._markUnsupportedNetworkCapture();
|
|
187
|
+
return false;
|
|
188
|
+
}
|
|
159
189
|
}
|
|
160
190
|
async readNetworkCapture() {
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
191
|
+
if (this._networkCaptureUnsupported)
|
|
192
|
+
return [];
|
|
193
|
+
try {
|
|
194
|
+
const result = await sendCommand('network-capture-read', {
|
|
195
|
+
...this._cmdOpts(),
|
|
196
|
+
});
|
|
197
|
+
return Array.isArray(result) ? result : [];
|
|
198
|
+
}
|
|
199
|
+
catch (err) {
|
|
200
|
+
if (!isUnsupportedNetworkCaptureError(err))
|
|
201
|
+
throw err;
|
|
202
|
+
this._markUnsupportedNetworkCapture();
|
|
203
|
+
return [];
|
|
204
|
+
}
|
|
165
205
|
}
|
|
166
206
|
/**
|
|
167
207
|
* Set local file paths on a file input element via CDP DOM.setFileInputFiles.
|
|
@@ -1,14 +1,26 @@
|
|
|
1
1
|
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
|
2
|
-
const { sendCommandMock } = vi.hoisted(() => ({
|
|
2
|
+
const { sendCommandMock, sendCommandFullMock } = vi.hoisted(() => ({
|
|
3
3
|
sendCommandMock: vi.fn(),
|
|
4
|
+
sendCommandFullMock: vi.fn(),
|
|
5
|
+
}));
|
|
6
|
+
const { warnMock } = vi.hoisted(() => ({
|
|
7
|
+
warnMock: vi.fn(),
|
|
4
8
|
}));
|
|
5
9
|
vi.mock('./daemon-client.js', () => ({
|
|
6
10
|
sendCommand: sendCommandMock,
|
|
11
|
+
sendCommandFull: sendCommandFullMock,
|
|
12
|
+
}));
|
|
13
|
+
vi.mock('../logger.js', () => ({
|
|
14
|
+
log: {
|
|
15
|
+
warn: warnMock,
|
|
16
|
+
},
|
|
7
17
|
}));
|
|
8
18
|
import { Page } from './page.js';
|
|
9
19
|
describe('Page.getCurrentUrl', () => {
|
|
10
20
|
beforeEach(() => {
|
|
11
21
|
sendCommandMock.mockReset();
|
|
22
|
+
sendCommandFullMock.mockReset();
|
|
23
|
+
warnMock.mockReset();
|
|
12
24
|
});
|
|
13
25
|
it('reads the real browser URL when no local navigation cache exists', async () => {
|
|
14
26
|
sendCommandMock.mockResolvedValueOnce('https://notebooklm.google.com/notebook/nb-live');
|
|
@@ -31,6 +43,8 @@ describe('Page.getCurrentUrl', () => {
|
|
|
31
43
|
describe('Page.evaluate', () => {
|
|
32
44
|
beforeEach(() => {
|
|
33
45
|
sendCommandMock.mockReset();
|
|
46
|
+
sendCommandFullMock.mockReset();
|
|
47
|
+
warnMock.mockReset();
|
|
34
48
|
});
|
|
35
49
|
it('retries once when the inspected target navigated during exec', async () => {
|
|
36
50
|
sendCommandMock
|
|
@@ -42,3 +56,49 @@ describe('Page.evaluate', () => {
|
|
|
42
56
|
expect(sendCommandMock).toHaveBeenCalledTimes(2);
|
|
43
57
|
});
|
|
44
58
|
});
|
|
59
|
+
describe('Page network capture compatibility', () => {
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
sendCommandMock.mockReset();
|
|
62
|
+
sendCommandFullMock.mockReset();
|
|
63
|
+
warnMock.mockReset();
|
|
64
|
+
});
|
|
65
|
+
it('treats unknown network-capture-start as unsupported and memoizes it', async () => {
|
|
66
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'));
|
|
67
|
+
const page = new Page('site:notebooklm');
|
|
68
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
69
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
70
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
71
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
72
|
+
expect(warnMock).toHaveBeenCalledWith(expect.stringContaining('does not support network capture'));
|
|
73
|
+
expect(sendCommandMock).toHaveBeenCalledWith('network-capture-start', expect.objectContaining({
|
|
74
|
+
workspace: 'site:notebooklm',
|
|
75
|
+
}));
|
|
76
|
+
});
|
|
77
|
+
it('returns an empty capture when network-capture-read is unsupported', async () => {
|
|
78
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
|
|
79
|
+
const page = new Page('site:notebooklm');
|
|
80
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
81
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
82
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
83
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
84
|
+
expect(sendCommandMock).toHaveBeenCalledWith('network-capture-read', expect.objectContaining({
|
|
85
|
+
workspace: 'site:notebooklm',
|
|
86
|
+
}));
|
|
87
|
+
});
|
|
88
|
+
it('rethrows unrelated network capture failures', async () => {
|
|
89
|
+
sendCommandMock.mockRejectedValueOnce(new Error('Extension disconnected'));
|
|
90
|
+
const page = new Page('site:notebooklm');
|
|
91
|
+
await expect(page.startNetworkCapture()).rejects.toThrow('Extension disconnected');
|
|
92
|
+
expect(sendCommandMock).toHaveBeenCalledTimes(1);
|
|
93
|
+
expect(warnMock).not.toHaveBeenCalled();
|
|
94
|
+
});
|
|
95
|
+
it('warns only once even if both start and read hit the compatibility fallback', async () => {
|
|
96
|
+
sendCommandMock
|
|
97
|
+
.mockRejectedValueOnce(new Error('Unknown action: network-capture-start'))
|
|
98
|
+
.mockRejectedValueOnce(new Error('Unknown action: network-capture-read'));
|
|
99
|
+
const page = new Page('site:notebooklm');
|
|
100
|
+
await expect(page.startNetworkCapture()).resolves.toBe(false);
|
|
101
|
+
await expect(page.readNetworkCapture()).resolves.toEqual([]);
|
|
102
|
+
expect(warnMock).toHaveBeenCalledTimes(1);
|
|
103
|
+
});
|
|
104
|
+
});
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the target resolution system.
|
|
3
|
+
*
|
|
4
|
+
* Every browser action (click, type, select, get) that targets a DOM element
|
|
5
|
+
* goes through the unified resolver. When resolution fails, one of these
|
|
6
|
+
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
|
+
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*/
|
|
9
|
+
export type TargetErrorCode = 'not_found' | 'ambiguous' | 'stale_ref';
|
|
10
|
+
export interface TargetErrorInfo {
|
|
11
|
+
code: TargetErrorCode;
|
|
12
|
+
message: string;
|
|
13
|
+
hint: string;
|
|
14
|
+
candidates?: string[];
|
|
15
|
+
}
|
|
16
|
+
export declare class TargetError extends Error {
|
|
17
|
+
readonly code: TargetErrorCode;
|
|
18
|
+
readonly hint: string;
|
|
19
|
+
readonly candidates?: string[];
|
|
20
|
+
constructor(info: TargetErrorInfo);
|
|
21
|
+
/** Serialize for structured output to AI agents */
|
|
22
|
+
toJSON(): TargetErrorInfo;
|
|
23
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Structured error types for the target resolution system.
|
|
3
|
+
*
|
|
4
|
+
* Every browser action (click, type, select, get) that targets a DOM element
|
|
5
|
+
* goes through the unified resolver. When resolution fails, one of these
|
|
6
|
+
* structured errors is thrown so that AI agents and adapter authors get
|
|
7
|
+
* actionable diagnostics instead of a generic "Element not found".
|
|
8
|
+
*/
|
|
9
|
+
export class TargetError extends Error {
|
|
10
|
+
code;
|
|
11
|
+
hint;
|
|
12
|
+
candidates;
|
|
13
|
+
constructor(info) {
|
|
14
|
+
super(info.message);
|
|
15
|
+
this.name = 'TargetError';
|
|
16
|
+
this.code = info.code;
|
|
17
|
+
this.hint = info.hint;
|
|
18
|
+
this.candidates = info.candidates;
|
|
19
|
+
}
|
|
20
|
+
/** Serialize for structured output to AI agents */
|
|
21
|
+
toJSON() {
|
|
22
|
+
return {
|
|
23
|
+
code: this.code,
|
|
24
|
+
message: this.message,
|
|
25
|
+
hint: this.hint,
|
|
26
|
+
...(this.candidates && { candidates: this.candidates }),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { TargetError } from './target-errors.js';
|
|
3
|
+
describe('TargetError', () => {
|
|
4
|
+
it('creates not_found error with code and hint', () => {
|
|
5
|
+
const err = new TargetError({
|
|
6
|
+
code: 'not_found',
|
|
7
|
+
message: 'ref=99 not found in DOM',
|
|
8
|
+
hint: 'Re-run `opencli browser state` to get a fresh snapshot.',
|
|
9
|
+
});
|
|
10
|
+
expect(err).toBeInstanceOf(Error);
|
|
11
|
+
expect(err.name).toBe('TargetError');
|
|
12
|
+
expect(err.code).toBe('not_found');
|
|
13
|
+
expect(err.message).toBe('ref=99 not found in DOM');
|
|
14
|
+
expect(err.hint).toContain('fresh snapshot');
|
|
15
|
+
expect(err.candidates).toBeUndefined();
|
|
16
|
+
});
|
|
17
|
+
it('creates ambiguous error with candidates', () => {
|
|
18
|
+
const err = new TargetError({
|
|
19
|
+
code: 'ambiguous',
|
|
20
|
+
message: 'CSS selector ".btn" matched 3 elements',
|
|
21
|
+
hint: 'Use a more specific selector.',
|
|
22
|
+
candidates: ['<button> "Login"', '<button> "Sign Up"', '<button> "Cancel"'],
|
|
23
|
+
});
|
|
24
|
+
expect(err.code).toBe('ambiguous');
|
|
25
|
+
expect(err.candidates).toHaveLength(3);
|
|
26
|
+
expect(err.candidates[0]).toContain('Login');
|
|
27
|
+
});
|
|
28
|
+
it('creates stale_ref error', () => {
|
|
29
|
+
const err = new TargetError({
|
|
30
|
+
code: 'stale_ref',
|
|
31
|
+
message: 'ref=12 was <button>"Login" but now points to <div>"Header"',
|
|
32
|
+
hint: 'Re-run `opencli browser state` to refresh.',
|
|
33
|
+
});
|
|
34
|
+
expect(err.code).toBe('stale_ref');
|
|
35
|
+
expect(err.message).toContain('was <button>');
|
|
36
|
+
});
|
|
37
|
+
it('serializes to JSON for structured output', () => {
|
|
38
|
+
const err = new TargetError({
|
|
39
|
+
code: 'ambiguous',
|
|
40
|
+
message: 'matched 3',
|
|
41
|
+
hint: 'be specific',
|
|
42
|
+
candidates: ['a', 'b'],
|
|
43
|
+
});
|
|
44
|
+
const json = err.toJSON();
|
|
45
|
+
expect(json).toEqual({
|
|
46
|
+
code: 'ambiguous',
|
|
47
|
+
message: 'matched 3',
|
|
48
|
+
hint: 'be specific',
|
|
49
|
+
candidates: ['a', 'b'],
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
it('omits candidates from JSON when not present', () => {
|
|
53
|
+
const err = new TargetError({
|
|
54
|
+
code: 'not_found',
|
|
55
|
+
message: 'gone',
|
|
56
|
+
hint: 'refresh',
|
|
57
|
+
});
|
|
58
|
+
const json = err.toJSON();
|
|
59
|
+
expect(json).not.toHaveProperty('candidates');
|
|
60
|
+
});
|
|
61
|
+
});
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Unified target resolver for browser actions.
|
|
3
|
+
*
|
|
4
|
+
* Replaces the ad-hoc 4-strategy fallback in dom-helpers.ts with a
|
|
5
|
+
* principled resolution pipeline:
|
|
6
|
+
*
|
|
7
|
+
* 1. Input classification: numeric → ref path, CSS-like → CSS path
|
|
8
|
+
* 2. Ref path: lookup by data-opencli-ref, then verify fingerprint
|
|
9
|
+
* 3. CSS path: querySelectorAll + uniqueness check
|
|
10
|
+
* 4. Structured errors: stale_ref / ambiguous / not_found
|
|
11
|
+
*
|
|
12
|
+
* All JS is generated as strings for page.evaluate() — runs in the browser.
|
|
13
|
+
*/
|
|
14
|
+
/**
|
|
15
|
+
* Generate JS that resolves a target to a single DOM element.
|
|
16
|
+
*
|
|
17
|
+
* Returns a JS expression that evaluates to:
|
|
18
|
+
* { ok: true, el: Element } — success (el is assigned to `__resolved`)
|
|
19
|
+
* { ok: false, code, message, hint, candidates } — structured error
|
|
20
|
+
*
|
|
21
|
+
* The resolved element is stored in `__resolved` for the caller to use.
|
|
22
|
+
*/
|
|
23
|
+
export declare function resolveTargetJs(ref: string): string;
|
|
24
|
+
/**
|
|
25
|
+
* Generate JS for click that uses the unified resolver.
|
|
26
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
27
|
+
*/
|
|
28
|
+
export declare function clickResolvedJs(): string;
|
|
29
|
+
/**
|
|
30
|
+
* Generate JS for type that uses the unified resolver.
|
|
31
|
+
*/
|
|
32
|
+
export declare function typeResolvedJs(text: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Generate JS for scrollTo that uses the unified resolver.
|
|
35
|
+
* Assumes resolveTargetJs has been called and __resolved is set.
|
|
36
|
+
*/
|
|
37
|
+
export declare function scrollResolvedJs(): string;
|
|
38
|
+
/**
|
|
39
|
+
* Generate JS to get text content of resolved element.
|
|
40
|
+
*/
|
|
41
|
+
export declare function getTextResolvedJs(): string;
|
|
42
|
+
/**
|
|
43
|
+
* Generate JS to get value of resolved input/textarea element.
|
|
44
|
+
*/
|
|
45
|
+
export declare function getValueResolvedJs(): string;
|
|
46
|
+
/**
|
|
47
|
+
* Generate JS to get all attributes of resolved element.
|
|
48
|
+
*/
|
|
49
|
+
export declare function getAttributesResolvedJs(): string;
|
|
50
|
+
/**
|
|
51
|
+
* Generate JS to select an option on a resolved <select> element.
|
|
52
|
+
*/
|
|
53
|
+
export declare function selectResolvedJs(option: string): string;
|
|
54
|
+
/**
|
|
55
|
+
* Generate JS to check if resolved element is an autocomplete/combobox field.
|
|
56
|
+
*/
|
|
57
|
+
export declare function isAutocompleteResolvedJs(): string;
|