├── .gitattributes ├── .gitignore ├── LICENSE ├── README.md ├── blivedm ├── __init__.py ├── clients │ ├── __init__.py │ ├── open_live.py │ ├── web.py │ └── ws_base.py ├── handlers.py ├── models │ ├── __init__.py │ ├── open_live.py │ └── web.py └── utils.py ├── open_live_sample.py ├── pyproject.toml ├── requirements.txt └── sample.py /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | *.egg-info/ 24 | .installed.cfg 25 | *.egg 26 | 27 | # PyInstaller 28 | # Usually these files are written by a python script from a template 29 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 30 | *.manifest 31 | *.spec 32 | 33 | # Installer logs 34 | pip-log.txt 35 | pip-delete-this-directory.txt 36 | 37 | # Unit test / coverage reports 38 | htmlcov/ 39 | .tox/ 40 | .coverage 41 | .coverage.* 42 | .cache 43 | nosetests.xml 44 | coverage.xml 45 | *.cover 46 | .hypothesis/ 47 | 48 | # Translations 49 | *.mo 50 | *.pot 51 | 52 | # Django stuff: 53 | *.log 54 | local_settings.py 55 | 56 | # Flask stuff: 57 | instance/ 58 | .webassets-cache 59 | 60 | # Scrapy stuff: 61 | .scrapy 62 | 63 | # Sphinx documentation 64 | docs/_build/ 65 | 66 | # PyBuilder 67 | target/ 68 | 69 | # Jupyter Notebook 70 | .ipynb_checkpoints 71 | 72 | # pyenv 73 | .python-version 74 | 75 | # celery beat schedule file 76 | celerybeat-schedule 77 | 78 | # SageMath parsed files 79 | *.sage.py 80 | 81 | # Environments 82 | .env 83 | .venv 84 | env/ 85 | venv/ 86 | ENV/ 87 | 88 | # Spyder project settings 89 | .spyderproject 90 | .spyproject 91 | 92 | # Rope project settings 93 | .ropeproject 94 | 95 | # mkdocs documentation 96 | /site 97 | 98 | # mypy 99 | .mypy_cache/ 100 | 101 | 102 | .idea/ 103 | .pdm-python 104 | pdm.lock 105 | pdm.toml 106 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 xfgryujk 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # blivedm 2 | 3 | Python获取bilibili直播弹幕的库,使用WebSocket协议,支持web端和B站直播开放平台两种接口 4 | 5 | [协议解释](https://open-live.bilibili.com/document/657d8e34-f926-a133-16c0-300c1afc6e6b) 6 | 7 | 基于本库开发的一个应用:[blivechat](https://github.com/xfgryujk/blivechat) 8 | 9 | ## 使用说明 10 | 11 | 1. 需要Python 3.8及以上版本 12 | 2. 安装依赖 13 | 14 | ```sh 15 | pip install -r requirements.txt 16 | ``` 17 | 18 | 3. web端例程在[sample.py](./sample.py),B站直播开放平台例程在[open_live_sample.py](./open_live_sample.py) 19 | -------------------------------------------------------------------------------- /blivedm/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | __version__ = '1.1.2' 3 | 4 | from .handlers import * 5 | from .clients import * 6 | -------------------------------------------------------------------------------- /blivedm/clients/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | from .web import * 3 | from .open_live import * 4 | -------------------------------------------------------------------------------- /blivedm/clients/open_live.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import datetime 4 | import hashlib 5 | import hmac 6 | import json 7 | import logging 8 | import uuid 9 | from typing import * 10 | 11 | import aiohttp 12 | 13 | from . import ws_base 14 | 15 | __all__ = ( 16 | 'OpenLiveClient', 17 | ) 18 | 19 | logger = logging.getLogger('blivedm') 20 | 21 | START_URL = 'https://live-open.biliapi.com/v2/app/start' 22 | HEARTBEAT_URL = 'https://live-open.biliapi.com/v2/app/heartbeat' 23 | END_URL = 'https://live-open.biliapi.com/v2/app/end' 24 | 25 | 26 | class OpenLiveClient(ws_base.WebSocketClientBase): 27 | """ 28 | 开放平台客户端 29 | 30 | 文档参考:https://open-live.bilibili.com/document/ 31 | 32 | :param access_key_id: 在开放平台申请的access_key_id 33 | :param access_key_secret: 在开放平台申请的access_key_secret 34 | :param app_id: 在开放平台创建的项目ID 35 | :param room_owner_auth_code: 主播身份码 36 | :param session: cookie、连接池 37 | :param heartbeat_interval: 发送连接心跳包的间隔时间(秒) 38 | :param game_heartbeat_interval: 发送项目心跳包的间隔时间(秒) 39 | """ 40 | 41 | def __init__( 42 | self, 43 | access_key_id: str, 44 | access_key_secret: str, 45 | app_id: int, 46 | room_owner_auth_code: str, 47 | *, 48 | session: Optional[aiohttp.ClientSession] = None, 49 | heartbeat_interval=30, 50 | game_heartbeat_interval=20, 51 | ): 52 | super().__init__(session, heartbeat_interval) 53 | 54 | self._access_key_id = access_key_id 55 | self._access_key_secret = access_key_secret 56 | self._app_id = app_id 57 | self._room_owner_auth_code = room_owner_auth_code 58 | self._game_heartbeat_interval = game_heartbeat_interval 59 | 60 | # 在调用init_room后初始化的字段 61 | self._room_owner_uid: Optional[int] = None 62 | """主播用户ID""" 63 | self._room_owner_open_id: Optional[str] = None 64 | """主播Open ID""" 65 | self._host_server_url_list: Optional[List[str]] = [] 66 | """弹幕服务器URL列表""" 67 | self._auth_body: Optional[str] = None 68 | """连接弹幕服务器用的认证包内容""" 69 | self._game_id: Optional[str] = None 70 | """项目场次ID""" 71 | 72 | # 在运行时初始化的字段 73 | self._game_heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None 74 | """发项目心跳包定时器的handle""" 75 | 76 | @property 77 | def room_owner_uid(self) -> Optional[int]: 78 | """ 79 | 主播用户ID,调用init_room后初始化 80 | """ 81 | return self._room_owner_uid 82 | 83 | @property 84 | def room_owner_open_id(self) -> Optional[str]: 85 | """ 86 | 主播Open ID,调用init_room后初始化 87 | """ 88 | return self._room_owner_open_id 89 | 90 | @property 91 | def room_owner_auth_code(self): 92 | """ 93 | 主播身份码 94 | """ 95 | return self._room_owner_auth_code 96 | 97 | @property 98 | def app_id(self): 99 | """ 100 | 在开放平台创建的项目ID 101 | """ 102 | return self._app_id 103 | 104 | @property 105 | def game_id(self) -> Optional[str]: 106 | """ 107 | 项目场次ID,调用init_room后初始化 108 | """ 109 | return self._game_id 110 | 111 | async def close(self): 112 | """ 113 | 释放本客户端的资源,调用后本客户端将不可用 114 | """ 115 | if self.is_running: 116 | logger.warning('room=%s is calling close(), but client is running', self.room_id) 117 | 118 | if self._game_heartbeat_timer_handle is not None: 119 | self._game_heartbeat_timer_handle.cancel() 120 | self._game_heartbeat_timer_handle = None 121 | await self._end_game() 122 | 123 | await super().close() 124 | 125 | def _request_open_live(self, url, body: dict): 126 | body_bytes = json.dumps(body).encode('utf-8') 127 | headers = { 128 | 'x-bili-accesskeyid': self._access_key_id, 129 | 'x-bili-content-md5': hashlib.md5(body_bytes).hexdigest(), 130 | 'x-bili-signature-method': 'HMAC-SHA256', 131 | 'x-bili-signature-nonce': uuid.uuid4().hex, 132 | 'x-bili-signature-version': '1.0', 133 | 'x-bili-timestamp': str(int(datetime.datetime.now().timestamp())), 134 | } 135 | 136 | str_to_sign = '\n'.join( 137 | f'{key}:{value}' 138 | for key, value in headers.items() 139 | ) 140 | signature = hmac.new( 141 | self._access_key_secret.encode('utf-8'), str_to_sign.encode('utf-8'), hashlib.sha256 142 | ).hexdigest() 143 | headers['Authorization'] = signature 144 | 145 | headers['Content-Type'] = 'application/json' 146 | headers['Accept'] = 'application/json' 147 | return self._session.post(url, headers=headers, data=body_bytes) 148 | 149 | async def init_room(self): 150 | """ 151 | 开启项目,并初始化连接房间需要的字段 152 | 153 | :return: 是否成功 154 | """ 155 | if not await self._start_game(): 156 | return False 157 | 158 | if self._game_id != '' and self._game_heartbeat_timer_handle is None: 159 | self._game_heartbeat_timer_handle = asyncio.get_running_loop().call_later( 160 | self._game_heartbeat_interval, self._on_send_game_heartbeat 161 | ) 162 | return True 163 | 164 | async def _start_game(self): 165 | try: 166 | async with self._request_open_live( 167 | START_URL, 168 | {'code': self._room_owner_auth_code, 'app_id': self._app_id} 169 | ) as res: 170 | if res.status != 200: 171 | logger.warning('_start_game() failed, status=%d, reason=%s', res.status, res.reason) 172 | return False 173 | data = await res.json() 174 | if data['code'] != 0: 175 | logger.warning('_start_game() failed, code=%d, message=%s, request_id=%s', 176 | data['code'], data['message'], data['request_id']) 177 | return False 178 | if not self._parse_start_game(data['data']): 179 | return False 180 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 181 | logger.exception('_start_game() failed:') 182 | return False 183 | return True 184 | 185 | def _parse_start_game(self, data): 186 | self._game_id = data['game_info']['game_id'] 187 | websocket_info = data['websocket_info'] 188 | self._auth_body = websocket_info['auth_body'] 189 | self._host_server_url_list = websocket_info['wss_link'] 190 | anchor_info = data['anchor_info'] 191 | self._room_id = anchor_info['room_id'] 192 | self._room_owner_uid = anchor_info['uid'] 193 | self._room_owner_open_id = anchor_info['open_id'] 194 | return True 195 | 196 | async def _end_game(self): 197 | """ 198 | 关闭项目。建议关闭客户端时保证调用到这个函数(close会调用),否则可能短时间内无法重复连接同一个房间 199 | """ 200 | if self._game_id in (None, ''): 201 | return True 202 | 203 | try: 204 | async with self._request_open_live( 205 | END_URL, 206 | {'app_id': self._app_id, 'game_id': self._game_id} 207 | ) as res: 208 | if res.status != 200: 209 | logger.warning('room=%d _end_game() failed, status=%d, reason=%s', 210 | self._room_id, res.status, res.reason) 211 | return False 212 | data = await res.json() 213 | code = data['code'] 214 | if code != 0: 215 | if code in (7000, 7003): 216 | # 项目已经关闭了也算成功 217 | return True 218 | 219 | logger.warning('room=%d _end_game() failed, code=%d, message=%s, request_id=%s', 220 | self._room_id, code, data['message'], data['request_id']) 221 | return False 222 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 223 | logger.exception('room=%d _end_game() failed:', self._room_id) 224 | return False 225 | return True 226 | 227 | def _on_send_game_heartbeat(self): 228 | """ 229 | 定时发送项目心跳包的回调 230 | """ 231 | self._game_heartbeat_timer_handle = asyncio.get_running_loop().call_later( 232 | self._game_heartbeat_interval, self._on_send_game_heartbeat 233 | ) 234 | asyncio.create_task(self._send_game_heartbeat()) 235 | 236 | async def _send_game_heartbeat(self): 237 | """ 238 | 发送项目心跳包 239 | """ 240 | if self._game_id in (None, ''): 241 | logger.warning('game=%d _send_game_heartbeat() failed, game_id not found', self._game_id) 242 | return False 243 | 244 | try: 245 | # 保存一下,防止await之后game_id改变 246 | game_id = self._game_id 247 | async with self._request_open_live( 248 | HEARTBEAT_URL, 249 | {'game_id': game_id} 250 | ) as res: 251 | if res.status != 200: 252 | logger.warning('room=%d _send_game_heartbeat() failed, status=%d, reason=%s', 253 | self._room_id, res.status, res.reason) 254 | return False 255 | data = await res.json() 256 | code = data['code'] 257 | if code != 0: 258 | logger.warning('room=%d _send_game_heartbeat() failed, code=%d, message=%s, request_id=%s', 259 | self._room_id, code, data['message'], data['request_id']) 260 | 261 | if code == 7003 and self._game_id == game_id: 262 | # 项目异常关闭,可能是心跳超时,需要重新开启项目 263 | self._need_init_room = True 264 | if self._websocket is not None and not self._websocket.closed: 265 | await self._websocket.close() 266 | 267 | return False 268 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 269 | logger.exception('room=%d _send_game_heartbeat() failed:', self._room_id) 270 | return False 271 | return True 272 | 273 | async def _on_before_ws_connect(self, retry_count): 274 | """ 275 | 在每次建立连接之前调用,可以用来初始化房间 276 | """ 277 | # 重连次数太多则重新init_room,保险 278 | reinit_period = max(3, len(self._host_server_url_list or ())) 279 | if retry_count > 0 and retry_count % reinit_period == 0: 280 | self._need_init_room = True 281 | await super()._on_before_ws_connect(retry_count) 282 | 283 | def _get_ws_url(self, retry_count) -> str: 284 | """ 285 | 返回WebSocket连接的URL,可以在这里做故障转移和负载均衡 286 | """ 287 | return self._host_server_url_list[retry_count % len(self._host_server_url_list)] 288 | 289 | async def _send_auth(self): 290 | """ 291 | 发送认证包 292 | """ 293 | await self._websocket.send_bytes(self._make_packet(self._auth_body, ws_base.Operation.AUTH)) 294 | 295 | def _handle_command(self, command: dict): 296 | cmd = command.get('cmd', '') 297 | if cmd == 'LIVE_OPEN_PLATFORM_INTERACTION_END' and command['data']['game_id'] == self._game_id: 298 | # 服务器主动停止推送,可能是心跳超时,需要重新开启项目 299 | logger.warning('room=%d game end by server, game_id=%s', self._room_id, self._game_id) 300 | 301 | self._need_init_room = True 302 | if self._websocket is not None and not self._websocket.closed: 303 | asyncio.create_task(self._websocket.close()) 304 | return 305 | 306 | super()._handle_command(command) 307 | -------------------------------------------------------------------------------- /blivedm/clients/web.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import logging 4 | from typing import * 5 | 6 | import aiohttp 7 | import yarl 8 | 9 | from . import ws_base 10 | from .. import utils 11 | 12 | __all__ = ( 13 | 'BLiveClient', 14 | ) 15 | 16 | logger = logging.getLogger('blivedm') 17 | 18 | UID_INIT_URL = 'https://api.bilibili.com/x/web-interface/nav' 19 | BUVID_INIT_URL = 'https://www.bilibili.com/' 20 | ROOM_INIT_URL = 'https://api.live.bilibili.com/room/v1/Room/get_info' 21 | DANMAKU_SERVER_CONF_URL = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getDanmuInfo' 22 | DEFAULT_DANMAKU_SERVER_LIST = [ 23 | {'host': 'broadcastlv.chat.bilibili.com', 'port': 2243, 'wss_port': 443, 'ws_port': 2244} 24 | ] 25 | 26 | 27 | class BLiveClient(ws_base.WebSocketClientBase): 28 | """ 29 | web端客户端 30 | 31 | :param room_id: URL中的房间ID,可以用短ID 32 | :param uid: B站用户ID,0表示未登录,None表示自动获取 33 | :param session: cookie、连接池 34 | :param heartbeat_interval: 发送心跳包的间隔时间(秒) 35 | """ 36 | 37 | def __init__( 38 | self, 39 | room_id: int, 40 | *, 41 | uid: Optional[int] = None, 42 | session: Optional[aiohttp.ClientSession] = None, 43 | heartbeat_interval=30, 44 | ): 45 | super().__init__(session, heartbeat_interval) 46 | 47 | self._tmp_room_id = room_id 48 | """用来init_room的临时房间ID,可以用短ID""" 49 | self._uid = uid 50 | 51 | # 在调用init_room后初始化的字段 52 | self._room_owner_uid: Optional[int] = None 53 | """主播用户ID""" 54 | self._host_server_list: Optional[List[dict]] = None 55 | """ 56 | 弹幕服务器列表 57 | 58 | `[{host: "tx-bj4-live-comet-04.chat.bilibili.com", port: 2243, wss_port: 443, ws_port: 2244}, ...]` 59 | """ 60 | self._host_server_token: Optional[str] = None 61 | """连接弹幕服务器用的token""" 62 | 63 | @property 64 | def tmp_room_id(self) -> int: 65 | """ 66 | 构造时传进来的room_id参数 67 | """ 68 | return self._tmp_room_id 69 | 70 | @property 71 | def room_owner_uid(self) -> Optional[int]: 72 | """ 73 | 主播用户ID,调用init_room后初始化 74 | """ 75 | return self._room_owner_uid 76 | 77 | @property 78 | def uid(self) -> Optional[int]: 79 | """ 80 | 当前登录的用户ID,未登录则为0,调用init_room后初始化 81 | """ 82 | return self._uid 83 | 84 | async def init_room(self): 85 | """ 86 | 初始化连接房间需要的字段 87 | 88 | :return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True 89 | """ 90 | if self._uid is None: 91 | if not await self._init_uid(): 92 | logger.warning('room=%d _init_uid() failed', self._tmp_room_id) 93 | self._uid = 0 94 | 95 | if self._get_buvid() == '': 96 | if not await self._init_buvid(): 97 | logger.warning('room=%d _init_buvid() failed', self._tmp_room_id) 98 | 99 | res = True 100 | if not await self._init_room_id_and_owner(): 101 | res = False 102 | # 失败了则降级 103 | self._room_id = self._tmp_room_id 104 | self._room_owner_uid = 0 105 | 106 | if not await self._init_host_server(): 107 | res = False 108 | # 失败了则降级 109 | self._host_server_list = DEFAULT_DANMAKU_SERVER_LIST 110 | self._host_server_token = None 111 | return res 112 | 113 | async def _init_uid(self): 114 | cookies = self._session.cookie_jar.filter_cookies(yarl.URL(UID_INIT_URL)) 115 | sessdata_cookie = cookies.get('SESSDATA', None) 116 | if sessdata_cookie is None or sessdata_cookie.value == '': 117 | # cookie都没有,不用请求了 118 | self._uid = 0 119 | return True 120 | 121 | try: 122 | async with self._session.get( 123 | UID_INIT_URL, 124 | headers={'User-Agent': utils.USER_AGENT}, 125 | ) as res: 126 | if res.status != 200: 127 | logger.warning('room=%d _init_uid() failed, status=%d, reason=%s', self._tmp_room_id, 128 | res.status, res.reason) 129 | return False 130 | data = await res.json() 131 | if data['code'] != 0: 132 | if data['code'] == -101: 133 | # 未登录 134 | self._uid = 0 135 | return True 136 | logger.warning('room=%d _init_uid() failed, message=%s', self._tmp_room_id, 137 | data['message']) 138 | return False 139 | 140 | data = data['data'] 141 | if not data['isLogin']: 142 | # 未登录 143 | self._uid = 0 144 | else: 145 | self._uid = data['mid'] 146 | return True 147 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 148 | logger.exception('room=%d _init_uid() failed:', self._tmp_room_id) 149 | return False 150 | 151 | def _get_buvid(self): 152 | cookies = self._session.cookie_jar.filter_cookies(yarl.URL(BUVID_INIT_URL)) 153 | buvid_cookie = cookies.get('buvid3', None) 154 | if buvid_cookie is None: 155 | return '' 156 | return buvid_cookie.value 157 | 158 | async def _init_buvid(self): 159 | try: 160 | async with self._session.get( 161 | BUVID_INIT_URL, 162 | headers={'User-Agent': utils.USER_AGENT}, 163 | ) as res: 164 | if res.status != 200: 165 | logger.warning('room=%d _init_buvid() status error, status=%d, reason=%s', 166 | self._tmp_room_id, res.status, res.reason) 167 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 168 | logger.exception('room=%d _init_buvid() exception:', self._tmp_room_id) 169 | return self._get_buvid() != '' 170 | 171 | async def _init_room_id_and_owner(self): 172 | try: 173 | async with self._session.get( 174 | ROOM_INIT_URL, 175 | headers={'User-Agent': utils.USER_AGENT}, 176 | params={ 177 | 'room_id': self._tmp_room_id 178 | }, 179 | ) as res: 180 | if res.status != 200: 181 | logger.warning('room=%d _init_room_id_and_owner() failed, status=%d, reason=%s', self._tmp_room_id, 182 | res.status, res.reason) 183 | return False 184 | data = await res.json() 185 | if data['code'] != 0: 186 | logger.warning('room=%d _init_room_id_and_owner() failed, message=%s', self._tmp_room_id, 187 | data['message']) 188 | return False 189 | if not self._parse_room_init(data['data']): 190 | return False 191 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 192 | logger.exception('room=%d _init_room_id_and_owner() failed:', self._tmp_room_id) 193 | return False 194 | return True 195 | 196 | def _parse_room_init(self, data): 197 | self._room_id = data['room_id'] 198 | self._room_owner_uid = data['uid'] 199 | return True 200 | 201 | async def _init_host_server(self): 202 | try: 203 | async with self._session.get( 204 | DANMAKU_SERVER_CONF_URL, 205 | headers={'User-Agent': utils.USER_AGENT}, 206 | params={ 207 | 'id': self._room_id, 208 | 'type': 0 209 | }, 210 | ) as res: 211 | if res.status != 200: 212 | logger.warning('room=%d _init_host_server() failed, status=%d, reason=%s', self._room_id, 213 | res.status, res.reason) 214 | return False 215 | data = await res.json() 216 | if data['code'] != 0: 217 | logger.warning('room=%d _init_host_server() failed, message=%s', self._room_id, data['message']) 218 | return False 219 | if not self._parse_danmaku_server_conf(data['data']): 220 | return False 221 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 222 | logger.exception('room=%d _init_host_server() failed:', self._room_id) 223 | return False 224 | return True 225 | 226 | def _parse_danmaku_server_conf(self, data): 227 | self._host_server_list = data['host_list'] 228 | self._host_server_token = data['token'] 229 | if not self._host_server_list: 230 | logger.warning('room=%d _parse_danmaku_server_conf() failed: host_server_list is empty', self._room_id) 231 | return False 232 | return True 233 | 234 | async def _on_before_ws_connect(self, retry_count): 235 | """ 236 | 在每次建立连接之前调用,可以用来初始化房间 237 | """ 238 | # 重连次数太多则重新init_room,保险 239 | reinit_period = max(3, len(self._host_server_list or ())) 240 | if retry_count > 0 and retry_count % reinit_period == 0: 241 | self._need_init_room = True 242 | await super()._on_before_ws_connect(retry_count) 243 | 244 | def _get_ws_url(self, retry_count) -> str: 245 | """ 246 | 返回WebSocket连接的URL,可以在这里做故障转移和负载均衡 247 | """ 248 | host_server = self._host_server_list[retry_count % len(self._host_server_list)] 249 | return f"wss://{host_server['host']}:{host_server['wss_port']}/sub" 250 | 251 | async def _send_auth(self): 252 | """ 253 | 发送认证包 254 | """ 255 | auth_params = { 256 | 'uid': self._uid, 257 | 'roomid': self._room_id, 258 | 'protover': 3, 259 | 'platform': 'web', 260 | 'type': 2, 261 | 'buvid': self._get_buvid(), 262 | } 263 | if self._host_server_token is not None: 264 | auth_params['key'] = self._host_server_token 265 | await self._websocket.send_bytes(self._make_packet(auth_params, ws_base.Operation.AUTH)) 266 | -------------------------------------------------------------------------------- /blivedm/clients/ws_base.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import enum 4 | import json 5 | import logging 6 | import struct 7 | import zlib 8 | from typing import * 9 | 10 | import aiohttp 11 | import brotli 12 | 13 | from .. import handlers, utils 14 | 15 | logger = logging.getLogger('blivedm') 16 | 17 | USER_AGENT = ( 18 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36' 19 | ) 20 | 21 | HEADER_STRUCT = struct.Struct('>I2H2I') 22 | 23 | 24 | class HeaderTuple(NamedTuple): 25 | pack_len: int 26 | raw_header_size: int 27 | ver: int 28 | operation: int 29 | seq_id: int 30 | 31 | 32 | # WS_BODY_PROTOCOL_VERSION 33 | class ProtoVer(enum.IntEnum): 34 | NORMAL = 0 35 | HEARTBEAT = 1 36 | DEFLATE = 2 37 | BROTLI = 3 38 | 39 | 40 | # go-common\app\service\main\broadcast\model\operation.go 41 | class Operation(enum.IntEnum): 42 | HANDSHAKE = 0 43 | HANDSHAKE_REPLY = 1 44 | HEARTBEAT = 2 45 | HEARTBEAT_REPLY = 3 46 | SEND_MSG = 4 47 | SEND_MSG_REPLY = 5 48 | DISCONNECT_REPLY = 6 49 | AUTH = 7 50 | AUTH_REPLY = 8 51 | RAW = 9 52 | PROTO_READY = 10 53 | PROTO_FINISH = 11 54 | CHANGE_ROOM = 12 55 | CHANGE_ROOM_REPLY = 13 56 | REGISTER = 14 57 | REGISTER_REPLY = 15 58 | UNREGISTER = 16 59 | UNREGISTER_REPLY = 17 60 | # B站业务自定义OP 61 | # MinBusinessOp = 1000 62 | # MaxBusinessOp = 10000 63 | 64 | 65 | # WS_AUTH 66 | class AuthReplyCode(enum.IntEnum): 67 | OK = 0 68 | TOKEN_ERROR = -101 69 | 70 | 71 | class InitError(Exception): 72 | """初始化失败""" 73 | 74 | 75 | class AuthError(Exception): 76 | """认证失败""" 77 | 78 | 79 | DEFAULT_RECONNECT_POLICY = utils.make_constant_retry_policy(1) 80 | 81 | 82 | class WebSocketClientBase: 83 | """ 84 | 基于WebSocket的客户端 85 | 86 | :param session: cookie、连接池 87 | :param heartbeat_interval: 发送心跳包的间隔时间(秒) 88 | """ 89 | 90 | def __init__( 91 | self, 92 | session: Optional[aiohttp.ClientSession] = None, 93 | heartbeat_interval: float = 30, 94 | ): 95 | if session is None: 96 | self._session = aiohttp.ClientSession(timeout=aiohttp.ClientTimeout(total=10)) 97 | self._own_session = True 98 | else: 99 | self._session = session 100 | self._own_session = False 101 | assert self._session.loop is asyncio.get_event_loop() # noqa 102 | 103 | self._heartbeat_interval = heartbeat_interval 104 | 105 | self._need_init_room = True 106 | self._handler: Optional[handlers.HandlerInterface] = None 107 | """消息处理器""" 108 | self._get_reconnect_interval: Callable[[int, int], float] = DEFAULT_RECONNECT_POLICY 109 | """重连间隔时间增长策略""" 110 | 111 | # 在调用init_room后初始化的字段 112 | self._room_id: Optional[int] = None 113 | 114 | # 在运行时初始化的字段 115 | self._websocket: Optional[aiohttp.ClientWebSocketResponse] = None 116 | """WebSocket连接""" 117 | self._network_future: Optional[asyncio.Future] = None 118 | """网络协程的future""" 119 | self._heartbeat_timer_handle: Optional[asyncio.TimerHandle] = None 120 | """发心跳包定时器的handle""" 121 | 122 | @property 123 | def is_running(self) -> bool: 124 | """ 125 | 本客户端正在运行,注意调用stop后还没完全停止也算正在运行 126 | """ 127 | return self._network_future is not None 128 | 129 | @property 130 | def room_id(self) -> Optional[int]: 131 | """ 132 | 房间ID,调用init_room后初始化 133 | """ 134 | return self._room_id 135 | 136 | def set_handler(self, handler: Optional['handlers.HandlerInterface']): 137 | """ 138 | 设置消息处理器 139 | 140 | 注意消息处理器和网络协程运行在同一个协程,如果处理消息耗时太长会阻塞接收消息。如果是CPU密集型的任务,建议将消息推到线程池处理; 141 | 如果是IO密集型的任务,应该使用async函数,并且在handler里使用create_task创建新的协程 142 | 143 | :param handler: 消息处理器 144 | """ 145 | self._handler = handler 146 | 147 | def set_reconnect_policy(self, get_reconnect_interval: Callable[[int, int], float]): 148 | """ 149 | 设置重连间隔时间增长策略 150 | 151 | :param get_reconnect_interval: 一个可调用对象,输入重试次数 (retry_count, total_retry_count),返回间隔时间 152 | """ 153 | self._get_reconnect_interval = get_reconnect_interval 154 | 155 | def start(self): 156 | """ 157 | 启动本客户端 158 | """ 159 | if self.is_running: 160 | logger.warning('room=%s client is running, cannot start() again', self.room_id) 161 | return 162 | 163 | self._network_future = asyncio.create_task(self._network_coroutine_wrapper()) 164 | 165 | def stop(self): 166 | """ 167 | 停止本客户端 168 | """ 169 | if not self.is_running: 170 | logger.warning('room=%s client is stopped, cannot stop() again', self.room_id) 171 | return 172 | 173 | self._network_future.cancel() 174 | 175 | async def stop_and_close(self): 176 | """ 177 | 便利函数,停止本客户端并释放本客户端的资源,调用后本客户端将不可用 178 | """ 179 | if self.is_running: 180 | self.stop() 181 | await self.join() 182 | await self.close() 183 | 184 | async def join(self): 185 | """ 186 | 等待本客户端停止 187 | """ 188 | if not self.is_running: 189 | logger.warning('room=%s client is stopped, cannot join()', self.room_id) 190 | return 191 | 192 | await asyncio.shield(self._network_future) 193 | 194 | async def close(self): 195 | """ 196 | 释放本客户端的资源,调用后本客户端将不可用 197 | """ 198 | if self.is_running: 199 | logger.warning('room=%s is calling close(), but client is running', self.room_id) 200 | 201 | # 如果session是自己创建的则关闭session 202 | if self._own_session: 203 | await self._session.close() 204 | 205 | async def init_room(self) -> bool: 206 | """ 207 | 初始化连接房间需要的字段 208 | 209 | :return: True代表没有降级,如果需要降级后还可用,重载这个函数返回True 210 | """ 211 | raise NotImplementedError 212 | 213 | @staticmethod 214 | def _make_packet(data: Union[dict, str, bytes], operation: int) -> bytes: 215 | """ 216 | 创建一个要发送给服务器的包 217 | 218 | :param data: 包体JSON数据 219 | :param operation: 操作码,见Operation 220 | :return: 整个包的数据 221 | """ 222 | if isinstance(data, dict): 223 | body = json.dumps(data).encode('utf-8') 224 | elif isinstance(data, str): 225 | body = data.encode('utf-8') 226 | else: 227 | body = data 228 | header = HEADER_STRUCT.pack(*HeaderTuple( 229 | pack_len=HEADER_STRUCT.size + len(body), 230 | raw_header_size=HEADER_STRUCT.size, 231 | ver=1, 232 | operation=operation, 233 | seq_id=1 234 | )) 235 | return header + body 236 | 237 | async def _network_coroutine_wrapper(self): 238 | """ 239 | 负责处理网络协程的异常,网络协程具体逻辑在_network_coroutine里 240 | """ 241 | exc = None 242 | try: 243 | await self._network_coroutine() 244 | except asyncio.CancelledError: 245 | # 正常停止 246 | pass 247 | except Exception as e: 248 | logger.exception('room=%s _network_coroutine() finished with exception:', self.room_id) 249 | exc = e 250 | finally: 251 | logger.debug('room=%s _network_coroutine() finished', self.room_id) 252 | self._network_future = None 253 | 254 | if self._handler is not None: 255 | self._handler.on_client_stopped(self, exc) 256 | 257 | async def _network_coroutine(self): 258 | """ 259 | 网络协程,负责连接服务器、接收消息、解包 260 | """ 261 | # retry_count在连接成功后会重置为0,total_retry_count不会 262 | retry_count = 0 263 | total_retry_count = 0 264 | while True: 265 | try: 266 | await self._on_before_ws_connect(retry_count) 267 | 268 | # 连接 269 | async with self._session.ws_connect( 270 | self._get_ws_url(retry_count), 271 | headers={'User-Agent': utils.USER_AGENT}, # web端的token也会签名UA 272 | receive_timeout=self._heartbeat_interval + 5, 273 | ) as websocket: 274 | self._websocket = websocket 275 | await self._on_ws_connect() 276 | 277 | # 处理消息 278 | message: aiohttp.WSMessage 279 | async for message in websocket: 280 | await self._on_ws_message(message) 281 | # 至少成功处理1条消息 282 | retry_count = 0 283 | 284 | except (aiohttp.ClientConnectionError, asyncio.TimeoutError): 285 | # 掉线重连 286 | pass 287 | except AuthError: 288 | # 认证失败了,应该重新获取token再重连 289 | logger.exception('room=%d auth failed, trying init_room() again', self.room_id) 290 | self._need_init_room = True 291 | finally: 292 | self._websocket = None 293 | await self._on_ws_close() 294 | 295 | # 准备重连 296 | retry_count += 1 297 | total_retry_count += 1 298 | logger.warning( 299 | 'room=%d is reconnecting, retry_count=%d, total_retry_count=%d', 300 | self.room_id, retry_count, total_retry_count 301 | ) 302 | await asyncio.sleep(self._get_reconnect_interval(retry_count, total_retry_count)) 303 | 304 | async def _on_before_ws_connect(self, retry_count): 305 | """ 306 | 在每次建立连接之前调用,可以用来初始化房间 307 | """ 308 | if not self._need_init_room: 309 | return 310 | 311 | if not await self.init_room(): 312 | raise InitError('init_room() failed') 313 | self._need_init_room = False 314 | 315 | def _get_ws_url(self, retry_count) -> str: 316 | """ 317 | 返回WebSocket连接的URL,可以在这里做故障转移和负载均衡 318 | """ 319 | raise NotImplementedError 320 | 321 | async def _on_ws_connect(self): 322 | """ 323 | WebSocket连接成功 324 | """ 325 | await self._send_auth() 326 | self._heartbeat_timer_handle = asyncio.get_running_loop().call_later( 327 | self._heartbeat_interval, self._on_send_heartbeat 328 | ) 329 | 330 | async def _on_ws_close(self): 331 | """ 332 | WebSocket连接断开 333 | """ 334 | if self._heartbeat_timer_handle is not None: 335 | self._heartbeat_timer_handle.cancel() 336 | self._heartbeat_timer_handle = None 337 | 338 | async def _send_auth(self): 339 | """ 340 | 发送认证包 341 | """ 342 | raise NotImplementedError 343 | 344 | def _on_send_heartbeat(self): 345 | """ 346 | 定时发送心跳包的回调 347 | """ 348 | if self._websocket is None or self._websocket.closed: 349 | self._heartbeat_timer_handle = None 350 | return 351 | 352 | self._heartbeat_timer_handle = asyncio.get_running_loop().call_later( 353 | self._heartbeat_interval, self._on_send_heartbeat 354 | ) 355 | asyncio.create_task(self._send_heartbeat()) 356 | 357 | async def _send_heartbeat(self): 358 | """ 359 | 发送心跳包 360 | """ 361 | if self._websocket is None or self._websocket.closed: 362 | return 363 | 364 | try: 365 | await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT)) 366 | except (ConnectionResetError, aiohttp.ClientConnectionError) as e: 367 | logger.warning('room=%d _send_heartbeat() failed: %r', self.room_id, e) 368 | except Exception: # noqa 369 | logger.exception('room=%d _send_heartbeat() failed:', self.room_id) 370 | 371 | async def _on_ws_message(self, message: aiohttp.WSMessage): 372 | """ 373 | 收到WebSocket消息 374 | 375 | :param message: WebSocket消息 376 | """ 377 | if message.type != aiohttp.WSMsgType.BINARY: 378 | logger.warning('room=%d unknown websocket message type=%s, data=%s', self.room_id, 379 | message.type, message.data) 380 | return 381 | 382 | try: 383 | await self._parse_ws_message(message.data) 384 | except AuthError: 385 | # 认证失败,让外层处理 386 | raise 387 | except Exception: # noqa 388 | logger.exception('room=%d _parse_ws_message() error:', self.room_id) 389 | 390 | async def _parse_ws_message(self, data: bytes): 391 | """ 392 | 解析WebSocket消息 393 | 394 | :param data: WebSocket消息数据 395 | """ 396 | offset = 0 397 | try: 398 | header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset)) 399 | except struct.error: 400 | logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data) 401 | return 402 | 403 | if header.operation in (Operation.SEND_MSG_REPLY, Operation.AUTH_REPLY): 404 | # 业务消息,可能有多个包一起发,需要分包 405 | while True: 406 | body = data[offset + header.raw_header_size: offset + header.pack_len] 407 | await self._parse_business_message(header, body) 408 | 409 | offset += header.pack_len 410 | if offset >= len(data): 411 | break 412 | 413 | try: 414 | header = HeaderTuple(*HEADER_STRUCT.unpack_from(data, offset)) 415 | except struct.error: 416 | logger.exception('room=%d parsing header failed, offset=%d, data=%s', self.room_id, offset, data) 417 | break 418 | 419 | elif header.operation == Operation.HEARTBEAT_REPLY: 420 | # 服务器心跳包,前4字节是人气值,后面是客户端发的心跳包内容 421 | # pack_len不包括客户端发的心跳包内容,不知道是不是服务器BUG 422 | body = data[offset + header.raw_header_size: offset + header.raw_header_size + 4] 423 | popularity = int.from_bytes(body, 'big') 424 | # 自己造个消息当成业务消息处理 425 | body = { 426 | 'cmd': '_HEARTBEAT', 427 | 'data': { 428 | 'popularity': popularity 429 | } 430 | } 431 | self._handle_command(body) 432 | 433 | else: 434 | # 未知消息 435 | body = data[offset + header.raw_header_size: offset + header.pack_len] 436 | logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id, 437 | header.operation, header, body) 438 | 439 | async def _parse_business_message(self, header: HeaderTuple, body: bytes): 440 | """ 441 | 解析业务消息 442 | """ 443 | if header.operation == Operation.SEND_MSG_REPLY: 444 | # 业务消息 445 | if header.ver == ProtoVer.BROTLI: 446 | # 压缩过的先解压,为了避免阻塞网络线程,放在其他线程执行 447 | body = await asyncio.get_running_loop().run_in_executor(None, brotli.decompress, body) 448 | await self._parse_ws_message(body) 449 | elif header.ver == ProtoVer.DEFLATE: 450 | # web端已经不用zlib压缩了,但是开放平台会用 451 | body = await asyncio.get_running_loop().run_in_executor(None, zlib.decompress, body) 452 | await self._parse_ws_message(body) 453 | elif header.ver == ProtoVer.NORMAL: 454 | # 没压缩过的直接反序列化,因为有万恶的GIL,这里不能并行避免阻塞 455 | if len(body) != 0: 456 | try: 457 | body = json.loads(body.decode('utf-8')) 458 | self._handle_command(body) 459 | except Exception: 460 | logger.error('room=%d, body=%s', self.room_id, body) 461 | raise 462 | else: 463 | # 未知格式 464 | logger.warning('room=%d unknown protocol version=%d, header=%s, body=%s', self.room_id, 465 | header.ver, header, body) 466 | 467 | elif header.operation == Operation.AUTH_REPLY: 468 | # 认证响应 469 | body = json.loads(body.decode('utf-8')) 470 | if body['code'] != AuthReplyCode.OK: 471 | raise AuthError(f"auth reply error, code={body['code']}, body={body}") 472 | await self._websocket.send_bytes(self._make_packet({}, Operation.HEARTBEAT)) 473 | 474 | else: 475 | # 未知消息 476 | logger.warning('room=%d unknown message operation=%d, header=%s, body=%s', self.room_id, 477 | header.operation, header, body) 478 | 479 | def _handle_command(self, command: dict): 480 | """ 481 | 处理业务消息 482 | 483 | :param command: 业务消息 484 | """ 485 | if self._handler is None: 486 | return 487 | try: 488 | # 为什么不做成异步的: 489 | # 1. 为了保持处理消息的顺序,这里不使用call_soon、create_task等方法延迟处理 490 | # 2. 如果支持handle使用async函数,用户可能会在里面处理耗时很长的异步操作,导致网络协程阻塞 491 | # 这里做成同步的,强制用户使用create_task或消息队列处理异步操作,这样就不会阻塞网络协程 492 | self._handler.handle(self, command) 493 | except Exception as e: 494 | logger.exception('room=%d _handle_command() failed, command=%s', self.room_id, command, exc_info=e) 495 | -------------------------------------------------------------------------------- /blivedm/handlers.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import logging 3 | from typing import * 4 | 5 | from .clients import ws_base 6 | from .models import web as web_models, open_live as open_models 7 | 8 | __all__ = ( 9 | 'HandlerInterface', 10 | 'BaseHandler', 11 | ) 12 | 13 | logger = logging.getLogger('blivedm') 14 | 15 | logged_unknown_cmds = { 16 | 'COMBO_SEND', 17 | 'ENTRY_EFFECT', 18 | 'HOT_RANK_CHANGED', 19 | 'HOT_RANK_CHANGED_V2', 20 | 'LIVE', 21 | 'LIVE_INTERACTIVE_GAME', 22 | 'NOTICE_MSG', 23 | 'ONLINE_RANK_COUNT', 24 | 'ONLINE_RANK_TOP3', 25 | 'ONLINE_RANK_V2', 26 | 'PK_BATTLE_END', 27 | 'PK_BATTLE_FINAL_PROCESS', 28 | 'PK_BATTLE_PROCESS', 29 | 'PK_BATTLE_PROCESS_NEW', 30 | 'PK_BATTLE_SETTLE', 31 | 'PK_BATTLE_SETTLE_USER', 32 | 'PK_BATTLE_SETTLE_V2', 33 | 'PREPARING', 34 | 'ROOM_REAL_TIME_MESSAGE_UPDATE', 35 | 'STOP_LIVE_ROOM_LIST', 36 | 'SUPER_CHAT_MESSAGE_JPN', 37 | 'USER_TOAST_MSG', 38 | 'WIDGET_BANNER', 39 | } 40 | """已打日志的未知cmd""" 41 | 42 | 43 | class HandlerInterface: 44 | """ 45 | 直播消息处理器接口 46 | """ 47 | 48 | def handle(self, client: ws_base.WebSocketClientBase, command: dict): 49 | raise NotImplementedError 50 | 51 | def on_client_stopped(self, client: ws_base.WebSocketClientBase, exception: Optional[Exception]): 52 | """ 53 | 当客户端停止时调用。可以在这里close或者重新start 54 | """ 55 | 56 | 57 | def _make_msg_callback(method_name, message_cls): 58 | def callback(self: 'BaseHandler', client: ws_base.WebSocketClientBase, command: dict): 59 | method = getattr(self, method_name) 60 | return method(client, message_cls.from_command(command['data'])) 61 | return callback 62 | 63 | 64 | class BaseHandler(HandlerInterface): 65 | """ 66 | 一个简单的消息处理器实现,带消息分发和消息类型转换。继承并重写_on_xxx方法即可实现自己的处理器 67 | """ 68 | 69 | def __danmu_msg_callback(self, client: ws_base.WebSocketClientBase, command: dict): 70 | return self._on_danmaku(client, web_models.DanmakuMessage.from_command(command['info'])) 71 | 72 | _CMD_CALLBACK_DICT: Dict[ 73 | str, 74 | Optional[Callable[ 75 | ['BaseHandler', ws_base.WebSocketClientBase, dict], 76 | Any 77 | ]] 78 | ] 79 | """cmd -> 处理回调""" 80 | _CMD_CALLBACK_DICT = { 81 | # 收到心跳包,这是blivedm自造的消息,原本的心跳包格式不一样 82 | '_HEARTBEAT': _make_msg_callback('_on_heartbeat', web_models.HeartbeatMessage), 83 | # 弹幕 84 | # go-common\app\service\live\live-dm\service\v1\send.go 85 | 'DANMU_MSG': __danmu_msg_callback, 86 | # 礼物 87 | 'SEND_GIFT': _make_msg_callback('_on_gift', web_models.GiftMessage), 88 | # 上舰 89 | 'GUARD_BUY': _make_msg_callback('_on_buy_guard', web_models.GuardBuyMessage), 90 | # 另一个上舰消息 91 | 'USER_TOAST_MSG_V2': _make_msg_callback('_on_user_toast_v2', web_models.UserToastV2Message), 92 | # 醒目留言 93 | 'SUPER_CHAT_MESSAGE': _make_msg_callback('_on_super_chat', web_models.SuperChatMessage), 94 | # 删除醒目留言 95 | 'SUPER_CHAT_MESSAGE_DELETE': _make_msg_callback('_on_super_chat_delete', web_models.SuperChatDeleteMessage), 96 | # 进入房间、关注主播等互动消息 97 | 'INTERACT_WORD': _make_msg_callback('_on_interact_word', web_models.InteractWordMessage), 98 | 99 | # 100 | # 开放平台消息 101 | # 102 | 103 | # 弹幕 104 | 'LIVE_OPEN_PLATFORM_DM': _make_msg_callback('_on_open_live_danmaku', open_models.DanmakuMessage), 105 | # 礼物 106 | 'LIVE_OPEN_PLATFORM_SEND_GIFT': _make_msg_callback('_on_open_live_gift', open_models.GiftMessage), 107 | # 上舰 108 | 'LIVE_OPEN_PLATFORM_GUARD': _make_msg_callback('_on_open_live_buy_guard', open_models.GuardBuyMessage), 109 | # 醒目留言 110 | 'LIVE_OPEN_PLATFORM_SUPER_CHAT': _make_msg_callback('_on_open_live_super_chat', open_models.SuperChatMessage), 111 | # 删除醒目留言 112 | 'LIVE_OPEN_PLATFORM_SUPER_CHAT_DEL': _make_msg_callback( 113 | '_on_open_live_super_chat_delete', open_models.SuperChatDeleteMessage 114 | ), 115 | # 点赞 116 | 'LIVE_OPEN_PLATFORM_LIKE': _make_msg_callback('_on_open_live_like', open_models.LikeMessage), 117 | # 进入房间 118 | 'LIVE_OPEN_PLATFORM_LIVE_ROOM_ENTER': _make_msg_callback('_on_open_live_enter_room', open_models.RoomEnterMessage), 119 | # 开始直播 120 | 'LIVE_OPEN_PLATFORM_LIVE_START': _make_msg_callback('_on_open_live_start_live', open_models.LiveStartMessage), 121 | # 结束直播 122 | 'LIVE_OPEN_PLATFORM_LIVE_END': _make_msg_callback('_on_open_live_end_live', open_models.LiveEndMessage), 123 | } 124 | 125 | def handle(self, client: ws_base.WebSocketClientBase, command: dict): 126 | cmd = command.get('cmd', '') 127 | pos = cmd.find(':') # 2019-5-29 B站弹幕升级新增了参数 128 | if pos != -1: 129 | cmd = cmd[:pos] 130 | 131 | if cmd not in self._CMD_CALLBACK_DICT: 132 | # 只有第一次遇到未知cmd时打日志 133 | if cmd not in logged_unknown_cmds: 134 | logger.warning('room=%d unknown cmd=%s, command=%s', client.room_id, cmd, command) 135 | logged_unknown_cmds.add(cmd) 136 | return 137 | 138 | callback = self._CMD_CALLBACK_DICT[cmd] 139 | if callback is not None: 140 | callback(self, client, command) 141 | 142 | def _on_heartbeat(self, client: ws_base.WebSocketClientBase, message: web_models.HeartbeatMessage): 143 | """收到心跳包""" 144 | 145 | def _on_danmaku(self, client: ws_base.WebSocketClientBase, message: web_models.DanmakuMessage): 146 | """弹幕""" 147 | 148 | def _on_gift(self, client: ws_base.WebSocketClientBase, message: web_models.GiftMessage): 149 | """礼物""" 150 | 151 | def _on_buy_guard(self, client: ws_base.WebSocketClientBase, message: web_models.GuardBuyMessage): 152 | """上舰""" 153 | 154 | def _on_user_toast_v2(self, client: ws_base.WebSocketClientBase, message: web_models.UserToastV2Message): 155 | """另一个上舰消息""" 156 | 157 | def _on_super_chat(self, client: ws_base.WebSocketClientBase, message: web_models.SuperChatMessage): 158 | """醒目留言""" 159 | 160 | def _on_super_chat_delete(self, client: ws_base.WebSocketClientBase, message: web_models.SuperChatDeleteMessage): 161 | """删除醒目留言""" 162 | 163 | def _on_interact_word(self, client: ws_base.WebSocketClientBase, message: web_models.InteractWordMessage): 164 | """进入房间、关注主播等互动消息""" 165 | 166 | # 167 | # 开放平台消息 168 | # 169 | 170 | def _on_open_live_danmaku(self, client: ws_base.WebSocketClientBase, message: open_models.DanmakuMessage): 171 | """弹幕""" 172 | 173 | def _on_open_live_gift(self, client: ws_base.WebSocketClientBase, message: open_models.GiftMessage): 174 | """礼物""" 175 | 176 | def _on_open_live_buy_guard(self, client: ws_base.WebSocketClientBase, message: open_models.GuardBuyMessage): 177 | """上舰""" 178 | 179 | def _on_open_live_super_chat(self, client: ws_base.WebSocketClientBase, message: open_models.SuperChatMessage): 180 | """醒目留言""" 181 | 182 | def _on_open_live_super_chat_delete( 183 | self, client: ws_base.WebSocketClientBase, message: open_models.SuperChatDeleteMessage 184 | ): 185 | """删除醒目留言""" 186 | 187 | def _on_open_live_like(self, client: ws_base.WebSocketClientBase, message: open_models.LikeMessage): 188 | """点赞""" 189 | 190 | def _on_open_live_enter_room(self, client: ws_base.WebSocketClientBase, message: open_models.RoomEnterMessage): 191 | """进入房间""" 192 | 193 | def _on_open_live_start_live(self, client: ws_base.WebSocketClientBase, message: open_models.LiveStartMessage): 194 | """开始直播""" 195 | 196 | def _on_open_live_end_live(self, client: ws_base.WebSocketClientBase, message: open_models.LiveEndMessage): 197 | """结束直播""" 198 | -------------------------------------------------------------------------------- /blivedm/models/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | -------------------------------------------------------------------------------- /blivedm/models/open_live.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import dataclasses 3 | from typing import * 4 | 5 | __all__ = ( 6 | 'DanmakuMessage', 7 | 'GiftMessage', 8 | 'GuardBuyMessage', 9 | 'SuperChatMessage', 10 | 'SuperChatDeleteMessage', 11 | 'LikeMessage', 12 | ) 13 | 14 | # 注释都是复制自官方文档的,看不懂的话问B站 15 | # https://open-live.bilibili.com/document/f9ce25be-312e-1f4a-85fd-fef21f1637f8 16 | 17 | 18 | @dataclasses.dataclass 19 | class DanmakuMessage: 20 | """ 21 | 弹幕消息 22 | """ 23 | 24 | uname: str = '' 25 | """用户昵称""" 26 | open_id: str = '' 27 | """用户唯一标识""" 28 | uface: str = '' 29 | """用户头像""" 30 | timestamp: int = 0 31 | """弹幕发送时间秒级时间戳""" 32 | room_id: int = 0 33 | """弹幕接收的直播间""" 34 | msg: str = '' 35 | """弹幕内容""" 36 | msg_id: str = '' 37 | """消息唯一id""" 38 | guard_level: int = 0 39 | """对应房间大航海等级""" 40 | fans_medal_wearing_status: bool = False 41 | """该房间粉丝勋章佩戴情况""" 42 | fans_medal_name: str = '' 43 | """粉丝勋章名""" 44 | fans_medal_level: int = 0 45 | """对应房间勋章信息""" 46 | emoji_img_url: str = '' 47 | """表情包图片地址""" 48 | dm_type: int = 0 49 | """弹幕类型 0:普通弹幕 1:表情包弹幕""" 50 | glory_level: int = 0 51 | """直播荣耀等级""" 52 | reply_open_id: str = '' 53 | """被at用户唯一标识""" 54 | reply_uname: str = '' 55 | """被at的用户昵称""" 56 | is_admin: int = 0 57 | """发送弹幕的用户是否是房管,取值范围0或1,取值为1时是房管""" 58 | 59 | @classmethod 60 | def from_command(cls, data: dict): 61 | return cls( 62 | uname=data['uname'], 63 | open_id=data['open_id'], 64 | uface=data['uface'], 65 | timestamp=data['timestamp'], 66 | room_id=data['room_id'], 67 | msg=data['msg'], 68 | msg_id=data['msg_id'], 69 | guard_level=data['guard_level'], 70 | fans_medal_wearing_status=data['fans_medal_wearing_status'], 71 | fans_medal_name=data['fans_medal_name'], 72 | fans_medal_level=data['fans_medal_level'], 73 | emoji_img_url=data['emoji_img_url'], 74 | dm_type=data['dm_type'], 75 | glory_level=data['glory_level'], 76 | reply_open_id=data['reply_open_id'], 77 | reply_uname=data['reply_uname'], 78 | is_admin=data['is_admin'], 79 | ) 80 | 81 | 82 | @dataclasses.dataclass 83 | class AnchorInfo: 84 | """ 85 | 主播信息 86 | """ 87 | 88 | uid: int = 0 89 | """收礼主播uid""" 90 | open_id: str = '' 91 | """收礼主播唯一标识""" 92 | uname: str = '' 93 | """收礼主播昵称""" 94 | uface: str = '' 95 | """收礼主播头像""" 96 | 97 | @classmethod 98 | def from_dict(cls, data: dict): 99 | return cls( 100 | uid=data['uid'], 101 | open_id=data['open_id'], 102 | uname=data['uname'], 103 | uface=data['uface'], 104 | ) 105 | 106 | 107 | @dataclasses.dataclass 108 | class ComboInfo: 109 | """ 110 | 连击信息 111 | """ 112 | 113 | combo_base_num: int = 0 114 | """每次连击赠送的道具数量""" 115 | combo_count: int = 0 116 | """连击次数""" 117 | combo_id: str = '' 118 | """连击id""" 119 | combo_timeout: int = 0 120 | """连击有效期秒""" 121 | 122 | @classmethod 123 | def from_dict(cls, data: dict): 124 | return cls( 125 | combo_base_num=data['combo_base_num'], 126 | combo_count=data['combo_count'], 127 | combo_id=data['combo_id'], 128 | combo_timeout=data['combo_timeout'], 129 | ) 130 | 131 | 132 | @dataclasses.dataclass 133 | class GiftMessage: 134 | """ 135 | 礼物消息 136 | """ 137 | 138 | room_id: int = 0 139 | """房间号""" 140 | open_id: str = '' 141 | """用户唯一标识""" 142 | uname: str = '' 143 | """送礼用户昵称""" 144 | uface: str = '' 145 | """送礼用户头像""" 146 | gift_id: int = 0 147 | """道具id(盲盒:爆出道具id)""" 148 | gift_name: str = '' 149 | """道具名(盲盒:爆出道具名)""" 150 | gift_num: int = 0 151 | """赠送道具数量""" 152 | price: int = 0 153 | """ 154 | 礼物爆出单价,(1000 = 1元 = 10电池),盲盒:爆出道具的价值 155 | 156 | 注意: 157 | 158 | - 免费礼物这个字段也可能不是0,而是银瓜子数 159 | - 有些打折礼物这里不是实际支付的价值,实际价值应该用 `r_price` 160 | """ 161 | r_price: int = 0 162 | """ 163 | 实际价值(1000 = 1元 = 10电池),盲盒:爆出道具的价值 164 | 165 | 注意:免费礼物这个字段也可能不是0 166 | """ 167 | paid: bool = False 168 | """是否是付费道具""" 169 | fans_medal_level: int = 0 170 | """实际送礼人的勋章信息""" 171 | fans_medal_name: str = '' 172 | """粉丝勋章名""" 173 | fans_medal_wearing_status: bool = False 174 | """该房间粉丝勋章佩戴情况""" 175 | guard_level: int = 0 176 | """大航海等级""" 177 | timestamp: int = 0 178 | """收礼时间秒级时间戳""" 179 | anchor_info: AnchorInfo = dataclasses.field(default_factory=AnchorInfo) 180 | """主播信息""" 181 | msg_id: str = '' 182 | """消息唯一id""" 183 | gift_icon: str = '' 184 | """道具icon""" 185 | combo_gift: bool = False 186 | """是否是combo道具""" 187 | combo_info: ComboInfo = dataclasses.field(default_factory=ComboInfo) 188 | """连击信息""" 189 | 190 | @classmethod 191 | def from_command(cls, data: dict): 192 | combo_info = data.get('combo_info', None) 193 | if combo_info is None: 194 | combo_info = ComboInfo() 195 | else: 196 | combo_info = ComboInfo.from_dict(combo_info) 197 | 198 | return cls( 199 | room_id=data['room_id'], 200 | open_id=data['open_id'], 201 | uname=data['uname'], 202 | uface=data['uface'], 203 | gift_id=data['gift_id'], 204 | gift_name=data['gift_name'], 205 | gift_num=data['gift_num'], 206 | price=data['price'], 207 | r_price=data['r_price'], 208 | paid=data['paid'], 209 | fans_medal_level=data['fans_medal_level'], 210 | fans_medal_name=data['fans_medal_name'], 211 | fans_medal_wearing_status=data['fans_medal_wearing_status'], 212 | guard_level=data['guard_level'], 213 | timestamp=data['timestamp'], 214 | anchor_info=AnchorInfo.from_dict(data['anchor_info']), 215 | msg_id=data['msg_id'], 216 | gift_icon=data['gift_icon'], 217 | combo_gift=data.get('combo_gift', False), # 官方的调试工具没发这个字段 218 | combo_info=combo_info, # 官方的调试工具没发这个字段 219 | ) 220 | 221 | 222 | @dataclasses.dataclass 223 | class UserInfo: 224 | """ 225 | 用户信息 226 | """ 227 | 228 | open_id: str = '' 229 | """用户唯一标识""" 230 | uname: str = '' 231 | """用户昵称""" 232 | uface: str = '' 233 | """用户头像""" 234 | 235 | @classmethod 236 | def from_dict(cls, data: dict): 237 | return cls( 238 | open_id=data['open_id'], 239 | uname=data['uname'], 240 | uface=data['uface'], 241 | ) 242 | 243 | 244 | @dataclasses.dataclass 245 | class GuardBuyMessage: 246 | """ 247 | 上舰消息 248 | """ 249 | 250 | user_info: UserInfo = dataclasses.field(default_factory=UserInfo) 251 | """用户信息""" 252 | guard_level: int = 0 253 | """大航海等级""" 254 | guard_num: int = 0 255 | """大航海数量""" 256 | guard_unit: str = '' 257 | """大航海单位(正常单位为“月”,如为其他内容,无视`guard_num`以本字段内容为准,例如`*3天`)""" 258 | price: int = 0 259 | """大航海金瓜子""" 260 | fans_medal_level: int = 0 261 | """粉丝勋章等级""" 262 | fans_medal_name: str = '' 263 | """粉丝勋章名""" 264 | fans_medal_wearing_status: bool = False 265 | """该房间粉丝勋章佩戴情况""" 266 | room_id: int = 0 267 | """房间号""" 268 | msg_id: str = '' 269 | """消息唯一id""" 270 | timestamp: int = 0 271 | """上舰时间秒级时间戳""" 272 | 273 | @classmethod 274 | def from_command(cls, data: dict): 275 | return cls( 276 | user_info=UserInfo.from_dict(data['user_info']), 277 | guard_level=data['guard_level'], 278 | guard_num=data['guard_num'], 279 | guard_unit=data['guard_unit'], 280 | price=data['price'], 281 | fans_medal_level=data['fans_medal_level'], 282 | fans_medal_name=data['fans_medal_name'], 283 | fans_medal_wearing_status=data['fans_medal_wearing_status'], 284 | room_id=data['room_id'], 285 | msg_id=data['msg_id'], 286 | timestamp=data['timestamp'], 287 | ) 288 | 289 | 290 | @dataclasses.dataclass 291 | class SuperChatMessage: 292 | """ 293 | 醒目留言消息 294 | """ 295 | 296 | room_id: int = 0 297 | """直播间id""" 298 | open_id: str = '' 299 | """用户唯一标识""" 300 | uname: str = '' 301 | """购买的用户昵称""" 302 | uface: str = '' 303 | """购买用户头像""" 304 | message_id: int = 0 305 | """留言id(风控场景下撤回留言需要)""" 306 | message: str = '' 307 | """留言内容""" 308 | rmb: int = 0 309 | """支付金额(元)""" 310 | timestamp: int = 0 311 | """赠送时间秒级""" 312 | start_time: int = 0 313 | """生效开始时间""" 314 | end_time: int = 0 315 | """生效结束时间""" 316 | guard_level: int = 0 317 | """对应房间大航海等级""" 318 | fans_medal_level: int = 0 319 | """对应房间勋章信息""" 320 | fans_medal_name: str = '' 321 | """对应房间勋章名字""" 322 | fans_medal_wearing_status: bool = False 323 | """该房间粉丝勋章佩戴情况""" 324 | msg_id: str = '' 325 | """消息唯一id""" 326 | 327 | @classmethod 328 | def from_command(cls, data: dict): 329 | return cls( 330 | room_id=data['room_id'], 331 | open_id=data['open_id'], 332 | uname=data['uname'], 333 | uface=data['uface'], 334 | message_id=data['message_id'], 335 | message=data['message'], 336 | rmb=data['rmb'], 337 | timestamp=data['timestamp'], 338 | start_time=data['start_time'], 339 | end_time=data['end_time'], 340 | guard_level=data['guard_level'], 341 | fans_medal_level=data['fans_medal_level'], 342 | fans_medal_name=data['fans_medal_name'], 343 | fans_medal_wearing_status=data['fans_medal_wearing_status'], 344 | msg_id=data['msg_id'], 345 | ) 346 | 347 | 348 | @dataclasses.dataclass 349 | class SuperChatDeleteMessage: 350 | """ 351 | 删除醒目留言消息 352 | """ 353 | 354 | room_id: int = 0 355 | """直播间id""" 356 | message_ids: List[int] = dataclasses.field(default_factory=list) 357 | """留言id""" 358 | msg_id: str = '' 359 | """消息唯一id""" 360 | 361 | @classmethod 362 | def from_command(cls, data: dict): 363 | return cls( 364 | room_id=data['room_id'], 365 | message_ids=data['message_ids'], 366 | msg_id=data['msg_id'], 367 | ) 368 | 369 | 370 | @dataclasses.dataclass 371 | class LikeMessage: 372 | """ 373 | 点赞消息 374 | 375 | 请注意: 376 | 377 | - 只有房间处于开播中,才会触发点赞事件 378 | - 对单一用户最近2秒聚合发送一次点赞次数 379 | """ 380 | 381 | uname: str = '' 382 | """用户昵称""" 383 | open_id: str = '' 384 | """用户唯一标识""" 385 | uface: str = '' 386 | """用户头像""" 387 | timestamp: int = 0 388 | """时间秒级时间戳""" 389 | room_id: int = 0 390 | """发生的直播间""" 391 | like_text: str = '' 392 | """点赞文案(“xxx点赞了”)""" 393 | like_count: int = 0 394 | """对单个用户最近2秒的点赞次数聚合""" 395 | fans_medal_wearing_status: bool = False 396 | """该房间粉丝勋章佩戴情况""" 397 | fans_medal_name: str = '' 398 | """粉丝勋章名""" 399 | fans_medal_level: int = 0 400 | """对应房间勋章信息""" 401 | msg_id: str = '' # 官方文档表格里没列出这个字段,但是参考JSON里面有 402 | """消息唯一id""" 403 | # 还有个guard_level,但官方文档没有出现这个字段,就不添加了 404 | 405 | @classmethod 406 | def from_command(cls, data: dict): 407 | return cls( 408 | uname=data['uname'], 409 | open_id=data['open_id'], 410 | uface=data['uface'], 411 | timestamp=data['timestamp'], 412 | room_id=data['room_id'], 413 | like_text=data['like_text'], 414 | like_count=data['like_count'], 415 | fans_medal_wearing_status=data['fans_medal_wearing_status'], 416 | fans_medal_name=data['fans_medal_name'], 417 | fans_medal_level=data['fans_medal_level'], 418 | msg_id=data.get('msg_id', ''), # 官方文档表格里没列出这个字段,但是参考JSON里面有 419 | ) 420 | 421 | 422 | @dataclasses.dataclass 423 | class RoomEnterMessage: 424 | """ 425 | 进入房间消息 426 | """ 427 | 428 | room_id: int = 0 429 | """直播间id""" 430 | uface: str = '' 431 | """用户头像""" 432 | uname: str = '' 433 | """用户昵称""" 434 | open_id: str = '' 435 | """用户唯一标识""" 436 | timestamp: int = 0 437 | """发生的时间戳""" 438 | msg_id: str = '' # 官方文档表格里没列出这个字段,但是实际上有 439 | """消息唯一id""" 440 | 441 | @classmethod 442 | def from_command(cls, data: dict): 443 | return cls( 444 | room_id=data['room_id'], 445 | uface=data['uface'], 446 | uname=data['uname'], 447 | open_id=data['open_id'], 448 | timestamp=data['timestamp'], 449 | msg_id=data.get('msg_id', ''), # 官方文档表格里没列出这个字段,但是实际上有 450 | ) 451 | 452 | 453 | @dataclasses.dataclass 454 | class LiveStartMessage: 455 | """ 456 | 开始直播消息 457 | """ 458 | 459 | room_id: int = 0 460 | """直播间id""" 461 | open_id: str = '' 462 | """用户唯一标识""" 463 | timestamp: int = 0 464 | """发生的时间戳""" 465 | area_name: str = '' 466 | """开播二级分区名""" 467 | title: str = '' 468 | """开播时刻,直播间的标题""" 469 | msg_id: str = '' # 官方文档表格里没列出这个字段,但是实际上有 470 | """消息唯一id""" 471 | 472 | @classmethod 473 | def from_command(cls, data: dict): 474 | return cls( 475 | room_id=data['room_id'], 476 | open_id=data['open_id'], 477 | timestamp=data['timestamp'], 478 | area_name=data['area_name'], 479 | title=data['title'], 480 | msg_id=data.get('msg_id', ''), # 官方文档表格里没列出这个字段,但是实际上有 481 | ) 482 | 483 | 484 | @dataclasses.dataclass 485 | class LiveEndMessage: 486 | """ 487 | 结束直播消息 488 | """ 489 | 490 | room_id: int = 0 491 | """直播间id""" 492 | open_id: str = '' 493 | """用户唯一标识""" 494 | timestamp: int = 0 495 | """发生的时间戳""" 496 | area_name: str = '' 497 | """开播二级分区名""" 498 | title: str = '' 499 | """开播时刻,直播间的标题""" 500 | msg_id: str = '' # 官方文档表格里没列出这个字段,但是实际上有 501 | """消息唯一id""" 502 | 503 | @classmethod 504 | def from_command(cls, data: dict): 505 | return cls( 506 | room_id=data['room_id'], 507 | open_id=data['open_id'], 508 | timestamp=data['timestamp'], 509 | area_name=data['area_name'], 510 | title=data['title'], 511 | msg_id=data.get('msg_id', ''), # 官方文档表格里没列出这个字段,但是实际上有 512 | ) 513 | -------------------------------------------------------------------------------- /blivedm/models/web.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import dataclasses 3 | import json 4 | from typing import * 5 | 6 | __all__ = ( 7 | 'HeartbeatMessage', 8 | 'DanmakuMessage', 9 | 'GiftMessage', 10 | 'GuardBuyMessage', 11 | 'SuperChatMessage', 12 | 'SuperChatDeleteMessage', 13 | ) 14 | 15 | 16 | @dataclasses.dataclass 17 | class HeartbeatMessage: 18 | """ 19 | 心跳消息 20 | """ 21 | 22 | popularity: int = 0 23 | """人气值,已废弃""" 24 | 25 | @classmethod 26 | def from_command(cls, data: dict): 27 | return cls( 28 | popularity=data['popularity'], 29 | ) 30 | 31 | 32 | @dataclasses.dataclass 33 | class DanmakuMessage: 34 | """ 35 | 弹幕消息 36 | """ 37 | 38 | mode: int = 0 39 | """弹幕显示模式(滚动、顶部、底部)""" 40 | font_size: int = 0 41 | """字体尺寸""" 42 | color: int = 0 43 | """颜色""" 44 | timestamp: int = 0 45 | """时间戳(毫秒)""" 46 | rnd: int = 0 47 | """随机数,前端叫作弹幕ID,可能是去重用的""" 48 | uid_crc32: str = '' 49 | """用户ID文本的CRC32""" 50 | msg_type: int = 0 51 | """是否礼物弹幕(节奏风暴)""" 52 | bubble: int = 0 53 | """右侧评论栏气泡""" 54 | dm_type: int = 0 55 | """弹幕类型,0文本,1表情,2语音""" 56 | emoticon_options: Union[dict, str] = '' 57 | """表情参数""" 58 | voice_config: Union[dict, str] = '' 59 | """语音参数""" 60 | mode_info: dict = dataclasses.field(default_factory=dict) 61 | """一些附加参数""" 62 | 63 | msg: str = '' 64 | """弹幕内容""" 65 | 66 | uid: int = 0 67 | """用户ID""" 68 | uname: str = '' 69 | """用户名""" 70 | face: str = '' 71 | """用户头像URL""" 72 | admin: int = 0 73 | """是否房管""" 74 | vip: int = 0 75 | """是否月费老爷""" 76 | svip: int = 0 77 | """是否年费老爷""" 78 | urank: int = 0 79 | """用户身份,用来判断是否正式会员,猜测非正式会员为5000,正式会员为10000""" 80 | mobile_verify: int = 0 81 | """是否绑定手机""" 82 | uname_color: str = '' 83 | """用户名颜色""" 84 | 85 | medal_level: int = 0 86 | """勋章等级""" 87 | medal_name: str = '' 88 | """勋章名""" 89 | runame: str = '' 90 | """勋章房间主播名""" 91 | medal_room_id: int = 0 92 | """勋章房间ID""" 93 | mcolor: int = 0 94 | """勋章颜色""" 95 | special_medal: str = '' 96 | """特殊勋章""" 97 | 98 | user_level: int = 0 99 | """用户等级""" 100 | ulevel_color: int = 0 101 | """用户等级颜色""" 102 | ulevel_rank: str = '' 103 | """用户等级排名,>50000时为'>50000'""" 104 | 105 | old_title: str = '' 106 | """旧头衔""" 107 | title: str = '' 108 | """头衔""" 109 | 110 | privilege_type: int = 0 111 | """舰队类型,0非舰队,1总督,2提督,3舰长""" 112 | 113 | wealth_level: int = 0 114 | """荣耀等级""" 115 | 116 | @classmethod 117 | def from_command(cls, info: list): 118 | mode_info = info[0][15] 119 | try: 120 | face = mode_info['user']['base']['face'] 121 | except (TypeError, KeyError): 122 | face = '' 123 | 124 | if len(info[3]) != 0: 125 | medal_level = info[3][0] 126 | medal_name = info[3][1] 127 | runame = info[3][2] 128 | medal_room_id = info[3][3] 129 | mcolor = info[3][4] 130 | special_medal = info[3][5] 131 | else: 132 | medal_level = 0 133 | medal_name = '' 134 | runame = '' 135 | medal_room_id = 0 136 | mcolor = 0 137 | special_medal = 0 138 | 139 | if len(info[5]) != 0: 140 | old_title = info[5][0] 141 | title = info[5][1] 142 | else: 143 | old_title = '' 144 | title = '' 145 | 146 | return cls( 147 | mode=info[0][1], 148 | font_size=info[0][2], 149 | color=info[0][3], 150 | timestamp=info[0][4], 151 | rnd=info[0][5], 152 | uid_crc32=info[0][7], 153 | msg_type=info[0][9], 154 | bubble=info[0][10], 155 | dm_type=info[0][12], 156 | emoticon_options=info[0][13], 157 | voice_config=info[0][14], 158 | mode_info=mode_info, 159 | 160 | msg=info[1], 161 | 162 | uid=info[2][0], 163 | uname=info[2][1], 164 | face=face, 165 | admin=info[2][2], 166 | vip=info[2][3], 167 | svip=info[2][4], 168 | urank=info[2][5], 169 | mobile_verify=info[2][6], 170 | uname_color=info[2][7], 171 | 172 | medal_level=medal_level, 173 | medal_name=medal_name, 174 | runame=runame, 175 | medal_room_id=medal_room_id, 176 | mcolor=mcolor, 177 | special_medal=special_medal, 178 | 179 | user_level=info[4][0], 180 | ulevel_color=info[4][2], 181 | ulevel_rank=info[4][3], 182 | 183 | old_title=old_title, 184 | title=title, 185 | 186 | privilege_type=info[7], 187 | 188 | wealth_level=info[16][0], 189 | ) 190 | 191 | @property 192 | def emoticon_options_dict(self) -> dict: 193 | """ 194 | 示例: 195 | 196 | ``` 197 | {'bulge_display': 0, 'emoticon_unique': 'official_13', 'height': 60, 'in_player_area': 1, 'is_dynamic': 1, 198 | 'url': 'https://i0.hdslb.com/bfs/live/a98e35996545509188fe4d24bd1a56518ea5af48.png', 'width': 183} 199 | ``` 200 | """ 201 | if isinstance(self.emoticon_options, dict): 202 | return self.emoticon_options 203 | try: 204 | return json.loads(self.emoticon_options) 205 | except (json.JSONDecodeError, TypeError): 206 | return {} 207 | 208 | @property 209 | def voice_config_dict(self) -> dict: 210 | """ 211 | 示例: 212 | 213 | ``` 214 | {'voice_url': 'https%3A%2F%2Fboss.hdslb.com%2Flive-dm-voice%2Fb5b26e48b556915cbf3312a59d3bb2561627725945.wav 215 | %3FX-Amz-Algorithm%3DAWS4-HMAC-SHA256%26X-Amz-Credential%3D2663ba902868f12f%252F20210731%252Fshjd%252Fs3%25 216 | 2Faws4_request%26X-Amz-Date%3D20210731T100545Z%26X-Amz-Expires%3D600000%26X-Amz-SignedHeaders%3Dhost%26 217 | X-Amz-Signature%3D114e7cb5ac91c72e231c26d8ca211e53914722f36309b861a6409ffb20f07ab8', 218 | 'file_format': 'wav', 'text': '汤,下午好。', 'file_duration': 1} 219 | ``` 220 | """ 221 | if isinstance(self.voice_config, dict): 222 | return self.voice_config 223 | try: 224 | return json.loads(self.voice_config) 225 | except (json.JSONDecodeError, TypeError): 226 | return {} 227 | 228 | @property 229 | def extra_dict(self) -> dict: 230 | """ 231 | 示例: 232 | 233 | ``` 234 | {'send_from_me': False, 'mode': 0, 'color': 14893055, 'dm_type': 0, 'font_size': 25, 'player_mode': 4, 235 | 'show_player_type': 0, 'content': '确实', 'user_hash': '2904574201', 'emoticon_unique': '', 'bulge_display': 0, 236 | 'recommend_score': 5, 'main_state_dm_color': '', 'objective_state_dm_color': '', 'direction': 0, 237 | 'pk_direction': 0, 'quartet_direction': 0, 'anniversary_crowd': 0, 'yeah_space_type': '', 'yeah_space_url': '', 238 | 'jump_to_url': '', 'space_type': '', 'space_url': '', 'animation': {}, 'emots': None, 'is_audited': False, 239 | 'id_str': '6fa9959ab8feabcd1b337aa5066768334027', 'icon': None, 'show_reply': True, 'reply_mid': 0, 240 | 'reply_uname': '', 'reply_uname_color': '', 'reply_is_mystery': False, 'reply_type_enum': 0, 'hit_combo': 0, 241 | 'esports_jump_url': ''} 242 | ``` 243 | """ 244 | try: 245 | extra = self.mode_info['extra'] 246 | if isinstance(extra, dict): 247 | return extra 248 | return json.loads(extra) 249 | except (KeyError, json.JSONDecodeError, TypeError): 250 | return {} 251 | 252 | 253 | @dataclasses.dataclass 254 | class GiftMessage: 255 | """ 256 | 礼物消息 257 | """ 258 | 259 | gift_name: str = '' 260 | """礼物名""" 261 | num: int = 0 262 | """数量""" 263 | uname: str = '' 264 | """用户名""" 265 | face: str = '' 266 | """用户头像URL""" 267 | guard_level: int = 0 268 | """舰队等级,0非舰队,1总督,2提督,3舰长""" 269 | uid: int = 0 270 | """用户ID""" 271 | timestamp: int = 0 272 | """时间戳""" 273 | gift_id: int = 0 274 | """礼物ID""" 275 | gift_type: int = 0 276 | """礼物类型(未知)""" 277 | gift_img_basic: str = '' 278 | """图标URL""" 279 | action: str = '' 280 | """目前遇到的有'喂食'、'赠送'""" 281 | price: int = 0 282 | """礼物单价瓜子数""" 283 | rnd: str = '' 284 | """随机数,可能是去重用的。有时是时间戳+去重ID,有时是UUID""" 285 | coin_type: str = '' 286 | """瓜子类型,'silver'或'gold',1000金瓜子 = 1元""" 287 | total_coin: int = 0 288 | """总瓜子数""" 289 | tid: str = '' 290 | """可能是事务ID,有时和rnd相同""" 291 | medal_level: int = 0 292 | """勋章等级""" 293 | medal_name: str = '' 294 | """勋章名""" 295 | medal_room_id: int = 0 296 | """勋章房间ID,未登录时是0""" 297 | medal_ruid: int = 0 298 | """勋章主播ID""" 299 | 300 | @classmethod 301 | def from_command(cls, data: dict): 302 | medal_info = data.get('medal_info', None) 303 | if medal_info is not None: 304 | medal_level = medal_info['medal_level'] 305 | medal_name = medal_info['medal_name'] 306 | medal_room_id = medal_info['anchor_roomid'] 307 | medal_ruid = medal_info['target_id'] 308 | else: 309 | medal_level = 0 310 | medal_name = '' 311 | medal_room_id = 0 312 | medal_ruid = 0 313 | 314 | return cls( 315 | gift_name=data['giftName'], 316 | num=data['num'], 317 | uname=data['uname'], 318 | face=data['face'], 319 | guard_level=data['guard_level'], 320 | uid=data['uid'], 321 | timestamp=data['timestamp'], 322 | gift_id=data['giftId'], 323 | gift_type=data['giftType'], 324 | gift_img_basic=data['gift_info']['img_basic'], 325 | action=data['action'], 326 | price=data['price'], 327 | rnd=data['rnd'], 328 | coin_type=data['coin_type'], 329 | total_coin=data['total_coin'], 330 | tid=data['tid'], 331 | medal_level=medal_level, 332 | medal_name=medal_name, 333 | medal_room_id=medal_room_id, 334 | medal_ruid=medal_ruid, 335 | ) 336 | 337 | 338 | @dataclasses.dataclass 339 | class GuardBuyMessage: 340 | """ 341 | 上舰消息 342 | """ 343 | 344 | uid: int = 0 345 | """用户ID""" 346 | username: str = '' 347 | """用户名""" 348 | guard_level: int = 0 349 | """舰队等级,0非舰队,1总督,2提督,3舰长""" 350 | num: int = 0 # 可以理解为礼物数量? 351 | """数量""" 352 | price: int = 0 353 | """单价金瓜子数""" 354 | gift_id: int = 0 355 | """礼物ID""" 356 | gift_name: str = '' 357 | """礼物名""" 358 | start_time: int = 0 359 | """开始时间戳,和结束时间戳相同""" 360 | end_time: int = 0 361 | """结束时间戳,和开始时间戳相同""" 362 | 363 | @classmethod 364 | def from_command(cls, data: dict): 365 | return cls( 366 | uid=data['uid'], 367 | username=data['username'], 368 | guard_level=data['guard_level'], 369 | num=data['num'], 370 | price=data['price'], 371 | gift_id=data['gift_id'], 372 | gift_name=data['gift_name'], 373 | start_time=data['start_time'], 374 | end_time=data['end_time'], 375 | ) 376 | 377 | 378 | @dataclasses.dataclass 379 | class UserToastV2Message: 380 | """ 381 | 另一个上舰消息,包含的数据更多 382 | """ 383 | 384 | uid: int = 0 385 | """用户ID""" 386 | username: str = '' 387 | """用户名""" 388 | guard_level: int = 0 389 | """舰队等级,0非舰队,1总督,2提督,3舰长""" 390 | num: int = 0 # 可以理解为礼物数量? 391 | """数量""" 392 | price: int = 0 393 | """单价金瓜子数""" 394 | unit: str = '' 395 | """单位,根据开放平台的文档,正常单位为“月”,如为其他内容,无视`guard_num`以本字段内容为准,例如`*3天`""" 396 | gift_id: int = 0 397 | """礼物ID""" 398 | start_time: int = 0 399 | """开始时间戳,和结束时间戳相同""" 400 | end_time: int = 0 401 | """结束时间戳,和开始时间戳相同""" 402 | source: int = 0 403 | """猜测0是自己买的,2是别人送的,这个只影响是否播动画""" 404 | toast_msg: str = '' 405 | """提示信息("<%XXX%> 在主播XXX的直播间续费了舰长,今天是TA陪伴主播的第XXX天")""" 406 | 407 | @classmethod 408 | def from_command(cls, data: dict): 409 | sender_info = data['sender_uinfo'] 410 | guard_info = data['guard_info'] 411 | pay_info = data['pay_info'] 412 | gift_info = data['gift_info'] 413 | option = data['option'] 414 | return cls( 415 | uid=sender_info['uid'], 416 | username=sender_info['base']['name'], 417 | guard_level=guard_info['guard_level'], 418 | num=pay_info['num'], 419 | price=pay_info['price'], 420 | unit=pay_info['unit'], 421 | gift_id=gift_info['gift_id'], 422 | start_time=guard_info['start_time'], 423 | end_time=guard_info['end_time'], 424 | source=option['source'], 425 | toast_msg=data['toast_msg'], 426 | ) 427 | 428 | 429 | @dataclasses.dataclass 430 | class SuperChatMessage: 431 | """ 432 | 醒目留言消息 433 | """ 434 | 435 | price: int = 0 436 | """价格(人民币)""" 437 | message: str = '' 438 | """消息""" 439 | message_trans: str = '' 440 | """消息日文翻译""" 441 | start_time: int = 0 442 | """开始时间戳""" 443 | end_time: int = 0 444 | """结束时间戳""" 445 | time: int = 0 446 | """剩余时间(约等于 结束时间戳 - 开始时间戳)""" 447 | id: int = 0 448 | """醒目留言ID,删除时用""" 449 | gift_id: int = 0 450 | """礼物ID""" 451 | gift_name: str = '' 452 | """礼物名""" 453 | uid: int = 0 454 | """用户ID""" 455 | uname: str = '' 456 | """用户名""" 457 | face: str = '' 458 | """用户头像URL""" 459 | guard_level: int = 0 460 | """舰队等级,0非舰队,1总督,2提督,3舰长""" 461 | user_level: int = 0 462 | """用户等级""" 463 | background_bottom_color: str = '' 464 | """底部背景色,'#rrggbb'""" 465 | background_color: str = '' 466 | """背景色,'#rrggbb'""" 467 | background_icon: str = '' 468 | """背景图标""" 469 | background_image: str = '' 470 | """背景图URL""" 471 | background_price_color: str = '' 472 | """背景价格颜色,'#rrggbb'""" 473 | medal_level: int = 0 474 | """勋章等级""" 475 | medal_name: str = '' 476 | """勋章名""" 477 | medal_room_id: int = 0 478 | """勋章房间ID""" 479 | medal_ruid: int = 0 480 | """勋章主播ID""" 481 | 482 | @classmethod 483 | def from_command(cls, data: dict): 484 | medal_info = data.get('medal_info', None) 485 | if medal_info is not None: 486 | medal_level = medal_info['medal_level'] 487 | medal_name = medal_info['medal_name'] 488 | medal_room_id = medal_info['anchor_roomid'] 489 | medal_ruid = medal_info['target_id'] 490 | else: 491 | medal_level = 0 492 | medal_name = '' 493 | medal_room_id = 0 494 | medal_ruid = 0 495 | 496 | return cls( 497 | price=data['price'], 498 | message=data['message'], 499 | message_trans=data['message_trans'], 500 | start_time=data['start_time'], 501 | end_time=data['end_time'], 502 | time=data['time'], 503 | id=data['id'], 504 | gift_id=data['gift']['gift_id'], 505 | gift_name=data['gift']['gift_name'], 506 | uid=data['uid'], 507 | uname=data['user_info']['uname'], 508 | face=data['user_info']['face'], 509 | guard_level=data['user_info']['guard_level'], 510 | user_level=data['user_info']['user_level'], 511 | background_bottom_color=data['background_bottom_color'], 512 | background_color=data['background_color'], 513 | background_icon=data['background_icon'], 514 | background_image=data['background_image'], 515 | background_price_color=data['background_price_color'], 516 | medal_level=medal_level, 517 | medal_name=medal_name, 518 | medal_room_id=medal_room_id, 519 | medal_ruid=medal_ruid, 520 | ) 521 | 522 | 523 | @dataclasses.dataclass 524 | class SuperChatDeleteMessage: 525 | """ 526 | 删除醒目留言消息 527 | """ 528 | 529 | ids: List[int] = dataclasses.field(default_factory=list) 530 | """醒目留言ID数组""" 531 | 532 | @classmethod 533 | def from_command(cls, data: dict): 534 | return cls( 535 | ids=data['ids'], 536 | ) 537 | 538 | 539 | @dataclasses.dataclass 540 | class InteractWordMessage: 541 | """ 542 | 进入房间、关注主播等互动消息 543 | """ 544 | 545 | uid: int = 0 546 | """用户ID""" 547 | username: str = '' 548 | """用户名""" 549 | face: str = '' 550 | """用户头像URL""" 551 | timestamp: int = 0 552 | """时间戳""" 553 | msg_type: int = 0 554 | """`{1: '进入', 2: '关注了', 3: '分享了', 4: '特别关注了', 5: '互粉了', 6: '为主播点赞了'}`""" 555 | 556 | @classmethod 557 | def from_command(cls, data: dict): 558 | user_info = data['uinfo'] 559 | user_base_info = user_info['base'] 560 | return cls( 561 | uid=user_info['uid'], 562 | username=user_base_info['name'], 563 | face=user_base_info['face'], 564 | timestamp=data['timestamp'], 565 | msg_type=data['msg_type'], 566 | ) 567 | -------------------------------------------------------------------------------- /blivedm/utils.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | USER_AGENT = ( 3 | 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/102.0.0.0 Safari/537.36' 4 | ) 5 | 6 | 7 | def make_constant_retry_policy(interval: float): 8 | def get_interval(_retry_count: int, _total_retry_count: int): 9 | return interval 10 | return get_interval 11 | 12 | 13 | def make_linear_retry_policy(start_interval: float, interval_step: float, max_interval: float): 14 | def get_interval(retry_count: int, _total_retry_count: int): 15 | return min( 16 | start_interval + (retry_count - 1) * interval_step, 17 | max_interval 18 | ) 19 | return get_interval 20 | -------------------------------------------------------------------------------- /open_live_sample.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | 4 | import blivedm 5 | import blivedm.models.open_live as open_models 6 | import blivedm.models.web as web_models 7 | 8 | # 在开放平台申请的开发者密钥 9 | ACCESS_KEY_ID = '' 10 | ACCESS_KEY_SECRET = '' 11 | # 在开放平台创建的项目ID 12 | APP_ID = 0 13 | # 主播身份码 14 | ROOM_OWNER_AUTH_CODE = '' 15 | 16 | 17 | async def main(): 18 | await run_single_client() 19 | 20 | 21 | async def run_single_client(): 22 | """ 23 | 演示监听一个直播间 24 | """ 25 | client = blivedm.OpenLiveClient( 26 | access_key_id=ACCESS_KEY_ID, 27 | access_key_secret=ACCESS_KEY_SECRET, 28 | app_id=APP_ID, 29 | room_owner_auth_code=ROOM_OWNER_AUTH_CODE, 30 | ) 31 | handler = MyHandler() 32 | client.set_handler(handler) 33 | 34 | client.start() 35 | try: 36 | # 演示70秒后停止 37 | # await asyncio.sleep(70) 38 | # client.stop() 39 | 40 | await client.join() 41 | finally: 42 | await client.stop_and_close() 43 | 44 | 45 | class MyHandler(blivedm.BaseHandler): 46 | def _on_heartbeat(self, client: blivedm.BLiveClient, message: web_models.HeartbeatMessage): 47 | print(f'[{client.room_id}] 心跳') 48 | 49 | def _on_open_live_danmaku(self, client: blivedm.OpenLiveClient, message: open_models.DanmakuMessage): 50 | print(f'[{message.room_id}] {message.uname}:{message.msg}') 51 | 52 | def _on_open_live_gift(self, client: blivedm.OpenLiveClient, message: open_models.GiftMessage): 53 | coin_type = '金瓜子' if message.paid else '银瓜子' 54 | total_coin = message.price * message.gift_num 55 | print(f'[{message.room_id}] {message.uname} 赠送{message.gift_name}x{message.gift_num}' 56 | f' ({coin_type}x{total_coin})') 57 | 58 | def _on_open_live_buy_guard(self, client: blivedm.OpenLiveClient, message: open_models.GuardBuyMessage): 59 | print(f'[{message.room_id}] {message.user_info.uname} 购买 大航海等级={message.guard_level}') 60 | 61 | def _on_open_live_super_chat( 62 | self, client: blivedm.OpenLiveClient, message: open_models.SuperChatMessage 63 | ): 64 | print(f'[{message.room_id}] 醒目留言 ¥{message.rmb} {message.uname}:{message.message}') 65 | 66 | def _on_open_live_super_chat_delete( 67 | self, client: blivedm.OpenLiveClient, message: open_models.SuperChatDeleteMessage 68 | ): 69 | print(f'[{message.room_id}] 删除醒目留言 message_ids={message.message_ids}') 70 | 71 | def _on_open_live_like(self, client: blivedm.OpenLiveClient, message: open_models.LikeMessage): 72 | print(f'[{message.room_id}] {message.uname} 点赞') 73 | 74 | def _on_open_live_enter_room(self, client: blivedm.OpenLiveClient, message: open_models.RoomEnterMessage): 75 | print(f'[{message.room_id}] {message.uname} 进入房间') 76 | 77 | def _on_open_live_start_live(self, client: blivedm.OpenLiveClient, message: open_models.LiveStartMessage): 78 | print(f'[{message.room_id}] 开始直播') 79 | 80 | def _on_open_live_end_live(self, client: blivedm.OpenLiveClient, message: open_models.LiveEndMessage): 81 | print(f'[{message.room_id}] 结束直播') 82 | 83 | 84 | if __name__ == '__main__': 85 | asyncio.run(main()) 86 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["pdm-backend"] 3 | build-backend = "pdm.backend" 4 | 5 | [project] 6 | name = "blivedm" 7 | dynamic = ["version"] 8 | description = "Python获取bilibili直播弹幕的库,使用WebSocket协议" 9 | readme = "README.md" 10 | keywords = ["bilibili", "bilibili-live", "danmaku"] 11 | requires-python = ">=3.8" 12 | authors = [ 13 | {name = "xfgryujk", email = "xfgryujk@126.com"}, 14 | ] 15 | license = {file = "LICENSE"} 16 | classifiers = [ 17 | "Development Status :: 5 - Production/Stable", 18 | "Framework :: AsyncIO", 19 | "Intended Audience :: Developers", 20 | "License :: OSI Approved :: MIT License", 21 | "Operating System :: OS Independent", 22 | "Programming Language :: Python :: 3", 23 | "Programming Language :: Python :: 3 :: Only", 24 | "Programming Language :: Python :: 3.8", 25 | "Programming Language :: Python :: 3.9", 26 | "Programming Language :: Python :: 3.10", 27 | "Programming Language :: Python :: 3.11", 28 | "Programming Language :: Python :: 3.12", 29 | "Programming Language :: Python :: 3.13", 30 | "Topic :: Internet :: WWW/HTTP", 31 | "Topic :: Software Development :: Libraries", 32 | ] 33 | dependencies = [ 34 | "aiohttp~=3.9.0", 35 | "Brotli~=1.1.0", 36 | "yarl~=1.9.3", 37 | ] 38 | 39 | [project.urls] 40 | Homepage = "https://github.com/xfgryujk/blivedm" 41 | Repository = "https://github.com/xfgryujk/blivedm" 42 | Issues = "https://github.com/xfgryujk/blivedm/issues" 43 | 44 | [tool.pdm] 45 | version = {source = "file", path = "blivedm/__init__.py"} 46 | distribution = true 47 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp~=3.9.0 2 | Brotli~=1.1.0 3 | yarl~=1.9.3 4 | -------------------------------------------------------------------------------- /sample.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | import asyncio 3 | import http.cookies 4 | import random 5 | from typing import * 6 | 7 | import aiohttp 8 | 9 | import blivedm 10 | import blivedm.models.web as web_models 11 | 12 | # 直播间ID的取值看直播间URL 13 | TEST_ROOM_IDS = [ 14 | 12235923, 15 | 14327465, 16 | 21396545, 17 | 21449083, 18 | 23105590, 19 | ] 20 | 21 | # 这里填一个已登录账号的cookie的SESSDATA字段的值。不填也可以连接,但是收到弹幕的用户名会打码,UID会变成0 22 | SESSDATA = '' 23 | 24 | session: Optional[aiohttp.ClientSession] = None 25 | 26 | 27 | async def main(): 28 | init_session() 29 | try: 30 | await run_single_client() 31 | await run_multi_clients() 32 | finally: 33 | await session.close() 34 | 35 | 36 | def init_session(): 37 | cookies = http.cookies.SimpleCookie() 38 | cookies['SESSDATA'] = SESSDATA 39 | cookies['SESSDATA']['domain'] = 'bilibili.com' 40 | 41 | global session 42 | session = aiohttp.ClientSession() 43 | session.cookie_jar.update_cookies(cookies) 44 | 45 | 46 | async def run_single_client(): 47 | """ 48 | 演示监听一个直播间 49 | """ 50 | room_id = random.choice(TEST_ROOM_IDS) 51 | client = blivedm.BLiveClient(room_id, session=session) 52 | handler = MyHandler() 53 | client.set_handler(handler) 54 | 55 | client.start() 56 | try: 57 | # 演示5秒后停止 58 | await asyncio.sleep(5) 59 | client.stop() 60 | 61 | await client.join() 62 | finally: 63 | await client.stop_and_close() 64 | 65 | 66 | async def run_multi_clients(): 67 | """ 68 | 演示同时监听多个直播间 69 | """ 70 | clients = [blivedm.BLiveClient(room_id, session=session) for room_id in TEST_ROOM_IDS] 71 | handler = MyHandler() 72 | for client in clients: 73 | client.set_handler(handler) 74 | client.start() 75 | 76 | try: 77 | await asyncio.gather(*( 78 | client.join() for client in clients 79 | )) 80 | finally: 81 | await asyncio.gather(*( 82 | client.stop_and_close() for client in clients 83 | )) 84 | 85 | 86 | class MyHandler(blivedm.BaseHandler): 87 | # # 演示如何添加自定义回调 88 | # _CMD_CALLBACK_DICT = blivedm.BaseHandler._CMD_CALLBACK_DICT.copy() 89 | # 90 | # # 看过数消息回调 91 | # def __watched_change_callback(self, client: blivedm.BLiveClient, command: dict): 92 | # print(f'[{client.room_id}] WATCHED_CHANGE: {command}') 93 | # _CMD_CALLBACK_DICT['WATCHED_CHANGE'] = __watched_change_callback # noqa 94 | 95 | def _on_heartbeat(self, client: blivedm.BLiveClient, message: web_models.HeartbeatMessage): 96 | print(f'[{client.room_id}] 心跳') 97 | 98 | def _on_danmaku(self, client: blivedm.BLiveClient, message: web_models.DanmakuMessage): 99 | print(f'[{client.room_id}] {message.uname}:{message.msg}') 100 | 101 | def _on_gift(self, client: blivedm.BLiveClient, message: web_models.GiftMessage): 102 | print(f'[{client.room_id}] {message.uname} 赠送{message.gift_name}x{message.num}' 103 | f' ({message.coin_type}瓜子x{message.total_coin})') 104 | 105 | # def _on_buy_guard(self, client: blivedm.BLiveClient, message: web_models.GuardBuyMessage): 106 | # print(f'[{client.room_id}] {message.username} 上舰,guard_level={message.guard_level}') 107 | 108 | def _on_user_toast_v2(self, client: blivedm.BLiveClient, message: web_models.UserToastV2Message): 109 | print(f'[{client.room_id}] {message.username} 上舰,guard_level={message.guard_level}') 110 | 111 | def _on_super_chat(self, client: blivedm.BLiveClient, message: web_models.SuperChatMessage): 112 | print(f'[{client.room_id}] 醒目留言 ¥{message.price} {message.uname}:{message.message}') 113 | 114 | # def _on_interact_word(self, client: blivedm.BLiveClient, message: web_models.InteractWordMessage): 115 | # if message.msg_type == 1: 116 | # print(f'[{client.room_id}] {message.username} 进入房间') 117 | 118 | 119 | if __name__ == '__main__': 120 | asyncio.run(main()) 121 | --------------------------------------------------------------------------------