dsh-bili-scroll 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +55 -50
- package/README.md +147 -137
- package/client.js +555 -522
- package/cordis.patch.yml +20 -20
- package/index.js +304 -54
- package/lib/login.mjs +103 -103
- package/lib/mcp.mjs +119 -115
- package/lib/state.mjs +151 -100
- package/lib/verify.mjs +179 -186
- package/package.json +72 -75
- package/prompts/duty.md +139 -95
- package/selfcheck.mjs +119 -0
- package/vendor/bilibili-mcp/mcp_server.py +1415 -1364
- package/vendor/bilibili-mcp/requirements.txt +8 -8
|
@@ -1,1365 +1,1416 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""
|
|
3
|
-
Bilibili MCP Server
|
|
4
|
-
B站数据采集 MCP 服务,支持 OpenClaw / Claude Code / Cursor / Cline
|
|
5
|
-
|
|
6
|
-
功能:搜索视频、抓取评论、获取字幕、弹幕、回复评论
|
|
7
|
-
首次运行需扫码登录B站,凭证自动保存复用
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
from
|
|
24
|
-
from
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
"
|
|
183
|
-
"
|
|
184
|
-
}, ensure_ascii=False)
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
).
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
"
|
|
218
|
-
"message": "
|
|
219
|
-
}, ensure_ascii=False)
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
# ========== Tool
|
|
228
|
-
|
|
229
|
-
@
|
|
230
|
-
async def
|
|
231
|
-
"""
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
)
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
Returns:
|
|
482
|
-
|
|
483
|
-
"""
|
|
484
|
-
cred = get_cred()
|
|
485
|
-
v = video.Video(bvid=bvid, credential=cred)
|
|
486
|
-
info = await v.get_info()
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
if
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
"
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
|
|
805
|
-
|
|
806
|
-
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
|
|
881
|
-
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
|
|
887
|
-
|
|
888
|
-
|
|
889
|
-
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
|
|
907
|
-
|
|
908
|
-
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
954
|
-
|
|
955
|
-
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
"
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
"
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1014
|
-
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
|
|
1026
|
-
# ========== Tool
|
|
1027
|
-
|
|
1028
|
-
@
|
|
1029
|
-
async def
|
|
1030
|
-
"""
|
|
1031
|
-
获取B
|
|
1032
|
-
|
|
1033
|
-
Args:
|
|
1034
|
-
|
|
1035
|
-
|
|
1036
|
-
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
|
|
1046
|
-
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1051
|
-
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
|
|
1062
|
-
|
|
1063
|
-
|
|
1064
|
-
|
|
1065
|
-
|
|
1066
|
-
|
|
1067
|
-
|
|
1068
|
-
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1079
|
-
|
|
1080
|
-
|
|
1081
|
-
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
1098
|
-
|
|
1099
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
|
|
1162
|
-
|
|
1163
|
-
|
|
1164
|
-
|
|
1165
|
-
|
|
1166
|
-
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
"
|
|
1186
|
-
"
|
|
1187
|
-
"
|
|
1188
|
-
"
|
|
1189
|
-
"
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1262
|
-
|
|
1263
|
-
"
|
|
1264
|
-
|
|
1265
|
-
|
|
1266
|
-
|
|
1267
|
-
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
"""
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
1302
|
-
|
|
1303
|
-
|
|
1304
|
-
|
|
1305
|
-
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
|
|
1326
|
-
|
|
1327
|
-
|
|
1328
|
-
|
|
1329
|
-
|
|
1330
|
-
|
|
1331
|
-
|
|
1332
|
-
|
|
1333
|
-
|
|
1334
|
-
|
|
1335
|
-
|
|
1336
|
-
return json.dumps({"error":
|
|
1337
|
-
|
|
1338
|
-
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
|
|
1347
|
-
|
|
1348
|
-
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1358
|
-
|
|
1359
|
-
|
|
1360
|
-
|
|
1361
|
-
|
|
1362
|
-
|
|
1363
|
-
|
|
1364
|
-
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Bilibili MCP Server
|
|
4
|
+
B站数据采集 MCP 服务,支持 OpenClaw / Claude Code / Cursor / Cline
|
|
5
|
+
|
|
6
|
+
功能:搜索视频、抓取评论、获取字幕、弹幕、回复评论
|
|
7
|
+
首次运行需扫码登录B站,凭证自动保存复用
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
# ⭐ 这个文件是从上游 AGI_Ananas/bilibili-mcp(MIT,见同目录 LICENSE)vendor 进来的。
|
|
11
|
+
# 本插件在它上面只加了下面这几处 —— **将来重新 vendor 时,这几处要原样补回来**(漏一处就少一道闸 ✗):
|
|
12
|
+
# ① `_ALLOWED` + `tool()` 白名单替身:工具按名单注册,名单外的连名字都不出现(见下)
|
|
13
|
+
# ② `load_credential` 读取容错 + encoding="utf-8" + isinstance(data, dict) 校验
|
|
14
|
+
# ③ 登录成功后凭证**原子写**(先写 .tmp 再 os.replace)
|
|
15
|
+
# ④ `bili_comments` 把抓取异常带进返回(风控 -412 不再伪装成"这条视频 0 条评论")
|
|
16
|
+
# ⑤ `bili_reply` 回执带上 `rpid` / `rpid_str` / `root` / `parent`(判重表与评论复核要用)
|
|
17
|
+
import asyncio
|
|
18
|
+
import json
|
|
19
|
+
import base64
|
|
20
|
+
import os
|
|
21
|
+
import sys
|
|
22
|
+
import tempfile
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from mcp.server.fastmcp import FastMCP
|
|
25
|
+
|
|
26
|
+
from bilibili_api import video, search, comment, Credential, dynamic, opus
|
|
27
|
+
from bilibili_api import hot, rank, user, session, favorite_list
|
|
28
|
+
from bilibili_api.video_uploader import (
|
|
29
|
+
VideoUploader, VideoUploaderPage, VideoMeta, Lines,
|
|
30
|
+
)
|
|
31
|
+
from bilibili_api.utils.picture import Picture
|
|
32
|
+
from bilibili_api.login_v2 import QrCodeLogin, QrCodeLoginEvents
|
|
33
|
+
|
|
34
|
+
# ========== 初始化 ==========
|
|
35
|
+
|
|
36
|
+
CRED_FILE = Path(__file__).parent / "bili_credential.json"
|
|
37
|
+
|
|
38
|
+
mcp = FastMCP("bilibili-mcp")
|
|
39
|
+
|
|
40
|
+
# ⭐ 工具白名单(2026-10-17 审查里的 H3):宿主通过 overlay 的 env 下发 BILI_MCP_TOOLS(逗号分隔)。
|
|
41
|
+
# · 不给这个变量 ⇒ **全放**(手工跑、或老配置,行为一点不变 ✓)
|
|
42
|
+
# · 给了 ⇒ 只注册名单里的;不在名单里的工具**连名字都不会出现在 list_tools 里**,
|
|
43
|
+
# 模型那边等于根本不存在 —— 这才是真的闸门(光靠提示词说"别用"不算)✓
|
|
44
|
+
_ALLOWED = {t.strip() for t in os.environ.get("BILI_MCP_TOOLS", "").split(",") if t.strip()}
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def tool():
|
|
48
|
+
"""@tool() 的替身:白名单里没有这个函数名,就原样返回、**不注册**。"""
|
|
49
|
+
def deco(fn):
|
|
50
|
+
if _ALLOWED and fn.__name__ not in _ALLOWED:
|
|
51
|
+
return fn
|
|
52
|
+
return mcp.tool()(fn)
|
|
53
|
+
return deco
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_credential() -> Credential:
|
|
57
|
+
"""从文件加载凭证"""
|
|
58
|
+
if not CRED_FILE.exists():
|
|
59
|
+
return None
|
|
60
|
+
try:
|
|
61
|
+
with open(CRED_FILE, encoding="utf-8") as f:
|
|
62
|
+
data = json.load(f)
|
|
63
|
+
except (OSError, ValueError) as e:
|
|
64
|
+
# ⚠️ 文件写坏一次(上一次写被打断)会让**所有**工具一起 JSONDecodeError:
|
|
65
|
+
# 这里退化成「没登录」,重新扫一次码就能恢复。(2026-10-16 审查修)
|
|
66
|
+
print(f"[bili-mcp] 凭证读不了({type(e).__name__}: {e}):{CRED_FILE}", file=sys.stderr)
|
|
67
|
+
return None
|
|
68
|
+
if not isinstance(data, dict):
|
|
69
|
+
return None
|
|
70
|
+
return Credential(
|
|
71
|
+
sessdata=data.get("sessdata", ""),
|
|
72
|
+
bili_jct=data.get("bili_jct", ""),
|
|
73
|
+
buvid3=data.get("buvid3") or "",
|
|
74
|
+
dedeuserid=data.get("dedeuserid", ""),
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ========== 登录会话(进程级缓存) ==========
|
|
79
|
+
|
|
80
|
+
_login_session: QrCodeLogin | None = None
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def get_cred() -> Credential:
|
|
84
|
+
"""获取凭证,未登录时引导用户调用 bili_login"""
|
|
85
|
+
cred = load_credential()
|
|
86
|
+
if not cred:
|
|
87
|
+
raise Exception("未登录B站!请先调用 bili_login 工具获取登录二维码,让用户扫码登录。")
|
|
88
|
+
return cred
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
# ========== Tool: 扫码登录(生成二维码) ==========
|
|
92
|
+
|
|
93
|
+
@tool()
|
|
94
|
+
async def bili_login() -> str:
|
|
95
|
+
"""
|
|
96
|
+
生成B站登录二维码,返回二维码图片(base64)供用户扫码。
|
|
97
|
+
用户扫码后,调用 bili_login_check 检查登录状态。
|
|
98
|
+
|
|
99
|
+
Returns:
|
|
100
|
+
包含二维码base64图片数据的JSON,前端可直接渲染为图片展示给用户
|
|
101
|
+
"""
|
|
102
|
+
global _login_session
|
|
103
|
+
|
|
104
|
+
# 如果已有凭证,直接返回
|
|
105
|
+
cred = load_credential()
|
|
106
|
+
if cred:
|
|
107
|
+
return json.dumps({
|
|
108
|
+
"status": "already_logged_in",
|
|
109
|
+
"message": "已登录B站,无需重复登录",
|
|
110
|
+
}, ensure_ascii=False)
|
|
111
|
+
|
|
112
|
+
# 生成新的登录会话
|
|
113
|
+
_login_session = QrCodeLogin()
|
|
114
|
+
await _login_session.generate_qrcode()
|
|
115
|
+
|
|
116
|
+
# 获取二维码图片的 base64(无损PNG)
|
|
117
|
+
pic = _login_session.get_qrcode_picture()
|
|
118
|
+
img_base64 = base64.b64encode(pic.content).decode("utf-8")
|
|
119
|
+
|
|
120
|
+
# 保存二维码图片到项目根目录
|
|
121
|
+
qr_file = Path(__file__).parent / "qrcode_login.png"
|
|
122
|
+
with open(qr_file, "wb") as f:
|
|
123
|
+
f.write(pic.content)
|
|
124
|
+
|
|
125
|
+
# 获取终端文本版二维码(纯文本客户端备用)
|
|
126
|
+
terminal_qr = _login_session.get_qrcode_terminal()
|
|
127
|
+
|
|
128
|
+
# 获取原始扫码URL(用户也可以手动在B站App打开)
|
|
129
|
+
qr_url = getattr(_login_session, "_QrCodeLogin__qr_link", "")
|
|
130
|
+
|
|
131
|
+
return json.dumps({
|
|
132
|
+
"status": "qrcode_ready",
|
|
133
|
+
"message": "请用B站App扫描二维码登录(180秒内有效)",
|
|
134
|
+
"qrcode_image": f"data:image/png;base64,{img_base64}",
|
|
135
|
+
"qrcode_file": str(qr_file),
|
|
136
|
+
"qrcode_terminal": terminal_qr,
|
|
137
|
+
"qrcode_url": qr_url,
|
|
138
|
+
"next_step": "用户扫码后,请调用 bili_login_check 检查登录状态",
|
|
139
|
+
}, ensure_ascii=False)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ========== Tool: 检查登录状态 ==========
|
|
143
|
+
|
|
144
|
+
@tool()
|
|
145
|
+
async def bili_login_check() -> str:
|
|
146
|
+
"""
|
|
147
|
+
检查B站扫码登录状态。在用户扫码后调用此工具。
|
|
148
|
+
如果返回 "scanning" 或 "confirming",请等待几秒后再次调用。
|
|
149
|
+
如果返回 "done",登录成功,可以使用其他工具了。
|
|
150
|
+
如果返回 "timeout",需要重新调用 bili_login 生成新二维码。
|
|
151
|
+
|
|
152
|
+
Returns:
|
|
153
|
+
当前登录状态
|
|
154
|
+
"""
|
|
155
|
+
global _login_session
|
|
156
|
+
|
|
157
|
+
if not _login_session:
|
|
158
|
+
# 没有登录会话,检查是否已登录
|
|
159
|
+
cred = load_credential()
|
|
160
|
+
if cred:
|
|
161
|
+
return json.dumps({
|
|
162
|
+
"status": "already_logged_in",
|
|
163
|
+
"message": "已登录B站",
|
|
164
|
+
}, ensure_ascii=False)
|
|
165
|
+
return json.dumps({
|
|
166
|
+
"status": "no_session",
|
|
167
|
+
"message": "没有进行中的登录,请先调用 bili_login 生成二维码",
|
|
168
|
+
}, ensure_ascii=False)
|
|
169
|
+
|
|
170
|
+
state = await _login_session.check_state()
|
|
171
|
+
|
|
172
|
+
if state == QrCodeLoginEvents.SCAN:
|
|
173
|
+
return json.dumps({
|
|
174
|
+
"status": "scanning",
|
|
175
|
+
"message": "已扫码,等待用户在手机上确认...",
|
|
176
|
+
"next_step": "请等待3秒后再次调用 bili_login_check",
|
|
177
|
+
}, ensure_ascii=False)
|
|
178
|
+
|
|
179
|
+
elif state == QrCodeLoginEvents.CONF:
|
|
180
|
+
return json.dumps({
|
|
181
|
+
"status": "confirming",
|
|
182
|
+
"message": "用户已确认,正在处理...",
|
|
183
|
+
"next_step": "请等待2秒后再次调用 bili_login_check",
|
|
184
|
+
}, ensure_ascii=False)
|
|
185
|
+
|
|
186
|
+
elif state == QrCodeLoginEvents.TIMEOUT:
|
|
187
|
+
_login_session = None
|
|
188
|
+
# 清理二维码文件
|
|
189
|
+
qr_file = Path(__file__).parent / "qrcode_login.png"
|
|
190
|
+
if qr_file.exists():
|
|
191
|
+
qr_file.unlink()
|
|
192
|
+
return json.dumps({
|
|
193
|
+
"status": "timeout",
|
|
194
|
+
"message": "二维码已过期,请重新调用 bili_login 生成新二维码",
|
|
195
|
+
}, ensure_ascii=False)
|
|
196
|
+
|
|
197
|
+
elif state == QrCodeLoginEvents.DONE:
|
|
198
|
+
# 登录成功,保存凭证
|
|
199
|
+
cred = _login_session.get_credential()
|
|
200
|
+
# ⚠️ 原子写:原来直接 open(w) 先截断再 dump,写到一半被打断就毁掉旧凭证,
|
|
201
|
+
# 此后所有工具都读不了。(2026-10-16 审查修)
|
|
202
|
+
tmp_cred = CRED_FILE.with_name(CRED_FILE.name + ".tmp")
|
|
203
|
+
with open(tmp_cred, "w", encoding="utf-8") as f:
|
|
204
|
+
json.dump({
|
|
205
|
+
"sessdata": cred.sessdata,
|
|
206
|
+
"bili_jct": cred.bili_jct,
|
|
207
|
+
"buvid3": cred.buvid3,
|
|
208
|
+
"dedeuserid": cred.dedeuserid,
|
|
209
|
+
}, f)
|
|
210
|
+
os.replace(tmp_cred, CRED_FILE)
|
|
211
|
+
_login_session = None
|
|
212
|
+
# 清理二维码文件
|
|
213
|
+
qr_file = Path(__file__).parent / "qrcode_login.png"
|
|
214
|
+
if qr_file.exists():
|
|
215
|
+
qr_file.unlink()
|
|
216
|
+
return json.dumps({
|
|
217
|
+
"status": "done",
|
|
218
|
+
"message": "登录成功!凭证已保存,现在可以使用所有B站功能了",
|
|
219
|
+
}, ensure_ascii=False)
|
|
220
|
+
|
|
221
|
+
return json.dumps({
|
|
222
|
+
"status": "unknown",
|
|
223
|
+
"message": f"未知状态: {state}",
|
|
224
|
+
}, ensure_ascii=False)
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# ========== Tool: 登录状态查询 ==========
|
|
228
|
+
|
|
229
|
+
@tool()
|
|
230
|
+
async def bili_check_credential() -> str:
|
|
231
|
+
"""
|
|
232
|
+
检查当前B站登录凭证是否有效
|
|
233
|
+
|
|
234
|
+
Returns:
|
|
235
|
+
登录状态信息
|
|
236
|
+
"""
|
|
237
|
+
cred = load_credential()
|
|
238
|
+
if not cred:
|
|
239
|
+
return json.dumps({
|
|
240
|
+
"logged_in": False,
|
|
241
|
+
"message": "未登录,请调用 bili_login 进行扫码登录",
|
|
242
|
+
}, ensure_ascii=False)
|
|
243
|
+
|
|
244
|
+
# 尝试用凭证请求一下,验证是否过期
|
|
245
|
+
try:
|
|
246
|
+
my_info = await user.User(
|
|
247
|
+
uid=int(cred.dedeuserid), credential=cred
|
|
248
|
+
).get_user_info()
|
|
249
|
+
return json.dumps({
|
|
250
|
+
"logged_in": True,
|
|
251
|
+
"uid": cred.dedeuserid,
|
|
252
|
+
"username": my_info.get("name", ""),
|
|
253
|
+
"message": "凭证有效",
|
|
254
|
+
}, ensure_ascii=False)
|
|
255
|
+
except Exception as e:
|
|
256
|
+
return json.dumps({
|
|
257
|
+
"logged_in": False,
|
|
258
|
+
"message": f"凭证可能已过期: {str(e)},请重新调用 bili_login 登录",
|
|
259
|
+
}, ensure_ascii=False)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
# ========== Tool 1: 搜索视频 ==========
|
|
263
|
+
|
|
264
|
+
@tool()
|
|
265
|
+
async def bili_search(keyword: str, num: int = 10, order: str = "totalrank") -> str:
|
|
266
|
+
"""
|
|
267
|
+
搜索B站视频
|
|
268
|
+
|
|
269
|
+
Args:
|
|
270
|
+
keyword: 搜索关键词,如"AI Agent"、"大模型教程"
|
|
271
|
+
num: 返回视频数量,默认10,最大50
|
|
272
|
+
order: 排序方式 totalrank=综合 click=播放量 pubdate=最新 dm=弹幕
|
|
273
|
+
|
|
274
|
+
Returns:
|
|
275
|
+
JSON格式的视频列表,包含标题、BV号、播放量、评论数、UP主等
|
|
276
|
+
"""
|
|
277
|
+
order_map = {
|
|
278
|
+
"totalrank": search.OrderVideo.TOTALRANK,
|
|
279
|
+
"click": search.OrderVideo.CLICK,
|
|
280
|
+
"pubdate": search.OrderVideo.PUBDATE,
|
|
281
|
+
"dm": search.OrderVideo.DM,
|
|
282
|
+
}
|
|
283
|
+
order_enum = order_map.get(order, search.OrderVideo.TOTALRANK)
|
|
284
|
+
|
|
285
|
+
result = await search.search_by_type(
|
|
286
|
+
keyword=keyword,
|
|
287
|
+
search_type=search.SearchObjectType.VIDEO,
|
|
288
|
+
page=1,
|
|
289
|
+
order_type=order_enum,
|
|
290
|
+
)
|
|
291
|
+
|
|
292
|
+
videos = []
|
|
293
|
+
for item in result.get("result", [])[:num]:
|
|
294
|
+
title = item.get("title", "").replace('<em class="keyword">', "").replace("</em>", "")
|
|
295
|
+
videos.append({
|
|
296
|
+
"bvid": item.get("bvid", ""),
|
|
297
|
+
"aid": item.get("aid", 0),
|
|
298
|
+
"title": title,
|
|
299
|
+
"author": item.get("author", ""),
|
|
300
|
+
"play": item.get("play", 0),
|
|
301
|
+
"review": item.get("review", 0),
|
|
302
|
+
"danmaku": item.get("video_review", 0),
|
|
303
|
+
"duration": item.get("duration", ""),
|
|
304
|
+
"description": item.get("description", "")[:200],
|
|
305
|
+
})
|
|
306
|
+
|
|
307
|
+
return json.dumps({"keyword": keyword, "count": len(videos), "videos": videos}, ensure_ascii=False)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
# ========== Tool 2: 获取评论 ==========
|
|
311
|
+
|
|
312
|
+
@tool()
|
|
313
|
+
async def bili_comments(bvid: str, num: int = 30) -> str:
|
|
314
|
+
"""
|
|
315
|
+
获取B站视频的热门评论
|
|
316
|
+
|
|
317
|
+
Args:
|
|
318
|
+
bvid: 视频BV号,如"BV1uNk1YxEJQ"
|
|
319
|
+
num: 获取评论数量,默认30
|
|
320
|
+
|
|
321
|
+
Returns:
|
|
322
|
+
JSON格式的评论列表,包含用户名、评论内容、点赞数、回复数
|
|
323
|
+
"""
|
|
324
|
+
cred = get_cred()
|
|
325
|
+
v = video.Video(bvid=bvid, credential=cred)
|
|
326
|
+
info = await v.get_info()
|
|
327
|
+
aid = info["aid"]
|
|
328
|
+
|
|
329
|
+
comments = []
|
|
330
|
+
page = 1
|
|
331
|
+
fetch_error = None
|
|
332
|
+
while len(comments) < num:
|
|
333
|
+
try:
|
|
334
|
+
resp = await comment.get_comments(
|
|
335
|
+
oid=aid,
|
|
336
|
+
type_=comment.CommentResourceType.VIDEO,
|
|
337
|
+
page_index=page,
|
|
338
|
+
order=comment.OrderType.LIKE,
|
|
339
|
+
credential=cred,
|
|
340
|
+
)
|
|
341
|
+
replies = resp.get("replies") or []
|
|
342
|
+
if not replies:
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
for r in replies:
|
|
346
|
+
member = r.get("member", {})
|
|
347
|
+
content = r.get("content", {})
|
|
348
|
+
c = {
|
|
349
|
+
"rpid": r.get("rpid", 0),
|
|
350
|
+
"user": member.get("uname", ""),
|
|
351
|
+
"content": content.get("message", ""),
|
|
352
|
+
"like": r.get("like", 0),
|
|
353
|
+
"reply_count": r.get("rcount", 0),
|
|
354
|
+
"time": r.get("ctime", 0),
|
|
355
|
+
}
|
|
356
|
+
# 子评论
|
|
357
|
+
sub_replies = []
|
|
358
|
+
for sub in (r.get("replies") or [])[:2]:
|
|
359
|
+
sub_replies.append({
|
|
360
|
+
"user": sub.get("member", {}).get("uname", ""),
|
|
361
|
+
"content": sub.get("content", {}).get("message", ""),
|
|
362
|
+
"like": sub.get("like", 0),
|
|
363
|
+
})
|
|
364
|
+
if sub_replies:
|
|
365
|
+
c["top_replies"] = sub_replies
|
|
366
|
+
comments.append(c)
|
|
367
|
+
|
|
368
|
+
page += 1
|
|
369
|
+
await asyncio.sleep(0.5)
|
|
370
|
+
except Exception as e:
|
|
371
|
+
# ⚠️ 抓取失败(风控 -412 / 网络错)不能吞成「这条视频 0 条评论」:
|
|
372
|
+
# 调用方必须能分辨「真没评论」和「没抓到」,否则日报静默失真。(2026-10-16 审查修)
|
|
373
|
+
fetch_error = f"{type(e).__name__}: {e}"
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
out = {"bvid": bvid, "count": len(comments[:num]), "comments": comments[:num]}
|
|
377
|
+
if fetch_error:
|
|
378
|
+
out["error"] = fetch_error
|
|
379
|
+
return json.dumps(out, ensure_ascii=False)
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
# ========== Tool 3: 获取字幕 ==========
|
|
383
|
+
|
|
384
|
+
@tool()
|
|
385
|
+
async def bili_subtitle(bvid: str) -> str:
|
|
386
|
+
"""
|
|
387
|
+
获取B站视频的AI字幕(语音转文字)
|
|
388
|
+
|
|
389
|
+
Args:
|
|
390
|
+
bvid: 视频BV号,如"BV1uNk1YxEJQ"
|
|
391
|
+
|
|
392
|
+
Returns:
|
|
393
|
+
视频的完整字幕文本
|
|
394
|
+
"""
|
|
395
|
+
cred = get_cred()
|
|
396
|
+
v = video.Video(bvid=bvid, credential=cred)
|
|
397
|
+
|
|
398
|
+
info = await v.get_info()
|
|
399
|
+
cid = info.get("cid", 0)
|
|
400
|
+
if not cid and info.get("pages"):
|
|
401
|
+
cid = info["pages"][0].get("cid", 0)
|
|
402
|
+
|
|
403
|
+
if not cid:
|
|
404
|
+
return json.dumps({"error": "无法获取cid"}, ensure_ascii=False)
|
|
405
|
+
|
|
406
|
+
subtitle_list = await v.get_subtitle(cid=cid)
|
|
407
|
+
subtitles = subtitle_list.get("subtitles", [])
|
|
408
|
+
|
|
409
|
+
if not subtitles:
|
|
410
|
+
return json.dumps({"message": "该视频没有字幕"}, ensure_ascii=False)
|
|
411
|
+
|
|
412
|
+
# 优先AI中文字幕
|
|
413
|
+
target = None
|
|
414
|
+
for s in subtitles:
|
|
415
|
+
if s.get("lan") in ["ai-zh", "zh-CN", "zh"]:
|
|
416
|
+
target = s
|
|
417
|
+
break
|
|
418
|
+
if not target:
|
|
419
|
+
target = subtitles[0]
|
|
420
|
+
|
|
421
|
+
# 下载字幕
|
|
422
|
+
import aiohttp
|
|
423
|
+
sub_url = target.get("subtitle_url", "")
|
|
424
|
+
if sub_url.startswith("//"):
|
|
425
|
+
sub_url = "https:" + sub_url
|
|
426
|
+
|
|
427
|
+
async with aiohttp.ClientSession() as session:
|
|
428
|
+
async with session.get(sub_url) as resp:
|
|
429
|
+
sub_data = await resp.json()
|
|
430
|
+
|
|
431
|
+
texts = [item.get("content", "") for item in sub_data.get("body", [])]
|
|
432
|
+
full_text = "\n".join(texts)
|
|
433
|
+
|
|
434
|
+
return json.dumps({
|
|
435
|
+
"bvid": bvid,
|
|
436
|
+
"title": info.get("title", ""),
|
|
437
|
+
"language": target.get("lan_doc", ""),
|
|
438
|
+
"segments": len(texts),
|
|
439
|
+
"text": full_text,
|
|
440
|
+
}, ensure_ascii=False)
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
# ========== Tool 4: 获取弹幕 ==========
|
|
444
|
+
|
|
445
|
+
@tool()
|
|
446
|
+
async def bili_danmaku(bvid: str, num: int = 100) -> str:
|
|
447
|
+
"""
|
|
448
|
+
获取B站视频的弹幕
|
|
449
|
+
|
|
450
|
+
Args:
|
|
451
|
+
bvid: 视频BV号
|
|
452
|
+
num: 获取弹幕数量,默认100
|
|
453
|
+
|
|
454
|
+
Returns:
|
|
455
|
+
弹幕列表,包含弹幕文本和出现时间
|
|
456
|
+
"""
|
|
457
|
+
cred = get_cred()
|
|
458
|
+
v = video.Video(bvid=bvid, credential=cred)
|
|
459
|
+
|
|
460
|
+
danmakus = await v.get_danmakus(page_index=0)
|
|
461
|
+
result = []
|
|
462
|
+
for d in danmakus[:num]:
|
|
463
|
+
result.append({
|
|
464
|
+
"text": d.text,
|
|
465
|
+
"time": d.dm_time,
|
|
466
|
+
})
|
|
467
|
+
|
|
468
|
+
return json.dumps({"bvid": bvid, "count": len(result), "danmakus": result}, ensure_ascii=False)
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
# ========== Tool 5: 视频详情 ==========
|
|
472
|
+
|
|
473
|
+
@tool()
|
|
474
|
+
async def bili_video_info(bvid: str) -> str:
|
|
475
|
+
"""
|
|
476
|
+
获取B站视频的详细信息
|
|
477
|
+
|
|
478
|
+
Args:
|
|
479
|
+
bvid: 视频BV号
|
|
480
|
+
|
|
481
|
+
Returns:
|
|
482
|
+
视频标题、描述、UP主、播放量、评论数、收藏数等详细数据
|
|
483
|
+
"""
|
|
484
|
+
cred = get_cred()
|
|
485
|
+
v = video.Video(bvid=bvid, credential=cred)
|
|
486
|
+
info = await v.get_info()
|
|
487
|
+
stat = info.get("stat", {})
|
|
488
|
+
|
|
489
|
+
return json.dumps({
|
|
490
|
+
"bvid": info.get("bvid"),
|
|
491
|
+
"aid": info.get("aid"),
|
|
492
|
+
"title": info.get("title"),
|
|
493
|
+
"description": info.get("desc"),
|
|
494
|
+
"author": info.get("owner", {}).get("name"),
|
|
495
|
+
"duration": info.get("duration"),
|
|
496
|
+
"pages": len(info.get("pages", [])),
|
|
497
|
+
"tags": [t.get("tag_name") for t in info.get("tag", []) if t.get("tag_name")],
|
|
498
|
+
"stat": {
|
|
499
|
+
"view": stat.get("view", 0),
|
|
500
|
+
"danmaku": stat.get("danmaku", 0),
|
|
501
|
+
"reply": stat.get("reply", 0),
|
|
502
|
+
"favorite": stat.get("favorite", 0),
|
|
503
|
+
"coin": stat.get("coin", 0),
|
|
504
|
+
"like": stat.get("like", 0),
|
|
505
|
+
"share": stat.get("share", 0),
|
|
506
|
+
},
|
|
507
|
+
}, ensure_ascii=False)
|
|
508
|
+
|
|
509
|
+
|
|
510
|
+
# ========== Tool 6: 回复评论 ==========
|
|
511
|
+
|
|
512
|
+
@tool()
|
|
513
|
+
async def bili_reply(bvid: str, text: str, rpid: int = 0, root: int = 0) -> str:
|
|
514
|
+
"""
|
|
515
|
+
在B站视频下发表评论或回复某条评论
|
|
516
|
+
|
|
517
|
+
Args:
|
|
518
|
+
bvid: 视频BV号
|
|
519
|
+
text: 评论/回复的文本内容
|
|
520
|
+
rpid: 要回复的目标评论ID(0表示发表新评论)
|
|
521
|
+
root: 楼层根评论ID(回复一级评论时不用填,回复楼中楼时填根评论ID)
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
发表结果
|
|
525
|
+
"""
|
|
526
|
+
cred = get_cred()
|
|
527
|
+
v = video.Video(bvid=bvid, credential=cred)
|
|
528
|
+
info = await v.get_info()
|
|
529
|
+
aid = info["aid"]
|
|
530
|
+
|
|
531
|
+
try:
|
|
532
|
+
if rpid == 0:
|
|
533
|
+
# 发表新评论
|
|
534
|
+
result = await comment.send_comment(
|
|
535
|
+
text=text,
|
|
536
|
+
oid=aid,
|
|
537
|
+
type_=comment.CommentResourceType.VIDEO,
|
|
538
|
+
credential=cred,
|
|
539
|
+
)
|
|
540
|
+
else:
|
|
541
|
+
# 回复评论
|
|
542
|
+
# root=0 表示回复一级评论,root=rpid
|
|
543
|
+
# root!=0 表示回复楼中楼,root=根评论,parent=目标评论
|
|
544
|
+
actual_root = root if root != 0 else rpid
|
|
545
|
+
result = await comment.send_comment(
|
|
546
|
+
text=text,
|
|
547
|
+
oid=aid,
|
|
548
|
+
type_=comment.CommentResourceType.VIDEO,
|
|
549
|
+
root=actual_root,
|
|
550
|
+
parent=rpid,
|
|
551
|
+
credential=cred,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# ⭐ 把回执里的 rpid 带回去:判重表要靠它记「我们发过哪条」,宿主的评论复核
|
|
555
|
+
# (lib/verify.mjs)也要拿它去 B站 查公开可见性。只回 success 的话那条链是断的。
|
|
556
|
+
#(2026-10-16 审查修:原实现把上面的 result 丢掉了)
|
|
557
|
+
out = {"success": True, "message": "评论发送成功"}
|
|
558
|
+
if isinstance(result, dict):
|
|
559
|
+
for key in ("rpid", "rpid_str", "root", "parent"):
|
|
560
|
+
val = result.get(key)
|
|
561
|
+
if isinstance(val, (int, str)) and str(val).strip() and str(val) != "0":
|
|
562
|
+
out[key] = val
|
|
563
|
+
return json.dumps(out, ensure_ascii=False)
|
|
564
|
+
except Exception as e:
|
|
565
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
566
|
+
|
|
567
|
+
|
|
568
|
+
# ========== Tool 7: 批量采集 ==========
|
|
569
|
+
|
|
570
|
+
@tool()
|
|
571
|
+
async def bili_crawl(keyword: str, max_videos: int = 5, comments_per_video: int = 20, get_subtitles: bool = True) -> str:
|
|
572
|
+
"""
|
|
573
|
+
批量采集:搜索B站视频并抓取每个视频的评论和字幕
|
|
574
|
+
|
|
575
|
+
Args:
|
|
576
|
+
keyword: 搜索关键词
|
|
577
|
+
max_videos: 最多采集视频数,默认5
|
|
578
|
+
comments_per_video: 每个视频采集评论数,默认20
|
|
579
|
+
get_subtitles: 是否获取字幕,默认True
|
|
580
|
+
|
|
581
|
+
Returns:
|
|
582
|
+
包含视频信息、评论和字幕的完整采集数据
|
|
583
|
+
"""
|
|
584
|
+
cred = get_cred()
|
|
585
|
+
|
|
586
|
+
# 搜索
|
|
587
|
+
search_result = await search.search_by_type(
|
|
588
|
+
keyword=keyword,
|
|
589
|
+
search_type=search.SearchObjectType.VIDEO,
|
|
590
|
+
page=1,
|
|
591
|
+
order_type=search.OrderVideo.TOTALRANK,
|
|
592
|
+
)
|
|
593
|
+
|
|
594
|
+
results = []
|
|
595
|
+
for item in search_result.get("result", [])[:max_videos]:
|
|
596
|
+
bvid = item.get("bvid", "")
|
|
597
|
+
if not bvid:
|
|
598
|
+
continue
|
|
599
|
+
|
|
600
|
+
title = item.get("title", "").replace('<em class="keyword">', "").replace("</em>", "")
|
|
601
|
+
video_data = {
|
|
602
|
+
"bvid": bvid,
|
|
603
|
+
"title": title,
|
|
604
|
+
"author": item.get("author", ""),
|
|
605
|
+
"play": item.get("play", 0),
|
|
606
|
+
"review": item.get("review", 0),
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
# 评论
|
|
610
|
+
comments_json = await bili_comments(bvid=bvid, num=comments_per_video)
|
|
611
|
+
comments_data = json.loads(comments_json)
|
|
612
|
+
|
|
613
|
+
# 字幕
|
|
614
|
+
subtitle_text = ""
|
|
615
|
+
if get_subtitles:
|
|
616
|
+
try:
|
|
617
|
+
subtitle_json = await bili_subtitle(bvid=bvid)
|
|
618
|
+
subtitle_data = json.loads(subtitle_json)
|
|
619
|
+
subtitle_text = subtitle_data.get("text", "")
|
|
620
|
+
except:
|
|
621
|
+
pass
|
|
622
|
+
|
|
623
|
+
results.append({
|
|
624
|
+
"video": video_data,
|
|
625
|
+
"comments": comments_data.get("comments", []),
|
|
626
|
+
"subtitle_text": subtitle_text,
|
|
627
|
+
})
|
|
628
|
+
|
|
629
|
+
await asyncio.sleep(1)
|
|
630
|
+
|
|
631
|
+
return json.dumps({
|
|
632
|
+
"keyword": keyword,
|
|
633
|
+
"video_count": len(results),
|
|
634
|
+
"total_comments": sum(len(r["comments"]) for r in results),
|
|
635
|
+
"results": results,
|
|
636
|
+
}, ensure_ascii=False)
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
# ========== 辅助:封面处理 ==========
|
|
640
|
+
|
|
641
|
+
import subprocess
|
|
642
|
+
|
|
643
|
+
|
|
644
|
+
def _extract_cover_from_video(video_path: str) -> Picture:
|
|
645
|
+
"""从视频第3秒截取一帧作为封面,返回 Picture 对象"""
|
|
646
|
+
tmp_cover = os.path.join(tempfile.gettempdir(), f"bili_cover_{os.getpid()}.png")
|
|
647
|
+
try:
|
|
648
|
+
subprocess.run(
|
|
649
|
+
["ffmpeg", "-y", "-ss", "3", "-i", video_path,
|
|
650
|
+
"-vframes", "1", "-q:v", "2", tmp_cover],
|
|
651
|
+
capture_output=True, timeout=30,
|
|
652
|
+
)
|
|
653
|
+
if os.path.isfile(tmp_cover) and os.path.getsize(tmp_cover) > 0:
|
|
654
|
+
return Picture.from_file(tmp_cover)
|
|
655
|
+
finally:
|
|
656
|
+
if os.path.isfile(tmp_cover):
|
|
657
|
+
os.remove(tmp_cover)
|
|
658
|
+
# fallback: 第0秒
|
|
659
|
+
tmp_cover2 = os.path.join(tempfile.gettempdir(), f"bili_cover2_{os.getpid()}.png")
|
|
660
|
+
try:
|
|
661
|
+
subprocess.run(
|
|
662
|
+
["ffmpeg", "-y", "-ss", "0", "-i", video_path,
|
|
663
|
+
"-vframes", "1", "-q:v", "2", tmp_cover2],
|
|
664
|
+
capture_output=True, timeout=30,
|
|
665
|
+
)
|
|
666
|
+
if os.path.isfile(tmp_cover2) and os.path.getsize(tmp_cover2) > 0:
|
|
667
|
+
return Picture.from_file(tmp_cover2)
|
|
668
|
+
finally:
|
|
669
|
+
if os.path.isfile(tmp_cover2):
|
|
670
|
+
os.remove(tmp_cover2)
|
|
671
|
+
raise RuntimeError(f"无法从视频截取封面: {video_path}")
|
|
672
|
+
|
|
673
|
+
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
@tool()
|
|
677
|
+
async def bili_send_dynamic(
|
|
678
|
+
text: str,
|
|
679
|
+
images: list[str] | None = None,
|
|
680
|
+
topic_id: int = 0,
|
|
681
|
+
schedule_time: int = 0,
|
|
682
|
+
) -> str:
|
|
683
|
+
"""
|
|
684
|
+
发布B站图文动态
|
|
685
|
+
|
|
686
|
+
Args:
|
|
687
|
+
text: 动态文本内容,支持@和表情(如 [doge])
|
|
688
|
+
images: 图片列表,每项可以是本地文件路径或图片URL,最多9张。为空则发布纯文字动态
|
|
689
|
+
topic_id: 话题ID(可选,0表示不关联话题)
|
|
690
|
+
schedule_time: 定时发布的Unix时间戳(可选,0表示立即发布)
|
|
691
|
+
|
|
692
|
+
Returns:
|
|
693
|
+
发布结果,包含动态ID
|
|
694
|
+
"""
|
|
695
|
+
if not text or not text.strip():
|
|
696
|
+
return json.dumps({"success": False, "error": "text 不能为空"}, ensure_ascii=False)
|
|
697
|
+
|
|
698
|
+
cred = get_cred()
|
|
699
|
+
|
|
700
|
+
dyn = dynamic.BuildDynamic.empty()
|
|
701
|
+
dyn.add_plain_text(text.strip())
|
|
702
|
+
# 上传图片
|
|
703
|
+
if images:
|
|
704
|
+
for img_path in images[:9]:
|
|
705
|
+
try:
|
|
706
|
+
if not img_path or not img_path.strip():
|
|
707
|
+
continue
|
|
708
|
+
img_path = img_path.strip()
|
|
709
|
+
if img_path.startswith(("http://", "https://")):
|
|
710
|
+
pic = await Picture.async_from_url(img_path)
|
|
711
|
+
else:
|
|
712
|
+
if not os.path.isfile(img_path):
|
|
713
|
+
return json.dumps({"success": False, "error": f"图片文件不存在: {img_path}"}, ensure_ascii=False)
|
|
714
|
+
pic = Picture.from_file(img_path)
|
|
715
|
+
dyn.add_image(pic)
|
|
716
|
+
except Exception as e:
|
|
717
|
+
return json.dumps({"success": False, "error": f"图片处理失败: {img_path} - {str(e)}"}, ensure_ascii=False)
|
|
718
|
+
|
|
719
|
+
if topic_id:
|
|
720
|
+
dyn.set_topic(topic_id)
|
|
721
|
+
|
|
722
|
+
if schedule_time > 0:
|
|
723
|
+
dyn.set_send_time(schedule_time)
|
|
724
|
+
|
|
725
|
+
try:
|
|
726
|
+
result = await dynamic.send_dynamic(info=dyn, credential=cred)
|
|
727
|
+
return json.dumps({
|
|
728
|
+
"success": True,
|
|
729
|
+
"message": "动态发布成功",
|
|
730
|
+
"data": result if isinstance(result, dict) else str(result),
|
|
731
|
+
}, ensure_ascii=False)
|
|
732
|
+
except Exception as e:
|
|
733
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
734
|
+
|
|
735
|
+
|
|
736
|
+
# ========== Tool 9: 上传视频 ==========
|
|
737
|
+
|
|
738
|
+
@tool()
|
|
739
|
+
async def bili_upload_video(
|
|
740
|
+
video_path: str,
|
|
741
|
+
title: str,
|
|
742
|
+
desc: str = "",
|
|
743
|
+
tid: int = 124,
|
|
744
|
+
tags: str = "AI",
|
|
745
|
+
cover_path: str = "",
|
|
746
|
+
source: str = "",
|
|
747
|
+
dynamic_text: str = "",
|
|
748
|
+
no_reprint: bool = True,
|
|
749
|
+
) -> str:
|
|
750
|
+
"""
|
|
751
|
+
上传视频到B站
|
|
752
|
+
|
|
753
|
+
Args:
|
|
754
|
+
video_path: 视频文件的本地路径
|
|
755
|
+
title: 视频标题(最多80字)
|
|
756
|
+
desc: 视频简介描述
|
|
757
|
+
tid: 分区ID,默认124(趣味科普人文-社科·法律·心理)。
|
|
758
|
+
常用分区:17=单机游戏 21=日常 95=数码 122=野生技术协会
|
|
759
|
+
124=社科 160=生活记录 171=电子竞技 183=影视杂谈
|
|
760
|
+
188=科技资讯 201=科学 207=财经商业 208=科技 209=手工
|
|
761
|
+
230=其他(生活) 231=美食 234=健身 32=完结动画
|
|
762
|
+
tags: 标签,逗号分隔,如"AI,教程,编程"(至少1个标签)
|
|
763
|
+
cover_path: 封面图片路径(可选,不填则B站自动截取)
|
|
764
|
+
source: 转载来源URL(非原创时必填)
|
|
765
|
+
dynamic_text: 粉丝动态文本(可选,投稿时同步发布的动态内容)
|
|
766
|
+
no_reprint: 是否启用未经作者授权禁止转载,默认True
|
|
767
|
+
|
|
768
|
+
Returns:
|
|
769
|
+
上传结果,包含BV号
|
|
770
|
+
"""
|
|
771
|
+
# ---- 参数校验 ----
|
|
772
|
+
if not video_path or not video_path.strip():
|
|
773
|
+
return json.dumps({"success": False, "error": "video_path 不能为空,请提供视频文件的本地路径"}, ensure_ascii=False)
|
|
774
|
+
|
|
775
|
+
video_path = video_path.strip()
|
|
776
|
+
if not os.path.isfile(video_path):
|
|
777
|
+
return json.dumps({"success": False, "error": f"视频文件不存在: {video_path}"}, ensure_ascii=False)
|
|
778
|
+
|
|
779
|
+
if not title or not title.strip():
|
|
780
|
+
return json.dumps({"success": False, "error": "title 不能为空"}, ensure_ascii=False)
|
|
781
|
+
|
|
782
|
+
cred = get_cred()
|
|
783
|
+
|
|
784
|
+
try:
|
|
785
|
+
# 准备分P
|
|
786
|
+
page = VideoUploaderPage(
|
|
787
|
+
path=video_path,
|
|
788
|
+
title=title.strip()[:80],
|
|
789
|
+
description=(desc or "")[:250],
|
|
790
|
+
)
|
|
791
|
+
|
|
792
|
+
# 标签处理
|
|
793
|
+
tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()]
|
|
794
|
+
if not tag_list:
|
|
795
|
+
tag_list = ["视频"]
|
|
796
|
+
|
|
797
|
+
# 判断原创/转载
|
|
798
|
+
is_original = not bool(source and source.strip())
|
|
799
|
+
|
|
800
|
+
# 封面:用户提供 > 自动从视频截取
|
|
801
|
+
if cover_path and cover_path.strip() and os.path.isfile(cover_path.strip()):
|
|
802
|
+
cover_pic = Picture.from_file(cover_path.strip())
|
|
803
|
+
else:
|
|
804
|
+
cover_pic = _extract_cover_from_video(video_path)
|
|
805
|
+
|
|
806
|
+
meta = VideoMeta(
|
|
807
|
+
tid=tid,
|
|
808
|
+
title=title.strip()[:80],
|
|
809
|
+
desc=desc or "",
|
|
810
|
+
cover=cover_pic,
|
|
811
|
+
tags=tag_list,
|
|
812
|
+
original=is_original,
|
|
813
|
+
source=source.strip() if source and source.strip() else None,
|
|
814
|
+
no_reprint=no_reprint if is_original else False,
|
|
815
|
+
dynamic=dynamic_text.strip() if dynamic_text and dynamic_text.strip() else None,
|
|
816
|
+
)
|
|
817
|
+
|
|
818
|
+
uploader = VideoUploader(
|
|
819
|
+
pages=[page],
|
|
820
|
+
meta=meta,
|
|
821
|
+
credential=cred,
|
|
822
|
+
)
|
|
823
|
+
|
|
824
|
+
# 上传事件回调(记录进度)
|
|
825
|
+
progress_info = {"phase": "初始化"}
|
|
826
|
+
|
|
827
|
+
@uploader.on("__ALL__")
|
|
828
|
+
async def on_event(data: dict):
|
|
829
|
+
progress_info["phase"] = str(data)
|
|
830
|
+
|
|
831
|
+
result = await uploader.start()
|
|
832
|
+
return json.dumps({
|
|
833
|
+
"success": True,
|
|
834
|
+
"message": "视频上传成功",
|
|
835
|
+
"data": result if isinstance(result, dict) else str(result),
|
|
836
|
+
}, ensure_ascii=False)
|
|
837
|
+
except Exception as e:
|
|
838
|
+
return json.dumps({
|
|
839
|
+
"success": False,
|
|
840
|
+
"error": str(e),
|
|
841
|
+
"last_phase": progress_info.get("phase", "unknown") if "progress_info" in dir() else "init",
|
|
842
|
+
}, ensure_ascii=False)
|
|
843
|
+
|
|
844
|
+
|
|
845
|
+
# ========== Tool 10: 上传多P视频 ==========
|
|
846
|
+
|
|
847
|
+
@tool()
|
|
848
|
+
async def bili_upload_video_multi(
|
|
849
|
+
video_paths: list[str],
|
|
850
|
+
page_titles: list[str],
|
|
851
|
+
title: str,
|
|
852
|
+
desc: str = "",
|
|
853
|
+
tid: int = 124,
|
|
854
|
+
tags: str = "AI",
|
|
855
|
+
cover_path: str = "",
|
|
856
|
+
source: str = "",
|
|
857
|
+
) -> str:
|
|
858
|
+
"""
|
|
859
|
+
上传多P视频到B站(多个分P合并为一个投稿)
|
|
860
|
+
|
|
861
|
+
Args:
|
|
862
|
+
video_paths: 视频文件路径列表,如["/path/p1.mp4", "/path/p2.mp4"]
|
|
863
|
+
page_titles: 各分P标题列表,与video_paths一一对应
|
|
864
|
+
title: 视频总标题
|
|
865
|
+
desc: 视频简介
|
|
866
|
+
tid: 分区ID,默认124
|
|
867
|
+
tags: 标签,逗号分隔
|
|
868
|
+
cover_path: 封面图片路径(可选)
|
|
869
|
+
source: 转载来源URL(非原创时必填)
|
|
870
|
+
|
|
871
|
+
Returns:
|
|
872
|
+
上传结果
|
|
873
|
+
"""
|
|
874
|
+
cred = get_cred()
|
|
875
|
+
|
|
876
|
+
if not video_paths:
|
|
877
|
+
return json.dumps({"success": False, "error": "video_paths 不能为空"}, ensure_ascii=False)
|
|
878
|
+
if not page_titles:
|
|
879
|
+
return json.dumps({"success": False, "error": "page_titles 不能为空"}, ensure_ascii=False)
|
|
880
|
+
if len(video_paths) != len(page_titles):
|
|
881
|
+
return json.dumps({"success": False, "error": "video_paths 和 page_titles 数量必须一致"}, ensure_ascii=False)
|
|
882
|
+
|
|
883
|
+
try:
|
|
884
|
+
pages = []
|
|
885
|
+
for i, vp in enumerate(video_paths):
|
|
886
|
+
vp = (vp or "").strip()
|
|
887
|
+
if not vp or not os.path.isfile(vp):
|
|
888
|
+
return json.dumps({"success": False, "error": f"文件不存在: {vp}"}, ensure_ascii=False)
|
|
889
|
+
pages.append(VideoUploaderPage(
|
|
890
|
+
path=vp,
|
|
891
|
+
title=(page_titles[i] or "").strip()[:80] or f"P{i+1}",
|
|
892
|
+
description="",
|
|
893
|
+
))
|
|
894
|
+
|
|
895
|
+
tag_list = [t.strip() for t in (tags or "").split(",") if t.strip()] or ["视频"]
|
|
896
|
+
is_original = not bool(source and source.strip())
|
|
897
|
+
|
|
898
|
+
# 封面:用户提供 > 从第一个视频自动截取
|
|
899
|
+
if cover_path and cover_path.strip() and os.path.isfile(cover_path.strip()):
|
|
900
|
+
cover_pic = Picture.from_file(cover_path.strip())
|
|
901
|
+
else:
|
|
902
|
+
cover_pic = _extract_cover_from_video(pages[0].path)
|
|
903
|
+
|
|
904
|
+
meta = VideoMeta(
|
|
905
|
+
tid=tid,
|
|
906
|
+
title=(title or "").strip()[:80],
|
|
907
|
+
desc=desc or "",
|
|
908
|
+
cover=cover_pic,
|
|
909
|
+
tags=tag_list,
|
|
910
|
+
original=is_original,
|
|
911
|
+
source=source.strip() if source and source.strip() else None,
|
|
912
|
+
)
|
|
913
|
+
|
|
914
|
+
uploader = VideoUploader(pages=pages, meta=meta, credential=cred)
|
|
915
|
+
|
|
916
|
+
result = await uploader.start()
|
|
917
|
+
return json.dumps({
|
|
918
|
+
"success": True,
|
|
919
|
+
"message": f"多P视频上传成功(共{len(pages)}P)",
|
|
920
|
+
"data": result if isinstance(result, dict) else str(result),
|
|
921
|
+
}, ensure_ascii=False)
|
|
922
|
+
except Exception as e:
|
|
923
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
924
|
+
|
|
925
|
+
|
|
926
|
+
# ========== Tool 11: 发布专栏文章 (Opus) ==========
|
|
927
|
+
|
|
928
|
+
@tool()
|
|
929
|
+
async def bili_send_opus(
|
|
930
|
+
title: str,
|
|
931
|
+
content: str,
|
|
932
|
+
images: list[str] | None = None,
|
|
933
|
+
category_id: int = 0,
|
|
934
|
+
) -> str:
|
|
935
|
+
"""
|
|
936
|
+
发布B站图文(Opus,新版专栏)
|
|
937
|
+
|
|
938
|
+
Args:
|
|
939
|
+
title: 文章标题
|
|
940
|
+
content: 文章正文内容(纯文本,段落用换行分隔)
|
|
941
|
+
images: 文章中插入的图片路径或URL列表(可选)
|
|
942
|
+
category_id: 分类ID(可选,0表示不指定)
|
|
943
|
+
|
|
944
|
+
Returns:
|
|
945
|
+
发布结果
|
|
946
|
+
"""
|
|
947
|
+
cred = get_cred()
|
|
948
|
+
|
|
949
|
+
dyn = dynamic.BuildDynamic.empty()
|
|
950
|
+
|
|
951
|
+
# 专栏动态以纯文本+图片方式构建
|
|
952
|
+
# 标题作为第一行加粗
|
|
953
|
+
full_text = f"【{title}】\n\n{content}"
|
|
954
|
+
dyn.add_plain_text(full_text)
|
|
955
|
+
|
|
956
|
+
if images:
|
|
957
|
+
for img_path in images:
|
|
958
|
+
try:
|
|
959
|
+
if not img_path or not img_path.strip():
|
|
960
|
+
continue
|
|
961
|
+
img_path = img_path.strip()
|
|
962
|
+
if img_path.startswith(("http://", "https://")):
|
|
963
|
+
pic = await Picture.async_from_url(img_path)
|
|
964
|
+
else:
|
|
965
|
+
pic = Picture.from_file(img_path)
|
|
966
|
+
dyn.add_image(pic)
|
|
967
|
+
except Exception as e:
|
|
968
|
+
pass # 图片失败不阻断发布
|
|
969
|
+
|
|
970
|
+
try:
|
|
971
|
+
result = await dynamic.send_dynamic(info=dyn, credential=cred)
|
|
972
|
+
return json.dumps({
|
|
973
|
+
"success": True,
|
|
974
|
+
"message": "图文发布成功",
|
|
975
|
+
"data": result if isinstance(result, dict) else str(result),
|
|
976
|
+
}, ensure_ascii=False)
|
|
977
|
+
except Exception as e:
|
|
978
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
979
|
+
|
|
980
|
+
|
|
981
|
+
# ========== Tool 12: 查询分区列表 ==========
|
|
982
|
+
|
|
983
|
+
@tool()
|
|
984
|
+
async def bili_video_zones() -> str:
|
|
985
|
+
"""
|
|
986
|
+
获取B站视频常用分区ID列表,供上传视频时选择tid参数
|
|
987
|
+
|
|
988
|
+
Returns:
|
|
989
|
+
常用分区ID及名称
|
|
990
|
+
"""
|
|
991
|
+
zones = {
|
|
992
|
+
"科技": {
|
|
993
|
+
188: "科技资讯", 122: "野生技术协会", 95: "数码",
|
|
994
|
+
208: "科技", 209: "手工",
|
|
995
|
+
},
|
|
996
|
+
"知识": {
|
|
997
|
+
201: "科学", 124: "社科·法律·心理", 207: "财经商业",
|
|
998
|
+
228: "人文历史", 36: "科技(知识)",
|
|
999
|
+
},
|
|
1000
|
+
"生活": {
|
|
1001
|
+
21: "日常", 160: "生活记录", 230: "其他",
|
|
1002
|
+
231: "美食", 234: "健身", 161: "搞笑",
|
|
1003
|
+
},
|
|
1004
|
+
"游戏": {
|
|
1005
|
+
17: "单机游戏", 171: "电子竞技", 172: "手机游戏",
|
|
1006
|
+
65: "网络游戏",
|
|
1007
|
+
},
|
|
1008
|
+
"影视": {
|
|
1009
|
+
183: "影视杂谈", 138: "搞笑", 182: "影视剪辑",
|
|
1010
|
+
},
|
|
1011
|
+
"动画": {
|
|
1012
|
+
32: "完结动画", 33: "连载动画", 51: "MAD·AMV",
|
|
1013
|
+
},
|
|
1014
|
+
"音乐": {
|
|
1015
|
+
28: "原创音乐", 31: "翻唱", 59: "演奏",
|
|
1016
|
+
},
|
|
1017
|
+
}
|
|
1018
|
+
return json.dumps(zones, ensure_ascii=False)
|
|
1019
|
+
|
|
1020
|
+
|
|
1021
|
+
# ========================================================================
|
|
1022
|
+
# V1.2 — 数据分析 & 互动运营
|
|
1023
|
+
# ========================================================================
|
|
1024
|
+
|
|
1025
|
+
|
|
1026
|
+
# ========== Tool 13: 热门视频 ==========
|
|
1027
|
+
|
|
1028
|
+
@tool()
|
|
1029
|
+
async def bili_hot_videos(pn: int = 1, ps: int = 20) -> str:
|
|
1030
|
+
"""
|
|
1031
|
+
获取B站当前热门视频列表
|
|
1032
|
+
|
|
1033
|
+
Args:
|
|
1034
|
+
pn: 页码,默认1
|
|
1035
|
+
ps: 每页数量,默认20,最大50
|
|
1036
|
+
|
|
1037
|
+
Returns:
|
|
1038
|
+
热门视频列表,包含标题、播放量、UP主等
|
|
1039
|
+
"""
|
|
1040
|
+
result = await hot.get_hot_videos(pn=pn, ps=min(ps, 50))
|
|
1041
|
+
videos = []
|
|
1042
|
+
for item in result.get("list", []):
|
|
1043
|
+
stat = item.get("stat", {})
|
|
1044
|
+
videos.append({
|
|
1045
|
+
"bvid": item.get("bvid", ""),
|
|
1046
|
+
"title": item.get("title", ""),
|
|
1047
|
+
"author": item.get("owner", {}).get("name", ""),
|
|
1048
|
+
"play": stat.get("view", 0),
|
|
1049
|
+
"like": stat.get("like", 0),
|
|
1050
|
+
"danmaku": stat.get("danmaku", 0),
|
|
1051
|
+
"reply": stat.get("reply", 0),
|
|
1052
|
+
"desc": (item.get("desc", "") or "")[:100],
|
|
1053
|
+
"duration": item.get("duration", 0),
|
|
1054
|
+
"tname": item.get("tname", ""),
|
|
1055
|
+
})
|
|
1056
|
+
return json.dumps({"page": pn, "count": len(videos), "videos": videos}, ensure_ascii=False)
|
|
1057
|
+
|
|
1058
|
+
|
|
1059
|
+
# ========== Tool 14: 热搜关键词 ==========
|
|
1060
|
+
|
|
1061
|
+
@tool()
|
|
1062
|
+
async def bili_hot_buzzwords(page_num: int = 1, page_size: int = 20) -> str:
|
|
1063
|
+
"""
|
|
1064
|
+
获取B站热搜词/热门关键词
|
|
1065
|
+
|
|
1066
|
+
Args:
|
|
1067
|
+
page_num: 页码,默认1
|
|
1068
|
+
page_size: 每页数量,默认20
|
|
1069
|
+
|
|
1070
|
+
Returns:
|
|
1071
|
+
热搜词列表
|
|
1072
|
+
"""
|
|
1073
|
+
result = await hot.get_hot_buzzwords(page_num=page_num, page_size=page_size)
|
|
1074
|
+
return json.dumps(result, ensure_ascii=False)
|
|
1075
|
+
|
|
1076
|
+
|
|
1077
|
+
# ========== Tool 15: 每周必看 ==========
|
|
1078
|
+
|
|
1079
|
+
@tool()
|
|
1080
|
+
async def bili_weekly_hot(week: int = 0) -> str:
|
|
1081
|
+
"""
|
|
1082
|
+
获取B站每周必看视频推荐
|
|
1083
|
+
|
|
1084
|
+
Args:
|
|
1085
|
+
week: 期数(0表示获取期数列表,>0表示获取该期的视频)
|
|
1086
|
+
|
|
1087
|
+
Returns:
|
|
1088
|
+
每周必看期数列表或指定期的视频列表
|
|
1089
|
+
"""
|
|
1090
|
+
if week <= 0:
|
|
1091
|
+
result = await hot.get_weekly_hot_videos_list()
|
|
1092
|
+
return json.dumps(result, ensure_ascii=False)
|
|
1093
|
+
else:
|
|
1094
|
+
result = await hot.get_weekly_hot_videos(week=week)
|
|
1095
|
+
return json.dumps(result, ensure_ascii=False)
|
|
1096
|
+
|
|
1097
|
+
|
|
1098
|
+
# ========== Tool 16: 排行榜 ==========
|
|
1099
|
+
|
|
1100
|
+
@tool()
|
|
1101
|
+
async def bili_rank(category: str = "all", day: int = 3) -> str:
|
|
1102
|
+
"""
|
|
1103
|
+
获取B站各分区排行榜
|
|
1104
|
+
|
|
1105
|
+
Args:
|
|
1106
|
+
category: 分区名,可选值:
|
|
1107
|
+
all=全站 original=原创 rookie=新人
|
|
1108
|
+
douga=动画 music=音乐 dance=舞蹈 game=游戏
|
|
1109
|
+
knowledge=知识 technology=科技 sports=运动 car=汽车
|
|
1110
|
+
life=生活 food=美食 animal=动物 fashion=时尚
|
|
1111
|
+
ent=娱乐 cinephile=影视
|
|
1112
|
+
day: 时间维度,3=三日 7=七日
|
|
1113
|
+
|
|
1114
|
+
Returns:
|
|
1115
|
+
排行榜视频列表
|
|
1116
|
+
"""
|
|
1117
|
+
type_map = {
|
|
1118
|
+
"all": rank.RankType.All, "original": rank.RankType.Original,
|
|
1119
|
+
"rookie": rank.RankType.Rookie, "douga": rank.RankType.Douga,
|
|
1120
|
+
"music": rank.RankType.Music, "dance": rank.RankType.Dance,
|
|
1121
|
+
"game": rank.RankType.Game, "knowledge": rank.RankType.Knowledge,
|
|
1122
|
+
"technology": rank.RankType.Technology, "sports": rank.RankType.Sports,
|
|
1123
|
+
"car": rank.RankType.Car, "life": rank.RankType.Life,
|
|
1124
|
+
"food": rank.RankType.Food, "animal": rank.RankType.Animal,
|
|
1125
|
+
"fashion": rank.RankType.Fashion, "ent": rank.RankType.Ent,
|
|
1126
|
+
"cinephile": rank.RankType.Cinephile,
|
|
1127
|
+
}
|
|
1128
|
+
day_map = {3: rank.RankDayType.THREE_DAY, 7: rank.RankDayType.WEEK}
|
|
1129
|
+
|
|
1130
|
+
rank_type = type_map.get(category.lower(), rank.RankType.All)
|
|
1131
|
+
rank_day = day_map.get(day, rank.RankDayType.THREE_DAY)
|
|
1132
|
+
|
|
1133
|
+
result = await rank.get_rank(type_=rank_type, day=rank_day)
|
|
1134
|
+
videos = []
|
|
1135
|
+
for item in result.get("list", []):
|
|
1136
|
+
stat = item.get("stat", {})
|
|
1137
|
+
videos.append({
|
|
1138
|
+
"bvid": item.get("bvid", ""),
|
|
1139
|
+
"title": item.get("title", ""),
|
|
1140
|
+
"author": item.get("owner", {}).get("name", ""),
|
|
1141
|
+
"play": stat.get("view", 0),
|
|
1142
|
+
"like": stat.get("like", 0),
|
|
1143
|
+
"coin": stat.get("coin", 0),
|
|
1144
|
+
"score": item.get("score", 0),
|
|
1145
|
+
"tname": item.get("tname", ""),
|
|
1146
|
+
})
|
|
1147
|
+
return json.dumps({"category": category, "day": day, "count": len(videos), "videos": videos}, ensure_ascii=False)
|
|
1148
|
+
|
|
1149
|
+
|
|
1150
|
+
# ========== Tool 17: 用户信息 ==========
|
|
1151
|
+
|
|
1152
|
+
@tool()
|
|
1153
|
+
async def bili_user_info(uid: int) -> str:
|
|
1154
|
+
"""
|
|
1155
|
+
获取B站用户的详细信息
|
|
1156
|
+
|
|
1157
|
+
Args:
|
|
1158
|
+
uid: 用户UID
|
|
1159
|
+
|
|
1160
|
+
Returns:
|
|
1161
|
+
用户昵称、粉丝数、关注数、签名、等级、视频数等
|
|
1162
|
+
"""
|
|
1163
|
+
cred = get_cred()
|
|
1164
|
+
u = user.User(uid=uid, credential=cred)
|
|
1165
|
+
info = await u.get_user_info()
|
|
1166
|
+
|
|
1167
|
+
# 尝试获取UP主数据
|
|
1168
|
+
up_stat = {}
|
|
1169
|
+
try:
|
|
1170
|
+
up_stat = await u.get_up_stat()
|
|
1171
|
+
except:
|
|
1172
|
+
pass
|
|
1173
|
+
|
|
1174
|
+
relation = {}
|
|
1175
|
+
try:
|
|
1176
|
+
relation = await u.get_relation_info()
|
|
1177
|
+
except:
|
|
1178
|
+
pass
|
|
1179
|
+
|
|
1180
|
+
return json.dumps({
|
|
1181
|
+
"uid": uid,
|
|
1182
|
+
"name": info.get("name", ""),
|
|
1183
|
+
"sign": info.get("sign", ""),
|
|
1184
|
+
"level": info.get("level", 0),
|
|
1185
|
+
"face": info.get("face", ""),
|
|
1186
|
+
"fans": relation.get("follower", info.get("follower", 0)),
|
|
1187
|
+
"following": relation.get("following", info.get("following", 0)),
|
|
1188
|
+
"likes": up_stat.get("likes", 0),
|
|
1189
|
+
"archive_view": up_stat.get("archive", {}).get("view", 0),
|
|
1190
|
+
"article_view": up_stat.get("article", {}).get("view", 0),
|
|
1191
|
+
"is_senior_member": info.get("is_senior_member", 0),
|
|
1192
|
+
"top_photo": info.get("top_photo", ""),
|
|
1193
|
+
}, ensure_ascii=False)
|
|
1194
|
+
|
|
1195
|
+
|
|
1196
|
+
# ========== Tool 18: 用户视频列表 ==========
|
|
1197
|
+
|
|
1198
|
+
@tool()
|
|
1199
|
+
async def bili_user_videos(uid: int, pn: int = 1, ps: int = 30, order: str = "pubdate", keyword: str = "") -> str:
|
|
1200
|
+
"""
|
|
1201
|
+
获取B站用户的投稿视频列表
|
|
1202
|
+
|
|
1203
|
+
Args:
|
|
1204
|
+
uid: 用户UID
|
|
1205
|
+
pn: 页码,默认1
|
|
1206
|
+
ps: 每页数量,默认30
|
|
1207
|
+
order: 排序方式 pubdate=最新 click=播放量 stow=收藏
|
|
1208
|
+
keyword: 搜索关键词(在该用户视频中搜索)
|
|
1209
|
+
|
|
1210
|
+
Returns:
|
|
1211
|
+
视频列表
|
|
1212
|
+
"""
|
|
1213
|
+
cred = get_cred()
|
|
1214
|
+
u = user.User(uid=uid, credential=cred)
|
|
1215
|
+
|
|
1216
|
+
order_map = {
|
|
1217
|
+
"pubdate": user.VideoOrder.PUBDATE,
|
|
1218
|
+
"click": user.VideoOrder.VIEW,
|
|
1219
|
+
"stow": user.VideoOrder.FAVORITE,
|
|
1220
|
+
}
|
|
1221
|
+
order_enum = order_map.get(order, user.VideoOrder.PUBDATE)
|
|
1222
|
+
|
|
1223
|
+
result = await u.get_videos(pn=pn, ps=ps, order=order_enum, keyword=keyword)
|
|
1224
|
+
videos = []
|
|
1225
|
+
for item in result.get("list", {}).get("vlist", []):
|
|
1226
|
+
videos.append({
|
|
1227
|
+
"bvid": item.get("bvid", ""),
|
|
1228
|
+
"title": item.get("title", ""),
|
|
1229
|
+
"play": item.get("play", 0),
|
|
1230
|
+
"comment": item.get("comment", 0),
|
|
1231
|
+
"created": item.get("created", 0),
|
|
1232
|
+
"length": item.get("length", ""),
|
|
1233
|
+
"description": (item.get("description", "") or "")[:100],
|
|
1234
|
+
})
|
|
1235
|
+
return json.dumps({
|
|
1236
|
+
"uid": uid,
|
|
1237
|
+
"page": pn,
|
|
1238
|
+
"total": result.get("page", {}).get("count", 0),
|
|
1239
|
+
"count": len(videos),
|
|
1240
|
+
"videos": videos,
|
|
1241
|
+
}, ensure_ascii=False)
|
|
1242
|
+
|
|
1243
|
+
|
|
1244
|
+
# ========== Tool 19: 收藏夹列表 ==========
|
|
1245
|
+
|
|
1246
|
+
@tool()
|
|
1247
|
+
async def bili_favorite_lists(uid: int = 0) -> str:
|
|
1248
|
+
"""
|
|
1249
|
+
获取用户的收藏夹列表
|
|
1250
|
+
|
|
1251
|
+
Args:
|
|
1252
|
+
uid: 用户UID(0表示获取自己的收藏夹)
|
|
1253
|
+
|
|
1254
|
+
Returns:
|
|
1255
|
+
收藏夹列表,包含ID、名称、视频数量
|
|
1256
|
+
"""
|
|
1257
|
+
cred = get_cred()
|
|
1258
|
+
if uid == 0:
|
|
1259
|
+
# 从凭证获取自己的UID
|
|
1260
|
+
import json as _json
|
|
1261
|
+
with open(CRED_FILE) as f:
|
|
1262
|
+
data = _json.load(f)
|
|
1263
|
+
uid = int(data.get("dedeuserid", 0))
|
|
1264
|
+
|
|
1265
|
+
result = await favorite_list.get_video_favorite_list(uid=uid, credential=cred)
|
|
1266
|
+
fav_lists = []
|
|
1267
|
+
for item in (result.get("list", []) or []):
|
|
1268
|
+
fav_lists.append({
|
|
1269
|
+
"id": item.get("id", 0),
|
|
1270
|
+
"title": item.get("title", ""),
|
|
1271
|
+
"media_count": item.get("media_count", 0),
|
|
1272
|
+
"fav_state": item.get("fav_state", 0),
|
|
1273
|
+
})
|
|
1274
|
+
return json.dumps({"uid": uid, "count": len(fav_lists), "lists": fav_lists}, ensure_ascii=False)
|
|
1275
|
+
|
|
1276
|
+
|
|
1277
|
+
# ========== Tool 20: 收藏夹内容 ==========
|
|
1278
|
+
|
|
1279
|
+
@tool()
|
|
1280
|
+
async def bili_favorite_content(media_id: int, page: int = 1, keyword: str = "") -> str:
|
|
1281
|
+
"""
|
|
1282
|
+
获取收藏夹内的视频列表
|
|
1283
|
+
|
|
1284
|
+
Args:
|
|
1285
|
+
media_id: 收藏夹ID(从 bili_favorite_lists 获取)
|
|
1286
|
+
page: 页码,默认1
|
|
1287
|
+
keyword: 搜索关键词(在收藏夹内搜索)
|
|
1288
|
+
|
|
1289
|
+
Returns:
|
|
1290
|
+
收藏夹内的视频列表
|
|
1291
|
+
"""
|
|
1292
|
+
cred = get_cred()
|
|
1293
|
+
result = await favorite_list.get_video_favorite_list_content(
|
|
1294
|
+
media_id=media_id,
|
|
1295
|
+
page=page,
|
|
1296
|
+
keyword=keyword if keyword else None,
|
|
1297
|
+
credential=cred,
|
|
1298
|
+
)
|
|
1299
|
+
medias = []
|
|
1300
|
+
for item in (result.get("medias", []) or []):
|
|
1301
|
+
medias.append({
|
|
1302
|
+
"bvid": item.get("bvid", ""),
|
|
1303
|
+
"title": item.get("title", ""),
|
|
1304
|
+
"play": item.get("cnt_info", {}).get("play", 0),
|
|
1305
|
+
"collect": item.get("cnt_info", {}).get("collect", 0),
|
|
1306
|
+
"author": item.get("upper", {}).get("name", ""),
|
|
1307
|
+
"duration": item.get("duration", 0),
|
|
1308
|
+
"fav_time": item.get("fav_time", 0),
|
|
1309
|
+
})
|
|
1310
|
+
return json.dumps({
|
|
1311
|
+
"media_id": media_id,
|
|
1312
|
+
"page": page,
|
|
1313
|
+
"has_more": result.get("has_more", False),
|
|
1314
|
+
"count": len(medias),
|
|
1315
|
+
"medias": medias,
|
|
1316
|
+
}, ensure_ascii=False)
|
|
1317
|
+
|
|
1318
|
+
|
|
1319
|
+
# ========== Tool 21: 发私信 ==========
|
|
1320
|
+
|
|
1321
|
+
@tool()
|
|
1322
|
+
async def bili_send_message(receiver_uid: int, text: str) -> str:
|
|
1323
|
+
"""
|
|
1324
|
+
给B站用户发送私信
|
|
1325
|
+
|
|
1326
|
+
Args:
|
|
1327
|
+
receiver_uid: 接收者的UID
|
|
1328
|
+
text: 私信文本内容
|
|
1329
|
+
|
|
1330
|
+
Returns:
|
|
1331
|
+
发送结果
|
|
1332
|
+
"""
|
|
1333
|
+
cred = get_cred()
|
|
1334
|
+
|
|
1335
|
+
if not text or not text.strip():
|
|
1336
|
+
return json.dumps({"success": False, "error": "text 不能为空"}, ensure_ascii=False)
|
|
1337
|
+
|
|
1338
|
+
try:
|
|
1339
|
+
result = await session.send_msg(
|
|
1340
|
+
credential=cred,
|
|
1341
|
+
receiver_id=receiver_uid,
|
|
1342
|
+
msg_type=session.EventType.TEXT,
|
|
1343
|
+
content=text.strip(),
|
|
1344
|
+
)
|
|
1345
|
+
return json.dumps({
|
|
1346
|
+
"success": True,
|
|
1347
|
+
"message": f"私信已发送给UID:{receiver_uid}",
|
|
1348
|
+
"data": result if isinstance(result, dict) else str(result),
|
|
1349
|
+
}, ensure_ascii=False)
|
|
1350
|
+
except Exception as e:
|
|
1351
|
+
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
1352
|
+
|
|
1353
|
+
|
|
1354
|
+
# ========== Tool 22: 未读消息 ==========
|
|
1355
|
+
|
|
1356
|
+
@tool()
|
|
1357
|
+
async def bili_unread_messages() -> str:
|
|
1358
|
+
"""
|
|
1359
|
+
获取B站未读消息数(私信、@、回复、点赞等)
|
|
1360
|
+
|
|
1361
|
+
Returns:
|
|
1362
|
+
各类未读消息数量
|
|
1363
|
+
"""
|
|
1364
|
+
cred = get_cred()
|
|
1365
|
+
try:
|
|
1366
|
+
result = await session.get_unread_messages(credential=cred)
|
|
1367
|
+
return json.dumps(result, ensure_ascii=False)
|
|
1368
|
+
except Exception as e:
|
|
1369
|
+
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
# ========== Tool 23: 最近收到的回复 ==========
|
|
1373
|
+
|
|
1374
|
+
@tool()
|
|
1375
|
+
async def bili_received_replies() -> str:
|
|
1376
|
+
"""
|
|
1377
|
+
获取最近收到的评论回复通知
|
|
1378
|
+
|
|
1379
|
+
Returns:
|
|
1380
|
+
回复列表
|
|
1381
|
+
"""
|
|
1382
|
+
cred = get_cred()
|
|
1383
|
+
try:
|
|
1384
|
+
result = await session.get_replies(credential=cred)
|
|
1385
|
+
return json.dumps(result, ensure_ascii=False)
|
|
1386
|
+
except Exception as e:
|
|
1387
|
+
return json.dumps({"error": str(e)}, ensure_ascii=False)
|
|
1388
|
+
|
|
1389
|
+
|
|
1390
|
+
# ========== Tool 24: 最近收到的@和点赞 ==========
|
|
1391
|
+
|
|
1392
|
+
@tool()
|
|
1393
|
+
async def bili_received_at_and_likes() -> str:
|
|
1394
|
+
"""
|
|
1395
|
+
获取最近收到的@提及和点赞通知
|
|
1396
|
+
|
|
1397
|
+
Returns:
|
|
1398
|
+
包含 at 和 likes 两部分的通知数据
|
|
1399
|
+
"""
|
|
1400
|
+
cred = get_cred()
|
|
1401
|
+
result = {}
|
|
1402
|
+
try:
|
|
1403
|
+
result["at"] = await session.get_at(credential=cred)
|
|
1404
|
+
except Exception as e:
|
|
1405
|
+
result["at_error"] = str(e)
|
|
1406
|
+
try:
|
|
1407
|
+
result["likes"] = await session.get_likes(credential=cred)
|
|
1408
|
+
except Exception as e:
|
|
1409
|
+
result["likes_error"] = str(e)
|
|
1410
|
+
return json.dumps(result, ensure_ascii=False)
|
|
1411
|
+
|
|
1412
|
+
|
|
1413
|
+
# ========== 启动 ==========
|
|
1414
|
+
|
|
1415
|
+
if __name__ == "__main__":
|
|
1365
1416
|
mcp.run(transport="stdio")
|