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:
snowy-yang committed 2026-10-23 21:55:45 +08:00
1 parent 800871ef6e
commit dfc06bf4de
182 files changed
+26620

No files matched your search

+95
View File
@@ -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,
)
+251
View File
@@ -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",
]
+89
View File
@@ -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)
+28
View File
@@ -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&notrace=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 [])}
+155
View File
@@ -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",
}
+124
View File
@@ -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
+38
View File
@@ -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()
+116
View File
@@ -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",
}
+117
View File
@@ -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)
+22
View File
@@ -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
+255
View File
@@ -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",
]
+517
View File
@@ -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",
]
+192
View File
@@ -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)