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

+1
View File
@@ -0,0 +1 @@
"""共享基础设施(请求层、工具函数)。"""
+36
View File
@@ -0,0 +1,36 @@
"""二维码生成 —— 把登录 URL 渲染成 data URL,供 /login/* 的 qrimg 字段直接展示。
对应原 TS 里 `import qrcode from 'qrcode'` 的 `toDataURL` 用法(bili 扫码登录)。
用 Pillow 后端输出 PNG,再 base64 成 data URL,前端 <img src> 可直接用。
"""
from __future__ import annotations
import base64
import io
import qrcode as _qrcode
from qrcode.constants import ERROR_CORRECT_M
def to_data_url(text: str, *, box_size: int = 6, border: int = 2) -> str:
"""生成 `data:image/png;base64,...`。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
encoded = base64.b64encode(buffer.getvalue()).decode("ascii")
return f"data:image/png;base64,{encoded}"
def to_png_bytes(text: str, *, box_size: int = 6, border: int = 2) -> bytes:
"""生成 PNG 原始字节(需要直接返回图片响应时用)。"""
qr = _qrcode.QRCode(version=None, error_correction=ERROR_CORRECT_M, box_size=box_size, border=border)
qr.add_data(text)
qr.make(fit=True)
image = qr.make_image(fill_color="black", back_color="white")
buffer = io.BytesIO()
image.save(buffer, "PNG")
return buffer.getvalue()
+224
View File
@@ -0,0 +1,224 @@
"""网络请求层。
对应原 sdk/common/request.ts,但把 Node 的 `{ promise, cancelHttp }` 句柄收敛为
原生 `async def` —— 取消语义由调用方用 `asyncio.wait_for` / task 取消表达。
保持与原文一致的行为细节:
* 不跟随重定向(Node http 默认行为;需要跟随的调用方自行处理 Location)
* 不保留 cookie(每次请求独立;各平台自己管理 Cookie 头,签名依赖这一点)
* body:dict/list 自动 JSON 序列化,str/bytes 原样发送;form 走 urlencoded
* 未显式给 content-type 时按 body 形式补 application/json / x-www-form-urlencoded
* 响应体:无论 content-type 都先尝试 JSON 解析,失败返回文本
* 64MB 响应上限
* 错误文案与原文一致(超时 / 无法连接到服务器 / 请求失败)
"""
from __future__ import annotations
import asyncio
import json
from collections.abc import Mapping
from dataclasses import dataclass, field
from typing import Any
import aiohttp
MAX_BUFFER_SIZE = 64 * 1024 * 1024
DEFAULT_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.100 Safari/537.36"
),
}
MSG_CANCEL = "请求已取消"
MSG_FAIL = "请求失败"
MSG_TIMEOUT = "请求超时"
MSG_NOT_CONNECT = "无法连接到服务器"
@dataclass
class HttpResponse:
status_code: int
headers: dict[str, str] = field(default_factory=dict)
body: Any = None
raw: bytes = b""
#: 原始多值 Set-Cookie(headers 折叠同名头后只留最后一个,登录链路必须按数组读)
set_cookies: list[str] = field(default_factory=list)
@property
def statusCode(self) -> int: # 兼容原文命名
return self.status_code
def header(self, name: str, default: str = "") -> str:
return self.headers.get(name.lower(), default)
def json(self) -> Any:
"""把 body 当 JSON 取;body 已是结构时原样返回。"""
return self.body
_session: aiohttp.ClientSession | None = None
_session_loop: asyncio.AbstractEventLoop | None = None
def _get_session() -> aiohttp.ClientSession:
"""进程级连接池。禁用 cookie jar 与自动重定向,语义对齐 Node http。
trust_env=False:Node 的 http/https 不读系统代理,而 aiohttp 在 Windows 上会通过
注册表拿到「Internet 选项」里的代理(本机是 127.0.0.1:7890)。走该代理会让部分域名
(如 xm 的 hdaa.shuzilm.cn)连不上,且所有请求的出口 IP/风控画像与 TS 版不一致,
因此这里显式直连,与 Node 行为保持一致。
"""
global _session, _session_loop
loop = asyncio.get_running_loop()
if _session is None or _session.closed or _session_loop is not loop:
_session = aiohttp.ClientSession(
cookie_jar=aiohttp.DummyCookieJar(),
auto_decompress=True,
trust_env=False,
connector=aiohttp.TCPConnector(limit=200, ttl_dns_cache=300),
)
_session_loop = loop
return _session
async def close_session() -> None:
global _session
if _session is not None and not _session.closed:
await _session.close()
_session = None
def _parse_body(raw: bytes) -> Any:
if not raw:
return None
text = raw.decode("utf-8", errors="replace")
try:
return json.loads(text)
except (ValueError, TypeError):
return text
def _build_body(options: Mapping[str, Any]) -> tuple[bytes | None, bool]:
form = options.get("form")
if form:
from urllib.parse import urlencode
pairs: list[tuple[str, str]] = []
for key, value in form.items():
if value is None:
continue
# 对象值对齐 URLSearchParams 的 JSON.stringify(紧凑、无空格)
pairs.append(
(
key,
json.dumps(value, ensure_ascii=False, separators=(",", ":"))
if isinstance(value, (dict, list))
else str(value),
)
)
return urlencode(pairs).encode(), True
body = options.get("body")
if body is None:
return None, False
if isinstance(body, bytes):
return body, False
if isinstance(body, str):
return body.encode(), False
# 必须与 JS 的 JSON.stringify 逐字节一致(紧凑分隔符):
# 部分平台(kg 网关)的 signature 就是按紧凑 JSON 计算的,空格会导致验签失败。
return json.dumps(body, ensure_ascii=False, separators=(",", ":")).encode(), False
def _flatten_headers(headers: Mapping[str, Any]) -> dict[str, str]:
return {str(k): str(v) for k, v in headers.items()}
async def http_fetch(url: str, **options: Any) -> HttpResponse:
"""发起 HTTP 请求并返回完整响应。
options: method / headers / body / form / timeout(毫秒) / lookup / family
"""
method = str(options.get("method") or "GET").upper()
payload, is_form = _build_body(options)
headers = {**DEFAULT_HEADERS, **_flatten_headers(options.get("headers") or {})}
lowered = {k.lower() for k in headers}
if payload is not None:
if "content-type" not in lowered:
headers["content-type"] = "application/x-www-form-urlencoded" if is_form else "application/json"
if "content-length" not in lowered:
headers["content-length"] = str(len(payload))
timeout_ms = options.get("timeout")
timeout = aiohttp.ClientTimeout(total=(timeout_ms / 1000) if timeout_ms else 60)
session = _get_session()
try:
async with session.request(
method,
url,
headers=headers,
data=payload,
timeout=timeout,
allow_redirects=False,
) as resp:
chunks: list[bytes] = []
size = 0
async for chunk in resp.content.iter_chunked(64 * 1024):
size += len(chunk)
if size > MAX_BUFFER_SIZE:
raise RuntimeError("Response too large")
chunks.append(chunk)
raw = b"".join(chunks)
return HttpResponse(
status_code=resp.status,
headers={k.lower(): v for k, v in resp.headers.items()},
body=_parse_body(raw),
raw=raw,
set_cookies=list(resp.headers.getall("set-cookie", [])),
)
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorDNSError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientConnectionError as err:
if isinstance(err, aiohttp.ServerDisconnectedError):
raise ConnectionError(MSG_FAIL) from err
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_probe(url: str, *, headers: Mapping[str, Any] | None = None, timeout_ms: int = 8000) -> tuple[int, str]:
"""只读响应头的探测请求:返回 ``(status, location)``,**不消费 body**。
取流 CDN 探测专用(bili):CDN 忽略 Range 时会尝试把整段音频推下来,
等 http_fetch 读完 body 就等于把文件下载完(点歌卡死)。拿到响应头即退出上下文,
aiohttp 随即中止并关闭连接。不跟随重定向,调用方用 location 自行跟跳(对应 Node 的 manual)。
"""
timeout_obj = aiohttp.ClientTimeout(total=timeout_ms / 1000)
session = _get_session()
try:
async with session.request(
"GET",
url,
headers={**DEFAULT_HEADERS, **_flatten_headers(headers or {})},
timeout=timeout_obj,
allow_redirects=False,
) as resp:
return resp.status, resp.headers.get("location", "")
except TimeoutError as err:
raise TimeoutError(MSG_TIMEOUT) from err
except aiohttp.ClientConnectorError as err:
raise ConnectionError(MSG_NOT_CONNECT) from err
except aiohttp.ClientError as err:
raise RuntimeError(MSG_FAIL) from err
async def http_get_json(url: str, **options: Any) -> Any:
"""GET 并返回解析后的 body。"""
options.setdefault("method", "get")
resp = await http_fetch(url, **options)
return resp.body
+158
View File
@@ -0,0 +1,158 @@
"""通用工具函数(对应原 sdk/common/utils.ts 与 sdk/utils.ts)。"""
from __future__ import annotations
import hashlib
import math
import re
import time
from collections.abc import Iterable, Mapping
from datetime import datetime
from typing import Any
def to_md5(text: str) -> str:
return hashlib.md5(text.encode("utf-8")).hexdigest()
def as_dict(value: Any) -> dict[str, Any]:
"""上游 ``x?.field ?? {}`` 的安全取法:非 dict 一律当空 dict。"""
return value if isinstance(value, dict) else {}
def as_list(value: Any) -> list[Any]:
"""上游 ``x ?? []`` 的安全取法:非 list 一律当空列表。"""
return value if isinstance(value, list) else []
def size_formate(size: float | int | None) -> str:
"""将字节数格式化为可读字符串。"""
if not size:
return "0 B"
units = ["B", "KB", "MB", "GB", "TB"]
number = math.floor(math.log(size) / math.log(1024))
number = min(number, len(units) - 1)
return f"{size / math.pow(1024, number):.2f} {units[number]}"
def format_play_time(seconds: float | int | str | None) -> str:
"""秒数 → mm:ss;0 返回 --/--。"""
try:
value = float(seconds) # type: ignore[arg-type]
except (TypeError, ValueError):
return "--/--"
if math.isnan(value) or value < 0:
value = 0
minutes = int(value // 60)
secs = int(value % 60)
if minutes == 0 and secs == 0:
return "--/--"
return f"{minutes:02d}:{secs:02d}"
def date_format(value: Any, fmt: str = "Y-M-D h:m:s") -> str:
"""时间对象/时间戳/字符串 → 日期字符串(Y/M/D/h/m/s 占位)。"""
if not value:
return ""
if isinstance(value, datetime):
date = value
elif isinstance(value, (int, float)):
date = datetime.fromtimestamp(value / 1000 if value > 1e11 else value)
elif isinstance(value, str):
raw = value
if "T" not in raw:
raw = raw.split(".")[0].replace("-", "/")
parsed: datetime | None = None
for pattern in ("%Y/%m/%d %H:%M:%S", "%Y/%m/%d", "%Y-%m-%dT%H:%M:%S", "%Y-%m-%dT%H:%M:%S.%f"):
try:
parsed = datetime.strptime(raw[: len(pattern) + 10], pattern)
break
except ValueError:
continue
if parsed is None:
try:
parsed = datetime.fromisoformat(value.replace("Z", "+00:00"))
except ValueError:
return ""
date = parsed
else:
return ""
return (
fmt.replace("Y", str(date.year))
.replace("M", f"{date.month:02d}")
.replace("D", f"{date.day:02d}")
.replace("h", f"{date.hour:02d}")
.replace("m", f"{date.minute:02d}")
.replace("s", f"{date.second:02d}")
)
def date_format2(timestamp_ms: float | int) -> str:
"""相对时间格式化(用于评论时间)。"""
differ = int((time.time() * 1000 - float(timestamp_ms)) / 1000)
if differ < 60:
return f"{differ}秒前"
if differ < 3600:
return f"{differ // 60}分钟前"
if differ < 86400:
return f"{differ // 3600}小时前"
if differ < 2592000:
return f"{differ // 86400}天前"
return date_format(timestamp_ms)
def format_play_count(num: float | int | None) -> str:
if num is None:
return "0"
if num > 100000000:
return f"{int(num / 10000000) / 10}亿"
if num > 10000:
return f"{int(num / 1000) / 10}万"
return str(num)
_ENTITY_MAP = {
"&amp;": "&",
"&lt;": "<",
"&gt;": ">",
"&quot;": '"',
"&#39;": "'",
"&apos;": "'",
"&nbsp;": " ",
}
def decode_name(text: Any = "") -> str:
"""解码 HTML 实体。"""
if not text:
return ""
if not isinstance(text, str):
text = str(text)
for entity, char in _ENTITY_MAP.items():
text = text.replace(entity, char)
return re.sub(r"&#(\d+);", lambda m: chr(int(m.group(1))), text)
def format_singer_name(
singers: Iterable[Mapping[str, Any]] | str | None,
name_key: str = "name",
join: str = "、",
) -> str:
"""歌手数组或字符串 → 展示名(数组按 name_key 提取后 join)。"""
if isinstance(singers, (list, tuple)):
names = [str(item[name_key]) for item in singers if isinstance(item, Mapping) and item.get(name_key)]
return decode_name(join.join(names))
return decode_name(str(singers or ""))
def pick(data: Any, *keys: str, default: Any = None) -> Any:
"""沿键路径取值(dict 缺失返回 default),迁移时统一空值访问。"""
cur = data
for key in keys:
if isinstance(cur, Mapping):
cur = cur.get(key)
else:
return default
if cur is None:
return default
return cur