aiconductor-cli 1.5.1 → 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 +7 -3
- package/README.md +69 -0
- package/bin/aiconductor-cli.js +56 -1
- package/package.json +1 -1
- package/src/api.js +227 -0
- package/src/api.test.js +263 -1
- package/src/commands/xhs.js +345 -4
- package/src/lib/cli-utils.js +89 -1
package/AGENTS.md
CHANGED
|
@@ -68,7 +68,7 @@ platform 侧还有、CLI 未接 10 个:`wx_rss_reader`、`flow_wx_history_arti
|
|
|
68
68
|
| 命令 | 用途 |
|
|
69
69
|
|---|---|
|
|
70
70
|
| `npm install` | 安装唯一依赖 `commander`(有 lock) |
|
|
71
|
-
| `npm test` | `node --test`,
|
|
71
|
+
| `npm test` | `node --test`,40 个用例,离线打桩,不需要 API Key |
|
|
72
72
|
| `node bin/aiconductor-cli.js --help` | 不安装也能本地运行,核对命令面 |
|
|
73
73
|
| `node bin/aiconductor-cli.js <组> <子命令> --help` | 核对某个命令的参数与默认值;CLI 契约以它为准 |
|
|
74
74
|
| `npm pack --dry-run` | 确认发布内容:含 `src/**`(含测试)、`api_docs/`、`AGENTS.md`,不含 `.agents/` 与 `references/`(平台侧规范只在本机,已从 `.npmignore`/`.gitignore` 排掉) |
|
|
@@ -96,7 +96,7 @@ platform 侧还有、CLI 未接 10 个:`wx_rss_reader`、`flow_wx_history_arti
|
|
|
96
96
|
|
|
97
97
|
- 技能放 `.agents/skills/<name>/`(2026-11-04 从 `.codex/skills/` 迁来,内容逐字未变,`git mv` 效果):pi 从 cwd 向上到仓库根发现项目级 `.agents/skills/`(`dist/core/package-manager.js` 的 `collectAncestorAgentsSkillDirs`),`.codex/skills/` 从来不是 pi 的发现路径。代价:项目里有 `.agents/skills/` 会触发**项目信任门禁**(`hasTrustRequiringProjectResources` 返回 true),未信任时这 11 个技能不加载。**这是故意的**(2026-11-04):本仓技能是**上架产物**(WorkBuddy / 技能仓库的发布件),不是本机开发时要用的上下文,所以 `~/.pi/agent/trust.json` 已给 `D:/Development/aiconductor-cli` 显式写 `false`——pi 不弹信任提示、技能不进上下文;需要某条技能内容时直接 `read .agents/skills/<name>/SKILL.md`,**不要为了加载它去信任项目**。
|
|
98
98
|
- `.gitignore` 用 `.agents/*` + `!.agents/skills/` 只放行技能,`.agents/ledger.md`、`.agents/plans/` 属本机脚手架不入库——**别把 `.agents/` 整目录 ignore**,那会让技能从版本控制里消失(`git status` 表现为 `.codex/` 全删、新目录全是 `??`)。
|
|
99
|
-
- 目录名、frontmatter `name`、实际调用的命令必须一致;`description` 写能力 + 用户原话触发 + 与相邻 skill 的边界(简报→content-briefing,单点搜索→doubao-search,网页→webpage-publish,抖音数据→douyin-toolbox,抖音文案→douyin-script-extract,抖音选题→douyin-topic-radar,抖音评论→douyin-comment-insight,抖音账号→douyin-account-analyze
|
|
99
|
+
- 目录名、frontmatter `name`、实际调用的命令必须一致;`description` 写能力 + 用户原话触发 + 与相邻 skill 的边界(简报→content-briefing,单点搜索→doubao-search,网页→webpage-publish,抖音数据→douyin-toolbox,抖音文案→douyin-script-extract,抖音选题→douyin-topic-radar,抖音评论→douyin-comment-insight,抖音账号→douyin-account-analyze,小红书发布→xhs-note-publish,小红书选题→xhs-topic-radar,小红书单篇拆解→xhs-note-insight,小红书账号→xhs-account-analyze,公众号发文列表→wx-account-articles,单篇公众号数据→wx-article-data)。
|
|
100
100
|
- `SKILL.md` 只放路由、决策、命令行、响应路径、安全规则;长选项表、安装与密钥恢复放 `references/`,并写明何时读。
|
|
101
101
|
- 默认 JSON 输出,文档写稳定的 `data.data.*` 路径(`outputResult` 会额外包一层 `{success, data}`)。
|
|
102
102
|
- 用户请求执行即等同授权:不要每次预检安装或密钥;安装说明只在需要时读一次,修复后重试原命令一次。
|
|
@@ -127,6 +127,10 @@ platform 侧还有、CLI 未接 10 个:`wx_rss_reader`、`flow_wx_history_arti
|
|
|
127
127
|
- **`wx_history_articles` 的上游失败藏在 `data.code`**:平台侧仍回 `success:true`,只有 `data.code` 非 0 才是失败(实测 `107` = 名称/链接对不上,`20001` = 平台侧上游故障),`src/api.js` 的 `throwOnWxHistoryFailure` 把它转成 `api_error`(退出码 1)。别用「数组为空」推断「这个号没发文」。
|
|
128
128
|
- **抖音字段单位不统一**:`video-search` 的 `video.duration` 是**毫秒**,`user-videos` 的 `duration` 是 `"MM:SS"` 字符串——同名不同单位,跨端点复用代码必翻车。
|
|
129
129
|
- **抖音时间戳是北京时间编码**:`create_time` 按 UTC 渲染出来就是北京时间,**不要再 +8 小时**(2026-10-28 实测 `video-search`/`video-comments`/`user-videos` 三处:+8 会让半小时前发的视频落到 7 小时后)。`user-videos` 另带 `create_time_formatted`,同口径。
|
|
130
|
+
- **小红书时间戳正好相反**:`note_info.time`(秒)与 `user_notes/country` 的 `time`(毫秒)都是**真 UTC epoch**,展示必须 **+8**(2026-11-05 实测:同一视频笔记 `note_info.time`+8h 等于 `search` 返回的 `notePublishTime` 字符串)。上游 `time_desc` 的 `MM-DD` 是 **UTC-8 口径**(10-29 04:50 UTC 的笔记显示 `10-28`),只能用 `time +8` 自算。
|
|
131
|
+
- **小红书端点取名与上游是错位的**:`xhs_user_notes_v2` 走 TikHub(可用、20 条/页),`xhs_user_notes_v3` 原本走 YDDM 且长期返回 `code 5003`(已于 2026-11-05 迁到同一 TikHub 上游)。CLI 选定 `xhs_user_notes_v2`;换端点要同步改测试与 `api_docs`。
|
|
132
|
+
- **小红书 `share_text` 不能透传中文口令**:平台侧是原样转发上游的,带「复制内容,然后进入【小红书】…」的分享文案直接空结果(实测)。所以 `resolveNoteId` 先本地抽 URL、再跟 302 展开 `xhslink.cn` 短链。
|
|
133
|
+
- **小红书同名不同结构**:`interact_info.*_count`、`like_count`、`sub_comment_count` 都是**字符串**;评论的 `user` 用 `userid`/`images`/`original_name`,笔记的 `user` 用 `user_id`/`avatar`/`nickname`。`noteInfo.readNum` 与 `userInfo.fansNum` 实测**恒为 0**,不要当真实阅读量/粉丝数承诺。
|
|
130
134
|
- **密钥解析**:`-k` > `AICONDUCTOR_API_KEY` > **当前工作目录**的 `.env`(不向上查找);缺失时 `resolveKeyOrExit` 打印错误并退出 1。
|
|
131
135
|
|
|
132
136
|
## 禁区
|
|
@@ -150,7 +154,7 @@ platform 侧还有、CLI 未接 10 个:`wx_rss_reader`、`flow_wx_history_arti
|
|
|
150
154
|
|
|
151
155
|
## 最短验证闭环
|
|
152
156
|
|
|
153
|
-
`npm test`(
|
|
157
|
+
`npm test`(40 个用例,离线打桩,不需要 API Key)。改动涉及 `bin/`、`README.md`、skill 文字或新增命令时,再补一条人工核对:`node bin/aiconductor-cli.js <组> <子命令> --help`——`bin/` 的接线没有自动化测试覆盖。
|
|
154
158
|
|
|
155
159
|
## 任务执行与报告约定
|
|
156
160
|
|
package/README.md
CHANGED
|
@@ -315,6 +315,70 @@ aiconductor-cli xhs hot-list [选项]
|
|
|
315
315
|
aiconductor-cli xhs hot-list -f text
|
|
316
316
|
```
|
|
317
317
|
|
|
318
|
+
#### 笔记详情 `xhs note`
|
|
319
|
+
|
|
320
|
+
```bash
|
|
321
|
+
aiconductor-cli xhs note <ref> [选项]
|
|
322
|
+
```
|
|
323
|
+
|
|
324
|
+
`ref` 支持笔记链接、`xhslink.cn`/`xhslink.com` 短链、完整分享口令,或 24 位 `note_id`。
|
|
325
|
+
|
|
326
|
+
```bash
|
|
327
|
+
aiconductor-cli xhs note "https://www.xiaohongshu.com/discovery/item/6ae972260000000015020d84" -f text
|
|
328
|
+
aiconductor-cli xhs note "标题 https://xhslink.cn/o/85lbqBJtO94 复制内容,然后进入【小红书】查看相关笔记。"
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
#### 笔记评论 `xhs comments`
|
|
332
|
+
|
|
333
|
+
```bash
|
|
334
|
+
aiconductor-cli xhs comments <ref> [选项]
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
| 选项 | 说明 | 默认值 |
|
|
338
|
+
|------|------|--------|
|
|
339
|
+
| `--max-pages <n>` | 最大翻页数,范围 1~5 | 1 |
|
|
340
|
+
| `--max-seconds <n>` | 最长运行时间(秒),上限 170 | 50 |
|
|
341
|
+
|
|
342
|
+
返回一级评论,并带上上游可见的楼内回复(`sub_comments`)。`data.data.comments[]` 里 `like_count`、`sub_comment_count` 是**字符串**,评论的 `user` 对象用 `userid`/`original_name`(与笔记的 `user_id`/`nickname` 不同)。
|
|
343
|
+
|
|
344
|
+
#### 笔记搜索 `xhs search`
|
|
345
|
+
|
|
346
|
+
```bash
|
|
347
|
+
aiconductor-cli xhs search <keyword> [选项]
|
|
348
|
+
```
|
|
349
|
+
|
|
350
|
+
| 选项 | 说明 | 默认值 |
|
|
351
|
+
|------|------|--------|
|
|
352
|
+
| `--sort <sort>` | `general` / `time_descending` / `popularity_descending` / `comment_descending` / `collect_descending` | 不限 |
|
|
353
|
+
| `--note-time <range>` | `day` / `week` / `halfyear` | 不限 |
|
|
354
|
+
| `--note-type <type>` | `video` / `note` | 不限 |
|
|
355
|
+
| `--max-pages <n>` | 最大翻页数,范围 1~5 | 1 |
|
|
356
|
+
|
|
357
|
+
示例:
|
|
358
|
+
```bash
|
|
359
|
+
aiconductor-cli xhs search "AI编程" --note-time week --sort popularity_descending --max-pages 2 -f text
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
结果在 `data.data.items[].noteInfo` 与 `.userInfo`。注意 `noteInfo.readNum` 与 `userInfo.fansNum` 实测恒为 0,不要当真实阅读量/粉丝数用。
|
|
363
|
+
|
|
364
|
+
#### 用户详情 `xhs user` / 用户笔记 `xhs user-notes`
|
|
365
|
+
|
|
366
|
+
```bash
|
|
367
|
+
aiconductor-cli xhs user <ref> [选项]
|
|
368
|
+
aiconductor-cli xhs user-notes <ref> [选项] # 支持 --max-pages,范围 1~5
|
|
369
|
+
```
|
|
370
|
+
|
|
371
|
+
`ref` 支持主页链接 `/user/profile/<id>` 或 24 位 `user_id`。
|
|
372
|
+
|
|
373
|
+
```bash
|
|
374
|
+
aiconductor-cli xhs user "https://www.xiaohongshu.com/user/profile/53cd3bbbb4c4d64f93463018" -f text
|
|
375
|
+
aiconductor-cli xhs user-notes 53cd3bbbb4c4d64f93463018 --max-pages 2 -f text
|
|
376
|
+
```
|
|
377
|
+
|
|
378
|
+
用户详情在 `user_info`(`basic_info` + 5 项中文 `interactions` + `tags`);用户笔记在 `data.data.notes[]`。
|
|
379
|
+
|
|
380
|
+
> 时间口径:小红书所有 `time` 都是 **UTC 时间戳**(笔记详情为秒、用户笔记为毫秒),`-f text` 已自动换算成北京时间;**不要**再额外加 8 小时。上游的 `time_desc`(如 `10-28`)是 UTC-8 口径,文本模式不使用它。
|
|
381
|
+
|
|
318
382
|
### 小红书扫码发布
|
|
319
383
|
|
|
320
384
|
生成图文或视频笔记的 H5 发布页与二维码。素材支持公网 URL、本地文件、Base64/data URL,以及包含 Base64 内容的文本文件。
|
|
@@ -397,6 +461,11 @@ aiconductor-cli wx history "https://mp.weixin.qq.com/s?__biz=xxxx" -f text
|
|
|
397
461
|
| 抖音视频搜索 | 0.15 元/页 |
|
|
398
462
|
| 抖音链接转文字 | 0.01 元/次 + 0.0003 元/秒 |
|
|
399
463
|
| 小红书热榜 | 以平台服务配置为准 |
|
|
464
|
+
| 小红书笔记详情 | 以平台服务配置为准(按次) |
|
|
465
|
+
| 小红书笔记评论 | 以平台服务配置为准(按成功翻页数) |
|
|
466
|
+
| 小红书笔记搜索 | 以平台服务配置为准(按成功翻页数) |
|
|
467
|
+
| 小红书用户详情 | 以平台服务配置为准(按次) |
|
|
468
|
+
| 小红书用户笔记列表 | 以平台服务配置为准(按成功翻页数) |
|
|
400
469
|
| 小红书扫码发布 | 0.05 元/次 |
|
|
401
470
|
| 公众号文章互动数据 | 以平台服务配置为准(按次) |
|
|
402
471
|
| 公众号历史文章列表 | 以平台服务配置为准(按成功翻页数) |
|
package/bin/aiconductor-cli.js
CHANGED
|
@@ -18,7 +18,15 @@ const {
|
|
|
18
18
|
videoSearchCommand,
|
|
19
19
|
linkToTextCommand,
|
|
20
20
|
} = require("../src/commands/douyin");
|
|
21
|
-
const {
|
|
21
|
+
const {
|
|
22
|
+
hotListCommand: xhsHotListCommand,
|
|
23
|
+
notePublishCommand,
|
|
24
|
+
noteCommand: xhsNoteCommand,
|
|
25
|
+
commentsCommand: xhsCommentsCommand,
|
|
26
|
+
searchCommand: xhsSearchCommand,
|
|
27
|
+
userCommand: xhsUserCommand,
|
|
28
|
+
userNotesCommand: xhsUserNotesCommand,
|
|
29
|
+
} = require("../src/commands/xhs");
|
|
22
30
|
const {
|
|
23
31
|
articleDataCommand: wxArticleDataCommand,
|
|
24
32
|
historyCommand: wxHistoryCommand,
|
|
@@ -257,6 +265,53 @@ xhsCmd
|
|
|
257
265
|
.option("-f, --format <format>", "输出格式:json 或 text", "json")
|
|
258
266
|
.action((type, options) => { notePublishCommand(type, options); });
|
|
259
267
|
|
|
268
|
+
xhsCmd
|
|
269
|
+
.command("note")
|
|
270
|
+
.description("获取小红书笔记详情(按次计费)")
|
|
271
|
+
.argument("<ref>", "笔记链接、xhslink 短链、分享口令或 24 位 note_id")
|
|
272
|
+
.option("-k, --api-key <key>", "AIConductor API Key")
|
|
273
|
+
.option("-f, --format <format>", "输出格式:json 或 text", "json")
|
|
274
|
+
.action((ref, options) => { xhsNoteCommand(ref, options); });
|
|
275
|
+
|
|
276
|
+
xhsCmd
|
|
277
|
+
.command("comments")
|
|
278
|
+
.description("获取小红书笔记评论,含可见的楼内回复(按成功翻页计费)")
|
|
279
|
+
.argument("<ref>", "笔记链接、xhslink 短链、分享口令或 24 位 note_id")
|
|
280
|
+
.option("-k, --api-key <key>", "AIConductor API Key")
|
|
281
|
+
.option("--max-pages <n>", "最大翻页数,范围 1~5(每页约 10 条)", "1")
|
|
282
|
+
.option("--max-seconds <n>", "最长运行时间(秒),最大 170", "50")
|
|
283
|
+
.option("-f, --format <format>", "输出格式:json 或 text", "json")
|
|
284
|
+
.action((ref, options) => { xhsCommentsCommand(ref, options); });
|
|
285
|
+
|
|
286
|
+
xhsCmd
|
|
287
|
+
.command("search")
|
|
288
|
+
.description("按关键词搜索小红书笔记(按成功翻页计费,每页约 20 条)")
|
|
289
|
+
.argument("<keyword>", "搜索关键词")
|
|
290
|
+
.option("-k, --api-key <key>", "AIConductor API Key")
|
|
291
|
+
.option("--sort <sort>", "排序:general/time_descending/popularity_descending/comment_descending/collect_descending")
|
|
292
|
+
.option("--note-time <range>", "发布时间:day/week/halfyear(不传=不限)")
|
|
293
|
+
.option("--note-type <type>", "笔记类型:video/note(不传=不限)")
|
|
294
|
+
.option("--max-pages <n>", "最大翻页数,范围 1~5", "1")
|
|
295
|
+
.option("-f, --format <format>", "输出格式:json 或 text", "json")
|
|
296
|
+
.action((keyword, options) => { xhsSearchCommand(keyword, options); });
|
|
297
|
+
|
|
298
|
+
xhsCmd
|
|
299
|
+
.command("user")
|
|
300
|
+
.description("获取小红书用户详情(按次计费)")
|
|
301
|
+
.argument("<ref>", "主页链接 /user/profile/ 或 24 位 user_id")
|
|
302
|
+
.option("-k, --api-key <key>", "AIConductor API Key")
|
|
303
|
+
.option("-f, --format <format>", "输出格式:json 或 text", "json")
|
|
304
|
+
.action((ref, options) => { xhsUserCommand(ref, options); });
|
|
305
|
+
|
|
306
|
+
xhsCmd
|
|
307
|
+
.command("user-notes")
|
|
308
|
+
.description("获取小红书用户笔记列表(按成功翻页计费,每页约 20 条)")
|
|
309
|
+
.argument("<ref>", "主页链接 /user/profile/ 或 24 位 user_id")
|
|
310
|
+
.option("-k, --api-key <key>", "AIConductor API Key")
|
|
311
|
+
.option("--max-pages <n>", "最大翻页数,范围 1~5", "1")
|
|
312
|
+
.option("-f, --format <format>", "输出格式:json 或 text", "json")
|
|
313
|
+
.action((ref, options) => { xhsUserNotesCommand(ref, options); });
|
|
314
|
+
|
|
260
315
|
// wx 子命令组(公众号数据)
|
|
261
316
|
const wxCmd = program
|
|
262
317
|
.command("wx")
|
package/package.json
CHANGED
package/src/api.js
CHANGED
|
@@ -19,6 +19,25 @@ const DOUYIN_USER_SEARCH_PATH = "/api/douyin_user_search";
|
|
|
19
19
|
const DOUYIN_VIDEO_SEARCH_PATH = "/api/douyin_video_search";
|
|
20
20
|
const DOUYIN_LINK_TO_TEXT_PATH = "/api/douyin_link_to_text_v2";
|
|
21
21
|
const XHS_NOTE_PUBLISH_PATH = "/api/xhs_note_publish";
|
|
22
|
+
const XHS_NOTE_INFO_PATH = "/api/xhs_note_info_v3";
|
|
23
|
+
const XHS_NOTE_COMMENTS_PATH = "/api/xhs_note_comments_fast";
|
|
24
|
+
const XHS_NOTE_SEARCH_PATH = "/api/xhs_note_search_v3";
|
|
25
|
+
const XHS_USER_INFO_PATH = "/api/xhs_user_info_v2";
|
|
26
|
+
// 刻意打 v2:v3 已于 2026-11-05 迁到同一上游且同输出,但 v2 是历史真实流量所在(api_docs 两篇一致)。
|
|
27
|
+
const XHS_USER_NOTES_PATH = "/api/xhs_user_notes_v2";
|
|
28
|
+
|
|
29
|
+
// 本地校验区间,命中就在发请求前拦截,不产生计费
|
|
30
|
+
const XHS_MAX_PAGES_LIMIT = 5;
|
|
31
|
+
const XHS_MAX_SECONDS_LIMIT = 170;
|
|
32
|
+
const XHS_SEARCH_SORTS = [
|
|
33
|
+
"general",
|
|
34
|
+
"time_descending",
|
|
35
|
+
"popularity_descending",
|
|
36
|
+
"comment_descending",
|
|
37
|
+
"collect_descending",
|
|
38
|
+
];
|
|
39
|
+
const XHS_SEARCH_NOTE_TIMES = ["day", "week", "halfyear"];
|
|
40
|
+
const XHS_SEARCH_NOTE_TYPES = ["video", "note"];
|
|
22
41
|
const WX_ARTICLE_DATA_PATH = "/api/wx_article_data";
|
|
23
42
|
const WX_HISTORY_ARTICLES_PATH = "/api/wx_history_articles";
|
|
24
43
|
const R2_UPLOADER_PATH = "/api/r2_uploader";
|
|
@@ -677,6 +696,208 @@ async function xhsNotePublish(apiKey, params) {
|
|
|
677
696
|
return stripBilling(resp);
|
|
678
697
|
}
|
|
679
698
|
|
|
699
|
+
/**
|
|
700
|
+
* 抛 api_error(上层按业务失败处理)
|
|
701
|
+
* @param {object} result
|
|
702
|
+
* @returns {object}
|
|
703
|
+
*/
|
|
704
|
+
function throwOnXhsFailure(result) {
|
|
705
|
+
if (result?.success === false) {
|
|
706
|
+
throw Object.assign(new Error(result.message || "小红书接口返回失败"), { type: "api_error" });
|
|
707
|
+
}
|
|
708
|
+
return result;
|
|
709
|
+
}
|
|
710
|
+
|
|
711
|
+
/**
|
|
712
|
+
* 校验 1~上限 的正整数(上限单独校验,避免与其他命令的 limit 语义混住)
|
|
713
|
+
* @param {*} value
|
|
714
|
+
* @param {string} name
|
|
715
|
+
* @param {number} max
|
|
716
|
+
* @returns {number|null}
|
|
717
|
+
*/
|
|
718
|
+
function validateXhsPositiveInt(value, name, max) {
|
|
719
|
+
if (value === undefined || value === null || value === "") return null;
|
|
720
|
+
const n = Number(value);
|
|
721
|
+
if (!Number.isInteger(n) || n < 1 || n > max) {
|
|
722
|
+
throw Object.assign(new Error(`${name} 须为 1~${max} 的整数,收到: ${value}`), {
|
|
723
|
+
type: "validation_error",
|
|
724
|
+
});
|
|
725
|
+
}
|
|
726
|
+
return n;
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* 获取小红书笔记详情。
|
|
731
|
+
* 注意:只传 note_id 即可(2026-11-05 实测,不需要 xsec_token)。
|
|
732
|
+
* @param {string} apiKey
|
|
733
|
+
* @param {string} noteId
|
|
734
|
+
* @returns {Promise<object>} `note_data` 在顶层,没有 data.data 嵌套
|
|
735
|
+
*/
|
|
736
|
+
async function xhsNoteInfo(apiKey, noteId) {
|
|
737
|
+
const resp = await httpPost(API_HOST, XHS_NOTE_INFO_PATH, { note_id: noteId }, {
|
|
738
|
+
Authorization: `Bearer ${apiKey}`,
|
|
739
|
+
}, http);
|
|
740
|
+
return throwOnXhsFailure(stripBilling(resp));
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/**
|
|
744
|
+
* 获取小红书笔记一级评论(含可见的楼内回复 sub_comments)。
|
|
745
|
+
* @param {string} apiKey
|
|
746
|
+
* @param {{noteId?: string, shareText?: string, maxPages?: number, maxSeconds?: number}} params
|
|
747
|
+
* @returns {Promise<object>} `data.data.comments`,10 条/页
|
|
748
|
+
*/
|
|
749
|
+
async function xhsNoteComments(apiKey, params = {}) {
|
|
750
|
+
const body = {};
|
|
751
|
+
const maxPages = validateXhsPositiveInt(params.maxPages, "max_pages", XHS_MAX_PAGES_LIMIT);
|
|
752
|
+
const maxSeconds = validateXhsPositiveInt(params.maxSeconds, "max_seconds", XHS_MAX_SECONDS_LIMIT);
|
|
753
|
+
if (maxPages !== null) body.max_pages = maxPages;
|
|
754
|
+
if (maxSeconds !== null) body.max_seconds = maxSeconds;
|
|
755
|
+
|
|
756
|
+
if (params.noteId) {
|
|
757
|
+
body.note_id = params.noteId;
|
|
758
|
+
} else if (params.shareText) {
|
|
759
|
+
body.share_text = params.shareText;
|
|
760
|
+
} else {
|
|
761
|
+
throw Object.assign(new Error("note_id 与分享链接至少提供一个"), { type: "validation_error" });
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const resp = await httpPost(API_HOST, XHS_NOTE_COMMENTS_PATH, body, {
|
|
765
|
+
Authorization: `Bearer ${apiKey}`,
|
|
766
|
+
}, http);
|
|
767
|
+
return throwOnXhsFailure(stripBilling(resp));
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/**
|
|
771
|
+
* 按关键词搜索小红书笔记。
|
|
772
|
+
* @param {string} apiKey
|
|
773
|
+
* @param {{keyword: string, maxPages?: number, sort?: string, noteTime?: string, noteType?: string}} params
|
|
774
|
+
* @returns {Promise<object>} `data.data.items[]`,20 条/页
|
|
775
|
+
*/
|
|
776
|
+
async function xhsNoteSearch(apiKey, params = {}) {
|
|
777
|
+
const keyword = typeof params.keyword === "string" ? params.keyword.trim() : "";
|
|
778
|
+
if (!keyword) {
|
|
779
|
+
throw Object.assign(new Error("keyword 不能为空"), { type: "validation_error" });
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
const body = { keyword };
|
|
783
|
+
const maxPages = validateXhsPositiveInt(params.maxPages, "max_pages", XHS_MAX_PAGES_LIMIT);
|
|
784
|
+
if (maxPages !== null) body.max_pages = maxPages;
|
|
785
|
+
|
|
786
|
+
if (params.sort) {
|
|
787
|
+
if (!XHS_SEARCH_SORTS.includes(params.sort)) {
|
|
788
|
+
throw Object.assign(new Error(`sort 仅支持 ${XHS_SEARCH_SORTS.join("|")},收到: ${params.sort}`), {
|
|
789
|
+
type: "validation_error",
|
|
790
|
+
});
|
|
791
|
+
}
|
|
792
|
+
body.sort = params.sort;
|
|
793
|
+
}
|
|
794
|
+
if (params.noteTime) {
|
|
795
|
+
if (!XHS_SEARCH_NOTE_TIMES.includes(params.noteTime)) {
|
|
796
|
+
throw Object.assign(new Error(`note_time 仅支持 ${XHS_SEARCH_NOTE_TIMES.join("|")},收到: ${params.noteTime}`), {
|
|
797
|
+
type: "validation_error",
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
body.note_time = params.noteTime;
|
|
801
|
+
}
|
|
802
|
+
if (params.noteType) {
|
|
803
|
+
if (!XHS_SEARCH_NOTE_TYPES.includes(params.noteType)) {
|
|
804
|
+
throw Object.assign(new Error(`note_type 仅支持 ${XHS_SEARCH_NOTE_TYPES.join("|")},收到: ${params.noteType}`), {
|
|
805
|
+
type: "validation_error",
|
|
806
|
+
});
|
|
807
|
+
}
|
|
808
|
+
body.note_type = params.noteType;
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
const resp = await httpPost(API_HOST, XHS_NOTE_SEARCH_PATH, body, {
|
|
812
|
+
Authorization: `Bearer ${apiKey}`,
|
|
813
|
+
}, http);
|
|
814
|
+
return throwOnXhsFailure(stripBilling(resp));
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* 获取小红书用户详情。
|
|
819
|
+
* @param {string} apiKey
|
|
820
|
+
* @param {string} userId
|
|
821
|
+
* @returns {Promise<object>} `user_info` 在顶层,interactions 为 5 项中文
|
|
822
|
+
*/
|
|
823
|
+
async function xhsUserInfo(apiKey, userId) {
|
|
824
|
+
const resp = await httpPost(API_HOST, XHS_USER_INFO_PATH, { user_id: userId }, {
|
|
825
|
+
Authorization: `Bearer ${apiKey}`,
|
|
826
|
+
}, http);
|
|
827
|
+
return throwOnXhsFailure(stripBilling(resp));
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
/**
|
|
831
|
+
* 获取小红书用户笔记列表。
|
|
832
|
+
* @param {string} apiKey
|
|
833
|
+
* @param {{userId: string, maxPages?: number}} params
|
|
834
|
+
* @returns {Promise<object>} `data.data.notes[]`,20 条/页
|
|
835
|
+
*/
|
|
836
|
+
async function xhsUserNotes(apiKey, params = {}) {
|
|
837
|
+
if (!params.userId) {
|
|
838
|
+
throw Object.assign(new Error("user_id 不能为空"), { type: "validation_error" });
|
|
839
|
+
}
|
|
840
|
+
const body = { user_id: params.userId };
|
|
841
|
+
const maxPages = validateXhsPositiveInt(params.maxPages, "max_pages", XHS_MAX_PAGES_LIMIT);
|
|
842
|
+
if (maxPages !== null) body.max_pages = maxPages;
|
|
843
|
+
|
|
844
|
+
const resp = await httpPost(API_HOST, XHS_USER_NOTES_PATH, body, {
|
|
845
|
+
Authorization: `Bearer ${apiKey}`,
|
|
846
|
+
}, http);
|
|
847
|
+
return throwOnXhsFailure(stripBilling(resp));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* 展开小红书分享短链(xhslink.cn / xhslink.com)为真实长链。
|
|
852
|
+
* 短链必须跟随 302,否则拿不到 note_id。
|
|
853
|
+
* @param {string} shortUrl
|
|
854
|
+
* @returns {Promise<string>} 长链;不是跳转时原样返回
|
|
855
|
+
*/
|
|
856
|
+
function resolveXhsShortLink(shortUrl) {
|
|
857
|
+
return new Promise((resolve, reject) => {
|
|
858
|
+
let parsed;
|
|
859
|
+
try {
|
|
860
|
+
parsed = new URL(shortUrl);
|
|
861
|
+
} catch {
|
|
862
|
+
reject(Object.assign(new Error(`短链格式无效: ${shortUrl}`), { type: "validation_error" }));
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
866
|
+
reject(Object.assign(new Error(`短链仅支持 HTTP/HTTPS: ${shortUrl}`), { type: "validation_error" }));
|
|
867
|
+
return;
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
const transport = parsed.protocol === "https:" ? https : http;
|
|
871
|
+
const req = transport.request({
|
|
872
|
+
hostname: parsed.hostname,
|
|
873
|
+
port: parsed.port || (parsed.protocol === "https:" ? 443 : 80),
|
|
874
|
+
path: `${parsed.pathname}${parsed.search}`,
|
|
875
|
+
method: "GET",
|
|
876
|
+
headers: {
|
|
877
|
+
"User-Agent": "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.0 Mobile/15E148 Safari/604.1",
|
|
878
|
+
},
|
|
879
|
+
}, (res) => {
|
|
880
|
+
const location = res.headers?.location;
|
|
881
|
+
if (res.statusCode >= 300 && res.statusCode < 400 && location) {
|
|
882
|
+
if (res.resume) res.resume();
|
|
883
|
+
resolve(location);
|
|
884
|
+
return;
|
|
885
|
+
}
|
|
886
|
+
if (res.resume) res.resume();
|
|
887
|
+
resolve(shortUrl);
|
|
888
|
+
});
|
|
889
|
+
|
|
890
|
+
req.setTimeout(15000, () => {
|
|
891
|
+
req.destroy();
|
|
892
|
+
reject(Object.assign(new Error(`短链解析超时: ${shortUrl}`), { type: "network_error" }));
|
|
893
|
+
});
|
|
894
|
+
req.on("error", (err) => {
|
|
895
|
+
reject(Object.assign(new Error(`短链解析失败: ${err.message}`), { type: "network_error" }));
|
|
896
|
+
});
|
|
897
|
+
req.end();
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
|
|
680
901
|
module.exports = {
|
|
681
902
|
resolveApiKey,
|
|
682
903
|
publishHtml,
|
|
@@ -695,6 +916,12 @@ module.exports = {
|
|
|
695
916
|
douyinLinkToText,
|
|
696
917
|
r2Upload,
|
|
697
918
|
xhsNotePublish,
|
|
919
|
+
xhsNoteInfo,
|
|
920
|
+
xhsNoteComments,
|
|
921
|
+
xhsNoteSearch,
|
|
922
|
+
xhsUserInfo,
|
|
923
|
+
xhsUserNotes,
|
|
924
|
+
resolveXhsShortLink,
|
|
698
925
|
wxArticleData,
|
|
699
926
|
wxHistoryArticles,
|
|
700
927
|
};
|
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,
|
|
@@ -513,3 +529,249 @@ test("wx article-data formatter labels the fields the live endpoint returns", ()
|
|
|
513
529
|
const emptyText = formatArticleDataText({ data: {} });
|
|
514
530
|
assert.match(emptyText, /上游未返回字段/);
|
|
515
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
|
+
};
|
package/src/lib/cli-utils.js
CHANGED
|
@@ -81,4 +81,92 @@ function safeParseInt(value, defaultValue) {
|
|
|
81
81
|
return Number.isNaN(n) ? defaultValue : n;
|
|
82
82
|
}
|
|
83
83
|
|
|
84
|
-
|
|
84
|
+
/**
|
|
85
|
+
* 从文本(分享口令/带链接的句子)中抽出第一个 http(s) 链接。
|
|
86
|
+
*
|
|
87
|
+
* 小红书用户复制的内容形如「标题 https://xhslink.cn/o/xxx 复制内容,然后进入【小红书】查看相关笔记。」
|
|
88
|
+
* 平台侧的 share_text 是**原样透传上游**的,带中文口令必失败(2026-11-05 实测),所以链接必须本地抽。
|
|
89
|
+
* @param {string} text
|
|
90
|
+
* @returns {string|null}
|
|
91
|
+
*/
|
|
92
|
+
function extractUrlFromText(text) {
|
|
93
|
+
if (typeof text !== "string") return null;
|
|
94
|
+
const match = text.match(/https?:\/\/[^\s\u4e00-\u9fa5,。、;:!?()【】《》“”‘’]+/i);
|
|
95
|
+
if (!match) return null;
|
|
96
|
+
// 去掉 URL 尾部的英文标点(中文标点已在字符集里排除)
|
|
97
|
+
return match[0].replace(/[),.;:!?'"\]}>]+$/, "");
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const XHS_ID_RE = /^[0-9a-f]{24}$/i;
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* 从用户输入里识别小红书笔记 ID(裸 ID / 长链 / 带链接的分享文案)。
|
|
104
|
+
* 短链(xhslink.cn)需先由 resolveXhsShortLink 换成长链。
|
|
105
|
+
* @param {string} text
|
|
106
|
+
* @returns {string|null}
|
|
107
|
+
*/
|
|
108
|
+
function parseXhsNoteId(text) {
|
|
109
|
+
if (typeof text !== "string") return null;
|
|
110
|
+
const raw = text.trim();
|
|
111
|
+
if (XHS_ID_RE.test(raw)) return raw;
|
|
112
|
+
const url = extractUrlFromText(raw);
|
|
113
|
+
if (!url) return null;
|
|
114
|
+
const match = url.match(/(?:explore|discovery\/item|note)\/([0-9a-f]{24})/i);
|
|
115
|
+
return match ? match[1] : null;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* 从用户输入里识别小红书用户 ID(裸 ID / 主页链接)。
|
|
120
|
+
* @param {string} text
|
|
121
|
+
* @returns {string|null}
|
|
122
|
+
*/
|
|
123
|
+
function parseXhsUserId(text) {
|
|
124
|
+
if (typeof text !== "string") return null;
|
|
125
|
+
const raw = text.trim();
|
|
126
|
+
if (XHS_ID_RE.test(raw)) return raw;
|
|
127
|
+
const url = extractUrlFromText(raw);
|
|
128
|
+
if (!url) return null;
|
|
129
|
+
const match = url.match(/user\/profile\/([0-9a-f]{24})/i);
|
|
130
|
+
return match ? match[1] : null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 小红书时间戳格式化:上游给的是**真 UTC epoch**(详情秒、笔记列表毫秒),展示需 +8 小时。
|
|
135
|
+
* 与抖音相反(抖音是「北京时间编码」,不要再加 8)。
|
|
136
|
+
* @param {number|string} value epoch 秒或毫秒
|
|
137
|
+
* @returns {string} `YYYY-MM-DD HH:mm:ss`,无效值返回空串
|
|
138
|
+
*/
|
|
139
|
+
function formatXhsTime(value) {
|
|
140
|
+
const n = Number(value);
|
|
141
|
+
if (!n || Number.isNaN(n)) return "";
|
|
142
|
+
const ms = n > 1e12 ? n : n * 1000;
|
|
143
|
+
return new Date(ms + 8 * 3600 * 1000).toISOString().replace("T", " ").slice(0, 19);
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* 归一化为整数计数:上游同一平台里数字有 int、字符串、以及「1.2万」这类糊糊写法。
|
|
148
|
+
* @param {*} value
|
|
149
|
+
* @returns {number}
|
|
150
|
+
*/
|
|
151
|
+
function toCount(value) {
|
|
152
|
+
if (typeof value === "number") return Number.isFinite(value) ? value : 0;
|
|
153
|
+
if (value === null || value === undefined) return 0;
|
|
154
|
+
const text = String(value).replace(/[,\s]/g, "");
|
|
155
|
+
const match = text.match(/^(\d+(?:\.\d+)?)(万|w)?\+?$/i);
|
|
156
|
+
if (!match) return 0;
|
|
157
|
+
const base = Number(match[1]);
|
|
158
|
+
return Math.round(match[2] ? base * 10000 : base);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
module.exports = {
|
|
162
|
+
outputResult,
|
|
163
|
+
outputError,
|
|
164
|
+
resolveKeyOrExit,
|
|
165
|
+
handleApiError,
|
|
166
|
+
safeParseInt,
|
|
167
|
+
extractUrlFromText,
|
|
168
|
+
parseXhsNoteId,
|
|
169
|
+
parseXhsUserId,
|
|
170
|
+
formatXhsTime,
|
|
171
|
+
toCount,
|
|
172
|
+
};
|