refactor(favsort): send bilibili requests through httpx

Reuse a pooled httpx client for favsort GET and POST requests, share the raw-request-derived browser headers, and add folder-specific referers for ids, infos and sort. Add transport tests for header parity and anonymous-to-authenticated fallback.
This commit is contained in:
kairee committed 2026-10-31 22:36:48 +08:00
1 parent 5b22ed1c5d
commit 8a87ebfb7c
5 files changed
+160 -22

No files matched your search

+1 -1
View File
@@ -654,7 +654,7 @@ uv run python tests/test_moves.py
- `app.py` 新增 `discover` / `candidates`;`run` 注册发现与候选清理任务,并启动 Telegram poller; - `app.py` 新增 `discover` / `candidates`;`run` 注册发现与候选清理任务,并启动 Telegram poller;
- 真实只读联调:4 次 WBI 搜索、168 条结果、1 条候选(score=6),Telegram 通知发送成功; - 真实只读联调:4 次 WBI 搜索、168 条结果、1 条候选(score=6),Telegram 通知发送成功;
- 真实 `deal` / `sort` 尚未在收藏夹执行,等待用户点击 Telegram 按钮或授权测试视频后验证; - 真实 `deal` / `sort` 尚未在收藏夹执行,等待用户点击 Telegram 按钮或授权测试视频后验证;
- 自动化测试见 `tests/test_classify.py`、`test_search.py`、`test_candidates.py`、`test_discovery.py`、`test_fav_position.py`、`test_favorites.py`、`test_telegram.py`。 - 自动化测试见 `tests/test_classify.py`、`test_search.py`、`test_candidates.py`、`test_discovery.py`、`test_fav_position.py`、`test_favorites.py`、`test_telegram.py`、`test_httpx_transport.py`、`test_favsort_http.py`。
### P2:本地看板 ### P2:本地看板
+1
View File
@@ -275,6 +275,7 @@ sort=A:2:B:2 → 将 B 插到 A 之后
```powershell ```powershell
uv run python tests/test_auth.py # Cookie 合并、设备指纹、Set-Cookie 解析 uv run python tests/test_auth.py # Cookie 合并、设备指纹、Set-Cookie 解析
uv run python tests/test_moves.py # 排序移动脚本:随机排列与头部用例 uv run python tests/test_moves.py # 排序移动脚本:随机排列与头部用例
uv run python tests/test_favsort_http.py # favsort httpx 请求头与匿名回退
uv run python tests/test_config.py # 配置加载、校验、ENV 覆盖与热加载 uv run python tests/test_config.py # 配置加载、校验、ENV 覆盖与热加载
uv run python tests/test_migrations.py # 迁移、备份、回滚与保留期 uv run python tests/test_migrations.py # 迁移、备份、回滚与保留期
uv run python tests/test_database.py # 单写队列、事务与并发 uv run python tests/test_database.py # 单写队列、事务与并发
+64 -20
View File
@@ -28,7 +28,8 @@ import random
import sys import sys
import time import time
import urllib.parse import urllib.parse
import urllib.request
import httpx
sys.stdout.reconfigure(encoding="utf-8") sys.stdout.reconfigure(encoding="utf-8")
@@ -95,41 +96,67 @@ def load_config(require_auth=True):
# ---------------------------------------------------------------- http # ---------------------------------------------------------------- http
def _get_json(cfg, url, auth): _HTTP_CLIENT: httpx.Client | None = None
req = urllib.request.Request(url, headers=make_headers(cfg, auth=auth))
with urllib.request.urlopen(req, timeout=30) as r:
return json.loads(r.read().decode("utf-8", "replace"))
def get_json(cfg, url, auth=False): def _http_client() -> httpx.Client:
global _HTTP_CLIENT
if _HTTP_CLIENT is None:
_HTTP_CLIENT = httpx.Client(
timeout=httpx.Timeout(30, connect=10),
limits=httpx.Limits(
max_keepalive_connections=5,
keepalive_expiry=30,
),
follow_redirects=False,
trust_env=True,
)
return _HTTP_CLIENT
def _get_json(cfg, url, auth, referer=None):
response = _http_client().get(
url,
headers=make_headers(cfg, auth=auth, referer=referer),
timeout=30,
)
response.raise_for_status()
return response.json()
def get_json(cfg, url, auth=False, referer=None):
"""只读请求:默认用去掉账号身份的 cookie。 """只读请求:默认用去掉账号身份的 cookie。
万一匿名请求被拒(HTTP 错误,或 code != 0),自动带完整 cookie 重试一次, 万一匿名请求被拒(HTTP 错误,或 code != 0),自动带完整 cookie 重试一次,
保证功能不会因为剥离身份而回退。 保证功能不会因为剥离身份而回退。
""" """
if auth: if auth:
return _get_json(cfg, url, auth=True) return _get_json(cfg, url, auth=True, referer=referer)
try: try:
r = _get_json(cfg, url, auth=False) r = _get_json(cfg, url, auth=False, referer=referer)
if r.get("code") == 0: if r.get("code") == 0:
return r return r
except Exception: # noqa: BLE001 网络错误 / 412 风控都走回退 except Exception: # noqa: BLE001 网络错误 / 412 风控都走回退
pass pass
return _get_json(cfg, url, auth=True) return _get_json(cfg, url, auth=True, referer=referer)
def post_form(cfg, url, data): def post_form(cfg, url, data, referer=None):
headers = make_headers(cfg, auth=True) headers = make_headers(cfg, auth=True, referer=referer)
headers["content-type"] = "application/x-www-form-urlencoded" headers["content-type"] = "application/x-www-form-urlencoded"
req = urllib.request.Request( response = _http_client().post(
url, data=urllib.parse.urlencode(data).encode(), headers=headers, method="POST" url, headers=headers, data=data, timeout=30
) )
with urllib.request.urlopen(req, timeout=30) as r: response.raise_for_status()
return json.loads(r.read().decode("utf-8", "replace")) return response.json()
def fetch_ids(cfg, media_id): def fetch_ids(cfg, media_id):
r = get_json(cfg, API["ids"].format(base=BASE, mid=media_id)) referer = (
f"https://space.bilibili.com/{cfg['mid']}/favlist"
f"?fid={media_id}&ftype=create"
)
r = get_json(cfg, API["ids"].format(base=BASE, mid=media_id), referer=referer)
if r.get("code") != 0: if r.get("code") != 0:
raise SystemExit(f"ids 失败: code={r.get('code')} msg={r.get('message')}") raise SystemExit(f"ids 失败: code={r.get('code')} msg={r.get('message')}")
return r.get("data") or [] return r.get("data") or []
@@ -137,13 +164,17 @@ def fetch_ids(cfg, media_id):
def fetch_infos(cfg, media_id, folder_mid, id_list): def fetch_infos(cfg, media_id, folder_mid, id_list):
items, calls = [], 0 items, calls = [], 0
referer = (
f"https://space.bilibili.com/{folder_mid}/favlist"
f"?fid={media_id}&ftype=create"
)
for i in range(0, len(id_list), CHUNK): for i in range(0, len(id_list), CHUNK):
chunk = id_list[i:i + CHUNK] chunk = id_list[i:i + CHUNK]
res = ",".join(f"{x['id']}:{x['type']}" for x in chunk) res = ",".join(f"{x['id']}:{x['type']}" for x in chunk)
url = API["infos"].format( url = API["infos"].format(
base=BASE, res=urllib.parse.quote(res), fmid=folder_mid, mid=media_id base=BASE, res=urllib.parse.quote(res), fmid=folder_mid, mid=media_id
) )
r = get_json(cfg, url) r = get_json(cfg, url, referer=referer)
calls += 1 calls += 1
if r.get("code") != 0: if r.get("code") != 0:
raise SystemExit(f"infos 失败 @{i}: code={r.get('code')} msg={r.get('message')}") raise SystemExit(f"infos 失败 @{i}: code={r.get('code')} msg={r.get('message')}")
@@ -310,7 +341,12 @@ def load_plan(media_id):
def cmd_folders(cfg, args): def cmd_folders(cfg, args):
# 注意:list-all 是"列出我自己的收藏夹",匿名调用只会返回公开收藏夹(实测 11 -> 3), # 注意:list-all 是"列出我自己的收藏夹",匿名调用只会返回公开收藏夹(实测 11 -> 3),
# 因此这里必须带完整 cookie(仍然只是 GET,不会改动任何数据)。 # 因此这里必须带完整 cookie(仍然只是 GET,不会改动任何数据)。
r = get_json(cfg, API["folders"].format(base=BASE, mid=cfg["mid"]), auth=True) r = get_json(
cfg,
API["folders"].format(base=BASE, mid=cfg["mid"]),
auth=True,
referer=f"https://space.bilibili.com/{cfg['mid']}/favlist",
)
if r.get("code") != 0: if r.get("code") != 0:
raise SystemExit(f"获取收藏夹列表失败: {r.get('code')} {r.get('message')}") raise SystemExit(f"获取收藏夹列表失败: {r.get('code')} {r.get('message')}")
lst = (r.get("data") or {}).get("list") or [] lst = (r.get("data") or {}).get("list") or []
@@ -431,6 +467,10 @@ def cmd_run(cfg, args):
work = list(actual) work = list(actual)
total = len(moves) total = len(moves)
long_after = random.randint(sc["long_every_min"], sc["long_every_max"]) long_after = random.randint(sc["long_every_min"], sc["long_every_max"])
sort_referer = (
f"https://space.bilibili.com/{cfg['mid']}/favlist"
f"?fid={media_id}&ftype=create"
)
for i, m in enumerate(moves, 1): for i, m in enumerate(moves, 1):
pred_type = types.get(m["pred"], 2) pred_type = types.get(m["pred"], 2)
@@ -442,14 +482,18 @@ def cmd_run(cfg, args):
} }
print(f"[run] {i}/{total} {titles[str(m['moved'])]['title'][:30]} " print(f"[run] {i}/{total} {titles[str(m['moved'])]['title'][:30]} "
f"-> after {m['pred']}", end=" ", flush=True) f"-> after {m['pred']}", end=" ", flush=True)
resp = post_form(cfg, API["sort"].format(base=BASE), body) resp = post_form(
cfg, API["sort"].format(base=BASE), body, referer=sort_referer
)
code = resp.get("code") code = resp.get("code")
print(f"code={code}") print(f"code={code}")
if code != 0: if code != 0:
wait = sc["retry_wait"] wait = sc["retry_wait"]
print(f"[run] 返回非 0,冷却 {wait}s 后重试一次") print(f"[run] 返回非 0,冷却 {wait}s 后重试一次")
time.sleep(wait) time.sleep(wait)
resp = post_form(cfg, API["sort"].format(base=BASE), body) resp = post_form(
cfg, API["sort"].format(base=BASE), body, referer=sort_referer
)
code = resp.get("code") code = resp.get("code")
print(f"[run] retry code={code}") print(f"[run] retry code={code}")
if code != 0: if code != 0:
+91
View File
@@ -0,0 +1,91 @@
# -*- coding: utf-8 -*-
"""favsort HTTP 层测试(httpx MockTransport,不联网):
uv run python tests/test_favsort_http.py
"""
import os
import sys
import httpx
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
sys.stdout.reconfigure(encoding="utf-8")
import favsort # noqa: E402
def with_client(handler):
client = httpx.Client(transport=httpx.MockTransport(handler))
original = favsort._HTTP_CLIENT
favsort._HTTP_CLIENT = client
return client, original
def test_get_json_and_post_form_headers():
calls = []
def handler(request):
calls.append(request)
return httpx.Response(200, json={"code": 0, "data": []})
client, original = with_client(handler)
try:
cfg = {"cookie": "SESSDATA=x; buvid3=y"}
assert favsort.get_json(
cfg, "https://api.bilibili.com/x/test", auth=False
) == {"code": 0, "data": []}
first = calls[0]
assert first.headers["origin"] == "https://space.bilibili.com"
assert first.headers["priority"] == "u=1, i"
assert "Chrome/155.0.0.0" in first.headers["user-agent"]
assert "SESSDATA" not in first.headers["cookie"]
assert "buvid3=y" in first.headers["cookie"]
favsort.post_form(
cfg,
"https://api.bilibili.com/x/v3/fav/resource/sort",
{"a": "b"},
referer="https://space.bilibili.com/1/favlist?fid=2",
)
post = calls[1]
assert post.method == "POST"
assert post.headers["content-type"] == "application/x-www-form-urlencoded"
assert post.headers["referer"] == "https://space.bilibili.com/1/favlist?fid=2"
assert post.content == b"a=b"
assert "SESSDATA=x" in post.headers["cookie"]
finally:
favsort._HTTP_CLIENT = original
client.close()
def test_anonymous_failure_falls_back_to_full_cookie():
calls = []
def handler(request):
calls.append(request)
if len(calls) == 1:
return httpx.Response(200, json={"code": -101, "message": "未登录"})
return httpx.Response(200, json={"code": 0, "data": []})
client, original = with_client(handler)
try:
cfg = {"cookie": "SESSDATA=x; buvid3=y"}
assert favsort.get_json(cfg, "https://api.bilibili.com/x/test") == {
"code": 0, "data": [],
}
assert len(calls) == 2
assert "SESSDATA" not in calls[0].headers["cookie"]
assert "SESSDATA=x" in calls[1].headers["cookie"]
finally:
favsort._HTTP_CLIENT = original
client.close()
def main():
test_get_json_and_post_form_headers()
test_anonymous_failure_falls_back_to_full_cookie()
print("OK: favsort httpx 请求头与匿名回退测试全部通过")
if __name__ == "__main__":
main()
+3 -1
View File
@@ -74,7 +74,9 @@ def test_sort_preserves_resource_types():
return [{"id": aid} for aid in order] return [{"id": aid} for aid in order]
favsort.fetch_ids = fetch_ids favsort.fetch_ids = fetch_ids
favsort.post_form = lambda cfg, url, body: bodies.append(body) or {"code": 0} favsort.post_form = (
lambda cfg, url, body, referer=None: bodies.append(body) or {"code": 0}
)
args = Args() args = Args()
try: try:
code = favsort.cmd_run(config(), args) code = favsort.cmd_run(config(), args)