@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.
- package/README.md +5 -2
- package/README.zh-CN.md +6 -3
- package/cli-manifest.json +1085 -73
- 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/binance/asks.js +21 -0
- package/clis/binance/commands.test.js +70 -0
- package/clis/binance/depth.js +21 -0
- package/clis/binance/gainers.js +22 -0
- package/clis/binance/klines.js +21 -0
- package/clis/binance/losers.js +22 -0
- package/clis/binance/pairs.js +21 -0
- package/clis/binance/price.js +18 -0
- package/clis/binance/prices.js +19 -0
- package/clis/binance/ticker.js +21 -0
- package/clis/binance/top.js +21 -0
- package/clis/binance/trades.js +20 -0
- 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/twitter/lists-parser.js +77 -0
- package/clis/twitter/lists.d.ts +5 -0
- package/clis/twitter/lists.js +62 -0
- package/clis/twitter/lists.test.js +50 -0
- package/clis/weibo/feed.js +18 -5
- 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/clis/zsxq/topic.js +5 -3
- package/clis/zsxq/topic.test.js +4 -3
- package/clis/zsxq/utils.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 +112 -2
- package/dist/src/daemon.js +5 -0
- package/dist/src/discovery.d.ts +5 -2
- package/dist/src/discovery.js +7 -35
- 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/engine.test.js +29 -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/main.js +6 -5
- 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.js +31 -0
- package/package.json +3 -3
- package/scripts/fetch-adapters.js +92 -34
- package/dist/src/clis/binance/asks.js +0 -20
- package/dist/src/clis/binance/commands.test.d.ts +0 -3
- package/dist/src/clis/binance/commands.test.js +0 -58
- package/dist/src/clis/binance/depth.d.ts +0 -1
- package/dist/src/clis/binance/depth.js +0 -20
- package/dist/src/clis/binance/gainers.d.ts +0 -1
- package/dist/src/clis/binance/gainers.js +0 -21
- package/dist/src/clis/binance/klines.d.ts +0 -1
- package/dist/src/clis/binance/klines.js +0 -20
- package/dist/src/clis/binance/losers.d.ts +0 -1
- package/dist/src/clis/binance/losers.js +0 -21
- package/dist/src/clis/binance/pairs.d.ts +0 -1
- package/dist/src/clis/binance/pairs.js +0 -20
- package/dist/src/clis/binance/price.d.ts +0 -1
- package/dist/src/clis/binance/price.js +0 -17
- package/dist/src/clis/binance/prices.d.ts +0 -1
- package/dist/src/clis/binance/prices.js +0 -18
- package/dist/src/clis/binance/ticker.d.ts +0 -1
- package/dist/src/clis/binance/ticker.js +0 -20
- package/dist/src/clis/binance/top.d.ts +0 -1
- package/dist/src/clis/binance/top.js +0 -20
- package/dist/src/clis/binance/trades.d.ts +0 -1
- package/dist/src/clis/binance/trades.js +0 -19
- /package/clis/{chatgpt → chatgpt-app}/ax.js +0 -0
- /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
|
});
|
|
@@ -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 {
|
package/clis/zsxq/topic.js
CHANGED
|
@@ -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)
|
package/clis/zsxq/topic.test.js
CHANGED
|
@@ -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(
|
|
28
|
+
expect(mockPage.evaluate).toHaveBeenCalledTimes(3);
|
|
28
29
|
});
|
|
29
30
|
});
|
package/clis/zsxq/utils.js
CHANGED
|
@@ -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));
|
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) => {
|