aiconductor-cli 1.3.5 → 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.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # AIConductor CLI
2
2
 
3
- AIConductor 平台命令行工具箱,提供网页发布、搜索、抖音数据和小红书扫码发布功能。
3
+ AIConductor 平台命令行工具箱,提供内容选题简报、网页发布、搜索、抖音数据和小红书工具。
4
4
 
5
5
  ## 安装
6
6
 
@@ -159,7 +159,7 @@ aiconductor-cli search image "长城风景" -n 3 --image-width-min 1200 --image-
159
159
 
160
160
  ### 热点选题简报
161
161
 
162
- 聚合多个平台的热点条目,为内容选题提供带来源链接的原料。默认查询知乎、微博、抖音和 AIHOT,每个来源返回 30 条。
162
+ 聚合多个平台热点与小红书实时热榜,为内容选题提供带来源链接的原料。默认查询知乎、微博、抖音和 AIHOT,每个来源返回 30 条,并额外请求一次小红书热榜。
163
163
 
164
164
  ```bash
165
165
  aiconductor-cli content briefing [选项]
@@ -181,7 +181,14 @@ aiconductor-cli content briefing -f text
181
181
  aiconductor-cli content briefing --sources aihot,hackernews,producthunt,juejin --limit 15 -f text
182
182
  ```
183
183
 
184
- 默认 JSON 输出会保留成功来源、缓存状态和失败来源,便于后续选题处理。部分来源失败时仍返回其他成功来源;仅成功返回数据的来源会计费。
184
+ 默认 JSON 输出保留以下字段,便于后续选题处理:
185
+
186
+ - `data.data.sources`:成功的综合热榜来源,兼容旧版本。
187
+ - `data.data.failed_sources`:综合热榜中失败的平台来源。
188
+ - `data.data.xhs_hot_list`:小红书热榜结果,包含日期与话题条目;小红书热榜接口失败时为 `null`。
189
+ - `data.data.failed_services`:综合热榜或小红书热榜接口级失败信息。
190
+
191
+ 任一服务成功时命令仍会返回可用数据;只有两个服务都失败时才退出失败。成功来源和成功的小红书热榜调用均按平台现有规则计费。
185
192
 
186
193
  ### 抖音数据工具箱
187
194
 
@@ -295,6 +302,19 @@ aiconductor-cli douyin link-to-text <视频URL> [选项]
295
302
  aiconductor-cli douyin link-to-text "https://www.douyin.com/video/xxxxx"
296
303
  ```
297
304
 
305
+ ### 小红书数据工具
306
+
307
+ 获取小红书实时热榜:
308
+
309
+ ```bash
310
+ aiconductor-cli xhs hot-list [选项]
311
+ ```
312
+
313
+ 示例:
314
+ ```bash
315
+ aiconductor-cli xhs hot-list -f text
316
+ ```
317
+
298
318
  ### 小红书扫码发布
299
319
 
300
320
  生成图文或视频笔记的 H5 发布页与二维码。素材支持公网 URL、本地文件、Base64/data URL,以及包含 Base64 内容的文本文件。
@@ -353,6 +373,7 @@ aiconductor-cli xhs note-publish video \
353
373
  | 抖音用户搜索 | 0.15 元/页 |
354
374
  | 抖音视频搜索 | 0.15 元/页 |
355
375
  | 抖音链接转文字 | 0.01 元/次 + 0.0003 元/秒 |
376
+ | 小红书热榜 | 以平台服务配置为准 |
356
377
  | 小红书扫码发布 | 0.05 元/次 |
357
378
 
358
379
  > 搜索/调用失败不扣费,仅成功时计费。
@@ -18,7 +18,7 @@ const {
18
18
  videoSearchCommand,
19
19
  linkToTextCommand,
20
20
  } = require("../src/commands/douyin");
21
- const { notePublishCommand } = require("../src/commands/xhs");
21
+ const { hotListCommand: xhsHotListCommand, notePublishCommand } = require("../src/commands/xhs");
22
22
 
23
23
  const program = new Command();
24
24
 
@@ -136,7 +136,7 @@ const contentCmd = program
136
136
 
137
137
  contentCmd
138
138
  .command("briefing")
139
- .description("聚合多平台热点,生成选题原料")
139
+ .description("聚合多平台热点与小红书热榜,生成选题原料")
140
140
  .option("-k, --api-key <key>", "AIConductor API Key(也可通过 AICONDUCTOR_API_KEY 环境变量或 .env 文件提供)")
141
141
  .option("--sources <ids>", "来源 ID,逗号分隔;默认 zhihu,weibo,douyin,aihot")
142
142
  .option("--limit <n>", "每个来源返回条数,范围 1 到 30", "30")
@@ -222,11 +222,18 @@ douyinCmd
222
222
  .action((videoUrl, options) => { linkToTextCommand(videoUrl, options); });
223
223
 
224
224
  // xhs 子命令组
225
- const xhsCmd = program
226
- .command("xhs")
227
- .description("小红书数据工具箱");
228
-
229
- 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
230
237
  .command("note-publish")
231
238
  .description("生成小红书扫码发布页与二维码(0.05元/次)")
232
239
  .argument("<type>", "笔记类型:normal(图文)或 video(视频)")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiconductor-cli",
3
- "version": "1.3.5",
3
+ "version": "1.4.0",
4
4
  "description": "AIConductor CLI 工具箱",
5
5
  "keywords": [
6
6
  "aiconductor",
package/src/api.js CHANGED
@@ -10,6 +10,7 @@ const PUBLISHED_WEBPAGES_API_PATH = "/api/published_webpages";
10
10
  const DELETE_PUBLISHED_WEBPAGE_API_PATH = "/api/delete_published_webpage";
11
11
  const SEARCH_API_PATH = "/api/doubao_search";
12
12
  const HOT_LIST_API_PATH = "/api/hot_list_mixed";
13
+ const XHS_HOT_LIST_API_PATH = "/api/xhs_hot_list";
13
14
  const DOUYIN_VIDEO_INFO_PATH = "/api/douyin_video_info";
14
15
  const DOUYIN_VIDEO_COMMENTS_PATH = "/api/douyin_video_comments";
15
16
  const DOUYIN_USER_VIDEOS_PATH = "/api/douyin_user_videos";
@@ -380,6 +381,22 @@ async function hotList(apiKey, params) {
380
381
  }
381
382
  return result;
382
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
+ }
383
400
 
384
401
  /**
385
402
  * Markdown 转 HTML 并发布
@@ -576,6 +593,7 @@ module.exports = {
576
593
  deletePublishedWebpage,
577
594
  webSearch,
578
595
  hotList,
596
+ xhsHotList,
579
597
  douyinVideoInfo,
580
598
  douyinVideoComments,
581
599
  douyinUserVideos,
package/src/api.test.js CHANGED
@@ -4,9 +4,16 @@ const http = require("node:http");
4
4
  const https = require("node:https");
5
5
  const test = require("node:test");
6
6
 
7
- const { hotList, webSearch } = require("./api");
8
- const { formatHotListText, parseSourceIds, DEFAULT_HOT_LIST_SOURCES } = require("./commands/content");
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");
9
15
  const { formatSearchText } = require("./commands/web_search");
16
+ const { hotListTextFormatter, notePublishTextFormatter } = require("./commands/xhs");
10
17
 
11
18
  function captureRequest(callback, responseBody = { success: true, billing: {} }, transport = https) {
12
19
  const originalRequest = transport.request;
@@ -26,7 +33,10 @@ function captureRequest(callback, responseBody = { success: true, billing: {} },
26
33
  return request;
27
34
  };
28
35
 
29
- return callback().then(() => captured).finally(() => {
36
+ return callback().then((result) => {
37
+ captured.result = result;
38
+ return captured;
39
+ }).finally(() => {
30
40
  transport.request = originalRequest;
31
41
  });
32
42
  }
@@ -217,3 +227,142 @@ test("hotList raises API failures", async () => {
217
227
  { type: "api_error", message: "没有可用热榜来源" }
218
228
  );
219
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
+ });
@@ -1,4 +1,4 @@
1
- const { hotList } = require("../api");
1
+ const { hotList, xhsHotList } = require("../api");
2
2
  const { outputResult, outputError, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
3
3
 
4
4
  const DEFAULT_HOT_LIST_SOURCES = ["zhihu", "weibo", "douyin", "aihot"];
@@ -20,11 +20,71 @@ function parseSourceIds(value) {
20
20
  return sourceIds;
21
21
  }
22
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
+
23
38
  function formatTimestamp(value) {
24
39
  if (typeof value !== "number" || !Number.isFinite(value)) return value || "";
25
40
  return new Date(value).toISOString();
26
41
  }
27
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
+
28
88
  /**
29
89
  * text 模式的热点简报格式化。
30
90
  * @param {object} result
@@ -58,6 +118,28 @@ function formatHotListText(result = {}) {
58
118
  });
59
119
  }
60
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
+
61
143
  return lines.join("\n").trimEnd();
62
144
  }
63
145
 
@@ -71,10 +153,15 @@ async function briefingCommand(options = {}) {
71
153
  const apiKey = resolveKeyOrExit(options.apiKey, format);
72
154
 
73
155
  try {
74
- const result = await hotList(apiKey, {
156
+ const hotListParams = {
75
157
  sourceIds: parseSourceIds(options.sources),
76
- limit: options.limit === undefined ? 30 : options.limit,
77
- });
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);
78
165
  outputResult(result, format, formatHotListText);
79
166
  process.exit(0);
80
167
  } catch (err) {
@@ -87,4 +174,11 @@ async function briefingCommand(options = {}) {
87
174
  }
88
175
  }
89
176
 
90
- module.exports = { briefingCommand, formatHotListText, parseSourceIds, DEFAULT_HOT_LIST_SOURCES };
177
+ module.exports = {
178
+ briefingCommand,
179
+ buildBriefingResult,
180
+ formatHotListText,
181
+ parseLimit,
182
+ parseSourceIds,
183
+ DEFAULT_HOT_LIST_SOURCES,
184
+ };
@@ -1,225 +1,268 @@
1
- const fs = require("fs");
2
- const path = require("path");
3
- const { r2Upload, xhsNotePublish } = require("../api");
4
- const { outputResult, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
5
-
6
- const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
7
- const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
8
-
9
- function validationError(message) {
10
- return Object.assign(new Error(message), { type: "validation_error" });
11
- }
12
-
13
- function resolveBase64Input(value, label) {
14
- if (!value.startsWith("@")) return value;
15
- const filePath = path.resolve(value.slice(1));
16
- let content;
17
- try {
18
- content = fs.readFileSync(filePath, "utf-8").trim();
19
- } catch (err) {
20
- throw validationError(`${label} Base64 文件读取失败 ${filePath}: ${err.message}`);
21
- }
22
- if (!content) throw validationError(`${label} Base64 文件内容为空: ${filePath}`);
23
- return content;
24
- }
25
-
26
- function validateLocalFile(filePath, label, maxSize) {
27
- const absolutePath = path.resolve(filePath);
28
- let stat;
29
- try {
30
- stat = fs.statSync(absolutePath);
31
- } catch (err) {
32
- throw validationError(`${label}文件读取失败 ${absolutePath}: ${err.message}`);
33
- }
34
- if (!stat.isFile()) throw validationError(`${label}路径不是文件: ${absolutePath}`);
35
- if (stat.size > maxSize) {
36
- throw validationError(`${label}文件超过 ${maxSize / 1024 / 1024}MB: ${absolutePath}`);
37
- }
38
- }
39
-
40
- function validateUrl(value, label) {
41
- let parsed;
42
- try {
43
- parsed = new URL(value);
44
- } catch (_) {
45
- throw validationError(`${label}不是有效 URL: ${value}`);
46
- }
47
- if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
48
- throw validationError(`${label}仅支持 HTTP/HTTPS URL: ${value}`);
49
- }
50
- }
51
-
52
- function validateBase64Size(value, label, maxSize) {
53
- const dataUrlPrefix = value.match(/^data:[^,]*,/i)?.[0];
54
- const payload = (dataUrlPrefix ? value.slice(dataUrlPrefix.length) : value).replace(/\s/g, "");
55
- const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
56
- const size = Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
57
- if (size > maxSize) {
58
- throw validationError(`${label} Base64 内容超过 ${maxSize / 1024 / 1024}MB`);
59
- }
60
- }
61
-
62
- async function uploadMedia(apiKey, params, label) {
63
- try {
64
- const result = await r2Upload(apiKey, params);
65
- return result.file_url;
66
- } catch (err) {
67
- const detail = err.type === "http_error"
68
- ? `HTTP ${err.statusCode}: ${err.body?.message || JSON.stringify(err.body)}`
69
- : err.message || err;
70
- const wrapped = new Error(`${label}上传失败: ${detail}`);
71
- wrapped.type = err.type === "validation_error" || err.type === "network_error"
72
- ? err.type
73
- : "api_error";
74
- wrapped.statusCode = err.statusCode;
75
- wrapped.body = err.body;
76
- throw wrapped;
77
- }
78
- }
79
-
80
- /**
81
- * text 格式输出:用 ✓ 开头展示关键信息
82
- */
83
- function textFormatter(resp) {
84
- const d = resp.data || {};
85
- const lines = [];
86
- lines.push("✓ 小红书笔记发布页已生成");
87
- lines.push(` ID: ${d.id}`);
88
- lines.push(` H5 地址: ${d.url}`);
89
- if (resp.message) lines.push(` 消息: ${resp.message}`);
90
- if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
91
- if (d.media) {
92
- if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
93
- if (d.media.video) lines.push(` 视频: ${d.media.video}`);
94
- if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
95
- }
96
- return lines.join("\n");
97
- }
98
-
99
- async function notePublishCommand(type, options = {}) {
100
- const format = options.format || "json";
101
- const apiKey = resolveKeyOrExit(options.apiKey, format);
102
-
103
- try {
104
- if (type !== "normal" && type !== "video") {
105
- throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
106
- }
107
-
108
- const imageUrls = options.images
109
- ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
110
- : [];
111
- const imageFiles = options.imageFile || [];
112
- const imageBase64 = (options.imageBase64 || []).map((value, index) =>
113
- resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
114
- );
115
- const videoBase64 = options.videoBase64
116
- ? resolveBase64Input(options.videoBase64, "视频")
117
- : undefined;
118
- const coverBase64 = options.coverBase64
119
- ? resolveBase64Input(options.coverBase64, "视频封面")
120
- : undefined;
121
- const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
122
- const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
123
- const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
124
- const validDays = Number(options.uploadValidDays || 7);
125
-
126
- if (validDays !== 1 && validDays !== 7) {
127
- throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
128
- }
129
- if (type === "normal") {
130
- if (videoSources.length > 0 || coverSources.length > 0) {
131
- throw validationError("normal 类型不能使用视频或封面参数");
132
- }
133
- if (imageCount === 0) {
134
- throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
135
- }
136
- if (imageCount > 18) {
137
- throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
138
- }
139
- imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
140
- imageFiles.forEach((filePath, index) => validateLocalFile(
141
- filePath,
142
- `第 ${imageUrls.length + index + 1} 张图片`,
143
- MAX_IMAGE_SIZE
144
- ));
145
- imageBase64.forEach((value, index) => validateBase64Size(
146
- value,
147
- `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
148
- MAX_IMAGE_SIZE
149
- ));
150
- } else {
151
- if (imageCount > 0) {
152
- throw validationError("video 类型不能使用图片参数");
153
- }
154
- if (videoSources.length !== 1) {
155
- throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
156
- }
157
- if (coverSources.length > 1) {
158
- throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
159
- }
160
- if (options.video) validateUrl(options.video, "视频");
161
- if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
162
- if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
163
- if (options.cover) validateUrl(options.cover, "视频封面");
164
- if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
165
- if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
166
- }
167
-
168
- let images;
169
- let video;
170
- let cover;
171
- if (type === "normal") {
172
- const uploadedImages = await Promise.all([
173
- ...imageFiles.map((filePath, index) => uploadMedia(
174
- apiKey,
175
- { filePath, validDays },
176
- `第 ${imageUrls.length + index + 1} 张图片`
177
- )),
178
- ...imageBase64.map((fileBase64, index) => uploadMedia(
179
- apiKey,
180
- { fileBase64, defaultContentType: "image/jpeg", validDays },
181
- `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
182
- )),
183
- ]);
184
- images = [...imageUrls, ...uploadedImages];
185
- } else {
186
- video = options.video;
187
- if (options.videoFile) {
188
- video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
189
- } else if (videoBase64) {
190
- video = await uploadMedia(
191
- apiKey,
192
- { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
193
- "视频"
194
- );
195
- }
196
-
197
- cover = options.cover;
198
- if (options.coverFile) {
199
- cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
200
- } else if (coverBase64) {
201
- cover = await uploadMedia(
202
- apiKey,
203
- { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
204
- "视频封面"
205
- );
206
- }
207
- }
208
-
209
- const result = await xhsNotePublish(apiKey, {
210
- type,
211
- title: options.title,
212
- content: options.content,
213
- images,
214
- video,
215
- cover,
216
- });
217
- outputResult(result, format, textFormatter);
218
- process.exit(0);
219
- } catch (err) {
220
- handleApiError(err, format);
221
- process.exit(1);
222
- }
223
- }
224
-
225
- module.exports = { notePublishCommand };
1
+ const fs = require("fs");
2
+ const path = require("path");
3
+ const { r2Upload, xhsHotList, xhsNotePublish } = require("../api");
4
+ const { outputResult, resolveKeyOrExit, handleApiError } = require("../lib/cli-utils");
5
+
6
+ const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
7
+ const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
8
+
9
+ function validationError(message) {
10
+ return Object.assign(new Error(message), { type: "validation_error" });
11
+ }
12
+
13
+ function resolveBase64Input(value, label) {
14
+ if (!value.startsWith("@")) return value;
15
+ const filePath = path.resolve(value.slice(1));
16
+ let content;
17
+ try {
18
+ content = fs.readFileSync(filePath, "utf-8").trim();
19
+ } catch (err) {
20
+ throw validationError(`${label} Base64 文件读取失败 ${filePath}: ${err.message}`);
21
+ }
22
+ if (!content) throw validationError(`${label} Base64 文件内容为空: ${filePath}`);
23
+ return content;
24
+ }
25
+
26
+ function validateLocalFile(filePath, label, maxSize) {
27
+ const absolutePath = path.resolve(filePath);
28
+ let stat;
29
+ try {
30
+ stat = fs.statSync(absolutePath);
31
+ } catch (err) {
32
+ throw validationError(`${label}文件读取失败 ${absolutePath}: ${err.message}`);
33
+ }
34
+ if (!stat.isFile()) throw validationError(`${label}路径不是文件: ${absolutePath}`);
35
+ if (stat.size > maxSize) {
36
+ throw validationError(`${label}文件超过 ${maxSize / 1024 / 1024}MB: ${absolutePath}`);
37
+ }
38
+ }
39
+
40
+ function validateUrl(value, label) {
41
+ let parsed;
42
+ try {
43
+ parsed = new URL(value);
44
+ } catch (_) {
45
+ throw validationError(`${label}不是有效 URL: ${value}`);
46
+ }
47
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
48
+ throw validationError(`${label}仅支持 HTTP/HTTPS URL: ${value}`);
49
+ }
50
+ }
51
+
52
+ function validateBase64Size(value, label, maxSize) {
53
+ const dataUrlPrefix = value.match(/^data:[^,]*,/i)?.[0];
54
+ const payload = (dataUrlPrefix ? value.slice(dataUrlPrefix.length) : value).replace(/\s/g, "");
55
+ const padding = payload.endsWith("==") ? 2 : payload.endsWith("=") ? 1 : 0;
56
+ const size = Math.max(0, Math.floor(payload.length * 3 / 4) - padding);
57
+ if (size > maxSize) {
58
+ throw validationError(`${label} Base64 内容超过 ${maxSize / 1024 / 1024}MB`);
59
+ }
60
+ }
61
+
62
+ async function uploadMedia(apiKey, params, label) {
63
+ try {
64
+ const result = await r2Upload(apiKey, params);
65
+ return result.file_url;
66
+ } catch (err) {
67
+ const detail = err.type === "http_error"
68
+ ? `HTTP ${err.statusCode}: ${err.body?.message || JSON.stringify(err.body)}`
69
+ : err.message || err;
70
+ const wrapped = new Error(`${label}上传失败: ${detail}`);
71
+ wrapped.type = err.type === "validation_error" || err.type === "network_error"
72
+ ? err.type
73
+ : "api_error";
74
+ wrapped.statusCode = err.statusCode;
75
+ wrapped.body = err.body;
76
+ throw wrapped;
77
+ }
78
+ }
79
+
80
+ /**
81
+ * text 格式输出:用 ✓ 开头展示关键信息
82
+ * @param {object} resp
83
+ * @returns {string}
84
+ */
85
+ function notePublishTextFormatter(resp) {
86
+ const d = resp.data || {};
87
+ const lines = [];
88
+ lines.push("✓ 小红书笔记发布页已生成");
89
+ lines.push(` ID: ${d.id}`);
90
+ lines.push(` H5 地址: ${d.url}`);
91
+ if (resp.message) lines.push(` 消息: ${resp.message}`);
92
+ if (d.qrcode_url) lines.push(` 二维码图: ${d.qrcode_url}`);
93
+ if (d.qrcode) lines.push(` 二维码: ${d.qrcode.substring(0, 50)}...`);
94
+ if (d.media) {
95
+ if (d.media.images) lines.push(` 图片: ${d.media.images.length} 张`);
96
+ if (d.media.video) lines.push(` 视频: ${d.media.video}`);
97
+ if (d.media.cover) lines.push(` 封面: ${d.media.cover}`);
98
+ }
99
+ return lines.join("\n");
100
+ }
101
+
102
+ /**
103
+ * text 格式输出:展示小红书实时热榜。
104
+ * @param {object} resp
105
+ * @returns {string}
106
+ */
107
+ function hotListTextFormatter(resp = {}) {
108
+ const data = resp.data || {};
109
+ const lines = ["小红书热榜"];
110
+ if (data.date) lines.push(`日期: ${data.date}`);
111
+
112
+ (data.items || []).forEach((item) => {
113
+ const rank = item.rank === undefined ? "-" : item.rank;
114
+ const trend = item.trend ? ` ${item.trend}` : "";
115
+ lines.push(`\n[${rank}] ${item.title || "未命名话题"}${trend}`);
116
+ if (item.hot || item.hot_value !== undefined) lines.push(` 热度: ${item.hot || item.hot_value}`);
117
+ if (item.url) lines.push(` URL: ${item.url}`);
118
+ });
119
+
120
+ return lines.join("\n");
121
+ }
122
+
123
+ /**
124
+ * 获取小红书实时热榜。
125
+ * @param {{ apiKey?: string, format?: "json"|"text" }} options
126
+ * @returns {Promise<void>}
127
+ */
128
+ async function hotListCommand(options = {}) {
129
+ const format = options.format || "json";
130
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
131
+
132
+ try {
133
+ const result = await xhsHotList(apiKey);
134
+ outputResult(result, format, hotListTextFormatter);
135
+ process.exit(0);
136
+ } catch (err) {
137
+ handleApiError(err, format);
138
+ process.exit(1);
139
+ }
140
+ }
141
+
142
+ async function notePublishCommand(type, options = {}) {
143
+ const format = options.format || "json";
144
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
145
+
146
+ try {
147
+ if (type !== "normal" && type !== "video") {
148
+ throw validationError(`笔记类型仅支持 normal 或 video,收到: ${type}`);
149
+ }
150
+
151
+ const imageUrls = options.images
152
+ ? options.images.split(",").map((value) => value.trim()).filter(Boolean)
153
+ : [];
154
+ const imageFiles = options.imageFile || [];
155
+ const imageBase64 = (options.imageBase64 || []).map((value, index) =>
156
+ resolveBase64Input(value, `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`)
157
+ );
158
+ const videoBase64 = options.videoBase64
159
+ ? resolveBase64Input(options.videoBase64, "视频")
160
+ : undefined;
161
+ const coverBase64 = options.coverBase64
162
+ ? resolveBase64Input(options.coverBase64, "视频封面")
163
+ : undefined;
164
+ const imageCount = imageUrls.length + imageFiles.length + imageBase64.length;
165
+ const videoSources = [options.video, options.videoFile, videoBase64].filter(Boolean);
166
+ const coverSources = [options.cover, options.coverFile, coverBase64].filter(Boolean);
167
+ const validDays = Number(options.uploadValidDays || 7);
168
+
169
+ if (validDays !== 1 && validDays !== 7) {
170
+ throw validationError(`--upload-valid-days 仅支持 1 或 7,收到: ${options.uploadValidDays}`);
171
+ }
172
+ if (type === "normal") {
173
+ if (videoSources.length > 0 || coverSources.length > 0) {
174
+ throw validationError("normal 类型不能使用视频或封面参数");
175
+ }
176
+ if (imageCount === 0) {
177
+ throw validationError("normal 类型至少需要一张图片,请使用 --images、--image-file 或 --image-base64");
178
+ }
179
+ if (imageCount > 18) {
180
+ throw validationError(`图片最多 18 张,当前共 ${imageCount} 张`);
181
+ }
182
+ imageUrls.forEach((url, index) => validateUrl(url, `第 ${index + 1} 张图片`));
183
+ imageFiles.forEach((filePath, index) => validateLocalFile(
184
+ filePath,
185
+ `第 ${imageUrls.length + index + 1} 张图片`,
186
+ MAX_IMAGE_SIZE
187
+ ));
188
+ imageBase64.forEach((value, index) => validateBase64Size(
189
+ value,
190
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`,
191
+ MAX_IMAGE_SIZE
192
+ ));
193
+ } else {
194
+ if (imageCount > 0) {
195
+ throw validationError("video 类型不能使用图片参数");
196
+ }
197
+ if (videoSources.length !== 1) {
198
+ throw validationError("video 类型必须且只能使用 --video、--video-file、--video-base64 之一");
199
+ }
200
+ if (coverSources.length > 1) {
201
+ throw validationError("视频封面只能使用 --cover、--cover-file、--cover-base64 之一");
202
+ }
203
+ if (options.video) validateUrl(options.video, "视频");
204
+ if (options.videoFile) validateLocalFile(options.videoFile, "视频", MAX_UPLOAD_SIZE);
205
+ if (videoBase64) validateBase64Size(videoBase64, "视频", MAX_UPLOAD_SIZE);
206
+ if (options.cover) validateUrl(options.cover, "视频封面");
207
+ if (options.coverFile) validateLocalFile(options.coverFile, "视频封面", MAX_IMAGE_SIZE);
208
+ if (coverBase64) validateBase64Size(coverBase64, "视频封面", MAX_IMAGE_SIZE);
209
+ }
210
+
211
+ let images;
212
+ let video;
213
+ let cover;
214
+ if (type === "normal") {
215
+ const uploadedImages = await Promise.all([
216
+ ...imageFiles.map((filePath, index) => uploadMedia(
217
+ apiKey,
218
+ { filePath, validDays },
219
+ `第 ${imageUrls.length + index + 1} 张图片`
220
+ )),
221
+ ...imageBase64.map((fileBase64, index) => uploadMedia(
222
+ apiKey,
223
+ { fileBase64, defaultContentType: "image/jpeg", validDays },
224
+ `第 ${imageUrls.length + imageFiles.length + index + 1} 张图片`
225
+ )),
226
+ ]);
227
+ images = [...imageUrls, ...uploadedImages];
228
+ } else {
229
+ video = options.video;
230
+ if (options.videoFile) {
231
+ video = await uploadMedia(apiKey, { filePath: options.videoFile, validDays }, "视频");
232
+ } else if (videoBase64) {
233
+ video = await uploadMedia(
234
+ apiKey,
235
+ { fileBase64: videoBase64, defaultContentType: "video/mp4", validDays },
236
+ "视频"
237
+ );
238
+ }
239
+
240
+ cover = options.cover;
241
+ if (options.coverFile) {
242
+ cover = await uploadMedia(apiKey, { filePath: options.coverFile, validDays }, "视频封面");
243
+ } else if (coverBase64) {
244
+ cover = await uploadMedia(
245
+ apiKey,
246
+ { fileBase64: coverBase64, defaultContentType: "image/jpeg", validDays },
247
+ "视频封面"
248
+ );
249
+ }
250
+ }
251
+
252
+ const result = await xhsNotePublish(apiKey, {
253
+ type,
254
+ title: options.title,
255
+ content: options.content,
256
+ images,
257
+ video,
258
+ cover,
259
+ });
260
+ outputResult(result, format, notePublishTextFormatter);
261
+ process.exit(0);
262
+ } catch (err) {
263
+ handleApiError(err, format);
264
+ process.exit(1);
265
+ }
266
+ }
267
+
268
+ module.exports = { hotListCommand, hotListTextFormatter, notePublishCommand, notePublishTextFormatter };