@jackwener/opencli 1.7.3 → 1.7.4

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.
Files changed (93) hide show
  1. package/README.md +16 -16
  2. package/README.zh-CN.md +28 -15
  3. package/cli-manifest.json +547 -10
  4. package/clis/bilibili/favorite.js +18 -13
  5. package/clis/binance/depth.js +3 -4
  6. package/clis/boss/utils.js +2 -3
  7. package/clis/chatgpt-app/ax.js +6 -3
  8. package/clis/douban/search.js +1 -0
  9. package/clis/douban/search.test.js +11 -0
  10. package/clis/douban/subject.js +20 -93
  11. package/clis/douban/subject.test.js +11 -0
  12. package/clis/douban/utils.js +250 -8
  13. package/clis/douban/utils.test.js +179 -4
  14. package/clis/doubao/utils.js +319 -130
  15. package/clis/doubao/utils.test.js +241 -2
  16. package/clis/eastmoney/hot-rank.js +50 -0
  17. package/clis/eastmoney/hot-rank.test.js +59 -0
  18. package/clis/grok/image.test.ts +107 -0
  19. package/clis/grok/image.ts +356 -0
  20. package/clis/tdx/hot-rank.js +47 -0
  21. package/clis/tdx/hot-rank.test.js +59 -0
  22. package/clis/ths/hot-rank.js +49 -0
  23. package/clis/ths/hot-rank.test.js +64 -0
  24. package/clis/twitter/bookmarks.js +2 -1
  25. package/clis/uiverse/_shared.js +368 -0
  26. package/clis/uiverse/_shared.test.js +55 -0
  27. package/clis/uiverse/code.js +47 -0
  28. package/clis/uiverse/preview.js +71 -0
  29. package/clis/xiaohongshu/comments.js +2 -2
  30. package/clis/xiaohongshu/comments.test.js +46 -25
  31. package/clis/xiaohongshu/download.js +6 -7
  32. package/clis/xiaohongshu/download.test.js +17 -5
  33. package/clis/xiaohongshu/note-helpers.js +46 -12
  34. package/clis/xiaohongshu/note.js +3 -5
  35. package/clis/xiaohongshu/note.test.js +52 -25
  36. package/clis/xiaoyuzhou/auth.js +303 -0
  37. package/clis/xiaoyuzhou/auth.test.js +124 -0
  38. package/clis/xiaoyuzhou/download.js +49 -0
  39. package/clis/xiaoyuzhou/download.test.js +125 -0
  40. package/clis/xiaoyuzhou/transcript.js +76 -0
  41. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  42. package/clis/youtube/feed.js +120 -0
  43. package/clis/youtube/history.js +118 -0
  44. package/clis/youtube/like.js +62 -0
  45. package/clis/youtube/playlist.js +97 -0
  46. package/clis/youtube/subscribe.js +71 -0
  47. package/clis/youtube/subscriptions.js +57 -0
  48. package/clis/youtube/unlike.js +62 -0
  49. package/clis/youtube/unsubscribe.js +71 -0
  50. package/clis/youtube/utils.js +122 -0
  51. package/clis/youtube/utils.test.js +32 -1
  52. package/clis/youtube/watch-later.js +76 -0
  53. package/dist/src/browser/base-page.js +25 -5
  54. package/dist/src/browser/bridge.d.ts +2 -0
  55. package/dist/src/browser/bridge.js +51 -14
  56. package/dist/src/browser/cdp.js +1 -0
  57. package/dist/src/browser/daemon-client.d.ts +1 -0
  58. package/dist/src/browser/dom-snapshot.js +13 -1
  59. package/dist/src/browser/page.d.ts +4 -1
  60. package/dist/src/browser/page.js +48 -8
  61. package/dist/src/browser/page.test.js +61 -1
  62. package/dist/src/browser/target-errors.d.ts +23 -0
  63. package/dist/src/browser/target-errors.js +29 -0
  64. package/dist/src/browser/target-errors.test.d.ts +1 -0
  65. package/dist/src/browser/target-errors.test.js +61 -0
  66. package/dist/src/browser/target-resolver.d.ts +57 -0
  67. package/dist/src/browser/target-resolver.js +298 -0
  68. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  69. package/dist/src/browser/target-resolver.test.js +43 -0
  70. package/dist/src/browser.test.js +38 -1
  71. package/dist/src/cli.js +45 -37
  72. package/dist/src/commands/daemon.d.ts +4 -2
  73. package/dist/src/commands/daemon.js +22 -2
  74. package/dist/src/commands/daemon.test.js +65 -2
  75. package/dist/src/daemon.js +2 -0
  76. package/dist/src/doctor.d.ts +1 -0
  77. package/dist/src/doctor.js +32 -9
  78. package/dist/src/doctor.test.js +28 -12
  79. package/dist/src/external-clis.yaml +2 -2
  80. package/dist/src/logger.d.ts +2 -2
  81. package/dist/src/logger.js +3 -3
  82. package/dist/src/output.js +1 -5
  83. package/dist/src/output.test.js +0 -21
  84. package/dist/src/pipeline/steps/transform.js +1 -1
  85. package/dist/src/pipeline/template.d.ts +1 -0
  86. package/dist/src/pipeline/template.js +11 -3
  87. package/dist/src/pipeline/template.test.js +3 -0
  88. package/dist/src/pipeline/transform.test.js +14 -0
  89. package/dist/src/plugin.d.ts +7 -1
  90. package/dist/src/plugin.js +23 -1
  91. package/dist/src/plugin.test.js +15 -1
  92. package/dist/src/types.d.ts +1 -1
  93. package/package.json +1 -1
@@ -0,0 +1,303 @@
1
+ import * as fs from 'node:fs';
2
+ import * as os from 'node:os';
3
+ import * as path from 'node:path';
4
+ import { CliError, CommandExecutionError, ConfigError, EXIT_CODES, getErrorMessage } from '@jackwener/opencli/errors';
5
+
6
+ export const XIAOYUZHOU_API_BASE_URL = 'https://api.xiaoyuzhoufm.com';
7
+ export const XIAOYUZHOU_TOKEN_TTL_MS = 20 * 60 * 1000;
8
+ export const XIAOYUZHOU_REFRESH_SKEW_MS = 60 * 1000;
9
+ export const XIAOYUZHOU_DEFAULT_DEVICE_ID = '81ADBFD6-6921-482B-9AB9-A29E7CC7BB55';
10
+ export const XIAOYUZHOU_DEFAULT_DEVICE_PROPERTIES = '';
11
+ export const XIAOYUZHOU_DEFAULT_USER_AGENT = 'Xiaoyuzhou/2.98.0 (build:2908; iOS 26.2.1)';
12
+
13
+ function getNowMs() {
14
+ return Date.now();
15
+ }
16
+
17
+ export function getXiaoyuzhouCredentialFile() {
18
+ return path.join(os.homedir(), '.opencli', 'xiaoyuzhou.json');
19
+ }
20
+
21
+ function createXiaoyuzhouAuthError(message) {
22
+ return new CliError('AUTH_REQUIRED', message, `Update ${getXiaoyuzhouCredentialFile()} with fresh Xiaoyuzhou credentials before retrying.`, EXIT_CODES.NOPERM);
23
+ }
24
+
25
+ function coerceNumber(value) {
26
+ const parsed = Number(value);
27
+ return Number.isFinite(parsed) ? parsed : 0;
28
+ }
29
+
30
+ export function normalizeXiaoyuzhouCredentials(raw = {}) {
31
+ const lastUpdatedTs = coerceNumber(raw.last_updated_ts ?? raw.lastUpdatedTs);
32
+ let expiresAt = coerceNumber(raw.expires_at ?? raw.expiresAt);
33
+ if (expiresAt > 0 && expiresAt < 10_000_000_000) {
34
+ expiresAt *= 1000;
35
+ }
36
+ if (!expiresAt && lastUpdatedTs > 0) {
37
+ expiresAt = lastUpdatedTs * 1000 + XIAOYUZHOU_TOKEN_TTL_MS;
38
+ }
39
+ return {
40
+ access_token: String(raw.access_token ?? raw.accessToken ?? '').trim(),
41
+ refresh_token: String(raw.refresh_token ?? raw.refreshToken ?? '').trim(),
42
+ expires_at: expiresAt,
43
+ device_id: String(raw.device_id ?? raw.deviceId ?? XIAOYUZHOU_DEFAULT_DEVICE_ID).trim() || XIAOYUZHOU_DEFAULT_DEVICE_ID,
44
+ device_properties: String(raw.device_properties ?? raw.deviceProperties ?? XIAOYUZHOU_DEFAULT_DEVICE_PROPERTIES),
45
+ };
46
+ }
47
+ export function loadXiaoyuzhouCredentials() {
48
+ const filePath = getXiaoyuzhouCredentialFile();
49
+ if (fs.existsSync(filePath)) {
50
+ try {
51
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
52
+ const credentials = normalizeXiaoyuzhouCredentials(parsed);
53
+ if (!credentials.access_token || !credentials.refresh_token) {
54
+ throw new ConfigError(`Xiaoyuzhou credential file is missing access_token or refresh_token: ${filePath}`, 'Recreate the file with valid credentials.');
55
+ }
56
+ return credentials;
57
+ }
58
+ catch (error) {
59
+ if (error instanceof ConfigError) {
60
+ throw error;
61
+ }
62
+ throw new ConfigError(`Failed to parse Xiaoyuzhou credential file: ${filePath}`, `Ensure ${filePath} contains valid JSON. (${getErrorMessage(error)})`);
63
+ }
64
+ }
65
+ throw new ConfigError(`Missing Xiaoyuzhou credentials. Expected ${filePath}`, `Create ${filePath} with access_token and refresh_token.`);
66
+ }
67
+
68
+ export function saveXiaoyuzhouCredentials(credentials) {
69
+ const filePath = getXiaoyuzhouCredentialFile();
70
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
71
+ fs.writeFileSync(filePath, `${JSON.stringify({
72
+ access_token: credentials.access_token,
73
+ refresh_token: credentials.refresh_token,
74
+ expires_at: credentials.expires_at,
75
+ device_id: credentials.device_id,
76
+ device_properties: credentials.device_properties,
77
+ }, null, 2)}\n`, 'utf-8');
78
+ }
79
+
80
+ export function shouldRefreshXiaoyuzhouCredentials(credentials, now = getNowMs()) {
81
+ return Number.isFinite(credentials.expires_at)
82
+ && credentials.expires_at > 0
83
+ && now >= credentials.expires_at - XIAOYUZHOU_REFRESH_SKEW_MS;
84
+ }
85
+
86
+ export function buildXiaoyuzhouHeaders(credentials, options = {}) {
87
+ const {
88
+ contentType = 'application/json',
89
+ includeLocalTime = false,
90
+ includeRefreshToken = false,
91
+ } = options;
92
+ const headers = {
93
+ 'Content-Type': contentType,
94
+ Host: 'api.xiaoyuzhoufm.com',
95
+ 'User-Agent': XIAOYUZHOU_DEFAULT_USER_AGENT,
96
+ Market: 'AppStore',
97
+ 'App-BuildNo': '2908',
98
+ OS: 'ios',
99
+ Manufacturer: 'Apple',
100
+ BundleID: 'app.podcast.cosmos',
101
+ Connection: 'keep-alive',
102
+ 'abtest-info': '{"old_user_discovery_feed":"enable"}',
103
+ 'Accept-Language': 'en-HK;q=1.0, zh-Hans-HK;q=0.9',
104
+ Model: 'iPhone18,1',
105
+ 'app-permissions': '100000',
106
+ Accept: '*/*',
107
+ 'App-Version': '2.98.0',
108
+ WifiConnected: 'true',
109
+ 'OS-Version': '26.2.1',
110
+ 'x-custom-xiaoyuzhou-app-dev': '',
111
+ 'x-jike-device-id': credentials.device_id || XIAOYUZHOU_DEFAULT_DEVICE_ID,
112
+ 'x-jike-device-properties': credentials.device_properties ?? XIAOYUZHOU_DEFAULT_DEVICE_PROPERTIES,
113
+ };
114
+ if (credentials.access_token) {
115
+ headers['x-jike-access-token'] = credentials.access_token;
116
+ }
117
+ if (includeRefreshToken && credentials.refresh_token) {
118
+ headers['x-jike-refresh-token'] = credentials.refresh_token;
119
+ }
120
+ if (includeLocalTime) {
121
+ headers['Local-Time'] = new Date().toISOString();
122
+ headers.Timezone = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
123
+ }
124
+ return headers;
125
+ }
126
+
127
+ export async function refreshXiaoyuzhouCredentials(credentials, fetchImpl = fetch) {
128
+ if (!credentials.refresh_token) {
129
+ throw createXiaoyuzhouAuthError('Xiaoyuzhou refresh token is missing');
130
+ }
131
+ let response;
132
+ try {
133
+ response = await fetchImpl(`${XIAOYUZHOU_API_BASE_URL}/app_auth_tokens.refresh`, {
134
+ method: 'POST',
135
+ headers: buildXiaoyuzhouHeaders(credentials, {
136
+ contentType: 'application/x-www-form-urlencoded; charset=utf-8',
137
+ includeLocalTime: true,
138
+ includeRefreshToken: true,
139
+ }),
140
+ signal: AbortSignal.timeout(20_000),
141
+ });
142
+ }
143
+ catch (error) {
144
+ throw new CommandExecutionError(`Failed to refresh Xiaoyuzhou credentials: ${getErrorMessage(error)}`);
145
+ }
146
+ const bodyText = await response.text();
147
+ if (!response.ok) {
148
+ throw createXiaoyuzhouAuthError(`Xiaoyuzhou token refresh failed with HTTP ${response.status}${bodyText ? `: ${bodyText}` : ''}`);
149
+ }
150
+ let parsed;
151
+ try {
152
+ parsed = JSON.parse(bodyText);
153
+ }
154
+ catch (error) {
155
+ throw new CommandExecutionError(`Xiaoyuzhou refresh returned invalid JSON: ${getErrorMessage(error)}`);
156
+ }
157
+ if (!parsed?.success) {
158
+ throw createXiaoyuzhouAuthError('Xiaoyuzhou refresh API returned success=false');
159
+ }
160
+ const nextCredentials = normalizeXiaoyuzhouCredentials({
161
+ ...credentials,
162
+ access_token: parsed['x-jike-access-token'] || '',
163
+ refresh_token: parsed['x-jike-refresh-token'] || '',
164
+ expires_at: getNowMs() + XIAOYUZHOU_TOKEN_TTL_MS,
165
+ });
166
+ if (!nextCredentials.access_token || !nextCredentials.refresh_token) {
167
+ throw createXiaoyuzhouAuthError('Xiaoyuzhou refresh API returned empty access_token or refresh_token');
168
+ }
169
+ saveXiaoyuzhouCredentials(nextCredentials);
170
+ return nextCredentials;
171
+ }
172
+
173
+ function buildApiUrl(endpoint, query) {
174
+ const url = new URL(endpoint, XIAOYUZHOU_API_BASE_URL);
175
+ if (query) {
176
+ for (const [key, value] of Object.entries(query)) {
177
+ if (value !== undefined && value !== null && value !== '') {
178
+ url.searchParams.set(key, String(value));
179
+ }
180
+ }
181
+ }
182
+ return url.toString();
183
+ }
184
+
185
+ async function performXiaoyuzhouJsonRequest(endpoint, options, credentials, fetchImpl) {
186
+ const {
187
+ method = 'GET',
188
+ query,
189
+ body,
190
+ } = options;
191
+ let response;
192
+ try {
193
+ response = await fetchImpl(buildApiUrl(endpoint, query), {
194
+ method,
195
+ headers: buildXiaoyuzhouHeaders(credentials, {
196
+ contentType: 'application/json',
197
+ includeLocalTime: true,
198
+ }),
199
+ body: body === undefined ? undefined : JSON.stringify(body),
200
+ signal: AbortSignal.timeout(20_000),
201
+ });
202
+ }
203
+ catch (error) {
204
+ throw new CommandExecutionError(`Failed to reach Xiaoyuzhou API: ${getErrorMessage(error)}`);
205
+ }
206
+ return response;
207
+ }
208
+
209
+ export async function requestXiaoyuzhouJson(endpoint, options = {}, fetchImpl = fetch) {
210
+ let credentials = options.credentials ?? loadXiaoyuzhouCredentials();
211
+ if (shouldRefreshXiaoyuzhouCredentials(credentials)) {
212
+ credentials = await refreshXiaoyuzhouCredentials(credentials, fetchImpl);
213
+ }
214
+ let response = await performXiaoyuzhouJsonRequest(endpoint, options, credentials, fetchImpl);
215
+ if (response.status === 401) {
216
+ credentials = await refreshXiaoyuzhouCredentials(credentials, fetchImpl);
217
+ response = await performXiaoyuzhouJsonRequest(endpoint, options, credentials, fetchImpl);
218
+ }
219
+ const bodyText = await response.text();
220
+ if (!response.ok) {
221
+ throw new CommandExecutionError(`Xiaoyuzhou API request failed with HTTP ${response.status}${bodyText ? `: ${bodyText}` : ''}`);
222
+ }
223
+ let parsed;
224
+ try {
225
+ parsed = JSON.parse(bodyText);
226
+ }
227
+ catch (error) {
228
+ throw new CommandExecutionError(`Xiaoyuzhou API returned invalid JSON: ${getErrorMessage(error)}`);
229
+ }
230
+ if (parsed?.success === false) {
231
+ throw new CommandExecutionError(parsed?.message || parsed?.msg || 'Xiaoyuzhou API returned success=false');
232
+ }
233
+ return {
234
+ credentials,
235
+ raw: parsed,
236
+ data: parsed?.data,
237
+ };
238
+ }
239
+
240
+ export async function fetchXiaoyuzhouTranscriptBody(url, fetchImpl = fetch) {
241
+ let response;
242
+ try {
243
+ response = await fetchImpl(url, {
244
+ method: 'GET',
245
+ headers: {
246
+ 'User-Agent': XIAOYUZHOU_DEFAULT_USER_AGENT,
247
+ Accept: '*/*',
248
+ Market: 'AppStore',
249
+ },
250
+ signal: AbortSignal.timeout(20_000),
251
+ });
252
+ }
253
+ catch (error) {
254
+ throw new CommandExecutionError(`Failed to fetch Xiaoyuzhou transcript content: ${getErrorMessage(error)}`);
255
+ }
256
+ const bodyText = await response.text();
257
+ if (!response.ok) {
258
+ throw new CommandExecutionError(`Xiaoyuzhou transcript download failed with HTTP ${response.status}${bodyText ? `: ${bodyText}` : ''}`);
259
+ }
260
+ return bodyText;
261
+ }
262
+
263
+ export function extractTranscriptText(transcriptBody) {
264
+ let parsed;
265
+ try {
266
+ parsed = JSON.parse(transcriptBody);
267
+ }
268
+ catch {
269
+ return { text: '', segmentCount: 0 };
270
+ }
271
+ let items = [];
272
+ if (Array.isArray(parsed)) {
273
+ items = parsed;
274
+ }
275
+ else if (parsed && typeof parsed === 'object') {
276
+ for (const key of ['segments', 'data', 'transcript', 'items']) {
277
+ if (Array.isArray(parsed[key])) {
278
+ items = parsed[key];
279
+ break;
280
+ }
281
+ }
282
+ if (items.length === 0) {
283
+ const directText = typeof parsed.text === 'string' ? parsed.text.trim() : '';
284
+ if (directText) {
285
+ return { text: directText, segmentCount: 1 };
286
+ }
287
+ }
288
+ }
289
+ const textItems = [];
290
+ for (const item of items) {
291
+ if (!item || typeof item !== 'object' || typeof item.text !== 'string') {
292
+ continue;
293
+ }
294
+ const cleaned = item.text.trim();
295
+ if (cleaned) {
296
+ textItems.push(cleaned);
297
+ }
298
+ }
299
+ return {
300
+ text: textItems.join('\n'),
301
+ segmentCount: textItems.length,
302
+ };
303
+ }
@@ -0,0 +1,124 @@
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+
3
+ const { mockExistsSync, mockReadFileSync, mockMkdirSync, mockWriteFileSync, mockHomedir } = vi.hoisted(() => ({
4
+ mockExistsSync: vi.fn(),
5
+ mockReadFileSync: vi.fn(),
6
+ mockMkdirSync: vi.fn(),
7
+ mockWriteFileSync: vi.fn(),
8
+ mockHomedir: vi.fn(() => '/Users/tester'),
9
+ }));
10
+
11
+ vi.mock('node:fs', () => ({
12
+ existsSync: mockExistsSync,
13
+ readFileSync: mockReadFileSync,
14
+ mkdirSync: mockMkdirSync,
15
+ writeFileSync: mockWriteFileSync,
16
+ }));
17
+
18
+ vi.mock('node:os', () => ({
19
+ homedir: mockHomedir,
20
+ }));
21
+
22
+ const { extractTranscriptText, getXiaoyuzhouCredentialFile, loadXiaoyuzhouCredentials, normalizeXiaoyuzhouCredentials, refreshXiaoyuzhouCredentials, requestXiaoyuzhouJson, shouldRefreshXiaoyuzhouCredentials, XIAOYUZHOU_TOKEN_TTL_MS } = await import('./auth.js');
23
+
24
+ function createJsonResponse(status, payload) {
25
+ return {
26
+ ok: status >= 200 && status < 300,
27
+ status,
28
+ text: vi.fn().mockResolvedValue(JSON.stringify(payload)),
29
+ };
30
+ }
31
+
32
+ describe('xiaoyuzhou auth helpers', () => {
33
+ beforeEach(() => {
34
+ mockExistsSync.mockReset();
35
+ mockReadFileSync.mockReset();
36
+ mockMkdirSync.mockReset();
37
+ mockWriteFileSync.mockReset();
38
+ vi.useRealTimers();
39
+ });
40
+
41
+ it('loads credentials from the local credential file', () => {
42
+ mockExistsSync.mockReturnValue(true);
43
+ mockReadFileSync.mockReturnValue(JSON.stringify({
44
+ access_token: 'file-access',
45
+ refresh_token: 'file-refresh',
46
+ expires_at: 123,
47
+ }));
48
+ const credentials = loadXiaoyuzhouCredentials();
49
+ expect(mockReadFileSync).toHaveBeenCalledWith(getXiaoyuzhouCredentialFile(), 'utf-8');
50
+ expect(credentials.access_token).toBe('file-access');
51
+ expect(credentials.refresh_token).toBe('file-refresh');
52
+ });
53
+
54
+ it('refreshes credentials and persists the updated token file', async () => {
55
+ vi.useFakeTimers();
56
+ vi.setSystemTime(new Date('2026-04-15T00:00:00Z'));
57
+ const fetchMock = vi.fn().mockResolvedValue(createJsonResponse(200, {
58
+ success: true,
59
+ 'x-jike-access-token': 'new-access',
60
+ 'x-jike-refresh-token': 'new-refresh',
61
+ }));
62
+ const refreshed = await refreshXiaoyuzhouCredentials(normalizeXiaoyuzhouCredentials({
63
+ access_token: 'old-access',
64
+ refresh_token: 'old-refresh',
65
+ device_id: 'device-1',
66
+ device_properties: 'props',
67
+ }), fetchMock);
68
+ expect(refreshed.access_token).toBe('new-access');
69
+ expect(refreshed.refresh_token).toBe('new-refresh');
70
+ expect(refreshed.expires_at).toBe(Date.now() + XIAOYUZHOU_TOKEN_TTL_MS);
71
+ expect(mockMkdirSync).toHaveBeenCalledWith('/Users/tester/.opencli', { recursive: true });
72
+ expect(mockWriteFileSync).toHaveBeenCalledWith('/Users/tester/.opencli/xiaoyuzhou.json', expect.stringContaining('"access_token": "new-access"'), 'utf-8');
73
+ });
74
+
75
+ it('retries once on 401 using refreshed credentials', async () => {
76
+ const fetchMock = vi.fn()
77
+ .mockResolvedValueOnce({
78
+ ok: false,
79
+ status: 401,
80
+ text: vi.fn().mockResolvedValue('unauthorized'),
81
+ })
82
+ .mockResolvedValueOnce(createJsonResponse(200, {
83
+ success: true,
84
+ 'x-jike-access-token': 'refreshed-access',
85
+ 'x-jike-refresh-token': 'refreshed-refresh',
86
+ }))
87
+ .mockResolvedValueOnce(createJsonResponse(200, {
88
+ success: true,
89
+ data: { title: 'Transcript Episode' },
90
+ }));
91
+ const result = await requestXiaoyuzhouJson('/v1/episode/get', {
92
+ query: { eid: 'ep123' },
93
+ credentials: normalizeXiaoyuzhouCredentials({
94
+ access_token: 'old-access',
95
+ refresh_token: 'old-refresh',
96
+ }),
97
+ }, fetchMock);
98
+ expect(fetchMock).toHaveBeenCalledTimes(3);
99
+ expect(result.data).toEqual({ title: 'Transcript Episode' });
100
+ expect(result.credentials.access_token).toBe('refreshed-access');
101
+ });
102
+
103
+ it('extracts transcript text from segment arrays and direct text payloads', () => {
104
+ expect(extractTranscriptText(JSON.stringify({
105
+ segments: [{ text: 'hello ' }, { text: ' world' }],
106
+ }))).toEqual({
107
+ text: 'hello\nworld',
108
+ segmentCount: 2,
109
+ });
110
+ expect(extractTranscriptText(JSON.stringify({ text: 'full transcript' }))).toEqual({
111
+ text: 'full transcript',
112
+ segmentCount: 1,
113
+ });
114
+ });
115
+
116
+ it('detects credentials that are close to expiry', () => {
117
+ expect(shouldRefreshXiaoyuzhouCredentials({
118
+ expires_at: Date.now() - 1,
119
+ })).toBe(true);
120
+ expect(shouldRefreshXiaoyuzhouCredentials({
121
+ expires_at: Date.now() + 10 * 60 * 1000,
122
+ })).toBe(false);
123
+ });
124
+ });
@@ -0,0 +1,49 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { cli, Strategy } from '@jackwener/opencli/registry';
4
+ import { CliError } from '@jackwener/opencli/errors';
5
+ import { httpDownload, sanitizeFilename } from '@jackwener/opencli/download';
6
+ import { formatBytes } from '@jackwener/opencli/download/progress';
7
+ import { fetchPageProps } from './utils.js';
8
+
9
+ cli({
10
+ site: 'xiaoyuzhou',
11
+ name: 'download',
12
+ description: 'Download Xiaoyuzhou episode audio',
13
+ domain: 'www.xiaoyuzhoufm.com',
14
+ strategy: Strategy.PUBLIC,
15
+ browser: false,
16
+ args: [
17
+ { name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' },
18
+ { name: 'output', default: './xiaoyuzhou-downloads', help: 'Output directory' },
19
+ ],
20
+ columns: ['title', 'podcast', 'status', 'size', 'file'],
21
+ func: async (_page, args) => {
22
+ const pageProps = await fetchPageProps(`/episode/${args.id}`);
23
+ const ep = pageProps.episode;
24
+ if (!ep) {
25
+ throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
26
+ }
27
+ const audioUrl = ep.media?.source?.url;
28
+ if (!audioUrl) {
29
+ throw new CliError('PARSE_ERROR', 'Audio URL not found in episode payload', 'Episode payload does not expose media.source.url');
30
+ }
31
+ const output = String(args.output || './xiaoyuzhou-downloads');
32
+ const ext = path.extname(new URL(audioUrl).pathname) || '.mp3';
33
+ const title = String(ep.title || 'episode');
34
+ const filename = `${args.id}_${sanitizeFilename(title, 80) || 'episode'}${ext}`;
35
+ const outputDir = path.join(output, String(args.id));
36
+ fs.mkdirSync(outputDir, { recursive: true });
37
+ const destPath = path.join(outputDir, filename);
38
+ const result = await httpDownload(audioUrl, destPath, {
39
+ timeout: 60000,
40
+ });
41
+ return [{
42
+ title,
43
+ podcast: ep.podcast?.title || '-',
44
+ status: result.success ? 'success' : 'failed',
45
+ size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
46
+ file: result.success ? destPath : '-',
47
+ }];
48
+ },
49
+ });
@@ -0,0 +1,125 @@
1
+ import path from 'node:path';
2
+ import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+
5
+ const { mockFetchPageProps, mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
6
+ mockFetchPageProps: vi.fn(),
7
+ mockHttpDownload: vi.fn(),
8
+ mockMkdirSync: vi.fn(),
9
+ }));
10
+
11
+ vi.mock('./utils.js', async () => {
12
+ const actual = await vi.importActual('./utils.js');
13
+ return {
14
+ ...actual,
15
+ fetchPageProps: mockFetchPageProps,
16
+ };
17
+ });
18
+
19
+ vi.mock('@jackwener/opencli/download', () => ({
20
+ httpDownload: mockHttpDownload,
21
+ sanitizeFilename: vi.fn((value) => value.replace(/\s+/g, '_')),
22
+ }));
23
+
24
+ vi.mock('@jackwener/opencli/download/progress', () => ({
25
+ formatBytes: vi.fn((size) => `${size} B`),
26
+ }));
27
+
28
+ vi.mock('node:fs', () => ({
29
+ mkdirSync: mockMkdirSync,
30
+ }));
31
+
32
+ await import('./download.js');
33
+
34
+ let cmd;
35
+
36
+ function toPosixPath(value) {
37
+ return value.replaceAll(path.sep, '/');
38
+ }
39
+
40
+ beforeAll(() => {
41
+ cmd = getRegistry().get('xiaoyuzhou/download');
42
+ expect(cmd?.func).toBeTypeOf('function');
43
+ });
44
+
45
+ describe('xiaoyuzhou download', () => {
46
+ beforeEach(() => {
47
+ mockFetchPageProps.mockReset();
48
+ mockHttpDownload.mockReset();
49
+ mockMkdirSync.mockReset();
50
+ });
51
+
52
+ it('downloads audio from media.source.url into an episode subdirectory', async () => {
53
+ mockFetchPageProps.mockResolvedValue({
54
+ episode: {
55
+ title: 'Hello World',
56
+ podcast: { title: 'OpenCLI FM' },
57
+ media: {
58
+ source: {
59
+ url: 'https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc',
60
+ },
61
+ },
62
+ },
63
+ });
64
+ mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
65
+
66
+ const result = await cmd.func(null, {
67
+ id: 'ep123',
68
+ output: '/tmp/xiaoyuzhou-test',
69
+ });
70
+
71
+ expect(mockFetchPageProps).toHaveBeenCalledWith('/episode/ep123');
72
+ expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/xiaoyuzhou-test/ep123');
73
+ expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
74
+ expect(mockHttpDownload).toHaveBeenCalledWith('https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc', expect.stringContaining('/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3'), {
75
+ timeout: 60000,
76
+ });
77
+ expect(result).toEqual([{
78
+ title: 'Hello World',
79
+ podcast: 'OpenCLI FM',
80
+ status: 'success',
81
+ size: '1234 B',
82
+ file: '/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3',
83
+ }]);
84
+ });
85
+
86
+ it('preserves non-mp3 extensions from media.source.url', async () => {
87
+ mockFetchPageProps.mockResolvedValue({
88
+ episode: {
89
+ title: 'Lossless Episode',
90
+ podcast: { title: 'OpenCLI FM' },
91
+ media: {
92
+ source: {
93
+ url: 'https://media.xyzcdn.net/audio/lossless.m4a',
94
+ },
95
+ },
96
+ },
97
+ });
98
+ mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
99
+
100
+ const result = await cmd.func(null, {
101
+ id: 'ep456',
102
+ output: '/tmp/xiaoyuzhou-test',
103
+ });
104
+
105
+ expect(mockHttpDownload.mock.calls[0][1]).toContain('ep456_Lossless_Episode.m4a');
106
+ expect(result[0].file).toBe('/tmp/xiaoyuzhou-test/ep456/ep456_Lossless_Episode.m4a');
107
+ });
108
+
109
+ it('throws when media.source.url is missing', async () => {
110
+ mockFetchPageProps.mockResolvedValue({
111
+ episode: {
112
+ title: 'No Audio',
113
+ podcast: { title: 'OpenCLI FM' },
114
+ media: {},
115
+ },
116
+ });
117
+
118
+ await expect(cmd.func(null, { id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
119
+ code: 'PARSE_ERROR',
120
+ message: 'Audio URL not found in episode payload',
121
+ hint: 'Episode payload does not expose media.source.url',
122
+ });
123
+ expect(mockHttpDownload).not.toHaveBeenCalled();
124
+ });
125
+ });
@@ -0,0 +1,76 @@
1
+ import * as fs from 'node:fs';
2
+ import * as path from 'node:path';
3
+ import { cli, Strategy } from '@jackwener/opencli/registry';
4
+ import { ArgumentError, CliError } from '@jackwener/opencli/errors';
5
+ import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson, fetchXiaoyuzhouTranscriptBody, extractTranscriptText } from './auth.js';
6
+
7
+ cli({
8
+ site: 'xiaoyuzhou',
9
+ name: 'transcript',
10
+ description: 'Download Xiaoyuzhou transcript as JSON and text (requires local credentials)',
11
+ domain: 'www.xiaoyuzhoufm.com',
12
+ strategy: Strategy.PUBLIC,
13
+ browser: false,
14
+ args: [
15
+ { name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' },
16
+ { name: 'output', default: './xiaoyuzhou-transcripts', help: 'Output directory' },
17
+ { name: 'json', type: 'boolean', default: true, help: 'Save transcript JSON file' },
18
+ { name: 'text', type: 'boolean', default: true, help: 'Save extracted transcript text file' },
19
+ ],
20
+ columns: ['title', 'podcast', 'status', 'segments', 'json_file', 'text_file'],
21
+ func: async (_page, kwargs) => {
22
+ if (kwargs.json === false && kwargs.text === false) {
23
+ throw new ArgumentError('At least one of --json or --text must be enabled', 'Example: opencli xiaoyuzhou transcript 69dd0c98e2c8be31551f6a33 --text true');
24
+ }
25
+ let credentials = loadXiaoyuzhouCredentials();
26
+ const episodeResponse = await requestXiaoyuzhouJson('/v1/episode/get', {
27
+ query: { eid: kwargs.id },
28
+ credentials,
29
+ });
30
+ credentials = episodeResponse.credentials;
31
+ const episode = episodeResponse.data;
32
+ if (!episode) {
33
+ throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the episode ID');
34
+ }
35
+ const mediaId = String(episode.transcript?.mediaId || episode.media?.id || episode.transcriptMediaId || '').trim();
36
+ if (!mediaId) {
37
+ throw new CliError('PARSE_ERROR', 'mediaId not found in episode payload', 'Transcript metadata requires episode.transcript.mediaId, episode.media.id, or episode.transcriptMediaId');
38
+ }
39
+ const transcriptResponse = await requestXiaoyuzhouJson('/v1/episode-transcript/get', {
40
+ method: 'POST',
41
+ body: {
42
+ eid: kwargs.id,
43
+ mediaId,
44
+ },
45
+ credentials,
46
+ });
47
+ const transcriptMeta = transcriptResponse.data;
48
+ const transcriptUrl = String(transcriptMeta?.transcriptUrl || transcriptMeta?.url || '').trim();
49
+ if (!transcriptUrl) {
50
+ throw new CliError('EMPTY_RESULT', 'Transcript URL not found', 'This episode may not have transcript data available');
51
+ }
52
+ const transcriptBody = await fetchXiaoyuzhouTranscriptBody(transcriptUrl);
53
+ const { text, segmentCount } = extractTranscriptText(transcriptBody);
54
+ if (kwargs.text !== false && transcriptBody.trim() && !text.trim()) {
55
+ throw new CliError('PARSE_ERROR', 'Failed to extract transcript text', 'Transcript payload format is unsupported. Re-run with --json true to inspect the raw payload.');
56
+ }
57
+ const outputDir = path.join(String(kwargs.output || './xiaoyuzhou-transcripts'), String(kwargs.id));
58
+ fs.mkdirSync(outputDir, { recursive: true });
59
+ const jsonPath = path.join(outputDir, 'transcript.json');
60
+ const textPath = path.join(outputDir, 'transcript.txt');
61
+ if (kwargs.json !== false) {
62
+ fs.writeFileSync(jsonPath, transcriptBody, 'utf-8');
63
+ }
64
+ if (kwargs.text !== false) {
65
+ fs.writeFileSync(textPath, text, 'utf-8');
66
+ }
67
+ return [{
68
+ title: episode.title || 'episode',
69
+ podcast: episode.podcast?.title || '-',
70
+ status: 'success',
71
+ segments: kwargs.text === false ? '-' : String(segmentCount),
72
+ json_file: kwargs.json === false ? '-' : jsonPath,
73
+ text_file: kwargs.text === false ? '-' : textPath,
74
+ }];
75
+ },
76
+ });