@jackwener/opencli 1.7.3 → 1.7.5

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 (197) hide show
  1. package/README.md +81 -59
  2. package/README.zh-CN.md +93 -67
  3. package/cli-manifest.json +5015 -2975
  4. package/clis/antigravity/serve.js +71 -25
  5. package/clis/baidu-scholar/search.js +87 -0
  6. package/clis/baidu-scholar/search.test.js +23 -0
  7. package/clis/bilibili/favorite.js +18 -13
  8. package/clis/binance/depth.js +3 -4
  9. package/clis/boss/utils.js +2 -3
  10. package/clis/chatgpt-app/ax.js +6 -3
  11. package/clis/deepseek/ask.js +74 -0
  12. package/clis/deepseek/history.js +25 -0
  13. package/clis/deepseek/new.js +20 -0
  14. package/clis/deepseek/read.js +22 -0
  15. package/clis/deepseek/status.js +24 -0
  16. package/clis/deepseek/utils.js +208 -0
  17. package/clis/douban/search.js +1 -0
  18. package/clis/douban/search.test.js +11 -0
  19. package/clis/douban/subject.js +20 -93
  20. package/clis/douban/subject.test.js +11 -0
  21. package/clis/douban/utils.js +250 -8
  22. package/clis/douban/utils.test.js +179 -4
  23. package/clis/doubao/utils.js +319 -130
  24. package/clis/doubao/utils.test.js +241 -2
  25. package/clis/eastmoney/_secid.js +78 -0
  26. package/clis/eastmoney/announcement.js +52 -0
  27. package/clis/eastmoney/convertible.js +73 -0
  28. package/clis/eastmoney/etf.js +65 -0
  29. package/clis/eastmoney/holders.js +78 -0
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/eastmoney/index-board.js +96 -0
  33. package/clis/eastmoney/kline.js +87 -0
  34. package/clis/eastmoney/kuaixun.js +54 -0
  35. package/clis/eastmoney/longhu.js +67 -0
  36. package/clis/eastmoney/money-flow.js +78 -0
  37. package/clis/eastmoney/northbound.js +57 -0
  38. package/clis/eastmoney/quote.js +107 -0
  39. package/clis/eastmoney/rank.js +94 -0
  40. package/clis/eastmoney/sectors.js +76 -0
  41. package/clis/google-scholar/search.js +58 -0
  42. package/clis/google-scholar/search.test.js +23 -0
  43. package/clis/gov-law/commands.test.js +39 -0
  44. package/clis/gov-law/recent.js +22 -0
  45. package/clis/gov-law/search.js +41 -0
  46. package/clis/gov-law/shared.js +51 -0
  47. package/clis/gov-policy/commands.test.js +27 -0
  48. package/clis/gov-policy/recent.js +47 -0
  49. package/clis/gov-policy/search.js +48 -0
  50. package/clis/grok/image.test.ts +107 -0
  51. package/clis/grok/image.ts +356 -0
  52. package/clis/nowcoder/companies.js +23 -0
  53. package/clis/nowcoder/creators.js +27 -0
  54. package/clis/nowcoder/detail.js +61 -0
  55. package/clis/nowcoder/experience.js +36 -0
  56. package/clis/nowcoder/hot.js +24 -0
  57. package/clis/nowcoder/jobs.js +21 -0
  58. package/clis/nowcoder/notifications.js +29 -0
  59. package/clis/nowcoder/papers.js +40 -0
  60. package/clis/nowcoder/practice.js +37 -0
  61. package/clis/nowcoder/recommend.js +30 -0
  62. package/clis/nowcoder/referral.js +39 -0
  63. package/clis/nowcoder/salary.js +40 -0
  64. package/clis/nowcoder/search.js +49 -0
  65. package/clis/nowcoder/suggest.js +33 -0
  66. package/clis/nowcoder/topics.js +27 -0
  67. package/clis/nowcoder/trending.js +25 -0
  68. package/clis/tdx/hot-rank.js +47 -0
  69. package/clis/tdx/hot-rank.test.js +59 -0
  70. package/clis/ths/hot-rank.js +49 -0
  71. package/clis/ths/hot-rank.test.js +64 -0
  72. package/clis/twitter/bookmarks.js +2 -1
  73. package/clis/twitter/list-add.js +337 -0
  74. package/clis/twitter/list-add.test.js +15 -0
  75. package/clis/twitter/list-remove.js +297 -0
  76. package/clis/twitter/list-remove.test.js +14 -0
  77. package/clis/twitter/list-tweets.js +185 -0
  78. package/clis/twitter/list-tweets.test.js +108 -0
  79. package/clis/twitter/lists.js +134 -47
  80. package/clis/twitter/lists.test.js +105 -38
  81. package/clis/uiverse/_shared.js +368 -0
  82. package/clis/uiverse/_shared.test.js +55 -0
  83. package/clis/uiverse/code.js +47 -0
  84. package/clis/uiverse/preview.js +71 -0
  85. package/clis/wanfang/search.js +66 -0
  86. package/clis/wanfang/search.test.js +23 -0
  87. package/clis/web/read.js +1 -1
  88. package/clis/weixin/download.js +3 -2
  89. package/clis/xiaohongshu/comments.js +2 -2
  90. package/clis/xiaohongshu/comments.test.js +46 -25
  91. package/clis/xiaohongshu/download.js +6 -7
  92. package/clis/xiaohongshu/download.test.js +17 -5
  93. package/clis/xiaohongshu/note-helpers.js +46 -12
  94. package/clis/xiaohongshu/note.js +3 -5
  95. package/clis/xiaohongshu/note.test.js +52 -25
  96. package/clis/xiaohongshu/publish.js +149 -28
  97. package/clis/xiaohongshu/publish.test.js +319 -6
  98. package/clis/xiaoyuzhou/auth.js +303 -0
  99. package/clis/xiaoyuzhou/auth.test.js +124 -0
  100. package/clis/xiaoyuzhou/download.js +53 -0
  101. package/clis/xiaoyuzhou/download.test.js +135 -0
  102. package/clis/xiaoyuzhou/episode.js +9 -4
  103. package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
  104. package/clis/xiaoyuzhou/podcast.js +9 -4
  105. package/clis/xiaoyuzhou/transcript.js +76 -0
  106. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  107. package/clis/xiaoyuzhou/utils.js +0 -40
  108. package/clis/xiaoyuzhou/utils.test.js +15 -75
  109. package/clis/youtube/feed.js +120 -0
  110. package/clis/youtube/history.js +118 -0
  111. package/clis/youtube/like.js +62 -0
  112. package/clis/youtube/playlist.js +97 -0
  113. package/clis/youtube/subscribe.js +71 -0
  114. package/clis/youtube/subscriptions.js +57 -0
  115. package/clis/youtube/unlike.js +62 -0
  116. package/clis/youtube/unsubscribe.js +71 -0
  117. package/clis/youtube/utils.js +122 -0
  118. package/clis/youtube/utils.test.js +32 -1
  119. package/clis/youtube/watch-later.js +76 -0
  120. package/clis/zsxq/dynamics.js +1 -1
  121. package/clis/zsxq/utils.js +6 -3
  122. package/clis/zsxq/utils.test.js +31 -0
  123. package/dist/src/browser/base-page.d.ts +1 -1
  124. package/dist/src/browser/base-page.js +25 -5
  125. package/dist/src/browser/bridge.d.ts +3 -0
  126. package/dist/src/browser/bridge.js +52 -15
  127. package/dist/src/browser/cdp.js +2 -1
  128. package/dist/src/browser/daemon-client.d.ts +7 -4
  129. package/dist/src/browser/daemon-client.js +6 -1
  130. package/dist/src/browser/daemon-client.test.js +40 -1
  131. package/dist/src/browser/dom-snapshot.js +20 -3
  132. package/dist/src/browser/page.d.ts +18 -5
  133. package/dist/src/browser/page.js +96 -15
  134. package/dist/src/browser/page.test.js +158 -1
  135. package/dist/src/browser/target-errors.d.ts +23 -0
  136. package/dist/src/browser/target-errors.js +29 -0
  137. package/dist/src/browser/target-errors.test.js +61 -0
  138. package/dist/src/browser/target-resolver.d.ts +57 -0
  139. package/dist/src/browser/target-resolver.js +298 -0
  140. package/dist/src/browser/target-resolver.test.js +43 -0
  141. package/dist/src/browser.test.js +38 -1
  142. package/dist/src/cli.js +272 -187
  143. package/dist/src/cli.test.js +167 -90
  144. package/dist/src/commanderAdapter.d.ts +0 -1
  145. package/dist/src/commanderAdapter.js +2 -16
  146. package/dist/src/commanderAdapter.test.js +1 -1
  147. package/dist/src/commands/daemon.d.ts +4 -2
  148. package/dist/src/commands/daemon.js +22 -2
  149. package/dist/src/commands/daemon.test.js +65 -2
  150. package/dist/src/completion-shared.js +2 -5
  151. package/dist/src/daemon.js +10 -0
  152. package/dist/src/doctor.d.ts +1 -0
  153. package/dist/src/doctor.js +32 -9
  154. package/dist/src/doctor.test.js +28 -12
  155. package/dist/src/download/article-download.d.ts +1 -0
  156. package/dist/src/download/article-download.js +3 -0
  157. package/dist/src/download/article-download.test.js +39 -0
  158. package/dist/src/external-clis.yaml +2 -2
  159. package/dist/src/logger.d.ts +2 -2
  160. package/dist/src/logger.js +3 -3
  161. package/dist/src/output.js +1 -5
  162. package/dist/src/output.test.js +0 -21
  163. package/dist/src/pipeline/steps/transform.js +1 -1
  164. package/dist/src/pipeline/template.d.ts +1 -0
  165. package/dist/src/pipeline/template.js +11 -3
  166. package/dist/src/pipeline/template.test.js +3 -0
  167. package/dist/src/pipeline/transform.test.js +14 -0
  168. package/dist/src/plugin.d.ts +8 -9
  169. package/dist/src/plugin.js +24 -28
  170. package/dist/src/plugin.test.js +16 -60
  171. package/dist/src/registry.d.ts +1 -0
  172. package/dist/src/registry.js +3 -2
  173. package/dist/src/registry.test.js +22 -0
  174. package/dist/src/types.d.ts +15 -6
  175. package/package.json +1 -1
  176. package/clis/twitter/lists-parser.js +0 -77
  177. package/clis/twitter/lists.d.ts +0 -5
  178. package/dist/src/cascade.d.ts +0 -46
  179. package/dist/src/cascade.js +0 -135
  180. package/dist/src/explore.d.ts +0 -99
  181. package/dist/src/explore.js +0 -402
  182. package/dist/src/generate-verified.d.ts +0 -105
  183. package/dist/src/generate-verified.js +0 -696
  184. package/dist/src/generate-verified.test.js +0 -925
  185. package/dist/src/generate.d.ts +0 -46
  186. package/dist/src/generate.js +0 -117
  187. package/dist/src/record.d.ts +0 -96
  188. package/dist/src/record.js +0 -657
  189. package/dist/src/record.test.js +0 -293
  190. package/dist/src/skill-generate.d.ts +0 -30
  191. package/dist/src/skill-generate.js +0 -75
  192. package/dist/src/skill-generate.test.js +0 -173
  193. package/dist/src/synthesize.d.ts +0 -97
  194. package/dist/src/synthesize.js +0 -208
  195. /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
  196. /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
  197. /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
@@ -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,53 @@
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 { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
8
+
9
+ cli({
10
+ site: 'xiaoyuzhou',
11
+ name: 'download',
12
+ description: 'Download Xiaoyuzhou episode audio',
13
+ domain: 'www.xiaoyuzhoufm.com',
14
+ strategy: Strategy.LOCAL,
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 credentials = loadXiaoyuzhouCredentials();
23
+ const response = await requestXiaoyuzhouJson('/v1/episode/get', {
24
+ query: { eid: args.id },
25
+ credentials,
26
+ });
27
+ const ep = response.data;
28
+ if (!ep) {
29
+ throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
30
+ }
31
+ const audioUrl = ep.media?.source?.url;
32
+ if (!audioUrl) {
33
+ throw new CliError('PARSE_ERROR', 'Audio URL not found in episode payload', 'Episode payload does not expose media.source.url');
34
+ }
35
+ const output = String(args.output || './xiaoyuzhou-downloads');
36
+ const ext = path.extname(new URL(audioUrl).pathname) || '.mp3';
37
+ const title = String(ep.title || 'episode');
38
+ const filename = `${args.id}_${sanitizeFilename(title, 80) || 'episode'}${ext}`;
39
+ const outputDir = path.join(output, String(args.id));
40
+ fs.mkdirSync(outputDir, { recursive: true });
41
+ const destPath = path.join(outputDir, filename);
42
+ const result = await httpDownload(audioUrl, destPath, {
43
+ timeout: 60000,
44
+ });
45
+ return [{
46
+ title,
47
+ podcast: ep.podcast?.title || '-',
48
+ status: result.success ? 'success' : 'failed',
49
+ size: result.success ? formatBytes(result.size) : (result.error || 'unknown error'),
50
+ file: result.success ? destPath : '-',
51
+ }];
52
+ },
53
+ });
@@ -0,0 +1,135 @@
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 { mockRequestJson, mockLoadCredentials, mockHttpDownload, mockMkdirSync } = vi.hoisted(() => ({
6
+ mockRequestJson: vi.fn(),
7
+ mockLoadCredentials: vi.fn(),
8
+ mockHttpDownload: vi.fn(),
9
+ mockMkdirSync: vi.fn(),
10
+ }));
11
+
12
+ vi.mock('./auth.js', async () => {
13
+ const actual = await vi.importActual('./auth.js');
14
+ return {
15
+ ...actual,
16
+ requestXiaoyuzhouJson: mockRequestJson,
17
+ loadXiaoyuzhouCredentials: mockLoadCredentials,
18
+ };
19
+ });
20
+
21
+ vi.mock('@jackwener/opencli/download', () => ({
22
+ httpDownload: mockHttpDownload,
23
+ sanitizeFilename: vi.fn((value) => value.replace(/\s+/g, '_')),
24
+ }));
25
+
26
+ vi.mock('@jackwener/opencli/download/progress', () => ({
27
+ formatBytes: vi.fn((size) => `${size} B`),
28
+ }));
29
+
30
+ vi.mock('node:fs', () => ({
31
+ mkdirSync: mockMkdirSync,
32
+ }));
33
+
34
+ await import('./download.js');
35
+
36
+ let cmd;
37
+
38
+ function toPosixPath(value) {
39
+ return value.replaceAll(path.sep, '/');
40
+ }
41
+
42
+ beforeAll(() => {
43
+ cmd = getRegistry().get('xiaoyuzhou/download');
44
+ expect(cmd?.func).toBeTypeOf('function');
45
+ });
46
+
47
+ describe('xiaoyuzhou download', () => {
48
+ beforeEach(() => {
49
+ mockRequestJson.mockReset();
50
+ mockLoadCredentials.mockReset();
51
+ mockHttpDownload.mockReset();
52
+ mockMkdirSync.mockReset();
53
+ mockLoadCredentials.mockReturnValue({});
54
+ });
55
+
56
+ it('downloads audio from media.source.url into an episode subdirectory', async () => {
57
+ mockRequestJson.mockResolvedValue({
58
+ credentials: {},
59
+ data: {
60
+ title: 'Hello World',
61
+ podcast: { title: 'OpenCLI FM' },
62
+ media: {
63
+ source: {
64
+ url: 'https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc',
65
+ },
66
+ },
67
+ },
68
+ });
69
+ mockHttpDownload.mockResolvedValue({ success: true, size: 1234 });
70
+
71
+ const result = await cmd.func(null, {
72
+ id: 'ep123',
73
+ output: '/tmp/xiaoyuzhou-test',
74
+ });
75
+
76
+ expect(mockRequestJson).toHaveBeenCalledWith('/v1/episode/get', {
77
+ query: { eid: 'ep123' },
78
+ credentials: {},
79
+ });
80
+ expect(toPosixPath(mockMkdirSync.mock.calls[0][0])).toBe('/tmp/xiaoyuzhou-test/ep123');
81
+ expect(mockMkdirSync.mock.calls[0][1]).toEqual({ recursive: true });
82
+ expect(mockHttpDownload).toHaveBeenCalledWith('https://media.xyzcdn.net/audio/hello-world.mp3?sign=abc', expect.stringContaining('/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3'), {
83
+ timeout: 60000,
84
+ });
85
+ expect(result).toEqual([{
86
+ title: 'Hello World',
87
+ podcast: 'OpenCLI FM',
88
+ status: 'success',
89
+ size: '1234 B',
90
+ file: '/tmp/xiaoyuzhou-test/ep123/ep123_Hello_World.mp3',
91
+ }]);
92
+ });
93
+
94
+ it('preserves non-mp3 extensions from media.source.url', async () => {
95
+ mockRequestJson.mockResolvedValue({
96
+ credentials: {},
97
+ data: {
98
+ title: 'Lossless Episode',
99
+ podcast: { title: 'OpenCLI FM' },
100
+ media: {
101
+ source: {
102
+ url: 'https://media.xyzcdn.net/audio/lossless.m4a',
103
+ },
104
+ },
105
+ },
106
+ });
107
+ mockHttpDownload.mockResolvedValue({ success: true, size: 2048 });
108
+
109
+ const result = await cmd.func(null, {
110
+ id: 'ep456',
111
+ output: '/tmp/xiaoyuzhou-test',
112
+ });
113
+
114
+ expect(mockHttpDownload.mock.calls[0][1]).toContain('ep456_Lossless_Episode.m4a');
115
+ expect(result[0].file).toBe('/tmp/xiaoyuzhou-test/ep456/ep456_Lossless_Episode.m4a');
116
+ });
117
+
118
+ it('throws when media.source.url is missing', async () => {
119
+ mockRequestJson.mockResolvedValue({
120
+ credentials: {},
121
+ data: {
122
+ title: 'No Audio',
123
+ podcast: { title: 'OpenCLI FM' },
124
+ media: {},
125
+ },
126
+ });
127
+
128
+ await expect(cmd.func(null, { id: 'ep789', output: '/tmp/xiaoyuzhou-test' })).rejects.toMatchObject({
129
+ code: 'PARSE_ERROR',
130
+ message: 'Audio URL not found in episode payload',
131
+ hint: 'Episode payload does not expose media.source.url',
132
+ });
133
+ expect(mockHttpDownload).not.toHaveBeenCalled();
134
+ });
135
+ });
@@ -1,18 +1,23 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CliError } from '@jackwener/opencli/errors';
3
- import { fetchPageProps, formatDuration, formatDate } from './utils.js';
3
+ import { loadXiaoyuzhouCredentials, requestXiaoyuzhouJson } from './auth.js';
4
+ import { formatDuration, formatDate } from './utils.js';
4
5
  cli({
5
6
  site: 'xiaoyuzhou',
6
7
  name: 'episode',
7
8
  description: 'View details of a Xiaoyuzhou podcast episode',
8
9
  domain: 'www.xiaoyuzhoufm.com',
9
- strategy: Strategy.PUBLIC,
10
+ strategy: Strategy.LOCAL,
10
11
  browser: false,
11
12
  args: [{ name: 'id', positional: true, required: true, help: 'Episode ID (eid from podcast-episodes output)' }],
12
13
  columns: ['title', 'podcast', 'duration', 'plays', 'comments', 'likes', 'date'],
13
14
  func: async (_page, args) => {
14
- const pageProps = await fetchPageProps(`/episode/${args.id}`);
15
- const ep = pageProps.episode;
15
+ const credentials = loadXiaoyuzhouCredentials();
16
+ const response = await requestXiaoyuzhouJson('/v1/episode/get', {
17
+ query: { eid: args.id },
18
+ credentials,
19
+ });
20
+ const ep = response.data;
16
21
  if (!ep)
17
22
  throw new CliError('NOT_FOUND', 'Episode not found', 'Please check the ID');
18
23
  return [{