dsh-deepmoyu 2.7.1 → 2.8.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 +9 -0
- package/CHANGELOG.md +43 -0
- package/CONTRIBUTING.md +1 -0
- package/README.md +10 -3
- package/SECURITY.md +4 -0
- package/client.js +509 -296
- package/docs/ACTIVITY_TUNING.md +50 -0
- package/docs/CONTINUOUS_BROWSING.md +35 -0
- package/docs/FUN_FEATURE_REFERENCES.md +128 -0
- package/docs/LOCAL_COMPAT_AUDIT_2026-11-12.md +58 -0
- package/docs/NATURAL_AUTONOMY.md +91 -0
- package/docs/PRIVACY.md +7 -0
- package/docs/RELEASE_2_8_0.md +27 -0
- package/docs/RELEASE_AUDIT.md +16 -2
- package/docs/RELEASING.md +15 -0
- package/docs/TEST_MATRIX.md +9 -0
- package/docs/WORKFLOW_REFERENCES.md +57 -0
- package/docs/tool-evidence.json +6 -1
- package/index.js +941 -268
- package/lib/asr.py +22 -0
- package/lib/background-tools.mjs +1 -1
- package/lib/browse-preferences.mjs +154 -0
- package/lib/browse-progress.mjs +165 -0
- package/lib/comment.mjs +29 -46
- package/lib/config-policy.mjs +2 -1
- package/lib/curiosity.mjs +153 -0
- package/lib/favops.py +38 -6
- package/lib/learn-review.mjs +13 -3
- package/lib/learn-store.mjs +7 -0
- package/lib/learn.mjs +234 -69
- package/lib/login.mjs +20 -3
- package/lib/mcp.mjs +5 -2
- package/lib/memory-center.mjs +21 -19
- package/lib/mirror.mjs +15 -1
- package/lib/mode.mjs +23 -9
- package/lib/model-relay-adapter.mjs +1 -1
- package/lib/model-relay.mjs +21 -4
- package/lib/pace.mjs +15 -7
- package/lib/persona.mjs +41 -30
- package/lib/pet-session.mjs +9 -3
- package/lib/product-status.mjs +38 -2
- package/lib/prompts.mjs +38 -37
- package/lib/pv-session.mjs +16 -1
- package/lib/pv.mjs +18 -3
- package/lib/pv.py +168 -30
- package/lib/pv_cover.py +11 -1
- package/lib/pv_editorial.py +28 -4
- package/lib/pv_plan.py +20 -2
- package/lib/pv_verify.py +14 -0
- package/lib/session-continuation.mjs +47 -0
- package/lib/state.mjs +6 -2
- package/lib/sticker-automation.mjs +16 -2
- package/lib/sticker.mjs +4 -1
- package/lib/uvfetch.mjs +38 -12
- package/package.json +1 -1
- package/presets/deepmoyu.patch.yml +8 -10
- package/prompts/duty.md +30 -38
- package/vendor/bilibili-mcp/mcp_server.py +204 -47
- package/vendor/bilibili-mcp/runtime_guard.py +164 -31
|
@@ -131,16 +131,17 @@ def _guard_blocked(reservation, kind="send", why=""):
|
|
|
131
131
|
return json.dumps(out, ensure_ascii=False)
|
|
132
132
|
if kind == "read":
|
|
133
133
|
if status == "pending":
|
|
134
|
-
message = "
|
|
134
|
+
message = "另一个进程正在取这条字幕,先看其他候选,完成后可从缓存补查"
|
|
135
135
|
else:
|
|
136
136
|
message = "这条已经刷过了(永久账本里有,重启也不会丢)—— 换一条"
|
|
137
137
|
out = {
|
|
138
|
-
"already_seen":
|
|
138
|
+
"already_seen": status == "seen",
|
|
139
139
|
"seen_recorded": False,
|
|
140
140
|
"seen_at": at,
|
|
141
141
|
"guard_status": status,
|
|
142
142
|
"message": message,
|
|
143
|
-
"hint": "
|
|
143
|
+
"hint": "已读标记用于选片去重,不禁止补查原文。读取正在进行时先处理其他候选,不要连续轮询。",
|
|
144
|
+
"next_action": "read-another-candidate",
|
|
144
145
|
}
|
|
145
146
|
if reservation.get("title"):
|
|
146
147
|
out["title"] = reservation.get("title")
|
|
@@ -149,8 +150,10 @@ def _guard_blocked(reservation, kind="send", why=""):
|
|
|
149
150
|
return json.dumps({
|
|
150
151
|
"success": False, "already_replied": True, "retry_blocked": True,
|
|
151
152
|
"guard_status": "unknown", "seen_at": at,
|
|
153
|
+
"slot_key": reservation.get("key", ""),
|
|
152
154
|
"error": "上一次发出去的结果不确定(超时 / 回执不明)—— 不许自动重发",
|
|
153
|
-
"hint": "先自己去评论区 /
|
|
155
|
+
"hint": ("先自己去评论区 / 私信里看一眼到底发出去没有;**确认没发出去**后,用 "
|
|
156
|
+
"bili_guard_status(release=['这条的 slot_key']) 人工解除 unknown 槽位(P1-2),再重试"),
|
|
154
157
|
}, ensure_ascii=False)
|
|
155
158
|
if status == "pending":
|
|
156
159
|
return json.dumps({
|
|
@@ -167,6 +170,18 @@ def _guard_blocked(reservation, kind="send", why=""):
|
|
|
167
170
|
}, ensure_ascii=False)
|
|
168
171
|
|
|
169
172
|
|
|
173
|
+
# P3-46: 守卫库被锁(SQLite busy>15s)时的统一 fail-closed 话术,措辞对齐 bili_subtitle
|
|
174
|
+
# 的「判重账本不可用」兜底回执 —— 原始 traceback 绝不能直接漏给模型。
|
|
175
|
+
_GUARD_LOCKED_ERROR = "判重账本暂不可用(可能被锁),先别重发;稍后用 bili_guard_status 核对坑位状态"
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _guard_unavailable(exc) -> str:
|
|
179
|
+
"""占坑阶段守卫异常 → JSON 错误回执(此时还没发任何东西,可以稍后重试)。"""
|
|
180
|
+
return json.dumps({"success": False, "error": _GUARD_LOCKED_ERROR,
|
|
181
|
+
"detail": f"{type(exc).__name__}: {exc}",
|
|
182
|
+
"guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
|
|
183
|
+
|
|
184
|
+
|
|
170
185
|
async def _guarded_send(guard, reservation, runner, record=None):
|
|
171
186
|
"""在**已经占好坑**的前提下真正发出去,并如实落账。
|
|
172
187
|
|
|
@@ -196,7 +211,13 @@ async def _guarded_send(guard, reservation, runner, record=None):
|
|
|
196
211
|
return None
|
|
197
212
|
|
|
198
213
|
def _rejected(error, result, code):
|
|
199
|
-
|
|
214
|
+
try:
|
|
215
|
+
pace = guard.reject_send(reservation, code)
|
|
216
|
+
except Exception as exc:
|
|
217
|
+
# P3-46: 拒绝落账失败(库被锁)不能裸抛 —— 事务已回滚、坑位留在 pending 正好挡重发,
|
|
218
|
+
# 回执转 fail-closed(guard_status=unknown ⇒ 调用方会回「别自动重发」的话术)。
|
|
219
|
+
return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
|
|
220
|
+
"result": result, "code": code, "ledger_detail": f"{type(exc).__name__}: {exc}"}
|
|
200
221
|
out = {"guard_status": "rejected", "error": error, "retry_blocked": False,
|
|
201
222
|
"result": result, "code": code}
|
|
202
223
|
if code == 49044:
|
|
@@ -232,8 +253,15 @@ async def _guarded_send(guard, reservation, runner, record=None):
|
|
|
232
253
|
return {"guard_status": "unknown", "error": str(result.get("error") or "没有确定的回执"),
|
|
233
254
|
"retry_blocked": True, "result": result, "code": code}
|
|
234
255
|
|
|
235
|
-
|
|
236
|
-
|
|
256
|
+
try:
|
|
257
|
+
guard.commit_send(reservation, record)
|
|
258
|
+
except Exception as exc:
|
|
259
|
+
# P3-46: 落账失败(库被锁 >15s)不能裸抛 —— 发送本身可能已成功,坑位留在 pending
|
|
260
|
+
# 挡住重发;回执转 fail-closed JSON 并指引去 bili_guard_status 核对,而不是 traceback。
|
|
261
|
+
return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
|
|
262
|
+
"result": result, "code": None, "ledger_detail": f"{type(exc).__name__}: {exc}"}
|
|
263
|
+
return {"guard_status": "sent", "error": "", "retry_blocked": False, "result": result, "code": None,
|
|
264
|
+
"pace_token": reservation.get("token", "")}
|
|
237
265
|
|
|
238
266
|
|
|
239
267
|
def ts_to_day(ts) -> str:
|
|
@@ -407,10 +435,13 @@ async def bili_login_check() -> str:
|
|
|
407
435
|
|
|
408
436
|
elif state == QrCodeLoginEvents.TIMEOUT:
|
|
409
437
|
_login_session = None
|
|
410
|
-
#
|
|
438
|
+
# 清理二维码文件(P3-47: Windows 下 PNG 被看图程序占用会 PermissionError,别让它炸回执)
|
|
411
439
|
qr_file = Path(__file__).parent / "qrcode_login.png"
|
|
412
440
|
if qr_file.exists():
|
|
413
|
-
|
|
441
|
+
try:
|
|
442
|
+
qr_file.unlink()
|
|
443
|
+
except OSError as e:
|
|
444
|
+
print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
|
|
414
445
|
return json.dumps({
|
|
415
446
|
"status": "timeout",
|
|
416
447
|
"message": "二维码已过期,请重新调用 bili_login 生成新二维码",
|
|
@@ -431,10 +462,13 @@ async def bili_login_check() -> str:
|
|
|
431
462
|
}, f)
|
|
432
463
|
os.replace(tmp_cred, CRED_FILE)
|
|
433
464
|
_login_session = None
|
|
434
|
-
#
|
|
465
|
+
# 清理二维码文件(P3-47: 删失败只记 stderr —— DONE 分支反复重入比带着孤儿 PNG 重坏了)
|
|
435
466
|
qr_file = Path(__file__).parent / "qrcode_login.png"
|
|
436
467
|
if qr_file.exists():
|
|
437
|
-
|
|
468
|
+
try:
|
|
469
|
+
qr_file.unlink()
|
|
470
|
+
except OSError as e:
|
|
471
|
+
print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
|
|
438
472
|
return json.dumps({
|
|
439
473
|
"status": "done",
|
|
440
474
|
"message": "登录成功!凭证已保存,现在可以使用所有B站功能了",
|
|
@@ -484,7 +518,7 @@ async def bili_check_credential() -> str:
|
|
|
484
518
|
# ========== Tool 1: 搜索视频 ==========
|
|
485
519
|
|
|
486
520
|
@tool()
|
|
487
|
-
async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", days: int = 0) -> str:
|
|
521
|
+
async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", days: int = 0, page: StrictInt = 1) -> str:
|
|
488
522
|
"""
|
|
489
523
|
搜索B站视频
|
|
490
524
|
|
|
@@ -493,12 +527,16 @@ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", day
|
|
|
493
527
|
num: 返回视频数量,默认10,最大50
|
|
494
528
|
order: 排序方式 totalrank=综合 click=播放量 pubdate=最新 dm=弹幕
|
|
495
529
|
days: 只看最近几天发布的(0=不限)
|
|
530
|
+
page: 搜索页码,1至50,默认1;同一关键词可翻下一页找新候选
|
|
496
531
|
|
|
497
532
|
Returns:
|
|
498
533
|
JSON格式的视频列表,包含标题、BV号、播放量、评论数、UP主、发布时间等
|
|
499
534
|
"""
|
|
500
535
|
from datetime import datetime
|
|
501
536
|
|
|
537
|
+
if type(page) is not int or not 1 <= page <= 50:
|
|
538
|
+
return json.dumps({"ok": False, "error": "page 需要1至50之间的整数"}, ensure_ascii=False)
|
|
539
|
+
|
|
502
540
|
def _pubdate_str(ts) -> str:
|
|
503
541
|
"""unix 秒 → 本地时区 YYYY-MM-DD;拿不到给空串。"""
|
|
504
542
|
try:
|
|
@@ -517,7 +555,7 @@ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", day
|
|
|
517
555
|
result = await search.search_by_type(
|
|
518
556
|
keyword=keyword,
|
|
519
557
|
search_type=search.SearchObjectType.VIDEO,
|
|
520
|
-
page=
|
|
558
|
+
page=page,
|
|
521
559
|
order_type=order_enum,
|
|
522
560
|
)
|
|
523
561
|
|
|
@@ -558,7 +596,29 @@ async def bili_search(keyword: str, num: int = 10, order: str = "totalrank", day
|
|
|
558
596
|
videos = videos[:min(num, 5)]
|
|
559
597
|
hint = f"近 {days} 天没有匹配的,下面是综合排序的兜底结果"
|
|
560
598
|
|
|
561
|
-
|
|
599
|
+
def _count(value):
|
|
600
|
+
if isinstance(value, bool):
|
|
601
|
+
return None
|
|
602
|
+
if isinstance(value, int) and value >= 0:
|
|
603
|
+
return value
|
|
604
|
+
if isinstance(value, str) and value.isdigit():
|
|
605
|
+
return int(value)
|
|
606
|
+
return None
|
|
607
|
+
|
|
608
|
+
# Do not infer exhaustion from our num/date filtering or a short result list.
|
|
609
|
+
# Unknown platform pagination remains null rather than claiming another page exists.
|
|
610
|
+
total_pages = _count(result.get("numPages"))
|
|
611
|
+
total_results = _count(result.get("numResults"))
|
|
612
|
+
page_size = _count(result.get("pagesize", result.get("page_size")))
|
|
613
|
+
has_more = None
|
|
614
|
+
if total_pages is not None:
|
|
615
|
+
has_more = page < min(total_pages, 50)
|
|
616
|
+
elif total_results == 0:
|
|
617
|
+
has_more = False
|
|
618
|
+
elif total_results is not None and page_size:
|
|
619
|
+
has_more = page < 50 and page * page_size < total_results
|
|
620
|
+
out = {"keyword": keyword, "count": len(videos), "days": days, "page": page,
|
|
621
|
+
"has_more": has_more, "videos": videos}
|
|
562
622
|
if filtered_out_all:
|
|
563
623
|
out["filtered_out_all"] = True
|
|
564
624
|
out["hint"] = hint
|
|
@@ -758,6 +818,31 @@ async def _fetch_subtitle(bvid: str, with_time: bool = False) -> str:
|
|
|
758
818
|
return json.dumps(stringify_ids(out), ensure_ascii=False)
|
|
759
819
|
|
|
760
820
|
|
|
821
|
+
def _subtitle_view(payload, with_time, *, cached=False):
|
|
822
|
+
"""Render both timestamp modes from the same cached, public source snapshot."""
|
|
823
|
+
out = dict(payload)
|
|
824
|
+
segments = payload.get("segments_detail")
|
|
825
|
+
if isinstance(segments, list) and segments:
|
|
826
|
+
text = []
|
|
827
|
+
for item in segments:
|
|
828
|
+
content = str(item.get("s") or "") if isinstance(item, dict) else ""
|
|
829
|
+
if not content.strip():
|
|
830
|
+
continue
|
|
831
|
+
try:
|
|
832
|
+
second = max(0.0, float(item.get("t") or 0))
|
|
833
|
+
except (TypeError, ValueError):
|
|
834
|
+
second = 0.0
|
|
835
|
+
prefix = f"[{int(second // 60):02d}:{second % 60:04.1f}] " if with_time else ""
|
|
836
|
+
text.append(prefix + content)
|
|
837
|
+
out["text"] = "\n".join(text)
|
|
838
|
+
if not with_time:
|
|
839
|
+
out.pop("segments_detail", None)
|
|
840
|
+
out.update(cached=cached, seen_recorded=not cached, already_seen=cached)
|
|
841
|
+
if cached:
|
|
842
|
+
out["hint"] = "这是上次实际取得的字幕快照,可补查时间点;不会重复抓取或增加新看片数。"
|
|
843
|
+
return out
|
|
844
|
+
|
|
845
|
+
|
|
761
846
|
@tool()
|
|
762
847
|
async def bili_subtitle(bvid: str, with_time: bool = False) -> str:
|
|
763
848
|
"""
|
|
@@ -769,18 +854,28 @@ async def bili_subtitle(bvid: str, with_time: bool = False) -> str:
|
|
|
769
854
|
|
|
770
855
|
Returns:
|
|
771
856
|
视频的完整字幕文本(含 duration;with_time=True 时含时间戳与分段时间轴)。
|
|
772
|
-
|
|
857
|
+
已读视频优先返回本地字幕快照,可补查时间点;缺少快照时取一次原文。
|
|
858
|
+
永久历史只用于选片去重,读请求仍有跨进程单飞租约。
|
|
773
859
|
"""
|
|
774
860
|
guard = _get_runtime_guard()
|
|
775
861
|
try:
|
|
776
|
-
|
|
862
|
+
cached = guard.cached_subtitle(bvid)
|
|
863
|
+
if cached is not None:
|
|
864
|
+
return json.dumps(_subtitle_view(cached, with_time, cached=True), ensure_ascii=False)
|
|
865
|
+
reservation = guard.reserve_subtitle(bvid)
|
|
777
866
|
except Exception as e:
|
|
778
867
|
return json.dumps({"error": f"判重账本不可用,先不抓字幕:{e}"}, ensure_ascii=False)
|
|
779
868
|
if not reservation.get("allowed"):
|
|
780
869
|
return _guard_blocked(reservation, "read")
|
|
781
870
|
|
|
782
871
|
try:
|
|
783
|
-
|
|
872
|
+
# Another reader may have published between our first cache lookup and this lease.
|
|
873
|
+
cached = guard.cached_subtitle(bvid)
|
|
874
|
+
if cached is not None:
|
|
875
|
+
guard.release(reservation)
|
|
876
|
+
return json.dumps(_subtitle_view(cached, with_time, cached=True), ensure_ascii=False)
|
|
877
|
+
# Always keep the timeline so a later with_time=True needs no second network read.
|
|
878
|
+
raw = await asyncio.wait_for(_fetch_subtitle(bvid, True), timeout=READ_TIMEOUT_SECONDS)
|
|
784
879
|
except BaseException:
|
|
785
880
|
# 读是幂等的:超时 / 出错都该把坑退掉,下次还能重试
|
|
786
881
|
guard.release(reservation)
|
|
@@ -805,9 +900,15 @@ async def bili_subtitle(bvid: str, with_time: bool = False) -> str:
|
|
|
805
900
|
guard.release(reservation)
|
|
806
901
|
return json.dumps(payload, ensure_ascii=False)
|
|
807
902
|
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
903
|
+
payload["bvid"] = bvid
|
|
904
|
+
try:
|
|
905
|
+
stored = guard.commit_subtitle(reservation, payload)
|
|
906
|
+
except Exception:
|
|
907
|
+
guard.release(reservation)
|
|
908
|
+
raise
|
|
909
|
+
out = _subtitle_view(payload, with_time)
|
|
910
|
+
out["cache_stored"] = stored
|
|
911
|
+
return json.dumps(out, ensure_ascii=False)
|
|
811
912
|
|
|
812
913
|
|
|
813
914
|
# ========== Tool 4: 获取弹幕 ==========
|
|
@@ -992,13 +1093,17 @@ async def bili_reply(bvid: str, text: str, rpid: IdInput = "0", root: IdInput =
|
|
|
992
1093
|
"guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
|
|
993
1094
|
|
|
994
1095
|
# ② 占坑:同一条评论(rpid)全机器只允许发一次;新评论按 BV 也只允许留一条
|
|
995
|
-
guard = _get_runtime_guard()
|
|
996
1096
|
try:
|
|
997
1097
|
key = key_video_reply(bvid, rpid)
|
|
998
1098
|
except Exception as e:
|
|
999
1099
|
return json.dumps({"success": False, "error": str(e),
|
|
1000
1100
|
"guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
|
|
1001
|
-
|
|
1101
|
+
try:
|
|
1102
|
+
guard = _get_runtime_guard()
|
|
1103
|
+
# P3-46: SQLite busy>15s 的原始异常不能外泄 —— 包成 fail-closed JSON 回执
|
|
1104
|
+
reservation = guard.reserve_send(key, cross_channel_keys(rpid))
|
|
1105
|
+
except Exception as e:
|
|
1106
|
+
return _guard_unavailable(e)
|
|
1002
1107
|
if not reservation.get("allowed"):
|
|
1003
1108
|
return _guard_blocked(reservation, "send",
|
|
1004
1109
|
"这条评论已经回过了" if rpid != "0" else "这条视频已经留过一条评论了")
|
|
@@ -1052,7 +1157,7 @@ async def bili_reply(bvid: str, text: str, rpid: IdInput = "0", root: IdInput =
|
|
|
1052
1157
|
# ④ 把回执里的 rpid 带回去:判重表要靠它记「我们发过哪条」,宿主的评论复核
|
|
1053
1158
|
# (lib/verify.mjs)也要拿它去 B站 查公开可见性。只回 success 的话那条链是断的。
|
|
1054
1159
|
result = outcome["result"]
|
|
1055
|
-
out = {"success": True, "message": "评论发送成功", "guard_status": "sent"}
|
|
1160
|
+
out = {"success": True, "message": "评论发送成功", "guard_status": "sent", "pace_token": outcome["pace_token"]}
|
|
1056
1161
|
if isinstance(result, dict):
|
|
1057
1162
|
for name in ("rpid", "rpid_str", "root", "parent"):
|
|
1058
1163
|
val = result.get(name)
|
|
@@ -1193,10 +1298,14 @@ async def bili_send_dynamic(
|
|
|
1193
1298
|
|
|
1194
1299
|
text = text.strip()
|
|
1195
1300
|
image_paths = [str(path).strip() for path in (images or [])[:9] if str(path or "").strip()]
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1301
|
+
try:
|
|
1302
|
+
guard = _get_runtime_guard()
|
|
1303
|
+
# P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
|
|
1304
|
+
reservation = guard.reserve_send(key_moment("dynamic", {
|
|
1305
|
+
"text": text, "images": image_paths, "topic_id": str(topic_id), "schedule_time": schedule_time,
|
|
1306
|
+
}))
|
|
1307
|
+
except Exception as exc:
|
|
1308
|
+
return _guard_unavailable(exc)
|
|
1200
1309
|
if not reservation["allowed"]:
|
|
1201
1310
|
return _guard_blocked(reservation)
|
|
1202
1311
|
try:
|
|
@@ -1235,6 +1344,7 @@ async def bili_send_dynamic(
|
|
|
1235
1344
|
return json.dumps(payload, ensure_ascii=False)
|
|
1236
1345
|
result = outcome["result"]
|
|
1237
1346
|
return json.dumps({"success": True, "message": "动态发布成功", "guard_status": "sent",
|
|
1347
|
+
"pace_token": outcome["pace_token"],
|
|
1238
1348
|
"data": stringify_ids(result) if isinstance(result, dict) else str(result)}, ensure_ascii=False)
|
|
1239
1349
|
|
|
1240
1350
|
|
|
@@ -1272,6 +1382,7 @@ async def bili_upload_video(
|
|
|
1272
1382
|
|
|
1273
1383
|
Returns:
|
|
1274
1384
|
上传结果,包含BV号
|
|
1385
|
+
⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
|
|
1275
1386
|
"""
|
|
1276
1387
|
# ---- 参数校验 ----
|
|
1277
1388
|
if not video_path or not video_path.strip():
|
|
@@ -1333,12 +1444,20 @@ async def bili_upload_video(
|
|
|
1333
1444
|
async def on_event(data: dict):
|
|
1334
1445
|
progress_info["phase"] = str(data)
|
|
1335
1446
|
|
|
1336
|
-
|
|
1447
|
+
# P2-17: uploader.start() 没有超时的话,挂死的上传在客户端超时后仍在后台继续,
|
|
1448
|
+
# 公开投稿完成却无任何记录 —— 这里给 15 分钟墙钟,超时明确报错。
|
|
1449
|
+
result = await asyncio.wait_for(uploader.start(), timeout=900)
|
|
1337
1450
|
return json.dumps({
|
|
1338
1451
|
"success": True,
|
|
1339
1452
|
"message": "视频上传成功",
|
|
1340
1453
|
"data": result if isinstance(result, dict) else str(result),
|
|
1341
1454
|
}, ensure_ascii=False)
|
|
1455
|
+
except asyncio.TimeoutError:
|
|
1456
|
+
return json.dumps({
|
|
1457
|
+
"success": False,
|
|
1458
|
+
"error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
|
|
1459
|
+
"last_phase": progress_info.get("phase", "unknown") if "progress_info" in dir() else "init",
|
|
1460
|
+
}, ensure_ascii=False)
|
|
1342
1461
|
except Exception as e:
|
|
1343
1462
|
return json.dumps({
|
|
1344
1463
|
"success": False,
|
|
@@ -1375,6 +1494,7 @@ async def bili_upload_video_multi(
|
|
|
1375
1494
|
|
|
1376
1495
|
Returns:
|
|
1377
1496
|
上传结果
|
|
1497
|
+
⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
|
|
1378
1498
|
"""
|
|
1379
1499
|
cred = get_cred()
|
|
1380
1500
|
|
|
@@ -1418,12 +1538,18 @@ async def bili_upload_video_multi(
|
|
|
1418
1538
|
|
|
1419
1539
|
uploader = VideoUploader(pages=pages, meta=meta, credential=cred)
|
|
1420
1540
|
|
|
1421
|
-
|
|
1541
|
+
# P2-17: 同 bili_upload_video —— 15 分钟墙钟,挂死的上传不能在后台无人知晓地继续
|
|
1542
|
+
result = await asyncio.wait_for(uploader.start(), timeout=900)
|
|
1422
1543
|
return json.dumps({
|
|
1423
1544
|
"success": True,
|
|
1424
1545
|
"message": f"多P视频上传成功(共{len(pages)}P)",
|
|
1425
1546
|
"data": result if isinstance(result, dict) else str(result),
|
|
1426
1547
|
}, ensure_ascii=False)
|
|
1548
|
+
except asyncio.TimeoutError:
|
|
1549
|
+
return json.dumps({
|
|
1550
|
+
"success": False,
|
|
1551
|
+
"error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
|
|
1552
|
+
}, ensure_ascii=False)
|
|
1427
1553
|
except Exception as e:
|
|
1428
1554
|
return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
|
|
1429
1555
|
|
|
@@ -1450,10 +1576,14 @@ async def bili_send_opus(
|
|
|
1450
1576
|
发布结果
|
|
1451
1577
|
"""
|
|
1452
1578
|
image_paths = [str(path).strip() for path in (images or []) if str(path or "").strip()]
|
|
1453
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
1456
|
-
|
|
1579
|
+
try:
|
|
1580
|
+
guard = _get_runtime_guard()
|
|
1581
|
+
# P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
|
|
1582
|
+
reservation = guard.reserve_send(key_moment("opus", {
|
|
1583
|
+
"title": title, "content": content, "images": image_paths, "category_id": str(category_id),
|
|
1584
|
+
}))
|
|
1585
|
+
except Exception as exc:
|
|
1586
|
+
return _guard_unavailable(exc)
|
|
1457
1587
|
if not reservation["allowed"]:
|
|
1458
1588
|
return _guard_blocked(reservation)
|
|
1459
1589
|
try:
|
|
@@ -1487,6 +1617,7 @@ async def bili_send_opus(
|
|
|
1487
1617
|
return json.dumps(payload, ensure_ascii=False)
|
|
1488
1618
|
result = outcome["result"]
|
|
1489
1619
|
return json.dumps({"success": True, "message": "图文发布成功", "guard_status": "sent",
|
|
1620
|
+
"pace_token": outcome["pace_token"],
|
|
1490
1621
|
"data": stringify_ids(result) if isinstance(result, dict) else str(result)}, ensure_ascii=False)
|
|
1491
1622
|
|
|
1492
1623
|
|
|
@@ -1798,11 +1929,14 @@ async def bili_favorite_lists(uid: int = 0) -> str:
|
|
|
1798
1929
|
"""
|
|
1799
1930
|
cred = get_cred()
|
|
1800
1931
|
if uid == 0:
|
|
1801
|
-
# 从凭证获取自己的UID
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1932
|
+
# 从凭证获取自己的UID(P3-43: 改走硬化的 load_credential —— 原来裸 open 读凭证文件,
|
|
1933
|
+
# dedeuserid 空串 → ValueError、文件中途被删 → FileNotFoundError,整工具裸 traceback)
|
|
1934
|
+
own = load_credential()
|
|
1935
|
+
dede = str(own.dedeuserid).strip() if own else ""
|
|
1936
|
+
if not dede.isdigit():
|
|
1937
|
+
return json.dumps({"error": "未登录或凭证不可读:拿不到自己的 UID(dedeuserid 为空或缺失),"
|
|
1938
|
+
"请先调用 bili_login 重新扫码登录"}, ensure_ascii=False)
|
|
1939
|
+
uid = int(dede)
|
|
1806
1940
|
|
|
1807
1941
|
result = await favorite_list.get_video_favorite_list(uid=uid, credential=cred)
|
|
1808
1942
|
fav_lists = []
|
|
@@ -1890,13 +2024,17 @@ async def bili_send_message(receiver_uid: IdInput, text: str, msg_seqno: IdInput
|
|
|
1890
2024
|
return json.dumps({"success": False, "error": str(e),
|
|
1891
2025
|
"guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
|
|
1892
2026
|
|
|
1893
|
-
guard = _get_runtime_guard()
|
|
1894
2027
|
try:
|
|
1895
2028
|
key = key_dm_reply(receiver_uid, body, msg_seqno)
|
|
1896
2029
|
except Exception as e:
|
|
1897
2030
|
return json.dumps({"success": False, "error": str(e),
|
|
1898
2031
|
"guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
|
|
1899
|
-
|
|
2032
|
+
try:
|
|
2033
|
+
guard = _get_runtime_guard()
|
|
2034
|
+
# P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
|
|
2035
|
+
reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
|
|
2036
|
+
except Exception as e:
|
|
2037
|
+
return _guard_unavailable(e)
|
|
1900
2038
|
if not reservation.get("allowed"):
|
|
1901
2039
|
return _guard_blocked(reservation, "send", f"这条私信已经回过了(UID:{receiver_uid})")
|
|
1902
2040
|
|
|
@@ -1929,6 +2067,7 @@ async def bili_send_message(receiver_uid: IdInput, text: str, msg_seqno: IdInput
|
|
|
1929
2067
|
"success": True,
|
|
1930
2068
|
"message": f"私信已发送给UID:{receiver_uid}",
|
|
1931
2069
|
"guard_status": "sent",
|
|
2070
|
+
"pace_token": outcome["pace_token"],
|
|
1932
2071
|
"data": result if isinstance(result, dict) else str(result),
|
|
1933
2072
|
}, ensure_ascii=False)
|
|
1934
2073
|
|
|
@@ -2483,7 +2622,6 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
|
|
|
2483
2622
|
return json.dumps({"success": False, "error": f"取不到这条动态的评论区:{e}",
|
|
2484
2623
|
"guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
|
|
2485
2624
|
|
|
2486
|
-
guard = _get_runtime_guard()
|
|
2487
2625
|
try:
|
|
2488
2626
|
key = key_opus_reply(oid_str, rpid)
|
|
2489
2627
|
except Exception as e:
|
|
@@ -2493,7 +2631,12 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
|
|
|
2493
2631
|
# 否则升级那一刻,昨天回过的那些今天会再回一遍。
|
|
2494
2632
|
# 🔁 2026-10-25(使用者报「回一个人一条评论两条消息」):**视频通道回过的同号 rpid 也认**
|
|
2495
2633
|
# —— 同一条评论只回一次,不管上一次走的是 bili_reply 还是 bili_reply_opus。
|
|
2496
|
-
|
|
2634
|
+
try:
|
|
2635
|
+
guard = _get_runtime_guard()
|
|
2636
|
+
# P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
|
|
2637
|
+
reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
|
|
2638
|
+
except Exception as e:
|
|
2639
|
+
return _guard_unavailable(e)
|
|
2497
2640
|
if not reservation.get("allowed"):
|
|
2498
2641
|
return _guard_blocked(reservation, "send", "这条动态评论已经回过了")
|
|
2499
2642
|
|
|
@@ -2527,6 +2670,7 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
|
|
|
2527
2670
|
# ⭐ opus_id 一律回字符串(JS 侧 1e18 这一档 number 必掉精度,账本键会错)
|
|
2528
2671
|
result = outcome["result"]
|
|
2529
2672
|
out = {"success": True, "message": "动态评论回复成功", "guard_status": "sent",
|
|
2673
|
+
"pace_token": outcome["pace_token"],
|
|
2530
2674
|
"opus_id": oid_str, "oid": str(tgt["oid"]), "ctype": tgt["ctype"]}
|
|
2531
2675
|
if isinstance(result, dict):
|
|
2532
2676
|
for name in ("rpid", "rpid_str", "root", "parent"):
|
|
@@ -2689,27 +2833,40 @@ async def bili_my_video_comments(num: int = 10, videos: int = 3) -> str:
|
|
|
2689
2833
|
# ========== Tool: 判重账本状态(只读) ==========
|
|
2690
2834
|
|
|
2691
2835
|
@tool()
|
|
2692
|
-
async def bili_guard_status(bvids: list = None, limit: int = 200) -> str:
|
|
2836
|
+
async def bili_guard_status(bvids: list = None, limit: int = 200, release: list = None) -> str:
|
|
2693
2837
|
"""
|
|
2694
2838
|
看一眼**永久判重账本**(SQLite,重启不丢、跨进程共用)
|
|
2695
2839
|
|
|
2696
2840
|
Args:
|
|
2697
2841
|
bvids: 想核对的 BV 号列表(给了就只查这几条;不给就返回最近刷过的一批)
|
|
2698
2842
|
limit: 最多返回几条(默认 200,上限 500)
|
|
2843
|
+
release: 【unknown 槽位的人工解除入口】要解除的坑位 key 列表。只在人工核实
|
|
2844
|
+
「那次结果不明的发送确实没发出去」之后才用 —— 只有 status='unknown'
|
|
2845
|
+
的行会被删(pending/sent 绝不碰);非空时本工具不再纯只读。
|
|
2699
2846
|
|
|
2700
2847
|
Returns:
|
|
2701
2848
|
counts(seen / sent / pending / unknown 各多少)、seenVideos(已刷过的 BV)、
|
|
2702
|
-
activeReads(正在读的)、recentSends(最近发过什么 ——
|
|
2849
|
+
activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**);
|
|
2850
|
+
传了 release 时额外带 released:每个 key 是否真的解除了一行
|
|
2703
2851
|
"""
|
|
2704
2852
|
guard = _get_runtime_guard()
|
|
2853
|
+
released = []
|
|
2854
|
+
if release:
|
|
2855
|
+
# P1-2: unknown 槽位的人工解除入口 —— release_unknown 只删 unknown 行,
|
|
2856
|
+
# 误传 pending/sent 的 key 时返回 false 并原样保留那行,绝不误删。
|
|
2857
|
+
for key in release:
|
|
2858
|
+
try:
|
|
2859
|
+
released.append({"key": str(key), "released": bool(guard.release_unknown(str(key)))})
|
|
2860
|
+
except Exception as e:
|
|
2861
|
+
released.append({"key": str(key), "released": False, "error": str(e)})
|
|
2705
2862
|
try:
|
|
2706
2863
|
data = guard.snapshot(bvids=bvids, limit=limit)
|
|
2707
2864
|
except Exception as e:
|
|
2708
|
-
return json.dumps({"ok": False, "error": str(e)}, ensure_ascii=False)
|
|
2709
|
-
return json.dumps({"ok": True, **data}, ensure_ascii=False)
|
|
2865
|
+
return json.dumps({"ok": False, "error": str(e), "released": released}, ensure_ascii=False)
|
|
2866
|
+
return json.dumps({"ok": True, "released": released, **data}, ensure_ascii=False)
|
|
2710
2867
|
|
|
2711
2868
|
|
|
2712
2869
|
# ========== 启动 ==========
|
|
2713
2870
|
|
|
2714
2871
|
if __name__ == "__main__":
|
|
2715
|
-
mcp.run(transport="stdio")
|
|
2872
|
+
mcp.run(transport="stdio")
|