@jackwener/opencli 1.0.3 → 1.0.5
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/.github/workflows/build-extension.yml +21 -3
- package/.github/workflows/docs.yml +52 -0
- package/README.md +28 -28
- package/README.zh-CN.md +28 -28
- package/dist/browser/cdp.d.ts +16 -1
- package/dist/browser/cdp.js +124 -80
- package/dist/browser/daemon-client.d.ts +3 -1
- package/dist/browser/daemon-client.js +4 -0
- package/dist/browser/dom-helpers.d.ts +20 -0
- package/dist/browser/dom-helpers.js +109 -0
- package/dist/browser/mcp.d.ts +1 -0
- package/dist/browser/mcp.js +10 -5
- package/dist/browser/page.d.ts +7 -0
- package/dist/browser/page.js +37 -100
- package/dist/browser.test.js +7 -0
- package/dist/build-manifest.js +3 -1
- package/dist/build-manifest.test.js +34 -0
- package/dist/capabilityRouting.d.ts +2 -0
- package/dist/capabilityRouting.js +30 -0
- package/dist/capabilityRouting.test.d.ts +1 -0
- package/dist/capabilityRouting.test.js +42 -0
- package/dist/chaoxing.test.js +11 -4
- package/dist/cli-manifest.json +635 -1
- package/dist/cli.js +48 -8
- package/dist/clis/antigravity/serve.d.ts +14 -0
- package/dist/clis/antigravity/serve.js +263 -0
- package/dist/clis/bilibili/download.js +4 -14
- package/dist/clis/boss/resume.d.ts +1 -0
- package/dist/clis/boss/resume.js +249 -0
- package/dist/clis/hf/top.d.ts +1 -0
- package/dist/clis/hf/top.js +119 -0
- package/dist/clis/jike/comment.d.ts +1 -0
- package/dist/clis/jike/comment.js +107 -0
- package/dist/clis/jike/create.d.ts +1 -0
- package/dist/clis/jike/create.js +106 -0
- package/dist/clis/jike/feed.d.ts +1 -0
- package/dist/clis/jike/feed.js +67 -0
- package/dist/clis/jike/like.d.ts +1 -0
- package/dist/clis/jike/like.js +61 -0
- package/dist/clis/jike/notifications.d.ts +1 -0
- package/dist/clis/jike/notifications.js +169 -0
- package/dist/clis/jike/post.yaml +58 -0
- package/dist/clis/jike/repost.d.ts +1 -0
- package/dist/clis/jike/repost.js +103 -0
- package/dist/clis/jike/search.d.ts +1 -0
- package/dist/clis/jike/search.js +67 -0
- package/dist/clis/jike/shared.d.ts +19 -0
- package/dist/clis/jike/shared.js +25 -0
- package/dist/clis/jike/topic.yaml +52 -0
- package/dist/clis/jike/user.yaml +51 -0
- package/dist/clis/smzdm/search.js +28 -39
- package/dist/clis/stackoverflow/bounties.yaml +29 -0
- package/dist/clis/stackoverflow/hot.yaml +28 -0
- package/dist/clis/stackoverflow/search.yaml +32 -0
- package/dist/clis/stackoverflow/unanswered.yaml +28 -0
- package/dist/clis/twitter/download.js +6 -16
- package/dist/clis/xiaohongshu/download.js +3 -3
- package/dist/clis/zhihu/download.js +3 -3
- package/dist/doctor.d.ts +7 -0
- package/dist/doctor.js +16 -0
- package/dist/download/index.d.ts +12 -8
- package/dist/download/index.js +11 -3
- package/dist/download/index.test.d.ts +1 -0
- package/dist/download/index.test.js +14 -0
- package/dist/engine.js +5 -5
- package/dist/explore.d.ts +1 -0
- package/dist/explore.js +3 -3
- package/dist/generate.js +1 -0
- package/dist/interceptor.js +3 -2
- package/dist/output.d.ts +1 -0
- package/dist/output.js +3 -1
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.js +14 -18
- package/dist/registry.d.ts +1 -0
- package/dist/registry.js +5 -2
- package/dist/runtime.d.ts +4 -1
- package/dist/runtime.js +2 -2
- package/dist/types.d.ts +12 -0
- package/dist/verify.d.ts +6 -1
- package/dist/verify.js +54 -2
- package/docs/.vitepress/config.mts +193 -0
- package/docs/adapters/browser/apple-podcasts.md +28 -0
- package/docs/adapters/browser/bbc.md +26 -0
- package/docs/adapters/browser/bilibili.md +38 -0
- package/docs/adapters/browser/boss.md +28 -0
- package/docs/adapters/browser/coupang.md +28 -0
- package/docs/adapters/browser/ctrip.md +27 -0
- package/docs/adapters/browser/github.md +26 -0
- package/docs/adapters/browser/hackernews.md +26 -0
- package/docs/adapters/browser/linkedin.md +27 -0
- package/docs/adapters/browser/reddit.md +41 -0
- package/docs/adapters/browser/reuters.md +27 -0
- package/docs/adapters/browser/smzdm.md +27 -0
- package/docs/adapters/browser/twitter.md +47 -0
- package/docs/adapters/browser/v2ex.md +32 -0
- package/docs/adapters/browser/weibo.md +27 -0
- package/docs/adapters/browser/xiaohongshu.md +32 -0
- package/docs/adapters/browser/xiaoyuzhou.md +28 -0
- package/docs/adapters/browser/xueqiu.md +32 -0
- package/docs/adapters/browser/yahoo-finance.md +26 -0
- package/docs/adapters/browser/youtube.md +29 -0
- package/docs/adapters/browser/zhihu.md +30 -0
- package/docs/adapters/desktop/antigravity.md +46 -0
- package/docs/adapters/desktop/chatgpt.md +43 -0
- package/docs/adapters/desktop/chatwise.md +38 -0
- package/docs/adapters/desktop/codex.md +32 -0
- package/docs/adapters/desktop/cursor.md +33 -0
- package/docs/adapters/desktop/discord.md +28 -0
- package/docs/adapters/desktop/feishu.md +20 -0
- package/docs/adapters/desktop/neteasemusic.md +31 -0
- package/docs/adapters/desktop/notion.md +29 -0
- package/docs/adapters/desktop/wechat.md +28 -0
- package/docs/adapters/index.md +49 -0
- package/docs/advanced/cdp.md +103 -0
- package/docs/advanced/download.md +63 -0
- package/docs/advanced/electron.md +125 -0
- package/docs/advanced/remote-chrome.md +72 -0
- package/docs/developer/ai-workflow.md +66 -0
- package/docs/developer/architecture.md +90 -0
- package/docs/developer/contributing.md +136 -0
- package/docs/developer/testing.md +237 -0
- package/docs/developer/ts-adapter.md +87 -0
- package/docs/developer/yaml-adapter.md +108 -0
- package/docs/guide/browser-bridge.md +38 -0
- package/docs/guide/getting-started.md +56 -0
- package/docs/guide/installation.md +37 -0
- package/docs/guide/troubleshooting.md +56 -0
- package/docs/index.md +35 -0
- package/docs/zh/adapters/index.md +5 -0
- package/docs/zh/advanced/cdp.md +3 -0
- package/docs/zh/developer/contributing.md +24 -0
- package/docs/zh/guide/browser-bridge.md +25 -0
- package/docs/zh/guide/getting-started.md +40 -0
- package/docs/zh/guide/installation.md +37 -0
- package/docs/zh/index.md +29 -0
- package/extension/dist/background.js +92 -52
- package/extension/package-lock.json +1156 -0
- package/extension/src/background.test.ts +151 -0
- package/extension/src/background.ts +122 -51
- package/extension/src/protocol.ts +3 -1
- package/package.json +7 -3
- package/src/browser/cdp.ts +154 -82
- package/src/browser/daemon-client.ts +7 -1
- package/src/browser/dom-helpers.ts +116 -0
- package/src/browser/mcp.ts +14 -6
- package/src/browser/page.ts +45 -100
- package/src/browser.test.ts +10 -0
- package/src/build-manifest.test.ts +36 -0
- package/src/build-manifest.ts +2 -1
- package/src/capabilityRouting.test.ts +47 -0
- package/src/capabilityRouting.ts +28 -0
- package/src/chaoxing.test.ts +12 -4
- package/src/cli.ts +30 -8
- package/src/clis/antigravity/serve.ts +329 -0
- package/src/clis/bilibili/download.ts +4 -15
- package/src/clis/boss/resume.ts +262 -0
- package/src/clis/hf/top.ts +141 -0
- package/src/clis/jike/comment.ts +113 -0
- package/src/clis/jike/create.ts +113 -0
- package/src/clis/jike/feed.ts +74 -0
- package/src/clis/jike/like.ts +65 -0
- package/src/clis/jike/notifications.ts +185 -0
- package/src/clis/jike/post.yaml +58 -0
- package/src/clis/jike/repost.ts +114 -0
- package/src/clis/jike/search.ts +74 -0
- package/src/clis/jike/shared.ts +36 -0
- package/src/clis/jike/topic.yaml +52 -0
- package/src/clis/jike/user.yaml +51 -0
- package/src/clis/smzdm/search.ts +30 -39
- package/src/clis/stackoverflow/bounties.yaml +29 -0
- package/src/clis/stackoverflow/hot.yaml +28 -0
- package/src/clis/stackoverflow/search.yaml +32 -0
- package/src/clis/stackoverflow/unanswered.yaml +28 -0
- package/src/clis/twitter/download.ts +6 -17
- package/src/clis/xiaohongshu/download.ts +3 -3
- package/src/clis/zhihu/download.ts +3 -3
- package/src/doctor.ts +18 -2
- package/src/download/index.test.ts +16 -0
- package/src/download/index.ts +22 -4
- package/src/engine.ts +4 -4
- package/src/explore.ts +4 -4
- package/src/generate.ts +1 -0
- package/src/interceptor.ts +3 -2
- package/src/output.ts +3 -1
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.ts +14 -17
- package/src/registry.ts +6 -2
- package/src/runtime.ts +3 -2
- package/src/types.ts +9 -0
- package/src/verify.ts +64 -3
package/src/browser/cdp.ts
CHANGED
|
@@ -1,10 +1,25 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* CDP client — implements IPage by connecting directly to a Chrome/Electron CDP WebSocket.
|
|
3
|
+
*
|
|
4
|
+
* Fixes applied:
|
|
5
|
+
* - send() now has a 30s timeout guard (P0 #4)
|
|
6
|
+
* - goto() waits for Page.loadEventFired instead of hardcoded 1s sleep (P1 #3)
|
|
7
|
+
* - Implemented scroll, autoScroll, screenshot, networkRequests (P1 #2)
|
|
8
|
+
* - Shared DOM helper methods extracted to reduce duplication with Page (P1 #5)
|
|
3
9
|
*/
|
|
4
10
|
|
|
5
11
|
import { WebSocket } from 'ws';
|
|
6
12
|
import type { IPage } from '../types.js';
|
|
7
13
|
import { wrapForEval } from './utils.js';
|
|
14
|
+
import {
|
|
15
|
+
clickJs,
|
|
16
|
+
typeTextJs,
|
|
17
|
+
pressKeyJs,
|
|
18
|
+
waitForTextJs,
|
|
19
|
+
scrollJs,
|
|
20
|
+
autoScrollJs,
|
|
21
|
+
networkRequestsJs,
|
|
22
|
+
} from './dom-helpers.js';
|
|
8
23
|
|
|
9
24
|
export interface CDPTarget {
|
|
10
25
|
type?: string;
|
|
@@ -13,12 +28,15 @@ export interface CDPTarget {
|
|
|
13
28
|
webSocketDebuggerUrl?: string;
|
|
14
29
|
}
|
|
15
30
|
|
|
31
|
+
const CDP_SEND_TIMEOUT = 30_000; // 30s per command
|
|
32
|
+
|
|
16
33
|
export class CDPBridge {
|
|
17
34
|
private _ws: WebSocket | null = null;
|
|
18
35
|
private _idCounter = 0;
|
|
19
|
-
private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void }>();
|
|
36
|
+
private _pending = new Map<number, { resolve: (val: any) => void; reject: (err: Error) => void; timer: ReturnType<typeof setTimeout> }>();
|
|
37
|
+
private _eventListeners = new Map<string, Set<(params: any) => void>>();
|
|
20
38
|
|
|
21
|
-
async connect(opts?: { timeout?: number }): Promise<IPage> {
|
|
39
|
+
async connect(opts?: { timeout?: number; workspace?: string }): Promise<IPage> {
|
|
22
40
|
const endpoint = process.env.OPENCLI_CDP_ENDPOINT;
|
|
23
41
|
if (!endpoint) throw new Error('OPENCLI_CDP_ENDPOINT is not set');
|
|
24
42
|
|
|
@@ -53,16 +71,25 @@ export class CDPBridge {
|
|
|
53
71
|
ws.on('message', (data) => {
|
|
54
72
|
try {
|
|
55
73
|
const msg = JSON.parse(data.toString());
|
|
74
|
+
// Handle command responses
|
|
56
75
|
if (msg.id && this._pending.has(msg.id)) {
|
|
57
|
-
const
|
|
76
|
+
const entry = this._pending.get(msg.id)!;
|
|
77
|
+
clearTimeout(entry.timer);
|
|
58
78
|
this._pending.delete(msg.id);
|
|
59
79
|
if (msg.error) {
|
|
60
|
-
reject(new Error(msg.error.message));
|
|
80
|
+
entry.reject(new Error(msg.error.message));
|
|
61
81
|
} else {
|
|
62
|
-
resolve(msg.result);
|
|
82
|
+
entry.resolve(msg.result);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
// Handle CDP events
|
|
86
|
+
if (msg.method) {
|
|
87
|
+
const listeners = this._eventListeners.get(msg.method);
|
|
88
|
+
if (listeners) {
|
|
89
|
+
for (const fn of listeners) fn(msg.params);
|
|
63
90
|
}
|
|
64
91
|
}
|
|
65
|
-
} catch
|
|
92
|
+
} catch {
|
|
66
93
|
// ignore parsing errors
|
|
67
94
|
}
|
|
68
95
|
});
|
|
@@ -75,29 +102,68 @@ export class CDPBridge {
|
|
|
75
102
|
this._ws = null;
|
|
76
103
|
}
|
|
77
104
|
for (const p of this._pending.values()) {
|
|
105
|
+
clearTimeout(p.timer);
|
|
78
106
|
p.reject(new Error('CDP connection closed'));
|
|
79
107
|
}
|
|
80
108
|
this._pending.clear();
|
|
109
|
+
this._eventListeners.clear();
|
|
81
110
|
}
|
|
82
111
|
|
|
83
|
-
|
|
112
|
+
/** Send a CDP command with timeout guard (P0 fix #4) */
|
|
113
|
+
async send(method: string, params: any = {}, timeoutMs: number = CDP_SEND_TIMEOUT): Promise<any> {
|
|
84
114
|
if (!this._ws || this._ws.readyState !== WebSocket.OPEN) {
|
|
85
115
|
throw new Error('CDP connection is not open');
|
|
86
116
|
}
|
|
87
117
|
const id = ++this._idCounter;
|
|
88
118
|
return new Promise((resolve, reject) => {
|
|
89
|
-
|
|
119
|
+
const timer = setTimeout(() => {
|
|
120
|
+
this._pending.delete(id);
|
|
121
|
+
reject(new Error(`CDP command '${method}' timed out after ${timeoutMs / 1000}s`));
|
|
122
|
+
}, timeoutMs);
|
|
123
|
+
this._pending.set(id, { resolve, reject, timer });
|
|
90
124
|
this._ws!.send(JSON.stringify({ id, method, params }));
|
|
91
125
|
});
|
|
92
126
|
}
|
|
127
|
+
|
|
128
|
+
/** Listen for a CDP event */
|
|
129
|
+
on(event: string, handler: (params: any) => void): void {
|
|
130
|
+
let set = this._eventListeners.get(event);
|
|
131
|
+
if (!set) { set = new Set(); this._eventListeners.set(event, set); }
|
|
132
|
+
set.add(handler);
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Remove a CDP event listener */
|
|
136
|
+
off(event: string, handler: (params: any) => void): void {
|
|
137
|
+
this._eventListeners.get(event)?.delete(handler);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Wait for a CDP event to fire (one-shot) */
|
|
141
|
+
waitForEvent(event: string, timeoutMs: number = 15_000): Promise<any> {
|
|
142
|
+
return new Promise((resolve, reject) => {
|
|
143
|
+
const timer = setTimeout(() => {
|
|
144
|
+
this.off(event, handler);
|
|
145
|
+
reject(new Error(`Timed out waiting for CDP event '${event}'`));
|
|
146
|
+
}, timeoutMs);
|
|
147
|
+
const handler = (params: any) => {
|
|
148
|
+
clearTimeout(timer);
|
|
149
|
+
this.off(event, handler);
|
|
150
|
+
resolve(params);
|
|
151
|
+
};
|
|
152
|
+
this.on(event, handler);
|
|
153
|
+
});
|
|
154
|
+
}
|
|
93
155
|
}
|
|
94
156
|
|
|
95
157
|
class CDPPage implements IPage {
|
|
96
158
|
constructor(private bridge: CDPBridge) {}
|
|
97
159
|
|
|
160
|
+
/** Navigate with proper load event waiting (P1 fix #3) */
|
|
98
161
|
async goto(url: string): Promise<void> {
|
|
162
|
+
await this.bridge.send('Page.enable');
|
|
163
|
+
const loadPromise = this.bridge.waitForEvent('Page.loadEventFired', 30_000)
|
|
164
|
+
.catch(() => {}); // Don't fail if event times out
|
|
99
165
|
await this.bridge.send('Page.navigate', { url });
|
|
100
|
-
await
|
|
166
|
+
await loadPromise;
|
|
101
167
|
}
|
|
102
168
|
|
|
103
169
|
async evaluate(js: string): Promise<any> {
|
|
@@ -113,53 +179,33 @@ class CDPPage implements IPage {
|
|
|
113
179
|
return result.result?.value;
|
|
114
180
|
}
|
|
115
181
|
|
|
116
|
-
async
|
|
117
|
-
|
|
182
|
+
async getCookies(opts: { domain?: string; url?: string } = {}): Promise<any[]> {
|
|
183
|
+
const result = await this.bridge.send('Network.getCookies', opts.url ? { urls: [opts.url] } : {});
|
|
184
|
+
const cookies = Array.isArray(result?.cookies) ? result.cookies : [];
|
|
185
|
+
return opts.domain
|
|
186
|
+
? cookies.filter((cookie: any) => typeof cookie.domain === 'string' && cookie.domain.includes(opts.domain!))
|
|
187
|
+
: cookies;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
async snapshot(_opts?: any): Promise<any> {
|
|
191
|
+
// CDP doesn't have a built-in accessibility tree equivalent without additional setup
|
|
192
|
+
return '(snapshot not available in CDP mode)';
|
|
118
193
|
}
|
|
194
|
+
|
|
195
|
+
// ── Shared DOM operations (P1 fix #5 — using dom-helpers.ts) ──
|
|
196
|
+
|
|
119
197
|
async click(ref: string): Promise<void> {
|
|
120
|
-
|
|
121
|
-
const code = `
|
|
122
|
-
(() => {
|
|
123
|
-
const ref = ${safeRef};
|
|
124
|
-
const el = document.querySelector('[data-ref="' + ref + '"]')
|
|
125
|
-
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
|
|
126
|
-
if (!el) throw new Error('Element not found: ' + ref);
|
|
127
|
-
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
128
|
-
el.click();
|
|
129
|
-
return 'clicked';
|
|
130
|
-
})()
|
|
131
|
-
`;
|
|
132
|
-
await this.evaluate(code);
|
|
198
|
+
await this.evaluate(clickJs(ref));
|
|
133
199
|
}
|
|
200
|
+
|
|
134
201
|
async typeText(ref: string, text: string): Promise<void> {
|
|
135
|
-
|
|
136
|
-
const safeText = JSON.stringify(text);
|
|
137
|
-
const code = `
|
|
138
|
-
(() => {
|
|
139
|
-
const ref = ${safeRef};
|
|
140
|
-
const el = document.querySelector('[data-ref="' + ref + '"]')
|
|
141
|
-
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
|
|
142
|
-
if (!el) throw new Error('Element not found: ' + ref);
|
|
143
|
-
el.focus();
|
|
144
|
-
el.value = ${safeText};
|
|
145
|
-
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
146
|
-
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
147
|
-
return 'typed';
|
|
148
|
-
})()
|
|
149
|
-
`;
|
|
150
|
-
await this.evaluate(code);
|
|
202
|
+
await this.evaluate(typeTextJs(ref, text));
|
|
151
203
|
}
|
|
204
|
+
|
|
152
205
|
async pressKey(key: string): Promise<void> {
|
|
153
|
-
|
|
154
|
-
(() => {
|
|
155
|
-
const el = document.activeElement || document.body;
|
|
156
|
-
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
|
|
157
|
-
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
|
|
158
|
-
return 'pressed';
|
|
159
|
-
})()
|
|
160
|
-
`;
|
|
161
|
-
await this.evaluate(code);
|
|
206
|
+
await this.evaluate(pressKeyJs(key));
|
|
162
207
|
}
|
|
208
|
+
|
|
163
209
|
async wait(options: any): Promise<void> {
|
|
164
210
|
if (typeof options === 'number') {
|
|
165
211
|
await new Promise(resolve => setTimeout(resolve, options * 1000));
|
|
@@ -171,54 +217,80 @@ class CDPPage implements IPage {
|
|
|
171
217
|
}
|
|
172
218
|
if (options.text) {
|
|
173
219
|
const timeout = (options.timeout ?? 30) * 1000;
|
|
174
|
-
|
|
175
|
-
new Promise((resolve, reject) => {
|
|
176
|
-
const deadline = Date.now() + ${timeout};
|
|
177
|
-
const check = () => {
|
|
178
|
-
if (document.body.innerText.includes(${JSON.stringify(options.text)})) return resolve('found');
|
|
179
|
-
if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(options.text)}));
|
|
180
|
-
setTimeout(check, 200);
|
|
181
|
-
};
|
|
182
|
-
check();
|
|
183
|
-
})
|
|
184
|
-
`;
|
|
185
|
-
await this.evaluate(code);
|
|
220
|
+
await this.evaluate(waitForTextJs(options.text, timeout));
|
|
186
221
|
}
|
|
187
222
|
}
|
|
188
|
-
|
|
189
|
-
|
|
223
|
+
|
|
224
|
+
// ── Implemented methods (P1 fix #2) ──
|
|
225
|
+
|
|
226
|
+
async scroll(direction: string = 'down', amount: number = 500): Promise<void> {
|
|
227
|
+
await this.evaluate(scrollJs(direction, amount));
|
|
190
228
|
}
|
|
191
|
-
|
|
192
|
-
|
|
229
|
+
|
|
230
|
+
async autoScroll(options?: { times?: number; delayMs?: number }): Promise<void> {
|
|
231
|
+
const times = options?.times ?? 3;
|
|
232
|
+
const delayMs = options?.delayMs ?? 2000;
|
|
233
|
+
await this.evaluate(autoScrollJs(times, delayMs));
|
|
193
234
|
}
|
|
194
|
-
|
|
195
|
-
|
|
235
|
+
|
|
236
|
+
async screenshot(options: any = {}): Promise<string> {
|
|
237
|
+
const result = await this.bridge.send('Page.captureScreenshot', {
|
|
238
|
+
format: options.format ?? 'png',
|
|
239
|
+
quality: options.format === 'jpeg' ? (options.quality ?? 80) : undefined,
|
|
240
|
+
captureBeyondViewport: options.fullPage ?? false,
|
|
241
|
+
});
|
|
242
|
+
const base64 = result.data;
|
|
243
|
+
if (options.path) {
|
|
244
|
+
const fs = await import('node:fs');
|
|
245
|
+
const path = await import('node:path');
|
|
246
|
+
const dir = path.dirname(options.path);
|
|
247
|
+
await fs.promises.mkdir(dir, { recursive: true });
|
|
248
|
+
await fs.promises.writeFile(options.path, Buffer.from(base64, 'base64'));
|
|
249
|
+
}
|
|
250
|
+
return base64;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async networkRequests(includeStatic: boolean = false): Promise<any> {
|
|
254
|
+
return this.evaluate(networkRequestsJs(includeStatic));
|
|
196
255
|
}
|
|
197
|
-
|
|
198
|
-
|
|
256
|
+
|
|
257
|
+
async tabs(): Promise<any> {
|
|
258
|
+
return [];
|
|
199
259
|
}
|
|
200
|
-
|
|
201
|
-
|
|
260
|
+
|
|
261
|
+
async closeTab(_index?: number): Promise<void> {
|
|
262
|
+
// Not supported in direct CDP mode
|
|
202
263
|
}
|
|
203
|
-
|
|
204
|
-
|
|
264
|
+
|
|
265
|
+
async newTab(): Promise<void> {
|
|
266
|
+
await this.bridge.send('Target.createTarget', { url: 'about:blank' });
|
|
205
267
|
}
|
|
206
|
-
|
|
207
|
-
|
|
268
|
+
|
|
269
|
+
async selectTab(_index: number): Promise<void> {
|
|
270
|
+
// Not supported in direct CDP mode
|
|
208
271
|
}
|
|
209
|
-
|
|
210
|
-
|
|
272
|
+
|
|
273
|
+
async consoleMessages(_level?: string): Promise<any> {
|
|
274
|
+
return [];
|
|
211
275
|
}
|
|
276
|
+
|
|
212
277
|
async installInterceptor(pattern: string): Promise<void> {
|
|
213
|
-
|
|
278
|
+
const { generateInterceptorJs } = await import('../interceptor.js');
|
|
279
|
+
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
|
280
|
+
arrayName: '__opencli_xhr',
|
|
281
|
+
patchGuard: '__opencli_interceptor_patched',
|
|
282
|
+
}));
|
|
214
283
|
}
|
|
284
|
+
|
|
215
285
|
async getInterceptedRequests(): Promise<any[]> {
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
throw new Error('Method not implemented.');
|
|
286
|
+
const { generateReadInterceptedJs } = await import('../interceptor.js');
|
|
287
|
+
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
288
|
+
return (result as any[]) || [];
|
|
220
289
|
}
|
|
221
290
|
}
|
|
291
|
+
|
|
292
|
+
// ── CDP target selection (unchanged) ──
|
|
293
|
+
|
|
222
294
|
function selectCDPTarget(targets: CDPTarget[]): CDPTarget | undefined {
|
|
223
295
|
const preferredPattern = compilePreferredPattern(process.env.OPENCLI_CDP_TARGET);
|
|
224
296
|
|
|
@@ -15,9 +15,10 @@ function generateId(): string {
|
|
|
15
15
|
|
|
16
16
|
export interface DaemonCommand {
|
|
17
17
|
id: string;
|
|
18
|
-
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
|
|
18
|
+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window' | 'sessions';
|
|
19
19
|
tabId?: number;
|
|
20
20
|
code?: string;
|
|
21
|
+
workspace?: string;
|
|
21
22
|
url?: string;
|
|
22
23
|
op?: string;
|
|
23
24
|
index?: number;
|
|
@@ -111,3 +112,8 @@ export async function sendCommand(
|
|
|
111
112
|
// Unreachable — the loop always returns or throws
|
|
112
113
|
throw new Error('sendCommand: max retries exhausted');
|
|
113
114
|
}
|
|
115
|
+
|
|
116
|
+
export async function listSessions(): Promise<any[]> {
|
|
117
|
+
const result = await sendCommand('sessions');
|
|
118
|
+
return Array.isArray(result) ? result : [];
|
|
119
|
+
}
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared DOM operation JS generators.
|
|
3
|
+
*
|
|
4
|
+
* Used by both Page (daemon mode) and CDPPage (direct CDP mode)
|
|
5
|
+
* to eliminate code duplication for click, type, press, wait, scroll, etc.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** Generate JS to click an element by ref */
|
|
9
|
+
export function clickJs(ref: string): string {
|
|
10
|
+
const safeRef = JSON.stringify(ref);
|
|
11
|
+
return `
|
|
12
|
+
(() => {
|
|
13
|
+
const ref = ${safeRef};
|
|
14
|
+
const el = document.querySelector('[data-ref="' + ref + '"]')
|
|
15
|
+
|| document.querySelectorAll('a, button, input, [role="button"], [tabindex]')[parseInt(ref, 10) || 0];
|
|
16
|
+
if (!el) throw new Error('Element not found: ' + ref);
|
|
17
|
+
el.scrollIntoView({ behavior: 'instant', block: 'center' });
|
|
18
|
+
el.click();
|
|
19
|
+
return 'clicked';
|
|
20
|
+
})()
|
|
21
|
+
`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Generate JS to type text into an element by ref */
|
|
25
|
+
export function typeTextJs(ref: string, text: string): string {
|
|
26
|
+
const safeRef = JSON.stringify(ref);
|
|
27
|
+
const safeText = JSON.stringify(text);
|
|
28
|
+
return `
|
|
29
|
+
(() => {
|
|
30
|
+
const ref = ${safeRef};
|
|
31
|
+
const el = document.querySelector('[data-ref="' + ref + '"]')
|
|
32
|
+
|| document.querySelectorAll('input, textarea, [contenteditable]')[parseInt(ref, 10) || 0];
|
|
33
|
+
if (!el) throw new Error('Element not found: ' + ref);
|
|
34
|
+
el.focus();
|
|
35
|
+
el.value = ${safeText};
|
|
36
|
+
el.dispatchEvent(new Event('input', { bubbles: true }));
|
|
37
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
38
|
+
return 'typed';
|
|
39
|
+
})()
|
|
40
|
+
`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Generate JS to press a keyboard key */
|
|
44
|
+
export function pressKeyJs(key: string): string {
|
|
45
|
+
return `
|
|
46
|
+
(() => {
|
|
47
|
+
const el = document.activeElement || document.body;
|
|
48
|
+
el.dispatchEvent(new KeyboardEvent('keydown', { key: ${JSON.stringify(key)}, bubbles: true }));
|
|
49
|
+
el.dispatchEvent(new KeyboardEvent('keyup', { key: ${JSON.stringify(key)}, bubbles: true }));
|
|
50
|
+
return 'pressed';
|
|
51
|
+
})()
|
|
52
|
+
`;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** Generate JS to wait for text to appear in the page */
|
|
56
|
+
export function waitForTextJs(text: string, timeoutMs: number): string {
|
|
57
|
+
return `
|
|
58
|
+
new Promise((resolve, reject) => {
|
|
59
|
+
const deadline = Date.now() + ${timeoutMs};
|
|
60
|
+
const check = () => {
|
|
61
|
+
if (document.body.innerText.includes(${JSON.stringify(text)})) return resolve('found');
|
|
62
|
+
if (Date.now() > deadline) return reject(new Error('Text not found: ' + ${JSON.stringify(text)}));
|
|
63
|
+
setTimeout(check, 200);
|
|
64
|
+
};
|
|
65
|
+
check();
|
|
66
|
+
})
|
|
67
|
+
`;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Generate JS for scroll */
|
|
71
|
+
export function scrollJs(direction: string, amount: number): string {
|
|
72
|
+
const dx = direction === 'left' ? -amount : direction === 'right' ? amount : 0;
|
|
73
|
+
const dy = direction === 'up' ? -amount : direction === 'down' ? amount : 0;
|
|
74
|
+
return `window.scrollBy(${dx}, ${dy})`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Generate JS for auto-scroll with lazy-load detection */
|
|
78
|
+
export function autoScrollJs(times: number, delayMs: number): string {
|
|
79
|
+
return `
|
|
80
|
+
(async () => {
|
|
81
|
+
for (let i = 0; i < ${times}; i++) {
|
|
82
|
+
const lastHeight = document.body.scrollHeight;
|
|
83
|
+
window.scrollTo(0, lastHeight);
|
|
84
|
+
await new Promise(resolve => {
|
|
85
|
+
let timeoutId;
|
|
86
|
+
const observer = new MutationObserver(() => {
|
|
87
|
+
if (document.body.scrollHeight > lastHeight) {
|
|
88
|
+
clearTimeout(timeoutId);
|
|
89
|
+
observer.disconnect();
|
|
90
|
+
setTimeout(resolve, 100);
|
|
91
|
+
}
|
|
92
|
+
});
|
|
93
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
94
|
+
timeoutId = setTimeout(() => { observer.disconnect(); resolve(null); }, ${delayMs});
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
})()
|
|
98
|
+
`;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Generate JS to read performance resource entries as network requests */
|
|
102
|
+
export function networkRequestsJs(includeStatic: boolean): string {
|
|
103
|
+
return `
|
|
104
|
+
(() => {
|
|
105
|
+
const entries = performance.getEntriesByType('resource');
|
|
106
|
+
return entries
|
|
107
|
+
${includeStatic ? '' : '.filter(e => !["img", "font", "css", "script"].some(t => e.initiatorType === t))'}
|
|
108
|
+
.map(e => ({
|
|
109
|
+
url: e.name,
|
|
110
|
+
type: e.initiatorType,
|
|
111
|
+
duration: Math.round(e.duration),
|
|
112
|
+
size: e.transferSize || 0,
|
|
113
|
+
}));
|
|
114
|
+
})()
|
|
115
|
+
`;
|
|
116
|
+
}
|
package/src/browser/mcp.ts
CHANGED
|
@@ -26,7 +26,7 @@ export class BrowserBridge {
|
|
|
26
26
|
return this._state;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async connect(opts: { timeout?: number } = {}): Promise<IPage> {
|
|
29
|
+
async connect(opts: { timeout?: number; workspace?: string } = {}): Promise<IPage> {
|
|
30
30
|
if (this._state === 'connected' && this._page) return this._page;
|
|
31
31
|
if (this._state === 'connecting') throw new Error('Already connecting');
|
|
32
32
|
if (this._state === 'closing') throw new Error('Session is closing');
|
|
@@ -35,8 +35,8 @@ export class BrowserBridge {
|
|
|
35
35
|
this._state = 'connecting';
|
|
36
36
|
|
|
37
37
|
try {
|
|
38
|
-
await this._ensureDaemon();
|
|
39
|
-
this._page = new Page();
|
|
38
|
+
await this._ensureDaemon(opts.timeout);
|
|
39
|
+
this._page = new Page(opts.workspace);
|
|
40
40
|
this._state = 'connected';
|
|
41
41
|
return this._page;
|
|
42
42
|
} catch (err) {
|
|
@@ -54,8 +54,16 @@ export class BrowserBridge {
|
|
|
54
54
|
this._state = 'closed';
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
-
private async _ensureDaemon(): Promise<void> {
|
|
58
|
-
|
|
57
|
+
private async _ensureDaemon(timeoutSeconds?: number): Promise<void> {
|
|
58
|
+
const timeoutMs = Math.max(1, timeoutSeconds ?? Math.ceil(DAEMON_SPAWN_TIMEOUT / 1000)) * 1000;
|
|
59
|
+
|
|
60
|
+
if (await isExtensionConnected()) return;
|
|
61
|
+
if (await isDaemonRunning()) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'Daemon is running but the Browser Extension is not connected.\n' +
|
|
64
|
+
'Please install and enable the opencli Browser Bridge extension in Chrome.',
|
|
65
|
+
);
|
|
66
|
+
}
|
|
59
67
|
|
|
60
68
|
// Find daemon relative to this file — works for both:
|
|
61
69
|
// npx tsx src/main.ts → src/browser/mcp.ts → src/daemon.ts
|
|
@@ -85,7 +93,7 @@ export class BrowserBridge {
|
|
|
85
93
|
this._daemonProc.unref();
|
|
86
94
|
|
|
87
95
|
// Wait for daemon to be ready AND extension to connect
|
|
88
|
-
const deadline = Date.now() +
|
|
96
|
+
const deadline = Date.now() + timeoutMs;
|
|
89
97
|
while (Date.now() < deadline) {
|
|
90
98
|
await new Promise(resolve => setTimeout(resolve, 300));
|
|
91
99
|
if (await isExtensionConnected()) return;
|