@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 +358 -2
- package/dist/index.js +14 -0
- package/dist/rednote/checkLogin.js +3 -3
- package/dist/rednote/comment.js +200 -0
- package/dist/rednote/getFeedDetail.js +47 -34
- package/dist/rednote/getProfile.js +1 -1
- package/dist/rednote/home.js +4 -4
- package/dist/rednote/index.js +14 -0
- package/dist/rednote/interact.js +217 -0
- package/dist/rednote/login.js +1 -1
- package/dist/rednote/publish.js +1 -1
- package/dist/rednote/search.js +1 -1
- package/package.json +5 -1
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
|
|
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,
|
|
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.
|
|
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
|
-
|
|
129
|
-
|
|
130
|
-
lines.push(
|
|
131
|
-
lines.push(
|
|
132
|
-
lines.push(
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
lines.push(
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
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('
|
|
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() {
|
package/dist/rednote/home.js
CHANGED
|
@@ -99,7 +99,7 @@ async function collectHomeFeedItems(page) {
|
|
|
99
99
|
return;
|
|
100
100
|
}
|
|
101
101
|
const url = new URL(response.url());
|
|
102
|
-
if (
|
|
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()
|
|
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() {
|
package/dist/rednote/index.js
CHANGED
|
@@ -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);
|
package/dist/rednote/login.js
CHANGED
|
@@ -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() {
|
package/dist/rednote/publish.js
CHANGED
|
@@ -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() {
|
package/dist/rednote/search.js
CHANGED
|
@@ -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.
|
|
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",
|