aiconductor-cli 1.6.0 → 1.7.0

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