Files
Live-streaming/app/danmu_bettergi.py
2026-08-15 14:43:56 +08:00

655 lines
24 KiB
Python

"""
BetterGI 弹幕联动主程序
========================
监听 B 站直播间弹幕 -> 关键词匹配 -> 调用 BetterGI.exe --startGroups 执行配置组
依赖: pip install websockets brotli
运行: python danmu_bettergi.py
"""
import asyncio
import json
import logging
import os
import struct
import subprocess
import sys
import time
import urllib.request
import zlib
from pathlib import Path
APP_DIR = Path(__file__).resolve().parent
if str(APP_DIR) not in sys.path:
sys.path.insert(0, str(APP_DIR))
from core.runtime_paths import APP_ROOT as PROJECT_ROOT, CONFIG_DIR, LOG_DIR, ensure_runtime_dirs, project_path
try:
import websockets
import brotli
except ImportError:
print("缺少依赖,请先运行: pip install websockets brotli")
sys.exit(1)
# ============== B站直播弹幕协议常量 ==============
HEADER_LEN = 16
OP_HEARTBEAT = 2 # 心跳请求
OP_HEARTBEAT_REPLY = 3 # 心跳响应(人气值)
OP_MESSAGE = 5 # 业务消息
OP_AUTH = 7 # 认证请求
OP_AUTH_REPLY = 8 # 认证响应
PROTO_JSON = 0 # 明文JSON
PROTO_ZLIB = 2 # zlib压缩
PROTO_BROTLI = 3 # brotli压缩
# ============== 弹幕服务器发现 ==============
def get_danmu_server(room_id: int) -> dict:
"""通过B站API获取弹幕服务器地址和token。优先使用host_server_list中的新服务器。"""
url = f"https://api.live.bilibili.com/room/v1/Danmu/getConf?room_id={room_id}"
req = urllib.request.Request(url)
req.add_header("User-Agent", "Mozilla/5.0")
resp = urllib.request.urlopen(req, timeout=10)
data = json.loads(resp.read())["data"]
token = data["token"]
host_list = data.get("host_server_list", [])
if host_list:
entry = host_list[0]
host = entry["host"]
port = entry.get("wss_port", 443)
else:
host = data["host"]
port = data.get("wss_port", 443)
return {
"ws_url": f"wss://{host}:{port}/sub",
"token": token,
"host": host,
"port": port,
}
def get_buvid3(sessdata: str = "") -> str:
"""通过B站finger/spi接口获取buvid3 (2024+协议认证必需)。"""
url = "https://api.bilibili.com/x/frontend/finger/spi"
req = urllib.request.Request(url)
req.add_header("User-Agent", "Mozilla/5.0")
if sessdata:
req.add_header("Cookie", f"SESSDATA={sessdata}")
try:
resp = urllib.request.urlopen(req, timeout=10)
data = json.loads(resp.read())
if data.get("code") == 0:
return data["data"]["b_3"]
except Exception:
pass
return ""
# ============== 配置管理 ==============
class Config:
def __init__(self, path: str):
self.path = Path(path)
self.data = {}
self.reload()
def reload(self):
with open(self.path, "r", encoding="utf-8") as f:
self.data = json.load(f)
@property
def room_id(self) -> int:
return int(self.data["bilibili"]["room_id"])
@property
def sessdata(self) -> str:
return self.data["bilibili"].get("sessdata", "")
@property
def bettergi_exe(self) -> str:
return self.data["bettergi"]["exe_path"]
@property
def bettergi_work_dir(self) -> str:
wd = self.data["bettergi"].get("work_dir", "")
return wd if wd else str(Path(self.bettergi_exe).parent)
@property
def default_cooldown(self) -> int:
return int(self.data["global"].get("default_cooldown", 30))
@property
def admin_uids(self) -> set:
return set(int(x) for x in self.data["global"].get("admin_uids", []))
@property
def rules(self) -> list:
return self.data.get("rules", [])
@property
def restart_mode(self) -> str:
"""重启模式: gentle(温和,跳过) / aggressive(激进,先杀再启)"""
return self.data["global"].get("restart_mode", "gentle")
# ============== 冷却管理 ==============
class CooldownManager:
"""按规则记录最后触发时间,防止同一指令被弹幕刷屏重复触发。"""
def __init__(self):
self._last_fire: dict = {} # keyword -> timestamp
def can_fire(self, keyword: str, cooldown: int) -> bool:
now = time.time()
last = self._last_fire.get(keyword, 0)
return (now - last) >= cooldown
def mark_fired(self, keyword: str):
self._last_fire[keyword] = time.time()
# ============== BetterGI 调用 ==============
class BetterGIRunner:
"""封装 BetterGI.exe --startGroups 调用。"""
def __init__(self, exe_path: str, work_dir: str, logger: logging.Logger,
restart_mode: str = "gentle"):
self.exe_path = exe_path
self.work_dir = work_dir
self.logger = logger
self.restart_mode = restart_mode # gentle / aggressive
self._busy = False # 标记是否正在执行任务
self._lock = asyncio.Lock()
# 缓存 cancelTaskHotkey (从 BetterGI Config.json 读)
self._cancel_hotkey = None
self._load_cancel_hotkey()
def _load_cancel_hotkey(self):
"""从 BetterGI 的 Config.json 读取取消任务热键。"""
try:
import os
cfg_path = os.path.join(self.work_dir, "User", "Config.json")
if os.path.exists(cfg_path):
with open(cfg_path, "r", encoding="utf-8") as f:
bgi_cfg = json.load(f)
hk = bgi_cfg.get("hotKeyConfig", {}).get("cancelTaskHotkey", "")
if hk:
self._cancel_hotkey = hk
self.logger.info(f"已读取 BetterGI 取消任务热键: {hk}")
except Exception as e:
self.logger.debug(f"读取 cancelTaskHotkey 失败(不影响使用): {e}")
async def _stop_bgi_gracefully(self):
"""优雅停止 BetterGI: 先按取消热键,再 taskkill 兜底。"""
# ① 模拟按取消热键 (让 BGI 内部收尾,保存进度)
if self._cancel_hotkey:
self.logger.info(f"[激进] 按取消热键: {self._cancel_hotkey}")
try:
await self._send_key(self._cancel_hotkey)
except Exception as e:
self.logger.warning(f"[激进] 按热键失败: {e}")
# 给 BetterGI 5 秒收尾时间
await asyncio.sleep(5)
# ② taskkill 强杀兜底
self.logger.info("[激进] taskkill /F /IM BetterGI.exe")
try:
proc = await asyncio.create_subprocess_exec(
"taskkill", "/F", "/IM", "BetterGI.exe",
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
# CREATE_NO_WINDOW = 0x08000000,避免弹黑窗
creationflags=0x08000000,
)
await proc.communicate()
except Exception as e:
self.logger.debug(f"[激进] taskkill 执行: {e}")
# 再等 1 秒让进程完全退出
await asyncio.sleep(1)
async def _send_key(self, key: str):
"""模拟按键。优先用 pywin32,其次 keyboard 库,都没有则跳过。"""
# 简单映射: BetterGI 的热键名通常是 "F9" "Ctrl+P" 这种
# 优先尝试 pywin32 (最稳定,不弹窗)
try:
import win32api
import win32con
# 简单支持单键 (F1-F24, 字母, 数字)
vk_map = {
"F1": win32con.VK_F1, "F2": win32con.VK_F2, "F3": win32con.VK_F3,
"F4": win32con.VK_F4, "F5": win32con.VK_F5, "F6": win32con.VK_F6,
"F7": win32con.VK_F7, "F8": win32con.VK_F8, "F9": win32con.VK_F9,
"F10": win32con.VK_F10, "F11": win32con.VK_F11, "F12": win32con.VK_F12,
"ESC": win32con.VK_ESCAPE, "ESCAPE": win32con.VK_ESCAPE,
}
# 处理 Ctrl+X Shift+X 这种组合键
parts = key.replace("+", " ").split()
main_key = parts[-1].upper()
ctrl = "CTRL" in [p.upper() for p in parts[:-1]]
shift = "SHIFT" in [p.upper() for p in parts[:-1]]
alt = "ALT" in [p.upper() for p in parts[:-1]]
vk = vk_map.get(main_key)
if vk is None and len(main_key) == 1:
vk = ord(main_key.upper()) # 字母键
if vk is None:
self.logger.warning(f"[激进] 不支持的热键: {key}, 跳过热键直接 taskkill")
return
if ctrl:
win32api.keybd_event(win32con.VK_CONTROL, 0, 0, 0)
if shift:
win32api.keybd_event(win32con.VK_SHIFT, 0, 0, 0)
if alt:
win32api.keybd_event(win32con.VK_MENU, 0, 0, 0)
win32api.keybd_event(vk, 0, 0, 0) # 按下
win32api.keybd_event(vk, 0, win32con.KEYEVENTF_KEYUP, 0) # 抬起
if ctrl:
win32api.keybd_event(win32con.VK_CONTROL, 0, win32con.KEYEVENTF_KEYUP, 0)
if shift:
win32api.keybd_event(win32con.VK_SHIFT, 0, win32con.KEYEVENTF_KEYUP, 0)
if alt:
win32api.keybd_event(win32con.VK_MENU, 0, win32con.KEYEVENTF_KEYUP, 0)
self.logger.info(f"[激进] 已模拟按键: {key}")
except ImportError:
self.logger.warning(
"[激进] 未安装 pywin32, 无法模拟热键。"
"可运行: pip install pywin32 (可选,不装则直接 taskkill)"
)
async def run_groups(self, groups: list) -> bool:
"""异步启动配置组。"""
async with self._lock:
if self._busy:
if self.restart_mode == "aggressive":
self.logger.warning(
f"[激进模式] BetterGI 正在执行任务,先杀再启: {groups}"
)
# 释放锁,执行停止 (停止可能耗时,不持有锁)
self._busy = False
else:
self.logger.warning(
f"[温和模式] BetterGI 正在执行任务,跳过本次触发: {groups}"
)
return False
else:
self._busy = True
# 激进模式: 先停止旧任务
if self.restart_mode == "aggressive":
await self._stop_bgi_gracefully()
async with self._lock:
self._busy = True
try:
cmd = [self.exe_path, "--startGroups"] + groups
self.logger.info(f"调用 BetterGI: {' '.join(cmd)}")
proc = await asyncio.create_subprocess_exec(
*cmd,
cwd=self.work_dir,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await proc.communicate()
if proc.returncode in (0, 553):
self.logger.info(f"BetterGI 启动成功: {groups} (rc={proc.returncode})")
return True
else:
self.logger.error(
f"BetterGI 启动失败 rc={proc.returncode}: "
f"{stderr.decode('gbk', errors='replace')}"
)
return False
except Exception as e:
self.logger.exception(f"调用 BetterGI 异常: {e}")
return False
finally:
self._busy = False
# ============== 弹幕匹配引擎 ==============
class DanmuMatcher:
"""把弹幕文本匹配到对应规则。"""
def __init__(self, config: Config, cooldown: CooldownManager,
runner: BetterGIRunner, logger: logging.Logger):
self.config = config
self.cooldown = cooldown
self.runner = runner
self.logger = logger
async def handle(self, text: str, uid: int, uname: str):
text = (text or "").strip()
if not text:
return
admins = self.config.admin_uids
is_admin = uid in admins
for rule in self.config.rules:
if not self._match(text, rule):
continue
keyword = rule["keyword"]
groups = rule["groups"]
cooldown_sec = rule.get("cooldown", self.config.default_cooldown)
admin_only = rule.get("admin_only", False)
reply = rule.get("reply", "")
# 权限检查
if admin_only and not is_admin:
self.logger.info(
f"[权限不足] {uname}({uid}) 弹幕'{text}' 命中'{keyword}' 但需管理员"
)
return # 注意: 这里return了,继续检查下一条规则(如果有)
# 冷却检查
if not self.cooldown.can_fire(keyword, cooldown_sec):
remain = int(cooldown_sec - (time.time() - self.cooldown._last_fire.get(keyword, 0)))
self.logger.info(
f"[冷却中] '{keyword}' 还需 {remain}s, 来自 {uname}({uid})"
)
return # 冷却不触发,但日志已打印
# 触发
self.cooldown.mark_fired(keyword)
self.logger.info(
f"[触发] {uname}({uid}) 弹幕'{text}' -> 配置组 {groups}"
)
if reply:
self.logger.info(f"[回复提示] {reply}")
await self.runner.run_groups(groups)
return # 一条弹幕只触发第一个命中的规则
@staticmethod
def _match(text: str, rule: dict) -> bool:
keyword = rule["keyword"]
mtype = rule.get("match_type", "contains")
if mtype == "exact":
return text == keyword
elif mtype == "contains":
return keyword in text
elif mtype == "startswith":
return text.startswith(keyword)
elif mtype == "regex":
import re
return re.search(keyword, text) is not None
return False
# ============== B站弹幕协议 ==============
def make_packet(op: int, body: bytes = b"") -> bytes:
"""组装协议包。header 16字节 + body。"""
if isinstance(body, str):
body = body.encode("utf-8")
total = HEADER_LEN + len(body)
header = struct.pack(">IHHII", total, HEADER_LEN, 1, op, 1)
return header + body
def parse_packets(data: bytes):
"""一个 WebSocket 帧可能含多个协议包,循环切分。"""
offset = 0
packets = []
while offset < len(data):
if offset + HEADER_LEN > len(data):
break
total, header_len, proto_ver, op, seq = struct.unpack(
">IHHII", data[offset:offset + HEADER_LEN]
)
body = data[offset + header_len:offset + total]
packets.append((proto_ver, op, body))
offset += total
return packets
def decode_body(proto_ver: int, body: bytes) -> bytes:
"""按协议版本解压 body。"""
if proto_ver == PROTO_JSON:
return body
if proto_ver == PROTO_ZLIB:
return zlib.decompress(body)
if proto_ver == PROTO_BROTLI:
return brotli.decompress(body)
return body
def _clean_uid(value) -> int:
"""只接受纯十进制正整数 UID,匿名或含星号的值返回 0。"""
if isinstance(value, bool):
return 0
if isinstance(value, int):
return value if value > 0 else 0
if isinstance(value, str):
value = value.strip()
return int(value) if value.isdecimal() and int(value) > 0 else 0
return 0
def extract_danmu(body: bytes):
"""从消息体中提取弹幕(DANMU_MSG)。返回 [(text, uid, uname), ...]。"""
results = []
try:
decoded = decode_body(0, body) # body 已是解压后的,proto_ver 此处忽略
except Exception:
decoded = body
try:
msg = json.loads(decoded.decode("utf-8", errors="replace"))
except Exception:
return results
cmd = msg.get("cmd", "")
if cmd.startswith("DANMU_MSG"):
info = msg.get("info", [])
if not isinstance(info, list) or len(info) <= 2:
return results
text = str(info[1] if len(info) > 1 else "")
member = info[2]
if isinstance(member, (list, tuple)):
uid = _clean_uid(member[0] if len(member) > 0 else 0)
uname = str(member[1] if len(member) > 1 and member[1] is not None else "")
elif isinstance(member, dict):
uid = _clean_uid(member.get("uid_str") or member.get("uid") or member.get("mid"))
uname = str(member.get("uname") or member.get("name") or "")
else:
uid, uname = 0, ""
results.append((text, uid, uname))
return results
# ============== 弹幕客户端 ==============
class BliveClient:
"""B站直播弹幕 WebSocket 客户端,带自动重连。"""
def __init__(self, room_id: int, sessdata: str,
matcher: DanmuMatcher, logger: logging.Logger):
self.room_id = room_id
self.sessdata = sessdata
self.matcher = matcher
self.logger = logger
self._stop = False
self._buvid3 = ""
async def run(self):
"""主循环:断线自动重连,间隔递增。"""
# 启动时获取buvid3 (2024+协议认证必需)
self._buvid3 = get_buvid3(self.sessdata)
if self._buvid3:
self.logger.info(f"获取buvid3成功: {self._buvid3[:20]}...")
else:
self.logger.warning("获取buvid3失败,弹幕可能收不到! 将尝试匿名连接")
retry = 0
while not self._stop:
try:
# 每次连接前重新获取服务器地址(避免IP变化)
server_info = get_danmu_server(self.room_id)
ws_url = server_info["ws_url"]
token = server_info["token"]
self.logger.info(f"弹幕服务器: {server_info['host']}:{server_info['port']}")
await self._connect_once(ws_url, token)
retry = 0 # 连上后重置
except asyncio.CancelledError:
break
except Exception as e:
self.logger.warning(f"连接断开: {e}")
if self._stop:
break
retry += 1
wait = min(2 ** retry, 60) # 指数退避,最多60秒
self.logger.info(f"{wait}秒后重连(第{retry}次)...")
await asyncio.sleep(wait)
def stop(self):
self._stop = True
async def _connect_once(self, ws_url: str, token: str):
self.logger.info(f"连接直播间 room_id={self.room_id} ...")
# 禁用 websockets 自带 ping/pong (B站有自己的心跳协议)
async with websockets.connect(
ws_url,
max_size=None,
ping_interval=None,
ping_timeout=None,
close_timeout=5,
) as ws:
# 1. 发送认证包(使用API返回的token + buvid3)
auth = {
"uid": 0,
"roomid": self.room_id,
"protover": PROTO_BROTLI,
"platform": "web",
"type": 2,
"key": token or (self.sessdata or ""),
}
if self._buvid3:
auth["buvid"] = self._buvid3
await ws.send(make_packet(OP_AUTH, json.dumps(auth)))
self.logger.info("已发送认证包,等待响应...")
# 立即发送一个心跳,激活弹幕推送
await ws.send(make_packet(OP_HEARTBEAT))
# 2. 心跳任务
async def heartbeat():
while True:
await asyncio.sleep(15)
try:
await ws.send(make_packet(OP_HEARTBEAT))
except Exception:
break
hb_task = asyncio.create_task(heartbeat())
# 3. 接收循环
msg_counter = 0
async for raw in ws:
if isinstance(raw, str):
continue
for proto_ver, op, body in parse_packets(raw):
if op == OP_AUTH_REPLY:
# 检查认证是否真的成功
try:
auth_resp = json.loads(body.decode("utf-8", errors="replace"))
code = auth_resp.get("code", -1)
if code == 0:
self.logger.info(f"认证成功,开始监听弹幕 (响应: {auth_resp})")
else:
self.logger.error(f"认证失败! 响应: {auth_resp}")
except Exception:
self.logger.info(f"认证响应(原始): {body}")
elif op == OP_HEARTBEAT_REPLY:
# body 是 4 字节人气值(大端序int32)
if len(body) >= 4:
popularity = struct.unpack(">I", body[:4])[0]
self.logger.debug(f"人气值: {popularity}")
elif op == OP_MESSAGE:
# body 可能被压缩,按 proto_ver 解压后再切包
try:
decoded = decode_body(proto_ver, body)
except Exception as e:
self.logger.error(f"解压失败 proto={proto_ver} len={len(body)}: {e}")
continue
# 解压后可能内含多个子包
for sub_proto, sub_op, sub_body in parse_packets(decoded):
if sub_op == OP_MESSAGE:
msg_counter += 1
# 调试:打印每条消息的cmd类型
try:
msg_json = json.loads(sub_body.decode("utf-8", errors="replace"))
cmd = msg_json.get("cmd", "?")
self.logger.debug(f"#{msg_counter} cmd={cmd}")
except Exception:
self.logger.debug(f"#{msg_counter} 解析JSON失败")
continue
for text, uid, uname in extract_danmu(sub_body):
self.logger.info(f"[弹幕] {uname}({uid}): {text}")
await self.matcher.handle(text, uid, uname)
hb_task.cancel()
# ============== 日志 ==============
def setup_logger(config: Config) -> logging.Logger:
logger = logging.getLogger("danmu_bettergi")
logger.setLevel(getattr(logging, config.data["global"].get("log_level", "INFO")))
fmt = logging.Formatter("%(asctime)s [%(levelname)s] %(message)s",
datefmt="%H:%M:%S")
sh = logging.StreamHandler(sys.stdout)
sh.setFormatter(fmt)
logger.addHandler(sh)
log_file = config.data["global"].get("log_file")
if log_file:
LOG_DIR.mkdir(parents=True, exist_ok=True)
fh = logging.FileHandler(project_path(log_file, base=LOG_DIR), encoding="utf-8")
fh.setFormatter(fmt)
logger.addHandler(fh)
return logger
# ============== 入口 ==============
async def main():
ensure_runtime_dirs()
config_path = CONFIG_DIR / "config.json"
if not config_path.exists():
legacy_config_path = PROJECT_ROOT / "config.json"
if legacy_config_path.exists():
config_path = legacy_config_path
if not config_path.exists():
print("找不到 config.json,请先配置!")
sys.exit(1)
config = Config(str(config_path))
logger = setup_logger(config)
logger.info("=" * 50)
logger.info("BetterGI 弹幕联动启动")
logger.info(f"直播间: {config.room_id}")
logger.info(f"BetterGI: {config.bettergi_exe}")
logger.info(f"规则数: {len(config.rules)}")
logger.info(f"管理员UID: {config.admin_uids or '无'}")
logger.info(f"默认冷却: {config.default_cooldown}s")
logger.info(f"重启模式: {config.restart_mode}")
logger.info("=" * 50)
cooldown = CooldownManager()
runner = BetterGIRunner(
config.bettergi_exe, config.bettergi_work_dir, logger,
restart_mode=config.restart_mode,
)
matcher = DanmuMatcher(config, cooldown, runner, logger)
client = BliveClient(config.room_id, config.sessdata, matcher, logger)
try:
await client.run()
except KeyboardInterrupt:
logger.info("收到退出信号,正在停止...")
client.stop()
if __name__ == "__main__":
try:
asyncio.run(main())
except KeyboardInterrupt:
pass