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.
430 lines
13 KiB
Python
430 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
"""B 站只读客户端测试(transport 边界 Mock,不联网):
|
|
uv run python tests/test_bilibili_client.py
|
|
"""
|
|
|
|
import json
|
|
import os
|
|
import sys
|
|
import urllib.parse
|
|
from dataclasses import dataclass, field
|
|
|
|
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
|
sys.stdout.reconfigure(encoding="utf-8")
|
|
|
|
from reaction_board.bilibili.client import ( # noqa: E402
|
|
BilibiliAPIError,
|
|
BilibiliClient,
|
|
BilibiliHTTPError,
|
|
BilibiliProtocolError,
|
|
HTTPResponse,
|
|
Pacer,
|
|
TransportError,
|
|
)
|
|
from fixtures import ( # noqa: E402
|
|
FOLDERS_PAYLOAD,
|
|
IDS_PAYLOAD,
|
|
INVALID_INFO,
|
|
NAV_PAYLOAD,
|
|
RELATION_PAYLOAD,
|
|
VALID_INFO,
|
|
VIEW_PAYLOAD,
|
|
VIEW_UNAVAILABLE,
|
|
info_for,
|
|
view_for,
|
|
)
|
|
|
|
|
|
def http_json(payload, status=200):
|
|
return HTTPResponse(
|
|
status=status,
|
|
headers={"content-type": "application/json"},
|
|
body=json.dumps(payload, ensure_ascii=False).encode("utf-8"),
|
|
)
|
|
|
|
|
|
@dataclass
|
|
class QueueTransport:
|
|
responses: list
|
|
calls: list = field(default_factory=list)
|
|
|
|
def request(self, method, url, headers, data=None, timeout=30):
|
|
self.calls.append({
|
|
"method": method, "url": url, "headers": dict(headers),
|
|
"data": data, "timeout": timeout,
|
|
})
|
|
item = self.responses.pop(0)
|
|
if isinstance(item, BaseException):
|
|
raise item
|
|
return item
|
|
|
|
|
|
@dataclass
|
|
class RouteTransport:
|
|
handler: object
|
|
calls: list = field(default_factory=list)
|
|
|
|
def request(self, method, url, headers, data=None, timeout=30):
|
|
self.calls.append({
|
|
"method": method, "url": url, "headers": dict(headers),
|
|
"data": data, "timeout": timeout,
|
|
})
|
|
return self.handler(url, headers)
|
|
|
|
|
|
def make_client(transport, *, cookie="SESSDATA=secret; buvid3=device", retries=3):
|
|
sleeps = []
|
|
client = BilibiliClient(
|
|
cookie=cookie,
|
|
csrf="csrf",
|
|
mid=28802351,
|
|
transport=transport,
|
|
pacer=Pacer(0, 0, sleep=sleeps.append),
|
|
sleeper=sleeps.append,
|
|
retries=retries,
|
|
backoff=1.0,
|
|
)
|
|
return client, sleeps
|
|
|
|
|
|
def test_fav_ids_parse_and_anonymous_header():
|
|
transport = QueueTransport([http_json(IDS_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
ids = client.fav_ids(3700023351)
|
|
assert len(ids) == 4
|
|
assert ids[0].aid == 117530286889638
|
|
assert ids[0].type == 2
|
|
assert ids[0].bvid == "BV1xvxi6LEZD"
|
|
call = transport.calls[0]
|
|
assert "media_id=3700023351" in call["url"]
|
|
assert "SESSDATA" not in call["headers"]["cookie"]
|
|
assert "buvid3=device" in call["headers"]["cookie"]
|
|
assert call["headers"]["origin"] == "https://space.bilibili.com"
|
|
assert call["headers"]["priority"] == "u=1, i"
|
|
assert "Chrome/155.0.0.0" in call["headers"]["user-agent"]
|
|
assert "fid=3700023351" in call["headers"]["referer"]
|
|
|
|
|
|
def test_anonymous_failure_falls_back_to_full_cookie():
|
|
transport = QueueTransport([
|
|
http_json({"code": -101, "message": "账号未登录", "data": None}),
|
|
http_json(IDS_PAYLOAD),
|
|
])
|
|
client, _ = make_client(transport)
|
|
ids = client.fav_ids(3700023351)
|
|
assert len(ids) == 4
|
|
assert len(transport.calls) == 2
|
|
assert "SESSDATA" not in transport.calls[0]["headers"]["cookie"]
|
|
assert "SESSDATA=secret" in transport.calls[1]["headers"]["cookie"]
|
|
|
|
|
|
def test_business_error_is_not_retried():
|
|
error = {"code": -404, "message": "啥都木有", "data": None}
|
|
transport = QueueTransport([http_json(error), http_json(error)])
|
|
client, sleeps = make_client(transport)
|
|
try:
|
|
client.fav_ids(3700023351)
|
|
except BilibiliAPIError as exc:
|
|
assert exc.code == -404
|
|
assert "啥都木有" in str(exc)
|
|
else:
|
|
raise AssertionError("业务错误应抛出")
|
|
assert len(transport.calls) == 2 # 匿名 + 完整 Cookie 各一次,不重试
|
|
assert sleeps == []
|
|
|
|
|
|
def test_http_500_retries_with_backoff():
|
|
transport = QueueTransport([
|
|
http_json({"code": 0}, status=500),
|
|
http_json({"code": 0}, status=500),
|
|
http_json({"code": 0}, status=500),
|
|
])
|
|
client, sleeps = make_client(transport, retries=3)
|
|
try:
|
|
client.get_json("https://api.bilibili.com/x/test", auth=True)
|
|
except BilibiliHTTPError as exc:
|
|
assert exc.status == 500
|
|
else:
|
|
raise AssertionError("HTTP 500 应抛出")
|
|
assert len(transport.calls) == 3
|
|
assert sleeps == [1.0, 2.0]
|
|
|
|
|
|
def test_network_error_retry_then_success():
|
|
transport = QueueTransport([TransportError("reset"), http_json(IDS_PAYLOAD)])
|
|
client, sleeps = make_client(transport)
|
|
ids = client.fav_ids(3700023351)
|
|
assert len(ids) == 4
|
|
assert len(transport.calls) == 2
|
|
assert sleeps == [1.0]
|
|
|
|
|
|
def test_invalid_json_is_protocol_error():
|
|
bad = HTTPResponse(200, {}, b"not json")
|
|
transport = QueueTransport([bad, bad])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.fav_ids(3700023351)
|
|
except BilibiliProtocolError as exc:
|
|
assert "JSON" in str(exc)
|
|
else:
|
|
raise AssertionError("非法 JSON 应抛出协议错误")
|
|
|
|
|
|
def test_infos_chunking_and_silent_missing():
|
|
aids = list(range(1000, 1450))
|
|
missing = 1003
|
|
|
|
def handler(url, headers):
|
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(url).query)
|
|
resources = query["resources"][0]
|
|
requested = [int(part.split(":")[0]) for part in resources.split(",")]
|
|
data = [
|
|
info_for(aid, title=f"视频{aid}", up_mid=100, up_name="UP")
|
|
for aid in requested if aid != missing
|
|
]
|
|
return http_json({"code": 0, "message": "0", "ttl": 1, "data": data})
|
|
|
|
transport = RouteTransport(handler)
|
|
client, _ = make_client(transport)
|
|
result = client.fav_infos(3700023351, aids, folder_mid=28802351, chunk_size=200)
|
|
assert len(transport.calls) == 3
|
|
sizes = []
|
|
for call in transport.calls:
|
|
query = urllib.parse.parse_qs(urllib.parse.urlparse(call["url"]).query)
|
|
sizes.append(len(query["resources"][0].split(",")))
|
|
assert sizes == [200, 200, 50]
|
|
assert result.requested == tuple(aids)
|
|
assert result.missing == (missing,)
|
|
assert len(result.items) == 449
|
|
assert result.items[1000].title == "视频1000"
|
|
assert result.items[1000].invalid is False
|
|
|
|
|
|
def test_infos_invalid_attr_and_empty():
|
|
transport = QueueTransport([
|
|
http_json({"code": 0, "message": "0", "ttl": 1, "data": [INVALID_INFO]})
|
|
])
|
|
client, _ = make_client(transport)
|
|
result = client.fav_infos(3700023351, [INVALID_INFO["id"]])
|
|
assert result.items[INVALID_INFO["id"]].invalid is True
|
|
assert result.missing == ()
|
|
|
|
# 空输入不发起请求
|
|
empty = client.fav_infos(3700023351, [])
|
|
assert empty.requested == () and empty.items == {}
|
|
assert len(transport.calls) == 1
|
|
|
|
|
|
def test_infos_unexpected_aid_is_protocol_error():
|
|
transport = QueueTransport([http_json({
|
|
"code": 0, "message": "0", "ttl": 1, "data": [VALID_INFO],
|
|
})])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.fav_infos(3700023351, [1])
|
|
except BilibiliProtocolError as exc:
|
|
assert "未请求" in str(exc)
|
|
else:
|
|
raise AssertionError("返回未请求 aid 应报错")
|
|
|
|
|
|
def test_fav_folders_uses_full_cookie():
|
|
transport = QueueTransport([http_json(FOLDERS_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
folders = client.fav_folders()
|
|
assert [(f.media_id, f.media_count) for f in folders] == [
|
|
(4451053851, 167), (3700023351, 664)
|
|
]
|
|
call = transport.calls[0]
|
|
assert "up_mid=28802351" in call["url"]
|
|
assert "SESSDATA=secret" in call["headers"]["cookie"]
|
|
|
|
|
|
def test_fav_infos_chunk_size_validation():
|
|
client, _ = make_client(QueueTransport([]))
|
|
for size in (0, 401):
|
|
try:
|
|
client.fav_infos(1, [1], chunk_size=size)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError(f"chunk_size={size} 应失败")
|
|
|
|
|
|
def test_pacer_delays_after_first_request():
|
|
sleeps = []
|
|
|
|
class Rng:
|
|
def uniform(self, a, b):
|
|
assert (a, b) == (1.5, 2.5)
|
|
return 2.0
|
|
|
|
pacer = Pacer(1.5, 2.5, sleep=sleeps.append, rng=Rng())
|
|
assert pacer.wait() == 0.0
|
|
assert pacer.wait() == 2.0
|
|
assert pacer.wait() == 2.0
|
|
assert sleeps == [2.0, 2.0]
|
|
try:
|
|
Pacer(2.0, 1.0)
|
|
except ValueError:
|
|
pass
|
|
else:
|
|
raise AssertionError("非法间隔应失败")
|
|
|
|
|
|
def test_timeout_and_method_forwarded():
|
|
transport = QueueTransport([http_json(IDS_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
client.fav_ids(1)
|
|
call = transport.calls[0]
|
|
assert call["method"] == "GET"
|
|
assert call["data"] is None
|
|
assert call["timeout"] == 30
|
|
|
|
|
|
def test_view_seven_metrics():
|
|
transport = QueueTransport([http_json(VIEW_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
stat = client.view(117530286889638)
|
|
assert stat.aid == 117530286889638
|
|
assert stat.bvid == "BV1xvxi6LEZD"
|
|
assert stat.metrics() == {
|
|
"view": 819, "like": 44, "favorite": 43, "coin": 9,
|
|
"share": 1, "reply": 16, "danmaku": 7,
|
|
}
|
|
assert "aid=117530286889638" in transport.calls[0]["url"]
|
|
|
|
|
|
def test_view_unavailable_and_malformed():
|
|
transport = QueueTransport([
|
|
http_json(VIEW_UNAVAILABLE), http_json(VIEW_UNAVAILABLE),
|
|
])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.view(1)
|
|
except BilibiliAPIError as exc:
|
|
assert exc.code == 62012
|
|
else:
|
|
raise AssertionError("不可访问视频应抛业务错误")
|
|
|
|
payload = view_for(1)
|
|
payload["data"]["stat"].pop("coin")
|
|
bad = http_json(payload)
|
|
transport = QueueTransport([bad, bad])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.view(1)
|
|
except BilibiliProtocolError as exc:
|
|
assert "coin" in str(exc)
|
|
else:
|
|
raise AssertionError("缺少 stat 字段应报协议错误")
|
|
|
|
|
|
def test_relation_stat():
|
|
transport = QueueTransport([http_json(RELATION_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
fan = client.relation_stat(37754047)
|
|
assert fan.mid == 37754047
|
|
assert fan.follower == 2767268
|
|
assert fan.following == 95
|
|
assert "vmid=37754047" in transport.calls[0]["url"]
|
|
|
|
|
|
def test_check_login():
|
|
transport = QueueTransport([http_json(NAV_PAYLOAD)])
|
|
client, _ = make_client(transport)
|
|
state = client.check_login()
|
|
assert state.is_login is True
|
|
assert state.mid == 28802351
|
|
assert state.uname == "冷萃编译器"
|
|
assert "SESSDATA=secret" in transport.calls[0]["headers"]["cookie"]
|
|
|
|
transport = QueueTransport([http_json({
|
|
"code": 0, "message": "0", "ttl": 1,
|
|
"data": {"isLogin": False, "mid": 0, "uname": ""},
|
|
})])
|
|
client, _ = make_client(transport)
|
|
state = client.check_login()
|
|
assert state.is_login is False and state.mid == 0
|
|
|
|
transport = QueueTransport([http_json({"code": -101, "message": "账号未登录", "data": None})])
|
|
client, _ = make_client(transport)
|
|
state = client.check_login()
|
|
assert state.is_login is False
|
|
assert state.code == -101
|
|
assert "账号未登录" in state.message
|
|
|
|
|
|
def test_check_login_without_cookie_makes_no_request():
|
|
transport = QueueTransport([])
|
|
client, _ = make_client(transport, cookie="")
|
|
state = client.check_login()
|
|
assert state.is_login is False
|
|
assert state.message == "未配置 cookie"
|
|
assert transport.calls == []
|
|
|
|
|
|
def test_missing_data_is_protocol_error():
|
|
for payload in (
|
|
{"code": 0, "message": "OK"},
|
|
{"code": 0, "message": "OK", "data": None},
|
|
):
|
|
transport = QueueTransport([http_json(payload)])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.fav_ids(1)
|
|
except BilibiliProtocolError as exc:
|
|
assert "data" in str(exc)
|
|
else:
|
|
raise AssertionError(f"ids 缺少 data 应报协议错误: {payload}")
|
|
|
|
transport = QueueTransport([http_json(payload)])
|
|
client, _ = make_client(transport)
|
|
try:
|
|
client.fav_infos(1, [123])
|
|
except BilibiliProtocolError as exc:
|
|
assert "data" in str(exc)
|
|
else:
|
|
raise AssertionError(f"infos 缺少 data 应报协议错误: {payload}")
|
|
|
|
|
|
def test_empty_data_list_is_valid():
|
|
transport = QueueTransport([http_json({"code": 0, "message": "OK", "data": []})])
|
|
client, _ = make_client(transport)
|
|
assert client.fav_ids(4413147139) == []
|
|
|
|
transport = QueueTransport([http_json({"code": 0, "message": "OK", "data": []})])
|
|
client, _ = make_client(transport)
|
|
result = client.fav_infos(1, [123, 456])
|
|
assert result.items == {}
|
|
assert result.missing == (123, 456)
|
|
|
|
|
|
def main():
|
|
test_fav_ids_parse_and_anonymous_header()
|
|
test_anonymous_failure_falls_back_to_full_cookie()
|
|
test_business_error_is_not_retried()
|
|
test_http_500_retries_with_backoff()
|
|
test_network_error_retry_then_success()
|
|
test_invalid_json_is_protocol_error()
|
|
test_infos_chunking_and_silent_missing()
|
|
test_infos_invalid_attr_and_empty()
|
|
test_infos_unexpected_aid_is_protocol_error()
|
|
test_fav_folders_uses_full_cookie()
|
|
test_fav_infos_chunk_size_validation()
|
|
test_pacer_delays_after_first_request()
|
|
test_timeout_and_method_forwarded()
|
|
test_view_seven_metrics()
|
|
test_view_unavailable_and_malformed()
|
|
test_relation_stat()
|
|
test_check_login()
|
|
test_check_login_without_cookie_makes_no_request()
|
|
test_missing_data_is_protocol_error()
|
|
test_empty_data_list_is_valid()
|
|
print("OK: B 站只读客户端契约与重试测试全部通过")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|