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.
- package/CHANGELOG.md +15 -0
- package/SECURITY.md +4 -0
- package/client.js +89 -13
- package/docs/RELEASE_AUDIT.md +2 -2
- package/docs/RELEASING.md +15 -0
- package/docs/tool-evidence.json +1 -1
- package/index.js +281 -62
- package/lib/asr.py +22 -0
- package/lib/favops.py +38 -6
- package/lib/learn-review.mjs +11 -1
- package/lib/login.mjs +20 -3
- package/lib/mirror.mjs +15 -1
- package/lib/model-relay.mjs +6 -2
- package/lib/persona.mjs +3 -1
- package/lib/pet-session.mjs +9 -3
- package/lib/pv-session.mjs +16 -1
- package/lib/pv.mjs +18 -3
- package/lib/pv.py +142 -18
- package/lib/pv_cover.py +11 -1
- package/lib/pv_editorial.py +23 -2
- package/lib/pv_plan.py +20 -2
- package/lib/pv_verify.py +14 -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/vendor/bilibili-mcp/mcp_server.py +116 -32
- package/vendor/bilibili-mcp/runtime_guard.py +32 -6
package/lib/pv_editorial.py
CHANGED
|
@@ -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(
|
|
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 -=
|
|
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,
|
|
261
|
-
return max(MIN_SHOT_SPEECH,
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
let
|
|
59
|
-
for
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1197
|
-
|
|
1198
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1454
|
-
|
|
1455
|
-
|
|
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
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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] = "" #
|
|
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
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
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:
|