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,95 @@
|
||||
"""酷我音乐(对应 sdk/kw/index.ts 的 default export)。
|
||||
|
||||
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 专辑 / 歌单 / 解析,
|
||||
顶层挂 get_music_url(取流在 apis/kw.py)、get_lyric、get_pic、get_music_info、
|
||||
handle_music_info、get_music_detail_page_url。
|
||||
|
||||
酷我没有歌手主页子模块(TS 侧也没有),`singer` 能力位留空,路由据此返回 404。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.apis.kw import get_music_url
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms._base import Platform
|
||||
from app.sdk.platforms.kw import (
|
||||
album,
|
||||
comment,
|
||||
hot_search,
|
||||
leaderboard,
|
||||
lyric,
|
||||
music_search,
|
||||
parse,
|
||||
pic,
|
||||
playlist,
|
||||
song_list,
|
||||
)
|
||||
from app.sdk.platforms.kw.util import format_singer
|
||||
|
||||
MUSIC_INFO_API = "http://www.kuwo.cn/api/www/music/musicInfo?mid={songmid}"
|
||||
|
||||
|
||||
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""歌曲详情(歌名/歌手/封面/专辑)。上游 code != 200 时抛出 msg 原文。"""
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
resp = await http_fetch(MUSIC_INFO_API.format(songmid=songmid))
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("code") == 200:
|
||||
return body.get("data") or {}
|
||||
raise RuntimeError(str(body.get("msg") or "获取歌曲信息失败"))
|
||||
|
||||
|
||||
async def handle_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
|
||||
"""用 get_music_info 就地补全 name / singer / img / albumName,与原实现一致。"""
|
||||
info = await get_music_info(song_info)
|
||||
song_info["name"] = info.get("name")
|
||||
song_info["singer"] = format_singer(str(info.get("artist") or ""))
|
||||
song_info["img"] = info.get("pic")
|
||||
song_info["albumName"] = info.get("album")
|
||||
return song_info
|
||||
|
||||
|
||||
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
|
||||
return f"http://www.kuwo.cn/play_detail/{song_info.get('songmid') or ''}"
|
||||
|
||||
|
||||
async def get_music_urls(music_info: dict[str, Any]) -> dict[str, str]:
|
||||
"""批量取各档直链,返回 `{type: url}`(原 getMusicUrls 的回调语义)。"""
|
||||
import asyncio
|
||||
|
||||
types = music_info.get("types") or []
|
||||
songmid = str(music_info.get("songmid") or "")
|
||||
results = await asyncio.gather(
|
||||
*(get_music_url({"songmid": songmid}, str(item.get("type") or "")) for item in types),
|
||||
return_exceptions=True,
|
||||
)
|
||||
type_url: dict[str, str] = {}
|
||||
for res in results:
|
||||
if isinstance(res, BaseException):
|
||||
continue
|
||||
if res.get("url"):
|
||||
type_url[str(res.get("type"))] = str(res["url"])
|
||||
return type_url
|
||||
|
||||
|
||||
MODULE = Platform(
|
||||
id="kw",
|
||||
name="酷我音乐",
|
||||
music_search=music_search,
|
||||
leaderboard=leaderboard,
|
||||
song_list=song_list,
|
||||
hot_search=hot_search,
|
||||
comment=comment,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
parse=parse,
|
||||
get_music_url=get_music_url,
|
||||
get_music_urls=get_music_urls,
|
||||
get_lyric=lyric.get_lyric,
|
||||
get_pic=pic.get_pic,
|
||||
get_music_info=get_music_info,
|
||||
handle_music_info=handle_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
)
|
||||
@@ -0,0 +1,251 @@
|
||||
"""酷我专辑(对应 sdk/kw/album.ts)。
|
||||
|
||||
上游 `search.kuwo.cn/r.s?stype=albuminfo` 返回伪 JSON(单引号),服务端固定
|
||||
1000 条/页。对外两个入口:
|
||||
* `get_album_list_detail(id, page)` —— 单页直取,第三参数是 retry_num
|
||||
* `get_album_detail(id, page, limit)` —— 统一形状,limit 小于 1000 时本地开窗
|
||||
路由按 hasattr 分流(`app/routes/music.py:584`),两个名字都要提供。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
from app.sdk.platforms.kw.util import format_singer, obj_str2json
|
||||
|
||||
LIST_LIMIT = 36
|
||||
SONG_LIMIT = 1000
|
||||
# 本地开窗最多翻到第 50 个服务端页,避免异常 total 导致长循环
|
||||
MAX_SERVER_PAGES = 50
|
||||
|
||||
DETAIL_API = (
|
||||
"http://search.kuwo.cn/r.s?pn={pn}&rn={rn}&stype=albuminfo&albumid={id}"
|
||||
"&show_copyright_off=0&encoding=utf&vipver=MUSIC_9.1.0"
|
||||
)
|
||||
SEARCH_API = (
|
||||
"http://search.kuwo.cn/r.s?all={keyword}&ft=album&itemset=web_2013&client=kt"
|
||||
"&pcmp4=1&geo=c&vipver=1&pn={pn}&rn={rn}&rformat=json&encoding=utf8"
|
||||
)
|
||||
|
||||
# `formats` 字段是 `|` 分隔的音质标记;这里只认对外暴露的四个档位
|
||||
_FORMAT_QUALITY = (
|
||||
("MP3128", "128k"),
|
||||
("MP3H", "320k"),
|
||||
("ALFLAC", "flac"),
|
||||
("HIRFLAC", "flac24bit"),
|
||||
)
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def _filter_list_detail(raw_list: list[dict[str, Any]], album_name: str, album_id: Any) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
formats = str(item.get("formats") or "").split("|")
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for marker, quality in _FORMAT_QUALITY:
|
||||
if marker not in formats:
|
||||
continue
|
||||
types.append({"type": quality, "size": None})
|
||||
_types[quality] = {"size": None}
|
||||
result.append(
|
||||
{
|
||||
"singer": format_singer(decode_name(item.get("artist"))),
|
||||
"name": decode_name(item.get("name")),
|
||||
"albumName": album_name,
|
||||
"albumId": album_id,
|
||||
"songmid": item.get("id"),
|
||||
"source": "kw",
|
||||
# 专辑接口不返回时长/封面,宁可为空也不猜
|
||||
"interval": None,
|
||||
"img": item.get("pic"),
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def get_album_list_detail(album_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
url = DETAIL_API.format(pn=page - 1, rn=SONG_LIMIT, id=album_id)
|
||||
resp = await http_fetch(url)
|
||||
if resp.status_code != 200:
|
||||
return await get_album_list_detail(album_id, page, retry_num + 1)
|
||||
body = obj_str2json(resp.raw.decode("utf-8", errors="replace"))
|
||||
if not body.get("musiclist"):
|
||||
return await get_album_list_detail(album_id, page, retry_num + 1)
|
||||
|
||||
album_name = decode_name(body.get("name"))
|
||||
return {
|
||||
"list": _filter_list_detail(body["musiclist"], album_name, body.get("albumid")),
|
||||
"page": page,
|
||||
"limit": SONG_LIMIT,
|
||||
"total": _to_int(body.get("songnum")),
|
||||
"source": "kw",
|
||||
"info": {
|
||||
"name": album_name,
|
||||
"img": body.get("img") or body.get("hts_img"),
|
||||
"desc": decode_name(body.get("info")),
|
||||
"author": decode_name(body.get("artist")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def get_album_detail(album_id: str, page: int = 1, limit: int = 1000) -> dict[str, Any]:
|
||||
"""统一形状的专辑详情。limit >= 1000 时服务端页大小够用,直通单页接口。"""
|
||||
album_key = str(album_id or "").strip()
|
||||
if not album_key:
|
||||
raise RuntimeError("酷我专辑 id 为空")
|
||||
page_no = max(1, _to_int(page) or 1)
|
||||
page_size = max(1, _to_int(limit) or SONG_LIMIT)
|
||||
if page_size >= SONG_LIMIT:
|
||||
return await get_album_list_detail(album_key, page_no)
|
||||
|
||||
start = (page_no - 1) * page_size
|
||||
server_page = start // SONG_LIMIT + 1
|
||||
skip = start % SONG_LIMIT
|
||||
songs: list[dict[str, Any]] = []
|
||||
info: dict[str, Any] | None = None
|
||||
total = 0
|
||||
while len(songs) < page_size and server_page <= MAX_SERVER_PAGES:
|
||||
res = await get_album_list_detail(album_key, server_page)
|
||||
if not res:
|
||||
break
|
||||
if info is None:
|
||||
info = res.get("info")
|
||||
total = _to_int(res.get("total")) or total
|
||||
raw_list = res.get("list") or []
|
||||
if not raw_list:
|
||||
break
|
||||
songs.extend(raw_list[skip : skip + (page_size - len(songs))])
|
||||
skip = 0
|
||||
if len(raw_list) < SONG_LIMIT:
|
||||
break
|
||||
if total > 0 and len(songs) >= total:
|
||||
break
|
||||
server_page += 1
|
||||
|
||||
return {
|
||||
"source": "kw",
|
||||
"info": info or {"name": album_key, "img": "", "desc": "", "author": ""},
|
||||
"list": songs,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": total or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
|
||||
"""聚合全部分页曲目并按 songmid 去重。"""
|
||||
album_key = str(album_id or "").strip()
|
||||
if not album_key:
|
||||
raise RuntimeError("酷我专辑 id 为空")
|
||||
songs: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for page in range(1, MAX_SERVER_PAGES + 1):
|
||||
res = await get_album_list_detail(album_key, page)
|
||||
raw_list = res.get("list") or []
|
||||
for song in raw_list:
|
||||
key = str(song.get("songmid") or "")
|
||||
if key and key in seen:
|
||||
continue
|
||||
if key:
|
||||
seen.add(key)
|
||||
songs.append(song)
|
||||
total = _to_int(res.get("total"))
|
||||
if len(raw_list) < SONG_LIMIT:
|
||||
break
|
||||
if total > 0 and len(songs) >= total:
|
||||
break
|
||||
return songs
|
||||
|
||||
|
||||
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
word = str(keyword or "").strip()
|
||||
if not word:
|
||||
return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0}
|
||||
page_no = max(1, _to_int(page) or 1)
|
||||
page_size = max(1, _to_int(limit) or 10)
|
||||
|
||||
resp = await http_fetch(SEARCH_API.format(keyword=quote(word, safe=""), pn=page_no - 1, rn=page_size))
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError("酷我专辑搜索失败")
|
||||
text = resp.raw.decode("utf-8", errors="replace")
|
||||
try:
|
||||
data = obj_str2json(text)
|
||||
except Exception as err:
|
||||
raise RuntimeError("酷我专辑搜索失败") from err
|
||||
if not data.get("albumlist"):
|
||||
raise RuntimeError("酷我专辑搜索失败")
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in data["albumlist"]:
|
||||
album_key = _first_non_empty(item.get("albumid"), item.get("id"))
|
||||
if not album_key:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": album_key,
|
||||
"name": decode_name(_first_non_empty(item.get("name"))),
|
||||
"cover": _first_non_empty(item.get("hts_img"), item.get("img")),
|
||||
"trackCount": _to_int(item.get("musiccnt")),
|
||||
"playCount": _to_int(item.get("PLAYCNT") or item.get("playcnt")),
|
||||
"creator": decode_name(_first_non_empty(item.get("aartist"), item.get("artist"))),
|
||||
"description": decode_name(_first_non_empty(item.get("info"))),
|
||||
"link": f"http://www.kuwo.cn/album_detail/{album_key}",
|
||||
"source": "kw",
|
||||
"type": "album",
|
||||
"company": decode_name(_first_non_empty(item.get("company"))),
|
||||
"publishTime": _first_non_empty(item.get("pub")),
|
||||
}
|
||||
)
|
||||
return {
|
||||
"source": "kw",
|
||||
"list": result,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": _to_int(data.get("TOTAL") or data.get("total")) or len(result),
|
||||
}
|
||||
|
||||
|
||||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||||
raw = str(link or "")
|
||||
for pattern in (r"album_detail/(\d+)", r"album/(\d+)", r"albumid=(\d+)"):
|
||||
match = re.search(pattern, raw)
|
||||
if match:
|
||||
return await get_album_detail(match.group(1), 1)
|
||||
raise RuntimeError("无效的酷我专辑链接")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_album_detail",
|
||||
"get_album_list_detail",
|
||||
"get_album_songs",
|
||||
"parse_album_link",
|
||||
"search_album",
|
||||
]
|
||||
@@ -0,0 +1,89 @@
|
||||
"""酷我评论(对应 sdk/kw/comment.ts)。
|
||||
|
||||
`ncomment.kuwo.cn/com.s` 只认安卓 Dalvik UA;`type=get_comment` 取最新评论、
|
||||
`type=get_rec_comment` 取热评,两者响应字段名不同(comments/comments_counts
|
||||
vs hot_comments/hot_comments_counts)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import date_format2
|
||||
|
||||
COMMENT_API = (
|
||||
"http://ncomment.kuwo.cn/com.s?f=web&type={type}&aapiver=1&prod=kwplayer_ar_10.5.2.0"
|
||||
"&digest=15&sid={songmid}&start={start}&msgflag=1&count={count}&newver=3&uid=0"
|
||||
)
|
||||
|
||||
HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"}
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
def _reply_comment(item: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
"id": item.get("id"),
|
||||
"text": item.get("msg"),
|
||||
"time": item.get("time"),
|
||||
"timeStr": date_format2(_to_int(item.get("time")) * 1000),
|
||||
"userName": item.get("u_name"),
|
||||
"avatar": item.get("u_pic"),
|
||||
"userId": item.get("u_id"),
|
||||
"likedCount": item.get("like_num"),
|
||||
"images": [item["mpic"]] if item.get("mpic") else [],
|
||||
}
|
||||
|
||||
|
||||
def _filter_comment(raw_list: list[dict[str, Any]] | None) -> list[dict[str, Any]]:
|
||||
if not raw_list:
|
||||
return []
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
data = _reply_comment(item)
|
||||
data["images"] = [unquote(str(item["mpic"]))] if item.get("mpic") else []
|
||||
# 顶层评论多一个楼中楼数组;原实现对父级 mpic 做 decodeURIComponent,子级不做
|
||||
data["reply"] = [_reply_comment(child) for child in item.get("child_comments") or []]
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_comment(songmid: str, page: int, limit: int, *, hot: bool) -> dict[str, Any]:
|
||||
comment_type = "get_rec_comment" if hot else "get_comment"
|
||||
url = COMMENT_API.format(type=comment_type, songmid=songmid, start=limit * (page - 1), count=limit)
|
||||
resp = await http_fetch(url, headers=HEADERS)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or str(body.get("code")) != "200":
|
||||
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
|
||||
|
||||
if hot:
|
||||
total = _to_int(body.get("hot_comments_counts"))
|
||||
comments = _filter_comment(body.get("hot_comments"))
|
||||
else:
|
||||
total = _to_int(body.get("comments_counts"))
|
||||
comments = _filter_comment(body.get("comments"))
|
||||
return {
|
||||
"source": "kw",
|
||||
"comments": comments,
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
# 原实现用 `Math.ceil(total/limit) || 1` 兜住 0 条
|
||||
"maxPage": math.ceil(total / limit) if limit and total else 1,
|
||||
}
|
||||
|
||||
|
||||
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=False)
|
||||
|
||||
|
||||
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
return await _get_comment(str(song_info.get("songmid") or ""), page, limit, hot=True)
|
||||
@@ -0,0 +1,28 @@
|
||||
"""酷我热搜(对应 sdk/kw/hotSearch.ts)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
|
||||
HOT_API = (
|
||||
"http://hotword.kuwo.cn/hotword.s?prod=kwplayer_ar_9.3.0.1&corp=kuwo&newver=2"
|
||||
"&vipver=9.3.0.1&source=kwplayer_ar_9.3.0.1_40.apk&p2p=1¬race=0&uid=0"
|
||||
"&plat=kwplayer_ar&rformat=json&encoding=utf8&tabid=1"
|
||||
)
|
||||
|
||||
# 该接口只认移动端 Dalvik UA,桌面 UA 会返回非 JSON
|
||||
HEADERS = {"User-Agent": "Dalvik/2.1.0 (Linux; U; Android 9;)"}
|
||||
|
||||
|
||||
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
|
||||
return [str(item.get("key") or "") for item in raw_list]
|
||||
|
||||
|
||||
async def get_list() -> dict[str, Any]:
|
||||
resp = await http_fetch(HOT_API, headers=HEADERS)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("status") != "ok":
|
||||
raise RuntimeError("获取热搜词失败")
|
||||
return {"source": "kw", "list": _filter_list(body.get("tagvalue") or [])}
|
||||
@@ -0,0 +1,155 @@
|
||||
"""酷我榜单(对应 sdk/kw/leaderboard.ts)。
|
||||
|
||||
`get_boards` 直接返回硬编码榜单表(不发请求),id 形如 `kw__93`(路由层会剥掉前缀
|
||||
再把裸 bangid 传回 `get_list`)。详情走 `wbd.kuwo.cn/api/bd/bang/bang_info`:
|
||||
请求体整体 AES-128-ECB 加密 + 签名,响应同样是加密体,`util.wbd_*` 负责双向转换。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
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, wbd_build_param, wbd_decode_data
|
||||
|
||||
LIST_DETAIL_LIMIT = 100
|
||||
|
||||
BOARD_LIST: list[dict[str, str]] = [
|
||||
{"id": "kw__93", "name": "飙升榜", "bangid": "93"},
|
||||
{"id": "kw__17", "name": "新歌榜", "bangid": "17"},
|
||||
{"id": "kw__16", "name": "热歌榜", "bangid": "16"},
|
||||
{"id": "kw__158", "name": "抖音热歌榜", "bangid": "158"},
|
||||
{"id": "kw__292", "name": "铃声榜", "bangid": "292"},
|
||||
{"id": "kw__284", "name": "热评榜", "bangid": "284"},
|
||||
{"id": "kw__290", "name": "ACG新歌榜", "bangid": "290"},
|
||||
{"id": "kw__286", "name": "台湾KKBOX榜", "bangid": "286"},
|
||||
{"id": "kw__279", "name": "冬日暖心榜", "bangid": "279"},
|
||||
{"id": "kw__281", "name": "巴士随身听榜", "bangid": "281"},
|
||||
{"id": "kw__255", "name": "KTV点唱榜", "bangid": "255"},
|
||||
{"id": "kw__280", "name": "家务进行曲榜", "bangid": "280"},
|
||||
{"id": "kw__282", "name": "熬夜修仙榜", "bangid": "282"},
|
||||
{"id": "kw__283", "name": "枕边轻音乐榜", "bangid": "283"},
|
||||
{"id": "kw__278", "name": "古风音乐榜", "bangid": "278"},
|
||||
{"id": "kw__264", "name": "Vlog音乐榜", "bangid": "264"},
|
||||
{"id": "kw__242", "name": "电音榜", "bangid": "242"},
|
||||
{"id": "kw__187", "name": "流行趋势榜", "bangid": "187"},
|
||||
{"id": "kw__204", "name": "现场音乐榜", "bangid": "204"},
|
||||
{"id": "kw__186", "name": "ACG神曲榜", "bangid": "186"},
|
||||
{"id": "kw__185", "name": "最强翻唱榜", "bangid": "185"},
|
||||
{"id": "kw__26", "name": "经典怀旧榜", "bangid": "26"},
|
||||
{"id": "kw__104", "name": "华语榜", "bangid": "104"},
|
||||
{"id": "kw__182", "name": "粤语榜", "bangid": "182"},
|
||||
{"id": "kw__22", "name": "欧美榜", "bangid": "22"},
|
||||
{"id": "kw__184", "name": "韩语榜", "bangid": "184"},
|
||||
{"id": "kw__183", "name": "日语榜", "bangid": "183"},
|
||||
{"id": "kw__145", "name": "会员畅听榜", "bangid": "145"},
|
||||
{"id": "kw__153", "name": "网红新歌榜", "bangid": "153"},
|
||||
{"id": "kw__64", "name": "影视金曲榜", "bangid": "64"},
|
||||
{"id": "kw__176", "name": "DJ嗨歌榜", "bangid": "176"},
|
||||
{"id": "kw__106", "name": "真声音", "bangid": "106"},
|
||||
{"id": "kw__12", "name": "Billboard榜", "bangid": "12"},
|
||||
{"id": "kw__49", "name": "iTunes音乐榜", "bangid": "49"},
|
||||
{"id": "kw__180", "name": "beatport电音榜", "bangid": "180"},
|
||||
{"id": "kw__13", "name": "英国UK榜", "bangid": "13"},
|
||||
{"id": "kw__164", "name": "百大DJ榜", "bangid": "164"},
|
||||
{"id": "kw__246", "name": "YouTube音乐排行榜", "bangid": "246"},
|
||||
{"id": "kw__265", "name": "韩国Genie榜", "bangid": "265"},
|
||||
{"id": "kw__14", "name": "韩国M-net榜", "bangid": "14"},
|
||||
{"id": "kw__8", "name": "香港电台榜", "bangid": "8"},
|
||||
{"id": "kw__15", "name": "日本公信榜", "bangid": "15"},
|
||||
{"id": "kw__151", "name": "腾讯音乐人原创榜", "bangid": "151"},
|
||||
]
|
||||
|
||||
BANG_API = "https://wbd.kuwo.cn/api/bd/bang/bang_info"
|
||||
|
||||
_BITRATE_QUALITY = {"4000": "flac24bit", "2000": "flac", "320": "320k", "128": "128k"}
|
||||
# 榜单列表按音质等级升序排列(128k → 320k → flac → flac24bit)
|
||||
_QUALITY_RANK = {"flac24bit": 4, "flac": 3, "320k": 2, "128k": 1}
|
||||
|
||||
_MININFO_RE = re.compile(r"level:(\w+),bitrate:(\d+),format:(\w+),size:([\w.]+)")
|
||||
|
||||
|
||||
def _filter_data(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]] = {}
|
||||
seen: set[str] = set()
|
||||
for entry in str(item.get("n_minfo") or "").split(";"):
|
||||
match = _MININFO_RE.search(entry)
|
||||
if match is None:
|
||||
continue
|
||||
bitrate = match.group(2)
|
||||
if bitrate in seen:
|
||||
continue
|
||||
seen.add(bitrate)
|
||||
quality = _BITRATE_QUALITY.get(bitrate)
|
||||
if quality is None:
|
||||
continue
|
||||
# 原实现 types 留原样、_types 转大写,两者不完全一致
|
||||
types.append({"type": quality, "size": match.group(4)})
|
||||
_types[quality] = {"size": match.group(4).upper()}
|
||||
types.sort(key=lambda entry: _QUALITY_RANK[entry["type"]])
|
||||
|
||||
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": item.get("pic"),
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
return int(float(value))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
async def get_boards() -> dict[str, Any]:
|
||||
return {"list": BOARD_LIST, "source": "kw"}
|
||||
|
||||
|
||||
async def get_list(board_id: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
|
||||
retry_num += 1
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("try max num")
|
||||
|
||||
body = {
|
||||
"uid": "",
|
||||
"devId": "",
|
||||
"sFrom": "kuwo_sdk",
|
||||
"user_type": "AP",
|
||||
"carSource": "kwplayercar_ar_6.0.1.0_apk_keluze.apk",
|
||||
"id": board_id,
|
||||
"pn": page - 1,
|
||||
"rn": LIST_DETAIL_LIMIT,
|
||||
}
|
||||
resp = await http_fetch(f"{BANG_API}?{wbd_build_param(body)}")
|
||||
# 原实现直接调 decodeData,解密失败即 reject、不重试,这里保持一致
|
||||
raw_data = wbd_decode_data(resp.body) if isinstance(resp.body, str) else {}
|
||||
data = raw_data.get("data") or {}
|
||||
if resp.status_code != 200 or raw_data.get("code") != 200 or not data.get("musiclist"):
|
||||
return await get_list(board_id, page, retry_num)
|
||||
|
||||
return {
|
||||
"total": _to_int(data.get("total")),
|
||||
"list": _filter_data(data["musiclist"]),
|
||||
"limit": LIST_DETAIL_LIMIT,
|
||||
"page": page,
|
||||
"source": "kw",
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
"""酷我歌词(对应 sdk/kw/lyric.ts)。
|
||||
|
||||
`newlyric.kuwo.cn/newlyric.lrc?<params>`:params 是明文字符串与 `yeelion` 逐字节异或
|
||||
后的 base64。响应体是 zlib 压缩 + 可选 yeelion 异或,由 `lyric_decode` 还原。
|
||||
|
||||
歌词分两种形态:普通 lrc(`[mm:ss.xx]文本`)与 lrcx/逐字(每行带 `<start,dur>` 标记)。
|
||||
lrcx 走 `util.parse_yrc` 归一到 `yrc` 字段,`lyric`/`tlyric` 里的逐字标记则剥掉,
|
||||
保持纯 lrc 可直接给播放器。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import decode_name
|
||||
from app.sdk.platforms.kw.lyric_decode import handle_kw_decode_lyric
|
||||
from app.sdk.platforms.kw.util import parse_yrc
|
||||
|
||||
_BUF_KEY = b"yeelion"
|
||||
|
||||
LYRIC_API = "http://newlyric.kuwo.cn/newlyric.lrc"
|
||||
|
||||
_TIME_EXP = re.compile(r"^\[([\d:.]*)\]")
|
||||
_TAG_LINE = re.compile(r"\[(ver|ti|ar|al|offset|by|kuwo):\s*(\S+(?:\s+\S+)*)\s*\]")
|
||||
_EXIST_TIME_EXP = re.compile(r"\[\d{1,2}:.*\d{1,4}\]")
|
||||
_LYRICX_TAG = re.compile(r"^<-?\d+,-?\d+>")
|
||||
_WORD_TIME_ALL = re.compile(r"<(-?\d+),(-?\d+)(?:,-?\d+)?>")
|
||||
|
||||
|
||||
def build_params(songmid: str, is_get_lyricx: bool) -> str:
|
||||
"""明文参数与 `yeelion` 循环异或后 base64。
|
||||
|
||||
原实现用 `new Uint16Array` 中转再 `Buffer.from(output)`;Node 对 TypedArray
|
||||
走 fromArrayLike,按元素截断成 8 位字节,等价于这里的逐字节异或。
|
||||
"""
|
||||
params = f"user=12345,web,web,web&requester=localhost&req=1&rid=MUSIC_{songmid}"
|
||||
if is_get_lyricx:
|
||||
params += "&lrcx=1"
|
||||
raw = params.encode()
|
||||
return base64.b64encode(bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(raw))).decode()
|
||||
|
||||
|
||||
def _sort_lrc_arr(arr: list[dict[str, str]]) -> tuple[list[dict[str, str]], list[dict[str, str]]]:
|
||||
"""按时间去重:重复时间视为翻译行,挤进 lrc_t。
|
||||
|
||||
全部行都没有逐字标记、且「翻译行」占比异常时,认为数据错乱并抛 failed。
|
||||
"""
|
||||
lrc_set: set[str] = set()
|
||||
lrc: list[dict[str, str]] = []
|
||||
lrc_t: list[dict[str, str]] = []
|
||||
is_lyricx = False
|
||||
for item in arr:
|
||||
if item["time"] in lrc_set:
|
||||
if len(lrc) < 2:
|
||||
continue
|
||||
t_item = lrc.pop()
|
||||
t_item["time"] = lrc[-1]["time"]
|
||||
lrc_t.append(t_item)
|
||||
lrc.append(item)
|
||||
else:
|
||||
lrc.append(item)
|
||||
lrc_set.add(item["time"])
|
||||
if not is_lyricx and _LYRICX_TAG.match(item["text"]):
|
||||
is_lyricx = True
|
||||
if not is_lyricx and len(lrc_t) > len(lrc) * 0.3 and len(lrc) - len(lrc_t) > 6:
|
||||
raise RuntimeError("failed")
|
||||
return lrc, lrc_t
|
||||
|
||||
|
||||
def _transform_lrc(tags: list[str], lrc_list: list[dict[str, str]]) -> str:
|
||||
body = "".join(f"[{item['time']}]{item['text']}\n" for item in lrc_list)
|
||||
return "\n".join(tags) + "\n" + body
|
||||
|
||||
|
||||
def parse_lrc(lrc: str) -> dict[str, str]:
|
||||
tags: list[str] = []
|
||||
arr: list[dict[str, str]] = []
|
||||
for raw_line in re.split(r"\r\n|\r|\n", lrc):
|
||||
line = raw_line.strip()
|
||||
match = _TIME_EXP.match(line)
|
||||
if match:
|
||||
text = _TIME_EXP.sub("", line, count=1).strip()
|
||||
time = match.group(1)
|
||||
# 只有两位小数的补一个 0,与三位/更长的保持同宽
|
||||
if re.search(r"\.\d\d$", time):
|
||||
time += "0"
|
||||
arr.append({"time": time, "text": text})
|
||||
elif _TAG_LINE.search(line):
|
||||
tags.append(line)
|
||||
|
||||
lrc_list, lrc_t = _sort_lrc_arr(arr)
|
||||
return {
|
||||
"lyric": decode_name(_transform_lrc(tags, lrc_list)),
|
||||
"tlyric": decode_name(_transform_lrc(tags, lrc_t)) if lrc_t else "",
|
||||
}
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any], is_get_lyricx: bool = True) -> dict[str, str]:
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
resp = await http_fetch(f"{LYRIC_API}?{build_params(songmid, is_get_lyricx)}")
|
||||
if resp.status_code != 200:
|
||||
raise RuntimeError(json.dumps(resp.body, ensure_ascii=False))
|
||||
|
||||
base64_data = await handle_kw_decode_lyric(base64.b64encode(resp.raw).decode(), is_get_lyricx)
|
||||
try:
|
||||
lrc_info = parse_lrc(base64.b64decode(base64_data).decode("utf-8", errors="replace"))
|
||||
except Exception as err:
|
||||
raise RuntimeError("Get lyric failed") from err
|
||||
|
||||
if lrc_info.get("tlyric"):
|
||||
lrc_info["tlyric"] = _WORD_TIME_ALL.sub("", lrc_info["tlyric"])
|
||||
# yrc 解析失败只代表这份歌词没有逐字数据,不影响普通歌词返回
|
||||
try:
|
||||
lrc_info["yrc"] = parse_yrc(lrc_info["lyric"])
|
||||
except Exception: # noqa: BLE001
|
||||
lrc_info["yrc"] = ""
|
||||
lrc_info["lyric"] = _WORD_TIME_ALL.sub("", lrc_info["lyric"])
|
||||
if not _EXIST_TIME_EXP.search(lrc_info["lyric"]):
|
||||
raise RuntimeError("Get lyric failed")
|
||||
return lrc_info
|
||||
@@ -0,0 +1,38 @@
|
||||
"""酷我歌词解码(对应 sdk/kw/lyricDecode.ts)。
|
||||
|
||||
响应体前缀是 `tp=content`,`\\r\\n\\r\\n` 之后为 zlib 压缩数据;非 lrcx(普通歌词)
|
||||
直接按 gb18030 解码,lrcx(逐字歌词)先 base64 解出、再与 `yeelion` 逐字节异或,
|
||||
最后按 gb18030 解码。算法来源为酷我桌面端歌词解码实现。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import zlib
|
||||
|
||||
_BUF_KEY = b"yeelion"
|
||||
|
||||
|
||||
def _xor_yeelion(data: bytes) -> bytes:
|
||||
"""逐字节与 `yeelion` 循环异或(原实现用双层 while,等价于 i % len(key))。"""
|
||||
return bytes(byte ^ _BUF_KEY[index % len(_BUF_KEY)] for index, byte in enumerate(data))
|
||||
|
||||
|
||||
async def decode_kw_lyric(buf: bytes, is_get_lyricx: bool) -> str:
|
||||
"""解出明文歌词。非酷我歌词格式返回空串(与原实现一致)。"""
|
||||
if buf[:10].decode("utf-8", errors="replace") != "tp=content":
|
||||
return ""
|
||||
# 找不到分隔时原实现 indexOf 返回 -1,+4 后从第 3 字节开始切片,这里保持一致
|
||||
lrc_data = zlib.decompress(buf[buf.find(b"\r\n\r\n") + 4 :])
|
||||
|
||||
if not is_get_lyricx:
|
||||
return lrc_data.decode("gb18030", errors="replace")
|
||||
|
||||
raw = base64.b64decode(lrc_data.decode("utf-8", errors="replace"))
|
||||
return _xor_yeelion(raw).decode("gb18030", errors="replace")
|
||||
|
||||
|
||||
async def handle_kw_decode_lyric(lrc_base64: str, is_get_lyricx: bool) -> str:
|
||||
"""解码入口:入参与返回值都是 base64(对应原 handleKwDecodeLyric)。"""
|
||||
lrc = await decode_kw_lyric(base64.b64decode(lrc_base64), is_get_lyricx)
|
||||
return base64.b64encode(lrc.encode()).decode()
|
||||
@@ -0,0 +1,116 @@
|
||||
"""酷我搜索(对应 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",
|
||||
}
|
||||
@@ -0,0 +1,117 @@
|
||||
"""酷我链接解析(对应 sdk/kw/parse.ts)。
|
||||
|
||||
歌曲:`www.kuwo.cn/play_detail/<rid>`、`m.kuwo.cn/h5app/song/<rid>`、
|
||||
`musicId=<rid>` 或裸 rid(4 位以上数字)。元数据取
|
||||
`m.kuwo.cn/newh5/singles/songinfoandlrc`,风控时会回 status 301「音乐查询失败」,
|
||||
按 600ms×(n+1) 退避重试最多 5 次(约 9 秒)。
|
||||
|
||||
直链是尽力的:版权/风控导致取流失败时仍返回元数据(与上游 fetchAudioURL 降级一致)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import format_play_time
|
||||
from app.sdk.platforms.kw import album, playlist
|
||||
from app.sdk.platforms.kw.util import format_singer
|
||||
|
||||
_UA = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36"
|
||||
|
||||
SONG_PATTERNS = (
|
||||
re.compile(r"play_detail/(\d+)"),
|
||||
re.compile(r"h5app/song/(\d+)"),
|
||||
re.compile(r"musicId=(\d+)"),
|
||||
re.compile(r"^\d{4,}$"),
|
||||
)
|
||||
|
||||
META_API = "http://m.kuwo.cn/newh5/singles/songinfoandlrc?musicId={rid}&httpsStatus=1"
|
||||
MAX_META_RETRY = 5
|
||||
|
||||
|
||||
def extract_song_id(link: Any) -> str:
|
||||
raw = str(link if link is not None else "").strip()
|
||||
for pattern in SONG_PATTERNS:
|
||||
match = pattern.search(raw)
|
||||
if match:
|
||||
return match.group(1) if match.groups() else raw
|
||||
return ""
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
try:
|
||||
return int(float(str(value).strip()))
|
||||
except (TypeError, ValueError):
|
||||
return 0
|
||||
|
||||
|
||||
async def fetch_song_meta(rid: str, retry_num: int = 0) -> dict[str, Any]:
|
||||
resp = await http_fetch(
|
||||
META_API.format(rid=rid),
|
||||
method="get",
|
||||
headers={"User-Agent": _UA, "Cookie": "wd=1;vip=0"},
|
||||
timeout=15_000,
|
||||
)
|
||||
data: Any = resp.body
|
||||
if isinstance(data, str):
|
||||
try:
|
||||
data = json.loads(data)
|
||||
except ValueError:
|
||||
data = None
|
||||
songinfo = ((data or {}).get("data") or {}).get("songinfo")
|
||||
if not songinfo:
|
||||
if retry_num < MAX_META_RETRY:
|
||||
await asyncio.sleep(0.6 * (retry_num + 1))
|
||||
return await fetch_song_meta(rid, retry_num + 1)
|
||||
msg = (data or {}).get("msg") if isinstance(data, dict) else None
|
||||
raise RuntimeError(msg or "酷我歌曲不存在或已下架")
|
||||
return songinfo
|
||||
|
||||
|
||||
async def parse_song(link: str) -> dict[str, Any]:
|
||||
rid = extract_song_id(link)
|
||||
if not rid:
|
||||
raise RuntimeError("无效的酷我歌曲链接")
|
||||
|
||||
meta = await fetch_song_meta(rid)
|
||||
duration = _to_int(meta.get("duration"))
|
||||
album_id = meta.get("albumId")
|
||||
song: dict[str, Any] = {
|
||||
"songmid": rid,
|
||||
"name": meta.get("songName") or f"Kuwo_Song_{rid}",
|
||||
"singer": format_singer(str(meta.get("artist") or "").strip()),
|
||||
"albumName": str(meta.get("album") or "").strip(),
|
||||
"albumId": "" if album_id is None else str(album_id),
|
||||
"interval": format_play_time(duration) if duration > 0 else "--/--",
|
||||
"img": meta.get("pic") or None,
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": [],
|
||||
"_types": {},
|
||||
"typeUrl": {},
|
||||
"source": "kw",
|
||||
"extra": {"rid": rid, "link": f"http://www.kuwo.cn/play_detail/{rid}"},
|
||||
}
|
||||
|
||||
try:
|
||||
from app.sdk.apis.kw import get_music_url
|
||||
|
||||
hit = await get_music_url({"songmid": rid}, "128k")
|
||||
if hit.get("url"):
|
||||
song["url"] = hit["url"]
|
||||
song["type"] = hit["type"]
|
||||
except Exception: # noqa: BLE001 - 版权/风控取流失败时仍返回元数据
|
||||
pass
|
||||
return song
|
||||
|
||||
|
||||
async def parse_album(link: str) -> dict[str, Any]:
|
||||
return await album.parse_album_link(link)
|
||||
|
||||
|
||||
async def parse_playlist(link: str) -> dict[str, Any]:
|
||||
return await playlist.parse_playlist_link(link)
|
||||
@@ -0,0 +1,22 @@
|
||||
"""酷我封面(对应 sdk/kw/pic.ts)。
|
||||
|
||||
`artistpicserver.kuwo.cn/pic.web` 返回的是一段纯文本(不是 JSON),内容就是图片 URL;
|
||||
取不到时返回 `None`(原实现用 `/^http/` 判定,不匹配即 null)。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
|
||||
PIC_API = "http://artistpicserver.kuwo.cn/pic.web"
|
||||
PIC_PARAMS = "corp=kuwo&type=rid_pic&pictype=500&size=500"
|
||||
|
||||
|
||||
async def get_pic(song_info: dict[str, Any]) -> str | None:
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
resp = await http_fetch(f"{PIC_API}?{PIC_PARAMS}&rid={songmid}")
|
||||
text = resp.body if isinstance(resp.body, str) else ""
|
||||
text = text.strip()
|
||||
return text if text.startswith("http") else None
|
||||
@@ -0,0 +1,255 @@
|
||||
"""酷我歌单(对应 sdk/kw/playlist.ts)。
|
||||
|
||||
在 `song_list` 之上做统一形状:
|
||||
* `get_playlist_detail(id, page, limit)` —— 服务端 1000 条/页,本地按 page/limit 开窗
|
||||
* `get_playlist_songs(id)` —— 聚合全部页曲目
|
||||
* `search_playlist` / `get_categories` / `get_category_playlists` / `get_recommended`
|
||||
推荐(getRcmPlayList)服务端 limit 固定 36,显式更小的 limit 在本地截断。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.kw import song_list
|
||||
|
||||
SERVER_PAGE_SIZE = song_list.SONG_LIMIT
|
||||
MAX_SERVER_PAGES = 60
|
||||
|
||||
_ID_PATTERNS = (
|
||||
re.compile(r"playlist_detail/(\d+)"),
|
||||
re.compile(r"/playlist/(\d+)"),
|
||||
re.compile(r"playlistId=(\d+)"),
|
||||
)
|
||||
_DIGEST_PREFIX_RE = re.compile(r"^digest-\w+__")
|
||||
|
||||
|
||||
def _js_number_or_raw(value: Any, default: Any = 0) -> Any:
|
||||
"""复现 JS 的 `Number(x) → isFinite ? 数字 : 原值` 语义。
|
||||
|
||||
`format_play_count` 返回的是 `"1.2万"` 这类字符串,`Number("1.2万")` 是 NaN,
|
||||
原实现于是把原字符串原样传下去;能解析成数字的才转成数字。
|
||||
"""
|
||||
if value is None or value == "":
|
||||
return default
|
||||
try:
|
||||
num = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return value
|
||||
if not math.isfinite(num):
|
||||
return value
|
||||
return int(num) if num.is_integer() else num
|
||||
|
||||
|
||||
def _to_number(value: Any) -> float:
|
||||
try:
|
||||
return float(value)
|
||||
except (TypeError, ValueError):
|
||||
return 0.0
|
||||
|
||||
|
||||
def _to_int(value: Any) -> int:
|
||||
return int(_to_number(value))
|
||||
|
||||
|
||||
def _first_non_empty(*values: Any) -> str:
|
||||
for value in values:
|
||||
if value is None:
|
||||
continue
|
||||
text = str(value).strip()
|
||||
if text:
|
||||
return text
|
||||
return ""
|
||||
|
||||
|
||||
def normalize_playlist_id(playlist_id: Any) -> str:
|
||||
"""歌单链接 → 裸 id;digest 前缀形态原样保留(交给 song_list 分流)。"""
|
||||
raw = "" if playlist_id is None else str(playlist_id).strip()
|
||||
if not raw:
|
||||
return ""
|
||||
for pattern in _ID_PATTERNS:
|
||||
match = pattern.search(raw)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return raw
|
||||
|
||||
|
||||
def _to_summary(item: dict[str, Any]) -> dict[str, Any]:
|
||||
"""歌单摘要:在原始条目上补齐契约要求的 id/name/cover/trackCount/… 字段。"""
|
||||
playlist_id = str(item.get("id") or "").strip()
|
||||
plain_id = _DIGEST_PREFIX_RE.sub("", playlist_id)
|
||||
return {
|
||||
**item,
|
||||
"id": playlist_id,
|
||||
"name": str(item.get("name") or "").strip(),
|
||||
"cover": _first_non_empty(item.get("img"), item.get("cover")),
|
||||
# 原实现用 `??` 而非 `||`,total 为 0 时不会退化到 trackCount
|
||||
"trackCount": _to_int(item["total"] if item.get("total") is not None else item.get("trackCount")) or 0,
|
||||
"playCount": _js_number_or_raw(item.get("play_count")),
|
||||
"creator": _first_non_empty(item.get("author"), item.get("creator"), item.get("uname")),
|
||||
"description": _first_non_empty(item.get("desc"), item.get("description")),
|
||||
"link": f"http://www.kuwo.cn/playlist_detail/{plain_id}" if plain_id else "",
|
||||
"source": "kw",
|
||||
}
|
||||
|
||||
|
||||
def _windowed(result: dict[str, Any], page: int, limit: int) -> dict[str, Any]:
|
||||
"""推荐/分类接口的 limit 由服务端固定,显式更小时本地截断。"""
|
||||
requested = _to_int(limit)
|
||||
server_limit = _to_int(result.get("limit"))
|
||||
songs = [item for item in (_to_summary(raw) for raw in result.get("list") or []) if item["id"]]
|
||||
if requested > 0 and len(songs) > requested:
|
||||
songs = songs[:requested]
|
||||
effective = min(requested, server_limit) if requested > 0 and server_limit > 0 else server_limit
|
||||
return {
|
||||
"source": "kw",
|
||||
"list": songs,
|
||||
"page": page,
|
||||
"limit": effective or len(songs),
|
||||
"total": _to_int(result.get("total")) or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
word = str(keyword or "").strip()
|
||||
if not word:
|
||||
return {"source": "kw", "list": [], "page": page, "limit": limit, "total": 0}
|
||||
res = await song_list.search(word, page, limit)
|
||||
songs = [item for item in (_to_summary(raw) for raw in res.get("list") or []) if item["id"]]
|
||||
return {
|
||||
"source": "kw",
|
||||
"list": songs,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": _to_int(res.get("total")) or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
|
||||
raw_id = normalize_playlist_id(playlist_id)
|
||||
if not raw_id:
|
||||
raise RuntimeError("酷我歌单 id 为空")
|
||||
page_no = max(1, _to_int(page) or 1)
|
||||
page_size = max(1, _to_int(limit) or 50)
|
||||
start = (page_no - 1) * page_size
|
||||
|
||||
server_page = start // SERVER_PAGE_SIZE + 1
|
||||
skip = start % SERVER_PAGE_SIZE
|
||||
songs: list[dict[str, Any]] = []
|
||||
info: dict[str, Any] | None = None
|
||||
total = 0
|
||||
while len(songs) < page_size and server_page <= MAX_SERVER_PAGES:
|
||||
res = await song_list.get_list_detail(raw_id, server_page)
|
||||
if not res:
|
||||
break
|
||||
if info is None:
|
||||
info = res.get("info")
|
||||
total = _to_int(res.get("total")) or total
|
||||
raw_list = res.get("list") or []
|
||||
if not raw_list:
|
||||
break
|
||||
songs.extend(raw_list[skip : skip + (page_size - len(songs))])
|
||||
skip = 0
|
||||
if len(raw_list) < SERVER_PAGE_SIZE:
|
||||
break
|
||||
if total > 0 and server_page * SERVER_PAGE_SIZE >= total:
|
||||
break
|
||||
server_page += 1
|
||||
|
||||
info = info or {}
|
||||
return {
|
||||
"source": "kw",
|
||||
"info": {
|
||||
"name": _first_non_empty(info.get("name"), raw_id),
|
||||
"img": _first_non_empty(info.get("img")),
|
||||
"desc": _first_non_empty(info.get("desc")),
|
||||
"author": _first_non_empty(info.get("author")),
|
||||
"play_count": info.get("play_count", ""),
|
||||
},
|
||||
"list": songs,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": total or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
|
||||
"""聚合全部页曲目并按 songmid 去重。"""
|
||||
raw_id = normalize_playlist_id(playlist_id)
|
||||
if not raw_id:
|
||||
raise RuntimeError("酷我歌单 id 为空")
|
||||
songs: list[dict[str, Any]] = []
|
||||
seen: set[str] = set()
|
||||
for server_page in range(1, MAX_SERVER_PAGES + 1):
|
||||
res = await song_list.get_list_detail(raw_id, server_page)
|
||||
raw_list = res.get("list") or []
|
||||
for song in raw_list:
|
||||
key = str(song.get("songmid") or "")
|
||||
if key and key in seen:
|
||||
continue
|
||||
if key:
|
||||
seen.add(key)
|
||||
songs.append(song)
|
||||
total = _to_int(res.get("total"))
|
||||
if len(raw_list) < SERVER_PAGE_SIZE:
|
||||
break
|
||||
if total > 0 and len(songs) >= total:
|
||||
break
|
||||
return songs
|
||||
|
||||
|
||||
async def get_categories() -> dict[str, Any]:
|
||||
tags_data = await song_list.get_tags()
|
||||
hot_ids = {str(item.get("id") or "") for item in tags_data.get("hotTag") or []}
|
||||
result: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
|
||||
for group in tags_data.get("tags") or []:
|
||||
for item in group.get("list") or []:
|
||||
tag_id = str(item.get("id") or "").strip()
|
||||
name = str(item.get("name") or "").strip()
|
||||
if not tag_id or not name:
|
||||
continue
|
||||
result.append(
|
||||
{
|
||||
"id": tag_id,
|
||||
"name": name,
|
||||
"group": str(group.get("name") or "").strip() or "其他",
|
||||
"hot": tag_id in hot_ids,
|
||||
}
|
||||
)
|
||||
return {"source": "kw", "list": result}
|
||||
|
||||
|
||||
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
tag_id = str(category_id or "").strip()
|
||||
page_no = max(1, _to_int(page) or 1)
|
||||
res = await song_list.get_list("hot", tag_id or None, page_no)
|
||||
return _windowed(res, page_no, limit)
|
||||
|
||||
|
||||
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
|
||||
page_no = max(1, _to_int(page) or 1)
|
||||
res = await song_list.get_list("hot", None, page_no)
|
||||
return _windowed(res, page_no, limit)
|
||||
|
||||
|
||||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||||
raw = str(link or "")
|
||||
for pattern in _ID_PATTERNS:
|
||||
match = pattern.search(raw)
|
||||
if match:
|
||||
return await get_playlist_detail(match.group(1), 1)
|
||||
raise RuntimeError("无效的酷我歌单链接")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"get_categories",
|
||||
"get_category_playlists",
|
||||
"get_playlist_detail",
|
||||
"get_playlist_songs",
|
||||
"get_recommended",
|
||||
"normalize_playlist_id",
|
||||
"parse_playlist_link",
|
||||
"search_playlist",
|
||||
]
|
||||
@@ -0,0 +1,517 @@
|
||||
"""酷我歌单广场与歌单详情(对应 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",
|
||||
]
|
||||
@@ -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