@jackwener/opencli 1.0.0 → 1.0.1
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 +20 -1
- package/README.zh-CN.md +20 -1
- package/dist/browser/daemon-client.d.ts +1 -1
- package/dist/browser/index.d.ts +1 -2
- package/dist/browser/index.js +1 -5
- package/dist/browser/mcp.d.ts +5 -8
- package/dist/browser/mcp.js +9 -10
- package/dist/browser/page.d.ts +8 -1
- package/dist/browser/page.js +23 -17
- package/dist/browser.test.js +6 -6
- package/dist/cli-manifest.json +394 -14
- package/dist/clis/apple-podcasts/episodes.d.ts +1 -0
- package/dist/clis/apple-podcasts/episodes.js +28 -0
- package/dist/clis/apple-podcasts/search.d.ts +1 -0
- package/dist/clis/apple-podcasts/search.js +29 -0
- package/dist/clis/apple-podcasts/top.d.ts +1 -0
- package/dist/clis/apple-podcasts/top.js +34 -0
- package/dist/clis/apple-podcasts/utils.d.ts +11 -0
- package/dist/clis/apple-podcasts/utils.js +30 -0
- package/dist/clis/apple-podcasts/utils.test.d.ts +1 -0
- package/dist/clis/apple-podcasts/utils.test.js +57 -0
- package/dist/clis/chatwise/history.js +18 -1
- package/dist/clis/discord-app/channels.js +33 -21
- package/dist/clis/twitter/accept.d.ts +1 -0
- package/dist/clis/twitter/accept.js +202 -0
- package/dist/clis/twitter/followers.js +30 -22
- package/dist/clis/twitter/following.js +19 -14
- package/dist/clis/twitter/notifications.js +29 -22
- package/dist/clis/twitter/reply-dm.d.ts +1 -0
- package/dist/clis/twitter/reply-dm.js +181 -0
- package/dist/clis/twitter/search.js +50 -12
- package/dist/clis/weread/book.d.ts +1 -0
- package/dist/clis/weread/book.js +26 -0
- package/dist/clis/weread/highlights.d.ts +1 -0
- package/dist/clis/weread/highlights.js +23 -0
- package/dist/clis/weread/notebooks.d.ts +1 -0
- package/dist/clis/weread/notebooks.js +21 -0
- package/dist/clis/weread/notes.d.ts +1 -0
- package/dist/clis/weread/notes.js +29 -0
- package/dist/clis/weread/ranking.d.ts +1 -0
- package/dist/clis/weread/ranking.js +28 -0
- package/dist/clis/weread/search.d.ts +1 -0
- package/dist/clis/weread/search.js +25 -0
- package/dist/clis/weread/shelf.d.ts +1 -0
- package/dist/clis/weread/shelf.js +24 -0
- package/dist/clis/weread/utils.d.ts +20 -0
- package/dist/clis/weread/utils.js +72 -0
- package/dist/clis/weread/utils.test.d.ts +1 -0
- package/dist/clis/weread/utils.test.js +85 -0
- package/dist/daemon.js +2 -2
- package/dist/doctor.d.ts +0 -21
- package/dist/doctor.js +2 -24
- package/dist/main.js +6 -16
- package/dist/runtime.d.ts +1 -4
- package/dist/runtime.js +1 -4
- package/dist/setup.js +2 -2
- package/extension/dist/background.js +484 -0
- package/extension/manifest.json +1 -1
- package/extension/package.json +1 -1
- package/extension/src/background.ts +99 -22
- package/extension/src/protocol.ts +1 -1
- package/package.json +1 -1
- package/src/browser/daemon-client.ts +1 -1
- package/src/browser/index.ts +1 -6
- package/src/browser/mcp.ts +14 -15
- package/src/browser/page.ts +23 -17
- package/src/browser.test.ts +6 -6
- package/src/clis/apple-podcasts/episodes.ts +28 -0
- package/src/clis/apple-podcasts/search.ts +29 -0
- package/src/clis/apple-podcasts/top.ts +34 -0
- package/src/clis/apple-podcasts/utils.test.ts +72 -0
- package/src/clis/apple-podcasts/utils.ts +37 -0
- package/src/clis/chatwise/history.ts +15 -1
- package/src/clis/discord-app/channels.ts +33 -21
- package/src/clis/twitter/accept.ts +213 -0
- package/src/clis/twitter/followers.ts +36 -29
- package/src/clis/twitter/following.ts +25 -20
- package/src/clis/twitter/notifications.ts +34 -27
- package/src/clis/twitter/reply-dm.ts +193 -0
- package/src/clis/twitter/search.ts +53 -13
- package/src/clis/weread/book.ts +28 -0
- package/src/clis/weread/highlights.ts +25 -0
- package/src/clis/weread/notebooks.ts +23 -0
- package/src/clis/weread/notes.ts +31 -0
- package/src/clis/weread/ranking.ts +29 -0
- package/src/clis/weread/search.ts +26 -0
- package/src/clis/weread/shelf.ts +26 -0
- package/src/clis/weread/utils.test.ts +104 -0
- package/src/clis/weread/utils.ts +74 -0
- package/src/daemon.ts +2 -2
- package/src/doctor.ts +2 -19
- package/src/main.ts +5 -11
- package/src/runtime.ts +2 -6
- package/src/setup.ts +2 -2
- package/tests/e2e/public-commands.test.ts +68 -1
- package/dist/clis/grok/debug.d.ts +0 -1
- package/dist/clis/grok/debug.js +0 -45
- package/src/clis/grok/debug.ts +0 -49
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import type { Command, Result } from './protocol';
|
|
9
9
|
import { DAEMON_WS_URL, WS_RECONNECT_BASE_DELAY, WS_RECONNECT_MAX_DELAY } from './protocol';
|
|
10
|
-
import * as
|
|
10
|
+
import * as executor from './cdp';
|
|
11
11
|
|
|
12
12
|
let ws: WebSocket | null = null;
|
|
13
13
|
let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
|
|
@@ -85,6 +85,66 @@ function scheduleReconnect(): void {
|
|
|
85
85
|
}, delay);
|
|
86
86
|
}
|
|
87
87
|
|
|
88
|
+
// ─── Automation window isolation ─────────────────────────────────────
|
|
89
|
+
// All opencli operations happen in a dedicated Chrome window so the
|
|
90
|
+
// user's active browsing session is never touched.
|
|
91
|
+
// The window auto-closes after 30s of idle (no commands).
|
|
92
|
+
|
|
93
|
+
let automationWindowId: number | null = null;
|
|
94
|
+
let windowIdleTimer: ReturnType<typeof setTimeout> | null = null;
|
|
95
|
+
const WINDOW_IDLE_TIMEOUT = 30000; // 30s
|
|
96
|
+
|
|
97
|
+
function resetWindowIdleTimer(): void {
|
|
98
|
+
if (windowIdleTimer) clearTimeout(windowIdleTimer);
|
|
99
|
+
windowIdleTimer = setTimeout(async () => {
|
|
100
|
+
if (automationWindowId !== null) {
|
|
101
|
+
try {
|
|
102
|
+
await chrome.windows.remove(automationWindowId);
|
|
103
|
+
console.log(`[opencli] Automation window ${automationWindowId} closed (idle timeout)`);
|
|
104
|
+
} catch {
|
|
105
|
+
// Already gone
|
|
106
|
+
}
|
|
107
|
+
automationWindowId = null;
|
|
108
|
+
}
|
|
109
|
+
windowIdleTimer = null;
|
|
110
|
+
}, WINDOW_IDLE_TIMEOUT);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Get or create the dedicated automation window. */
|
|
114
|
+
async function getAutomationWindow(): Promise<number> {
|
|
115
|
+
// Check if our window is still alive
|
|
116
|
+
if (automationWindowId !== null) {
|
|
117
|
+
try {
|
|
118
|
+
await chrome.windows.get(automationWindowId);
|
|
119
|
+
return automationWindowId;
|
|
120
|
+
} catch {
|
|
121
|
+
// Window was closed by user
|
|
122
|
+
automationWindowId = null;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Create a new window with about:blank (not chrome://newtab which blocks scripting)
|
|
127
|
+
const win = await chrome.windows.create({
|
|
128
|
+
url: 'about:blank',
|
|
129
|
+
focused: false,
|
|
130
|
+
width: 1280,
|
|
131
|
+
height: 900,
|
|
132
|
+
type: 'normal',
|
|
133
|
+
});
|
|
134
|
+
automationWindowId = win.id!;
|
|
135
|
+
console.log(`[opencli] Created automation window ${automationWindowId}`);
|
|
136
|
+
return automationWindowId;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Clean up when the automation window is closed
|
|
140
|
+
chrome.windows.onRemoved.addListener((windowId) => {
|
|
141
|
+
if (windowId === automationWindowId) {
|
|
142
|
+
console.log('[opencli] Automation window closed');
|
|
143
|
+
automationWindowId = null;
|
|
144
|
+
if (windowIdleTimer) { clearTimeout(windowIdleTimer); windowIdleTimer = null; }
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
88
148
|
// ─── Lifecycle events ────────────────────────────────────────────────
|
|
89
149
|
|
|
90
150
|
let initialized = false;
|
|
@@ -93,7 +153,7 @@ function initialize(): void {
|
|
|
93
153
|
if (initialized) return;
|
|
94
154
|
initialized = true;
|
|
95
155
|
chrome.alarms.create('keepalive', { periodInMinutes: 0.4 }); // ~24 seconds
|
|
96
|
-
|
|
156
|
+
executor.registerListeners();
|
|
97
157
|
connect();
|
|
98
158
|
console.log('[opencli] Browser Bridge extension initialized');
|
|
99
159
|
}
|
|
@@ -113,6 +173,8 @@ chrome.alarms.onAlarm.addListener((alarm) => {
|
|
|
113
173
|
// ─── Command dispatcher ─────────────────────────────────────────────
|
|
114
174
|
|
|
115
175
|
async function handleCommand(cmd: Command): Promise<Result> {
|
|
176
|
+
// Reset idle timer on every command (window stays alive while active)
|
|
177
|
+
resetWindowIdleTimer();
|
|
116
178
|
try {
|
|
117
179
|
switch (cmd.action) {
|
|
118
180
|
case 'exec':
|
|
@@ -125,6 +187,8 @@ async function handleCommand(cmd: Command): Promise<Result> {
|
|
|
125
187
|
return await handleCookies(cmd);
|
|
126
188
|
case 'screenshot':
|
|
127
189
|
return await handleScreenshot(cmd);
|
|
190
|
+
case 'close-window':
|
|
191
|
+
return await handleCloseWindow(cmd);
|
|
128
192
|
default:
|
|
129
193
|
return { id: cmd.id, ok: false, error: `Unknown action: ${cmd.action}` };
|
|
130
194
|
}
|
|
@@ -145,27 +209,28 @@ function isWebUrl(url?: string): boolean {
|
|
|
145
209
|
return !url.startsWith('chrome://') && !url.startsWith('chrome-extension://');
|
|
146
210
|
}
|
|
147
211
|
|
|
148
|
-
/**
|
|
212
|
+
/**
|
|
213
|
+
* Resolve target tab in the automation window.
|
|
214
|
+
* If explicit tabId is given, use that directly.
|
|
215
|
+
* Otherwise, find or create a tab in the dedicated automation window.
|
|
216
|
+
*/
|
|
149
217
|
async function resolveTabId(tabId?: number): Promise<number> {
|
|
150
218
|
if (tabId !== undefined) return tabId;
|
|
151
219
|
|
|
152
|
-
//
|
|
153
|
-
const
|
|
154
|
-
if (activeTab?.id && isWebUrl(activeTab.url)) {
|
|
155
|
-
return activeTab.id;
|
|
156
|
-
}
|
|
220
|
+
// Get (or create) the automation window
|
|
221
|
+
const windowId = await getAutomationWindow();
|
|
157
222
|
|
|
158
|
-
//
|
|
159
|
-
const
|
|
160
|
-
const webTab =
|
|
161
|
-
if (webTab?.id)
|
|
162
|
-
await chrome.tabs.update(webTab.id, { active: true });
|
|
163
|
-
return webTab.id;
|
|
164
|
-
}
|
|
223
|
+
// Find the active tab in our automation window
|
|
224
|
+
const tabs = await chrome.tabs.query({ windowId });
|
|
225
|
+
const webTab = tabs.find(t => t.id && isWebUrl(t.url));
|
|
226
|
+
if (webTab?.id) return webTab.id;
|
|
165
227
|
|
|
166
|
-
//
|
|
167
|
-
|
|
168
|
-
|
|
228
|
+
// Use the first tab if it's a blank/new tab page
|
|
229
|
+
if (tabs.length > 0 && tabs[0]?.id) return tabs[0].id;
|
|
230
|
+
|
|
231
|
+
// No suitable tab — create one
|
|
232
|
+
const newTab = await chrome.tabs.create({ windowId, url: 'about:blank', active: true });
|
|
233
|
+
if (!newTab.id) throw new Error('Failed to create tab in automation window');
|
|
169
234
|
return newTab.id;
|
|
170
235
|
}
|
|
171
236
|
|
|
@@ -173,7 +238,7 @@ async function handleExec(cmd: Command): Promise<Result> {
|
|
|
173
238
|
if (!cmd.code) return { id: cmd.id, ok: false, error: 'Missing code' };
|
|
174
239
|
const tabId = await resolveTabId(cmd.tabId);
|
|
175
240
|
try {
|
|
176
|
-
const data = await
|
|
241
|
+
const data = await executor.evaluateAsync(tabId, cmd.code);
|
|
177
242
|
return { id: cmd.id, ok: true, data };
|
|
178
243
|
} catch (err) {
|
|
179
244
|
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
@@ -235,12 +300,12 @@ async function handleTabs(cmd: Command): Promise<Result> {
|
|
|
235
300
|
const target = tabs[cmd.index];
|
|
236
301
|
if (!target?.id) return { id: cmd.id, ok: false, error: `Tab index ${cmd.index} not found` };
|
|
237
302
|
await chrome.tabs.remove(target.id);
|
|
238
|
-
|
|
303
|
+
executor.detach(target.id);
|
|
239
304
|
return { id: cmd.id, ok: true, data: { closed: target.id } };
|
|
240
305
|
}
|
|
241
306
|
const tabId = await resolveTabId(cmd.tabId);
|
|
242
307
|
await chrome.tabs.remove(tabId);
|
|
243
|
-
|
|
308
|
+
executor.detach(tabId);
|
|
244
309
|
return { id: cmd.id, ok: true, data: { closed: tabId } };
|
|
245
310
|
}
|
|
246
311
|
case 'select': {
|
|
@@ -281,7 +346,7 @@ async function handleCookies(cmd: Command): Promise<Result> {
|
|
|
281
346
|
async function handleScreenshot(cmd: Command): Promise<Result> {
|
|
282
347
|
const tabId = await resolveTabId(cmd.tabId);
|
|
283
348
|
try {
|
|
284
|
-
const data = await
|
|
349
|
+
const data = await executor.screenshot(tabId, {
|
|
285
350
|
format: cmd.format,
|
|
286
351
|
quality: cmd.quality,
|
|
287
352
|
fullPage: cmd.fullPage,
|
|
@@ -291,3 +356,15 @@ async function handleScreenshot(cmd: Command): Promise<Result> {
|
|
|
291
356
|
return { id: cmd.id, ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
292
357
|
}
|
|
293
358
|
}
|
|
359
|
+
|
|
360
|
+
async function handleCloseWindow(cmd: Command): Promise<Result> {
|
|
361
|
+
if (automationWindowId !== null) {
|
|
362
|
+
try {
|
|
363
|
+
await chrome.windows.remove(automationWindowId);
|
|
364
|
+
} catch {
|
|
365
|
+
// Window may already be closed
|
|
366
|
+
}
|
|
367
|
+
automationWindowId = null;
|
|
368
|
+
}
|
|
369
|
+
return { id: cmd.id, ok: true, data: { closed: true } };
|
|
370
|
+
}
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* Everything else is just JS code sent via 'exec'.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
|
|
8
|
+
export type Action = 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
|
|
9
9
|
|
|
10
10
|
export interface Command {
|
|
11
11
|
/** Unique request ID */
|
package/package.json
CHANGED
|
@@ -15,7 +15,7 @@ function generateId(): string {
|
|
|
15
15
|
|
|
16
16
|
export interface DaemonCommand {
|
|
17
17
|
id: string;
|
|
18
|
-
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot';
|
|
18
|
+
action: 'exec' | 'navigate' | 'tabs' | 'cookies' | 'screenshot' | 'close-window';
|
|
19
19
|
tabId?: number;
|
|
20
20
|
code?: string;
|
|
21
21
|
url?: string;
|
package/src/browser/index.ts
CHANGED
|
@@ -6,14 +6,9 @@
|
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
8
|
export { Page } from './page.js';
|
|
9
|
-
export { PlaywrightMCP } from './mcp.js';
|
|
9
|
+
export { BrowserBridge, BrowserBridge as PlaywrightMCP } from './mcp.js';
|
|
10
10
|
export { isDaemonRunning } from './daemon-client.js';
|
|
11
11
|
|
|
12
|
-
// Backward compatibility: getTokenFingerprint is no longer needed but kept as no-op export
|
|
13
|
-
export function getTokenFingerprint(_token: string | undefined): string | null {
|
|
14
|
-
return null;
|
|
15
|
-
}
|
|
16
|
-
|
|
17
12
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
18
13
|
import { withTimeoutMs } from '../runtime.js';
|
|
19
14
|
|
package/src/browser/mcp.ts
CHANGED
|
@@ -1,8 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Browser session manager — auto-spawns daemon and provides IPage.
|
|
3
|
-
*
|
|
4
|
-
* Replaces the old PlaywrightMCP class. Still exports as PlaywrightMCP
|
|
5
|
-
* for backward compatibility with main.ts and other consumers.
|
|
6
3
|
*/
|
|
7
4
|
|
|
8
5
|
import { spawn, type ChildProcess } from 'node:child_process';
|
|
@@ -15,21 +12,17 @@ import { isDaemonRunning, isExtensionConnected } from './daemon-client.js';
|
|
|
15
12
|
|
|
16
13
|
const DAEMON_SPAWN_TIMEOUT = 10000; // 10s to wait for daemon + extension
|
|
17
14
|
|
|
18
|
-
export type
|
|
19
|
-
|
|
20
|
-
|
|
15
|
+
export type BrowserBridgeState = 'idle' | 'connecting' | 'connected' | 'closing' | 'closed';
|
|
21
16
|
|
|
22
17
|
/**
|
|
23
18
|
* Browser factory: manages daemon lifecycle and provides IPage instances.
|
|
24
|
-
*
|
|
25
|
-
* Kept as `PlaywrightMCP` class name for backward compatibility.
|
|
26
19
|
*/
|
|
27
|
-
export class
|
|
28
|
-
private _state:
|
|
20
|
+
export class BrowserBridge {
|
|
21
|
+
private _state: BrowserBridgeState = 'idle';
|
|
29
22
|
private _page: Page | null = null;
|
|
30
23
|
private _daemonProc: ChildProcess | null = null;
|
|
31
24
|
|
|
32
|
-
get state():
|
|
25
|
+
get state(): BrowserBridgeState {
|
|
33
26
|
return this._state;
|
|
34
27
|
}
|
|
35
28
|
|
|
@@ -78,10 +71,13 @@ export class PlaywrightMCP {
|
|
|
78
71
|
console.error(`[opencli] Starting daemon (${isTs ? 'ts' : 'js'})...`);
|
|
79
72
|
}
|
|
80
73
|
|
|
81
|
-
//
|
|
82
|
-
//
|
|
83
|
-
|
|
84
|
-
|
|
74
|
+
// For compiled .js, use the current node binary directly (fast).
|
|
75
|
+
// For .ts dev mode, node can't run .ts files — use tsx via --import.
|
|
76
|
+
const spawnArgs = isTs
|
|
77
|
+
? [process.execPath, '--import', 'tsx/esm', daemonPath]
|
|
78
|
+
: [process.execPath, daemonPath];
|
|
79
|
+
|
|
80
|
+
this._daemonProc = spawn(spawnArgs[0], spawnArgs.slice(1), {
|
|
85
81
|
detached: true,
|
|
86
82
|
stdio: 'ignore',
|
|
87
83
|
env: { ...process.env },
|
|
@@ -110,3 +106,6 @@ export class PlaywrightMCP {
|
|
|
110
106
|
);
|
|
111
107
|
}
|
|
112
108
|
}
|
|
109
|
+
|
|
110
|
+
/** @deprecated Use BrowserBridge instead */
|
|
111
|
+
export const PlaywrightMCP = BrowserBridge;
|
package/src/browser/page.ts
CHANGED
|
@@ -37,6 +37,15 @@ export class Page implements IPage {
|
|
|
37
37
|
}
|
|
38
38
|
}
|
|
39
39
|
|
|
40
|
+
/** Close the automation window in the extension */
|
|
41
|
+
async closeWindow(): Promise<void> {
|
|
42
|
+
try {
|
|
43
|
+
await sendCommand('close-window', {});
|
|
44
|
+
} catch {
|
|
45
|
+
// Window may already be closed or daemon may be down
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
40
49
|
async evaluate(js: string): Promise<any> {
|
|
41
50
|
const code = wrapForEval(js);
|
|
42
51
|
return sendCommand('exec', { code, ...this._tabOpt() });
|
|
@@ -183,12 +192,12 @@ export class Page implements IPage {
|
|
|
183
192
|
return sendCommand('exec', { code, ...this._tabOpt() });
|
|
184
193
|
}
|
|
185
194
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
195
|
+
/**
|
|
196
|
+
* Console messages are not available in lightweight daemon mode.
|
|
197
|
+
* Would require CDP Runtime.consoleAPICalled event listener.
|
|
198
|
+
* @returns Always returns empty array.
|
|
199
|
+
*/
|
|
200
|
+
async consoleMessages(_level: string = 'info'): Promise<any> {
|
|
192
201
|
return [];
|
|
193
202
|
}
|
|
194
203
|
|
|
@@ -260,21 +269,18 @@ export class Page implements IPage {
|
|
|
260
269
|
|
|
261
270
|
async installInterceptor(pattern: string): Promise<void> {
|
|
262
271
|
const { generateInterceptorJs } = await import('../interceptor.js');
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
});
|
|
272
|
+
// Must use evaluate() so wrapForEval() converts the arrow function into an IIFE;
|
|
273
|
+
// sendCommand('exec') sends the code as-is, and CDP never executes a bare arrow.
|
|
274
|
+
await this.evaluate(generateInterceptorJs(JSON.stringify(pattern), {
|
|
275
|
+
arrayName: '__opencli_xhr',
|
|
276
|
+
patchGuard: '__opencli_interceptor_patched',
|
|
277
|
+
}));
|
|
270
278
|
}
|
|
271
279
|
|
|
272
280
|
async getInterceptedRequests(): Promise<any[]> {
|
|
273
281
|
const { generateReadInterceptedJs } = await import('../interceptor.js');
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
...this._tabOpt(),
|
|
277
|
-
});
|
|
282
|
+
// Same as installInterceptor: must go through evaluate() for IIFE wrapping
|
|
283
|
+
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
278
284
|
return (result as any[]) || [];
|
|
279
285
|
}
|
|
280
286
|
}
|
package/src/browser.test.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { afterEach, describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import {
|
|
2
|
+
import { BrowserBridge, __test__ } from './browser/index.js';
|
|
3
3
|
|
|
4
4
|
describe('browser helpers', () => {
|
|
5
5
|
it('extracts tab entries from string snapshots', () => {
|
|
@@ -45,9 +45,9 @@ describe('browser helpers', () => {
|
|
|
45
45
|
});
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
-
describe('
|
|
48
|
+
describe('BrowserBridge state', () => {
|
|
49
49
|
it('transitions to closed after close()', async () => {
|
|
50
|
-
const mcp = new
|
|
50
|
+
const mcp = new BrowserBridge();
|
|
51
51
|
|
|
52
52
|
expect(mcp.state).toBe('idle');
|
|
53
53
|
|
|
@@ -57,21 +57,21 @@ describe('PlaywrightMCP state', () => {
|
|
|
57
57
|
});
|
|
58
58
|
|
|
59
59
|
it('rejects connect() after the session has been closed', async () => {
|
|
60
|
-
const mcp = new
|
|
60
|
+
const mcp = new BrowserBridge();
|
|
61
61
|
await mcp.close();
|
|
62
62
|
|
|
63
63
|
await expect(mcp.connect()).rejects.toThrow('Session is closed');
|
|
64
64
|
});
|
|
65
65
|
|
|
66
66
|
it('rejects connect() while already connecting', async () => {
|
|
67
|
-
const mcp = new
|
|
67
|
+
const mcp = new BrowserBridge();
|
|
68
68
|
(mcp as any)._state = 'connecting';
|
|
69
69
|
|
|
70
70
|
await expect(mcp.connect()).rejects.toThrow('Already connecting');
|
|
71
71
|
});
|
|
72
72
|
|
|
73
73
|
it('rejects connect() while closing', async () => {
|
|
74
|
-
const mcp = new
|
|
74
|
+
const mcp = new BrowserBridge();
|
|
75
75
|
(mcp as any)._state = 'closing';
|
|
76
76
|
|
|
77
77
|
await expect(mcp.connect()).rejects.toThrow('Session is closing');
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { itunesFetch, formatDuration, formatDate } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'apple-podcasts',
|
|
7
|
+
name: 'episodes',
|
|
8
|
+
description: 'List recent episodes of an Apple Podcast (use ID from search)',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'id', positional: true, required: true, help: 'Podcast ID (collectionId from search output)' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 15, help: 'Max episodes to show' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['title', 'duration', 'date'],
|
|
16
|
+
func: async (_page, args) => {
|
|
17
|
+
const limit = Math.max(1, Math.min(Number(args.limit), 200));
|
|
18
|
+
// results[0] is the podcast itself; the rest are episodes
|
|
19
|
+
const data = await itunesFetch(`/lookup?id=${args.id}&entity=podcastEpisode&limit=${limit + 1}`);
|
|
20
|
+
const episodes = (data.results ?? []).filter((r: any) => r.kind === 'podcast-episode');
|
|
21
|
+
if (!episodes.length) throw new CliError('NOT_FOUND', 'No episodes found', 'Check the podcast ID from: opencli apple-podcasts search <keyword>');
|
|
22
|
+
return episodes.slice(0, limit).map((ep: any) => ({
|
|
23
|
+
title: ep.trackName,
|
|
24
|
+
duration: formatDuration(ep.trackTimeMillis),
|
|
25
|
+
date: formatDate(ep.releaseDate),
|
|
26
|
+
}));
|
|
27
|
+
},
|
|
28
|
+
});
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
import { itunesFetch } from './utils.js';
|
|
4
|
+
|
|
5
|
+
cli({
|
|
6
|
+
site: 'apple-podcasts',
|
|
7
|
+
name: 'search',
|
|
8
|
+
description: 'Search Apple Podcasts',
|
|
9
|
+
strategy: Strategy.PUBLIC,
|
|
10
|
+
browser: false,
|
|
11
|
+
args: [
|
|
12
|
+
{ name: 'keyword', positional: true, required: true, help: 'Search keyword' },
|
|
13
|
+
{ name: 'limit', type: 'int', default: 10, help: 'Max results' },
|
|
14
|
+
],
|
|
15
|
+
columns: ['id', 'title', 'author', 'episodes', 'genre'],
|
|
16
|
+
func: async (_page, args) => {
|
|
17
|
+
const term = encodeURIComponent(args.keyword);
|
|
18
|
+
const limit = Math.max(1, Math.min(Number(args.limit), 25));
|
|
19
|
+
const data = await itunesFetch(`/search?term=${term}&media=podcast&limit=${limit}`);
|
|
20
|
+
if (!data.results?.length) throw new CliError('NOT_FOUND', 'No podcasts found', `Try a different keyword`);
|
|
21
|
+
return data.results.map((p: any) => ({
|
|
22
|
+
id: p.collectionId,
|
|
23
|
+
title: p.collectionName,
|
|
24
|
+
author: p.artistName,
|
|
25
|
+
episodes: p.trackCount ?? '-',
|
|
26
|
+
genre: p.primaryGenreName ?? '-',
|
|
27
|
+
}));
|
|
28
|
+
},
|
|
29
|
+
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { cli, Strategy } from '../../registry.js';
|
|
2
|
+
import { CliError } from '../../errors.js';
|
|
3
|
+
|
|
4
|
+
// Apple Marketing Tools RSS API — public, no key required
|
|
5
|
+
const CHARTS_URL = 'https://rss.applemarketingtools.com/api/v2';
|
|
6
|
+
|
|
7
|
+
cli({
|
|
8
|
+
site: 'apple-podcasts',
|
|
9
|
+
name: 'top',
|
|
10
|
+
description: 'Top podcasts chart on Apple Podcasts',
|
|
11
|
+
strategy: Strategy.PUBLIC,
|
|
12
|
+
browser: false,
|
|
13
|
+
args: [
|
|
14
|
+
{ name: 'limit', type: 'int', default: 20, help: 'Number of podcasts (max 100)' },
|
|
15
|
+
{ name: 'country', default: 'us', help: 'Country code (e.g. us, cn, gb, jp)' },
|
|
16
|
+
],
|
|
17
|
+
columns: ['rank', 'title', 'author', 'id'],
|
|
18
|
+
func: async (_page, args) => {
|
|
19
|
+
const limit = Math.max(1, Math.min(Number(args.limit), 100));
|
|
20
|
+
const country = String(args.country || 'us').trim().toLowerCase();
|
|
21
|
+
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
|
22
|
+
const resp = await fetch(url);
|
|
23
|
+
if (!resp.ok) throw new CliError('FETCH_ERROR', `Charts API HTTP ${resp.status}`, `Check country code: ${country}`);
|
|
24
|
+
const data = await resp.json();
|
|
25
|
+
const results = data?.feed?.results;
|
|
26
|
+
if (!results?.length) throw new CliError('NOT_FOUND', 'No chart data found', `Try a different country code`);
|
|
27
|
+
return results.map((p: any, i: number) => ({
|
|
28
|
+
rank: i + 1,
|
|
29
|
+
title: p.name,
|
|
30
|
+
author: p.artistName,
|
|
31
|
+
id: p.id,
|
|
32
|
+
}));
|
|
33
|
+
},
|
|
34
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
2
|
+
import { formatDuration, formatDate, itunesFetch } from './utils.js';
|
|
3
|
+
|
|
4
|
+
describe('formatDuration', () => {
|
|
5
|
+
it('formats typical duration in ms', () => {
|
|
6
|
+
expect(formatDuration(3661000)).toBe('61:01');
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it('pads single-digit seconds', () => {
|
|
10
|
+
expect(formatDuration(65000)).toBe('1:05');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('formats exact minutes', () => {
|
|
14
|
+
expect(formatDuration(3600000)).toBe('60:00');
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
it('rounds fractional milliseconds', () => {
|
|
18
|
+
expect(formatDuration(3600500)).toBe('60:01');
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('returns dash for zero', () => {
|
|
22
|
+
expect(formatDuration(0)).toBe('-');
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('returns dash for NaN', () => {
|
|
26
|
+
expect(formatDuration(NaN)).toBe('-');
|
|
27
|
+
});
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
describe('formatDate', () => {
|
|
31
|
+
it('extracts YYYY-MM-DD from ISO string', () => {
|
|
32
|
+
expect(formatDate('2026-03-19T12:00:00.000Z')).toBe('2026-03-19');
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('handles date-only string', () => {
|
|
36
|
+
expect(formatDate('2025-01-01')).toBe('2025-01-01');
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('returns dash for empty string', () => {
|
|
40
|
+
expect(formatDate('')).toBe('-');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('returns dash for undefined', () => {
|
|
44
|
+
expect(formatDate(undefined as any)).toBe('-');
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('itunesFetch', () => {
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
vi.restoreAllMocks();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('returns parsed JSON on success', async () => {
|
|
54
|
+
const mockData = { resultCount: 1, results: [{ collectionId: 123 }] };
|
|
55
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
56
|
+
ok: true,
|
|
57
|
+
json: () => Promise.resolve(mockData),
|
|
58
|
+
}));
|
|
59
|
+
|
|
60
|
+
const result = await itunesFetch('/search?term=test&media=podcast&limit=1');
|
|
61
|
+
expect(result).toEqual(mockData);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('throws CliError on HTTP error', async () => {
|
|
65
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
66
|
+
ok: false,
|
|
67
|
+
status: 403,
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
await expect(itunesFetch('/search?term=test')).rejects.toThrow('iTunes API HTTP 403');
|
|
71
|
+
});
|
|
72
|
+
});
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared Apple Podcasts utilities.
|
|
3
|
+
*
|
|
4
|
+
* Uses the public iTunes Search API — no API key required.
|
|
5
|
+
* https://developer.apple.com/library/archive/documentation/AudioVideo/Conceptual/iTuneSearchAPI/
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { CliError } from '../../errors.js';
|
|
9
|
+
|
|
10
|
+
const BASE = 'https://itunes.apple.com';
|
|
11
|
+
|
|
12
|
+
export async function itunesFetch(path: string): Promise<any> {
|
|
13
|
+
const resp = await fetch(`${BASE}${path}`);
|
|
14
|
+
if (!resp.ok) {
|
|
15
|
+
throw new CliError(
|
|
16
|
+
'FETCH_ERROR',
|
|
17
|
+
`iTunes API HTTP ${resp.status}`,
|
|
18
|
+
'Check your search term or podcast ID',
|
|
19
|
+
);
|
|
20
|
+
}
|
|
21
|
+
return resp.json();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Format milliseconds to mm:ss. Returns '-' for missing input. */
|
|
25
|
+
export function formatDuration(ms: number): string {
|
|
26
|
+
if (!ms || !Number.isFinite(ms)) return '-';
|
|
27
|
+
const totalSec = Math.round(ms / 1000);
|
|
28
|
+
const m = Math.floor(totalSec / 60);
|
|
29
|
+
const s = totalSec % 60;
|
|
30
|
+
return `${m}:${String(s).padStart(2, '0')}`;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Format ISO date string to YYYY-MM-DD. Returns '-' for missing input. */
|
|
34
|
+
export function formatDate(iso: string): string {
|
|
35
|
+
if (!iso) return '-';
|
|
36
|
+
return iso.slice(0, 10);
|
|
37
|
+
}
|
|
@@ -42,6 +42,20 @@ export const historyCommand = cli({
|
|
|
42
42
|
return [{ Index: 0, Title: 'No history found. Ensure the sidebar is visible.' }];
|
|
43
43
|
}
|
|
44
44
|
|
|
45
|
-
|
|
45
|
+
const dateHeaders = /^(today|yesterday|last week|last month|last year|this week|this month|older|previous \d+ days|\d+ days ago)$/i;
|
|
46
|
+
const numericOnly = /^[\d\s]+$/;
|
|
47
|
+
const modelPath = /^[\w.-]+\/[\w.-]/;
|
|
48
|
+
const seen = new Set<string>();
|
|
49
|
+
const deduped = items.filter((item: { Index: number; Title: string }) => {
|
|
50
|
+
const t = item.Title.trim();
|
|
51
|
+
if (dateHeaders.test(t)) return false;
|
|
52
|
+
if (numericOnly.test(t)) return false;
|
|
53
|
+
if (modelPath.test(t)) return false;
|
|
54
|
+
if (seen.has(t)) return false;
|
|
55
|
+
seen.add(t);
|
|
56
|
+
return true;
|
|
57
|
+
}).map((item: { Index: number; Title: string }, i: number) => ({ Index: i + 1, Title: item.Title }));
|
|
58
|
+
|
|
59
|
+
return deduped;
|
|
46
60
|
},
|
|
47
61
|
});
|