@jackwener/opencli 1.7.3 → 1.7.5
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 +81 -59
- package/README.zh-CN.md +93 -67
- package/cli-manifest.json +5015 -2975
- package/clis/antigravity/serve.js +71 -25
- package/clis/baidu-scholar/search.js +87 -0
- package/clis/baidu-scholar/search.test.js +23 -0
- package/clis/bilibili/favorite.js +18 -13
- package/clis/binance/depth.js +3 -4
- package/clis/boss/utils.js +2 -3
- package/clis/chatgpt-app/ax.js +6 -3
- package/clis/deepseek/ask.js +74 -0
- package/clis/deepseek/history.js +25 -0
- package/clis/deepseek/new.js +20 -0
- package/clis/deepseek/read.js +22 -0
- package/clis/deepseek/status.js +24 -0
- package/clis/deepseek/utils.js +208 -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 +250 -8
- package/clis/douban/utils.test.js +179 -4
- package/clis/doubao/utils.js +319 -130
- package/clis/doubao/utils.test.js +241 -2
- package/clis/eastmoney/_secid.js +78 -0
- package/clis/eastmoney/announcement.js +52 -0
- package/clis/eastmoney/convertible.js +73 -0
- package/clis/eastmoney/etf.js +65 -0
- package/clis/eastmoney/holders.js +78 -0
- package/clis/eastmoney/hot-rank.js +50 -0
- package/clis/eastmoney/hot-rank.test.js +59 -0
- package/clis/eastmoney/index-board.js +96 -0
- package/clis/eastmoney/kline.js +87 -0
- package/clis/eastmoney/kuaixun.js +54 -0
- package/clis/eastmoney/longhu.js +67 -0
- package/clis/eastmoney/money-flow.js +78 -0
- package/clis/eastmoney/northbound.js +57 -0
- package/clis/eastmoney/quote.js +107 -0
- package/clis/eastmoney/rank.js +94 -0
- package/clis/eastmoney/sectors.js +76 -0
- package/clis/google-scholar/search.js +58 -0
- package/clis/google-scholar/search.test.js +23 -0
- package/clis/gov-law/commands.test.js +39 -0
- package/clis/gov-law/recent.js +22 -0
- package/clis/gov-law/search.js +41 -0
- package/clis/gov-law/shared.js +51 -0
- package/clis/gov-policy/commands.test.js +27 -0
- package/clis/gov-policy/recent.js +47 -0
- package/clis/gov-policy/search.js +48 -0
- package/clis/grok/image.test.ts +107 -0
- package/clis/grok/image.ts +356 -0
- package/clis/nowcoder/companies.js +23 -0
- package/clis/nowcoder/creators.js +27 -0
- package/clis/nowcoder/detail.js +61 -0
- package/clis/nowcoder/experience.js +36 -0
- package/clis/nowcoder/hot.js +24 -0
- package/clis/nowcoder/jobs.js +21 -0
- package/clis/nowcoder/notifications.js +29 -0
- package/clis/nowcoder/papers.js +40 -0
- package/clis/nowcoder/practice.js +37 -0
- package/clis/nowcoder/recommend.js +30 -0
- package/clis/nowcoder/referral.js +39 -0
- package/clis/nowcoder/salary.js +40 -0
- package/clis/nowcoder/search.js +49 -0
- package/clis/nowcoder/suggest.js +33 -0
- package/clis/nowcoder/topics.js +27 -0
- package/clis/nowcoder/trending.js +25 -0
- 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/twitter/list-add.js +337 -0
- package/clis/twitter/list-add.test.js +15 -0
- package/clis/twitter/list-remove.js +297 -0
- package/clis/twitter/list-remove.test.js +14 -0
- package/clis/twitter/list-tweets.js +185 -0
- package/clis/twitter/list-tweets.test.js +108 -0
- package/clis/twitter/lists.js +134 -47
- package/clis/twitter/lists.test.js +105 -38
- 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/wanfang/search.js +66 -0
- package/clis/wanfang/search.test.js +23 -0
- package/clis/web/read.js +1 -1
- package/clis/weixin/download.js +3 -2
- package/clis/xiaohongshu/comments.js +2 -2
- package/clis/xiaohongshu/comments.test.js +46 -25
- package/clis/xiaohongshu/download.js +6 -7
- package/clis/xiaohongshu/download.test.js +17 -5
- package/clis/xiaohongshu/note-helpers.js +46 -12
- package/clis/xiaohongshu/note.js +3 -5
- package/clis/xiaohongshu/note.test.js +52 -25
- package/clis/xiaohongshu/publish.js +149 -28
- package/clis/xiaohongshu/publish.test.js +319 -6
- package/clis/xiaoyuzhou/auth.js +303 -0
- package/clis/xiaoyuzhou/auth.test.js +124 -0
- package/clis/xiaoyuzhou/download.js +53 -0
- package/clis/xiaoyuzhou/download.test.js +135 -0
- package/clis/xiaoyuzhou/episode.js +9 -4
- package/clis/xiaoyuzhou/podcast-episodes.js +15 -11
- package/clis/xiaoyuzhou/podcast.js +9 -4
- package/clis/xiaoyuzhou/transcript.js +76 -0
- package/clis/xiaoyuzhou/transcript.test.js +195 -0
- package/clis/xiaoyuzhou/utils.js +0 -40
- package/clis/xiaoyuzhou/utils.test.js +15 -75
- 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/clis/zsxq/dynamics.js +1 -1
- package/clis/zsxq/utils.js +6 -3
- package/clis/zsxq/utils.test.js +31 -0
- package/dist/src/browser/base-page.d.ts +1 -1
- package/dist/src/browser/base-page.js +25 -5
- package/dist/src/browser/bridge.d.ts +3 -0
- package/dist/src/browser/bridge.js +52 -15
- package/dist/src/browser/cdp.js +2 -1
- package/dist/src/browser/daemon-client.d.ts +7 -4
- package/dist/src/browser/daemon-client.js +6 -1
- package/dist/src/browser/daemon-client.test.js +40 -1
- package/dist/src/browser/dom-snapshot.js +20 -3
- package/dist/src/browser/page.d.ts +18 -5
- package/dist/src/browser/page.js +96 -15
- package/dist/src/browser/page.test.js +158 -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.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.js +43 -0
- package/dist/src/browser.test.js +38 -1
- package/dist/src/cli.js +272 -187
- package/dist/src/cli.test.js +167 -90
- package/dist/src/commanderAdapter.d.ts +0 -1
- package/dist/src/commanderAdapter.js +2 -16
- package/dist/src/commanderAdapter.test.js +1 -1
- 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/completion-shared.js +2 -5
- package/dist/src/daemon.js +10 -0
- package/dist/src/doctor.d.ts +1 -0
- package/dist/src/doctor.js +32 -9
- package/dist/src/doctor.test.js +28 -12
- package/dist/src/download/article-download.d.ts +1 -0
- package/dist/src/download/article-download.js +3 -0
- package/dist/src/download/article-download.test.js +39 -0
- package/dist/src/external-clis.yaml +2 -2
- package/dist/src/logger.d.ts +2 -2
- package/dist/src/logger.js +3 -3
- 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 +8 -9
- package/dist/src/plugin.js +24 -28
- package/dist/src/plugin.test.js +16 -60
- package/dist/src/registry.d.ts +1 -0
- package/dist/src/registry.js +3 -2
- package/dist/src/registry.test.js +22 -0
- package/dist/src/types.d.ts +15 -6
- package/package.json +1 -1
- package/clis/twitter/lists-parser.js +0 -77
- package/clis/twitter/lists.d.ts +0 -5
- package/dist/src/cascade.d.ts +0 -46
- package/dist/src/cascade.js +0 -135
- package/dist/src/explore.d.ts +0 -99
- package/dist/src/explore.js +0 -402
- package/dist/src/generate-verified.d.ts +0 -105
- package/dist/src/generate-verified.js +0 -696
- package/dist/src/generate-verified.test.js +0 -925
- package/dist/src/generate.d.ts +0 -46
- package/dist/src/generate.js +0 -117
- package/dist/src/record.d.ts +0 -96
- package/dist/src/record.js +0 -657
- package/dist/src/record.test.js +0 -293
- package/dist/src/skill-generate.d.ts +0 -30
- package/dist/src/skill-generate.js +0 -75
- package/dist/src/skill-generate.test.js +0 -173
- package/dist/src/synthesize.d.ts +0 -97
- package/dist/src/synthesize.js +0 -208
- /package/dist/src/{generate-verified.test.d.ts → browser/target-errors.test.d.ts} +0 -0
- /package/dist/src/{record.test.d.ts → browser/target-resolver.test.d.ts} +0 -0
- /package/dist/src/{skill-generate.test.d.ts → download/article-download.test.d.ts} +0 -0
|
@@ -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,22 +71,21 @@ 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
|
});
|
|
64
|
-
it('
|
|
65
|
-
const page = createPageMock({ loginWall: true, results: [] });
|
|
66
|
-
await expect(command.func(page, { 'note-id': 'abc123', limit: 5 })).rejects.toThrow('Note comments require login');
|
|
67
|
-
});
|
|
68
|
-
it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the comments page', async () => {
|
|
74
|
+
it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
|
|
69
75
|
const page = createPageMock({
|
|
70
|
-
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
|
|
71
|
-
securityBlock: true,
|
|
72
76
|
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'),
|
|
77
|
+
results: [{ author: 'Alice', text: 'Nice', likes: 1, time: '2024-01-01', is_reply: false, reply_to: '' }],
|
|
78
78
|
});
|
|
79
|
-
|
|
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
|
+
});
|
|
83
|
+
it('throws AuthRequiredError when login wall is detected', async () => {
|
|
84
|
+
const page = createPageMock({ loginWall: true, results: [] });
|
|
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');
|
|
80
89
|
});
|
|
81
90
|
it('throws SECURITY_BLOCK with retry guidance when a full URL comments page is blocked', async () => {
|
|
82
91
|
const page = createPageMock({
|
|
@@ -95,11 +104,17 @@ describe('xiaohongshu comments', () => {
|
|
|
95
104
|
});
|
|
96
105
|
it('returns empty array when no comments are found', async () => {
|
|
97
106
|
const page = createPageMock({ loginWall: false, results: [] });
|
|
98
|
-
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([]);
|
|
99
111
|
});
|
|
100
112
|
it('uses condition-based comment scrolling instead of a fixed blind loop', async () => {
|
|
101
113
|
const page = createPageMock({ loginWall: false, results: [] });
|
|
102
|
-
await command.func(page, {
|
|
114
|
+
await command.func(page, {
|
|
115
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
116
|
+
limit: 5,
|
|
117
|
+
});
|
|
103
118
|
const script = page.evaluate.mock.calls[0][0];
|
|
104
119
|
expect(script).toContain("const beforeCount = scroller.querySelectorAll('.parent-comment').length");
|
|
105
120
|
expect(script).toContain("const afterCount = scroller.querySelectorAll('.parent-comment').length");
|
|
@@ -115,7 +130,10 @@ describe('xiaohongshu comments', () => {
|
|
|
115
130
|
reply_to: '',
|
|
116
131
|
}));
|
|
117
132
|
const page = createPageMock({ loginWall: false, results: manyComments });
|
|
118
|
-
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
|
+
}));
|
|
119
137
|
expect(result).toHaveLength(3);
|
|
120
138
|
expect(result[0].rank).toBe(1);
|
|
121
139
|
expect(result[2].rank).toBe(3);
|
|
@@ -128,7 +146,10 @@ describe('xiaohongshu comments', () => {
|
|
|
128
146
|
{ author: 'Bob', text: 'Very helpful', likes: 0, time: '2024-01-02', is_reply: false, reply_to: '' },
|
|
129
147
|
],
|
|
130
148
|
});
|
|
131
|
-
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
|
+
}));
|
|
132
153
|
expect(result).toHaveLength(1);
|
|
133
154
|
expect(result[0]).toMatchObject({ rank: 1, author: 'Alice' });
|
|
134
155
|
});
|
|
@@ -143,7 +164,7 @@ describe('xiaohongshu comments', () => {
|
|
|
143
164
|
],
|
|
144
165
|
});
|
|
145
166
|
const result = (await command.func(page, {
|
|
146
|
-
'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,
|
|
147
168
|
}));
|
|
148
169
|
expect(result).toHaveLength(3);
|
|
149
170
|
expect(result[0]).toMatchObject({ author: 'Alice', is_reply: false, reply_to: '' });
|
|
@@ -166,7 +187,7 @@ describe('xiaohongshu comments', () => {
|
|
|
166
187
|
});
|
|
167
188
|
// Limit to 2 top-level comments — should include A + 2 replies + B = 4 rows
|
|
168
189
|
const result = (await command.func(page, {
|
|
169
|
-
'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,
|
|
170
191
|
}));
|
|
171
192
|
expect(result).toHaveLength(4);
|
|
172
193
|
expect(result.map((r) => r.author)).toEqual(['A', 'A1', 'A2', 'B']);
|
|
@@ -2,10 +2,9 @@
|
|
|
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';
|
|
@@ -20,7 +19,7 @@ cli({
|
|
|
20
19
|
strategy: Strategy.COOKIE,
|
|
21
20
|
navigateBefore: false,
|
|
22
21
|
args: [
|
|
23
|
-
{ 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' },
|
|
24
23
|
{ name: 'output', default: './xiaohongshu-downloads', help: 'Output directory' },
|
|
25
24
|
],
|
|
26
25
|
columns: ['index', 'type', 'status', 'size'],
|
|
@@ -28,7 +27,7 @@ cli({
|
|
|
28
27
|
const rawInput = String(kwargs['note-id']);
|
|
29
28
|
const output = kwargs.output;
|
|
30
29
|
const noteId = parseNoteId(rawInput);
|
|
31
|
-
await page.goto(buildNoteUrl(rawInput));
|
|
30
|
+
await page.goto(buildNoteUrl(rawInput, { allowShortLink: true, commandName: 'xiaohongshu download' }));
|
|
32
31
|
await page.wait({ time: 1 + Math.random() * 2 });
|
|
33
32
|
// Extract note info and media URLs
|
|
34
33
|
const data = await page.evaluate(`
|
|
@@ -51,9 +50,9 @@ cli({
|
|
|
51
50
|
seenMedia.add(key);
|
|
52
51
|
result.media.push({ type, url });
|
|
53
52
|
};
|
|
54
|
-
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);
|
|
55
54
|
if (locationMatch) {
|
|
56
|
-
result.noteId = locationMatch[1];
|
|
55
|
+
result.noteId = locationMatch[1] || locationMatch[2];
|
|
57
56
|
}
|
|
58
57
|
|
|
59
58
|
// Get title
|
|
@@ -70,19 +70,31 @@ describe('xiaohongshu download', () => {
|
|
|
70
70
|
filenamePrefix: '69bc166f000000001a02069a',
|
|
71
71
|
}));
|
|
72
72
|
});
|
|
73
|
-
it('
|
|
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 () => {
|
|
74
87
|
const page = createPageMock({
|
|
75
|
-
pageUrl: 'https://www.xiaohongshu.com/website-login/error?error_code=300017',
|
|
76
|
-
securityBlock: true,
|
|
77
88
|
noteId: '69bc166f000000001a02069a',
|
|
78
89
|
media: [],
|
|
79
90
|
});
|
|
80
91
|
await expect(command.func(page, { 'note-id': '69bc166f000000001a02069a', output: './out' })).rejects.toMatchObject({
|
|
81
|
-
code: '
|
|
92
|
+
code: 'ARGUMENT',
|
|
93
|
+
message: expect.stringContaining('signed URL'),
|
|
82
94
|
hint: expect.stringContaining('xsec_token'),
|
|
83
95
|
});
|
|
96
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
84
97
|
expect(mockDownloadMedia).not.toHaveBeenCalled();
|
|
85
|
-
expect(page.wait).toHaveBeenCalledWith(expect.objectContaining({ time: expect.any(Number) }));
|
|
86
98
|
});
|
|
87
99
|
it('throws SECURITY_BLOCK with retry guidance for blocked full URLs', async () => {
|
|
88
100
|
const page = createPageMock({
|
|
@@ -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,9 +4,7 @@
|
|
|
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
10
|
import { AuthRequiredError, CliError, EmptyResultError } from '@jackwener/opencli/errors';
|
|
@@ -19,13 +17,13 @@ cli({
|
|
|
19
17
|
strategy: Strategy.COOKIE,
|
|
20
18
|
navigateBefore: false,
|
|
21
19
|
args: [
|
|
22
|
-
{ name: 'note-id', required: true, positional: true, help: '
|
|
20
|
+
{ name: 'note-id', required: true, positional: true, help: 'Full Xiaohongshu note URL with xsec_token' },
|
|
23
21
|
],
|
|
24
22
|
columns: ['field', 'value'],
|
|
25
23
|
func: async (page, kwargs) => {
|
|
26
24
|
const raw = String(kwargs['note-id']);
|
|
27
25
|
const noteId = parseNoteId(raw);
|
|
28
|
-
const url = buildNoteUrl(raw);
|
|
26
|
+
const url = buildNoteUrl(raw, { commandName: 'xiaohongshu note' });
|
|
29
27
|
await page.goto(url);
|
|
30
28
|
await page.wait({ time: 2 + Math.random() * 3 });
|
|
31
29
|
const data = await page.evaluate(`
|
|
@@ -36,6 +36,9 @@ describe('parseNoteId', () => {
|
|
|
36
36
|
it('extracts ID from /note/ URL', () => {
|
|
37
37
|
expect(parseNoteId('https://www.xiaohongshu.com/note/69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
|
|
38
38
|
});
|
|
39
|
+
it('extracts ID from signed /user/profile/<user>/<note> URL', () => {
|
|
40
|
+
expect(parseNoteId('https://www.xiaohongshu.com/user/profile/user123/69c131c9000000002800be4c?xsec_token=abc&xsec_source=pc_user')).toBe('69c131c9000000002800be4c');
|
|
41
|
+
});
|
|
39
42
|
it('returns raw string when no URL pattern matches', () => {
|
|
40
43
|
expect(parseNoteId('69c131c9000000002800be4c')).toBe('69c131c9000000002800be4c');
|
|
41
44
|
});
|
|
@@ -48,8 +51,14 @@ describe('buildNoteUrl', () => {
|
|
|
48
51
|
const url = 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok';
|
|
49
52
|
expect(buildNoteUrl(url)).toBe(url);
|
|
50
53
|
});
|
|
51
|
-
it('
|
|
52
|
-
expect(buildNoteUrl('
|
|
54
|
+
it('rejects signed URLs from non-xiaohongshu hosts', () => {
|
|
55
|
+
expect(() => buildNoteUrl('https://example.com/?xsec_token=tok')).toThrow(/xiaohongshu/i);
|
|
56
|
+
});
|
|
57
|
+
it('rejects signed URLs with an empty xsec_token value', () => {
|
|
58
|
+
expect(() => buildNoteUrl('https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=')).toThrow(/xsec_token|signed url/i);
|
|
59
|
+
});
|
|
60
|
+
it('rejects bare note IDs because xiaohongshu now requires a signed URL', () => {
|
|
61
|
+
expect(() => buildNoteUrl('abc123')).toThrow(/xsec_token|signed url/i);
|
|
53
62
|
});
|
|
54
63
|
});
|
|
55
64
|
describe('xiaohongshu note', () => {
|
|
@@ -58,7 +67,7 @@ describe('xiaohongshu note', () => {
|
|
|
58
67
|
expect(command).toBeDefined();
|
|
59
68
|
expect(command.func).toBeTypeOf('function');
|
|
60
69
|
});
|
|
61
|
-
it('returns note content as field/value rows', async () => {
|
|
70
|
+
it('returns note content as field/value rows for signed full URLs', async () => {
|
|
62
71
|
const page = createPageMock({
|
|
63
72
|
loginWall: false,
|
|
64
73
|
notFound: false,
|
|
@@ -70,8 +79,9 @@ describe('xiaohongshu note', () => {
|
|
|
70
79
|
comments: '45',
|
|
71
80
|
tags: ['#尚界Z7', '#鸿蒙智行'],
|
|
72
81
|
});
|
|
73
|
-
const
|
|
74
|
-
|
|
82
|
+
const signedUrl = 'https://www.xiaohongshu.com/search_result/69c131c9000000002800be4c?xsec_token=abc';
|
|
83
|
+
const result = (await command.func(page, { 'note-id': signedUrl }));
|
|
84
|
+
expect(page.goto.mock.calls[0][0]).toBe(signedUrl);
|
|
75
85
|
expect(result).toEqual([
|
|
76
86
|
{ field: 'title', value: '尚界Z7实车体验' },
|
|
77
87
|
{ field: 'author', value: '小红薯用户' },
|
|
@@ -82,6 +92,18 @@ describe('xiaohongshu note', () => {
|
|
|
82
92
|
{ field: 'tags', value: '#尚界Z7, #鸿蒙智行' },
|
|
83
93
|
]);
|
|
84
94
|
});
|
|
95
|
+
it('rejects bare note IDs before browser navigation', async () => {
|
|
96
|
+
const page = createPageMock({
|
|
97
|
+
loginWall: false, notFound: false,
|
|
98
|
+
title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
|
|
99
|
+
});
|
|
100
|
+
await expect(command.func(page, { 'note-id': '69c131c9000000002800be4c' })).rejects.toMatchObject({
|
|
101
|
+
code: 'ARGUMENT',
|
|
102
|
+
message: expect.stringContaining('signed URL'),
|
|
103
|
+
hint: expect.stringContaining('xsec_token'),
|
|
104
|
+
});
|
|
105
|
+
expect(page.goto).not.toHaveBeenCalled();
|
|
106
|
+
});
|
|
85
107
|
it('parses note ID from full /explore/ URL', async () => {
|
|
86
108
|
const page = createPageMock({
|
|
87
109
|
loginWall: false, notFound: false,
|
|
@@ -102,23 +124,20 @@ describe('xiaohongshu note', () => {
|
|
|
102
124
|
// Should navigate to the full URL as-is, not strip the token
|
|
103
125
|
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
104
126
|
});
|
|
105
|
-
it('
|
|
106
|
-
const page = createPageMock({ loginWall: true, notFound: false });
|
|
107
|
-
await expect(command.func(page, { 'note-id': 'abc123' })).rejects.toThrow('Note content requires login');
|
|
108
|
-
});
|
|
109
|
-
it('throws SECURITY_BLOCK with bare-id guidance when risk control blocks the note page', async () => {
|
|
127
|
+
it('preserves signed /user/profile/<user>/<note> URLs for navigation', async () => {
|
|
110
128
|
const page = createPageMock({
|
|
111
|
-
|
|
112
|
-
|
|
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'),
|
|
129
|
+
loginWall: false, notFound: false,
|
|
130
|
+
title: 'Test', desc: '', author: '', likes: '0', collects: '0', comments: '0', tags: [],
|
|
120
131
|
});
|
|
121
|
-
|
|
132
|
+
const fullUrl = 'https://www.xiaohongshu.com/user/profile/user123/69c131c9000000002800be4c?xsec_token=abc&xsec_source=pc_user';
|
|
133
|
+
await command.func(page, { 'note-id': fullUrl });
|
|
134
|
+
expect(page.goto.mock.calls[0][0]).toBe(fullUrl);
|
|
135
|
+
});
|
|
136
|
+
it('throws AuthRequiredError on login wall', async () => {
|
|
137
|
+
const page = createPageMock({ loginWall: true, notFound: false });
|
|
138
|
+
await expect(command.func(page, {
|
|
139
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
140
|
+
})).rejects.toThrow('Note content requires login');
|
|
122
141
|
});
|
|
123
142
|
it('throws SECURITY_BLOCK with retry guidance when a full URL is blocked', async () => {
|
|
124
143
|
const page = createPageMock({
|
|
@@ -136,7 +155,9 @@ describe('xiaohongshu note', () => {
|
|
|
136
155
|
});
|
|
137
156
|
it('throws EmptyResultError when note is not found', async () => {
|
|
138
157
|
const page = createPageMock({ loginWall: false, notFound: true });
|
|
139
|
-
await expect(command.func(page, {
|
|
158
|
+
await expect(command.func(page, {
|
|
159
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
160
|
+
})).rejects.toThrow('returned no data');
|
|
140
161
|
});
|
|
141
162
|
it('throws an empty-result error when the note page renders as an empty shell', async () => {
|
|
142
163
|
const page = createPageMock({
|
|
@@ -151,7 +172,9 @@ describe('xiaohongshu note', () => {
|
|
|
151
172
|
tags: [],
|
|
152
173
|
});
|
|
153
174
|
try {
|
|
154
|
-
await command.func(page, {
|
|
175
|
+
await command.func(page, {
|
|
176
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/69ca3927000000001a020fd5?xsec_token=abc',
|
|
177
|
+
});
|
|
155
178
|
throw new Error('expected xiaohongshu note to fail on an empty shell page');
|
|
156
179
|
}
|
|
157
180
|
catch (error) {
|
|
@@ -193,7 +216,9 @@ describe('xiaohongshu note', () => {
|
|
|
193
216
|
title: 'New note', desc: 'Just posted', author: 'Author',
|
|
194
217
|
likes: '赞', collects: '收藏', comments: '评论', tags: [],
|
|
195
218
|
});
|
|
196
|
-
const result = (await command.func(page, {
|
|
219
|
+
const result = (await command.func(page, {
|
|
220
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
221
|
+
}));
|
|
197
222
|
expect(result.find((r) => r.field === 'likes').value).toBe('0');
|
|
198
223
|
expect(result.find((r) => r.field === 'collects').value).toBe('0');
|
|
199
224
|
expect(result.find((r) => r.field === 'comments').value).toBe('0');
|
|
@@ -203,7 +228,7 @@ describe('xiaohongshu note', () => {
|
|
|
203
228
|
loginWall: false, notFound: false,
|
|
204
229
|
title: 'Test', desc: '', author: 'Author', likes: '10', collects: '5', comments: '3', tags: [],
|
|
205
230
|
});
|
|
206
|
-
await command.func(page, { 'note-id': 'abc123' });
|
|
231
|
+
await command.func(page, { 'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok' });
|
|
207
232
|
const evaluateScript = page.evaluate.mock.calls[0][0];
|
|
208
233
|
expect(evaluateScript).toContain('.interact-container .like-wrapper .count');
|
|
209
234
|
expect(evaluateScript).toContain('.interact-container .collect-wrapper .count');
|
|
@@ -215,7 +240,9 @@ describe('xiaohongshu note', () => {
|
|
|
215
240
|
title: 'No tags', desc: 'Content', author: 'Author',
|
|
216
241
|
likes: '1', collects: '2', comments: '3', tags: [],
|
|
217
242
|
});
|
|
218
|
-
const result = (await command.func(page, {
|
|
243
|
+
const result = (await command.func(page, {
|
|
244
|
+
'note-id': 'https://www.xiaohongshu.com/search_result/abc123?xsec_token=tok',
|
|
245
|
+
}));
|
|
219
246
|
expect(result.find((r) => r.field === 'tags')).toBeUndefined();
|
|
220
247
|
expect(result).toHaveLength(6);
|
|
221
248
|
});
|