yuanflow-cli 0.1.4 → 0.1.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -29,6 +29,8 @@ yuanflow-cli douyin video-detail "https://v.douyin.com/xxx/" --format agent-json
29
29
  yuanflow-cli shortcuts douyin
30
30
  yuanflow-cli commands list
31
31
  yuanflow-cli schema douyin.video-detail
32
+ yuanflow-cli schema comments.douyin.comments
33
+ yuanflow-cli comments collect --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
32
34
  yuanflow-cli list douyin
33
35
  ```
34
36
 
@@ -40,6 +42,27 @@ YUANCHUANG_API_TOKEN=<你的令牌>
40
42
 
41
43
  token 优先级:`--token` > `YUANCHUANG_API_TOKEN` > 本地 `config.token`。独立 CLI 用户可以使用环境变量或 `config set-token`;在 YuanFlow 主程序内使用时,token 由主程序认证系统注入,不需要手动配置。
42
44
 
45
+ ### 作品评论采集
46
+
47
+ 评论采集统一走 `comments collect`,用于把 Agent 的自然语言需求稳定映射到平台评论接口:
48
+
49
+ ```bash
50
+ yuanflow-cli comments collect --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
51
+ yuanflow-cli comments collect --platform xiaohongshu --target note_id --action replies --comment-id root_comment_id --format agent-json
52
+ yuanflow-cli comments collect --platform youtube --target video_id --action comments --dry-run --format agent-json
53
+ ```
54
+
55
+ 常用参数:
56
+
57
+ - `--platform`:平台标识,如 `douyin`、`xiaohongshu`、`bilibili`、`wechat_mp`、`tiktok`、`instagram`、`kuaishou`、`reddit`、`twitter`、`weibo`、`youtube`、`zhihu`。
58
+ - `--action`:评论类型,默认 `comments`,可选 `replies`、`post_comments`、`post_replies`。
59
+ - `--target`:作品、文章、帖子、视频或回答 ID,也可以传支持的平台链接。
60
+ - `--comment-id`:采集二级评论或评论回复时使用。
61
+ - `--prefer fallback`:使用备用接口。
62
+ - `--dry-run`:只预览请求映射,不发起真实接口请求,也不要求 token。
63
+
64
+ Agent 可以先用 `yuanflow-cli commands list` 查看全部命令,再用 `yuanflow-cli schema comments.douyin.comments` 查看某个评论采集命令的参数、接口路径和返回说明。
65
+
43
66
  ## Skill 安装器
44
67
 
45
68
  ```bash
@@ -104,6 +104,14 @@ function resolveLocalSkillRoot(packageRoot) {
104
104
  }
105
105
 
106
106
  async function prepareSkillSource({ packageRoot }) {
107
+ const localRoot = resolveLocalSkillRoot(packageRoot);
108
+ if (localRoot) {
109
+ return {
110
+ sourceRoot: localRoot,
111
+ cleanup() {},
112
+ };
113
+ }
114
+
107
115
  const repoConfig = readRepositoryConfig(packageRoot);
108
116
  const tempRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'yuanflow-skill-'));
109
117
  const archiveFile = path.join(tempRoot, `${repoConfig.repo}-${repoConfig.ref}.tar.gz`);
@@ -131,15 +139,6 @@ async function prepareSkillSource({ packageRoot }) {
131
139
  cloneRepository({ repoConfig, targetDir: cloneDir });
132
140
  sourceRoot = resolveRepositoryRoot(cloneDir);
133
141
  } catch (gitError) {
134
- const localRoot = resolveLocalSkillRoot(packageRoot);
135
- if (localRoot) {
136
- fs.rmSync(tempRoot, { recursive: true, force: true });
137
- return {
138
- sourceRoot: localRoot,
139
- cleanup() {},
140
- };
141
- }
142
-
143
142
  const archiveMessage = archiveError instanceof Error ? archiveError.message : String(archiveError);
144
143
  const gitMessage = gitError instanceof Error ? gitError.message : String(gitError);
145
144
  throw new Error(`下载 skill 仓库失败。archive: ${archiveMessage}; git: ${gitMessage}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "yuanflow-cli",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "YuanFlow API CLI and skill installer for supported AI coding agents.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -9,10 +9,11 @@ description: Use when the user asks about social-media API workflows, platform d
9
9
 
10
10
  ## 当前发布范围
11
11
 
12
- 当前 skill 目录只包含:
12
+ 当前 skill 目录包含:
13
13
 
14
14
  - 根目录这一份 `SKILL.md`
15
15
  - `yuanflow-cli/`
16
+ - `作品评论采集/`
16
17
 
17
18
  ## 环境判断
18
19
 
@@ -48,6 +49,18 @@ description: Use when the user asks about social-media API workflows, platform d
48
49
 
49
50
  - `yuanflow-cli`
50
51
 
52
+ ### 2. 走 `作品评论采集`
53
+
54
+ 遇到下面这些需求,优先进入这个子 Skill:
55
+
56
+ - 采集、抓取、导出或分析作品评论。
57
+ - 获取评论回复、二级评论、楼中楼、子评论。
58
+ - 用户给出抖音、小红书、B站、微信公众号、微信视频号、TikTok、Instagram、快手、Reddit、Twitter/X、微博、YouTube、知乎链接或 ID,并明确要评论数据。
59
+
60
+ 子 Skill 名称:
61
+
62
+ - `作品评论采集`
63
+
51
64
  ## 多需求时怎么处理
52
65
 
53
66
  如果用户一次提了多段流程,不要强行塞进一个子 Skill,按阶段拆开:
@@ -132,9 +132,10 @@ yuanflow-cli schema douyin.video-detail
132
132
  yuanflow-cli shortcuts
133
133
  yuanflow-cli shortcuts douyin
134
134
  yuanflow-cli list douyin
135
- yuanflow-cli commands list
136
- yuanflow-cli commands describe xiaohongshu.search-notes
137
- yuanflow-cli schema xiaohongshu.search-notes
135
+ yuanflow-cli commands list
136
+ yuanflow-cli commands describe xiaohongshu.search-notes
137
+ yuanflow-cli schema xiaohongshu.search-notes
138
+ yuanflow-cli comments collect --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
138
139
  ```
139
140
 
140
141
  当前 registry 覆盖的平台包括:
@@ -174,6 +175,43 @@ yuanflow-cli bilibili video-detail "https://www.bilibili.com/video/BVxxx" -o bil
174
175
  | 作品评论 | `yuanflow-cli douyin comments <aweme_id> --count 20 --cursor 0 --format agent-json` |
175
176
  | 抖音热榜 | `yuanflow-cli douyin hot-search --format agent-json` |
176
177
 
178
+ ### 作品评论采集统一入口
179
+
180
+ 评论采集优先使用统一入口:
181
+
182
+ ```powershell
183
+ yuanflow-cli comments collect --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
184
+ ```
185
+
186
+ 常用参数:
187
+
188
+ - `--platform`:平台,如 `douyin`、`xiaohongshu`、`bilibili`、`wechat_mp`、`wechat_channels`、`tiktok`、`instagram`、`kuaishou`、`reddit`、`twitter`、`weibo`、`youtube`、`zhihu`。
189
+ - `--action`:`comments` 一级评论,`replies` 评论回复;YouTube 社区帖子使用 `post_comments` 或 `post_replies`。
190
+ - `--target`:作品链接、文章链接、视频 ID、笔记 ID、BV 号等。
191
+ - `--comment-id`:采集回复时的父评论 ID。
192
+ - `--cursor`:翻页游标。
193
+ - `--count`:返回数量。
194
+ - `--page`:B站页码。
195
+ - `--prefer fallback`:主接口失败时切换备用接口。
196
+ - `--extra`:JSON 字符串,用于公众号 `content_id` 等补充参数。
197
+
198
+ 在 YuanFlow 主程序内,用 `yuanflow_cli_call` 调用时传参数数组:
199
+
200
+ ```json
201
+ {
202
+ "args": [
203
+ "comments",
204
+ "collect",
205
+ "--platform",
206
+ "douyin",
207
+ "--target",
208
+ "https://v.douyin.com/xxx/",
209
+ "--format",
210
+ "agent-json"
211
+ ]
212
+ }
213
+ ```
214
+
177
215
  ### 小红书
178
216
 
179
217
  | 需求 | 命令 |
@@ -0,0 +1,169 @@
1
+ ---
2
+ name: 作品评论采集
3
+ description: Use when the user needs to collect, export, inspect, or continue comment and reply data for Douyin, Xiaohongshu, Bilibili, WeChat MP, WeChat Channels, TikTok, Instagram, Kuaishou, Reddit, Twitter/X, Weibo, YouTube, or Zhihu posts.
4
+ builtin_skill_version: 1.0.0
5
+ tags:
6
+ - 自媒体
7
+ - 评论采集
8
+ - 抖音
9
+ - 小红书
10
+ - B站
11
+ - 微信公众号
12
+ - 微信视频号
13
+ - TikTok
14
+ - Instagram
15
+ - 快手
16
+ - Reddit
17
+ - Twitter
18
+ - 微博
19
+ - YouTube
20
+ - 知乎
21
+ emoji: 💬
22
+ ---
23
+
24
+ # 作品评论采集
25
+
26
+ 本技能把用户的评论采集需求稳定映射到 `yuanflow-cli comments collect`。在 YuanFlow 主程序内,优先使用受控工具 `yuanflow_cli_call`;它会由主程序认证系统注入 `YUANCHUANG_API_TOKEN`,不要让用户粘贴 KEY,不要在回复、日志或文件里暴露 token。
27
+
28
+ ## 什么时候使用
29
+
30
+ 用户出现以下意图时使用:
31
+
32
+ - 采集、抓取、获取、导出某个作品、文章、帖子、回答的评论。
33
+ - 获取某条评论下的回复、二级评论、楼中楼、子评论。
34
+ - 用户给出抖音、小红书、B站、微信公众号文章、微信视频号、TikTok、Instagram、快手、Reddit、Twitter/X、微博、YouTube、知乎链接或 ID,并要求看评论数据。
35
+
36
+ 不要用于发评论、回复评论、点赞评论、绕过平台限制、批量抓取或未授权数据访问。作品详情、作品下载、用户主页、综合搜索应交给 `yuanflow-cli` 其它命令。
37
+
38
+ ## 调用优先级
39
+
40
+ 1. YuanFlow 主程序内:调用 `yuanflow_cli_call`,参数数组从 `comments collect` 开始。
41
+ 2. 外部 Agent 且本机有 CLI:执行 `yuanflow-cli comments collect ...`。
42
+ 3. 外部 Agent 且没有 CLI:再提示用户安装 `npm install -g yuanflow-cli`。
43
+
44
+ YuanFlow 内置调用示例:
45
+
46
+ ```json
47
+ {
48
+ "args": [
49
+ "comments",
50
+ "collect",
51
+ "--platform",
52
+ "douyin",
53
+ "--target",
54
+ "https://v.douyin.com/xxx/",
55
+ "--format",
56
+ "agent-json"
57
+ ]
58
+ }
59
+ ```
60
+
61
+ 外部 CLI 示例:
62
+
63
+ ```powershell
64
+ yuanflow-cli comments collect --platform douyin --target "https://v.douyin.com/xxx/" --format agent-json
65
+ ```
66
+
67
+ ## 先查命令和 schema
68
+
69
+ 不确定参数时先查,不要猜:
70
+
71
+ ```json
72
+ {"args":["commands","list"]}
73
+ {"args":["schema","comments.douyin.comments"]}
74
+ ```
75
+
76
+ 命令 key 格式是:
77
+
78
+ ```text
79
+ comments.<platform>.<action>
80
+ ```
81
+
82
+ 例如 `comments.douyin.comments`、`comments.xiaohongshu.replies`、`comments.youtube.post_comments`。
83
+
84
+ ## 参数规则
85
+
86
+ 通用参数:
87
+
88
+ - `--platform`:平台标识。
89
+ - `--action`:`comments` 一级评论;`replies` 评论回复;YouTube 社区帖子用 `post_comments` 或 `post_replies`。默认 `comments`。
90
+ - `--target`:作品、文章、帖子或回答 ID,也可以传支持的平台链接。
91
+ - `--comment-id`:多数平台采集回复时必填。
92
+ - `--cursor`:翻页游标,必须来自上一次响应,不要编造。
93
+ - `--count`:返回数量,仅平台支持时传。
94
+ - `--page`:B站页码。
95
+ - `--prefer fallback`:主接口失败或用户要求备用接口时使用。
96
+ - `--extra`:JSON 字符串补充参数,例如微信公众号回复需要 `content_id`。
97
+ - `--format agent-json`:Agent 调用时必须加,便于稳定解析。
98
+
99
+ ## 平台识别
100
+
101
+ | 平台参数 | 用户常见说法 | 链接或 ID 标识 |
102
+ |---|---|---|
103
+ | `douyin` | 抖音、Douyin | `douyin.com`、`iesdouyin.com`、`v.douyin.com`、`aweme_id` |
104
+ | `xiaohongshu` | 小红书、XHS | `xiaohongshu.com`、`xhslink.com`、`note_id` |
105
+ | `bilibili` | B站、哔哩哔哩 | `bilibili.com`、`b23.tv`、`BV` 号 |
106
+ | `wechat_mp` | 微信公众号、公众号文章 | `mp.weixin.qq.com/s/` |
107
+ | `wechat_channels` | 微信视频号、视频号 | 视频号视频 ID、视频号详情里的 `id` |
108
+ | `tiktok` | TikTok | `tiktok.com`、`vm.tiktok.com`、`aweme_id` |
109
+ | `instagram` | Instagram、ins | `instagram.com/p/`、`instagram.com/reel/`、shortcode、media_id |
110
+ | `kuaishou` | 快手 | `kuaishou.com/short-video/`、`photo_id` |
111
+ | `reddit` | Reddit | `reddit.com/r/.../comments/`、`t3_` 帖子 ID |
112
+ | `twitter` | Twitter、X | `twitter.com`、`x.com`、推文数字 ID |
113
+ | `weibo` | 微博 | `weibo.com`、微博 `id`、`mid` |
114
+ | `youtube` | YouTube、油管 | `youtube.com/watch?v=`、`youtu.be/`、视频 ID、社区帖子 ID |
115
+ | `zhihu` | 知乎 | 知乎回答 ID、评论 ID |
116
+
117
+ 如果短链文本无法判断平台,先按域名判断;仍无法判断就追问平台。
118
+
119
+ ## 接口映射
120
+
121
+ Agent 不需要直接拼接口地址,只需要选对 `platform`、`action` 和参数。
122
+
123
+ | 平台 | action | 首选能力 | 主要参数 | 备用能力 |
124
+ |---|---|---|---|---|
125
+ | 抖音 | `comments` | 抖音作品一级评论 | `aweme_id`、`cursor`、`count` | 支持 |
126
+ | 抖音 | `replies` | 抖音评论回复 | `item_id`、`comment_id`、`cursor`、`count` | 支持 |
127
+ | 小红书 | `comments` | 小红书笔记一级评论 | `note_id`、`cursor` | 支持 |
128
+ | 小红书 | `replies` | 小红书笔记二级评论 | `note_id`、`comment_id`、`cursor`、`count` | 支持 |
129
+ | B站 | `comments` | B站视频一级评论 | `bv_id`、`page` | 支持 |
130
+ | B站 | `replies` | B站评论回复 | `bv_id`、`comment_id`、`page` | 暂无 |
131
+ | 微信公众号 | `comments` | 公众号文章一级评论 | `url`、`comment_id`、`cursor` | 暂无 |
132
+ | 微信公众号 | `replies` | 公众号文章评论回复 | `url`、`comment_id`、`extra.content_id`、`cursor` | 暂无 |
133
+ | 微信视频号 | `comments` | 视频号一级评论 | `id`、`cursor` | 暂无 |
134
+ | 微信视频号 | `replies` | 视频号评论回复 | `id`、`comment_id`、`cursor` | 暂无 |
135
+ | TikTok | `comments` | TikTok 作品一级评论 | `aweme_id`、`cursor`、`count` | 支持 |
136
+ | TikTok | `replies` | TikTok 评论回复 | `item_id`、`comment_id`、`cursor`、`count` | 支持 |
137
+ | Instagram | `comments` | Instagram 帖子一级评论 | `code_or_url`、`cursor` | 支持 |
138
+ | Instagram | `replies` | Instagram 评论回复 | `code_or_url`、`comment_id`、`cursor` | 支持 |
139
+ | 快手 | `comments` | 快手作品一级评论 | `photo_id`、`cursor` | 支持 |
140
+ | 快手 | `replies` | 快手作品二级评论 | `photo_id`、`comment_id`、`cursor` | 暂无 |
141
+ | Reddit | `comments` | Reddit 帖子一级评论 | `post_id`、`cursor` | 暂无 |
142
+ | Reddit | `replies` | Reddit 评论回复 | `post_id`、`cursor` | 暂无 |
143
+ | Twitter/X | `comments` | 推文评论 | `tweet_id`、`cursor` | 支持 |
144
+ | 微博 | `comments` | 微博一级评论 | `id`、`count`、`cursor` | 支持 |
145
+ | 微博 | `replies` | 微博子评论 | `id`、`count`、`cursor` | 暂无 |
146
+ | YouTube | `comments` | 视频一级评论 | `video_id`、`cursor` | 支持 |
147
+ | YouTube | `replies` | 视频二级评论 | `continuation_token` | 支持 |
148
+ | YouTube | `post_comments` | 社区帖子一级评论 | `post_id`、`cursor` | 暂无 |
149
+ | YouTube | `post_replies` | 社区帖子评论回复 | `continuation_token` | 暂无 |
150
+ | 知乎 | `comments` | 回答评论区 | `answer_id`、`count`、`cursor` | 暂无 |
151
+ | 知乎 | `replies` | 子评论区 | `comment_id`、`count`、`cursor` | 暂无 |
152
+
153
+ ## 输出要求
154
+
155
+ 成功后用中文简短说明:
156
+
157
+ - 调用了哪个平台和评论类型。
158
+ - 使用首选能力还是备用能力。
159
+ - 如果响应里有评论数量、下一页游标或 continuation token,要提示用户。
160
+ - 用户要求导出时,再整理为表格、JSON 或文件。
161
+
162
+ 失败时按 `agent-json` 的 `error.code` 和 `error.message` 解释。常见处理:
163
+
164
+ - `TOKEN_MISSING`:提示先完成 YuanFlow KEY 认证或外部 CLI 环境变量配置。
165
+ - `BAD_ARGUMENT`:检查平台、action、target、comment-id、cursor。
166
+ - `AUTH_INVALID`:认证无效或权限不足。
167
+ - `UPSTREAM_ERROR`、`NETWORK_ERROR`:说明上游或网络失败,可稍后重试,或在有备用能力的平台加 `--prefer fallback`。
168
+
169
+ 不要展示完整 Authorization 请求头、token、cookie 或敏感账号信息。
@@ -1,5 +1,6 @@
1
1
  import { listEndpoints } from './registry.js';
2
2
  import { listShortcuts } from './shortcuts.js';
3
+ import { listCommentCommands } from './comment-collector.js';
3
4
 
4
5
  const ERROR_MAP = [
5
6
  {
@@ -81,7 +82,8 @@ export function getCommandName(platform, command) {
81
82
  export function buildCommandRegistry() {
82
83
  const shortcuts = listShortcuts().map((shortcut) => shortcutToCommand(shortcut));
83
84
  const endpoints = listEndpoints().map((endpoint) => endpointToCommand(endpoint));
84
- return [...shortcuts, ...endpoints].sort((left, right) =>
85
+ const commentCommands = listCommentCommands();
86
+ return [...shortcuts, ...endpoints, ...commentCommands].sort((left, right) =>
85
87
  left.key.localeCompare(right.key),
86
88
  );
87
89
  }
package/src/cli.js CHANGED
@@ -16,6 +16,7 @@ import {
16
16
  isAgentJsonFormat,
17
17
  } from './agent-protocol.js';
18
18
  import { findShortcut, listShortcuts } from './shortcuts.js';
19
+ import { collectComments } from './comment-collector.js';
19
20
 
20
21
  export async function main(argv) {
21
22
  const args = argv.slice(2);
@@ -46,6 +47,11 @@ export async function main(argv) {
46
47
  return;
47
48
  }
48
49
 
50
+ if (command === 'comments') {
51
+ await handleComments(rest);
52
+ return;
53
+ }
54
+
49
55
  if (command === 'schema') {
50
56
  await handleSchema(rest);
51
57
  return;
@@ -210,6 +216,33 @@ async function handleCall(args) {
210
216
  });
211
217
  }
212
218
 
219
+ async function handleComments(args) {
220
+ const { positionals, options } = parseOptions(args);
221
+ const [action = 'collect'] = positionals;
222
+ if (action !== 'collect') {
223
+ throw new Error('未知 comments 命令。用法:yuanflow-cli comments collect --platform douyin --target <作品链接或ID>');
224
+ }
225
+ const platform = options.named?.platform;
226
+ const target = options.named?.target || positionals[1];
227
+ if (!platform) {
228
+ throw new Error('缺少 --platform,例如 douyin、xiaohongshu、bilibili。');
229
+ }
230
+ if (!target) {
231
+ throw new Error('缺少 --target 或目标位置参数。');
232
+ }
233
+ const result = await collectComments({
234
+ platform,
235
+ action: options.named?.action || 'comments',
236
+ target,
237
+ prefer: options.named?.prefer || 'primary',
238
+ options,
239
+ });
240
+ await outputResult(result, options, {
241
+ command: 'comments collect',
242
+ meta: { endpoint: result.endpoint.path, kind: 'comment-collector' },
243
+ });
244
+ }
245
+
213
246
  async function handleGeneratedCommand(platform, args) {
214
247
  if (!getPlatforms().includes(platform)) {
215
248
  throw new Error(`未知平台:${platform}。可用平台:${getPlatforms().join(', ')}`);
@@ -363,6 +396,7 @@ function printHelp() {
363
396
  yuanflow-cli shortcuts douyin
364
397
  yuanflow-cli commands list
365
398
  yuanflow-cli schema douyin.video-detail
399
+ yuanflow-cli comments collect --platform douyin --target "https://v.douyin.com/xxx/" --dry-run
366
400
  yuanflow-cli list douyin
367
401
 
368
402
  说明:
@@ -0,0 +1,823 @@
1
+ import { callEndpoint } from './request.js';
2
+
3
+ const PRIMARY_ENDPOINTS = {
4
+ 'douyin:comments': {
5
+ platform: 'douyin',
6
+ action: 'comments',
7
+ method: 'GET',
8
+ path: '/douyin/web/fetch_video_comments',
9
+ targetParam: 'aweme_id',
10
+ cursorParam: 'cursor',
11
+ countParam: 'count',
12
+ description: '抖音作品一级评论',
13
+ },
14
+ 'douyin:replies': {
15
+ platform: 'douyin',
16
+ action: 'replies',
17
+ method: 'GET',
18
+ path: '/douyin/web/fetch_video_comment_replies',
19
+ targetParam: 'item_id',
20
+ commentParam: 'comment_id',
21
+ cursorParam: 'cursor',
22
+ countParam: 'count',
23
+ description: '抖音指定评论回复',
24
+ },
25
+ 'xiaohongshu:comments': {
26
+ platform: 'xiaohongshu',
27
+ action: 'comments',
28
+ method: 'GET',
29
+ path: '/xiaohongshu/web_v3/fetch_note_comments',
30
+ targetParam: 'note_id',
31
+ cursorParam: 'cursor',
32
+ description: '小红书笔记一级评论',
33
+ },
34
+ 'xiaohongshu:replies': {
35
+ platform: 'xiaohongshu',
36
+ action: 'replies',
37
+ method: 'GET',
38
+ path: '/xiaohongshu/web_v3/fetch_sub_comments',
39
+ targetParam: 'note_id',
40
+ commentParam: 'root_comment_id',
41
+ cursorParam: 'cursor',
42
+ countParam: 'num',
43
+ description: '小红书笔记二级评论',
44
+ },
45
+ 'bilibili:comments': {
46
+ platform: 'bilibili',
47
+ action: 'comments',
48
+ method: 'GET',
49
+ path: '/bilibili/web/fetch_video_comments',
50
+ targetParam: 'bv_id',
51
+ pageParam: 'pn',
52
+ description: 'B站视频一级评论',
53
+ },
54
+ 'bilibili:replies': {
55
+ platform: 'bilibili',
56
+ action: 'replies',
57
+ method: 'GET',
58
+ path: '/bilibili/web/fetch_comment_reply',
59
+ targetParam: 'bv_id',
60
+ commentParam: 'rpid',
61
+ pageParam: 'pn',
62
+ description: 'B站指定评论回复',
63
+ },
64
+ 'wechat_mp:comments': {
65
+ platform: 'wechat_mp',
66
+ action: 'comments',
67
+ method: 'GET',
68
+ path: '/wechat_mp/web/fetch_mp_article_comment_list',
69
+ targetParam: 'url',
70
+ commentParam: 'comment_id',
71
+ cursorParam: 'buffer',
72
+ description: '微信公众号文章一级评论',
73
+ },
74
+ 'wechat_mp:replies': {
75
+ platform: 'wechat_mp',
76
+ action: 'replies',
77
+ method: 'GET',
78
+ path: '/wechat_mp/web/fetch_mp_article_comment_reply_list',
79
+ targetParam: 'url',
80
+ commentParam: 'comment_id',
81
+ cursorParam: 'offset',
82
+ requiredExtra: ['content_id'],
83
+ description: '微信公众号文章评论回复',
84
+ },
85
+ 'wechat_channels:comments': {
86
+ platform: 'wechat_channels',
87
+ action: 'comments',
88
+ method: 'POST',
89
+ path: '/wechat_channels/fetch_comments',
90
+ targetParam: 'id',
91
+ cursorParam: 'lastBuffer',
92
+ bodyMode: true,
93
+ description: '微信视频号一级评论',
94
+ },
95
+ 'wechat_channels:replies': {
96
+ platform: 'wechat_channels',
97
+ action: 'replies',
98
+ method: 'POST',
99
+ path: '/wechat_channels/fetch_comments',
100
+ targetParam: 'id',
101
+ commentParam: 'comment_id',
102
+ cursorParam: 'lastBuffer',
103
+ bodyMode: true,
104
+ description: '微信视频号指定评论回复',
105
+ },
106
+ 'tiktok:comments': {
107
+ platform: 'tiktok',
108
+ action: 'comments',
109
+ method: 'GET',
110
+ path: '/tiktok/web/fetch_post_comment',
111
+ targetParam: 'aweme_id',
112
+ cursorParam: 'cursor',
113
+ countParam: 'count',
114
+ description: 'TikTok 作品一级评论',
115
+ },
116
+ 'tiktok:replies': {
117
+ platform: 'tiktok',
118
+ action: 'replies',
119
+ method: 'GET',
120
+ path: '/tiktok/web/fetch_post_comment_reply',
121
+ targetParam: 'item_id',
122
+ commentParam: 'comment_id',
123
+ cursorParam: 'cursor',
124
+ countParam: 'count',
125
+ description: 'TikTok 作品评论回复',
126
+ },
127
+ 'instagram:comments': {
128
+ platform: 'instagram',
129
+ action: 'comments',
130
+ method: 'GET',
131
+ path: '/instagram/v2/fetch_post_comments',
132
+ targetParam: 'code_or_url',
133
+ cursorParam: 'pagination_token',
134
+ description: 'Instagram 帖子一级评论',
135
+ },
136
+ 'instagram:replies': {
137
+ platform: 'instagram',
138
+ action: 'replies',
139
+ method: 'GET',
140
+ path: '/instagram/v2/fetch_comment_replies',
141
+ targetParam: 'code_or_url',
142
+ commentParam: 'comment_id',
143
+ cursorParam: 'pagination_token',
144
+ description: 'Instagram 帖子评论回复',
145
+ },
146
+ 'kuaishou:comments': {
147
+ platform: 'kuaishou',
148
+ action: 'comments',
149
+ method: 'GET',
150
+ path: '/kuaishou/web/fetch_one_video_comment',
151
+ targetParam: 'photo_id',
152
+ cursorParam: 'pcursor',
153
+ description: '快手作品一级评论',
154
+ },
155
+ 'kuaishou:replies': {
156
+ platform: 'kuaishou',
157
+ action: 'replies',
158
+ method: 'GET',
159
+ path: '/kuaishou/web/fetch_one_video_sub_comment',
160
+ targetParam: 'photo_id',
161
+ commentParam: 'root_comment_id',
162
+ cursorParam: 'pcursor',
163
+ description: '快手作品二级评论',
164
+ },
165
+ 'reddit:comments': {
166
+ platform: 'reddit',
167
+ action: 'comments',
168
+ method: 'GET',
169
+ path: '/reddit/app/fetch_post_comments',
170
+ targetParam: 'post_id',
171
+ cursorParam: 'after',
172
+ description: 'Reddit 帖子一级评论',
173
+ },
174
+ 'reddit:replies': {
175
+ platform: 'reddit',
176
+ action: 'replies',
177
+ method: 'GET',
178
+ path: '/reddit/app/fetch_comment_replies',
179
+ targetParam: 'post_id',
180
+ cursorParam: 'cursor',
181
+ requiredCursor: true,
182
+ description: 'Reddit 评论回复',
183
+ },
184
+ 'twitter:comments': {
185
+ platform: 'twitter',
186
+ action: 'comments',
187
+ method: 'GET',
188
+ path: '/twitter/web/fetch_post_comments',
189
+ targetParam: 'tweet_id',
190
+ cursorParam: 'cursor',
191
+ description: 'Twitter/X 推文评论',
192
+ },
193
+ 'weibo:comments': {
194
+ platform: 'weibo',
195
+ action: 'comments',
196
+ method: 'GET',
197
+ path: '/weibo/web_v2/fetch_post_comments',
198
+ targetParam: 'id',
199
+ cursorParam: 'max_id',
200
+ countParam: 'count',
201
+ description: '微博一级评论',
202
+ },
203
+ 'weibo:replies': {
204
+ platform: 'weibo',
205
+ action: 'replies',
206
+ method: 'GET',
207
+ path: '/weibo/web_v2/fetch_post_sub_comments',
208
+ targetParam: 'id',
209
+ cursorParam: 'max_id',
210
+ countParam: 'count',
211
+ description: '微博子评论',
212
+ },
213
+ 'youtube:comments': {
214
+ platform: 'youtube',
215
+ action: 'comments',
216
+ method: 'GET',
217
+ path: '/youtube/web/get_video_comments',
218
+ targetParam: 'video_id',
219
+ cursorParam: 'continuation_token',
220
+ description: 'YouTube 视频一级评论',
221
+ },
222
+ 'youtube:replies': {
223
+ platform: 'youtube',
224
+ action: 'replies',
225
+ method: 'GET',
226
+ path: '/youtube/web/get_video_comment_replies',
227
+ targetParam: 'continuation_token',
228
+ description: 'YouTube 视频二级评论',
229
+ },
230
+ 'youtube:post_comments': {
231
+ platform: 'youtube',
232
+ action: 'post_comments',
233
+ method: 'GET',
234
+ path: '/youtube/web_v2/get_post_comments',
235
+ targetParam: 'post_id',
236
+ cursorParam: 'continuation_token',
237
+ description: 'YouTube 帖子一级评论',
238
+ },
239
+ 'youtube:post_replies': {
240
+ platform: 'youtube',
241
+ action: 'post_replies',
242
+ method: 'GET',
243
+ path: '/youtube/web_v2/get_post_comment_replies',
244
+ targetParam: 'continuation_token',
245
+ description: 'YouTube 帖子评论回复',
246
+ },
247
+ 'zhihu:comments': {
248
+ platform: 'zhihu',
249
+ action: 'comments',
250
+ method: 'GET',
251
+ path: '/zhihu/web/fetch_comment_v5',
252
+ targetParam: 'answer_id',
253
+ cursorParam: 'offset',
254
+ countParam: 'limit',
255
+ description: '知乎回答评论区',
256
+ },
257
+ 'zhihu:replies': {
258
+ platform: 'zhihu',
259
+ action: 'replies',
260
+ method: 'GET',
261
+ path: '/zhihu/web/fetch_sub_comment_v5',
262
+ targetParam: 'comment_id',
263
+ cursorParam: 'offset',
264
+ countParam: 'limit',
265
+ description: '知乎子评论区',
266
+ },
267
+ };
268
+
269
+ const FALLBACK_ENDPOINTS = {
270
+ 'douyin:comments': {
271
+ platform: 'douyin',
272
+ action: 'comments',
273
+ method: 'GET',
274
+ path: '/douyin/app/v3/fetch_video_comments',
275
+ targetParam: 'aweme_id',
276
+ cursorParam: 'cursor',
277
+ countParam: 'count',
278
+ description: '抖音作品一级评论备用 app v3 接口',
279
+ },
280
+ 'douyin:replies': {
281
+ platform: 'douyin',
282
+ action: 'replies',
283
+ method: 'GET',
284
+ path: '/douyin/app/v3/fetch_video_comment_replies',
285
+ targetParam: 'item_id',
286
+ commentParam: 'comment_id',
287
+ cursorParam: 'cursor',
288
+ countParam: 'count',
289
+ description: '抖音评论回复备用 app v3 接口',
290
+ },
291
+ 'xiaohongshu:comments': {
292
+ platform: 'xiaohongshu',
293
+ action: 'comments',
294
+ method: 'GET',
295
+ path: '/xiaohongshu/web_v2/fetch_note_comments',
296
+ targetParam: 'note_id',
297
+ cursorParam: 'cursor',
298
+ description: '小红书笔记一级评论备用 web_v2 接口',
299
+ },
300
+ 'xiaohongshu:replies': {
301
+ platform: 'xiaohongshu',
302
+ action: 'replies',
303
+ method: 'GET',
304
+ path: '/xiaohongshu/web_v2/fetch_sub_comments',
305
+ targetParam: 'note_id',
306
+ commentParam: 'comment_id',
307
+ cursorParam: 'cursor',
308
+ description: '小红书笔记二级评论备用 web_v2 接口',
309
+ },
310
+ 'bilibili:comments': {
311
+ platform: 'bilibili',
312
+ action: 'comments',
313
+ method: 'GET',
314
+ path: '/bilibili/app/fetch_video_comments',
315
+ targetParam: 'bv_id',
316
+ cursorParam: 'next_offset',
317
+ description: 'B站视频一级评论备用 app 接口',
318
+ },
319
+ 'tiktok:comments': {
320
+ platform: 'tiktok',
321
+ action: 'comments',
322
+ method: 'GET',
323
+ path: '/tiktok/app/v3/fetch_video_comments',
324
+ targetParam: 'aweme_id',
325
+ cursorParam: 'cursor',
326
+ countParam: 'count',
327
+ description: 'TikTok 作品一级评论备用 app v3 接口',
328
+ },
329
+ 'tiktok:replies': {
330
+ platform: 'tiktok',
331
+ action: 'replies',
332
+ method: 'GET',
333
+ path: '/tiktok/app/v3/fetch_video_comment_replies',
334
+ targetParam: 'item_id',
335
+ commentParam: 'comment_id',
336
+ cursorParam: 'cursor',
337
+ countParam: 'count',
338
+ description: 'TikTok 评论回复备用 app v3 接口',
339
+ },
340
+ 'instagram:comments': {
341
+ platform: 'instagram',
342
+ action: 'comments',
343
+ method: 'GET',
344
+ path: '/instagram/v1/fetch_post_comments_v2',
345
+ targetParam: 'media_id',
346
+ cursorParam: 'min_id',
347
+ description: 'Instagram 帖子一级评论备用 v1 接口',
348
+ },
349
+ 'instagram:replies': {
350
+ platform: 'instagram',
351
+ action: 'replies',
352
+ method: 'GET',
353
+ path: '/instagram/v1/fetch_comment_replies',
354
+ targetParam: 'media_id',
355
+ commentParam: 'comment_id',
356
+ cursorParam: 'min_id',
357
+ description: 'Instagram 评论回复备用 v1 接口',
358
+ },
359
+ 'kuaishou:comments': {
360
+ platform: 'kuaishou',
361
+ action: 'comments',
362
+ method: 'GET',
363
+ path: '/kuaishou/app/fetch_one_video_comment',
364
+ targetParam: 'photo_id',
365
+ cursorParam: 'pcursor',
366
+ description: '快手作品一级评论备用 app 接口',
367
+ },
368
+ 'twitter:comments': {
369
+ platform: 'twitter',
370
+ action: 'comments',
371
+ method: 'GET',
372
+ path: '/twitter/web/fetch_latest_post_comments',
373
+ targetParam: 'tweet_id',
374
+ cursorParam: 'cursor',
375
+ description: 'Twitter/X 最新推文评论备用接口',
376
+ },
377
+ 'weibo:comments': {
378
+ platform: 'weibo',
379
+ action: 'comments',
380
+ method: 'GET',
381
+ path: '/weibo/app/fetch_status_comments',
382
+ targetParam: 'status_id',
383
+ cursorParam: 'max_id',
384
+ description: '微博一级评论备用 app 接口',
385
+ },
386
+ 'youtube:comments': {
387
+ platform: 'youtube',
388
+ action: 'comments',
389
+ method: 'GET',
390
+ path: '/youtube/web_v2/get_video_comments',
391
+ targetParam: 'video_id',
392
+ cursorParam: 'continuation_token',
393
+ description: 'YouTube 视频一级评论备用 web_v2 接口',
394
+ },
395
+ 'youtube:replies': {
396
+ platform: 'youtube',
397
+ action: 'replies',
398
+ method: 'GET',
399
+ path: '/youtube/web_v2/get_video_comment_replies',
400
+ targetParam: 'continuation_token',
401
+ description: 'YouTube 视频二级评论备用 web_v2 接口',
402
+ },
403
+ };
404
+
405
+ const PLATFORM_ALIASES = {
406
+ 抖音: 'douyin',
407
+ douyin: 'douyin',
408
+ 小红书: 'xiaohongshu',
409
+ xiaohongshu: 'xiaohongshu',
410
+ xhs: 'xiaohongshu',
411
+ B站: 'bilibili',
412
+ b站: 'bilibili',
413
+ 哔哩哔哩: 'bilibili',
414
+ bilibili: 'bilibili',
415
+ 微信公众号: 'wechat_mp',
416
+ 公众号: 'wechat_mp',
417
+ wechat_mp: 'wechat_mp',
418
+ 微信视频号: 'wechat_channels',
419
+ 视频号: 'wechat_channels',
420
+ wechat_channels: 'wechat_channels',
421
+ TikTok: 'tiktok',
422
+ tiktok: 'tiktok',
423
+ Instagram: 'instagram',
424
+ instagram: 'instagram',
425
+ ins: 'instagram',
426
+ 快手: 'kuaishou',
427
+ kuaishou: 'kuaishou',
428
+ Reddit: 'reddit',
429
+ reddit: 'reddit',
430
+ Twitter: 'twitter',
431
+ 'Twitter/X': 'twitter',
432
+ 'twitter/x': 'twitter',
433
+ twitter: 'twitter',
434
+ X: 'twitter',
435
+ x: 'twitter',
436
+ 微博: 'weibo',
437
+ weibo: 'weibo',
438
+ YouTube: 'youtube',
439
+ youtube: 'youtube',
440
+ 油管: 'youtube',
441
+ 知乎: 'zhihu',
442
+ zhihu: 'zhihu',
443
+ };
444
+
445
+ export function listCommentCommands() {
446
+ return Object.values(PRIMARY_ENDPOINTS).map((endpoint) => ({
447
+ key: `comments.${endpoint.platform}.${endpoint.action}`,
448
+ command: `comments collect --platform ${endpoint.platform} --action ${endpoint.action}`,
449
+ kind: 'comment-collector',
450
+ description: endpoint.description,
451
+ method: endpoint.method,
452
+ socialPath: endpoint.path,
453
+ positionals: [],
454
+ options: buildCommentCommandOptions(endpoint),
455
+ queryParams: buildCommentCommandQueryParams(endpoint),
456
+ requestBody: endpoint.bodyMode ? buildCommentCommandRequestBody(endpoint) : null,
457
+ returns: '返回评论列表、翻页游标和平台原始响应字段,字段以上游接口实际响应为准。',
458
+ }));
459
+ }
460
+
461
+ function buildCommentCommandOptions(endpoint) {
462
+ const options = [
463
+ {
464
+ flag: '--platform',
465
+ name: 'platform',
466
+ required: true,
467
+ label: '平台标识,例如 douyin、xiaohongshu、bilibili。',
468
+ },
469
+ {
470
+ flag: '--action',
471
+ name: 'action',
472
+ required: false,
473
+ label: '评论类型:comments、replies、post_comments 或 post_replies。',
474
+ },
475
+ {
476
+ flag: '--target',
477
+ name: 'target',
478
+ required: true,
479
+ label: '作品、文章、帖子或回答 ID,也可以传支持的平台链接。',
480
+ },
481
+ {
482
+ flag: '--prefer',
483
+ name: 'prefer',
484
+ required: false,
485
+ label: 'primary 使用首选接口,fallback 使用备用接口。',
486
+ },
487
+ {
488
+ flag: '--format',
489
+ name: 'format',
490
+ required: false,
491
+ label: 'Agent 调用时建议使用 agent-json。',
492
+ },
493
+ {
494
+ flag: '--dry-run',
495
+ name: 'dryRun',
496
+ required: false,
497
+ label: '仅返回请求映射,不发起真实接口请求。',
498
+ },
499
+ ];
500
+ if (endpoint.commentParam) {
501
+ options.push({
502
+ flag: '--comment-id',
503
+ name: 'commentId',
504
+ required: true,
505
+ label: '父评论 ID,采集评论回复时必填。',
506
+ });
507
+ }
508
+ if (endpoint.cursorParam) {
509
+ options.push({
510
+ flag: '--cursor',
511
+ name: 'cursor',
512
+ required: Boolean(endpoint.requiredCursor),
513
+ label: '翻页游标,按上一次响应返回字段继续传。',
514
+ });
515
+ }
516
+ if (endpoint.countParam) {
517
+ options.push({
518
+ flag: '--count',
519
+ name: 'count',
520
+ required: false,
521
+ label: '返回数量,仅对支持 count/num 的接口生效。',
522
+ });
523
+ }
524
+ if (endpoint.pageParam) {
525
+ options.push({
526
+ flag: '--page',
527
+ name: 'page',
528
+ required: false,
529
+ label: '页码,仅对分页页码型接口生效。',
530
+ });
531
+ }
532
+ if (endpoint.requiredExtra?.length) {
533
+ options.push({
534
+ flag: '--extra',
535
+ name: 'extra',
536
+ required: true,
537
+ label: `JSON 字符串补充参数,必须包含:${endpoint.requiredExtra.join(', ')}。`,
538
+ });
539
+ } else {
540
+ options.push({
541
+ flag: '--extra',
542
+ name: 'extra',
543
+ required: false,
544
+ label: 'JSON 字符串补充参数,用于平台特殊字段。',
545
+ });
546
+ }
547
+ return options;
548
+ }
549
+
550
+ function buildCommentCommandQueryParams(endpoint) {
551
+ const params = [
552
+ {
553
+ name: endpoint.targetParam,
554
+ required: true,
555
+ description: '由 --target 映射得到。',
556
+ },
557
+ ];
558
+ if (endpoint.commentParam) {
559
+ params.push({
560
+ name: endpoint.commentParam,
561
+ required: true,
562
+ description: '由 --comment-id 映射得到。',
563
+ });
564
+ }
565
+ if (endpoint.cursorParam) {
566
+ params.push({
567
+ name: endpoint.cursorParam,
568
+ required: Boolean(endpoint.requiredCursor),
569
+ description: '由 --cursor 或 --extra 映射得到。',
570
+ });
571
+ }
572
+ if (endpoint.countParam) {
573
+ params.push({
574
+ name: endpoint.countParam,
575
+ required: false,
576
+ description: '由 --count 映射得到。',
577
+ });
578
+ }
579
+ if (endpoint.pageParam) {
580
+ params.push({
581
+ name: endpoint.pageParam,
582
+ required: false,
583
+ description: '由 --page 映射得到。',
584
+ });
585
+ }
586
+ return params;
587
+ }
588
+
589
+ function buildCommentCommandRequestBody(endpoint) {
590
+ const body = {};
591
+ for (const item of buildCommentCommandQueryParams(endpoint)) {
592
+ body[item.name] = item.required ? `<${item.name}>` : '';
593
+ }
594
+ return body;
595
+ }
596
+
597
+ export function normalizePlatform(value) {
598
+ const raw = String(value || '').trim();
599
+ return PLATFORM_ALIASES[raw] || raw.toLowerCase();
600
+ }
601
+
602
+ export function normalizeAction(value, platform = '') {
603
+ const raw = String(value || 'comments').trim().toLowerCase();
604
+ if (
605
+ [
606
+ 'post_comments',
607
+ 'post-comments',
608
+ 'youtube_post_comments',
609
+ 'youtube-post-comments',
610
+ '社区帖子评论',
611
+ ].includes(raw)
612
+ ) {
613
+ return 'post_comments';
614
+ }
615
+ if (
616
+ [
617
+ 'post_replies',
618
+ 'post-replies',
619
+ 'youtube_post_replies',
620
+ 'youtube-post-replies',
621
+ '帖子评论回复',
622
+ '帖子回复',
623
+ ].includes(raw)
624
+ ) {
625
+ return 'post_replies';
626
+ }
627
+ if (raw === '帖子评论') {
628
+ return platform === 'youtube' ? 'post_comments' : 'comments';
629
+ }
630
+ if (
631
+ [
632
+ 'reply',
633
+ 'replies',
634
+ 'sub_comments',
635
+ 'sub-comments',
636
+ '二级评论',
637
+ '子评论',
638
+ '评论回复',
639
+ '回复',
640
+ ].includes(raw)
641
+ ) {
642
+ return 'replies';
643
+ }
644
+ return 'comments';
645
+ }
646
+
647
+ export function extractTargetId(platform, target) {
648
+ const raw = String(target || '').trim();
649
+ if (!raw) {
650
+ return '';
651
+ }
652
+ if (platform === 'wechat_mp') {
653
+ return raw;
654
+ }
655
+ if (platform === 'instagram' && raw.startsWith('http')) {
656
+ return raw;
657
+ }
658
+ if (platform === 'bilibili') {
659
+ const match = raw.match(/BV[0-9A-Za-z]+/);
660
+ if (match) {
661
+ return match[0];
662
+ }
663
+ }
664
+ let parsed;
665
+ try {
666
+ parsed = new URL(raw);
667
+ } catch {
668
+ return raw;
669
+ }
670
+ for (const key of [
671
+ 'v',
672
+ 'aweme_id',
673
+ 'note_id',
674
+ 'bv_id',
675
+ 'bvid',
676
+ 'id',
677
+ 'video_id',
678
+ 'item_id',
679
+ 'tweet_id',
680
+ 'post_id',
681
+ 'photo_id',
682
+ 'media_id',
683
+ 'code',
684
+ 'answer_id',
685
+ ]) {
686
+ const value = parsed.searchParams.get(key);
687
+ if (value) {
688
+ return value.trim();
689
+ }
690
+ }
691
+ const pathParts = parsed.pathname.split('/').filter(Boolean);
692
+ if (platform === 'reddit' && pathParts.includes('comments')) {
693
+ const index = pathParts.indexOf('comments');
694
+ if (pathParts[index + 1]) {
695
+ const postId = pathParts[index + 1];
696
+ return postId.startsWith('t3_') ? postId : `t3_${postId}`;
697
+ }
698
+ }
699
+ for (const part of pathParts.reverse()) {
700
+ if (part && !['video', 'note', 'discovery', 'explore'].includes(part)) {
701
+ return part;
702
+ }
703
+ }
704
+ return raw;
705
+ }
706
+
707
+ export function resolveCommentEndpoint(platform, action, prefer = 'primary') {
708
+ const key = `${platform}:${action}`;
709
+ if (prefer === 'fallback' && FALLBACK_ENDPOINTS[key]) {
710
+ return { endpoint: FALLBACK_ENDPOINTS[key], usedFallback: true };
711
+ }
712
+ return { endpoint: PRIMARY_ENDPOINTS[key] || null, usedFallback: false };
713
+ }
714
+
715
+ export function buildCommentRequest(endpoint, { target, options }) {
716
+ const payload = { [endpoint.targetParam]: target };
717
+ const commentId = cleanOptional(options.named?.['comment-id']);
718
+ if (endpoint.commentParam && commentId !== undefined) {
719
+ payload[endpoint.commentParam] = commentId;
720
+ }
721
+ let cursor = cleanOptional(options.named?.cursor);
722
+ const extra = parseExtra(options.named?.extra);
723
+ if (cursor === undefined && endpoint.cursorParam) {
724
+ cursor = cleanOptional(extra[endpoint.cursorParam]);
725
+ }
726
+ if (endpoint.cursorParam && cursor !== undefined) {
727
+ payload[endpoint.cursorParam] = cursor;
728
+ }
729
+ const count = cleanOptional(options.named?.count);
730
+ if (endpoint.countParam && count !== undefined) {
731
+ payload[endpoint.countParam] = Number(count);
732
+ }
733
+ const page = cleanOptional(options.named?.page);
734
+ if (endpoint.pageParam && page !== undefined) {
735
+ payload[endpoint.pageParam] = Number(page);
736
+ }
737
+ for (const key of endpoint.requiredExtra || []) {
738
+ const value = cleanOptional(extra[key]);
739
+ if (value !== undefined) {
740
+ payload[key] = value;
741
+ }
742
+ }
743
+ for (const [key, value] of Object.entries(extra)) {
744
+ const cleaned = cleanOptional(value);
745
+ if (!(key in payload) && cleaned !== undefined) {
746
+ payload[key] = cleaned;
747
+ }
748
+ }
749
+ return payload;
750
+ }
751
+
752
+ export async function collectComments({ platform, action, target, prefer, options }) {
753
+ const normalizedPlatform = normalizePlatform(platform);
754
+ const normalizedAction = normalizeAction(action, normalizedPlatform);
755
+ const { endpoint, usedFallback } = resolveCommentEndpoint(normalizedPlatform, normalizedAction, prefer);
756
+ if (!endpoint) {
757
+ throw new Error(`当前平台或评论类型暂未接入:${normalizedPlatform} ${normalizedAction}`);
758
+ }
759
+ const targetId = extractTargetId(normalizedPlatform, target);
760
+ if (!targetId) {
761
+ throw new Error('请提供作品、文章或视频号目标 ID/链接。');
762
+ }
763
+ if (endpoint.commentParam && cleanOptional(options.named?.['comment-id']) === undefined) {
764
+ throw new Error('采集二级评论时必须提供 --comment-id。');
765
+ }
766
+ if (endpoint.requiredCursor && cleanOptional(options.named?.cursor) === undefined) {
767
+ throw new Error('该平台采集二级评论时必须提供 --cursor。');
768
+ }
769
+ const extra = parseExtra(options.named?.extra);
770
+ const missingExtra = (endpoint.requiredExtra || []).filter((key) => cleanOptional(extra[key]) === undefined);
771
+ if (missingExtra.length > 0) {
772
+ throw new Error(`缺少必要补充参数:${missingExtra.join(', ')}。`);
773
+ }
774
+ const body = buildCommentRequest(endpoint, { target: targetId, options });
775
+ const response = await callEndpoint(endpoint.path, {
776
+ ...options,
777
+ method: endpoint.method,
778
+ body,
779
+ });
780
+ return {
781
+ ok: true,
782
+ platform: normalizedPlatform,
783
+ action: normalizedAction,
784
+ target: targetId,
785
+ endpoint: {
786
+ method: endpoint.method,
787
+ path: endpoint.path,
788
+ description: endpoint.description,
789
+ },
790
+ usedFallback,
791
+ request: {
792
+ params: endpoint.bodyMode ? undefined : body,
793
+ body: endpoint.bodyMode ? body : undefined,
794
+ },
795
+ response,
796
+ };
797
+ }
798
+
799
+ function cleanOptional(value) {
800
+ if (value === undefined || value === null) {
801
+ return undefined;
802
+ }
803
+ if (typeof value === 'string') {
804
+ const trimmed = value.trim();
805
+ return trimmed ? trimmed : undefined;
806
+ }
807
+ return value;
808
+ }
809
+
810
+ function parseExtra(value) {
811
+ if (!value) {
812
+ return {};
813
+ }
814
+ if (typeof value === 'object') {
815
+ return value;
816
+ }
817
+ try {
818
+ const parsed = JSON.parse(String(value));
819
+ return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : {};
820
+ } catch {
821
+ return {};
822
+ }
823
+ }
package/src/request.js CHANGED
@@ -11,10 +11,6 @@ export async function callEndpoint(socialPath, options = {}) {
11
11
  const method = options.method || endpoint?.method || 'POST';
12
12
  const url = new URL(`/social${normalizedPath}`, baseUrl);
13
13
 
14
- if (!token) {
15
- throw new Error('缺少 token。请设置 YUANCHUANG_API_TOKEN,或执行 yuanflow-cli config set-token <你的令牌>');
16
- }
17
-
18
14
  const body = await resolveBody(options);
19
15
  if (method.toUpperCase() === 'GET' && body && typeof body === 'object') {
20
16
  for (const [key, value] of Object.entries(body)) {
@@ -30,12 +26,16 @@ export async function callEndpoint(socialPath, options = {}) {
30
26
  method: method.toUpperCase(),
31
27
  url: url.toString(),
32
28
  headers: {
33
- Authorization: `Bearer ${maskToken(token)}`,
29
+ ...(token ? { Authorization: `Bearer ${maskToken(token)}` } : {}),
34
30
  },
35
31
  body: method.toUpperCase() === 'GET' ? undefined : body || {},
36
32
  };
37
33
  }
38
34
 
35
+ if (!token) {
36
+ throw new Error('缺少 token。请设置 YUANCHUANG_API_TOKEN,或执行 yuanflow-cli config set-token <你的令牌>');
37
+ }
38
+
39
39
  const response = await fetch(url, {
40
40
  method: method.toUpperCase(),
41
41
  headers: {