@jackwener/opencli 1.7.2 → 1.7.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -15
- package/README.zh-CN.md +31 -15
- package/cli-manifest.json +1265 -101
- 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/favorite.js +18 -13
- package/clis/bilibili/feed.js +202 -48
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -2
- 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}/ax.js +6 -3
- 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/search.js +1 -0
- package/clis/douban/search.test.js +11 -0
- package/clis/douban/subject.js +20 -93
- package/clis/douban/subject.test.js +11 -0
- package/clis/douban/utils.js +279 -10
- package/clis/douban/utils.test.js +296 -1
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- 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/tdx/hot-rank.js +47 -0
- package/clis/tdx/hot-rank.test.js +59 -0
- package/clis/ths/hot-rank.js +49 -0
- package/clis/ths/hot-rank.test.js +64 -0
- package/clis/twitter/bookmarks.js +2 -1
- package/clis/uiverse/_shared.js +368 -0
- package/clis/uiverse/_shared.test.js +55 -0
- package/clis/uiverse/code.js +47 -0
- package/clis/uiverse/preview.js +71 -0
- package/clis/xiaohongshu/comments.js +20 -8
- package/clis/xiaohongshu/comments.test.js +69 -12
- 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 +18 -7
- package/clis/xiaohongshu/download.test.js +42 -0
- package/clis/xiaohongshu/navigation.test.js +34 -0
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +17 -10
- package/clis/xiaohongshu/note.test.js +66 -11
- package/clis/xiaohongshu/publish.js +1 -0
- package/clis/xiaohongshu/search.js +1 -0
- package/clis/xiaohongshu/user.js +1 -0
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +49 -0
- package/clis/xiaoyuzhou/download.test.js +125 -0
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/yahoo-finance/quote.js +1 -1
- package/clis/youtube/feed.js +120 -0
- package/clis/youtube/history.js +118 -0
- package/clis/youtube/like.js +62 -0
- package/clis/youtube/playlist.js +97 -0
- package/clis/youtube/subscribe.js +71 -0
- package/clis/youtube/subscriptions.js +57 -0
- package/clis/youtube/unlike.js +62 -0
- package/clis/youtube/unsubscribe.js +71 -0
- package/clis/youtube/utils.js +122 -0
- package/clis/youtube/utils.test.js +32 -1
- package/clis/youtube/watch-later.js +76 -0
- package/dist/src/browser/base-page.d.ts +9 -0
- package/dist/src/browser/base-page.js +44 -5
- package/dist/src/browser/bridge.d.ts +2 -0
- package/dist/src/browser/bridge.js +51 -14
- package/dist/src/browser/cdp.js +11 -2
- package/dist/src/browser/daemon-client.d.ts +2 -0
- package/dist/src/browser/dom-snapshot.js +13 -1
- package/dist/src/browser/page.d.ts +4 -1
- package/dist/src/browser/page.js +48 -8
- package/dist/src/browser/page.test.js +61 -1
- package/dist/src/browser/target-errors.d.ts +23 -0
- package/dist/src/browser/target-errors.js +29 -0
- package/dist/src/browser/target-errors.test.d.ts +1 -0
- package/dist/src/browser/target-errors.test.js +61 -0
- package/dist/src/browser/target-resolver.d.ts +57 -0
- package/dist/src/browser/target-resolver.js +298 -0
- package/dist/src/browser/target-resolver.test.d.ts +1 -0
- package/dist/src/browser/target-resolver.test.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +45 -35
- package/dist/src/commands/daemon.d.ts +4 -2
- package/dist/src/commands/daemon.js +22 -2
- package/dist/src/commands/daemon.test.js +65 -2
- package/dist/src/daemon.js +7 -0
- package/dist/src/doctor.d.ts +2 -0
- package/dist/src/doctor.js +82 -10
- package/dist/src/doctor.test.js +28 -12
- 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/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -8
- package/dist/src/output.js +1 -5
- package/dist/src/output.test.js +0 -21
- package/dist/src/pipeline/steps/transform.js +1 -1
- package/dist/src/pipeline/template.d.ts +1 -0
- package/dist/src/pipeline/template.js +11 -3
- package/dist/src/pipeline/template.test.js +3 -0
- package/dist/src/pipeline/transform.test.js +14 -0
- package/dist/src/plugin.d.ts +7 -1
- package/dist/src/plugin.js +23 -1
- package/dist/src/plugin.test.js +15 -1
- package/dist/src/registry.js +3 -4
- package/dist/src/types.d.ts +3 -1
- 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
|
@@ -27,7 +27,7 @@ function createPageMock(evaluateResult) {
|
|
|
27
27
|
}
|
|
28
28
|
describe('xiaohongshu comments', () => {
|
|
29
29
|
const command = getRegistry().get('xiaohongshu/comments');
|
|
30
|
-
it('returns ranked comment rows', async () => {
|
|
30
|
+
it('returns ranked comment rows for signed full URLs', async () => {
|
|
31
31
|
const page = createPageMock({
|
|
32
32
|
loginWall: false,
|
|
33
33
|
results: [
|
|
@@ -35,22 +35,32 @@ describe('xiaohongshu comments', () => {
|
|
|
35
35
|
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
|
|
36
36
|
],
|
|
37
37
|
});
|
|
38
|
-
const
|
|
39
|
-
|
|
38
|
+
const signedUrl = 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search';
|
|
39
|
+
const result = (await command.func(page, { 'note-id': signedUrl, limit: 5 }));
|
|
40
|
+
expect(page.goto.mock.calls[0][0]).toBe(signedUrl);
|
|
40
41
|
expect(result).toHaveLength(2);
|
|
41
42
|
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice', text: 'Great note!', likes: 10 });
|
|
42
43
|
expect(result[1]).toMatchObject({ rank: 2, author: 'Bob', text: 'Very helpful', likes: 0 });
|
|
43
44
|
});
|
|
44
|
-
it('
|
|
45
|
+
it('rejects bare note IDs before browser navigation', async () => {
|
|
46
|
+
const page = createPageMock({ loginWall: false, results: [] });
|
|
47
|
+
await expect(command.func(page, { 'note-id': '69aadbcb000000002202f131', limit: 5 })).rejects.toMatchObject({
|
|
48
|
+
code: 'ARGUMENT',
|
|
49
|
+
message: expect.stringContaining('signed URL'),
|
|
50
|
+
hint: expect.stringContaining('xsec_token'),
|
|
51
|
+
});
|
|
52
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
53
|
+
});
|
|
54
|
+
it('preserves signed /explore/ URL as-is for navigation', async () => {
|
|
45
55
|
const page = createPageMock({
|
|
46
56
|
loginWall: false,
|
|
47
57
|
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
|
|
48
58
|
});
|
|
49
59
|
await command.func(page, {
|
|
50
|
-
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131',
|
|
60
|
+
'note-id': 'https://www.xiaohongshu.com/explore/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
|
|
51
61
|
limit: 5,
|
|
52
62
|
});
|
|
53
|
-
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131');
|
|
63
|
+
expect(page.goto.mock.calls[0][0]).toContain('/explore/69aadbcb000000002202f131?xsec_token=abc');
|
|
54
64
|
});
|
|
55
65
|
it('preserves full search_result URL with xsec_token for navigation', async () => {
|
|
56
66
|
const page = createPageMock({
|
|
@@ -61,13 +71,54 @@ describe('xiaohongshu comments', () => {
|
|
|
61
71
|
await command.func(page, { 'note-id': fullUrl, limit: 5 });
|
|
62
72
|
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
63
73
|
});
|
|
74
|
+
it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
|
|
75
|
+
const page = createPageMock({
|
|
76
|
+
loginWall: false,
|
|
77
|
+
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
|
|
78
|
+
});
|
|
79
|
+
const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_user';
|
|
80
|
+
await command.func(page, { 'note-id': fullUrl, limit: 5 });
|
|
81
|
+
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
82
|
+
});
|
|
64
83
|
it('throws AuthRequiredError when login wall is detected', async () => {
|
|
65
84
|
const page = createPageMock({ loginWall: true, results: [] });
|
|
66
|
-
await expect(command.func(page, {
|
|
85
|
+
await expect(command.func(page, {
|
|
86
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
87
|
+
limit: 5,
|
|
88
|
+
})).rejects.toThrow('Note comments require login');
|
|
89
|
+
});
|
|
90
|
+
it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
|
|
91
|
+
const page = createPageMock({
|
|
92
|
+
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
|
|
93
|
+
securityBlock: true,
|
|
94
|
+
loginWall: false,
|
|
95
|
+
results: [],
|
|
96
|
+
});
|
|
97
|
+
await expect(command.func(page, {
|
|
98
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/69aadbcb000000002202f131?xsec_token=abc&xsec_source=pc_search',
|
|
99
|
+
limit: 5,
|
|
100
|
+
})).rejects.toMatchObject({
|
|
101
|
+
code: 'SECURITY_BLOCK',
|
|
102
|
+
hint: expect.stringContaining('Try again later'),
|
|
103
|
+
});
|
|
67
104
|
});
|
|
68
105
|
it('returns empty array when no comments are found', async () => {
|
|
69
106
|
const page = createPageMock({ loginWall: false, results: [] });
|
|
70
|
-
await expect(command.func(page, {
|
|
107
|
+
await expect(command.func(page, {
|
|
108
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
109
|
+
limit: 5,
|
|
110
|
+
})).resolves.toEqual([]);
|
|
111
|
+
});
|
|
112
|
+
it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
|
|
113
|
+
const page = createPageMock({ loginWall: false, results: [] });
|
|
114
|
+
await command.func(page, {
|
|
115
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
116
|
+
limit: 5,
|
|
117
|
+
});
|
|
118
|
+
const script = page.evaluate.mock.calls[0][0];
|
|
119
|
+
expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
|
|
120
|
+
expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
|
|
121
|
+
expect(script).toContain('if (afterCount <= beforeCount) break');
|
|
71
122
|
});
|
|
72
123
|
it('respects the limit for top-level comments', async () => {
|
|
73
124
|
const manyComments = Array.from({ length: 10 }, (_, i) => ({
|
|
@@ -79,7 +130,10 @@ describe('xiaohongshu comments', () => {
|
|
|
79
130
|
reply_to: '',
|
|
80
131
|
}));
|
|
81
132
|
const page = createPageMock({ loginWall: false, results: manyComments });
|
|
82
|
-
const result = (await command.func(page, {
|
|
133
|
+
const result = (await command.func(page, {
|
|
134
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
135
|
+
limit: 3,
|
|
136
|
+
}));
|
|
83
137
|
expect(result).toHaveLength(3);
|
|
84
138
|
expect(result[0].rank).toBe(1);
|
|
85
139
|
expect(result[2].rank).toBe(3);
|
|
@@ -92,7 +146,10 @@ describe('xiaohongshu comments', () => {
|
|
|
92
146
|
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
|
|
93
147
|
],
|
|
94
148
|
});
|
|
95
|
-
const result = (await command.func(page, {
|
|
149
|
+
const result = (await command.func(page, {
|
|
150
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
151
|
+
limit: -3,
|
|
152
|
+
}));
|
|
96
153
|
expect(result).toHaveLength(1);
|
|
97
154
|
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
|
|
98
155
|
});
|
|
@@ -107,7 +164,7 @@ describe('xiaohongshu comments', () => {
|
|
|
107
164
|
],
|
|
108
165
|
});
|
|
109
166
|
const result = (await command.func(page, {
|
|
110
|
-
'note-id': 'abc123', limit: 50, 'with-replies': true,
|
|
167
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 50, 'with-replies': true,
|
|
111
168
|
}));
|
|
112
169
|
expect(result).toHaveLength(3);
|
|
113
170
|
expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
|
|
@@ -130,7 +187,7 @@ describe('xiaohongshu comments', () => {
|
|
|
130
187
|
});
|
|
131
188
|
// Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
|
|
132
189
|
const result = (await command.func(page, {
|
|
133
|
-
'note-id': 'abc123', limit: 2, 'with-replies': true,
|
|
190
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok', limit: 2, 'with-replies': true,
|
|
134
191
|
}));
|
|
135
192
|
expect(result).toHaveLength(4);
|
|
136
193
|
expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
|
|
@@ -253,6 +253,7 @@ async function captureNoteDetailPayload(page, noteId) {
|
|
|
253
253
|
let captured = 0;
|
|
254
254
|
// Try to fetch each API endpoint through the page context (uses the browser's cookies)
|
|
255
255
|
for (const { suffix, key } of DETAIL_API_ENDPOINTS) {
|
|
256
|
+
await page.wait({ time: 0.5 + Math.random() });
|
|
256
257
|
const apiUrl = `${suffix}?note_id=${noteId}`;
|
|
257
258
|
try {
|
|
258
259
|
const data = await page.evaluate(`
|
|
@@ -325,6 +326,7 @@ cli({
|
|
|
325
326
|
domain: 'creator.xiaohongshu.com',
|
|
326
327
|
strategy: Strategy.COOKIE,
|
|
327
328
|
browser: true,
|
|
329
|
+
navigateBefore: false,
|
|
328
330
|
args: [
|
|
329
331
|
{ name: 'note-id', positional: true, type: 'string', required: true, help: 'Note ID (from creator-notes or note-detail page URL)' },
|
|
330
332
|
],
|
|
@@ -256,4 +256,36 @@ describe('xiaohongshu creator-note-detail', () => {
|
|
|
256
256
|
{ section: '互动数据', metric: '分享数', value: '0', extra: '粉丝占比 0%' },
|
|
257
257
|
]);
|
|
258
258
|
});
|
|
259
|
+
it('waits between creator detail API fetches to avoid burst traffic', async () => {
|
|
260
|
+
const cmd = getRegistry().get('xiaohongshu/creator-note-detail');
|
|
261
|
+
const domData = {
|
|
262
|
+
title: '示例笔记',
|
|
263
|
+
infoText: '示例笔记\n2026-03-19 12:00\n切换笔记',
|
|
264
|
+
sections: [
|
|
265
|
+
{
|
|
266
|
+
title: '基础数据',
|
|
267
|
+
metrics: [
|
|
268
|
+
{ label: '曝光数', value: '100', extra: '粉丝占比 10%' },
|
|
269
|
+
{ label: '观看数', value: '50', extra: '粉丝占比 20%' },
|
|
270
|
+
{ label: '封面点击率', value: '12%', extra: '粉丝 11%' },
|
|
271
|
+
{ label: '平均观看时长', value: '30秒', extra: '粉丝 31秒' },
|
|
272
|
+
{ label: '涨粉数', value: '2', extra: '' },
|
|
273
|
+
],
|
|
274
|
+
},
|
|
275
|
+
{
|
|
276
|
+
title: '互动数据',
|
|
277
|
+
metrics: [
|
|
278
|
+
{ label: '点赞数', value: '8', extra: '粉丝占比 25%' },
|
|
279
|
+
{ label: '评论数', value: '1', extra: '粉丝占比 0%' },
|
|
280
|
+
{ label: '收藏数', value: '3', extra: '粉丝占比 50%' },
|
|
281
|
+
{ label: '分享数', value: '0', extra: '粉丝占比 0%' },
|
|
282
|
+
],
|
|
283
|
+
},
|
|
284
|
+
],
|
|
285
|
+
};
|
|
286
|
+
const page = createPageMock([domData, null, null, null, null]);
|
|
287
|
+
await cmd.func(page, { 'note-id': 'demo-note-id' });
|
|
288
|
+
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
|
|
289
|
+
expect(page.wait.mock.calls.length).toBeGreaterThanOrEqual(4);
|
|
290
|
+
});
|
|
259
291
|
});
|
|
@@ -50,6 +50,7 @@ cli({
|
|
|
50
50
|
domain: 'creator.xiaohongshu.com',
|
|
51
51
|
strategy: Strategy.COOKIE,
|
|
52
52
|
browser: true,
|
|
53
|
+
navigateBefore: false,
|
|
53
54
|
args: [
|
|
54
55
|
{ name: 'limit', type: 'int', default: 3, help: 'Number of recent notes to summarize' },
|
|
55
56
|
],
|
|
@@ -63,6 +64,9 @@ cli({
|
|
|
63
64
|
}
|
|
64
65
|
const results = [];
|
|
65
66
|
for (const [index, note] of notes.entries()) {
|
|
67
|
+
if (index > 0) {
|
|
68
|
+
await page.wait({ time: 1 + Math.random() * 2 });
|
|
69
|
+
}
|
|
66
70
|
if (!note.id) {
|
|
67
71
|
results.push({
|
|
68
72
|
rank: index + 1,
|
|
@@ -1,5 +1,8 @@
|
|
|
1
|
-
import { describe, expect, it } from 'vitest';
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
2
|
import { summarizeCreatorNote } from './creator-notes-summary.js';
|
|
3
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
4
|
+
import * as creatorNotesModule from './creator-notes.js';
|
|
5
|
+
import * as creatorDetailModule from './creator-note-detail.js';
|
|
3
6
|
import './creator-notes-summary.js';
|
|
4
7
|
describe('xiaohongshu creator-notes-summary', () => {
|
|
5
8
|
it('summarizes note list row and detail rows into one compact row', () => {
|
|
@@ -46,4 +49,39 @@ describe('xiaohongshu creator-notes-summary', () => {
|
|
|
46
49
|
url: 'https://creator.xiaohongshu.com/statistics/note-detail?noteId=cccccccccccccccccccccccc',
|
|
47
50
|
});
|
|
48
51
|
});
|
|
52
|
+
it('waits between note detail fetches after the first note', async () => {
|
|
53
|
+
const cmd = getRegistry().get('xiaohongshu/creator-notes-summary');
|
|
54
|
+
const page = {
|
|
55
|
+
wait: vi.fn().mockResolvedValue(undefined),
|
|
56
|
+
};
|
|
57
|
+
vi.spyOn(creatorNotesModule, 'fetchCreatorNotes').mockResolvedValue([
|
|
58
|
+
{
|
|
59
|
+
id: 'aaaaaaaaaaaaaaaaaaaaaaaa',
|
|
60
|
+
title: 'n1',
|
|
61
|
+
date: '2026年03月18日 20:01',
|
|
62
|
+
views: 1,
|
|
63
|
+
likes: 1,
|
|
64
|
+
collects: 1,
|
|
65
|
+
comments: 1,
|
|
66
|
+
url: 'u1',
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: 'bbbbbbbbbbbbbbbbbbbbbbbb',
|
|
70
|
+
title: 'n2',
|
|
71
|
+
date: '2026年03月19日 20:01',
|
|
72
|
+
views: 2,
|
|
73
|
+
likes: 2,
|
|
74
|
+
collects: 2,
|
|
75
|
+
comments: 2,
|
|
76
|
+
url: 'u2',
|
|
77
|
+
},
|
|
78
|
+
]);
|
|
79
|
+
vi.spyOn(creatorDetailModule, 'fetchCreatorNoteDetailRows').mockResolvedValue([
|
|
80
|
+
{ section: '笔记信息', metric: 'published_at', value: '2026-03-18 20:01', extra: '' },
|
|
81
|
+
{ section: '基础数据', metric: '观看数', value: '1', extra: '' },
|
|
82
|
+
]);
|
|
83
|
+
await cmd.func(page, { limit: 2 });
|
|
84
|
+
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
|
|
85
|
+
expect(page.wait.mock.calls).toHaveLength(1);
|
|
86
|
+
});
|
|
49
87
|
});
|
|
@@ -2,14 +2,14 @@
|
|
|
2
2
|
* Xiaohongshu download — download images and videos from a note.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* opencli xiaohongshu download <note-
|
|
5
|
+
* opencli xiaohongshu download <signed-note-url-or-shortlink> --output ./xhs
|
|
6
6
|
*
|
|
7
|
-
* Accepts a
|
|
8
|
-
* or a short link (http://xhslink.com/...).
|
|
7
|
+
* Accepts a full xiaohongshu.com URL with xsec_token or an xhslink short link.
|
|
9
8
|
*/
|
|
10
9
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
11
10
|
import { formatCookieHeader } from '@jackwener/opencli/download';
|
|
12
11
|
import { downloadMedia } from '@jackwener/opencli/download/media-download';
|
|
12
|
+
import { CliError } from '@jackwener/opencli/errors';
|
|
13
13
|
import { buildNoteUrl, parseNoteId } from './note-helpers.js';
|
|
14
14
|
cli({
|
|
15
15
|
site: 'xiaohongshu',
|
|
@@ -17,8 +17,9 @@ cli({
|
|
|
17
17
|
description: '下载小红书笔记中的图片和视频',
|
|
18
18
|
domain: 'www.xiaohongshu.com',
|
|
19
19
|
strategy: Strategy.COOKIE,
|
|
20
|
+
navigateBefore: false,
|
|
20
21
|
args: [
|
|
21
|
-
{ name: 'note-id', positional: true, required: true, help: '
|
|
22
|
+
{ name: 'note-id', positional: true, required: true, help: 'Full Xiaohongshu note URL with xsec_token, or xhslink short link' },
|
|
22
23
|
{ name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
|
|
23
24
|
],
|
|
24
25
|
columns: ['index', 'type', 'status', 'size'],
|
|
@@ -26,12 +27,17 @@ cli({
|
|
|
26
27
|
const rawInput = String(kwargs['note-id']);
|
|
27
28
|
const output = kwargs.output;
|
|
28
29
|
const noteId = parseNoteId(rawInput);
|
|
29
|
-
await page.goto(buildNoteUrl(rawInput));
|
|
30
|
+
await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
|
|
31
|
+
await page.wait({ time: 1 + Math.random() * 2 });
|
|
30
32
|
// Extract note info and media URLs
|
|
31
33
|
const data = await page.evaluate(`
|
|
32
34
|
(() => {
|
|
35
|
+
const bodyText = document.body?.innerText || '';
|
|
33
36
|
const result = {
|
|
34
37
|
noteId: '${noteId}',
|
|
38
|
+
pageUrl: location.href,
|
|
39
|
+
securityBlock: /安全限制|访问链接异常/.test(bodyText)
|
|
40
|
+
|| /website-login\\/error|error_code=300017|error_code=300031/.test(location.href),
|
|
35
41
|
title: '',
|
|
36
42
|
author: '',
|
|
37
43
|
media: []
|
|
@@ -44,9 +50,9 @@ cli({
|
|
|
44
50
|
seenMedia.add(key);
|
|
45
51
|
result.media.push({ type, url });
|
|
46
52
|
};
|
|
47
|
-
const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)/i);
|
|
53
|
+
const locationMatch = (location.pathname || '').match(/\\/(?:explore|note|search_result|discovery\\/item)\\/([a-f0-9]+)|\\/user\\/profile\\/[^/?#]+\\/([a-f0-9]+)/i);
|
|
48
54
|
if (locationMatch) {
|
|
49
|
-
result.noteId = locationMatch[1];
|
|
55
|
+
result.noteId = locationMatch[1] || locationMatch[2];
|
|
50
56
|
}
|
|
51
57
|
|
|
52
58
|
// Get title
|
|
@@ -148,6 +154,11 @@ cli({
|
|
|
148
154
|
return result;
|
|
149
155
|
})()
|
|
150
156
|
`);
|
|
157
|
+
if (data?.securityBlock) {
|
|
158
|
+
throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(rawInput)
|
|
159
|
+
? 'The page may be temporarily restricted. Try again later or from a different session.'
|
|
160
|
+
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
|
|
161
|
+
}
|
|
151
162
|
if (!data || !data.media || data.media.length === 0) {
|
|
152
163
|
return [{ index: 0, type: '-', status: 'failed', size: 'No media found' }];
|
|
153
164
|
}
|
|
@@ -70,4 +70,46 @@ describe('xiaohongshu download', () => {
|
|
|
70
70
|
filenamePrefix: '69bc166f000000001a02069a',
|
|
71
71
|
}));
|
|
72
72
|
});
|
|
73
|
+
it('uses canonical note id for signed user profile note URLs', async () => {
|
|
74
|
+
const page = createPageMock({
|
|
75
|
+
noteId: '',
|
|
76
|
+
media: [{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }],
|
|
77
|
+
});
|
|
78
|
+
const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_user';
|
|
79
|
+
await command.func(page, { 'note-id': fullUrl, output: './out' });
|
|
80
|
+
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
81
|
+
expect(mockDownloadMedia).toHaveBeenCalledWith([{ type: 'image', url: 'https://ci.xiaohongshu.com/example.jpg' }], expect.objectContaining({
|
|
82
|
+
subdir: '69bc166f000000001a02069a',
|
|
83
|
+
filenamePrefix: '69bc166f000000001a02069a',
|
|
84
|
+
}));
|
|
85
|
+
});
|
|
86
|
+
it('rejects bare note IDs before browser navigation', async () => {
|
|
87
|
+
const page = createPageMock({
|
|
88
|
+
noteId: '69bc166f000000001a02069a',
|
|
89
|
+
media: [],
|
|
90
|
+
});
|
|
91
|
+
await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({
|
|
92
|
+
code: 'ARGUMENT',
|
|
93
|
+
message: expect.stringContaining('signed URL'),
|
|
94
|
+
hint: expect.stringContaining('xsec_token'),
|
|
95
|
+
});
|
|
96
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
97
|
+
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
|
98
|
+
});
|
|
99
|
+
it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => {
|
|
100
|
+
const page = createPageMock({
|
|
101
|
+
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300031',
|
|
102
|
+
securityBlock: true,
|
|
103
|
+
noteId: '69bc166f000000001a02069a',
|
|
104
|
+
media: [],
|
|
105
|
+
});
|
|
106
|
+
await expect(command.func(page, {
|
|
107
|
+
'note-id': 'https://www.xiaohongshu.com/explore/69bc166f000000001a02069a?xsec_token=abc&xsec_source=pc_search',
|
|
108
|
+
output: './out',
|
|
109
|
+
})).rejects.toMatchObject({
|
|
110
|
+
code: 'SECURITY_BLOCK',
|
|
111
|
+
hint: expect.stringContaining('Try again later'),
|
|
112
|
+
});
|
|
113
|
+
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
|
114
|
+
});
|
|
73
115
|
});
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { getRegistry } from '@jackwener/opencli/registry';
|
|
3
|
+
import './note.js';
|
|
4
|
+
import './comments.js';
|
|
5
|
+
import './download.js';
|
|
6
|
+
import './search.js';
|
|
7
|
+
import './user.js';
|
|
8
|
+
import './publish.js';
|
|
9
|
+
import './creator-notes.js';
|
|
10
|
+
import './creator-note-detail.js';
|
|
11
|
+
import './creator-notes-summary.js';
|
|
12
|
+
import './creator-profile.js';
|
|
13
|
+
import './creator-stats.js';
|
|
14
|
+
|
|
15
|
+
describe('xiaohongshu navigateBefore hardening', () => {
|
|
16
|
+
const expectedFalse = [
|
|
17
|
+
'xiaohongshu/note',
|
|
18
|
+
'xiaohongshu/comments',
|
|
19
|
+
'xiaohongshu/download',
|
|
20
|
+
'xiaohongshu/search',
|
|
21
|
+
'xiaohongshu/user',
|
|
22
|
+
'xiaohongshu/publish',
|
|
23
|
+
'xiaohongshu/creator-notes',
|
|
24
|
+
'xiaohongshu/creator-note-detail',
|
|
25
|
+
'xiaohongshu/creator-notes-summary',
|
|
26
|
+
'xiaohongshu/creator-profile',
|
|
27
|
+
'xiaohongshu/creator-stats',
|
|
28
|
+
];
|
|
29
|
+
it.each(expectedFalse)('%s sets navigateBefore=false', (name) => {
|
|
30
|
+
const cmd = getRegistry().get(name);
|
|
31
|
+
expect(cmd).toBeDefined();
|
|
32
|
+
expect(cmd.navigateBefore).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
});
|
|
@@ -1,25 +1,59 @@
|
|
|
1
|
+
import { ArgumentError } from '@jackwener/opencli/errors';
|
|
2
|
+
|
|
1
3
|
/** Side-effect-free helpers shared by xiaohongshu note and comments commands. */
|
|
2
4
|
/** Extract a bare note ID from a full URL or raw ID string. */
|
|
3
5
|
export function parseNoteId(input) {
|
|
4
6
|
const trimmed = input.trim();
|
|
5
|
-
const match = trimmed.match(/\/(?:explore|note|search_result)\/([a-f0-9]+)/);
|
|
6
|
-
return match ? match[1] : trimmed;
|
|
7
|
+
const match = trimmed.match(/\/(?:explore|note|search_result|discovery\/item)\/([a-f0-9]+)|\/user\/profile\/[^/?#]+\/([a-f0-9]+)/i);
|
|
8
|
+
return match ? (match[1] || match[2]) : trimmed;
|
|
7
9
|
}
|
|
10
|
+
|
|
11
|
+
export const XHS_SIGNED_URL_HINT = 'Pass a full Xiaohongshu note URL with xsec_token from search results or user/profile context.';
|
|
12
|
+
|
|
13
|
+
function isShortLink(input) {
|
|
14
|
+
return /^https?:\/\/xhslink\.com\//i.test(input);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function isXiaohongshuHost(hostname) {
|
|
18
|
+
const normalized = hostname.toLowerCase();
|
|
19
|
+
return normalized === 'xiaohongshu.com' || normalized.endsWith('.xiaohongshu.com');
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function isSupportedNotePath(pathname) {
|
|
23
|
+
return /^\/(?:explore|note|search_result|discovery\/item)\/[a-f0-9]+(?:[/?#]|$)/i.test(pathname)
|
|
24
|
+
|| /^\/user\/profile\/[^/?#]+\/[a-f0-9]+(?:[/?#]|$)/i.test(pathname);
|
|
25
|
+
}
|
|
26
|
+
|
|
8
27
|
/**
|
|
9
28
|
* Build the best navigation URL for a note.
|
|
10
29
|
*
|
|
11
|
-
* XHS
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* `/search_result/<id>` which works without xsec_token when cookies are present.
|
|
30
|
+
* XHS note detail pages now require a valid signed URL for reliable access.
|
|
31
|
+
* Bare note IDs no longer resolve deterministically, so callers must provide
|
|
32
|
+
* a full note URL with xsec_token or, for downloads only, an xhslink short link.
|
|
15
33
|
*/
|
|
16
|
-
export function buildNoteUrl(input) {
|
|
34
|
+
export function buildNoteUrl(input, options = {}) {
|
|
35
|
+
const { allowShortLink = false, commandName = 'xiaohongshu note' } = options;
|
|
17
36
|
const trimmed = input.trim();
|
|
37
|
+
const message = `${commandName} now requires a full signed URL`;
|
|
38
|
+
const hint = allowShortLink
|
|
39
|
+
? `${XHS_SIGNED_URL_HINT} For downloads, xhslink short links are also supported.`
|
|
40
|
+
: XHS_SIGNED_URL_HINT;
|
|
41
|
+
|
|
18
42
|
if (/^https?:\/\//.test(trimmed)) {
|
|
19
|
-
|
|
20
|
-
|
|
43
|
+
if (isShortLink(trimmed)) {
|
|
44
|
+
if (allowShortLink)
|
|
45
|
+
return trimmed;
|
|
46
|
+
throw new ArgumentError(message, hint);
|
|
47
|
+
}
|
|
48
|
+
try {
|
|
49
|
+
const url = new URL(trimmed);
|
|
50
|
+
const xsecToken = url.searchParams.get('xsec_token')?.trim();
|
|
51
|
+
if (isXiaohongshuHost(url.hostname) && isSupportedNotePath(url.pathname) && xsecToken) {
|
|
52
|
+
return trimmed;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
throw new ArgumentError(message, hint);
|
|
21
57
|
}
|
|
22
|
-
|
|
23
|
-
// when the user is logged in via cookies (which is always the case with opencli).
|
|
24
|
-
return `https://www.xiaohongshu.com/search_result/${trimmed}`;
|
|
58
|
+
throw new ArgumentError(message, hint);
|
|
25
59
|
}
|
package/clis/xiaohongshu/note.js
CHANGED
|
@@ -4,12 +4,10 @@
|
|
|
4
4
|
* Extracts title, author, description text, and engagement metrics
|
|
5
5
|
* (likes, collects, comment count) via DOM extraction.
|
|
6
6
|
*
|
|
7
|
-
*
|
|
8
|
-
* Bare IDs now use /search_result/<id> which works without xsec_token
|
|
9
|
-
* when the user is logged in via cookies.
|
|
7
|
+
* Requires a full Xiaohongshu note URL with xsec_token.
|
|
10
8
|
*/
|
|
11
9
|
import { cli, Strategy } from '@jackwener/opencli/registry';
|
|
12
|
-
import { AuthRequiredError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
10
|
+
import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
13
11
|
import { parseNoteId, buildNoteUrl } from './note-helpers.js';
|
|
14
12
|
cli({
|
|
15
13
|
site: 'xiaohongshu',
|
|
@@ -17,20 +15,24 @@ cli({
|
|
|
17
15
|
description: '获取小红书笔记正文和互动数据',
|
|
18
16
|
domain: 'www.xiaohongshu.com',
|
|
19
17
|
strategy: Strategy.COOKIE,
|
|
18
|
+
navigateBefore: false,
|
|
20
19
|
args: [
|
|
21
|
-
{ name: 'note-id', required: true, positional: true, help: '
|
|
20
|
+
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
|
|
22
21
|
],
|
|
23
22
|
columns: ['field', 'value'],
|
|
24
23
|
func: async (page, kwargs) => {
|
|
25
24
|
const raw = String(kwargs['note-id']);
|
|
26
25
|
const noteId = parseNoteId(raw);
|
|
27
|
-
const url = buildNoteUrl(raw);
|
|
26
|
+
const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
|
|
28
27
|
await page.goto(url);
|
|
29
|
-
await page.wait(3);
|
|
28
|
+
await page.wait({ time: 2 + Math.random() * 3 });
|
|
30
29
|
const data = await page.evaluate(`
|
|
31
30
|
(() => {
|
|
32
|
-
const
|
|
33
|
-
const
|
|
31
|
+
const bodyText = document.body?.innerText || ''
|
|
32
|
+
const loginWall = /登录后查看|请登录/.test(bodyText)
|
|
33
|
+
const notFound = /页面不见了|笔记不存在|无法浏览/.test(bodyText)
|
|
34
|
+
const securityBlock = /安全限制|访问链接异常/.test(bodyText)
|
|
35
|
+
|| /website-login\\/error|error_code=300017|error_code=300031/.test(location.href)
|
|
34
36
|
|
|
35
37
|
const clean = (el) => (el?.textContent || '').replace(/\\s+/g, ' ').trim()
|
|
36
38
|
|
|
@@ -53,12 +55,17 @@ cli({
|
|
|
53
55
|
if (t) tags.push(t)
|
|
54
56
|
})
|
|
55
57
|
|
|
56
|
-
return { loginWall, notFound, title, desc, author, likes, collects, comments, tags }
|
|
58
|
+
return { pageUrl: location.href, securityBlock, loginWall, notFound, title, desc, author, likes, collects, comments, tags }
|
|
57
59
|
})()
|
|
58
60
|
`);
|
|
59
61
|
if (!data || typeof data !== 'object') {
|
|
60
62
|
throw new EmptyResultError('xiaohongshu/note', 'Unexpected evaluate response');
|
|
61
63
|
}
|
|
64
|
+
if (data.securityBlock) {
|
|
65
|
+
throw new CliError('SECURITY_BLOCK', 'Xiaohongshu security block: the note detail page was blocked by risk control.', /^https?:\/\//.test(raw)
|
|
66
|
+
? 'The page may be temporarily restricted. Try again later or from a different session.'
|
|
67
|
+
: 'Try using a full URL from search results (with xsec_token) instead of a bare note ID.');
|
|
68
|
+
}
|
|
62
69
|
if (data.loginWall) {
|
|
63
70
|
throw new AuthRequiredError('www.xiaohongshu.com', 'Note content requires login');
|
|
64
71
|
}
|