├── LICENSE ├── README.md ├── app.py ├── docker-compose.yml ├── docker └── mmock │ └── accept-all.yml ├── requirements.txt └── src ├── blivedm ├── __init__.py ├── client.py ├── handlers.py └── models.py ├── handlers ├── __init__.py ├── debug_handler.py ├── post_handler.py └── websocket_handler.py ├── libs ├── __init__.py └── bilibili_apis.py └── server ├── __init__.py └── server.py /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright 2022.10.24 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 6 | 7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 8 | 9 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 10 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## 通过websocket和JSON向前端app提供关于哔哩哔哩直播的各种API 2 | 3 | #### 哔哩哔哩直播的监听部分来自这个项目 https://github.com/xfgryujk/blivedm 4 | 5 | #### 请使用Python3.10以上 6 | 7 | ## 安装方法 8 | ```bash 9 | git clone https://github.com/MOCABEROS-TEAM/MocaBliveAPI.git 10 | cd ./MocaBliveAPI 11 | python3 -m pip install -r requirements.txt 12 | ``` 13 | 14 | ## 运行方法 15 | ```bash 16 | # 启动api服务器 17 | python3 app.py server --port 8080 18 | 19 | # 显示其他功能 20 | python app.py --help 21 | ``` 22 | 23 | ## 使用方法 24 | - 用websocket连接服务器。`ws://<服务器的IP>:服务器的端口/live` 比如 `ws://127.0.0.1:8080/live` 25 | - 发送认证信息(JSON格式) 26 | ```json 27 | { 28 | "room_id": "想监听的直播间的房间号", 29 | "secret_key": "自定义的API秘钥。通过环境变量 `SECRET_KEY` 设置" 30 | } 31 | ``` 32 | - 之后服务器会实时向客户端发送直播信息。 33 | -------------------------------------------------------------------------------- /app.py: -------------------------------------------------------------------------------- 1 | from typing import * 2 | from src.handlers import DebugHandler, POSTHandler 3 | from src.blivedm import BLiveClient 4 | from src.libs import get_live_info, get_live_status, get_user_info, get_user_icon, get_user_name 5 | from typer import Typer 6 | from pprint import pp 7 | import asyncio 8 | import logging 9 | import sys 10 | from os import environ 11 | import uvicorn 12 | from src.server import app 13 | 14 | 15 | console: Typer = Typer() 16 | logger: logging.Logger = logging.getLogger('MocaBliveAPI') 17 | logger.setLevel(logging.DEBUG) 18 | logger.addHandler(logging.StreamHandler(sys.stdout)) 19 | 20 | 21 | @console.command('server') 22 | def server_cmd(port: int = 3000): 23 | """ 24 | 启动websocket服务器 25 | """ 26 | uvicorn.run( 27 | app, 28 | host='0.0.0.0', 29 | port=port 30 | ) 31 | 32 | 33 | @console.command('debug') 34 | def debug_cmd(room_ids: List[int]): 35 | """ 36 | 用于查看各种直播数据 37 | """ 38 | 39 | async def _run(): 40 | handler: DebugHandler = DebugHandler() 41 | clients: List[BLiveClient] = [BLiveClient(room_id) for room_id in room_ids] 42 | 43 | for client in clients: 44 | client.add_handler(handler) 45 | client.start() 46 | 47 | try: 48 | await asyncio.gather(*( 49 | client.join() for client in clients 50 | )) 51 | finally: 52 | await asyncio.gather(*( 53 | client.stop_and_close() for client in clients 54 | )) 55 | 56 | logger.setLevel(logging.DEBUG) 57 | loop = asyncio.new_event_loop() 58 | asyncio.set_event_loop(loop) 59 | loop.run_until_complete(_run()) 60 | 61 | 62 | @console.command('post') 63 | def post_cmd(room_ids: List[int], url: Optional[str] = None): 64 | """ 65 | 将接收到的直播数据转发到指定URL 66 | """ 67 | try: 68 | target = url or environ['POST_TO'] 69 | except KeyError: 70 | logger.warning('请设置 "POST_TO" 环境变量,或者使用 --url 指定数据接收地址') 71 | return None 72 | 73 | async def _run(): 74 | handler: POSTHandler = POSTHandler() 75 | clients: List[BLiveClient] = [BLiveClient(room_id, opts={'URL': target}) for room_id in room_ids] 76 | 77 | for client in clients: 78 | client.add_handler(handler) 79 | client.start() 80 | 81 | try: 82 | await asyncio.gather(*( 83 | client.join() for client in clients 84 | )) 85 | finally: 86 | await asyncio.gather(*( 87 | client.stop_and_close() for client in clients 88 | )) 89 | 90 | logger.setLevel(logging.DEBUG) 91 | loop = asyncio.new_event_loop() 92 | asyncio.set_event_loop(loop) 93 | loop.run_until_complete(_run()) 94 | 95 | 96 | @console.command('live-info') 97 | def live_info_cmd(room_id: str): 98 | """ 99 | 获取直播信息 100 | """ 101 | 102 | async def _run(): 103 | pp(await get_live_info(room_id)) 104 | 105 | loop = asyncio.new_event_loop() 106 | asyncio.set_event_loop(loop) 107 | loop.run_until_complete(_run()) 108 | 109 | 110 | @console.command('live-status') 111 | def live_status_cmd(room_id: str): 112 | """ 113 | 获取直播信息 114 | """ 115 | 116 | async def _run(): 117 | print("直播状态: %s" % {0: '未开播', 1: '直播中', 2: '轮播中'}[await get_live_status(room_id)]) 118 | 119 | loop = asyncio.new_event_loop() 120 | asyncio.set_event_loop(loop) 121 | loop.run_until_complete(_run()) 122 | 123 | 124 | @console.command('user-info') 125 | def user_info_cmd(user_id: str): 126 | """ 127 | 获取用户信息 128 | """ 129 | 130 | async def _run(): 131 | pp(await get_user_info(user_id)) 132 | 133 | loop = asyncio.new_event_loop() 134 | asyncio.set_event_loop(loop) 135 | loop.run_until_complete(_run()) 136 | 137 | 138 | @console.command('user-icon') 139 | def user_icon_cmd(user_id: str): 140 | """ 141 | 获取用户头像 142 | """ 143 | 144 | async def _run(): 145 | print(await get_user_icon(user_id)) 146 | 147 | loop = asyncio.new_event_loop() 148 | asyncio.set_event_loop(loop) 149 | loop.run_until_complete(_run()) 150 | 151 | 152 | @console.command('user-name') 153 | def user_icon_cmd(user_id: str): 154 | """ 155 | 获取用户昵称 156 | """ 157 | 158 | async def _run(): 159 | print(await get_user_name(user_id)) 160 | 161 | loop = asyncio.new_event_loop() 162 | asyncio.set_event_loop(loop) 163 | loop.run_until_complete(_run()) 164 | 165 | 166 | console() 167 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.9' 2 | 3 | services: 4 | moca_blive_api_mmock: 5 | container_name: moca-blive-api-mmock 6 | image: jordimartin/mmock:v3.0.3 7 | volumes: 8 | - ./docker/mmock:/config:z 9 | ports: 10 | - '8082:8082' # console port 11 | - '8083:8083' # server port 12 | -------------------------------------------------------------------------------- /docker/mmock/accept-all.yml: -------------------------------------------------------------------------------- 1 | # To verify the request, accept all accesses and return status code 200. 2 | 3 | request: 4 | method: POST 5 | path: "/*" 6 | response: 7 | statusCode: 200 8 | body: 'Success' 9 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | mypy==0.982 2 | flake8==5.0.4 3 | ujson==5.5.0 4 | uvloop==0.17.0 5 | brotli==1.0.9 6 | aiohttp[speedups]==3.8.3 7 | typer[all]==0.6.1 8 | uvicorn[standard]==0.19.0 9 | fastapi==0.85.1 10 | websockets==10.3 11 | async_lru==1.0.3 12 | -------------------------------------------------------------------------------- /src/blivedm/__init__.py: -------------------------------------------------------------------------------- 1 | from .models import * 2 | from .handlers import * 3 | from .client import * 4 | -------------------------------------------------------------------------------- /src/blivedm/client.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import collections 3 | import enum 4 | from ujson import loads, dumps 5 | import logging 6 | import ssl as ssl_ 7 | import struct 8 | from typing import * 9 | 10 | import aiohttp 11 | import brotli 12 | 13 | from . import handlers 14 | 15 | __all__ = ( 16 | 'BLiveClient', 17 | ) 18 | 19 | logger = logging.getLogger('blivedm') 20 | 21 | ROOM_INIT_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom' 22 | DANMAKU_SERVER_CONF_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo' 23 | DEFAULT_DANMAKU_SERVER_LIST = [ 24 | {'host': 'broadcastlv.chat.bilibili.com', 'port': 2243, 'wss_port': 443, 'ws_port': 2244} 25 | ] 26 | 27 | HEADER_STRUCT = struct.Struct('>I2H2I') 28 | HeaderTuple = collections.namedtuple('HeaderTuple', ('pack_len', 'raw_header_size', 'ver', 'operation', 'seq_id')) 29 | 30 | 31 | # WS_BODY_PROTOCOL_VERSION 32 | class ProtoVer(enum.IntEnum): 33 | NORMAL = 0 34 | HEARTBEAT = 1 35 | DEFLATE = 2 36 | BROTLI = 3 37 | 38 | 39 | # go-common\app\service\main\broadcast\model\operation.go 40 | class Operation(enum.IntEnum): 41 | HANDSHAKE = 0 42 | HANDSHAKE_REPLY = 1 43 | HEARTBEAT = 2 44 | HEARTBEAT_REPLY = 3 45 | SEND_MSG = 4 46 | SEND_MSG_REPLY = 5 47 | DISCONNECT_REPLY = 6 48 | AUTH = 7 49 | AUTH_REPLY = 8 50 | RAW = 9 51 | PROTO_READY = 10 52 | PROTO_FINISH = 11 53 | CHANGE_ROOM = 12 54 | CHANGE_ROOM_REPLY = 13 55 | REGISTER = 14 56 | REGISTER_REPLY = 15 57 | UNREGISTER = 16 58 | UNREGISTER_REPLY = 17 59 | # B站业务自定义OP 60 | # MinBusinessOp = 1000 61 | # MaxBusinessOp = 10000 62 | 63 | 64 | # WS_AUTH 65 | class AuthReplyCode(enum.IntEnum): 66 | OK = 0 67 | TOKEN_ERROR = -101 68 | 69 | 70 | class InitError(Exception): 71 | """初始化失败""" 72 | 73 | 74 | class AuthError(Exception): 75 | """认证失败""" 76 | 77 | 78 | class BLiveClient: 79 | """ 80 | B站直播弹幕客户端,负责连接房间 81 | 82 | :param room_id: URL中的房间ID,可以用短ID 83 | :param uid: B站用户ID,0表示未登录 84 | :param session: cookie、连接池 85 | :param heartbeat_interval: 发送心跳包的间隔时间(秒) 86 | :param ssl: True表示用默认的SSLContext验证,False表示不验证,也可以传入SSLContext 87 | :param opts: 用于传递任意变量到处理器 88 | """ 89 | 90 | def __init__( 91 | self, 92 | room_id, 93 | uid=0, 94 | session: Optional[aiohttp.ClientSession] = None, 95 | heartbeat_interval=30, 96 | ssl: Union[bool, ssl_.SSLContext] = True, 97 | opts: Optional[Dict[str, Any]] = None 98 | ): 99 | # 这个变量会被直接传递给处理器 100 | self._opts = opts 101 | 102 | # 用来init_room的临时房间ID,可以用短ID 103 | self._tmp_room_id = room_id 104 | self._uid = uid 105 | 106 | if session is None: 107 | self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) 108 | self._own_session = True 109 | else: 110 | self._session = session 111 | self._own_session = False 112 | 113 | self._heartbeat_interval = heartbeat_interval 114 | self._ssl = ssl if ssl else ssl_._create_unverified_context() # noqa 115 | 116 | # 消息处理器,可动态增删 117 | self._handlers: List[handlers.HandlerInterface] = [] 118 | 119 | # 在调用init_room后初始化的字段 120 | # 真实房间ID 121 | self._room_id = None 122 | # 房间短ID,没有则为0 123 | self._room_short_id = None 124 | # 主播用户ID 125 | self._room_owner_uid = None 126 | # 弹幕服务器列表 127 | # [{host: "tx-bj4-live-comet-04.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}, ...] 128 | self._host_server_list: Optional[List[dict]] = None 129 | # 连接弹幕服务器用的token 130 | self._host_server_token = None 131 | 132 | # 在运行时初始化的字段 133 | # websocket连接 134 | self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None 135 | # 网络协程的future 136 | self._network_future: Optional[asyncio.Future] = None 137 | # 发心跳包定时器的handle 138 | self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None 139 | 140 | @property 141 | def is_running(self) -> bool: 142 | """ 143 | 本客户端正在运行,注意调用stop后还没完全停止也算正在运行 144 | """ 145 | return self._network_future is not None 146 | 147 | @property 148 | def room_id(self) -> Optional[int]: 149 | """ 150 | 房间ID,调用init_room后初始化 151 | """ 152 | return self._room_id 153 | 154 | @property 155 | def room_short_id(self) -> Optional[int]: 156 | """ 157 | 房间短ID,没有则为0,调用init_room后初始化 158 | """ 159 | return self._room_short_id 160 | 161 | @property 162 | def room_owner_uid(self) -> Optional[int]: 163 | """ 164 | 主播用户ID,调用init_room后初始化 165 | """ 166 | return self._room_owner_uid 167 | 168 | def add_handler(self, handler: 'handlers.HandlerInterface'): 169 | """ 170 | 添加消息处理器 171 | 注意多个处理器是并发处理的,不要依赖处理的顺序 172 | 消息处理器和接收消息运行在同一协程,如果处理消息耗时太长会阻塞接收消息,这种情况建议将消息推到队列,让另一个协程处理 173 | 174 | :param handler: 消息处理器 175 | """ 176 | if handler not in self._handlers: 177 | self._handlers.append(handler) 178 | 179 | def remove_handler(self, handler: 'handlers.HandlerInterface'): 180 | """ 181 | 移除消息处理器 182 | 183 | :param handler: 消息处理器 184 | """ 185 | try: 186 | self._handlers.remove(handler) 187 | except ValueError: 188 | pass 189 | 190 | def start(self): 191 | """ 192 | 启动本客户端 193 | """ 194 | if self.is_running: 195 | logger.warning('room=%s client is running, cannot start() again', self.room_id) 196 | return 197 | 198 | self._network_future = asyncio.ensure_future(self._network_coroutine_wrapper()) 199 | 200 | def stop(self): 201 | """ 202 | 停止本客户端 203 | """ 204 | if not self.is_running: 205 | logger.warning('room=%s client is stopped, cannot stop() again', self.room_id) 206 | return 207 | 208 | self._network_future.cancel() 209 | 210 | async def stop_and_close(self): 211 | """ 212 | 便利函数,停止本客户端并释放本客户端的资源,调用后本客户端将不可用 213 | """ 214 | if self.is_running: 215 | self.stop() 216 | await self.join() 217 | await self.close() 218 | 219 | async def join(self): 220 | """ 221 | 等待本客户端停止 222 | """ 223 | if not self.is_running: 224 | logger.warning('room=%s client is stopped, cannot join()', self.room_id) 225 | return 226 | 227 | await asyncio.shield(self._network_future) 228 | 229 | async def close(self): 230 | """ 231 | 释放本客户端的资源,调用后本客户端将不可用 232 | """ 233 | if self.is_running: 234 | logger.warning('room=%s is calling close(), but client is running', self.room_id) 235 | 236 | # 如果session是自己创建的则关闭session 237 | if self._own_session: 238 | await self._session.close() 239 | 240 | async def init_room(self): 241 | """ 242 | 初始化连接房间需要的字段 243 | 244 | :return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True 245 | """ 246 | res = True 247 | if not await self._init_room_id_and_owner(): 248 | res = False 249 | # 失败了则降级 250 | self._room_id = self._room_short_id = self._tmp_room_id 251 | self._room_owner_uid = 0 252 | 253 | if not await self._init_host_server(): 254 | res = False 255 | # 失败了则降级 256 | self._host_server_list = DEFAULT_DANMAKU_SERVER_LIST 257 | self._host_server_token = None 258 | return res 259 | 260 | async def _init_room_id_and_owner(self): 261 | try: 262 | async with self._session.get( 263 | ROOM_INIT_URL, 264 | headers={ 265 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 266 | ' Chrome/102.0.0.0 Safari/537.36' 267 | }, 268 | params={ 269 | 'room_id': self._tmp_room_id 270 | }, 271 | ssl=self._ssl 272 | ) as res: 273 | if res.status != 200: 274 | logger.warning('room=%d _init_room_id_and_owner() failed, status=%d, reason=%s', self._tmp_room_id, 275 | res.status, res.reason) 276 | return False 277 | data = await res.json() 278 | if data['code'] != 0: 279 | logger.warning('room=%d _init_room_id_and_owner() failed, message=%s', self._tmp_room_id, 280 | data['message']) 281 | return False 282 | if not self._parse_room_init(data['data']): 283 | return False 284 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 285 | logger.exception('room=%d _init_room_id_and_owner() failed:', self._tmp_room_id) 286 | return False 287 | return True 288 | 289 | def _parse_room_init(self, data): 290 | room_info = data['room_info'] 291 | self._room_id = room_info['room_id'] 292 | self._room_short_id = room_info['short_id'] 293 | self._room_owner_uid = room_info['uid'] 294 | return True 295 | 296 | async def _init_host_server(self): 297 | try: 298 | async with self._session.get( 299 | DANMAKU_SERVER_CONF_URL, 300 | headers={ 301 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 302 | ' Chrome/102.0.0.0 Safari/537.36' 303 | }, 304 | params={ 305 | 'id': self._room_id, 306 | 'type': 0 307 | }, 308 | ssl=self._ssl 309 | ) as res: 310 | if res.status != 200: 311 | logger.warning('room=%d _init_host_server() failed, status=%d, reason=%s', self._room_id, 312 | res.status, res.reason) 313 | return False 314 | data = await res.json() 315 | if data['code'] != 0: 316 | logger.warning('room=%d _init_host_server() failed, message=%s', self._room_id, data['message']) 317 | return False 318 | if not self._parse_danmaku_server_conf(data['data']): 319 | return False 320 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 321 | logger.exception('room=%d _init_host_server() failed:', self._room_id) 322 | return False 323 | return True 324 | 325 | def _parse_danmaku_server_conf(self, data): 326 | self._host_server_list = data['host_list'] 327 | self._host_server_token = data['token'] 328 | if not self._host_server_list: 329 | logger.warning('room=%d _parse_danmaku_server_conf() failed: host_server_list is empty', self._room_id) 330 | return False 331 | return True 332 | 333 | @staticmethod 334 | def _make_packet(data: dict, operation: int) -> bytes: 335 | """ 336 | 创建一个要发送给服务器的包 337 | 338 | :param data: 包体JSON数据 339 | :param operation: 操作码,见Operation 340 | :return: 整个包的数据 341 | """ 342 | body = dumps(data).encode('utf-8') 343 | header = HEADER_STRUCT.pack(*HeaderTuple( 344 | pack_len=HEADER_STRUCT.size + len(body), 345 | raw_header_size=HEADER_STRUCT.size, 346 | ver=1, 347 | operation=operation, 348 | seq_id=1 349 | )) 350 | return header + body 351 | 352 | async def _network_coroutine_wrapper(self): 353 | """ 354 | 负责处理网络协程的异常,网络协程具体逻辑在_network_coroutine里 355 | """ 356 | try: 357 | await self._network_coroutine() 358 | except asyncio.CancelledError: 359 | # 正常停止 360 | pass 361 | except Exception as e: # noqa 362 | logger.exception('room=%s _network_coroutine() finished with exception:', self.room_id) 363 | finally: 364 | logger.debug('room=%s _network_coroutine() finished', self.room_id) 365 | self._network_future = None 366 | 367 | async def _network_coroutine(self): 368 | """ 369 | 网络协程,负责连接服务器、接收消息、解包 370 | """ 371 | # 如果之前未初始化则初始化 372 | if self._host_server_token is None: 373 | if not await self.init_room(): 374 | raise InitError('init_room() failed') 375 | 376 | retry_count = 0 377 | while True: 378 | try: 379 | # 连接 380 | host_server = self._host_server_list[retry_count % len(self._host_server_list)] 381 | async with self._session.ws_connect( 382 | f"wss://{host_server['host']}:{host_server['wss_port']}/sub", 383 | headers={ 384 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)' 385 | ' AppleWebKit/537.36 (KHTML, like Gecko)' 386 | ' Chrome/102.0.0.0 Safari/537.36' 387 | }, 388 | receive_timeout=self._heartbeat_interval + 5, 389 | ssl=self._ssl 390 | ) as websocket: 391 | self._websocket = websocket 392 | await self._on_ws_connect() 393 | 394 | # 处理消息 395 | message: aiohttp.WSMessage 396 | async for message in websocket: 397 | await self._on_ws_message(message) 398 | # 至少成功处理1条消息 399 | retry_count = 0 400 | 401 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 402 | # 掉线重连 403 | pass 404 | except AuthError: 405 | # 认证失败了,应该重新获取token再重连 406 | logger.exception('room=%d auth failed, trying init_room() again', self.room_id) 407 | if not await self.init_room(): 408 | raise InitError('init_room() failed') 409 | except ssl_.SSLError: 410 | logger.error('room=%d a SSLError happened, cannot reconnect', self.room_id) 411 | raise 412 | finally: 413 | self._websocket = None 414 | await self._on_ws_close() 415 | 416 | # 准备重连 417 | retry_count += 1 418 | logger.warning('room=%d is reconnecting, retry_count=%d', self.room_id, retry_count) 419 | await asyncio.sleep(1) 420 | 421 | async def _on_ws_connect(self): 422 | """ 423 | websocket连接成功 424 | """ 425 | await self._send_auth() 426 | self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(self._heartbeat_interval, self._on_send_heartbeat) 427 | 428 | async def _on_ws_close(self): 429 | """ 430 | websocket连接断开 431 | """ 432 | if self._heartbeat_timer_handle is not None: 433 | self._heartbeat_timer_handle.cancel() 434 | self._heartbeat_timer_handle = None 435 | 436 | async def _send_auth(self): 437 | """ 438 | 发送认证包 439 | """ 440 | auth_params = { 441 | 'uid': self._uid, 442 | 'roomid': self._room_id, 443 | 'protover': 3, 444 | 'platform': 'web', 445 | 'type': 2 446 | } 447 | if self._host_server_token is not None: 448 | auth_params['key'] = self._host_server_token 449 | await self._websocket.send_bytes(self._make_packet(auth_params, Operation.AUTH)) 450 | 451 | def _on_send_heartbeat(self): 452 | """ 453 | 定时发送心跳包的回调 454 | """ 455 | if self._websocket is None or self._websocket.closed: 456 | self._heartbeat_timer_handle = None 457 | return 458 | 459 | self._heartbeat_timer_handle = asyncio.get_event_loop().call_later(self._heartbeat_interval, self._on_send_heartbeat) 460 | asyncio.ensure_future(self._send_heartbeat()) 461 | 462 | async def _send_heartbeat(self): 463 | """ 464 | 发送心跳包 465 | """ 466 | if self._websocket is None or self._websocket.closed: 467 | return 468 | 469 | try: 470 | await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT)) 471 | except (ConnectionResetError, aiohttp.ClientConnectionError) as e: 472 | logger.warning('room=%d _send_heartbeat() failed: %r', self.room_id, e) 473 | except Exception: # noqa 474 | logger.exception('room=%d _send_heartbeat() failed:', self.room_id) 475 | 476 | async def _on_ws_message(self, message: aiohttp.WSMessage): 477 | """ 478 | 收到websocket消息 479 | 480 | :param message: websocket消息 481 | """ 482 | if message.type != aiohttp.WSMsgType.BINARY: 483 | logger.warning('room=%d unknown websocket message type=%s, data=%s', self.room_id, 484 | message.type, message.data) 485 | return 486 | 487 | try: 488 | await self._parse_ws_message(message.data) 489 | except (asyncio.CancelledError, AuthError): 490 | # 正常停止、认证失败,让外层处理 491 | raise 492 | except Exception: # noqa 493 | logger.exception('room=%d _parse_ws_message() error:', self.room_id) 494 | 495 | async def _parse_ws_message(self, data: bytes): 496 | """ 497 | 解析websocket消息 498 | 499 | :param data: websocket消息数据 500 | """ 501 | offset = 0 502 | try: 503 | header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset)) 504 | except struct.error: 505 | logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data) 506 | return 507 | 508 | if header.operation in (Operation.SEND_MSG_REPLY, Operation.AUTH_REPLY): 509 | # 业务消息,可能有多个包一起发,需要分包 510 | while True: 511 | body = data[offset + header.raw_header_size: offset + header.pack_len] 512 | await self._parse_business_message(header, body) 513 | 514 | offset += header.pack_len 515 | if offset >= len(data): 516 | break 517 | 518 | try: 519 | header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset)) 520 | except struct.error: 521 | logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data) 522 | break 523 | 524 | elif header.operation == Operation.HEARTBEAT_REPLY: 525 | # 服务器心跳包,前4字节是人气值,后面是客户端发的心跳包内容 526 | # pack_len不包括客户端发的心跳包内容,不知道是不是服务器BUG 527 | body = data[offset + header.raw_header_size: offset + header.raw_header_size + 4] 528 | popularity = int.from_bytes(body, 'big') 529 | # 自己造个消息当成业务消息处理 530 | body = { 531 | 'cmd': '_HEARTBEAT', 532 | 'data': { 533 | 'popularity': popularity 534 | } 535 | } 536 | await self._handle_command(body) 537 | 538 | else: 539 | # 未知消息 540 | body = data[offset + header.raw_header_size: offset + header.pack_len] 541 | logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id, 542 | header.operation, header, body) 543 | 544 | async def _parse_business_message(self, header: HeaderTuple, body: bytes): 545 | """ 546 | 解析业务消息 547 | """ 548 | if header.operation == Operation.SEND_MSG_REPLY: 549 | # 业务消息 550 | if header.ver == ProtoVer.BROTLI: 551 | # 压缩过的先解压,为了避免阻塞网络线程,放在其他线程执行 552 | body = await asyncio.get_event_loop().run_in_executor(None, brotli.decompress, body) 553 | await self._parse_ws_message(body) 554 | elif header.ver == ProtoVer.NORMAL: 555 | # 没压缩过的直接反序列化,因为有万恶的GIL,这里不能并行避免阻塞 556 | if len(body) != 0: 557 | try: 558 | body = loads(body.decode('utf-8')) 559 | await self._handle_command(body) 560 | except asyncio.CancelledError: 561 | raise 562 | except Exception: 563 | logger.error('room=%d, body=%s', self.room_id, body) 564 | raise 565 | else: 566 | # 未知格式 567 | logger.warning('room=%d unknown protocol version=%d, header=%s, body=%s', self.room_id, 568 | header.ver, header, body) 569 | 570 | elif header.operation == Operation.AUTH_REPLY: 571 | # 认证响应 572 | body = loads(body.decode('utf-8')) 573 | if body['code'] != AuthReplyCode.OK: 574 | raise AuthError(f"auth reply error, code={body['code']}, body={body}") 575 | await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT)) 576 | 577 | else: 578 | # 未知消息 579 | logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id, 580 | header.operation, header, body) 581 | 582 | async def _handle_command(self, command: dict): 583 | """ 584 | 解析并处理业务消息 585 | 586 | :param command: 业务消息 587 | """ 588 | # 外部代码可能不能正常处理取消,所以这里加shield 589 | results = await asyncio.shield( 590 | asyncio.gather( 591 | *(handler.handle(self, command, self._opts) for handler in self._handlers), 592 | return_exceptions=True 593 | ) 594 | ) 595 | for res in results: 596 | if isinstance(res, Exception): 597 | logger.exception('room=%d _handle_command() failed, command=%s', self.room_id, command, exc_info=res) 598 | -------------------------------------------------------------------------------- /src/blivedm/handlers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import * 3 | 4 | from . import client as client_ 5 | from . import models 6 | 7 | __all__ = ( 8 | 'HandlerInterface', 9 | 'BaseHandler', 10 | ) 11 | 12 | logger = logging.getLogger('blivedm') 13 | 14 | # 常见可忽略的cmd 15 | IGNORED_CMDS = ( 16 | 'COMBO_SEND', 17 | 'ENTRY_EFFECT', 18 | 'HOT_RANK_CHANGED', 19 | 'HOT_RANK_CHANGED_V2', 20 | 'INTERACT_WORD', 21 | 'LIVE', 22 | 'LIVE_INTERACTIVE_GAME', 23 | 'NOTICE_MSG', 24 | 'ONLINE_RANK_COUNT', 25 | 'ONLINE_RANK_TOP3', 26 | 'ONLINE_RANK_V2', 27 | 'PK_BATTLE_END', 28 | 'PK_BATTLE_FINAL_PROCESS', 29 | 'PK_BATTLE_PROCESS', 30 | 'PK_BATTLE_PROCESS_NEW', 31 | 'PK_BATTLE_SETTLE', 32 | 'PK_BATTLE_SETTLE_USER', 33 | 'PK_BATTLE_SETTLE_V2', 34 | 'PREPARING', 35 | 'ROOM_REAL_TIME_MESSAGE_UPDATE', 36 | 'STOP_LIVE_ROOM_LIST', 37 | 'SUPER_CHAT_MESSAGE_JPN', 38 | 'WIDGET_BANNER', 39 | 'WATCHED_CHANGE', 40 | 'LIKE_INFO_V3_UPDATE', 41 | 'USER_TOAST_MSG', 42 | ) 43 | 44 | # 已打日志的未知cmd 45 | logged_unknown_cmds = set() 46 | 47 | 48 | class HandlerInterface: 49 | """ 50 | 直播消息处理器接口 51 | """ 52 | 53 | async def handle(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 54 | raise NotImplementedError 55 | 56 | 57 | class BaseHandler(HandlerInterface): 58 | """ 59 | 一个简单的消息处理器实现,带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器 60 | """ 61 | 62 | def __heartbeat_callback(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 63 | return self._on_heartbeat(client, models.HeartbeatMessage.from_command(command['data']), opts) 64 | 65 | def __danmu_msg_callback(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 66 | return self._on_danmaku(client, models.DanmakuMessage.from_command(command['info']), opts) 67 | 68 | def __send_gift_callback(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 69 | return self._on_gift(client, models.GiftMessage.from_command(command['data']), opts) 70 | 71 | def __guard_buy_callback(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 72 | return self._on_buy_guard(client, models.GuardBuyMessage.from_command(command['data']), opts) 73 | 74 | def __super_chat_message_callback(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 75 | return self._on_super_chat(client, models.SuperChatMessage.from_command(command['data']), opts) 76 | 77 | def __super_chat_message_delete_callback(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 78 | return self._on_super_chat_delete(client, models.SuperChatDeleteMessage.from_command(command['data']), opts) 79 | 80 | # cmd -> 处理回调 81 | _CMD_CALLBACK_DICT: Dict[ 82 | str, 83 | Optional[Callable[ 84 | ['BaseHandler', client_.BLiveClient, dict, Optional[Dict[str, Any]]], 85 | Awaitable 86 | ]] 87 | ] = { 88 | # 收到心跳包,这是blivedm自造的消息,原本的心跳包格式不一样 89 | '_HEARTBEAT': __heartbeat_callback, 90 | # 收到弹幕 91 | # go-common\app\service\live\live-dm\service\v1\send.go 92 | 'DANMU_MSG': __danmu_msg_callback, 93 | # 有人送礼 94 | 'SEND_GIFT': __send_gift_callback, 95 | # 有人上舰 96 | 'GUARD_BUY': __guard_buy_callback, 97 | # 醒目留言 98 | 'SUPER_CHAT_MESSAGE': __super_chat_message_callback, 99 | # 删除醒目留言 100 | 'SUPER_CHAT_MESSAGE_DELETE': __super_chat_message_delete_callback, 101 | } 102 | # 忽略其他常见cmd 103 | for cmd in IGNORED_CMDS: 104 | _CMD_CALLBACK_DICT[cmd] = None 105 | del cmd 106 | 107 | async def handle(self, client: client_.BLiveClient, command: dict, opts: Optional[Dict[str, Any]]): 108 | cmd = command.get('cmd', '') 109 | pos = cmd.find(':') # 2019-5-29 B站弹幕升级新增了参数 110 | if pos != -1: 111 | cmd = cmd[:pos] 112 | 113 | if cmd not in self._CMD_CALLBACK_DICT: 114 | # 只有第一次遇到未知cmd时打日志 115 | if cmd not in logged_unknown_cmds: 116 | logger.warning('room=%d unknown cmd=%s, command=%s', client.room_id, cmd, command) 117 | logged_unknown_cmds.add(cmd) 118 | return 119 | 120 | callback = self._CMD_CALLBACK_DICT[cmd] 121 | if callback is not None: 122 | await callback(self, client, command, opts) 123 | 124 | async def _on_heartbeat(self, client: client_.BLiveClient, message: models.HeartbeatMessage, opts: Optional[Dict[str, Any]]): 125 | """ 126 | 收到心跳包(人气值) 127 | """ 128 | 129 | async def _on_danmaku(self, client: client_.BLiveClient, message: models.DanmakuMessage, opts: Optional[Dict[str, Any]]): 130 | """ 131 | 收到弹幕 132 | """ 133 | 134 | async def _on_gift(self, client: client_.BLiveClient, message: models.GiftMessage, opts: Optional[Dict[str, Any]]): 135 | """ 136 | 收到礼物 137 | """ 138 | 139 | async def _on_buy_guard(self, client: client_.BLiveClient, message: models.GuardBuyMessage, opts: Optional[Dict[str, Any]]): 140 | """ 141 | 有人上舰 142 | """ 143 | 144 | async def _on_super_chat(self, client: client_.BLiveClient, message: models.SuperChatMessage, opts: Optional[Dict[str, Any]]): 145 | """ 146 | 醒目留言 147 | """ 148 | 149 | async def _on_super_chat_delete(self, client: client_.BLiveClient, message: models.SuperChatDeleteMessage, opts: Optional[Dict[str, Any]]): 150 | """ 151 | 删除醒目留言 152 | """ 153 | -------------------------------------------------------------------------------- /src/blivedm/models.py: -------------------------------------------------------------------------------- 1 | from ujson import loads, JSONDecodeError 2 | from typing import * 3 | 4 | __all__ = ( 5 | 'HeartbeatMessage', 6 | 'DanmakuMessage', 7 | 'GiftMessage', 8 | 'GuardBuyMessage', 9 | 'SuperChatMessage', 10 | 'SuperChatDeleteMessage', 11 | ) 12 | 13 | 14 | class HeartbeatMessage: 15 | """ 16 | 心跳消息 17 | 18 | :param popularity: 人气值 19 | """ 20 | 21 | def __init__( 22 | self, 23 | popularity: int = None, 24 | ): 25 | self.popularity: int = popularity 26 | 27 | @classmethod 28 | def from_command(cls, data: dict): 29 | return cls( 30 | popularity=data['popularity'], 31 | ) 32 | 33 | 34 | class DanmakuMessage: 35 | """ 36 | 弹幕消息 37 | 38 | :param mode: 弹幕显示模式(滚动、顶部、底部) 39 | :param font_size: 字体尺寸 40 | :param color: 颜色 41 | :param timestamp: 时间戳(毫秒) 42 | :param rnd: 随机数,前端叫作弹幕ID,可能是去重用的 43 | :param uid_crc32: 用户ID文本的CRC32 44 | :param msg_type: 是否礼物弹幕(节奏风暴) 45 | :param bubble: 右侧评论栏气泡 46 | :param dm_type: 弹幕类型,0文本,1表情,2语音 47 | :param emoticon_options: 表情参数 48 | :param voice_config: 语音参数 49 | :param mode_info: 一些附加参数 50 | 51 | :param msg: 弹幕内容 52 | 53 | :param uid: 用户ID 54 | :param uname: 用户名 55 | :param admin: 是否房管 56 | :param vip: 是否月费老爷 57 | :param svip: 是否年费老爷 58 | :param urank: 用户身份,用来判断是否正式会员,猜测非正式会员为5000,正式会员为10000 59 | :param mobile_verify: 是否绑定手机 60 | :param uname_color: 用户名颜色 61 | 62 | :param medal_level: 勋章等级 63 | :param medal_name: 勋章名 64 | :param runame: 勋章房间主播名 65 | :param medal_room_id: 勋章房间ID 66 | :param mcolor: 勋章颜色 67 | :param special_medal: 特殊勋章 68 | 69 | :param user_level: 用户等级 70 | :param ulevel_color: 用户等级颜色 71 | :param ulevel_rank: 用户等级排名,>50000时为'>50000' 72 | 73 | :param old_title: 旧头衔 74 | :param title: 头衔 75 | 76 | :param privilege_type: 舰队类型,0非舰队,1总督,2提督,3舰长 77 | """ 78 | 79 | def __init__( 80 | self, 81 | mode: int = None, 82 | font_size: int = None, 83 | color: int = None, 84 | timestamp: int = None, 85 | rnd: int = None, 86 | uid_crc32: str = None, 87 | msg_type: int = None, 88 | bubble: int = None, 89 | dm_type: int = None, 90 | emoticon_options: Union[dict, str] = None, 91 | voice_config: Union[dict, str] = None, 92 | mode_info: dict = None, 93 | 94 | msg: str = None, 95 | 96 | uid: int = None, 97 | uname: str = None, 98 | admin: int = None, 99 | vip: int = None, 100 | svip: int = None, 101 | urank: int = None, 102 | mobile_verify: int = None, 103 | uname_color: str = None, 104 | 105 | medal_level: str = None, 106 | medal_name: str = None, 107 | runame: str = None, 108 | medal_room_id: int = None, 109 | mcolor: int = None, 110 | special_medal: str = None, 111 | 112 | user_level: int = None, 113 | ulevel_color: int = None, 114 | ulevel_rank: str = None, 115 | 116 | old_title: str = None, 117 | title: str = None, 118 | 119 | privilege_type: int = None, 120 | ): 121 | self.mode: int = mode 122 | self.font_size: int = font_size 123 | self.color: int = color 124 | self.timestamp: int = timestamp 125 | self.rnd: int = rnd 126 | self.uid_crc32: str = uid_crc32 127 | self.msg_type: int = msg_type 128 | self.bubble: int = bubble 129 | self.dm_type: int = dm_type 130 | self.emoticon_options: Union[dict, str] = emoticon_options 131 | self.voice_config: Union[dict, str] = voice_config 132 | self.mode_info: dict = mode_info 133 | 134 | self.msg: str = msg 135 | 136 | self.uid: int = uid 137 | self.uname: str = uname 138 | self.admin: int = admin 139 | self.vip: int = vip 140 | self.svip: int = svip 141 | self.urank: int = urank 142 | self.mobile_verify: int = mobile_verify 143 | self.uname_color: str = uname_color 144 | 145 | self.medal_level: str = medal_level 146 | self.medal_name: str = medal_name 147 | self.runame: str = runame 148 | self.medal_room_id: int = medal_room_id 149 | self.mcolor: int = mcolor 150 | self.special_medal: str = special_medal 151 | 152 | self.user_level: int = user_level 153 | self.ulevel_color: int = ulevel_color 154 | self.ulevel_rank: str = ulevel_rank 155 | 156 | self.old_title: str = old_title 157 | self.title: str = title 158 | 159 | self.privilege_type: int = privilege_type 160 | 161 | @classmethod 162 | def from_command(cls, info: dict): 163 | if len(info[3]) != 0: 164 | medal_level = info[3][0] 165 | medal_name = info[3][1] 166 | runame = info[3][2] 167 | room_id = info[3][3] 168 | mcolor = info[3][4] 169 | special_medal = info[3][5] 170 | else: 171 | medal_level = 0 172 | medal_name = '' 173 | runame = '' 174 | room_id = 0 175 | mcolor = 0 176 | special_medal = 0 177 | 178 | return cls( 179 | mode=info[0][1], 180 | font_size=info[0][2], 181 | color=info[0][3], 182 | timestamp=info[0][4], 183 | rnd=info[0][5], 184 | uid_crc32=info[0][7], 185 | msg_type=info[0][9], 186 | bubble=info[0][10], 187 | dm_type=info[0][12], 188 | emoticon_options=info[0][13], 189 | voice_config=info[0][14], 190 | mode_info=info[0][15], 191 | 192 | msg=info[1], 193 | 194 | uid=info[2][0], 195 | uname=info[2][1], 196 | admin=info[2][2], 197 | vip=info[2][3], 198 | svip=info[2][4], 199 | urank=info[2][5], 200 | mobile_verify=info[2][6], 201 | uname_color=info[2][7], 202 | 203 | medal_level=medal_level, 204 | medal_name=medal_name, 205 | runame=runame, 206 | medal_room_id=room_id, 207 | mcolor=mcolor, 208 | special_medal=special_medal, 209 | 210 | user_level=info[4][0], 211 | ulevel_color=info[4][2], 212 | ulevel_rank=info[4][3], 213 | 214 | old_title=info[5][0], 215 | title=info[5][1], 216 | 217 | privilege_type=info[7], 218 | ) 219 | 220 | @property 221 | def emoticon_options_dict(self) -> dict: 222 | """ 223 | 示例: 224 | {'bulge_display': 0, 'emoticon_unique': 'official_13', 'height': 60, 'in_player_area': 1, 'is_dynamic': 1, 225 | 'url': 'https://i0.hdslb.com/bfs/live/a98e35996545509188fe4d24bd1a56518ea5af48.png', 'width': 183} 226 | """ 227 | if isinstance(self.emoticon_options, dict): 228 | return self.emoticon_options 229 | try: 230 | return loads(self.emoticon_options) 231 | except (JSONDecodeError, TypeError): 232 | return {} 233 | 234 | @property 235 | def voice_config_dict(self) -> dict: 236 | """ 237 | 示例: 238 | {'voice_url': 'https%3A%2F%2Fboss.hdslb.com%2Flive-dm-voice%2Fb5b26e48b556915cbf3312a59d3bb2561627725945.wav 239 | %3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Credential%3D2663ba902868f12f%252F20210731%252Fshjd%252Fs3%25 240 | 2Faws4_request%26X-Amz-Date%3D20210731T100545Z%26X-Amz-Expires%3D600000%26X-Amz-SignedHeaders%3Dhost%26 241 | X-Amz-Signature%3D114e7cb5ac91c72e231c26d8ca211e53914722f36309b861a6409ffb20f07ab8', 242 | 'file_format': 'wav', 'text': '汤,下午好。', 'file_duration': 1} 243 | """ 244 | if isinstance(self.voice_config, dict): 245 | return self.voice_config 246 | try: 247 | return loads(self.voice_config) 248 | except (JSONDecodeError, TypeError): 249 | return {} 250 | 251 | 252 | class GiftMessage: 253 | """ 254 | 礼物消息 255 | 256 | :param gift_name: 礼物名 257 | :param num: 数量 258 | :param uname: 用户名 259 | :param face: 用户头像URL 260 | :param guard_level: 舰队等级,0非舰队,1总督,2提督,3舰长 261 | :param uid: 用户ID 262 | :param timestamp: 时间戳 263 | :param gift_id: 礼物ID 264 | :param gift_type: 礼物类型(未知) 265 | :param action: 目前遇到的有'喂食'、'赠送' 266 | :param price: 礼物单价瓜子数 267 | :param rnd: 随机数,可能是去重用的。有时是时间戳+去重ID,有时是UUID 268 | :param coin_type: 瓜子类型,'silver'或'gold',1000金瓜子 = 1元 269 | :param total_coin: 总瓜子数 270 | :param tid: 可能是事务ID,有时和rnd相同 271 | """ 272 | 273 | def __init__( 274 | self, 275 | gift_name: str = None, 276 | num: int = None, 277 | uname: str = None, 278 | face: str = None, 279 | guard_level: int = None, 280 | uid: int = None, 281 | timestamp: int = None, 282 | gift_id: int = None, 283 | gift_type: int = None, 284 | action: str = None, 285 | price: int = None, 286 | rnd: str = None, 287 | coin_type: str = None, 288 | total_coin: int = None, 289 | tid: str = None, 290 | ): 291 | self.gift_name = gift_name 292 | self.num = num 293 | self.uname = uname 294 | self.face = face 295 | self.guard_level = guard_level 296 | self.uid = uid 297 | self.timestamp = timestamp 298 | self.gift_id = gift_id 299 | self.gift_type = gift_type 300 | self.action = action 301 | self.price = price 302 | self.rnd = rnd 303 | self.coin_type = coin_type 304 | self.total_coin = total_coin 305 | self.tid = tid 306 | 307 | @classmethod 308 | def from_command(cls, data: dict): 309 | return cls( 310 | gift_name=data['giftName'], 311 | num=data['num'], 312 | uname=data['uname'], 313 | face=data['face'], 314 | guard_level=data['guard_level'], 315 | uid=data['uid'], 316 | timestamp=data['timestamp'], 317 | gift_id=data['giftId'], 318 | gift_type=data['giftType'], 319 | action=data['action'], 320 | price=data['price'], 321 | rnd=data['rnd'], 322 | coin_type=data['coin_type'], 323 | total_coin=data['total_coin'], 324 | tid=data['tid'], 325 | ) 326 | 327 | 328 | class GuardBuyMessage: 329 | """ 330 | 上舰消息 331 | 332 | :param uid: 用户ID 333 | :param username: 用户名 334 | :param guard_level: 舰队等级,0非舰队,1总督,2提督,3舰长 335 | :param num: 数量 336 | :param price: 单价金瓜子数 337 | :param gift_id: 礼物ID 338 | :param gift_name: 礼物名 339 | :param start_time: 开始时间戳,和结束时间戳相同 340 | :param end_time: 结束时间戳,和开始时间戳相同 341 | """ 342 | 343 | def __init__( 344 | self, 345 | uid: int = None, 346 | username: str = None, 347 | guard_level: int = None, 348 | num: int = None, 349 | price: int = None, 350 | gift_id: int = None, 351 | gift_name: str = None, 352 | start_time: int = None, 353 | end_time: int = None, 354 | ): 355 | self.uid: int = uid 356 | self.username: str = username 357 | self.guard_level: int = guard_level 358 | self.num: int = num 359 | self.price: int = price 360 | self.gift_id: int = gift_id 361 | self.gift_name: str = gift_name 362 | self.start_time: int = start_time 363 | self.end_time: int = end_time 364 | 365 | @classmethod 366 | def from_command(cls, data: dict): 367 | return cls( 368 | uid=data['uid'], 369 | username=data['username'], 370 | guard_level=data['guard_level'], 371 | num=data['num'], 372 | price=data['price'], 373 | gift_id=data['gift_id'], 374 | gift_name=data['gift_name'], 375 | start_time=data['start_time'], 376 | end_time=data['end_time'], 377 | ) 378 | 379 | 380 | class SuperChatMessage: 381 | """ 382 | 醒目留言消息 383 | 384 | :param price: 价格(人民币) 385 | :param message: 消息 386 | :param message_trans: 消息日文翻译(目前只出现在SUPER_CHAT_MESSAGE_JPN) 387 | :param start_time: 开始时间戳 388 | :param end_time: 结束时间戳 389 | :param time: 剩余时间(约等于 结束时间戳 - 开始时间戳) 390 | :param id_: str,醒目留言ID,删除时用 391 | :param gift_id: 礼物ID 392 | :param gift_name: 礼物名 393 | :param uid: 用户ID 394 | :param uname: 用户名 395 | :param face: 用户头像URL 396 | :param guard_level: 舰队等级,0非舰队,1总督,2提督,3舰长 397 | :param user_level: 用户等级 398 | :param background_bottom_color: 底部背景色,'#rrggbb' 399 | :param background_color: 背景色,'#rrggbb' 400 | :param background_icon: 背景图标 401 | :param background_image: 背景图URL 402 | :param background_price_color: 背景价格颜色,'#rrggbb' 403 | """ 404 | 405 | def __init__( 406 | self, 407 | price: int = None, 408 | message: str = None, 409 | message_trans: str = None, 410 | start_time: int = None, 411 | end_time: int = None, 412 | time: int = None, 413 | id_: int = None, 414 | gift_id: int = None, 415 | gift_name: str = None, 416 | uid: int = None, 417 | uname: str = None, 418 | face: str = None, 419 | guard_level: int = None, 420 | user_level: int = None, 421 | background_bottom_color: str = None, 422 | background_color: str = None, 423 | background_icon: str = None, 424 | background_image: str = None, 425 | background_price_color: str = None, 426 | ): 427 | self.price: int = price 428 | self.message: str = message 429 | self.message_trans: str = message_trans 430 | self.start_time: int = start_time 431 | self.end_time: int = end_time 432 | self.time: int = time 433 | self.id: int = id_ 434 | self.gift_id: int = gift_id 435 | self.gift_name: str = gift_name 436 | self.uid: int = uid 437 | self.uname: str = uname 438 | self.face: str = face 439 | self.guard_level: int = guard_level 440 | self.user_level: int = user_level 441 | self.background_bottom_color: str = background_bottom_color 442 | self.background_color: str = background_color 443 | self.background_icon: str = background_icon 444 | self.background_image: str = background_image 445 | self.background_price_color: str = background_price_color 446 | 447 | @classmethod 448 | def from_command(cls, data: dict): 449 | return cls( 450 | price=data['price'], 451 | message=data['message'], 452 | message_trans=data['message_trans'], 453 | start_time=data['start_time'], 454 | end_time=data['end_time'], 455 | time=data['time'], 456 | id_=data['id'], 457 | gift_id=data['gift']['gift_id'], 458 | gift_name=data['gift']['gift_name'], 459 | uid=data['uid'], 460 | uname=data['user_info']['uname'], 461 | face=data['user_info']['face'], 462 | guard_level=data['user_info']['guard_level'], 463 | user_level=data['user_info']['user_level'], 464 | background_bottom_color=data['background_bottom_color'], 465 | background_color=data['background_color'], 466 | background_icon=data['background_icon'], 467 | background_image=data['background_image'], 468 | background_price_color=data['background_price_color'], 469 | ) 470 | 471 | 472 | class SuperChatDeleteMessage: 473 | """ 474 | 删除醒目留言消息 475 | 476 | :param ids: 醒目留言ID数组 477 | """ 478 | 479 | def __init__( 480 | self, 481 | ids: List[int] = None, 482 | ): 483 | self.ids: List[int] = ids 484 | 485 | @classmethod 486 | def from_command(cls, data: dict): 487 | return cls( 488 | ids=data['ids'], 489 | ) 490 | -------------------------------------------------------------------------------- /src/handlers/__init__.py: -------------------------------------------------------------------------------- 1 | from .debug_handler import DebugHandler 2 | from .post_handler import POSTHandler 3 | from .websocket_handler import WebSocketHandler 4 | -------------------------------------------------------------------------------- /src/handlers/debug_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import * 3 | 4 | from ..blivedm import ( 5 | BaseHandler, BLiveClient, HeartbeatMessage, DanmakuMessage, GiftMessage, GuardBuyMessage, SuperChatMessage, 6 | SuperChatDeleteMessage 7 | ) 8 | 9 | logger: logging.Logger = logging.getLogger('MocaBliveAPI') 10 | 11 | 12 | class DebugHandler(BaseHandler): 13 | async def _on_heartbeat(self, client: BLiveClient, message: HeartbeatMessage, opts: Optional[Dict[str, Any]]): 14 | """ 15 | 收到心跳包(人气值) 16 | """ 17 | params: Dict[str, Any] = vars(message) 18 | logger.debug('\n---- 收到心跳包(人气值) ----\n' + ''.join([f'{key}: \t{params[key]}\n' for key in params])) 19 | 20 | async def _on_danmaku(self, client: BLiveClient, message: DanmakuMessage, opts: Optional[Dict[str, Any]]): 21 | """ 22 | 收到弹幕 23 | """ 24 | params: Dict[str, Any] = vars(message) 25 | logger.debug('\n---- 收到弹幕 ----\n' + ''.join([f'{key}: \t{params[key]}\n' for key in params])) 26 | 27 | async def _on_gift(self, client: BLiveClient, message: GiftMessage, opts: Optional[Dict[str, Any]]): 28 | """ 29 | 收到礼物 30 | """ 31 | params: Dict[str, Any] = vars(message) 32 | logger.debug('\n---- 收到礼物 ----\n' + ''.join([f'{key}: \t{params[key]}\n' for key in params])) 33 | 34 | async def _on_buy_guard(self, client: BLiveClient, message: GuardBuyMessage, opts: Optional[Dict[str, Any]]): 35 | """ 36 | 有人上舰 37 | """ 38 | params: Dict[str, Any] = vars(message) 39 | logger.debug('\n---- 有人上舰 ----\n' + ''.join([f'{key}: \t{params[key]}\n' for key in params])) 40 | 41 | async def _on_super_chat(self, client: BLiveClient, message: SuperChatMessage, opts: Optional[Dict[str, Any]]): 42 | """ 43 | 醒目留言 44 | """ 45 | params: Dict[str, Any] = vars(message) 46 | logger.debug('\n---- 醒目留言 ----\n' + ''.join([f'{key}: \t{params[key]}\n' for key in params])) 47 | 48 | async def _on_super_chat_delete(self, client: BLiveClient, message: SuperChatDeleteMessage, 49 | opts: Optional[Dict[str, Any]]): 50 | """ 51 | 删除醒目留言 52 | """ 53 | params: Dict[str, Any] = vars(message) 54 | logger.debug('\n---- 删除醒目留言 ----\n' + ''.join([f'{key}: \t{params[key]}\n' for key in params])) 55 | -------------------------------------------------------------------------------- /src/handlers/post_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import * 3 | 4 | import aiohttp 5 | from ujson import dumps 6 | 7 | from ..blivedm import ( 8 | BaseHandler, BLiveClient, HeartbeatMessage, DanmakuMessage, 9 | GiftMessage, GuardBuyMessage, SuperChatMessage, SuperChatDeleteMessage 10 | ) 11 | 12 | logger: logging.Logger = logging.getLogger('MocaBliveAPI') 13 | 14 | 15 | async def _post_message_to_target_server(room_id: int, url: str, message: Dict[str, Any]) -> None: 16 | """ 17 | 将接收到的直播数据转发到指定URL 18 | """ 19 | body: Dict[str, Any] = dict(message, room_id=room_id) 20 | async with aiohttp.ClientSession(json_serialize=dumps) as session: 21 | async with session.post(url, json=body) as res: 22 | if res.status != 200: 23 | logger.error('POST "%s", status=%d, reason=%s, message=%s', url, res.status, res.reason, dumps(body)) 24 | 25 | 26 | class POSTHandler(BaseHandler): 27 | async def _on_heartbeat(self, client: BLiveClient, message: HeartbeatMessage, opts: Optional[Dict[str, Any]]): 28 | """ 29 | 收到心跳包(人气值) 30 | """ 31 | await _post_message_to_target_server(client.room_id, opts['URL'], vars(message)) 32 | 33 | async def _on_danmaku(self, client: BLiveClient, message: DanmakuMessage, opts: Optional[Dict[str, Any]]): 34 | """ 35 | 收到弹幕 36 | """ 37 | await _post_message_to_target_server(client.room_id, opts['URL'], vars(message)) 38 | 39 | async def _on_gift(self, client: BLiveClient, message: GiftMessage, opts: Optional[Dict[str, Any]]): 40 | """ 41 | 收到礼物 42 | """ 43 | await _post_message_to_target_server(client.room_id, opts['URL'], vars(message)) 44 | 45 | async def _on_buy_guard(self, client: BLiveClient, message: GuardBuyMessage, opts: Optional[Dict[str, Any]]): 46 | """ 47 | 有人上舰 48 | """ 49 | await _post_message_to_target_server(client.room_id, opts['URL'], vars(message)) 50 | 51 | async def _on_super_chat(self, client: BLiveClient, message: SuperChatMessage, opts: Optional[Dict[str, Any]]): 52 | """ 53 | 醒目留言 54 | """ 55 | await _post_message_to_target_server(client.room_id, opts['URL'], vars(message)) 56 | 57 | async def _on_super_chat_delete(self, client: BLiveClient, message: SuperChatDeleteMessage, 58 | opts: Optional[Dict[str, Any]]): 59 | """ 60 | 删除醒目留言 61 | """ 62 | await _post_message_to_target_server(client.room_id, opts['URL'], vars(message)) 63 | -------------------------------------------------------------------------------- /src/handlers/websocket_handler.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import * 3 | from fastapi import WebSocket 4 | 5 | import aiohttp 6 | from ujson import dumps 7 | 8 | from ..blivedm import ( 9 | BaseHandler, BLiveClient, HeartbeatMessage, DanmakuMessage, 10 | GiftMessage, GuardBuyMessage, SuperChatMessage, SuperChatDeleteMessage 11 | ) 12 | 13 | logger: logging.Logger = logging.getLogger('MocaBliveAPI') 14 | 15 | 16 | async def _send_live_info_via_websocket(room_id: int, ws: WebSocket, message: Dict[str, Any]) -> None: 17 | """ 18 | 将接收到的直播数据通过websocket转发 19 | """ 20 | body: Dict[str, Any] = dict(message, room_id=room_id) 21 | await ws.send_json(body) 22 | 23 | 24 | class WebSocketHandler(BaseHandler): 25 | async def _on_heartbeat(self, client: BLiveClient, message: HeartbeatMessage, opts: Optional[Dict[str, Any]]): 26 | """ 27 | 收到心跳包(人气值) 28 | """ 29 | await _send_live_info_via_websocket(client.room_id, opts['WS'], vars(message)) 30 | 31 | async def _on_danmaku(self, client: BLiveClient, message: DanmakuMessage, opts: Optional[Dict[str, Any]]): 32 | """ 33 | 收到弹幕 34 | """ 35 | await _send_live_info_via_websocket(client.room_id, opts['WS'], vars(message)) 36 | 37 | async def _on_gift(self, client: BLiveClient, message: GiftMessage, opts: Optional[Dict[str, Any]]): 38 | """ 39 | 收到礼物 40 | """ 41 | await _send_live_info_via_websocket(client.room_id, opts['WS'], vars(message)) 42 | 43 | async def _on_buy_guard(self, client: BLiveClient, message: GuardBuyMessage, opts: Optional[Dict[str, Any]]): 44 | """ 45 | 有人上舰 46 | """ 47 | await _send_live_info_via_websocket(client.room_id, opts['WS'], vars(message)) 48 | 49 | async def _on_super_chat(self, client: BLiveClient, message: SuperChatMessage, opts: Optional[Dict[str, Any]]): 50 | """ 51 | 醒目留言 52 | """ 53 | await _send_live_info_via_websocket(client.room_id, opts['WS'], vars(message)) 54 | 55 | async def _on_super_chat_delete(self, client: BLiveClient, message: SuperChatDeleteMessage, 56 | opts: Optional[Dict[str, Any]]): 57 | """ 58 | 删除醒目留言 59 | """ 60 | await _send_live_info_via_websocket(client.room_id, opts['WS'], vars(message)) 61 | -------------------------------------------------------------------------------- /src/libs/__init__.py: -------------------------------------------------------------------------------- 1 | from .bilibili_apis import ( 2 | get_live_info, get_live_status, get_user_info, get_user_icon, get_user_name 3 | ) 4 | -------------------------------------------------------------------------------- /src/libs/bilibili_apis.py: -------------------------------------------------------------------------------- 1 | from os import environ 2 | from typing import * 3 | 4 | import aiohttp 5 | from async_lru import alru_cache 6 | 7 | LRU_CACHE_MAX_SIZE: int = int(environ.get('LRU_CACHE_MAX_SIZE', 1024)) 8 | GET_LIVE_INFO_API: str = 'https://api.live.bilibili.com/room/v1/Room/get_info?room_id=%s' 9 | GET_USER_INFO_API: str = 'https://api.bilibili.com/x/space/acc/info?mid=%s' 10 | 11 | headers: Dict[str, str] = { 12 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko)' 13 | ' Chrome/102.0.0.0 Safari/537.36' 14 | } 15 | 16 | 17 | @alru_cache(maxsize=LRU_CACHE_MAX_SIZE) 18 | async def get_live_info(room_id: str) -> Optional[dict]: 19 | """ 20 | 获取直播间信息 21 | :param room_id: 直播间号 22 | :return: 直播间信息 23 | """ 24 | async with aiohttp.ClientSession(headers=headers) as session: 25 | async with session.get(GET_LIVE_INFO_API % room_id) as res: 26 | if res.status != 200: 27 | return None 28 | return await res.json() 29 | 30 | 31 | async def get_live_status(room_id: str) -> Optional[int]: 32 | """ 33 | 获取直播状态 34 | 35 | :param room_id: 直播间号 36 | :return: 直播状态 37 | 38 | 0:未开播 39 | 1:直播中 40 | 2:轮播中 41 | """ 42 | info: Optional[dict] = await get_live_info(room_id) 43 | if info is None: 44 | return info 45 | return info['data']['live_status'] 46 | 47 | 48 | @alru_cache(maxsize=LRU_CACHE_MAX_SIZE) 49 | async def get_user_info(user_id: str) -> Optional[dict]: 50 | """ 51 | 获取用户信息 52 | 53 | :param user_id: 用户ID 54 | :return: 用户信息 55 | """ 56 | async with aiohttp.ClientSession(headers=headers) as session: 57 | async with session.get(GET_USER_INFO_API % user_id) as res: 58 | if res.status != 200: 59 | return None 60 | return await res.json() 61 | 62 | 63 | async def get_user_icon(user_id: str) -> Optional[str]: 64 | """ 65 | 获取用户头像 66 | 67 | :param user_id: 用户ID 68 | :return: 用户头像URL 69 | """ 70 | info: Optional[dict] = await get_user_info(user_id) 71 | if info is None: 72 | return info 73 | return info['data']['face'] 74 | 75 | 76 | async def get_user_name(user_id: str) -> Optional[str]: 77 | """ 78 | 获取用户昵称 79 | 80 | :param user_id: 用户ID 81 | :return: 用户昵称 82 | """ 83 | info: Optional[dict] = await get_user_info(user_id) 84 | if info is None: 85 | return info 86 | return info['data']['name'] 87 | -------------------------------------------------------------------------------- /src/server/__init__.py: -------------------------------------------------------------------------------- 1 | from .server import app 2 | -------------------------------------------------------------------------------- /src/server/server.py: -------------------------------------------------------------------------------- 1 | from fastapi import FastAPI 2 | from fastapi.websockets import WebSocket 3 | from ..blivedm import BLiveClient 4 | from ..handlers import WebSocketHandler 5 | from ..libs import * 6 | from os import environ 7 | 8 | 9 | SECRET_KEY: str = environ.get('SECRET_KEY', 'change_me') 10 | app: FastAPI = FastAPI() 11 | 12 | 13 | @app.websocket('/live') 14 | async def live_endpoint(websocket: WebSocket): 15 | """获取信息信息""" 16 | await websocket.accept() 17 | while True: 18 | init_msg: dict = await websocket.receive_json() 19 | 20 | try: 21 | certification: str = init_msg['secret_key'] 22 | room_id: str = init_msg['room_id'] 23 | 24 | if certification != SECRET_KEY: 25 | await websocket.send_text('Invalid Certification.') 26 | await websocket.close() 27 | return None 28 | except KeyError: 29 | await websocket.send_text('Invalid Usage.') 30 | await websocket.close() 31 | return None 32 | 33 | client: BLiveClient = BLiveClient(room_id, opts={'WS': websocket}) 34 | client.add_handler(WebSocketHandler()) 35 | client.start() 36 | await client.join() 37 | --------------------------------------------------------------------------------