@jackwener/opencli 1.5.1 → 1.5.3
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/ci.yml +6 -7
- package/README.md +21 -362
- package/dist/browser/cdp.js +20 -1
- package/dist/browser/daemon-client.js +3 -2
- package/dist/browser/discover.js +11 -7
- package/dist/browser/dom-helpers.d.ts +11 -0
- package/dist/browser/dom-helpers.js +42 -0
- package/dist/browser/dom-helpers.test.d.ts +1 -0
- package/dist/browser/dom-helpers.test.js +92 -0
- package/dist/browser/index.d.ts +0 -10
- package/dist/browser/index.js +0 -11
- package/dist/browser/mcp.js +4 -3
- package/dist/browser/page.d.ts +2 -0
- package/dist/browser/page.js +42 -3
- package/dist/browser.test.js +17 -8
- package/dist/cli-manifest.json +4 -5
- package/dist/clis/36kr/hot.js +1 -1
- package/dist/clis/36kr/search.js +1 -1
- package/dist/clis/_shared/common.d.ts +8 -0
- package/dist/clis/_shared/common.js +10 -0
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -1
- package/dist/clis/bloomberg/news.js +1 -1
- package/dist/clis/douban/utils.js +3 -6
- package/dist/clis/medium/utils.js +1 -1
- package/dist/clis/producthunt/browse.js +1 -1
- package/dist/clis/producthunt/hot.js +1 -1
- package/dist/clis/sinablog/utils.js +6 -7
- package/dist/clis/substack/utils.js +2 -2
- package/dist/clis/twitter/block.js +1 -1
- package/dist/clis/twitter/bookmark.js +1 -1
- package/dist/clis/twitter/delete.js +1 -1
- package/dist/clis/twitter/follow.js +1 -1
- package/dist/clis/twitter/followers.js +2 -2
- package/dist/clis/twitter/following.js +2 -2
- package/dist/clis/twitter/hide-reply.js +1 -1
- package/dist/clis/twitter/like.js +1 -1
- package/dist/clis/twitter/notifications.js +1 -1
- package/dist/clis/twitter/profile.js +1 -1
- package/dist/clis/twitter/reply-dm.js +1 -1
- package/dist/clis/twitter/reply.js +1 -1
- package/dist/clis/twitter/search.js +1 -1
- package/dist/clis/twitter/unblock.js +1 -1
- package/dist/clis/twitter/unbookmark.js +1 -1
- package/dist/clis/twitter/unfollow.js +1 -1
- package/dist/clis/v2ex/hot.yaml +3 -17
- package/dist/clis/weread/shelf.js +132 -9
- package/dist/clis/weread/utils.js +5 -1
- package/dist/clis/xiaohongshu/comments.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-note-detail.test.js +1 -0
- package/dist/clis/xiaohongshu/creator-notes.test.js +1 -0
- package/dist/clis/xiaohongshu/publish.test.js +1 -0
- package/dist/clis/xiaohongshu/search.test.js +1 -0
- package/dist/daemon.js +1 -0
- package/dist/doctor.js +7 -3
- package/dist/download/index.js +39 -33
- package/dist/download/index.test.js +15 -1
- package/dist/execution.js +3 -8
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/main.js +2 -0
- package/dist/node-network.d.ts +10 -0
- package/dist/node-network.js +174 -0
- package/dist/node-network.test.d.ts +1 -0
- package/dist/node-network.test.js +55 -0
- package/dist/pipeline/executor.test.js +1 -0
- package/dist/pipeline/steps/download.test.js +1 -0
- package/dist/pipeline/steps/intercept.js +4 -5
- package/dist/types.d.ts +2 -0
- package/dist/utils.d.ts +2 -0
- package/dist/utils.js +4 -0
- package/dist/weread-private-api-regression.test.js +185 -0
- package/docs/superpowers/plans/2026-03-28-perf-smart-wait.md +1143 -0
- package/docs/superpowers/specs/2026-03-28-perf-smart-wait-design.md +170 -0
- package/extension/dist/background.js +4 -2
- package/extension/manifest.json +4 -1
- package/extension/package-lock.json +2 -2
- package/extension/package.json +1 -1
- package/extension/src/background.ts +2 -1
- package/package.json +2 -1
- package/src/browser/cdp.ts +21 -0
- package/src/browser/daemon-client.ts +3 -2
- package/src/browser/discover.ts +10 -7
- package/src/browser/dom-helpers.test.ts +100 -0
- package/src/browser/dom-helpers.ts +44 -0
- package/src/browser/index.ts +0 -13
- package/src/browser/mcp.ts +4 -3
- package/src/browser/page.ts +41 -2
- package/src/browser.test.ts +19 -9
- package/src/clis/36kr/hot.ts +1 -1
- package/src/clis/36kr/search.ts +1 -1
- package/src/clis/_shared/common.ts +11 -0
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -1
- package/src/clis/bloomberg/news.ts +1 -1
- package/src/clis/douban/utils.ts +3 -7
- package/src/clis/medium/utils.ts +1 -1
- package/src/clis/producthunt/browse.ts +1 -1
- package/src/clis/producthunt/hot.ts +1 -1
- package/src/clis/sinablog/utils.ts +6 -7
- package/src/clis/substack/utils.ts +2 -2
- package/src/clis/twitter/block.ts +1 -1
- package/src/clis/twitter/bookmark.ts +1 -1
- package/src/clis/twitter/delete.ts +1 -1
- package/src/clis/twitter/follow.ts +1 -1
- package/src/clis/twitter/followers.ts +2 -2
- package/src/clis/twitter/following.ts +2 -2
- package/src/clis/twitter/hide-reply.ts +1 -1
- package/src/clis/twitter/like.ts +1 -1
- package/src/clis/twitter/notifications.ts +1 -1
- package/src/clis/twitter/profile.ts +1 -1
- package/src/clis/twitter/reply-dm.ts +1 -1
- package/src/clis/twitter/reply.ts +1 -1
- package/src/clis/twitter/search.ts +1 -1
- package/src/clis/twitter/unblock.ts +1 -1
- package/src/clis/twitter/unbookmark.ts +1 -1
- package/src/clis/twitter/unfollow.ts +1 -1
- package/src/clis/v2ex/hot.yaml +3 -17
- package/src/clis/weread/shelf.ts +169 -9
- package/src/clis/weread/utils.ts +6 -1
- package/src/clis/xiaohongshu/comments.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-note-detail.test.ts +1 -0
- package/src/clis/xiaohongshu/creator-notes.test.ts +1 -0
- package/src/clis/xiaohongshu/publish.test.ts +1 -0
- package/src/clis/xiaohongshu/search.test.ts +1 -0
- package/src/daemon.ts +1 -0
- package/src/doctor.ts +9 -5
- package/src/download/index.test.ts +19 -1
- package/src/download/index.ts +50 -41
- package/src/execution.ts +3 -11
- package/src/extension-manifest-regression.test.ts +17 -0
- package/src/main.ts +3 -0
- package/src/node-network.test.ts +93 -0
- package/src/node-network.ts +213 -0
- package/src/pipeline/executor.test.ts +1 -0
- package/src/pipeline/steps/download.test.ts +1 -0
- package/src/pipeline/steps/intercept.ts +4 -5
- package/src/types.ts +2 -0
- package/src/utils.ts +5 -0
- package/src/weread-private-api-regression.test.ts +207 -0
- package/tests/e2e/browser-public.test.ts +1 -1
- package/tests/e2e/output-formats.test.ts +10 -14
- package/tests/e2e/plugin-management.test.ts +4 -1
- package/tests/e2e/public-commands.test.ts +12 -1
- package/vitest.config.ts +1 -15
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { waitForCaptureJs, waitForSelectorJs } from './dom-helpers.js';
|
|
3
|
+
describe('waitForCaptureJs', () => {
|
|
4
|
+
it('returns a non-empty string', () => {
|
|
5
|
+
const code = waitForCaptureJs(1000);
|
|
6
|
+
expect(typeof code).toBe('string');
|
|
7
|
+
expect(code.length).toBeGreaterThan(0);
|
|
8
|
+
expect(code).toContain('__opencli_xhr');
|
|
9
|
+
expect(code).toContain('resolve');
|
|
10
|
+
expect(code).toContain('reject');
|
|
11
|
+
});
|
|
12
|
+
it('resolves "captured" when __opencli_xhr is populated before deadline', async () => {
|
|
13
|
+
const g = globalThis;
|
|
14
|
+
g.__opencli_xhr = [];
|
|
15
|
+
g.window = g; // stub window for Node eval
|
|
16
|
+
const code = waitForCaptureJs(1000);
|
|
17
|
+
const promise = eval(code);
|
|
18
|
+
g.__opencli_xhr.push({ data: 'test' });
|
|
19
|
+
await expect(promise).resolves.toBe('captured');
|
|
20
|
+
delete g.__opencli_xhr;
|
|
21
|
+
delete g.window;
|
|
22
|
+
});
|
|
23
|
+
it('rejects when __opencli_xhr stays empty past deadline', async () => {
|
|
24
|
+
const g = globalThis;
|
|
25
|
+
g.__opencli_xhr = [];
|
|
26
|
+
g.window = g;
|
|
27
|
+
const code = waitForCaptureJs(50); // 50ms timeout
|
|
28
|
+
const promise = eval(code);
|
|
29
|
+
await expect(promise).rejects.toThrow('No network capture within 0.05s');
|
|
30
|
+
delete g.__opencli_xhr;
|
|
31
|
+
delete g.window;
|
|
32
|
+
});
|
|
33
|
+
it('resolves immediately when __opencli_xhr already has data', async () => {
|
|
34
|
+
const g = globalThis;
|
|
35
|
+
g.__opencli_xhr = [{ data: 'already here' }];
|
|
36
|
+
g.window = g;
|
|
37
|
+
const code = waitForCaptureJs(1000);
|
|
38
|
+
await expect(eval(code)).resolves.toBe('captured');
|
|
39
|
+
delete g.__opencli_xhr;
|
|
40
|
+
delete g.window;
|
|
41
|
+
});
|
|
42
|
+
});
|
|
43
|
+
describe('waitForSelectorJs', () => {
|
|
44
|
+
it('returns a non-empty string', () => {
|
|
45
|
+
const code = waitForSelectorJs('#app', 1000);
|
|
46
|
+
expect(typeof code).toBe('string');
|
|
47
|
+
expect(code).toContain('#app');
|
|
48
|
+
expect(code).toContain('querySelector');
|
|
49
|
+
expect(code).toContain('MutationObserver');
|
|
50
|
+
});
|
|
51
|
+
it('resolves "found" immediately when selector already present', async () => {
|
|
52
|
+
const g = globalThis;
|
|
53
|
+
const fakeEl = { tagName: 'DIV' };
|
|
54
|
+
g.document = { querySelector: (_) => fakeEl };
|
|
55
|
+
const code = waitForSelectorJs('[data-testid="primaryColumn"]', 1000);
|
|
56
|
+
await expect(eval(code)).resolves.toBe('found');
|
|
57
|
+
delete g.document;
|
|
58
|
+
});
|
|
59
|
+
it('resolves "found" when selector appears after DOM mutation', async () => {
|
|
60
|
+
const g = globalThis;
|
|
61
|
+
let mutationCallback;
|
|
62
|
+
g.MutationObserver = class {
|
|
63
|
+
constructor(cb) { mutationCallback = cb; }
|
|
64
|
+
observe() { }
|
|
65
|
+
disconnect() { }
|
|
66
|
+
};
|
|
67
|
+
let calls = 0;
|
|
68
|
+
g.document = {
|
|
69
|
+
querySelector: (_) => (calls++ > 0 ? { tagName: 'DIV' } : null),
|
|
70
|
+
body: {},
|
|
71
|
+
};
|
|
72
|
+
const code = waitForSelectorJs('#app', 1000);
|
|
73
|
+
const promise = eval(code);
|
|
74
|
+
mutationCallback(); // simulate DOM mutation
|
|
75
|
+
await expect(promise).resolves.toBe('found');
|
|
76
|
+
delete g.document;
|
|
77
|
+
delete g.MutationObserver;
|
|
78
|
+
});
|
|
79
|
+
it('rejects when selector never appears within timeout', async () => {
|
|
80
|
+
const g = globalThis;
|
|
81
|
+
g.MutationObserver = class {
|
|
82
|
+
constructor(_cb) { }
|
|
83
|
+
observe() { }
|
|
84
|
+
disconnect() { }
|
|
85
|
+
};
|
|
86
|
+
g.document = { querySelector: (_) => null, body: {} };
|
|
87
|
+
const code = waitForSelectorJs('#missing', 50);
|
|
88
|
+
await expect(eval(code)).rejects.toThrow('Selector not found: #missing');
|
|
89
|
+
delete g.document;
|
|
90
|
+
delete g.MutationObserver;
|
|
91
|
+
});
|
|
92
|
+
});
|
package/dist/browser/index.d.ts
CHANGED
|
@@ -11,13 +11,3 @@ export { isDaemonRunning } from './daemon-client.js';
|
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
|
13
13
|
export type { DomSnapshotOptions } from './dom-snapshot.js';
|
|
14
|
-
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
15
|
-
import { withTimeoutMs } from '../runtime.js';
|
|
16
|
-
export declare const __test__: {
|
|
17
|
-
extractTabEntries: typeof extractTabEntries;
|
|
18
|
-
diffTabIndexes: typeof diffTabIndexes;
|
|
19
|
-
appendLimited: typeof appendLimited;
|
|
20
|
-
withTimeoutMs: typeof withTimeoutMs;
|
|
21
|
-
selectCDPTarget: (targets: import("./cdp.js").CDPTarget[]) => import("./cdp.js").CDPTarget | undefined;
|
|
22
|
-
scoreCDPTarget: (target: import("./cdp.js").CDPTarget, preferredPattern?: RegExp) => number;
|
|
23
|
-
};
|
package/dist/browser/index.js
CHANGED
|
@@ -10,14 +10,3 @@ export { CDPBridge } from './cdp.js';
|
|
|
10
10
|
export { isDaemonRunning } from './daemon-client.js';
|
|
11
11
|
export { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
12
12
|
export { generateStealthJs } from './stealth.js';
|
|
13
|
-
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
14
|
-
import { __test__ as cdpTest } from './cdp.js';
|
|
15
|
-
import { withTimeoutMs } from '../runtime.js';
|
|
16
|
-
export const __test__ = {
|
|
17
|
-
extractTabEntries,
|
|
18
|
-
diffTabIndexes,
|
|
19
|
-
appendLimited,
|
|
20
|
-
withTimeoutMs,
|
|
21
|
-
selectCDPTarget: cdpTest.selectCDPTarget,
|
|
22
|
-
scoreCDPTarget: cdpTest.scoreCDPTarget,
|
|
23
|
-
};
|
package/dist/browser/mcp.js
CHANGED
|
@@ -82,10 +82,11 @@ export class BrowserBridge {
|
|
|
82
82
|
env: { ...process.env },
|
|
83
83
|
});
|
|
84
84
|
this._daemonProc.unref();
|
|
85
|
-
// Wait for daemon to be ready AND extension to connect
|
|
85
|
+
// Wait for daemon to be ready AND extension to connect (exponential backoff)
|
|
86
|
+
const backoffs = [50, 100, 200, 400, 800, 1500, 3000];
|
|
86
87
|
const deadline = Date.now() + timeoutMs;
|
|
87
|
-
|
|
88
|
-
await new Promise(resolve => setTimeout(resolve,
|
|
88
|
+
for (let i = 0; Date.now() < deadline; i++) {
|
|
89
|
+
await new Promise(resolve => setTimeout(resolve, backoffs[Math.min(i, backoffs.length - 1)]));
|
|
89
90
|
if (await isExtensionConnected())
|
|
90
91
|
return;
|
|
91
92
|
}
|
package/dist/browser/page.d.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
* chrome-extension:// tab that can't be debugged.
|
|
11
11
|
*/
|
|
12
12
|
import type { BrowserCookie, IPage, ScreenshotOptions, SnapshotOptions, WaitOptions } from '../types.js';
|
|
13
|
+
export declare function isRetryableSettleError(err: unknown): boolean;
|
|
13
14
|
/**
|
|
14
15
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
15
16
|
*/
|
|
@@ -71,4 +72,5 @@ export declare class Page implements IPage {
|
|
|
71
72
|
}): Promise<void>;
|
|
72
73
|
installInterceptor(pattern: string): Promise<void>;
|
|
73
74
|
getInterceptedRequests(): Promise<unknown[]>;
|
|
75
|
+
waitForCapture(timeout?: number): Promise<void>;
|
|
74
76
|
}
|
package/dist/browser/page.js
CHANGED
|
@@ -15,7 +15,12 @@ import { wrapForEval } from './utils.js';
|
|
|
15
15
|
import { saveBase64ToFile } from '../utils.js';
|
|
16
16
|
import { generateSnapshotJs, scrollToRefJs, getFormStateJs } from './dom-snapshot.js';
|
|
17
17
|
import { generateStealthJs } from './stealth.js';
|
|
18
|
-
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
18
|
+
import { clickJs, typeTextJs, pressKeyJs, waitForTextJs, waitForCaptureJs, waitForSelectorJs, scrollJs, autoScrollJs, networkRequestsJs, waitForDomStableJs, } from './dom-helpers.js';
|
|
19
|
+
export function isRetryableSettleError(err) {
|
|
20
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
21
|
+
return message.includes('Inspected target navigated or closed')
|
|
22
|
+
|| (message.includes('-32000') && message.toLowerCase().includes('target'));
|
|
23
|
+
}
|
|
19
24
|
/**
|
|
20
25
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
21
26
|
*/
|
|
@@ -63,10 +68,31 @@ export class Page {
|
|
|
63
68
|
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
64
69
|
if (options?.waitUntil !== 'none') {
|
|
65
70
|
const maxMs = options?.settleMs ?? 1000;
|
|
66
|
-
|
|
71
|
+
const settleOpts = {
|
|
67
72
|
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
|
68
73
|
...this._cmdOpts(),
|
|
69
|
-
}
|
|
74
|
+
};
|
|
75
|
+
try {
|
|
76
|
+
await sendCommand('exec', settleOpts);
|
|
77
|
+
}
|
|
78
|
+
catch (err) {
|
|
79
|
+
if (!isRetryableSettleError(err))
|
|
80
|
+
throw err;
|
|
81
|
+
// SPA client-side redirects can invalidate the CDP target after
|
|
82
|
+
// chrome.tabs reports 'complete'. Wait briefly for the new document
|
|
83
|
+
// to load, then retry the settle probe once.
|
|
84
|
+
try {
|
|
85
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
86
|
+
await sendCommand('exec', settleOpts);
|
|
87
|
+
}
|
|
88
|
+
catch (retryErr) {
|
|
89
|
+
if (!isRetryableSettleError(retryErr))
|
|
90
|
+
throw retryErr;
|
|
91
|
+
// Retry also failed — give up silently. Settle is best-effort
|
|
92
|
+
// after successful navigation; the next real command will surface
|
|
93
|
+
// any persistent target error immediately.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
70
96
|
}
|
|
71
97
|
}
|
|
72
98
|
async getCurrentUrl() {
|
|
@@ -193,6 +219,12 @@ export class Page {
|
|
|
193
219
|
await new Promise(resolve => setTimeout(resolve, options.time * 1000));
|
|
194
220
|
return;
|
|
195
221
|
}
|
|
222
|
+
if (options.selector) {
|
|
223
|
+
const timeout = (options.timeout ?? 10) * 1000;
|
|
224
|
+
const code = waitForSelectorJs(options.selector, timeout);
|
|
225
|
+
await sendCommand('exec', { code, ...this._cmdOpts() });
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
196
228
|
if (options.text) {
|
|
197
229
|
const timeout = (options.timeout ?? 30) * 1000;
|
|
198
230
|
const code = waitForTextJs(options.text, timeout);
|
|
@@ -276,5 +308,12 @@ export class Page {
|
|
|
276
308
|
const result = await this.evaluate(generateReadInterceptedJs('__opencli_xhr'));
|
|
277
309
|
return Array.isArray(result) ? result : [];
|
|
278
310
|
}
|
|
311
|
+
async waitForCapture(timeout = 10) {
|
|
312
|
+
const maxMs = timeout * 1000;
|
|
313
|
+
await sendCommand('exec', {
|
|
314
|
+
code: waitForCaptureJs(maxMs),
|
|
315
|
+
...this._cmdOpts(),
|
|
316
|
+
});
|
|
317
|
+
}
|
|
279
318
|
}
|
|
280
319
|
// (End of file)
|
package/dist/browser.test.js
CHANGED
|
@@ -1,23 +1,27 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from 'vitest';
|
|
2
|
-
import { BrowserBridge,
|
|
2
|
+
import { BrowserBridge, generateStealthJs } from './browser/index.js';
|
|
3
|
+
import { extractTabEntries, diffTabIndexes, appendLimited } from './browser/tabs.js';
|
|
4
|
+
import { withTimeoutMs } from './runtime.js';
|
|
5
|
+
import { __test__ as cdpTest } from './browser/cdp.js';
|
|
6
|
+
import { isRetryableSettleError } from './browser/page.js';
|
|
3
7
|
import * as daemonClient from './browser/daemon-client.js';
|
|
4
8
|
describe('browser helpers', () => {
|
|
5
9
|
it('extracts tab entries from string snapshots', () => {
|
|
6
|
-
const entries =
|
|
10
|
+
const entries = extractTabEntries('Tab 0 https://example.com\nTab 1 Chrome Extension');
|
|
7
11
|
expect(entries).toEqual([
|
|
8
12
|
{ index: 0, identity: 'https://example.com' },
|
|
9
13
|
{ index: 1, identity: 'Chrome Extension' },
|
|
10
14
|
]);
|
|
11
15
|
});
|
|
12
16
|
it('extracts tab entries from MCP markdown format', () => {
|
|
13
|
-
const entries =
|
|
17
|
+
const entries = extractTabEntries('- 0: (current) [Playwright MCP extension](chrome-extension://abc/connect.html)\n- 1: [知乎 - 首页](https://www.zhihu.com/)');
|
|
14
18
|
expect(entries).toEqual([
|
|
15
19
|
{ index: 0, identity: '(current) [Playwright MCP extension](chrome-extension://abc/connect.html)' },
|
|
16
20
|
{ index: 1, identity: '[知乎 - 首页](https://www.zhihu.com/)' },
|
|
17
21
|
]);
|
|
18
22
|
});
|
|
19
23
|
it('closes only tabs that were opened during the session', () => {
|
|
20
|
-
const tabsToClose =
|
|
24
|
+
const tabsToClose = diffTabIndexes(['https://example.com', 'Chrome Extension'], [
|
|
21
25
|
{ index: 0, identity: 'https://example.com' },
|
|
22
26
|
{ index: 1, identity: 'Chrome Extension' },
|
|
23
27
|
{ index: 2, identity: 'https://target.example/page' },
|
|
@@ -26,13 +30,18 @@ describe('browser helpers', () => {
|
|
|
26
30
|
expect(tabsToClose).toEqual([3, 2]);
|
|
27
31
|
});
|
|
28
32
|
it('keeps only the tail of stderr buffers', () => {
|
|
29
|
-
expect(
|
|
33
|
+
expect(appendLimited('12345', '67890', 8)).toBe('34567890');
|
|
30
34
|
});
|
|
31
35
|
it('times out slow promises', async () => {
|
|
32
|
-
await expect(
|
|
36
|
+
await expect(withTimeoutMs(new Promise(() => { }), 10, 'timeout')).rejects.toThrow('timeout');
|
|
37
|
+
});
|
|
38
|
+
it('retries settle only for target-invalidated errors', () => {
|
|
39
|
+
expect(isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
|
|
40
|
+
expect(isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
|
|
41
|
+
expect(isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
|
|
33
42
|
});
|
|
34
43
|
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
|
35
|
-
const target =
|
|
44
|
+
const target = cdpTest.selectCDPTarget([
|
|
36
45
|
{
|
|
37
46
|
type: 'page',
|
|
38
47
|
title: 'DevTools - localhost:9224',
|
|
@@ -56,7 +65,7 @@ describe('browser helpers', () => {
|
|
|
56
65
|
});
|
|
57
66
|
it('honors OPENCLI_CDP_TARGET when multiple inspectable targets exist', () => {
|
|
58
67
|
vi.stubEnv('OPENCLI_CDP_TARGET', 'codex');
|
|
59
|
-
const target =
|
|
68
|
+
const target = cdpTest.selectCDPTarget([
|
|
60
69
|
{
|
|
61
70
|
type: 'app',
|
|
62
71
|
title: 'Cursor',
|
package/dist/cli-manifest.json
CHANGED
|
@@ -10900,7 +10900,7 @@
|
|
|
10900
10900
|
"description": "V2EX 热门话题",
|
|
10901
10901
|
"domain": "www.v2ex.com",
|
|
10902
10902
|
"strategy": "public",
|
|
10903
|
-
"browser":
|
|
10903
|
+
"browser": false,
|
|
10904
10904
|
"args": [
|
|
10905
10905
|
{
|
|
10906
10906
|
"name": "limit",
|
|
@@ -10918,10 +10918,9 @@
|
|
|
10918
10918
|
],
|
|
10919
10919
|
"pipeline": [
|
|
10920
10920
|
{
|
|
10921
|
-
"
|
|
10922
|
-
|
|
10923
|
-
|
|
10924
|
-
"evaluate": "(async () => {\n const response = await fetch('/api/topics/hot.json', {\n credentials: 'include',\n headers: {\n accept: 'application/json, text/plain, */*',\n 'x-requested-with': 'XMLHttpRequest',\n },\n });\n if (!response.ok) {\n throw new Error(`V2EX hot API request failed: ${response.status}`);\n }\n return await response.json();\n})()\n"
|
|
10921
|
+
"fetch": {
|
|
10922
|
+
"url": "https://www.v2ex.com/api/topics/hot.json"
|
|
10923
|
+
}
|
|
10925
10924
|
},
|
|
10926
10925
|
{
|
|
10927
10926
|
"map": {
|
package/dist/clis/36kr/hot.js
CHANGED
|
@@ -48,7 +48,7 @@ cli({
|
|
|
48
48
|
const url = buildHotListUrl(listType);
|
|
49
49
|
await page.installInterceptor('36kr.com/api');
|
|
50
50
|
await page.goto(url);
|
|
51
|
-
await page.
|
|
51
|
+
await page.waitForCapture(6);
|
|
52
52
|
// Scrape rendered article links from DOM (deduplicated)
|
|
53
53
|
const domItems = await page.evaluate(`
|
|
54
54
|
(() => {
|
package/dist/clis/36kr/search.js
CHANGED
|
@@ -21,7 +21,7 @@ cli({
|
|
|
21
21
|
const query = encodeURIComponent(String(args.query ?? ''));
|
|
22
22
|
await page.installInterceptor('36kr.com/api');
|
|
23
23
|
await page.goto(`https://www.36kr.com/search/articles/${query}`);
|
|
24
|
-
await page.
|
|
24
|
+
await page.waitForCapture(6);
|
|
25
25
|
const domItems = await page.evaluate(`
|
|
26
26
|
(() => {
|
|
27
27
|
const seen = new Set();
|
|
@@ -31,13 +31,14 @@ describe('apple-podcasts search command', () => {
|
|
|
31
31
|
});
|
|
32
32
|
expect(fetchMock).toHaveBeenCalledWith('https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5');
|
|
33
33
|
expect(result).toEqual([
|
|
34
|
-
{
|
|
34
|
+
expect.objectContaining({
|
|
35
35
|
id: 42,
|
|
36
36
|
title: 'Machine Learning Guide',
|
|
37
37
|
author: 'OpenCLI',
|
|
38
38
|
episodes: 12,
|
|
39
39
|
genre: 'Technology',
|
|
40
|
-
|
|
40
|
+
url: '',
|
|
41
|
+
}),
|
|
41
42
|
]);
|
|
42
43
|
});
|
|
43
44
|
});
|
|
@@ -45,6 +46,26 @@ describe('apple-podcasts top command', () => {
|
|
|
45
46
|
beforeEach(() => {
|
|
46
47
|
vi.restoreAllMocks();
|
|
47
48
|
});
|
|
49
|
+
it('adds a timeout signal to chart fetches', async () => {
|
|
50
|
+
const cmd = getRegistry().get('apple-podcasts/top');
|
|
51
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
52
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
53
|
+
ok: true,
|
|
54
|
+
json: () => Promise.resolve({
|
|
55
|
+
feed: {
|
|
56
|
+
results: [
|
|
57
|
+
{ id: '100', name: 'Top Show', artistName: 'Host A' },
|
|
58
|
+
],
|
|
59
|
+
},
|
|
60
|
+
}),
|
|
61
|
+
});
|
|
62
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
63
|
+
await cmd.func(null, { country: 'US', limit: 1 });
|
|
64
|
+
const [, options] = fetchMock.mock.calls[0] ?? [];
|
|
65
|
+
expect(options).toBeDefined();
|
|
66
|
+
expect(options.signal).toBeDefined();
|
|
67
|
+
expect(options.signal).toHaveProperty('aborted', false);
|
|
68
|
+
});
|
|
48
69
|
it('uses the canonical Apple charts host and maps ranked results', async () => {
|
|
49
70
|
const cmd = getRegistry().get('apple-podcasts/top');
|
|
50
71
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -61,7 +82,9 @@ describe('apple-podcasts top command', () => {
|
|
|
61
82
|
});
|
|
62
83
|
vi.stubGlobal('fetch', fetchMock);
|
|
63
84
|
const result = await cmd.func(null, { country: 'US', limit: 2 });
|
|
64
|
-
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json'
|
|
85
|
+
expect(fetchMock).toHaveBeenCalledWith('https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json', expect.objectContaining({
|
|
86
|
+
signal: expect.any(Object),
|
|
87
|
+
}));
|
|
65
88
|
expect(result).toEqual([
|
|
66
89
|
{ rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
|
|
67
90
|
{ rank: 2, title: 'Second Show', author: 'Host B', id: '101' },
|
|
@@ -2,6 +2,7 @@ import { cli, Strategy } from '../../registry.js';
|
|
|
2
2
|
import { CliError } from '../../errors.js';
|
|
3
3
|
// Apple Marketing Tools RSS API — public, no key required
|
|
4
4
|
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
|
|
5
|
+
const CHARTS_TIMEOUT_MS = 15_000;
|
|
5
6
|
cli({
|
|
6
7
|
site: 'apple-podcasts',
|
|
7
8
|
name: 'top',
|
|
@@ -19,7 +20,9 @@ cli({
|
|
|
19
20
|
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
|
20
21
|
let resp;
|
|
21
22
|
try {
|
|
22
|
-
resp = await fetch(url
|
|
23
|
+
resp = await fetch(url, {
|
|
24
|
+
signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
|
|
25
|
+
});
|
|
23
26
|
}
|
|
24
27
|
catch (error) {
|
|
25
28
|
const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
const url = validateBloombergLink(kwargs.link);
|
|
17
17
|
// Navigate and wait for the page to hydrate before extracting story data.
|
|
18
18
|
await page.goto(url);
|
|
19
|
-
await page.wait(5);
|
|
19
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
20
20
|
const loadStory = async () => page.evaluate(`(() => {
|
|
21
21
|
const isRobot = /Are you a robot/i.test(document.title)
|
|
22
22
|
|| /unusual activity/i.test(document.body.innerText)
|
|
@@ -2,14 +2,11 @@
|
|
|
2
2
|
* Douban adapter utilities.
|
|
3
3
|
*/
|
|
4
4
|
import { ArgumentError, CliError, EmptyResultError } from '../../errors.js';
|
|
5
|
+
import { clamp } from '../_shared/common.js';
|
|
5
6
|
const DOUBAN_PHOTO_PAGE_SIZE = 30;
|
|
6
7
|
const MAX_DOUBAN_PHOTOS = 500;
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
}
|
|
10
|
-
function clampPhotoLimit(limit) {
|
|
11
|
-
return Math.max(1, Math.min(limit || 120, MAX_DOUBAN_PHOTOS));
|
|
12
|
-
}
|
|
8
|
+
const clampLimit = (limit) => clamp(limit || 20, 1, 50);
|
|
9
|
+
const clampPhotoLimit = (limit) => clamp(limit || 120, 1, MAX_DOUBAN_PHOTOS);
|
|
13
10
|
async function ensureDoubanReady(page) {
|
|
14
11
|
const state = await page.evaluate(`
|
|
15
12
|
(() => {
|
|
@@ -12,7 +12,7 @@ export async function loadMediumPosts(page, url, limit) {
|
|
|
12
12
|
if (!page)
|
|
13
13
|
throw new CommandExecutionError('Browser session required for medium posts');
|
|
14
14
|
await page.goto(url);
|
|
15
|
-
await page.wait(5);
|
|
15
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
16
16
|
const data = await page.evaluate(`
|
|
17
17
|
(async () => {
|
|
18
18
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -29,7 +29,7 @@ cli({
|
|
|
29
29
|
const slug = String(args.category || '').trim().toLowerCase();
|
|
30
30
|
await page.installInterceptor('producthunt.com');
|
|
31
31
|
await page.goto(`https://www.producthunt.com/categories/${slug}`);
|
|
32
|
-
await page.
|
|
32
|
+
await page.waitForCapture(5);
|
|
33
33
|
const domItems = await page.evaluate(`
|
|
34
34
|
(() => {
|
|
35
35
|
const seen = new Set();
|
|
@@ -20,7 +20,7 @@ cli({
|
|
|
20
20
|
const count = Math.min(Number(args.limit) || 20, 50);
|
|
21
21
|
await page.installInterceptor('producthunt.com');
|
|
22
22
|
await page.goto('https://www.producthunt.com');
|
|
23
|
-
await page.
|
|
23
|
+
await page.waitForCapture(5);
|
|
24
24
|
const domItems = await page.evaluate(`
|
|
25
25
|
(() => {
|
|
26
26
|
const seen = new Set();
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
}
|
|
1
|
+
import { clamp } from '../_shared/common.js';
|
|
2
|
+
const clampLimit = (limit) => clamp(limit || 20, 1, 50);
|
|
4
3
|
export function buildSinaBlogSearchUrl(keyword) {
|
|
5
4
|
return `https://search.sina.com.cn/search?q=${encodeURIComponent(keyword)}&tp=mix`;
|
|
6
5
|
}
|
|
@@ -9,7 +8,7 @@ export function buildSinaBlogUserUrl(uid) {
|
|
|
9
8
|
}
|
|
10
9
|
export async function loadSinaBlogArticle(page, url) {
|
|
11
10
|
await page.goto(url);
|
|
12
|
-
await page.wait(3);
|
|
11
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
13
12
|
return page.evaluate(`
|
|
14
13
|
(async () => {
|
|
15
14
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
@@ -46,7 +45,7 @@ export async function loadSinaBlogArticle(page, url) {
|
|
|
46
45
|
export async function loadSinaBlogHot(page, limit) {
|
|
47
46
|
const safeLimit = clampLimit(limit);
|
|
48
47
|
await page.goto('https://blog.sina.com.cn/');
|
|
49
|
-
await page.wait(3);
|
|
48
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
50
49
|
const data = await page.evaluate(`
|
|
51
50
|
(async () => {
|
|
52
51
|
await new Promise((resolve) => setTimeout(resolve, 1500));
|
|
@@ -114,7 +113,7 @@ export async function loadSinaBlogHot(page, limit) {
|
|
|
114
113
|
export async function loadSinaBlogSearch(page, keyword, limit) {
|
|
115
114
|
const safeLimit = clampLimit(limit);
|
|
116
115
|
await page.goto(buildSinaBlogSearchUrl(keyword));
|
|
117
|
-
await page.wait(5);
|
|
116
|
+
await page.wait({ selector: '.result-item', timeout: 5 });
|
|
118
117
|
const data = await page.evaluate(`
|
|
119
118
|
(async () => {
|
|
120
119
|
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
@@ -149,7 +148,7 @@ export async function loadSinaBlogSearch(page, keyword, limit) {
|
|
|
149
148
|
export async function loadSinaBlogUser(page, uid, limit) {
|
|
150
149
|
const safeLimit = clampLimit(limit);
|
|
151
150
|
await page.goto(buildSinaBlogUserUrl(uid));
|
|
152
|
-
await page.wait(3);
|
|
151
|
+
await page.wait({ selector: 'h1', timeout: 3 });
|
|
153
152
|
const data = await page.evaluate(`
|
|
154
153
|
(async () => {
|
|
155
154
|
await new Promise((resolve) => setTimeout(resolve, 1000));
|
|
@@ -9,7 +9,7 @@ export async function loadSubstackFeed(page, url, limit) {
|
|
|
9
9
|
if (!page)
|
|
10
10
|
throw new CommandExecutionError('Browser session required for substack feed');
|
|
11
11
|
await page.goto(url);
|
|
12
|
-
await page.wait(5);
|
|
12
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
13
13
|
const data = await page.evaluate(`
|
|
14
14
|
(async () => {
|
|
15
15
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -77,7 +77,7 @@ export async function loadSubstackArchive(page, baseUrl, limit) {
|
|
|
77
77
|
if (!page)
|
|
78
78
|
throw new CommandExecutionError('Browser session required for substack archive');
|
|
79
79
|
await page.goto(`${baseUrl}/archive`);
|
|
80
|
-
await page.wait(5);
|
|
80
|
+
await page.wait({ selector: 'article', timeout: 5 });
|
|
81
81
|
const data = await page.evaluate(`
|
|
82
82
|
(async () => {
|
|
83
83
|
await new Promise((resolve) => setTimeout(resolve, 3000));
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter block');
|
|
17
17
|
const username = kwargs.username.replace(/^@/, '');
|
|
18
18
|
await page.goto(`https://x.com/${username}`);
|
|
19
|
-
await page.wait(
|
|
19
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
20
20
|
const result = await page.evaluate(`(async () => {
|
|
21
21
|
try {
|
|
22
22
|
let attempts = 0;
|
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter bookmark');
|
|
17
17
|
await page.goto(kwargs.url);
|
|
18
|
-
await page.wait(
|
|
18
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
19
19
|
const result = await page.evaluate(`(async () => {
|
|
20
20
|
try {
|
|
21
21
|
let attempts = 0;
|
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter delete');
|
|
17
17
|
await page.goto(kwargs.url);
|
|
18
|
-
await page.wait(
|
|
18
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' }); // Wait for tweet to load completely
|
|
19
19
|
const result = await page.evaluate(`(async () => {
|
|
20
20
|
try {
|
|
21
21
|
// Wait for caret button (which has 'More' aria-label) within the main tweet body
|
|
@@ -16,7 +16,7 @@ cli({
|
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter follow');
|
|
17
17
|
const username = kwargs.username.replace(/^@/, '');
|
|
18
18
|
await page.goto(`https://x.com/${username}`);
|
|
19
|
-
await page.wait(
|
|
19
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
20
20
|
const result = await page.evaluate(`(async () => {
|
|
21
21
|
try {
|
|
22
22
|
let attempts = 0;
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
// If no user is specified, figure out the logged-in user's handle
|
|
18
18
|
if (!targetUser) {
|
|
19
19
|
await page.goto('https://x.com/home');
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
const href = await page.evaluate(`() => {
|
|
22
22
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
23
23
|
return link ? link.getAttribute('href') : null;
|
|
@@ -51,7 +51,7 @@ cli({
|
|
|
51
51
|
if (!clicked) {
|
|
52
52
|
throw new SelectorError('Twitter followers link', 'Twitter may have changed the layout.');
|
|
53
53
|
}
|
|
54
|
-
await page.
|
|
54
|
+
await page.waitForCapture(5);
|
|
55
55
|
// 4. Scroll to trigger pagination API calls
|
|
56
56
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
57
57
|
// 5. Retrieve intercepted data
|
|
@@ -17,7 +17,7 @@ cli({
|
|
|
17
17
|
// If no user is specified, figure out the logged-in user's handle
|
|
18
18
|
if (!targetUser) {
|
|
19
19
|
await page.goto('https://x.com/home');
|
|
20
|
-
await page.wait(
|
|
20
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
21
21
|
const href = await page.evaluate(`() => {
|
|
22
22
|
const link = document.querySelector('a[data-testid="AppTabBar_Profile_Link"]');
|
|
23
23
|
return link ? link.getAttribute('href') : null;
|
|
@@ -44,7 +44,7 @@ cli({
|
|
|
44
44
|
if (!clicked) {
|
|
45
45
|
throw new SelectorError('Twitter following link', 'Twitter may have changed the layout.');
|
|
46
46
|
}
|
|
47
|
-
await page.
|
|
47
|
+
await page.waitForCapture(5);
|
|
48
48
|
// 4. Scroll to trigger pagination API calls
|
|
49
49
|
await page.autoScroll({ times: Math.ceil(kwargs.limit / 20), delayMs: 2000 });
|
|
50
50
|
// 5. Retrieve intercepted data
|
|
@@ -15,7 +15,7 @@ cli({
|
|
|
15
15
|
if (!page)
|
|
16
16
|
throw new CommandExecutionError('Browser session required for twitter hide-reply');
|
|
17
17
|
await page.goto(kwargs.url);
|
|
18
|
-
await page.wait(
|
|
18
|
+
await page.wait({ selector: '[data-testid="primaryColumn"]' });
|
|
19
19
|
const result = await page.evaluate(`(async () => {
|
|
20
20
|
try {
|
|
21
21
|
let attempts = 0;
|