@skills-store/rednote 0.1.11 → 0.1.13

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/bin/rednote.js ADDED
@@ -0,0 +1,26 @@
1
+ #!/usr/bin/env node
2
+ import { runRootCli } from '../dist/index.js';
3
+ import { stringifyError } from '../dist/utils/browser-cli.js';
4
+
5
+ async function finalizeCliProcess(exitCode) {
6
+ await new Promise((resolve) => process.stdout.write('', () => resolve()));
7
+ await new Promise((resolve) => process.stderr.write('', () => resolve()));
8
+ process.exit(exitCode);
9
+ }
10
+
11
+ try {
12
+ await runRootCli(process.argv.slice(2));
13
+ await finalizeCliProcess(0);
14
+ } catch (error) {
15
+ process.stderr.write(
16
+ `${JSON.stringify(
17
+ {
18
+ ok: false,
19
+ error: stringifyError(error),
20
+ },
21
+ null,
22
+ 2,
23
+ )}\n`,
24
+ );
25
+ await finalizeCliProcess(1);
26
+ }
@@ -1,17 +1,19 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
- import { printJson, runCli } from '../utils/browser-cli.js';
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
- printJson(getRednoteEnvironmentInfo());
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(values);
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 { printJson, runCli } from '../utils/browser-cli.js';
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 { values, positionals } = parseArgs({
25
- args: argv,
26
- allowPositionals: true,
27
- strict: false,
28
- options: {
29
- instance: {
30
- type: 'string'
31
- },
32
- url: {
33
- type: 'string',
34
- multiple: true
35
- },
36
- format: {
37
- type: 'string'
38
- },
39
- help: {
40
- type: 'boolean',
41
- short: 'h'
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
- const format = values.format ?? 'md';
49
- if (format !== 'md' && format !== 'json') {
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
- interactInfo: {
104
- liked: note?.interactInfo?.liked ?? null,
105
- likedCount: note?.interactInfo?.likedCount ?? null,
106
- commentCount: note?.interactInfo?.commentCount ?? null,
107
- collected: note?.interactInfo?.collected ?? null,
108
- collectedCount: note?.interactInfo?.collectedCount ?? null,
109
- shareCount: note?.interactInfo?.shareCount ?? null,
110
- followed: note?.interactInfo?.followed ?? null
111
- },
112
- tagList: Array.isArray(note?.tagList) ? note.tagList.map((tag)=>({
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
- likedCount: comment?.interactInfo?.likedCount ?? null,
135
- subCommentCount: typeof comment?.subCommentCount === 'number' ? comment.subCommentCount : null,
136
- raw: comment
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 '没有获取到帖子详情。\n';
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(`- Type: ${formatDetailField(item.note.type)}`);
153
- lines.push(`- Liked: ${formatDetailField(item.note.interactInfo.liked)}`);
154
- lines.push(`- Collected: ${formatDetailField(item.note.interactInfo.collected)}`);
155
- lines.push(`- LikedCount: ${formatDetailField(item.note.interactInfo.likedCount)}`);
156
- lines.push(`- CommentCount: ${formatDetailField(item.note.interactInfo.commentCount)}`);
157
- lines.push(`- CollectedCount: ${formatDetailField(item.note.interactInfo.collectedCount)}`);
158
- lines.push(`- ShareCount: ${formatDetailField(item.note.interactInfo.shareCount)}`);
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?.url) {
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((image, index)=>{
169
- if (image.urlDefault) {
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?.url) {
174
- lines.push(`- Video: ${item.note.video.url}`);
281
+ if (item.note.video) {
282
+ lines.push(`- Video: ${item.note.video}`);
175
283
  }
176
284
  }
177
- lines.push('');
178
- lines.push('## Comments');
179
- lines.push('');
180
- if (item.comments.length === 0) {
181
- lines.push('- Comments not found');
182
- } else {
183
- item.comments.forEach((comment)=>{
184
- const prefix = comment.nickname ? `${comment.nickname}: ` : '';
185
- lines.push(`- ${prefix}${comment.content ?? ''}`);
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 comments = null;
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
- comments = Array.isArray(data?.data?.comments) ? data.data.comments : [];
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 && comments !== null) {
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
- comments: normalizeComments(comments ?? [])
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 writeFeedDetailOutput(result, format) {
265
- if (format === 'json') {
266
- printJson(result);
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.format);
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 { printJson, runCli } from '../utils/browser-cli.js';
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 renderProfileMarkdown(result) {
201
- const { user, notes, url, userId } = result.profile;
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(captured.userPageData),
290
- notes: normalizeProfileNotes(captured.notes),
291
- raw: captured
297
+ user: normalizeProfileUser({
298
+ ...captured.userPageData,
299
+ userId
300
+ }),
301
+ notes: normalizeProfileNotes(captured.notes)
292
302
  }
293
303
  };
294
304
  }
295
- function writeProfileOutput(result, format) {
296
- if (format === 'json') {
297
- printJson(result);
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.format);
333
+ writeProfileOutput(result, values);
319
334
  } finally{
320
335
  await disconnectRednoteSession(session);
321
336
  }
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env node
2
2
  import { parseArgs } from 'node:util';
3
- import { printJson, runCli } from '../utils/browser-cli.js';
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] Save posts as JSONL. Uses a default path when PATH is omitted
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 {
@@ -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 标题 --content 描述
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 护肤 --format json --save ./output/search.jsonl
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) {
@@ -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, '未找到评论输入框,请确认帖子详情页已正确加载。', 15_000);
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, '未找到“发送”按钮,请确认评论工具栏已正确加载。', 15_000);
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), '未找到互动工具栏,请确认帖子详情页已正确加载。', 15_000);
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) {
@@ -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 '没有获取到帖子。\n';
180
+ return 'No posts were captured.\n';
107
181
  }
108
182
  return `${posts.map((post)=>[
109
183
  `- id: ${post.id}`,
@@ -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 标题 --content 描述 [--tag 穿搭] [--tag 日常] [--publish] [--instance NAME]
38
- npx -y @skills-store/rednote publish --type image --image ./1.jpg --image ./2.jpg --title 标题 --content 描述 [--tag 探店] [--publish] [--instance NAME]
39
- npx -y @skills-store/rednote publish --type article --title 标题 --content '# 一级标题\n\n正文' [--publish] [--instance NAME]
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 可选。video | image | article;不传时会按参数自动推断
46
- --title TEXT Required. 发布标题
47
- --content TEXT 必填。视频/图文时为描述,长文时为 Markdown 内容
48
- --tag TEXT 可选。重复传入多个标签,例如 --tag 穿搭 --tag OOTD
49
- --video PATH 视频模式必填。只能传 1 个视频文件
50
- --image PATH 图文模式必填。重复传入多张图片,最多 ${MAX_IMAGE_COUNT} 张,首张为首图
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, '未找到“发布笔记”按钮,请确认已进入创作服务首页。', 15_000);
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, `未找到“${publishTypeTabText}”入口,请确认创作服务页面已正确加载。`, 15_000);
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, '未找到“上传视频”按钮,请确认已进入视频发布页。', 15_000);
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, '未找到“上传图片”按钮,请确认已进入图文发布页。', 15_000);
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, '未等待到图文发布页,请确认图片上传成功。', 30_000);
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, '未找到图文标题输入框,请确认图文发布页已正确加载。', 15_000);
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, '未找到图文正文编辑器,请确认图文发布页已正确加载。', 15_000);
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, '未找到“新的创作”按钮,请确认已进入长文发布页。', 15_000);
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, '未找到长文标题输入框,请确认已进入长文编辑页。', 15_000);
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, '未找到长文正文编辑器,请确认已进入长文编辑页。', 15_000);
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, `未找到“${buttonText}”按钮,请确认发布页已正确加载。`, 15_000);
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(`点击“创作服务”后,未跳转到 ${CREATOR_HOME_URL}`);
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) {
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
- import { printJson, runCli } from '../utils/browser-cli.js';
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] Save posts as JSONL. Uses a default path when PATH is omitted
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');
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@skills-store/rednote",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {