@jackwener/opencli 1.7.2 → 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 (75) hide show
  1. package/README.md +4 -1
  2. package/README.zh-CN.md +5 -2
  3. package/cli-manifest.json +658 -31
  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/boss/utils.js +2 -1
  10. package/clis/chatgpt/image.js +97 -0
  11. package/clis/chatgpt/utils.js +297 -0
  12. package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
  13. package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
  14. package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
  15. package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
  16. package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
  17. package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
  18. package/clis/discord-app/delete.js +114 -0
  19. package/clis/douban/utils.js +29 -2
  20. package/clis/douban/utils.test.js +121 -1
  21. package/clis/ke/chengjiao.js +77 -0
  22. package/clis/ke/ershoufang.js +100 -0
  23. package/clis/ke/utils.js +104 -0
  24. package/clis/ke/xiaoqu.js +77 -0
  25. package/clis/ke/zufang.js +94 -0
  26. package/clis/maimai/search-talents.js +172 -0
  27. package/clis/mubu/doc.js +40 -0
  28. package/clis/mubu/docs.js +43 -0
  29. package/clis/mubu/notes.js +244 -0
  30. package/clis/mubu/recent.js +27 -0
  31. package/clis/mubu/search.js +62 -0
  32. package/clis/mubu/utils.js +304 -0
  33. package/clis/reuters/search.js +1 -1
  34. package/clis/xiaohongshu/comments.js +18 -6
  35. package/clis/xiaohongshu/comments.test.js +36 -0
  36. package/clis/xiaohongshu/creator-note-detail.js +2 -0
  37. package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
  38. package/clis/xiaohongshu/creator-notes-summary.js +4 -0
  39. package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
  40. package/clis/xiaohongshu/creator-notes.js +1 -0
  41. package/clis/xiaohongshu/creator-profile.js +1 -0
  42. package/clis/xiaohongshu/creator-stats.js +1 -0
  43. package/clis/xiaohongshu/download.js +12 -0
  44. package/clis/xiaohongshu/download.test.js +30 -0
  45. package/clis/xiaohongshu/navigation.test.js +34 -0
  46. package/clis/xiaohongshu/note.js +14 -5
  47. package/clis/xiaohongshu/note.test.js +28 -0
  48. package/clis/xiaohongshu/publish.js +1 -0
  49. package/clis/xiaohongshu/search.js +1 -0
  50. package/clis/xiaohongshu/user.js +1 -0
  51. package/clis/yahoo-finance/quote.js +1 -1
  52. package/dist/src/browser/base-page.d.ts +9 -0
  53. package/dist/src/browser/base-page.js +19 -0
  54. package/dist/src/browser/cdp.js +10 -2
  55. package/dist/src/browser/daemon-client.d.ts +1 -0
  56. package/dist/src/cli.js +4 -2
  57. package/dist/src/daemon.js +5 -0
  58. package/dist/src/doctor.d.ts +1 -0
  59. package/dist/src/doctor.js +51 -2
  60. package/dist/src/electron-apps.js +1 -1
  61. package/dist/src/errors.d.ts +1 -0
  62. package/dist/src/errors.js +13 -0
  63. package/dist/src/execution.js +36 -9
  64. package/dist/src/execution.test.js +23 -0
  65. package/dist/src/logger.d.ts +2 -2
  66. package/dist/src/logger.js +4 -9
  67. package/dist/src/registry.js +3 -4
  68. package/dist/src/types.d.ts +2 -0
  69. package/dist/src/update-check.d.ts +14 -0
  70. package/dist/src/update-check.js +48 -3
  71. package/dist/src/update-check.test.d.ts +1 -0
  72. package/dist/src/update-check.test.js +31 -0
  73. package/package.json +1 -1
  74. package/scripts/fetch-adapters.js +35 -8
  75. /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
@@ -6,7 +6,7 @@
6
6
  * the --with-replies flag.
7
7
  */
8
8
  import { cli, Strategy } from '@jackwener/opencli/registry';
9
- import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
9
+ import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
10
10
  import { parseNoteId, buildNoteUrl } from './note-helpers.js';
11
11
  function parseCommentLimit(raw, fallback = 20) {
12
12
  const n = Number(raw);
@@ -20,6 +20,7 @@ cli({
20
20
  description: '获取小红书笔记评论(支持楼中楼子回复)',
21
21
  domain: 'www.xiaohongshu.com',
22
22
  strategy: Strategy.COOKIE,
23
+ navigateBefore: false,
23
24
  args: [
24
25
  { name: 'note-id', required: true, positional: true, help: 'Note ID or full URL (preserves xsec_token for access)' },
25
26
  { name: 'limit', type: 'int', default: 20, help: 'Number of top-level comments (max 50)' },
@@ -32,21 +33,27 @@ cli({
32
33
  const raw = String(kwargs['note-id']);
33
34
  const noteId = parseNoteId(raw);
34
35
  await page.goto(buildNoteUrl(raw));
35
- await page.wait(3);
36
+ await page.wait({ time: 2 + Math.random() * 3 });
36
37
  const data = await page.evaluate(`
37
38
  (async () => {
38
39
  const wait = (ms) => new Promise(r => setTimeout(r, ms))
39
40
  const withReplies = ${withReplies}
40
41
 
41
42
  // Check login state
42
- const loginWall = /登录后查看|请登录/.test(document.body.innerText || '')
43
+ const bodyText = document.body?.innerText || ''
44
+ const loginWall = /登录后查看|请登录/.test(bodyText)
45
+ const securityBlock = /安全限制|访问链接异常/.test(bodyText)
46
+ || /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)
43
47
 
44
48
  // Scroll the note container to trigger comment loading
45
49
  const scroller = document.querySelector('.note-scroller') || document.querySelector('.container')
46
50
  if (scroller) {
47
51
  for (let i = 0; i < 3; i++) {
52
+ const beforeCount = scroller.querySelectorAll('.parent-comment').length
48
53
  scroller.scrollTo(0, scroller.scrollHeight)
49
- await wait(1000)
54
+ await wait(800 + Math.random() * 1200)
55
+ const afterCount = scroller.querySelectorAll('.parent-comment').length
56
+ if (afterCount <= beforeCount) break
50
57
  }
51
58
  }
52
59
 
@@ -72,7 +79,7 @@ cli({
72
79
  const text = clean(el)
73
80
  el.click()
74
81
  clickedTexts.add(text)
75
- await wait(300)
82
+ await wait(200 + Math.random() * 300)
76
83
  }
77
84
  }
78
85
  }
@@ -105,12 +112,17 @@ cli({
105
112
  }
106
113
  }
107
114
 
108
- return { loginWall, results }
115
+ return { pageUrl: location.href, securityBlock, loginWall, results }
109
116
  })()
110
117
  `);
111
118
  if (!data || typeof data !== 'object') {
112
119
  throw new EmptyResultError('xiaohongshu/comments', 'Unexpected evaluate response');
113
120
  }
121
+ if (data.securityBlock) {
122
+ throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
123
+ ? 'The page may be temporarily restricted. Try again later or from a different session.'
124
+ : 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
125
+ }
114
126
  if (data.loginWall) {
115
127
  throw new AuthRequiredError('www.xiaohongshu.com', 'Note comments require login');
116
128
  }
@@ -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 {
@@ -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;
package/dist/src/cli.js CHANGED
@@ -369,9 +369,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
369
369
  await page.wait(0.3);
370
370
  await page.typeText(index, text);
371
371
  // Detect autocomplete/combobox fields and wait for dropdown suggestions
372
+ const safeIndex = JSON.stringify(String(index));
372
373
  const isAutocomplete = await page.evaluate(`
373
374
  (() => {
374
- const el = document.querySelector('[data-opencli-ref="${index}"]');
375
+ const el = document.querySelector('[data-opencli-ref="' + ${safeIndex} + '"]');
375
376
  if (!el) return false;
376
377
  const role = el.getAttribute('role');
377
378
  const ac = el.getAttribute('aria-autocomplete');
@@ -390,9 +391,10 @@ export function createProgram(BUILTIN_CLIS, USER_CLIS) {
390
391
  browser.command('select').argument('<index>', 'Element index of <select>').argument('<option>', 'Option text')
391
392
  .description('Select dropdown option')
392
393
  .action(browserAction(async (page, index, option) => {
394
+ const safeIdx = JSON.stringify(String(index));
393
395
  const result = await page.evaluate(`
394
396
  (function() {
395
- var sel = document.querySelector('[data-opencli-ref="${index}"]');
397
+ var sel = document.querySelector('[data-opencli-ref="' + ${safeIdx} + '"]');
396
398
  if (!sel || sel.tagName !== 'SELECT') return { error: 'Not a <select>' };
397
399
  var match = Array.from(sel.options).find(o => o.text.trim() === ${JSON.stringify(option)} || o.value === ${JSON.stringify(option)});
398
400
  if (!match) return { error: 'Option not found', available: Array.from(sel.options).map(o => o.text.trim()) };