aiconductor-cli 1.6.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.
Files changed (92) hide show
  1. package/README.md +582 -488
  2. package/bin/aiconductor-cli.js +412 -351
  3. package/package.json +1 -1
  4. package/src/api.js +151 -3
  5. package/src/api.test.js +360 -4
  6. package/src/commands/content.js +184 -184
  7. package/src/commands/douyin.js +252 -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
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "aiconductor-cli",
3
- "version": "1.6.0",
3
+ "version": "1.8.0",
4
4
  "description": "AIConductor CLI 工具箱",
5
5
  "keywords": [
6
6
  "aiconductor",
package/src/api.js CHANGED
@@ -11,6 +11,7 @@ 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
13
  const XHS_HOT_LIST_API_PATH = "/api/xhs_hot_list";
14
+ const URL_TO_MARKDOWN_API_PATH = "/api/url_to_markdown";
14
15
  const DOUYIN_VIDEO_INFO_PATH = "/api/douyin_video_info";
15
16
  const DOUYIN_VIDEO_COMMENTS_PATH = "/api/douyin_video_comments";
16
17
  const DOUYIN_USER_VIDEOS_PATH = "/api/douyin_user_videos";
@@ -18,13 +19,21 @@ const DOUYIN_USER_DETAIL_PATH = "/api/douyin_user_detail";
18
19
  const DOUYIN_USER_SEARCH_PATH = "/api/douyin_user_search";
19
20
  const DOUYIN_VIDEO_SEARCH_PATH = "/api/douyin_video_search";
20
21
  const DOUYIN_LINK_TO_TEXT_PATH = "/api/douyin_link_to_text_v2";
22
+ const DOUYIN_HOT_WORDS_PATH = "/api/douyin_hot_words";
23
+ const DOUYIN_HOT_TOPICS_PATH = "/api/douyin_hot_topics";
24
+ const DOUYIN_RISING_TOPICS_PATH = "/api/douyin_rising_topics";
25
+ const DOUYIN_VIDEO_ORIGINAL_PATH = "/api/douyin_video_original";
26
+ const VIDEO_DOWNLOAD_URL_PATH = "/api/video_download_url";
21
27
  const XHS_NOTE_PUBLISH_PATH = "/api/xhs_note_publish";
22
28
  const XHS_NOTE_INFO_PATH = "/api/xhs_note_info_v3";
23
29
  const XHS_NOTE_COMMENTS_PATH = "/api/xhs_note_comments_fast";
24
30
  const XHS_NOTE_SEARCH_PATH = "/api/xhs_note_search_v3";
25
31
  const XHS_USER_INFO_PATH = "/api/xhs_user_info_v2";
26
- // 刻意打 v2:v3 已于 2026-11-05 迁到同一上游且同输出,但 v2 是历史真实流量所在(api_docs 两篇一致)。
32
+ // 刻意打 v2:v3 已于 2026-11-05 迁到同一上游且同输出,但 v2 是历史真实流量所在(backend/api_docs 两篇一致)。
27
33
  const XHS_USER_NOTES_PATH = "/api/xhs_user_notes_v2";
34
+ const XHS_PGY_BLOGGER_PATH = "/api/xhs_pgy_blogger";
35
+ const XHS_PGY_AUDIENCE_PATH = "/api/xhs_pgy_audience";
36
+ const XHS_PGY_PERFORMANCE_PATH = "/api/xhs_pgy_performance";
28
37
 
29
38
  // 本地校验区间,命中就在发请求前拦截,不产生计费
30
39
  const XHS_MAX_PAGES_LIMIT = 5;
@@ -197,7 +206,7 @@ function throwOnBusinessFailure(resp) {
197
206
 
198
207
  /**
199
208
  * wx_history_articles 把上游失败塞在 HTTP 200 + success:true 的 data.code 里
200
- * (api_docs:data.code 为 0 才算成功,平台侧并不改写成 success:false)。
209
+ * (backend/api_docs:data.code 为 0 才算成功,平台侧并不改写成 success:false)。
201
210
  * 不认这个码,就会把「上游挂了」当成「这个号没有文章」。
202
211
  * @param {object} resp
203
212
  * @returns {object}
@@ -432,7 +441,7 @@ async function hotList(apiKey, params) {
432
441
  }
433
442
 
434
443
  const limit = validatePositiveInt(params.limit === undefined ? 30 : params.limit, "limit", 30);
435
- // The hot-list service is currently served over HTTP; see api_docs/hot_list_mixed.md.
444
+ // The hot-list service is currently served over HTTP; see backend/api_docs/hot_list_mixed.md.
436
445
  const resp = await httpPost(API_HOST, HOT_LIST_API_PATH, {
437
446
  source_ids: sourceIds,
438
447
  latest: true,
@@ -548,6 +557,67 @@ async function douyinVideoComments(apiKey, videoUrl, maxCount) {
548
557
  return stripBilling(resp);
549
558
  }
550
559
 
560
+ /**
561
+ * 抖音原画下载(最高画质直链)
562
+ * @param {string} apiKey
563
+ * @param {string} videoUrl 链接、分享文案或作品 ID
564
+ * @returns {Promise<object>}
565
+ */
566
+ async function douyinVideoOriginal(apiKey, videoUrl) {
567
+ const resp = await httpPost(API_HOST, DOUYIN_VIDEO_ORIGINAL_PATH, {
568
+ api_key: apiKey,
569
+ video_url: videoUrl,
570
+ });
571
+ return stripBilling(resp);
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
+
588
+ /**
589
+ * 抖音热词榜(原子接口)
590
+ * @param {string} apiKey
591
+ * @returns {Promise<object>}
592
+ */
593
+ async function douyinHotWords(apiKey) {
594
+ const resp = await httpPost(API_HOST, DOUYIN_HOT_WORDS_PATH, { api_key: apiKey });
595
+ return stripBilling(resp);
596
+ }
597
+
598
+ /**
599
+ * 抖音热点榜(原子接口,一次返回 current + rocketing)
600
+ * @param {string} apiKey
601
+ * @returns {Promise<object>}
602
+ */
603
+ async function douyinHotTopics(apiKey) {
604
+ const resp = await httpPost(API_HOST, DOUYIN_HOT_TOPICS_PATH, { api_key: apiKey });
605
+ return stripBilling(resp);
606
+ }
607
+
608
+ /**
609
+ * 抖音飙升热点榜(原子接口)
610
+ * @param {string} apiKey
611
+ * @param {number} [limit] 1~20,越界时后端回退为 10
612
+ * @returns {Promise<object>}
613
+ */
614
+ async function douyinRisingTopics(apiKey, limit) {
615
+ const body = { api_key: apiKey };
616
+ if (limit !== undefined && limit !== null) body.limit = limit;
617
+ const resp = await httpPost(API_HOST, DOUYIN_RISING_TOPICS_PATH, body);
618
+ return stripBilling(resp);
619
+ }
620
+
551
621
  /**
552
622
  * 获取抖音用户视频列表
553
623
  * @param {string} apiKey
@@ -645,6 +715,23 @@ async function wxArticleData(apiKey, url) {
645
715
  return throwOnBusinessFailure(stripBilling(resp));
646
716
  }
647
717
 
718
+ /**
719
+ * 将任意网页(含微信公众号文章)转成干净的 Markdown
720
+ * @param {string} apiKey
721
+ * @param {{ url: string, timeout?: number|string }} params
722
+ * @returns {Promise<object>} `markdown` 在顶层,不在 data 下
723
+ */
724
+ async function urlToMarkdown(apiKey, params = {}) {
725
+ const body = { url: requireText(params.url, "url(网页链接)") };
726
+ if (params.timeout !== undefined && params.timeout !== null && params.timeout !== "") {
727
+ body.timeout = validatePositiveInt(params.timeout, "timeout");
728
+ }
729
+ const resp = await httpPost(API_HOST, URL_TO_MARKDOWN_API_PATH, body, {
730
+ Authorization: `Bearer ${apiKey}`,
731
+ });
732
+ return throwOnBusinessFailure(stripBilling(resp));
733
+ }
734
+
648
735
  /**
649
736
  * 获取微信公众号历史文章列表(按成功翻页次数计费)
650
737
  * @param {string} apiKey
@@ -827,6 +914,58 @@ async function xhsUserInfo(apiKey, userId) {
827
914
  return throwOnXhsFailure(stripBilling(resp));
828
915
  }
829
916
 
917
+ /**
918
+ * 蒲公英三件套的公共请求体:24 位小写十六进制当 user_id,其余当主页链接。
919
+ * @param {string} apiKey
920
+ * @param {string} input user_id 或主页链接
921
+ * @returns {object}
922
+ */
923
+ function pgyRequestBody(apiKey, input) {
924
+ const text = input === undefined || input === null ? "" : String(input).trim();
925
+ if (!text) {
926
+ throw Object.assign(new Error("请提供小红书 user_id 或达人主页链接"), {
927
+ type: "validation_error",
928
+ });
929
+ }
930
+ const body = { api_key: apiKey };
931
+ if (/^[0-9a-f]{24}$/.test(text)) body.user_id = text;
932
+ else body.profile_url = text;
933
+ return body;
934
+ }
935
+
936
+ /**
937
+ * 小红书达人详情(蒲公英)
938
+ * @param {string} apiKey
939
+ * @param {string} input user_id 或主页链接
940
+ * @returns {Promise<object>}
941
+ */
942
+ async function xhsPgyBlogger(apiKey, input) {
943
+ const resp = await httpPost(API_HOST, XHS_PGY_BLOGGER_PATH, pgyRequestBody(apiKey, input));
944
+ return stripBilling(resp);
945
+ }
946
+
947
+ /**
948
+ * 小红书达人粉丝画像(蒲公英)
949
+ * @param {string} apiKey
950
+ * @param {string} input user_id 或主页链接
951
+ * @returns {Promise<object>}
952
+ */
953
+ async function xhsPgyAudience(apiKey, input) {
954
+ const resp = await httpPost(API_HOST, XHS_PGY_AUDIENCE_PATH, pgyRequestBody(apiKey, input));
955
+ return stripBilling(resp);
956
+ }
957
+
958
+ /**
959
+ * 小红书达人笔记性价比(蒲公英)
960
+ * @param {string} apiKey
961
+ * @param {string} input user_id 或主页链接
962
+ * @returns {Promise<object>}
963
+ */
964
+ async function xhsPgyPerformance(apiKey, input) {
965
+ const resp = await httpPost(API_HOST, XHS_PGY_PERFORMANCE_PATH, pgyRequestBody(apiKey, input));
966
+ return stripBilling(resp);
967
+ }
968
+
830
969
  /**
831
970
  * 获取小红书用户笔记列表。
832
971
  * @param {string} apiKey
@@ -914,6 +1053,11 @@ module.exports = {
914
1053
  douyinUserSearch,
915
1054
  douyinVideoSearch,
916
1055
  douyinLinkToText,
1056
+ douyinHotWords,
1057
+ douyinHotTopics,
1058
+ douyinRisingTopics,
1059
+ douyinVideoOriginal,
1060
+ douyinVideoDownload,
917
1061
  r2Upload,
918
1062
  xhsNotePublish,
919
1063
  xhsNoteInfo,
@@ -922,6 +1066,10 @@ module.exports = {
922
1066
  xhsUserInfo,
923
1067
  xhsUserNotes,
924
1068
  resolveXhsShortLink,
1069
+ xhsPgyBlogger,
1070
+ xhsPgyAudience,
1071
+ xhsPgyPerformance,
925
1072
  wxArticleData,
926
1073
  wxHistoryArticles,
1074
+ urlToMarkdown,
927
1075
  };
package/src/api.test.js CHANGED
@@ -4,7 +4,20 @@ 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, xhsHotList } = require("./api");
7
+ const {
8
+ hotList,
9
+ webSearch,
10
+ xhsHotList,
11
+ urlToMarkdown,
12
+ douyinHotWords,
13
+ douyinHotTopics,
14
+ douyinRisingTopics,
15
+ xhsPgyBlogger,
16
+ xhsPgyAudience,
17
+ xhsPgyPerformance,
18
+ douyinVideoOriginal,
19
+ douyinVideoDownload,
20
+ } = require("./api");
8
21
  const {
9
22
  xhsNoteInfo,
10
23
  xhsNoteComments,
@@ -28,7 +41,20 @@ const {
28
41
  DEFAULT_HOT_LIST_SOURCES,
29
42
  } = require("./commands/content");
30
43
  const { formatSearchText } = require("./commands/web_search");
31
- const { hotListTextFormatter, notePublishTextFormatter } = require("./commands/xhs");
44
+ const { formatToMdText, readFrontmatter } = require("./commands/web");
45
+ const {
46
+ hotListTextFormatter,
47
+ notePublishTextFormatter,
48
+ buildDarenReportResult,
49
+ formatDarenReportText,
50
+ } = require("./commands/xhs");
51
+ const {
52
+ buildTopicRadarResult,
53
+ formatTopicRadarText,
54
+ formatVideoOriginalText,
55
+ buildDownloadResult,
56
+ formatDownloadText,
57
+ } = require("./commands/douyin");
32
58
  const {
33
59
  wxArticleData,
34
60
  wxHistoryArticles,
@@ -450,7 +476,7 @@ test("wx endpoints turn HTTP 200 business failures into api_error", async () =>
450
476
  });
451
477
 
452
478
  test("wx history treats an upstream code inside a successful response as a failure", async () => {
453
- // 2026-11-05 线上实测:平台仍回 success:true,把上游失败塞进 data.code(api_docs:code 0 为成功),
479
+ // 2026-11-05 线上实测:平台仍回 success:true,把上游失败塞进 data.code(backend/api_docs:code 0 为成功),
454
480
  // 文章数组为空。若照 success 判断,会把「上游挂了」报成「这个号最近没发文」。
455
481
  const upstreamFailure = {
456
482
  success: true,
@@ -532,7 +558,7 @@ test("wx article-data formatter labels the fields the live endpoint returns", ()
532
558
 
533
559
  // ---------------------------------------------------------------------------
534
560
  // 小红书(xhs)数据命令组
535
- // 依据:api_docs/xhs_*.md 的请求字段 + 2026-11-05 线上真实响应
561
+ // 依据:backend/api_docs/xhs_*.md 的请求字段 + 2026-11-05 线上真实响应
536
562
  // ---------------------------------------------------------------------------
537
563
 
538
564
  test("xhsNoteInfo posts note_id to the v3 note detail endpoint over http", async () => {
@@ -775,3 +801,333 @@ test("xhs text formatters: 评论按赞排序并展开楼内回复,搜索按
775
801
  const noteText = noteTextFormatter({ note_data: { title: "T", type: "normal", time: 1793578330 } });
776
802
  assert.match(noteText, /2026-11-02 08:12:10/);
777
803
  });
804
+
805
+ test("urlToMarkdown posts the url with a Bearer header and strips billing", async () => {
806
+ const request = await captureRequest(
807
+ () => urlToMarkdown("test-key", { url: " https://mp.weixin.qq.com/s?__biz=M1 " }),
808
+ {
809
+ success: true,
810
+ message: "转换成功",
811
+ markdown: "# 标题\n\n正文段落",
812
+ billing: { service_fee: 0.02, transaction_id: 9 },
813
+ }
814
+ );
815
+
816
+ assert.equal(request.options.path, "/api/url_to_markdown");
817
+ assert.equal(request.options.port, 443);
818
+ assert.equal(request.options.headers.Authorization, "Bearer test-key");
819
+ assert.deepEqual(request.body, { url: "https://mp.weixin.qq.com/s?__biz=M1" });
820
+ assert.equal(request.result.billing, undefined);
821
+ assert.equal(request.result.markdown, "# 标题\n\n正文段落");
822
+ });
823
+
824
+ test("urlToMarkdown rejects a blank url without calling the endpoint", async () => {
825
+ await assert.rejects(() => urlToMarkdown("test-key", { url: " " }), { type: "validation_error" });
826
+ await assert.rejects(() => urlToMarkdown("test-key", {}), { type: "validation_error" });
827
+ });
828
+
829
+ test("urlToMarkdown only forwards an explicit timeout", async () => {
830
+ const withTimeout = await captureRequest(
831
+ () => urlToMarkdown("test-key", { url: "https://example.com/a", timeout: "45" }),
832
+ { success: true, markdown: "x", billing: {} }
833
+ );
834
+ assert.deepEqual(withTimeout.body, { url: "https://example.com/a", timeout: 45 });
835
+
836
+ const withoutTimeout = await captureRequest(
837
+ () => urlToMarkdown("test-key", { url: "https://example.com/a" }),
838
+ { success: true, markdown: "x", billing: {} }
839
+ );
840
+ assert.deepEqual(withoutTimeout.body, { url: "https://example.com/a" });
841
+ });
842
+
843
+ test("formatToMdText previews frontmatter and truncates long bodies", () => {
844
+ const body = Array.from({ length: 30 }, (_, i) => `第${i + 1}行`).join("\n");
845
+ const text = formatToMdText({
846
+ markdown: `---\ntitle: "测试标题"\nword_count: 1120\n---\n${body}`,
847
+ });
848
+ assert.match(text, /✓ 已转换为 Markdown/);
849
+ assert.match(text, /标题: 测试标题/);
850
+ assert.match(text, /字数: 1120/);
851
+ assert.match(text, /第1行/);
852
+ assert.doesNotMatch(text, /第25行/, "超过 20 行应被截断");
853
+ assert.match(text, /省略/);
854
+ });
855
+
856
+ test("formatToMdText handles empty and frontmatter-less markdown", () => {
857
+ assert.equal(formatToMdText({ markdown: " " }), "✗ 未取到 Markdown 内容");
858
+ const plain = formatToMdText({ markdown: "# 只有正文" });
859
+ assert.match(plain, /字符: 6/);
860
+ assert.doesNotMatch(plain, /标题:/);
861
+ });
862
+
863
+ test("readFrontmatter returns empty for malformed blocks", () => {
864
+ assert.equal(readFrontmatter("no frontmatter", "title"), "");
865
+ assert.equal(readFrontmatter("---\ntitle: 没闭合", "title"), "");
866
+ assert.equal(readFrontmatter('---\nauthor: "张三"\n---\n正文', "author"), "张三");
867
+ });
868
+
869
+ test("douyin hot endpoints post api_key in the body and map their paths", async () => {
870
+ const words = await captureRequest(() => douyinHotWords("test-key"), { success: true, data: {} });
871
+ assert.equal(words.options.path, "/api/douyin_hot_words");
872
+ assert.deepEqual(words.body, { api_key: "test-key" });
873
+
874
+ const topics = await captureRequest(() => douyinHotTopics("test-key"), { success: true, data: {} });
875
+ assert.equal(topics.options.path, "/api/douyin_hot_topics");
876
+ assert.deepEqual(topics.body, { api_key: "test-key" });
877
+
878
+ const rising = await captureRequest(() => douyinRisingTopics("test-key", 5), { success: true, data: {} });
879
+ assert.equal(rising.options.path, "/api/douyin_rising_topics");
880
+ assert.deepEqual(rising.body, { api_key: "test-key", limit: 5 });
881
+
882
+ const risingDefault = await captureRequest(() => douyinRisingTopics("test-key"), { success: true, data: {} });
883
+ assert.deepEqual(risingDefault.body, { api_key: "test-key" });
884
+ });
885
+
886
+ test("buildTopicRadarResult keeps whatever segment succeeded", () => {
887
+ const result = buildTopicRadarResult(
888
+ { status: "fulfilled", value: { data: { words: [{ keyword: "美食" }] } } },
889
+ { status: "rejected", reason: Object.assign(new Error("热点挂了"), { type: "api_error" }) },
890
+ { status: "fulfilled", value: { data: { items: [{ sentence: "卡点" }] } } }
891
+ );
892
+
893
+ assert.equal(result.success, true);
894
+ assert.deepEqual(result.data.hot_words, [{ keyword: "美食" }]);
895
+ assert.deepEqual(result.data.rising, [{ sentence: "卡点" }]);
896
+ assert.deepEqual(result.data.hot_topics, []);
897
+ assert.equal(result.data.failed_sources.length, 1);
898
+ assert.equal(result.data.failed_sources[0].service, "hot_topics");
899
+ assert.equal(result.data.failed_sources[0].message, "热点挂了");
900
+ assert.match(result.message, /部分数据源不可用/);
901
+ });
902
+
903
+ test("buildTopicRadarResult throws api_error only when every segment fails", () => {
904
+ const rejected = (message) => ({ status: "rejected", reason: new Error(message) });
905
+ assert.throws(
906
+ () => buildTopicRadarResult(rejected("a"), rejected("b"), rejected("c")),
907
+ (err) => err.type === "api_error" && /均获取失败/.test(err.message)
908
+ );
909
+ });
910
+
911
+ test("formatTopicRadarText renders every section and failed sources", () => {
912
+ const text = formatTopicRadarText({
913
+ message: "选题雷达已生成",
914
+ data: {
915
+ hot_words: [{ keyword: "美食", hot_index: 100, trend_label: "升温" }],
916
+ hot_topics: [{ topic_name: "开拓者惨败马刺", category: "体育", vv: 33860082, content_cnt: 15 }],
917
+ rocketing_topics: [{ topic_name: "卡点", vv: 3686735 }],
918
+ rising: [{
919
+ sentence: "卡点视频",
920
+ hot_score: 7620270,
921
+ video_count: 2,
922
+ rank_diff: 3,
923
+ trend: [
924
+ { datetime: "2026-11-08 10", hot_score: 7640313 },
925
+ { datetime: "2026-11-08 16", hot_score: 7638220 },
926
+ ],
927
+ }],
928
+ failed_sources: [{ service: "hot_topics", message: "超时" }],
929
+ },
930
+ });
931
+
932
+ assert.match(text, /抖音选题雷达/);
933
+ assert.match(text, /【热词】共 1 条/);
934
+ assert.match(text, /美食 热度 100 · 升温/);
935
+ assert.match(text, /【当前热点】共 1 条/);
936
+ assert.match(text, /\[体育\] 开拓者惨败马刺/);
937
+ assert.match(text, /【飙升话题】共 1 条/);
938
+ assert.match(text, /【飙升热点】共 1 条/);
939
+ assert.match(text, /趋势: 2026-11-08 10 7640313 → 2026-11-08 16 7638220/);
940
+ assert.match(text, /失败数据源:/);
941
+ assert.match(text, /hot_topics: 超时/);
942
+ });
943
+
944
+ test("douyinVideoOriginal posts video_url with api_key and strips billing", async () => {
945
+ const request = await captureRequest(
946
+ () => douyinVideoOriginal("test-key", "https://www.douyin.com/video/7669664934044795354"),
947
+ {
948
+ success: true,
949
+ message: "原画直链获取成功(219.1 MB)",
950
+ data: { video_id: "7669664934044795354", file_size_mb: 219.11 },
951
+ billing: { service_fee: 0.1 },
952
+ }
953
+ );
954
+ assert.equal(request.options.path, "/api/douyin_video_original");
955
+ assert.deepEqual(request.body, {
956
+ api_key: "test-key",
957
+ video_url: "https://www.douyin.com/video/7669664934044795354",
958
+ });
959
+ assert.equal(request.result.billing, undefined);
960
+ assert.equal(request.result.data.file_size_mb, 219.11);
961
+ });
962
+
963
+ test("formatVideoOriginalText renders size, link and expiry hint", () => {
964
+ const text = formatVideoOriginalText({
965
+ message: "原画直链获取成功(219.1 MB)",
966
+ data: {
967
+ video_id: "7669664934044795354",
968
+ url: "https://v5-dy-ov-experiment.zjcdn.com/orig.mp4",
969
+ file_size_mb: 219.11,
970
+ content_type: "video/mp4",
971
+ },
972
+ });
973
+ assert.match(text, /抖音原画直链/);
974
+ assert.match(text, /作品: 7669664934044795354/);
975
+ assert.match(text, /大小: 219\.11 MB/);
976
+ assert.match(text, /类型: video\/mp4/);
977
+ assert.match(text, /直链: https:\/\/v5-dy-ov-experiment/);
978
+ assert.match(text, /直链有时效/);
979
+ });
980
+
981
+ test("pgy endpoints switch between user_id and profile_url", async () => {
982
+ const byId = await captureRequest(
983
+ () => xhsPgyBlogger("test-key", "5c5f8f4c000000001801612c"),
984
+ { success: true, data: {} }
985
+ );
986
+ assert.equal(byId.options.path, "/api/xhs_pgy_blogger");
987
+ assert.deepEqual(byId.body, { api_key: "test-key", user_id: "5c5f8f4c000000001801612c" });
988
+
989
+ const byUrl = await captureRequest(
990
+ () => xhsPgyAudience("test-key", " https://www.xiaohongshu.com/user/profile/5c5f8f4c000000001801612c "),
991
+ { success: true, data: {} }
992
+ );
993
+ assert.equal(byUrl.options.path, "/api/xhs_pgy_audience");
994
+ assert.equal(byUrl.body.profile_url, "https://www.xiaohongshu.com/user/profile/5c5f8f4c000000001801612c");
995
+ assert.equal(byUrl.body.user_id, undefined);
996
+
997
+ const performance = await captureRequest(() => xhsPgyPerformance("test-key", "5c5f8f4c000000001801612c"), { success: true, data: {} });
998
+ assert.equal(performance.options.path, "/api/xhs_pgy_performance");
999
+ });
1000
+
1001
+ test("pgy endpoints reject a blank input before calling", async () => {
1002
+ await assert.rejects(() => xhsPgyBlogger("test-key", " "), { type: "validation_error" });
1003
+ await assert.rejects(() => xhsPgyPerformance("test-key", undefined), { type: "validation_error" });
1004
+ });
1005
+
1006
+ test("buildDarenReportResult merges two segments and scores them", () => {
1007
+ const result = buildDarenReportResult(
1008
+ { status: "fulfilled", value: { data: { name: "十七楼.", fans_count: 4205 } } },
1009
+ { status: "rejected", reason: new Error("画像挂了") },
1010
+ { status: "fulfilled", value: { data: { interaction_rate: 25.6, hundred_like_percent: 72.4 } } }
1011
+ );
1012
+
1013
+ assert.equal(result.success, true);
1014
+ assert.equal(result.data.profile.name, "十七楼.");
1015
+ assert.equal(result.data.partial, true);
1016
+ assert.equal(result.data.failed_sources.length, 1);
1017
+ assert.equal(result.data.failed_sources[0].service, "audience");
1018
+ // 粉丝 4205 → 10/40(≥1千 档);互动率 25.6 → 30/30;百赞率 72.4 → 30/30 → 70/100
1019
+ assert.equal(result.data.score.value, 70);
1020
+ assert.equal(result.data.score.tier, "腰部");
1021
+ assert.equal(result.data.score.basis.length, 3);
1022
+ });
1023
+
1024
+ test("buildDarenReportResult fails when fewer than two segments succeed", () => {
1025
+ const rejected = (message) => ({ status: "rejected", reason: new Error(message) });
1026
+ assert.throws(
1027
+ () => buildDarenReportResult(
1028
+ { status: "fulfilled", value: { data: {} } },
1029
+ rejected("b"),
1030
+ rejected("c")
1031
+ ),
1032
+ (err) => err.type === "api_error" && /达人数据获取不完整/.test(err.message)
1033
+ );
1034
+ });
1035
+
1036
+ test("formatDarenReportText renders profile, audience, performance and score", () => {
1037
+ const text = formatDarenReportText({
1038
+ data: {
1039
+ profile: { name: "十七楼.", red_id: "615758825", location: "江苏 南京", fans_count: 4205, business_note_count: 107 },
1040
+ audience: {
1041
+ gender: { male: 0.25147, female: 0.74853 },
1042
+ ages: [{ group: "25-34", percent: 0.5076 }],
1043
+ interests: [{ name: "美食", percent: 0.1339 }],
1044
+ },
1045
+ performance: {
1046
+ note_number: 29,
1047
+ hundred_like_percent: 72.4,
1048
+ imp_median: 2690,
1049
+ imp_median_beyond_rate: 88.4,
1050
+ read_median: 341,
1051
+ read_median_beyond_rate: 84.9,
1052
+ interaction_rate: 25.6,
1053
+ interaction_beyond_rate: 99.0,
1054
+ },
1055
+ score: { value: 78, tier: "腰部", partial: true, basis: [{ item: "粉丝数", got: 18, max: 40 }] },
1056
+ failed_sources: [{ service: "audience", message: "超时" }],
1057
+ },
1058
+ });
1059
+
1060
+ assert.match(text, /小红书达人报告/);
1061
+ assert.match(text, /达人: 十七楼\.(小红书号 615758825)/);
1062
+ assert.match(text, /性别: 女 74\.9% \/ 男 25\.1%/);
1063
+ assert.match(text, /年龄: 25-34 50\.8%/);
1064
+ assert.match(text, /百赞率: 72\.4%/);
1065
+ assert.match(text, /互动率: 25\.6%(超越 99%)/);
1066
+ assert.match(text, /【评分】78 分 · 腰部(部分维度缺失,已归一)/);
1067
+ assert.match(text, /失败数据源:/);
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
+ });