Files
MusicServer/app/sdk/common/request.py
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

225 lines
8.3 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""网络请求层。
对应原 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