@skills-store/rednote 0.1.7 → 0.1.9

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  # @skills-store/rednote
2
2
 
3
- A Xiaohongshu (RED) automation CLI for browser session management, login, search, feed detail lookup, and profile lookup.
3
+ A Xiaohongshu (RED) automation CLI for browser session management, login, search, feed detail lookup, profile lookup, and note interactions such as like, collect, and commenting through `interact`.
4
4
 
5
5
  ## Install
6
6
 
@@ -27,7 +27,7 @@ For most tasks, run commands in this order:
27
27
  3. browser connect
28
28
  4. login or check-login
29
29
  5. status
30
- 6. home, search, get-feed-detail, or get-profile
30
+ 6. home, search, get-feed-detail, get-profile, or interact
31
31
  ```
32
32
 
33
33
  ## Quick start
@@ -39,6 +39,7 @@ rednote browser connect --instance seller-main
39
39
  rednote login --instance seller-main
40
40
  rednote status --instance seller-main
41
41
  rednote search --instance seller-main --keyword 护肤
42
+ rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
42
43
  ```
43
44
 
44
45
  ## Commands
@@ -121,6 +122,16 @@ rednote get-profile --instance seller-main --id USER_ID
121
122
 
122
123
  Use `get-profile` when you want author or account profile information.
123
124
 
125
+
126
+ ### `interact`
127
+
128
+ ```bash
129
+ rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect
130
+ rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
131
+ ```
132
+
133
+ Use `interact` when you want the single entrypoint for note operations such as like, collect, and comment in one command. Use `--comment TEXT` for replies; there is no standalone `comment` command.
134
+
124
135
  ## Important flags
125
136
 
126
137
  - `--instance NAME` selects the browser instance for account-scoped commands.
@@ -130,6 +141,329 @@ Use `get-profile` when you want author or account profile information.
130
141
  - `--keyword` is required for `search`.
131
142
  - `--url` is required for `get-feed-detail`.
132
143
  - `--id` is required for `get-profile`.
144
+ - `--url` is required for `interact`; at least one of `--like`, `--collect`, or `--comment TEXT` must be provided.
145
+ - replies are sent with `interact --comment TEXT`.
146
+
147
+ ## JSON success shapes
148
+
149
+ Use these shapes as the success model when a command returns JSON.
150
+
151
+ ### Shared note item
152
+
153
+ `home`, `search`, and `profile.notes` share the normalized `RednotePost` shape:
154
+
155
+ ```json
156
+ {
157
+ "id": "string",
158
+ "modelType": "string",
159
+ "xsecToken": "string|null",
160
+ "url": "string",
161
+ "noteCard": {
162
+ "type": "string|null",
163
+ "displayTitle": "string|null",
164
+ "cover": {
165
+ "urlDefault": "string|null",
166
+ "urlPre": "string|null",
167
+ "url": "string|null",
168
+ "fileId": "string|null",
169
+ "width": "number|null",
170
+ "height": "number|null",
171
+ "infoList": [{ "imageScene": "string|null", "url": "string|null" }]
172
+ },
173
+ "user": {
174
+ "userId": "string|null",
175
+ "nickname": "string|null",
176
+ "nickName": "string|null",
177
+ "avatar": "string|null",
178
+ "xsecToken": "string|null"
179
+ },
180
+ "interactInfo": {
181
+ "liked": "boolean",
182
+ "likedCount": "string|null",
183
+ "commentCount": "string|null",
184
+ "collectedCount": "string|null",
185
+ "sharedCount": "string|null"
186
+ },
187
+ "cornerTagInfo": [{ "type": "string|null", "text": "string|null" }],
188
+ "imageList": [{ "width": "number|null", "height": "number|null", "infoList": [{ "imageScene": "string|null", "url": "string|null" }] }],
189
+ "video": { "duration": "number|null" }
190
+ }
191
+ }
192
+ ```
193
+
194
+ ### `env --format json`
195
+
196
+ `env` is the main exception: it returns a raw environment object instead of `{ "ok": true, ... }`.
197
+
198
+ ```json
199
+ {
200
+ "packageRoot": "string",
201
+ "homeDir": "string",
202
+ "platform": "string",
203
+ "nodeVersion": "string",
204
+ "storageHome": "string",
205
+ "storageRoot": "string",
206
+ "instancesDir": "string",
207
+ "instanceStorePath": "string",
208
+ "legacyPackageInstancesDir": "string"
209
+ }
210
+ ```
211
+
212
+ ### Browser commands
213
+
214
+ `browser list`:
215
+
216
+ ```json
217
+ {
218
+ "lastConnect": { "scope": "default|custom", "name": "string", "browser": "chrome|edge|chromium|brave" } | null,
219
+ "instances": [{
220
+ "type": "chrome|edge|chromium|brave",
221
+ "name": "string",
222
+ "executablePath": "string",
223
+ "userDataDir": "string",
224
+ "exists": true,
225
+ "inUse": false,
226
+ "pid": "number|null",
227
+ "lockFiles": ["string"],
228
+ "matchedProcess": { "pid": "number", "name": "string", "cmdline": "string" } | null,
229
+ "staleLock": false,
230
+ "remotePort": "number|null",
231
+ "scope": "default|custom",
232
+ "instanceName": "string",
233
+ "createdAt": "string|null",
234
+ "lastConnect": false
235
+ }]
236
+ }
237
+ ```
238
+
239
+ `browser create`:
240
+
241
+ ```json
242
+ {
243
+ "ok": true,
244
+ "instance": {
245
+ "name": "string",
246
+ "browser": "chrome|edge|chromium|brave",
247
+ "userDataDir": "string",
248
+ "createdAt": "string",
249
+ "remoteDebuggingPort": "number|undefined"
250
+ }
251
+ }
252
+ ```
253
+
254
+ `browser connect`:
255
+
256
+ ```json
257
+ {
258
+ "ok": true,
259
+ "type": "chrome|edge|chromium|brave",
260
+ "executablePath": "string",
261
+ "userDataDir": "string",
262
+ "remoteDebuggingPort": "number",
263
+ "wsUrl": "string",
264
+ "pid": "number|null"
265
+ }
266
+ ```
267
+
268
+ `browser remove`:
269
+
270
+ ```json
271
+ {
272
+ "ok": true,
273
+ "removedInstance": {
274
+ "name": "string",
275
+ "browser": "chrome|edge|chromium|brave",
276
+ "userDataDir": "string",
277
+ "createdAt": "string",
278
+ "remoteDebuggingPort": "number|undefined"
279
+ },
280
+ "removedDir": true,
281
+ "closedPids": ["number"]
282
+ }
283
+ ```
284
+
285
+ ### Session and account commands
286
+
287
+ `status`:
288
+
289
+ ```json
290
+ {
291
+ "ok": true,
292
+ "instance": {
293
+ "scope": "default|custom",
294
+ "name": "string",
295
+ "browser": "chrome|edge|chromium|brave",
296
+ "source": "argument|last-connect|single-instance",
297
+ "status": "running|stopped|missing|stale-lock",
298
+ "exists": true,
299
+ "inUse": false,
300
+ "pid": "number|null",
301
+ "remotePort": "number|null",
302
+ "userDataDir": "string",
303
+ "createdAt": "string|null",
304
+ "lastConnect": false
305
+ },
306
+ "rednote": {
307
+ "loginStatus": "logged-in|logged-out|unknown",
308
+ "lastLoginAt": "string|null"
309
+ }
310
+ }
311
+ ```
312
+
313
+ `check-login`:
314
+
315
+ ```json
316
+ {
317
+ "ok": true,
318
+ "rednote": {
319
+ "loginStatus": "logged-in|logged-out|unknown",
320
+ "lastLoginAt": "string|null",
321
+ "needLogin": false,
322
+ "checkedAt": "string"
323
+ }
324
+ }
325
+ ```
326
+
327
+ `login`:
328
+
329
+ ```json
330
+ {
331
+ "ok": true,
332
+ "rednote": {
333
+ "loginClicked": true,
334
+ "pageUrl": "string",
335
+ "waitingForPhoneLogin": true,
336
+ "message": "string"
337
+ }
338
+ }
339
+ ```
340
+
341
+ ### Feed and profile commands
342
+
343
+ `home --format json`:
344
+
345
+ ```json
346
+ {
347
+ "ok": true,
348
+ "home": {
349
+ "pageUrl": "string",
350
+ "fetchedAt": "string",
351
+ "total": "number",
352
+ "posts": ["RednotePost"],
353
+ "savedPath": "string|undefined"
354
+ }
355
+ }
356
+ ```
357
+
358
+ `search --format json`:
359
+
360
+ ```json
361
+ {
362
+ "ok": true,
363
+ "search": {
364
+ "keyword": "string",
365
+ "pageUrl": "string",
366
+ "fetchedAt": "string",
367
+ "total": "number",
368
+ "posts": ["RednotePost"],
369
+ "savedPath": "string|undefined"
370
+ }
371
+ }
372
+ ```
373
+
374
+ `get-feed-detail --format json`:
375
+
376
+ ```json
377
+ {
378
+ "ok": true,
379
+ "detail": {
380
+ "fetchedAt": "string",
381
+ "total": "number",
382
+ "items": [{
383
+ "url": "string",
384
+ "note": {
385
+ "noteId": "string|null",
386
+ "title": "string|null",
387
+ "desc": "string|null",
388
+ "type": "string|null",
389
+ "interactInfo": {
390
+ "liked": "boolean|null",
391
+ "likedCount": "string|null",
392
+ "commentCount": "string|null",
393
+ "collected": "boolean|null",
394
+ "collectedCount": "string|null",
395
+ "shareCount": "string|null",
396
+ "followed": "boolean|null"
397
+ },
398
+ "tagList": [{ "name": "string|null" }],
399
+ "imageList": [{ "urlDefault": "string|null", "urlPre": "string|null", "width": "number|null", "height": "number|null" }],
400
+ "video": { "url": "string|null", "raw": "unknown" } | null,
401
+ "raw": "unknown"
402
+ },
403
+ "comments": [{
404
+ "id": "string|null",
405
+ "content": "string|null",
406
+ "userId": "string|null",
407
+ "nickname": "string|null",
408
+ "likedCount": "string|null",
409
+ "subCommentCount": "number|null",
410
+ "raw": "unknown"
411
+ }]
412
+ }]
413
+ }
414
+ }
415
+ ```
416
+
417
+ `get-profile --format json`:
418
+
419
+ ```json
420
+ {
421
+ "ok": true,
422
+ "profile": {
423
+ "userId": "string",
424
+ "url": "string",
425
+ "fetchedAt": "string",
426
+ "user": {
427
+ "userId": "string|null",
428
+ "nickname": "string|null",
429
+ "desc": "string|null",
430
+ "avatar": "string|null",
431
+ "ipLocation": "string|null",
432
+ "gender": "string|null",
433
+ "follows": "string|number|null",
434
+ "fans": "string|number|null",
435
+ "interaction": "string|number|null",
436
+ "tags": ["string"],
437
+ "raw": "unknown"
438
+ },
439
+ "notes": ["RednotePost"],
440
+ "raw": {
441
+ "userPageData": "unknown",
442
+ "notes": "unknown"
443
+ }
444
+ }
445
+ }
446
+ ```
447
+
448
+ ### Action commands
449
+
450
+ `publish`:
451
+
452
+ ```json
453
+ {
454
+ "ok": true,
455
+ "message": "string"
456
+ }
457
+ ```
458
+
459
+ `interact`:
460
+
461
+ ```json
462
+ {
463
+ "ok": true,
464
+ "message": "string"
465
+ }
466
+ ```
133
467
 
134
468
  ## Storage
135
469
 
package/dist/index.js CHANGED
@@ -1,19 +1,26 @@
1
1
  #!/usr/bin/env node
2
+ import { createRequire } from 'node:module';
2
3
  import { runCli } from './utils/browser-cli.js';
4
+ const require = createRequire(import.meta.url);
5
+ const { version } = require('../package.json');
3
6
  function printRootHelp() {
4
7
  process.stdout.write(`rednote
8
+ Version:
9
+ ${version}
5
10
 
6
11
  Usage:
7
12
  npx -y @skills-store/rednote browser <command> [...args]
8
13
  npx -y @skills-store/rednote <command> [...args]
9
14
 
10
15
  Commands:
16
+ --version
11
17
  browser <list|create|remove|connect>
12
18
  env [--format md|json]
13
19
  status [--instance NAME]
14
20
  check-login [--instance NAME]
15
21
  login [--instance NAME]
16
22
  publish [--instance NAME]
23
+ interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
17
24
  home [--instance NAME] [--format md|json] [--save [PATH]]
18
25
  search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
19
26
  get-feed-detail [--instance NAME] --url URL [--format md|json]
@@ -25,6 +32,7 @@ Examples:
25
32
  npx -y @skills-store/rednote browser connect --instance seller-main
26
33
  npx -y @skills-store/rednote env
27
34
  npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title 标题 --content 描述
35
+ npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
28
36
  npx -y @skills-store/rednote search --instance seller-main --keyword 护肤
29
37
  `);
30
38
  }
@@ -34,6 +42,10 @@ export async function runRootCli(argv = process.argv.slice(2)) {
34
42
  printRootHelp();
35
43
  return;
36
44
  }
45
+ if (command === '--version' || command === '-v' || command === 'version') {
46
+ process.stdout.write(`${version}\n`);
47
+ return;
48
+ }
37
49
  if (command === 'browser') {
38
50
  const { runBrowserCli } = await import('./browser/index.js');
39
51
  await runBrowserCli(restArgv);
@@ -55,9 +55,9 @@ export async function createRednoteSession(target) {
55
55
  page
56
56
  };
57
57
  }
58
- export function disconnectRednoteSession(session) {
58
+ export async function disconnectRednoteSession(session) {
59
59
  try {
60
- session.browser._connection?.close();
60
+ await session.browser.close();
61
61
  } catch {}
62
62
  }
63
63
  export async function checkRednoteLogin(target, session) {
@@ -91,7 +91,7 @@ export async function checkRednoteLogin(target, session) {
91
91
  };
92
92
  } finally{
93
93
  if (ownsSession) {
94
- disconnectRednoteSession(activeSession);
94
+ await disconnectRednoteSession(activeSession);
95
95
  }
96
96
  }
97
97
  }
@@ -72,6 +72,20 @@ function validateFeedDetailUrl(url) {
72
72
  throw error;
73
73
  }
74
74
  }
75
+ function normalizeFeedDetailUrl(url) {
76
+ try {
77
+ const parsed = new URL(url);
78
+ if (!parsed.searchParams.has('xsec_source')) {
79
+ parsed.searchParams.set('xsec_source', 'pc_feed');
80
+ }
81
+ return parsed.toString();
82
+ } catch (error) {
83
+ if (error instanceof TypeError) {
84
+ throw new Error(`url is not valid: ${url}`);
85
+ }
86
+ throw error;
87
+ }
88
+ }
75
89
  async function getOrCreateXiaohongshuPage(session) {
76
90
  return session.page;
77
91
  }
@@ -87,10 +101,13 @@ function normalizeDetailNote(note) {
87
101
  desc: note?.desc ?? null,
88
102
  type: note?.type ?? null,
89
103
  interactInfo: {
104
+ liked: note?.interactInfo?.liked ?? null,
90
105
  likedCount: note?.interactInfo?.likedCount ?? null,
91
106
  commentCount: note?.interactInfo?.commentCount ?? null,
107
+ collected: note?.interactInfo?.collected ?? null,
92
108
  collectedCount: note?.interactInfo?.collectedCount ?? null,
93
- shareCount: note?.interactInfo?.shareCount ?? null
109
+ shareCount: note?.interactInfo?.shareCount ?? null,
110
+ followed: note?.interactInfo?.followed ?? null
94
111
  },
95
112
  tagList: Array.isArray(note?.tagList) ? note.tagList.map((tag)=>({
96
113
  name: tag?.name ?? null
@@ -119,45 +136,55 @@ function normalizeComments(comments) {
119
136
  raw: comment
120
137
  }));
121
138
  }
139
+ function formatDetailField(value) {
140
+ return value ?? '';
141
+ }
122
142
  function renderDetailMarkdown(items) {
123
143
  if (items.length === 0) {
124
144
  return '没有获取到帖子详情。\n';
125
145
  }
126
146
  return `${items.map((item)=>{
127
- const lines = [
128
- '<note>'
129
- ];
130
- lines.push(`### Url: ${item.url}`);
131
- lines.push(`### 标题:${item.note.title ?? ''}`);
132
- lines.push(`### 内容\n${item.note.desc ?? ''}`);
133
- if (item.note.interactInfo.likedCount) {
134
- lines.push(`### 点赞: ${item.note.interactInfo.likedCount}`);
135
- }
136
- if (item.note.interactInfo.commentCount) {
137
- lines.push(`### 评论: ${item.note.interactInfo.commentCount}`);
138
- }
139
- if (item.note.interactInfo.collectedCount) {
140
- lines.push(`### 收藏: ${item.note.interactInfo.collectedCount}`);
141
- }
142
- if (item.note.interactInfo.shareCount) {
143
- lines.push(`### 分享: ${item.note.interactInfo.shareCount}`);
144
- }
145
- if (item.note.tagList.length > 0) {
146
- lines.push(`### 标签: ${item.note.tagList.map((tag)=>tag.name ? `#${tag.name}` : '').filter(Boolean).join(' ')}`);
147
- }
148
- if (item.note.imageList.length > 0) {
149
- lines.push(`### 图片\n${item.note.imageList.map((image)=>image.urlDefault ? `![](${image.urlDefault})` : '').filter(Boolean).join('\n')}`);
150
- }
151
- if (item.note.video?.url) {
152
- lines.push(`### 视频\n[](${item.note.video.url})`);
153
- }
154
- if (item.comments.length > 0) {
155
- lines.push('### 评论:');
156
- for (const comment of item.comments){
157
- lines.push(`- ${comment.content ?? ''}`);
147
+ const lines = [];
148
+ lines.push('## Note');
149
+ lines.push('');
150
+ lines.push(`- Url: ${item.url}`);
151
+ lines.push(`- Title: ${formatDetailField(item.note.title)}`);
152
+ lines.push(`- Type: ${formatDetailField(item.note.type)}`);
153
+ lines.push(`- Liked: ${formatDetailField(item.note.interactInfo.liked)}`);
154
+ lines.push(`- Collected: ${formatDetailField(item.note.interactInfo.collected)}`);
155
+ lines.push(`- LikedCount: ${formatDetailField(item.note.interactInfo.likedCount)}`);
156
+ lines.push(`- CommentCount: ${formatDetailField(item.note.interactInfo.commentCount)}`);
157
+ lines.push(`- CollectedCount: ${formatDetailField(item.note.interactInfo.collectedCount)}`);
158
+ lines.push(`- ShareCount: ${formatDetailField(item.note.interactInfo.shareCount)}`);
159
+ lines.push(`- Tags: ${item.note.tagList.map((tag)=>tag.name ? `#${tag.name}` : '').filter(Boolean).join(' ')}`);
160
+ lines.push('');
161
+ lines.push('## Content');
162
+ lines.push('');
163
+ lines.push(item.note.desc ?? '');
164
+ if (item.note.imageList.length > 0 || item.note.video?.url) {
165
+ lines.push('');
166
+ lines.push('## Media');
167
+ lines.push('');
168
+ item.note.imageList.forEach((image, index)=>{
169
+ if (image.urlDefault) {
170
+ lines.push(`- Image${index + 1}: ${image.urlDefault}`);
171
+ }
172
+ });
173
+ if (item.note.video?.url) {
174
+ lines.push(`- Video: ${item.note.video.url}`);
158
175
  }
159
176
  }
160
- lines.push('</note>');
177
+ lines.push('');
178
+ lines.push('## Comments');
179
+ lines.push('');
180
+ if (item.comments.length === 0) {
181
+ lines.push('- Comments not found');
182
+ } else {
183
+ item.comments.forEach((comment)=>{
184
+ const prefix = comment.nickname ? `${comment.nickname}: ` : '';
185
+ lines.push(`- ${prefix}${comment.content ?? ''}`);
186
+ });
187
+ }
161
188
  return lines.join('\n');
162
189
  }).join('\n\n---\n\n')}\n`;
163
190
  }
@@ -221,8 +248,9 @@ export async function getFeedDetails(session, urls) {
221
248
  const page = await getOrCreateXiaohongshuPage(session);
222
249
  const items = [];
223
250
  for (const url of urls){
224
- validateFeedDetailUrl(url);
225
- items.push(await captureFeedDetail(page, url));
251
+ const normalizedUrl = normalizeFeedDetailUrl(url);
252
+ validateFeedDetailUrl(normalizedUrl);
253
+ items.push(await captureFeedDetail(page, normalizedUrl));
226
254
  }
227
255
  return {
228
256
  ok: true,
@@ -258,7 +286,7 @@ export async function runGetFeedDetailCommand(values = {
258
286
  const result = await getFeedDetails(session, values.urls);
259
287
  writeFeedDetailOutput(result, values.format);
260
288
  } finally{
261
- disconnectRednoteSession(session);
289
+ await disconnectRednoteSession(session);
262
290
  }
263
291
  }
264
292
  async function main() {
@@ -317,7 +317,7 @@ export async function runGetProfileCommand(values = {
317
317
  const result = await getProfile(session, buildProfileUrl(normalizedUserId), normalizedUserId);
318
318
  writeProfileOutput(result, values.format);
319
319
  } finally{
320
- disconnectRednoteSession(session);
320
+ await disconnectRednoteSession(session);
321
321
  }
322
322
  }
323
323
  async function main() {
@@ -99,7 +99,7 @@ async function collectHomeFeedItems(page) {
99
99
  return;
100
100
  }
101
101
  const url = new URL(response.url());
102
- if (!url.href.endsWith('/explore')) {
102
+ if (url.pathname !== '/explore' && url.pathname !== '/explore/') {
103
103
  return;
104
104
  }
105
105
  const html = await response.text();
@@ -138,12 +138,12 @@ async function collectHomeFeedItems(page) {
138
138
  }, 15_000);
139
139
  page.on('response', handleResponse);
140
140
  });
141
- if (page.url().startsWith('https://www.xiaohongshu.com/explore')) {
141
+ if (page.url() === 'https://www.xiaohongshu.com/explore/') {
142
142
  await page.reload({
143
143
  waitUntil: 'domcontentloaded'
144
144
  });
145
145
  } else {
146
- await page.goto('https://www.xiaohongshu.com/explore', {
146
+ await page.goto('https://www.xiaohongshu.com/explore/', {
147
147
  waitUntil: 'domcontentloaded'
148
148
  });
149
149
  }
@@ -196,7 +196,7 @@ export async function runHomeCommand(values = {
196
196
  const result = await getRednoteHomePosts(session);
197
197
  writeHomeOutput(result, values);
198
198
  } finally{
199
- disconnectRednoteSession(session);
199
+ await disconnectRednoteSession(session);
200
200
  }
201
201
  }
202
202
  async function main() {
@@ -11,6 +11,7 @@ Commands:
11
11
  check-login [--instance NAME]
12
12
  login [--instance NAME]
13
13
  publish [--instance NAME]
14
+ interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
14
15
  home [--instance NAME] [--format md|json] [--save [PATH]]
15
16
  search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
16
17
  get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
@@ -23,6 +24,7 @@ Examples:
23
24
  npx -y @skills-store/rednote status --instance seller-main
24
25
  npx -y @skills-store/rednote login --instance seller-main
25
26
  npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title 标题 --content 描述
27
+ npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
26
28
  npx -y @skills-store/rednote home --instance seller-main --format md --save
27
29
  npx -y @skills-store/rednote search --instance seller-main --keyword 护肤 --format json --save ./output/search.jsonl
28
30
  npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
@@ -99,6 +101,11 @@ export async function runRednoteCli(argv = process.argv.slice(2)) {
99
101
  await runPublishCommand(parsePublishCliArgs(commandArgv));
100
102
  return;
101
103
  }
104
+ if (command === 'interact') {
105
+ const { parseInteractCliArgs, runInteractCommand } = await import('./interact.js');
106
+ await runInteractCommand(parseInteractCliArgs(commandArgv));
107
+ return;
108
+ }
102
109
  if (command === 'home') {
103
110
  const { parseHomeCliArgs, runHomeCommand } = await import('./home.js');
104
111
  await runHomeCommand(parseHomeCliArgs(commandArgv));
@@ -0,0 +1,304 @@
1
+ #!/usr/bin/env node
2
+ import { parseArgs } from 'node:util';
3
+ import { printJson, runCli } from '../utils/browser-cli.js';
4
+ import { resolveStatusTarget } from './status.js';
5
+ import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
6
+ import { getFeedDetails } from './getFeedDetail.js';
7
+ const INTERACT_CONTAINER_SELECTOR = '.interact-container';
8
+ const LIKE_WRAPPER_SELECTOR = `${INTERACT_CONTAINER_SELECTOR} .like-wrapper`;
9
+ const COLLECT_WRAPPER_SELECTOR = `${INTERACT_CONTAINER_SELECTOR} .collect-wrapper, ${INTERACT_CONTAINER_SELECTOR} #note-page-collect-board-guide`;
10
+ const COMMENT_INPUT_SELECTOR = '#content-textarea[contenteditable="true"]';
11
+ const COMMENT_SEND_BUTTON_SELECTOR = 'button.btn.submit';
12
+ const COMMENT_SEND_BUTTON_TEXT = '发送';
13
+ function printInteractHelp() {
14
+ process.stdout.write(`rednote interact
15
+
16
+ Usage:
17
+ npx -y @skills-store/rednote interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
18
+ node --experimental-strip-types ./scripts/rednote/interact.ts --instance NAME --url URL [--like] [--collect] [--comment TEXT]
19
+ bun ./scripts/rednote/interact.ts --instance NAME --url URL [--like] [--collect] [--comment TEXT]
20
+
21
+ Options:
22
+ --instance NAME Optional. Defaults to the saved lastConnect instance
23
+ --url URL Required. Xiaohongshu explore url
24
+ --like Optional. Perform like
25
+ --collect Optional. Perform collect
26
+ --comment TEXT Optional. Post comment content
27
+ -h, --help Show this help
28
+ `);
29
+ }
30
+ export function parseInteractCliArgs(argv) {
31
+ const { values, positionals } = parseArgs({
32
+ args: argv,
33
+ allowPositionals: true,
34
+ strict: false,
35
+ options: {
36
+ instance: {
37
+ type: 'string'
38
+ },
39
+ url: {
40
+ type: 'string'
41
+ },
42
+ like: {
43
+ type: 'boolean'
44
+ },
45
+ collect: {
46
+ type: 'boolean'
47
+ },
48
+ comment: {
49
+ type: 'string'
50
+ },
51
+ help: {
52
+ type: 'boolean',
53
+ short: 'h'
54
+ }
55
+ }
56
+ });
57
+ if (positionals.length > 0) {
58
+ throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
59
+ }
60
+ return {
61
+ instance: values.instance,
62
+ url: values.url,
63
+ like: values.like,
64
+ collect: values.collect,
65
+ comment: values.comment,
66
+ help: values.help
67
+ };
68
+ }
69
+ function ensureNonEmpty(value, optionName) {
70
+ const normalized = value?.trim();
71
+ if (!normalized) {
72
+ throw new Error(`Missing required option: ${optionName}`);
73
+ }
74
+ return normalized;
75
+ }
76
+ function validateFeedDetailUrl(url) {
77
+ try {
78
+ const parsed = new URL(url);
79
+ if (!parsed.href.startsWith('https://www.xiaohongshu.com/explore/')) {
80
+ throw new Error(`url is not valid: ${url},must start with "https://www.xiaohongshu.com/explore/"`);
81
+ }
82
+ if (!parsed.searchParams.get('xsec_token')) {
83
+ throw new Error(`url is not valid: ${url},must include "xsec_token="`);
84
+ }
85
+ } catch (error) {
86
+ if (error instanceof TypeError) {
87
+ throw new Error(`url is not valid: ${url}`);
88
+ }
89
+ throw error;
90
+ }
91
+ }
92
+ function resolveInteractActions(values) {
93
+ const actions = [];
94
+ if (values.like) {
95
+ actions.push('like');
96
+ }
97
+ if (values.collect) {
98
+ actions.push('collect');
99
+ }
100
+ const commentContent = values.comment?.trim();
101
+ if (commentContent) {
102
+ actions.push('comment');
103
+ }
104
+ if (actions.length === 0) {
105
+ throw new Error('At least one interact option is required: --like, --collect, or --comment');
106
+ }
107
+ return {
108
+ actions,
109
+ commentContent
110
+ };
111
+ }
112
+ async function getOrCreateXiaohongshuPage(session) {
113
+ return session.page;
114
+ }
115
+ async function findVisibleLocator(locator, timeoutMs = 5_000) {
116
+ const deadline = Date.now() + timeoutMs;
117
+ while(Date.now() < deadline){
118
+ const count = await locator.count();
119
+ for(let index = 0; index < count; index += 1){
120
+ const candidate = locator.nth(index);
121
+ if (await candidate.isVisible().catch(()=>false)) {
122
+ return candidate;
123
+ }
124
+ }
125
+ await new Promise((resolve)=>setTimeout(resolve, 100));
126
+ }
127
+ return null;
128
+ }
129
+ async function requireVisibleLocator(locator, errorMessage, timeoutMs = 5_000) {
130
+ const visibleLocator = await findVisibleLocator(locator, timeoutMs);
131
+ if (!visibleLocator) {
132
+ throw new Error(errorMessage);
133
+ }
134
+ return visibleLocator;
135
+ }
136
+ async function typeCommentContent(page, content) {
137
+ const commentInput = page.locator(COMMENT_INPUT_SELECTOR);
138
+ const visibleCommentInput = await requireVisibleLocator(commentInput, '未找到评论输入框,请确认帖子详情页已正确加载。', 15_000);
139
+ await visibleCommentInput.scrollIntoViewIfNeeded();
140
+ await visibleCommentInput.click({
141
+ force: true
142
+ });
143
+ await page.keyboard.press(process.platform === 'darwin' ? 'Meta+A' : 'Control+A').catch(()=>{});
144
+ await page.keyboard.press('Backspace').catch(()=>{});
145
+ await page.keyboard.insertText(content);
146
+ await page.waitForFunction(({ selector, expectedContent })=>{
147
+ const element = document.querySelector(selector);
148
+ if (!(element instanceof HTMLElement)) {
149
+ return false;
150
+ }
151
+ return element.innerText.trim() === expectedContent;
152
+ }, {
153
+ selector: COMMENT_INPUT_SELECTOR,
154
+ expectedContent: content
155
+ }, {
156
+ timeout: 5_000
157
+ });
158
+ }
159
+ async function clickSendComment(page) {
160
+ const sendButton = page.locator(COMMENT_SEND_BUTTON_SELECTOR).filter({
161
+ hasText: COMMENT_SEND_BUTTON_TEXT
162
+ });
163
+ const visibleSendButton = await requireVisibleLocator(sendButton, '未找到“发送”按钮,请确认评论工具栏已正确加载。', 15_000);
164
+ await page.waitForFunction(({ selector, text })=>{
165
+ const buttons = [
166
+ ...document.querySelectorAll(selector)
167
+ ];
168
+ const target = buttons.find((candidate)=>candidate.textContent?.includes(text));
169
+ return target instanceof HTMLButtonElement && !target.disabled;
170
+ }, {
171
+ selector: COMMENT_SEND_BUTTON_SELECTOR,
172
+ text: COMMENT_SEND_BUTTON_TEXT
173
+ }, {
174
+ timeout: 5_000
175
+ });
176
+ await visibleSendButton.click();
177
+ await page.waitForFunction(({ inputSelector, buttonSelector, text })=>{
178
+ const input = document.querySelector(inputSelector);
179
+ const buttons = [
180
+ ...document.querySelectorAll(buttonSelector)
181
+ ];
182
+ const button = buttons.find((candidate)=>candidate.textContent?.includes(text));
183
+ const inputCleared = input instanceof HTMLElement ? input.innerText.trim().length === 0 : false;
184
+ const buttonDisabled = button instanceof HTMLButtonElement ? button.disabled : false;
185
+ return inputCleared || buttonDisabled;
186
+ }, {
187
+ inputSelector: COMMENT_INPUT_SELECTOR,
188
+ buttonSelector: COMMENT_SEND_BUTTON_SELECTOR,
189
+ text: COMMENT_SEND_BUTTON_TEXT
190
+ }, {
191
+ timeout: 10_000
192
+ });
193
+ }
194
+ async function commentOnCurrentFeedPage(page, content) {
195
+ await typeCommentContent(page, content);
196
+ await clickSendComment(page);
197
+ }
198
+ async function waitForInteractContainer(page) {
199
+ await page.waitForLoadState('domcontentloaded');
200
+ await page.waitForTimeout(500);
201
+ await requireVisibleLocator(page.locator(INTERACT_CONTAINER_SELECTOR), '未找到互动工具栏,请确认帖子详情页已正确加载。', 15_000);
202
+ }
203
+ function getActionErrorMessage(action) {
204
+ return action === 'like' ? '未找到点赞按钮,请确认帖子详情页已正确加载。' : '未找到收藏按钮,请确认帖子详情页已正确加载。';
205
+ }
206
+ async function ensureActionApplied(page, action, alreadyActive) {
207
+ if (alreadyActive) {
208
+ return true;
209
+ }
210
+ const locator = page.locator(action === 'like' ? LIKE_WRAPPER_SELECTOR : COLLECT_WRAPPER_SELECTOR);
211
+ const visibleLocator = await requireVisibleLocator(locator, getActionErrorMessage(action), 15_000);
212
+ await visibleLocator.scrollIntoViewIfNeeded();
213
+ await visibleLocator.click({
214
+ force: true
215
+ });
216
+ await page.waitForFunction(({ selector, currentAction })=>{
217
+ const nodes = [
218
+ ...document.querySelectorAll(selector)
219
+ ];
220
+ const target = nodes.find((candidate)=>candidate instanceof HTMLElement && candidate.offsetParent !== null);
221
+ if (!(target instanceof HTMLElement)) {
222
+ return false;
223
+ }
224
+ const classNames = [
225
+ ...target.classList
226
+ ].map((item)=>item.toLowerCase());
227
+ if (classNames.includes(`${currentAction}-active`) || classNames.includes('active') || classNames.some((item)=>item.endsWith('-active'))) {
228
+ return true;
229
+ }
230
+ const iconRefs = [
231
+ ...target.querySelectorAll('use')
232
+ ].map((node)=>(node.getAttribute('xlink:href') ?? node.getAttribute('href') ?? '').toLowerCase()).filter(Boolean).join(' ');
233
+ if (currentAction === 'like') {
234
+ return iconRefs.includes('liked') || iconRefs.includes('like-filled') || iconRefs.includes('like_fill');
235
+ }
236
+ return iconRefs.includes('collected') || iconRefs.includes('collect-filled') || iconRefs.includes('collect_fill');
237
+ }, {
238
+ selector: action === 'like' ? LIKE_WRAPPER_SELECTOR : COLLECT_WRAPPER_SELECTOR,
239
+ currentAction: action
240
+ }, {
241
+ timeout: 10_000
242
+ });
243
+ return false;
244
+ }
245
+ export async function interactWithFeed(session, url, actions, commentContent) {
246
+ validateFeedDetailUrl(url);
247
+ const detailResult = await getFeedDetails(session, [
248
+ url
249
+ ]);
250
+ const detailItem = detailResult.detail.items[0];
251
+ if (!detailItem) {
252
+ throw new Error(`Failed to load feed detail: ${url}`);
253
+ }
254
+ const page = await getOrCreateXiaohongshuPage(session);
255
+ await waitForInteractContainer(page);
256
+ let liked = detailItem.note.interactInfo.liked === true;
257
+ let collected = detailItem.note.interactInfo.collected === true;
258
+ const messages = [];
259
+ for (const action of actions){
260
+ if (action === 'like') {
261
+ const alreadyActive = liked;
262
+ await ensureActionApplied(page, 'like', alreadyActive);
263
+ liked = true;
264
+ messages.push(alreadyActive ? 'Like already active' : 'Like completed');
265
+ continue;
266
+ }
267
+ if (action === 'collect') {
268
+ const alreadyActive = collected;
269
+ await ensureActionApplied(page, 'collect', alreadyActive);
270
+ collected = true;
271
+ messages.push(alreadyActive ? 'Collect already active' : 'Collect completed');
272
+ continue;
273
+ }
274
+ const normalizedContent = ensureNonEmpty(commentContent, '--comment');
275
+ await commentOnCurrentFeedPage(page, normalizedContent);
276
+ messages.push('Comment posted');
277
+ }
278
+ return {
279
+ ok: true,
280
+ message: `${messages.join('; ')}: ${url}`
281
+ };
282
+ }
283
+ export async function runInteractCommand(values = {}) {
284
+ if (values.help) {
285
+ printInteractHelp();
286
+ return;
287
+ }
288
+ const url = ensureNonEmpty(values.url, '--url');
289
+ const { actions, commentContent } = resolveInteractActions(values);
290
+ const target = resolveStatusTarget(values.instance);
291
+ const session = await createRednoteSession(target);
292
+ try {
293
+ await ensureRednoteLoggedIn(target, `performing ${actions.join(', ')} interact`, session);
294
+ const result = await interactWithFeed(session, url, actions, commentContent);
295
+ printJson(result);
296
+ } finally{
297
+ await disconnectRednoteSession(session);
298
+ }
299
+ }
300
+ async function main() {
301
+ const values = parseInteractCliArgs(process.argv.slice(2));
302
+ await runInteractCommand(values);
303
+ }
304
+ runCli(import.meta.url, main);
@@ -79,7 +79,7 @@ export async function runLoginCommand(values = {}) {
79
79
  const result = await openRednoteLogin(target, session);
80
80
  printJson(result);
81
81
  } finally{
82
- disconnectRednoteSession(session);
82
+ await disconnectRednoteSession(session);
83
83
  }
84
84
  }
85
85
  async function main() {
File without changes
@@ -483,7 +483,7 @@ export async function runPublishCommand(values) {
483
483
  const result = await openRednotePublish(session, payload);
484
484
  printJson(result);
485
485
  } finally{
486
- disconnectRednoteSession(session);
486
+ await disconnectRednoteSession(session);
487
487
  }
488
488
  }
489
489
  async function main() {
@@ -193,7 +193,7 @@ export async function runSearchCommand(values = {
193
193
  const result = await searchRednotePosts(session, keyword);
194
194
  writeSearchOutput(result, values);
195
195
  } finally{
196
- disconnectRednoteSession(session);
196
+ await disconnectRednoteSession(session);
197
197
  }
198
198
  }
199
199
  async function main() {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skills-store/rednote",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,6 +16,7 @@
16
16
  "check-login": "node --experimental-strip-types ./scripts/rednote/checkLogin.ts",
17
17
  "login": "node --experimental-strip-types ./scripts/rednote/login.ts",
18
18
  "publish": "node --experimental-strip-types ./scripts/rednote/publish.ts",
19
+ "interact": "node --experimental-strip-types ./scripts/rednote/interact.ts",
19
20
  "home": "node --experimental-strip-types ./scripts/rednote/home.ts",
20
21
  "search": "node --experimental-strip-types ./scripts/rednote/search.ts",
21
22
  "get-feed-detail": "node --experimental-strip-types ./scripts/rednote/getFeedDetail.ts",
@@ -27,6 +28,7 @@
27
28
  "bun:check-login": "bun ./scripts/rednote/checkLogin.ts",
28
29
  "bun:login": "bun ./scripts/rednote/login.ts",
29
30
  "bun:publish": "bun ./scripts/rednote/publish.ts",
31
+ "bun:interact": "bun ./scripts/rednote/interact.ts",
30
32
  "bun:home": "bun ./scripts/rednote/home.ts",
31
33
  "bun:search": "bun ./scripts/rednote/search.ts",
32
34
  "bun:get-feed-detail": "bun ./scripts/rednote/getFeedDetail.ts",