@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.
- package/README.md +4 -1
- package/README.zh-CN.md +5 -2
- package/cli-manifest.json +658 -31
- package/clis/barchart/flow.js +1 -1
- package/clis/barchart/greeks.js +2 -2
- package/clis/barchart/options.js +2 -2
- package/clis/barchart/quote.js +1 -1
- package/clis/bilibili/feed.js +202 -48
- package/clis/boss/utils.js +2 -1
- package/clis/chatgpt/image.js +97 -0
- package/clis/chatgpt/utils.js +297 -0
- package/clis/{chatgpt → chatgpt-app}/ask.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/model.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/new.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/read.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/send.js +1 -1
- package/clis/{chatgpt → chatgpt-app}/status.js +1 -1
- package/clis/discord-app/delete.js +114 -0
- package/clis/douban/utils.js +29 -2
- package/clis/douban/utils.test.js +121 -1
- package/clis/ke/chengjiao.js +77 -0
- package/clis/ke/ershoufang.js +100 -0
- package/clis/ke/utils.js +104 -0
- package/clis/ke/xiaoqu.js +77 -0
- package/clis/ke/zufang.js +94 -0
- package/clis/maimai/search-talents.js +172 -0
- package/clis/mubu/doc.js +40 -0
- package/clis/mubu/docs.js +43 -0
- package/clis/mubu/notes.js +244 -0
- package/clis/mubu/recent.js +27 -0
- package/clis/mubu/search.js +62 -0
- package/clis/mubu/utils.js +304 -0
- package/clis/reuters/search.js +1 -1
- package/clis/xiaohongshu/comments.js +18 -6
- package/clis/xiaohongshu/comments.test.js +36 -0
- package/clis/xiaohongshu/creator-note-detail.js +2 -0
- package/clis/xiaohongshu/creator-note-detail.test.js +32 -0
- package/clis/xiaohongshu/creator-notes-summary.js +4 -0
- package/clis/xiaohongshu/creator-notes-summary.test.js +39 -1
- package/clis/xiaohongshu/creator-notes.js +1 -0
- package/clis/xiaohongshu/creator-profile.js +1 -0
- package/clis/xiaohongshu/creator-stats.js +1 -0
- package/clis/xiaohongshu/download.js +12 -0
- package/clis/xiaohongshu/download.test.js +30 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note.js +14 -5
- package/clis/xiaohongshu/note.test.js +28 -0
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +19 -0
- package/dist/src/browser/cdp.js +10 -2
- package/dist/src/browser/daemon-client.d.ts +1 -0
- package/dist/src/cli.js +4 -2
- package/dist/src/daemon.js +5 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +51 -2
- package/dist/src/electron-apps.js +1 -1
- package/dist/src/errors.d.ts +1 -0
- package/dist/src/errors.js +13 -0
- package/dist/src/execution.js +36 -9
- package/dist/src/execution.test.js +23 -0
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +4 -9
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +2 -0
- package/dist/src/update-check.d.ts +14 -0
- package/dist/src/update-check.js +48 -3
- package/dist/src/update-check.test.d.ts +1 -0
- package/dist/src/update-check.test.js +31 -0
- package/package.json +1 -1
- package/scripts/fetch-adapters.js +35 -8
- /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
|
|
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(
|
|
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
|
});
|
|
@@ -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
|
+
});
|
package/clis/xiaohongshu/note.js
CHANGED
|
@@ -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
|
|
33
|
-
const
|
|
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' },
|
package/clis/xiaohongshu/user.js
CHANGED
|
@@ -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 =
|
|
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));
|
package/dist/src/browser/cdp.js
CHANGED
|
@@ -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(() =>
|
|
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) => {
|
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="${
|
|
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="${
|
|
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()) };
|