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 +23 -0
- package/lib/skill-installer/repo-source.cjs +8 -9
- package/package.json +1 -1
- package/skills/yuanflow-skill/SKILL.md +14 -1
- package/skills/yuanflow-skill/yuanflow-cli/SKILL.md +41 -3
- package/skills/yuanflow-skill//344/275/234/345/223/201/350/257/204/350/256/272/351/207/207/351/233/206/SKILL.md +169 -0
- package/src/agent-protocol.js +3 -1
- package/src/cli.js +34 -0
- package/src/comment-collector.js +823 -0
- package/src/request.js +5 -5
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
|
@@ -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 或敏感账号信息。
|
package/src/agent-protocol.js
CHANGED
|
@@ -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
|
-
|
|
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: {
|