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

+124
View File
@@ -0,0 +1,124 @@
"""酷我歌词(对应 sdk/kw/lyric.ts)。
`newlyric.kuwo.cn/newlyric.lrc?<params>`:params 是明文字符串与 `yeelion` 逐字节异或
后的 base64。响应体是 zlib 压缩 + 可选 yeelion 异或,由 `lyric_decode` 还原。
歌词分两种形态:普通 lrc(`[mm:ss.xx]文本`)与 lrcx/逐字(每行带 `<start,dur>` 标记)。
lrcx 走 `util.parse_yrc` 归一到 `yrc` 字段,`lyric`/`tlyric` 里的逐字标记则剥掉,
保持纯 lrc 可直接给播放器。
"""
from __future__ import annotations
import base64
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name
from app.sdk.platforms.kw.lyric_decode import handle_kw_decode_lyric
from app.sdk.platforms.kw.util import parse_yrc
_BUF_KEY = b"yeelion"
LYRIC_API = "http://newlyric.kuwo.cn/newlyric.lrc"
_TIME_EXP = re.compile(r"^\[([\d:.]*)\]")
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
_EXIST_TIME_EXP = re.compile(r"\[\d{1,2}:.*\d{1,4}\]")
_LYRICX_TAG = re.compile(r"^<-?\d+,-?\d+>")
_WORD_TIME_ALL = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
def build_params(songmid: str, is_get_lyricx: bool) -> str:
"""明文参数与 `yeelion` 循环异或后 base64。
原实现用 `new Uint16Array` 中转再 `Buffer.from(output)`;Node 对 TypedArray
走 fromArrayLike,按元素截断成 8 位字节,等价于这里的逐字节异或。
"""
params = f"user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_{songmid}"
if is_get_lyricx:
params += "&lrcx=1"
raw = params.encode()
return base64.b64encode(bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(raw))).decode()
def _sort_lrc_arr(arr: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
"""按时间去重:重复时间视为翻译行,挤进 lrc_t。
全部行都没有逐字标记、且「翻译行」占比异常时,认为数据错乱并抛 failed。
"""
lrc_set: set[str] = set()
lrc: list[dict[str, str]] = []
lrc_t: list[dict[str, str]] = []
is_lyricx = False
for item in arr:
if item["time"] in lrc_set:
if len(lrc) < 2:
continue
t_item = lrc.pop()
t_item["time"] = lrc[-1]["time"]
lrc_t.append(t_item)
lrc.append(item)
else:
lrc.append(item)
lrc_set.add(item["time"])
if not is_lyricx and _LYRICX_TAG.match(item["text"]):
is_lyricx = True
if not is_lyricx and len(lrc_t) > len(lrc) * 0.3 and len(lrc) - len(lrc_t) > 6:
raise RuntimeError("failed")
return lrc, lrc_t
def _transform_lrc(tags: list[str], lrc_list: list[dict[str, str]]) -> str:
body = "".join(f"[{item['time']}]{item['text']}\n" for item in lrc_list)
return "\n".join(tags) + "\n" + body
def parse_lrc(lrc: str) -> dict[str, str]:
tags: list[str] = []
arr: list[dict[str, str]] = []
for raw_line in re.split(r"\r\n|\r|\n", lrc):
line = raw_line.strip()
match = _TIME_EXP.match(line)
if match:
text = _TIME_EXP.sub("", line, count=1).strip()
time = match.group(1)
# 只有两位小数的补一个 0,与三位/更长的保持同宽
if re.search(r"\.\d\d$", time):
time += "0"
arr.append({"time": time, "text": text})
elif _TAG_LINE.search(line):
tags.append(line)
lrc_list, lrc_t = _sort_lrc_arr(arr)
return {
"lyric": decode_name(_transform_lrc(tags, lrc_list)),
"tlyric": decode_name(_transform_lrc(tags, lrc_t)) if lrc_t else "",
}
async def get_lyric(song_info: dict[str, Any], is_get_lyricx: bool = True) -> dict[str, str]:
songmid = str(song_info.get("songmid") or "")
resp = await http_fetch(f"{LYRIC_API}?{build_params(songmid, is_get_lyricx)}")
if resp.status_code != 200:
raise RuntimeError(json.dumps(resp.body, ensure_ascii=False))
base64_data = await handle_kw_decode_lyric(base64.b64encode(resp.raw).decode(), is_get_lyricx)
try:
lrc_info = parse_lrc(base64.b64decode(base64_data).decode("utf-8", errors="replace"))
except Exception as err:
raise RuntimeError("Get lyric failed") from err
if lrc_info.get("tlyric"):
lrc_info["tlyric"] = _WORD_TIME_ALL.sub("", lrc_info["tlyric"])
# yrc 解析失败只代表这份歌词没有逐字数据,不影响普通歌词返回
try:
lrc_info["yrc"] = parse_yrc(lrc_info["lyric"])
except Exception: # noqa: BLE001
lrc_info["yrc"] = ""
lrc_info["lyric"] = _WORD_TIME_ALL.sub("", lrc_info["lyric"])
if not _EXIST_TIME_EXP.search(lrc_info["lyric"]):
raise RuntimeError("Get lyric failed")
return lrc_info