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

+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