Compare commits

...

4 Commits

Author SHA1 Message Date
acgnhiki
47f16f2c4a chore: update workflows 2025-06-03 22:39:22 +08:00
acgnhiki
340972c878 release: 2.0.0-beta.5 2025-06-03 21:59:39 +08:00
acgnhiki
228e5ad46d fix: update web api 2025-06-03 21:50:14 +08:00
mosh
f75d91e7f5 fix: ensure room_id scope 2025-06-03 21:42:34 +08:00
7 changed files with 139 additions and 16 deletions

View File

@@ -18,7 +18,7 @@ jobs:
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@@ -17,7 +17,7 @@ jobs:
uses: docker/setup-buildx-action@v1
- name: Cache Docker layers
uses: actions/cache@v2
uses: actions/cache@v4
with:
path: /tmp/.buildx-cache
key: ${{ runner.os }}-buildx-${{ github.sha }}

View File

@@ -1,5 +1,10 @@
# 更新日志
## 2.0.0-beta.5
- 兼容长 room id
- 更新 web api
## 2.0.0-beta.4
- 添加只使用 ipv4 的命令行选项

View File

@@ -1,3 +1,3 @@
__prog__ = 'blrec'
__version__ = '2.0.0-beta.4'
__version__ = '2.0.0-beta.5'
__github__ = 'https://github.com/acgnhiki/blrec'

View File

@@ -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()

84
src/blrec/bili/wbi.py Normal file
View File

@@ -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()

View File

@@ -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
@@ -315,7 +311,7 @@ class TaskOptions(BaseModel):
class TaskSettings(TaskOptions):
# must use the real room id rather than the short room id!
room_id: Annotated[int, Field(ge=1, lt=2**32)]
room_id: Annotated[int, Field(ge=1, lt=2**100)]
enable_monitor: bool = True
enable_recorder: bool = True