yuanflow-cli 0.1.3 → 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.
@@ -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
+ }