dsh-deepmoyu 2.7.0 → 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 +25 -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 +296 -62
- package/lib/asr.py +22 -0
- package/lib/favops.py +38 -6
- package/lib/host-compat.mjs +123 -0
- 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/presets/deepmoyu.patch.yml +6 -1
- package/vendor/bilibili-mcp/mcp_server.py +116 -32
- package/vendor/bilibili-mcp/runtime_guard.py +32 -6
|
@@ -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:
|