aiconductor-cli 1.5.0 → 1.6.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/AGENTS.md +17 -10
- package/README.md +71 -0
- package/api_docs/xhs_user_info_v2.md +5 -1
- package/api_docs/xhs_user_notes_v3.md +2 -0
- package/bin/aiconductor-cli.js +56 -1
- package/package.json +1 -1
- package/src/api.js +244 -1
- package/src/api.test.js +297 -1
- package/src/commands/xhs.js +345 -4
- package/src/lib/cli-utils.js +89 -1
- package/.agents/plans/2026-10-28T11-20-16-943Z-plan.md +0 -161
package/src/api.test.js
CHANGED
|
@@ -4,7 +4,23 @@ 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 { hotList, webSearch, xhsHotList } = require("./api");
|
|
8
|
+
const {
|
|
9
|
+
xhsNoteInfo,
|
|
10
|
+
xhsNoteComments,
|
|
11
|
+
xhsNoteSearch,
|
|
12
|
+
xhsUserInfo,
|
|
13
|
+
xhsUserNotes,
|
|
14
|
+
resolveXhsShortLink,
|
|
15
|
+
} = require("./api");
|
|
16
|
+
const {
|
|
17
|
+
extractUrlFromText,
|
|
18
|
+
parseXhsNoteId,
|
|
19
|
+
parseXhsUserId,
|
|
20
|
+
formatXhsTime,
|
|
21
|
+
toCount,
|
|
22
|
+
} = require("./lib/cli-utils");
|
|
23
|
+
const {
|
|
8
24
|
buildBriefingResult,
|
|
9
25
|
formatHotListText,
|
|
10
26
|
parseLimit,
|
|
@@ -433,6 +449,40 @@ test("wx endpoints turn HTTP 200 business failures into api_error", async () =>
|
|
|
433
449
|
);
|
|
434
450
|
});
|
|
435
451
|
|
|
452
|
+
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 为成功),
|
|
454
|
+
// 文章数组为空。若照 success 判断,会把「上游挂了」报成「这个号最近没发文」。
|
|
455
|
+
const upstreamFailure = {
|
|
456
|
+
success: true,
|
|
457
|
+
message: "未能获取到任何数据。请检查参数或账户余额。",
|
|
458
|
+
billing: { pages_billed: 0, transaction_ids: [] },
|
|
459
|
+
data: { code: 20001, msg: "error", data: [], now_page: 0, now_page_articles_num: 0 },
|
|
460
|
+
};
|
|
461
|
+
|
|
462
|
+
await assert.rejects(
|
|
463
|
+
() => captureRequest(() => wxHistoryArticles("test-key", { name: "人民日报" }), upstreamFailure),
|
|
464
|
+
(err) =>
|
|
465
|
+
err.type === "api_error" &&
|
|
466
|
+
err.message.includes("未能获取到任何数据") &&
|
|
467
|
+
err.message.includes("20001")
|
|
468
|
+
);
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test("wx history still resolves an empty page when the upstream code is 0", async () => {
|
|
472
|
+
const emptyButOk = {
|
|
473
|
+
success: true,
|
|
474
|
+
message: "微信公众号历史文章获取完成。成功获取 1 页数据,共 0 篇文章。",
|
|
475
|
+
data: { code: 0, msg: "success", data: [], now_page: 1 },
|
|
476
|
+
};
|
|
477
|
+
|
|
478
|
+
const request = await captureRequest(
|
|
479
|
+
() => wxHistoryArticles("test-key", { name: "不存在的号" }),
|
|
480
|
+
emptyButOk
|
|
481
|
+
);
|
|
482
|
+
assert.deepEqual(request.result.data.data, []);
|
|
483
|
+
assert.equal(request.result.success, true);
|
|
484
|
+
});
|
|
485
|
+
|
|
436
486
|
test("wx history formatter lists documented article fields", () => {
|
|
437
487
|
const text = formatHistoryText({
|
|
438
488
|
data: {
|
|
@@ -479,3 +529,249 @@ test("wx article-data formatter labels the fields the live endpoint returns", ()
|
|
|
479
529
|
const emptyText = formatArticleDataText({ data: {} });
|
|
480
530
|
assert.match(emptyText, /上游未返回字段/);
|
|
481
531
|
});
|
|
532
|
+
|
|
533
|
+
// ---------------------------------------------------------------------------
|
|
534
|
+
// 小红书(xhs)数据命令组
|
|
535
|
+
// 依据:api_docs/xhs_*.md 的请求字段 + 2026-11-05 线上真实响应
|
|
536
|
+
// ---------------------------------------------------------------------------
|
|
537
|
+
|
|
538
|
+
test("xhsNoteInfo posts note_id to the v3 note detail endpoint over http", async () => {
|
|
539
|
+
const request = await captureRequest(
|
|
540
|
+
() => xhsNoteInfo("test-key", "6ae972260000000015020d84"),
|
|
541
|
+
{ success: true, message: "笔记详情获取成功", note_data: { note_id: "6ae972260000000015020d84" }, billing: { service_fee: 0.15 } },
|
|
542
|
+
http
|
|
543
|
+
);
|
|
544
|
+
|
|
545
|
+
assert.equal(request.options.hostname, "plugin.aiconductor.fun");
|
|
546
|
+
assert.equal(request.options.path, "/api/xhs_note_info_v3");
|
|
547
|
+
assert.equal(request.options.port, 80);
|
|
548
|
+
assert.equal(request.options.method, "POST");
|
|
549
|
+
assert.equal(request.options.headers.Authorization, "Bearer test-key");
|
|
550
|
+
assert.deepEqual(request.body, { note_id: "6ae972260000000015020d84" });
|
|
551
|
+
// billing 不进任何输出
|
|
552
|
+
assert.equal(request.result.billing, undefined);
|
|
553
|
+
assert.equal(request.result.note_data.note_id, "6ae972260000000015020d84");
|
|
554
|
+
});
|
|
555
|
+
|
|
556
|
+
test("xhsNoteComments sends pagination fields and accepts share_text", async () => {
|
|
557
|
+
const request = await captureRequest(
|
|
558
|
+
() => xhsNoteComments("test-key", { noteId: "6ae972260000000015020d84", maxPages: 2, maxSeconds: 40 }),
|
|
559
|
+
{ success: true, data: { comments: [], total_comments: 0 } },
|
|
560
|
+
http
|
|
561
|
+
);
|
|
562
|
+
|
|
563
|
+
assert.equal(request.options.path, "/api/xhs_note_comments_fast");
|
|
564
|
+
assert.equal(request.options.port, 80);
|
|
565
|
+
assert.deepEqual(request.body, {
|
|
566
|
+
note_id: "6ae972260000000015020d84",
|
|
567
|
+
max_pages: 2,
|
|
568
|
+
max_seconds: 40,
|
|
569
|
+
});
|
|
570
|
+
});
|
|
571
|
+
|
|
572
|
+
test("xhsNoteComments prefers share_text only when note id is absent", async () => {
|
|
573
|
+
const withShare = await captureRequest(
|
|
574
|
+
() => xhsNoteComments("test-key", { shareText: "https://xhslink.cn/o/85lbqBJtO94" }),
|
|
575
|
+
{ success: true, data: { comments: [] } },
|
|
576
|
+
http
|
|
577
|
+
);
|
|
578
|
+
assert.deepEqual(withShare.body, { share_text: "https://xhslink.cn/o/85lbqBJtO94" });
|
|
579
|
+
|
|
580
|
+
// 两个都没给:本地拒绝,不发请求
|
|
581
|
+
await assert.rejects(() => xhsNoteComments("test-key", {}), { type: "validation_error" });
|
|
582
|
+
// 越界:max_pages 只允许 1~5,max_seconds 不超过平台 170s 上限
|
|
583
|
+
await assert.rejects(
|
|
584
|
+
() => xhsNoteComments("test-key", { noteId: "6ae972260000000015020d84", maxPages: 6 }),
|
|
585
|
+
{ type: "validation_error" }
|
|
586
|
+
);
|
|
587
|
+
await assert.rejects(
|
|
588
|
+
() => xhsNoteComments("test-key", { noteId: "6ae972260000000015020d84", maxSeconds: 171 }),
|
|
589
|
+
{ type: "validation_error" }
|
|
590
|
+
);
|
|
591
|
+
});
|
|
592
|
+
|
|
593
|
+
test("xhsNoteSearch maps keyword filters to the v3 search endpoint", async () => {
|
|
594
|
+
const request = await captureRequest(
|
|
595
|
+
() => xhsNoteSearch("test-key", {
|
|
596
|
+
keyword: "AI编程",
|
|
597
|
+
maxPages: 3,
|
|
598
|
+
sort: "popularity_descending",
|
|
599
|
+
noteTime: "week",
|
|
600
|
+
noteType: "video",
|
|
601
|
+
}),
|
|
602
|
+
{ success: true, data: { items: [], has_more: false } },
|
|
603
|
+
http
|
|
604
|
+
);
|
|
605
|
+
|
|
606
|
+
assert.equal(request.options.path, "/api/xhs_note_search_v3");
|
|
607
|
+
assert.equal(request.options.port, 80);
|
|
608
|
+
assert.equal(request.options.headers.Authorization, "Bearer test-key");
|
|
609
|
+
assert.deepEqual(request.body, {
|
|
610
|
+
keyword: "AI编程",
|
|
611
|
+
max_pages: 3,
|
|
612
|
+
sort: "popularity_descending",
|
|
613
|
+
note_time: "week",
|
|
614
|
+
note_type: "video",
|
|
615
|
+
});
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("xhsNoteSearch rejects unknown enum values and empty keywords", async () => {
|
|
619
|
+
await assert.rejects(() => xhsNoteSearch("test-key", { keyword: " " }), { type: "validation_error" });
|
|
620
|
+
await assert.rejects(
|
|
621
|
+
() => xhsNoteSearch("test-key", { keyword: "AI", sort: "likes" }),
|
|
622
|
+
{ type: "validation_error" }
|
|
623
|
+
);
|
|
624
|
+
await assert.rejects(
|
|
625
|
+
() => xhsNoteSearch("test-key", { keyword: "AI", noteTime: "year" }),
|
|
626
|
+
{ type: "validation_error" }
|
|
627
|
+
);
|
|
628
|
+
await assert.rejects(
|
|
629
|
+
() => xhsNoteSearch("test-key", { keyword: "AI", noteType: "image" }),
|
|
630
|
+
{ type: "validation_error" }
|
|
631
|
+
);
|
|
632
|
+
});
|
|
633
|
+
|
|
634
|
+
test("xhsUserInfo maps user_id and xhsUserNotes maps pagination", async () => {
|
|
635
|
+
const userRequest = await captureRequest(
|
|
636
|
+
() => xhsUserInfo("test-key", "53cd3bbbb4c4d64f93463018"),
|
|
637
|
+
{ success: true, user_info: { basic_info: {} } },
|
|
638
|
+
http
|
|
639
|
+
);
|
|
640
|
+
assert.equal(userRequest.options.path, "/api/xhs_user_info_v2");
|
|
641
|
+
assert.equal(userRequest.options.port, 80);
|
|
642
|
+
assert.deepEqual(userRequest.body, { user_id: "53cd3bbbb4c4d64f93463018" });
|
|
643
|
+
|
|
644
|
+
const notesRequest = await captureRequest(
|
|
645
|
+
() => xhsUserNotes("test-key", { userId: "53cd3bbbb4c4d64f93463018", maxPages: 2 }),
|
|
646
|
+
{ success: true, data: { notes: [] } },
|
|
647
|
+
http
|
|
648
|
+
);
|
|
649
|
+
// 刻意打 v2 端点:v2/v3 现在同上游同输出,v2 是历史真实流量所在
|
|
650
|
+
assert.equal(notesRequest.options.path, "/api/xhs_user_notes_v2");
|
|
651
|
+
assert.equal(notesRequest.options.port, 80);
|
|
652
|
+
assert.deepEqual(notesRequest.body, { user_id: "53cd3bbbb4c4d64f93463018", max_pages: 2 });
|
|
653
|
+
});
|
|
654
|
+
|
|
655
|
+
test("xhs endpoints turn business failures into api_error", async () => {
|
|
656
|
+
await assert.rejects(
|
|
657
|
+
() => captureRequest(
|
|
658
|
+
() => xhsNoteInfo("test-key", "6ae972260000000015020d84"),
|
|
659
|
+
{ success: false, message: "笔记ID无效或笔记不可访问" },
|
|
660
|
+
http
|
|
661
|
+
),
|
|
662
|
+
{ type: "api_error", message: "笔记ID无效或笔记不可访问" }
|
|
663
|
+
);
|
|
664
|
+
});
|
|
665
|
+
|
|
666
|
+
test("parseXhsNoteId and parseXhsUserId accept ids, long links and share copy", () => {
|
|
667
|
+
const id = "6ae972260000000015020d84";
|
|
668
|
+
assert.equal(parseXhsNoteId(id), id);
|
|
669
|
+
assert.equal(
|
|
670
|
+
parseXhsNoteId(`字节早就不是下狗了 https://www.xiaohongshu.com/discovery/item/${id}?xsec_token=abc 复制内容`),
|
|
671
|
+
id
|
|
672
|
+
);
|
|
673
|
+
assert.equal(parseXhsNoteId(`https://www.xiaohongshu.com/explore/${id}?xsec_source=pc_share`), id);
|
|
674
|
+
// 小红书号/主页/空串都不该被当成 note id
|
|
675
|
+
assert.equal(parseXhsNoteId("hainingshushu"), null);
|
|
676
|
+
assert.equal(parseXhsNoteId(""), null);
|
|
677
|
+
assert.equal(parseXhsNoteId(undefined), null);
|
|
678
|
+
|
|
679
|
+
const userId = "53cd3bbbb4c4d64f93463018";
|
|
680
|
+
assert.equal(parseXhsUserId(userId), userId);
|
|
681
|
+
assert.equal(parseXhsUserId(`https://www.xiaohongshu.com/user/profile/${userId}?xsec_token=x`), userId);
|
|
682
|
+
assert.equal(parseXhsUserId("https://www.xiaohongshu.com/explore/6ae972260000000015020d84"), null);
|
|
683
|
+
});
|
|
684
|
+
|
|
685
|
+
test("extractUrlFromText pulls the first url out of share copy", () => {
|
|
686
|
+
assert.equal(
|
|
687
|
+
extractUrlFromText("字节早就不是下狗了,不想承认也不行 https://xhslink.cn/o/85lbqBJtO94 复制内容,然后进入【小红书】查看相关笔记。"),
|
|
688
|
+
"https://xhslink.cn/o/85lbqBJtO94"
|
|
689
|
+
);
|
|
690
|
+
// 中文全角标点紧贴链接时不能带进 url
|
|
691
|
+
assert.equal(extractUrlFromText("看这个https://xhslink.cn/o/abc),很准"), "https://xhslink.cn/o/abc");
|
|
692
|
+
assert.equal(extractUrlFromText("没有链接"), null);
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
test("formatXhsTime renders UTC epoch as Beijing time", () => {
|
|
696
|
+
// 独立来源:同一视频笔记 note_info.time = 1793578330,+8h 等于 search 返回的 notePublishTime "2026-11-02 08:12:10"
|
|
697
|
+
assert.equal(formatXhsTime(1793578330), "2026-11-02 08:12:10");
|
|
698
|
+
// 毫秒(用户笔记列表)同口径
|
|
699
|
+
assert.equal(formatXhsTime(1793690772000), "2026-11-03 15:26:12");
|
|
700
|
+
assert.equal(formatXhsTime(0), "");
|
|
701
|
+
assert.equal(formatXhsTime(null), "");
|
|
702
|
+
});
|
|
703
|
+
|
|
704
|
+
test("toCount normalises string counts coming from the xhs upstream", () => {
|
|
705
|
+
assert.equal(toCount("54"), 54);
|
|
706
|
+
assert.equal(toCount("1,234"), 1234);
|
|
707
|
+
assert.equal(toCount(7), 7);
|
|
708
|
+
assert.equal(toCount("1.2万"), 12000);
|
|
709
|
+
assert.equal(toCount("1万+"), 10000);
|
|
710
|
+
assert.equal(toCount(undefined), 0);
|
|
711
|
+
assert.equal(toCount("abc"), 0);
|
|
712
|
+
});
|
|
713
|
+
|
|
714
|
+
test("resolveXhsShortLink follows the 302 to the real note url", async () => {
|
|
715
|
+
const finalUrl = "https://www.xiaohongshu.com/discovery/item/6ae972260000000015020d84?type=normal&xsec_token=abc";
|
|
716
|
+
const originalRequest = https.request;
|
|
717
|
+
https.request = (options, responseCallback) => {
|
|
718
|
+
const request = new EventEmitter();
|
|
719
|
+
request.setTimeout = () => {};
|
|
720
|
+
request.end = () => {
|
|
721
|
+
const response = new EventEmitter();
|
|
722
|
+
response.statusCode = 302;
|
|
723
|
+
response.headers = { location: finalUrl };
|
|
724
|
+
response.resume = () => {};
|
|
725
|
+
responseCallback(response);
|
|
726
|
+
};
|
|
727
|
+
request.destroy = () => {};
|
|
728
|
+
return request;
|
|
729
|
+
};
|
|
730
|
+
|
|
731
|
+
try {
|
|
732
|
+
const url = await resolveXhsShortLink("https://xhslink.cn/o/85lbqBJtO94");
|
|
733
|
+
assert.equal(url, finalUrl);
|
|
734
|
+
} finally {
|
|
735
|
+
https.request = originalRequest;
|
|
736
|
+
}
|
|
737
|
+
});
|
|
738
|
+
|
|
739
|
+
test("xhs text formatters: 评论按赞排序并展开楼内回复,搜索按赞排序", () => {
|
|
740
|
+
const { commentsTextFormatter, searchTextFormatter, noteTextFormatter } = require("./commands/xhs");
|
|
741
|
+
|
|
742
|
+
const commentsText = commentsTextFormatter({
|
|
743
|
+
data: {
|
|
744
|
+
has_more: true,
|
|
745
|
+
total_pages: 1,
|
|
746
|
+
comments: [
|
|
747
|
+
{ content: "低赞", like_count: "1", sub_comment_count: "0", user: { original_name: "甲" } },
|
|
748
|
+
{
|
|
749
|
+
content: "高赞",
|
|
750
|
+
like_count: "43",
|
|
751
|
+
sub_comment_count: "1",
|
|
752
|
+
user: { original_name: "乙" },
|
|
753
|
+
sub_comments: [{ content: "楼内回复", user: { original_name: "丙" } }],
|
|
754
|
+
},
|
|
755
|
+
],
|
|
756
|
+
},
|
|
757
|
+
});
|
|
758
|
+
assert.match(commentsText, /共取到 2 条评论/);
|
|
759
|
+
assert.ok(commentsText.indexOf("高赞") < commentsText.indexOf("低赞"), "评论应按点赞降序");
|
|
760
|
+
assert.match(commentsText, /↳ 丙: 楼内回复/);
|
|
761
|
+
assert.match(commentsText, /还有更多评论/);
|
|
762
|
+
|
|
763
|
+
const searchText = searchTextFormatter({
|
|
764
|
+
data: {
|
|
765
|
+
has_more: false,
|
|
766
|
+
items: [
|
|
767
|
+
{ noteInfo: { title: "低", likeNum: 5, favNum: 1, cmtNum: 0, noteType: "normal" }, userInfo: { nickName: "A" } },
|
|
768
|
+
{ noteInfo: { title: "高", likeNum: 900, favNum: 10, cmtNum: 3, noteType: "video" }, userInfo: { nickName: "B" } },
|
|
769
|
+
],
|
|
770
|
+
},
|
|
771
|
+
});
|
|
772
|
+
assert.ok(searchText.indexOf("高") < searchText.indexOf("低"), "搜索结果应按点赞降序");
|
|
773
|
+
|
|
774
|
+
// 时间口径:上游是 UTC epoch,展示必须 +8(独立来源:+8h 等于上游 notePublishTime)
|
|
775
|
+
const noteText = noteTextFormatter({ note_data: { title: "T", type: "normal", time: 1793578330 } });
|
|
776
|
+
assert.match(noteText, /2026-11-02 08:12:10/);
|
|
777
|
+
});
|
package/src/commands/xhs.js
CHANGED
|
@@ -1,7 +1,29 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
|
-
const {
|
|
4
|
-
|
|
3
|
+
const {
|
|
4
|
+
r2Upload,
|
|
5
|
+
xhsHotList,
|
|
6
|
+
xhsNotePublish,
|
|
7
|
+
xhsNoteInfo,
|
|
8
|
+
xhsNoteComments,
|
|
9
|
+
xhsNoteSearch,
|
|
10
|
+
xhsUserInfo,
|
|
11
|
+
xhsUserNotes,
|
|
12
|
+
resolveXhsShortLink,
|
|
13
|
+
} = require("../api");
|
|
14
|
+
const {
|
|
15
|
+
outputResult,
|
|
16
|
+
resolveKeyOrExit,
|
|
17
|
+
handleApiError,
|
|
18
|
+
extractUrlFromText,
|
|
19
|
+
parseXhsNoteId,
|
|
20
|
+
parseXhsUserId,
|
|
21
|
+
formatXhsTime,
|
|
22
|
+
toCount,
|
|
23
|
+
safeParseInt,
|
|
24
|
+
} = require("../lib/cli-utils");
|
|
25
|
+
|
|
26
|
+
const XHS_SHORT_LINK_HOSTS = ["xhslink.cn", "xhslink.com"];
|
|
5
27
|
|
|
6
28
|
const MAX_IMAGE_SIZE = 32 * 1024 * 1024;
|
|
7
29
|
const MAX_UPLOAD_SIZE = 100 * 1024 * 1024;
|
|
@@ -41,7 +63,7 @@ function validateUrl(value, label) {
|
|
|
41
63
|
let parsed;
|
|
42
64
|
try {
|
|
43
65
|
parsed = new URL(value);
|
|
44
|
-
} catch
|
|
66
|
+
} catch {
|
|
45
67
|
throw validationError(`${label}不是有效 URL: ${value}`);
|
|
46
68
|
}
|
|
47
69
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
|
|
@@ -265,4 +287,323 @@ async function notePublishCommand(type, options = {}) {
|
|
|
265
287
|
}
|
|
266
288
|
}
|
|
267
289
|
|
|
268
|
-
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// 小红书数据命令(note / comments / search / user / user-notes)
|
|
292
|
+
// 口径依据:api_docs/xhs_*.md + 2026-11-05 线上实测
|
|
293
|
+
// ---------------------------------------------------------------------------
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* 把用户输入(笔记链接 / xhslink 短链 / 分享口令 / 裸 note_id)解析成 note_id。
|
|
297
|
+
* 短链必须本地展开成 302 后的长链,否则拿不到 id。
|
|
298
|
+
* @param {string} input
|
|
299
|
+
* @returns {Promise<string>}
|
|
300
|
+
*/
|
|
301
|
+
async function resolveNoteId(input) {
|
|
302
|
+
const direct = parseXhsNoteId(input);
|
|
303
|
+
if (direct) return direct;
|
|
304
|
+
|
|
305
|
+
const url = extractUrlFromText(typeof input === "string" ? input : "");
|
|
306
|
+
if (url) {
|
|
307
|
+
let hostname = "";
|
|
308
|
+
try {
|
|
309
|
+
hostname = new URL(url).hostname.toLowerCase();
|
|
310
|
+
} catch {
|
|
311
|
+
hostname = "";
|
|
312
|
+
}
|
|
313
|
+
if (XHS_SHORT_LINK_HOSTS.includes(hostname)) {
|
|
314
|
+
const longUrl = await resolveXhsShortLink(url);
|
|
315
|
+
const id = parseXhsNoteId(longUrl);
|
|
316
|
+
if (id) return id;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
throw validationError("无法从输入里识别小红书笔记(支持笔记链接、xhslink 短链、分享口令或 24 位 note_id)");
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* 把用户输入(主页链接 / 裸 user_id)解析成 user_id。
|
|
325
|
+
* @param {string} input
|
|
326
|
+
* @returns {string}
|
|
327
|
+
*/
|
|
328
|
+
function resolveUserId(input) {
|
|
329
|
+
const id = parseXhsUserId(input);
|
|
330
|
+
if (id) return id;
|
|
331
|
+
throw validationError("无法从输入里识别小红书用户(支持主页链接 /user/profile/ 或 24 位 user_id)");
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* text 格式化:单篇笔记详情。
|
|
336
|
+
* @param {object} resp
|
|
337
|
+
* @returns {string}
|
|
338
|
+
*/
|
|
339
|
+
function noteTextFormatter(resp = {}) {
|
|
340
|
+
const note = resp.note_data || {};
|
|
341
|
+
const user = note.user || {};
|
|
342
|
+
const interact = note.interact_info || {};
|
|
343
|
+
const lines = [];
|
|
344
|
+
|
|
345
|
+
lines.push(`✓ ${note.title || "(无标题)"}`);
|
|
346
|
+
if (user.nickname) lines.push(` 作者: ${user.nickname}`);
|
|
347
|
+
lines.push(` 类型: ${note.type === "video" ? "视频笔记" : "图文笔记"}`);
|
|
348
|
+
const publishedAt = formatXhsTime(note.time);
|
|
349
|
+
if (publishedAt) lines.push(` 发布: ${publishedAt}`);
|
|
350
|
+
lines.push(
|
|
351
|
+
` 互动: 赞 ${toCount(interact.liked_count)} / 藏 ${toCount(interact.collected_count)}` +
|
|
352
|
+
` / 评 ${toCount(interact.comment_count)} / 分享 ${toCount(interact.share_count)}`
|
|
353
|
+
);
|
|
354
|
+
const tags = (note.tag_list || []).map((tag) => tag.name).filter(Boolean);
|
|
355
|
+
if (tags.length) lines.push(` 话题: ${tags.join("、")}`);
|
|
356
|
+
if (note.image_list && note.image_list.length) {
|
|
357
|
+
lines.push(` 图片: ${note.image_list.length} 张`);
|
|
358
|
+
}
|
|
359
|
+
if (note.type === "video" && note.video && note.video.duration) {
|
|
360
|
+
lines.push(` 视频时长: ${Math.round(note.video.duration)}s`);
|
|
361
|
+
}
|
|
362
|
+
if (note.desc) {
|
|
363
|
+
lines.push("");
|
|
364
|
+
lines.push(note.desc);
|
|
365
|
+
}
|
|
366
|
+
if (note.note_id) lines.push("", `笔记 ID: ${note.note_id}`);
|
|
367
|
+
return lines.join("\n");
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/**
|
|
371
|
+
* text 格式化:笔记评论(按点赞降序)。
|
|
372
|
+
* @param {object} resp
|
|
373
|
+
* @returns {string}
|
|
374
|
+
*/
|
|
375
|
+
function commentsTextFormatter(resp = {}) {
|
|
376
|
+
const data = resp.data || {};
|
|
377
|
+
const comments = [...(data.comments || [])].sort((a, b) => toCount(b.like_count) - toCount(a.like_count));
|
|
378
|
+
const lines = [`✓ 共取到 ${comments.length} 条评论`];
|
|
379
|
+
// total_comments 是本次各页累计的总数,不是笔记真实评论总数(那要 note 接口的 comment_count)
|
|
380
|
+
if (data.has_more) lines.push(` 提示: 还有更多评论,可加 --max-pages(当前 ${toCount(data.total_pages) || 1} 页)`);
|
|
381
|
+
comments.forEach((item, index) => {
|
|
382
|
+
const nick = (item.user && (item.user.original_name || item.user.nickname)) || "匿名";
|
|
383
|
+
const ip = item.ip_location ? ` · ${item.ip_location}` : "";
|
|
384
|
+
lines.push("", `[${index + 1}] ${nick}${ip} · 赞 ${toCount(item.like_count)}`);
|
|
385
|
+
lines.push(` ${(item.content || "").replace(/\n+/g, " ")}`);
|
|
386
|
+
const replies = Array.isArray(item.sub_comments) ? item.sub_comments : [];
|
|
387
|
+
replies.forEach((reply) => {
|
|
388
|
+
const replyUser = reply.user || {};
|
|
389
|
+
const replyNick = replyUser.original_name || replyUser.nickname || "匿名";
|
|
390
|
+
lines.push(` ↳ ${replyNick}: ${(reply.content || "").replace(/\n+/g, " ")}`);
|
|
391
|
+
});
|
|
392
|
+
if (!replies.length && toCount(item.sub_comment_count) > 0) {
|
|
393
|
+
lines.push(` ↳ 另有 ${toCount(item.sub_comment_count)} 条回复未展开`);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
return lines.join("\n");
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* text 格式化:笔记搜索结果(按点赞降序)。
|
|
401
|
+
* @param {object} resp
|
|
402
|
+
* @returns {string}
|
|
403
|
+
*/
|
|
404
|
+
function searchTextFormatter(resp = {}) {
|
|
405
|
+
const data = resp.data || {};
|
|
406
|
+
const items = data.items || [];
|
|
407
|
+
const rows = items
|
|
408
|
+
.map((item) => ({ note: item.noteInfo || {}, user: item.userInfo || {} }))
|
|
409
|
+
.sort((a, b) => toCount(b.note.likeNum) - toCount(a.note.likeNum));
|
|
410
|
+
|
|
411
|
+
const lines = [`✓ 共取到 ${rows.length} 条笔记`];
|
|
412
|
+
if (data.has_more) lines.push(" 提示: 还有更多结果,可加 --max-pages");
|
|
413
|
+
rows.forEach((row, index) => {
|
|
414
|
+
const note = row.note;
|
|
415
|
+
const author = row.user.nickName || "未知作者";
|
|
416
|
+
lines.push("", `[${index + 1}] ${note.title || "(无标题)"}`);
|
|
417
|
+
lines.push(
|
|
418
|
+
` ${note.noteType === "video" ? "视频" : "图文"} · 赞 ${toCount(note.likeNum)}` +
|
|
419
|
+
` / 藏 ${toCount(note.favNum)} / 评 ${toCount(note.cmtNum)} · ${author}`
|
|
420
|
+
);
|
|
421
|
+
if (note.notePublishTime) lines.push(` 发布: ${note.notePublishTime}`);
|
|
422
|
+
if (note.noteLink) lines.push(` ${note.noteLink}`);
|
|
423
|
+
});
|
|
424
|
+
return lines.join("\n");
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
/**
|
|
428
|
+
* text 格式化:用户详情。
|
|
429
|
+
* @param {object} resp
|
|
430
|
+
* @returns {string}
|
|
431
|
+
*/
|
|
432
|
+
function userInfoTextFormatter(resp = {}) {
|
|
433
|
+
const info = resp.user_info || {};
|
|
434
|
+
const basic = info.basic_info || {};
|
|
435
|
+
const lines = [`✓ ${basic.nickname || "(无昵称)"}`];
|
|
436
|
+
if (basic.red_id) lines.push(` 小红书号: ${basic.red_id}`);
|
|
437
|
+
if (basic.ip_location) lines.push(` 地区: ${basic.ip_location}`);
|
|
438
|
+
const interactions = info.interactions || [];
|
|
439
|
+
if (interactions.length) {
|
|
440
|
+
lines.push(` 互动: ${interactions.map((item) => `${item.name} ${toCount(item.count)}`).join(" / ")}`);
|
|
441
|
+
}
|
|
442
|
+
const tags = info.tags || [];
|
|
443
|
+
if (tags.length) lines.push(` 标签: ${tags.join("、")}`);
|
|
444
|
+
if (basic.desc) lines.push("", basic.desc);
|
|
445
|
+
return lines.join("\n");
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
/**
|
|
449
|
+
* text 格式化:用户笔记列表。
|
|
450
|
+
* @param {object} resp
|
|
451
|
+
* @returns {string}
|
|
452
|
+
*/
|
|
453
|
+
function userNotesTextFormatter(resp = {}) {
|
|
454
|
+
const notes = (resp.data && resp.data.notes) || [];
|
|
455
|
+
const lines = [`✓ 共取到 ${notes.length} 条笔记`];
|
|
456
|
+
let totalLikes = 0;
|
|
457
|
+
notes.forEach((note, index) => {
|
|
458
|
+
totalLikes += toCount(note.likes);
|
|
459
|
+
const flags = [];
|
|
460
|
+
if (note.sticky) flags.push("置顶");
|
|
461
|
+
if (note.is_goods_note) flags.push("带货");
|
|
462
|
+
const flagText = flags.length ? ` [${flags.join("/")}]` : "";
|
|
463
|
+
const published = formatXhsTime(note.time).slice(0, 10);
|
|
464
|
+
lines.push(
|
|
465
|
+
"",
|
|
466
|
+
`[${index + 1}] ${note.title || "(无标题)"}${flagText}`,
|
|
467
|
+
` ${note.type === "video" ? "视频" : "图文"} · 赞 ${toCount(note.likes)} / 藏 ${toCount(note.collected_count)}` +
|
|
468
|
+
` / 评 ${toCount(note.comments_count)}${published ? ` · ${published}` : ""}`
|
|
469
|
+
);
|
|
470
|
+
});
|
|
471
|
+
if (notes.length) lines.push("", `点赞合计: ${totalLikes}`);
|
|
472
|
+
return lines.join("\n");
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
/**
|
|
476
|
+
* xhs note:获取单篇笔记详情。
|
|
477
|
+
* @param {string} input 笔记链接 / 短链 / 分享口令 / note_id
|
|
478
|
+
* @param {{apiKey?: string, format?: "json"|"text"}} options
|
|
479
|
+
* @returns {Promise<void>}
|
|
480
|
+
*/
|
|
481
|
+
async function noteCommand(input, options = {}) {
|
|
482
|
+
const format = options.format || "json";
|
|
483
|
+
const apiKey = resolveKeyOrExit(options.apiKey, format);
|
|
484
|
+
|
|
485
|
+
try {
|
|
486
|
+
const noteId = await resolveNoteId(input);
|
|
487
|
+
const result = await xhsNoteInfo(apiKey, noteId);
|
|
488
|
+
outputResult(result, format, noteTextFormatter);
|
|
489
|
+
process.exit(0);
|
|
490
|
+
} catch (err) {
|
|
491
|
+
handleApiError(err, format);
|
|
492
|
+
process.exit(1);
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/**
|
|
497
|
+
* xhs comments:获取笔记一级评论(含可见的楼内回复)。
|
|
498
|
+
* @param {string} input 笔记链接 / 短链 / 分享口令 / note_id
|
|
499
|
+
* @param {{apiKey?: string, format?: string, maxPages?: string, maxSeconds?: string}} options
|
|
500
|
+
* @returns {Promise<void>}
|
|
501
|
+
*/
|
|
502
|
+
async function commentsCommand(input, options = {}) {
|
|
503
|
+
const format = options.format || "json";
|
|
504
|
+
const apiKey = resolveKeyOrExit(options.apiKey, format);
|
|
505
|
+
|
|
506
|
+
try {
|
|
507
|
+
const noteId = await resolveNoteId(input);
|
|
508
|
+
const result = await xhsNoteComments(apiKey, {
|
|
509
|
+
noteId,
|
|
510
|
+
maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
|
|
511
|
+
maxSeconds: options.maxSeconds === undefined ? undefined : safeParseInt(options.maxSeconds, 50),
|
|
512
|
+
});
|
|
513
|
+
outputResult(result, format, commentsTextFormatter);
|
|
514
|
+
process.exit(0);
|
|
515
|
+
} catch (err) {
|
|
516
|
+
handleApiError(err, format);
|
|
517
|
+
process.exit(1);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
/**
|
|
522
|
+
* xhs search:按关键词搜索笔记。
|
|
523
|
+
* @param {string} keyword
|
|
524
|
+
* @param {{apiKey?: string, format?: string, sort?: string, noteTime?: string, noteType?: string, maxPages?: string}} options
|
|
525
|
+
* @returns {Promise<void>}
|
|
526
|
+
*/
|
|
527
|
+
async function searchCommand(keyword, options = {}) {
|
|
528
|
+
const format = options.format || "json";
|
|
529
|
+
const apiKey = resolveKeyOrExit(options.apiKey, format);
|
|
530
|
+
|
|
531
|
+
try {
|
|
532
|
+
const result = await xhsNoteSearch(apiKey, {
|
|
533
|
+
keyword,
|
|
534
|
+
maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
|
|
535
|
+
sort: options.sort,
|
|
536
|
+
noteTime: options.noteTime,
|
|
537
|
+
noteType: options.noteType,
|
|
538
|
+
});
|
|
539
|
+
outputResult(result, format, searchTextFormatter);
|
|
540
|
+
process.exit(0);
|
|
541
|
+
} catch (err) {
|
|
542
|
+
handleApiError(err, format);
|
|
543
|
+
process.exit(1);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
/**
|
|
548
|
+
* xhs user:获取用户详情。
|
|
549
|
+
* @param {string} input 主页链接 / user_id
|
|
550
|
+
* @param {{apiKey?: string, format?: "json"|"text"}} options
|
|
551
|
+
* @returns {Promise<void>}
|
|
552
|
+
*/
|
|
553
|
+
async function userCommand(input, options = {}) {
|
|
554
|
+
const format = options.format || "json";
|
|
555
|
+
const apiKey = resolveKeyOrExit(options.apiKey, format);
|
|
556
|
+
|
|
557
|
+
try {
|
|
558
|
+
const userId = resolveUserId(input);
|
|
559
|
+
const result = await xhsUserInfo(apiKey, userId);
|
|
560
|
+
outputResult(result, format, userInfoTextFormatter);
|
|
561
|
+
process.exit(0);
|
|
562
|
+
} catch (err) {
|
|
563
|
+
handleApiError(err, format);
|
|
564
|
+
process.exit(1);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* xhs user-notes:获取用户笔记列表。
|
|
570
|
+
* @param {string} input 主页链接 / user_id
|
|
571
|
+
* @param {{apiKey?: string, format?: string, maxPages?: string}} options
|
|
572
|
+
* @returns {Promise<void>}
|
|
573
|
+
*/
|
|
574
|
+
async function userNotesCommand(input, options = {}) {
|
|
575
|
+
const format = options.format || "json";
|
|
576
|
+
const apiKey = resolveKeyOrExit(options.apiKey, format);
|
|
577
|
+
|
|
578
|
+
try {
|
|
579
|
+
const userId = resolveUserId(input);
|
|
580
|
+
const result = await xhsUserNotes(apiKey, {
|
|
581
|
+
userId,
|
|
582
|
+
maxPages: options.maxPages === undefined ? 1 : safeParseInt(options.maxPages, 1),
|
|
583
|
+
});
|
|
584
|
+
outputResult(result, format, userNotesTextFormatter);
|
|
585
|
+
process.exit(0);
|
|
586
|
+
} catch (err) {
|
|
587
|
+
handleApiError(err, format);
|
|
588
|
+
process.exit(1);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
module.exports = {
|
|
593
|
+
hotListCommand,
|
|
594
|
+
hotListTextFormatter,
|
|
595
|
+
notePublishCommand,
|
|
596
|
+
notePublishTextFormatter,
|
|
597
|
+
noteCommand,
|
|
598
|
+
commentsCommand,
|
|
599
|
+
searchCommand,
|
|
600
|
+
userCommand,
|
|
601
|
+
userNotesCommand,
|
|
602
|
+
noteTextFormatter,
|
|
603
|
+
commentsTextFormatter,
|
|
604
|
+
searchTextFormatter,
|
|
605
|
+
userInfoTextFormatter,
|
|
606
|
+
userNotesTextFormatter,
|
|
607
|
+
resolveNoteId,
|
|
608
|
+
resolveUserId,
|
|
609
|
+
};
|