feat(http): use httpx with pooled connections and browser headers
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.
This commit is contained in:
1 parent
71a0e8c1d6
commit
5b22ed1c5d
12 files changed
+430
-25
No files matched your search
@@ -45,15 +45,16 @@ def strip_identity(cookie: str) -> str:
|
||||
return "; ".join(kept)
|
||||
|
||||
|
||||
def make_headers(cfg: dict, auth: bool = False) -> dict[str, str]:
|
||||
"""构造 B 站 Web 请求头;``auth=False`` 时去掉账号身份 cookie。"""
|
||||
def make_headers(cfg: dict, auth: bool = False, *, referer: str | None = None) -> dict[str, str]:
|
||||
"""构造与 ``data/raw-request.txt`` 对齐的 B 站 Web 请求头。"""
|
||||
return {
|
||||
"accept": "*/*",
|
||||
"accept-language": "zh-CN,zh;q=0.9,en-US;q=0.8,en;q=0.7",
|
||||
"cookie": cfg["cookie"] if auth else strip_identity(cfg["cookie"]),
|
||||
"dnt": "1",
|
||||
"origin": "https://www.bilibili.com",
|
||||
"referer": "https://www.bilibili.com/",
|
||||
"origin": "https://space.bilibili.com",
|
||||
"priority": "u=1, i",
|
||||
"referer": referer or "https://space.bilibili.com/",
|
||||
"sec-ch-ua": '"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"Windows"',
|
||||
|
||||
@@ -17,6 +17,8 @@ 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,
|
||||
@@ -129,6 +131,54 @@ class UrllibTransport:
|
||||
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:
|
||||
"""请求间随机等待;第一个请求不等待。"""
|
||||
|
||||
@@ -185,7 +235,7 @@ class BilibiliClient:
|
||||
self.cookie = cookie or ""
|
||||
self.csrf = csrf or ""
|
||||
self.mid = mid
|
||||
self.transport = transport or UrllibTransport()
|
||||
self.transport = transport or HttpxTransport()
|
||||
self.pacer = pacer or Pacer(sleep=sleeper)
|
||||
self._sleeper = sleeper
|
||||
self.retries = retries
|
||||
@@ -193,15 +243,22 @@ class BilibiliClient:
|
||||
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) -> dict[str, str]:
|
||||
return make_headers({"cookie": self.cookie}, auth=auth)
|
||||
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) -> dict[str, Any]:
|
||||
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), data=None, timeout=self.timeout
|
||||
"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])
|
||||
@@ -210,11 +267,13 @@ class BilibiliClient:
|
||||
raise BilibiliProtocolError("接口响应顶层不是 JSON 对象")
|
||||
return payload
|
||||
|
||||
def _request_with_retry(self, url: str, *, auth: bool) -> dict[str, Any]:
|
||||
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)
|
||||
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:
|
||||
@@ -238,17 +297,19 @@ class BilibiliClient:
|
||||
raise BilibiliProtocolError("接口响应缺少整数 code")
|
||||
return BilibiliAPIError(code, str(payload.get("message") or ""))
|
||||
|
||||
def get_json(self, url: str, *, auth: bool = False) -> dict[str, Any]:
|
||||
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)
|
||||
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)
|
||||
payload = self._request_with_retry(url, auth=False, referer=referer)
|
||||
if payload.get("code") == 0:
|
||||
return payload
|
||||
anon_error = self._api_error(payload)
|
||||
@@ -257,7 +318,7 @@ class BilibiliClient:
|
||||
|
||||
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)
|
||||
payload = self._request_with_retry(url, auth=True, referer=referer)
|
||||
if payload.get("code") != 0:
|
||||
raise self._api_error(payload)
|
||||
return payload
|
||||
@@ -282,7 +343,11 @@ class BilibiliClient:
|
||||
url = (
|
||||
f"{BASE}/x/v3/fav/resource/ids?media_id={int(media_id)}&platform=web"
|
||||
)
|
||||
payload = self.get_json(url)
|
||||
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):
|
||||
@@ -321,6 +386,10 @@ class BilibiliClient:
|
||||
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)
|
||||
@@ -329,7 +398,7 @@ class BilibiliClient:
|
||||
f"?resources={urllib.parse.quote(resources)}"
|
||||
f"&platform=web&folder_mid={fmid}&folder_id={int(media_id)}"
|
||||
)
|
||||
payload = self.get_json(url)
|
||||
payload = self.get_json(url, referer=referer)
|
||||
data = self._data_list(payload, "fav/resource/infos")
|
||||
chunk_set = set(chunk)
|
||||
for item in data:
|
||||
@@ -351,7 +420,11 @@ class BilibiliClient:
|
||||
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)
|
||||
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 不是对象")
|
||||
|
||||
@@ -33,6 +33,10 @@ class ClientProvider:
|
||||
credentials = getattr(self.store.get().bilibili, self.role)
|
||||
key = (credentials.mid, credentials.cookie, credentials.csrf)
|
||||
if self._client is None or key != self._key:
|
||||
if self._client is not None:
|
||||
close = getattr(self._client, "close", None)
|
||||
if callable(close):
|
||||
close()
|
||||
self._client = self.factory(credentials)
|
||||
self._key = key
|
||||
return self._client
|
||||
@@ -18,9 +18,11 @@ from .client import (
|
||||
|
||||
|
||||
class BilibiliWriteClient(BilibiliClient):
|
||||
def post_form(self, url: str, data: Mapping[str, Any]) -> dict[str, Any]:
|
||||
def post_form(
|
||||
self, url: str, data: Mapping[str, Any], *, referer: str | None = None
|
||||
) -> dict[str, Any]:
|
||||
self.pacer.wait()
|
||||
headers = self._headers(auth=True)
|
||||
headers = self._headers(auth=True, referer=referer)
|
||||
headers["content-type"] = "application/x-www-form-urlencoded"
|
||||
body = urllib.parse.urlencode(data).encode("utf-8")
|
||||
response = self.transport.request(
|
||||
@@ -47,6 +49,10 @@ class BilibiliWriteClient(BilibiliClient):
|
||||
"del_media_ids": "",
|
||||
"csrf": self.csrf,
|
||||
},
|
||||
referer=(
|
||||
f"https://space.bilibili.com/{self.mid}/favlist"
|
||||
f"?fid={int(folder_id)}&ftype=create"
|
||||
),
|
||||
)
|
||||
|
||||
def sort(self, media_id: int, sort_spec: str) -> dict[str, Any]:
|
||||
@@ -57,4 +63,8 @@ class BilibiliWriteClient(BilibiliClient):
|
||||
"sort": sort_spec,
|
||||
"csrf": self.csrf,
|
||||
},
|
||||
referer=(
|
||||
f"https://space.bilibili.com/{self.mid}/favlist"
|
||||
f"?fid={int(media_id)}&ftype=create"
|
||||
),
|
||||
)
|
||||
Reference in new issue
Block a user