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

+133
View File
@@ -0,0 +1,133 @@
"""酷狗链接解析(对应 sdk/kg/parse.ts)。
歌曲:`https://www.kugou.com/song/#hash=<32位hash>`(含 &album_id= 等尾串)或裸 hash。
元数据取 `m.kugou.com/app/i/getSongInfo.php`(songName/author_name/timeLength/album_img/bitRate),
再并发合并音乐信息里的专辑与多档 hash;直链优先用 playInfo 的 url,仍为空时用 apis/kg
签名通道尽力补一次 —— 失败只返回元数据(与上游付费曲语义一致)。
"""
from __future__ import annotations
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kg import album, playlist
from app.sdk.platforms.kg.music_info import get_music_info
_UA = (
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
)
HASH_RE = re.compile(r"hash=([a-fA-F0-9]{32})", re.IGNORECASE)
RAW_HASH_RE = re.compile(r"^[a-fA-F0-9]{32}$")
def extract_hash(link: Any) -> str:
"""链接/裸 hash → 32 位小写 hash;都不匹配返回空串。"""
raw = str(link if link is not None else "").strip()
match = HASH_RE.search(raw)
if match:
return match.group(1).lower()
return raw.lower() if RAW_HASH_RE.match(raw) else ""
async def _fetch_song_info_php(hash_value: str) -> dict[str, Any] | None:
resp = await http_fetch(
f"http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash_value}",
method="get",
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
timeout=15000,
)
if resp.status_code != 200 or not isinstance(resp.body, dict):
return None
body = resp.body
if int(body.get("errcode") or 0) != 0 or not body.get("hash"):
return None
return body
async def parse_song(link: str) -> dict[str, Any]:
import asyncio
hash_value = extract_hash(link)
if not hash_value:
raise RuntimeError("无效的酷狗歌曲链接(未找到 32 位 hash)")
raw, info = await asyncio.gather(
_fetch_song_info_php(hash_value), get_music_info(hash_value), return_exceptions=True
)
raw = None if isinstance(raw, BaseException) else raw
info = None if isinstance(info, BaseException) else info
if not raw and not info:
raise RuntimeError("酷狗歌曲不存在或已下架")
raw = raw or {}
info = info or {}
file_name = str(raw.get("fileName") or "")
fallback_name = " - ".join(file_name.split(" - ")[1:]) if " - " in file_name else ""
name = decode_name(str(raw.get("songName") or "").strip() or fallback_name or info.get("name") or "")
singer = decode_name(str(raw.get("author_name") or "").strip() or info.get("singer") or "")
duration = int(raw.get("timeLength") or 0) or 0
song: dict[str, Any] = {
"songmid": str(info.get("songmid") or raw.get("audio_id") or "").strip(),
"hash": str(raw.get("hash") or hash_value).lower(),
"name": name,
"singer": singer,
"albumName": decode_name(str(info.get("albumName") or "").strip()),
"albumId": str(info.get("albumId") or raw.get("albumid") or raw.get("req_albumid") or "").strip(),
"interval": format_play_time(duration) if duration > 0 else (info.get("interval") or "--/--"),
"img": str(raw.get("album_img") or "").replace("{size}", "240") or info.get("img") or None,
"lrc": None,
"otherSource": None,
"types": info.get("types") or [],
"_types": info.get("_types") or {},
"typeUrl": {},
"source": "kg",
"extra": {
"hash": str(raw.get("hash") or hash_value).lower(),
"url": str(raw.get("url") or ""),
"backup_url": raw.get("backup_url") or [],
"bitrate": int(raw.get("bitRate") or 0) or 0,
"link": f"https://www.kugou.com/song/#hash={hash_value}",
},
}
# playInfo 的 url/backup_url 是官方直链(付费曲为空),优先使用
if not song.get("url"):
backup = raw.get("backup_url") or []
raw_url = str(raw.get("url") or "") or str(backup[0] if backup else "")
if raw_url:
song["url"] = raw_url
song["type"] = "320k" if (int(raw.get("bitRate") or 0) or 0) >= 320000 else "128k"
# 仍无直链时(风控/付费)用签名通道尽力补一次
if not song.get("url"):
try:
from app.sdk.apis.kg import get_music_url
hit = await get_music_url(
{
"hash": song["hash"],
"albumId": song["albumId"],
"songmid": song["songmid"],
"_types": song["_types"],
},
"128k",
)
if hit.get("url"):
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception: # noqa: BLE001 - 与上游付费曲无直链的失败语义一致:元数据仍返回
pass
return song
async def parse_album(link: str) -> dict[str, Any]:
return await album.parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await playlist.parse_playlist_link(link)