@skills-store/rednote 0.1.8 → 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, profile lookup, and note interactions such as like, collect, and comment.
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, get-profile, comment, or interact
30
+ 6. home, search, get-feed-detail, get-profile, or interact
31
31
  ```
32
32
 
33
33
  ## Quick start
@@ -39,8 +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 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
+ rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
44
43
  ```
45
44
 
46
45
  ## Commands
@@ -123,23 +122,15 @@ rednote get-profile --instance seller-main --id USER_ID
123
122
 
124
123
  Use `get-profile` when you want author or account profile information.
125
124
 
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
125
 
134
126
  ### `interact`
135
127
 
136
128
  ```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 "写得真好"
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 "写得真好"
140
131
  ```
141
132
 
142
- Use `interact` when you want a single entrypoint for like, collect, or comment operations on a note.
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.
143
134
 
144
135
  ## Important flags
145
136
 
@@ -150,8 +141,8 @@ Use `interact` when you want a single entrypoint for like, collect, or comment o
150
141
  - `--keyword` is required for `search`.
151
142
  - `--url` is required for `get-feed-detail`.
152
143
  - `--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`.
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`.
155
146
 
156
147
  ## JSON success shapes
157
148
 
@@ -465,19 +456,6 @@ Use these shapes as the success model when a command returns JSON.
465
456
  }
466
457
  ```
467
458
 
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
459
  `interact`:
482
460
 
483
461
  ```json
package/dist/index.js CHANGED
@@ -20,8 +20,7 @@ Commands:
20
20
  check-login [--instance NAME]
21
21
  login [--instance NAME]
22
22
  publish [--instance NAME]
23
- comment [--instance NAME] --url URL --content TEXT
24
- interact [--instance NAME] --url URL --action like|collect|comment [--content TEXT]
23
+ interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
25
24
  home [--instance NAME] [--format md|json] [--save [PATH]]
26
25
  search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
27
26
  get-feed-detail [--instance NAME] --url URL [--format md|json]
@@ -33,8 +32,7 @@ Examples:
33
32
  npx -y @skills-store/rednote browser connect --instance seller-main
34
33
  npx -y @skills-store/rednote env
35
34
  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
35
+ npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
38
36
  npx -y @skills-store/rednote search --instance seller-main --keyword 护肤
39
37
  `);
40
38
  }
@@ -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
  }
@@ -234,8 +248,9 @@ export async function getFeedDetails(session, urls) {
234
248
  const page = await getOrCreateXiaohongshuPage(session);
235
249
  const items = [];
236
250
  for (const url of urls){
237
- validateFeedDetailUrl(url);
238
- items.push(await captureFeedDetail(page, url));
251
+ const normalizedUrl = normalizeFeedDetailUrl(url);
252
+ validateFeedDetailUrl(normalizedUrl);
253
+ items.push(await captureFeedDetail(page, normalizedUrl));
239
254
  }
240
255
  return {
241
256
  ok: true,
@@ -11,8 +11,7 @@ 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
+ interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
16
15
  home [--instance NAME] [--format md|json] [--save [PATH]]
17
16
  search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
18
17
  get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
@@ -25,8 +24,7 @@ Examples:
25
24
  npx -y @skills-store/rednote status --instance seller-main
26
25
  npx -y @skills-store/rednote login --instance seller-main
27
26
  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
27
+ npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
30
28
  npx -y @skills-store/rednote home --instance seller-main --format md --save
31
29
  npx -y @skills-store/rednote search --instance seller-main --keyword 护肤 --format json --save ./output/search.jsonl
32
30
  npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
@@ -103,11 +101,6 @@ export async function runRednoteCli(argv = process.argv.slice(2)) {
103
101
  await runPublishCommand(parsePublishCliArgs(commandArgv));
104
102
  return;
105
103
  }
106
- if (command === 'comment') {
107
- const { parseCommentCliArgs, runCommentCommand } = await import('./comment.js');
108
- await runCommentCommand(parseCommentCliArgs(commandArgv));
109
- return;
110
- }
111
104
  if (command === 'interact') {
112
105
  const { parseInteractCliArgs, runInteractCommand } = await import('./interact.js');
113
106
  await runInteractCommand(parseInteractCliArgs(commandArgv));
@@ -3,24 +3,27 @@ import { parseArgs } from 'node:util';
3
3
  import { printJson, runCli } from '../utils/browser-cli.js';
4
4
  import { resolveStatusTarget } from './status.js';
5
5
  import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
6
- import { commentOnFeed } from './comment.js';
7
6
  import { getFeedDetails } from './getFeedDetail.js';
8
7
  const INTERACT_CONTAINER_SELECTOR = '.interact-container';
9
8
  const LIKE_WRAPPER_SELECTOR = `${INTERACT_CONTAINER_SELECTOR} .like-wrapper`;
10
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 = '发送';
11
13
  function printInteractHelp() {
12
14
  process.stdout.write(`rednote interact
13
15
 
14
16
  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]
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]
18
20
 
19
21
  Options:
20
22
  --instance NAME Optional. Defaults to the saved lastConnect instance
21
23
  --url URL Required. Xiaohongshu explore url
22
- --action ACTION Required. like | collect | comment
23
- --content TEXT Required only when --action comment
24
+ --like Optional. Perform like
25
+ --collect Optional. Perform collect
26
+ --comment TEXT Optional. Post comment content
24
27
  -h, --help Show this help
25
28
  `);
26
29
  }
@@ -36,10 +39,13 @@ export function parseInteractCliArgs(argv) {
36
39
  url: {
37
40
  type: 'string'
38
41
  },
39
- action: {
40
- type: 'string'
42
+ like: {
43
+ type: 'boolean'
44
+ },
45
+ collect: {
46
+ type: 'boolean'
41
47
  },
42
- content: {
48
+ comment: {
43
49
  type: 'string'
44
50
  },
45
51
  help: {
@@ -54,8 +60,9 @@ export function parseInteractCliArgs(argv) {
54
60
  return {
55
61
  instance: values.instance,
56
62
  url: values.url,
57
- action: values.action,
58
- content: values.content,
63
+ like: values.like,
64
+ collect: values.collect,
65
+ comment: values.comment,
59
66
  help: values.help
60
67
  };
61
68
  }
@@ -82,18 +89,25 @@ function validateFeedDetailUrl(url) {
82
89
  throw error;
83
90
  }
84
91
  }
85
- function resolveInteractAction(action) {
86
- const normalized = action?.trim().toLowerCase();
87
- if (!normalized) {
88
- throw new Error('Missing required option: --action');
92
+ function resolveInteractActions(values) {
93
+ const actions = [];
94
+ if (values.like) {
95
+ actions.push('like');
96
+ }
97
+ if (values.collect) {
98
+ actions.push('collect');
89
99
  }
90
- if (normalized === 'like' || normalized === 'collect' || normalized === 'comment') {
91
- return normalized;
100
+ const commentContent = values.comment?.trim();
101
+ if (commentContent) {
102
+ actions.push('comment');
92
103
  }
93
- if (normalized === 'favorite') {
94
- return 'collect';
104
+ if (actions.length === 0) {
105
+ throw new Error('At least one interact option is required: --like, --collect, or --comment');
95
106
  }
96
- throw new Error(`Invalid --action value: ${String(action)}. Expected like | collect | comment`);
107
+ return {
108
+ actions,
109
+ commentContent
110
+ };
97
111
  }
98
112
  async function getOrCreateXiaohongshuPage(session) {
99
113
  return session.page;
@@ -119,6 +133,68 @@ async function requireVisibleLocator(locator, errorMessage, timeoutMs = 5_000) {
119
133
  }
120
134
  return visibleLocator;
121
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
+ }
122
198
  async function waitForInteractContainer(page) {
123
199
  await page.waitForLoadState('domcontentloaded');
124
200
  await page.waitForTimeout(500);
@@ -166,15 +242,7 @@ async function ensureActionApplied(page, action, alreadyActive) {
166
242
  });
167
243
  return false;
168
244
  }
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
- }
245
+ export async function interactWithFeed(session, url, actions, commentContent) {
178
246
  validateFeedDetailUrl(url);
179
247
  const detailResult = await getFeedDetails(session, [
180
248
  url
@@ -183,14 +251,33 @@ export async function interactWithFeed(session, url, action, content) {
183
251
  if (!detailItem) {
184
252
  throw new Error(`Failed to load feed detail: ${url}`);
185
253
  }
186
- const alreadyActive = action === 'like' ? detailItem.note.interactInfo.liked === true : detailItem.note.interactInfo.collected === true;
187
254
  const page = await getOrCreateXiaohongshuPage(session);
188
255
  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}`;
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
+ }
191
278
  return {
192
279
  ok: true,
193
- message
280
+ message: `${messages.join('; ')}: ${url}`
194
281
  };
195
282
  }
196
283
  export async function runInteractCommand(values = {}) {
@@ -199,12 +286,12 @@ export async function runInteractCommand(values = {}) {
199
286
  return;
200
287
  }
201
288
  const url = ensureNonEmpty(values.url, '--url');
202
- const action = resolveInteractAction(values.action);
289
+ const { actions, commentContent } = resolveInteractActions(values);
203
290
  const target = resolveStatusTarget(values.instance);
204
291
  const session = await createRednoteSession(target);
205
292
  try {
206
- await ensureRednoteLoggedIn(target, `performing ${action} interact`, session);
207
- const result = await interactWithFeed(session, url, action, values.content);
293
+ await ensureRednoteLoggedIn(target, `performing ${actions.join(', ')} interact`, session);
294
+ const result = await interactWithFeed(session, url, actions, commentContent);
208
295
  printJson(result);
209
296
  } finally{
210
297
  await disconnectRednoteSession(session);
File without changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skills-store/rednote",
3
- "version": "0.1.8",
3
+ "version": "0.1.9",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -16,7 +16,6 @@
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
19
  "interact": "node --experimental-strip-types ./scripts/rednote/interact.ts",
21
20
  "home": "node --experimental-strip-types ./scripts/rednote/home.ts",
22
21
  "search": "node --experimental-strip-types ./scripts/rednote/search.ts",
@@ -29,7 +28,6 @@
29
28
  "bun:check-login": "bun ./scripts/rednote/checkLogin.ts",
30
29
  "bun:login": "bun ./scripts/rednote/login.ts",
31
30
  "bun:publish": "bun ./scripts/rednote/publish.ts",
32
- "bun:comment": "bun ./scripts/rednote/comment.ts",
33
31
  "bun:interact": "bun ./scripts/rednote/interact.ts",
34
32
  "bun:home": "bun ./scripts/rednote/home.ts",
35
33
  "bun:search": "bun ./scripts/rednote/search.ts",
@@ -1,200 +0,0 @@
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);