dsh-bili-scroll 2.0.4 → 2.2.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.
@@ -14,17 +14,23 @@ B站数据采集 MCP 服务,支持 OpenClaw / Claude Code / Cursor / Cline
14
14
  # ③ 登录成功后凭证**原子写**(先写 .tmp 再 os.replace)
15
15
  # ④ `bili_comments` 把抓取异常带进返回(风控 -412 不再伪装成"这条视频 0 条评论")
16
16
  # ⑤ `bili_reply` 回执带上 `rpid` / `rpid_str` / `root` / `parent`(判重表与评论复核要用)
17
+ # ⑥ `bili_cover` / `bili_frames`:封面和画面帧**作为图片**返回(上游没有;
18
+ # 图能不能进模型上下文由宿主判断)
17
19
  import asyncio
18
20
  import json
21
+ import re
19
22
  import base64
20
23
  import os
24
+ import struct
21
25
  import sys
22
26
  import tempfile
23
27
  from pathlib import Path
24
28
  from mcp.server.fastmcp import FastMCP
29
+ from mcp.types import ImageContent, TextContent
25
30
 
26
31
  from bilibili_api import video, search, comment, Credential, dynamic, opus
27
32
  from bilibili_api import hot, rank, user, session, favorite_list
33
+ from bilibili_api import homepage
28
34
  from bilibili_api.video_uploader import (
29
35
  VideoUploader, VideoUploaderPage, VideoMeta, Lines,
30
36
  )
@@ -64,6 +70,112 @@ def _guard(name: str) -> str:
64
70
  return ""
65
71
 
66
72
 
73
+ # ⭐⭐ 语感闸门(治"鱼八股"):**发评论之前,先在函数里拦一道**
74
+ # 背景:章程里写"要有具体细节、别客套",模型照样写"这条我认""值得一看" ——
75
+ # 禁令没有范例有效、自觉没有拦截可靠 ⇒ 在执行层做最后一道检查(跟 _guard 同一思路)。
76
+ # 三道检查(便宜、纯本地 / 一次轻请求):
77
+ # ① 开头八股黑名单(正则)
78
+ # ② 跟**自己以前发过的评论**太像(bigram 相似度)—— 治"每条一个味"
79
+ # ③ 跟**这条视频现有热评**太像 —— 治"跟别的鱼撞车"
80
+ # ⚠️ 撞了就**不发**,把原因和改法返回给模型,让它重写一句。
81
+ # ⚠️ 关掉:`BILI_STYLE_OFF=1`。
82
+ _STYLE_OFF = os.environ.get("BILI_STYLE_OFF", "") in ("1", "true", "yes")
83
+ _STYLE_LEAD = re.compile(
84
+ r"^\s*(?:"
85
+ r"这条(?:我认|我记下了|值得)|我记下了|值得一看|确实值得|不得不说|"
86
+ r"好家伙|这个(?:必须|真的要)|强烈推荐|感谢分享|学到了|受教了|"
87
+ r"作为一个|总之|综上"
88
+ r")"
89
+ )
90
+
91
+
92
+ def _bigrams(s: str) -> set:
93
+ t = re.sub(r"[^\w\u4e00-\u9fff]", "", "".join(ch for ch in str(s or "") if not ch.isspace()))
94
+ if len(t) < 2:
95
+ return set(t)
96
+ return {t[i:i + 2] for i in range(len(t) - 1)}
97
+
98
+
99
+ def _sim(a: str, b: str) -> float:
100
+ """bigram Jaccard —— 够用、够快、零依赖"""
101
+ x, y = _bigrams(a), _bigrams(b)
102
+ if not x or not y:
103
+ return 0.0
104
+ return len(x & y) / max(1, len(x | y))
105
+
106
+
107
+ def _ledger_path():
108
+ try:
109
+ return DUTY_HOME / "comments.md"
110
+ except Exception:
111
+ return Path(__file__).parent / "comments.md"
112
+
113
+
114
+ def _ledger_recent(n: int = 30):
115
+ """最近 n 条"自己发过的评论"正文(台账一行:`- 日期 BVID rpid=… · 正文`)"""
116
+ try:
117
+ p = _ledger_path()
118
+ if not p.exists():
119
+ return []
120
+ out = []
121
+ for ln in p.read_text(encoding="utf-8").splitlines():
122
+ ln = ln.strip()
123
+ if not ln.startswith("- "):
124
+ continue
125
+ body = ln.split("·", 1)[1].strip() if "·" in ln else ln[2:].strip()
126
+ if body:
127
+ out.append(body)
128
+ return out[-n:]
129
+ except Exception:
130
+ return []
131
+
132
+
133
+ def _ledger_append(text: str, bvid: str, rpid=None):
134
+ try:
135
+ p = _ledger_path()
136
+ p.parent.mkdir(parents=True, exist_ok=True)
137
+ with open(p, "a", encoding="utf-8") as f:
138
+ f.write(f"- {_dt.datetime.now().strftime('%Y-%m-%d %H:%M')} {bvid} rpid={rpid or 0} · {' '.join(str(text).split())}\n")
139
+ except Exception:
140
+ pass
141
+
142
+
143
+ async def _style_reason(text: str, bvid: str, cred) -> str:
144
+ """返回"为什么这句不能发";空串 = 可以发"""
145
+ if _STYLE_OFF:
146
+ return ""
147
+ body = " ".join(str(text or "").split())
148
+ if not body:
149
+ return "评论是空的。"
150
+ m = _STYLE_LEAD.match(body)
151
+ if m:
152
+ return (f"开头「{m.group(0).strip()}」是鱼八股。直接说这条视频里的具体东西:"
153
+ "「这期讲了什么 + 一个只有看了才说得出的细节」,开头别用套话。")
154
+ for old in _ledger_recent(30):
155
+ s = _sim(body, old)
156
+ if s >= 0.55:
157
+ return (f"这句跟你以前发过的「{old[:18]}…」太像了(相似度 {s:.2f})。"
158
+ "换开头、换关注点,别套同一个句式。")
159
+ try:
160
+ v = video.Video(bvid=bvid, credential=cred)
161
+ aid = (await v.get_info())["aid"]
162
+ resp = await comment.get_comments(
163
+ oid=aid, type_=comment.CommentResourceType.VIDEO, page_index=1,
164
+ order=comment.OrderType.LIKE, credential=cred,
165
+ )
166
+ for c in (resp.get("replies") or [])[:20]:
167
+ other = " ".join(str((c.get("content") or {}).get("message", "")).split())
168
+ if not other:
169
+ continue
170
+ s = _sim(body, other)
171
+ if s >= 0.5:
172
+ return (f"这条视频底下已经有一句很像的(相似度 {s:.2f}):{other[:24]}… "
173
+ "撞车了 —— 换个角度写,或者这一轮不发。")
174
+ except Exception:
175
+ pass # 读不到热评就不做这道检查(不能因为读不到就不让发)
176
+ return ""
177
+
178
+
67
179
  def load_credential() -> Credential:
68
180
  """从文件加载凭证"""
69
181
  if not CRED_FILE.exists():
@@ -536,6 +648,10 @@ async def bili_reply(bvid: str, text: str, rpid: int = 0, root: int = 0) -> str:
536
648
  """
537
649
  _deny = _guard("bili_reply")
538
650
  if _deny: return _deny
651
+ # ⭐ 语感闸门:撞八股 / 撞自己以前的 / 撞这条视频的热评 ⇒ 不发,把改法告诉模型
652
+ _style = await _style_reason(text, bvid, get_cred())
653
+ if _style:
654
+ return json.dumps({"ok": False, "sent": False, "blocked_by": "style", "error": _style}, ensure_ascii=False)
539
655
  cred = get_cred()
540
656
  v = video.Video(bvid=bvid, credential=cred)
541
657
  info = await v.get_info()
@@ -568,6 +684,7 @@ async def bili_reply(bvid: str, text: str, rpid: int = 0, root: int = 0) -> str:
568
684
  # (lib/verify.mjs)也要拿它去 B站 查公开可见性。只回 success 的话那条链是断的。
569
685
  #(2026-10-16 审查修:原实现把上面的 result 丢掉了)
570
686
  out = {"success": True, "message": "评论发送成功"}
687
+ _ledger_append(text, bvid, result.get("rpid") if isinstance(result, dict) else None)
571
688
  if isinstance(result, dict):
572
689
  for key in ("rpid", "rpid_str", "root", "parent"):
573
690
  val = result.get(key)
@@ -1134,6 +1251,595 @@ async def bili_received_at_and_likes() -> str:
1134
1251
  return json.dumps(result, ensure_ascii=False)
1135
1252
 
1136
1253
 
1254
+ # ========== 值班轮窄口 IO(2026-10-24 · issue #96 安全加固)==========
1255
+ # ⭐ 值班轮现在跑在「最小权限 profile」上:**没有文件系统、没有 shell、没有 web**。
1256
+ # 但它还得写日报 / 判重表 / 归档、还得能转写 —— 所以这几件事做成**固定路径的窄口工具**:
1257
+ # · 只写 <家>/out/<日期>.md、<家>/seen.md、<家>/digest/<年-月>.md
1258
+ # · **不接受任何路径参数**(想写别处也写不了)✓
1259
+ # · 输入里的竖线会被转义,标题带 | 也不会把表格弄坏
1260
+ import datetime as _dt
1261
+
1262
+ DUTY_HOME = Path(os.environ.get("BILI_DUTY_HOME") or (Path.home() / ".dsh" / "bili-duty"))
1263
+
1264
+
1265
+ def _duty_today() -> str:
1266
+ return _dt.datetime.now().strftime("%Y-%m-%d")
1267
+
1268
+
1269
+ def _cell(s: str, limit: int = 60) -> str:
1270
+ """表格单元格:去换行、转义竖线、截断。"""
1271
+ t = " ".join(str(s or "").split())
1272
+ return t.replace("|", "\\|")[:limit]
1273
+
1274
+
1275
+ def _append(path: Path, text: str) -> str:
1276
+ path.parent.mkdir(parents=True, exist_ok=True)
1277
+ with open(path, "a", encoding="utf-8", newline="\n") as f:
1278
+ f.write(text)
1279
+ return str(path)
1280
+
1281
+
1282
+ @tool()
1283
+ def duty_paths() -> str:
1284
+ """
1285
+ 这一轮允许写的三个位置(绝对路径)—— 想写东西只能通过这些 duty_* 工具,不要找别的路。
1286
+
1287
+ Returns:
1288
+ 报告 / 判重表 / 归档 的绝对路径
1289
+ """
1290
+ out = {
1291
+ "home": str(DUTY_HOME),
1292
+ "report": str(DUTY_HOME / "out" / (_duty_today() + ".md")),
1293
+ "seen": str(DUTY_HOME / "seen.md"),
1294
+ "digest": str(DUTY_HOME / "digest" / (_dt.datetime.now().strftime("%Y-%m") + ".md")),
1295
+ }
1296
+ return json.dumps(out, ensure_ascii=False)
1297
+
1298
+
1299
+ @tool()
1300
+ def duty_report_append(text: str) -> str:
1301
+ """
1302
+ 往**今天的日报**末尾追加一段(文件不存在会自动建)。
1303
+
1304
+ Args:
1305
+ text: 要追加的内容(markdown 纯文本)
1306
+
1307
+ Returns:
1308
+ 写到了哪个文件
1309
+ """
1310
+ body = str(text or "").replace("\r\n", "\n").strip()
1311
+ if not body:
1312
+ return "没写:内容是空的"
1313
+ p = DUTY_HOME / "out" / (_duty_today() + ".md")
1314
+ fresh = not p.exists()
1315
+ payload = body + "\n"
1316
+ if fresh:
1317
+ payload = "# 刷B站日报 " + _duty_today() + "\n\n" + payload
1318
+ return "已追加到 " + _append(p, payload)
1319
+
1320
+
1321
+ @tool()
1322
+ def duty_seen_append(bvid: str, title: str, up: str, status: str, date: str = "") -> str:
1323
+ """
1324
+ 往判重表追加一行(「挑了」= 进过报告 / 「跳过」= 看过但没进 / 「评论」= 本轮 @ 过)。
1325
+
1326
+ Args:
1327
+ bvid: BV 号
1328
+ title: 标题
1329
+ up: UP 主
1330
+ status: 挑了 / 跳过 / 评论
1331
+ date: 日期(YYYY-MM-DD,留空=今天)
1332
+
1333
+ Returns:
1334
+ 写到了哪个文件
1335
+ """
1336
+ bv = str(bvid or "").strip()
1337
+ if not bv:
1338
+ return "没写:bvid 是空的"
1339
+ d = (str(date or "").strip() or _duty_today())[:10]
1340
+ row = f"| {_cell(d, 10)} | {_cell(bv, 16)} | {_cell(title)} | {_cell(up, 30)} | {_cell(status, 8)} |\n"
1341
+ return "已追加到 " + _append(DUTY_HOME / "seen.md", row)
1342
+
1343
+
1344
+ @tool()
1345
+ def duty_seen_recent(limit: int = 40, bvid: str = "") -> str:
1346
+ """
1347
+ 读判重表的最近若干行(只读,只读插件自己那份 seen.md)。
1348
+
1349
+ Args:
1350
+ limit: 取最近多少行(默认 40,上限 200)
1351
+ bvid: 只查某个 BV(留空=看全部)
1352
+
1353
+ Returns:
1354
+ 表格行(日期 | BV | 标题 | UP | 状态);空 = 这份表还没内容
1355
+ """
1356
+ f = DUTY_HOME / "seen.md"
1357
+ if not f.exists():
1358
+ return "(判重表还不存在)"
1359
+ try:
1360
+ lines = f.read_text(encoding="utf-8", errors="replace").splitlines()
1361
+ except Exception as e:
1362
+ return "读不到判重表:" + str(e)
1363
+ rows = [ln for ln in lines if ln.strip().startswith("|") and "---" not in ln and not ln.strip().startswith("| 日期")]
1364
+ if bvid:
1365
+ bv = str(bvid).strip()
1366
+ rows = [ln for ln in rows if bv in ln]
1367
+ n = max(1, min(int(limit or 40), 200))
1368
+ tail = rows[-n:]
1369
+ if not tail:
1370
+ return "(最近没有记录)"
1371
+ return "共 " + str(len(rows)) + " 行,最近 " + str(len(tail)) + " 行:\n" + "\n".join(tail)
1372
+
1373
+
1374
+ @tool()
1375
+ def duty_digest_append(text: str) -> str:
1376
+ """
1377
+ 往**本月归档**末尾追加一段(「digest/<年-月>.md」,给"后来翻旧账"用的)。
1378
+
1379
+ Args:
1380
+ text: 要追加的内容
1381
+
1382
+ Returns:
1383
+ 写到了哪个文件
1384
+ """
1385
+ body = str(text or "").replace("\r\n", "\n").strip()
1386
+ if not body:
1387
+ return "没写:内容是空的"
1388
+ p = DUTY_HOME / "digest" / (_dt.datetime.now().strftime("%Y-%m") + ".md")
1389
+ return "已追加到 " + _append(p, body + "\n")
1390
+
1391
+
1392
+ @tool()
1393
+ def bili_transcribe(bvid: str) -> str:
1394
+ """
1395
+ 用本地 ASR(家目录里的独立环境)把一条**没有字幕**的视频转成文字。慢(几分钟)。
1396
+
1397
+ Args:
1398
+ bvid: BV 号
1399
+
1400
+ Returns:
1401
+ 转写出来的文字(截断到 12000 字);空的 / 只有署名 ⇒ 当作这条没有语音
1402
+ """
1403
+ import subprocess
1404
+
1405
+ bv = str(bvid or "").strip()
1406
+ if not bv.startswith("BV"):
1407
+ return "参数不对:要给 BV 号"
1408
+ py_candidates = [
1409
+ DUTY_HOME / ".venv-asr" / "Scripts" / "python.exe",
1410
+ DUTY_HOME / ".venv-asr" / "bin" / "python",
1411
+ ]
1412
+ py = next((str(p) for p in py_candidates if p.exists()), "")
1413
+ if not py:
1414
+ return "转写环境没装好(设置页里有「装转写依赖」)"
1415
+ script = DUTY_HOME / "asr.py"
1416
+ if not script.exists():
1417
+ return "找不到 asr.py(家目录里没有转写脚本)"
1418
+ out = DUTY_HOME / "tmp" / (bv + ".txt")
1419
+ out.parent.mkdir(parents=True, exist_ok=True)
1420
+ try:
1421
+ r = subprocess.run(
1422
+ [py, str(script), bv, "-o", str(out)],
1423
+ capture_output=True, timeout=1800,
1424
+ env={**os.environ, "PYTHONUTF8": "1", "PYTHONIOENCODING": "utf-8"},
1425
+ )
1426
+ except subprocess.TimeoutExpired:
1427
+ return "转写超时(30 分钟)—— 这条按「读不到」处理,跳过它"
1428
+ except Exception as e:
1429
+ return "转写启动失败:" + str(e)
1430
+ if not out.exists():
1431
+ tail = (r.stderr or b"")[-400:].decode("utf-8", "replace")
1432
+ return "转写没产出文件(exit " + str(r.returncode) + "):" + tail
1433
+ text = out.read_text(encoding="utf-8", errors="replace").strip()
1434
+ if not text:
1435
+ return "(空)—— 这条没有语音,跳过它"
1436
+ if len(text) <= 40 and ("字幕by" in text or "字幕BY" in text):
1437
+ return "(只有署名「" + text[:40] + "」)—— 当没有语音处理,跳过"
1438
+ return text[:12000]
1439
+
1440
+ # ========== Tool 25: 看画面(封面) ==========
1441
+ #
1442
+ # 图作为 MCP image content 返回,由宿主决定能否进模型上下文。
1443
+ # 缩图交给 B 站图片 CDN(URL 后缀 @512w.jpg),不引 Pillow、不落临时文件。
1444
+
1445
+ _WEB_UA = (
1446
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
1447
+ "(KHTML, like Gecko) Chrome/124.0 Safari/537.36"
1448
+ )
1449
+ _COVER_WIDTH = 512
1450
+ _HTTP_TIMEOUT = 25 # 图床 / 快照下载统一超时(秒)
1451
+ # 防御性上限:实测封面 23–36KB、帧 2.6–29KB,正常走不到这一行
1452
+ _MAX_IMAGE_BYTES = 1_500_000
1453
+
1454
+
1455
+ def _https(url: str) -> str:
1456
+ """把 B 站的图片/资源地址统一成 https。
1457
+
1458
+ view 接口给的 `pic` 实测 10/10 都是 `http://`,而 http 不会跳转 https;
1459
+ 图床请求再带上 SESSDATA 就等于把登录凭据明文发出去。
1460
+ """
1461
+ u = str(url or "")
1462
+ if u.startswith("//"):
1463
+ return "https:" + u
1464
+ if u.startswith("http://"):
1465
+ return "https://" + u[len("http://"):]
1466
+ return u
1467
+
1468
+
1469
+ def _cover_url(pic: str, width: int = _COVER_WIDTH) -> str:
1470
+ """封面原图地址 → 带 CDN 缩放后缀(@<宽>w.jpg)的地址,等比不裁切。"""
1471
+ base = _https(str(pic).split("@", 1)[0])
1472
+ return f"{base}@{width}w.jpg"
1473
+
1474
+
1475
+ def _image_headers() -> dict:
1476
+ """图床请求头:只给 UA + Referer,**不带 Cookie**(图床不需要登录态)。"""
1477
+ return {"User-Agent": _WEB_UA, "Referer": "https://www.bilibili.com/"}
1478
+
1479
+
1480
+ def _text_only(payload: dict) -> list:
1481
+ return [TextContent(type="text", text=json.dumps(payload, ensure_ascii=False))]
1482
+
1483
+
1484
+ @tool()
1485
+ async def bili_cover(bvid: str) -> list:
1486
+ """
1487
+ 看这个视频的封面(等比缩到 512px 宽)
1488
+
1489
+ 最便宜的一档"看一眼":能看出风格、标题美术、大致题材,但看不出具体发生了什么。
1490
+
1491
+ Args:
1492
+ bvid: 视频BV号,如"BV1uNk1YxEJQ"
1493
+
1494
+ Returns:
1495
+ 封面图 + 一句说明(标题、时长、图多大)。
1496
+ 图能不能真被看见取决于当前模型是否支持图片输入;不支持时会被降级成文字。
1497
+ """
1498
+ import aiohttp
1499
+
1500
+ cred = load_credential() # 封面是公开的,没登录也取得到
1501
+ try:
1502
+ v = video.Video(bvid=bvid, credential=cred) # 格式不对时**构造时就抛**
1503
+ info = await v.get_info()
1504
+ except Exception as e:
1505
+ # ⚠️ 2026-10-23 实测两种失败:`62002 稿件不可见`(BV 不存在)
1506
+ # 和 `ArgsException`(格式不对,构造时就抛)。都收成 ok:false。
1507
+ return _text_only({"bvid": bvid, "ok": False, "error": f"取视频信息失败:{e}"})
1508
+
1509
+ pic = str(info.get("pic") or "")
1510
+ if not pic:
1511
+ return _text_only({"bvid": bvid, "ok": False, "error": "这个视频没有封面地址"})
1512
+
1513
+ url = _cover_url(pic)
1514
+ try:
1515
+ timeout = aiohttp.ClientTimeout(total=_HTTP_TIMEOUT)
1516
+ async with aiohttp.ClientSession(timeout=timeout) as s:
1517
+ async with s.get(url, headers=_image_headers()) as resp:
1518
+ if resp.status != 200:
1519
+ raise RuntimeError(f"HTTP {resp.status}")
1520
+ raw = await resp.read()
1521
+ mime = (resp.headers.get("Content-Type") or "image/jpeg").split(";")[0].strip()
1522
+ except Exception as e:
1523
+ return _text_only({"bvid": bvid, "ok": False, "error": f"下载封面失败:{e}"})
1524
+
1525
+ allowed = ("image/jpeg", "image/png", "image/webp", "image/gif")
1526
+ if mime not in allowed:
1527
+ return _text_only({"bvid": bvid, "ok": False, "error": f"封面不是可用的图片类型:{mime}"})
1528
+ if len(raw) > _MAX_IMAGE_BYTES:
1529
+ return _text_only({
1530
+ "bvid": bvid, "ok": False,
1531
+ "error": f"封面太大({len(raw) // 1024}KB),没放进来",
1532
+ })
1533
+
1534
+ return [
1535
+ TextContent(type="text", text=json.dumps({
1536
+ "bvid": bvid,
1537
+ "ok": True,
1538
+ "title": info.get("title"),
1539
+ "duration": info.get("duration"),
1540
+ "cover_kb": round(len(raw) / 1024, 1),
1541
+ }, ensure_ascii=False)),
1542
+ ImageContent(
1543
+ type="image",
1544
+ data=base64.b64encode(raw).decode("ascii"),
1545
+ mimeType=mime,
1546
+ ),
1547
+ ]
1548
+
1549
+
1550
+ # ========== Tool 26: 看画面(画面帧) ==========
1551
+ #
1552
+ # 画面来自 B 站自带的故事板(x/player/videoshot),不解码视频、不用 ffmpeg。
1553
+ # Video.get_video_snapshot() 有封装,但 pvideo 默认 True 会走老接口,必须传 False。
1554
+ # 裁切用 Pillow(requirements 里的 qrcode[pil] 已经带进来)。
1555
+
1556
+ _FRAME_MAX = 6
1557
+ _TILE_W_DEFAULT = 480
1558
+ _TILE_H_DEFAULT = 270
1559
+
1560
+
1561
+ def _snapshot_entries(data: dict, duration: float = 0.0) -> list:
1562
+ """把 videoshot 的 index 归一成 [(秒, 块序号), ...]。
1563
+
1564
+ 时间单位实测两种都出现过(秒 / 毫秒),只能靠和时长比较来判断:
1565
+ 超过时长 1.5 倍才当毫秒。⚠️ 别再加"绝对值大于某个数就当毫秒"这类判据 ——
1566
+ 6 小时视频的秒数(22163)会被误判成毫秒,三帧全塌到同一块、时间缩小 1000 倍。
1567
+ """
1568
+ raws = []
1569
+ for i, item in enumerate(data.get("index") or []):
1570
+ ts = item.get("timestamp") if isinstance(item, dict) else item
1571
+ try:
1572
+ raws.append((float(ts), i))
1573
+ except (TypeError, ValueError):
1574
+ continue
1575
+ if not raws:
1576
+ return []
1577
+
1578
+ biggest = max(v for v, _ in raws)
1579
+ looks_ms = duration > 0 and biggest > duration * 1.5
1580
+ scale = 1000.0 if looks_ms else 1.0
1581
+ return [(v / scale, i) for v, i in raws]
1582
+
1583
+
1584
+ async def _entries_from_pvdata(data: dict) -> list:
1585
+ """index 为空时的兜底:pvdata 指向一个大端 uint16 秒数数组,与 index 逐项相同。"""
1586
+ url = _https(data.get("pvdata") or "")
1587
+ if not url:
1588
+ return []
1589
+ try:
1590
+ raw = await _fetch_bytes(url, _image_headers())
1591
+ except Exception:
1592
+ return []
1593
+ if not raw or len(raw) % 2:
1594
+ return []
1595
+ count = len(raw) // 2
1596
+ if count > 20000: # 明显不对,别乱解
1597
+ return []
1598
+ return [(float(sec), i) for i, sec in enumerate(struct.unpack(f">{count}H", raw))]
1599
+
1600
+
1601
+ async def _fetch_bytes(url: str, headers: dict) -> bytes:
1602
+ import aiohttp
1603
+
1604
+ timeout = aiohttp.ClientTimeout(total=_HTTP_TIMEOUT)
1605
+ async with aiohttp.ClientSession(timeout=timeout) as s:
1606
+ async with s.get(_https(url), headers=headers) as resp:
1607
+ if resp.status != 200:
1608
+ raise RuntimeError(f"HTTP {resp.status}")
1609
+ return await resp.read()
1610
+
1611
+
1612
+ def _crop_tile(sheet: bytes, tile_index: int, tw: int, th: int) -> bytes:
1613
+ """从拼图里裁出第 tile_index 块,返回 jpeg 字节。"""
1614
+ import io
1615
+
1616
+ from PIL import Image
1617
+
1618
+ img = Image.open(io.BytesIO(sheet))
1619
+ cols = max(1, img.width // tw)
1620
+ rows = max(1, img.height // th)
1621
+ if tile_index >= cols * rows:
1622
+ raise RuntimeError(f"第 {tile_index} 块超出拼图(只有 {cols}×{rows})")
1623
+ col, row = tile_index % cols, tile_index // cols
1624
+ tile = img.crop((col * tw, row * th, (col + 1) * tw, (row + 1) * th)).convert("RGB")
1625
+ buf = io.BytesIO()
1626
+ tile.save(buf, format="JPEG", quality=85)
1627
+ return buf.getvalue()
1628
+
1629
+
1630
+ @tool()
1631
+ async def bili_frames(bvid: str, num: int = 3, at: list | None = None) -> list:
1632
+ """
1633
+ 看这个视频里的画面(从 B 站自带的故事板里裁几帧)
1634
+
1635
+ 想看"它到底拍了什么"就用它。默认按时长均匀取 3 帧;
1636
+ 想盯某个时刻(比如字幕里提到的那一段)就传 at(单位:秒),会取**最接近的那一块**。
1637
+
1638
+ Args:
1639
+ bvid: 视频BV号,如"BV1uNk1YxEJQ"
1640
+ num: 取几帧,1-6,默认 3
1641
+ at: 指定时间点(秒)的列表;传了就以它为准,忽略 num
1642
+
1643
+ Returns:
1644
+ 若干张 480×270 的画面 + 一句说明(时长、实际取到的那一刻、哪几帧失败)。
1645
+ 失败会写进 failed。**多P 视频的故事板通常只覆盖第 1P**,返回里会用
1646
+ covered_sec / pages 标明实际覆盖范围。图能否被看见取决于模型是否支持图片输入。
1647
+ """
1648
+ cred = load_credential() # 故事板不登录也能取
1649
+ try:
1650
+ v = video.Video(bvid=bvid, credential=cred)
1651
+ info = await v.get_info()
1652
+ except Exception as e:
1653
+ return _text_only({"bvid": bvid, "ok": False, "error": f"取视频信息失败:{e}"})
1654
+ duration = float(info.get("duration") or 0)
1655
+ pages = info.get("pages") or []
1656
+ cid = info.get("cid") or ((pages or [{}])[0].get("cid") or 0)
1657
+ if not cid:
1658
+ return _text_only({"bvid": bvid, "ok": False, "error": "拿不到 cid"})
1659
+
1660
+ # 故事板只覆盖第 1P:目标时间点按"实际覆盖的长度"算,别落到没采样的 P2 上
1661
+ covered = float(pages[0].get("duration") or duration) if pages else duration
1662
+ covered = covered or duration
1663
+
1664
+ # index 会偶发返回空(实测约 25%):先用 pvdata 兜底,两个都空才重试
1665
+ snap = {}
1666
+ entries = []
1667
+ last_err = None
1668
+ for attempt in range(2):
1669
+ try:
1670
+ snap = await v.get_video_snapshot(cid=cid, json_index=True, pvideo=False) or {}
1671
+ last_err = None
1672
+ except Exception as e:
1673
+ snap, last_err = {}, e
1674
+ entries = _snapshot_entries(snap, covered)
1675
+ if not entries:
1676
+ entries = await _entries_from_pvdata(snap)
1677
+ if entries and (snap.get("image") or []):
1678
+ break
1679
+ if attempt < 1:
1680
+ await asyncio.sleep(1.0)
1681
+
1682
+ sheets = snap.get("image") or []
1683
+ if not entries or not sheets:
1684
+ err = f"(最后一次错误:{last_err})" if last_err is not None else ""
1685
+ return _text_only({
1686
+ "bvid": bvid, "ok": False,
1687
+ "error": f"取不到画面快照(试了 2 次){err}",
1688
+ })
1689
+
1690
+ # 覆盖长度以**故事板实测到的那一刻**为准(比 P1 的标称时长保守,通常短 1–10%)
1691
+ story_max = max((e[0] for e in entries), default=0.0)
1692
+ if story_max > 0:
1693
+ covered = min(covered, story_max)
1694
+
1695
+ # 目标时间点:传了 at 就用它;否则按"覆盖长度"均分
1696
+ if at:
1697
+ targets = [min(max(0.0, float(t)), max(0.0, covered - 0.1)) for t in at][:_FRAME_MAX]
1698
+ else:
1699
+ n = max(1, min(int(num or 3), _FRAME_MAX))
1700
+ targets = [covered * (i + 1) / (n + 1) for i in range(n)]
1701
+
1702
+ picked = []
1703
+ for t in targets:
1704
+ ts, tile = min(entries, key=lambda e: abs(e[0] - t))
1705
+ picked.append((round(t, 1), round(ts, 1), tile))
1706
+
1707
+ tw = int(snap.get("img_x_size") or _TILE_W_DEFAULT)
1708
+ th = int(snap.get("img_y_size") or _TILE_H_DEFAULT)
1709
+ headers = _image_headers()
1710
+
1711
+ cache = {}
1712
+
1713
+ async def get_sheet(no: int) -> bytes:
1714
+ if no not in cache:
1715
+ if no >= len(sheets):
1716
+ raise RuntimeError(f"拼图 {no} 不存在(共 {len(sheets)} 张)")
1717
+ cache[no] = await _fetch_bytes(str(sheets[no]), headers)
1718
+ return cache[no]
1719
+
1720
+ # 每张拼图装几块要等第一张下来才知道(列数 = 宽 // 块宽)
1721
+ blocks, frames, failed = [], [], []
1722
+ cap = None
1723
+ for i, (want, got, tile) in enumerate(picked):
1724
+ try:
1725
+ if cap is None:
1726
+ first = await get_sheet(0)
1727
+ import io
1728
+
1729
+ from PIL import Image
1730
+
1731
+ im = Image.open(io.BytesIO(first))
1732
+ cap = max(1, (im.width // tw) * (im.height // th))
1733
+ sheet_no, pos = tile // cap, tile % cap
1734
+ raw = _crop_tile(await get_sheet(sheet_no), pos, tw, th)
1735
+ if len(raw) > _MAX_IMAGE_BYTES:
1736
+ raise RuntimeError(f"这一帧太大({len(raw) // 1024}KB)")
1737
+ blocks.append(ImageContent(
1738
+ type="image",
1739
+ data=base64.b64encode(raw).decode("ascii"),
1740
+ mimeType="image/jpeg",
1741
+ ))
1742
+ frames.append({"index": i, "want_sec": want, "got_sec": got,
1743
+ "kb": round(len(raw) / 1024, 1)})
1744
+ except Exception as e:
1745
+ failed.append({"index": i, "want_sec": want, "got_sec": got, "reason": str(e)})
1746
+
1747
+ if not blocks:
1748
+ return _text_only({
1749
+ "bvid": bvid, "ok": False,
1750
+ "error": "一帧都没取到", "failed": failed,
1751
+ })
1752
+
1753
+ return [
1754
+ TextContent(type="text", text=json.dumps({
1755
+ "bvid": bvid,
1756
+ "ok": True,
1757
+ "title": info.get("title"),
1758
+ "duration": duration,
1759
+ "covered_sec": round(covered, 1),
1760
+ "pages": max(1, len(pages)),
1761
+ "tile": f"{tw}x{th}",
1762
+ "frames": frames,
1763
+ "failed": failed, # 空列表 = 全成
1764
+ "note": "want_sec 是想要的时刻,got_sec 是实际取到的那一块(故事板按固定间隔切);"
1765
+ "多P 视频只覆盖第 1P,看 covered_sec",
1766
+ }, ensure_ascii=False)),
1767
+ *blocks,
1768
+ ]
1769
+
1770
+
1771
+ # ========== Tool: 首页推荐流(不填关键词时用它,替代固定关键词搜索) ==========
1772
+ # ⭐ 为什么要它(2026-10-23 主人拍板):
1773
+ # 以前不填关键词就用内置那几组(DeepSeek Harness / DSH 插件 …)⇒ **所有装了插件的人刷的是同一批视频**
1774
+ # ⇒ 相关视频的评论区必然堆成一片"同款插件在互相报到" ✗
1775
+ # 推荐流按各自账号画像推,天然散开 ✓ —— 这是结构性解法,比"每轮少发几条"靠谱得多。
1776
+ #
1777
+ # ⚠️ 它需要登录凭证(匿名也能拿到一部分,但带凭证才是"这个账号的推荐"),
1778
+ # 所以这里用 load_credential();拿不到就退成匿名(仍然能用,只是不那么"散")。
1779
+ @tool()
1780
+ async def bili_recommend(ps: int = 20) -> str:
1781
+ """
1782
+ 获取「首页推荐流」的视频(按当前登录账号的画像)
1783
+
1784
+ Args:
1785
+ ps: 返回几条,默认 20,最多 40
1786
+
1787
+ Returns:
1788
+ JSON 格式的视频列表,字段与 bili_search 对齐(bvid / title / author / play / danmaku / duration…)
1789
+ """
1790
+ denied = _guard("bili_recommend")
1791
+ if denied:
1792
+ return json.dumps({"ok": False, "error": denied}, ensure_ascii=False)
1793
+
1794
+ cred = None
1795
+ try:
1796
+ cred = load_credential()
1797
+ except Exception:
1798
+ cred = None
1799
+
1800
+ try:
1801
+ data = await homepage.get_videos(credential=cred)
1802
+ except Exception as e: # noqa: BLE001 - 统一收成 ok:false,别把原始异常甩给模型
1803
+ return json.dumps({"ok": False, "error": f"取推荐流失败:{e}"}, ensure_ascii=False)
1804
+
1805
+ items = []
1806
+ if isinstance(data, dict):
1807
+ items = data.get("item") or data.get("data") or []
1808
+ elif isinstance(data, list):
1809
+ items = data
1810
+
1811
+ videos = []
1812
+ for it in items[: max(1, min(int(ps), 40))]:
1813
+ if not isinstance(it, dict):
1814
+ continue
1815
+ # 只留视频(推荐流里会混进直播 / 番剧 / 广告卡片)
1816
+ if it.get("goto") and it.get("goto") != "av":
1817
+ continue
1818
+ if not it.get("bvid"):
1819
+ continue
1820
+ owner = it.get("owner") or {}
1821
+ stat = it.get("stat") or {}
1822
+ dur = it.get("duration")
1823
+ videos.append({
1824
+ "bvid": it.get("bvid", ""),
1825
+ "title": str(it.get("title", "")).strip(),
1826
+ "author": owner.get("name", ""),
1827
+ "play": stat.get("view", 0),
1828
+ "danmaku": stat.get("danmaku", 0),
1829
+ "like": stat.get("like", 0),
1830
+ "duration": f"{int(dur) // 60}:{int(dur) % 60:02d}" if isinstance(dur, (int, float)) else str(dur or ""),
1831
+ "pubdate": it.get("pubdate", 0),
1832
+ "reason": (it.get("rcmd_reason") or {}).get("content", "") if isinstance(it.get("rcmd_reason"), dict) else "",
1833
+ })
1834
+
1835
+ return json.dumps({
1836
+ "ok": True,
1837
+ "source": "recommend",
1838
+ "count": len(videos),
1839
+ "videos": videos,
1840
+ }, ensure_ascii=False)
1841
+
1842
+
1137
1843
  # ========== 启动 ==========
1138
1844
 
1139
1845
  if __name__ == "__main__":