@skills-store/rednote 0.1.13 → 0.1.14
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 +42 -28
- 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/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
|
@@ -97,6 +97,16 @@ rednote home --instance seller-main --format md --save
|
|
|
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
|
+
|
|
108
|
+
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.
|
|
109
|
+
|
|
100
110
|
### `search`
|
|
101
111
|
|
|
102
112
|
```bash
|
|
@@ -106,13 +116,26 @@ rednote search --instance seller-main --keyword 护肤 --format json --save ./ou
|
|
|
106
116
|
|
|
107
117
|
Use `search` for keyword-based note lookup.
|
|
108
118
|
|
|
119
|
+
The terminal output always uses the compact summary format below, even when `--format json` is selected:
|
|
120
|
+
|
|
121
|
+
```text
|
|
122
|
+
id=<database nanoid>
|
|
123
|
+
title=<post title>
|
|
124
|
+
like=<liked count>
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
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`.
|
|
128
|
+
|
|
109
129
|
### `get-feed-detail`
|
|
110
130
|
|
|
111
131
|
```bash
|
|
112
132
|
rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
|
|
133
|
+
rednote get-feed-detail --instance seller-main --id AynQ7_utnNiW1Ytk
|
|
113
134
|
```
|
|
114
135
|
|
|
115
|
-
Use `get-feed-detail` when you already have a Xiaohongshu note URL.
|
|
136
|
+
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.
|
|
137
|
+
|
|
138
|
+
Captured note details and comments are also upserted into `~/.skills-router/rednote/main.db` in `rednote_post_details` and `rednote_post_comments`.
|
|
116
139
|
|
|
117
140
|
### `get-profile`
|
|
118
141
|
|
|
@@ -137,9 +160,10 @@ Use `interact` when you want the single entrypoint for note operations such as l
|
|
|
137
160
|
- `--instance NAME` selects the browser instance for account-scoped commands.
|
|
138
161
|
- `--format json` is best for scripting.
|
|
139
162
|
- `--format md` is best for direct reading.
|
|
140
|
-
- `--save` is useful for `home` and `search` when you want
|
|
163
|
+
- `--save` is useful for `home` and `search` when you want the raw post array written to disk.
|
|
141
164
|
- `--keyword` is required for `search`.
|
|
142
|
-
- `--
|
|
165
|
+
- `home` and `search` always print `id/title/like` summaries to stdout; `--format json` only changes the saved file payload.
|
|
166
|
+
- `get-feed-detail` accepts either `--url URL` or `--id ID`.
|
|
143
167
|
- `--id` is required for `get-profile`.
|
|
144
168
|
- `--url` is required for `interact`; at least one of `--like`, `--collect`, or `--comment TEXT` must be provided.
|
|
145
169
|
- replies are sent with `interact --comment TEXT`.
|
|
@@ -203,6 +227,7 @@ Use these shapes as the success model when a command returns JSON.
|
|
|
203
227
|
"nodeVersion": "string",
|
|
204
228
|
"storageHome": "string",
|
|
205
229
|
"storageRoot": "string",
|
|
230
|
+
"databasePath": "string",
|
|
206
231
|
"instancesDir": "string",
|
|
207
232
|
"instanceStorePath": "string",
|
|
208
233
|
"legacyPackageInstancesDir": "string"
|
|
@@ -341,37 +366,26 @@ Use these shapes as the success model when a command returns JSON.
|
|
|
341
366
|
|
|
342
367
|
### Feed and profile commands
|
|
343
368
|
|
|
344
|
-
`home
|
|
369
|
+
`home` stdout (both `md` and `json`):
|
|
345
370
|
|
|
346
|
-
```
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
"pageUrl": "string",
|
|
351
|
-
"fetchedAt": "string",
|
|
352
|
-
"total": "number",
|
|
353
|
-
"posts": ["RednotePost"],
|
|
354
|
-
"savedPath": "string|undefined"
|
|
355
|
-
}
|
|
356
|
-
}
|
|
371
|
+
```text
|
|
372
|
+
id=<database nanoid>
|
|
373
|
+
title=<post title>
|
|
374
|
+
like=<liked count>
|
|
357
375
|
```
|
|
358
376
|
|
|
359
|
-
`
|
|
377
|
+
`home --format json --save PATH` writes the raw `RednotePost[]` array to disk, while stdout still prints the summary list above.
|
|
360
378
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
"fetchedAt": "string",
|
|
368
|
-
"total": "number",
|
|
369
|
-
"posts": ["RednotePost"],
|
|
370
|
-
"savedPath": "string|undefined"
|
|
371
|
-
}
|
|
372
|
-
}
|
|
379
|
+
`search` stdout (both `md` and `json`):
|
|
380
|
+
|
|
381
|
+
```text
|
|
382
|
+
id=<database nanoid>
|
|
383
|
+
title=<post title>
|
|
384
|
+
like=<liked count>
|
|
373
385
|
```
|
|
374
386
|
|
|
387
|
+
`search --format json --save PATH` writes the raw `RednotePost[]` array to disk, while stdout still prints the summary list above.
|
|
388
|
+
|
|
375
389
|
`get-feed-detail --format json`:
|
|
376
390
|
|
|
377
391
|
```json
|
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);
|
|
@@ -121,6 +121,7 @@ function normalizeProfileUser(userPageData) {
|
|
|
121
121
|
tags
|
|
122
122
|
};
|
|
123
123
|
}
|
|
124
|
+
import { buildExploreUrl, decodeUrlEscapedValue } from './url-format.js';
|
|
124
125
|
function normalizeProfileNote(item) {
|
|
125
126
|
const id = firstNonNull(item.id, item.noteId);
|
|
126
127
|
if (!id) {
|
|
@@ -132,12 +133,12 @@ function normalizeProfileNote(item) {
|
|
|
132
133
|
const cover = noteCard.cover ?? {};
|
|
133
134
|
const imageList = Array.isArray(noteCard.imageList ?? noteCard.image_list) ? noteCard.imageList ?? noteCard.image_list : [];
|
|
134
135
|
const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo ?? noteCard.corner_tag_info) ? noteCard.cornerTagInfo ?? noteCard.corner_tag_info : [];
|
|
135
|
-
const xsecToken = firstNonNull(item.xsecToken, item.xsec_token);
|
|
136
|
+
const xsecToken = decodeUrlEscapedValue(firstNonNull(item.xsecToken, item.xsec_token));
|
|
136
137
|
return {
|
|
137
138
|
id,
|
|
138
139
|
modelType: firstNonNull(item.modelType, item.model_type) ?? 'note',
|
|
139
140
|
xsecToken,
|
|
140
|
-
url:
|
|
141
|
+
url: buildExploreUrl(id, xsecToken),
|
|
141
142
|
noteCard: {
|
|
142
143
|
type: firstNonNull(noteCard.type, null),
|
|
143
144
|
displayTitle: firstNonNull(noteCard.displayTitle, noteCard.display_title),
|
package/dist/rednote/home.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
3
|
import { runCli } from '../utils/browser-cli.js';
|
|
4
|
+
import { simulateMousePresence } from '../utils/mouse-helper.js';
|
|
4
5
|
import { resolveStatusTarget } from './status.js';
|
|
5
6
|
import * as cheerio from 'cheerio';
|
|
6
7
|
import vm from 'node:vm';
|
|
7
|
-
import { ensureJsonSavePath, parseOutputCliArgs,
|
|
8
|
+
import { ensureJsonSavePath, parseOutputCliArgs, renderPostSummaryList, resolveJsonSavePath, resolveSavePath, writeJsonFile, writePostsJsonl } from './output-format.js';
|
|
8
9
|
import { createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
|
|
10
|
+
import { initializeRednoteDatabase, listPersistedPostSummaries, persistHomePosts } from './persistence.js';
|
|
9
11
|
export function parseHomeCliArgs(argv) {
|
|
10
12
|
return parseOutputCliArgs(argv);
|
|
11
13
|
}
|
|
@@ -32,11 +34,12 @@ function normalizeHomePost(item) {
|
|
|
32
34
|
const imageList = Array.isArray(noteCard.imageList) ? noteCard.imageList : [];
|
|
33
35
|
const cornerTagInfo = Array.isArray(noteCard.cornerTagInfo) ? noteCard.cornerTagInfo : [];
|
|
34
36
|
const xsecToken = item.xsecToken ?? null;
|
|
37
|
+
const url = xsecToken ? `https://www.xiaohongshu.com/explore/${item.id}?xsec_token=${xsecToken}` : `https://www.xiaohongshu.com/explore/${item.id}`;
|
|
35
38
|
return {
|
|
36
39
|
id: item.id,
|
|
37
40
|
modelType: item.modelType,
|
|
38
41
|
xsecToken,
|
|
39
|
-
url
|
|
42
|
+
url,
|
|
40
43
|
noteCard: {
|
|
41
44
|
type: noteCard.type ?? null,
|
|
42
45
|
displayTitle: noteCard.displayTitle ?? null,
|
|
@@ -84,6 +87,20 @@ function normalizeHomePost(item) {
|
|
|
84
87
|
}
|
|
85
88
|
};
|
|
86
89
|
}
|
|
90
|
+
function buildPostSummaryList(posts, persistedRows = []) {
|
|
91
|
+
const persistedMap = new Map(persistedRows.map((row)=>[
|
|
92
|
+
row.noteId,
|
|
93
|
+
row
|
|
94
|
+
]));
|
|
95
|
+
return posts.map((post)=>{
|
|
96
|
+
const persisted = persistedMap.get(post.id);
|
|
97
|
+
return {
|
|
98
|
+
id: persisted?.id ?? post.id,
|
|
99
|
+
title: persisted?.title ?? post.noteCard.displayTitle ?? '',
|
|
100
|
+
like: persisted?.likeCount ?? post.noteCard.interactInfo.likedCount ?? ''
|
|
101
|
+
};
|
|
102
|
+
});
|
|
103
|
+
}
|
|
87
104
|
async function getOrCreateXiaohongshuPage(session) {
|
|
88
105
|
return session.page;
|
|
89
106
|
}
|
|
@@ -147,20 +164,32 @@ async function collectHomeFeedItems(page) {
|
|
|
147
164
|
waitUntil: 'domcontentloaded'
|
|
148
165
|
});
|
|
149
166
|
}
|
|
167
|
+
await simulateMousePresence(page);
|
|
150
168
|
await page.waitForTimeout(500);
|
|
151
|
-
|
|
169
|
+
const feedItems = await feedPromise;
|
|
170
|
+
await simulateMousePresence(page);
|
|
171
|
+
return feedItems;
|
|
152
172
|
}
|
|
153
|
-
export async function getRednoteHomePosts(session) {
|
|
173
|
+
export async function getRednoteHomePosts(session, instanceName) {
|
|
154
174
|
const page = await getOrCreateXiaohongshuPage(session);
|
|
155
175
|
const items = await collectHomeFeedItems(page);
|
|
156
176
|
const posts = items.map(normalizeHomePost);
|
|
177
|
+
let summaries = buildPostSummaryList(posts);
|
|
178
|
+
if (instanceName) {
|
|
179
|
+
await persistHomePosts(instanceName, posts.map((post, index)=>({
|
|
180
|
+
post,
|
|
181
|
+
raw: items[index] ?? post
|
|
182
|
+
})));
|
|
183
|
+
summaries = buildPostSummaryList(posts, await listPersistedPostSummaries(instanceName, posts.map((post)=>post.id)));
|
|
184
|
+
}
|
|
157
185
|
return {
|
|
158
186
|
ok: true,
|
|
159
187
|
home: {
|
|
160
188
|
pageUrl: page.url(),
|
|
161
189
|
fetchedAt: new Date().toISOString(),
|
|
162
190
|
total: posts.length,
|
|
163
|
-
posts
|
|
191
|
+
posts,
|
|
192
|
+
summaries
|
|
164
193
|
}
|
|
165
194
|
};
|
|
166
195
|
}
|
|
@@ -169,21 +198,16 @@ function writeHomeOutput(result, values) {
|
|
|
169
198
|
const savedPath = resolveJsonSavePath(values.savePath);
|
|
170
199
|
result.home.savedPath = savedPath;
|
|
171
200
|
writeJsonFile(result.home.posts, savedPath);
|
|
172
|
-
process.stdout.write(
|
|
201
|
+
process.stdout.write(renderPostSummaryList(result.home.summaries));
|
|
173
202
|
return;
|
|
174
203
|
}
|
|
175
204
|
const posts = result.home.posts;
|
|
176
|
-
let savedPath;
|
|
177
205
|
if (values.saveRequested) {
|
|
178
|
-
savedPath = resolveSavePath('home', values.savePath);
|
|
206
|
+
const savedPath = resolveSavePath('home', values.savePath);
|
|
179
207
|
writePostsJsonl(posts, savedPath);
|
|
180
208
|
result.home.savedPath = savedPath;
|
|
181
209
|
}
|
|
182
|
-
|
|
183
|
-
if (savedPath) {
|
|
184
|
-
markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
|
|
185
|
-
}
|
|
186
|
-
process.stdout.write(markdown);
|
|
210
|
+
process.stdout.write(renderPostSummaryList(result.home.summaries));
|
|
187
211
|
}
|
|
188
212
|
export async function runHomeCommand(values = {
|
|
189
213
|
format: 'md',
|
|
@@ -194,10 +218,11 @@ export async function runHomeCommand(values = {
|
|
|
194
218
|
return;
|
|
195
219
|
}
|
|
196
220
|
ensureJsonSavePath(values.format, values.savePath);
|
|
221
|
+
await initializeRednoteDatabase();
|
|
197
222
|
const target = resolveStatusTarget(values.instance);
|
|
198
223
|
const session = await createRednoteSession(target);
|
|
199
224
|
try {
|
|
200
|
-
const result = await getRednoteHomePosts(session);
|
|
225
|
+
const result = await getRednoteHomePosts(session, target.instanceName);
|
|
201
226
|
writeHomeOutput(result, values);
|
|
202
227
|
} finally{
|
|
203
228
|
await disconnectRednoteSession(session);
|
|
@@ -175,6 +175,16 @@ export function renderJsonSaveSummary(filePath, payload) {
|
|
|
175
175
|
function formatField(value) {
|
|
176
176
|
return value ?? '';
|
|
177
177
|
}
|
|
178
|
+
export function renderPostSummaryList(items) {
|
|
179
|
+
if (items.length === 0) {
|
|
180
|
+
return 'No posts were captured.\n';
|
|
181
|
+
}
|
|
182
|
+
return `${items.map((item)=>[
|
|
183
|
+
`id=${item.id}`,
|
|
184
|
+
`title=${item.title}`,
|
|
185
|
+
`like=${item.like}`
|
|
186
|
+
].join('\n')).join('\n\n')}\n`;
|
|
187
|
+
}
|
|
178
188
|
export function renderPostsMarkdown(posts) {
|
|
179
189
|
if (posts.length === 0) {
|
|
180
190
|
return 'No posts were captured.\n';
|