aiconductor-cli 1.7.0 → 1.8.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
@@ -302,13 +302,30 @@ aiconductor-cli douyin link-to-text <视频URL> [选项]
302
302
  aiconductor-cli douyin link-to-text "https://www.douyin.com/video/xxxxx"
303
303
  ```
304
304
 
305
+ #### 视频下载(普通清晰度)
306
+
307
+ ```bash
308
+ aiconductor-cli douyin download <视频URL|分享文案|作品ID> [选项]
309
+ ```
310
+
311
+ 一次返回多档媒体直链(通常 4 条:`hd_no_watermark` / `no_watermark` / `watermark` / `audio`),带高清无水印、普通无水印、带水印与纯音频,各自带清晰度标签与文件大小。
312
+
313
+ ```bash
314
+ aiconductor-cli douyin download "https://www.douyin.com/video/7696718696647036223" -f text
315
+ aiconductor-cli douyin download "https://v.douyin.com/xxxxx/"
316
+ ```
317
+
318
+ 返回路径:`data.medias[]`(`url` / `quality` / `type` / `extension` / `data_size`,音频条目额外带 `duration` 秒),另有 `author` / `title` / `thumbnail` / `duration`。
319
+
320
+ > **备用方案不等于失败**:上游主链路失败时会走兜底,此时 `medias` 只剩 2 条(`No Watermark` + `Audio`)、`data_size` 为 0、`message` 含「(备用方案)」。
321
+
305
322
  #### 原画下载
306
323
 
307
324
  ```bash
308
325
  aiconductor-cli douyin video-original <视频URL|分享文案|作品ID> [选项]
309
326
  ```
310
327
 
311
- 拿最高画质(原画)直链与文件大小,和普通清晰度的 `Get_Download_Url` 是两个档位。
328
+ 拿最高画质(原画)直链与文件大小,和普通清晰度的 `download` 是两个档位。
312
329
 
313
330
  ```bash
314
331
  aiconductor-cli douyin video-original "https://www.douyin.com/video/7669664934044795354" -f text
@@ -20,6 +20,7 @@ const {
20
20
  linkToTextCommand,
21
21
  topicRadarCommand,
22
22
  videoOriginalCommand,
23
+ downloadCommand,
23
24
  } = require("../src/commands/douyin");
24
25
  const {
25
26
  hotListCommand: xhsHotListCommand,
@@ -245,9 +246,19 @@ douyinCmd
245
246
  .option("-f, --format <format>", "输出格式:json 或 text", "json")
246
247
  .action((keyword, options) => { videoSearchCommand(keyword, options); });
247
248
 
249
+ douyinCmd
250
+ .command("download")
251
+ .description("抖音视频下载:普通清晰度多档直链(原画请用 video-original)")
252
+ .argument("<video-url>", "抖音链接、分享文案或作品 ID")
253
+ .option("-k, --api-key <key>", "AIConductor API Key(也可通过 AICONDUCTOR_API_KEY 环境变量或 .env 文件提供)")
254
+ .option("-f, --format <format>", "输出格式:json 或 text", "json")
255
+ .action((videoUrl, options) => {
256
+ downloadCommand(videoUrl, options);
257
+ });
258
+
248
259
  douyinCmd
249
260
  .command("video-original")
250
- .description("抖音原画下载:取最高画质播放直链(普通清晰度请用 Get_Download_Url)")
261
+ .description("抖音原画下载:取最高画质播放直链(普通清晰度请用 download)")
251
262
  .argument("<video-url>", "抖音链接、分享文案或作品 ID")
252
263
  .option("-k, --api-key <key>", "AIConductor API Key(也可通过 AICONDUCTOR_API_KEY 环境变量或 .env 文件提供)")
253
264
  .option("-f, --format <format>", "输出格式:json 或 text", "json")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiconductor-cli",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "AIConductor CLI 工具箱",
5
5
  "keywords": [
6
6
  "aiconductor",
package/src/api.js CHANGED
@@ -23,6 +23,7 @@ const DOUYIN_HOT_WORDS_PATH = "/api/douyin_hot_words";
23
23
  const DOUYIN_HOT_TOPICS_PATH = "/api/douyin_hot_topics";
24
24
  const DOUYIN_RISING_TOPICS_PATH = "/api/douyin_rising_topics";
25
25
  const DOUYIN_VIDEO_ORIGINAL_PATH = "/api/douyin_video_original";
26
+ const VIDEO_DOWNLOAD_URL_PATH = "/api/video_download_url";
26
27
  const XHS_NOTE_PUBLISH_PATH = "/api/xhs_note_publish";
27
28
  const XHS_NOTE_INFO_PATH = "/api/xhs_note_info_v3";
28
29
  const XHS_NOTE_COMMENTS_PATH = "/api/xhs_note_comments_fast";
@@ -570,6 +571,20 @@ async function douyinVideoOriginal(apiKey, videoUrl) {
570
571
  return stripBilling(resp);
571
572
  }
572
573
 
574
+ /**
575
+ * 抖音视频下载(普通清晰度,多档媒体列表)
576
+ * @param {string} apiKey
577
+ * @param {string} videoUrl 链接、分享文案或作品 ID
578
+ * @returns {Promise<object>}
579
+ */
580
+ async function douyinVideoDownload(apiKey, videoUrl) {
581
+ const resp = await httpPost(API_HOST, VIDEO_DOWNLOAD_URL_PATH, {
582
+ api_key: apiKey,
583
+ video_url: videoUrl,
584
+ });
585
+ return stripBilling(resp);
586
+ }
587
+
573
588
  /**
574
589
  * 抖音热词榜(原子接口)
575
590
  * @param {string} apiKey
@@ -1042,6 +1057,7 @@ module.exports = {
1042
1057
  douyinHotTopics,
1043
1058
  douyinRisingTopics,
1044
1059
  douyinVideoOriginal,
1060
+ douyinVideoDownload,
1045
1061
  r2Upload,
1046
1062
  xhsNotePublish,
1047
1063
  xhsNoteInfo,
package/src/api.test.js CHANGED
@@ -16,6 +16,7 @@ const {
16
16
  xhsPgyAudience,
17
17
  xhsPgyPerformance,
18
18
  douyinVideoOriginal,
19
+ douyinVideoDownload,
19
20
  } = require("./api");
20
21
  const {
21
22
  xhsNoteInfo,
@@ -51,6 +52,8 @@ const {
51
52
  buildTopicRadarResult,
52
53
  formatTopicRadarText,
53
54
  formatVideoOriginalText,
55
+ buildDownloadResult,
56
+ formatDownloadText,
54
57
  } = require("./commands/douyin");
55
58
  const {
56
59
  wxArticleData,
@@ -1063,3 +1066,68 @@ test("formatDarenReportText renders profile, audience, performance and score", (
1063
1066
  assert.match(text, /【评分】78 分 · 腰部(部分维度缺失,已归一)/);
1064
1067
  assert.match(text, /失败数据源:/);
1065
1068
  });
1069
+
1070
+ test("douyin download posts to the download endpoint and strips billing", async () => {
1071
+ const request = await captureRequest(
1072
+ () => douyinVideoDownload("test-key", "https://v.douyin.com/abc/"),
1073
+ { success: true, message: "ok", medias: [{ url: "https://cdn/x.mp4" }], billing: { service_fee: 0.01 } }
1074
+ );
1075
+
1076
+ assert.equal(request.options.path, "/api/video_download_url");
1077
+ assert.equal(request.options.method, "POST");
1078
+ assert.deepEqual(request.body, { api_key: "test-key", video_url: "https://v.douyin.com/abc/" });
1079
+ assert.equal(request.result.billing, undefined);
1080
+ assert.equal(request.result.medias.length, 1);
1081
+ });
1082
+
1083
+ test("buildDownloadResult passes a successful payload through unchanged", () => {
1084
+ const resp = { success: true, medias: [{ url: "https://cdn/x.mp4" }] };
1085
+ assert.equal(buildDownloadResult(resp), resp);
1086
+ });
1087
+
1088
+ test("buildDownloadResult turns a business failure into api_error with the upstream message", () => {
1089
+ assert.throws(
1090
+ () => buildDownloadResult({ success: false, message: "账户余额不足,当前余额:0.0,需要:0.01", medias: [] }),
1091
+ (err) => err.type === "api_error" && /账户余额不足/.test(err.message)
1092
+ );
1093
+ });
1094
+
1095
+ test("formatDownloadText lists every media, sizes and the fallback caveat", () => {
1096
+ const text = formatDownloadText({
1097
+ success: true,
1098
+ message: "获取视频下载URL成功",
1099
+ source: "douyin",
1100
+ author: "某作者",
1101
+ title: "某条视频",
1102
+ duration: 33000,
1103
+ medias: [
1104
+ { url: "https://cdn/hd.mp4", type: "video", quality: "hd_no_watermark", extension: "mp4", data_size: 19465218 },
1105
+ { url: "https://cdn/audio.mp3", type: "audio", quality: "audio", extension: "mp3", duration: 33 },
1106
+ ],
1107
+ });
1108
+
1109
+ assert.match(text, /视频下载直链/);
1110
+ assert.match(text, /作者: 某作者/);
1111
+ assert.match(text, /标题: 某条视频/);
1112
+ assert.match(text, /hd_no_watermark/);
1113
+ assert.match(text, /18\.6 MB/);
1114
+ assert.match(text, /https:\/\/cdn\/hd\.mp4/);
1115
+ assert.match(text, /audio/);
1116
+ assert.match(text, /https:\/\/cdn\/audio\.mp3/);
1117
+ // 正常档音频本来就不带大小,不能误报成“备用方案”
1118
+ assert.doesNotMatch(text, /备用方案/);
1119
+ });
1120
+
1121
+ test("formatDownloadText explains the fallback shape instead of looking broken", () => {
1122
+ const text = formatDownloadText({
1123
+ success: true,
1124
+ message: "获取视频下载URL成功(备用方案)",
1125
+ medias: [
1126
+ { url: "https://cdn/nw.mp4", type: "video", quality: "No Watermark", extension: "mp4", data_size: 0 },
1127
+ { url: "https://cdn/a.mp3", type: "audio", quality: "Audio", extension: "mp3", duration: 15 },
1128
+ ],
1129
+ });
1130
+
1131
+ assert.match(text, /备用方案/);
1132
+ assert.doesNotMatch(text, /0 B/);
1133
+ });
@@ -10,6 +10,7 @@ const {
10
10
  douyinHotTopics,
11
11
  douyinRisingTopics,
12
12
  douyinVideoOriginal,
13
+ douyinVideoDownload,
13
14
  } = require("../api");
14
15
  const { outputResult, resolveKeyOrExit, handleApiError, safeParseInt } = require("../lib/cli-utils");
15
16
 
@@ -250,6 +251,79 @@ async function videoOriginalCommand(videoUrl, options = {}) {
250
251
  } catch (err) { handleApiError(err, format); process.exit(1); }
251
252
  }
252
253
 
254
+ async function downloadCommand(videoUrl, options = {}) {
255
+ const format = options.format || "json";
256
+ const apiKey = resolveKeyOrExit(options.apiKey, format);
257
+ try {
258
+ const result = buildDownloadResult(await douyinVideoDownload(apiKey, videoUrl));
259
+ outputResult(result, format, formatDownloadText);
260
+ process.exit(0);
261
+ } catch (err) { handleApiError(err, format); process.exit(1); }
262
+ }
263
+
264
+ /**
265
+ * 业务失败(success:false,HTTP 200)要变成 api_error,否则会被当成退出码 0。
266
+ * @param {object} resp
267
+ * @returns {object}
268
+ */
269
+ function buildDownloadResult(resp) {
270
+ if (resp && resp.success === false) {
271
+ throw Object.assign(
272
+ new Error(resp.message || "视频下载解析失败"),
273
+ { type: "api_error", body: resp }
274
+ );
275
+ }
276
+ return resp;
277
+ }
278
+
279
+ /**
280
+ * 字节数转人类可读;0 / 空值(备用方案拿不到大小)返回空串,不打印 "0 B"。
281
+ * @param {number} bytes
282
+ * @returns {string}
283
+ */
284
+ function formatFileSize(bytes) {
285
+ const n = Number(bytes);
286
+ if (!Number.isFinite(n) || n <= 0) return "";
287
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`;
288
+ if (n >= 1024) return `${(n / 1024).toFixed(1)} KB`;
289
+ return `${n} B`;
290
+ }
291
+
292
+ /**
293
+ * text 输出:视频下载直链(多档媒体列表)
294
+ * @param {object} result
295
+ * @returns {string}
296
+ */
297
+ function formatDownloadText(result = {}) {
298
+ const medias = Array.isArray(result.medias) ? result.medias : [];
299
+ const lines = ["✓ 抖音视频下载直链"];
300
+ if (result.message) lines.push(` ${result.message}`);
301
+
302
+ const meta = [];
303
+ if (result.author) meta.push(`作者: ${result.author}`);
304
+ if (result.source) meta.push(`平台: ${result.source}`);
305
+ if (meta.length) lines.push(` ${meta.join(" · ")}`);
306
+ if (result.title) lines.push(` 标题: ${result.title}`);
307
+
308
+ lines.push(` 共 ${medias.length} 个文件:`);
309
+ medias.forEach((item = {}, index) => {
310
+ const parts = [item.quality || "unknown"];
311
+ const kind = [item.type, item.extension].filter(Boolean).join("/");
312
+ if (kind) parts.push(kind);
313
+ const size = formatFileSize(item.data_size);
314
+ if (size) parts.push(size);
315
+ lines.push(` ${index + 1}. ${parts.join(" · ")}`);
316
+ if (item.url) lines.push(` ${item.url}`);
317
+ });
318
+
319
+ const fallback = typeof result.message === "string" && result.message.includes("备用方案");
320
+ if (fallback) {
321
+ lines.push(" 提示: 备用方案只给无水印视频与音频,拿不到文件大小");
322
+ }
323
+ lines.push(" 提示: 直链有时效,请立即下载");
324
+ return lines.join("\n");
325
+ }
326
+
253
327
  /**
254
328
  * text 输出:原画直链
255
329
  * @param {object} result
@@ -280,4 +354,8 @@ module.exports = {
280
354
  formatTopicRadarText,
281
355
  videoOriginalCommand,
282
356
  formatVideoOriginalText,
357
+ downloadCommand,
358
+ buildDownloadResult,
359
+ formatDownloadText,
360
+ formatFileSize,
283
361
  };