Files
MusicServer/app/sdk/platforms/kw/song_list.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

518 lines
20 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/songList.ts)。
四条互不相同的上游链路,按歌单 id 形态分流(`get_list_detail`):
* `digest-<n>__<id>` —— 广场列表返回的内部 id,digest 决定走哪个接口:
13 → 专辑(转交 album.get_album_list_detail)
5 → 旧歌单(先 qukudata 查 sourceid,再用 nplserver 取曲目)
其余 → nplserver 直取
* `/bodian/` 链接 —— BD 接口(bd-api.kuwo.cn),需要 reqId 与 h5 头
* 网页链接 —— 正则抽出裸 id 后按 nplserver 取
nplserver 固定 1000 条/页,因此详情类调用都在调用方(playlist.py)做本地开窗。
"""
from __future__ import annotations
import asyncio
import random
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_count, format_play_time
from app.sdk.platforms.kw.util import format_singer, obj_str2json
LIST_LIMIT = 36
SONG_LIMIT = 1000
SUCCESS_CODE = 200
SORT_LIST: list[dict[str, str]] = [
{"name": "最新", "id": "new"},
{"name": "最热", "id": "hot"},
]
# 路由层以 `song_list.sort_list[0]["id"]` 取默认排序,名字沿用原 TS 的 sortList
sort_list = SORT_LIST
TAGS_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getTagList?cmd=rcm_keyword_playlist"
"&user=0&prod=kwplayer_pc_9.0.5.0&vipver=9.0.5.0&source=kwplayer_pc_9.0.5.0"
"&loginUid=0&loginSid=0&appUid=76039576"
)
HOT_TAG_API = "http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmTagList?loginUid=0&loginSid=0&appUid=76039576"
RCM_LIST_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getRcmPlayList?loginUid=0&loginSid=0"
"&appUid=76039576&&pn={page}&rn={rn}&order={sort}"
)
TAG_LIST_API = (
"http://wapi.kuwo.cn/api/pc/classify/playlist/getTagPlayList?loginUid=0&loginSid=0"
"&appUid=76039576&pn={page}&id={id}&rn={rn}"
)
QZ_LIST_API = "http://mobileinterfaces.kuwo.cn/er.s?type=get_pc_qz_data&f=web&id={id}&prod=pc"
LIST_DETAIL_API = (
"http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}"
"&encode=utf8&keyset=pl2012&identity=kuwo&pcmp4=1&vipver=MUSIC_9.0.5.0_W1&newver=1"
)
DIGEST5_INFO_API = "http://qukudata.kuwo.cn/q.k?op=query&cont=ninfo&node={id}&pn=0&rn=1&fmt=json&src=mbox&level=2"
DIGEST5_MUSIC_API = (
"http://nplserver.kuwo.cn/pl.svc?op=getlistinfo&pid={id}&pn={pn}&rn={rn}"
"&encode=utf-8&keyset=pl2012&identity=kuwo&pcmp4=1"
)
BD_INFO_API = "https://bd-api.kuwo.cn/api/service/playlist/info/{id}?reqId={req_id}&source={source}"
BD_USER_API = "https://bd-api.kuwo.cn/api/ucenter/users/pub/{id}?reqId={req_id}"
BD_LIST_API = (
"https://bd-api.kuwo.cn/api/service/playlist/{id}/musicList?reqId={req_id}&source={source}&pn={page}&rn={rn}"
)
SEARCH_API = (
"http://search.kuwo.cn/r.s?all={keyword}&pn={pn}&rn={rn}&rformat=json&encoding=utf8"
"&ver=mbox&vipver=MUSIC_8.7.7.0_BCS37&plat=pc&devid=28156413&ft=playlist&pay=0&needliveshow=0"
)
BD_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/86.0.4240.198 Safari/537.36"
),
"plat": "h5",
}
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
_DETAIL_LINK_RE = re.compile(r"^.+\/playlist(?:_detail)?\/(\d+)(?:\?.*|&.*$|#.*$|$)")
_LINKISH_RE = re.compile(r"[?&:/]")
_DIGEST_RE = re.compile(r"^digest-")
_BD_RE = re.compile(r"/bodian/")
_DIGEST_SPLIT_RE = re.compile(r"__")
def _to_int(value: Any) -> int:
try:
return int(float(value))
except (TypeError, ValueError):
return 0
def _as_str(value: Any) -> str:
return "" if value is None else str(value)
def _get_list_url(sort_id: str, playlist_id: str | None, digest: str, page: int) -> str:
if not playlist_id:
return RCM_LIST_API.format(page=page, rn=LIST_LIMIT, sort=sort_id)
if digest == "10000":
return TAG_LIST_API.format(page=page, id=playlist_id, rn=LIST_LIMIT)
if digest == "43":
return QZ_LIST_API.format(id=playlist_id)
# 原文此处无 return,httpFetch(undefined) 会直接抛错;这里显式抛同样的异常语义
raise RuntimeError(f"不支持的酷我歌单标签类型: {digest}")
def _filter_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, str]] = []
_types: dict[str, dict[str, str]] = {}
for entry in str(item.get("N_MINFO") or "").split(";"):
match = _MININFO_RE.search(entry)
if match is None:
continue
quality = _BITRATE_QUALITY.get(match.group(2))
if quality is None:
continue
types.append({"type": quality, "size": match.group(4)})
_types[quality] = {"size": match.group(4).upper()}
types.reverse()
result.append(
{
"singer": format_singer(decode_name(item.get("artist"))),
"name": decode_name(item.get("name")),
"albumName": decode_name(item.get("album")),
"albumId": item.get("albumid"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": None,
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _filter_list(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
{
"play_count": format_play_count(_to_int(item.get("listencnt"))),
"id": f"digest-{item.get('digest')}__{item.get('id')}",
"author": item.get("uname"),
"name": item.get("name"),
"total": item.get("total"),
"img": item.get("img"),
"grade": _to_int(item.get("favorcnt")) / 10,
"desc": item.get("desc"),
"source": "kw",
}
for item in raw_list
]
def _filter_list2(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""qz_data 接口返回按 label 分组的二维结构,逐组展开成一维。
原实现用 `a && f(b)` 做兜底:条件为真才格式化,否则原样返回条件值。
play_count 用 `item.play_count` 作条件却格式化 `item.listencnt`,逐个照搬。
"""
result: list[dict[str, Any]] = []
for group in raw_data:
if not group.get("label"):
continue
for item in group.get("list") or []:
raw_play = item.get("play_count")
raw_favor = item.get("favorcnt")
result.append(
{
"play_count": format_play_count(_to_int(item.get("listencnt"))) if raw_play else (raw_play or 0),
"id": f"digest-{item.get('digest')}__{item.get('id')}",
"author": item.get("uname"),
"name": item.get("name"),
"total": item.get("total"),
"img": item.get("img"),
"grade": _to_int(raw_favor) / 10 if raw_favor else (raw_favor or 0),
"desc": item.get("desc"),
"source": "kw",
}
)
return result
def _detail_info(body: dict[str, Any]) -> dict[str, Any]:
return {
"name": body.get("title"),
"img": body.get("pic"),
"desc": body.get("info"),
"author": body.get("uname"),
"play_count": format_play_count(_to_int(body.get("playnum"))),
}
async def get_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(TAGS_API)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(retry_num + 1)
return [
{
"name": group.get("name"),
"list": [
{
"parent_id": group.get("id"),
"parent_name": group.get("name"),
"id": f"{item.get('id')}-{item.get('digest')}",
"name": item.get("name"),
"source": "kw",
}
for item in group.get("data") or []
],
}
for group in body.get("data") or []
]
async def get_hot_tag(retry_num: int = 0) -> list[dict[str, Any]]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(HOT_TAG_API)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_hot_tag(retry_num + 1)
groups = body.get("data") or [{}]
return [
{"id": f"{item.get('id')}-{item.get('digest')}", "name": item.get("name"), "source": "kw"}
for item in (groups[0] or {}).get("data") or []
]
async def get_tags() -> dict[str, Any]:
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag())
return {"tags": tags, "hotTag": hot_tag, "source": "kw"}
async def get_list(sort_id: str, tag_id: str | None = None, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
playlist_id: str | None = None
digest = ""
if tag_id:
parts = tag_id.split("-")
playlist_id = parts[0]
digest = parts[1] if len(parts) > 1 else ""
url = _get_list_url(sort_id, playlist_id, digest, page)
resp = await http_fetch(url)
body = resp.body
if not playlist_id or digest == "10000":
if not isinstance(body, dict) or body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, retry_num + 1)
data = body.get("data") or {}
return {
"list": _filter_list(data.get("data") or []),
"total": data.get("total"),
"page": data.get("pn"),
"limit": data.get("rn"),
"source": "kw",
}
if not isinstance(body, list) or not body:
return await get_list(sort_id, tag_id, page, retry_num + 1)
return {"list": _filter_list2(body), "total": SONG_LIMIT, "page": page, "limit": SONG_LIMIT, "source": "kw"}
async def get_list_detail_digest8(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(LIST_DETAIL_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("result") != "ok":
return await get_list_detail(playlist_id, page, retry_num + 1)
return {
"list": _filter_list_detail(body.get("musiclist") or []),
"page": page,
"limit": body.get("rn"),
"total": body.get("total"),
"source": "kw",
"info": _detail_info(body),
}
async def get_list_detail_digest5_info(playlist_id: str, retry_num: int = 0) -> str | None:
"""digest=5 的歌单 id 不能直接给 nplserver,要先查出真正的 sourceid。"""
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(DIGEST5_INFO_API.format(id=playlist_id))
body = resp.body if isinstance(resp.body, dict) else {}
children = body.get("child")
if resp.status_code != 200 or not children:
return await get_list_detail_digest5_info(playlist_id, retry_num + 1)
return str(children[0].get("sourceid")) if children else None
async def get_list_detail_digest5_music(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
if retry_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(DIGEST5_MUSIC_API.format(id=playlist_id, pn=page - 1, rn=SONG_LIMIT))
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("result") != "ok":
return await get_list_detail(playlist_id, page, retry_num + 1)
return {
"list": _filter_list_detail(body.get("musiclist") or []),
"page": page,
"limit": body.get("rn"),
"total": body.get("total"),
"source": "kw",
"info": _detail_info(body),
}
async def get_list_detail_digest5(playlist_id: str, page: int, retry_num: int = 0) -> dict[str, Any]:
detail_id = await get_list_detail_digest5_info(playlist_id, retry_num)
if detail_id is None:
raise RuntimeError("failed")
return await get_list_detail_digest5_music(detail_id, page, retry_num)
def _filter_bd_list_detail(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
result: list[dict[str, Any]] = []
for item in raw_list:
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
for audio in item.get("audios") or []:
size = _as_str(audio.get("size")).upper() if audio.get("size") is not None else None
quality = _BITRATE_QUALITY.get(_as_str(audio.get("bitrate")))
if quality is None:
continue
types.append({"type": quality, "size": size})
_types[quality] = {"size": size}
types.reverse()
result.append(
{
"singer": "、".join(_as_str(s.get("name")) for s in item.get("artists") or []),
"name": item.get("name"),
"albumName": item.get("album"),
"albumId": item.get("albumId"),
"songmid": item.get("id"),
"source": "kw",
"interval": format_play_time(_to_int(item.get("duration"))),
"img": item.get("albumPic"),
"releaseDate": item.get("releaseDate"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return result
def _get_req_id() -> str:
"""BD 接口的 reqId:八段随机 16 进制。
原实现 `(65536 * (1 + Math.random()) | 0).toString(16).substring(1)`:
值落在 [0x10000, 0x1ffff],固定 5 位 16 进制,去掉首位后取 4 位。
"""
def segment() -> str:
return f"{int(65536 * (1 + random.random())):05x}"[1:]
return "".join(segment() for _ in range(8))
async def _bd_playlist_info(playlist_id: str, source: str) -> dict[str, Any] | None:
resp = await http_fetch(BD_INFO_API.format(id=playlist_id, req_id=_get_req_id(), source=source), headers=BD_HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
return None
data = body.get("data") or {}
return {
"name": data.get("name"),
"img": data.get("pic"),
"desc": data.get("description"),
"author": data.get("creatorName"),
"play_count": data.get("playNum"),
}
async def _bd_user_pub_info(user_id: str) -> dict[str, Any] | None:
resp = await http_fetch(BD_USER_API.format(id=user_id, req_id=_get_req_id()), headers=BD_HEADERS)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
return None
info = (body.get("data") or {}).get("userInfo") or {}
nickname = _as_str(info.get("nickname"))
return {
"name": nickname + "喜欢的音乐",
"img": info.get("headImg"),
"desc": "",
"author": nickname,
"play_count": "",
}
async def _bd_playlist_songs(playlist_id: str, source: str, page: int, retry_num: int = 0) -> dict[str, Any]:
try:
resp = await http_fetch(
BD_LIST_API.format(id=playlist_id, req_id=_get_req_id(), source=source, page=page, rn=SONG_LIMIT),
headers=BD_HEADERS,
)
except Exception as err:
if retry_num > 2:
raise RuntimeError("try max num") from err
return await _bd_playlist_songs(playlist_id, source, page, retry_num + 1)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200:
raise RuntimeError("failed")
data = body.get("data") or {}
return {
"list": _filter_bd_list_detail(data.get("list") or []),
"page": page,
"limit": data.get("pageSize"),
"total": data.get("total"),
"source": "kw",
}
def _match_group(link: str, pattern: str) -> str | None:
found = re.search(pattern, link)
return found.group(1) if found else None
async def _get_list_detail_by_bd(raw_id: str, page: int) -> dict[str, Any]:
"""bodian 分享链接:uid/playlistId/source 都在 query 里,缺 source 时按原实现拼字面量。"""
uid = _match_group(raw_id, r"uid=(\d+)")
playlist_id = _match_group(raw_id, r"playlistId=(\d+)")
source = _match_group(raw_id, r"source=(\d+)") or "undefined"
if not playlist_id:
raise RuntimeError("failed")
tasks: list[Any] = [_bd_playlist_songs(playlist_id, source, page)]
if source == "4":
tasks.append(_bd_playlist_info(playlist_id, source))
elif source == "5":
tasks.append(_bd_user_pub_info(uid or playlist_id))
results = await asyncio.gather(*tasks)
list_data = results[0]
info = results[1] if len(results) > 1 else None
list_data["info"] = info or {"name": "", "img": "", "desc": "", "author": "", "play_count": ""}
return list_data
async def get_list_detail(playlist_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
if _BD_RE.search(playlist_id):
return await _get_list_detail_by_bd(playlist_id, page)
if _LINKISH_RE.search(playlist_id):
playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id)
elif _DIGEST_RE.search(playlist_id):
parts = _DIGEST_SPLIT_RE.split(playlist_id)
digest = parts[0].replace("digest-", "")
playlist_id = parts[1] if len(parts) > 1 else ""
if digest == "13":
from app.sdk.platforms.kw import album
return await album.get_album_list_detail(playlist_id, page, retry_num)
# 原文 switch 里只有 '8' 会 break 落到 digest8,其余(含 '5' 与未知 digest)全走 digest5
if digest == "8":
return await get_list_detail_digest8(playlist_id, page, retry_num)
return await get_list_detail_digest5(playlist_id, page, retry_num)
return await get_list_detail_digest8(playlist_id, page, retry_num)
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
resp = await http_fetch(SEARCH_API.format(keyword=quote(keyword, safe=""), pn=page - 1, rn=limit))
body = obj_str2json(resp.raw.decode("utf-8", errors="replace"))
return {
"list": [
{
"play_count": format_play_count(_to_int(item.get("playcnt"))),
"id": _as_str(item.get("playlistid")),
"author": decode_name(item.get("nickname")),
"name": decode_name(item.get("name")),
"total": item.get("songnum"),
"img": item.get("pic"),
"desc": decode_name(item.get("intro")),
"source": "kw",
}
for item in body.get("abslist") or []
],
"limit": limit,
"total": _to_int(body.get("TOTAL")),
"source": "kw",
}
def get_detail_page_url(playlist_id: str) -> str:
if _LINKISH_RE.search(playlist_id):
playlist_id = _DETAIL_LINK_RE.sub(r"\1", playlist_id)
elif _DIGEST_RE.search(playlist_id):
parts = playlist_id.split("__")
playlist_id = parts[1] if len(parts) > 1 else ""
return f"http://www.kuwo.cn/playlist_detail/{playlist_id}"
__all__ = [
"SORT_LIST",
"get_detail_page_url",
"get_hot_tag",
"get_list",
"get_list_detail",
"get_tag",
"get_tags",
"search",
]