@skills-store/rednote 0.1.13 → 0.1.15
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 +45 -339
- package/dist/index.js +2 -2
- package/dist/rednote/env.js +1 -0
- package/dist/rednote/getFeedDetail.js +74 -30
- package/dist/rednote/getProfile.js +3 -2
- package/dist/rednote/home.js +39 -14
- package/dist/rednote/index.js +2 -2
- package/dist/rednote/interact.js +30 -7
- package/dist/rednote/output-format.js +10 -0
- package/dist/rednote/persistence.js +578 -0
- package/dist/rednote/search.js +41 -14
- package/dist/rednote/url-format.js +41 -0
- package/dist/utils/browser-core.js +2 -0
- package/dist/utils/mouse-helper.js +105 -0
- package/package.json +7 -2
package/README.md
CHANGED
|
@@ -36,10 +36,10 @@ For most tasks, run commands in this order:
|
|
|
36
36
|
rednote env
|
|
37
37
|
rednote browser create --name seller-main --browser chrome --port 9222
|
|
38
38
|
rednote browser connect --instance seller-main
|
|
39
|
-
rednote login
|
|
40
|
-
rednote status
|
|
41
|
-
rednote search --
|
|
42
|
-
rednote interact --
|
|
39
|
+
rednote login
|
|
40
|
+
rednote status
|
|
41
|
+
rednote search --keyword 护肤
|
|
42
|
+
rednote interact --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "写得真好"
|
|
43
43
|
```
|
|
44
44
|
|
|
45
45
|
## Commands
|
|
@@ -68,7 +68,7 @@ Use `env` first when checking installation, runtime info, or storage paths.
|
|
|
68
68
|
### `status`
|
|
69
69
|
|
|
70
70
|
```bash
|
|
71
|
-
rednote status
|
|
71
|
+
rednote status
|
|
72
72
|
```
|
|
73
73
|
|
|
74
74
|
Use `status` to confirm whether an instance exists, is running, and appears logged in.
|
|
@@ -76,7 +76,7 @@ Use `status` to confirm whether an instance exists, is running, and appears logg
|
|
|
76
76
|
### `check-login`
|
|
77
77
|
|
|
78
78
|
```bash
|
|
79
|
-
rednote check-login
|
|
79
|
+
rednote check-login
|
|
80
80
|
```
|
|
81
81
|
|
|
82
82
|
Use `check-login` when you only want to verify whether the session is still valid.
|
|
@@ -84,7 +84,7 @@ Use `check-login` when you only want to verify whether the session is still vali
|
|
|
84
84
|
### `login`
|
|
85
85
|
|
|
86
86
|
```bash
|
|
87
|
-
rednote login
|
|
87
|
+
rednote login
|
|
88
88
|
```
|
|
89
89
|
|
|
90
90
|
Use `login` after `browser connect` if the instance is not authenticated yet.
|
|
@@ -92,32 +92,59 @@ Use `login` after `browser connect` if the instance is not authenticated yet.
|
|
|
92
92
|
### `home`
|
|
93
93
|
|
|
94
94
|
```bash
|
|
95
|
-
rednote home --
|
|
95
|
+
rednote home --format md --save
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
Use `home` when you want the current home feed and optionally want to save it to disk.
|
|
99
99
|
|
|
100
|
+
The terminal output always uses the compact summary format below, even when `--format json` is selected:
|
|
101
|
+
|
|
102
|
+
```text
|
|
103
|
+
id=<database nanoid>
|
|
104
|
+
title=<post title>
|
|
105
|
+
like=<liked count>
|
|
106
|
+
|
|
107
|
+
id=...
|
|
108
|
+
title=...
|
|
109
|
+
like=...
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
Captured home feed posts are upserted into `~/.skills-router/rednote/main.db` (the same path returned by `rednote env`). They are stored in the `rednote_posts` table, and the printed `id` is that table's `nanoid(16)` primary key.
|
|
113
|
+
|
|
100
114
|
### `search`
|
|
101
115
|
|
|
102
116
|
```bash
|
|
103
|
-
rednote search --
|
|
104
|
-
rednote search --
|
|
117
|
+
rednote search --keyword 护肤
|
|
118
|
+
rednote search --keyword 护肤 --format json --save ./output/search.jsonl
|
|
105
119
|
```
|
|
106
120
|
|
|
107
121
|
Use `search` for keyword-based note lookup.
|
|
108
122
|
|
|
123
|
+
The terminal output always uses the compact summary format below, even when `--format json` is selected:
|
|
124
|
+
|
|
125
|
+
```text
|
|
126
|
+
id=<database nanoid>
|
|
127
|
+
title=<post title>
|
|
128
|
+
like=<liked count>
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
Captured search results are also upserted into `~/.skills-router/rednote/main.db` in the `rednote_posts` table. The printed `id` can be passed directly to `get-feed-detail --id`.
|
|
132
|
+
|
|
109
133
|
### `get-feed-detail`
|
|
110
134
|
|
|
111
135
|
```bash
|
|
112
|
-
rednote get-feed-detail --
|
|
136
|
+
rednote get-feed-detail --id <nanoid>
|
|
137
|
+
rednote get-feed-detail --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
|
|
113
138
|
```
|
|
114
139
|
|
|
115
|
-
Use `get-feed-detail` when you already have a Xiaohongshu note URL.
|
|
140
|
+
Use `get-feed-detail` when you already have a Xiaohongshu note URL, or when you have a database `id` returned by `home` or `search`. With `--id`, the CLI looks up the saved URL from `~/.skills-router/rednote/main.db` and then navigates with that raw URL.
|
|
141
|
+
|
|
142
|
+
Captured note details and comments are also upserted into `~/.skills-router/rednote/main.db` in `rednote_post_details` and `rednote_post_comments`.
|
|
116
143
|
|
|
117
144
|
### `get-profile`
|
|
118
145
|
|
|
119
146
|
```bash
|
|
120
|
-
rednote get-profile --
|
|
147
|
+
rednote get-profile --id USER_ID
|
|
121
148
|
```
|
|
122
149
|
|
|
123
150
|
Use `get-profile` when you want author or account profile information.
|
|
@@ -126,8 +153,8 @@ Use `get-profile` when you want author or account profile information.
|
|
|
126
153
|
### `interact`
|
|
127
154
|
|
|
128
155
|
```bash
|
|
129
|
-
rednote interact --
|
|
130
|
-
rednote interact --
|
|
156
|
+
rednote interact --id <nanoid> --like --collect
|
|
157
|
+
rednote interact --id <nanoid> --like --collect --comment "写得真好"
|
|
131
158
|
```
|
|
132
159
|
|
|
133
160
|
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.
|
|
@@ -137,335 +164,14 @@ Use `interact` when you want the single entrypoint for note operations such as l
|
|
|
137
164
|
- `--instance NAME` selects the browser instance for account-scoped commands.
|
|
138
165
|
- `--format json` is best for scripting.
|
|
139
166
|
- `--format md` is best for direct reading.
|
|
140
|
-
- `--save` is useful for `home` and `search` when you want
|
|
167
|
+
- `--save` is useful for `home` and `search` when you want the raw post array written to disk.
|
|
141
168
|
- `--keyword` is required for `search`.
|
|
142
|
-
- `--
|
|
169
|
+
- `home` and `search` always print `id/title/like` summaries to stdout; `--format json` only changes the saved file payload.
|
|
170
|
+
- `get-feed-detail` accepts either `--url URL` or `--id ID`.
|
|
143
171
|
- `--id` is required for `get-profile`.
|
|
144
172
|
- `--url` is required for `interact`; at least one of `--like`, `--collect`, or `--comment TEXT` must be provided.
|
|
145
173
|
- replies are sent with `interact --comment TEXT`.
|
|
146
174
|
|
|
147
|
-
## JSON success shapes
|
|
148
|
-
|
|
149
|
-
Use these shapes as the success model when a command returns JSON.
|
|
150
|
-
|
|
151
|
-
### Shared note item
|
|
152
|
-
|
|
153
|
-
`home`, `search`, and `profile.notes` share the normalized `RednotePost` shape:
|
|
154
|
-
|
|
155
|
-
```json
|
|
156
|
-
{
|
|
157
|
-
"id": "string",
|
|
158
|
-
"modelType": "string",
|
|
159
|
-
"xsecToken": "string|null",
|
|
160
|
-
"url": "string",
|
|
161
|
-
"noteCard": {
|
|
162
|
-
"type": "string|null",
|
|
163
|
-
"displayTitle": "string|null",
|
|
164
|
-
"cover": {
|
|
165
|
-
"urlDefault": "string|null",
|
|
166
|
-
"urlPre": "string|null",
|
|
167
|
-
"url": "string|null",
|
|
168
|
-
"fileId": "string|null",
|
|
169
|
-
"width": "number|null",
|
|
170
|
-
"height": "number|null",
|
|
171
|
-
"infoList": [{ "imageScene": "string|null", "url": "string|null" }]
|
|
172
|
-
},
|
|
173
|
-
"user": {
|
|
174
|
-
"userId": "string|null",
|
|
175
|
-
"nickname": "string|null",
|
|
176
|
-
"nickName": "string|null",
|
|
177
|
-
"avatar": "string|null",
|
|
178
|
-
"xsecToken": "string|null"
|
|
179
|
-
},
|
|
180
|
-
"interactInfo": {
|
|
181
|
-
"liked": "boolean",
|
|
182
|
-
"likedCount": "string|null",
|
|
183
|
-
"commentCount": "string|null",
|
|
184
|
-
"collectedCount": "string|null",
|
|
185
|
-
"sharedCount": "string|null"
|
|
186
|
-
},
|
|
187
|
-
"cornerTagInfo": [{ "type": "string|null", "text": "string|null" }],
|
|
188
|
-
"imageList": [{ "width": "number|null", "height": "number|null", "infoList": [{ "imageScene": "string|null", "url": "string|null" }] }],
|
|
189
|
-
"video": { "duration": "number|null" }
|
|
190
|
-
}
|
|
191
|
-
}
|
|
192
|
-
```
|
|
193
|
-
|
|
194
|
-
### `env --format json`
|
|
195
|
-
|
|
196
|
-
`env` is the main exception: it returns a raw environment object instead of `{ "ok": true, ... }`.
|
|
197
|
-
|
|
198
|
-
```json
|
|
199
|
-
{
|
|
200
|
-
"packageRoot": "string",
|
|
201
|
-
"homeDir": "string",
|
|
202
|
-
"platform": "string",
|
|
203
|
-
"nodeVersion": "string",
|
|
204
|
-
"storageHome": "string",
|
|
205
|
-
"storageRoot": "string",
|
|
206
|
-
"instancesDir": "string",
|
|
207
|
-
"instanceStorePath": "string",
|
|
208
|
-
"legacyPackageInstancesDir": "string"
|
|
209
|
-
}
|
|
210
|
-
```
|
|
211
|
-
|
|
212
|
-
### Browser commands
|
|
213
|
-
|
|
214
|
-
`browser list`:
|
|
215
|
-
|
|
216
|
-
```json
|
|
217
|
-
{
|
|
218
|
-
"lastConnect": { "scope": "default|custom", "name": "string", "browser": "chrome|edge|chromium|brave" } | null,
|
|
219
|
-
"instances": [{
|
|
220
|
-
"type": "chrome|edge|chromium|brave",
|
|
221
|
-
"name": "string",
|
|
222
|
-
"executablePath": "string",
|
|
223
|
-
"userDataDir": "string",
|
|
224
|
-
"exists": true,
|
|
225
|
-
"inUse": false,
|
|
226
|
-
"pid": "number|null",
|
|
227
|
-
"lockFiles": ["string"],
|
|
228
|
-
"matchedProcess": { "pid": "number", "name": "string", "cmdline": "string" } | null,
|
|
229
|
-
"staleLock": false,
|
|
230
|
-
"remotePort": "number|null",
|
|
231
|
-
"scope": "default|custom",
|
|
232
|
-
"instanceName": "string",
|
|
233
|
-
"createdAt": "string|null",
|
|
234
|
-
"lastConnect": false
|
|
235
|
-
}]
|
|
236
|
-
}
|
|
237
|
-
```
|
|
238
|
-
|
|
239
|
-
`browser create`:
|
|
240
|
-
|
|
241
|
-
```json
|
|
242
|
-
{
|
|
243
|
-
"ok": true,
|
|
244
|
-
"instance": {
|
|
245
|
-
"name": "string",
|
|
246
|
-
"browser": "chrome|edge|chromium|brave",
|
|
247
|
-
"userDataDir": "string",
|
|
248
|
-
"createdAt": "string",
|
|
249
|
-
"remoteDebuggingPort": "number|undefined"
|
|
250
|
-
}
|
|
251
|
-
}
|
|
252
|
-
```
|
|
253
|
-
|
|
254
|
-
`browser connect`:
|
|
255
|
-
|
|
256
|
-
```json
|
|
257
|
-
{
|
|
258
|
-
"ok": true,
|
|
259
|
-
"type": "chrome|edge|chromium|brave",
|
|
260
|
-
"executablePath": "string",
|
|
261
|
-
"userDataDir": "string",
|
|
262
|
-
"remoteDebuggingPort": "number",
|
|
263
|
-
"wsUrl": "string",
|
|
264
|
-
"pid": "number|null"
|
|
265
|
-
}
|
|
266
|
-
```
|
|
267
|
-
|
|
268
|
-
`browser remove`:
|
|
269
|
-
|
|
270
|
-
```json
|
|
271
|
-
{
|
|
272
|
-
"ok": true,
|
|
273
|
-
"removedInstance": {
|
|
274
|
-
"name": "string",
|
|
275
|
-
"browser": "chrome|edge|chromium|brave",
|
|
276
|
-
"userDataDir": "string",
|
|
277
|
-
"createdAt": "string",
|
|
278
|
-
"remoteDebuggingPort": "number|undefined"
|
|
279
|
-
},
|
|
280
|
-
"removedDir": true,
|
|
281
|
-
"closedPids": ["number"]
|
|
282
|
-
}
|
|
283
|
-
```
|
|
284
|
-
|
|
285
|
-
### Session and account commands
|
|
286
|
-
|
|
287
|
-
`status`:
|
|
288
|
-
|
|
289
|
-
```json
|
|
290
|
-
{
|
|
291
|
-
"ok": true,
|
|
292
|
-
"instance": {
|
|
293
|
-
"scope": "default|custom",
|
|
294
|
-
"name": "string",
|
|
295
|
-
"browser": "chrome|edge|chromium|brave",
|
|
296
|
-
"source": "argument|last-connect|single-instance",
|
|
297
|
-
"status": "running|stopped|missing|stale-lock",
|
|
298
|
-
"exists": true,
|
|
299
|
-
"inUse": false,
|
|
300
|
-
"pid": "number|null",
|
|
301
|
-
"remotePort": "number|null",
|
|
302
|
-
"userDataDir": "string",
|
|
303
|
-
"createdAt": "string|null",
|
|
304
|
-
"lastConnect": false
|
|
305
|
-
},
|
|
306
|
-
"rednote": {
|
|
307
|
-
"loginStatus": "logged-in|logged-out|unknown",
|
|
308
|
-
"lastLoginAt": "string|null"
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
```
|
|
312
|
-
|
|
313
|
-
`check-login`:
|
|
314
|
-
|
|
315
|
-
```json
|
|
316
|
-
{
|
|
317
|
-
"ok": true,
|
|
318
|
-
"rednote": {
|
|
319
|
-
"loginStatus": "logged-in|logged-out|unknown",
|
|
320
|
-
"lastLoginAt": "string|null",
|
|
321
|
-
"needLogin": false,
|
|
322
|
-
"checkedAt": "string"
|
|
323
|
-
}
|
|
324
|
-
}
|
|
325
|
-
```
|
|
326
|
-
|
|
327
|
-
`login`:
|
|
328
|
-
|
|
329
|
-
```json
|
|
330
|
-
{
|
|
331
|
-
"ok": true,
|
|
332
|
-
"rednote": {
|
|
333
|
-
"loginClicked": true,
|
|
334
|
-
"pageUrl": "string",
|
|
335
|
-
"waitingForPhoneLogin": true,
|
|
336
|
-
"qrCodePath": "string|null",
|
|
337
|
-
"message": "string"
|
|
338
|
-
}
|
|
339
|
-
}
|
|
340
|
-
```
|
|
341
|
-
|
|
342
|
-
### Feed and profile commands
|
|
343
|
-
|
|
344
|
-
`home --format json`:
|
|
345
|
-
|
|
346
|
-
```json
|
|
347
|
-
{
|
|
348
|
-
"ok": true,
|
|
349
|
-
"home": {
|
|
350
|
-
"pageUrl": "string",
|
|
351
|
-
"fetchedAt": "string",
|
|
352
|
-
"total": "number",
|
|
353
|
-
"posts": ["RednotePost"],
|
|
354
|
-
"savedPath": "string|undefined"
|
|
355
|
-
}
|
|
356
|
-
}
|
|
357
|
-
```
|
|
358
|
-
|
|
359
|
-
`search --format json`:
|
|
360
|
-
|
|
361
|
-
```json
|
|
362
|
-
{
|
|
363
|
-
"ok": true,
|
|
364
|
-
"search": {
|
|
365
|
-
"keyword": "string",
|
|
366
|
-
"pageUrl": "string",
|
|
367
|
-
"fetchedAt": "string",
|
|
368
|
-
"total": "number",
|
|
369
|
-
"posts": ["RednotePost"],
|
|
370
|
-
"savedPath": "string|undefined"
|
|
371
|
-
}
|
|
372
|
-
}
|
|
373
|
-
```
|
|
374
|
-
|
|
375
|
-
`get-feed-detail --format json`:
|
|
376
|
-
|
|
377
|
-
```json
|
|
378
|
-
{
|
|
379
|
-
"ok": true,
|
|
380
|
-
"detail": {
|
|
381
|
-
"fetchedAt": "string",
|
|
382
|
-
"total": "number",
|
|
383
|
-
"items": [{
|
|
384
|
-
"url": "string",
|
|
385
|
-
"note": {
|
|
386
|
-
"noteId": "string|null",
|
|
387
|
-
"title": "string|null",
|
|
388
|
-
"desc": "string|null",
|
|
389
|
-
"type": "string|null",
|
|
390
|
-
"interactInfo": {
|
|
391
|
-
"liked": "boolean|null",
|
|
392
|
-
"likedCount": "string|null",
|
|
393
|
-
"commentCount": "string|null",
|
|
394
|
-
"collected": "boolean|null",
|
|
395
|
-
"collectedCount": "string|null",
|
|
396
|
-
"shareCount": "string|null",
|
|
397
|
-
"followed": "boolean|null"
|
|
398
|
-
},
|
|
399
|
-
"tagList": [{ "name": "string|null" }],
|
|
400
|
-
"imageList": [{ "urlDefault": "string|null", "urlPre": "string|null", "width": "number|null", "height": "number|null" }],
|
|
401
|
-
"video": { "url": "string|null", "raw": "unknown" } | null,
|
|
402
|
-
"raw": "unknown"
|
|
403
|
-
},
|
|
404
|
-
"comments": [{
|
|
405
|
-
"id": "string|null",
|
|
406
|
-
"content": "string|null",
|
|
407
|
-
"userId": "string|null",
|
|
408
|
-
"nickname": "string|null",
|
|
409
|
-
"likedCount": "string|null",
|
|
410
|
-
"subCommentCount": "number|null",
|
|
411
|
-
"raw": "unknown"
|
|
412
|
-
}]
|
|
413
|
-
}]
|
|
414
|
-
}
|
|
415
|
-
}
|
|
416
|
-
```
|
|
417
|
-
|
|
418
|
-
`get-profile --format json`:
|
|
419
|
-
|
|
420
|
-
```json
|
|
421
|
-
{
|
|
422
|
-
"ok": true,
|
|
423
|
-
"profile": {
|
|
424
|
-
"userId": "string",
|
|
425
|
-
"url": "string",
|
|
426
|
-
"fetchedAt": "string",
|
|
427
|
-
"user": {
|
|
428
|
-
"userId": "string|null",
|
|
429
|
-
"nickname": "string|null",
|
|
430
|
-
"desc": "string|null",
|
|
431
|
-
"avatar": "string|null",
|
|
432
|
-
"ipLocation": "string|null",
|
|
433
|
-
"gender": "string|null",
|
|
434
|
-
"follows": "string|number|null",
|
|
435
|
-
"fans": "string|number|null",
|
|
436
|
-
"interaction": "string|number|null",
|
|
437
|
-
"tags": ["string"],
|
|
438
|
-
"raw": "unknown"
|
|
439
|
-
},
|
|
440
|
-
"notes": ["RednotePost"],
|
|
441
|
-
"raw": {
|
|
442
|
-
"userPageData": "unknown",
|
|
443
|
-
"notes": "unknown"
|
|
444
|
-
}
|
|
445
|
-
}
|
|
446
|
-
}
|
|
447
|
-
```
|
|
448
|
-
|
|
449
|
-
### Action commands
|
|
450
|
-
|
|
451
|
-
`publish`:
|
|
452
|
-
|
|
453
|
-
```json
|
|
454
|
-
{
|
|
455
|
-
"ok": true,
|
|
456
|
-
"message": "string"
|
|
457
|
-
}
|
|
458
|
-
```
|
|
459
|
-
|
|
460
|
-
`interact`:
|
|
461
|
-
|
|
462
|
-
```json
|
|
463
|
-
{
|
|
464
|
-
"ok": true,
|
|
465
|
-
"message": "string"
|
|
466
|
-
}
|
|
467
|
-
```
|
|
468
|
-
|
|
469
175
|
## Storage
|
|
470
176
|
|
|
471
177
|
The CLI stores browser instances and metadata under:
|
package/dist/index.js
CHANGED
|
@@ -20,7 +20,7 @@ Commands:
|
|
|
20
20
|
check-login [--instance NAME]
|
|
21
21
|
login [--instance NAME]
|
|
22
22
|
publish [--instance NAME]
|
|
23
|
-
interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
|
|
23
|
+
interact [--instance NAME] [--id ID | --url URL] [--like] [--collect] [--comment TEXT]
|
|
24
24
|
home [--instance NAME] [--format md|json] [--save [PATH]]
|
|
25
25
|
search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
|
|
26
26
|
get-feed-detail [--instance NAME] --url URL [--format md|json]
|
|
@@ -32,7 +32,7 @@ Examples:
|
|
|
32
32
|
npx -y @skills-store/rednote browser connect --instance seller-main
|
|
33
33
|
npx -y @skills-store/rednote env
|
|
34
34
|
npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title 标题 --content 描述
|
|
35
|
-
npx -y @skills-store/rednote interact --instance seller-main --
|
|
35
|
+
npx -y @skills-store/rednote interact --instance seller-main --id NOTE_ID --like --collect --comment "写得真好"
|
|
36
36
|
npx -y @skills-store/rednote search --instance seller-main --keyword 护肤
|
|
37
37
|
`);
|
|
38
38
|
}
|
package/dist/rednote/env.js
CHANGED
|
@@ -28,6 +28,7 @@ function renderEnvironmentMarkdown() {
|
|
|
28
28
|
`- Package Root: ${info.packageRoot}`,
|
|
29
29
|
`- Storage Home: ${info.storageHome}`,
|
|
30
30
|
`- Storage Root: ${info.storageRoot}`,
|
|
31
|
+
`- Database: ${info.databasePath}`,
|
|
31
32
|
`- Instances Dir: ${info.instancesDir}`,
|
|
32
33
|
`- Instance Store: ${info.instanceStorePath}`,
|
|
33
34
|
`- Legacy Package Instances: ${info.legacyPackageInstancesDir}`,
|
|
@@ -3,20 +3,23 @@ import * as cheerio from 'cheerio';
|
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import vm from 'node:vm';
|
|
5
5
|
import { runCli } from '../utils/browser-cli.js';
|
|
6
|
+
import { simulateMouseMove, simulateMousePresence, simulateMouseWheel } from '../utils/mouse-helper.js';
|
|
6
7
|
import { resolveStatusTarget } from './status.js';
|
|
7
8
|
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
8
9
|
import { ensureJsonSavePath, renderJsonSaveSummary, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
10
|
+
import { findPersistedPostUrlByRecordId, initializeRednoteDatabase, persistFeedDetail } from './persistence.js';
|
|
9
11
|
function printGetFeedDetailHelp() {
|
|
10
12
|
process.stdout.write(`rednote get-feed-detail
|
|
11
13
|
|
|
12
14
|
Usage:
|
|
13
|
-
npx -y @skills-store/rednote get-feed-detail [--instance NAME] --url URL [--url URL] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
14
|
-
node --experimental-strip-types ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
15
|
-
bun ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
15
|
+
npx -y @skills-store/rednote get-feed-detail [--instance NAME] [--url URL] [--url URL] [--id ID] [--id ID] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
16
|
+
node --experimental-strip-types ./scripts/rednote/getFeedDetail.ts --instance NAME [--url URL] [--url URL] [--id ID] [--id ID] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
17
|
+
bun ./scripts/rednote/getFeedDetail.ts --instance NAME [--url URL] [--url URL] [--id ID] [--id ID] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
16
18
|
|
|
17
19
|
Options:
|
|
18
20
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
19
|
-
--url URL
|
|
21
|
+
--url URL Optional. Xiaohongshu explore url, repeatable
|
|
22
|
+
--id ID Optional. Database record id from home/search output, repeatable
|
|
20
23
|
--comments [COUNT] Optional. Include comment data. When COUNT is provided, scroll \`.note-scroller\` until COUNT comments, the end, or timeout
|
|
21
24
|
--format FORMAT Output format: md | json. Default: md
|
|
22
25
|
--save PATH Required when --format json is used. Saves the selected result array as JSON
|
|
@@ -46,6 +49,7 @@ function parseCommentsValue(value) {
|
|
|
46
49
|
export function parseGetFeedDetailCliArgs(argv) {
|
|
47
50
|
const values = {
|
|
48
51
|
urls: [],
|
|
52
|
+
ids: [],
|
|
49
53
|
format: 'md',
|
|
50
54
|
comments: undefined,
|
|
51
55
|
help: false
|
|
@@ -80,6 +84,19 @@ export function parseGetFeedDetailCliArgs(argv) {
|
|
|
80
84
|
index += 1;
|
|
81
85
|
continue;
|
|
82
86
|
}
|
|
87
|
+
if (withEquals?.key === '--id') {
|
|
88
|
+
values.ids.push(withEquals.value);
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (arg === '--id') {
|
|
92
|
+
const nextArg = argv[index + 1];
|
|
93
|
+
if (!nextArg || nextArg.startsWith('-')) {
|
|
94
|
+
throw new Error('Missing required option value: --id');
|
|
95
|
+
}
|
|
96
|
+
values.ids.push(nextArg);
|
|
97
|
+
index += 1;
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
83
100
|
if (withEquals?.key === '--format') {
|
|
84
101
|
values.format = withEquals.value;
|
|
85
102
|
continue;
|
|
@@ -138,20 +155,6 @@ function validateFeedDetailUrl(url) {
|
|
|
138
155
|
throw error;
|
|
139
156
|
}
|
|
140
157
|
}
|
|
141
|
-
function normalizeFeedDetailUrl(url) {
|
|
142
|
-
try {
|
|
143
|
-
const parsed = new URL(url);
|
|
144
|
-
if (!parsed.searchParams.has('xsec_source')) {
|
|
145
|
-
parsed.searchParams.set('xsec_source', 'pc_feed');
|
|
146
|
-
}
|
|
147
|
-
return parsed.toString();
|
|
148
|
-
} catch (error) {
|
|
149
|
-
if (error instanceof TypeError) {
|
|
150
|
-
throw new Error(`url is not valid: ${url}`);
|
|
151
|
-
}
|
|
152
|
-
throw error;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
158
|
async function getOrCreateXiaohongshuPage(session) {
|
|
156
159
|
return session.page;
|
|
157
160
|
}
|
|
@@ -179,7 +182,10 @@ async function scrollCommentsContainer(page, targetCount, getCount) {
|
|
|
179
182
|
return;
|
|
180
183
|
}
|
|
181
184
|
await container.scrollIntoViewIfNeeded().catch(()=>{});
|
|
182
|
-
await
|
|
185
|
+
await simulateMouseMove(page, {
|
|
186
|
+
locator: container,
|
|
187
|
+
settleMs: 100
|
|
188
|
+
}).catch(()=>{});
|
|
183
189
|
const getMetrics = async ()=>await container.evaluate((element)=>{
|
|
184
190
|
const htmlElement = element;
|
|
185
191
|
const atBottom = htmlElement.scrollTop + htmlElement.clientHeight >= htmlElement.scrollHeight - 8;
|
|
@@ -202,8 +208,12 @@ async function scrollCommentsContainer(page, targetCount, getCount) {
|
|
|
202
208
|
}
|
|
203
209
|
const beforeCount = getCount();
|
|
204
210
|
const delta = Math.max(Math.floor(beforeMetrics.clientHeight * 0.85), 480);
|
|
205
|
-
await page
|
|
206
|
-
|
|
211
|
+
await simulateMouseWheel(page, {
|
|
212
|
+
locator: container,
|
|
213
|
+
deltaY: delta,
|
|
214
|
+
moveBeforeScroll: false,
|
|
215
|
+
settleMs: 900
|
|
216
|
+
}).catch(()=>{});
|
|
207
217
|
const afterMetrics = await getMetrics();
|
|
208
218
|
await page.waitForTimeout(400);
|
|
209
219
|
const afterCount = getCount();
|
|
@@ -298,7 +308,7 @@ function renderDetailMarkdown(items, includeComments = false) {
|
|
|
298
308
|
return lines.join('\n');
|
|
299
309
|
}).join('\n\n---\n\n')}\n`;
|
|
300
310
|
}
|
|
301
|
-
async function captureFeedDetail(page, targetUrl, commentsOption = undefined) {
|
|
311
|
+
async function captureFeedDetail(page, targetUrl, commentsOption = undefined, instanceName) {
|
|
302
312
|
const includeComments = hasCommentsEnabled(commentsOption);
|
|
303
313
|
const commentsTarget = typeof commentsOption === 'number' ? commentsOption : null;
|
|
304
314
|
let note = null;
|
|
@@ -342,6 +352,7 @@ async function captureFeedDetail(page, targetUrl, commentsOption = undefined) {
|
|
|
342
352
|
await page.goto(targetUrl, {
|
|
343
353
|
waitUntil: 'domcontentloaded'
|
|
344
354
|
});
|
|
355
|
+
await simulateMousePresence(page);
|
|
345
356
|
const deadline = Date.now() + 15_000;
|
|
346
357
|
while(Date.now() < deadline){
|
|
347
358
|
if (note && commentsLoaded) {
|
|
@@ -355,7 +366,7 @@ async function captureFeedDetail(page, targetUrl, commentsOption = undefined) {
|
|
|
355
366
|
if (includeComments && commentsTarget) {
|
|
356
367
|
await scrollCommentsContainer(page, commentsTarget, ()=>getCommentCount(commentsMap));
|
|
357
368
|
}
|
|
358
|
-
|
|
369
|
+
const item = {
|
|
359
370
|
url: targetUrl,
|
|
360
371
|
note: normalizeDetailNote(note),
|
|
361
372
|
...includeComments ? {
|
|
@@ -364,17 +375,28 @@ async function captureFeedDetail(page, targetUrl, commentsOption = undefined) {
|
|
|
364
375
|
])
|
|
365
376
|
} : {}
|
|
366
377
|
};
|
|
378
|
+
if (instanceName) {
|
|
379
|
+
await persistFeedDetail({
|
|
380
|
+
instanceName,
|
|
381
|
+
url: targetUrl,
|
|
382
|
+
note: item.note,
|
|
383
|
+
rawNote: note,
|
|
384
|
+
rawComments: includeComments ? [
|
|
385
|
+
...commentsMap.values()
|
|
386
|
+
] : []
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
await simulateMousePresence(page);
|
|
390
|
+
return item;
|
|
367
391
|
} finally{
|
|
368
392
|
page.off('response', handleResponse);
|
|
369
393
|
}
|
|
370
394
|
}
|
|
371
|
-
export async function getFeedDetails(session, urls, commentsOption = undefined) {
|
|
395
|
+
export async function getFeedDetails(session, urls, commentsOption = undefined, instanceName) {
|
|
372
396
|
const page = await getOrCreateXiaohongshuPage(session);
|
|
373
397
|
const items = [];
|
|
374
398
|
for (const url of urls){
|
|
375
|
-
|
|
376
|
-
validateFeedDetailUrl(normalizedUrl);
|
|
377
|
-
items.push(await captureFeedDetail(page, normalizedUrl, commentsOption));
|
|
399
|
+
items.push(await captureFeedDetail(page, url, commentsOption, instanceName));
|
|
378
400
|
}
|
|
379
401
|
return {
|
|
380
402
|
ok: true,
|
|
@@ -385,6 +407,25 @@ export async function getFeedDetails(session, urls, commentsOption = undefined)
|
|
|
385
407
|
}
|
|
386
408
|
};
|
|
387
409
|
}
|
|
410
|
+
async function resolveFeedDetailUrls(values, instanceName) {
|
|
411
|
+
const urls = [
|
|
412
|
+
...values.urls
|
|
413
|
+
];
|
|
414
|
+
if (values.ids.length === 0) {
|
|
415
|
+
return urls;
|
|
416
|
+
}
|
|
417
|
+
if (!instanceName) {
|
|
418
|
+
throw new Error('The --id option requires an instance-backed session.');
|
|
419
|
+
}
|
|
420
|
+
for (const id of values.ids){
|
|
421
|
+
const url = await findPersistedPostUrlByRecordId(instanceName, id);
|
|
422
|
+
if (!url) {
|
|
423
|
+
throw new Error(`No saved post url found for id: ${id}`);
|
|
424
|
+
}
|
|
425
|
+
urls.push(url);
|
|
426
|
+
}
|
|
427
|
+
return urls;
|
|
428
|
+
}
|
|
388
429
|
function selectFeedDetailOutput(result) {
|
|
389
430
|
return result.detail.items;
|
|
390
431
|
}
|
|
@@ -400,6 +441,7 @@ function writeFeedDetailOutput(result, values) {
|
|
|
400
441
|
}
|
|
401
442
|
export async function runGetFeedDetailCommand(values = {
|
|
402
443
|
urls: [],
|
|
444
|
+
ids: [],
|
|
403
445
|
format: 'md'
|
|
404
446
|
}) {
|
|
405
447
|
if (values.help) {
|
|
@@ -407,14 +449,16 @@ export async function runGetFeedDetailCommand(values = {
|
|
|
407
449
|
return;
|
|
408
450
|
}
|
|
409
451
|
ensureJsonSavePath(values.format, values.savePath);
|
|
410
|
-
if (values.urls.length === 0) {
|
|
411
|
-
throw new Error('Missing required option: --url');
|
|
452
|
+
if (values.urls.length === 0 && values.ids.length === 0) {
|
|
453
|
+
throw new Error('Missing required option: --url or --id');
|
|
412
454
|
}
|
|
455
|
+
await initializeRednoteDatabase();
|
|
413
456
|
const target = resolveStatusTarget(values.instance);
|
|
414
457
|
const session = await createRednoteSession(target);
|
|
415
458
|
try {
|
|
416
459
|
await ensureRednoteLoggedIn(target, 'fetching feed detail', session);
|
|
417
|
-
const
|
|
460
|
+
const urls = await resolveFeedDetailUrls(values, target.instanceName);
|
|
461
|
+
const result = await getFeedDetails(session, urls, values.comments, target.instanceName);
|
|
418
462
|
writeFeedDetailOutput(result, values);
|
|
419
463
|
} finally{
|
|
420
464
|
await disconnectRednoteSession(session);
|