- 新增 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 路由验证通过
225 lines
8.3 KiB
Python
225 lines
8.3 KiB
Python
"""网络请求层。
|
||
|
||
对应原 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
|