@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,213 @@
|
|
|
1
|
+
import { Agent, EnvHttpProxyAgent, fetch as undiciFetch, type Dispatcher } from 'undici';
|
|
2
|
+
|
|
3
|
+
const LOOPBACK_NO_PROXY_ENTRIES = ['127.0.0.1', 'localhost', '::1'];
|
|
4
|
+
|
|
5
|
+
type ProxyEnvKey =
|
|
6
|
+
| 'http_proxy'
|
|
7
|
+
| 'https_proxy'
|
|
8
|
+
| 'all_proxy'
|
|
9
|
+
| 'HTTP_PROXY'
|
|
10
|
+
| 'HTTPS_PROXY'
|
|
11
|
+
| 'ALL_PROXY';
|
|
12
|
+
|
|
13
|
+
const PROXY_ENV_BY_PROTOCOL: Record<'http:' | 'https:', ProxyEnvKey[]> = {
|
|
14
|
+
'http:': ['http_proxy', 'HTTP_PROXY', 'all_proxy', 'ALL_PROXY'],
|
|
15
|
+
'https:': ['https_proxy', 'HTTPS_PROXY', 'all_proxy', 'ALL_PROXY'],
|
|
16
|
+
};
|
|
17
|
+
const DEFAULT_PORT_BY_PROTOCOL: Record<'http:' | 'https:', string> = {
|
|
18
|
+
'http:': '80',
|
|
19
|
+
'https:': '443',
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
export interface ProxyDecision {
|
|
23
|
+
mode: 'direct' | 'proxy';
|
|
24
|
+
proxyUrl?: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface NoProxyEntry {
|
|
28
|
+
host: string;
|
|
29
|
+
port?: string;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
interface ProxyConfig {
|
|
33
|
+
httpProxy?: string;
|
|
34
|
+
httpsProxy?: string;
|
|
35
|
+
noProxy?: string;
|
|
36
|
+
noProxyEntries: NoProxyEntry[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
let installed = false;
|
|
40
|
+
const directDispatcher = new Agent();
|
|
41
|
+
const proxyDispatcherCache = new Map<string, Dispatcher>();
|
|
42
|
+
const nativeFetch = globalThis.fetch.bind(globalThis);
|
|
43
|
+
|
|
44
|
+
function readEnv(env: NodeJS.ProcessEnv, lower: string, upper: string): string | undefined {
|
|
45
|
+
const lowerValue = env[lower];
|
|
46
|
+
if (typeof lowerValue === 'string' && lowerValue.trim() !== '') return lowerValue;
|
|
47
|
+
const upperValue = env[upper];
|
|
48
|
+
if (typeof upperValue === 'string' && upperValue.trim() !== '') return upperValue;
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function readProxyEnv(env: NodeJS.ProcessEnv, keys: ProxyEnvKey[]): string | undefined {
|
|
53
|
+
for (const key of keys) {
|
|
54
|
+
const value = env[key];
|
|
55
|
+
if (typeof value === 'string' && value.trim() !== '') return value;
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function normalizeHostname(hostname: string): string {
|
|
61
|
+
return hostname.replace(/^\[(.*)\]$/, '$1').toLowerCase();
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function splitNoProxy(raw: string | undefined): string[] {
|
|
65
|
+
return (raw ?? '')
|
|
66
|
+
.split(/[,\s]+/)
|
|
67
|
+
.map((token) => token.trim())
|
|
68
|
+
.filter(Boolean);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function parseNoProxyEntry(entry: string): NoProxyEntry {
|
|
72
|
+
if (entry === '*') return { host: '*' };
|
|
73
|
+
|
|
74
|
+
const trimmed = entry.trim().replace(/^\*?\./, '');
|
|
75
|
+
if (trimmed.startsWith('[')) {
|
|
76
|
+
const end = trimmed.indexOf(']');
|
|
77
|
+
if (end !== -1) {
|
|
78
|
+
const host = trimmed.slice(1, end);
|
|
79
|
+
const rest = trimmed.slice(end + 1);
|
|
80
|
+
if (rest.startsWith(':')) return { host: normalizeHostname(host), port: rest.slice(1) };
|
|
81
|
+
return { host: normalizeHostname(host) };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const colonCount = (trimmed.match(/:/g) ?? []).length;
|
|
86
|
+
if (colonCount === 1) {
|
|
87
|
+
const [host, port] = trimmed.split(':');
|
|
88
|
+
return { host: normalizeHostname(host), port };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return { host: normalizeHostname(trimmed) };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function effectiveNoProxyEntries(env: NodeJS.ProcessEnv): NoProxyEntry[] {
|
|
95
|
+
const raw = readEnv(env, 'no_proxy', 'NO_PROXY');
|
|
96
|
+
const entries = splitNoProxy(raw).map(parseNoProxyEntry);
|
|
97
|
+
const seen = new Set(entries.map((entry) => `${entry.host}:${entry.port ?? ''}`));
|
|
98
|
+
for (const rawEntry of LOOPBACK_NO_PROXY_ENTRIES) {
|
|
99
|
+
const entry = parseNoProxyEntry(rawEntry);
|
|
100
|
+
const key = `${entry.host}:${entry.port ?? ''}`;
|
|
101
|
+
if (seen.has(key)) continue;
|
|
102
|
+
entries.push(entry);
|
|
103
|
+
seen.add(key);
|
|
104
|
+
}
|
|
105
|
+
return entries;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function serializeNoProxyEntry(entry: NoProxyEntry): string {
|
|
109
|
+
if (entry.host === '*') return '*';
|
|
110
|
+
|
|
111
|
+
const host = entry.host.includes(':') ? `[${entry.host}]` : entry.host;
|
|
112
|
+
return entry.port ? `${host}:${entry.port}` : host;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function effectiveNoProxyValue(entries: NoProxyEntry[]): string | undefined {
|
|
116
|
+
if (entries.length === 0) return undefined;
|
|
117
|
+
|
|
118
|
+
return entries.map(serializeNoProxyEntry).join(',');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function matchesNoProxyEntry(url: URL, entry: NoProxyEntry): boolean {
|
|
122
|
+
const { host, port } = entry;
|
|
123
|
+
if (host === '*') return true;
|
|
124
|
+
|
|
125
|
+
const hostname = normalizeHostname(url.hostname);
|
|
126
|
+
const urlPort = url.port || DEFAULT_PORT_BY_PROTOCOL[url.protocol as 'http:' | 'https:'] || undefined;
|
|
127
|
+
if (port && port !== urlPort) return false;
|
|
128
|
+
return hostname === host || hostname.endsWith(`.${host}`);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function resolveProxyConfig(env: NodeJS.ProcessEnv = process.env): ProxyConfig {
|
|
132
|
+
const noProxyEntries = effectiveNoProxyEntries(env);
|
|
133
|
+
return {
|
|
134
|
+
httpProxy: readProxyEnv(env, PROXY_ENV_BY_PROTOCOL['http:']),
|
|
135
|
+
httpsProxy: readProxyEnv(env, [
|
|
136
|
+
'https_proxy',
|
|
137
|
+
'HTTPS_PROXY',
|
|
138
|
+
'http_proxy',
|
|
139
|
+
'HTTP_PROXY',
|
|
140
|
+
'all_proxy',
|
|
141
|
+
'ALL_PROXY',
|
|
142
|
+
]),
|
|
143
|
+
noProxy: effectiveNoProxyValue(noProxyEntries),
|
|
144
|
+
noProxyEntries,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function createProxyDispatcher(config: ProxyConfig): Dispatcher {
|
|
149
|
+
const cacheKey = JSON.stringify([
|
|
150
|
+
config.httpProxy ?? '',
|
|
151
|
+
config.httpsProxy ?? '',
|
|
152
|
+
config.noProxy ?? '',
|
|
153
|
+
]);
|
|
154
|
+
const cached = proxyDispatcherCache.get(cacheKey);
|
|
155
|
+
if (cached) return cached;
|
|
156
|
+
const dispatcher = new EnvHttpProxyAgent({
|
|
157
|
+
httpProxy: config.httpProxy,
|
|
158
|
+
httpsProxy: config.httpsProxy,
|
|
159
|
+
noProxy: config.noProxy,
|
|
160
|
+
});
|
|
161
|
+
proxyDispatcherCache.set(cacheKey, dispatcher);
|
|
162
|
+
return dispatcher;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function resolveUrl(input: RequestInfo | URL): URL | null {
|
|
166
|
+
if (typeof input === 'string') return new URL(input);
|
|
167
|
+
if (input instanceof URL) return input;
|
|
168
|
+
if (typeof Request !== 'undefined' && input instanceof Request) return new URL(input.url);
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export function hasProxyEnv(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
173
|
+
const config = resolveProxyConfig(env);
|
|
174
|
+
return Boolean(config.httpProxy || config.httpsProxy);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
export function decideProxy(url: URL, env: NodeJS.ProcessEnv = process.env): ProxyDecision {
|
|
178
|
+
const config = resolveProxyConfig(env);
|
|
179
|
+
if (config.noProxyEntries.some((entry) => matchesNoProxyEntry(url, entry))) {
|
|
180
|
+
return { mode: 'direct' };
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
const proxyUrl = url.protocol === 'https:' ? config.httpsProxy : config.httpProxy;
|
|
184
|
+
if (!proxyUrl) return { mode: 'direct' };
|
|
185
|
+
return { mode: 'proxy', proxyUrl };
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function getDispatcherForUrl(url: URL, env: NodeJS.ProcessEnv = process.env): Dispatcher {
|
|
189
|
+
const config = resolveProxyConfig(env);
|
|
190
|
+
if (!config.httpProxy && !config.httpsProxy) return directDispatcher;
|
|
191
|
+
return createProxyDispatcher(config);
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export async function fetchWithNodeNetwork(input: RequestInfo | URL, init: RequestInit = {}): Promise<Response> {
|
|
195
|
+
const url = resolveUrl(input);
|
|
196
|
+
if (!url || !hasProxyEnv()) {
|
|
197
|
+
return nativeFetch(input, init);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
return (await undiciFetch(input as Parameters<typeof undiciFetch>[0], {
|
|
201
|
+
...init,
|
|
202
|
+
dispatcher: getDispatcherForUrl(url),
|
|
203
|
+
} as Parameters<typeof undiciFetch>[1])) as unknown as Response;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export function installNodeNetwork(): void {
|
|
207
|
+
if (installed) return;
|
|
208
|
+
|
|
209
|
+
globalThis.fetch = ((input: RequestInfo | URL, init?: RequestInit) => (
|
|
210
|
+
fetchWithNodeNetwork(input, init)
|
|
211
|
+
)) as typeof globalThis.fetch;
|
|
212
|
+
installed = true;
|
|
213
|
+
}
|
|
@@ -31,6 +31,7 @@ function createMockPage(overrides: Partial<IPage> = {}): IPage {
|
|
|
31
31
|
installInterceptor: vi.fn(),
|
|
32
32
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
33
33
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
34
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
34
35
|
...overrides,
|
|
35
36
|
};
|
|
36
37
|
}
|
|
@@ -44,6 +44,7 @@ function createMockPage(getCookies: IPage['getCookies']): IPage {
|
|
|
44
44
|
installInterceptor: vi.fn(),
|
|
45
45
|
getInterceptedRequests: vi.fn().mockResolvedValue([]),
|
|
46
46
|
screenshot: vi.fn().mockResolvedValue(''),
|
|
47
|
+
waitForCapture: vi.fn().mockResolvedValue(undefined),
|
|
47
48
|
};
|
|
48
49
|
}
|
|
49
50
|
|
|
@@ -4,7 +4,6 @@
|
|
|
4
4
|
|
|
5
5
|
import type { IPage } from '../../types.js';
|
|
6
6
|
import { render, normalizeEvaluateSource } from '../template.js';
|
|
7
|
-
import { generateInterceptorJs, generateReadInterceptedJs } from '../../interceptor.js';
|
|
8
7
|
|
|
9
8
|
export async function stepIntercept(page: IPage | null, params: any, data: any, args: Record<string, any>): Promise<any> {
|
|
10
9
|
const cfg = typeof params === 'object' ? params : {};
|
|
@@ -16,7 +15,7 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
|
|
|
16
15
|
if (!capturePattern) return data;
|
|
17
16
|
|
|
18
17
|
// Step 1: Inject fetch/XHR interceptor BEFORE trigger
|
|
19
|
-
await page!.
|
|
18
|
+
await page!.installInterceptor(capturePattern);
|
|
20
19
|
|
|
21
20
|
// Step 2: Execute the trigger action
|
|
22
21
|
if (trigger.startsWith('navigate:')) {
|
|
@@ -32,11 +31,11 @@ export async function stepIntercept(page: IPage | null, params: any, data: any,
|
|
|
32
31
|
await page!.scroll('down');
|
|
33
32
|
}
|
|
34
33
|
|
|
35
|
-
// Step 3: Wait
|
|
36
|
-
await page!.
|
|
34
|
+
// Step 3: Wait for network capture (event-driven, not fixed sleep)
|
|
35
|
+
await page!.waitForCapture(timeout);
|
|
37
36
|
|
|
38
37
|
// Step 4: Retrieve captured data
|
|
39
|
-
const matchingResponses = await page!.
|
|
38
|
+
const matchingResponses = await page!.getInterceptedRequests();
|
|
40
39
|
|
|
41
40
|
// Step 5: Select from response if specified
|
|
42
41
|
let result = matchingResponses.length === 1 ? matchingResponses[0] :
|
package/src/types.ts
CHANGED
|
@@ -26,6 +26,7 @@ export interface SnapshotOptions {
|
|
|
26
26
|
|
|
27
27
|
export interface WaitOptions {
|
|
28
28
|
text?: string;
|
|
29
|
+
selector?: string; // wait until document.querySelector(selector) matches
|
|
29
30
|
time?: number;
|
|
30
31
|
timeout?: number;
|
|
31
32
|
}
|
|
@@ -64,6 +65,7 @@ export interface IPage {
|
|
|
64
65
|
autoScroll(options?: { times?: number; delayMs?: number }): Promise<void>;
|
|
65
66
|
installInterceptor(pattern: string): Promise<void>;
|
|
66
67
|
getInterceptedRequests(): Promise<any[]>;
|
|
68
|
+
waitForCapture(timeout?: number): Promise<void>;
|
|
67
69
|
screenshot(options?: ScreenshotOptions): Promise<string>;
|
|
68
70
|
closeWindow?(): Promise<void>;
|
|
69
71
|
/** Returns the current page URL, or null if unavailable. */
|
package/src/utils.ts
CHANGED
|
@@ -31,6 +31,11 @@ export async function mapConcurrent<T, R>(
|
|
|
31
31
|
return results;
|
|
32
32
|
}
|
|
33
33
|
|
|
34
|
+
/** Pause for the given number of milliseconds. */
|
|
35
|
+
export function sleep(ms: number): Promise<void> {
|
|
36
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
37
|
+
}
|
|
38
|
+
|
|
34
39
|
/** Save a base64-encoded string to a file, creating parent directories as needed. */
|
|
35
40
|
export async function saveBase64ToFile(base64: string, filePath: string): Promise<void> {
|
|
36
41
|
const dir = path.dirname(filePath);
|
|
@@ -56,6 +56,24 @@ describe('weread private API regression', () => {
|
|
|
56
56
|
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
|
|
57
57
|
});
|
|
58
58
|
|
|
59
|
+
it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
|
|
60
|
+
const mockPage = {
|
|
61
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
62
|
+
evaluate: vi.fn(),
|
|
63
|
+
} as any;
|
|
64
|
+
|
|
65
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
66
|
+
ok: true,
|
|
67
|
+
status: 200,
|
|
68
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
69
|
+
}));
|
|
70
|
+
|
|
71
|
+
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
|
|
72
|
+
code: 'AUTH_REQUIRED',
|
|
73
|
+
message: 'Not logged in to WeRead',
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
|
|
59
77
|
it('maps non-auth API errors to API_ERROR', async () => {
|
|
60
78
|
const mockPage = {
|
|
61
79
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
@@ -147,4 +165,193 @@ describe('weread private API regression', () => {
|
|
|
147
165
|
},
|
|
148
166
|
]);
|
|
149
167
|
});
|
|
168
|
+
|
|
169
|
+
it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
|
|
170
|
+
const command = getRegistry().get('weread/shelf');
|
|
171
|
+
expect(command?.func).toBeTypeOf('function');
|
|
172
|
+
|
|
173
|
+
const mockPage = {
|
|
174
|
+
getCookies: vi.fn()
|
|
175
|
+
.mockResolvedValueOnce([
|
|
176
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
177
|
+
])
|
|
178
|
+
.mockResolvedValueOnce([
|
|
179
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
180
|
+
]),
|
|
181
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
182
|
+
evaluate: vi.fn().mockImplementation(async (source: string) => {
|
|
183
|
+
expect(source).toContain('shelf:rawBooks:vid-current');
|
|
184
|
+
expect(source).toContain('shelf:shelfIndexes:vid-current');
|
|
185
|
+
return {
|
|
186
|
+
cacheFound: true,
|
|
187
|
+
rawBooks: [
|
|
188
|
+
{
|
|
189
|
+
bookId: '40055543',
|
|
190
|
+
title: '置身事内:中国政府与经济发展',
|
|
191
|
+
author: '兰小欢',
|
|
192
|
+
},
|
|
193
|
+
{
|
|
194
|
+
bookId: '29196155',
|
|
195
|
+
title: '文明、现代化、价值投资与中国',
|
|
196
|
+
author: '李录',
|
|
197
|
+
},
|
|
198
|
+
],
|
|
199
|
+
shelfIndexes: [
|
|
200
|
+
{ bookId: '29196155', idx: 0, role: 'book' },
|
|
201
|
+
{ bookId: '40055543', idx: 1, role: 'book' },
|
|
202
|
+
],
|
|
203
|
+
lastChapters: {
|
|
204
|
+
'29196155': 40,
|
|
205
|
+
'40055543': 60,
|
|
206
|
+
},
|
|
207
|
+
};
|
|
208
|
+
}),
|
|
209
|
+
} as any;
|
|
210
|
+
|
|
211
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
212
|
+
ok: false,
|
|
213
|
+
status: 401,
|
|
214
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
215
|
+
}));
|
|
216
|
+
|
|
217
|
+
const result = await command!.func!(mockPage, { limit: 1 });
|
|
218
|
+
|
|
219
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
220
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
221
|
+
expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
|
|
222
|
+
expect(result).toEqual([
|
|
223
|
+
{
|
|
224
|
+
title: '文明、现代化、价值投资与中国',
|
|
225
|
+
author: '李录',
|
|
226
|
+
progress: '-',
|
|
227
|
+
bookId: '29196155',
|
|
228
|
+
},
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
|
|
233
|
+
const command = getRegistry().get('weread/shelf');
|
|
234
|
+
expect(command?.func).toBeTypeOf('function');
|
|
235
|
+
|
|
236
|
+
const mockPage = {
|
|
237
|
+
getCookies: vi.fn()
|
|
238
|
+
.mockResolvedValueOnce([
|
|
239
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
240
|
+
])
|
|
241
|
+
.mockResolvedValueOnce([
|
|
242
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
243
|
+
]),
|
|
244
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
245
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
246
|
+
cacheFound: false,
|
|
247
|
+
rawBooks: [],
|
|
248
|
+
shelfIndexes: [],
|
|
249
|
+
lastChapters: {},
|
|
250
|
+
}),
|
|
251
|
+
} as any;
|
|
252
|
+
|
|
253
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
254
|
+
ok: false,
|
|
255
|
+
status: 401,
|
|
256
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
257
|
+
}));
|
|
258
|
+
|
|
259
|
+
await expect(command!.func!(mockPage, { limit: 20 })).rejects.toMatchObject({
|
|
260
|
+
code: 'AUTH_REQUIRED',
|
|
261
|
+
message: 'Not logged in to WeRead',
|
|
262
|
+
});
|
|
263
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
264
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
it('returns an empty list when the current session cache is confirmed but empty', async () => {
|
|
268
|
+
const command = getRegistry().get('weread/shelf');
|
|
269
|
+
expect(command?.func).toBeTypeOf('function');
|
|
270
|
+
|
|
271
|
+
const mockPage = {
|
|
272
|
+
getCookies: vi.fn()
|
|
273
|
+
.mockResolvedValueOnce([
|
|
274
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
275
|
+
])
|
|
276
|
+
.mockResolvedValueOnce([
|
|
277
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
278
|
+
]),
|
|
279
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
280
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
281
|
+
cacheFound: true,
|
|
282
|
+
rawBooks: [],
|
|
283
|
+
shelfIndexes: [],
|
|
284
|
+
lastChapters: {},
|
|
285
|
+
}),
|
|
286
|
+
} as any;
|
|
287
|
+
|
|
288
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
289
|
+
ok: false,
|
|
290
|
+
status: 401,
|
|
291
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
292
|
+
}));
|
|
293
|
+
|
|
294
|
+
const result = await command!.func!(mockPage, { limit: 20 });
|
|
295
|
+
|
|
296
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
297
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
298
|
+
expect(result).toEqual([]);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
|
|
302
|
+
const command = getRegistry().get('weread/shelf');
|
|
303
|
+
expect(command?.func).toBeTypeOf('function');
|
|
304
|
+
|
|
305
|
+
const mockPage = {
|
|
306
|
+
getCookies: vi.fn()
|
|
307
|
+
.mockResolvedValueOnce([
|
|
308
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
309
|
+
])
|
|
310
|
+
.mockResolvedValueOnce([
|
|
311
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
312
|
+
]),
|
|
313
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
314
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
315
|
+
cacheFound: true,
|
|
316
|
+
rawBooks: [
|
|
317
|
+
{
|
|
318
|
+
bookId: '40055543',
|
|
319
|
+
title: '置身事内:中国政府与经济发展',
|
|
320
|
+
author: '兰小欢',
|
|
321
|
+
},
|
|
322
|
+
{
|
|
323
|
+
bookId: '29196155',
|
|
324
|
+
title: '文明、现代化、价值投资与中国',
|
|
325
|
+
author: '李录',
|
|
326
|
+
},
|
|
327
|
+
],
|
|
328
|
+
shelfIndexes: [],
|
|
329
|
+
}),
|
|
330
|
+
} as any;
|
|
331
|
+
|
|
332
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
333
|
+
ok: false,
|
|
334
|
+
status: 401,
|
|
335
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
336
|
+
}));
|
|
337
|
+
|
|
338
|
+
const result = await command!.func!(mockPage, { limit: 2 });
|
|
339
|
+
|
|
340
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
|
|
341
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
342
|
+
expect(result).toEqual([
|
|
343
|
+
{
|
|
344
|
+
title: '置身事内:中国政府与经济发展',
|
|
345
|
+
author: '兰小欢',
|
|
346
|
+
progress: '-',
|
|
347
|
+
bookId: '40055543',
|
|
348
|
+
},
|
|
349
|
+
{
|
|
350
|
+
title: '文明、现代化、价值投资与中国',
|
|
351
|
+
author: '李录',
|
|
352
|
+
progress: '-',
|
|
353
|
+
bookId: '29196155',
|
|
354
|
+
},
|
|
355
|
+
]);
|
|
356
|
+
});
|
|
150
357
|
});
|
|
@@ -35,7 +35,7 @@ function isImdbChallenge(result: CliResult): boolean {
|
|
|
35
35
|
|
|
36
36
|
function isBrowserBridgeUnavailable(result: CliResult): boolean {
|
|
37
37
|
const text = `${result.stderr}\n${result.stdout}`;
|
|
38
|
-
return /Browser Bridge
|
|
38
|
+
return /Browser Bridge.*not connected|Extension.*not connected/i.test(text);
|
|
39
39
|
}
|
|
40
40
|
|
|
41
41
|
async function expectImdbDataOrChallengeSkip(args: string[], label: string): Promise<any[] | null> {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* E2E tests for output format rendering.
|
|
3
|
-
* Uses
|
|
3
|
+
* Uses the built-in list command so renderer coverage does not depend on
|
|
4
|
+
* external network availability.
|
|
4
5
|
*/
|
|
5
6
|
|
|
6
7
|
import { describe, it, expect } from 'vitest';
|
|
@@ -10,19 +11,22 @@ const FORMATS = ['json', 'yaml', 'csv', 'md'] as const;
|
|
|
10
11
|
|
|
11
12
|
describe('output formats E2E', () => {
|
|
12
13
|
for (const fmt of FORMATS) {
|
|
13
|
-
it(`
|
|
14
|
-
const { stdout, code } = await runCli(['
|
|
14
|
+
it(`list -f ${fmt} produces valid output`, async () => {
|
|
15
|
+
const { stdout, code } = await runCli(['list', '-f', fmt]);
|
|
15
16
|
expect(code).toBe(0);
|
|
16
17
|
expect(stdout.trim().length).toBeGreaterThan(0);
|
|
17
18
|
|
|
18
19
|
if (fmt === 'json') {
|
|
19
20
|
const data = parseJsonOutput(stdout);
|
|
20
21
|
expect(Array.isArray(data)).toBe(true);
|
|
21
|
-
expect(data.length).
|
|
22
|
+
expect(data.length).toBeGreaterThan(50);
|
|
23
|
+
expect(data[0]).toHaveProperty('command');
|
|
24
|
+
expect(data[0]).toHaveProperty('site');
|
|
22
25
|
}
|
|
23
26
|
|
|
24
27
|
if (fmt === 'yaml') {
|
|
25
|
-
expect(stdout).toContain('
|
|
28
|
+
expect(stdout).toContain('command:');
|
|
29
|
+
expect(stdout).toContain('site:');
|
|
26
30
|
}
|
|
27
31
|
|
|
28
32
|
if (fmt === 'csv') {
|
|
@@ -33,16 +37,8 @@ describe('output formats E2E', () => {
|
|
|
33
37
|
|
|
34
38
|
if (fmt === 'md') {
|
|
35
39
|
// Markdown table should have pipe characters
|
|
36
|
-
expect(stdout).toContain('|');
|
|
40
|
+
expect(stdout).toContain('| command |');
|
|
37
41
|
}
|
|
38
42
|
}, 30_000);
|
|
39
43
|
}
|
|
40
|
-
|
|
41
|
-
it('list -f csv produces valid csv', async () => {
|
|
42
|
-
const { stdout, code } = await runCli(['list', '-f', 'csv']);
|
|
43
|
-
expect(code).toBe(0);
|
|
44
|
-
const lines = stdout.trim().split('\n');
|
|
45
|
-
// Header + many data lines
|
|
46
|
-
expect(lines.length).toBeGreaterThan(50);
|
|
47
|
-
});
|
|
48
44
|
});
|
|
@@ -60,7 +60,10 @@ describe('plugin management E2E', () => {
|
|
|
60
60
|
const lock = JSON.parse(fs.readFileSync(LOCK_FILE, 'utf-8'));
|
|
61
61
|
expect(lock[PLUGIN_NAME]).toBeDefined();
|
|
62
62
|
expect(lock[PLUGIN_NAME].commitHash).toBeTruthy();
|
|
63
|
-
expect(lock[PLUGIN_NAME].source).
|
|
63
|
+
expect(lock[PLUGIN_NAME].source).toMatchObject({
|
|
64
|
+
kind: 'git',
|
|
65
|
+
});
|
|
66
|
+
expect(lock[PLUGIN_NAME].source.url).toContain('opencli-plugin-hot-digest');
|
|
64
67
|
expect(lock[PLUGIN_NAME].installedAt).toBeTruthy();
|
|
65
68
|
}, 60_000);
|
|
66
69
|
|
|
@@ -21,7 +21,7 @@ function isExpectedChineseSiteRestriction(code: number, stderr: string): boolean
|
|
|
21
21
|
|
|
22
22
|
function isExpectedApplePodcastsRestriction(code: number, stderr: string): boolean {
|
|
23
23
|
if (code === 0) return false;
|
|
24
|
-
return /Error \[FETCH_ERROR\]: (Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr)
|
|
24
|
+
return /(?:Error \[FETCH_ERROR\]: )?(Charts API HTTP \d+|Unable to reach Apple Podcasts charts)/.test(stderr)
|
|
25
25
|
|| stderr === ''; // timeout killed the process before any output
|
|
26
26
|
}
|
|
27
27
|
|
|
@@ -34,6 +34,17 @@ function isExpectedGoogleRestriction(code: number, stderr: string): boolean {
|
|
|
34
34
|
// Keep old name as alias for existing tests
|
|
35
35
|
const isExpectedXiaoyuzhouRestriction = isExpectedChineseSiteRestriction;
|
|
36
36
|
|
|
37
|
+
describe('public command restriction detectors', () => {
|
|
38
|
+
it('treats current Apple Podcasts CliError rendering as an expected restriction', () => {
|
|
39
|
+
expect(
|
|
40
|
+
isExpectedApplePodcastsRestriction(
|
|
41
|
+
1,
|
|
42
|
+
'⚠️ Unable to reach Apple Podcasts charts for US\n→ Apple charts may be temporarily unavailable (ECONNRESET). Try again later.\n',
|
|
43
|
+
),
|
|
44
|
+
).toBe(true);
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
|
|
37
48
|
describe('public commands E2E', () => {
|
|
38
49
|
// ── bloomberg (RSS-backed, browser: false) ──
|
|
39
50
|
it('bloomberg main returns structured headline data', async () => {
|
package/vitest.config.ts
CHANGED
|
@@ -16,21 +16,7 @@ export default defineConfig({
|
|
|
16
16
|
{
|
|
17
17
|
test: {
|
|
18
18
|
name: 'adapter',
|
|
19
|
-
include: [
|
|
20
|
-
'src/clis/bilibili/**/*.test.ts',
|
|
21
|
-
'src/clis/imdb/**/*.test.ts',
|
|
22
|
-
'src/clis/jd/**/*.test.ts',
|
|
23
|
-
'src/clis/linux-do/**/*.test.ts',
|
|
24
|
-
'src/clis/xiaohongshu/**/*.test.ts',
|
|
25
|
-
'src/clis/twitter/**/*.test.ts',
|
|
26
|
-
'src/clis/douban/**/*.test.ts',
|
|
27
|
-
'src/clis/zhihu/**/*.test.ts',
|
|
28
|
-
'src/clis/v2ex/**/*.test.ts',
|
|
29
|
-
'src/clis/weread/**/*.test.ts',
|
|
30
|
-
'src/clis/36kr/**/*.test.ts',
|
|
31
|
-
'src/clis/producthunt/**/*.test.ts',
|
|
32
|
-
'src/clis/paperreview/**/*.test.ts',
|
|
33
|
-
],
|
|
19
|
+
include: ['src/clis/**/*.test.ts'],
|
|
34
20
|
sequence: { groupOrder: 1 },
|
|
35
21
|
},
|
|
36
22
|
},
|