Files
MusicServer/app/sdk/platforms/kw/music_search.py
T
snowy-yang dfc06bf4de 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 路由验证通过
2026-10-23 21:55:45 +08:00

117 lines
4.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""酷我搜索(对应 sdk/kw/musicSearch.ts)。
搜索接口 `search.kuwo.cn/r.s` 返回的是带单引号的 JS 对象字面量,必须走
`util.obj_str2json` 而不能直接 json.loads。音质清单在 `N_MINFO` 字段里,
`;` 分隔多档、每档形如 `level:ff,bitrate:2000,format:flac,size:32.43Mb`;
`mflac`/`mgg`/`mmp4`/`zp` 等加密码流的 bitrate 是 20000+ 的随机值,不在
4000/2000/320/128 四档之内,天然被过滤掉。
"""
from __future__ import annotations
import math
import re
from typing import Any
from urllib.parse import quote
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import decode_name, format_play_time
from app.sdk.platforms.kw.util import format_singer, obj_str2json
DEFAULT_LIMIT = 30
SEARCH_API = (
"http://search.kuwo.cn/r.s?client=kt&all={keyword}&pn={pn}&rn={rn}"
"&uid=794762570&ver=kwplayer_ar_9.2.2.1&vipver=1&show_copyright_off=1&newver=1"
"&ft=music&cluster=0&strategy=2012&encoding=utf8&rformat=json&vermerge=1&mobi=1&issubtitle=1"
)
# bitrate 精确值 → 对外音质名。只认这四档,加密档位一律丢弃。
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
_MINFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]] | None:
"""`N_MINFO` 缺失时返回 None(原文用 console.log 提示后触发重试)。"""
result: list[dict[str, Any]] = []
for info in raw_list:
n_minfo = info.get("N_MINFO")
if not n_minfo:
return None
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for entry in str(n_minfo).split(";"):
match = _MINFO_RE.search(entry)
if match is None:
continue
quality = _BITRATE_QUALITY.get(match.group(2))
if quality is None:
continue
size = match.group(4)
types.append({"type": quality, "size": size})
_types[quality] = {"size": size.upper()}
# 原文按 N_MINFO 的书写顺序收集后整体反转(加密档在前,明文档在后)
types.reverse()
interval = _to_int(info.get("DURATION"))
result.append(
{
"name": decode_name(info.get("SONGNAME")),
"singer": format_singer(decode_name(info.get("ARTIST"))),
"source": "kw",
"songmid": str(info.get("MUSICRID") or "").replace("MUSIC_", ""),
"albumId": decode_name(info.get("ALBUMID") or ""),
"interval": format_play_time(interval),
"albumName": decode_name(info.get("ALBUM")) if info.get("ALBUM") else "",
"lrc": None,
"img": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _to_int(value: Any) -> int:
try:
return int(float(str(value).strip()))
except (TypeError, ValueError):
return 0
async def search(keyword: str, page: int = 1, limit: int | None = None, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
page_size = DEFAULT_LIMIT if limit is None else limit
url = SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=page_size)
resp = await http_fetch(url)
# 上游偶发返回非 JSON(风控页);原文靠 `!result` 兜住并重试
result = resp.body
if isinstance(result, str):
# rformat=json 正常回合法 JSON,但风控页/旧版接口会回单引号伪 JSON
try:
result = obj_str2json(result)
except ValueError:
return await search(keyword, page, limit, retry_num + 1)
if not isinstance(result, dict):
return await search(keyword, page, limit, retry_num + 1)
if result.get("TOTAL") != "0" and result.get("SHOW") == "0":
return await search(keyword, page, limit, retry_num + 1)
songs = _handle_result(result.get("abslist") or [])
if songs is None:
return await search(keyword, page, limit, retry_num + 1)
total = _to_int(result.get("TOTAL"))
return {
"list": songs,
"allPage": math.ceil(total / page_size) if page_size else 1,
"total": total,
"limit": page_size,
"source": "kw",
}