@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 +8 -30
- package/dist/index.js +2 -4
- package/dist/rednote/getFeedDetail.js +17 -2
- package/dist/rednote/index.js +2 -9
- package/dist/rednote/interact.js +123 -36
- package/dist/rednote/mentions.js +0 -0
- package/package.json +1 -3
- package/dist/rednote/comment.js +0 -200
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
|
|
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,
|
|
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
|
|
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" --
|
|
138
|
-
rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --
|
|
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
|
|
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`
|
|
154
|
-
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
238
|
-
|
|
251
|
+
const normalizedUrl = normalizeFeedDetailUrl(url);
|
|
252
|
+
validateFeedDetailUrl(normalizedUrl);
|
|
253
|
+
items.push(await captureFeedDetail(page, normalizedUrl));
|
|
239
254
|
}
|
|
240
255
|
return {
|
|
241
256
|
ok: true,
|
package/dist/rednote/index.js
CHANGED
|
@@ -11,8 +11,7 @@ Commands:
|
|
|
11
11
|
check-login [--instance NAME]
|
|
12
12
|
login [--instance NAME]
|
|
13
13
|
publish [--instance NAME]
|
|
14
|
-
|
|
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
|
|
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));
|
package/dist/rednote/interact.js
CHANGED
|
@@ -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 --
|
|
16
|
-
node --experimental-strip-types ./scripts/rednote/interact.ts --instance NAME --url URL --
|
|
17
|
-
bun ./scripts/rednote/interact.ts --instance NAME --url URL --
|
|
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
|
-
--
|
|
23
|
-
--
|
|
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
|
-
|
|
40
|
-
type: '
|
|
42
|
+
like: {
|
|
43
|
+
type: 'boolean'
|
|
44
|
+
},
|
|
45
|
+
collect: {
|
|
46
|
+
type: 'boolean'
|
|
41
47
|
},
|
|
42
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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
|
|
86
|
-
const
|
|
87
|
-
if (
|
|
88
|
-
|
|
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
|
-
|
|
91
|
-
|
|
100
|
+
const commentContent = values.comment?.trim();
|
|
101
|
+
if (commentContent) {
|
|
102
|
+
actions.push('comment');
|
|
92
103
|
}
|
|
93
|
-
if (
|
|
94
|
-
|
|
104
|
+
if (actions.length === 0) {
|
|
105
|
+
throw new Error('At least one interact option is required: --like, --collect, or --comment');
|
|
95
106
|
}
|
|
96
|
-
|
|
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,
|
|
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
|
-
|
|
190
|
-
|
|
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
|
|
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 ${
|
|
207
|
-
const result = await interactWithFeed(session, url,
|
|
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.
|
|
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",
|
package/dist/rednote/comment.js
DELETED
|
@@ -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);
|