@jackwener/opencli 1.7.1 → 1.7.3

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 (122) hide show
  1. package/README.md +5 -2
  2. package/README.zh-CN.md +6 -3
  3. package/cli-manifest.json +1085 -73
  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/feed.js +202 -48
  9. package/clis/binance/asks.js +21 -0
  10. package/clis/binance/commands.test.js +70 -0
  11. package/clis/binance/depth.js +21 -0
  12. package/clis/binance/gainers.js +22 -0
  13. package/clis/binance/klines.js +21 -0
  14. package/clis/binance/losers.js +22 -0
  15. package/clis/binance/pairs.js +21 -0
  16. package/clis/binance/price.js +18 -0
  17. package/clis/binance/prices.js +19 -0
  18. package/clis/binance/ticker.js +21 -0
  19. package/clis/binance/top.js +21 -0
  20. package/clis/binance/trades.js +20 -0
  21. package/clis/boss/utils.js +2 -1
  22. package/clis/chatgpt/image.js +97 -0
  23. package/clis/chatgpt/utils.js +297 -0
  24. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  25. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  26. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  27. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  28. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  29. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  30. package/clis/discord-app/delete.js +114 -0
  31. package/clis/douban/utils.js +29 -2
  32. package/clis/douban/utils.test.js +121 -1
  33. package/clis/ke/chengjiao.js +77 -0
  34. package/clis/ke/ershoufang.js +100 -0
  35. package/clis/ke/utils.js +104 -0
  36. package/clis/ke/xiaoqu.js +77 -0
  37. package/clis/ke/zufang.js +94 -0
  38. package/clis/maimai/search-talents.js +172 -0
  39. package/clis/mubu/doc.js +40 -0
  40. package/clis/mubu/docs.js +43 -0
  41. package/clis/mubu/notes.js +244 -0
  42. package/clis/mubu/recent.js +27 -0
  43. package/clis/mubu/search.js +62 -0
  44. package/clis/mubu/utils.js +304 -0
  45. package/clis/reuters/search.js +1 -1
  46. package/clis/twitter/lists-parser.js +77 -0
  47. package/clis/twitter/lists.d.ts +5 -0
  48. package/clis/twitter/lists.js +62 -0
  49. package/clis/twitter/lists.test.js +50 -0
  50. package/clis/weibo/feed.js +18 -5
  51. package/clis/xiaohongshu/comments.js +18 -6
  52. package/clis/xiaohongshu/comments.test.js +36 -0
  53. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  54. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  55. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  56. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  57. package/clis/xiaohongshu/creator-notes.js +1 -0
  58. package/clis/xiaohongshu/creator-profile.js +1 -0
  59. package/clis/xiaohongshu/creator-stats.js +1 -0
  60. package/clis/xiaohongshu/download.js +12 -0
  61. package/clis/xiaohongshu/download.test.js +30 -0
  62. package/clis/xiaohongshu/navigation.test.js +34 -0
  63. package/clis/xiaohongshu/note.js +14 -5
  64. package/clis/xiaohongshu/note.test.js +28 -0
  65. package/clis/xiaohongshu/publish.js +1 -0
  66. package/clis/xiaohongshu/search.js +1 -0
  67. package/clis/xiaohongshu/user.js +1 -0
  68. package/clis/yahoo-finance/quote.js +1 -1
  69. package/clis/zsxq/topic.js +5 -3
  70. package/clis/zsxq/topic.test.js +4 -3
  71. package/clis/zsxq/utils.js +1 -1
  72. package/dist/src/browser/base-page.d.ts +9 -0
  73. package/dist/src/browser/base-page.js +19 -0
  74. package/dist/src/browser/cdp.js +10 -2
  75. package/dist/src/browser/daemon-client.d.ts +1 -0
  76. package/dist/src/cli.js +112 -2
  77. package/dist/src/daemon.js +5 -0
  78. package/dist/src/discovery.d.ts +5 -2
  79. package/dist/src/discovery.js +7 -35
  80. package/dist/src/doctor.d.ts +1 -0
  81. package/dist/src/doctor.js +51 -2
  82. package/dist/src/electron-apps.js +1 -1
  83. package/dist/src/engine.test.js +29 -1
  84. package/dist/src/errors.d.ts +1 -0
  85. package/dist/src/errors.js +13 -0
  86. package/dist/src/execution.js +36 -9
  87. package/dist/src/execution.test.js +23 -0
  88. package/dist/src/logger.d.ts +2 -2
  89. package/dist/src/logger.js +4 -9
  90. package/dist/src/main.js +6 -5
  91. package/dist/src/registry.js +3 -4
  92. package/dist/src/types.d.ts +2 -0
  93. package/dist/src/update-check.d.ts +14 -0
  94. package/dist/src/update-check.js +48 -3
  95. package/dist/src/update-check.test.js +31 -0
  96. package/package.json +3 -3
  97. package/scripts/fetch-adapters.js +92 -34
  98. package/dist/src/clis/binance/asks.js +0 -20
  99. package/dist/src/clis/binance/commands.test.d.ts +0 -3
  100. package/dist/src/clis/binance/commands.test.js +0 -58
  101. package/dist/src/clis/binance/depth.d.ts +0 -1
  102. package/dist/src/clis/binance/depth.js +0 -20
  103. package/dist/src/clis/binance/gainers.d.ts +0 -1
  104. package/dist/src/clis/binance/gainers.js +0 -21
  105. package/dist/src/clis/binance/klines.d.ts +0 -1
  106. package/dist/src/clis/binance/klines.js +0 -20
  107. package/dist/src/clis/binance/losers.d.ts +0 -1
  108. package/dist/src/clis/binance/losers.js +0 -21
  109. package/dist/src/clis/binance/pairs.d.ts +0 -1
  110. package/dist/src/clis/binance/pairs.js +0 -20
  111. package/dist/src/clis/binance/price.d.ts +0 -1
  112. package/dist/src/clis/binance/price.js +0 -17
  113. package/dist/src/clis/binance/prices.d.ts +0 -1
  114. package/dist/src/clis/binance/prices.js +0 -18
  115. package/dist/src/clis/binance/ticker.d.ts +0 -1
  116. package/dist/src/clis/binance/ticker.js +0 -20
  117. package/dist/src/clis/binance/top.d.ts +0 -1
  118. package/dist/src/clis/binance/top.js +0 -20
  119. package/dist/src/clis/binance/trades.d.ts +0 -1
  120. package/dist/src/clis/binance/trades.js +0 -19
  121. /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
  122. /package/dist/src/{clis/binance/asks.d.ts → update-check.test.d.ts} +0 -0
@@ -65,10 +65,46 @@ describe('xiaohongshu comments', () => {
65
65
  const page = createPageMock({ loginWall: true, results: [] });
66
66
  await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
67
67
  });
68
+ it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the comments page', async () => {
69
+ const page = createPageMock({
70
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
71
+ securityBlock: true,
72
+ 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'),
78
+ });
79
+ expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
80
+ });
81
+ it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
82
+ const page = createPageMock({
83
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
84
+ securityBlock: true,
85
+ loginWall: false,
86
+ results: [],
87
+ });
88
+ await expect(command.func(page, {
89
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
90
+ limit: 5,
91
+ })).rejects.toMatchObject({
92
+ code: 'SECURITY_BLOCK',
93
+ hint: expect.stringContaining('Try again later'),
94
+ });
95
+ });
68
96
  it('returns empty array when no comments are found', async () => {
69
97
  const page = createPageMock({ loginWall: false, results: [] });
70
98
  await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).resolves.toEqual([]);
71
99
  });
100
+ it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
101
+ const page = createPageMock({ loginWall: false, results: [] });
102
+ await command.func(page, { 'note-id': 'abc123', limit: 5 });
103
+ const script = page.evaluate.mock.calls[0][0];
104
+ expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
105
+ expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
106
+ expect(script).toContain('if (afterCount <= beforeCount) break');
107
+ });
72
108
  it('respects the limit for top-level comments', async () => {
73
109
  const manyComments = Array.from({ length: 10 }, (_, i) => ({
74
110
  author: `User${i}`,
@@ -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',
@@ -10,6 +10,7 @@
10
10
  import { cli, Strategy } from '@jackwener/opencli/registry';
11
11
  import { formatCookieHeader } from '@jackwener/opencli/download';
12
12
  import { downloadMedia } from '@jackwener/opencli/download/media-download';
13
+ import { CliError } from '@jackwener/opencli/errors';
13
14
  import { buildNoteUrl, parseNoteId } from './note-helpers.js';
14
15
  cli({
15
16
  site: 'xiaohongshu',
@@ -17,6 +18,7 @@ cli({
17
18
  description: '下载小红书笔记中的图片和视频',
18
19
  domain: 'www.xiaohongshu.com',
19
20
  strategy: Strategy.COOKIE,
21
+ navigateBefore: false,
20
22
  args: [
21
23
  { name: 'note-id', positional: true, required: true, help: 'Note ID, full URL, or short link' },
22
24
  { name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
@@ -27,11 +29,16 @@ cli({
27
29
  const output = kwargs.output;
28
30
  const noteId = parseNoteId(rawInput);
29
31
  await page.goto(buildNoteUrl(rawInput));
32
+ await page.wait({ time: 1 + Math.random() * 2 });
30
33
  // Extract note info and media URLs
31
34
  const data = await page.evaluate(`
32
35
  (() => {
36
+ const bodyText = document.body?.innerText || '';
33
37
  const result = {
34
38
  noteId: '${noteId}',
39
+ pageUrl: location.href,
40
+ securityBlock: /安全限制|访问链接异常/.test(bodyText)
41
+ || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href),
35
42
  title: '',
36
43
  author: '',
37
44
  media: []
@@ -148,6 +155,11 @@ cli({
148
155
  return result;
149
156
  })()
150
157
  `);
158
+ if (data?.securityBlock) {
159
+ throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
160
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
161
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
162
+ }
151
163
  if (!data || !data.media || data.media.length === 0) {
152
164
  return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
153
165
  }
@@ -70,4 +70,34 @@ describe('xiaohongshu download', () => {
70
70
  filenamePrefix: '69bc166f000000001a02069a',
71
71
  }));
72
72
  });
73
+ it('throws SECURITY_BLOCK with bare-id guidance before starting downloads', async () => {
74
+ const page = createPageMock({
75
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
76
+ securityBlock: true,
77
+ noteId: '69bc166f000000001a02069a',
78
+ media: [],
79
+ });
80
+ await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({
81
+ code: 'SECURITY_BLOCK',
82
+ hint: expect.stringContaining('xsec_token'),
83
+ });
84
+ expect(mockDownloadMedia).not.toHaveBeenCalled();
85
+ expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
86
+ });
87
+ it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => {
88
+ const page = createPageMock({
89
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
90
+ securityBlock: true,
91
+ noteId: '69bc166f000000001a02069a',
92
+ media: [],
93
+ });
94
+ await expect(command.func(page, {
95
+ 'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search',
96
+ output: './out',
97
+ })).rejects.toMatchObject({
98
+ code: 'SECURITY_BLOCK',
99
+ hint: expect.stringContaining('Try again later'),
100
+ });
101
+ expect(mockDownloadMedia).not.toHaveBeenCalled();
102
+ });
73
103
  });
@@ -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
+ });
@@ -9,7 +9,7 @@
9
9
  * when the user is logged in via cookies.
10
10
  */
11
11
  import { cli, Strategy } from '@jackwener/opencli/registry';
12
- import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
12
+ import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
13
13
  import { parseNoteId, buildNoteUrl } from './note-helpers.js';
14
14
  cli({
15
15
  site: 'xiaohongshu',
@@ -17,6 +17,7 @@ cli({
17
17
  description: '获取小红书笔记正文和互动数据',
18
18
  domain: 'www.xiaohongshu.com',
19
19
  strategy: Strategy.COOKIE,
20
+ navigateBefore: false,
20
21
  args: [
21
22
  { name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
22
23
  ],
@@ -26,11 +27,14 @@ cli({
26
27
  const noteId = parseNoteId(raw);
27
28
  const url = buildNoteUrl(raw);
28
29
  await page.goto(url);
29
- await page.wait(3);
30
+ await page.wait({ time: 2 + Math.random() * 3 });
30
31
  const data = await page.evaluate(`
31
32
  (() => {
32
- const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
33
- const notFound = /页面不见了|笔记不存在|无法浏览/.test(document.body.innerText || '')
33
+ const bodyText = document.body?.innerText || ''
34
+ const loginWall = /登录后查看|请登录/.test(bodyText)
35
+ const notFound = /页面不见了|笔记不存在|无法浏览/.test(bodyText)
36
+ const securityBlock = /安全限制|访问链接异常/.test(bodyText)
37
+ || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)
34
38
 
35
39
  const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
36
40
 
@@ -53,12 +57,17 @@ cli({
53
57
  if (t) tags.push(t)
54
58
  })
55
59
 
56
- return { loginWall, notFound, title, desc, author, likes, collects, comments, tags }
60
+ return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags }
57
61
  })()
58
62
  `);
59
63
  if (!data || typeof data !== 'object') {
60
64
  throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
61
65
  }
66
+ if (data.securityBlock) {
67
+ throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
68
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
69
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
70
+ }
62
71
  if (data.loginWall) {
63
72
  throw new AuthRequiredError('www.xiaohongshu.com', 'Note content requires login');
64
73
  }
@@ -106,6 +106,34 @@ describe('xiaohongshu note', () => {
106
106
  const page = createPageMock({ loginWall: true, notFound: false });
107
107
  await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
108
108
  });
109
+ it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the note page', async () => {
110
+ 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'),
120
+ });
121
+ expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
122
+ });
123
+ it('throws SECURITY_BLOCK with retry guidance when a full URL is blocked', async () => {
124
+ const page = createPageMock({
125
+ pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
126
+ securityBlock: true,
127
+ loginWall: false,
128
+ notFound: false,
129
+ });
130
+ await expect(command.func(page, {
131
+ 'note-id': 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc',
132
+ })).rejects.toMatchObject({
133
+ code: 'SECURITY_BLOCK',
134
+ hint: expect.stringContaining('Try again later'),
135
+ });
136
+ });
109
137
  it('throws EmptyResultError when note is not found', async () => {
110
138
  const page = createPageMock({ loginWall: false, notFound: true });
111
139
  await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('returned no data');
@@ -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' },
@@ -18,7 +18,7 @@ cli({
18
18
  await page.goto(`https://finance.yahoo.com/quote/${encodeURIComponent(symbol)}/`);
19
19
  const data = await page.evaluate(`
20
20
  (async () => {
21
- const sym = '${symbol}';
21
+ const sym = ${JSON.stringify(symbol)};
22
22
 
23
23
  // Strategy 1: v8 chart API
24
24
  try {
@@ -1,6 +1,6 @@
1
1
  import { cli, Strategy } from '@jackwener/opencli/registry';
2
2
  import { CliError } from '@jackwener/opencli/errors';
3
- import { browserJsonRequest, ensureZsxqAuth, ensureZsxqPage, fetchFirstJson, getCommentsFromResponse, getTopicFromResponse, getTopicUrl, summarizeComments, toTopicRow, } from './utils.js';
3
+ import { getActiveGroupId, browserJsonRequest, ensureZsxqAuth, ensureZsxqPage, fetchFirstJson, getCommentsFromResponse, getTopicFromResponse, getTopicUrl, summarizeComments, toTopicRow, } from './utils.js';
4
4
  cli({
5
5
  site: 'zsxq',
6
6
  name: 'topic',
@@ -10,6 +10,7 @@ cli({
10
10
  browser: true,
11
11
  args: [
12
12
  { name: 'id', required: true, positional: true, help: 'Topic ID' },
13
+ { name: 'group_id', help: 'Group ID (optional; defaults to active group in Chrome)' },
13
14
  { name: 'comment_limit', type: 'int', default: 20, help: 'Number of comments to fetch' },
14
15
  ],
15
16
  columns: ['topic_id', 'type', 'author', 'title', 'comments', 'likes', 'comment_preview', 'url'],
@@ -17,8 +18,9 @@ cli({
17
18
  await ensureZsxqPage(page);
18
19
  await ensureZsxqAuth(page);
19
20
  const topicId = String(kwargs.id);
21
+ const groupId = String(kwargs.group_id || await getActiveGroupId(page));
20
22
  const commentLimit = Math.max(1, Number(kwargs.comment_limit) || 20);
21
- const detailUrl = `https://api.zsxq.com/v2/topics/${topicId}`;
23
+ const detailUrl = `https://api.zsxq.com/v2/groups/${groupId}/topics/${topicId}`;
22
24
  const detailResp = await browserJsonRequest(page, detailUrl);
23
25
  if (detailResp.status === 404) {
24
26
  throw new CliError('NOT_FOUND', `Topic ${topicId} not found`);
@@ -27,7 +29,7 @@ cli({
27
29
  throw new CliError('FETCH_ERROR', detailResp.error || `Failed to fetch topic ${topicId}`, `Checked endpoint: ${detailUrl}`);
28
30
  }
29
31
  const commentsResp = await fetchFirstJson(page, [
30
- `https://api.zsxq.com/v2/topics/${topicId}/comments?sort=asc&count=${commentLimit}`,
32
+ `https://api.zsxq.com/v2/groups/${groupId}/topics/${topicId}/comments?sort=asc&count=${commentLimit}`,
31
33
  ]);
32
34
  const topic = getTopicFromResponse(detailResp.data);
33
35
  if (!topic)
@@ -11,11 +11,12 @@ describe('zsxq topic command', () => {
11
11
  const mockPage = {
12
12
  goto: vi.fn().mockResolvedValue(undefined),
13
13
  evaluate: vi.fn()
14
- .mockResolvedValueOnce(true)
14
+ .mockResolvedValueOnce(true) // ensureZsxqAuth
15
+ .mockResolvedValueOnce('12345') // getActiveGroupId
15
16
  .mockResolvedValueOnce({
16
17
  ok: true,
17
18
  status: 404,
18
- url: 'https://api.zsxq.com/v2/topics/404',
19
+ url: 'https://api.zsxq.com/v2/groups/12345/topics/404',
19
20
  data: null,
20
21
  }),
21
22
  };
@@ -24,6 +25,6 @@ describe('zsxq topic command', () => {
24
25
  message: 'Topic 404 not found',
25
26
  });
26
27
  expect(mockPage.goto).toHaveBeenCalledWith('https://wx.zsxq.com');
27
- expect(mockPage.evaluate).toHaveBeenCalledTimes(2);
28
+ expect(mockPage.evaluate).toHaveBeenCalledTimes(3);
28
29
  });
29
30
  });
@@ -168,7 +168,7 @@ export function getTopicFromResponse(payload) {
168
168
  const data = unwrapRespData(payload);
169
169
  if (Array.isArray(data))
170
170
  return data[0] ?? null;
171
- if (typeof data.topic_id === 'number')
171
+ if (typeof data.topic_id === 'number' || typeof data.topic_id === 'string')
172
172
  return data;
173
173
  const record = asRecord(data);
174
174
  if (!record)
@@ -18,6 +18,15 @@ export declare abstract class BasePage implements IPage {
18
18
  settleMs?: number;
19
19
  }): Promise<void>;
20
20
  abstract evaluate(js: string): Promise<unknown>;
21
+ /**
22
+ * Safely evaluate JS with pre-serialized arguments.
23
+ * Each key in `args` becomes a `const` declaration with JSON-serialized value,
24
+ * prepended to the JS code. Prevents injection by design.
25
+ *
26
+ * Usage:
27
+ * page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
28
+ */
29
+ evaluateWithArgs(js: string, args: Record<string, unknown>): Promise<unknown>;
21
30
  abstract getCookies(opts?: {
22
31
  domain?: string;
23
32
  url?: string;
@@ -15,6 +15,25 @@ export class BasePage {
15
15
  _lastUrl = null;
16
16
  /** Cached previous snapshot hashes for incremental diff marking */
17
17
  _prevSnapshotHashes = null;
18
+ /**
19
+ * Safely evaluate JS with pre-serialized arguments.
20
+ * Each key in `args` becomes a `const` declaration with JSON-serialized value,
21
+ * prepended to the JS code. Prevents injection by design.
22
+ *
23
+ * Usage:
24
+ * page.evaluateWithArgs(`(async () => { return sym; })()`, { sym: userInput })
25
+ */
26
+ async evaluateWithArgs(js, args) {
27
+ const declarations = Object.entries(args)
28
+ .map(([key, value]) => {
29
+ if (!/^[a-zA-Z_$][a-zA-Z0-9_$]*$/.test(key)) {
30
+ throw new Error(`evaluateWithArgs: invalid key "${key}"`);
31
+ }
32
+ return `const ${key} = ${JSON.stringify(value)};`;
33
+ })
34
+ .join('\n');
35
+ return this.evaluate(`${declarations}\n${js}`);
36
+ }
18
37
  // ── Shared DOM helper implementations ──
19
38
  async click(ref) {
20
39
  const result = await this.evaluate(clickJs(ref));
@@ -40,7 +40,11 @@ export class CDPBridge {
40
40
  return new Promise((resolve, reject) => {
41
41
  const ws = new WebSocket(wsUrl);
42
42
  const timeoutMs = (opts?.timeout ?? 10) * 1000;
43
- const timeout = setTimeout(() => reject(new Error('CDP connect timeout')), timeoutMs);
43
+ const timeout = setTimeout(() => {
44
+ this._ws = null;
45
+ ws.close();
46
+ reject(new Error('CDP connect timeout'));
47
+ }, timeoutMs);
44
48
  ws.on('open', async () => {
45
49
  clearTimeout(timeout);
46
50
  this._ws = ws;
@@ -48,7 +52,11 @@ export class CDPBridge {
48
52
  await this.send('Page.enable');
49
53
  await this.send('Page.addScriptToEvaluateOnNewDocument', { source: generateStealthJs() });
50
54
  }
51
- catch { }
55
+ catch (err) {
56
+ ws.close();
57
+ reject(err instanceof Error ? err : new Error(String(err)));
58
+ return;
59
+ }
52
60
  resolve(new CDPPage(this));
53
61
  });
54
62
  ws.on('error', (err) => {
@@ -49,6 +49,7 @@ export interface DaemonStatus {
49
49
  uptime: number;
50
50
  extensionConnected: boolean;
51
51
  extensionVersion?: string;
52
+ extensionCompatRange?: string;
52
53
  pending: number;
53
54
  memoryMB: number;
54
55
  port: number;