@skills-store/rednote 0.1.11 → 0.1.12
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/dist/rednote/env.js +19 -6
- package/dist/rednote/getFeedDetail.js +234 -103
- package/dist/rednote/getProfile.js +47 -32
- package/dist/rednote/home.js +11 -7
- package/dist/rednote/index.js +9 -8
- package/dist/rednote/interact.js +4 -4
- package/dist/rednote/login.js +7 -5
- package/dist/rednote/output-format.js +75 -1
- package/dist/rednote/publish.js +38 -26
- package/dist/rednote/search.js +31 -7
- package/package.json +1 -1
package/dist/rednote/env.js
CHANGED
|
@@ -1,17 +1,19 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
4
4
|
import { getRednoteEnvironmentInfo } from '../utils/browser-core.js';
|
|
5
|
+
import { ensureJsonSavePath, renderJsonSaveSummary, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
5
6
|
function printEnvHelp() {
|
|
6
7
|
process.stdout.write(`rednote env
|
|
7
8
|
|
|
8
9
|
Usage:
|
|
9
|
-
npx -y @skills-store/rednote env [--format md|json]
|
|
10
|
-
node --experimental-strip-types ./scripts/rednote/env.ts [--format md|json]
|
|
11
|
-
bun ./scripts/rednote/env.ts [--format md|json]
|
|
10
|
+
npx -y @skills-store/rednote env [--format md|json] [--save PATH]
|
|
11
|
+
node --experimental-strip-types ./scripts/rednote/env.ts [--format md|json] [--save PATH]
|
|
12
|
+
bun ./scripts/rednote/env.ts [--format md|json] [--save PATH]
|
|
12
13
|
|
|
13
14
|
Options:
|
|
14
15
|
--format FORMAT Output format: md | json. Default: md
|
|
16
|
+
--save PATH Required when --format json is used. Saves the full result as JSON
|
|
15
17
|
-h, --help Show this help
|
|
16
18
|
`);
|
|
17
19
|
}
|
|
@@ -40,8 +42,12 @@ export async function runEnvCommand(values = {}) {
|
|
|
40
42
|
return;
|
|
41
43
|
}
|
|
42
44
|
const format = values.format ?? 'md';
|
|
45
|
+
ensureJsonSavePath(format, values.savePath);
|
|
43
46
|
if (format === 'json') {
|
|
44
|
-
|
|
47
|
+
const result = getRednoteEnvironmentInfo();
|
|
48
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
49
|
+
writeJsonFile(result, savedPath);
|
|
50
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, result));
|
|
45
51
|
return;
|
|
46
52
|
}
|
|
47
53
|
process.stdout.write(renderEnvironmentMarkdown());
|
|
@@ -55,6 +61,9 @@ async function main() {
|
|
|
55
61
|
format: {
|
|
56
62
|
type: 'string'
|
|
57
63
|
},
|
|
64
|
+
save: {
|
|
65
|
+
type: 'string'
|
|
66
|
+
},
|
|
58
67
|
help: {
|
|
59
68
|
type: 'boolean',
|
|
60
69
|
short: 'h'
|
|
@@ -64,6 +73,10 @@ async function main() {
|
|
|
64
73
|
if (values.format && values.format !== 'md' && values.format !== 'json') {
|
|
65
74
|
throw new Error(`Invalid --format value: ${String(values.format)}`);
|
|
66
75
|
}
|
|
67
|
-
await runEnvCommand(
|
|
76
|
+
await runEnvCommand({
|
|
77
|
+
format: values.format,
|
|
78
|
+
savePath: values.save,
|
|
79
|
+
help: values.help
|
|
80
|
+
});
|
|
68
81
|
}
|
|
69
82
|
runCli(import.meta.url, main);
|
|
@@ -2,59 +2,125 @@
|
|
|
2
2
|
import * as cheerio from 'cheerio';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import vm from 'node:vm';
|
|
5
|
-
import {
|
|
5
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
6
6
|
import { resolveStatusTarget } from './status.js';
|
|
7
7
|
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
8
|
+
import { ensureJsonSavePath, renderJsonSaveSummary, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
8
9
|
function printGetFeedDetailHelp() {
|
|
9
10
|
process.stdout.write(`rednote get-feed-detail
|
|
10
11
|
|
|
11
12
|
Usage:
|
|
12
|
-
npx -y @skills-store/rednote get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
|
|
13
|
-
node --experimental-strip-types ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--format md|json]
|
|
14
|
-
bun ./scripts/rednote/getFeedDetail.ts --instance NAME --url URL [--url URL] [--format md|json]
|
|
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
16
|
|
|
16
17
|
Options:
|
|
17
18
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
18
19
|
--url URL Required. Xiaohongshu explore url, repeatable
|
|
20
|
+
--comments [COUNT] Optional. Include comment data. When COUNT is provided, scroll \`.note-scroller\` until COUNT comments, the end, or timeout
|
|
19
21
|
--format FORMAT Output format: md | json. Default: md
|
|
22
|
+
--save PATH Required when --format json is used. Saves the selected result array as JSON
|
|
20
23
|
-h, --help Show this help
|
|
21
24
|
`);
|
|
22
25
|
}
|
|
26
|
+
function parseOptionWithEquals(arg) {
|
|
27
|
+
const equalIndex = arg.indexOf('=');
|
|
28
|
+
if (equalIndex === -1) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
return {
|
|
32
|
+
key: arg.slice(0, equalIndex),
|
|
33
|
+
value: arg.slice(equalIndex + 1)
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
function parseCommentsValue(value) {
|
|
37
|
+
if (!value) {
|
|
38
|
+
throw new Error('Missing value for --comments');
|
|
39
|
+
}
|
|
40
|
+
const parsed = Number(value);
|
|
41
|
+
if (!Number.isInteger(parsed) || parsed <= 0) {
|
|
42
|
+
throw new Error(`Invalid --comments value: ${String(value)}`);
|
|
43
|
+
}
|
|
44
|
+
return parsed;
|
|
45
|
+
}
|
|
23
46
|
export function parseGetFeedDetailCliArgs(argv) {
|
|
24
|
-
const
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
const values = {
|
|
48
|
+
urls: [],
|
|
49
|
+
format: 'md',
|
|
50
|
+
comments: undefined,
|
|
51
|
+
help: false
|
|
52
|
+
};
|
|
53
|
+
const positionals = [];
|
|
54
|
+
for(let index = 0; index < argv.length; index += 1){
|
|
55
|
+
const arg = argv[index];
|
|
56
|
+
const withEquals = parseOptionWithEquals(arg);
|
|
57
|
+
if (arg === '-h' || arg === '--help') {
|
|
58
|
+
values.help = true;
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
if (withEquals?.key === '--instance') {
|
|
62
|
+
values.instance = withEquals.value;
|
|
63
|
+
continue;
|
|
64
|
+
}
|
|
65
|
+
if (arg === '--instance') {
|
|
66
|
+
values.instance = argv[index + 1];
|
|
67
|
+
index += 1;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
if (withEquals?.key === '--url') {
|
|
71
|
+
values.urls.push(withEquals.value);
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
if (arg === '--url') {
|
|
75
|
+
const nextArg = argv[index + 1];
|
|
76
|
+
if (!nextArg || nextArg.startsWith('-')) {
|
|
77
|
+
throw new Error('Missing required option value: --url');
|
|
78
|
+
}
|
|
79
|
+
values.urls.push(nextArg);
|
|
80
|
+
index += 1;
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
if (withEquals?.key === '--format') {
|
|
84
|
+
values.format = withEquals.value;
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
if (arg === '--format') {
|
|
88
|
+
values.format = argv[index + 1];
|
|
89
|
+
index += 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (withEquals?.key === '--comments') {
|
|
93
|
+
values.comments = withEquals.value ? parseCommentsValue(withEquals.value) : null;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (arg === '--comments') {
|
|
97
|
+
const nextArg = argv[index + 1];
|
|
98
|
+
if (nextArg && !nextArg.startsWith('-')) {
|
|
99
|
+
values.comments = parseCommentsValue(nextArg);
|
|
100
|
+
index += 1;
|
|
101
|
+
} else {
|
|
102
|
+
values.comments = null;
|
|
42
103
|
}
|
|
104
|
+
continue;
|
|
43
105
|
}
|
|
44
|
-
|
|
106
|
+
if (withEquals?.key === '--save') {
|
|
107
|
+
values.savePath = withEquals.value;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (arg === '--save') {
|
|
111
|
+
values.savePath = argv[index + 1];
|
|
112
|
+
index += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
positionals.push(arg);
|
|
116
|
+
}
|
|
45
117
|
if (positionals.length > 0) {
|
|
46
118
|
throw new Error(`Unexpected positional arguments: ${positionals.join(' ')}`);
|
|
47
119
|
}
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
throw new Error(`Invalid --format value: ${String(format)}`);
|
|
120
|
+
if (values.format !== 'md' && values.format !== 'json') {
|
|
121
|
+
throw new Error(`Invalid --format value: ${String(values.format)}`);
|
|
51
122
|
}
|
|
52
|
-
return
|
|
53
|
-
instance: values.instance,
|
|
54
|
-
urls: values.url ?? [],
|
|
55
|
-
format,
|
|
56
|
-
help: values.help
|
|
57
|
-
};
|
|
123
|
+
return values;
|
|
58
124
|
}
|
|
59
125
|
function validateFeedDetailUrl(url) {
|
|
60
126
|
try {
|
|
@@ -94,103 +160,150 @@ function extractVideoUrl(note) {
|
|
|
94
160
|
const firstAvailable = streams.find((items)=>Array.isArray(items) && items.length > 0);
|
|
95
161
|
return firstAvailable?.[0]?.backupUrls?.[0] ?? null;
|
|
96
162
|
}
|
|
163
|
+
const COMMENTS_CONTAINER_SELECTOR = '.note-scroller';
|
|
164
|
+
const COMMENT_SCROLL_TIMEOUT_MS = 20_000;
|
|
165
|
+
const COMMENT_SCROLL_IDLE_LIMIT = 4;
|
|
166
|
+
function hasCommentsEnabled(comments) {
|
|
167
|
+
return comments !== undefined;
|
|
168
|
+
}
|
|
169
|
+
function buildCommentKey(comment) {
|
|
170
|
+
return String(comment?.id ?? comment?.commentId ?? comment?.comment_id ?? `${comment?.userInfo?.userId ?? comment?.user_info?.user_id ?? 'unknown'}:${comment?.createTime ?? comment?.create_time ?? 'unknown'}:${comment?.content ?? ''}`);
|
|
171
|
+
}
|
|
172
|
+
function getCommentCount(commentsMap) {
|
|
173
|
+
return commentsMap.size;
|
|
174
|
+
}
|
|
175
|
+
async function scrollCommentsContainer(page, targetCount, getCount) {
|
|
176
|
+
const container = page.locator(COMMENTS_CONTAINER_SELECTOR).first();
|
|
177
|
+
const visible = await container.isVisible().catch(()=>false);
|
|
178
|
+
if (!visible) {
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
181
|
+
await container.scrollIntoViewIfNeeded().catch(()=>{});
|
|
182
|
+
await container.hover().catch(()=>{});
|
|
183
|
+
const getMetrics = async ()=>await container.evaluate((element)=>{
|
|
184
|
+
const htmlElement = element;
|
|
185
|
+
const atBottom = htmlElement.scrollTop + htmlElement.clientHeight >= htmlElement.scrollHeight - 8;
|
|
186
|
+
return {
|
|
187
|
+
scrollTop: htmlElement.scrollTop,
|
|
188
|
+
scrollHeight: htmlElement.scrollHeight,
|
|
189
|
+
clientHeight: htmlElement.clientHeight,
|
|
190
|
+
atBottom
|
|
191
|
+
};
|
|
192
|
+
}).catch(()=>null);
|
|
193
|
+
const deadline = Date.now() + COMMENT_SCROLL_TIMEOUT_MS;
|
|
194
|
+
let idleRounds = 0;
|
|
195
|
+
while(Date.now() < deadline){
|
|
196
|
+
if (getCount() >= targetCount) {
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
const beforeMetrics = await getMetrics();
|
|
200
|
+
if (!beforeMetrics) {
|
|
201
|
+
return;
|
|
202
|
+
}
|
|
203
|
+
const beforeCount = getCount();
|
|
204
|
+
const delta = Math.max(Math.floor(beforeMetrics.clientHeight * 0.85), 480);
|
|
205
|
+
await page.mouse.wheel(0, delta).catch(()=>{});
|
|
206
|
+
await page.waitForTimeout(900);
|
|
207
|
+
const afterMetrics = await getMetrics();
|
|
208
|
+
await page.waitForTimeout(400);
|
|
209
|
+
const afterCount = getCount();
|
|
210
|
+
const countChanged = afterCount > beforeCount;
|
|
211
|
+
const scrollMoved = Boolean(afterMetrics) && afterMetrics.scrollTop > beforeMetrics.scrollTop;
|
|
212
|
+
const reachedBottom = Boolean(afterMetrics?.atBottom);
|
|
213
|
+
if (countChanged || scrollMoved) {
|
|
214
|
+
idleRounds = 0;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
idleRounds += 1;
|
|
218
|
+
if (reachedBottom && idleRounds >= 2 || idleRounds >= COMMENT_SCROLL_IDLE_LIMIT) {
|
|
219
|
+
return;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
97
223
|
function normalizeDetailNote(note) {
|
|
98
224
|
return {
|
|
99
225
|
noteId: note?.noteId ?? null,
|
|
100
226
|
title: note?.title ?? null,
|
|
101
227
|
desc: note?.desc ?? null,
|
|
102
228
|
type: note?.type ?? null,
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
name: tag?.name ?? null
|
|
114
|
-
})) : [],
|
|
115
|
-
imageList: Array.isArray(note?.imageList) ? note.imageList.map((image)=>({
|
|
116
|
-
urlDefault: image?.urlDefault ?? null,
|
|
117
|
-
urlPre: image?.urlPre ?? null,
|
|
118
|
-
width: image?.width ?? null,
|
|
119
|
-
height: image?.height ?? null
|
|
120
|
-
})) : [],
|
|
121
|
-
video: note?.video ? {
|
|
122
|
-
url: extractVideoUrl(note),
|
|
123
|
-
raw: note.video
|
|
124
|
-
} : null,
|
|
125
|
-
raw: note
|
|
229
|
+
liked: note?.interactInfo?.liked ?? null,
|
|
230
|
+
likedCount: note?.interactInfo?.likedCount ?? null,
|
|
231
|
+
commentCount: note?.interactInfo?.commentCount ?? null,
|
|
232
|
+
collected: note?.interactInfo?.collected ?? null,
|
|
233
|
+
collectedCount: note?.interactInfo?.collectedCount ?? null,
|
|
234
|
+
shareCount: note?.interactInfo?.shareCount ?? null,
|
|
235
|
+
followed: note?.interactInfo?.followed ?? null,
|
|
236
|
+
tagList: Array.isArray(note?.tagList) ? note.tagList.map((tag)=>tag?.name ?? null).filter((tag)=>Boolean(tag)) : [],
|
|
237
|
+
imageList: Array.isArray(note?.imageList) ? note.imageList.map((image)=>image?.urlDefault ?? null).filter((imageUrl)=>Boolean(imageUrl)) : [],
|
|
238
|
+
video: extractVideoUrl(note)
|
|
126
239
|
};
|
|
127
240
|
}
|
|
128
241
|
function normalizeComments(comments) {
|
|
129
242
|
return comments.map((comment)=>({
|
|
130
|
-
id: comment?.id ?? comment?.commentId ?? null,
|
|
131
243
|
content: comment?.content ?? null,
|
|
132
|
-
userId: comment?.userInfo?.userId ?? null,
|
|
133
|
-
nickname: comment?.userInfo?.nickname ?? null,
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
244
|
+
userId: comment?.userInfo?.userId ?? comment?.user_info?.user_id ?? null,
|
|
245
|
+
nickname: comment?.userInfo?.nickname ?? comment?.user_info?.nickname ?? null,
|
|
246
|
+
create_time: comment?.createTime ?? comment?.create_time ?? null,
|
|
247
|
+
like_count: comment?.likeCount ?? comment?.like_count ?? comment?.interactInfo?.likedCount ?? null,
|
|
248
|
+
sub_comment_count: typeof (comment?.subCommentCount ?? comment?.sub_comment_count) === 'string' ? comment?.subCommentCount ?? comment?.sub_comment_count : null
|
|
137
249
|
}));
|
|
138
250
|
}
|
|
139
251
|
function formatDetailField(value) {
|
|
140
252
|
return value ?? '';
|
|
141
253
|
}
|
|
142
|
-
function renderDetailMarkdown(items) {
|
|
254
|
+
function renderDetailMarkdown(items, includeComments = false) {
|
|
143
255
|
if (items.length === 0) {
|
|
144
|
-
return '
|
|
256
|
+
return 'No feed details were captured.\n';
|
|
145
257
|
}
|
|
146
258
|
return `${items.map((item)=>{
|
|
147
259
|
const lines = [];
|
|
148
260
|
lines.push('## Note');
|
|
149
261
|
lines.push('');
|
|
150
|
-
lines.push(`- Url: ${item.url}`);
|
|
151
262
|
lines.push(`- Title: ${formatDetailField(item.note.title)}`);
|
|
152
|
-
lines.push(`-
|
|
153
|
-
lines.push(`-
|
|
154
|
-
lines.push(`-
|
|
155
|
-
lines.push(`-
|
|
156
|
-
lines.push(`-
|
|
157
|
-
lines.push(`-
|
|
158
|
-
lines.push(`-
|
|
159
|
-
lines.push(`- Tags: ${item.note.tagList.map((tag)=>tag.name ? `#${tag.name}` : '').filter(Boolean).join(' ')}`);
|
|
263
|
+
lines.push(`- Liked: ${formatDetailField(item.note.liked)}`);
|
|
264
|
+
lines.push(`- Collected: ${formatDetailField(item.note.collected)}`);
|
|
265
|
+
lines.push(`- LikedCount: ${formatDetailField(item.note.likedCount)}`);
|
|
266
|
+
lines.push(`- CommentCount: ${formatDetailField(item.note.commentCount)}`);
|
|
267
|
+
lines.push(`- CollectedCount: ${formatDetailField(item.note.collectedCount)}`);
|
|
268
|
+
lines.push(`- ShareCount: ${formatDetailField(item.note.shareCount)}`);
|
|
269
|
+
lines.push(`- Tags: ${item.note.tagList.map((tag)=>`#${tag}`).join(' ')}`);
|
|
160
270
|
lines.push('');
|
|
161
271
|
lines.push('## Content');
|
|
162
272
|
lines.push('');
|
|
163
273
|
lines.push(item.note.desc ?? '');
|
|
164
|
-
if (item.note.imageList.length > 0 || item.note.video
|
|
274
|
+
if (item.note.imageList.length > 0 || item.note.video) {
|
|
165
275
|
lines.push('');
|
|
166
276
|
lines.push('## Media');
|
|
167
277
|
lines.push('');
|
|
168
|
-
item.note.imageList.forEach((
|
|
169
|
-
|
|
170
|
-
lines.push(`- Image${index + 1}: ${image.urlDefault}`);
|
|
171
|
-
}
|
|
278
|
+
item.note.imageList.forEach((imageUrl, index)=>{
|
|
279
|
+
lines.push(`- Image${index + 1}: ${imageUrl}`);
|
|
172
280
|
});
|
|
173
|
-
if (item.note.video
|
|
174
|
-
lines.push(`- Video: ${item.note.video
|
|
281
|
+
if (item.note.video) {
|
|
282
|
+
lines.push(`- Video: ${item.note.video}`);
|
|
175
283
|
}
|
|
176
284
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
285
|
+
if (includeComments) {
|
|
286
|
+
lines.push('');
|
|
287
|
+
lines.push('## Comments');
|
|
288
|
+
lines.push('');
|
|
289
|
+
if (!item.comments || item.comments.length === 0) {
|
|
290
|
+
lines.push('- Comments not found');
|
|
291
|
+
} else {
|
|
292
|
+
item.comments.forEach((comment)=>{
|
|
293
|
+
const prefix = comment.nickname ? `${comment.nickname}: ` : '';
|
|
294
|
+
lines.push(`- ${prefix}${comment.content ?? ''}`);
|
|
295
|
+
});
|
|
296
|
+
}
|
|
187
297
|
}
|
|
188
298
|
return lines.join('\n');
|
|
189
299
|
}).join('\n\n---\n\n')}\n`;
|
|
190
300
|
}
|
|
191
|
-
async function captureFeedDetail(page, targetUrl) {
|
|
301
|
+
async function captureFeedDetail(page, targetUrl, commentsOption = undefined) {
|
|
302
|
+
const includeComments = hasCommentsEnabled(commentsOption);
|
|
303
|
+
const commentsTarget = typeof commentsOption === 'number' ? commentsOption : null;
|
|
192
304
|
let note = null;
|
|
193
|
-
let
|
|
305
|
+
let commentsLoaded = !includeComments;
|
|
306
|
+
const commentsMap = new Map();
|
|
194
307
|
const handleResponse = async (response)=>{
|
|
195
308
|
try {
|
|
196
309
|
const url = new URL(response.url());
|
|
@@ -214,9 +327,13 @@ async function captureFeedDetail(page, targetUrl) {
|
|
|
214
327
|
note = noteState.noteDetailMap[noteState.currentNoteId]?.note ?? note;
|
|
215
328
|
}
|
|
216
329
|
});
|
|
217
|
-
} else if (url.href.includes('comment/page?')) {
|
|
330
|
+
} else if (includeComments && url.href.includes('comment/page?')) {
|
|
218
331
|
const data = await response.json();
|
|
219
|
-
|
|
332
|
+
const nextComments = Array.isArray(data?.data?.comments) ? data.data.comments : [];
|
|
333
|
+
commentsLoaded = true;
|
|
334
|
+
for (const comment of nextComments){
|
|
335
|
+
commentsMap.set(buildCommentKey(comment), comment);
|
|
336
|
+
}
|
|
220
337
|
}
|
|
221
338
|
} catch {}
|
|
222
339
|
};
|
|
@@ -227,7 +344,7 @@ async function captureFeedDetail(page, targetUrl) {
|
|
|
227
344
|
});
|
|
228
345
|
const deadline = Date.now() + 15_000;
|
|
229
346
|
while(Date.now() < deadline){
|
|
230
|
-
if (note &&
|
|
347
|
+
if (note && commentsLoaded) {
|
|
231
348
|
break;
|
|
232
349
|
}
|
|
233
350
|
await page.waitForTimeout(200);
|
|
@@ -235,22 +352,29 @@ async function captureFeedDetail(page, targetUrl) {
|
|
|
235
352
|
if (!note) {
|
|
236
353
|
throw new Error(`Failed to capture note detail: ${targetUrl}`);
|
|
237
354
|
}
|
|
355
|
+
if (includeComments && commentsTarget) {
|
|
356
|
+
await scrollCommentsContainer(page, commentsTarget, ()=>getCommentCount(commentsMap));
|
|
357
|
+
}
|
|
238
358
|
return {
|
|
239
359
|
url: targetUrl,
|
|
240
360
|
note: normalizeDetailNote(note),
|
|
241
|
-
|
|
361
|
+
...includeComments ? {
|
|
362
|
+
comments: normalizeComments([
|
|
363
|
+
...commentsMap.values()
|
|
364
|
+
])
|
|
365
|
+
} : {}
|
|
242
366
|
};
|
|
243
367
|
} finally{
|
|
244
368
|
page.off('response', handleResponse);
|
|
245
369
|
}
|
|
246
370
|
}
|
|
247
|
-
export async function getFeedDetails(session, urls) {
|
|
371
|
+
export async function getFeedDetails(session, urls, commentsOption = undefined) {
|
|
248
372
|
const page = await getOrCreateXiaohongshuPage(session);
|
|
249
373
|
const items = [];
|
|
250
374
|
for (const url of urls){
|
|
251
375
|
const normalizedUrl = normalizeFeedDetailUrl(url);
|
|
252
376
|
validateFeedDetailUrl(normalizedUrl);
|
|
253
|
-
items.push(await captureFeedDetail(page, normalizedUrl));
|
|
377
|
+
items.push(await captureFeedDetail(page, normalizedUrl, commentsOption));
|
|
254
378
|
}
|
|
255
379
|
return {
|
|
256
380
|
ok: true,
|
|
@@ -261,12 +385,18 @@ export async function getFeedDetails(session, urls) {
|
|
|
261
385
|
}
|
|
262
386
|
};
|
|
263
387
|
}
|
|
264
|
-
function
|
|
265
|
-
|
|
266
|
-
|
|
388
|
+
function selectFeedDetailOutput(result) {
|
|
389
|
+
return result.detail.items;
|
|
390
|
+
}
|
|
391
|
+
function writeFeedDetailOutput(result, values) {
|
|
392
|
+
const output = selectFeedDetailOutput(result);
|
|
393
|
+
if (values.format === 'json') {
|
|
394
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
395
|
+
writeJsonFile(output, savedPath);
|
|
396
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, output));
|
|
267
397
|
return;
|
|
268
398
|
}
|
|
269
|
-
process.stdout.write(renderDetailMarkdown(result.detail.items));
|
|
399
|
+
process.stdout.write(renderDetailMarkdown(result.detail.items, hasCommentsEnabled(values.comments)));
|
|
270
400
|
}
|
|
271
401
|
export async function runGetFeedDetailCommand(values = {
|
|
272
402
|
urls: [],
|
|
@@ -276,6 +406,7 @@ export async function runGetFeedDetailCommand(values = {
|
|
|
276
406
|
printGetFeedDetailHelp();
|
|
277
407
|
return;
|
|
278
408
|
}
|
|
409
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
279
410
|
if (values.urls.length === 0) {
|
|
280
411
|
throw new Error('Missing required option: --url');
|
|
281
412
|
}
|
|
@@ -283,8 +414,8 @@ export async function runGetFeedDetailCommand(values = {
|
|
|
283
414
|
const session = await createRednoteSession(target);
|
|
284
415
|
try {
|
|
285
416
|
await ensureRednoteLoggedIn(target, 'fetching feed detail', session);
|
|
286
|
-
const result = await getFeedDetails(session, values.urls);
|
|
287
|
-
writeFeedDetailOutput(result, values
|
|
417
|
+
const result = await getFeedDetails(session, values.urls, values.comments);
|
|
418
|
+
writeFeedDetailOutput(result, values);
|
|
288
419
|
} finally{
|
|
289
420
|
await disconnectRednoteSession(session);
|
|
290
421
|
}
|
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
import * as cheerio from 'cheerio';
|
|
3
3
|
import { parseArgs } from 'node:util';
|
|
4
4
|
import vm from 'node:vm';
|
|
5
|
-
import {
|
|
5
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
6
6
|
import { resolveStatusTarget } from './status.js';
|
|
7
7
|
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
8
|
+
import { ensureJsonSavePath, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, writeJsonFile } from './output-format.js';
|
|
8
9
|
function printGetProfileHelp() {
|
|
9
10
|
process.stdout.write(`rednote get-profile
|
|
10
11
|
|
|
11
12
|
Usage:
|
|
12
|
-
npx -y @skills-store/rednote get-profile [--instance NAME] --id USER_ID [--format md|json]
|
|
13
|
-
node --experimental-strip-types ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--format md|json]
|
|
14
|
-
bun ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--format md|json]
|
|
13
|
+
npx -y @skills-store/rednote get-profile [--instance NAME] --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
14
|
+
node --experimental-strip-types ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
15
|
+
bun ./scripts/rednote/getProfile.ts --instance NAME --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
15
16
|
|
|
16
17
|
Options:
|
|
17
18
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
18
19
|
--id USER_ID Required. Xiaohongshu profile user id
|
|
20
|
+
--mode MODE Optional. profile | notes. Default: profile
|
|
19
21
|
--format FORMAT Output format: md | json. Default: md
|
|
22
|
+
--save PATH Required when --format json is used. Saves only the selected mode data as JSON
|
|
20
23
|
-h, --help Show this help
|
|
21
24
|
`);
|
|
22
25
|
}
|
|
@@ -35,6 +38,12 @@ export function parseGetProfileCliArgs(argv) {
|
|
|
35
38
|
format: {
|
|
36
39
|
type: 'string'
|
|
37
40
|
},
|
|
41
|
+
mode: {
|
|
42
|
+
type: 'string'
|
|
43
|
+
},
|
|
44
|
+
save: {
|
|
45
|
+
type: 'string'
|
|
46
|
+
},
|
|
38
47
|
help: {
|
|
39
48
|
type: 'boolean',
|
|
40
49
|
short: 'h'
|
|
@@ -48,10 +57,16 @@ export function parseGetProfileCliArgs(argv) {
|
|
|
48
57
|
if (format !== 'md' && format !== 'json') {
|
|
49
58
|
throw new Error(`Invalid --format value: ${String(format)}`);
|
|
50
59
|
}
|
|
60
|
+
const mode = values.mode ?? 'profile';
|
|
61
|
+
if (mode !== 'profile' && mode !== 'notes') {
|
|
62
|
+
throw new Error(`Invalid --mode value: ${String(values.mode)}`);
|
|
63
|
+
}
|
|
51
64
|
return {
|
|
52
65
|
instance: values.instance,
|
|
53
66
|
id: values.id,
|
|
54
67
|
format,
|
|
68
|
+
mode,
|
|
69
|
+
savePath: values.save,
|
|
55
70
|
help: values.help
|
|
56
71
|
};
|
|
57
72
|
}
|
|
@@ -103,8 +118,7 @@ function normalizeProfileUser(userPageData) {
|
|
|
103
118
|
follows: follows,
|
|
104
119
|
fans: fans,
|
|
105
120
|
interaction: interaction,
|
|
106
|
-
tags
|
|
107
|
-
raw: userPageData
|
|
121
|
+
tags
|
|
108
122
|
};
|
|
109
123
|
}
|
|
110
124
|
function normalizeProfileNote(item) {
|
|
@@ -197,8 +211,8 @@ function normalizeProfileNotes(notesRaw) {
|
|
|
197
211
|
function formatProfileField(value) {
|
|
198
212
|
return value ?? '';
|
|
199
213
|
}
|
|
200
|
-
function
|
|
201
|
-
const { user,
|
|
214
|
+
function renderProfileUserMarkdown(result) {
|
|
215
|
+
const { user, url, userId } = result.profile;
|
|
202
216
|
const lines = [];
|
|
203
217
|
lines.push('## UserInfo');
|
|
204
218
|
lines.push('');
|
|
@@ -211,23 +225,17 @@ function renderProfileMarkdown(result) {
|
|
|
211
225
|
lines.push(`- Fans: ${formatProfileField(user.fans)}`);
|
|
212
226
|
lines.push(`- Interactions: ${formatProfileField(user.interaction)}`);
|
|
213
227
|
lines.push(`- Tags: ${user.tags.length > 0 ? user.tags.map((tag)=>`#${tag}`).join(' ') : ''}`);
|
|
214
|
-
lines.push('');
|
|
215
|
-
lines.push('## Notes');
|
|
216
|
-
lines.push('');
|
|
217
|
-
if (notes.length === 0) {
|
|
218
|
-
lines.push('- Notes not found or the profile is private');
|
|
219
|
-
} else {
|
|
220
|
-
notes.forEach((note, index)=>{
|
|
221
|
-
lines.push(`- Title: ${note.noteCard.displayTitle ?? ''}`);
|
|
222
|
-
lines.push(` Url: ${note.url}`);
|
|
223
|
-
lines.push(` Interaction: ${note.noteCard.interactInfo.likedCount ?? ''}`);
|
|
224
|
-
if (index < notes.length - 1) {
|
|
225
|
-
lines.push('');
|
|
226
|
-
}
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
228
|
return `${lines.join('\n')}\n`;
|
|
230
229
|
}
|
|
230
|
+
function selectProfileOutput(result, mode) {
|
|
231
|
+
return mode === 'notes' ? result.profile.notes : result.profile.user;
|
|
232
|
+
}
|
|
233
|
+
function renderProfileMarkdown(result, mode) {
|
|
234
|
+
if (mode === 'notes') {
|
|
235
|
+
return renderPostsMarkdown(result.profile.notes);
|
|
236
|
+
}
|
|
237
|
+
return renderProfileUserMarkdown(result);
|
|
238
|
+
}
|
|
231
239
|
async function captureProfile(page, targetUrl) {
|
|
232
240
|
let userPageData = null;
|
|
233
241
|
let notes = null;
|
|
@@ -286,26 +294,33 @@ export async function getProfile(session, url, userId) {
|
|
|
286
294
|
userId,
|
|
287
295
|
url,
|
|
288
296
|
fetchedAt: new Date().toISOString(),
|
|
289
|
-
user: normalizeProfileUser(
|
|
290
|
-
|
|
291
|
-
|
|
297
|
+
user: normalizeProfileUser({
|
|
298
|
+
...captured.userPageData,
|
|
299
|
+
userId
|
|
300
|
+
}),
|
|
301
|
+
notes: normalizeProfileNotes(captured.notes)
|
|
292
302
|
}
|
|
293
303
|
};
|
|
294
304
|
}
|
|
295
|
-
function writeProfileOutput(result,
|
|
296
|
-
|
|
297
|
-
|
|
305
|
+
function writeProfileOutput(result, values) {
|
|
306
|
+
const output = selectProfileOutput(result, values.mode);
|
|
307
|
+
if (values.format === 'json') {
|
|
308
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
309
|
+
writeJsonFile(output, savedPath);
|
|
310
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, output));
|
|
298
311
|
return;
|
|
299
312
|
}
|
|
300
|
-
process.stdout.write(renderProfileMarkdown(result));
|
|
313
|
+
process.stdout.write(renderProfileMarkdown(result, values.mode));
|
|
301
314
|
}
|
|
302
315
|
export async function runGetProfileCommand(values = {
|
|
303
|
-
format: 'md'
|
|
316
|
+
format: 'md',
|
|
317
|
+
mode: 'profile'
|
|
304
318
|
}) {
|
|
305
319
|
if (values.help) {
|
|
306
320
|
printGetProfileHelp();
|
|
307
321
|
return;
|
|
308
322
|
}
|
|
323
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
309
324
|
if (!values.id) {
|
|
310
325
|
throw new Error('Missing required option: --id');
|
|
311
326
|
}
|
|
@@ -315,7 +330,7 @@ export async function runGetProfileCommand(values = {
|
|
|
315
330
|
try {
|
|
316
331
|
await ensureRednoteLoggedIn(target, 'fetching profile', session);
|
|
317
332
|
const result = await getProfile(session, buildProfileUrl(normalizedUserId), normalizedUserId);
|
|
318
|
-
writeProfileOutput(result, values
|
|
333
|
+
writeProfileOutput(result, values);
|
|
319
334
|
} finally{
|
|
320
335
|
await disconnectRednoteSession(session);
|
|
321
336
|
}
|
package/dist/rednote/home.js
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { parseArgs } from 'node:util';
|
|
3
|
-
import {
|
|
3
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
4
4
|
import { resolveStatusTarget } from './status.js';
|
|
5
5
|
import * as cheerio from 'cheerio';
|
|
6
6
|
import vm from 'node:vm';
|
|
7
|
-
import { parseOutputCliArgs, renderPostsMarkdown, resolveSavePath, writePostsJsonl } from './output-format.js';
|
|
7
|
+
import { ensureJsonSavePath, parseOutputCliArgs, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, resolveSavePath, writeJsonFile, writePostsJsonl } from './output-format.js';
|
|
8
8
|
import { createRednoteSession, disconnectRednoteSession } from './checkLogin.js';
|
|
9
9
|
export function parseHomeCliArgs(argv) {
|
|
10
10
|
return parseOutputCliArgs(argv);
|
|
@@ -20,7 +20,7 @@ Usage:
|
|
|
20
20
|
Options:
|
|
21
21
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
22
22
|
--format FORMAT Output format: md | json. Default: md
|
|
23
|
-
--save [PATH]
|
|
23
|
+
--save [PATH] In markdown mode, saves posts as JSONL and uses a default path when PATH is omitted. In json mode, PATH is required and the full result is saved as JSON
|
|
24
24
|
-h, --help Show this help
|
|
25
25
|
`);
|
|
26
26
|
}
|
|
@@ -165,6 +165,13 @@ export async function getRednoteHomePosts(session) {
|
|
|
165
165
|
};
|
|
166
166
|
}
|
|
167
167
|
function writeHomeOutput(result, values) {
|
|
168
|
+
if (values.format === 'json') {
|
|
169
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
170
|
+
result.home.savedPath = savedPath;
|
|
171
|
+
writeJsonFile(result.home.posts, savedPath);
|
|
172
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, result.home.posts));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
168
175
|
const posts = result.home.posts;
|
|
169
176
|
let savedPath;
|
|
170
177
|
if (values.saveRequested) {
|
|
@@ -172,10 +179,6 @@ function writeHomeOutput(result, values) {
|
|
|
172
179
|
writePostsJsonl(posts, savedPath);
|
|
173
180
|
result.home.savedPath = savedPath;
|
|
174
181
|
}
|
|
175
|
-
if (values.format === 'json') {
|
|
176
|
-
printJson(result);
|
|
177
|
-
return;
|
|
178
|
-
}
|
|
179
182
|
let markdown = renderPostsMarkdown(posts);
|
|
180
183
|
if (savedPath) {
|
|
181
184
|
markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
|
|
@@ -190,6 +193,7 @@ export async function runHomeCommand(values = {
|
|
|
190
193
|
printHomeHelp();
|
|
191
194
|
return;
|
|
192
195
|
}
|
|
196
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
193
197
|
const target = resolveStatusTarget(values.instance);
|
|
194
198
|
const session = await createRednoteSession(target);
|
|
195
199
|
try {
|
package/dist/rednote/index.js
CHANGED
|
@@ -6,7 +6,7 @@ function printRednoteHelp() {
|
|
|
6
6
|
|
|
7
7
|
Commands:
|
|
8
8
|
browser <list|create|remove|connect>
|
|
9
|
-
env [--format md|json]
|
|
9
|
+
env [--format md|json] [--save PATH]
|
|
10
10
|
status [--instance NAME]
|
|
11
11
|
check-login [--instance NAME]
|
|
12
12
|
login [--instance NAME]
|
|
@@ -14,21 +14,22 @@ Commands:
|
|
|
14
14
|
interact [--instance NAME] --url URL [--like] [--collect] [--comment TEXT]
|
|
15
15
|
home [--instance NAME] [--format md|json] [--save [PATH]]
|
|
16
16
|
search [--instance NAME] --keyword TEXT [--format md|json] [--save [PATH]]
|
|
17
|
-
get-feed-detail [--instance NAME] --url URL [--url URL] [--format md|json]
|
|
18
|
-
get-profile [--instance NAME] --id USER_ID [--format md|json]
|
|
17
|
+
get-feed-detail [--instance NAME] --url URL [--url URL] [--comments [COUNT]] [--format md|json] [--save PATH]
|
|
18
|
+
get-profile [--instance NAME] --id USER_ID [--mode profile|notes] [--format md|json] [--save PATH]
|
|
19
19
|
|
|
20
20
|
Examples:
|
|
21
21
|
npx -y @skills-store/rednote browser list
|
|
22
22
|
npx -y @skills-store/rednote browser create --name seller-main --browser chrome
|
|
23
23
|
npx -y @skills-store/rednote env
|
|
24
|
+
npx -y @skills-store/rednote env --format json --save ./output/env.json
|
|
24
25
|
npx -y @skills-store/rednote status --instance seller-main
|
|
25
26
|
npx -y @skills-store/rednote login --instance seller-main
|
|
26
|
-
npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title
|
|
27
|
-
npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "
|
|
27
|
+
npx -y @skills-store/rednote publish --instance seller-main --type video --video ./note.mp4 --title "Video title" --content "Video description"
|
|
28
|
+
npx -y @skills-store/rednote interact --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --like --collect --comment "Great post"
|
|
28
29
|
npx -y @skills-store/rednote home --instance seller-main --format md --save
|
|
29
|
-
npx -y @skills-store/rednote search --instance seller-main --keyword
|
|
30
|
-
npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy"
|
|
31
|
-
npx -y @skills-store/rednote get-profile --instance seller-main --id USER_ID
|
|
30
|
+
npx -y @skills-store/rednote search --instance seller-main --keyword skincare --format json --save ./output/search.json
|
|
31
|
+
npx -y @skills-store/rednote get-feed-detail --instance seller-main --url "https://www.xiaohongshu.com/explore/xxx?xsec_token=yyy" --comments 100 --format json --save ./output/feed-detail.json
|
|
32
|
+
npx -y @skills-store/rednote get-profile --instance seller-main --id USER_ID --mode notes --format json --save ./output/profile-notes.json
|
|
32
33
|
`);
|
|
33
34
|
}
|
|
34
35
|
function parseBasicArgs(argv) {
|
package/dist/rednote/interact.js
CHANGED
|
@@ -135,7 +135,7 @@ async function requireVisibleLocator(locator, errorMessage, timeoutMs = 5_000) {
|
|
|
135
135
|
}
|
|
136
136
|
async function typeCommentContent(page, content) {
|
|
137
137
|
const commentInput = page.locator(COMMENT_INPUT_SELECTOR);
|
|
138
|
-
const visibleCommentInput = await requireVisibleLocator(commentInput, '
|
|
138
|
+
const visibleCommentInput = await requireVisibleLocator(commentInput, 'Could not find the comment input. Make sure the feed detail page finished loading.', 15_000);
|
|
139
139
|
await visibleCommentInput.scrollIntoViewIfNeeded();
|
|
140
140
|
await visibleCommentInput.click({
|
|
141
141
|
force: true
|
|
@@ -160,7 +160,7 @@ async function clickSendComment(page) {
|
|
|
160
160
|
const sendButton = page.locator(COMMENT_SEND_BUTTON_SELECTOR).filter({
|
|
161
161
|
hasText: COMMENT_SEND_BUTTON_TEXT
|
|
162
162
|
});
|
|
163
|
-
const visibleSendButton = await requireVisibleLocator(sendButton, '
|
|
163
|
+
const visibleSendButton = await requireVisibleLocator(sendButton, 'Could not find the Send button. Make sure the comment toolbar finished loading.', 15_000);
|
|
164
164
|
await page.waitForFunction(({ selector, text })=>{
|
|
165
165
|
const buttons = [
|
|
166
166
|
...document.querySelectorAll(selector)
|
|
@@ -198,10 +198,10 @@ async function commentOnCurrentFeedPage(page, content) {
|
|
|
198
198
|
async function waitForInteractContainer(page) {
|
|
199
199
|
await page.waitForLoadState('domcontentloaded');
|
|
200
200
|
await page.waitForTimeout(500);
|
|
201
|
-
await requireVisibleLocator(page.locator(INTERACT_CONTAINER_SELECTOR), '
|
|
201
|
+
await requireVisibleLocator(page.locator(INTERACT_CONTAINER_SELECTOR), 'Could not find the interaction toolbar. Make sure the feed detail page finished loading.', 15_000);
|
|
202
202
|
}
|
|
203
203
|
function getActionErrorMessage(action) {
|
|
204
|
-
return action === 'like' ? '
|
|
204
|
+
return action === 'like' ? 'Could not find the Like button. Make sure the feed detail page finished loading.' : 'Could not find the Collect button. Make sure the feed detail page finished loading.';
|
|
205
205
|
}
|
|
206
206
|
async function ensureActionApplied(page, action, alreadyActive) {
|
|
207
207
|
if (alreadyActive) {
|
package/dist/rednote/login.js
CHANGED
|
@@ -72,7 +72,7 @@ async function saveQrCodeImage(page) {
|
|
|
72
72
|
});
|
|
73
73
|
return filePath;
|
|
74
74
|
}
|
|
75
|
-
throw new Error('
|
|
75
|
+
throw new Error('No usable Xiaohongshu login QR code was detected. Make sure the login dialog is open.');
|
|
76
76
|
}
|
|
77
77
|
function printLoginHelp() {
|
|
78
78
|
process.stdout.write(`rednote login
|
|
@@ -108,11 +108,12 @@ export async function openRednoteLogin(target, session) {
|
|
|
108
108
|
pageUrl: session.page.url(),
|
|
109
109
|
waitingForPhoneLogin: false,
|
|
110
110
|
qrCodePath: null,
|
|
111
|
-
message: '
|
|
111
|
+
message: 'The current instance is already logged in. No additional login step is required.'
|
|
112
112
|
}
|
|
113
113
|
};
|
|
114
114
|
}
|
|
115
115
|
const { page } = await getOrCreateXiaohongshuPage(session);
|
|
116
|
+
await page.reload();
|
|
116
117
|
const loginButton = page.locator('#login-btn');
|
|
117
118
|
const hasLoginButton = await loginButton.count() > 0;
|
|
118
119
|
if (!hasLoginButton) {
|
|
@@ -123,12 +124,13 @@ export async function openRednoteLogin(target, session) {
|
|
|
123
124
|
pageUrl: page.url(),
|
|
124
125
|
waitingForPhoneLogin: false,
|
|
125
126
|
qrCodePath: null,
|
|
126
|
-
message: '
|
|
127
|
+
message: 'No login button was found. The current instance may already be logged in.'
|
|
127
128
|
}
|
|
128
129
|
};
|
|
129
130
|
}
|
|
130
131
|
await loginButton.first().click({
|
|
131
|
-
timeout: 2000
|
|
132
|
+
timeout: 2000,
|
|
133
|
+
force: true
|
|
132
134
|
});
|
|
133
135
|
await page.waitForTimeout(500);
|
|
134
136
|
const qrCodePath = await saveQrCodeImage(page);
|
|
@@ -139,7 +141,7 @@ export async function openRednoteLogin(target, session) {
|
|
|
139
141
|
pageUrl: page.url(),
|
|
140
142
|
waitingForPhoneLogin: true,
|
|
141
143
|
qrCodePath,
|
|
142
|
-
message: '
|
|
144
|
+
message: 'The login button was clicked and the QR code image was exported. Scan the code to finish logging in.'
|
|
143
145
|
}
|
|
144
146
|
};
|
|
145
147
|
}
|
|
@@ -98,12 +98,86 @@ export function writePostsJsonl(posts, filePath) {
|
|
|
98
98
|
const content = posts.map((post)=>JSON.stringify(post)).join('\n');
|
|
99
99
|
fs.writeFileSync(filePath, content ? `${content}\n` : '', 'utf8');
|
|
100
100
|
}
|
|
101
|
+
export function ensureJsonSavePath(format, savePath) {
|
|
102
|
+
if (format !== 'json') {
|
|
103
|
+
return;
|
|
104
|
+
}
|
|
105
|
+
if (!savePath?.trim()) {
|
|
106
|
+
throw new Error('The --save PATH option is required when --format json is used.');
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
export function resolveJsonSavePath(explicitPath) {
|
|
110
|
+
const normalizedPath = explicitPath?.trim();
|
|
111
|
+
if (!normalizedPath) {
|
|
112
|
+
throw new Error('The --save PATH option is required when --format json is used.');
|
|
113
|
+
}
|
|
114
|
+
return path.resolve(normalizedPath);
|
|
115
|
+
}
|
|
116
|
+
export function writeJsonFile(payload, filePath) {
|
|
117
|
+
fs.mkdirSync(path.dirname(filePath), {
|
|
118
|
+
recursive: true
|
|
119
|
+
});
|
|
120
|
+
fs.writeFileSync(filePath, `${JSON.stringify(payload, null, 2)}\n`, 'utf8');
|
|
121
|
+
}
|
|
122
|
+
function describeStringValue(value, key) {
|
|
123
|
+
if (/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value) || key.toLowerCase().endsWith('at')) {
|
|
124
|
+
return 'ISO-8601 string';
|
|
125
|
+
}
|
|
126
|
+
if (key.toLowerCase().endsWith('url')) {
|
|
127
|
+
return 'string (URL)';
|
|
128
|
+
}
|
|
129
|
+
if (key.toLowerCase().endsWith('path')) {
|
|
130
|
+
return 'string (path)';
|
|
131
|
+
}
|
|
132
|
+
return 'string';
|
|
133
|
+
}
|
|
134
|
+
function buildJsonFieldExample(value, key = '', depth = 0) {
|
|
135
|
+
if (value === null) {
|
|
136
|
+
return 'null';
|
|
137
|
+
}
|
|
138
|
+
if (value === undefined) {
|
|
139
|
+
return 'undefined';
|
|
140
|
+
}
|
|
141
|
+
if (typeof value === 'string') {
|
|
142
|
+
return describeStringValue(value, key);
|
|
143
|
+
}
|
|
144
|
+
if (typeof value === 'number') {
|
|
145
|
+
return 'number';
|
|
146
|
+
}
|
|
147
|
+
if (typeof value === 'boolean') {
|
|
148
|
+
return 'boolean';
|
|
149
|
+
}
|
|
150
|
+
if (Array.isArray(value)) {
|
|
151
|
+
return value.length > 0 ? [
|
|
152
|
+
buildJsonFieldExample(value[0], key, depth + 1)
|
|
153
|
+
] : [
|
|
154
|
+
'unknown'
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
if (typeof value === 'object') {
|
|
158
|
+
if (depth >= 3 || key === 'raw') {
|
|
159
|
+
return 'object';
|
|
160
|
+
}
|
|
161
|
+
const entries = Object.entries(value);
|
|
162
|
+
if (entries.length === 0) {
|
|
163
|
+
return 'object';
|
|
164
|
+
}
|
|
165
|
+
return Object.fromEntries(entries.map(([entryKey, entryValue])=>[
|
|
166
|
+
entryKey,
|
|
167
|
+
buildJsonFieldExample(entryValue, entryKey, depth + 1)
|
|
168
|
+
]));
|
|
169
|
+
}
|
|
170
|
+
return typeof value;
|
|
171
|
+
}
|
|
172
|
+
export function renderJsonSaveSummary(filePath, payload) {
|
|
173
|
+
return `Saved JSON: ${filePath}\n\nField format example:\n${JSON.stringify(buildJsonFieldExample(payload), null, 3)}\n`;
|
|
174
|
+
}
|
|
101
175
|
function formatField(value) {
|
|
102
176
|
return value ?? '';
|
|
103
177
|
}
|
|
104
178
|
export function renderPostsMarkdown(posts) {
|
|
105
179
|
if (posts.length === 0) {
|
|
106
|
-
return '
|
|
180
|
+
return 'No posts were captured.\n';
|
|
107
181
|
}
|
|
108
182
|
return `${posts.map((post)=>[
|
|
109
183
|
`- id: ${post.id}`,
|
package/dist/rednote/publish.js
CHANGED
|
@@ -34,24 +34,36 @@ function printPublishHelp() {
|
|
|
34
34
|
process.stdout.write(`rednote publish
|
|
35
35
|
|
|
36
36
|
Usage:
|
|
37
|
-
npx -y @skills-store/rednote publish --type video --video ./video.mp4 --title
|
|
38
|
-
npx -y @skills-store/rednote publish --type image --image ./1.jpg --image ./2.jpg --title
|
|
39
|
-
npx -y @skills-store/rednote publish --type article --title
|
|
37
|
+
npx -y @skills-store/rednote publish --type video --video ./video.mp4 --title "Video title" --content "Video description" [--tag fashion] [--tag ootd] [--publish] [--instance NAME]
|
|
38
|
+
npx -y @skills-store/rednote publish --type image --image ./1.jpg --image ./2.jpg --title "Image title" --content "Image description" [--tag travel] [--publish] [--instance NAME]
|
|
39
|
+
npx -y @skills-store/rednote publish --type article --title "Article title" --content '# Heading\n\nBody content' [--publish] [--instance NAME]
|
|
40
40
|
node --experimental-strip-types ./scripts/rednote/publish.ts ...
|
|
41
41
|
bun ./scripts/rednote/publish.ts ...
|
|
42
42
|
|
|
43
43
|
Options:
|
|
44
44
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
45
|
-
--type TYPE
|
|
46
|
-
--title TEXT Required.
|
|
47
|
-
--content TEXT
|
|
48
|
-
--tag TEXT
|
|
49
|
-
--video PATH
|
|
50
|
-
--image PATH
|
|
51
|
-
--publish
|
|
45
|
+
--type TYPE Optional. video | image | article. If omitted, the type is inferred from the provided assets
|
|
46
|
+
--title TEXT Required. Post title
|
|
47
|
+
--content TEXT Required. Description for video/image posts, or Markdown content for article posts
|
|
48
|
+
--tag TEXT Optional. Repeat to provide multiple tags, for example: --tag fashion --tag OOTD
|
|
49
|
+
--video PATH Required for video posts. Only one video file is accepted
|
|
50
|
+
--image PATH Required for image posts. Repeat to provide multiple images, up to ${MAX_IMAGE_COUNT}; the first image becomes the cover
|
|
51
|
+
--publish Publish immediately. If omitted, the content is saved as a draft
|
|
52
52
|
-h, --help Show this help
|
|
53
53
|
`);
|
|
54
54
|
}
|
|
55
|
+
function describePublishType(type) {
|
|
56
|
+
if (type === 'video') {
|
|
57
|
+
return 'Upload Video';
|
|
58
|
+
}
|
|
59
|
+
if (type === 'image') {
|
|
60
|
+
return 'Upload Image Post';
|
|
61
|
+
}
|
|
62
|
+
return 'Write Article';
|
|
63
|
+
}
|
|
64
|
+
function describePublishAction(draft) {
|
|
65
|
+
return draft ? 'Save Draft and Leave' : 'Publish';
|
|
66
|
+
}
|
|
55
67
|
function isCreatorHomeUrl(url) {
|
|
56
68
|
return url === CREATOR_HOME_URL || url.startsWith(`${CREATOR_HOME_URL}?`) || url.startsWith(`${CREATOR_HOME_URL}#`);
|
|
57
69
|
}
|
|
@@ -313,13 +325,13 @@ async function openPublishComposer(page, type) {
|
|
|
313
325
|
const publishNoteButton = page.locator(PUBLISH_NOTE_BUTTON_SELECTOR).filter({
|
|
314
326
|
hasText: PUBLISH_NOTE_BUTTON_TEXT
|
|
315
327
|
});
|
|
316
|
-
const visiblePublishNoteButton = await requireVisibleLocator(publishNoteButton, '
|
|
328
|
+
const visiblePublishNoteButton = await requireVisibleLocator(publishNoteButton, 'Could not find the Publish Note button. Make sure the Creator Services home page is open.', 15_000);
|
|
317
329
|
await visiblePublishNoteButton.click();
|
|
318
330
|
const publishTypeTabText = resolvePublishTypeTabText(type);
|
|
319
331
|
const publishTypeTab = page.locator(PUBLISH_TYPE_TAB_SELECTOR).filter({
|
|
320
332
|
hasText: publishTypeTabText
|
|
321
333
|
}).last();
|
|
322
|
-
const visiblePublishTypeTab = await requireVisibleLocator(publishTypeTab,
|
|
334
|
+
const visiblePublishTypeTab = await requireVisibleLocator(publishTypeTab, `Could not find the ${describePublishType(type)} entry. Make sure the Creator Services page finished loading.`, 15_000);
|
|
323
335
|
await visiblePublishTypeTab.click();
|
|
324
336
|
await page.waitForTimeout(300);
|
|
325
337
|
}
|
|
@@ -327,7 +339,7 @@ async function uploadVideoFile(page, videoPath) {
|
|
|
327
339
|
const uploadVideoButton = page.locator(VIDEO_UPLOAD_BUTTON_SELECTOR).filter({
|
|
328
340
|
hasText: VIDEO_UPLOAD_BUTTON_TEXT
|
|
329
341
|
});
|
|
330
|
-
const visibleUploadVideoButton = await requireVisibleLocator(uploadVideoButton, '
|
|
342
|
+
const visibleUploadVideoButton = await requireVisibleLocator(uploadVideoButton, 'Could not find the Upload Video button. Make sure the video publishing page is open.', 15_000);
|
|
331
343
|
const fileChooserPromise = page.waitForEvent('filechooser', {
|
|
332
344
|
timeout: 3_000
|
|
333
345
|
}).catch(()=>null);
|
|
@@ -339,7 +351,7 @@ async function uploadVideoFile(page, videoPath) {
|
|
|
339
351
|
}
|
|
340
352
|
const videoFileInput = page.locator(`${VIDEO_FILE_INPUT_SELECTOR}[accept*="video"], ${VIDEO_FILE_INPUT_SELECTOR}`);
|
|
341
353
|
if (await videoFileInput.count() === 0) {
|
|
342
|
-
throw new Error('
|
|
354
|
+
throw new Error('Could not find the video file input. Make sure the upload component finished loading.');
|
|
343
355
|
}
|
|
344
356
|
await videoFileInput.first().setInputFiles(videoPath);
|
|
345
357
|
}
|
|
@@ -347,7 +359,7 @@ async function uploadImageFiles(page, imagePaths) {
|
|
|
347
359
|
const uploadImageButton = page.locator(IMAGE_UPLOAD_BUTTON_SELECTOR).filter({
|
|
348
360
|
hasText: IMAGE_UPLOAD_BUTTON_TEXT
|
|
349
361
|
});
|
|
350
|
-
const visibleUploadImageButton = await requireVisibleLocator(uploadImageButton, '
|
|
362
|
+
const visibleUploadImageButton = await requireVisibleLocator(uploadImageButton, 'Could not find the Upload Image button. Make sure the image publishing page is open.', 15_000);
|
|
351
363
|
const fileChooserPromise = page.waitForEvent('filechooser', {
|
|
352
364
|
timeout: 3_000
|
|
353
365
|
}).catch(()=>null);
|
|
@@ -359,13 +371,13 @@ async function uploadImageFiles(page, imagePaths) {
|
|
|
359
371
|
}
|
|
360
372
|
const imageFileInput = page.locator(`${IMAGE_FILE_INPUT_SELECTOR}[accept*="image"], ${IMAGE_FILE_INPUT_SELECTOR}`);
|
|
361
373
|
if (await imageFileInput.count() === 0) {
|
|
362
|
-
throw new Error('
|
|
374
|
+
throw new Error('Could not find the image file input. Make sure the upload component finished loading.');
|
|
363
375
|
}
|
|
364
376
|
await imageFileInput.first().setInputFiles(imagePaths);
|
|
365
377
|
}
|
|
366
378
|
async function waitForImagePublishPage(page) {
|
|
367
379
|
const imagePublishPage = page.locator(IMAGE_PUBLISH_PAGE_SELECTOR);
|
|
368
|
-
const visibleImagePublishPage = await requireVisibleLocator(imagePublishPage, '
|
|
380
|
+
const visibleImagePublishPage = await requireVisibleLocator(imagePublishPage, 'The image publishing page did not appear. Make sure the image upload completed successfully.', 30_000);
|
|
369
381
|
await visibleImagePublishPage.waitFor({
|
|
370
382
|
state: 'visible',
|
|
371
383
|
timeout: 30_000
|
|
@@ -373,13 +385,13 @@ async function waitForImagePublishPage(page) {
|
|
|
373
385
|
}
|
|
374
386
|
async function fillImageTitle(page, title) {
|
|
375
387
|
const imageTitleInput = page.locator(IMAGE_TITLE_INPUT_SELECTOR);
|
|
376
|
-
const visibleImageTitleInput = await requireVisibleLocator(imageTitleInput, '
|
|
388
|
+
const visibleImageTitleInput = await requireVisibleLocator(imageTitleInput, 'Could not find the image post title input. Make sure the image publishing page finished loading.', 15_000);
|
|
377
389
|
await visibleImageTitleInput.fill(title);
|
|
378
390
|
await page.waitForTimeout(200);
|
|
379
391
|
}
|
|
380
392
|
async function fillImageContent(page, content) {
|
|
381
393
|
const imageContentEditor = page.locator(IMAGE_CONTENT_EDITOR_SELECTOR);
|
|
382
|
-
const visibleImageContentEditor = await requireVisibleLocator(imageContentEditor, '
|
|
394
|
+
const visibleImageContentEditor = await requireVisibleLocator(imageContentEditor, 'Could not find the image post content editor. Make sure the image publishing page finished loading.', 15_000);
|
|
383
395
|
await visibleImageContentEditor.fill(content);
|
|
384
396
|
await page.waitForTimeout(200);
|
|
385
397
|
}
|
|
@@ -387,19 +399,19 @@ async function openArticleEditor(page) {
|
|
|
387
399
|
const articleNewButton = page.locator(ARTICLE_NEW_BUTTON_SELECTOR).filter({
|
|
388
400
|
hasText: ARTICLE_NEW_BUTTON_TEXT
|
|
389
401
|
});
|
|
390
|
-
const visibleArticleNewButton = await requireVisibleLocator(articleNewButton, '
|
|
402
|
+
const visibleArticleNewButton = await requireVisibleLocator(articleNewButton, 'Could not find the New Creation button. Make sure the article publishing page is open.', 15_000);
|
|
391
403
|
await visibleArticleNewButton.click();
|
|
392
404
|
await page.waitForTimeout(300);
|
|
393
405
|
}
|
|
394
406
|
async function fillArticleTitle(page, title) {
|
|
395
407
|
const articleTitleInput = page.locator(ARTICLE_TITLE_INPUT_SELECTOR);
|
|
396
|
-
const visibleArticleTitleInput = await requireVisibleLocator(articleTitleInput, '
|
|
408
|
+
const visibleArticleTitleInput = await requireVisibleLocator(articleTitleInput, 'Could not find the article title input. Make sure the article editor is open.', 15_000);
|
|
397
409
|
await visibleArticleTitleInput.fill(title);
|
|
398
410
|
await page.waitForTimeout(200);
|
|
399
411
|
}
|
|
400
412
|
async function fillArticleContent(page, content) {
|
|
401
413
|
const articleContentEditor = page.locator(ARTICLE_CONTENT_EDITOR_SELECTOR);
|
|
402
|
-
const visibleArticleContentEditor = await requireVisibleLocator(articleContentEditor, '
|
|
414
|
+
const visibleArticleContentEditor = await requireVisibleLocator(articleContentEditor, 'Could not find the article content editor. Make sure the article editor is open.', 15_000);
|
|
403
415
|
await visibleArticleContentEditor.fill(content);
|
|
404
416
|
await page.waitForTimeout(200);
|
|
405
417
|
}
|
|
@@ -427,7 +439,7 @@ async function finalizePublish(page, draft) {
|
|
|
427
439
|
const publishActionButton = page.locator(PUBLISH_ACTION_BUTTON_SELECTOR).filter({
|
|
428
440
|
hasText: buttonText
|
|
429
441
|
});
|
|
430
|
-
const visiblePublishActionButton = await requireVisibleLocator(publishActionButton,
|
|
442
|
+
const visiblePublishActionButton = await requireVisibleLocator(publishActionButton, `Could not find the ${describePublishAction(draft)} button. Make sure the publish page finished loading.`, 15_000);
|
|
431
443
|
await visiblePublishActionButton.click();
|
|
432
444
|
await page.waitForTimeout(500);
|
|
433
445
|
}
|
|
@@ -442,7 +454,7 @@ export async function openRednotePublish(session, payload) {
|
|
|
442
454
|
const creatorServiceLink = resolved.page.locator(CREATOR_SERVICE_SELECTOR).filter({
|
|
443
455
|
hasText: '创作服务'
|
|
444
456
|
});
|
|
445
|
-
const visibleCreatorServiceLink = await requireVisibleLocator(creatorServiceLink, '
|
|
457
|
+
const visibleCreatorServiceLink = await requireVisibleLocator(creatorServiceLink, 'Could not find the Creator Services entry. Open the Xiaohongshu home page and make sure the account is logged in.');
|
|
446
458
|
const popupPromise = session.browserContext.waitForEvent('page', {
|
|
447
459
|
timeout: 3_000
|
|
448
460
|
}).catch(()=>null);
|
|
@@ -454,7 +466,7 @@ export async function openRednotePublish(session, payload) {
|
|
|
454
466
|
} catch {
|
|
455
467
|
const existingCreatorHomePage = getSessionPages(session).find((page)=>isCreatorHomeUrl(page.url()));
|
|
456
468
|
if (!existingCreatorHomePage) {
|
|
457
|
-
throw new Error(
|
|
469
|
+
throw new Error(`After clicking Creator Services, the page did not navigate to ${CREATOR_HOME_URL}`);
|
|
458
470
|
}
|
|
459
471
|
targetPage = existingCreatorHomePage;
|
|
460
472
|
openedInNewPage = targetPage !== resolved.page;
|
|
@@ -467,7 +479,7 @@ export async function openRednotePublish(session, payload) {
|
|
|
467
479
|
await finalizePublish(targetPage, payload.draft);
|
|
468
480
|
return {
|
|
469
481
|
ok: true,
|
|
470
|
-
message: payload.draft ? '
|
|
482
|
+
message: payload.draft ? 'Publishing page actions completed and Save Draft and Leave was clicked. The content was saved as a draft.' : 'Publishing page actions completed and Publish was clicked.'
|
|
471
483
|
};
|
|
472
484
|
}
|
|
473
485
|
export async function runPublishCommand(values) {
|
package/dist/rednote/search.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
2
|
+
import { runCli } from '../utils/browser-cli.js';
|
|
3
3
|
import { resolveStatusTarget } from './status.js';
|
|
4
4
|
import { createRednoteSession, disconnectRednoteSession, ensureRednoteLoggedIn } from './checkLogin.js';
|
|
5
|
-
import { parseOutputCliArgs, renderPostsMarkdown, resolveSavePath, writePostsJsonl } from './output-format.js';
|
|
5
|
+
import { ensureJsonSavePath, parseOutputCliArgs, renderJsonSaveSummary, renderPostsMarkdown, resolveJsonSavePath, resolveSavePath, writeJsonFile, writePostsJsonl } from './output-format.js';
|
|
6
6
|
export function parseSearchCliArgs(argv) {
|
|
7
7
|
return parseOutputCliArgs(argv, {
|
|
8
8
|
includeKeyword: true
|
|
@@ -20,7 +20,7 @@ Options:
|
|
|
20
20
|
--instance NAME Optional. Defaults to the saved lastConnect instance
|
|
21
21
|
--keyword TEXT Required. Search keyword
|
|
22
22
|
--format FORMAT Output format: md | json. Default: md
|
|
23
|
-
--save [PATH]
|
|
23
|
+
--save [PATH] In markdown mode, saves posts as JSONL and uses a default path when PATH is omitted. In json mode, PATH is required and the posts array is saved as JSON
|
|
24
24
|
-h, --help Show this help
|
|
25
25
|
`);
|
|
26
26
|
}
|
|
@@ -84,6 +84,26 @@ function normalizeSearchPost(item) {
|
|
|
84
84
|
}
|
|
85
85
|
};
|
|
86
86
|
}
|
|
87
|
+
function toSearchJsonPost(post) {
|
|
88
|
+
return {
|
|
89
|
+
id: post.id,
|
|
90
|
+
modelType: post.modelType,
|
|
91
|
+
xsecToken: post.xsecToken,
|
|
92
|
+
url: post.url,
|
|
93
|
+
displayTitle: post.noteCard.displayTitle,
|
|
94
|
+
cover: post.noteCard.cover.urlDefault,
|
|
95
|
+
userId: post.noteCard.user.userId,
|
|
96
|
+
nickname: post.noteCard.user.nickname,
|
|
97
|
+
avatar: post.noteCard.user.avatar,
|
|
98
|
+
liked: post.noteCard.interactInfo.liked,
|
|
99
|
+
likedCount: post.noteCard.interactInfo.likedCount,
|
|
100
|
+
commentCount: post.noteCard.interactInfo.commentCount,
|
|
101
|
+
collectedCount: post.noteCard.interactInfo.collectedCount,
|
|
102
|
+
sharedCount: post.noteCard.interactInfo.sharedCount,
|
|
103
|
+
imageList: post.noteCard.imageList.map((image)=>image.infoList.find((info)=>typeof info.url === 'string' && info.url)?.url ?? null).filter((url)=>typeof url === 'string' && url.length > 0),
|
|
104
|
+
videoDuration: post.noteCard.video.duration
|
|
105
|
+
};
|
|
106
|
+
}
|
|
87
107
|
async function getOrCreateXiaohongshuPage(session) {
|
|
88
108
|
return session.page;
|
|
89
109
|
}
|
|
@@ -157,6 +177,13 @@ export async function searchRednotePosts(session, keyword) {
|
|
|
157
177
|
};
|
|
158
178
|
}
|
|
159
179
|
function writeSearchOutput(result, values) {
|
|
180
|
+
if (values.format === 'json') {
|
|
181
|
+
const savedPath = resolveJsonSavePath(values.savePath);
|
|
182
|
+
const posts = result.search.posts.map(toSearchJsonPost);
|
|
183
|
+
writeJsonFile(posts, savedPath);
|
|
184
|
+
process.stdout.write(renderJsonSaveSummary(savedPath, posts));
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
160
187
|
const posts = result.search.posts;
|
|
161
188
|
let savedPath;
|
|
162
189
|
if (values.saveRequested) {
|
|
@@ -164,10 +191,6 @@ function writeSearchOutput(result, values) {
|
|
|
164
191
|
writePostsJsonl(posts, savedPath);
|
|
165
192
|
result.search.savedPath = savedPath;
|
|
166
193
|
}
|
|
167
|
-
if (values.format === 'json') {
|
|
168
|
-
printJson(result);
|
|
169
|
-
return;
|
|
170
|
-
}
|
|
171
194
|
let markdown = renderPostsMarkdown(posts);
|
|
172
195
|
if (savedPath) {
|
|
173
196
|
markdown = `Saved JSONL: ${savedPath}\n\n${markdown}`;
|
|
@@ -182,6 +205,7 @@ export async function runSearchCommand(values = {
|
|
|
182
205
|
printSearchHelp();
|
|
183
206
|
return;
|
|
184
207
|
}
|
|
208
|
+
ensureJsonSavePath(values.format, values.savePath);
|
|
185
209
|
const keyword = values.keyword?.trim();
|
|
186
210
|
if (!keyword) {
|
|
187
211
|
throw new Error('Missing required option: --keyword');
|