feat(python): 完成 Python 全量迁移——剩余平台、登录通道与公共层修复

- 新增 5sing/apple/xm/me/qt/lr 平台包与取流实现(含 xm-sign、MissEvan 签名、蜻蜓 HMAC、Apple developer token)
- 新增 wy/kg/tx/lr 登录:扫码、手机号、账密(kg 20028 风控指纹)、cookie 绑定、kg/tx 续期
- request 层:保留多值 Set-Cookie;JSON body 紧凑化(修复 kg 网关验签);直连不经系统代理(对齐 Node)
- 登录路由统一 Body(embed=True),修复扫码轮询 422;lifespan 接入登录续期调度
- ruff/format/pyright 全绿;14 平台真实联网与 HTTP 路由验证通过
This commit is contained in:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+223
View File
@@ -0,0 +1,223 @@
"""哔哩哔哩扫码登录(对应 sdk/bili/login.ts,上游 bilibili/login.go + account.go 的 IsVipAccount)。
接口:
* GET passport.bilibili.com/x/passport-login/web/qrcode/generate → data.{url, qrcode_key}
* GET passport.bilibili.com/x/passport-login/web/qrcode/poll?qrcode_key= → data.code
0 成功 / 86038 过期 / 86090 已扫码待确认 / 86101 未扫码
成功时登录态 Cookie 在 poll 响应的 Set-Cookie 与 data.url 的 crossDomain 查询串里(两处都收集)。
凭据落盘到 auth_store 的 ``bili`` 记录(cookie / refreshToken / 账号资料),与 kg/wy 一致;
上游 ``New(cookie)`` 的实例化语义由 ``get_credentials('bili')`` 承担(见 platforms/bili/util.py)。
状态码沿用本仓库约定:800 过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功。
"""
from __future__ import annotations
import random
import time
from typing import Any
from urllib.parse import parse_qs, urlsplit
from app.http.api_error import ApiError
from app.sdk.auth_store import get_credentials, set_credentials
from app.sdk.common.qrcode import to_data_url
from app.sdk.common.request import http_fetch
from app.sdk.platforms.bili.util import REFERER, UA, as_dict
QR_GENERATE_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/generate"
QR_POLL_API = "https://passport.bilibili.com/x/passport-login/web/qrcode/poll"
#: 上游 utils.WithRandomIPHeader:随机国内 IP,规避部分区域限制
_CHINA_IP_FIRST = [
36,
42,
58,
60,
101,
106,
110,
111,
112,
113,
114,
115,
116,
117,
118,
119,
120,
121,
122,
123,
124,
125,
171,
175,
180,
182,
183,
210,
211,
218,
219,
220,
221,
222,
223,
]
_CROSS_DOMAIN_KEYS = ("DedeUserID", "DedeUserID__ckMd5", "SESSDATA", "bili_jct", "sid")
def _random_china_ip() -> str:
first = random.choice(_CHINA_IP_FIRST)
return f"{first}.{random.randint(1, 254)}.{random.randint(1, 254)}.{random.randint(1, 254)}"
def _qr_headers() -> dict[str, str]:
ip = _random_china_ip()
return {"User-Agent": UA, "Referer": REFERER, "X-Forwarded-For": ip, "X-Real-IP": ip}
def _cookie_map(cookie_lines: list[str]) -> dict[str, str]:
jar: dict[str, str] = {}
for value in cookie_lines:
pair = str(value).split(";")[0].strip()
idx = pair.find("=")
if idx > 0:
jar[pair[:idx].strip()] = pair[idx + 1 :].strip()
return jar
def _cookie_map_from_cross_domain(cross_url: str) -> dict[str, str]:
"""crossDomain 回跳地址的查询串里也带 SESSDATA 等(无 cookie 包时后备)。"""
jar: dict[str, str] = {}
try:
query = parse_qs(urlsplit(str(cross_url or "")).query)
except ValueError:
return jar
for name in _CROSS_DOMAIN_KEYS:
values = query.get(name)
if values:
jar[name] = values[0]
return jar
def _cookie_string(jar: dict[str, str]) -> str:
return "; ".join(f"{key}={value}" for key, value in jar.items())
async def create_qr_login() -> dict[str, Any]:
"""创建扫码登录会话:返回 ``{key, qrurl, qrimg, expiresAt}``(qrimg 可直接展示)。"""
resp = await http_fetch(QR_GENERATE_API, headers=_qr_headers(), timeout=15_000)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError(f"获取B站登录二维码失败(HTTP {resp.status_code})")
data = as_dict(body.get("data"))
key = str(data.get("qrcode_key") or "").strip()
if body.get("code") != 0 or not key:
message = body.get("message")
raise RuntimeError(f"获取B站登录二维码失败({body.get('code')}{f': {message}' if message else ''})")
url = str(data.get("url") or "")
qrimg = ""
try:
qrimg = to_data_url(url)
except Exception: # noqa: BLE001 - 二维码渲染失败不影响 key/url 返回
qrimg = ""
return {
"key": key,
"qrurl": url,
"qrimg": qrimg,
# 上游 QRLoginSession.ExpiresAt:3 分钟有效期
"expiresAt": int(time.time()) + 180,
}
async def _fetch_profile(cookie: str) -> dict[str, Any]:
"""拉取账号资料(nav;失败不阻断登录)。"""
try:
resp = await http_fetch(
"https://api.bilibili.com/x/web-interface/nav",
headers={"User-Agent": UA, "Referer": REFERER, "Cookie": cookie},
timeout=15_000,
)
body = as_dict(resp.body)
data = as_dict(body.get("data"))
return {
"mid": data.get("mid"),
"nickname": data.get("uname"),
"vipType": int(data.get("vipType") or 0),
"vipStatus": int(data.get("vipStatus") or 0),
}
except Exception: # noqa: BLE001 - 资料失败不阻断登录
return {"mid": None, "nickname": None, "vipType": 0, "vipStatus": 0}
async def _poll(key: str) -> tuple[int, list[str], Any]:
"""轮询请求(http_fetch 的 set_cookies 保留全部 Set-Cookie,登录凭据靠它取)。"""
url = f"{QR_POLL_API}?qrcode_key={key}"
resp = await http_fetch(url, headers={"User-Agent": UA, "Referer": REFERER}, timeout=15_000)
return resp.status_code, resp.set_cookies, resp.body
async def check_qr_login(key: str) -> dict[str, Any]:
"""轮询扫码状态。
返回 code:800 二维码过期 / 801 等待扫码 / 802 已扫码待确认 / 803 登录成功(凭据已落盘)。
"""
qrcode_key = str(key or "").strip()
if not qrcode_key:
raise RuntimeError("B站扫码 key 为空")
status, cookie_lines, body = await _poll(qrcode_key)
if status != 200:
raise ApiError(502, f"轮询B站扫码状态失败(HTTP {status})")
data = as_dict(body.get("data")) if isinstance(body, dict) else {}
code = int(data.get("code") or 0)
if code == 0:
jar = {**_cookie_map_from_cross_domain(data.get("url") or ""), **_cookie_map(cookie_lines)}
cookie = _cookie_string(jar)
if not cookie:
raise RuntimeError("B站登录成功但未获取到 Cookie,请重试")
profile = await _fetch_profile(cookie)
set_credentials(
"bili",
{
"cookie": cookie,
"refreshToken": str(data.get("refresh_token") or ""),
"mid": profile["mid"],
"nickname": profile["nickname"],
"vipType": profile["vipType"],
"vipStatus": profile["vipStatus"],
},
)
from app.sdk.apis.bili import reset_vip_cache
reset_vip_cache()
return {"code": 803, "message": "登录成功", "status": "success", "profile": profile}
status_map: dict[int, tuple[int, str, str]] = {
86038: (800, "二维码已过期", "expired"),
86090: (802, "已扫码,待确认", "scanned"),
86101: (801, "等待扫码", "waiting"),
}
if code in status_map:
mapped, message, state = status_map[code]
return {"code": mapped, "message": message, "status": state}
message = data.get("message") or f"未知状态 {code}"
return {"code": 800, "message": str(message), "status": "failed"}
def status() -> dict[str, Any]:
"""当前登录态(凭据存在性 + 落盘时保存的账号资料)。"""
cred = get_credentials("bili") or {}
if cred.get("cookie"):
return {
"logged": True,
"nickname": cred.get("nickname"),
"mid": cred.get("mid"),
"vipType": cred.get("vipType") or 0,
"updatedAt": cred.get("updatedAt"),
}
return {"logged": False}