aiconductor-cli 1.3.4 → 1.4.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 (83) hide show
  1. package/AGENTS.md +44 -4
  2. package/README.md +48 -1
  3. package/api_docs/aic_web_search.md +84 -0
  4. package/api_docs/audio_converter.md +51 -0
  5. package/api_docs/balance_query.md +38 -0
  6. package/api_docs/bing_search.md +55 -0
  7. package/api_docs/china_stock_data.md +221 -0
  8. package/api_docs/delete_published_webpage.md +38 -0
  9. package/api_docs/doubao_search.md +80 -0
  10. package/api_docs/douyin_link_to_text_v1.md +50 -0
  11. package/api_docs/douyin_link_to_text_v2.md +50 -0
  12. package/api_docs/douyin_user_detail.md +65 -0
  13. package/api_docs/douyin_user_search.md +58 -0
  14. package/api_docs/douyin_user_videos.md +49 -0
  15. package/api_docs/douyin_user_videos_async.md +45 -0
  16. package/api_docs/douyin_user_videos_async_query.md +41 -0
  17. package/api_docs/douyin_user_videos_max.md +59 -0
  18. package/api_docs/douyin_video_comments.md +68 -0
  19. package/api_docs/douyin_video_info.md +70 -0
  20. package/api_docs/douyin_video_search.md +60 -0
  21. package/api_docs/douyin_video_search_fast.md +48 -0
  22. package/api_docs/fast_ocr.md +51 -0
  23. package/api_docs/flow_article2podcast_creat.md +41 -0
  24. package/api_docs/flow_article2podcast_query.md +41 -0
  25. package/api_docs/flow_wx_history_articles.md +45 -0
  26. package/api_docs/generate_image.md +68 -0
  27. package/api_docs/get_duration.md +37 -0
  28. package/api_docs/hot_list_mixed.md +122 -0
  29. package/api_docs/html_publish.md +50 -0
  30. package/api_docs/html_publish_flexible.md +65 -0
  31. package/api_docs/image_resize.md +58 -0
  32. package/api_docs/image_to_permalink.md +61 -0
  33. package/api_docs/link_expander.md +39 -0
  34. package/api_docs/llm_proxy.md +61 -0
  35. package/api_docs/markitdown.md +47 -0
  36. package/api_docs/md_to_docx.md +51 -0
  37. package/api_docs/md_to_html.md +53 -0
  38. package/api_docs/md_to_mindmap.md +52 -0
  39. package/api_docs/md_to_pdf.md +52 -0
  40. package/api_docs/md_to_png.md +53 -0
  41. package/api_docs/md_to_txt.md +51 -0
  42. package/api_docs/md_to_wxhtml.md +64 -0
  43. package/api_docs/md_to_xhs.md +87 -0
  44. package/api_docs/md_to_xlsx.md +52 -0
  45. package/api_docs/portrait_enhancement.md +69 -0
  46. package/api_docs/published_webpages.md +45 -0
  47. package/api_docs/r2_uploader.md +51 -0
  48. package/api_docs/schedulerx_job_create.md +56 -0
  49. package/api_docs/transaction_query.md +52 -0
  50. package/api_docs/url_to_markdown.md +50 -0
  51. package/api_docs/video_download_url.md +57 -0
  52. package/api_docs/video_download_url_oss.md +55 -0
  53. package/api_docs/video_to_text.md +48 -0
  54. package/api_docs/video_to_text_v2.md +49 -0
  55. package/api_docs/volcengine_face_pretty.md +55 -0
  56. package/api_docs/volcengine_faceswap.md +55 -0
  57. package/api_docs/website_screenshot.md +49 -0
  58. package/api_docs/wx_access_token.md +44 -0
  59. package/api_docs/wx_article_analysis_data_push.md +43 -0
  60. package/api_docs/wx_article_data.md +48 -0
  61. package/api_docs/wx_article_style_rewrite.md +45 -0
  62. package/api_docs/wx_draft_add.md +56 -0
  63. package/api_docs/wx_draft_publish.md +47 -0
  64. package/api_docs/wx_history_articles.md +70 -0
  65. package/api_docs/wx_image_upload.md +46 -0
  66. package/api_docs/wx_md_to_html.md +48 -0
  67. package/api_docs/wx_rss_reader.md +59 -0
  68. package/api_docs/xhs_hot_list.md +48 -0
  69. package/api_docs/xhs_note_comments_fast.md +54 -0
  70. package/api_docs/xhs_note_info_v2.md +45 -0
  71. package/api_docs/xhs_note_info_v3.md +69 -0
  72. package/api_docs/xhs_note_publish.md +54 -0
  73. package/api_docs/xhs_note_search_v2.md +73 -0
  74. package/api_docs/xhs_note_search_v3.md +54 -0
  75. package/api_docs/xhs_user_info_v2.md +57 -0
  76. package/api_docs/xhs_user_notes_v2.md +56 -0
  77. package/api_docs/xhs_user_notes_v3.md +46 -0
  78. package/bin/aiconductor-cli.js +36 -12
  79. package/package.json +1 -1
  80. package/src/api.js +63 -7
  81. package/src/api.test.js +217 -6
  82. package/src/commands/content.js +184 -0
  83. package/src/commands/xhs.js +268 -225
@@ -0,0 +1,46 @@
1
+ ## 接口信息
2
+ - **URL**: `http://plugin.aiconductor.fun/api/xhs_user_notes_v3`
3
+ - **方法**: `POST` / `GET`
4
+ - **Content-Type**: `application/json`
5
+ - **鉴权**: 请求头 `Authorization: Bearer <api_key>`(兼容 body/query 中 `api_key` 参数)
6
+
7
+ ## 功能说明
8
+ 根据用户 ID 获取小红书用户笔记列表,支持自动翻页(cursor 机制),兼容图文与视频笔记,按页计费。费用以 api_services 表配置为准(service_code: `xhs_user_notes_v2`)。
9
+
10
+ ## 请求参数
11
+
12
+ | 参数名 | 类型 | 必填 | 默认值 | 说明 |
13
+ |--------|------|------|--------|------|
14
+ | user_id | string | 是 | — | 小红书用户 ID |
15
+ | max_pages | int | 否 | 1 | 最大翻页数 |
16
+ | max_seconds | int | 否 | 50 | 最长运行时长(秒) |
17
+
18
+ > **鉴权参数**:api_key 通过请求头 `Authorization: Bearer <api_key>` 传递,不再列入请求参数表。
19
+
20
+ ## 响应参数
21
+
22
+ | 参数名 | 类型 | 说明 |
23
+ |--------|------|------|
24
+ | success | boolean | 是否成功 |
25
+ | message | string | 响应消息 |
26
+ | billing | object | 计费信息(service_fee / discount_applied / balance_before / balance_after / currency / pages_billed / total_service_fee / transaction_ids) |
27
+ | data.notes | array[object] | 笔记列表(见下) |
28
+
29
+ `data.notes[]` 字段:`noteId`(string)、`time`(int, 毫秒时间戳)、`title`(string)、`type`(string, normal/video)、`desc`(string)、`time_desc`(string)、`likes`(int)、`comments_count`(int)、`collected_count`(int)、`shared_count`(int)、`ip_location`(string)、`sticky`(bool)、`is_goods_note`(bool)、`has_music`(bool)、`user`(object{user_id, nickname, avatar})、`images`(array[{url, url_size_large, width, height}])。
30
+ 视频笔记额外包含 `video`(object):`duration`(int)、`width`(int)、`height`(int)、`cover_url`(string)、`thumbnail_url`(string)、`streams`(array[{codec, quality, url, backup_urls, width, height, duration, fps, format, size}])。
31
+
32
+ ## 请求示例
33
+
34
+ ```bash
35
+ curl -X POST "http://plugin.aiconductor.fun/api/xhs_user_notes_v3" \
36
+ -H "Authorization: Bearer your_api_key" \
37
+ -H "Content-Type: application/json" \
38
+ -d '{"user_id": "5f8e9c1b0000000001001a2b", "max_pages": 3}'
39
+ ```
40
+
41
+ ## 注意事项
42
+ 1. 按页计费:每成功获取一页计费一次,余额不足、超时或上游无更多数据自动停止翻页。
43
+ 2. 采用后扣费模式:仅成功获取并解析后才扣费,失败不扣费。
44
+ 3. 翻页由内部维护 `cursor`(取每页最后一条笔记的 cursor),调用方无需关心。
45
+ 4. 返回数据同时兼容图文(normal)与视频(video)笔记,视频笔记带 streams 流信息。
46
+ 5. 上游单页请求超时 55s,最多重试 3 次。
@@ -7,7 +7,8 @@ const {
7
7
  listCommand: webpageListCommand,
8
8
  deleteCommand: webpageDeleteCommand,
9
9
  } = require("../src/commands/md_publish");
10
- const { searchCommand } = require("../src/commands/web_search");
10
+ const { searchCommand } = require("../src/commands/web_search");
11
+ const { briefingCommand } = require("../src/commands/content");
11
12
  const {
12
13
  videoInfoCommand,
13
14
  videoCommentsCommand,
@@ -17,7 +18,7 @@ const {
17
18
  videoSearchCommand,
18
19
  linkToTextCommand,
19
20
  } = require("../src/commands/douyin");
20
- const { notePublishCommand } = require("../src/commands/xhs");
21
+ const { hotListCommand: xhsHotListCommand, notePublishCommand } = require("../src/commands/xhs");
21
22
 
22
23
  const program = new Command();
23
24
 
@@ -111,8 +112,8 @@ searchCmd
111
112
  searchCommand(query, "web", options);
112
113
  });
113
114
 
114
- searchCmd
115
- .command("image")
115
+ searchCmd
116
+ .command("image")
116
117
  .description("图片搜索")
117
118
  .argument("<query>", "搜索关键词(1~100字符)")
118
119
  .option("-k, --api-key <key>", "AIConductor API Key(也可通过 AICONDUCTOR_API_KEY 环境变量或 .env 文件提供)")
@@ -124,9 +125,25 @@ searchCmd
124
125
  .option("--image-shapes <shapes>", "图片形状,以 | 分隔:横长方形、竖长方形、方形")
125
126
  .option("--query-rewrite", "开启 Query 改写")
126
127
  .option("-f, --format <format>", "输出格式:json 或 text", "json")
127
- .action((query, options) => {
128
- searchCommand(query, "image", options);
129
- });
128
+ .action((query, options) => {
129
+ searchCommand(query, "image", options);
130
+ });
131
+
132
+ // content 子命令组
133
+ const contentCmd = program
134
+ .command("content")
135
+ .description("内容选题与素材工具");
136
+
137
+ contentCmd
138
+ .command("briefing")
139
+ .description("聚合多平台热点与小红书热榜,生成选题原料")
140
+ .option("-k, --api-key <key>", "AIConductor API Key(也可通过 AICONDUCTOR_API_KEY 环境变量或 .env 文件提供)")
141
+ .option("--sources <ids>", "来源 ID,逗号分隔;默认 zhihu,weibo,douyin,aihot")
142
+ .option("--limit <n>", "每个来源返回条数,范围 1 到 30", "30")
143
+ .option("-f, --format <format>", "输出格式:json 或 text", "json")
144
+ .action((options) => {
145
+ briefingCommand(options);
146
+ });
130
147
 
131
148
  // 未知命令处理
132
149
 
@@ -205,11 +222,18 @@ douyinCmd
205
222
  .action((videoUrl, options) => { linkToTextCommand(videoUrl, options); });
206
223
 
207
224
  // xhs 子命令组
208
- const xhsCmd = program
209
- .command("xhs")
210
- .description("小红书数据工具箱");
211
-
212
- xhsCmd
225
+ const xhsCmd = program
226
+ .command("xhs")
227
+ .description("小红书数据工具箱");
228
+
229
+ xhsCmd
230
+ .command("hot-list")
231
+ .description("获取小红书实时热榜")
232
+ .option("-k, --api-key <key>", "AIConductor API Key")
233
+ .option("-f, --format <format>", "输出格式:json 或 text", "json")
234
+ .action((options) => { xhsHotListCommand(options); });
235
+
236
+ xhsCmd
213
237
  .command("note-publish")
214
238
  .description("生成小红书扫码发布页与二维码(0.05元/次)")
215
239
  .argument("<type>", "笔记类型:normal(图文)或 video(视频)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiconductor-cli",
3
- "version": "1.3.4",
3
+ "version": "1.4.0",
4
4
  "description": "AIConductor CLI 工具箱",
5
5
  "keywords": [
6
6
  "aiconductor",
package/src/api.js CHANGED
@@ -1,4 +1,5 @@
1
- const https = require("https");
1
+ const http = require("http");
2
+ const https = require("https");
2
3
  const fs = require("fs");
3
4
  const path = require("path");
4
5
 
@@ -8,6 +9,8 @@ const MD_TO_HTML_API_PATH = "/api/md_to_html";
8
9
  const PUBLISHED_WEBPAGES_API_PATH = "/api/published_webpages";
9
10
  const DELETE_PUBLISHED_WEBPAGE_API_PATH = "/api/delete_published_webpage";
10
11
  const SEARCH_API_PATH = "/api/doubao_search";
12
+ const HOT_LIST_API_PATH = "/api/hot_list_mixed";
13
+ const XHS_HOT_LIST_API_PATH = "/api/xhs_hot_list";
11
14
  const DOUYIN_VIDEO_INFO_PATH = "/api/douyin_video_info";
12
15
  const DOUYIN_VIDEO_COMMENTS_PATH = "/api/douyin_video_comments";
13
16
  const DOUYIN_USER_VIDEOS_PATH = "/api/douyin_user_videos";
@@ -75,14 +78,15 @@ function validateInt(value, name) {
75
78
  * @param {string} host
76
79
  * @param {string} path
77
80
  * @param {object} body
78
- * @returns {Promise<object>}
79
- */
80
- function httpPost(host, requestPath, body, headers = {}) {
81
+ * @param {typeof http|typeof https} [transport]
82
+ * @returns {Promise<object>}
83
+ */
84
+ function httpPost(host, requestPath, body, headers = {}, transport = https) {
81
85
  return new Promise((resolve, reject) => {
82
86
  const data = JSON.stringify(body);
83
87
  const options = {
84
88
  hostname: host,
85
- port: 443,
89
+ port: transport === http ? 80 : 443,
86
90
  path: requestPath,
87
91
  method: "POST",
88
92
  headers: {
@@ -92,7 +96,7 @@ function httpPost(host, requestPath, body, headers = {}) {
92
96
  },
93
97
  };
94
98
 
95
- const req = https.request(options, (res) => {
99
+ const req = transport.request(options, (res) => {
96
100
  let chunks = [];
97
101
  res.on("data", (chunk) => chunks.push(chunk));
98
102
  res.on("end", () => {
@@ -343,6 +347,56 @@ async function webSearch(apiKey, params) {
343
347
 
344
348
  return result;
345
349
  }
350
+
351
+ /**
352
+ * 聚合多个平台的热榜条目。
353
+ * @param {string} apiKey
354
+ * @param {{ sourceIds: string[]|string, limit?: number|string }} params
355
+ * @returns {Promise<object>}
356
+ */
357
+ async function hotList(apiKey, params) {
358
+ const inputSources = Array.isArray(params.sourceIds)
359
+ ? params.sourceIds
360
+ : String(params.sourceIds || "").split(",");
361
+ const sourceIds = [...new Set(inputSources.map((source) => String(source).trim()).filter(Boolean))];
362
+ if (sourceIds.length === 0) {
363
+ throw Object.assign(new Error("sourceIds 至少需要提供一个来源"), { type: "validation_error" });
364
+ }
365
+ if (sourceIds.length > 52) {
366
+ throw Object.assign(new Error("sourceIds 最多支持 52 个来源"), { type: "validation_error" });
367
+ }
368
+
369
+ const limit = validatePositiveInt(params.limit === undefined ? 30 : params.limit, "limit", 30);
370
+ // The hot-list service is currently served over HTTP; see api_docs/hot_list_mixed.md.
371
+ const resp = await httpPost(API_HOST, HOT_LIST_API_PATH, {
372
+ source_ids: sourceIds,
373
+ latest: true,
374
+ limit,
375
+ }, {
376
+ Authorization: `Bearer ${apiKey}`,
377
+ }, http);
378
+ const result = stripBilling(resp);
379
+ if (result?.success === false) {
380
+ throw Object.assign(new Error(result.message || "热榜接口返回失败"), { type: "api_error" });
381
+ }
382
+ return result;
383
+ }
384
+
385
+ /**
386
+ * 获取小红书实时热榜。
387
+ * @param {string} apiKey
388
+ * @returns {Promise<object>}
389
+ */
390
+ async function xhsHotList(apiKey) {
391
+ const resp = await httpPost(API_HOST, XHS_HOT_LIST_API_PATH, {}, {
392
+ Authorization: `Bearer ${apiKey}`,
393
+ }, http);
394
+ const result = stripBilling(resp);
395
+ if (result?.success === false) {
396
+ throw Object.assign(new Error(result.message || "小红书热榜接口返回失败"), { type: "api_error" });
397
+ }
398
+ return result;
399
+ }
346
400
 
347
401
  /**
348
402
  * Markdown 转 HTML 并发布
@@ -537,7 +591,9 @@ module.exports = {
537
591
  publishMdToHtml,
538
592
  getPublishedWebpages,
539
593
  deletePublishedWebpage,
540
- webSearch,
594
+ webSearch,
595
+ hotList,
596
+ xhsHotList,
541
597
  douyinVideoInfo,
542
598
  douyinVideoComments,
543
599
  douyinUserVideos,
package/src/api.test.js CHANGED
@@ -1,15 +1,24 @@
1
1
  const assert = require("node:assert/strict");
2
2
  const { EventEmitter } = require("node:events");
3
+ const http = require("node:http");
3
4
  const https = require("node:https");
4
5
  const test = require("node:test");
5
6
 
6
- const { webSearch } = require("./api");
7
+ const { hotList, webSearch, xhsHotList } = require("./api");
8
+ const {
9
+ buildBriefingResult,
10
+ formatHotListText,
11
+ parseLimit,
12
+ parseSourceIds,
13
+ DEFAULT_HOT_LIST_SOURCES,
14
+ } = require("./commands/content");
7
15
  const { formatSearchText } = require("./commands/web_search");
16
+ const { hotListTextFormatter, notePublishTextFormatter } = require("./commands/xhs");
8
17
 
9
- function captureRequest(callback, responseBody = { success: true, billing: {} }) {
10
- const originalRequest = https.request;
18
+ function captureRequest(callback, responseBody = { success: true, billing: {} }, transport = https) {
19
+ const originalRequest = transport.request;
11
20
  let captured;
12
- https.request = (options, responseCallback) => {
21
+ transport.request = (options, responseCallback) => {
13
22
  const request = new EventEmitter();
14
23
  const chunks = [];
15
24
  request.write = (chunk) => chunks.push(Buffer.from(chunk));
@@ -24,8 +33,11 @@ function captureRequest(callback, responseBody = { success: true, billing: {} })
24
33
  return request;
25
34
  };
26
35
 
27
- return callback().then(() => captured).finally(() => {
28
- https.request = originalRequest;
36
+ return callback().then((result) => {
37
+ captured.result = result;
38
+ return captured;
39
+ }).finally(() => {
40
+ transport.request = originalRequest;
29
41
  });
30
42
  }
31
43
 
@@ -155,3 +167,202 @@ test("webSearch raises nested API failures", async () => {
155
167
  { type: "api_error", message: "image 搜索的 count 必须为 1~5" }
156
168
  );
157
169
  });
170
+
171
+ test("hotList sends deduplicated sources with current defaults", async () => {
172
+ const request = await captureRequest(
173
+ () => hotList("test-key", { sourceIds: ["zhihu", "weibo", "zhihu", "aihot"] }),
174
+ undefined,
175
+ http
176
+ );
177
+
178
+ assert.equal(request.options.path, "/api/hot_list_mixed");
179
+ assert.equal(request.options.port, 80);
180
+ assert.equal(request.options.headers.Authorization, "Bearer test-key");
181
+ assert.deepEqual(request.body, {
182
+ source_ids: ["zhihu", "weibo", "aihot"],
183
+ latest: true,
184
+ limit: 30,
185
+ });
186
+ assert.deepEqual(parseSourceIds(), DEFAULT_HOT_LIST_SOURCES);
187
+ assert.deepEqual(parseSourceIds("zhihu, weibo,zhihu"), ["zhihu", "weibo"]);
188
+ });
189
+
190
+ test("hotList validates source IDs and limits", async () => {
191
+ await assert.rejects(hotList("test-key", { sourceIds: [] }), { type: "validation_error" });
192
+ await assert.rejects(
193
+ hotList("test-key", { sourceIds: Array.from({ length: 53 }, (_, index) => `source-${index}`) }),
194
+ { type: "validation_error" }
195
+ );
196
+ await assert.rejects(hotList("test-key", { sourceIds: ["zhihu"], limit: 0 }), { type: "validation_error" });
197
+ await assert.rejects(hotList("test-key", { sourceIds: ["zhihu"], limit: 31 }), { type: "validation_error" });
198
+ });
199
+
200
+ test("formatHotListText keeps successful sources and reports failures", () => {
201
+ const text = formatHotListText({
202
+ data: {
203
+ sources: [{
204
+ source_id: "zhihu",
205
+ status: "cache",
206
+ updated_time: 0,
207
+ items: [{ title: "AI 应用趋势", url: "https://example.com/topic", pub_date: "今天" }],
208
+ }],
209
+ failed_sources: [{ source_id: "weibo", message: "上游暂不可用" }],
210
+ },
211
+ });
212
+
213
+ assert.match(text, /【zhihu】缓存/);
214
+ assert.match(text, /AI 应用趋势/);
215
+ assert.match(text, /URL: https:\/\/example\.com\/topic/);
216
+ assert.match(text, /失败来源:/);
217
+ assert.match(text, /weibo: 上游暂不可用/);
218
+ });
219
+
220
+ test("hotList raises API failures", async () => {
221
+ await assert.rejects(
222
+ captureRequest(
223
+ () => hotList("test-key", { sourceIds: ["zhihu"] }),
224
+ { success: false, message: "没有可用热榜来源" },
225
+ http
226
+ ),
227
+ { type: "api_error", message: "没有可用热榜来源" }
228
+ );
229
+ });
230
+
231
+ test("xhsHotList calls the XHS hot-list endpoint over HTTP", async () => {
232
+ const responseBody = {
233
+ success: true,
234
+ data: {
235
+ date: "2026-07-29",
236
+ items: [{ rank: 1, title: "夏日穿搭", hot: "123万", trend: "up", url: "https://example.com/xhs" }],
237
+ },
238
+ billing: { service_fee: 0.01 },
239
+ };
240
+ const request = await captureRequest(() => xhsHotList("test-key"), responseBody, http);
241
+
242
+ assert.equal(request.options.path, "/api/xhs_hot_list");
243
+ assert.equal(request.options.port, 80);
244
+ assert.equal(request.options.headers.Authorization, "Bearer test-key");
245
+ assert.deepEqual(request.body, {});
246
+ assert.equal(request.result.billing, undefined);
247
+ });
248
+
249
+ test("xhsHotList raises API failures", async () => {
250
+ await assert.rejects(
251
+ captureRequest(
252
+ () => xhsHotList("test-key"),
253
+ { success: false, message: "小红书热榜暂不可用" },
254
+ http
255
+ ),
256
+ { type: "api_error", message: "小红书热榜暂不可用" }
257
+ );
258
+ });
259
+
260
+ test("buildBriefingResult preserves hot-list fields and adds XHS data", () => {
261
+ const result = buildBriefingResult(
262
+ {
263
+ status: "fulfilled",
264
+ value: {
265
+ success: true,
266
+ data: {
267
+ sources: [{ source_id: "zhihu", items: [{ title: "AI 应用趋势" }] }],
268
+ failed_sources: [{ source_id: "weibo", message: "上游暂不可用" }],
269
+ },
270
+ },
271
+ },
272
+ {
273
+ status: "fulfilled",
274
+ value: {
275
+ success: true,
276
+ data: { date: "2026-07-29", items: [{ rank: 1, title: "AI 工具" }] },
277
+ },
278
+ }
279
+ );
280
+
281
+ assert.equal(result.success, true);
282
+ assert.equal(result.data.sources[0].source_id, "zhihu");
283
+ assert.equal(result.data.failed_sources[0].source_id, "weibo");
284
+ assert.equal(result.data.xhs_hot_list.items[0].title, "AI 工具");
285
+ assert.deepEqual(result.data.failed_services, []);
286
+ });
287
+
288
+ test("buildBriefingResult keeps either service when the other one fails", () => {
289
+ const hotListOnly = buildBriefingResult(
290
+ { status: "fulfilled", value: { success: true, data: { sources: [], failed_sources: [] } } },
291
+ { status: "rejected", reason: Object.assign(new Error("小红书超时"), { type: "network_error" }) }
292
+ );
293
+ assert.equal(hotListOnly.data.xhs_hot_list, null);
294
+ assert.deepEqual(hotListOnly.data.failed_services, [{ service: "xhs_hot_list", message: "小红书超时" }]);
295
+
296
+ const xhsOnly = buildBriefingResult(
297
+ { status: "rejected", reason: Object.assign(new Error("热榜超时"), { type: "network_error" }) },
298
+ { status: "fulfilled", value: { success: true, data: { date: "2026-07-29", items: [] } } }
299
+ );
300
+ assert.deepEqual(xhsOnly.data.sources, []);
301
+ assert.equal(xhsOnly.data.xhs_hot_list.date, "2026-07-29");
302
+ assert.deepEqual(xhsOnly.data.failed_services, [{ service: "hot_list", message: "热榜超时" }]);
303
+ });
304
+
305
+ test("buildBriefingResult fails only when both services fail", () => {
306
+ assert.throws(
307
+ () => buildBriefingResult(
308
+ { status: "rejected", reason: new Error("热榜超时") },
309
+ { status: "rejected", reason: new Error("小红书超时") }
310
+ ),
311
+ { type: "api_error", message: /均获取失败/ }
312
+ );
313
+ });
314
+
315
+ test("hot-list formatters render XHS trends and service failures", () => {
316
+ const briefingText = formatHotListText({
317
+ data: {
318
+ sources: [],
319
+ xhs_hot_list: {
320
+ date: "2026-07-29",
321
+ items: [{ rank: 1, title: "夏日穿搭", hot: "123万", trend: "up", url: "https://example.com/xhs" }],
322
+ },
323
+ failed_services: [{ service: "hot_list", message: "上游暂不可用" }],
324
+ },
325
+ });
326
+ assert.match(briefingText, /【小红书热榜】2026-07-29/);
327
+ assert.match(briefingText, /\[1\] 夏日穿搭 up/);
328
+ assert.match(briefingText, /服务失败:/);
329
+
330
+ const xhsText = hotListTextFormatter({
331
+ data: { date: "2026-07-29", items: [{ rank: 2, title: "露营", hot_value: 88, trend: "new" }] },
332
+ });
333
+ assert.match(xhsText, /小红书热榜/);
334
+ assert.match(xhsText, /\[2\] 露营 new/);
335
+ assert.match(xhsText, /热度: 88/);
336
+ });
337
+
338
+ test("note-publish formatter surfaces the shareable qrcode image URL", () => {
339
+ const text = notePublishTextFormatter({
340
+ message: "发布页生成成功",
341
+ data: {
342
+ id: "note-1",
343
+ url: "https://oss.aiconductor.fun/temporary_7/xhs_note_publish/note-1/index.html",
344
+ qrcode: "data:image/png;base64,AAAA",
345
+ qrcode_url: "https://oss.aiconductor.fun/temporary_7/xhs_note_publish/note-1/qrcode.png",
346
+ },
347
+ });
348
+ assert.match(text, /H5 地址:/);
349
+ assert.match(text, /二维码图: https:\/\/oss\.aiconductor\.fun\/temporary_7\/xhs_note_publish\/note-1\/qrcode\.png/);
350
+ });
351
+
352
+ test("note-publish formatter tolerates responses without qrcode_url", () => {
353
+ // 旧版后端只返回 base64 二维码,formatter 不能因此报错
354
+ const text = notePublishTextFormatter({
355
+ data: { id: "note-2", url: "https://example.com/index.html" },
356
+ });
357
+ assert.match(text, /H5 地址:/);
358
+ assert.doesNotMatch(text, /二维码图/);
359
+ });
360
+
361
+ test("parseLimit accepts the documented range", () => {
362
+ assert.equal(parseLimit(), 30);
363
+ assert.equal(parseLimit("1"), 1);
364
+ assert.equal(parseLimit(30), 30);
365
+ assert.throws(() => parseLimit("0"), { type: "validation_error" });
366
+ assert.throws(() => parseLimit("31"), { type: "validation_error" });
367
+ assert.throws(() => parseLimit("invalid"), { type: "validation_error" });
368
+ });
@@ -0,0 +1,184 @@
1
+ const { hotList, xhsHotList } = require("../api");
2
+ const { outputResult, outputError, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
3
+
4
+ const DEFAULT_HOT_LIST_SOURCES = ["zhihu", "weibo", "douyin", "aihot"];
5
+
6
+ /**
7
+ * 解析并去重逗号分隔的平台来源 ID。
8
+ * @param {string|undefined} value
9
+ * @returns {string[]}
10
+ */
11
+ function parseSourceIds(value) {
12
+ const rawSources = value === undefined ? DEFAULT_HOT_LIST_SOURCES : String(value).split(",");
13
+ const sourceIds = [...new Set(rawSources.map((source) => source.trim()).filter(Boolean))];
14
+ if (sourceIds.length === 0) {
15
+ throw Object.assign(new Error("--sources 至少需要提供一个来源 ID"), { type: "validation_error" });
16
+ }
17
+ if (sourceIds.length > 52) {
18
+ throw Object.assign(new Error("--sources 最多支持 52 个来源"), { type: "validation_error" });
19
+ }
20
+ return sourceIds;
21
+ }
22
+
23
+ /**
24
+ * 解析每个热榜来源的返回条数。
25
+ * @param {string|number|undefined} value
26
+ * @returns {number}
27
+ */
28
+ function parseLimit(value) {
29
+ const limit = Number(value === undefined ? 30 : value);
30
+ if (!Number.isInteger(limit) || limit < 1 || limit > 30) {
31
+ throw Object.assign(new Error(`--limit 须为 1 到 30 的整数,收到: ${value}`), {
32
+ type: "validation_error",
33
+ });
34
+ }
35
+ return limit;
36
+ }
37
+
38
+ function formatTimestamp(value) {
39
+ if (typeof value !== "number" || !Number.isFinite(value)) return value || "";
40
+ return new Date(value).toISOString();
41
+ }
42
+
43
+ function errorMessage(reason) {
44
+ if (reason?.body?.message) return reason.body.message;
45
+ return reason?.message || String(reason || "未知错误");
46
+ }
47
+
48
+ /**
49
+ * 将热榜和小红书热榜的并行结果组合为兼容既有字段的简报响应。
50
+ * @param {PromiseSettledResult<object>} hotListResult
51
+ * @param {PromiseSettledResult<object>} xhsHotListResult
52
+ * @returns {object}
53
+ */
54
+ function buildBriefingResult(hotListResult, xhsHotListResult) {
55
+ const hotListSucceeded = hotListResult.status === "fulfilled";
56
+ const xhsHotListSucceeded = xhsHotListResult.status === "fulfilled";
57
+
58
+ if (!hotListSucceeded && !xhsHotListSucceeded) {
59
+ throw Object.assign(
60
+ new Error(`热点与小红书热榜均获取失败: ${errorMessage(hotListResult.reason)}; ${errorMessage(xhsHotListResult.reason)}`),
61
+ { type: "api_error" }
62
+ );
63
+ }
64
+
65
+ const failedServices = [];
66
+ if (!hotListSucceeded) {
67
+ failedServices.push({ service: "hot_list", message: errorMessage(hotListResult.reason) });
68
+ }
69
+ if (!xhsHotListSucceeded) {
70
+ failedServices.push({ service: "xhs_hot_list", message: errorMessage(xhsHotListResult.reason) });
71
+ }
72
+
73
+ const hotListData = hotListSucceeded
74
+ ? hotListResult.value.data || {}
75
+ : { sources: [], failed_sources: [] };
76
+ const xhsHotListData = xhsHotListSucceeded ? xhsHotListResult.value.data || {} : null;
77
+ return {
78
+ success: true,
79
+ message: failedServices.length ? "热点简报已生成,部分服务不可用" : "热点简报已生成",
80
+ data: {
81
+ ...hotListData,
82
+ xhs_hot_list: xhsHotListData,
83
+ failed_services: failedServices,
84
+ },
85
+ };
86
+ }
87
+
88
+ /**
89
+ * text 模式的热点简报格式化。
90
+ * @param {object} result
91
+ * @returns {string}
92
+ */
93
+ function formatHotListText(result = {}) {
94
+ const data = result.data || {};
95
+ const sources = data.sources || [];
96
+ const failedSources = data.failed_sources || [];
97
+ const lines = ["热点选题简报"];
98
+
99
+ if (!sources.length) {
100
+ lines.push("没有获取到可用热点来源。");
101
+ }
102
+
103
+ sources.forEach((source) => {
104
+ const status = source.status === "cache" ? "缓存" : source.status || "未知状态";
105
+ lines.push(`\n【${source.source_id}】${status}`);
106
+ if (source.updated_time) lines.push(`更新时间: ${formatTimestamp(source.updated_time)}`);
107
+ (source.items || []).forEach((item, index) => {
108
+ lines.push(`[${index + 1}] ${item.title || "未命名条目"}`);
109
+ if (item.url) lines.push(` URL: ${item.url}`);
110
+ if (item.pub_date) lines.push(` 发布时间: ${item.pub_date}`);
111
+ });
112
+ });
113
+
114
+ if (failedSources.length) {
115
+ lines.push("\n失败来源:");
116
+ failedSources.forEach((source) => {
117
+ lines.push(`- ${source.source_id}: ${source.message || "未知错误"}`);
118
+ });
119
+ }
120
+
121
+ const xhsHotList = data.xhs_hot_list;
122
+ if (xhsHotList) {
123
+ lines.push(`\n【小红书热榜】${xhsHotList.date || ""}`.trimEnd());
124
+ (xhsHotList.items || []).forEach((item) => {
125
+ const rank = item.rank === undefined ? "-" : item.rank;
126
+ const trend = item.trend ? ` ${item.trend}` : "";
127
+ lines.push(`[${rank}] ${item.title || "未命名话题"}${trend}`);
128
+ if (item.hot || item.hot_value !== undefined) {
129
+ lines.push(` 热度: ${item.hot || item.hot_value}`);
130
+ }
131
+ if (item.url) lines.push(` URL: ${item.url}`);
132
+ });
133
+ }
134
+
135
+ const failedServices = data.failed_services || [];
136
+ if (failedServices.length) {
137
+ lines.push("\n服务失败:");
138
+ failedServices.forEach((service) => {
139
+ lines.push(`- ${service.service || "未知服务"}: ${service.message || "未知错误"}`);
140
+ });
141
+ }
142
+
143
+ return lines.join("\n").trimEnd();
144
+ }
145
+
146
+ /**
147
+ * content briefing 命令。
148
+ * @param {{ apiKey?: string, sources?: string, limit?: string, format?: "json"|"text" }} options
149
+ * @returns {Promise<void>}
150
+ */
151
+ async function briefingCommand(options = {}) {
152
+ const format = options.format || "json";
153
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
154
+
155
+ try {
156
+ const hotListParams = {
157
+ sourceIds: parseSourceIds(options.sources),
158
+ limit: parseLimit(options.limit),
159
+ };
160
+ const [hotListResult, xhsHotListResult] = await Promise.allSettled([
161
+ hotList(apiKey, hotListParams),
162
+ xhsHotList(apiKey),
163
+ ]);
164
+ const result = buildBriefingResult(hotListResult, xhsHotListResult);
165
+ outputResult(result, format, formatHotListText);
166
+ process.exit(0);
167
+ } catch (err) {
168
+ if (err.type === "validation_error") {
169
+ outputError(err, format);
170
+ } else {
171
+ handleApiError(err, format);
172
+ }
173
+ process.exit(1);
174
+ }
175
+ }
176
+
177
+ module.exports = {
178
+ briefingCommand,
179
+ buildBriefingResult,
180
+ formatHotListText,
181
+ parseLimit,
182
+ parseSourceIds,
183
+ DEFAULT_HOT_LIST_SOURCES,
184
+ };