@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
@@ -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,13 +71,54 @@ 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
  });
74
+ it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
75
+ const page = createPageMock({
76
+ loginWall: false,
77
+ results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
78
+ });
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
+ });
64
83
  it('throws AuthRequiredError when login wall is detected', async () => {
65
84
  const page = createPageMock({ loginWall: true, results: [] });
66
- await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
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');
89
+ });
90
+ it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
91
+ const page = createPageMock({
92
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
93
+ securityBlock: true,
94
+ loginWall: false,
95
+ results: [],
96
+ });
97
+ await expect(command.func(page, {
98
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
99
+ limit: 5,
100
+ })).rejects.toMatchObject({
101
+ code: 'SECURITY_BLOCK',
102
+ hint: expect.stringContaining('Try again later'),
103
+ });
67
104
  });
68
105
  it('returns empty array when no comments are found', async () => {
69
106
  const page = createPageMock({ loginWall: false, results: [] });
70
- 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([]);
111
+ });
112
+ it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
113
+ const page = createPageMock({ loginWall: false, results: [] });
114
+ await command.func(page, {
115
+ 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
116
+ limit: 5,
117
+ });
118
+ const script = page.evaluate.mock.calls[0][0];
119
+ expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
120
+ expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
121
+ expect(script).toContain('if (afterCount <= beforeCount) break');
71
122
  });
72
123
  it('respects the limit for top-level comments', async () => {
73
124
  const manyComments = Array.from({ length: 10 }, (_, i) => ({
@@ -79,7 +130,10 @@ describe('xiaohongshu comments', () => {
79
130
  reply_to: '',
80
131
  }));
81
132
  const page = createPageMock({ loginWall: false, results: manyComments });
82
- 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
+ }));
83
137
  expect(result).toHaveLength(3);
84
138
  expect(result[0].rank).toBe(1);
85
139
  expect(result[2].rank).toBe(3);
@@ -92,7 +146,10 @@ describe('xiaohongshu comments', () => {
92
146
  { author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
93
147
  ],
94
148
  });
95
- 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
+ }));
96
153
  expect(result).toHaveLength(1);
97
154
  expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
98
155
  });
@@ -107,7 +164,7 @@ describe('xiaohongshu comments', () => {
107
164
  ],
108
165
  });
109
166
  const result = (await command.func(page, {
110
- '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,
111
168
  }));
112
169
  expect(result).toHaveLength(3);
113
170
  expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
@@ -130,7 +187,7 @@ describe('xiaohongshu comments', () => {
130
187
  });
131
188
  // Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
132
189
  const result = (await command.func(page, {
133
- '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,
134
191
  }));
135
192
  expect(result).toHaveLength(4);
136
193
  expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
@@ -253,6 +253,7 @@ async function captureNoteDetailPayload(page, noteId) {
253
253
  let captured = 0;
254
254
  // Try to fetch each API endpoint through the page context (uses the browser's cookies)
255
255
  for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
256
+ await page.wait({ time: 0.5 + Math.random() });
256
257
  const apiUrl = `${suffix}?note_id=${noteId}`;
257
258
  try {
258
259
  const data = await page.evaluate(`
@@ -325,6 +326,7 @@ cli({
325
326
  domain: 'creator.xiaohongshu.com',
326
327
  strategy: Strategy.COOKIE,
327
328
  browser: true,
329
+ navigateBefore: false,
328
330
  args: [
329
331
  { name: 'note-id', positional: true, type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
330
332
  ],
@@ -256,4 +256,36 @@ describe('xiaohongshu creator-note-detail', () => {
256
256
  { section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
257
257
  ]);
258
258
  });
259
+ it('waits between creator detail API fetches to avoid burst traffic', async () => {
260
+ const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
261
+ const domData = {
262
+ title: '示例笔记',
263
+ infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
264
+ sections: [
265
+ {
266
+ title: '基础数据',
267
+ metrics: [
268
+ { label: '曝光数', value: '100', extra: '粉丝占比 10%' },
269
+ { label: '观看数', value: '50', extra: '粉丝占比 20%' },
270
+ { label: '封面点击率', value: '12%', extra: '粉丝 11%' },
271
+ { label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
272
+ { label: '涨粉数', value: '2', extra: '' },
273
+ ],
274
+ },
275
+ {
276
+ title: '互动数据',
277
+ metrics: [
278
+ { label: '点赞数', value: '8', extra: '粉丝占比 25%' },
279
+ { label: '评论数', value: '1', extra: '粉丝占比 0%' },
280
+ { label: '收藏数', value: '3', extra: '粉丝占比 50%' },
281
+ { label: '分享数', value: '0', extra: '粉丝占比 0%' },
282
+ ],
283
+ },
284
+ ],
285
+ };
286
+ const page = createPageMock([domData, null, null, null, null]);
287
+ await cmd.func(page, { 'note-id': 'demo-note-id' });
288
+ expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
289
+ expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4);
290
+ });
259
291
  });
@@ -50,6 +50,7 @@ cli({
50
50
  domain: 'creator.xiaohongshu.com',
51
51
  strategy: Strategy.COOKIE,
52
52
  browser: true,
53
+ navigateBefore: false,
53
54
  args: [
54
55
  { name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' },
55
56
  ],
@@ -63,6 +64,9 @@ cli({
63
64
  }
64
65
  const results = [];
65
66
  for (const [index, note] of notes.entries()) {
67
+ if (index > 0) {
68
+ await page.wait({ time: 1 + Math.random() * 2 });
69
+ }
66
70
  if (!note.id) {
67
71
  results.push({
68
72
  rank: index + 1,
@@ -1,5 +1,8 @@
1
- import { describe, expect, it } from 'vitest';
1
+ import { describe, expect, it, vi } from 'vitest';
2
2
  import { summarizeCreatorNote } from './creator-notes-summary.js';
3
+ import { getRegistry } from '@jackwener/opencli/registry';
4
+ import * as creatorNotesModule from './creator-notes.js';
5
+ import * as creatorDetailModule from './creator-note-detail.js';
3
6
  import './creator-notes-summary.js';
4
7
  describe('xiaohongshu creator-notes-summary', () => {
5
8
  it('summarizes note list row and detail rows into one compact row', () => {
@@ -46,4 +49,39 @@ describe('xiaohongshu creator-notes-summary', () => {
46
49
  url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc',
47
50
  });
48
51
  });
52
+ it('waits between note detail fetches after the first note', async () => {
53
+ const cmd = getRegistry().get('xiaohongshu/creator-notes-summary');
54
+ const page = {
55
+ wait: vi.fn().mockResolvedValue(undefined),
56
+ };
57
+ vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([
58
+ {
59
+ id: 'aaaaaaaaaaaaaaaaaaaaaaaa',
60
+ title: 'n1',
61
+ date: '2026年03月18日 20:01',
62
+ views: 1,
63
+ likes: 1,
64
+ collects: 1,
65
+ comments: 1,
66
+ url: 'u1',
67
+ },
68
+ {
69
+ id: 'bbbbbbbbbbbbbbbbbbbbbbbb',
70
+ title: 'n2',
71
+ date: '2026年03月19日 20:01',
72
+ views: 2,
73
+ likes: 2,
74
+ collects: 2,
75
+ comments: 2,
76
+ url: 'u2',
77
+ },
78
+ ]);
79
+ vi.spyOn(creatorDetailModule, 'fetchCreatorNoteDetailRows').mockResolvedValue([
80
+ { section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
81
+ { section: '基础数据', metric: '观看数', value: '1', extra: '' },
82
+ ]);
83
+ await cmd.func(page, { limit: 2 });
84
+ expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
85
+ expect(page.wait.mock.calls).toHaveLength(1);
86
+ });
49
87
  });
@@ -200,6 +200,7 @@ cli({
200
200
  domain: 'creator.xiaohongshu.com',
201
201
  strategy: Strategy.COOKIE,
202
202
  browser: true,
203
+ navigateBefore: false,
203
204
  args: [
204
205
  { name: 'limit', type: 'int', default: 20, help: 'Number of notes to return' },
205
206
  ],
@@ -15,6 +15,7 @@ cli({
15
15
  domain: 'creator.xiaohongshu.com',
16
16
  strategy: Strategy.COOKIE,
17
17
  browser: true,
18
+ navigateBefore: false,
18
19
  args: [],
19
20
  columns: ['field', 'value'],
20
21
  func: async (page, _kwargs) => {
@@ -15,6 +15,7 @@ cli({
15
15
  domain: 'creator.xiaohongshu.com',
16
16
  strategy: Strategy.COOKIE,
17
17
  browser: true,
18
+ navigateBefore: false,
18
19
  args: [
19
20
  {
20
21
  name: 'period',
@@ -2,14 +2,14 @@
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';
12
11
  import { downloadMedia } from '@jackwener/opencli/download/media-download';
12
+ import { CliError } from '@jackwener/opencli/errors';
13
13
  import { buildNoteUrl, parseNoteId } from './note-helpers.js';
14
14
  cli({
15
15
  site: 'xiaohongshu',
@@ -17,8 +17,9 @@ cli({
17
17
  description: '下载小红书笔记中的图片和视频',
18
18
  domain: 'www.xiaohongshu.com',
19
19
  strategy: Strategy.COOKIE,
20
+ navigateBefore: false,
20
21
  args: [
21
- { 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' },
22
23
  { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
23
24
  ],
24
25
  columns: ['index', 'type', 'status', 'size'],
@@ -26,12 +27,17 @@ cli({
26
27
  const rawInput = String(kwargs['note-id']);
27
28
  const output = kwargs.output;
28
29
  const noteId = parseNoteId(rawInput);
29
- await page.goto(buildNoteUrl(rawInput));
30
+ await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
31
+ await page.wait({ time: 1 + Math.random() * 2 });
30
32
  // Extract note info and media URLs
31
33
  const data = await page.evaluate(`
32
34
  (() => {
35
+ const bodyText = document.body?.innerText || '';
33
36
  const result = {
34
37
  noteId: '${noteId}',
38
+ pageUrl: location.href,
39
+ securityBlock: /安全限制|访问链接异常/.test(bodyText)
40
+ || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href),
35
41
  title: '',
36
42
  author: '',
37
43
  media: []
@@ -44,9 +50,9 @@ cli({
44
50
  seenMedia.add(key);
45
51
  result.media.push({ type, url });
46
52
  };
47
- 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);
48
54
  if (locationMatch) {
49
- result.noteId = locationMatch[1];
55
+ result.noteId = locationMatch[1] || locationMatch[2];
50
56
  }
51
57
 
52
58
  // Get title
@@ -148,6 +154,11 @@ cli({
148
154
  return result;
149
155
  })()
150
156
  `);
157
+ if (data?.securityBlock) {
158
+ throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
159
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
160
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
161
+ }
151
162
  if (!data || !data.media || data.media.length === 0) {
152
163
  return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
153
164
  }
@@ -70,4 +70,46 @@ describe('xiaohongshu download', () => {
70
70
  filenamePrefix: '69bc166f000000001a02069a',
71
71
  }));
72
72
  });
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 () => {
87
+ const page = createPageMock({
88
+ noteId: '69bc166f000000001a02069a',
89
+ media: [],
90
+ });
91
+ await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({
92
+ code: 'ARGUMENT',
93
+ message: expect.stringContaining('signed URL'),
94
+ hint: expect.stringContaining('xsec_token'),
95
+ });
96
+ expect(page.goto).not.toHaveBeenCalled();
97
+ expect(mockDownloadMedia).not.toHaveBeenCalled();
98
+ });
99
+ it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => {
100
+ const page = createPageMock({
101
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
102
+ securityBlock: true,
103
+ noteId: '69bc166f000000001a02069a',
104
+ media: [],
105
+ });
106
+ await expect(command.func(page, {
107
+ 'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search',
108
+ output: './out',
109
+ })).rejects.toMatchObject({
110
+ code: 'SECURITY_BLOCK',
111
+ hint: expect.stringContaining('Try again later'),
112
+ });
113
+ expect(mockDownloadMedia).not.toHaveBeenCalled();
114
+ });
73
115
  });
@@ -0,0 +1,34 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { getRegistry } from '@jackwener/opencli/registry';
3
+ import './note.js';
4
+ import './comments.js';
5
+ import './download.js';
6
+ import './search.js';
7
+ import './user.js';
8
+ import './publish.js';
9
+ import './creator-notes.js';
10
+ import './creator-note-detail.js';
11
+ import './creator-notes-summary.js';
12
+ import './creator-profile.js';
13
+ import './creator-stats.js';
14
+
15
+ describe('xiaohongshu navigateBefore hardening', () => {
16
+ const expectedFalse = [
17
+ 'xiaohongshu/note',
18
+ 'xiaohongshu/comments',
19
+ 'xiaohongshu/download',
20
+ 'xiaohongshu/search',
21
+ 'xiaohongshu/user',
22
+ 'xiaohongshu/publish',
23
+ 'xiaohongshu/creator-notes',
24
+ 'xiaohongshu/creator-note-detail',
25
+ 'xiaohongshu/creator-notes-summary',
26
+ 'xiaohongshu/creator-profile',
27
+ 'xiaohongshu/creator-stats',
28
+ ];
29
+ it.each(expectedFalse)('%s sets navigateBefore=false', (name) => {
30
+ const cmd = getRegistry().get(name);
31
+ expect(cmd).toBeDefined();
32
+ expect(cmd.navigateBefore).toBe(false);
33
+ });
34
+ });
@@ -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,12 +4,10 @@
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
- import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
10
+ import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
13
11
  import { parseNoteId, buildNoteUrl } from './note-helpers.js';
14
12
  cli({
15
13
  site: 'xiaohongshu',
@@ -17,20 +15,24 @@ cli({
17
15
  description: '获取小红书笔记正文和互动数据',
18
16
  domain: 'www.xiaohongshu.com',
19
17
  strategy: Strategy.COOKIE,
18
+ navigateBefore: false,
20
19
  args: [
21
- { 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' },
22
21
  ],
23
22
  columns: ['field', 'value'],
24
23
  func: async (page, kwargs) => {
25
24
  const raw = String(kwargs['note-id']);
26
25
  const noteId = parseNoteId(raw);
27
- const url = buildNoteUrl(raw);
26
+ const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
28
27
  await page.goto(url);
29
- await page.wait(3);
28
+ await page.wait({ time: 2 + Math.random() * 3 });
30
29
  const data = await page.evaluate(`
31
30
  (() => {
32
- const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
33
- const notFound = /页面不见了|笔记不存在|无法浏览/.test(document.body.innerText || '')
31
+ const bodyText = document.body?.innerText || ''
32
+ const loginWall = /登录后查看|请登录/.test(bodyText)
33
+ const notFound = /页面不见了|笔记不存在|无法浏览/.test(bodyText)
34
+ const securityBlock = /安全限制|访问链接异常/.test(bodyText)
35
+ || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)
34
36
 
35
37
  const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
36
38
 
@@ -53,12 +55,17 @@ cli({
53
55
  if (t) tags.push(t)
54
56
  })
55
57
 
56
- return { loginWall, notFound, title, desc, author, likes, collects, comments, tags }
58
+ return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags }
57
59
  })()
58
60
  `);
59
61
  if (!data || typeof data !== 'object') {
60
62
  throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
61
63
  }
64
+ if (data.securityBlock) {
65
+ throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
66
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
67
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
68
+ }
62
69
  if (data.loginWall) {
63
70
  throw new AuthRequiredError('www.xiaohongshu.com', 'Note content requires login');
64
71
  }