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,192 @@
|
||||
"""酷我平台工具(对应 sdk/kw/util.ts)。
|
||||
|
||||
四块内容:legacy 接口的 JS 对象字面量转 JSON、歌手名格式化、逐字歌词(lrcx)解析、
|
||||
wbd 榜单接口的 AES-128-ECB 加解密与签名。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
from urllib.parse import quote, unquote
|
||||
|
||||
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import to_md5
|
||||
|
||||
UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36"
|
||||
MOBI_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 14_0 like Mac OS X) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Version/14.0 Mobile/15E148 Safari/537.36"
|
||||
)
|
||||
HOME_URL = "https://www.kuwo.cn/"
|
||||
|
||||
# 单引号替换:原文是一条含变长后顾断言的正则,Python re 不支持变长 lookbehind,
|
||||
# 因此拆成「引号前是 {,: 」与「引号后是 },: 」两步,覆盖范围与原文等价。
|
||||
_QUOTE_START = re.compile(r"(?P<pre>[{:,]\s*)'")
|
||||
_QUOTE_END = re.compile(r"'(?P<post>\s*[}:,])")
|
||||
|
||||
|
||||
def obj_str2json(text: str) -> Any:
|
||||
"""legacy r.s 返回的是 JS 对象字面量(单引号 + 未加引号的形态),先转成合法 JSON。
|
||||
|
||||
专辑详情实测确认仍是这一形态,不能直接 json.loads。
|
||||
"""
|
||||
fixed = _QUOTE_START.sub(r'\g<pre>"', text)
|
||||
fixed = _QUOTE_END.sub(r'"\g<post>', fixed)
|
||||
return json.loads(fixed)
|
||||
|
||||
|
||||
def format_singer(raw: str) -> str:
|
||||
"""酷我用 `&` 分隔合唱歌手,统一换成顿号。"""
|
||||
return raw.replace("&", "、")
|
||||
|
||||
|
||||
# ---------- 逐字歌词 ----------
|
||||
|
||||
_WORD_LINE = re.compile(r"^(\[\d{1,2}:.*\d{1,4}\])\s*(\S+(?:\s+\S+)*)?\s*")
|
||||
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
|
||||
_WORD_TIME = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
|
||||
_LINE_SPLIT = re.compile(r"\r\n|\r|\n")
|
||||
|
||||
|
||||
def _js_number(value: float) -> str:
|
||||
"""JS 的 Number → String:整数值不带小数点,其余取最短往返表示(与 repr 同)。"""
|
||||
return str(int(value)) if float(value).is_integer() else repr(float(value))
|
||||
|
||||
|
||||
class LrcTools:
|
||||
"""lrcx 逐字歌词解析器。原实现靠 Object.create 做每轮隔离,这里用新实例。"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.offset = 1
|
||||
self.offset2 = 1
|
||||
self.is_ok = True
|
||||
self.lines: list[str] = []
|
||||
self.tags: list[str] = []
|
||||
|
||||
def get_word_info(self, first: str, second: str, prev: dict[str, Any] | None) -> dict[str, Any]:
|
||||
offset = int(first)
|
||||
offset2 = int(second)
|
||||
start = abs((offset + offset2) / (self.offset * 2))
|
||||
end = abs((offset - offset2) / (self.offset2 * 2)) + start
|
||||
if prev is not None and start < prev["end"]:
|
||||
# 上一句被本句起点截断,需要回头改写已拼进 words 的那一段
|
||||
prev["end"] = start
|
||||
if prev["start"] > prev["end"]:
|
||||
prev["start"] = prev["end"]
|
||||
prev["new"] = f"<{_js_number(prev['start'])},{_js_number(prev['end'] - prev['start'])}>"
|
||||
return {"start": start, "end": end, "text": f"<{_js_number(start)},{_js_number(end - start)}>"}
|
||||
|
||||
def parse_line(self, line: str) -> None:
|
||||
if len(line) < 6:
|
||||
return
|
||||
match = _WORD_LINE.match(line)
|
||||
if match:
|
||||
head = match.group(1)
|
||||
words = match.group(2) or ""
|
||||
spans = [item.group(0) for item in _WORD_TIME.finditer(words)]
|
||||
if not spans:
|
||||
return
|
||||
prev: dict[str, Any] | None = None
|
||||
for span in spans:
|
||||
found = _WORD_TIME.search(span)
|
||||
if found is None:
|
||||
continue
|
||||
info = self.get_word_info(found.group(1), found.group(2), prev)
|
||||
words = words.replace(span, info["text"], 1)
|
||||
if prev is not None and prev.get("new"):
|
||||
words = words.replace(prev["text"], prev["new"], 1)
|
||||
prev = info
|
||||
self.lines.append(head + words)
|
||||
return
|
||||
tag = _TAG_LINE.search(line)
|
||||
if tag is None:
|
||||
return
|
||||
if tag.group(1) != "kuwo":
|
||||
self.tags.append(line)
|
||||
return
|
||||
content = tag.group(2)
|
||||
if "][" in content:
|
||||
content = content[: content.index("][")]
|
||||
try:
|
||||
value = int(content.strip(), 8)
|
||||
except ValueError:
|
||||
self.is_ok = False
|
||||
return
|
||||
self.offset = int(value / 10)
|
||||
self.offset2 = value % 10
|
||||
# 原文对 NaN/0 一律判失败,等价于这里两个偏移都非零
|
||||
if self.offset == 0 or self.offset2 == 0:
|
||||
self.is_ok = False
|
||||
|
||||
|
||||
def parse_yrc(lrc: str) -> str:
|
||||
"""把歌词里的 lrcx 时间标记规范化;失败抛 RuntimeError(原文文案 failed)。"""
|
||||
tools = LrcTools()
|
||||
for line in _LINE_SPLIT.split(lrc):
|
||||
if not tools.is_ok:
|
||||
raise RuntimeError("failed")
|
||||
tools.parse_line(line)
|
||||
if not tools.lines:
|
||||
return ""
|
||||
merged = "\n".join(tools.lines)
|
||||
if tools.tags:
|
||||
merged = "\n".join(tools.tags) + "\n" + merged
|
||||
return merged
|
||||
|
||||
|
||||
# ---------- wbd 榜单接口加解密 ----------
|
||||
|
||||
WBD_APP_ID = "y67sprxhhpws"
|
||||
_WBD_KEY = bytes([112, 87, 39, 61, 199, 250, 41, 191, 57, 68, 45, 114, 221, 94, 140, 228])
|
||||
|
||||
|
||||
def _aes_ecb(data: bytes, *, encrypt: bool) -> bytes:
|
||||
cipher = Cipher(algorithms.AES(_WBD_KEY), modes.ECB())
|
||||
if encrypt:
|
||||
pad = 16 - len(data) % 16
|
||||
padded = data + bytes([pad]) * pad
|
||||
context = cipher.encryptor()
|
||||
return context.update(padded) + context.finalize()
|
||||
context = cipher.decryptor()
|
||||
out = context.update(data) + context.finalize()
|
||||
pad = out[-1] if out else 0
|
||||
return out[:-pad] if 0 < pad <= 16 else out
|
||||
|
||||
|
||||
def wbd_decode_data(payload: str) -> Any:
|
||||
"""解码 wbd 响应:URL 编码的 base64 → AES-128-ECB 解密 → JSON。"""
|
||||
raw = base64.b64decode(unquote(payload))
|
||||
return json.loads(_aes_ecb(raw, encrypt=False).decode())
|
||||
|
||||
|
||||
def wbd_build_param(json_data: Any) -> str:
|
||||
"""构造 wbd 请求 query:加密体 + 毫秒时间戳 + appId + MD5 大写签名。"""
|
||||
data = json.dumps(json_data, ensure_ascii=False, separators=(",", ":")).encode()
|
||||
time_ms = int(time.time() * 1000)
|
||||
encoded = base64.b64encode(_aes_ecb(data, encrypt=True)).decode()
|
||||
sign = to_md5(f"{WBD_APP_ID}{encoded}{time_ms}").upper()
|
||||
return f"data={quote(encoded, safe='')}&time={time_ms}&appId={WBD_APP_ID}&sign={sign}"
|
||||
|
||||
|
||||
async def fetch_home_set_cookie() -> list[str]:
|
||||
"""取酷我首页下发的全部 Set-Cookie。
|
||||
|
||||
用 http_fetch 的 set_cookies(原始多值头),而不是折叠后的 headers dict——
|
||||
Secret 签名必须认出动态下发的 Hm_Iuvt_* cookie(名字与值均随机),
|
||||
同名头被折叠会恰好丢掉目标 cookie。
|
||||
"""
|
||||
resp = await http_fetch(HOME_URL, headers={"User-Agent": UA}, timeout=15_000)
|
||||
return list(resp.set_cookies)
|
||||
|
||||
|
||||
def random_digits() -> int:
|
||||
"""JS 的 Math.round(1e9 * Math.random()) —— round 是四舍五入,不是 Python 的银行家舍入。"""
|
||||
return math.floor(1e9 * random.random() + 0.5)
|
||||
Reference in new issue
Block a user