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,220 @@
|
||||
"""酷我音乐 取流(对应 sdk/apis/kw.ts)。
|
||||
|
||||
双通道,按优先级:
|
||||
1) mobi.s convert_url_with_sign(主通道,匿名可用)
|
||||
`source=kwplayer_ar_8.5.5.0_apk_keluze.apk` + `user=0` 即可匿名拿到真音频,
|
||||
但请求档位缺失时会**静默换档**(实测 rid=440613 请求 4000kflac 回的是
|
||||
bitrate=20501 / format=mflac 的加密流,不可播),因此必须严格校验
|
||||
「返回 bitrate 精确等于请求档位」且「format 在明文白名单内」,
|
||||
任一不满足视为未命中、沿降级链换档重试。
|
||||
2) www.kuwo.cn/api/v1/www/music/playUrl(回退通道,web 端官方接口,需 Secret 签名头)
|
||||
签名所需 cookie(`Hm_Iuvt_*`,名字与值均随机下发)必须现场访问首页动态获取;
|
||||
写死 token 一律返回 `The request is illegal!`(已实测确认)。
|
||||
匿名该接口恒回 128k MP3,是 mobi 通道网络故障时的兜底。
|
||||
|
||||
CDN 返回的 URL query 用 `$` 顶替 `=`(`?bitrate$320&format$mp3&…&loginUid$`),
|
||||
非标准分隔符播放器不认,统一还原成 `=` 再对外返回。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.kw.util import (
|
||||
HOME_URL,
|
||||
MOBI_UA,
|
||||
UA,
|
||||
fetch_home_set_cookie,
|
||||
random_digits,
|
||||
)
|
||||
|
||||
SUPPORT_QUALITYS: list[str] = ["128k", "320k", "flac", "flac24bit"]
|
||||
|
||||
# 音质 → mobi.s 请求档位(bitrate 是响应必须精确命中的数值)
|
||||
QUALITY_TIERS: dict[str, dict[str, Any]] = {
|
||||
"128k": {"br": "128kmp3", "bitrate": 128},
|
||||
"320k": {"br": "320kmp3", "bitrate": 320},
|
||||
"flac": {"br": "2000kflac", "bitrate": 2000},
|
||||
"flac24bit": {"br": "4000kflac", "bitrate": 4000},
|
||||
}
|
||||
|
||||
# 档位缺失时的降级链(请求档位 → 逐级尝试)
|
||||
FALLBACK_CHAIN: dict[str, list[str]] = {
|
||||
"flac24bit": ["flac", "320k", "128k"],
|
||||
"flac": ["320k", "128k"],
|
||||
"320k": ["128k"],
|
||||
"128k": [],
|
||||
}
|
||||
|
||||
# 明文音频格式白名单;mgg/mflac/mmp4/zp 是加密格式,拿到也不能播
|
||||
PLAIN_FORMATS = frozenset({"mp3", "flac", "ogg", "aac", "m4a"})
|
||||
|
||||
MOBI_API = "http://mobi.kuwo.cn/mobi.s"
|
||||
PLAY_URL_API = "https://www.kuwo.cn/api/v1/www/music/playUrl"
|
||||
|
||||
_TOKEN_NAME_RE = re.compile(r"^Hm_Iuvt_\w+$")
|
||||
_ILLEGAL_RE = re.compile(r"illegal", re.IGNORECASE)
|
||||
|
||||
|
||||
def normalize_url(url: str) -> str:
|
||||
return url.replace("$", "=")
|
||||
|
||||
|
||||
async def get_url_by_mobi(songmid: str, tier: dict[str, Any]) -> str | None:
|
||||
"""mobi.s 取流:未命中(无 url / bitrate 不符 / 加密格式 / 网络异常)返回 None,由调用方换档。"""
|
||||
url = (
|
||||
f"{MOBI_API}?f=web&user=0&source=kwplayer_ar_8.5.5.0_apk_keluze.apk"
|
||||
f"&type=convert_url_with_sign&br={tier['br']}&rid={songmid}"
|
||||
)
|
||||
try:
|
||||
resp = await http_fetch(
|
||||
url,
|
||||
method="get",
|
||||
headers={"User-Agent": MOBI_UA, "Accept-Language": "zh-CN,zh;q=0.9"},
|
||||
timeout=15_000,
|
||||
)
|
||||
except Exception: # noqa: BLE001 — 网络失败视为本档未命中,继续降级
|
||||
return None
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
data = body.get("data") or {}
|
||||
if resp.status_code != 200 or not isinstance(data, dict) or not data.get("url"):
|
||||
return None
|
||||
raw_bitrate = data.get("bitrate")
|
||||
if not isinstance(raw_bitrate, (int, float, str)):
|
||||
return None
|
||||
try:
|
||||
bitrate = int(float(raw_bitrate))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if bitrate != tier["bitrate"] or str(data.get("format")) not in PLAIN_FORMATS:
|
||||
return None
|
||||
return normalize_url(str(data["url"]))
|
||||
|
||||
|
||||
def kw_secret(message: str, key: str) -> str:
|
||||
"""酷我 web 端 Secret 头哈希:key 为 cookie 名,message 为 cookie 值。
|
||||
|
||||
逐字移植自 web 端 secret.js:先把 key 的每个字符码拼成数字串,
|
||||
取 5 个固定位置的数字组成乘子,再用随机种子一起做 LCG 式的异或掩码。
|
||||
"""
|
||||
digits = "".join(str(ord(ch)) for ch in key)
|
||||
step = len(digits) // 5
|
||||
multiplier = int(digits[step] + digits[2 * step] + digits[3 * step] + digits[4 * step] + digits[5 * step])
|
||||
addend = -(-len(key) // 2) # Math.ceil(e.length / 2)
|
||||
modulus = 2**31 - 1
|
||||
if multiplier < 2:
|
||||
raise RuntimeError("酷我 Secret 哈希失败")
|
||||
|
||||
seed = random_digits() % 10**8
|
||||
digits += str(seed)
|
||||
while len(digits) > 10:
|
||||
digits = str(int(digits[:10]) + int(digits[10:]))
|
||||
|
||||
state = (multiplier * int(digits) + addend) % modulus
|
||||
out: list[str] = []
|
||||
for ch in message:
|
||||
mask = int(state / modulus * 255) # 对应 Math.floor(n / c * 255)
|
||||
value = ord(ch) ^ mask
|
||||
out.append(f"{value:02x}" if value < 16 else format(value, "x"))
|
||||
state = (multiplier * state + addend) % modulus
|
||||
return "".join(out) + f"{seed:08x}"
|
||||
|
||||
|
||||
class KwToken:
|
||||
__slots__ = ("name", "secret", "value")
|
||||
|
||||
def __init__(self, name: str, value: str) -> None:
|
||||
self.name = name
|
||||
self.value = value
|
||||
self.secret = kw_secret(value, name)
|
||||
|
||||
|
||||
_state: dict[str, Any] = {"token": None, "lock": None}
|
||||
|
||||
|
||||
def _lock() -> asyncio.Lock:
|
||||
lock = _state["lock"]
|
||||
if lock is None:
|
||||
lock = asyncio.Lock()
|
||||
_state["lock"] = lock
|
||||
return lock
|
||||
|
||||
|
||||
async def get_token(retry_num: int = 0) -> KwToken:
|
||||
"""访问首页解析动态下发的 `Hm_Iuvt_*` cookie,生成 Secret。会话级缓存。"""
|
||||
cached: KwToken | None = _state["token"]
|
||||
if cached is not None:
|
||||
return cached
|
||||
async with _lock():
|
||||
cached = _state["token"]
|
||||
if cached is not None:
|
||||
return cached
|
||||
try:
|
||||
cookies = await fetch_home_set_cookie()
|
||||
except Exception as err:
|
||||
if retry_num >= 2:
|
||||
raise RuntimeError(f"获取酷我token失败: {err}") from err
|
||||
return await get_token(retry_num + 1)
|
||||
|
||||
found: list[tuple[str, str]] = []
|
||||
for raw in cookies:
|
||||
name, _, rest = raw.partition("=")
|
||||
name = name.strip()
|
||||
value = rest.split(";")[0] if rest else ""
|
||||
if _TOKEN_NAME_RE.match(name) and value:
|
||||
found.append((name, value))
|
||||
if not found:
|
||||
if retry_num >= 2:
|
||||
raise RuntimeError("获取酷我token失败")
|
||||
return await get_token(retry_num + 1)
|
||||
|
||||
token = KwToken(found[0][0], found[0][1])
|
||||
_state["token"] = token
|
||||
return token
|
||||
|
||||
|
||||
async def get_url_by_playurl(songmid: str, token: KwToken, retry_num: int = 0) -> dict[str, Any]:
|
||||
"""官方 playUrl 接口(需 Secret 签名)。签名/token 失效时刷新后重试一次。"""
|
||||
url = f"{PLAY_URL_API}?mid={songmid}&type=music&httpsStatus=1&plat=web_www&from=&br=128kmp3&reqId={uuid.uuid4()}"
|
||||
resp = await http_fetch(
|
||||
url,
|
||||
method="get",
|
||||
headers={
|
||||
"Cookie": f"{token.name}={token.value}",
|
||||
"Secret": token.secret,
|
||||
"Referer": HOME_URL,
|
||||
"User-Agent": UA,
|
||||
},
|
||||
timeout=15_000,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
msg = str(body.get("msg") or body.get("message") or "")
|
||||
data = body.get("data") or {}
|
||||
if resp.status_code != 200 or not body.get("success") or not isinstance(data, dict) or not data.get("url"):
|
||||
if retry_num == 0 and _ILLEGAL_RE.search(msg):
|
||||
_state["token"] = None
|
||||
fresh = await get_token()
|
||||
return await get_url_by_playurl(songmid, fresh, retry_num + 1)
|
||||
raise RuntimeError(msg or "获取失败")
|
||||
# 服务器无视 br 参数,匿名实际只回 128k,如实上报
|
||||
return {"type": "128k", "url": normalize_url(str(data["url"]))}
|
||||
|
||||
|
||||
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
songmid = str(song_info.get("songmid") or "").strip()
|
||||
if not songmid:
|
||||
raise RuntimeError("缺少 songmid")
|
||||
|
||||
start = quality if quality in QUALITY_TIERS else "128k"
|
||||
# 逐档尝试 mobi 通道,全未命中再回退 web playUrl(匿名 128k;VIP 歌会被明确拒绝)
|
||||
for tier_type in [start, *FALLBACK_CHAIN[start]]:
|
||||
hit = await get_url_by_mobi(songmid, QUALITY_TIERS[tier_type])
|
||||
if hit:
|
||||
return {"type": tier_type, "url": hit}
|
||||
|
||||
token = await get_token()
|
||||
return await get_url_by_playurl(songmid, token)
|
||||
Reference in new issue
Block a user