dsh-bili-scroll 0.1.7 → 2.0.4
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/CONTRIBUTORS.md +18 -0
- package/LICENSE +21 -31
- package/LICENSE.bak-20261020 +31 -0
- package/NOTICE.md +18 -0
- package/README.md +39 -18
- package/client.js +343 -84
- package/index.js +1298 -93
- package/lib/asr.py +3 -0
- package/lib/dm.mjs +101 -0
- package/lib/favops.py +84 -0
- package/lib/login.mjs +19 -0
- package/lib/state.mjs +39 -8
- package/lib/uid.mjs +61 -0
- package/lib/verify.mjs +1 -1
- package/package.json +4 -2
- package/prompts/duty.md +4 -1
- package/vendor/bilibili-mcp/mcp_server.py +36 -312
|
@@ -53,6 +53,17 @@ def tool():
|
|
|
53
53
|
return deco
|
|
54
54
|
|
|
55
55
|
|
|
56
|
+
# ⭐⭐ 2026-10-23 安全加固(issue #39):**白名单以前只挡"注册",不挡"直接调用"** ——
|
|
57
|
+
# agent 手里有 python/shell,可以自己写脚本 import 这个文件再调函数 ⇒ 闸门形同虚设。
|
|
58
|
+
# ⇒ 每个"会往 B站 写东西"的函数入口都过一遍 _guard();没开就直接返回、什么都不做 ✓
|
|
59
|
+
def _guard(name: str) -> str:
|
|
60
|
+
"""没开这个工具就返回一句提示;开着返回空串。"""
|
|
61
|
+
if _ALLOWED and name not in _ALLOWED:
|
|
62
|
+
print(f"[bili-mcp] 拒绝调用 {name}(不在 BILI_MCP_TOOLS 白名单里)", file=sys.stderr)
|
|
63
|
+
return f"这个工具没开:{name}"
|
|
64
|
+
return ""
|
|
65
|
+
|
|
66
|
+
|
|
56
67
|
def load_credential() -> Credential:
|
|
57
68
|
"""从文件加载凭证"""
|
|
58
69
|
if not CRED_FILE.exists():
|
|
@@ -523,6 +534,8 @@ async def bili_reply(bvid: str, text: str, rpid: int = 0, root: int = 0) -> str:
|
|
|
523
534
|
Returns:
|
|
524
535
|
发表结果
|
|
525
536
|
"""
|
|
537
|
+
_deny = _guard("bili_reply")
|
|
538
|
+
if _deny: return _deny
|
|
526
539
|
cred = get_cred()
|
|
527
540
|
v = video.Video(bvid=bvid, credential=cred)
|
|
528
541
|
info = await v.get_info()
|
|
@@ -670,316 +683,6 @@ def _extract_cover_from_video(video_path: str) -> Picture:
|
|
|
670
683
|
os.remove(tmp_cover2)
|
|
671
684
|
raise RuntimeError(f"无法从视频截取封面: {video_path}")
|
|
672
685
|
|
|
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
686
|
@tool()
|
|
984
687
|
async def bili_video_zones() -> str:
|
|
985
688
|
"""
|
|
@@ -1330,6 +1033,8 @@ async def bili_send_message(receiver_uid: int, text: str) -> str:
|
|
|
1330
1033
|
Returns:
|
|
1331
1034
|
发送结果
|
|
1332
1035
|
"""
|
|
1036
|
+
_deny = _guard("bili_send_message")
|
|
1037
|
+
if _deny: return _deny
|
|
1333
1038
|
cred = get_cred()
|
|
1334
1039
|
|
|
1335
1040
|
if not text or not text.strip():
|
|
@@ -1342,10 +1047,29 @@ async def bili_send_message(receiver_uid: int, text: str) -> str:
|
|
|
1342
1047
|
msg_type=session.EventType.TEXT,
|
|
1343
1048
|
content=text.strip(),
|
|
1344
1049
|
)
|
|
1050
|
+
# ⭐ 2.0.4(issue #35):**接口没抛异常 ≠ 真的送到了** ——
|
|
1051
|
+
# 以前只要不抛就报 success: True,而 B站 可能出现 code=0 却没给投递回执(msg_key)的情况 ✗
|
|
1052
|
+
# 回执就在返回值里:有 msg_key 才算发出去;code 非 0 直接算失败 ✓
|
|
1053
|
+
data = result if isinstance(result, dict) else {}
|
|
1054
|
+
code = data.get("code", 0)
|
|
1055
|
+
inner = data.get("data")
|
|
1056
|
+
msg_key = data.get("msg_key") or (inner.get("msg_key") if isinstance(inner, dict) else None)
|
|
1057
|
+
if code not in (0, None):
|
|
1058
|
+
return json.dumps({
|
|
1059
|
+
"success": False,
|
|
1060
|
+
"error": f"接口拒绝(code={code}):{data.get('message') or data.get('msg') or '未知原因'}",
|
|
1061
|
+
"data": data,
|
|
1062
|
+
}, ensure_ascii=False)
|
|
1063
|
+
if not msg_key:
|
|
1064
|
+
return json.dumps({
|
|
1065
|
+
"success": False,
|
|
1066
|
+
"error": "接口回了成功,但**没有投递回执(msg_key)** ⇒ 可能没真正送到(常见于:对方不接受陌生人私信 / 被风控)",
|
|
1067
|
+
"data": data,
|
|
1068
|
+
}, ensure_ascii=False)
|
|
1345
1069
|
return json.dumps({
|
|
1346
1070
|
"success": True,
|
|
1347
|
-
"message": f"
|
|
1348
|
-
"data":
|
|
1071
|
+
"message": f"私信已投递(回执 {str(msg_key)[:12]}…)",
|
|
1072
|
+
"data": data,
|
|
1349
1073
|
}, ensure_ascii=False)
|
|
1350
1074
|
except Exception as e:
|
|
1351
1075
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|