diff --git a/src/blrec/bili/api.py b/src/blrec/bili/api.py index 6ee79d6..e4d7b28 100644 --- a/src/blrec/bili/api.py +++ b/src/blrec/bili/api.py @@ -1,5 +1,6 @@ import asyncio import hashlib +import time from abc import ABC from datetime import datetime from typing import Any, Dict, Final, List, Mapping, Optional @@ -10,6 +11,7 @@ from loguru import logger from tenacity import retry, stop_after_delay, wait_exponential from .exceptions import ApiRequestError +from . import wbi from .typing import JsonResponse, QualityNumber, ResponseData __all__ = 'AppApi', 'WebApi' @@ -23,7 +25,7 @@ BASE_HEADERS: Final = { 'Connection': 'keep-alive', 'Origin': 'https://live.bilibili.com', 'Pragma': 'no-cache', - 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36', # noqa + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36', # noqa } @@ -242,6 +244,30 @@ class AppApi(BaseApi): class WebApi(BaseApi): + _wbi_key = wbi.make_key( + img_key="7cd084941338484aae1ad9425b84077c", + sub_key="4932caff0ff746eab6f01bf08b70ac45", + ) + _wbi_key_mtime = 0.0 + + @retry(reraise=True, stop=stop_after_delay(20), wait=wait_exponential(0.1)) + async def _get_json_res( + self, url: str, with_wbi: bool = False, *args: Any, **kwds: Any + ) -> JsonResponse: + if with_wbi: + key = self.__class__._wbi_key + ts = int(datetime.now().timestamp()) + params = list(kwds.pop("params").items()) + query = wbi.build_query(key, ts, params) + url = f'{url}?{query}' + + try: + return await super()._get_json_res(url, *args, **kwds) + except ApiRequestError as e: + if e.code == -352 and time.monotonic() - self.__class__._wbi_key_mtime > 60: + await self._update_wbi_key() + raise + async def room_init(self, room_id: int) -> ResponseData: path = '/room/v1/Room/room_init' params = {'id': room_id} @@ -262,14 +288,16 @@ class WebApi(BaseApi): 'ptype': 8, } json_responses = await self._get_jsons_concurrently( - self.base_play_info_api_urls, path, params=params + self.base_play_info_api_urls, path, with_wbi=True, params=params ) return [r['data'] for r in json_responses] async def get_info_by_room(self, room_id: int) -> ResponseData: path = '/xlive/web-room/v1/index/getInfoByRoom' params = {'room_id': room_id} - json_res = await self._get_json(self.base_live_api_urls, path, params=params) + json_res = await self._get_json( + self.base_live_api_urls, path, with_wbi=True, params=params + ) return json_res['data'] async def get_info(self, room_id: int) -> ResponseData: @@ -285,19 +313,29 @@ class WebApi(BaseApi): return json_res['data']['timestamp'] async def get_user_info(self, uid: int) -> ResponseData: - # FIXME: "code": -352, "message": "风控校验失败", path = '/x/space/wbi/acc/info' params = {'mid': uid} - json_res = await self._get_json(self.base_api_urls, path, params=params) + json_res = await self._get_json( + self.base_api_urls, path, with_wbi=True, params=params + ) return json_res['data'] async def get_danmu_info(self, room_id: int) -> ResponseData: path = '/xlive/web-room/v1/index/getDanmuInfo' params = {'id': room_id} - json_res = await self._get_json(self.base_live_api_urls, path, params=params) + json_res = await self._get_json( + self.base_live_api_urls, path, with_wbi=True, params=params + ) return json_res['data'] async def get_nav(self) -> ResponseData: path = '/x/web-interface/nav' json_res = await self._get_json(self.base_api_urls, path, check_response=False) return json_res + + async def _update_wbi_key(self) -> None: + nav = await self.get_nav() + img_key = wbi.extract_key(nav['data']['wbi_img']['img_url']) + sub_key = wbi.extract_key(nav['data']['wbi_img']['sub_url']) + self.__class__._wbi_key = wbi.make_key(img_key, sub_key) + self.__class__._wbi_key_mtime = time.monotonic() diff --git a/src/blrec/bili/wbi.py b/src/blrec/bili/wbi.py new file mode 100644 index 0000000..5cc1bd0 --- /dev/null +++ b/src/blrec/bili/wbi.py @@ -0,0 +1,84 @@ +import hashlib +from typing import Any, List, Tuple + + +def extract_key(url: str) -> str: + return url.rsplit("/", 1)[-1].rsplit(".", 1)[0] + + +def make_key(img_key: str, sub_key: str) -> str: + # fmt: off + MAPPING = [ + 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, + 27, 43, 5, 49, 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, + ] + key = (img_key + sub_key).encode() + return bytes([key[n] for n in MAPPING]).decode() + + +def encode_value(value: str) -> str: + chars = [] + + for c in value: + if c in "!'()*": + continue + if (c.isascii() and c.isalnum()) or c in "-_.~": + chars.append(c) + else: + for b in c.encode(): + chars.append(f"%{b:02X}") + + return "".join(chars) + + +def build_query(key: str, ts: int, params: List[Tuple[str, Any]]) -> str: + params.append(("wts", str(ts))) + params.sort(key=lambda p: p[0]) + + parts = [] + for name, value in params: + parts.append(f"{name}={encode_value(str(value))}") + query = "&".join(parts) + + sign = hashlib.md5((query + key).encode()).hexdigest() + query += f"&w_rid={sign}" + + return query + + +def test_extract_key() -> None: + url = "https://i0.hdslb.com/bfs/wbi/7cd084941338484aae1ad9425b84077c.png" + key = extract_key(url) + assert key == "7cd084941338484aae1ad9425b84077c" + + +def test_make_key() -> None: + img_key = "7cd084941338484aae1ad9425b84077c" + sub_key = "4932caff0ff746eab6f01bf08b70ac45" + expected = "ea1db124af3c7062474693fa704f4ff8" + key = make_key(img_key, sub_key) + assert key == expected + + +def test_encode_value() -> None: + expected = "-_-%20F%20%E5%93%94~" + assert encode_value(")-_-( F**' 哔~!") == expected + + +def test_build_query() -> None: + img_key = "7cd084941338484aae1ad9425b84077c" + sub_key = "4932caff0ff746eab6f01bf08b70ac45" + + key = make_key(img_key, sub_key) + ts = 1748867128 + params = [("foo", ")-_-( F**' 哔~!"), ("bar", 2333)] + + expected = "bar=2333&foo=-_-%20F%20%E5%93%94~&wts=1748867128&w_rid=6ba96e28a3f09b40e704f1e4b4f8e3e3" # noqa + assert build_query(key, ts, params) == expected + + +if __name__ == "__main__": + test_extract_key() + test_make_key() + test_encode_value() + test_build_query() diff --git a/src/blrec/setting/models.py b/src/blrec/setting/models.py index 20007f9..4b4f50b 100644 --- a/src/blrec/setting/models.py +++ b/src/blrec/setting/models.py @@ -131,9 +131,7 @@ class HeaderOptions(BaseModel): class HeaderSettings(HeaderOptions): user_agent: str = ( - 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) ' - 'AppleWebKit/537.36 (KHTML, like Gecko) ' - 'Chrome/89.0.4389.114 Safari/537.36' + 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/136.0.0.0 Safari/537.36' # noqa ) cookie: str = '' @@ -198,9 +196,7 @@ class RecorderSettings(RecorderOptions): fmp4_stream_timeout: int = 10 read_timeout: int = 3 disconnection_timeout: int = 600 - buffer_size: Annotated[ - int, Field(ge=4096, le=1024**2 * 512, multiple_of=2) - ] = 8192 + buffer_size: Annotated[int, Field(ge=4096, le=1024**2 * 512, multiple_of=2)] = 8192 save_cover: bool = False cover_save_strategy: CoverSaveStrategy = CoverSaveStrategy.DEFAULT