@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
@@ -27,7 +27,7 @@ function createPageMock(evaluateResult) {
27
27
  }
28
28
  describe('xiaohongshu comments', () => {
29
29
  const command = getRegistry().get('xiaohongshu/comments');
30
- it('returns ranked comment rows', async () => {
30
+ it('returns ranked comment rows for signed full URLs', async () => {
31
31
  const page = createPageMock({
32
32
  loginWall: false,
33
33
  results: [
@@ -35,22 +35,32 @@ describe('xiaohongshu comments', () => {
35
35
  { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
36
36
  ],
37
37
  });
38
- const result = (await command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 }));
39
- expect(page.goto.mock.calls[0][0]).toContain('/search_result/69aadbcb000000002202f131');
38
+ const signedUrl = 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search';
39
+ const result = (await command.func(page, { 'note-id': signedUrl, limit: 5 }));
40
+ expect(page.goto.mock.calls[0][0]).toBe(signedUrl);
40
41
  expect(result).toHaveLength(2);
41
42
  expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', text: 'Great note!', likes: 10 });
42
43
  expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0 });
43
44
  });
44
- it('preserves full /explore/ URL as-is for navigation', async () => {
45
+ it('rejects bare note IDs before browser navigation', async () => {
46
+ const page = createPageMock({ loginWall: false, results: [] });
47
+ await expect(command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })).rejects.toMatchObject({
48
+ code: 'ARGUMENT',
49
+ message: expect.stringContaining('signed URL'),
50
+ hint: expect.stringContaining('xsec_token'),
51
+ });
52
+ expect(page.goto).not.toHaveBeenCalled();
53
+ });
54
+ it('preserves signed /explore/ URL as-is for navigation', async () => {
45
55
  const page = createPageMock({
46
56
  loginWall: false,
47
57
  results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
48
58
  });
49
59
  await command.func(page, {
50
- 'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
60
+ 'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
51
61
  limit: 5,
52
62
  });
53
- expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
63
+ expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131?xsec_token=abc');
54
64
  });
55
65
  it('preserves full search_result URL with xsec_token for navigation', async () => {
56
66
  const page = createPageMock({
@@ -61,22 +71,21 @@ describe('xiaohongshu comments', () => {
61
71
  await command.func(page, { 'note-id': fullUrl, limit: 5 });
62
72
  expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
63
73
  });
64
- it('throws AuthRequiredError when login wall is detected', async () => {
65
- const page = createPageMock({ loginWall: true, results: [] });
66
- await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
67
- });
68
- it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the comments page', async () => {
74
+ it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
69
75
  const page = createPageMock({
70
- pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
71
- securityBlock: true,
72
76
  loginWall: false,
73
- results: [],
74
- });
75
- await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toMatchObject({
76
- code: 'SECURITY_BLOCK',
77
- hint: expect.stringContaining('xsec_token'),
77
+ results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
78
78
  });
79
- expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
79
+ const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_user';
80
+ await command.func(page, { 'note-id': fullUrl, limit: 5 });
81
+ expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
82
+ });
83
+ it('throws AuthRequiredError when login wall is detected', async () => {
84
+ const page = createPageMock({ loginWall: true, results: [] });
85
+ await expect(command.func(page, {
86
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
87
+ limit: 5,
88
+ })).rejects.toThrow('Note comments require login');
80
89
  });
81
90
  it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
82
91
  const page = createPageMock({
@@ -95,11 +104,17 @@ describe('xiaohongshu comments', () => {
95
104
  });
96
105
  it('returns empty array when no comments are found', async () => {
97
106
  const page = createPageMock({ loginWall: false, results: [] });
98
- await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
107
+ await expect(command.func(page, {
108
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
109
+ limit: 5,
110
+ })).resolves.toEqual([]);
99
111
  });
100
112
  it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
101
113
  const page = createPageMock({ loginWall: false, results: [] });
102
- await command.func(page, { 'note-id': 'abc123', limit: 5 });
114
+ await command.func(page, {
115
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
116
+ limit: 5,
117
+ });
103
118
  const script = page.evaluate.mock.calls[0][0];
104
119
  expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
105
120
  expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
@@ -115,7 +130,10 @@ describe('xiaohongshu comments', () => {
115
130
  reply_to: '',
116
131
  }));
117
132
  const page = createPageMock({ loginWall: false, results: manyComments });
118
- const result = (await command.func(page, { 'note-id': 'abc123', limit: 3 }));
133
+ const result = (await command.func(page, {
134
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
135
+ limit: 3,
136
+ }));
119
137
  expect(result).toHaveLength(3);
120
138
  expect(result[0].rank).toBe(1);
121
139
  expect(result[2].rank).toBe(3);
@@ -128,7 +146,10 @@ describe('xiaohongshu comments', () => {
128
146
  { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
129
147
  ],
130
148
  });
131
- const result = (await command.func(page, { 'note-id': 'abc123', limit: -3 }));
149
+ const result = (await command.func(page, {
150
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
151
+ limit: -3,
152
+ }));
132
153
  expect(result).toHaveLength(1);
133
154
  expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
134
155
  });
@@ -143,7 +164,7 @@ describe('xiaohongshu comments', () => {
143
164
  ],
144
165
  });
145
166
  const result = (await command.func(page, {
146
- 'note-id': 'abc123', limit: 50, 'with-replies': true,
167
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 50, 'with-replies': true,
147
168
  }));
148
169
  expect(result).toHaveLength(3);
149
170
  expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
@@ -166,7 +187,7 @@ describe('xiaohongshu comments', () => {
166
187
  });
167
188
  // Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
168
189
  const result = (await command.func(page, {
169
- 'note-id': 'abc123', limit: 2, 'with-replies': true,
190
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 2, 'with-replies': true,
170
191
  }));
171
192
  expect(result).toHaveLength(4);
172
193
  expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
@@ -2,10 +2,9 @@
2
2
  * Xiaohongshu download — download images and videos from a note.
3
3
  *
4
4
  * Usage:
5
- * opencli xiaohongshu download <note-id-or-url> --output ./xhs
5
+ * opencli xiaohongshu download <signed-note-url-or-shortlink> --output ./xhs
6
6
  *
7
- * Accepts a bare note ID, a full xiaohongshu.com URL (with xsec_token),
8
- * or a short link (http://xhslink.com/...).
7
+ * Accepts a full xiaohongshu.com URL with xsec_token or an xhslink short link.
9
8
  */
10
9
  import { cli, Strategy } from '@jackwener/opencli/registry';
11
10
  import { formatCookieHeader } from '@jackwener/opencli/download';
@@ -20,7 +19,7 @@ cli({
20
19
  strategy: Strategy.COOKIE,
21
20
  navigateBefore: false,
22
21
  args: [
23
- { name: 'note-id', positional: true, required: true, help: 'Note ID, full URL, or short link' },
22
+ { name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
24
23
  { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
25
24
  ],
26
25
  columns: ['index', 'type', 'status', 'size'],
@@ -28,7 +27,7 @@ cli({
28
27
  const rawInput = String(kwargs['note-id']);
29
28
  const output = kwargs.output;
30
29
  const noteId = parseNoteId(rawInput);
31
- await page.goto(buildNoteUrl(rawInput));
30
+ await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
32
31
  await page.wait({ time: 1 + Math.random() * 2 });
33
32
  // Extract note info and media URLs
34
33
  const data = await page.evaluate(`
@@ -51,9 +50,9 @@ cli({
51
50
  seenMedia.add(key);
52
51
  result.media.push({ type, url });
53
52
  };
54
- const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)/i);
53
+ const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)|\\/user\\/profile\\/[^/?#]+\\/([a-f0-9]+)/i);
55
54
  if (locationMatch) {
56
- result.noteId = locationMatch[1];
55
+ result.noteId = locationMatch[1] || locationMatch[2];
57
56
  }
58
57
 
59
58
  // Get title
@@ -70,19 +70,31 @@ describe('xiaohongshu download', () => {
70
70
  filenamePrefix: '69bc166f000000001a02069a',
71
71
  }));
72
72
  });
73
- it('throws SECURITY_BLOCK with bare-id guidance before starting downloads', async () => {
73
+ it('uses canonical note id for signed user profile note URLs', async () => {
74
+ const page = createPageMock({
75
+ noteId: '',
76
+ media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }],
77
+ });
78
+ const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_user';
79
+ await command.func(page, { 'note-id': fullUrl, output: './out' });
80
+ expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
81
+ expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], expect.objectContaining({
82
+ subdir: '69bc166f000000001a02069a',
83
+ filenamePrefix: '69bc166f000000001a02069a',
84
+ }));
85
+ });
86
+ it('rejects bare note IDs before browser navigation', async () => {
74
87
  const page = createPageMock({
75
- pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
76
- securityBlock: true,
77
88
  noteId: '69bc166f000000001a02069a',
78
89
  media: [],
79
90
  });
80
91
  await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({
81
- code: 'SECURITY_BLOCK',
92
+ code: 'ARGUMENT',
93
+ message: expect.stringContaining('signed URL'),
82
94
  hint: expect.stringContaining('xsec_token'),
83
95
  });
96
+ expect(page.goto).not.toHaveBeenCalled();
84
97
  expect(mockDownloadMedia).not.toHaveBeenCalled();
85
- expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
86
98
  });
87
99
  it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => {
88
100
  const page = createPageMock({
@@ -1,25 +1,59 @@
1
+ import { ArgumentError } from '@jackwener/opencli/errors';
2
+
1
3
  /** Side-effect-free helpers shared by xiaohongshu note and comments commands. */
2
4
  /** Extract a bare note ID from a full URL or raw ID string. */
3
5
  export function parseNoteId(input) {
4
6
  const trimmed = input.trim();
5
- const match = trimmed.match(/\/(?:explore|note|search_result)\/([a-f0-9]+)/);
6
- return match ? match[1] : trimmed;
7
+ const match = trimmed.match(/\/(?:explore|note|search_result|discovery\/item)\/([a-f0-9]+)|\/user\/profile\/[^/?#]+\/([a-f0-9]+)/i);
8
+ return match ? (match[1] || match[2]) : trimmed;
7
9
  }
10
+
11
+ export const XHS_SIGNED_URL_HINT = 'Pass a full Xiaohongshu note URL with xsec_token from search results or user/profile context.';
12
+
13
+ function isShortLink(input) {
14
+ return /^https?:\/\/xhslink\.com\//i.test(input);
15
+ }
16
+
17
+ function isXiaohongshuHost(hostname) {
18
+ const normalized = hostname.toLowerCase();
19
+ return normalized === 'xiaohongshu.com' || normalized.endsWith('.xiaohongshu.com');
20
+ }
21
+
22
+ function isSupportedNotePath(pathname) {
23
+ return /^\/(?:explore|note|search_result|discovery\/item)\/[a-f0-9]+(?:[/?#]|$)/i.test(pathname)
24
+ || /^\/user\/profile\/[^/?#]+\/[a-f0-9]+(?:[/?#]|$)/i.test(pathname);
25
+ }
26
+
8
27
  /**
9
28
  * Build the best navigation URL for a note.
10
29
  *
11
- * XHS blocks direct `/explore/<id>` access without a valid `xsec_token`.
12
- * When the user passes a full URL (from search results), we preserve it
13
- * so the browser navigates with the token intact. For bare IDs we now use
14
- * `/search_result/<id>` which works without xsec_token when cookies are present.
30
+ * XHS note detail pages now require a valid signed URL for reliable access.
31
+ * Bare note IDs no longer resolve deterministically, so callers must provide
32
+ * a full note URL with xsec_token or, for downloads only, an xhslink short link.
15
33
  */
16
- export function buildNoteUrl(input) {
34
+ export function buildNoteUrl(input, options = {}) {
35
+ const { allowShortLink = false, commandName = 'xiaohongshu note' } = options;
17
36
  const trimmed = input.trim();
37
+ const message = `${commandName} now requires a full signed URL`;
38
+ const hint = allowShortLink
39
+ ? `${XHS_SIGNED_URL_HINT} For downloads, xhslink short links are also supported.`
40
+ : XHS_SIGNED_URL_HINT;
41
+
18
42
  if (/^https?:\/\//.test(trimmed)) {
19
- // Full URL — navigate as-is; the browser will follow any redirects
20
- return trimmed;
43
+ if (isShortLink(trimmed)) {
44
+ if (allowShortLink)
45
+ return trimmed;
46
+ throw new ArgumentError(message, hint);
47
+ }
48
+ try {
49
+ const url = new URL(trimmed);
50
+ const xsecToken = url.searchParams.get('xsec_token')?.trim();
51
+ if (isXiaohongshuHost(url.hostname) && isSupportedNotePath(url.pathname) && xsecToken) {
52
+ return trimmed;
53
+ }
54
+ }
55
+ catch { }
56
+ throw new ArgumentError(message, hint);
21
57
  }
22
- // Use /search_result/<id> instead of /explore/<id> — works without xsec_token
23
- // when the user is logged in via cookies (which is always the case with opencli).
24
- return `https://www.xiaohongshu.com/search_result/${trimmed}`;
58
+ throw new ArgumentError(message, hint);
25
59
  }
@@ -4,9 +4,7 @@
4
4
  * Extracts title, author, description text, and engagement metrics
5
5
  * (likes, collects, comment count) via DOM extraction.
6
6
  *
7
- * Supports both bare note IDs and full URLs (with xsec_token).
8
- * Bare IDs now use /search_result/<id> which works without xsec_token
9
- * when the user is logged in via cookies.
7
+ * Requires a full Xiaohongshu note URL with xsec_token.
10
8
  */
11
9
  import { cli, Strategy } from '@jackwener/opencli/registry';
12
10
  import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
@@ -19,13 +17,13 @@ cli({
19
17
  strategy: Strategy.COOKIE,
20
18
  navigateBefore: false,
21
19
  args: [
22
- { name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
20
+ { name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
23
21
  ],
24
22
  columns: ['field', 'value'],
25
23
  func: async (page, kwargs) => {
26
24
  const raw = String(kwargs['note-id']);
27
25
  const noteId = parseNoteId(raw);
28
- const url = buildNoteUrl(raw);
26
+ const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
29
27
  await page.goto(url);
30
28
  await page.wait({ time: 2 + Math.random() * 3 });
31
29
  const data = await page.evaluate(`
@@ -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,23 +124,20 @@ 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
  });
105
- it('throws AuthRequiredError on login wall', async () => {
106
- const page = createPageMock({ loginWall: true, notFound: false });
107
- await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
108
- });
109
- it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the note page', async () => {
127
+ it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
110
128
  const page = createPageMock({
111
- pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
112
- securityBlock: true,
113
- loginWall: false,
114
- notFound: false,
115
- });
116
- await expect(command.func(page, { 'note-id': '69c131c9000000002800be4c' })).rejects.toMatchObject({
117
- code: 'SECURITY_BLOCK',
118
- message: 'Xiaohongshu security block: the note detail page was blocked by risk control.',
119
- hint: expect.stringContaining('xsec_token'),
129
+ loginWall: false, notFound: false,
130
+ title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
120
131
  });
121
- expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
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
+ });
136
+ it('throws AuthRequiredError on login wall', async () => {
137
+ const page = createPageMock({ loginWall: true, notFound: false });
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');
122
141
  });
123
142
  it('throws SECURITY_BLOCK with retry guidance when a full URL is blocked', async () => {
124
143
  const page = createPageMock({
@@ -136,7 +155,9 @@ describe('xiaohongshu note', () => {
136
155
  });
137
156
  it('throws EmptyResultError when note is not found', async () => {
138
157
  const page = createPageMock({ loginWall: false, notFound: true });
139
- 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');
140
161
  });
141
162
  it('throws an empty-result error when the note page renders as an empty shell', async () => {
142
163
  const page = createPageMock({
@@ -151,7 +172,9 @@ describe('xiaohongshu note', () => {
151
172
  tags: [],
152
173
  });
153
174
  try {
154
- 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
+ });
155
178
  throw new Error('expected xiaohongshu note to fail on an empty shell page');
156
179
  }
157
180
  catch (error) {
@@ -193,7 +216,9 @@ describe('xiaohongshu note', () => {
193
216
  title: 'New note', desc: 'Just posted', author: 'Author',
194
217
  likes: '赞', collects: '收藏', comments: '评论', tags: [],
195
218
  });
196
- 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
+ }));
197
222
  expect(result.find((r) => r.field === 'likes').value).toBe('0');
198
223
  expect(result.find((r) => r.field === 'collects').value).toBe('0');
199
224
  expect(result.find((r) => r.field === 'comments').value).toBe('0');
@@ -203,7 +228,7 @@ describe('xiaohongshu note', () => {
203
228
  loginWall: false, notFound: false,
204
229
  title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
205
230
  });
206
- await command.func(page, { 'note-id': 'abc123' });
231
+ await command.func(page, { 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok' });
207
232
  const evaluateScript = page.evaluate.mock.calls[0][0];
208
233
  expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
209
234
  expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
@@ -215,7 +240,9 @@ describe('xiaohongshu note', () => {
215
240
  title: 'No tags', desc: 'Content', author: 'Author',
216
241
  likes: '1', collects: '2', comments: '3', tags: [],
217
242
  });
218
- 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
+ }));
219
246
  expect(result.find((r) => r.field === 'tags')).toBeUndefined();
220
247
  expect(result).toHaveLength(6);
221
248
  });