├── requirements.txt ├── conf └── user.sample.toml ├── README.md ├── LICENSE ├── .gitignore ├── api.py └── run.py /requirements.txt: -------------------------------------------------------------------------------- 1 | aiohttp>=3.6.2 2 | tenacity>=6.2.0 3 | toml>=0.10.1 4 | colorama>=0.4.3 5 | bili-spyder>=0.2.0 -------------------------------------------------------------------------------- /conf/user.sample.toml: -------------------------------------------------------------------------------- 1 | [[users]] 2 | username = "" 3 | cookie = "" 4 | 5 | [[users]] 6 | username = "" 7 | cookie = "" 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # bili-spyder-example 2 | 3 | Bilibili 直播小心心获取示例 4 | 5 | ## 使用方法 6 | 7 | **已安装了 python 3.6+** 8 | 9 | 1. clone 或下载 10 | 11 | 2. 安装依赖 `pip3 install -r requirements.txt` 12 | 13 | 3. 用以下方法之一配置好 conf/user.toml 14 | 15 | 1. 复制 user.sample.toml 为 user.toml, 然后填上 cookie(必须)和用户名(可选、随意) 16 | 2. 把 bili2.0 的 user.toml 直接复制过来用 17 | 3. 用 ln 链接 bili2.0 的 user.toml 文件 18 | 19 | 4. 运行 `python3 run.py` 20 | 21 | 加上 `--debug` 选项可显示调试日志 `python3 run.py --debug` 22 | 23 | ## 示例截图 24 | 25 | ![1_2020-08-29_095807](https://user-images.githubusercontent.com/33854576/91628943-4d390d80-e9f7-11ea-916a-9064beebc16e.png) 26 | ![2_2020-08-29_100204](https://user-images.githubusercontent.com/33854576/91628950-62ae3780-e9f7-11ea-88a4-ab6879decd7b.png) 27 | ![3_2020-08-29_101616](https://user-images.githubusercontent.com/33854576/91628954-693caf00-e9f7-11ea-9ff8-df84738b2a06.png) 28 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 acgnhiki 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. 22 | -------------------------------------------------------------------------------- /.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 | pip-wheel-metadata/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | target/ 76 | 77 | # Jupyter Notebook 78 | .ipynb_checkpoints 79 | 80 | # IPython 81 | profile_default/ 82 | ipython_config.py 83 | 84 | # pyenv 85 | .python-version 86 | 87 | # pipenv 88 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 89 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 90 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 91 | # install all needed dependencies. 92 | #Pipfile.lock 93 | 94 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 95 | __pypackages__/ 96 | 97 | # Celery stuff 98 | celerybeat-schedule 99 | celerybeat.pid 100 | 101 | # SageMath parsed files 102 | *.sage.py 103 | 104 | # Environments 105 | .env 106 | .venv 107 | env/ 108 | venv/ 109 | ENV/ 110 | env.bak/ 111 | venv.bak/ 112 | 113 | # Spyder project settings 114 | .spyderproject 115 | .spyproject 116 | 117 | # Rope project settings 118 | .ropeproject 119 | 120 | # mkdocs documentation 121 | /site 122 | 123 | # mypy 124 | .mypy_cache/ 125 | .dmypy.json 126 | dmypy.json 127 | 128 | # Pyre type checker 129 | .pyre/ 130 | 131 | # vscode 132 | .vscode/ 133 | 134 | # conf 135 | conf/*.toml 136 | !conf/user.sample.toml 137 | 138 | # private folders 139 | _*/ -------------------------------------------------------------------------------- /api.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import re 3 | import time 4 | import random 5 | from urllib.parse import urlencode 6 | from typing import Dict 7 | 8 | import aiohttp 9 | from aiohttp import ClientSession 10 | from tenacity import ( 11 | retry, 12 | wait_random, 13 | stop_after_delay, 14 | retry_if_exception_type, 15 | ) 16 | 17 | from bili_spyder import calc_sign_async as calc_sign 18 | 19 | __all__ = 'WebApi', 'WebApiRequestError' 20 | 21 | class WebApiRequestError(Exception): 22 | pass 23 | 24 | class WebApi: 25 | @staticmethod 26 | def _check(res_json): 27 | if res_json['code'] != 0: 28 | raise WebApiRequestError(res_json['message']) 29 | 30 | @classmethod 31 | @retry(stop=stop_after_delay(5), 32 | wait=wait_random(0, 1), 33 | retry=retry_if_exception_type(aiohttp.ServerConnectionError)) 34 | async def _get(cls, session: ClientSession, *args, **kwds): 35 | async with session.get(*args, **kwds) as res: 36 | res_json = await res.json() 37 | cls._check(res_json) 38 | return res_json['data'] 39 | 40 | @classmethod 41 | @retry(stop=stop_after_delay(5), 42 | wait=wait_random(0, 1), 43 | retry=retry_if_exception_type(aiohttp.ServerConnectionError)) 44 | async def _post(cls, session: ClientSession, *args, **kwds): 45 | async with session.post(*args, **kwds) as res: 46 | res_json = await res.json() 47 | cls._check(res_json) 48 | return res_json['data'] 49 | 50 | @classmethod 51 | async def post_enter_room_heartbeat(cls, 52 | session: ClientSession, csrf: str, buvid: str, uuid: str, 53 | room_id: int, parent_area_id: int, area_id: int) -> Dict: 54 | url = 'https://live-trace.bilibili.com/xlive/data-interface/v1/x25Kn/E' 55 | 56 | headers = { 57 | 'Referer': f'https://live.bilibili.com/{room_id}', 58 | 'Content-Type': 'application/x-www-form-urlencoded', 59 | } 60 | 61 | data = { 62 | 'id': f'[{parent_area_id}, {area_id}, 0, {room_id}]', 63 | # 'device': '["unknown", "23333333-3333-3333-3333-333333333333"]', # LIVE_BUVID, xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 64 | 'device': f'["{buvid}", "{uuid}"]', 65 | 'ts': int(time.time()) * 1000, 66 | 'is_patch': 0, 67 | 'heart_beat': [], 68 | 'ua': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36', 69 | 'csrf_token': csrf, 70 | 'csrf': csrf, 71 | 'visit_id': '', 72 | } 73 | 74 | return await cls._post(session, url, headers=headers, data=urlencode(data)) 75 | 76 | @classmethod 77 | async def post_in_room_heartbeat(cls, 78 | session: ClientSession, csrf: str, buvid: str, uuid: str, 79 | room_id: int, parent_area_id: int, area_id: int, 80 | sequence: int, interval: int, ets: int, 81 | secret_key: str, secret_rule: list) -> Dict: 82 | url = 'https://live-trace.bilibili.com/xlive/data-interface/v1/x25Kn/X' 83 | 84 | headers = { 85 | 'Referer': f'https://live.bilibili.com/{room_id}', 86 | 'Content-Type': 'application/x-www-form-urlencoded', 87 | } 88 | 89 | data = { 90 | 'id': f'[{parent_area_id}, {area_id}, {sequence}, {room_id}]', 91 | # 'device': '["unknown", "23333333-3333-3333-3333-333333333333"]', # LIVE_BUVID, xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx 92 | 'device': f'["{buvid}", "{uuid}"]', 93 | 'ets': ets, 94 | 'benchmark': secret_key, 95 | 'time': interval, 96 | 'ts': int(time.time()) * 1000, 97 | 'ua': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/80.0.3987.163 Safari/537.36', 98 | } 99 | 100 | data.update({ 101 | 'csrf_token': csrf, 102 | 'csrf': csrf, 103 | 'visit_id': '', 104 | 's': await calc_sign(data, secret_rule), 105 | }) 106 | 107 | return await cls._post(session, url, headers=headers, data=urlencode(data)) 108 | 109 | @classmethod 110 | async def get_medal(cls, session: ClientSession, page=1, page_size=10) -> Dict: 111 | url = 'https://api.live.bilibili.com/i/api/medal' 112 | 113 | params = { 114 | 'page': page, 115 | 'pageSize': page_size, 116 | } 117 | 118 | return await cls._get(session, url, params=params) 119 | 120 | @classmethod 121 | async def get_info(cls, session: ClientSession, room_id: int) -> Dict: 122 | url = 'https://api.live.bilibili.com/room/v1/Room/get_info' 123 | return await cls._get(session, url, params={'room_id': room_id}) 124 | 125 | @classmethod 126 | async def get_info_by_room(cls, session: ClientSession, room_id: int) -> Dict: 127 | url = 'https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom' 128 | return await cls._get(session, url, params={'room_id': room_id}) 129 | -------------------------------------------------------------------------------- /run.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python3 2 | import re 3 | import json 4 | import time 5 | import uuid 6 | import logging 7 | import argparse 8 | import asyncio 9 | from datetime import datetime 10 | from collections import namedtuple 11 | from asyncio import CancelledError 12 | from concurrent.futures import ProcessPoolExecutor 13 | 14 | import aiohttp 15 | import toml 16 | from colorama import init, deinit, Fore, Back, Style 17 | from bili_spyder import set_executor 18 | 19 | from api import WebApi 20 | 21 | async def get_info(session, room_id): 22 | try: 23 | info = await WebApi.get_info(session, room_id) 24 | except CancelledError: 25 | raise 26 | except Exception as e: 27 | info = await WebApi.get_info_by_room(session, room_id) 28 | info = info['room_info'] 29 | 30 | return info 31 | 32 | async def medals(session): 33 | page = 1 34 | 35 | while True: 36 | data = await WebApi.get_medal(session, page=page) 37 | page_info = data['pageinfo'] 38 | assert page == page_info['curPage'] 39 | 40 | for medal in data['fansMedalList']: 41 | yield medal 42 | 43 | if page < page_info['totalpages']: 44 | page += 1 45 | else: 46 | break 47 | 48 | def extract_csrf(cookie): 49 | try: 50 | return re.search(r'bili_jct=([^;]+);', cookie).group(1) 51 | except Exception: 52 | return None 53 | 54 | def extract_buvid(cookie): 55 | try: 56 | return re.search(r'LIVE_BUVID=([^;]+);', cookie).group(1) 57 | except Exception: 58 | return None 59 | 60 | async def obtain_buvid(cookie): 61 | async with aiohttp.request('GET', 'https://live.bilibili.com/3', 62 | headers={'Cookie': cookie}) as res: 63 | return extract_buvid(str(res.cookies['LIVE_BUVID'])) 64 | 65 | class User: 66 | count = 1 67 | 68 | def __init__(self, name, cookie, csrf, buvid, uuid): 69 | cls = self.__class__ 70 | self.name = name 71 | self.num = cls.count 72 | cls.count += 1 73 | self.cookie = cookie 74 | self.csrf = csrf 75 | self.uuid = uuid 76 | self.buvid = buvid 77 | 78 | class DailyTask: 79 | async def run(self): 80 | deviation = 60 # tolerated deviation 81 | 82 | while True: 83 | try: 84 | seconds = self.seconds_to_tomorrow() - deviation 85 | await asyncio.wait_for(self.do_work(), timeout=seconds) 86 | except asyncio.TimeoutError: 87 | self.timeout_handler() 88 | 89 | seconds = self.seconds_to_tomorrow() + deviation 90 | await self.sleep(seconds) 91 | 92 | async def do_work(self): 93 | pass 94 | 95 | def timeout_handler(self): 96 | pass 97 | 98 | @staticmethod 99 | def seconds_to_tomorrow(): 100 | now = datetime.now() 101 | delta = now.replace(hour=23, minute=59, second=59) - now 102 | return delta.total_seconds() + 1 103 | 104 | @staticmethod 105 | async def sleep(seconds): 106 | ts = datetime.now().timestamp() + seconds 107 | 108 | while datetime.now().timestamp() <= ts: 109 | await asyncio.sleep(300) 110 | 111 | RoomInfo = namedtuple('RoomInfo', 'room_id, parent_area_id, area_id') 112 | 113 | class SmallHeartTask(DailyTask): 114 | def __init__(self, user: User): 115 | self.user = user 116 | self.MAX_HEARTS_PER_DAY = 24 117 | self.MAX_CONCURRENT_ROOMS = self.MAX_HEARTS_PER_DAY 118 | self.HEART_INTERVAL = 300 119 | 120 | def timeout_handler(self): 121 | logger.warning(f'今天小心心任务未能完成(用户{self.user.num}:{self.user.name})') 122 | 123 | async def do_work(self): 124 | uname = self.user.name 125 | num = self.user.num 126 | MAX_HEARTS_PER_DAY = self.MAX_HEARTS_PER_DAY 127 | 128 | headers = { 129 | 'Referer': 'https://live.bilibili.com', 130 | 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/83.0.4103.116 Safari/537.36', 131 | 'Cookie': self.user.cookie, 132 | } 133 | 134 | try: 135 | logger.info(f'开始今天的小心心任务(用户{num}:{uname})') 136 | self.session = session = aiohttp.ClientSession(headers=headers) 137 | # self.session = session = aiohttp.ClientSession(headers=headers, trust_env=True) 138 | 139 | room_infos = [] 140 | count = 0 141 | 142 | async for m in medals(session): 143 | info = await get_info(session, m['roomid']) 144 | room_id = info['room_id'] # ensure not the short id 145 | area_id = info['area_id'] 146 | parent_area_id = info['parent_area_id'] 147 | room_info = RoomInfo(room_id, parent_area_id, area_id) 148 | 149 | if parent_area_id == 0 or area_id == 0: 150 | logger.warning(f'Invalid room info(用户{num}:{uname}): {room_info}') 151 | continue 152 | 153 | room_infos.append(room_info) 154 | count += 1 155 | 156 | if count == self.MAX_CONCURRENT_ROOMS: 157 | break 158 | 159 | if len(room_infos) == 0: 160 | logger.warning(f'一个勋章都没有~结束任务(用户{num}:{uname})') 161 | return 162 | 163 | self.queue = queue = asyncio.Queue(MAX_HEARTS_PER_DAY) 164 | 165 | for i in range(1, MAX_HEARTS_PER_DAY + 1): 166 | queue.put_nowait(i) 167 | 168 | dispatcher = asyncio.create_task(self.dispatch(room_infos)) 169 | 170 | await queue.join() 171 | logger.info(f'今天小心心任务已完成(用户{num}:{uname})') 172 | except CancelledError: 173 | raise 174 | finally: 175 | try: 176 | dispatcher.cancel() 177 | except Exception: 178 | pass 179 | 180 | try: 181 | for task in self.tasks: 182 | task.cancel() 183 | except Exception: 184 | pass 185 | 186 | await session.close() 187 | self.queue = None 188 | self.tasks = None 189 | self.session = None 190 | 191 | async def dispatch(self, room_infos): 192 | uname = self.user.name 193 | num = self.user.num 194 | self.tasks = tasks = [] 195 | 196 | for room_info in room_infos: 197 | task = asyncio.create_task(self.post_heartbeats(*room_info)) 198 | tasks.append(task) 199 | logger.debug(f'{room_info.room_id}号直播间心跳任务开始(用户{num}:{uname})') 200 | 201 | async def post_heartbeats(self, room_id, parent_area_id, area_id): 202 | session = self.session 203 | csrf = self.user.csrf 204 | buvid = self.user.buvid 205 | uuid = self.user.uuid 206 | uname = self.user.name 207 | num = self.user.num 208 | queue = self.queue 209 | 210 | while True: 211 | sequence = 0 212 | 213 | try: 214 | result = await WebApi.post_enter_room_heartbeat(session, csrf, buvid, uuid, room_id, parent_area_id, area_id) 215 | logger.debug(f'进入{room_id}号直播间心跳已发送(用户{num}:{uname})') 216 | logger.debug(f'进入{room_id}号直播间心跳发送结果(用户{num}:{uname}): {result}') 217 | 218 | while True: 219 | sequence += 1 220 | interval = result['heartbeat_interval'] 221 | logger.debug(f'{interval}秒后发送第{sequence}个{room_id}号直播间内心跳(用户{num}:{uname})') 222 | await asyncio.sleep(interval) 223 | 224 | result = await WebApi.post_in_room_heartbeat( 225 | session, csrf, buvid, uuid, 226 | room_id, parent_area_id, area_id, 227 | sequence, interval, 228 | result['timestamp'], 229 | result['secret_key'], 230 | result['secret_rule'], 231 | ) 232 | 233 | logger.debug(f'第{sequence}个{room_id}号直播间内心跳已发送(用户{num}:{uname})') 234 | logger.debug(f'第{sequence}个{room_id}号直播间内心跳发送结果(用户{num}:{uname}): {result}') 235 | 236 | assert self.HEART_INTERVAL % interval == 0, interval 237 | heartbeats_per_heart = self.HEART_INTERVAL // interval 238 | 239 | if sequence % heartbeats_per_heart == 0: 240 | n = queue.get_nowait() 241 | logger.info(f'获得第{n}个小心心(用户{num}:{uname})') 242 | queue.task_done() 243 | except asyncio.QueueEmpty: 244 | logger.debug(f'小心心任务已完成, {room_id}号直播间心跳任务终止。(用户{num}:{uname})') 245 | break 246 | except CancelledError: 247 | logger.debug(f'{room_id}号直播间心跳任务取消(用户{num}:{uname})') 248 | raise 249 | except Exception as e: 250 | if sequence == 0: 251 | logger.error(f'进入{room_id}号直播间心跳发送异常(用户{num}:{uname}): {repr(e)}') 252 | else: 253 | logger.error(f'第{sequence}个{room_id}号直播间内心跳发送异常(用户{num}:{uname}): {repr(e)}') 254 | 255 | delay = 60 256 | logger.info(f'{delay}秒后重试{room_id}号直播间心跳任务(用户{num}:{uname})') 257 | await asyncio.sleep(delay) 258 | 259 | class ConsoleHandler(logging.StreamHandler): 260 | def __init__(self, stream=None): 261 | super().__init__(stream) 262 | 263 | def emit(self, record): 264 | level = record.levelno 265 | if level == logging.DEBUG: 266 | self.stream.write(Fore.GREEN) 267 | elif level == logging.WARNING: 268 | self.stream.write(Fore.YELLOW) 269 | elif level == logging.ERROR: 270 | self.stream.write(Fore.RED) 271 | elif level == logging.CRITICAL: 272 | self.stream.write(Fore.WHITE + Back.RED + Style.BRIGHT) 273 | 274 | super().emit(record) 275 | 276 | if level != logging.INFO: 277 | self.stream.write(Style.RESET_ALL) 278 | self.stream.flush() 279 | 280 | def configure_logging(*, name='root', filename='logging.log', debug=False): 281 | global logger 282 | logger = logging.getLogger(name) 283 | logger.setLevel(logging.DEBUG) 284 | 285 | formatter = logging.Formatter('[%(asctime)s] [%(levelname)s] %(message)s') 286 | # logging to console 287 | console_handler = ConsoleHandler() 288 | console_handler.setLevel(logging.DEBUG if debug else logging.INFO) 289 | console_handler.setFormatter(formatter) 290 | logger.addHandler(console_handler) 291 | # logging to file 292 | # ... 293 | 294 | def get_args(): 295 | parser = argparse.ArgumentParser(description='') 296 | parser.add_argument('--debug', action='store_true', 297 | help='enable logging debug information') 298 | args = parser.parse_args() 299 | return args 300 | 301 | async def main(args): 302 | init() 303 | configure_logging(debug=args.debug) 304 | user_config = toml.load('./conf/user.toml') 305 | tasks = [] 306 | 307 | for u in user_config['users']: 308 | name = u['username'] 309 | cookie = u['cookie'] 310 | csrf = extract_csrf(cookie) 311 | buvid = extract_buvid(cookie) or await obtain_buvid(cookie) 312 | user = User(name, cookie, csrf, buvid, uuid.uuid4()) 313 | task = asyncio.create_task(SmallHeartTask(user).run()) 314 | tasks.append(task) 315 | 316 | try: 317 | e = ProcessPoolExecutor() 318 | set_executor(e) 319 | await asyncio.wait(tasks) 320 | finally: 321 | set_executor(None) 322 | e.shutdown(True) 323 | deinit() 324 | 325 | if __name__ == '__main__': 326 | if hasattr(asyncio, 'run'): 327 | try: 328 | asyncio.run(main(get_args())) 329 | except KeyboardInterrupt: 330 | pass 331 | else: 332 | if not hasattr(asyncio, 'create_task'): 333 | asyncio.create_task = asyncio.ensure_future 334 | 335 | if not hasattr(asyncio, 'get_running_loop'): 336 | asyncio.get_running_loop = asyncio.get_event_loop 337 | 338 | try: 339 | loop = asyncio.get_event_loop() 340 | loop.run_until_complete(main(get_args())) 341 | except KeyboardInterrupt: 342 | pass 343 | finally: 344 | loop.close() 345 | --------------------------------------------------------------------------------