aiconductor-cli 1.6.0 → 1.7.0

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.
Files changed (92) hide show
  1. package/README.md +565 -488
  2. package/bin/aiconductor-cli.js +401 -351
  3. package/package.json +1 -1
  4. package/src/api.js +135 -3
  5. package/src/api.test.js +292 -4
  6. package/src/commands/content.js +184 -184
  7. package/src/commands/douyin.js +174 -0
  8. package/src/commands/html_publish.js +54 -54
  9. package/src/commands/md_publish.js +175 -175
  10. package/src/commands/web.js +69 -0
  11. package/src/commands/web_search.js +99 -99
  12. package/src/commands/wx.js +116 -116
  13. package/src/commands/xhs.js +763 -609
  14. package/src/lib/cli-utils.js +172 -172
  15. package/src/lib/daren-score.js +91 -0
  16. package/src/lib/daren-score.test.js +57 -0
  17. package/AGENTS.md +0 -193
  18. package/api_docs/aic_web_search.md +0 -84
  19. package/api_docs/audio_converter.md +0 -51
  20. package/api_docs/balance_query.md +0 -38
  21. package/api_docs/bing_search.md +0 -55
  22. package/api_docs/china_stock_data.md +0 -221
  23. package/api_docs/delete_published_webpage.md +0 -38
  24. package/api_docs/doubao_search.md +0 -80
  25. package/api_docs/douyin_link_to_text_v1.md +0 -50
  26. package/api_docs/douyin_link_to_text_v2.md +0 -50
  27. package/api_docs/douyin_user_detail.md +0 -65
  28. package/api_docs/douyin_user_search.md +0 -58
  29. package/api_docs/douyin_user_videos.md +0 -49
  30. package/api_docs/douyin_user_videos_async.md +0 -45
  31. package/api_docs/douyin_user_videos_async_query.md +0 -41
  32. package/api_docs/douyin_user_videos_max.md +0 -59
  33. package/api_docs/douyin_video_comments.md +0 -68
  34. package/api_docs/douyin_video_info.md +0 -70
  35. package/api_docs/douyin_video_search.md +0 -60
  36. package/api_docs/douyin_video_search_fast.md +0 -48
  37. package/api_docs/fast_ocr.md +0 -51
  38. package/api_docs/flow_article2podcast_creat.md +0 -41
  39. package/api_docs/flow_article2podcast_query.md +0 -41
  40. package/api_docs/flow_wx_history_articles.md +0 -45
  41. package/api_docs/generate_image.md +0 -68
  42. package/api_docs/get_duration.md +0 -37
  43. package/api_docs/hot_list_mixed.md +0 -122
  44. package/api_docs/html_publish.md +0 -50
  45. package/api_docs/html_publish_flexible.md +0 -65
  46. package/api_docs/image_resize.md +0 -58
  47. package/api_docs/image_to_permalink.md +0 -61
  48. package/api_docs/link_expander.md +0 -39
  49. package/api_docs/llm_proxy.md +0 -61
  50. package/api_docs/markitdown.md +0 -47
  51. package/api_docs/md_to_docx.md +0 -51
  52. package/api_docs/md_to_html.md +0 -53
  53. package/api_docs/md_to_mindmap.md +0 -52
  54. package/api_docs/md_to_pdf.md +0 -52
  55. package/api_docs/md_to_png.md +0 -53
  56. package/api_docs/md_to_txt.md +0 -51
  57. package/api_docs/md_to_wxhtml.md +0 -64
  58. package/api_docs/md_to_xhs.md +0 -87
  59. package/api_docs/md_to_xlsx.md +0 -52
  60. package/api_docs/portrait_enhancement.md +0 -69
  61. package/api_docs/published_webpages.md +0 -45
  62. package/api_docs/r2_uploader.md +0 -51
  63. package/api_docs/schedulerx_job_create.md +0 -56
  64. package/api_docs/transaction_query.md +0 -52
  65. package/api_docs/url_to_markdown.md +0 -50
  66. package/api_docs/video_download_url.md +0 -57
  67. package/api_docs/video_download_url_oss.md +0 -55
  68. package/api_docs/video_to_text.md +0 -48
  69. package/api_docs/video_to_text_v2.md +0 -49
  70. package/api_docs/volcengine_face_pretty.md +0 -55
  71. package/api_docs/volcengine_faceswap.md +0 -55
  72. package/api_docs/website_screenshot.md +0 -49
  73. package/api_docs/wx_access_token.md +0 -44
  74. package/api_docs/wx_article_analysis_data_push.md +0 -43
  75. package/api_docs/wx_article_data.md +0 -48
  76. package/api_docs/wx_article_style_rewrite.md +0 -45
  77. package/api_docs/wx_draft_add.md +0 -56
  78. package/api_docs/wx_draft_publish.md +0 -47
  79. package/api_docs/wx_history_articles.md +0 -89
  80. package/api_docs/wx_image_upload.md +0 -46
  81. package/api_docs/wx_md_to_html.md +0 -48
  82. package/api_docs/wx_rss_reader.md +0 -59
  83. package/api_docs/xhs_hot_list.md +0 -48
  84. package/api_docs/xhs_note_comments_fast.md +0 -54
  85. package/api_docs/xhs_note_info_v2.md +0 -45
  86. package/api_docs/xhs_note_info_v3.md +0 -69
  87. package/api_docs/xhs_note_publish.md +0 -54
  88. package/api_docs/xhs_note_search_v2.md +0 -73
  89. package/api_docs/xhs_note_search_v3.md +0 -54
  90. package/api_docs/xhs_user_info_v2.md +0 -61
  91. package/api_docs/xhs_user_notes_v2.md +0 -56
  92. package/api_docs/xhs_user_notes_v3.md +0 -48
@@ -1,609 +1,763 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const {
4
- r2Upload,
5
- xhsHotList,
6
- xhsNotePublish,
7
- xhsNoteInfo,
8
- xhsNoteComments,
9
- xhsNoteSearch,
10
- xhsUserInfo,
11
- xhsUserNotes,
12
- resolveXhsShortLink,
13
- } = require("../api");
14
- const {
15
- outputResult,
16
- resolveKeyOrExit,
17
- handleApiError,
18
- extractUrlFromText,
19
- parseXhsNoteId,
20
- parseXhsUserId,
21
- formatXhsTime,
22
- toCount,
23
- safeParseInt,
24
- } = require("../lib/cli-utils");
25
-
26
- const XHS_SHORT_LINK_HOSTS = ["xhslink.cn", "xhslink.com"];
27
-
28
- const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
29
- const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
30
-
31
- function validationError(message) {
32
- return Object.assign(new Error(message), { type: "validation_error" });
33
- }
34
-
35
- function resolveBase64Input(value, label) {
36
- if (!value.startsWith("@")) return value;
37
- const filePath = path.resolve(value.slice(1));
38
- let content;
39
- try {
40
- content = fs.readFileSync(filePath, "utf-8").trim();
41
- } catch (err) {
42
- throw validationError(`${label} Base64 文件读取失败 ${filePath}: ${err.message}`);
43
- }
44
- if (!content) throw validationError(`${label} Base64 文件内容为空: ${filePath}`);
45
- return content;
46
- }
47
-
48
- function validateLocalFile(filePath, label, maxSize) {
49
- const absolutePath = path.resolve(filePath);
50
- let stat;
51
- try {
52
- stat = fs.statSync(absolutePath);
53
- } catch (err) {
54
- throw validationError(`${label}文件读取失败 ${absolutePath}: ${err.message}`);
55
- }
56
- if (!stat.isFile()) throw validationError(`${label}路径不是文件: ${absolutePath}`);
57
- if (stat.size > maxSize) {
58
- throw validationError(`${label}文件超过 ${maxSize / 1024 / 1024}MB: ${absolutePath}`);
59
- }
60
- }
61
-
62
- function validateUrl(value, label) {
63
- let parsed;
64
- try {
65
- parsed = new URL(value);
66
- } catch {
67
- throw validationError(`${label}不是有效 URL: ${value}`);
68
- }
69
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
70
- throw validationError(`${label}仅支持 HTTP/HTTPS URL: ${value}`);
71
- }
72
- }
73
-
74
- function validateBase64Size(value, label, maxSize) {
75
- const dataUrlPrefix = value.match(/^data:[^,]*,/i)?.[0];
76
- const payload = (dataUrlPrefix ? value.slice(dataUrlPrefix.length) : value).replace(/\s/g, "");
77
- const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
78
- const size = Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
79
- if (size > maxSize) {
80
- throw validationError(`${label} Base64 内容超过 ${maxSize / 1024 / 1024}MB`);
81
- }
82
- }
83
-
84
- async function uploadMedia(apiKey, params, label) {
85
- try {
86
- const result = await r2Upload(apiKey, params);
87
- return result.file_url;
88
- } catch (err) {
89
- const detail = err.type === "http_error"
90
- ? `HTTP ${err.statusCode}: ${err.body?.message || JSON.stringify(err.body)}`
91
- : err.message || err;
92
- const wrapped = new Error(`${label}上传失败: ${detail}`);
93
- wrapped.type = err.type === "validation_error" || err.type === "network_error"
94
- ? err.type
95
- : "api_error";
96
- wrapped.statusCode = err.statusCode;
97
- wrapped.body = err.body;
98
- throw wrapped;
99
- }
100
- }
101
-
102
- /**
103
- * text 格式输出:用 ✓ 开头展示关键信息
104
- * @param {object} resp
105
- * @returns {string}
106
- */
107
- function notePublishTextFormatter(resp) {
108
- const d = resp.data || {};
109
- const lines = [];
110
- lines.push("✓ 小红书笔记发布页已生成");
111
- lines.push(` ID: ${d.id}`);
112
- lines.push(` H5 地址: ${d.url}`);
113
- if (resp.message) lines.push(` 消息: ${resp.message}`);
114
- if (d.qrcode_url) lines.push(` 二维码图: ${d.qrcode_url}`);
115
- if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
116
- if (d.media) {
117
- if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
118
- if (d.media.video) lines.push(` 视频: ${d.media.video}`);
119
- if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
120
- }
121
- return lines.join("\n");
122
- }
123
-
124
- /**
125
- * text 格式输出:展示小红书实时热榜。
126
- * @param {object} resp
127
- * @returns {string}
128
- */
129
- function hotListTextFormatter(resp = {}) {
130
- const data = resp.data || {};
131
- const lines = ["小红书热榜"];
132
- if (data.date) lines.push(`日期: ${data.date}`);
133
-
134
- (data.items || []).forEach((item) => {
135
- const rank = item.rank === undefined ? "-" : item.rank;
136
- const trend = item.trend ? ` ${item.trend}` : "";
137
- lines.push(`\n[${rank}] ${item.title || "未命名话题"}${trend}`);
138
- if (item.hot || item.hot_value !== undefined) lines.push(` 热度: ${item.hot || item.hot_value}`);
139
- if (item.url) lines.push(` URL: ${item.url}`);
140
- });
141
-
142
- return lines.join("\n");
143
- }
144
-
145
- /**
146
- * 获取小红书实时热榜。
147
- * @param {{ apiKey?: string, format?: "json"|"text" }} options
148
- * @returns {Promise<void>}
149
- */
150
- async function hotListCommand(options = {}) {
151
- const format = options.format || "json";
152
- const apiKey = resolveKeyOrExit(options.apiKey, format);
153
-
154
- try {
155
- const result = await xhsHotList(apiKey);
156
- outputResult(result, format, hotListTextFormatter);
157
- process.exit(0);
158
- } catch (err) {
159
- handleApiError(err, format);
160
- process.exit(1);
161
- }
162
- }
163
-
164
- async function notePublishCommand(type, options = {}) {
165
- const format = options.format || "json";
166
- const apiKey = resolveKeyOrExit(options.apiKey, format);
167
-
168
- try {
169
- if (type !== "normal" && type !== "video") {
170
- throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
171
- }
172
-
173
- const imageUrls = options.images
174
- ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
175
- : [];
176
- const imageFiles = options.imageFile || [];
177
- const imageBase64 = (options.imageBase64 || []).map((value, index) =>
178
- resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
179
- );
180
- const videoBase64 = options.videoBase64
181
- ? resolveBase64Input(options.videoBase64, "视频")
182
- : undefined;
183
- const coverBase64 = options.coverBase64
184
- ? resolveBase64Input(options.coverBase64, "视频封面")
185
- : undefined;
186
- const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
187
- const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
188
- const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
189
- const validDays = Number(options.uploadValidDays || 7);
190
-
191
- if (validDays !== 1 && validDays !== 7) {
192
- throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
193
- }
194
- if (type === "normal") {
195
- if (videoSources.length > 0 || coverSources.length > 0) {
196
- throw validationError("normal 类型不能使用视频或封面参数");
197
- }
198
- if (imageCount === 0) {
199
- throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
200
- }
201
- if (imageCount > 18) {
202
- throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
203
- }
204
- imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
205
- imageFiles.forEach((filePath, index) => validateLocalFile(
206
- filePath,
207
- `第 ${imageUrls.length + index + 1} 张图片`,
208
- MAX_IMAGE_SIZE
209
- ));
210
- imageBase64.forEach((value, index) => validateBase64Size(
211
- value,
212
- `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
213
- MAX_IMAGE_SIZE
214
- ));
215
- } else {
216
- if (imageCount > 0) {
217
- throw validationError("video 类型不能使用图片参数");
218
- }
219
- if (videoSources.length !== 1) {
220
- throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
221
- }
222
- if (coverSources.length > 1) {
223
- throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
224
- }
225
- if (options.video) validateUrl(options.video, "视频");
226
- if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
227
- if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
228
- if (options.cover) validateUrl(options.cover, "视频封面");
229
- if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
230
- if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
231
- }
232
-
233
- let images;
234
- let video;
235
- let cover;
236
- if (type === "normal") {
237
- const uploadedImages = await Promise.all([
238
- ...imageFiles.map((filePath, index) => uploadMedia(
239
- apiKey,
240
- { filePath, validDays },
241
- `第 ${imageUrls.length + index + 1} 张图片`
242
- )),
243
- ...imageBase64.map((fileBase64, index) => uploadMedia(
244
- apiKey,
245
- { fileBase64, defaultContentType: "image/jpeg", validDays },
246
- `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
247
- )),
248
- ]);
249
- images = [...imageUrls, ...uploadedImages];
250
- } else {
251
- video = options.video;
252
- if (options.videoFile) {
253
- video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
254
- } else if (videoBase64) {
255
- video = await uploadMedia(
256
- apiKey,
257
- { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
258
- "视频"
259
- );
260
- }
261
-
262
- cover = options.cover;
263
- if (options.coverFile) {
264
- cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
265
- } else if (coverBase64) {
266
- cover = await uploadMedia(
267
- apiKey,
268
- { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
269
- "视频封面"
270
- );
271
- }
272
- }
273
-
274
- const result = await xhsNotePublish(apiKey, {
275
- type,
276
- title: options.title,
277
- content: options.content,
278
- images,
279
- video,
280
- cover,
281
- });
282
- outputResult(result, format, notePublishTextFormatter);
283
- process.exit(0);
284
- } catch (err) {
285
- handleApiError(err, format);
286
- process.exit(1);
287
- }
288
- }
289
-
290
- // ---------------------------------------------------------------------------
291
- // 小红书数据命令(note / comments / search / user / user-notes)
292
- // 口径依据:api_docs/xhs_*.md + 2026-11-05 线上实测
293
- // ---------------------------------------------------------------------------
294
-
295
- /**
296
- * 把用户输入(笔记链接 / xhslink 短链 / 分享口令 / 裸 note_id)解析成 note_id。
297
- * 短链必须本地展开成 302 后的长链,否则拿不到 id。
298
- * @param {string} input
299
- * @returns {Promise<string>}
300
- */
301
- async function resolveNoteId(input) {
302
- const direct = parseXhsNoteId(input);
303
- if (direct) return direct;
304
-
305
- const url = extractUrlFromText(typeof input === "string" ? input : "");
306
- if (url) {
307
- let hostname = "";
308
- try {
309
- hostname = new URL(url).hostname.toLowerCase();
310
- } catch {
311
- hostname = "";
312
- }
313
- if (XHS_SHORT_LINK_HOSTS.includes(hostname)) {
314
- const longUrl = await resolveXhsShortLink(url);
315
- const id = parseXhsNoteId(longUrl);
316
- if (id) return id;
317
- }
318
- }
319
-
320
- throw validationError("无法从输入里识别小红书笔记(支持笔记链接、xhslink 短链、分享口令或 24 位 note_id)");
321
- }
322
-
323
- /**
324
- * 把用户输入(主页链接 / 裸 user_id)解析成 user_id。
325
- * @param {string} input
326
- * @returns {string}
327
- */
328
- function resolveUserId(input) {
329
- const id = parseXhsUserId(input);
330
- if (id) return id;
331
- throw validationError("无法从输入里识别小红书用户(支持主页链接 /user/profile/ 或 24 位 user_id)");
332
- }
333
-
334
- /**
335
- * text 格式化:单篇笔记详情。
336
- * @param {object} resp
337
- * @returns {string}
338
- */
339
- function noteTextFormatter(resp = {}) {
340
- const note = resp.note_data || {};
341
- const user = note.user || {};
342
- const interact = note.interact_info || {};
343
- const lines = [];
344
-
345
- lines.push(`✓ ${note.title || "(无标题)"}`);
346
- if (user.nickname) lines.push(` 作者: ${user.nickname}`);
347
- lines.push(` 类型: ${note.type === "video" ? "视频笔记" : "图文笔记"}`);
348
- const publishedAt = formatXhsTime(note.time);
349
- if (publishedAt) lines.push(` 发布: ${publishedAt}`);
350
- lines.push(
351
- ` 互动: 赞 ${toCount(interact.liked_count)} / 藏 ${toCount(interact.collected_count)}` +
352
- ` / 评 ${toCount(interact.comment_count)} / 分享 ${toCount(interact.share_count)}`
353
- );
354
- const tags = (note.tag_list || []).map((tag) => tag.name).filter(Boolean);
355
- if (tags.length) lines.push(` 话题: ${tags.join("、")}`);
356
- if (note.image_list && note.image_list.length) {
357
- lines.push(` 图片: ${note.image_list.length} 张`);
358
- }
359
- if (note.type === "video" && note.video && note.video.duration) {
360
- lines.push(` 视频时长: ${Math.round(note.video.duration)}s`);
361
- }
362
- if (note.desc) {
363
- lines.push("");
364
- lines.push(note.desc);
365
- }
366
- if (note.note_id) lines.push("", `笔记 ID: ${note.note_id}`);
367
- return lines.join("\n");
368
- }
369
-
370
- /**
371
- * text 格式化:笔记评论(按点赞降序)。
372
- * @param {object} resp
373
- * @returns {string}
374
- */
375
- function commentsTextFormatter(resp = {}) {
376
- const data = resp.data || {};
377
- const comments = [...(data.comments || [])].sort((a, b) => toCount(b.like_count) - toCount(a.like_count));
378
- const lines = [`✓ 共取到 ${comments.length} 条评论`];
379
- // total_comments 是本次各页累计的总数,不是笔记真实评论总数(那要 note 接口的 comment_count)
380
- if (data.has_more) lines.push(` 提示: 还有更多评论,可加 --max-pages(当前 ${toCount(data.total_pages) || 1} 页)`);
381
- comments.forEach((item, index) => {
382
- const nick = (item.user && (item.user.original_name || item.user.nickname)) || "匿名";
383
- const ip = item.ip_location ? ` · ${item.ip_location}` : "";
384
- lines.push("", `[${index + 1}] ${nick}${ip} · 赞 ${toCount(item.like_count)}`);
385
- lines.push(` ${(item.content || "").replace(/\n+/g, " ")}`);
386
- const replies = Array.isArray(item.sub_comments) ? item.sub_comments : [];
387
- replies.forEach((reply) => {
388
- const replyUser = reply.user || {};
389
- const replyNick = replyUser.original_name || replyUser.nickname || "匿名";
390
- lines.push(` ↳ ${replyNick}: ${(reply.content || "").replace(/\n+/g, " ")}`);
391
- });
392
- if (!replies.length && toCount(item.sub_comment_count) > 0) {
393
- lines.push(` ↳ 另有 ${toCount(item.sub_comment_count)} 条回复未展开`);
394
- }
395
- });
396
- return lines.join("\n");
397
- }
398
-
399
- /**
400
- * text 格式化:笔记搜索结果(按点赞降序)。
401
- * @param {object} resp
402
- * @returns {string}
403
- */
404
- function searchTextFormatter(resp = {}) {
405
- const data = resp.data || {};
406
- const items = data.items || [];
407
- const rows = items
408
- .map((item) => ({ note: item.noteInfo || {}, user: item.userInfo || {} }))
409
- .sort((a, b) => toCount(b.note.likeNum) - toCount(a.note.likeNum));
410
-
411
- const lines = [`✓ 共取到 ${rows.length} 条笔记`];
412
- if (data.has_more) lines.push(" 提示: 还有更多结果,可加 --max-pages");
413
- rows.forEach((row, index) => {
414
- const note = row.note;
415
- const author = row.user.nickName || "未知作者";
416
- lines.push("", `[${index + 1}] ${note.title || "(无标题)"}`);
417
- lines.push(
418
- ` ${note.noteType === "video" ? "视频" : "图文"} · 赞 ${toCount(note.likeNum)}` +
419
- ` / 藏 ${toCount(note.favNum)} / 评 ${toCount(note.cmtNum)} · ${author}`
420
- );
421
- if (note.notePublishTime) lines.push(` 发布: ${note.notePublishTime}`);
422
- if (note.noteLink) lines.push(` ${note.noteLink}`);
423
- });
424
- return lines.join("\n");
425
- }
426
-
427
- /**
428
- * text 格式化:用户详情。
429
- * @param {object} resp
430
- * @returns {string}
431
- */
432
- function userInfoTextFormatter(resp = {}) {
433
- const info = resp.user_info || {};
434
- const basic = info.basic_info || {};
435
- const lines = [`✓ ${basic.nickname || "(无昵称)"}`];
436
- if (basic.red_id) lines.push(` 小红书号: ${basic.red_id}`);
437
- if (basic.ip_location) lines.push(` 地区: ${basic.ip_location}`);
438
- const interactions = info.interactions || [];
439
- if (interactions.length) {
440
- lines.push(` 互动: ${interactions.map((item) => `${item.name} ${toCount(item.count)}`).join(" / ")}`);
441
- }
442
- const tags = info.tags || [];
443
- if (tags.length) lines.push(` 标签: ${tags.join("、")}`);
444
- if (basic.desc) lines.push("", basic.desc);
445
- return lines.join("\n");
446
- }
447
-
448
- /**
449
- * text 格式化:用户笔记列表。
450
- * @param {object} resp
451
- * @returns {string}
452
- */
453
- function userNotesTextFormatter(resp = {}) {
454
- const notes = (resp.data && resp.data.notes) || [];
455
- const lines = [`✓ 共取到 ${notes.length} 条笔记`];
456
- let totalLikes = 0;
457
- notes.forEach((note, index) => {
458
- totalLikes += toCount(note.likes);
459
- const flags = [];
460
- if (note.sticky) flags.push("置顶");
461
- if (note.is_goods_note) flags.push("带货");
462
- const flagText = flags.length ? ` [${flags.join("/")}]` : "";
463
- const published = formatXhsTime(note.time).slice(0, 10);
464
- lines.push(
465
- "",
466
- `[${index + 1}] ${note.title || "(无标题)"}${flagText}`,
467
- ` ${note.type === "video" ? "视频" : "图文"} · 赞 ${toCount(note.likes)} / 藏 ${toCount(note.collected_count)}` +
468
- ` / 评 ${toCount(note.comments_count)}${published ? ` · ${published}` : ""}`
469
- );
470
- });
471
- if (notes.length) lines.push("", `点赞合计: ${totalLikes}`);
472
- return lines.join("\n");
473
- }
474
-
475
- /**
476
- * xhs note:获取单篇笔记详情。
477
- * @param {string} input 笔记链接 / 短链 / 分享口令 / note_id
478
- * @param {{apiKey?: string, format?: "json"|"text"}} options
479
- * @returns {Promise<void>}
480
- */
481
- async function noteCommand(input, options = {}) {
482
- const format = options.format || "json";
483
- const apiKey = resolveKeyOrExit(options.apiKey, format);
484
-
485
- try {
486
- const noteId = await resolveNoteId(input);
487
- const result = await xhsNoteInfo(apiKey, noteId);
488
- outputResult(result, format, noteTextFormatter);
489
- process.exit(0);
490
- } catch (err) {
491
- handleApiError(err, format);
492
- process.exit(1);
493
- }
494
- }
495
-
496
- /**
497
- * xhs comments:获取笔记一级评论(含可见的楼内回复)。
498
- * @param {string} input 笔记链接 / 短链 / 分享口令 / note_id
499
- * @param {{apiKey?: string, format?: string, maxPages?: string, maxSeconds?: string}} options
500
- * @returns {Promise<void>}
501
- */
502
- async function commentsCommand(input, options = {}) {
503
- const format = options.format || "json";
504
- const apiKey = resolveKeyOrExit(options.apiKey, format);
505
-
506
- try {
507
- const noteId = await resolveNoteId(input);
508
- const result = await xhsNoteComments(apiKey, {
509
- noteId,
510
- maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
511
- maxSeconds: options.maxSeconds === undefined ? undefined : safeParseInt(options.maxSeconds, 50),
512
- });
513
- outputResult(result, format, commentsTextFormatter);
514
- process.exit(0);
515
- } catch (err) {
516
- handleApiError(err, format);
517
- process.exit(1);
518
- }
519
- }
520
-
521
- /**
522
- * xhs search:按关键词搜索笔记。
523
- * @param {string} keyword
524
- * @param {{apiKey?: string, format?: string, sort?: string, noteTime?: string, noteType?: string, maxPages?: string}} options
525
- * @returns {Promise<void>}
526
- */
527
- async function searchCommand(keyword, options = {}) {
528
- const format = options.format || "json";
529
- const apiKey = resolveKeyOrExit(options.apiKey, format);
530
-
531
- try {
532
- const result = await xhsNoteSearch(apiKey, {
533
- keyword,
534
- maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
535
- sort: options.sort,
536
- noteTime: options.noteTime,
537
- noteType: options.noteType,
538
- });
539
- outputResult(result, format, searchTextFormatter);
540
- process.exit(0);
541
- } catch (err) {
542
- handleApiError(err, format);
543
- process.exit(1);
544
- }
545
- }
546
-
547
- /**
548
- * xhs user:获取用户详情。
549
- * @param {string} input 主页链接 / user_id
550
- * @param {{apiKey?: string, format?: "json"|"text"}} options
551
- * @returns {Promise<void>}
552
- */
553
- async function userCommand(input, options = {}) {
554
- const format = options.format || "json";
555
- const apiKey = resolveKeyOrExit(options.apiKey, format);
556
-
557
- try {
558
- const userId = resolveUserId(input);
559
- const result = await xhsUserInfo(apiKey, userId);
560
- outputResult(result, format, userInfoTextFormatter);
561
- process.exit(0);
562
- } catch (err) {
563
- handleApiError(err, format);
564
- process.exit(1);
565
- }
566
- }
567
-
568
- /**
569
- * xhs user-notes:获取用户笔记列表。
570
- * @param {string} input 主页链接 / user_id
571
- * @param {{apiKey?: string, format?: string, maxPages?: string}} options
572
- * @returns {Promise<void>}
573
- */
574
- async function userNotesCommand(input, options = {}) {
575
- const format = options.format || "json";
576
- const apiKey = resolveKeyOrExit(options.apiKey, format);
577
-
578
- try {
579
- const userId = resolveUserId(input);
580
- const result = await xhsUserNotes(apiKey, {
581
- userId,
582
- maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
583
- });
584
- outputResult(result, format, userNotesTextFormatter);
585
- process.exit(0);
586
- } catch (err) {
587
- handleApiError(err, format);
588
- process.exit(1);
589
- }
590
- }
591
-
592
- module.exports = {
593
- hotListCommand,
594
- hotListTextFormatter,
595
- notePublishCommand,
596
- notePublishTextFormatter,
597
- noteCommand,
598
- commentsCommand,
599
- searchCommand,
600
- userCommand,
601
- userNotesCommand,
602
- noteTextFormatter,
603
- commentsTextFormatter,
604
- searchTextFormatter,
605
- userInfoTextFormatter,
606
- userNotesTextFormatter,
607
- resolveNoteId,
608
- resolveUserId,
609
- };
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const {
4
+ r2Upload,
5
+ xhsHotList,
6
+ xhsNotePublish,
7
+ xhsNoteInfo,
8
+ xhsNoteComments,
9
+ xhsNoteSearch,
10
+ xhsUserInfo,
11
+ xhsUserNotes,
12
+ resolveXhsShortLink,
13
+ xhsPgyBlogger,
14
+ xhsPgyAudience,
15
+ xhsPgyPerformance,
16
+ } = require("../api");
17
+ const { scoreDaren } = require("../lib/daren-score");
18
+ const {
19
+ outputResult,
20
+ resolveKeyOrExit,
21
+ handleApiError,
22
+ extractUrlFromText,
23
+ parseXhsNoteId,
24
+ parseXhsUserId,
25
+ formatXhsTime,
26
+ toCount,
27
+ safeParseInt,
28
+ } = require("../lib/cli-utils");
29
+
30
+ const XHS_SHORT_LINK_HOSTS = ["xhslink.cn", "xhslink.com"];
31
+
32
+ const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
33
+ const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
34
+
35
+ function validationError(message) {
36
+ return Object.assign(new Error(message), { type: "validation_error" });
37
+ }
38
+
39
+ function resolveBase64Input(value, label) {
40
+ if (!value.startsWith("@")) return value;
41
+ const filePath = path.resolve(value.slice(1));
42
+ let content;
43
+ try {
44
+ content = fs.readFileSync(filePath, "utf-8").trim();
45
+ } catch (err) {
46
+ throw validationError(`${label} Base64 文件读取失败 ${filePath}: ${err.message}`);
47
+ }
48
+ if (!content) throw validationError(`${label} Base64 文件内容为空: ${filePath}`);
49
+ return content;
50
+ }
51
+
52
+ function validateLocalFile(filePath, label, maxSize) {
53
+ const absolutePath = path.resolve(filePath);
54
+ let stat;
55
+ try {
56
+ stat = fs.statSync(absolutePath);
57
+ } catch (err) {
58
+ throw validationError(`${label}文件读取失败 ${absolutePath}: ${err.message}`);
59
+ }
60
+ if (!stat.isFile()) throw validationError(`${label}路径不是文件: ${absolutePath}`);
61
+ if (stat.size > maxSize) {
62
+ throw validationError(`${label}文件超过 ${maxSize / 1024 / 1024}MB: ${absolutePath}`);
63
+ }
64
+ }
65
+
66
+ function validateUrl(value, label) {
67
+ let parsed;
68
+ try {
69
+ parsed = new URL(value);
70
+ } catch {
71
+ throw validationError(`${label}不是有效 URL: ${value}`);
72
+ }
73
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
74
+ throw validationError(`${label}仅支持 HTTP/HTTPS URL: ${value}`);
75
+ }
76
+ }
77
+
78
+ function validateBase64Size(value, label, maxSize) {
79
+ const dataUrlPrefix = value.match(/^data:[^,]*,/i)?.[0];
80
+ const payload = (dataUrlPrefix ? value.slice(dataUrlPrefix.length) : value).replace(/\s/g, "");
81
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
82
+ const size = Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
83
+ if (size > maxSize) {
84
+ throw validationError(`${label} Base64 内容超过 ${maxSize / 1024 / 1024}MB`);
85
+ }
86
+ }
87
+
88
+ async function uploadMedia(apiKey, params, label) {
89
+ try {
90
+ const result = await r2Upload(apiKey, params);
91
+ return result.file_url;
92
+ } catch (err) {
93
+ const detail = err.type === "http_error"
94
+ ? `HTTP ${err.statusCode}: ${err.body?.message || JSON.stringify(err.body)}`
95
+ : err.message || err;
96
+ const wrapped = new Error(`${label}上传失败: ${detail}`);
97
+ wrapped.type = err.type === "validation_error" || err.type === "network_error"
98
+ ? err.type
99
+ : "api_error";
100
+ wrapped.statusCode = err.statusCode;
101
+ wrapped.body = err.body;
102
+ throw wrapped;
103
+ }
104
+ }
105
+
106
+ /**
107
+ * text 格式输出:用 ✓ 开头展示关键信息
108
+ * @param {object} resp
109
+ * @returns {string}
110
+ */
111
+ function notePublishTextFormatter(resp) {
112
+ const d = resp.data || {};
113
+ const lines = [];
114
+ lines.push("✓ 小红书笔记发布页已生成");
115
+ lines.push(` ID: ${d.id}`);
116
+ lines.push(` H5 地址: ${d.url}`);
117
+ if (resp.message) lines.push(` 消息: ${resp.message}`);
118
+ if (d.qrcode_url) lines.push(` 二维码图: ${d.qrcode_url}`);
119
+ if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
120
+ if (d.media) {
121
+ if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
122
+ if (d.media.video) lines.push(` 视频: ${d.media.video}`);
123
+ if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
124
+ }
125
+ return lines.join("\n");
126
+ }
127
+
128
+ /**
129
+ * text 格式输出:展示小红书实时热榜。
130
+ * @param {object} resp
131
+ * @returns {string}
132
+ */
133
+ function hotListTextFormatter(resp = {}) {
134
+ const data = resp.data || {};
135
+ const lines = ["小红书热榜"];
136
+ if (data.date) lines.push(`日期: ${data.date}`);
137
+
138
+ (data.items || []).forEach((item) => {
139
+ const rank = item.rank === undefined ? "-" : item.rank;
140
+ const trend = item.trend ? ` ${item.trend}` : "";
141
+ lines.push(`\n[${rank}] ${item.title || "未命名话题"}${trend}`);
142
+ if (item.hot || item.hot_value !== undefined) lines.push(` 热度: ${item.hot || item.hot_value}`);
143
+ if (item.url) lines.push(` URL: ${item.url}`);
144
+ });
145
+
146
+ return lines.join("\n");
147
+ }
148
+
149
+ /**
150
+ * 获取小红书实时热榜。
151
+ * @param {{ apiKey?: string, format?: "json"|"text" }} options
152
+ * @returns {Promise<void>}
153
+ */
154
+ async function hotListCommand(options = {}) {
155
+ const format = options.format || "json";
156
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
157
+
158
+ try {
159
+ const result = await xhsHotList(apiKey);
160
+ outputResult(result, format, hotListTextFormatter);
161
+ process.exit(0);
162
+ } catch (err) {
163
+ handleApiError(err, format);
164
+ process.exit(1);
165
+ }
166
+ }
167
+
168
+ async function notePublishCommand(type, options = {}) {
169
+ const format = options.format || "json";
170
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
171
+
172
+ try {
173
+ if (type !== "normal" && type !== "video") {
174
+ throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
175
+ }
176
+
177
+ const imageUrls = options.images
178
+ ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
179
+ : [];
180
+ const imageFiles = options.imageFile || [];
181
+ const imageBase64 = (options.imageBase64 || []).map((value, index) =>
182
+ resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
183
+ );
184
+ const videoBase64 = options.videoBase64
185
+ ? resolveBase64Input(options.videoBase64, "视频")
186
+ : undefined;
187
+ const coverBase64 = options.coverBase64
188
+ ? resolveBase64Input(options.coverBase64, "视频封面")
189
+ : undefined;
190
+ const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
191
+ const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
192
+ const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
193
+ const validDays = Number(options.uploadValidDays || 7);
194
+
195
+ if (validDays !== 1 && validDays !== 7) {
196
+ throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
197
+ }
198
+ if (type === "normal") {
199
+ if (videoSources.length > 0 || coverSources.length > 0) {
200
+ throw validationError("normal 类型不能使用视频或封面参数");
201
+ }
202
+ if (imageCount === 0) {
203
+ throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
204
+ }
205
+ if (imageCount > 18) {
206
+ throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
207
+ }
208
+ imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
209
+ imageFiles.forEach((filePath, index) => validateLocalFile(
210
+ filePath,
211
+ `第 ${imageUrls.length + index + 1} 张图片`,
212
+ MAX_IMAGE_SIZE
213
+ ));
214
+ imageBase64.forEach((value, index) => validateBase64Size(
215
+ value,
216
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
217
+ MAX_IMAGE_SIZE
218
+ ));
219
+ } else {
220
+ if (imageCount > 0) {
221
+ throw validationError("video 类型不能使用图片参数");
222
+ }
223
+ if (videoSources.length !== 1) {
224
+ throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
225
+ }
226
+ if (coverSources.length > 1) {
227
+ throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
228
+ }
229
+ if (options.video) validateUrl(options.video, "视频");
230
+ if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
231
+ if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
232
+ if (options.cover) validateUrl(options.cover, "视频封面");
233
+ if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
234
+ if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
235
+ }
236
+
237
+ let images;
238
+ let video;
239
+ let cover;
240
+ if (type === "normal") {
241
+ const uploadedImages = await Promise.all([
242
+ ...imageFiles.map((filePath, index) => uploadMedia(
243
+ apiKey,
244
+ { filePath, validDays },
245
+ `第 ${imageUrls.length + index + 1} 张图片`
246
+ )),
247
+ ...imageBase64.map((fileBase64, index) => uploadMedia(
248
+ apiKey,
249
+ { fileBase64, defaultContentType: "image/jpeg", validDays },
250
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
251
+ )),
252
+ ]);
253
+ images = [...imageUrls, ...uploadedImages];
254
+ } else {
255
+ video = options.video;
256
+ if (options.videoFile) {
257
+ video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
258
+ } else if (videoBase64) {
259
+ video = await uploadMedia(
260
+ apiKey,
261
+ { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
262
+ "视频"
263
+ );
264
+ }
265
+
266
+ cover = options.cover;
267
+ if (options.coverFile) {
268
+ cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
269
+ } else if (coverBase64) {
270
+ cover = await uploadMedia(
271
+ apiKey,
272
+ { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
273
+ "视频封面"
274
+ );
275
+ }
276
+ }
277
+
278
+ const result = await xhsNotePublish(apiKey, {
279
+ type,
280
+ title: options.title,
281
+ content: options.content,
282
+ images,
283
+ video,
284
+ cover,
285
+ });
286
+ outputResult(result, format, notePublishTextFormatter);
287
+ process.exit(0);
288
+ } catch (err) {
289
+ handleApiError(err, format);
290
+ process.exit(1);
291
+ }
292
+ }
293
+
294
+ // ---------------------------------------------------------------------------
295
+ // 小红书数据命令(note / comments / search / user / user-notes)
296
+ // 口径依据:backend/api_docs/xhs_*.md + 2026-11-05 线上实测
297
+ // ---------------------------------------------------------------------------
298
+
299
+ /**
300
+ * 把用户输入(笔记链接 / xhslink 短链 / 分享口令 / 裸 note_id)解析成 note_id。
301
+ * 短链必须本地展开成 302 后的长链,否则拿不到 id。
302
+ * @param {string} input
303
+ * @returns {Promise<string>}
304
+ */
305
+ async function resolveNoteId(input) {
306
+ const direct = parseXhsNoteId(input);
307
+ if (direct) return direct;
308
+
309
+ const url = extractUrlFromText(typeof input === "string" ? input : "");
310
+ if (url) {
311
+ let hostname = "";
312
+ try {
313
+ hostname = new URL(url).hostname.toLowerCase();
314
+ } catch {
315
+ hostname = "";
316
+ }
317
+ if (XHS_SHORT_LINK_HOSTS.includes(hostname)) {
318
+ const longUrl = await resolveXhsShortLink(url);
319
+ const id = parseXhsNoteId(longUrl);
320
+ if (id) return id;
321
+ }
322
+ }
323
+
324
+ throw validationError("无法从输入里识别小红书笔记(支持笔记链接、xhslink 短链、分享口令或 24 位 note_id)");
325
+ }
326
+
327
+ /**
328
+ * 把用户输入(主页链接 / 裸 user_id)解析成 user_id。
329
+ * @param {string} input
330
+ * @returns {string}
331
+ */
332
+ function resolveUserId(input) {
333
+ const id = parseXhsUserId(input);
334
+ if (id) return id;
335
+ throw validationError("无法从输入里识别小红书用户(支持主页链接 /user/profile/ 或 24 位 user_id)");
336
+ }
337
+
338
+ /**
339
+ * text 格式化:单篇笔记详情。
340
+ * @param {object} resp
341
+ * @returns {string}
342
+ */
343
+ function noteTextFormatter(resp = {}) {
344
+ const note = resp.note_data || {};
345
+ const user = note.user || {};
346
+ const interact = note.interact_info || {};
347
+ const lines = [];
348
+
349
+ lines.push(`✓ ${note.title || "(无标题)"}`);
350
+ if (user.nickname) lines.push(` 作者: ${user.nickname}`);
351
+ lines.push(` 类型: ${note.type === "video" ? "视频笔记" : "图文笔记"}`);
352
+ const publishedAt = formatXhsTime(note.time);
353
+ if (publishedAt) lines.push(` 发布: ${publishedAt}`);
354
+ lines.push(
355
+ ` 互动: 赞 ${toCount(interact.liked_count)} / 藏 ${toCount(interact.collected_count)}` +
356
+ ` / 评 ${toCount(interact.comment_count)} / 分享 ${toCount(interact.share_count)}`
357
+ );
358
+ const tags = (note.tag_list || []).map((tag) => tag.name).filter(Boolean);
359
+ if (tags.length) lines.push(` 话题: ${tags.join("、")}`);
360
+ if (note.image_list && note.image_list.length) {
361
+ lines.push(` 图片: ${note.image_list.length} 张`);
362
+ }
363
+ if (note.type === "video" && note.video && note.video.duration) {
364
+ lines.push(` 视频时长: ${Math.round(note.video.duration)}s`);
365
+ }
366
+ if (note.desc) {
367
+ lines.push("");
368
+ lines.push(note.desc);
369
+ }
370
+ if (note.note_id) lines.push("", `笔记 ID: ${note.note_id}`);
371
+ return lines.join("\n");
372
+ }
373
+
374
+ /**
375
+ * text 格式化:笔记评论(按点赞降序)。
376
+ * @param {object} resp
377
+ * @returns {string}
378
+ */
379
+ function commentsTextFormatter(resp = {}) {
380
+ const data = resp.data || {};
381
+ const comments = [...(data.comments || [])].sort((a, b) => toCount(b.like_count) - toCount(a.like_count));
382
+ const lines = [`✓ 共取到 ${comments.length} 条评论`];
383
+ // total_comments 是本次各页累计的总数,不是笔记真实评论总数(那要 note 接口的 comment_count)
384
+ if (data.has_more) lines.push(` 提示: 还有更多评论,可加 --max-pages(当前 ${toCount(data.total_pages) || 1} 页)`);
385
+ comments.forEach((item, index) => {
386
+ const nick = (item.user && (item.user.original_name || item.user.nickname)) || "匿名";
387
+ const ip = item.ip_location ? ` · ${item.ip_location}` : "";
388
+ lines.push("", `[${index + 1}] ${nick}${ip} · 赞 ${toCount(item.like_count)}`);
389
+ lines.push(` ${(item.content || "").replace(/\n+/g, " ")}`);
390
+ const replies = Array.isArray(item.sub_comments) ? item.sub_comments : [];
391
+ replies.forEach((reply) => {
392
+ const replyUser = reply.user || {};
393
+ const replyNick = replyUser.original_name || replyUser.nickname || "匿名";
394
+ lines.push(` ↳ ${replyNick}: ${(reply.content || "").replace(/\n+/g, " ")}`);
395
+ });
396
+ if (!replies.length && toCount(item.sub_comment_count) > 0) {
397
+ lines.push(` ↳ 另有 ${toCount(item.sub_comment_count)} 条回复未展开`);
398
+ }
399
+ });
400
+ return lines.join("\n");
401
+ }
402
+
403
+ /**
404
+ * text 格式化:笔记搜索结果(按点赞降序)。
405
+ * @param {object} resp
406
+ * @returns {string}
407
+ */
408
+ function searchTextFormatter(resp = {}) {
409
+ const data = resp.data || {};
410
+ const items = data.items || [];
411
+ const rows = items
412
+ .map((item) => ({ note: item.noteInfo || {}, user: item.userInfo || {} }))
413
+ .sort((a, b) => toCount(b.note.likeNum) - toCount(a.note.likeNum));
414
+
415
+ const lines = [`✓ 共取到 ${rows.length} 条笔记`];
416
+ if (data.has_more) lines.push(" 提示: 还有更多结果,可加 --max-pages");
417
+ rows.forEach((row, index) => {
418
+ const note = row.note;
419
+ const author = row.user.nickName || "未知作者";
420
+ lines.push("", `[${index + 1}] ${note.title || "(无标题)"}`);
421
+ lines.push(
422
+ ` ${note.noteType === "video" ? "视频" : "图文"} · 赞 ${toCount(note.likeNum)}` +
423
+ ` / 藏 ${toCount(note.favNum)} / 评 ${toCount(note.cmtNum)} · ${author}`
424
+ );
425
+ if (note.notePublishTime) lines.push(` 发布: ${note.notePublishTime}`);
426
+ if (note.noteLink) lines.push(` ${note.noteLink}`);
427
+ });
428
+ return lines.join("\n");
429
+ }
430
+
431
+ /**
432
+ * text 格式化:用户详情。
433
+ * @param {object} resp
434
+ * @returns {string}
435
+ */
436
+ function userInfoTextFormatter(resp = {}) {
437
+ const info = resp.user_info || {};
438
+ const basic = info.basic_info || {};
439
+ const lines = [`✓ ${basic.nickname || "(无昵称)"}`];
440
+ if (basic.red_id) lines.push(` 小红书号: ${basic.red_id}`);
441
+ if (basic.ip_location) lines.push(` 地区: ${basic.ip_location}`);
442
+ const interactions = info.interactions || [];
443
+ if (interactions.length) {
444
+ lines.push(` 互动: ${interactions.map((item) => `${item.name} ${toCount(item.count)}`).join(" / ")}`);
445
+ }
446
+ const tags = info.tags || [];
447
+ if (tags.length) lines.push(` 标签: ${tags.join("、")}`);
448
+ if (basic.desc) lines.push("", basic.desc);
449
+ return lines.join("\n");
450
+ }
451
+
452
+ /**
453
+ * text 格式化:用户笔记列表。
454
+ * @param {object} resp
455
+ * @returns {string}
456
+ */
457
+ function userNotesTextFormatter(resp = {}) {
458
+ const notes = (resp.data && resp.data.notes) || [];
459
+ const lines = [`✓ 共取到 ${notes.length} 条笔记`];
460
+ let totalLikes = 0;
461
+ notes.forEach((note, index) => {
462
+ totalLikes += toCount(note.likes);
463
+ const flags = [];
464
+ if (note.sticky) flags.push("置顶");
465
+ if (note.is_goods_note) flags.push("带货");
466
+ const flagText = flags.length ? ` [${flags.join("/")}]` : "";
467
+ const published = formatXhsTime(note.time).slice(0, 10);
468
+ lines.push(
469
+ "",
470
+ `[${index + 1}] ${note.title || "(无标题)"}${flagText}`,
471
+ ` ${note.type === "video" ? "视频" : "图文"} · 赞 ${toCount(note.likes)} / 藏 ${toCount(note.collected_count)}` +
472
+ ` / 评 ${toCount(note.comments_count)}${published ? ` · ${published}` : ""}`
473
+ );
474
+ });
475
+ if (notes.length) lines.push("", `点赞合计: ${totalLikes}`);
476
+ return lines.join("\n");
477
+ }
478
+
479
+ /**
480
+ * xhs note:获取单篇笔记详情。
481
+ * @param {string} input 笔记链接 / 短链 / 分享口令 / note_id
482
+ * @param {{apiKey?: string, format?: "json"|"text"}} options
483
+ * @returns {Promise<void>}
484
+ */
485
+ async function noteCommand(input, options = {}) {
486
+ const format = options.format || "json";
487
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
488
+
489
+ try {
490
+ const noteId = await resolveNoteId(input);
491
+ const result = await xhsNoteInfo(apiKey, noteId);
492
+ outputResult(result, format, noteTextFormatter);
493
+ process.exit(0);
494
+ } catch (err) {
495
+ handleApiError(err, format);
496
+ process.exit(1);
497
+ }
498
+ }
499
+
500
+ /**
501
+ * xhs comments:获取笔记一级评论(含可见的楼内回复)。
502
+ * @param {string} input 笔记链接 / 短链 / 分享口令 / note_id
503
+ * @param {{apiKey?: string, format?: string, maxPages?: string, maxSeconds?: string}} options
504
+ * @returns {Promise<void>}
505
+ */
506
+ async function commentsCommand(input, options = {}) {
507
+ const format = options.format || "json";
508
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
509
+
510
+ try {
511
+ const noteId = await resolveNoteId(input);
512
+ const result = await xhsNoteComments(apiKey, {
513
+ noteId,
514
+ maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
515
+ maxSeconds: options.maxSeconds === undefined ? undefined : safeParseInt(options.maxSeconds, 50),
516
+ });
517
+ outputResult(result, format, commentsTextFormatter);
518
+ process.exit(0);
519
+ } catch (err) {
520
+ handleApiError(err, format);
521
+ process.exit(1);
522
+ }
523
+ }
524
+
525
+ /**
526
+ * xhs search:按关键词搜索笔记。
527
+ * @param {string} keyword
528
+ * @param {{apiKey?: string, format?: string, sort?: string, noteTime?: string, noteType?: string, maxPages?: string}} options
529
+ * @returns {Promise<void>}
530
+ */
531
+ async function searchCommand(keyword, options = {}) {
532
+ const format = options.format || "json";
533
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
534
+
535
+ try {
536
+ const result = await xhsNoteSearch(apiKey, {
537
+ keyword,
538
+ maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
539
+ sort: options.sort,
540
+ noteTime: options.noteTime,
541
+ noteType: options.noteType,
542
+ });
543
+ outputResult(result, format, searchTextFormatter);
544
+ process.exit(0);
545
+ } catch (err) {
546
+ handleApiError(err, format);
547
+ process.exit(1);
548
+ }
549
+ }
550
+
551
+ /**
552
+ * xhs user:获取用户详情。
553
+ * @param {string} input 主页链接 / user_id
554
+ * @param {{apiKey?: string, format?: "json"|"text"}} options
555
+ * @returns {Promise<void>}
556
+ */
557
+ async function userCommand(input, options = {}) {
558
+ const format = options.format || "json";
559
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
560
+
561
+ try {
562
+ const userId = resolveUserId(input);
563
+ const result = await xhsUserInfo(apiKey, userId);
564
+ outputResult(result, format, userInfoTextFormatter);
565
+ process.exit(0);
566
+ } catch (err) {
567
+ handleApiError(err, format);
568
+ process.exit(1);
569
+ }
570
+ }
571
+
572
+ /**
573
+ * xhs user-notes:获取用户笔记列表。
574
+ * @param {string} input 主页链接 / user_id
575
+ * @param {{apiKey?: string, format?: string, maxPages?: string}} options
576
+ * @returns {Promise<void>}
577
+ */
578
+ async function userNotesCommand(input, options = {}) {
579
+ const format = options.format || "json";
580
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
581
+
582
+ try {
583
+ const userId = resolveUserId(input);
584
+ const result = await xhsUserNotes(apiKey, {
585
+ userId,
586
+ maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
587
+ });
588
+ outputResult(result, format, userNotesTextFormatter);
589
+ process.exit(0);
590
+ } catch (err) {
591
+ handleApiError(err, format);
592
+ process.exit(1);
593
+ }
594
+ }
595
+
596
+ async function darenReportCommand(input, options = {}) {
597
+ const format = options.format || "json";
598
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
599
+ try {
600
+ const [bloggerResult, audienceResult, performanceResult] = await Promise.allSettled([
601
+ xhsPgyBlogger(apiKey, input).then((r) => assertDarenSegment(r, "blogger")),
602
+ xhsPgyAudience(apiKey, input).then((r) => assertDarenSegment(r, "audience")),
603
+ xhsPgyPerformance(apiKey, input).then((r) => assertDarenSegment(r, "performance")),
604
+ ]);
605
+ const result = buildDarenReportResult(bloggerResult, audienceResult, performanceResult);
606
+ outputResult(result, format, formatDarenReportText);
607
+ process.exit(0);
608
+ } catch (err) { handleApiError(err, format); process.exit(1); }
609
+ }
610
+
611
+ /**
612
+ * 原子接口的业务失败(success:false)不抛异常,这里转成 rejected 让 allSettled 能识别。
613
+ * @param {object} resp
614
+ * @param {string} service
615
+ * @returns {object}
616
+ */
617
+ function assertDarenSegment(resp, service) {
618
+ if (resp && resp.success === false) {
619
+ throw Object.assign(
620
+ new Error(resp.message || `${service} 获取失败`),
621
+ { type: "api_error", service, body: resp }
622
+ );
623
+ }
624
+ return resp;
625
+ }
626
+
627
+ function darenErrorMessage(reason) {
628
+ if (reason?.body?.message) return reason.body.message;
629
+ return reason?.message || String(reason || "未知错误");
630
+ }
631
+
632
+ /**
633
+ * 合并三段并行结果并打分。至少两段成功才算成功。
634
+ * @param {PromiseSettledResult<object>} bloggerResult
635
+ * @param {PromiseSettledResult<object>} audienceResult
636
+ * @param {PromiseSettledResult<object>} performanceResult
637
+ * @returns {object}
638
+ */
639
+ function buildDarenReportResult(bloggerResult, audienceResult, performanceResult) {
640
+ const segments = [
641
+ ["blogger", bloggerResult],
642
+ ["audience", audienceResult],
643
+ ["performance", performanceResult],
644
+ ];
645
+ const succeeded = segments.filter(([, r]) => r.status === "fulfilled");
646
+ if (succeeded.length < 2) {
647
+ throw Object.assign(
648
+ new Error(`达人数据获取不完整: ${segments.map(([name, r]) => `${name}=${darenErrorMessage(r.reason)}`).join("; ")}`),
649
+ { type: "api_error" }
650
+ );
651
+ }
652
+
653
+ const valueOf = (result) => (result.status === "fulfilled" ? result.value.data || {} : {});
654
+ const profile = valueOf(bloggerResult);
655
+ const audience = valueOf(audienceResult);
656
+ const performance = valueOf(performanceResult);
657
+
658
+ const score = scoreDaren({
659
+ fansCount: profile.fans_count,
660
+ interactionRate: performance.interaction_rate,
661
+ hundredLikePercent: performance.hundred_like_percent,
662
+ });
663
+
664
+ const failedSources = segments
665
+ .filter(([, r]) => r.status === "rejected")
666
+ .map(([service, r]) => ({ service, message: darenErrorMessage(r.reason) }));
667
+
668
+ return {
669
+ success: true,
670
+ message: failedSources.length
671
+ ? "达人报告已生成,部分数据不可用"
672
+ : "达人报告已生成",
673
+ data: {
674
+ profile,
675
+ audience,
676
+ performance,
677
+ score,
678
+ partial: failedSources.length > 0,
679
+ failed_sources: failedSources,
680
+ },
681
+ };
682
+ }
683
+
684
+ /**
685
+ * text 输出:达人报告
686
+ * @param {object} result
687
+ * @returns {string}
688
+ */
689
+ function formatDarenReportText(result = {}) {
690
+ const data = result.data || {};
691
+ const profile = data.profile || {};
692
+ const audience = data.audience || {};
693
+ const performance = data.performance || {};
694
+ const score = data.score || {};
695
+ const pct = (value) => `${Math.round(Number(value) * 1000) / 10}%`;
696
+
697
+ const lines = ["✓ 小红书达人报告"];
698
+ if (profile.name) {
699
+ const redId = profile.red_id ? `(小红书号 ${profile.red_id})` : "";
700
+ lines.push(` 达人: ${profile.name}${redId}`);
701
+ }
702
+ if (profile.location) lines.push(` 地区: ${profile.location}`);
703
+ if (profile.fans_count !== undefined) lines.push(` 粉丝: ${profile.fans_count}`);
704
+ if (profile.business_note_count !== undefined) {
705
+ lines.push(` 合作笔记: ${profile.business_note_count}`);
706
+ }
707
+
708
+ if (audience.gender) {
709
+ lines.push("", "【粉丝画像】");
710
+ lines.push(` 性别: 女 ${pct(audience.gender.female)} / 男 ${pct(audience.gender.male)}`);
711
+ if (Array.isArray(audience.ages) && audience.ages.length) {
712
+ lines.push(` 年龄: ${audience.ages.map((a) => `${a.group} ${pct(a.percent)}`).join(" · ")}`);
713
+ }
714
+ if (Array.isArray(audience.interests) && audience.interests.length) {
715
+ lines.push(` 兴趣: ${audience.interests.map((i) => `${i.name} ${pct(i.percent)}`).join(" · ")}`);
716
+ }
717
+ }
718
+
719
+ if (performance.note_number !== undefined) {
720
+ lines.push("", "【笔记性价比】(近 30 天)");
721
+ lines.push(` 笔记数: ${performance.note_number}`);
722
+ lines.push(` 百赞率: ${performance.hundred_like_percent}%`);
723
+ lines.push(` 曝光中位数: ${performance.imp_median}(超越 ${performance.imp_median_beyond_rate}%)`);
724
+ lines.push(` 阅读中位数: ${performance.read_median}(超越 ${performance.read_median_beyond_rate}%)`);
725
+ lines.push(` 互动率: ${performance.interaction_rate}%(超越 ${performance.interaction_beyond_rate}%)`);
726
+ }
727
+
728
+ if (score.tier) {
729
+ lines.push("", `【评分】${score.value} 分 · ${score.tier}${score.partial ? "(部分维度缺失,已归一)" : ""}`);
730
+ (score.basis || []).forEach((item) => {
731
+ lines.push(` - ${item.item}: ${item.got}/${item.max}`);
732
+ });
733
+ }
734
+
735
+ const failed = data.failed_sources || [];
736
+ if (failed.length) {
737
+ lines.push("", "失败数据源:");
738
+ failed.forEach((item) => lines.push(` - ${item.service}: ${item.message}`));
739
+ }
740
+ return lines.join("\n");
741
+ }
742
+
743
+ module.exports = {
744
+ hotListCommand,
745
+ hotListTextFormatter,
746
+ notePublishCommand,
747
+ notePublishTextFormatter,
748
+ noteCommand,
749
+ commentsCommand,
750
+ searchCommand,
751
+ userCommand,
752
+ userNotesCommand,
753
+ noteTextFormatter,
754
+ commentsTextFormatter,
755
+ searchTextFormatter,
756
+ userInfoTextFormatter,
757
+ userNotesTextFormatter,
758
+ resolveNoteId,
759
+ resolveUserId,
760
+ darenReportCommand,
761
+ buildDarenReportResult,
762
+ formatDarenReportText,
763
+ };