Add httpx and route Bilibili and Telegram requests through pooled clients with keepalive and explicit timeouts. Align Bilibili headers with data/raw-request.txt: Chrome 155 UA, sec-ch-ua, space.bilibili.com origin, folder-specific referer, and priority header. Close replaced clients when credentials change. Add transport contract tests and live read-only validation for nav, folder sync, WBI search, and Telegram getUpdates.
513 lines
18 KiB
Python
513 lines
18 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""只读 B 站 Web API 客户端(P0 范围)。
|
|
|
|
P0 只需要收藏夹读取能力:``ids`` / ``infos`` / ``folder/created/list-all``。
|
|
所有网络访问都经过 ``Transport`` 边界,测试可直接替换该边界,而不需要访问
|
|
真实 B 站。写操作(``deal`` / ``sort``)不在本模块实现。
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import random
|
|
import time
|
|
import urllib.error
|
|
import urllib.parse
|
|
import urllib.request
|
|
from dataclasses import dataclass
|
|
from typing import Any, Mapping, Protocol, Sequence
|
|
|
|
import httpx
|
|
|
|
from .auth import make_headers
|
|
from .models import (
|
|
FanStat,
|
|
FavId,
|
|
FavResource,
|
|
FolderInfo,
|
|
InfosResult,
|
|
LoginState,
|
|
ViewStat,
|
|
)
|
|
|
|
BASE = "https://api.bilibili.com"
|
|
DEFAULT_CHUNK = 200
|
|
MAX_CHUNK = 400
|
|
# 视频不可访问/已删除:-404 为旧口径,62002/62012 为当前实测返回。
|
|
VIDEO_UNAVAILABLE_CODES = frozenset({-404, 62002, 62012})
|
|
|
|
|
|
class BilibiliError(RuntimeError):
|
|
"""B 站访问层基础异常。"""
|
|
|
|
|
|
class TransportError(BilibiliError):
|
|
"""网络层错误,可重试。"""
|
|
|
|
retryable = True
|
|
|
|
|
|
class BilibiliHTTPError(BilibiliError):
|
|
def __init__(self, status: int, message: str = "") -> None:
|
|
self.status = status
|
|
self.message = message
|
|
self.retryable = status >= 500 or status == 429
|
|
super().__init__(f"HTTP {status}: {message}".rstrip())
|
|
|
|
def __str__(self) -> str:
|
|
return f"HTTP {self.status}: {self.message}".rstrip(": ")
|
|
|
|
|
|
class BilibiliAPIError(BilibiliError):
|
|
def __init__(self, code: int, message: str = "") -> None:
|
|
self.code = code
|
|
self.message = message
|
|
super().__init__(f"B 站接口业务错误 code={code}: {message}".rstrip(": "))
|
|
|
|
def __str__(self) -> str:
|
|
return f"B 站接口业务错误 code={self.code}: {self.message}".rstrip(": ")
|
|
|
|
|
|
class BilibiliProtocolError(BilibiliError):
|
|
"""响应结构与已知契约不一致。"""
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class HTTPResponse:
|
|
status: int
|
|
headers: Mapping[str, str]
|
|
body: bytes
|
|
|
|
def json(self) -> Any:
|
|
try:
|
|
return json.loads(self.body.decode("utf-8", "replace"))
|
|
except json.JSONDecodeError as exc:
|
|
raise BilibiliProtocolError(f"响应不是合法 JSON: {exc}") from exc
|
|
|
|
|
|
class Transport(Protocol):
|
|
def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: Mapping[str, str],
|
|
data: bytes | None = None,
|
|
timeout: float = 30,
|
|
) -> HTTPResponse: ...
|
|
|
|
|
|
class UrllibTransport:
|
|
"""基于 ``urllib.request`` 的默认传输实现。"""
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: Mapping[str, str],
|
|
data: bytes | None = None,
|
|
timeout: float = 30,
|
|
) -> HTTPResponse:
|
|
req = urllib.request.Request(
|
|
url, data=data, headers=dict(headers), method=method
|
|
)
|
|
try:
|
|
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
return HTTPResponse(
|
|
status=int(resp.status),
|
|
headers=dict(resp.headers.items()),
|
|
body=resp.read(),
|
|
)
|
|
except urllib.error.HTTPError as exc:
|
|
try:
|
|
body = exc.read()
|
|
except Exception: # noqa: BLE001
|
|
body = b""
|
|
return HTTPResponse(
|
|
status=int(exc.code),
|
|
headers=dict(exc.headers.items()) if exc.headers else {},
|
|
body=body,
|
|
)
|
|
except (urllib.error.URLError, TimeoutError, OSError) as exc:
|
|
raise TransportError(str(exc)) from exc
|
|
|
|
|
|
class HttpxTransport:
|
|
"""基于 httpx 的连接池传输,默认用于 B 站请求。"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
timeout: float = 30,
|
|
client: httpx.Client | None = None,
|
|
) -> None:
|
|
self.timeout = timeout
|
|
self._client = client or httpx.Client(
|
|
timeout=httpx.Timeout(timeout, connect=10),
|
|
limits=httpx.Limits(
|
|
max_keepalive_connections=10,
|
|
keepalive_expiry=30,
|
|
),
|
|
follow_redirects=False,
|
|
trust_env=True,
|
|
)
|
|
|
|
def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: Mapping[str, str],
|
|
data: bytes | None = None,
|
|
timeout: float = 30,
|
|
) -> HTTPResponse:
|
|
try:
|
|
response = self._client.request(
|
|
method,
|
|
url,
|
|
headers=dict(headers),
|
|
content=data,
|
|
timeout=timeout,
|
|
)
|
|
except httpx.TransportError as exc:
|
|
raise TransportError(str(exc)) from exc
|
|
return HTTPResponse(
|
|
status=int(response.status_code),
|
|
headers=dict(response.headers),
|
|
body=response.content,
|
|
)
|
|
|
|
def close(self) -> None:
|
|
self._client.close()
|
|
|
|
|
|
class Pacer:
|
|
"""请求间随机等待;第一个请求不等待。"""
|
|
|
|
def __init__(
|
|
self,
|
|
min_delay: float = 1.5,
|
|
max_delay: float = 2.5,
|
|
*,
|
|
sleep=time.sleep,
|
|
rng: random.Random | None = None,
|
|
) -> None:
|
|
if min_delay < 0 or max_delay < min_delay:
|
|
raise ValueError("非法的请求间隔")
|
|
self.min_delay = min_delay
|
|
self.max_delay = max_delay
|
|
self._sleep = sleep
|
|
self._rng = rng or random.Random()
|
|
self._first = True
|
|
|
|
def wait(self) -> float:
|
|
if self._first:
|
|
self._first = False
|
|
return 0.0
|
|
delay = self._rng.uniform(self.min_delay, self.max_delay)
|
|
if delay > 0:
|
|
self._sleep(delay)
|
|
return delay
|
|
|
|
|
|
def _chunks(seq: Sequence[int], size: int):
|
|
for index in range(0, len(seq), size):
|
|
yield seq[index:index + size]
|
|
|
|
|
|
class BilibiliClient:
|
|
"""只读收藏夹 API 客户端。"""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
cookie: str = "",
|
|
csrf: str = "",
|
|
mid: int = 0,
|
|
transport: Transport | None = None,
|
|
pacer: Pacer | None = None,
|
|
sleeper=time.sleep,
|
|
retries: int = 3,
|
|
backoff: float = 1.0,
|
|
timeout: float = 30,
|
|
logger=None,
|
|
) -> None:
|
|
if retries < 1:
|
|
raise ValueError("retries 必须 >= 1")
|
|
self.cookie = cookie or ""
|
|
self.csrf = csrf or ""
|
|
self.mid = mid
|
|
self.transport = transport or HttpxTransport()
|
|
self.pacer = pacer or Pacer(sleep=sleeper)
|
|
self._sleeper = sleeper
|
|
self.retries = retries
|
|
self.backoff = backoff
|
|
self.timeout = timeout
|
|
self.logger = logger
|
|
|
|
def close(self) -> None:
|
|
close = getattr(self.transport, "close", None)
|
|
if callable(close):
|
|
close()
|
|
|
|
# ------------------------------------------------------------ core
|
|
|
|
def _headers(self, auth: bool, referer: str | None = None) -> dict[str, str]:
|
|
return make_headers({"cookie": self.cookie}, auth=auth, referer=referer)
|
|
|
|
def _request_json(
|
|
self, url: str, *, auth: bool, referer: str | None = None
|
|
) -> dict[str, Any]:
|
|
self.pacer.wait()
|
|
resp = self.transport.request(
|
|
"GET", url, self._headers(auth, referer), data=None, timeout=self.timeout
|
|
)
|
|
if resp.status >= 400:
|
|
raise BilibiliHTTPError(resp.status, resp.body.decode("utf-8", "replace")[:200])
|
|
payload = resp.json()
|
|
if not isinstance(payload, dict):
|
|
raise BilibiliProtocolError("接口响应顶层不是 JSON 对象")
|
|
return payload
|
|
|
|
def _request_with_retry(
|
|
self, url: str, *, auth: bool, referer: str | None = None
|
|
) -> dict[str, Any]:
|
|
last: BilibiliError | None = None
|
|
for attempt in range(self.retries):
|
|
try:
|
|
return self._request_json(url, auth=auth, referer=referer)
|
|
except (TransportError, BilibiliHTTPError) as exc:
|
|
last = exc
|
|
if not exc.retryable or attempt >= self.retries - 1:
|
|
raise
|
|
delay = self.backoff * (2 ** attempt)
|
|
if self.logger is not None:
|
|
self.logger.warning(
|
|
"bili.retry",
|
|
attempt=attempt + 1,
|
|
delay=delay,
|
|
status=getattr(exc, "status", None),
|
|
)
|
|
if delay > 0:
|
|
self._sleeper(delay)
|
|
raise last or TransportError("未知网络错误")
|
|
|
|
@staticmethod
|
|
def _api_error(payload: Mapping[str, Any]) -> BilibiliAPIError:
|
|
code = payload.get("code")
|
|
if isinstance(code, bool) or not isinstance(code, int):
|
|
raise BilibiliProtocolError("接口响应缺少整数 code")
|
|
return BilibiliAPIError(code, str(payload.get("message") or ""))
|
|
|
|
def get_json(
|
|
self, url: str, *, auth: bool = False, referer: str | None = None
|
|
) -> dict[str, Any]:
|
|
"""GET JSON;匿名失败时自动带完整 Cookie 重试一次(与 favsort 一致)。"""
|
|
if auth:
|
|
payload = self._request_with_retry(url, auth=True, referer=referer)
|
|
if payload.get("code") != 0:
|
|
raise self._api_error(payload)
|
|
return payload
|
|
|
|
anon_error: BilibiliError | None = None
|
|
try:
|
|
payload = self._request_with_retry(url, auth=False, referer=referer)
|
|
if payload.get("code") == 0:
|
|
return payload
|
|
anon_error = self._api_error(payload)
|
|
except BilibiliError as exc:
|
|
anon_error = exc
|
|
|
|
if self.logger is not None and anon_error is not None:
|
|
self.logger.debug("bili.anon_fallback", error=str(anon_error))
|
|
payload = self._request_with_retry(url, auth=True, referer=referer)
|
|
if payload.get("code") != 0:
|
|
raise self._api_error(payload)
|
|
return payload
|
|
|
|
@staticmethod
|
|
def _data_list(payload: Mapping[str, Any], name: str) -> list[Any]:
|
|
"""区分“真实空数组”与“协议缺字段”。
|
|
|
|
实测空收藏夹返回 ``{"code":0,"data":[]}``;缺少 ``data`` 或为 ``null``
|
|
都不是已知契约,必须中止,避免被误判成空收藏夹后批量改状态。
|
|
"""
|
|
if "data" not in payload:
|
|
raise BilibiliProtocolError(f"{name} 响应缺少 data 字段")
|
|
data = payload["data"]
|
|
if not isinstance(data, list):
|
|
raise BilibiliProtocolError(f"{name} data 不是数组")
|
|
return data
|
|
|
|
# ------------------------------------------------------------ fav
|
|
|
|
def fav_ids(self, media_id: int) -> list[FavId]:
|
|
url = (
|
|
f"{BASE}/x/v3/fav/resource/ids?media_id={int(media_id)}&platform=web"
|
|
)
|
|
referer = (
|
|
f"https://space.bilibili.com/{self.mid}/favlist"
|
|
f"?fid={int(media_id)}&ftype=create"
|
|
)
|
|
payload = self.get_json(url, referer=referer)
|
|
data = self._data_list(payload, "fav/resource/ids")
|
|
out: list[FavId] = []
|
|
for index, item in enumerate(data):
|
|
if not isinstance(item, Mapping):
|
|
raise BilibiliProtocolError(f"fav/resource/ids data[{index}] 不是对象")
|
|
aid = item.get("id")
|
|
if isinstance(aid, bool) or not isinstance(aid, int):
|
|
raise BilibiliProtocolError(f"fav/resource/ids data[{index}].id 不是整数")
|
|
raw_type = item.get("type", 2)
|
|
if isinstance(raw_type, bool) or not isinstance(raw_type, int):
|
|
raise BilibiliProtocolError(f"fav/resource/ids data[{index}].type 不是整数")
|
|
bvid = item.get("bvid") or item.get("bv_id")
|
|
if not isinstance(bvid, str) or not bvid:
|
|
bvid = None
|
|
out.append(FavId(aid=aid, type=raw_type, bvid=bvid))
|
|
return out
|
|
|
|
def fav_infos(
|
|
self,
|
|
media_id: int,
|
|
aids: Sequence[int],
|
|
*,
|
|
folder_mid: int | None = None,
|
|
chunk_size: int = DEFAULT_CHUNK,
|
|
) -> InfosResult:
|
|
if not 1 <= chunk_size <= MAX_CHUNK:
|
|
raise ValueError(f"chunk_size 必须在 1..{MAX_CHUNK} 之间")
|
|
requested: list[int] = []
|
|
seen: set[int] = set()
|
|
for raw in aids:
|
|
aid = int(raw)
|
|
if aid not in seen:
|
|
seen.add(aid)
|
|
requested.append(aid)
|
|
if not requested:
|
|
return InfosResult(requested=(), items={}, missing=())
|
|
|
|
fmid = self.mid if folder_mid is None else int(folder_mid)
|
|
referer = (
|
|
f"https://space.bilibili.com/{fmid}/favlist"
|
|
f"?fid={int(media_id)}&ftype=create"
|
|
)
|
|
items: dict[int, FavResource] = {}
|
|
for chunk in _chunks(requested, chunk_size):
|
|
resources = ",".join(f"{aid}:2" for aid in chunk)
|
|
url = (
|
|
f"{BASE}/x/v3/fav/resource/infos"
|
|
f"?resources={urllib.parse.quote(resources)}"
|
|
f"&platform=web&folder_mid={fmid}&folder_id={int(media_id)}"
|
|
)
|
|
payload = self.get_json(url, referer=referer)
|
|
data = self._data_list(payload, "fav/resource/infos")
|
|
chunk_set = set(chunk)
|
|
for item in data:
|
|
if not isinstance(item, Mapping):
|
|
raise BilibiliProtocolError("fav/resource/infos item 不是对象")
|
|
try:
|
|
parsed = FavResource.from_api(item)
|
|
except (TypeError, ValueError) as exc:
|
|
raise BilibiliProtocolError(f"fav/resource/infos 解析失败: {exc}") from exc
|
|
if parsed.aid not in chunk_set:
|
|
raise BilibiliProtocolError(
|
|
f"fav/resource/infos 返回了未请求的 aid={parsed.aid}"
|
|
)
|
|
items[parsed.aid] = parsed
|
|
|
|
missing = tuple(aid for aid in requested if aid not in items)
|
|
return InfosResult(requested=tuple(requested), items=items, missing=missing)
|
|
|
|
def fav_folders(self, up_mid: int | None = None) -> list[FolderInfo]:
|
|
mid = self.mid if up_mid is None else int(up_mid)
|
|
url = f"{BASE}/x/v3/fav/folder/created/list-all?up_mid={mid}&jsonp=jsonp"
|
|
payload = self.get_json(
|
|
url,
|
|
auth=True,
|
|
referer=f"https://space.bilibili.com/{mid}/favlist",
|
|
)
|
|
data = payload.get("data") or {}
|
|
if not isinstance(data, Mapping):
|
|
raise BilibiliProtocolError("fav/folder/created/list-all data 不是对象")
|
|
rows = data.get("list")
|
|
if rows is None:
|
|
return []
|
|
if not isinstance(rows, list):
|
|
raise BilibiliProtocolError("fav/folder/created/list-all data.list 不是数组")
|
|
out: list[FolderInfo] = []
|
|
for index, row in enumerate(rows):
|
|
if not isinstance(row, Mapping):
|
|
raise BilibiliProtocolError(f"folder list[{index}] 不是对象")
|
|
media_id = row.get("id")
|
|
if isinstance(media_id, bool) or not isinstance(media_id, int):
|
|
raise BilibiliProtocolError(f"folder list[{index}].id 不是整数")
|
|
count = row.get("media_count", 0)
|
|
if isinstance(count, bool) or not isinstance(count, int):
|
|
count = 0
|
|
out.append(FolderInfo(
|
|
media_id=media_id,
|
|
title=str(row.get("title") or ""),
|
|
media_count=count,
|
|
))
|
|
return out
|
|
|
|
# ------------------------------------------------------------ view / fan
|
|
|
|
def view(self, aid: int) -> ViewStat:
|
|
url = f"{BASE}/x/web-interface/view?aid={int(aid)}"
|
|
payload = self.get_json(url)
|
|
data = payload.get("data")
|
|
if not isinstance(data, Mapping):
|
|
raise BilibiliProtocolError("view data 不是对象")
|
|
try:
|
|
return ViewStat.from_api(data)
|
|
except (TypeError, ValueError) as exc:
|
|
raise BilibiliProtocolError(f"view 解析失败: {exc}") from exc
|
|
|
|
def relation_stat(self, mid: int) -> FanStat:
|
|
url = f"{BASE}/x/relation/stat?vmid={int(mid)}"
|
|
payload = self.get_json(url)
|
|
data = payload.get("data")
|
|
if not isinstance(data, Mapping):
|
|
raise BilibiliProtocolError("relation/stat data 不是对象")
|
|
try:
|
|
return FanStat.from_api(data)
|
|
except (TypeError, ValueError) as exc:
|
|
raise BilibiliProtocolError(f"relation/stat 解析失败: {exc}") from exc
|
|
|
|
def check_login(self) -> LoginState:
|
|
"""检查 Cookie 登录态;未配置 Cookie 时不发请求。"""
|
|
if not self.cookie:
|
|
return LoginState(
|
|
is_login=False,
|
|
mid=None,
|
|
uname=None,
|
|
code=0,
|
|
message="未配置 cookie",
|
|
)
|
|
url = f"{BASE}/x/web-interface/nav"
|
|
try:
|
|
payload = self.get_json(url, auth=True)
|
|
except BilibiliAPIError as exc:
|
|
return LoginState(
|
|
is_login=False, mid=None, uname=None,
|
|
code=exc.code, message=exc.message,
|
|
)
|
|
except BilibiliError as exc:
|
|
return LoginState(
|
|
is_login=False, mid=None, uname=None,
|
|
code=-1, message=str(exc),
|
|
)
|
|
data = payload.get("data") or {}
|
|
if not isinstance(data, Mapping):
|
|
raise BilibiliProtocolError("nav data 不是对象")
|
|
raw_mid = data.get("mid")
|
|
mid = raw_mid if isinstance(raw_mid, int) and not isinstance(raw_mid, bool) else None
|
|
uname = data.get("uname")
|
|
return LoginState(
|
|
is_login=bool(data.get("isLogin")),
|
|
mid=mid,
|
|
uname=str(uname) if isinstance(uname, str) else None,
|
|
code=0,
|
|
message=str(payload.get("message") or ""),
|
|
)
|