dsh-bili-scroll 2.0.2 → 2.1.0
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 +3 -1
- package/CONTRIBUTORS.md +5 -1
- package/README.md +12 -2
- package/client.js +12 -0
- package/index.js +555 -47
- package/lib/login.mjs +19 -0
- package/lib/mcp.mjs +34 -9
- package/lib/state.mjs +11 -12
- package/lib/uvfetch.mjs +217 -161
- package/package.json +2 -1
- package/profile-template/bili-duty/cordis.patch.yml +116 -0
- package/profile-template/bili-duty/cordis.yml +4 -0
- package/profile-template/bili-duty/package.json +13 -0
- package/prompts/duty.md +109 -11
- package/vendor/bilibili-mcp/__pycache__/mcp_server.cpython-314.pyc +0 -0
- package/vendor/bilibili-mcp/mcp_server.py +557 -312
- package/vendor/bilibili-mcp/requirements.txt +2 -0
|
@@ -14,14 +14,18 @@ B站数据采集 MCP 服务,支持 OpenClaw / Claude Code / Cursor / Cline
|
|
|
14
14
|
# ③ 登录成功后凭证**原子写**(先写 .tmp 再 os.replace)
|
|
15
15
|
# ④ `bili_comments` 把抓取异常带进返回(风控 -412 不再伪装成"这条视频 0 条评论")
|
|
16
16
|
# ⑤ `bili_reply` 回执带上 `rpid` / `rpid_str` / `root` / `parent`(判重表与评论复核要用)
|
|
17
|
+
# ⑥ `bili_cover` / `bili_frames`:封面和画面帧**作为图片**返回(上游没有;
|
|
18
|
+
# 图能不能进模型上下文由宿主判断)
|
|
17
19
|
import asyncio
|
|
18
20
|
import json
|
|
19
21
|
import base64
|
|
20
22
|
import os
|
|
23
|
+
import struct
|
|
21
24
|
import sys
|
|
22
25
|
import tempfile
|
|
23
26
|
from pathlib import Path
|
|
24
27
|
from mcp.server.fastmcp import FastMCP
|
|
28
|
+
from mcp.types import ImageContent, TextContent
|
|
25
29
|
|
|
26
30
|
from bilibili_api import video, search, comment, Credential, dynamic, opus
|
|
27
31
|
from bilibili_api import hot, rank, user, session, favorite_list
|
|
@@ -53,6 +57,17 @@ def tool():
|
|
|
53
57
|
return deco
|
|
54
58
|
|
|
55
59
|
|
|
60
|
+
# ⭐⭐ 2026-10-23 安全加固(issue #39):**白名单以前只挡"注册",不挡"直接调用"** ——
|
|
61
|
+
# agent 手里有 python/shell,可以自己写脚本 import 这个文件再调函数 ⇒ 闸门形同虚设。
|
|
62
|
+
# ⇒ 每个"会往 B站 写东西"的函数入口都过一遍 _guard();没开就直接返回、什么都不做 ✓
|
|
63
|
+
def _guard(name: str) -> str:
|
|
64
|
+
"""没开这个工具就返回一句提示;开着返回空串。"""
|
|
65
|
+
if _ALLOWED and name not in _ALLOWED:
|
|
66
|
+
print(f"[bili-mcp] 拒绝调用 {name}(不在 BILI_MCP_TOOLS 白名单里)", file=sys.stderr)
|
|
67
|
+
return f"这个工具没开:{name}"
|
|
68
|
+
return ""
|
|
69
|
+
|
|
70
|
+
|
|
56
71
|
def load_credential() -> Credential:
|
|
57
72
|
"""从文件加载凭证"""
|
|
58
73
|
if not CRED_FILE.exists():
|
|
@@ -523,6 +538,8 @@ async def bili_reply(bvid: str, text: str, rpid: int = 0, root: int = 0) -> str:
|
|
|
523
538
|
Returns:
|
|
524
539
|
发表结果
|
|
525
540
|
"""
|
|
541
|
+
_deny = _guard("bili_reply")
|
|
542
|
+
if _deny: return _deny
|
|
526
543
|
cred = get_cred()
|
|
527
544
|
v = video.Video(bvid=bvid, credential=cred)
|
|
528
545
|
info = await v.get_info()
|
|
@@ -670,316 +687,6 @@ def _extract_cover_from_video(video_path: str) -> Picture:
|
|
|
670
687
|
os.remove(tmp_cover2)
|
|
671
688
|
raise RuntimeError(f"无法从视频截取封面: {video_path}")
|
|
672
689
|
|
|
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
690
|
@tool()
|
|
984
691
|
async def bili_video_zones() -> str:
|
|
985
692
|
"""
|
|
@@ -1330,6 +1037,8 @@ async def bili_send_message(receiver_uid: int, text: str) -> str:
|
|
|
1330
1037
|
Returns:
|
|
1331
1038
|
发送结果
|
|
1332
1039
|
"""
|
|
1040
|
+
_deny = _guard("bili_send_message")
|
|
1041
|
+
if _deny: return _deny
|
|
1333
1042
|
cred = get_cred()
|
|
1334
1043
|
|
|
1335
1044
|
if not text or not text.strip():
|
|
@@ -1342,10 +1051,29 @@ async def bili_send_message(receiver_uid: int, text: str) -> str:
|
|
|
1342
1051
|
msg_type=session.EventType.TEXT,
|
|
1343
1052
|
content=text.strip(),
|
|
1344
1053
|
)
|
|
1054
|
+
# ⭐ 2.0.4(issue #35):**接口没抛异常 ≠ 真的送到了** ——
|
|
1055
|
+
# 以前只要不抛就报 success: True,而 B站 可能出现 code=0 却没给投递回执(msg_key)的情况 ✗
|
|
1056
|
+
# 回执就在返回值里:有 msg_key 才算发出去;code 非 0 直接算失败 ✓
|
|
1057
|
+
data = result if isinstance(result, dict) else {}
|
|
1058
|
+
code = data.get("code", 0)
|
|
1059
|
+
inner = data.get("data")
|
|
1060
|
+
msg_key = data.get("msg_key") or (inner.get("msg_key") if isinstance(inner, dict) else None)
|
|
1061
|
+
if code not in (0, None):
|
|
1062
|
+
return json.dumps({
|
|
1063
|
+
"success": False,
|
|
1064
|
+
"error": f"接口拒绝(code={code}):{data.get('message') or data.get('msg') or '未知原因'}",
|
|
1065
|
+
"data": data,
|
|
1066
|
+
}, ensure_ascii=False)
|
|
1067
|
+
if not msg_key:
|
|
1068
|
+
return json.dumps({
|
|
1069
|
+
"success": False,
|
|
1070
|
+
"error": "接口回了成功,但**没有投递回执(msg_key)** ⇒ 可能没真正送到(常见于:对方不接受陌生人私信 / 被风控)",
|
|
1071
|
+
"data": data,
|
|
1072
|
+
}, ensure_ascii=False)
|
|
1345
1073
|
return json.dumps({
|
|
1346
1074
|
"success": True,
|
|
1347
|
-
"message": f"
|
|
1348
|
-
"data":
|
|
1075
|
+
"message": f"私信已投递(回执 {str(msg_key)[:12]}…)",
|
|
1076
|
+
"data": data,
|
|
1349
1077
|
}, ensure_ascii=False)
|
|
1350
1078
|
except Exception as e:
|
|
1351
1079
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
@@ -1410,6 +1138,523 @@ async def bili_received_at_and_likes() -> str:
|
|
|
1410
1138
|
return json.dumps(result, ensure_ascii=False)
|
|
1411
1139
|
|
|
1412
1140
|
|
|
1141
|
+
# ========== 值班轮窄口 IO(2026-10-24 · issue #96 安全加固)==========
|
|
1142
|
+
# ⭐ 值班轮现在跑在「最小权限 profile」上:**没有文件系统、没有 shell、没有 web**。
|
|
1143
|
+
# 但它还得写日报 / 判重表 / 归档、还得能转写 —— 所以这几件事做成**固定路径的窄口工具**:
|
|
1144
|
+
# · 只写 <家>/out/<日期>.md、<家>/seen.md、<家>/digest/<年-月>.md
|
|
1145
|
+
# · **不接受任何路径参数**(想写别处也写不了)✓
|
|
1146
|
+
# · 输入里的竖线会被转义,标题带 | 也不会把表格弄坏
|
|
1147
|
+
import datetime as _dt
|
|
1148
|
+
|
|
1149
|
+
DUTY_HOME = Path(os.environ.get("BILI_DUTY_HOME") or (Path.home() / ".dsh" / "bili-duty"))
|
|
1150
|
+
|
|
1151
|
+
|
|
1152
|
+
def _duty_today() -> str:
|
|
1153
|
+
return _dt.datetime.now().strftime("%Y-%m-%d")
|
|
1154
|
+
|
|
1155
|
+
|
|
1156
|
+
def _cell(s: str, limit: int = 60) -> str:
|
|
1157
|
+
"""表格单元格:去换行、转义竖线、截断。"""
|
|
1158
|
+
t = " ".join(str(s or "").split())
|
|
1159
|
+
return t.replace("|", "\\|")[:limit]
|
|
1160
|
+
|
|
1161
|
+
|
|
1162
|
+
def _append(path: Path, text: str) -> str:
|
|
1163
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1164
|
+
with open(path, "a", encoding="utf-8", newline="\n") as f:
|
|
1165
|
+
f.write(text)
|
|
1166
|
+
return str(path)
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
@tool()
|
|
1170
|
+
def duty_paths() -> str:
|
|
1171
|
+
"""
|
|
1172
|
+
这一轮允许写的三个位置(绝对路径)—— 想写东西只能通过这些 duty_* 工具,不要找别的路。
|
|
1173
|
+
|
|
1174
|
+
Returns:
|
|
1175
|
+
报告 / 判重表 / 归档 的绝对路径
|
|
1176
|
+
"""
|
|
1177
|
+
out = {
|
|
1178
|
+
"home": str(DUTY_HOME),
|
|
1179
|
+
"report": str(DUTY_HOME / "out" / (_duty_today() + ".md")),
|
|
1180
|
+
"seen": str(DUTY_HOME / "seen.md"),
|
|
1181
|
+
"digest": str(DUTY_HOME / "digest" / (_dt.datetime.now().strftime("%Y-%m") + ".md")),
|
|
1182
|
+
}
|
|
1183
|
+
return json.dumps(out, ensure_ascii=False)
|
|
1184
|
+
|
|
1185
|
+
|
|
1186
|
+
@tool()
|
|
1187
|
+
def duty_report_append(text: str) -> str:
|
|
1188
|
+
"""
|
|
1189
|
+
往**今天的日报**末尾追加一段(文件不存在会自动建)。
|
|
1190
|
+
|
|
1191
|
+
Args:
|
|
1192
|
+
text: 要追加的内容(markdown 纯文本)
|
|
1193
|
+
|
|
1194
|
+
Returns:
|
|
1195
|
+
写到了哪个文件
|
|
1196
|
+
"""
|
|
1197
|
+
body = str(text or "").replace("\r\n", "\n").strip()
|
|
1198
|
+
if not body:
|
|
1199
|
+
return "没写:内容是空的"
|
|
1200
|
+
p = DUTY_HOME / "out" / (_duty_today() + ".md")
|
|
1201
|
+
fresh = not p.exists()
|
|
1202
|
+
payload = body + "\n"
|
|
1203
|
+
if fresh:
|
|
1204
|
+
payload = "# 刷B站日报 " + _duty_today() + "\n\n" + payload
|
|
1205
|
+
return "已追加到 " + _append(p, payload)
|
|
1206
|
+
|
|
1207
|
+
|
|
1208
|
+
@tool()
|
|
1209
|
+
def duty_seen_append(bvid: str, title: str, up: str, status: str, date: str = "") -> str:
|
|
1210
|
+
"""
|
|
1211
|
+
往判重表追加一行(「挑了」= 进过报告 / 「跳过」= 看过但没进 / 「评论」= 本轮 @ 过)。
|
|
1212
|
+
|
|
1213
|
+
Args:
|
|
1214
|
+
bvid: BV 号
|
|
1215
|
+
title: 标题
|
|
1216
|
+
up: UP 主
|
|
1217
|
+
status: 挑了 / 跳过 / 评论
|
|
1218
|
+
date: 日期(YYYY-MM-DD,留空=今天)
|
|
1219
|
+
|
|
1220
|
+
Returns:
|
|
1221
|
+
写到了哪个文件
|
|
1222
|
+
"""
|
|
1223
|
+
bv = str(bvid or "").strip()
|
|
1224
|
+
if not bv:
|
|
1225
|
+
return "没写:bvid 是空的"
|
|
1226
|
+
d = (str(date or "").strip() or _duty_today())[:10]
|
|
1227
|
+
row = f"| {_cell(d, 10)} | {_cell(bv, 16)} | {_cell(title)} | {_cell(up, 30)} | {_cell(status, 8)} |\n"
|
|
1228
|
+
return "已追加到 " + _append(DUTY_HOME / "seen.md", row)
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
@tool()
|
|
1232
|
+
def duty_seen_recent(limit: int = 40, bvid: str = "") -> str:
|
|
1233
|
+
"""
|
|
1234
|
+
读判重表的最近若干行(只读,只读插件自己那份 seen.md)。
|
|
1235
|
+
|
|
1236
|
+
Args:
|
|
1237
|
+
limit: 取最近多少行(默认 40,上限 200)
|
|
1238
|
+
bvid: 只查某个 BV(留空=看全部)
|
|
1239
|
+
|
|
1240
|
+
Returns:
|
|
1241
|
+
表格行(日期 | BV | 标题 | UP | 状态);空 = 这份表还没内容
|
|
1242
|
+
"""
|
|
1243
|
+
f = DUTY_HOME / "seen.md"
|
|
1244
|
+
if not f.exists():
|
|
1245
|
+
return "(判重表还不存在)"
|
|
1246
|
+
try:
|
|
1247
|
+
lines = f.read_text(encoding="utf-8", errors="replace").splitlines()
|
|
1248
|
+
except Exception as e:
|
|
1249
|
+
return "读不到判重表:" + str(e)
|
|
1250
|
+
rows = [ln for ln in lines if ln.strip().startswith("|") and "---" not in ln and not ln.strip().startswith("| 日期")]
|
|
1251
|
+
if bvid:
|
|
1252
|
+
bv = str(bvid).strip()
|
|
1253
|
+
rows = [ln for ln in rows if bv in ln]
|
|
1254
|
+
n = max(1, min(int(limit or 40), 200))
|
|
1255
|
+
tail = rows[-n:]
|
|
1256
|
+
if not tail:
|
|
1257
|
+
return "(最近没有记录)"
|
|
1258
|
+
return "共 " + str(len(rows)) + " 行,最近 " + str(len(tail)) + " 行:\n" + "\n".join(tail)
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
@tool()
|
|
1262
|
+
def duty_digest_append(text: str) -> str:
|
|
1263
|
+
"""
|
|
1264
|
+
往**本月归档**末尾追加一段(「digest/<年-月>.md」,给"后来翻旧账"用的)。
|
|
1265
|
+
|
|
1266
|
+
Args:
|
|
1267
|
+
text: 要追加的内容
|
|
1268
|
+
|
|
1269
|
+
Returns:
|
|
1270
|
+
写到了哪个文件
|
|
1271
|
+
"""
|
|
1272
|
+
body = str(text or "").replace("\r\n", "\n").strip()
|
|
1273
|
+
if not body:
|
|
1274
|
+
return "没写:内容是空的"
|
|
1275
|
+
p = DUTY_HOME / "digest" / (_dt.datetime.now().strftime("%Y-%m") + ".md")
|
|
1276
|
+
return "已追加到 " + _append(p, body + "\n")
|
|
1277
|
+
|
|
1278
|
+
|
|
1279
|
+
@tool()
|
|
1280
|
+
def bili_transcribe(bvid: str) -> str:
|
|
1281
|
+
"""
|
|
1282
|
+
用本地 ASR(家目录里的独立环境)把一条**没有字幕**的视频转成文字。慢(几分钟)。
|
|
1283
|
+
|
|
1284
|
+
Args:
|
|
1285
|
+
bvid: BV 号
|
|
1286
|
+
|
|
1287
|
+
Returns:
|
|
1288
|
+
转写出来的文字(截断到 12000 字);空的 / 只有署名 ⇒ 当作这条没有语音
|
|
1289
|
+
"""
|
|
1290
|
+
import subprocess
|
|
1291
|
+
|
|
1292
|
+
bv = str(bvid or "").strip()
|
|
1293
|
+
if not bv.startswith("BV"):
|
|
1294
|
+
return "参数不对:要给 BV 号"
|
|
1295
|
+
py_candidates = [
|
|
1296
|
+
DUTY_HOME / ".venv-asr" / "Scripts" / "python.exe",
|
|
1297
|
+
DUTY_HOME / ".venv-asr" / "bin" / "python",
|
|
1298
|
+
]
|
|
1299
|
+
py = next((str(p) for p in py_candidates if p.exists()), "")
|
|
1300
|
+
if not py:
|
|
1301
|
+
return "转写环境没装好(设置页里有「装转写依赖」)"
|
|
1302
|
+
script = DUTY_HOME / "asr.py"
|
|
1303
|
+
if not script.exists():
|
|
1304
|
+
return "找不到 asr.py(家目录里没有转写脚本)"
|
|
1305
|
+
out = DUTY_HOME / "tmp" / (bv + ".txt")
|
|
1306
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
1307
|
+
try:
|
|
1308
|
+
r = subprocess.run(
|
|
1309
|
+
[py, str(script), bv, "-o", str(out)],
|
|
1310
|
+
capture_output=True, timeout=1800,
|
|
1311
|
+
env={**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"},
|
|
1312
|
+
)
|
|
1313
|
+
except subprocess.TimeoutExpired:
|
|
1314
|
+
return "转写超时(30 分钟)—— 这条按「读不到」处理,跳过它"
|
|
1315
|
+
except Exception as e:
|
|
1316
|
+
return "转写启动失败:" + str(e)
|
|
1317
|
+
if not out.exists():
|
|
1318
|
+
tail = (r.stderr or b"")[-400:].decode("utf-8", "replace")
|
|
1319
|
+
return "转写没产出文件(exit " + str(r.returncode) + "):" + tail
|
|
1320
|
+
text = out.read_text(encoding="utf-8", errors="replace").strip()
|
|
1321
|
+
if not text:
|
|
1322
|
+
return "(空)—— 这条没有语音,跳过它"
|
|
1323
|
+
if len(text) <= 40 and ("字幕by" in text or "字幕BY" in text):
|
|
1324
|
+
return "(只有署名「" + text[:40] + "」)—— 当没有语音处理,跳过"
|
|
1325
|
+
return text[:12000]
|
|
1326
|
+
|
|
1327
|
+
# ========== Tool 25: 看画面(封面) ==========
|
|
1328
|
+
#
|
|
1329
|
+
# 图作为 MCP image content 返回,由宿主决定能否进模型上下文。
|
|
1330
|
+
# 缩图交给 B 站图片 CDN(URL 后缀 @512w.jpg),不引 Pillow、不落临时文件。
|
|
1331
|
+
|
|
1332
|
+
_WEB_UA = (
|
|
1333
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
1334
|
+
"(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
|
|
1335
|
+
)
|
|
1336
|
+
_COVER_WIDTH = 512
|
|
1337
|
+
_HTTP_TIMEOUT = 25 # 图床 / 快照下载统一超时(秒)
|
|
1338
|
+
# 防御性上限:实测封面 23–36KB、帧 2.6–29KB,正常走不到这一行
|
|
1339
|
+
_MAX_IMAGE_BYTES = 1_500_000
|
|
1340
|
+
|
|
1341
|
+
|
|
1342
|
+
def _https(url: str) -> str:
|
|
1343
|
+
"""把 B 站的图片/资源地址统一成 https。
|
|
1344
|
+
|
|
1345
|
+
view 接口给的 `pic` 实测 10/10 都是 `http://`,而 http 不会跳转 https;
|
|
1346
|
+
图床请求再带上 SESSDATA 就等于把登录凭据明文发出去。
|
|
1347
|
+
"""
|
|
1348
|
+
u = str(url or "")
|
|
1349
|
+
if u.startswith("//"):
|
|
1350
|
+
return "https:" + u
|
|
1351
|
+
if u.startswith("http://"):
|
|
1352
|
+
return "https://" + u[len("http://"):]
|
|
1353
|
+
return u
|
|
1354
|
+
|
|
1355
|
+
|
|
1356
|
+
def _cover_url(pic: str, width: int = _COVER_WIDTH) -> str:
|
|
1357
|
+
"""封面原图地址 → 带 CDN 缩放后缀(@<宽>w.jpg)的地址,等比不裁切。"""
|
|
1358
|
+
base = _https(str(pic).split("@", 1)[0])
|
|
1359
|
+
return f"{base}@{width}w.jpg"
|
|
1360
|
+
|
|
1361
|
+
|
|
1362
|
+
def _image_headers() -> dict:
|
|
1363
|
+
"""图床请求头:只给 UA + Referer,**不带 Cookie**(图床不需要登录态)。"""
|
|
1364
|
+
return {"User-Agent": _WEB_UA, "Referer": "https://www.bilibili.com/"}
|
|
1365
|
+
|
|
1366
|
+
|
|
1367
|
+
def _text_only(payload: dict) -> list:
|
|
1368
|
+
return [TextContent(type="text", text=json.dumps(payload, ensure_ascii=False))]
|
|
1369
|
+
|
|
1370
|
+
|
|
1371
|
+
@tool()
|
|
1372
|
+
async def bili_cover(bvid: str) -> list:
|
|
1373
|
+
"""
|
|
1374
|
+
看这个视频的封面(等比缩到 512px 宽)
|
|
1375
|
+
|
|
1376
|
+
最便宜的一档"看一眼":能看出风格、标题美术、大致题材,但看不出具体发生了什么。
|
|
1377
|
+
|
|
1378
|
+
Args:
|
|
1379
|
+
bvid: 视频BV号,如"BV1uNk1YxEJQ"
|
|
1380
|
+
|
|
1381
|
+
Returns:
|
|
1382
|
+
封面图 + 一句说明(标题、时长、图多大)。
|
|
1383
|
+
图能不能真被看见取决于当前模型是否支持图片输入;不支持时会被降级成文字。
|
|
1384
|
+
"""
|
|
1385
|
+
import aiohttp
|
|
1386
|
+
|
|
1387
|
+
cred = load_credential() # 封面是公开的,没登录也取得到
|
|
1388
|
+
try:
|
|
1389
|
+
v = video.Video(bvid=bvid, credential=cred) # 格式不对时**构造时就抛**
|
|
1390
|
+
info = await v.get_info()
|
|
1391
|
+
except Exception as e:
|
|
1392
|
+
# ⚠️ 2026-10-23 实测两种失败:`62002 稿件不可见`(BV 不存在)
|
|
1393
|
+
# 和 `ArgsException`(格式不对,构造时就抛)。都收成 ok:false。
|
|
1394
|
+
return _text_only({"bvid": bvid, "ok": False, "error": f"取视频信息失败:{e}"})
|
|
1395
|
+
|
|
1396
|
+
pic = str(info.get("pic") or "")
|
|
1397
|
+
if not pic:
|
|
1398
|
+
return _text_only({"bvid": bvid, "ok": False, "error": "这个视频没有封面地址"})
|
|
1399
|
+
|
|
1400
|
+
url = _cover_url(pic)
|
|
1401
|
+
try:
|
|
1402
|
+
timeout = aiohttp.ClientTimeout(total=_HTTP_TIMEOUT)
|
|
1403
|
+
async with aiohttp.ClientSession(timeout=timeout) as s:
|
|
1404
|
+
async with s.get(url, headers=_image_headers()) as resp:
|
|
1405
|
+
if resp.status != 200:
|
|
1406
|
+
raise RuntimeError(f"HTTP {resp.status}")
|
|
1407
|
+
raw = await resp.read()
|
|
1408
|
+
mime = (resp.headers.get("Content-Type") or "image/jpeg").split(";")[0].strip()
|
|
1409
|
+
except Exception as e:
|
|
1410
|
+
return _text_only({"bvid": bvid, "ok": False, "error": f"下载封面失败:{e}"})
|
|
1411
|
+
|
|
1412
|
+
allowed = ("image/jpeg", "image/png", "image/webp", "image/gif")
|
|
1413
|
+
if mime not in allowed:
|
|
1414
|
+
return _text_only({"bvid": bvid, "ok": False, "error": f"封面不是可用的图片类型:{mime}"})
|
|
1415
|
+
if len(raw) > _MAX_IMAGE_BYTES:
|
|
1416
|
+
return _text_only({
|
|
1417
|
+
"bvid": bvid, "ok": False,
|
|
1418
|
+
"error": f"封面太大({len(raw) // 1024}KB),没放进来",
|
|
1419
|
+
})
|
|
1420
|
+
|
|
1421
|
+
return [
|
|
1422
|
+
TextContent(type="text", text=json.dumps({
|
|
1423
|
+
"bvid": bvid,
|
|
1424
|
+
"ok": True,
|
|
1425
|
+
"title": info.get("title"),
|
|
1426
|
+
"duration": info.get("duration"),
|
|
1427
|
+
"cover_kb": round(len(raw) / 1024, 1),
|
|
1428
|
+
}, ensure_ascii=False)),
|
|
1429
|
+
ImageContent(
|
|
1430
|
+
type="image",
|
|
1431
|
+
data=base64.b64encode(raw).decode("ascii"),
|
|
1432
|
+
mimeType=mime,
|
|
1433
|
+
),
|
|
1434
|
+
]
|
|
1435
|
+
|
|
1436
|
+
|
|
1437
|
+
# ========== Tool 26: 看画面(画面帧) ==========
|
|
1438
|
+
#
|
|
1439
|
+
# 画面来自 B 站自带的故事板(x/player/videoshot),不解码视频、不用 ffmpeg。
|
|
1440
|
+
# Video.get_video_snapshot() 有封装,但 pvideo 默认 True 会走老接口,必须传 False。
|
|
1441
|
+
# 裁切用 Pillow(requirements 里的 qrcode[pil] 已经带进来)。
|
|
1442
|
+
|
|
1443
|
+
_FRAME_MAX = 6
|
|
1444
|
+
_TILE_W_DEFAULT = 480
|
|
1445
|
+
_TILE_H_DEFAULT = 270
|
|
1446
|
+
|
|
1447
|
+
|
|
1448
|
+
def _snapshot_entries(data: dict, duration: float = 0.0) -> list:
|
|
1449
|
+
"""把 videoshot 的 index 归一成 [(秒, 块序号), ...]。
|
|
1450
|
+
|
|
1451
|
+
时间单位实测两种都出现过(秒 / 毫秒),只能靠和时长比较来判断:
|
|
1452
|
+
超过时长 1.5 倍才当毫秒。⚠️ 别再加"绝对值大于某个数就当毫秒"这类判据 ——
|
|
1453
|
+
6 小时视频的秒数(22163)会被误判成毫秒,三帧全塌到同一块、时间缩小 1000 倍。
|
|
1454
|
+
"""
|
|
1455
|
+
raws = []
|
|
1456
|
+
for i, item in enumerate(data.get("index") or []):
|
|
1457
|
+
ts = item.get("timestamp") if isinstance(item, dict) else item
|
|
1458
|
+
try:
|
|
1459
|
+
raws.append((float(ts), i))
|
|
1460
|
+
except (TypeError, ValueError):
|
|
1461
|
+
continue
|
|
1462
|
+
if not raws:
|
|
1463
|
+
return []
|
|
1464
|
+
|
|
1465
|
+
biggest = max(v for v, _ in raws)
|
|
1466
|
+
looks_ms = duration > 0 and biggest > duration * 1.5
|
|
1467
|
+
scale = 1000.0 if looks_ms else 1.0
|
|
1468
|
+
return [(v / scale, i) for v, i in raws]
|
|
1469
|
+
|
|
1470
|
+
|
|
1471
|
+
async def _entries_from_pvdata(data: dict) -> list:
|
|
1472
|
+
"""index 为空时的兜底:pvdata 指向一个大端 uint16 秒数数组,与 index 逐项相同。"""
|
|
1473
|
+
url = _https(data.get("pvdata") or "")
|
|
1474
|
+
if not url:
|
|
1475
|
+
return []
|
|
1476
|
+
try:
|
|
1477
|
+
raw = await _fetch_bytes(url, _image_headers())
|
|
1478
|
+
except Exception:
|
|
1479
|
+
return []
|
|
1480
|
+
if not raw or len(raw) % 2:
|
|
1481
|
+
return []
|
|
1482
|
+
count = len(raw) // 2
|
|
1483
|
+
if count > 20000: # 明显不对,别乱解
|
|
1484
|
+
return []
|
|
1485
|
+
return [(float(sec), i) for i, sec in enumerate(struct.unpack(f">{count}H", raw))]
|
|
1486
|
+
|
|
1487
|
+
|
|
1488
|
+
async def _fetch_bytes(url: str, headers: dict) -> bytes:
|
|
1489
|
+
import aiohttp
|
|
1490
|
+
|
|
1491
|
+
timeout = aiohttp.ClientTimeout(total=_HTTP_TIMEOUT)
|
|
1492
|
+
async with aiohttp.ClientSession(timeout=timeout) as s:
|
|
1493
|
+
async with s.get(_https(url), headers=headers) as resp:
|
|
1494
|
+
if resp.status != 200:
|
|
1495
|
+
raise RuntimeError(f"HTTP {resp.status}")
|
|
1496
|
+
return await resp.read()
|
|
1497
|
+
|
|
1498
|
+
|
|
1499
|
+
def _crop_tile(sheet: bytes, tile_index: int, tw: int, th: int) -> bytes:
|
|
1500
|
+
"""从拼图里裁出第 tile_index 块,返回 jpeg 字节。"""
|
|
1501
|
+
import io
|
|
1502
|
+
|
|
1503
|
+
from PIL import Image
|
|
1504
|
+
|
|
1505
|
+
img = Image.open(io.BytesIO(sheet))
|
|
1506
|
+
cols = max(1, img.width // tw)
|
|
1507
|
+
rows = max(1, img.height // th)
|
|
1508
|
+
if tile_index >= cols * rows:
|
|
1509
|
+
raise RuntimeError(f"第 {tile_index} 块超出拼图(只有 {cols}×{rows})")
|
|
1510
|
+
col, row = tile_index % cols, tile_index // cols
|
|
1511
|
+
tile = img.crop((col * tw, row * th, (col + 1) * tw, (row + 1) * th)).convert("RGB")
|
|
1512
|
+
buf = io.BytesIO()
|
|
1513
|
+
tile.save(buf, format="JPEG", quality=85)
|
|
1514
|
+
return buf.getvalue()
|
|
1515
|
+
|
|
1516
|
+
|
|
1517
|
+
@tool()
|
|
1518
|
+
async def bili_frames(bvid: str, num: int = 3, at: list | None = None) -> list:
|
|
1519
|
+
"""
|
|
1520
|
+
看这个视频里的画面(从 B 站自带的故事板里裁几帧)
|
|
1521
|
+
|
|
1522
|
+
想看"它到底拍了什么"就用它。默认按时长均匀取 3 帧;
|
|
1523
|
+
想盯某个时刻(比如字幕里提到的那一段)就传 at(单位:秒),会取**最接近的那一块**。
|
|
1524
|
+
|
|
1525
|
+
Args:
|
|
1526
|
+
bvid: 视频BV号,如"BV1uNk1YxEJQ"
|
|
1527
|
+
num: 取几帧,1-6,默认 3
|
|
1528
|
+
at: 指定时间点(秒)的列表;传了就以它为准,忽略 num
|
|
1529
|
+
|
|
1530
|
+
Returns:
|
|
1531
|
+
若干张 480×270 的画面 + 一句说明(时长、实际取到的那一刻、哪几帧失败)。
|
|
1532
|
+
失败会写进 failed。**多P 视频的故事板通常只覆盖第 1P**,返回里会用
|
|
1533
|
+
covered_sec / pages 标明实际覆盖范围。图能否被看见取决于模型是否支持图片输入。
|
|
1534
|
+
"""
|
|
1535
|
+
cred = load_credential() # 故事板不登录也能取
|
|
1536
|
+
try:
|
|
1537
|
+
v = video.Video(bvid=bvid, credential=cred)
|
|
1538
|
+
info = await v.get_info()
|
|
1539
|
+
except Exception as e:
|
|
1540
|
+
return _text_only({"bvid": bvid, "ok": False, "error": f"取视频信息失败:{e}"})
|
|
1541
|
+
duration = float(info.get("duration") or 0)
|
|
1542
|
+
pages = info.get("pages") or []
|
|
1543
|
+
cid = info.get("cid") or ((pages or [{}])[0].get("cid") or 0)
|
|
1544
|
+
if not cid:
|
|
1545
|
+
return _text_only({"bvid": bvid, "ok": False, "error": "拿不到 cid"})
|
|
1546
|
+
|
|
1547
|
+
# 故事板只覆盖第 1P:目标时间点按"实际覆盖的长度"算,别落到没采样的 P2 上
|
|
1548
|
+
covered = float(pages[0].get("duration") or duration) if pages else duration
|
|
1549
|
+
covered = covered or duration
|
|
1550
|
+
|
|
1551
|
+
# index 会偶发返回空(实测约 25%):先用 pvdata 兜底,两个都空才重试
|
|
1552
|
+
snap = {}
|
|
1553
|
+
entries = []
|
|
1554
|
+
last_err = None
|
|
1555
|
+
for attempt in range(2):
|
|
1556
|
+
try:
|
|
1557
|
+
snap = await v.get_video_snapshot(cid=cid, json_index=True, pvideo=False) or {}
|
|
1558
|
+
last_err = None
|
|
1559
|
+
except Exception as e:
|
|
1560
|
+
snap, last_err = {}, e
|
|
1561
|
+
entries = _snapshot_entries(snap, covered)
|
|
1562
|
+
if not entries:
|
|
1563
|
+
entries = await _entries_from_pvdata(snap)
|
|
1564
|
+
if entries and (snap.get("image") or []):
|
|
1565
|
+
break
|
|
1566
|
+
if attempt < 1:
|
|
1567
|
+
await asyncio.sleep(1.0)
|
|
1568
|
+
|
|
1569
|
+
sheets = snap.get("image") or []
|
|
1570
|
+
if not entries or not sheets:
|
|
1571
|
+
err = f"(最后一次错误:{last_err})" if last_err is not None else ""
|
|
1572
|
+
return _text_only({
|
|
1573
|
+
"bvid": bvid, "ok": False,
|
|
1574
|
+
"error": f"取不到画面快照(试了 2 次){err}",
|
|
1575
|
+
})
|
|
1576
|
+
|
|
1577
|
+
# 覆盖长度以**故事板实测到的那一刻**为准(比 P1 的标称时长保守,通常短 1–10%)
|
|
1578
|
+
story_max = max((e[0] for e in entries), default=0.0)
|
|
1579
|
+
if story_max > 0:
|
|
1580
|
+
covered = min(covered, story_max)
|
|
1581
|
+
|
|
1582
|
+
# 目标时间点:传了 at 就用它;否则按"覆盖长度"均分
|
|
1583
|
+
if at:
|
|
1584
|
+
targets = [min(max(0.0, float(t)), max(0.0, covered - 0.1)) for t in at][:_FRAME_MAX]
|
|
1585
|
+
else:
|
|
1586
|
+
n = max(1, min(int(num or 3), _FRAME_MAX))
|
|
1587
|
+
targets = [covered * (i + 1) / (n + 1) for i in range(n)]
|
|
1588
|
+
|
|
1589
|
+
picked = []
|
|
1590
|
+
for t in targets:
|
|
1591
|
+
ts, tile = min(entries, key=lambda e: abs(e[0] - t))
|
|
1592
|
+
picked.append((round(t, 1), round(ts, 1), tile))
|
|
1593
|
+
|
|
1594
|
+
tw = int(snap.get("img_x_size") or _TILE_W_DEFAULT)
|
|
1595
|
+
th = int(snap.get("img_y_size") or _TILE_H_DEFAULT)
|
|
1596
|
+
headers = _image_headers()
|
|
1597
|
+
|
|
1598
|
+
cache = {}
|
|
1599
|
+
|
|
1600
|
+
async def get_sheet(no: int) -> bytes:
|
|
1601
|
+
if no not in cache:
|
|
1602
|
+
if no >= len(sheets):
|
|
1603
|
+
raise RuntimeError(f"拼图 {no} 不存在(共 {len(sheets)} 张)")
|
|
1604
|
+
cache[no] = await _fetch_bytes(str(sheets[no]), headers)
|
|
1605
|
+
return cache[no]
|
|
1606
|
+
|
|
1607
|
+
# 每张拼图装几块要等第一张下来才知道(列数 = 宽 // 块宽)
|
|
1608
|
+
blocks, frames, failed = [], [], []
|
|
1609
|
+
cap = None
|
|
1610
|
+
for i, (want, got, tile) in enumerate(picked):
|
|
1611
|
+
try:
|
|
1612
|
+
if cap is None:
|
|
1613
|
+
first = await get_sheet(0)
|
|
1614
|
+
import io
|
|
1615
|
+
|
|
1616
|
+
from PIL import Image
|
|
1617
|
+
|
|
1618
|
+
im = Image.open(io.BytesIO(first))
|
|
1619
|
+
cap = max(1, (im.width // tw) * (im.height // th))
|
|
1620
|
+
sheet_no, pos = tile // cap, tile % cap
|
|
1621
|
+
raw = _crop_tile(await get_sheet(sheet_no), pos, tw, th)
|
|
1622
|
+
if len(raw) > _MAX_IMAGE_BYTES:
|
|
1623
|
+
raise RuntimeError(f"这一帧太大({len(raw) // 1024}KB)")
|
|
1624
|
+
blocks.append(ImageContent(
|
|
1625
|
+
type="image",
|
|
1626
|
+
data=base64.b64encode(raw).decode("ascii"),
|
|
1627
|
+
mimeType="image/jpeg",
|
|
1628
|
+
))
|
|
1629
|
+
frames.append({"index": i, "want_sec": want, "got_sec": got,
|
|
1630
|
+
"kb": round(len(raw) / 1024, 1)})
|
|
1631
|
+
except Exception as e:
|
|
1632
|
+
failed.append({"index": i, "want_sec": want, "got_sec": got, "reason": str(e)})
|
|
1633
|
+
|
|
1634
|
+
if not blocks:
|
|
1635
|
+
return _text_only({
|
|
1636
|
+
"bvid": bvid, "ok": False,
|
|
1637
|
+
"error": "一帧都没取到", "failed": failed,
|
|
1638
|
+
})
|
|
1639
|
+
|
|
1640
|
+
return [
|
|
1641
|
+
TextContent(type="text", text=json.dumps({
|
|
1642
|
+
"bvid": bvid,
|
|
1643
|
+
"ok": True,
|
|
1644
|
+
"title": info.get("title"),
|
|
1645
|
+
"duration": duration,
|
|
1646
|
+
"covered_sec": round(covered, 1),
|
|
1647
|
+
"pages": max(1, len(pages)),
|
|
1648
|
+
"tile": f"{tw}x{th}",
|
|
1649
|
+
"frames": frames,
|
|
1650
|
+
"failed": failed, # 空列表 = 全成
|
|
1651
|
+
"note": "want_sec 是想要的时刻,got_sec 是实际取到的那一块(故事板按固定间隔切);"
|
|
1652
|
+
"多P 视频只覆盖第 1P,看 covered_sec",
|
|
1653
|
+
}, ensure_ascii=False)),
|
|
1654
|
+
*blocks,
|
|
1655
|
+
]
|
|
1656
|
+
|
|
1657
|
+
|
|
1413
1658
|
# ========== 启动 ==========
|
|
1414
1659
|
|
|
1415
1660
|
if __name__ == "__main__":
|