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,276 @@
|
||||
"""QQ音乐歌词(移植自 sdk/tx/lyric.ts)。
|
||||
|
||||
上游返回 QRC(hex 密文),需 decode_qrc 解密后是逐字时间轴格式:
|
||||
[起始毫秒,持续毫秒]字(起始,持续)字(起始,持续)...
|
||||
同一份数据要拆成两路:
|
||||
lyric —— 标准 LRC(把逐字标记剥掉,只留行时间)
|
||||
yrc —— 逐字歌词(`<相对起始,持续>` 标记 + 文字)
|
||||
翻译(trans)与罗马音(roma)的对齐由 fix*_time_tag 按 ±100ms 容差匹配行时间。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import re
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.tx.music_info import get_music_info_by_mid
|
||||
from app.sdk.platforms.tx.qrc_decode import decode_qrc
|
||||
from app.sdk.platforms.tx.utils import MUSICU_URL
|
||||
|
||||
SUCCESS_CODE = 0
|
||||
|
||||
_UA = "Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
|
||||
|
||||
_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
|
||||
_LINE_TIME2 = re.compile(r"^\[([\d:.]+)\]")
|
||||
_WORD_TIME = re.compile(r"\(\d+,\d+\)")
|
||||
_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+\))")
|
||||
_WORD_TIME_PARTS = re.compile(r"\((\d+),(\d+)\)")
|
||||
_TIME_SPLIT = re.compile(r"[:.]")
|
||||
_TAG_HEAD = re.compile(r'^[\S\s]*?LyricContent="')
|
||||
_TAG_TAIL = re.compile(r'"\/>[\S\s]*?$')
|
||||
|
||||
# songmid → songId 缓存;promises 用于合并并发请求(同一首歌并发取歌词只查一次详情)
|
||||
_song_id_map: dict[str, Any] = {}
|
||||
_promises: dict[str, asyncio.Task[Any]] = {}
|
||||
|
||||
|
||||
def ms_format(time_ms: int) -> str:
|
||||
"""毫秒 → `[mm:ss.SSS]`。"""
|
||||
ms = time_ms % 1000
|
||||
total = time_ms // 1000
|
||||
minute = total // 60
|
||||
second = total % 60
|
||||
return f"[{minute:02d}:{second:02d}.{ms:03d}]"
|
||||
|
||||
|
||||
def parse_lyric(lrc: str) -> dict[str, str]:
|
||||
"""逐字 QRC 正文 → {lyric: 标准 LRC, yrc: 逐字歌词}。"""
|
||||
text = lrc.strip().replace("\r", "")
|
||||
if not text:
|
||||
return {"lyric": "", "yrc": ""}
|
||||
|
||||
yrc_lines: list[str] = []
|
||||
lrc_lines: list[str] = []
|
||||
|
||||
for raw_line in text.split("\n"):
|
||||
line = raw_line.strip()
|
||||
result = _LINE_TIME.search(line)
|
||||
if not result:
|
||||
if line.startswith("[offset"):
|
||||
yrc_lines.append(line)
|
||||
lrc_lines.append(line)
|
||||
if _LINE_TIME2.match(line):
|
||||
lrc_lines.append(line)
|
||||
continue
|
||||
|
||||
start_ms = int(result.group(1))
|
||||
start_str = ms_format(start_ms)
|
||||
|
||||
words = _LINE_TIME.sub("", line, count=1)
|
||||
lrc_lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
|
||||
|
||||
times = _WORD_TIME_ALL.findall(words)
|
||||
if not times:
|
||||
continue
|
||||
marks = []
|
||||
for time in times:
|
||||
parts = _WORD_TIME_PARTS.fullmatch(time)
|
||||
if parts is None:
|
||||
continue
|
||||
marks.append(f"<{max(int(parts.group(1)) - start_ms, 0)},{parts.group(2)}>")
|
||||
word_arr = _WORD_TIME.split(words)
|
||||
new_words = "".join(
|
||||
f"{mark}{word_arr[index] if index < len(word_arr) else ''}" for index, mark in enumerate(marks)
|
||||
)
|
||||
yrc_lines.append(f"{start_str}{new_words}")
|
||||
|
||||
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
|
||||
|
||||
|
||||
def parse_rlyric(lrc: str) -> str:
|
||||
"""罗马音(逐字格式)→ 标准 LRC 行。"""
|
||||
text = lrc.strip().replace("\r", "")
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
lines: list[str] = []
|
||||
for raw_line in text.split("\n"):
|
||||
line = raw_line.strip()
|
||||
result = _LINE_TIME.search(line)
|
||||
if not result:
|
||||
continue
|
||||
start_str = ms_format(int(result.group(1)))
|
||||
words = _LINE_TIME.sub("", line, count=1)
|
||||
lines.append(f"{start_str}{_WORD_TIME_ALL.sub('', words)}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def remove_tag(text: str) -> str:
|
||||
"""剥离 `<LyricContent="..." />` 外层标签,只留正文。"""
|
||||
return _TAG_TAIL.sub("", _TAG_HEAD.sub("", text))
|
||||
|
||||
|
||||
def get_intv(interval: str) -> int:
|
||||
"""`mm:ss.SSS` / `m:ss` → 毫秒(沿用原实现的 3600000 系数,两侧比较口径一致)。"""
|
||||
if not interval:
|
||||
return 0
|
||||
if "." not in interval:
|
||||
interval += ".0"
|
||||
parts = _TIME_SPLIT.split(interval)
|
||||
while len(parts) < 3:
|
||||
parts.insert(0, "0")
|
||||
minute, second, ms = parts[0], parts[1], parts[2]
|
||||
|
||||
def _int(value: str) -> int:
|
||||
try:
|
||||
return int(value)
|
||||
except ValueError:
|
||||
return 0
|
||||
|
||||
return _int(minute) * 3600000 + _int(second) * 1000 + _int(ms)
|
||||
|
||||
|
||||
def _align_times(raw_lyric: str, lrc: str) -> str:
|
||||
"""把 raw_lyric 的行时间替换成 lrc 中时间最近的标签(±100ms 内才算命中)。
|
||||
|
||||
与原文一致:lrc 行是一次性指针,未命中的行被丢弃(不做回补)。
|
||||
"""
|
||||
out: list[str] = []
|
||||
lrc_lines = lrc.split("\n")
|
||||
for line in raw_lyric.split("\n"):
|
||||
result = _LINE_TIME2.match(line)
|
||||
if not result:
|
||||
continue
|
||||
if not _LINE_TIME2.sub("", line, count=1).strip():
|
||||
continue
|
||||
t1 = get_intv(result.group(1))
|
||||
|
||||
while lrc_lines:
|
||||
lrc_line = lrc_lines.pop(0)
|
||||
lrc_result = _LINE_TIME2.match(lrc_line)
|
||||
if not lrc_result:
|
||||
continue
|
||||
if abs(t1 - get_intv(lrc_result.group(1))) < 100:
|
||||
out.append(_LINE_TIME2.sub(_literal(lrc_result.group(0)), line, count=1))
|
||||
break
|
||||
return "\n".join(out)
|
||||
|
||||
|
||||
def _literal(text: str) -> Callable[[re.Match[str]], str]:
|
||||
r"""替换串按字面量处理(`\` 与组引用需转义;时间标签本身不带这些字符,保持严格)。"""
|
||||
return lambda _match: text
|
||||
|
||||
|
||||
def fix_rlrc_time_tag(rlrc: str, lrc: str) -> str:
|
||||
return _align_times(rlrc, lrc)
|
||||
|
||||
|
||||
def fix_tlrc_time_tag(tlrc: str, lrc: str) -> str:
|
||||
"""翻译行对齐。
|
||||
|
||||
原 TS 打算把 `mm:ss.d` 的小数位补齐到 3 位,但写的是
|
||||
`time += ''.padStart(3 - n, '0')` —— 对空串补位恒为空,实际是空操作。
|
||||
这里保持同样的换算口径(不补位),否则与 TS 版的 tlyric 对齐结果不一致。
|
||||
"""
|
||||
return _align_times(tlrc, lrc)
|
||||
|
||||
|
||||
def parse(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
|
||||
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
|
||||
if lrc:
|
||||
parsed = parse_lyric(remove_tag(lrc))
|
||||
info["lyric"] = parsed["lyric"]
|
||||
info["yrc"] = parsed["yrc"]
|
||||
if rlrc:
|
||||
info["rlyric"] = fix_rlrc_time_tag(parse_rlyric(remove_tag(rlrc)), info["lyric"])
|
||||
if tlrc:
|
||||
info["tlyric"] = fix_tlrc_time_tag(tlrc, info["lyric"])
|
||||
return info
|
||||
|
||||
|
||||
async def parse_lyric_hex(lrc: str | None, tlrc: str | None, rlrc: str | None) -> dict[str, str]:
|
||||
"""三路 QRC hex 解密后解析。"""
|
||||
lyric, tlyric, rlyric = decode_qrc(lrc), decode_qrc(tlrc), decode_qrc(rlrc)
|
||||
return parse(lyric, tlyric, rlyric)
|
||||
|
||||
|
||||
async def get_song_id(song_info: dict[str, Any]) -> Any:
|
||||
"""songId 优先;否则用 songmid 反查(带缓存 + 并发合并)。"""
|
||||
song_id = song_info.get("songId")
|
||||
if song_id:
|
||||
return song_id
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
if not songmid:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
if songmid in _song_id_map:
|
||||
return _song_id_map[songmid]
|
||||
|
||||
task = _promises.get(songmid)
|
||||
if task is None:
|
||||
task = asyncio.ensure_future(get_music_info_by_mid(songmid))
|
||||
_promises[songmid] = task
|
||||
try:
|
||||
info = await asyncio.shield(task)
|
||||
finally:
|
||||
_promises.pop(songmid, None)
|
||||
if not info:
|
||||
raise RuntimeError("获取歌曲信息失败")
|
||||
_song_id_map[songmid] = info.get("songId")
|
||||
return info.get("songId")
|
||||
|
||||
|
||||
async def _fetch(song_id: Any, retry_num: int) -> dict[str, str]:
|
||||
resp = await http_fetch(
|
||||
MUSICU_URL,
|
||||
method="post",
|
||||
headers={"referer": "https://y.qq.com", "user-agent": _UA},
|
||||
body={
|
||||
"comm": {"ct": "19", "cv": "1859", "uin": "0"},
|
||||
"req": {
|
||||
"method": "GetPlayLyricInfo",
|
||||
"module": "music.musichallSong.PlayLyricInfo",
|
||||
"param": {
|
||||
"format": "json",
|
||||
"crypt": 1,
|
||||
"ct": 19,
|
||||
"cv": 1873,
|
||||
"interval": 0,
|
||||
"lrc_t": 0,
|
||||
"qrc": 1,
|
||||
"qrc_t": 0,
|
||||
"roma": 1,
|
||||
"roma_t": 0,
|
||||
"songID": song_id,
|
||||
"trans": 1,
|
||||
"trans_t": 0,
|
||||
"type": -1,
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
req = body.get("req") or {}
|
||||
data = req.get("data") or {}
|
||||
if body.get("code") != SUCCESS_CODE or req.get("code") != SUCCESS_CODE:
|
||||
return await _retry(song_id, retry_num)
|
||||
return await parse_lyric_hex(data.get("lyric"), data.get("trans"), data.get("roma"))
|
||||
|
||||
|
||||
async def _retry(song_id: Any, retry_num: int) -> dict[str, str]:
|
||||
"""失败重试(上限 3 次,文案与原文一致:Get lyric failed)。"""
|
||||
retry_num += 1
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("Get lyric failed")
|
||||
# 原实现在此处把 songId 当 song_info 再走一次 getSongId(会解析成空 mid 而必然失败),
|
||||
# 这里直接复用已取到的 songId 重发同一请求,降级链(同接口重试)保持一致。
|
||||
return await _fetch(song_id, retry_num)
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
"""Platform.get_lyric 契约:song_info → {lyric, tlyric, rlyric, yrc}。"""
|
||||
song_id = await get_song_id(song_info)
|
||||
return await _fetch(song_id, 0)
|
||||
Reference in new issue
Block a user