- 新增 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 路由验证通过
193 lines
7.2 KiB
Python
193 lines
7.2 KiB
Python
"""酷我平台工具(对应 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)
|