dsh-deepmoyu 2.7.1 → 2.7.2

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.
@@ -173,14 +173,32 @@ def caption_parts(text, limit=23):
173
173
  return parts
174
174
 
175
175
 
176
+ def weight_len(s) -> float:
177
+ """P3-27: 加权字符数(CJK=1、数字=0.5、拉丁字母=0.35、其他=1)—— 与 lib/pv.py 同款。
178
+
179
+ 等权字符数会让「数字/英文读得快」的短语字幕切得慢(探针 F:英文估 3 倍偏差)。
180
+ """
181
+ total = 0.0
182
+ for ch in str(s or ""):
183
+ if "\u4e00" <= ch <= "\u9fff":
184
+ total += 1
185
+ elif ch.isascii() and ch.isdigit():
186
+ total += 0.5
187
+ elif ch.isascii() and ch.isalpha():
188
+ total += 0.35
189
+ else:
190
+ total += 1
191
+ return total
192
+
193
+
176
194
  def active_caption(text, t, duration):
177
195
  """Phrase timing is proportional within the measured utterance, not ASR."""
178
196
  if not text or not 0 <= t < duration:
179
197
  return ""
180
198
  parts = caption_parts(text)
181
- position = t / max(.01, duration) * sum(len(part) for part in parts)
199
+ position = t / max(.01, duration) * sum(max(1.0, weight_len(part)) for part in parts)
182
200
  for part in parts:
183
- position -= len(part)
201
+ position -= max(1.0, weight_len(part))
184
202
  if position < 0:
185
203
  return part
186
204
  return parts[-1]
@@ -232,6 +250,9 @@ def place_host(image, draw, state, t, colors, compact=False):
232
250
 
233
251
  def render(shot, t, duration, state):
234
252
  theme = state.get("theme", "paper")
253
+ if theme not in THEMES:
254
+ # P3-29: 未知 theme 直接 KeyError 裸崩(探针 I)—— 回退 paper,渲染继续而不是渲到一半炸
255
+ theme = "paper"
235
256
  colors = THEMES[theme]
236
257
  image = backdrop(theme).copy().convert("RGBA")
237
258
  draw = ImageDraw.Draw(image)
package/lib/pv_plan.py CHANGED
@@ -256,9 +256,27 @@ CLOSING_LINES = ("所以,这碗白米饭终于能吃了?", # ⚠️ 收
256
256
  "等本鱼把笔记放好。饭要趁热,脑袋也要开工。") # auto_plan 各出现一次,改时两处同步
257
257
 
258
258
 
259
+ def weight_len(s) -> float:
260
+ """P3-27: 加权字符数(CJK=1、数字=0.5、拉丁字母=0.35、其他=1)—— 与 lib/pv.py 同款。
261
+
262
+ 等权 len() 会给英文/数字多的镜头多算时长(它们读得快),规划预算随之虚高。
263
+ """
264
+ total = 0.0
265
+ for ch in str(s or ""):
266
+ if "\u4e00" <= ch <= "\u9fff":
267
+ total += 1
268
+ elif ch.isascii() and ch.isdigit():
269
+ total += 0.5
270
+ elif ch.isascii() and ch.isalpha():
271
+ total += 0.35
272
+ else:
273
+ total += 1
274
+ return total
275
+
276
+
259
277
  def est_dur(shot: dict) -> float:
260
- """单镜时长估算:max(2.0, 字数/3.8) + 0.35。"""
261
- return max(MIN_SHOT_SPEECH, len(str(shot.get("text") or "")) / SPEECH_RATE) + SHOT_OVERHEAD
278
+ """单镜时长估算:max(2.0, 加权字数/4.4) + 0.35。"""
279
+ return max(MIN_SHOT_SPEECH, weight_len(str(shot.get("text") or "")) / SPEECH_RATE) + SHOT_OVERHEAD
262
280
 
263
281
 
264
282
  def plan_seconds(shots: list) -> float:
package/lib/pv_verify.py CHANGED
@@ -11,11 +11,19 @@ import json
11
11
  import math
12
12
  import subprocess
13
13
  import sys
14
+ import time
14
15
  from pathlib import Path
15
16
 
16
17
  import imageio_ffmpeg
17
18
  from PIL import Image
18
19
 
20
+ # P3-31: Windows 管道默认 GBK —— 错误 JSON 里的中文会乱码,先钉 UTF-8(写法照 lib/pv.py:41-46)
21
+ for _stream in (sys.stdout, sys.stderr):
22
+ try:
23
+ _stream.reconfigure(encoding="utf-8", errors="replace")
24
+ except Exception:
25
+ pass
26
+
19
27
 
20
28
  def _probe(video: Path) -> tuple[dict, int]:
21
29
  # imageio-ffmpeg bundles ffmpeg, not ffprobe. Decode without frame duplication
@@ -24,10 +32,16 @@ def _probe(video: Path) -> tuple[dict, int]:
24
32
  reader = imageio_ffmpeg.read_frames(
25
33
  str(video), pix_fmt="rgb24", input_params=["-xerror"], output_params=["-vsync", "0"]
26
34
  )
35
+ started = time.monotonic()
27
36
  try:
28
37
  metadata = next(reader)
29
38
  for _ in reader:
30
39
  frame_count += 1
40
+ if frame_count % 500 == 0 and time.monotonic() - started > 90.0:
41
+ # P3-30: 全帧解码没有看门狗会挂到 JS 任务级强杀(对照 _decode_frames 的 120s)
42
+ raise TimeoutError # 哨兵:下面的分支原样转成带上下文的 TimeoutError
43
+ except TimeoutError:
44
+ raise TimeoutError(f"视频解码超时(>90s,已解 {frame_count} 帧):{video.name}") from None
31
45
  except Exception as exc:
32
46
  raise ValueError(f"视频无法完整解码:{str(exc)[-300:]}") from exc
33
47
  finally:
package/lib/state.mjs CHANGED
@@ -79,11 +79,15 @@ export function ensureHomeFiles(pluginDir, stateDir, log = () => {}) {
79
79
  [join(pluginDir, 'vendor', 'bilibili-mcp', 'runtime_guard.py'), join(stateDir, 'runtime_guard.py'), true],
80
80
  [join(pluginDir, 'lib', 'asr.py'), join(stateDir, 'asr.py'), true],
81
81
  [join(pluginDir, 'lib', 'favops.py'), join(stateDir, 'favops.py'), true],
82
- // 🎬 「昨日冲浪报告」视频(2026-10-22):pv.mjs 起的是**家目录**那两个脚本,
83
- // 所以它们必须跟 mcp_server.py 一样铺过来;不铺 ⇒ 家目录没有、渲染直接报「脚本没铺好」。
82
+ // 🎬 「昨日冲浪报告」视频(2026-10-22):pv.mjs 优先起插件 lib 下的脚本,家目录副本是
83
+ // pluginDir/lib 不可用时的回退 —— 回退形态也要能独立跑全。审计 P3-21(2026-11-12)
84
+ // 补齐 pv_verify.py / pv_editorial.py:pv.py 渲染中段 `import pv_editorial`,verify 阶段
85
+ // 独立跑 pv_verify.py —— 这两个以前没铺,回退部署会在 plan/cover 都跑完之后才崩。
84
86
  [join(pluginDir, 'lib', 'pv.py'), join(stateDir, 'pv.py'), true],
85
87
  [join(pluginDir, 'lib', 'pv_plan.py'), join(stateDir, 'pv_plan.py'), true],
86
88
  [join(pluginDir, 'lib', 'pv_cover.py'), join(stateDir, 'pv_cover.py'), true],
89
+ [join(pluginDir, 'lib', 'pv_verify.py'), join(stateDir, 'pv_verify.py'), true],
90
+ [join(pluginDir, 'lib', 'pv_editorial.py'), join(stateDir, 'pv_editorial.py'), true],
87
91
  // 🎙 真·本鱼音色(2026-10-22):pv.py 用 subprocess 调它跑 GPT-SoVITS 批量合成,
88
92
  // 同样必须在家目录里 —— 不然设置页填了音色也会报「找不到 pv_gsv.py」。
89
93
  [join(pluginDir, 'lib', 'pv_gsv.py'), join(stateDir, 'pv_gsv.py'), true],
@@ -84,7 +84,18 @@ export function createStickerAutomation({ stateDir, getConfig, now = Date.now, r
84
84
  if (veto) return { suitable: false, score: 0, description: '', tags: [], reason: veto, source: 'lexicon' }
85
85
  if (!enabled || typeof judge !== 'function') return { suitable: true, score: null, description: '', tags: [], reason: '', source: 'off' }
86
86
  try {
87
- const verdict = await judge({ candidate, signal })
87
+ /* 审计 P2-7(2026-11-12):20s 超时全靠 judge 内部把 signal 传播给宿主 llm——宿主
88
+ * 不理会 AbortSignal 时 judge 永不 settle,drain 的 active 永不释放,后续 offer 全部
89
+ * 静默拒绝,自动收集停摆到重启。这里加一道**独立墙钟**兜底(judge 自己的 20s + 1s 余量):
90
+ * 超时按 fail-open 记 'error',队列照常流动——与文件头的 fail-open 约定一致。 */
91
+ const verdict = await Promise.race([
92
+ Promise.resolve(judge({ candidate, signal })),
93
+ new Promise((resolve) => {
94
+ const timer = setTimeout(() => resolve(null), 21_000)
95
+ timer.unref?.()
96
+ }),
97
+ ])
98
+ if (verdict === null) return { suitable: true, score: null, description: '', tags: [], reason: '判图 21 秒无响应(宿主线路挂起),本轮 fail-open 跳过', source: 'timeout' }
88
99
  if (!verdict || typeof verdict !== 'object') return { suitable: true, score: null, description: '', tags: [], reason: '', source: 'off' }
89
100
  return { suitable: verdict.suitable !== false, score: Number.isFinite(Number(verdict.score)) ? Number(verdict.score) : null,
90
101
  description: String(verdict.description || '').slice(0, 60), tags: Array.isArray(verdict.tags) ? verdict.tags.slice(0, 4).map(String) : [],
@@ -99,8 +110,11 @@ export function createStickerAutomation({ stateDir, getConfig, now = Date.now, r
99
110
  }
100
111
  const schedule = () => {
101
112
  if (disposed || active || immediate || !queue.length) return
113
+ /* 审计 P3-12(2026-11-12):unref 的 immediate 在事件循环只靠长 timer 空转时会被推迟。
114
+ * 修复 = **去掉 unref**:ref 的 setImmediate 在同一轮 check 相位就执行(不引入 timer 相位的
115
+ * 延迟,测试时序不变),并且会把事件循环拽醒——空闲进程里排队项不再被无限期搁置。
116
+ * stop() 的 clearImmediate 取消语义不变。 */
102
117
  immediate = setImmediate(() => { immediate = null; void drain() })
103
- immediate.unref?.()
104
118
  }
105
119
  const drain = async () => {
106
120
  if (active || disposed) return
package/lib/sticker.mjs CHANGED
@@ -177,7 +177,10 @@ export function snapshotStickerForSend(stateDir, input) {
177
177
  const bytes = readFileSync(info.file)
178
178
  const hash = sha256(bytes)
179
179
  // A stale ledger hash fails closed; never send bytes that changed after review.
180
- if (meta.hash && meta.hash !== hash) return null
180
+ // 审计 P3-5(2026-11-12):缺 hash 的历史条目以前 fail-open(同名换字节发现不了)——
181
+ // 现在 fail-closed,与「发送绑定已批准字节快照」一致。恢复路径:重新 collect + 过审
182
+ //(decideSticker 落库时必写 hash)。
183
+ if (meta.hash !== hash) return null
181
184
  return { name, source: 'collected', bytes: bytes.length, hash, ...makeSendSnapshot(bytes, info.fmt) }
182
185
  }
183
186
  // Builtins are read-only and always approved.
package/lib/uvfetch.mjs CHANGED
@@ -46,23 +46,49 @@ export async function extractArchive(name, archive, into, run = nativeRun) {
46
46
  return null
47
47
  }
48
48
 
49
+ /** 审计 P3-23:允许的下载源与其合法跳转目标(GitHub release 会 302 到
50
+ * objects/release-assets.githubusercontent.com——这两个必须在清单里,否则正常下载全挂)。 */
51
+ const ALLOWED_REDIRECT_HOSTS = ['github.com', 'objects.githubusercontent.com', 'release-assets.githubusercontent.com',
52
+ 'gh-proxy.com', 'ghproxy.net', 'files.pythonhosted.org', 'pypi.org']
53
+
49
54
  /** The deadline covers headers and the whole streamed response, including slow bodies. */
50
55
  async function consumeUvResponse(url, { fetchImpl = globalThis.fetch, timeoutMs = 120_000, maxBytes = MAX_ARCHIVE } = {}, consume) {
51
56
  const controller = new AbortController()
52
57
  const timer = setTimeout(() => controller.abort(new Error('UV_DOWNLOAD_TIMEOUT')), timeoutMs)
53
58
  try {
54
- const response = await fetchImpl(url, { signal: controller.signal, redirect: 'follow' })
55
- if (!response.ok) throw new Error('UV_HTTP_' + response.status)
56
- if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('UV_DOWNLOAD_TOO_LARGE')
57
- if (!response.body) throw new Error('UV_EMPTY_RESPONSE')
58
- let total = 0
59
- for await (const chunk of response.body) {
60
- total += chunk.length
61
- if (total > maxBytes) { controller.abort(); throw new Error('UV_DOWNLOAD_TOO_LARGE') }
62
- await consume(chunk)
59
+ /* 审计 P3-23(2026-11-12):以前 `redirect:'follow'`——镜像被劫持时可 302 到任意主机
60
+ * (下载源固定为 github/gh-proxy/pythonhosted/pypi,跳转目标却不受限)。SHA-256 兜底
61
+ * 挡住了投毒,但挡不住拿下载器当内网探测器。现在手动跟跳,每跳的目标主机都必须
62
+ * 仍在允许的下载源清单里,最多 5 跳。 */
63
+ let target = String(url)
64
+ for (let hop = 0; hop <= 5; hop++) {
65
+ const response = await fetchImpl(target, { signal: controller.signal, redirect: 'manual' })
66
+ const location = response.headers.get('location')
67
+ if (response.status >= 300 && response.status < 400 && location) {
68
+ if (!ALLOWED_REDIRECT_HOSTS.some((host) => new URL(target).hostname === host || new URL(target).hostname.endsWith('.' + host))) {
69
+ throw new Error('UV_REDIRECT_HOST_BLOCKED')
70
+ }
71
+ target = new URL(location, target).toString()
72
+ const next = new URL(target)
73
+ if (!/^https?:$/.test(next.protocol)) throw new Error('UV_REDIRECT_SCHEME_BLOCKED')
74
+ if (!ALLOWED_REDIRECT_HOSTS.some((host) => next.hostname === host || next.hostname.endsWith('.' + host))) {
75
+ throw new Error('UV_REDIRECT_HOST_BLOCKED: ' + next.hostname)
76
+ }
77
+ continue
78
+ }
79
+ if (!response.ok) throw new Error('UV_HTTP_' + response.status)
80
+ if (Number(response.headers.get('content-length')) > maxBytes) throw new Error('UV_DOWNLOAD_TOO_LARGE')
81
+ if (!response.body) throw new Error('UV_EMPTY_RESPONSE')
82
+ let total = 0
83
+ for await (const chunk of response.body) {
84
+ total += chunk.length
85
+ if (total > maxBytes) { controller.abort(); throw new Error('UV_DOWNLOAD_TOO_LARGE') }
86
+ await consume(chunk)
87
+ }
88
+ if (!total) throw new Error('UV_EMPTY_RESPONSE')
89
+ return total
63
90
  }
64
- if (!total) throw new Error('UV_EMPTY_RESPONSE')
65
- return total
91
+ throw new Error('UV_REDIRECT_LOOP')
66
92
  } finally { clearTimeout(timer); controller.abort() }
67
93
  }
68
94
 
@@ -188,4 +214,4 @@ export function ensureUv({ dir, log = () => {}, fetchImpl = globalThis.fetch, ru
188
214
  const pending = installUv({ dir: destination, log, asset, fetchImpl, run, timeoutMs }).finally(() => inflight.delete(key))
189
215
  inflight.set(key, pending)
190
216
  return pending
191
- }
217
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-deepmoyu",
3
- "version": "2.7.1",
3
+ "version": "2.7.2",
4
4
  "private": false,
5
5
  "description": "🐋 DeepMoYu 鲸鱼摸鱼 —— 让一只鲸鱼娘自己逛 B站:自主搜片、读字幕、发评论、发动态、回私信,还能写日报、做视频的 DSH 插件(dsh-bili-scroll 0.1.19 分叉;内置 npm 更新通道,对本地分叉自动锁定)",
6
6
  "keywords": [
@@ -149,8 +149,10 @@ def _guard_blocked(reservation, kind="send", why=""):
149
149
  return json.dumps({
150
150
  "success": False, "already_replied": True, "retry_blocked": True,
151
151
  "guard_status": "unknown", "seen_at": at,
152
+ "slot_key": reservation.get("key", ""),
152
153
  "error": "上一次发出去的结果不确定(超时 / 回执不明)—— 不许自动重发",
153
- "hint": "先自己去评论区 / 私信里看一眼到底发出去没有;确认没有,再手工重试",
154
+ "hint": ("先自己去评论区 / 私信里看一眼到底发出去没有;**确认没发出去**后,用 "
155
+ "bili_guard_status(release=['这条的 slot_key']) 人工解除 unknown 槽位(P1-2),再重试"),
154
156
  }, ensure_ascii=False)
155
157
  if status == "pending":
156
158
  return json.dumps({
@@ -167,6 +169,18 @@ def _guard_blocked(reservation, kind="send", why=""):
167
169
  }, ensure_ascii=False)
168
170
 
169
171
 
172
+ # P3-46: 守卫库被锁(SQLite busy>15s)时的统一 fail-closed 话术,措辞对齐 bili_subtitle
173
+ # 的「判重账本不可用」兜底回执 —— 原始 traceback 绝不能直接漏给模型。
174
+ _GUARD_LOCKED_ERROR = "判重账本暂不可用(可能被锁),先别重发;稍后用 bili_guard_status 核对坑位状态"
175
+
176
+
177
+ def _guard_unavailable(exc) -> str:
178
+ """占坑阶段守卫异常 → JSON 错误回执(此时还没发任何东西,可以稍后重试)。"""
179
+ return json.dumps({"success": False, "error": _GUARD_LOCKED_ERROR,
180
+ "detail": f"{type(exc).__name__}: {exc}",
181
+ "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
182
+
183
+
170
184
  async def _guarded_send(guard, reservation, runner, record=None):
171
185
  """在**已经占好坑**的前提下真正发出去,并如实落账。
172
186
 
@@ -196,7 +210,13 @@ async def _guarded_send(guard, reservation, runner, record=None):
196
210
  return None
197
211
 
198
212
  def _rejected(error, result, code):
199
- pace = guard.reject_send(reservation, code)
213
+ try:
214
+ pace = guard.reject_send(reservation, code)
215
+ except Exception as exc:
216
+ # P3-46: 拒绝落账失败(库被锁)不能裸抛 —— 事务已回滚、坑位留在 pending 正好挡重发,
217
+ # 回执转 fail-closed(guard_status=unknown ⇒ 调用方会回「别自动重发」的话术)。
218
+ return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
219
+ "result": result, "code": code, "ledger_detail": f"{type(exc).__name__}: {exc}"}
200
220
  out = {"guard_status": "rejected", "error": error, "retry_blocked": False,
201
221
  "result": result, "code": code}
202
222
  if code == 49044:
@@ -232,7 +252,13 @@ async def _guarded_send(guard, reservation, runner, record=None):
232
252
  return {"guard_status": "unknown", "error": str(result.get("error") or "没有确定的回执"),
233
253
  "retry_blocked": True, "result": result, "code": code}
234
254
 
235
- guard.commit_send(reservation, record)
255
+ try:
256
+ guard.commit_send(reservation, record)
257
+ except Exception as exc:
258
+ # P3-46: 落账失败(库被锁 >15s)不能裸抛 —— 发送本身可能已成功,坑位留在 pending
259
+ # 挡住重发;回执转 fail-closed JSON 并指引去 bili_guard_status 核对,而不是 traceback。
260
+ return {"guard_status": "unknown", "error": _GUARD_LOCKED_ERROR, "retry_blocked": True,
261
+ "result": result, "code": None, "ledger_detail": f"{type(exc).__name__}: {exc}"}
236
262
  return {"guard_status": "sent", "error": "", "retry_blocked": False, "result": result, "code": None}
237
263
 
238
264
 
@@ -407,10 +433,13 @@ async def bili_login_check() -> str:
407
433
 
408
434
  elif state == QrCodeLoginEvents.TIMEOUT:
409
435
  _login_session = None
410
- # 清理二维码文件
436
+ # 清理二维码文件(P3-47: Windows 下 PNG 被看图程序占用会 PermissionError,别让它炸回执)
411
437
  qr_file = Path(__file__).parent / "qrcode_login.png"
412
438
  if qr_file.exists():
413
- qr_file.unlink()
439
+ try:
440
+ qr_file.unlink()
441
+ except OSError as e:
442
+ print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
414
443
  return json.dumps({
415
444
  "status": "timeout",
416
445
  "message": "二维码已过期,请重新调用 bili_login 生成新二维码",
@@ -431,10 +460,13 @@ async def bili_login_check() -> str:
431
460
  }, f)
432
461
  os.replace(tmp_cred, CRED_FILE)
433
462
  _login_session = None
434
- # 清理二维码文件
463
+ # 清理二维码文件(P3-47: 删失败只记 stderr —— DONE 分支反复重入比带着孤儿 PNG 重坏了)
435
464
  qr_file = Path(__file__).parent / "qrcode_login.png"
436
465
  if qr_file.exists():
437
- qr_file.unlink()
466
+ try:
467
+ qr_file.unlink()
468
+ except OSError as e:
469
+ print(f"[bili-mcp] 二维码文件删不掉({type(e).__name__}: {e}):{qr_file}", file=sys.stderr)
438
470
  return json.dumps({
439
471
  "status": "done",
440
472
  "message": "登录成功!凭证已保存,现在可以使用所有B站功能了",
@@ -992,13 +1024,17 @@ async def bili_reply(bvid: str, text: str, rpid: IdInput = "0", root: IdInput =
992
1024
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
993
1025
 
994
1026
  # ② 占坑:同一条评论(rpid)全机器只允许发一次;新评论按 BV 也只允许留一条
995
- guard = _get_runtime_guard()
996
1027
  try:
997
1028
  key = key_video_reply(bvid, rpid)
998
1029
  except Exception as e:
999
1030
  return json.dumps({"success": False, "error": str(e),
1000
1031
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1001
- reservation = guard.reserve_send(key, cross_channel_keys(rpid))
1032
+ try:
1033
+ guard = _get_runtime_guard()
1034
+ # P3-46: SQLite busy>15s 的原始异常不能外泄 —— 包成 fail-closed JSON 回执
1035
+ reservation = guard.reserve_send(key, cross_channel_keys(rpid))
1036
+ except Exception as e:
1037
+ return _guard_unavailable(e)
1002
1038
  if not reservation.get("allowed"):
1003
1039
  return _guard_blocked(reservation, "send",
1004
1040
  "这条评论已经回过了" if rpid != "0" else "这条视频已经留过一条评论了")
@@ -1193,10 +1229,14 @@ async def bili_send_dynamic(
1193
1229
 
1194
1230
  text = text.strip()
1195
1231
  image_paths = [str(path).strip() for path in (images or [])[:9] if str(path or "").strip()]
1196
- guard = _get_runtime_guard()
1197
- reservation = guard.reserve_send(key_moment("dynamic", {
1198
- "text": text, "images": image_paths, "topic_id": str(topic_id), "schedule_time": schedule_time,
1199
- }))
1232
+ try:
1233
+ guard = _get_runtime_guard()
1234
+ # P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
1235
+ reservation = guard.reserve_send(key_moment("dynamic", {
1236
+ "text": text, "images": image_paths, "topic_id": str(topic_id), "schedule_time": schedule_time,
1237
+ }))
1238
+ except Exception as exc:
1239
+ return _guard_unavailable(exc)
1200
1240
  if not reservation["allowed"]:
1201
1241
  return _guard_blocked(reservation)
1202
1242
  try:
@@ -1272,6 +1312,7 @@ async def bili_upload_video(
1272
1312
 
1273
1313
  Returns:
1274
1314
  上传结果,包含BV号
1315
+ ⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
1275
1316
  """
1276
1317
  # ---- 参数校验 ----
1277
1318
  if not video_path or not video_path.strip():
@@ -1333,12 +1374,20 @@ async def bili_upload_video(
1333
1374
  async def on_event(data: dict):
1334
1375
  progress_info["phase"] = str(data)
1335
1376
 
1336
- result = await uploader.start()
1377
+ # P2-17: uploader.start() 没有超时的话,挂死的上传在客户端超时后仍在后台继续,
1378
+ # 公开投稿完成却无任何记录 —— 这里给 15 分钟墙钟,超时明确报错。
1379
+ result = await asyncio.wait_for(uploader.start(), timeout=900)
1337
1380
  return json.dumps({
1338
1381
  "success": True,
1339
1382
  "message": "视频上传成功",
1340
1383
  "data": result if isinstance(result, dict) else str(result),
1341
1384
  }, ensure_ascii=False)
1385
+ except asyncio.TimeoutError:
1386
+ return json.dumps({
1387
+ "success": False,
1388
+ "error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
1389
+ "last_phase": progress_info.get("phase", "unknown") if "progress_info" in dir() else "init",
1390
+ }, ensure_ascii=False)
1342
1391
  except Exception as e:
1343
1392
  return json.dumps({
1344
1393
  "success": False,
@@ -1375,6 +1424,7 @@ async def bili_upload_video_multi(
1375
1424
 
1376
1425
  Returns:
1377
1426
  上传结果
1427
+ ⚠️ 直跑无判重账本与节奏闸——仅限手动运行,自动化请走插件的 bili_pv(P2-17)
1378
1428
  """
1379
1429
  cred = get_cred()
1380
1430
 
@@ -1418,12 +1468,18 @@ async def bili_upload_video_multi(
1418
1468
 
1419
1469
  uploader = VideoUploader(pages=pages, meta=meta, credential=cred)
1420
1470
 
1421
- result = await uploader.start()
1471
+ # P2-17: 同 bili_upload_video —— 15 分钟墙钟,挂死的上传不能在后台无人知晓地继续
1472
+ result = await asyncio.wait_for(uploader.start(), timeout=900)
1422
1473
  return json.dumps({
1423
1474
  "success": True,
1424
1475
  "message": f"多P视频上传成功(共{len(pages)}P)",
1425
1476
  "data": result if isinstance(result, dict) else str(result),
1426
1477
  }, ensure_ascii=False)
1478
+ except asyncio.TimeoutError:
1479
+ return json.dumps({
1480
+ "success": False,
1481
+ "error": "投稿上传超时(15 分钟)——网络或 B站 分片接口卡住;注意客户端超时后上传可能仍在后台进行,重试前先到创作中心确认有没有半截投稿",
1482
+ }, ensure_ascii=False)
1427
1483
  except Exception as e:
1428
1484
  return json.dumps({"success": False, "error": str(e)}, ensure_ascii=False)
1429
1485
 
@@ -1450,10 +1506,14 @@ async def bili_send_opus(
1450
1506
  发布结果
1451
1507
  """
1452
1508
  image_paths = [str(path).strip() for path in (images or []) if str(path or "").strip()]
1453
- guard = _get_runtime_guard()
1454
- reservation = guard.reserve_send(key_moment("opus", {
1455
- "title": title, "content": content, "images": image_paths, "category_id": str(category_id),
1456
- }))
1509
+ try:
1510
+ guard = _get_runtime_guard()
1511
+ # P3-46: 占坑异常包成 fail-closed JSON,原始 traceback 不外泄
1512
+ reservation = guard.reserve_send(key_moment("opus", {
1513
+ "title": title, "content": content, "images": image_paths, "category_id": str(category_id),
1514
+ }))
1515
+ except Exception as exc:
1516
+ return _guard_unavailable(exc)
1457
1517
  if not reservation["allowed"]:
1458
1518
  return _guard_blocked(reservation)
1459
1519
  try:
@@ -1798,11 +1858,14 @@ async def bili_favorite_lists(uid: int = 0) -> str:
1798
1858
  """
1799
1859
  cred = get_cred()
1800
1860
  if uid == 0:
1801
- # 从凭证获取自己的UID
1802
- import json as _json
1803
- with open(CRED_FILE) as f:
1804
- data = _json.load(f)
1805
- uid = int(data.get("dedeuserid", 0))
1861
+ # 从凭证获取自己的UID(P3-43: 改走硬化的 load_credential —— 原来裸 open 读凭证文件,
1862
+ # dedeuserid 空串 → ValueError、文件中途被删 → FileNotFoundError,整工具裸 traceback)
1863
+ own = load_credential()
1864
+ dede = str(own.dedeuserid).strip() if own else ""
1865
+ if not dede.isdigit():
1866
+ return json.dumps({"error": "未登录或凭证不可读:拿不到自己的 UID(dedeuserid 为空或缺失),"
1867
+ "请先调用 bili_login 重新扫码登录"}, ensure_ascii=False)
1868
+ uid = int(dede)
1806
1869
 
1807
1870
  result = await favorite_list.get_video_favorite_list(uid=uid, credential=cred)
1808
1871
  fav_lists = []
@@ -1890,13 +1953,17 @@ async def bili_send_message(receiver_uid: IdInput, text: str, msg_seqno: IdInput
1890
1953
  return json.dumps({"success": False, "error": str(e),
1891
1954
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1892
1955
 
1893
- guard = _get_runtime_guard()
1894
1956
  try:
1895
1957
  key = key_dm_reply(receiver_uid, body, msg_seqno)
1896
1958
  except Exception as e:
1897
1959
  return json.dumps({"success": False, "error": str(e),
1898
1960
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
1899
- reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
1961
+ try:
1962
+ guard = _get_runtime_guard()
1963
+ # P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
1964
+ reservation = guard.reserve_send(key, legacy_dm_keys(msg_seqno))
1965
+ except Exception as e:
1966
+ return _guard_unavailable(e)
1900
1967
  if not reservation.get("allowed"):
1901
1968
  return _guard_blocked(reservation, "send", f"这条私信已经回过了(UID:{receiver_uid})")
1902
1969
 
@@ -2483,7 +2550,6 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2483
2550
  return json.dumps({"success": False, "error": f"取不到这条动态的评论区:{e}",
2484
2551
  "guard_status": "not_sent", "retry_blocked": False}, ensure_ascii=False)
2485
2552
 
2486
- guard = _get_runtime_guard()
2487
2553
  try:
2488
2554
  key = key_opus_reply(oid_str, rpid)
2489
2555
  except Exception as e:
@@ -2493,7 +2559,12 @@ async def bili_reply_opus(opus_id: IdInput, text: str, rpid: IdInput = "0", root
2493
2559
  # 否则升级那一刻,昨天回过的那些今天会再回一遍。
2494
2560
  # 🔁 2026-10-25(使用者报「回一个人一条评论两条消息」):**视频通道回过的同号 rpid 也认**
2495
2561
  # —— 同一条评论只回一次,不管上一次走的是 bili_reply 还是 bili_reply_opus。
2496
- reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
2562
+ try:
2563
+ guard = _get_runtime_guard()
2564
+ # P3-46: 占坑异常(含账本库打不开)包成 fail-closed JSON,原始 traceback 不外泄
2565
+ reservation = guard.reserve_send(key, [*legacy_opus_keys(rpid, root), *cross_channel_keys(rpid)])
2566
+ except Exception as e:
2567
+ return _guard_unavailable(e)
2497
2568
  if not reservation.get("allowed"):
2498
2569
  return _guard_blocked(reservation, "send", "这条动态评论已经回过了")
2499
2570
 
@@ -2689,24 +2760,37 @@ async def bili_my_video_comments(num: int = 10, videos: int = 3) -> str:
2689
2760
  # ========== Tool: 判重账本状态(只读) ==========
2690
2761
 
2691
2762
  @tool()
2692
- async def bili_guard_status(bvids: list = None, limit: int = 200) -> str:
2763
+ async def bili_guard_status(bvids: list = None, limit: int = 200, release: list = None) -> str:
2693
2764
  """
2694
2765
  看一眼**永久判重账本**(SQLite,重启不丢、跨进程共用)
2695
2766
 
2696
2767
  Args:
2697
2768
  bvids: 想核对的 BV 号列表(给了就只查这几条;不给就返回最近刷过的一批)
2698
2769
  limit: 最多返回几条(默认 200,上限 500)
2770
+ release: 【unknown 槽位的人工解除入口】要解除的坑位 key 列表。只在人工核实
2771
+ 「那次结果不明的发送确实没发出去」之后才用 —— 只有 status='unknown'
2772
+ 的行会被删(pending/sent 绝不碰);非空时本工具不再纯只读。
2699
2773
 
2700
2774
  Returns:
2701
2775
  counts(seen / sent / pending / unknown 各多少)、seenVideos(已刷过的 BV)、
2702
- activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**)
2776
+ activeReads(正在读的)、recentSends(最近发过什么 —— 只有键和元信息,**不含正文**);
2777
+ 传了 release 时额外带 released:每个 key 是否真的解除了一行
2703
2778
  """
2704
2779
  guard = _get_runtime_guard()
2780
+ released = []
2781
+ if release:
2782
+ # P1-2: unknown 槽位的人工解除入口 —— release_unknown 只删 unknown 行,
2783
+ # 误传 pending/sent 的 key 时返回 false 并原样保留那行,绝不误删。
2784
+ for key in release:
2785
+ try:
2786
+ released.append({"key": str(key), "released": bool(guard.release_unknown(str(key)))})
2787
+ except Exception as e:
2788
+ released.append({"key": str(key), "released": False, "error": str(e)})
2705
2789
  try:
2706
2790
  data = guard.snapshot(bvids=bvids, limit=limit)
2707
2791
  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)
2792
+ return json.dumps({"ok": False, "error": str(e), "released": released}, ensure_ascii=False)
2793
+ return json.dumps({"ok": True, "released": released, **data}, ensure_ascii=False)
2710
2794
 
2711
2795
 
2712
2796
  # ========== 启动 ==========
@@ -122,8 +122,12 @@ def stringify_ids(value):
122
122
  result[key] = str(item) # Python SDK integers are exact, unlike JSON numbers in JS.
123
123
  elif isinstance(item, str) and re.fullmatch(r"[0-9]+", item.strip()):
124
124
  result[key] = item.strip().lstrip("0") or "0"
125
+ elif isinstance(item, str):
126
+ # P3-44: 非数值字符串原样保留({"id":"hot-word-12"} 不能被静默清成 "",
127
+ # 模型会把「有数据」误读成「没有」);空串在这里自然仍是空串(缺失)。
128
+ result[key] = item
125
129
  else:
126
- result[key] = "" # unavailable/malformed is never guessed as another ID
130
+ result[key] = "" # 空值/非字符串仍按「缺失」处理,绝不猜成另一个 ID
127
131
  else:
128
132
  result[key] = stringify_ids(item)
129
133
  return result
@@ -238,10 +242,15 @@ class RuntimeGuard:
238
242
  conn.close()
239
243
 
240
244
  def _connect(self):
241
- conn = sqlite3.connect(str(self.db_path), timeout=15, isolation_level=None)
242
- conn.row_factory = sqlite3.Row
243
- conn.execute("PRAGMA busy_timeout=15000")
244
- conn.execute("PRAGMA synchronous=FULL")
245
+ try:
246
+ conn = sqlite3.connect(str(self.db_path), timeout=15, isolation_level=None)
247
+ conn.row_factory = sqlite3.Row
248
+ conn.execute("PRAGMA busy_timeout=15000")
249
+ conn.execute("PRAGMA synchronous=FULL")
250
+ except sqlite3.OperationalError as exc:
251
+ # E2: node/python 临时目录解析不一致时这里报「unable to open database file」
252
+ # 且不带路径,排障只能盲猜 —— 报错必须带上 db_path 本身。
253
+ raise RuntimeError(f"runtime guard 打不开账本库 {self.db_path}: {exc}") from exc
245
254
  return conn
246
255
 
247
256
  @contextmanager
@@ -636,10 +645,27 @@ class RuntimeGuard:
636
645
  (reservation["key"], reservation["token"])).rowcount
637
646
  if changed != 1:
638
647
  raise RuntimeError("guard reservation was lost; refusing an unsafe retry")
639
- conn.execute("DELETE FROM guard_pace_events WHERE event_key=?", ("send:" + reservation["token"],))
648
+ # P2-16: 节奏事件不再删,改标 rejected 并保留 at/next —— burst/hour/gap
649
+ # (_pace_check 按 at/next 统计、不看 status)要把「确定没发出去」的尝试也计入,
650
+ # 否则坏凭据下模型可以对 B站 无限速连打(探针实测 12 连发全放行,
651
+ # 而 -352 的最短触发路径恰恰就是高频尝试)。
652
+ conn.execute("UPDATE guard_pace_events SET status='rejected' WHERE event_key=?",
653
+ ("send:" + reservation["token"],))
640
654
  self._note_rejection(conn, code, now)
641
655
  return self._pace_check(conn, pace_kind(reservation["key"]), now)
642
656
 
657
+ def release_unknown(self, key):
658
+ """P1-2: unknown 槽位的**人工**解除入口。
659
+
660
+ 只删 status='unknown' 的行(pending/sent 绝不可删:前者正被某个进程占着,
661
+ 后者是发出去的铁账);返回是否真的解除了一行。
662
+ 调用前提是人工核实「那次结果不明的发送确实没发出去」—— 这不是自动重试的后门。
663
+ """
664
+ with self._transaction() as conn:
665
+ changed = conn.execute("DELETE FROM guard_entries WHERE key=? AND status='unknown'",
666
+ (str(key),)).rowcount
667
+ return changed == 1
668
+
643
669
  def pace_status(self, action):
644
670
  """Read the current common floor without reserving or consuming a send slot."""
645
671
  with self._transaction() as conn: