@jackwener/opencli 1.7.2 → 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 (144) hide show
  1. package/README.md +18 -15
  2. package/README.zh-CN.md +31 -15
  3. package/cli-manifest.json +1265 -101
  4. package/clis/barchart/flow.js +1 -1
  5. package/clis/barchart/greeks.js +2 -2
  6. package/clis/barchart/options.js +2 -2
  7. package/clis/barchart/quote.js +1 -1
  8. package/clis/bilibili/favorite.js +18 -13
  9. package/clis/bilibili/feed.js +202 -48
  10. package/clis/binance/depth.js +3 -4
  11. package/clis/boss/utils.js +2 -2
  12. package/clis/chatgpt/image.js +97 -0
  13. package/clis/chatgpt/utils.js +297 -0
  14. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/ax.js +6 -3
  16. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  18. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  19. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  20. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  21. package/clis/discord-app/delete.js +114 -0
  22. package/clis/douban/search.js +1 -0
  23. package/clis/douban/search.test.js +11 -0
  24. package/clis/douban/subject.js +20 -93
  25. package/clis/douban/subject.test.js +11 -0
  26. package/clis/douban/utils.js +279 -10
  27. package/clis/douban/utils.test.js +296 -1
  28. package/clis/doubao/utils.js +319 -130
  29. package/clis/doubao/utils.test.js +241 -2
  30. package/clis/eastmoney/hot-rank.js +50 -0
  31. package/clis/eastmoney/hot-rank.test.js +59 -0
  32. package/clis/grok/image.test.ts +107 -0
  33. package/clis/grok/image.ts +356 -0
  34. package/clis/ke/chengjiao.js +77 -0
  35. package/clis/ke/ershoufang.js +100 -0
  36. package/clis/ke/utils.js +104 -0
  37. package/clis/ke/xiaoqu.js +77 -0
  38. package/clis/ke/zufang.js +94 -0
  39. package/clis/maimai/search-talents.js +172 -0
  40. package/clis/mubu/doc.js +40 -0
  41. package/clis/mubu/docs.js +43 -0
  42. package/clis/mubu/notes.js +244 -0
  43. package/clis/mubu/recent.js +27 -0
  44. package/clis/mubu/search.js +62 -0
  45. package/clis/mubu/utils.js +304 -0
  46. package/clis/reuters/search.js +1 -1
  47. package/clis/tdx/hot-rank.js +47 -0
  48. package/clis/tdx/hot-rank.test.js +59 -0
  49. package/clis/ths/hot-rank.js +49 -0
  50. package/clis/ths/hot-rank.test.js +64 -0
  51. package/clis/twitter/bookmarks.js +2 -1
  52. package/clis/uiverse/_shared.js +368 -0
  53. package/clis/uiverse/_shared.test.js +55 -0
  54. package/clis/uiverse/code.js +47 -0
  55. package/clis/uiverse/preview.js +71 -0
  56. package/clis/xiaohongshu/comments.js +20 -8
  57. package/clis/xiaohongshu/comments.test.js +69 -12
  58. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  59. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  60. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  61. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  62. package/clis/xiaohongshu/creator-notes.js +1 -0
  63. package/clis/xiaohongshu/creator-profile.js +1 -0
  64. package/clis/xiaohongshu/creator-stats.js +1 -0
  65. package/clis/xiaohongshu/download.js +18 -7
  66. package/clis/xiaohongshu/download.test.js +42 -0
  67. package/clis/xiaohongshu/navigation.test.js +34 -0
  68. package/clis/xiaohongshu/note-helpers.js +46 -12
  69. package/clis/xiaohongshu/note.js +17 -10
  70. package/clis/xiaohongshu/note.test.js +66 -11
  71. package/clis/xiaohongshu/publish.js +1 -0
  72. package/clis/xiaohongshu/search.js +1 -0
  73. package/clis/xiaohongshu/user.js +1 -0
  74. package/clis/xiaoyuzhou/auth.js +303 -0
  75. package/clis/xiaoyuzhou/auth.test.js +124 -0
  76. package/clis/xiaoyuzhou/download.js +49 -0
  77. package/clis/xiaoyuzhou/download.test.js +125 -0
  78. package/clis/xiaoyuzhou/transcript.js +76 -0
  79. package/clis/xiaoyuzhou/transcript.test.js +195 -0
  80. package/clis/yahoo-finance/quote.js +1 -1
  81. package/clis/youtube/feed.js +120 -0
  82. package/clis/youtube/history.js +118 -0
  83. package/clis/youtube/like.js +62 -0
  84. package/clis/youtube/playlist.js +97 -0
  85. package/clis/youtube/subscribe.js +71 -0
  86. package/clis/youtube/subscriptions.js +57 -0
  87. package/clis/youtube/unlike.js +62 -0
  88. package/clis/youtube/unsubscribe.js +71 -0
  89. package/clis/youtube/utils.js +122 -0
  90. package/clis/youtube/utils.test.js +32 -1
  91. package/clis/youtube/watch-later.js +76 -0
  92. package/dist/src/browser/base-page.d.ts +9 -0
  93. package/dist/src/browser/base-page.js +44 -5
  94. package/dist/src/browser/bridge.d.ts +2 -0
  95. package/dist/src/browser/bridge.js +51 -14
  96. package/dist/src/browser/cdp.js +11 -2
  97. package/dist/src/browser/daemon-client.d.ts +2 -0
  98. package/dist/src/browser/dom-snapshot.js +13 -1
  99. package/dist/src/browser/page.d.ts +4 -1
  100. package/dist/src/browser/page.js +48 -8
  101. package/dist/src/browser/page.test.js +61 -1
  102. package/dist/src/browser/target-errors.d.ts +23 -0
  103. package/dist/src/browser/target-errors.js +29 -0
  104. package/dist/src/browser/target-errors.test.d.ts +1 -0
  105. package/dist/src/browser/target-errors.test.js +61 -0
  106. package/dist/src/browser/target-resolver.d.ts +57 -0
  107. package/dist/src/browser/target-resolver.js +298 -0
  108. package/dist/src/browser/target-resolver.test.d.ts +1 -0
  109. package/dist/src/browser/target-resolver.test.js +43 -0
  110. package/dist/src/browser.test.js +38 -1
  111. package/dist/src/cli.js +45 -35
  112. package/dist/src/commands/daemon.d.ts +4 -2
  113. package/dist/src/commands/daemon.js +22 -2
  114. package/dist/src/commands/daemon.test.js +65 -2
  115. package/dist/src/daemon.js +7 -0
  116. package/dist/src/doctor.d.ts +2 -0
  117. package/dist/src/doctor.js +82 -10
  118. package/dist/src/doctor.test.js +28 -12
  119. package/dist/src/electron-apps.js +1 -1
  120. package/dist/src/errors.d.ts +1 -0
  121. package/dist/src/errors.js +13 -0
  122. package/dist/src/execution.js +36 -9
  123. package/dist/src/execution.test.js +23 -0
  124. package/dist/src/external-clis.yaml +2 -2
  125. package/dist/src/logger.d.ts +2 -2
  126. package/dist/src/logger.js +3 -8
  127. package/dist/src/output.js +1 -5
  128. package/dist/src/output.test.js +0 -21
  129. package/dist/src/pipeline/steps/transform.js +1 -1
  130. package/dist/src/pipeline/template.d.ts +1 -0
  131. package/dist/src/pipeline/template.js +11 -3
  132. package/dist/src/pipeline/template.test.js +3 -0
  133. package/dist/src/pipeline/transform.test.js +14 -0
  134. package/dist/src/plugin.d.ts +7 -1
  135. package/dist/src/plugin.js +23 -1
  136. package/dist/src/plugin.test.js +15 -1
  137. package/dist/src/registry.js +3 -4
  138. package/dist/src/types.d.ts +3 -1
  139. package/dist/src/update-check.d.ts +14 -0
  140. package/dist/src/update-check.js +48 -3
  141. package/dist/src/update-check.test.d.ts +1 -0
  142. package/dist/src/update-check.test.js +31 -0
  143. package/package.json +1 -1
  144. package/scripts/fetch-adapters.js +35 -8
@@ -36,6 +36,9 @@ describe('parseNoteId', () => {
36
36
  it('extracts ID from /note/ URL', () => {
37
37
  expect(parseNoteId('https://www.xiaohongshu.com/note/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
38
38
  });
39
+ it('extracts ID from signed /user/profile/<user>/<note> URL', () => {
40
+ expect(parseNoteId('https://www.xiaohongshu.com/user/profile/user123/69c131c9000000002800be4c?xsec_token=abc&xsec_source=pc_user')).toBe('69c131c9000000002800be4c');
41
+ });
39
42
  it('returns raw string when no URL pattern matches', () => {
40
43
  expect(parseNoteId('69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
41
44
  });
@@ -48,8 +51,14 @@ describe('buildNoteUrl', () => {
48
51
  const url = 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok';
49
52
  expect(buildNoteUrl(url)).toBe(url);
50
53
  });
51
- it('constructs /search_result/ URL for bare note ID', () => {
52
- expect(buildNoteUrl('abc123')).toBe('https://www.xiaohongshu.com/search_result/abc123');
54
+ it('rejects signed URLs from non-xiaohongshu hosts', () => {
55
+ expect(() => buildNoteUrl('https://example.com/?xsec_token=tok')).toThrow(/xiaohongshu/i);
56
+ });
57
+ it('rejects signed URLs with an empty xsec_token value', () => {
58
+ expect(() => buildNoteUrl('https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=')).toThrow(/xsec_token|signed url/i);
59
+ });
60
+ it('rejects bare note IDs because xiaohongshu now requires a signed URL', () => {
61
+ expect(() => buildNoteUrl('abc123')).toThrow(/xsec_token|signed url/i);
53
62
  });
54
63
  });
55
64
  describe('xiaohongshu note', () => {
@@ -58,7 +67,7 @@ describe('xiaohongshu note', () => {
58
67
  expect(command).toBeDefined();
59
68
  expect(command.func).toBeTypeOf('function');
60
69
  });
61
- it('returns note content as field/value rows', async () => {
70
+ it('returns note content as field/value rows for signed full URLs', async () => {
62
71
  const page = createPageMock({
63
72
  loginWall: false,
64
73
  notFound: false,
@@ -70,8 +79,9 @@ describe('xiaohongshu note', () => {
70
79
  comments: '45',
71
80
  tags: ['#尚界Z7', '#鸿蒙智行'],
72
81
  });
73
- const result = (await command.func(page, { 'note-id': '69c131c9000000002800be4c' }));
74
- expect(page.goto.mock.calls[0][0]).toContain('/search_result/69c131c9000000002800be4c');
82
+ const signedUrl = 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc';
83
+ const result = (await command.func(page, { 'note-id': signedUrl }));
84
+ expect(page.goto.mock.calls[0][0]).toBe(signedUrl);
75
85
  expect(result).toEqual([
76
86
  { field: 'title', value: '尚界Z7实车体验' },
77
87
  { field: 'author', value: '小红薯用户' },
@@ -82,6 +92,18 @@ describe('xiaohongshu note', () => {
82
92
  { field: 'tags', value: '#尚界Z7, #鸿蒙智行' },
83
93
  ]);
84
94
  });
95
+ it('rejects bare note IDs before browser navigation', async () => {
96
+ const page = createPageMock({
97
+ loginWall: false, notFound: false,
98
+ title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
99
+ });
100
+ await expect(command.func(page, { 'note-id': '69c131c9000000002800be4c' })).rejects.toMatchObject({
101
+ code: 'ARGUMENT',
102
+ message: expect.stringContaining('signed URL'),
103
+ hint: expect.stringContaining('xsec_token'),
104
+ });
105
+ expect(page.goto).not.toHaveBeenCalled();
106
+ });
85
107
  it('parses note ID from full /explore/ URL', async () => {
86
108
  const page = createPageMock({
87
109
  loginWall: false, notFound: false,
@@ -102,13 +124,40 @@ describe('xiaohongshu note', () => {
102
124
  // Should navigate to the full URL as-is, not strip the token
103
125
  expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
104
126
  });
127
+ it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
128
+ const page = createPageMock({
129
+ loginWall: false, notFound: false,
130
+ title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
131
+ });
132
+ const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69c131c9000000002800be4c?xsec_token=abc&xsec_source=pc_user';
133
+ await command.func(page, { 'note-id': fullUrl });
134
+ expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
135
+ });
105
136
  it('throws AuthRequiredError on login wall', async () => {
106
137
  const page = createPageMock({ loginWall: true, notFound: false });
107
- await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
138
+ await expect(command.func(page, {
139
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
140
+ })).rejects.toThrow('Note content requires login');
141
+ });
142
+ it('throws SECURITY_BLOCK with retry guidance when a full URL is blocked', async () => {
143
+ const page = createPageMock({
144
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
145
+ securityBlock: true,
146
+ loginWall: false,
147
+ notFound: false,
148
+ });
149
+ await expect(command.func(page, {
150
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc',
151
+ })).rejects.toMatchObject({
152
+ code: 'SECURITY_BLOCK',
153
+ hint: expect.stringContaining('Try again later'),
154
+ });
108
155
  });
109
156
  it('throws EmptyResultError when note is not found', async () => {
110
157
  const page = createPageMock({ loginWall: false, notFound: true });
111
- await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
158
+ await expect(command.func(page, {
159
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
160
+ })).rejects.toThrow('returned no data');
112
161
  });
113
162
  it('throws an empty-result error when the note page renders as an empty shell', async () => {
114
163
  const page = createPageMock({
@@ -123,7 +172,9 @@ describe('xiaohongshu note', () => {
123
172
  tags: [],
124
173
  });
125
174
  try {
126
- await command.func(page, { 'note-id': '69ca3927000000001a020fd5' });
175
+ await command.func(page, {
176
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69ca3927000000001a020fd5?xsec_token=abc',
177
+ });
127
178
  throw new Error('expected xiaohongshu note to fail on an empty shell page');
128
179
  }
129
180
  catch (error) {
@@ -165,7 +216,9 @@ describe('xiaohongshu note', () => {
165
216
  title: 'New note', desc: 'Just posted', author: 'Author',
166
217
  likes: '赞', collects: '收藏', comments: '评论', tags: [],
167
218
  });
168
- const result = (await command.func(page, { 'note-id': 'abc123' }));
219
+ const result = (await command.func(page, {
220
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
221
+ }));
169
222
  expect(result.find((r) => r.field === 'likes').value).toBe('0');
170
223
  expect(result.find((r) => r.field === 'collects').value).toBe('0');
171
224
  expect(result.find((r) => r.field === 'comments').value).toBe('0');
@@ -175,7 +228,7 @@ describe('xiaohongshu note', () => {
175
228
  loginWall: false, notFound: false,
176
229
  title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
177
230
  });
178
- await command.func(page, { 'note-id': 'abc123' });
231
+ await command.func(page, { 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok' });
179
232
  const evaluateScript = page.evaluate.mock.calls[0][0];
180
233
  expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
181
234
  expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
@@ -187,7 +240,9 @@ describe('xiaohongshu note', () => {
187
240
  title: 'No tags', desc: 'Content', author: 'Author',
188
241
  likes: '1', collects: '2', comments: '3', tags: [],
189
242
  });
190
- const result = (await command.func(page, { 'note-id': 'abc123' }));
243
+ const result = (await command.func(page, {
244
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
245
+ }));
191
246
  expect(result.find((r) => r.field === 'tags')).toBeUndefined();
192
247
  expect(result).toHaveLength(6);
193
248
  });
@@ -333,6 +333,7 @@ cli({
333
333
  domain: 'creator.xiaohongshu.com',
334
334
  strategy: Strategy.COOKIE,
335
335
  browser: true,
336
+ navigateBefore: false,
336
337
  args: [
337
338
  { name: 'title', required: true, help: '笔记标题 (最多20字)' },
338
339
  { name: 'content', required: true, positional: true, help: '笔记正文' },
@@ -53,6 +53,7 @@ cli({
53
53
  description: '搜索小红书笔记',
54
54
  domain: 'www.xiaohongshu.com',
55
55
  strategy: Strategy.COOKIE,
56
+ navigateBefore: false,
56
57
  args: [
57
58
  { name: 'query', required: true, positional: true, help: 'Search keyword' },
58
59
  { name: 'limit', type: 'int', default: 20, help: 'Number of results' },
@@ -26,6 +26,7 @@ cli({
26
26
  domain: 'www.xiaohongshu.com',
27
27
  strategy: Strategy.COOKIE,
28
28
  browser: true,
29
+ navigateBefore: false,
29
30
  args: [
30
31
  { name: 'id', type: 'string', required: true, positional: true, help: 'User id or profile URL' },
31
32
  { name: 'limit', type: 'int', default: 15, help: 'Number of notes to return' },
@@ -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
+ });