@jackwener/opencli 1.5.1 → 1.5.2
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/dist/browser/discover.js +11 -7
- package/dist/browser/index.d.ts +2 -0
- package/dist/browser/index.js +2 -0
- package/dist/browser/page.d.ts +1 -0
- package/dist/browser/page.js +28 -2
- package/dist/browser.test.js +5 -0
- package/dist/cli-manifest.json +4 -5
- package/dist/clis/apple-podcasts/commands.test.js +26 -3
- package/dist/clis/apple-podcasts/top.js +4 -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/daemon.js +1 -0
- package/dist/doctor.js +7 -3
- package/dist/execution.js +0 -6
- package/dist/extension-manifest-regression.test.d.ts +1 -0
- package/dist/extension-manifest-regression.test.js +12 -0
- package/dist/weread-private-api-regression.test.js +185 -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 +1 -1
- package/src/browser/discover.ts +10 -7
- package/src/browser/index.ts +2 -0
- package/src/browser/page.ts +25 -2
- package/src/browser.test.ts +6 -0
- package/src/clis/apple-podcasts/commands.test.ts +30 -2
- package/src/clis/apple-podcasts/top.ts +4 -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/daemon.ts +1 -0
- package/src/doctor.ts +9 -5
- package/src/execution.ts +0 -9
- package/src/extension-manifest-regression.test.ts +17 -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
|
@@ -44,6 +44,21 @@ describe('weread private API regression', () => {
|
|
|
44
44
|
}));
|
|
45
45
|
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toThrow('Not logged in');
|
|
46
46
|
});
|
|
47
|
+
it('maps auth-expired API error codes to AUTH_REQUIRED even on HTTP 200', async () => {
|
|
48
|
+
const mockPage = {
|
|
49
|
+
getCookies: vi.fn().mockResolvedValue([]),
|
|
50
|
+
evaluate: vi.fn(),
|
|
51
|
+
};
|
|
52
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
53
|
+
ok: true,
|
|
54
|
+
status: 200,
|
|
55
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
56
|
+
}));
|
|
57
|
+
await expect(fetchPrivateApi(mockPage, '/book/info')).rejects.toMatchObject({
|
|
58
|
+
code: 'AUTH_REQUIRED',
|
|
59
|
+
message: 'Not logged in to WeRead',
|
|
60
|
+
});
|
|
61
|
+
});
|
|
47
62
|
it('maps non-auth API errors to API_ERROR', async () => {
|
|
48
63
|
const mockPage = {
|
|
49
64
|
getCookies: vi.fn().mockResolvedValue([]),
|
|
@@ -119,4 +134,174 @@ describe('weread private API regression', () => {
|
|
|
119
134
|
},
|
|
120
135
|
]);
|
|
121
136
|
});
|
|
137
|
+
it('falls back to structured shelf cache when the private API reports AUTH_REQUIRED', async () => {
|
|
138
|
+
const command = getRegistry().get('weread/shelf');
|
|
139
|
+
expect(command?.func).toBeTypeOf('function');
|
|
140
|
+
const mockPage = {
|
|
141
|
+
getCookies: vi.fn()
|
|
142
|
+
.mockResolvedValueOnce([
|
|
143
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
144
|
+
])
|
|
145
|
+
.mockResolvedValueOnce([
|
|
146
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
147
|
+
]),
|
|
148
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
149
|
+
evaluate: vi.fn().mockImplementation(async (source) => {
|
|
150
|
+
expect(source).toContain('shelf:rawBooks:vid-current');
|
|
151
|
+
expect(source).toContain('shelf:shelfIndexes:vid-current');
|
|
152
|
+
return {
|
|
153
|
+
cacheFound: true,
|
|
154
|
+
rawBooks: [
|
|
155
|
+
{
|
|
156
|
+
bookId: '40055543',
|
|
157
|
+
title: '置身事内:中国政府与经济发展',
|
|
158
|
+
author: '兰小欢',
|
|
159
|
+
},
|
|
160
|
+
{
|
|
161
|
+
bookId: '29196155',
|
|
162
|
+
title: '文明、现代化、价值投资与中国',
|
|
163
|
+
author: '李录',
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
shelfIndexes: [
|
|
167
|
+
{ bookId: '29196155', idx: 0, role: 'book' },
|
|
168
|
+
{ bookId: '40055543', idx: 1, role: 'book' },
|
|
169
|
+
],
|
|
170
|
+
lastChapters: {
|
|
171
|
+
'29196155': 40,
|
|
172
|
+
'40055543': 60,
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
}),
|
|
176
|
+
};
|
|
177
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
178
|
+
ok: false,
|
|
179
|
+
status: 401,
|
|
180
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
181
|
+
}));
|
|
182
|
+
const result = await command.func(mockPage, { limit: 1 });
|
|
183
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
184
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
185
|
+
expect(mockPage.evaluate).toHaveBeenCalledTimes(1);
|
|
186
|
+
expect(result).toEqual([
|
|
187
|
+
{
|
|
188
|
+
title: '文明、现代化、价值投资与中国',
|
|
189
|
+
author: '李录',
|
|
190
|
+
progress: '-',
|
|
191
|
+
bookId: '29196155',
|
|
192
|
+
},
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
it('rethrows AUTH_REQUIRED when the current session has no structured shelf cache', async () => {
|
|
196
|
+
const command = getRegistry().get('weread/shelf');
|
|
197
|
+
expect(command?.func).toBeTypeOf('function');
|
|
198
|
+
const mockPage = {
|
|
199
|
+
getCookies: vi.fn()
|
|
200
|
+
.mockResolvedValueOnce([
|
|
201
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
202
|
+
])
|
|
203
|
+
.mockResolvedValueOnce([
|
|
204
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
205
|
+
]),
|
|
206
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
207
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
208
|
+
cacheFound: false,
|
|
209
|
+
rawBooks: [],
|
|
210
|
+
shelfIndexes: [],
|
|
211
|
+
lastChapters: {},
|
|
212
|
+
}),
|
|
213
|
+
};
|
|
214
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
215
|
+
ok: false,
|
|
216
|
+
status: 401,
|
|
217
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
218
|
+
}));
|
|
219
|
+
await expect(command.func(mockPage, { limit: 20 })).rejects.toMatchObject({
|
|
220
|
+
code: 'AUTH_REQUIRED',
|
|
221
|
+
message: 'Not logged in to WeRead',
|
|
222
|
+
});
|
|
223
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
224
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
225
|
+
});
|
|
226
|
+
it('returns an empty list when the current session cache is confirmed but empty', async () => {
|
|
227
|
+
const command = getRegistry().get('weread/shelf');
|
|
228
|
+
expect(command?.func).toBeTypeOf('function');
|
|
229
|
+
const mockPage = {
|
|
230
|
+
getCookies: vi.fn()
|
|
231
|
+
.mockResolvedValueOnce([
|
|
232
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
233
|
+
])
|
|
234
|
+
.mockResolvedValueOnce([
|
|
235
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
236
|
+
]),
|
|
237
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
238
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
239
|
+
cacheFound: true,
|
|
240
|
+
rawBooks: [],
|
|
241
|
+
shelfIndexes: [],
|
|
242
|
+
lastChapters: {},
|
|
243
|
+
}),
|
|
244
|
+
};
|
|
245
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
246
|
+
ok: false,
|
|
247
|
+
status: 401,
|
|
248
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
249
|
+
}));
|
|
250
|
+
const result = await command.func(mockPage, { limit: 20 });
|
|
251
|
+
expect(mockPage.goto).toHaveBeenCalledWith('https://weread.qq.com/web/shelf');
|
|
252
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
253
|
+
expect(result).toEqual([]);
|
|
254
|
+
});
|
|
255
|
+
it('falls back to raw book cache order when shelf indexes are unavailable', async () => {
|
|
256
|
+
const command = getRegistry().get('weread/shelf');
|
|
257
|
+
expect(command?.func).toBeTypeOf('function');
|
|
258
|
+
const mockPage = {
|
|
259
|
+
getCookies: vi.fn()
|
|
260
|
+
.mockResolvedValueOnce([
|
|
261
|
+
{ name: 'wr_skey', value: 'skey123', domain: '.weread.qq.com' },
|
|
262
|
+
])
|
|
263
|
+
.mockResolvedValueOnce([
|
|
264
|
+
{ name: 'wr_vid', value: 'vid-current', domain: '.weread.qq.com' },
|
|
265
|
+
]),
|
|
266
|
+
goto: vi.fn().mockResolvedValue(undefined),
|
|
267
|
+
evaluate: vi.fn().mockResolvedValue({
|
|
268
|
+
cacheFound: true,
|
|
269
|
+
rawBooks: [
|
|
270
|
+
{
|
|
271
|
+
bookId: '40055543',
|
|
272
|
+
title: '置身事内:中国政府与经济发展',
|
|
273
|
+
author: '兰小欢',
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
bookId: '29196155',
|
|
277
|
+
title: '文明、现代化、价值投资与中国',
|
|
278
|
+
author: '李录',
|
|
279
|
+
},
|
|
280
|
+
],
|
|
281
|
+
shelfIndexes: [],
|
|
282
|
+
}),
|
|
283
|
+
};
|
|
284
|
+
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({
|
|
285
|
+
ok: false,
|
|
286
|
+
status: 401,
|
|
287
|
+
json: () => Promise.resolve({ errcode: -2012, errmsg: '登录超时' }),
|
|
288
|
+
}));
|
|
289
|
+
const result = await command.func(mockPage, { limit: 2 });
|
|
290
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ url: 'https://i.weread.qq.com/shelf/sync?synckey=0&lectureSynckey=0' });
|
|
291
|
+
expect(mockPage.getCookies).toHaveBeenCalledWith({ domain: 'weread.qq.com' });
|
|
292
|
+
expect(result).toEqual([
|
|
293
|
+
{
|
|
294
|
+
title: '置身事内:中国政府与经济发展',
|
|
295
|
+
author: '兰小欢',
|
|
296
|
+
progress: '-',
|
|
297
|
+
bookId: '40055543',
|
|
298
|
+
},
|
|
299
|
+
{
|
|
300
|
+
title: '文明、现代化、价值投资与中国',
|
|
301
|
+
author: '李录',
|
|
302
|
+
progress: '-',
|
|
303
|
+
bookId: '29196155',
|
|
304
|
+
},
|
|
305
|
+
]);
|
|
306
|
+
});
|
|
122
307
|
});
|
|
@@ -164,6 +164,7 @@ function connect() {
|
|
|
164
164
|
clearTimeout(reconnectTimer);
|
|
165
165
|
reconnectTimer = null;
|
|
166
166
|
}
|
|
167
|
+
ws?.send(JSON.stringify({ type: "hello", version: chrome.runtime.getManifest().version }));
|
|
167
168
|
};
|
|
168
169
|
ws.onmessage = async (event) => {
|
|
169
170
|
try {
|
|
@@ -195,7 +196,7 @@ function scheduleReconnect() {
|
|
|
195
196
|
}, delay);
|
|
196
197
|
}
|
|
197
198
|
const automationSessions = /* @__PURE__ */ new Map();
|
|
198
|
-
const WINDOW_IDLE_TIMEOUT =
|
|
199
|
+
const WINDOW_IDLE_TIMEOUT = 3e4;
|
|
199
200
|
function getWorkspaceKey(workspace) {
|
|
200
201
|
return workspace?.trim() || "default";
|
|
201
202
|
}
|
|
@@ -230,7 +231,8 @@ async function getAutomationWindow(workspace) {
|
|
|
230
231
|
focused: false,
|
|
231
232
|
width: 1280,
|
|
232
233
|
height: 900,
|
|
233
|
-
type: "normal"
|
|
234
|
+
type: "normal",
|
|
235
|
+
state: "minimized"
|
|
234
236
|
});
|
|
235
237
|
const session = {
|
|
236
238
|
windowId: win.id,
|
package/extension/manifest.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"manifest_version": 3,
|
|
3
3
|
"name": "OpenCLI",
|
|
4
|
-
"version": "1.
|
|
4
|
+
"version": "1.5.2",
|
|
5
5
|
"description": "Browser automation bridge for the OpenCLI CLI tool. Executes commands in isolated Chrome windows via a local daemon.",
|
|
6
6
|
"permissions": [
|
|
7
7
|
"debugger",
|
|
@@ -10,6 +10,9 @@
|
|
|
10
10
|
"activeTab",
|
|
11
11
|
"alarms"
|
|
12
12
|
],
|
|
13
|
+
"host_permissions": [
|
|
14
|
+
"<all_urls>"
|
|
15
|
+
],
|
|
13
16
|
"background": {
|
|
14
17
|
"service_worker": "dist/background.js",
|
|
15
18
|
"type": "module"
|
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencli-extension",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "1.5.2",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "opencli-extension",
|
|
9
|
-
"version": "
|
|
9
|
+
"version": "1.5.2",
|
|
10
10
|
"devDependencies": {
|
|
11
11
|
"@types/chrome": "^0.0.287",
|
|
12
12
|
"typescript": "^5.7.0",
|
package/extension/package.json
CHANGED
|
@@ -106,7 +106,7 @@ type AutomationSession = {
|
|
|
106
106
|
};
|
|
107
107
|
|
|
108
108
|
const automationSessions = new Map<string, AutomationSession>();
|
|
109
|
-
const WINDOW_IDLE_TIMEOUT =
|
|
109
|
+
const WINDOW_IDLE_TIMEOUT = 30000; // 30s — quick cleanup after command finishes
|
|
110
110
|
|
|
111
111
|
function getWorkspaceKey(workspace?: string): string {
|
|
112
112
|
return workspace?.trim() || 'default';
|
|
@@ -152,6 +152,7 @@ async function getAutomationWindow(workspace: string): Promise<number> {
|
|
|
152
152
|
width: 1280,
|
|
153
153
|
height: 900,
|
|
154
154
|
type: 'normal',
|
|
155
|
+
state: 'minimized',
|
|
155
156
|
});
|
|
156
157
|
const session: AutomationSession = {
|
|
157
158
|
windowId: win.id!,
|
package/package.json
CHANGED
package/src/browser/discover.ts
CHANGED
|
@@ -22,13 +22,16 @@ export async function checkDaemonStatus(opts?: { timeout?: number }): Promise<{
|
|
|
22
22
|
const port = parseInt(process.env.OPENCLI_DAEMON_PORT ?? String(DEFAULT_DAEMON_PORT), 10);
|
|
23
23
|
const controller = new AbortController();
|
|
24
24
|
const timer = setTimeout(() => controller.abort(), opts?.timeout ?? 2000);
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
25
|
+
try {
|
|
26
|
+
const res = await fetch(`http://127.0.0.1:${port}/status`, {
|
|
27
|
+
headers: { 'X-OpenCLI': '1' },
|
|
28
|
+
signal: controller.signal,
|
|
29
|
+
});
|
|
30
|
+
const data = await res.json() as { ok: boolean; extensionConnected: boolean; extensionVersion?: string };
|
|
31
|
+
return { running: true, extensionConnected: data.extensionConnected, extensionVersion: data.extensionVersion };
|
|
32
|
+
} finally {
|
|
33
|
+
clearTimeout(timer);
|
|
34
|
+
}
|
|
32
35
|
} catch {
|
|
33
36
|
return { running: false, extensionConnected: false };
|
|
34
37
|
}
|
package/src/browser/index.ts
CHANGED
|
@@ -15,6 +15,7 @@ export type { DomSnapshotOptions } from './dom-snapshot.js';
|
|
|
15
15
|
|
|
16
16
|
import { extractTabEntries, diffTabIndexes, appendLimited } from './tabs.js';
|
|
17
17
|
import { __test__ as cdpTest } from './cdp.js';
|
|
18
|
+
import { isRetryableSettleError } from './page.js';
|
|
18
19
|
import { withTimeoutMs } from '../runtime.js';
|
|
19
20
|
|
|
20
21
|
export const __test__ = {
|
|
@@ -24,4 +25,5 @@ export const __test__ = {
|
|
|
24
25
|
withTimeoutMs,
|
|
25
26
|
selectCDPTarget: cdpTest.selectCDPTarget,
|
|
26
27
|
scoreCDPTarget: cdpTest.scoreCDPTarget,
|
|
28
|
+
isRetryableSettleError,
|
|
27
29
|
};
|
package/src/browser/page.ts
CHANGED
|
@@ -28,6 +28,12 @@ import {
|
|
|
28
28
|
waitForDomStableJs,
|
|
29
29
|
} from './dom-helpers.js';
|
|
30
30
|
|
|
31
|
+
export function isRetryableSettleError(err: unknown): boolean {
|
|
32
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
33
|
+
return message.includes('Inspected target navigated or closed')
|
|
34
|
+
|| (message.includes('-32000') && message.toLowerCase().includes('target'));
|
|
35
|
+
}
|
|
36
|
+
|
|
31
37
|
/**
|
|
32
38
|
* Page — implements IPage by talking to the daemon via HTTP.
|
|
33
39
|
*/
|
|
@@ -75,10 +81,27 @@ export class Page implements IPage {
|
|
|
75
81
|
// settleMs is now a timeout cap (default 1000ms), not a fixed wait.
|
|
76
82
|
if (options?.waitUntil !== 'none') {
|
|
77
83
|
const maxMs = options?.settleMs ?? 1000;
|
|
78
|
-
|
|
84
|
+
const settleOpts = {
|
|
79
85
|
code: waitForDomStableJs(maxMs, Math.min(500, maxMs)),
|
|
80
86
|
...this._cmdOpts(),
|
|
81
|
-
}
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
await sendCommand('exec', settleOpts);
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (!isRetryableSettleError(err)) throw err;
|
|
92
|
+
// SPA client-side redirects can invalidate the CDP target after
|
|
93
|
+
// chrome.tabs reports 'complete'. Wait briefly for the new document
|
|
94
|
+
// to load, then retry the settle probe once.
|
|
95
|
+
try {
|
|
96
|
+
await new Promise((r) => setTimeout(r, 200));
|
|
97
|
+
await sendCommand('exec', settleOpts);
|
|
98
|
+
} catch (retryErr) {
|
|
99
|
+
if (!isRetryableSettleError(retryErr)) throw retryErr;
|
|
100
|
+
// Retry also failed — give up silently. Settle is best-effort
|
|
101
|
+
// after successful navigation; the next real command will surface
|
|
102
|
+
// any persistent target error immediately.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
82
105
|
}
|
|
83
106
|
}
|
|
84
107
|
|
package/src/browser.test.ts
CHANGED
|
@@ -45,6 +45,12 @@ describe('browser helpers', () => {
|
|
|
45
45
|
await expect(__test__.withTimeoutMs(new Promise(() => {}), 10, 'timeout')).rejects.toThrow('timeout');
|
|
46
46
|
});
|
|
47
47
|
|
|
48
|
+
it('retries settle only for target-invalidated errors', () => {
|
|
49
|
+
expect(__test__.isRetryableSettleError(new Error('{"code":-32000,"message":"Inspected target navigated or closed"}'))).toBe(true);
|
|
50
|
+
expect(__test__.isRetryableSettleError(new Error('attach failed: target no longer exists'))).toBe(false);
|
|
51
|
+
expect(__test__.isRetryableSettleError(new Error('malformed exec payload'))).toBe(false);
|
|
52
|
+
});
|
|
53
|
+
|
|
48
54
|
it('prefers the real Electron app target over DevTools and blank pages', () => {
|
|
49
55
|
const target = __test__.selectCDPTarget([
|
|
50
56
|
{
|
|
@@ -38,13 +38,14 @@ describe('apple-podcasts search command', () => {
|
|
|
38
38
|
'https://itunes.apple.com/search?term=machine%20learning&media=podcast&limit=5',
|
|
39
39
|
);
|
|
40
40
|
expect(result).toEqual([
|
|
41
|
-
{
|
|
41
|
+
expect.objectContaining({
|
|
42
42
|
id: 42,
|
|
43
43
|
title: 'Machine Learning Guide',
|
|
44
44
|
author: 'OpenCLI',
|
|
45
45
|
episodes: 12,
|
|
46
46
|
genre: 'Technology',
|
|
47
|
-
|
|
47
|
+
url: '',
|
|
48
|
+
}),
|
|
48
49
|
]);
|
|
49
50
|
});
|
|
50
51
|
});
|
|
@@ -54,6 +55,30 @@ describe('apple-podcasts top command', () => {
|
|
|
54
55
|
vi.restoreAllMocks();
|
|
55
56
|
});
|
|
56
57
|
|
|
58
|
+
it('adds a timeout signal to chart fetches', async () => {
|
|
59
|
+
const cmd = getRegistry().get('apple-podcasts/top');
|
|
60
|
+
expect(cmd?.func).toBeTypeOf('function');
|
|
61
|
+
|
|
62
|
+
const fetchMock = vi.fn().mockResolvedValue({
|
|
63
|
+
ok: true,
|
|
64
|
+
json: () => Promise.resolve({
|
|
65
|
+
feed: {
|
|
66
|
+
results: [
|
|
67
|
+
{ id: '100', name: 'Top Show', artistName: 'Host A' },
|
|
68
|
+
],
|
|
69
|
+
},
|
|
70
|
+
}),
|
|
71
|
+
});
|
|
72
|
+
vi.stubGlobal('fetch', fetchMock);
|
|
73
|
+
|
|
74
|
+
await cmd!.func!(null as any, { country: 'US', limit: 1 });
|
|
75
|
+
|
|
76
|
+
const [, options] = fetchMock.mock.calls[0] ?? [];
|
|
77
|
+
expect(options).toBeDefined();
|
|
78
|
+
expect(options.signal).toBeDefined();
|
|
79
|
+
expect(options.signal).toHaveProperty('aborted', false);
|
|
80
|
+
});
|
|
81
|
+
|
|
57
82
|
it('uses the canonical Apple charts host and maps ranked results', async () => {
|
|
58
83
|
const cmd = getRegistry().get('apple-podcasts/top');
|
|
59
84
|
expect(cmd?.func).toBeTypeOf('function');
|
|
@@ -75,6 +100,9 @@ describe('apple-podcasts top command', () => {
|
|
|
75
100
|
|
|
76
101
|
expect(fetchMock).toHaveBeenCalledWith(
|
|
77
102
|
'https://rss.marketingtools.apple.com/api/v2/us/podcasts/top/2/podcasts.json',
|
|
103
|
+
expect.objectContaining({
|
|
104
|
+
signal: expect.any(Object),
|
|
105
|
+
}),
|
|
78
106
|
);
|
|
79
107
|
expect(result).toEqual([
|
|
80
108
|
{ rank: 1, title: 'Top Show', author: 'Host A', id: '100' },
|
|
@@ -3,6 +3,7 @@ import { CliError } from '../../errors.js';
|
|
|
3
3
|
|
|
4
4
|
// Apple Marketing Tools RSS API — public, no key required
|
|
5
5
|
const CHARTS_URL = 'https://rss.marketingtools.apple.com/api/v2';
|
|
6
|
+
const CHARTS_TIMEOUT_MS = 15_000;
|
|
6
7
|
|
|
7
8
|
cli({
|
|
8
9
|
site: 'apple-podcasts',
|
|
@@ -21,7 +22,9 @@ cli({
|
|
|
21
22
|
const url = `${CHARTS_URL}/${country}/podcasts/top/${limit}/podcasts.json`;
|
|
22
23
|
let resp: Response;
|
|
23
24
|
try {
|
|
24
|
-
resp = await fetch(url
|
|
25
|
+
resp = await fetch(url, {
|
|
26
|
+
signal: AbortSignal.timeout(CHARTS_TIMEOUT_MS),
|
|
27
|
+
});
|
|
25
28
|
} catch (error: any) {
|
|
26
29
|
const reason = error?.cause?.code ?? error?.message ?? 'unknown network error';
|
|
27
30
|
throw new CliError(
|
package/src/clis/v2ex/hot.yaml
CHANGED
|
@@ -3,7 +3,7 @@ name: hot
|
|
|
3
3
|
description: V2EX 热门话题
|
|
4
4
|
domain: www.v2ex.com
|
|
5
5
|
strategy: public
|
|
6
|
-
browser:
|
|
6
|
+
browser: false
|
|
7
7
|
|
|
8
8
|
args:
|
|
9
9
|
limit:
|
|
@@ -12,22 +12,8 @@ args:
|
|
|
12
12
|
description: Number of topics
|
|
13
13
|
|
|
14
14
|
pipeline:
|
|
15
|
-
-
|
|
16
|
-
|
|
17
|
-
- evaluate: |
|
|
18
|
-
(async () => {
|
|
19
|
-
const response = await fetch('/api/topics/hot.json', {
|
|
20
|
-
credentials: 'include',
|
|
21
|
-
headers: {
|
|
22
|
-
accept: 'application/json, text/plain, */*',
|
|
23
|
-
'x-requested-with': 'XMLHttpRequest',
|
|
24
|
-
},
|
|
25
|
-
});
|
|
26
|
-
if (!response.ok) {
|
|
27
|
-
throw new Error(`V2EX hot API request failed: ${response.status}`);
|
|
28
|
-
}
|
|
29
|
-
return await response.json();
|
|
30
|
-
})()
|
|
15
|
+
- fetch:
|
|
16
|
+
url: https://www.v2ex.com/api/topics/hot.json
|
|
31
17
|
|
|
32
18
|
- map:
|
|
33
19
|
rank: ${{ index + 1 }}
|