@skills-store/rednote 0.1.7 → 0.1.8

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