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:
1 parent
800871ef6e
commit
dfc06bf4de
182 files changed
+26620
No files matched your search
@@ -0,0 +1,296 @@
|
||||
"""千千音乐(music.91q.com / 91q.com)公共请求层(对应 sdk/bd/util.ts)。
|
||||
|
||||
上游全部接口都走 `/v1/*`,参数 = 业务参数 + appid + timestamp + sign:
|
||||
|
||||
sign = md5(按 key 升序拼接 "k=v&..."(不含 sign)+ Secret)
|
||||
|
||||
匿名即可搜索 / 查专辑 / 查歌单 / 拿免费曲目直链;cookie 只影响 VIP、收藏等登录态资源。
|
||||
|
||||
**签名串与 query 必须分开编码**:sign 用参数原值拼接(服务端拿收到的参数值重算),
|
||||
query 用 `URLSearchParams` 序列化。两者共用同一份 `sign_params` 结果,且 URL 拼好后
|
||||
直接交给 `http_fetch`(不再二次序列化),这是历史上「签名串 ≠ 实际 body」踩过的坑。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import math
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.auth_store import get_cookie
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import size_formate
|
||||
|
||||
APP_ID = "16073360"
|
||||
SECRET = "0b50b02fd0d73a9c4c8c3a781c30845f"
|
||||
USER_AGENT = (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36"
|
||||
)
|
||||
REFERER = "https://music.91q.com/player"
|
||||
API_BASE = "https://music.91q.com"
|
||||
|
||||
# 上游音质档位顺序(3000 = flac,其余为 kbps)
|
||||
RATE_ORDER = ["3000", "320", "128", "64"]
|
||||
|
||||
# URLSearchParams 不编码的字符集。与 Python quote 的默认安全字符不同:
|
||||
# URLSearchParams 保留 `*` 却编码 `~`,差一个字符服务端就验签失败,故自行实现。
|
||||
_FORM_SAFE = frozenset("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789*-._")
|
||||
|
||||
# rateFileInfo 档位 → MusicServer 既有 Quality 桶(64k 不参与 types 展示,仅影响取流回退)
|
||||
_RATE_LABEL = {"128": "128k", "320": "320k", "3000": "flac"}
|
||||
|
||||
# 上游 sanitizeQianqianAlbumKeyword 的特殊字符集
|
||||
_ALBUM_KEYWORD_SPECIALS = re.compile(r"""[::"'“”‘’()()\[\]【】,,/\\\-.]""")
|
||||
|
||||
|
||||
def to_number(value: Any) -> float:
|
||||
"""JS `Number()` 语义:None(undefined) 与不可解析 → NaN,bool → 1/0,空串 → 0。"""
|
||||
if value is None:
|
||||
return math.nan
|
||||
if isinstance(value, bool):
|
||||
return 1.0 if value else 0.0
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
text = str(value).strip()
|
||||
if not text:
|
||||
return 0.0
|
||||
try:
|
||||
return float(text)
|
||||
except ValueError:
|
||||
return math.nan
|
||||
|
||||
|
||||
def num_or_zero(value: Any) -> float:
|
||||
"""`Number(value ?? 0)` 语义:缺失(null/undefined)视作 0,不可解析才是 NaN。"""
|
||||
return 0.0 if value is None else to_number(value)
|
||||
|
||||
|
||||
def to_int(value: Any, fallback: int = 0) -> int:
|
||||
"""`Number(value) || fallback` 语义:0 / NaN / 不可解析都落到 fallback。"""
|
||||
number = to_number(value)
|
||||
if not number or math.isnan(number):
|
||||
return fallback
|
||||
return int(number)
|
||||
|
||||
|
||||
def form_encode(value: Any) -> str:
|
||||
"""`new URLSearchParams(...).toString()` 的逐字节等价实现(空格写 `+`,其余按 UTF-8 编码)。"""
|
||||
out: list[str] = []
|
||||
for byte in str(value).encode("utf-8"):
|
||||
char = chr(byte)
|
||||
if char in _FORM_SAFE:
|
||||
out.append(char)
|
||||
elif char == " ":
|
||||
out.append("+")
|
||||
else:
|
||||
out.append(f"%{byte:02X}")
|
||||
return "".join(out)
|
||||
|
||||
|
||||
def get_qian_cookie() -> str:
|
||||
"""千千平台 cookie(未绑定返回空串;上游所有请求都会带上该 header,此处保持一致)。"""
|
||||
return get_cookie("bd")
|
||||
|
||||
|
||||
def request_headers() -> dict[str, str]:
|
||||
headers = {
|
||||
"User-Agent": USER_AGENT,
|
||||
"Referer": REFERER,
|
||||
"Accept": "application/json, text/plain, */*",
|
||||
}
|
||||
cookie = get_qian_cookie()
|
||||
if cookie:
|
||||
headers["Cookie"] = cookie
|
||||
return headers
|
||||
|
||||
|
||||
def sign_params(params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""写入当前秒级时间戳,按 key 字典序拼串后追加 Secret 做 md5。"""
|
||||
signed: dict[str, Any] = {str(key): value for key, value in params.items()}
|
||||
signed["timestamp"] = str(int(time.time()))
|
||||
payload = "&".join(f"{key}={signed[key]}" for key in sorted(signed)) + SECRET
|
||||
signed["sign"] = hashlib.md5(payload.encode("utf-8")).hexdigest()
|
||||
return signed
|
||||
|
||||
|
||||
def signed_url(path: str, params: dict[str, Any]) -> str:
|
||||
"""签名后的完整 URL:签名用原值,query 用 URLSearchParams 等价编码。"""
|
||||
query = "&".join(f"{key}={form_encode(value)}" for key, value in sign_params(params).items())
|
||||
return f"{API_BASE}{path}?{query}"
|
||||
|
||||
|
||||
async def api_get(path: str, params: dict[str, Any]) -> Any:
|
||||
"""发起带签名的 GET,返回响应 JSON body(非 200 按原文案抛错)。"""
|
||||
resp = await http_fetch(signed_url(path, params), method="get", headers=request_headers(), timeout=15000)
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(f"千千音乐接口失败(HTTP {resp.status_code})")
|
||||
return resp.body if resp.body is not None else {}
|
||||
|
||||
|
||||
def api_ok(body: Any) -> bool:
|
||||
"""上游状态判定:state=true 或 errno=22000 视为成功。"""
|
||||
if not isinstance(body, dict):
|
||||
return False
|
||||
return body.get("state") is True or to_number(body.get("errno")) == 22000
|
||||
|
||||
|
||||
def error_text(body: Any, prefix: str) -> str:
|
||||
"""上游错误文案:errmsg 优先,缺失时拼 errno(两种文案都逐字沿用原实现)。"""
|
||||
if not isinstance(body, dict):
|
||||
return prefix
|
||||
message = str(body.get("errmsg") or "").strip()
|
||||
if message:
|
||||
return message
|
||||
return f"{prefix}(errno {body.get('errno')})"
|
||||
|
||||
|
||||
async def track_link(tsid: str, rate: str) -> Any:
|
||||
"""tracklink 单档位请求(data 内含 path/format/rate/size;VIP 曲目无 path)。"""
|
||||
return await api_get("/v1/song/tracklink", {"TSID": tsid, "appid": APP_ID, "rate": rate})
|
||||
|
||||
|
||||
def interval_of(duration: Any) -> str | int:
|
||||
"""时间格式 mm:ss(duration 为秒;无效或 <=0 显示 0)。"""
|
||||
seconds = to_number(duration)
|
||||
if math.isnan(seconds) or seconds <= 0:
|
||||
return 0
|
||||
return f"{int(seconds // 60):02d}:{int(seconds % 60):02d}"
|
||||
|
||||
|
||||
def join_artists(artists: Any) -> str:
|
||||
"""千千歌手数组 → 展示名(优先 artistType=38 歌手;去重后以 、 连接)。"""
|
||||
if not isinstance(artists, list) or not artists:
|
||||
return ""
|
||||
names: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
def add(name: Any) -> None:
|
||||
text = "" if name is None else str(name).strip()
|
||||
if not text or text in seen:
|
||||
return
|
||||
seen.add(text)
|
||||
names.append(text)
|
||||
|
||||
for item in artists:
|
||||
if isinstance(item, dict) and to_number(item.get("artistType")) == 38:
|
||||
add(item.get("name"))
|
||||
if not names:
|
||||
for item in artists:
|
||||
if isinstance(item, dict):
|
||||
add(item.get("name"))
|
||||
return "、".join(names)
|
||||
|
||||
|
||||
def normalize_album_asset_code(id: Any) -> str:
|
||||
"""上游 normalizeQianqianAlbumAssetCode:仅接受 p/P 开头(长度 >=2)并归一为大写 P。"""
|
||||
text = ("" if id is None else str(id)).strip()
|
||||
if len(text) < 2:
|
||||
return ""
|
||||
return f"P{text[1:]}" if text[0] in ("p", "P") else ""
|
||||
|
||||
|
||||
def sanitize_album_keyword(keyword: Any) -> str:
|
||||
"""上游 sanitizeQianqianAlbumKeyword:特殊字符替换为空格并折叠空白(23001 时重试用)。"""
|
||||
trimmed = ("" if keyword is None else str(keyword)).strip()
|
||||
if not trimmed:
|
||||
return ""
|
||||
return " ".join(part for part in _ALBUM_KEYWORD_SPECIALS.sub(" ", trimmed).split() if part)
|
||||
|
||||
|
||||
def first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
text = "" if value is None else str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def release_date10(raw: Any) -> str:
|
||||
"""上游 qianqianReleaseDate:只取前 10 位(YYYY-MM-DD)。"""
|
||||
text = ("" if raw is None else str(raw)).strip()
|
||||
return text[:10] if len(text) >= 10 else text
|
||||
|
||||
|
||||
def rate_stats(rate_file_info: Any, duration: float) -> dict[str, int]:
|
||||
"""上游 qianqianRateStats:从 rateFileInfo 取最高可用档位的文件大小与码率(kbps)。"""
|
||||
for rate in RATE_ORDER:
|
||||
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
size = to_number(info.get("size"))
|
||||
if not size > 0:
|
||||
continue
|
||||
if duration > 0:
|
||||
return {"size": int(size), "bitrate": int(size * 8 / 1000 / duration)}
|
||||
return {"size": int(size), "bitrate": 800 if rate == "3000" else int(rate)}
|
||||
return {"size": 0, "bitrate": 0}
|
||||
|
||||
|
||||
def rate_types(rate_file_info: Any) -> tuple[list[dict[str, Any]], dict[str, dict[str, Any]]]:
|
||||
"""rateFileInfo → 既有 Quality 桶的 types / _types。"""
|
||||
types: list[dict[str, Any]] = []
|
||||
type_map: dict[str, dict[str, Any]] = {}
|
||||
for rate in ("128", "320", "3000"):
|
||||
info = rate_file_info.get(rate) if isinstance(rate_file_info, dict) else None
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
size = to_number(info.get("size"))
|
||||
if not size > 0:
|
||||
continue
|
||||
label = _RATE_LABEL[rate]
|
||||
size_text = size_formate(int(size))
|
||||
types.append({"type": label, "size": size_text})
|
||||
type_map[label] = {"size": size_text}
|
||||
return types, type_map
|
||||
|
||||
|
||||
def map_track(
|
||||
item: Any,
|
||||
*,
|
||||
album_id: str = "",
|
||||
album_name: str = "",
|
||||
img: str | None = None,
|
||||
singer: str = "",
|
||||
) -> dict[str, Any]:
|
||||
"""千千曲目字段 → MusicServer 统一 Song 形状。
|
||||
|
||||
搜索用 TSID/albumAssetCode,专辑详情用 assetId(无 albumAssetCode,需外部补 albumId),
|
||||
歌单详情用 TSID + albumTitle + pic。
|
||||
注:上游 Search 会跳过 isVip != 0 的曲目;本实现保留它们并标记 payPlay,
|
||||
取流端对无直链的 VIP 曲目给出明确错误(路由可走外部音源/kw 兜底)。
|
||||
"""
|
||||
source = item if isinstance(item, dict) else {}
|
||||
duration = num_or_zero(source.get("duration"))
|
||||
stats = rate_stats(source.get("rateFileInfo"), duration)
|
||||
types, type_map = rate_types(source.get("rateFileInfo"))
|
||||
return {
|
||||
"songmid": first_non_empty(source.get("TSID"), source.get("assetId"), source.get("tsid")),
|
||||
"name": str(source.get("title") or "").strip(),
|
||||
"singer": first_non_empty(join_artists(source.get("artist")), singer),
|
||||
"albumId": first_non_empty(normalize_album_asset_code(source.get("albumAssetCode")), album_id),
|
||||
"albumName": first_non_empty(source.get("albumTitle"), album_name),
|
||||
"interval": interval_of(duration),
|
||||
"size": stats["size"] or 0,
|
||||
"bitrate": stats["bitrate"] or 0,
|
||||
"payPlay": num_or_zero(source.get("isVip")) != 0 or num_or_zero(source.get("isPaid")) != 0,
|
||||
"img": first_non_empty(source.get("pic"), img) or None,
|
||||
"lrc": None,
|
||||
"lrcx": "",
|
||||
"types": types,
|
||||
"_types": type_map,
|
||||
"source": "bd",
|
||||
}
|
||||
|
||||
|
||||
def song_link(tsid: str) -> str:
|
||||
return f"https://music.91q.com/song/{tsid}"
|
||||
|
||||
|
||||
def album_link(id: str) -> str:
|
||||
return f"https://music.91q.com/album/{id}"
|
||||
|
||||
|
||||
def playlist_link(id: str) -> str:
|
||||
return f"https://music.91q.com/songlist/{id}"
|
||||
Reference in new issue
Block a user