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,65 @@
|
||||
"""酷狗音乐(对应 sdk/kg/index.ts 的 default export)。
|
||||
|
||||
能力位与 TS 一致:搜索 / 榜单 / 歌单广场 / 热搜 / 评论 / 歌手 / 专辑 / 歌单 / 解析,
|
||||
顶层挂 get_music_url(取流在 apis/kg.py)、get_lyric、get_pic、get_music_info
|
||||
(hash 反查歌名/歌手/时长,供取流失败的酷我兜底使用)、get_music_detail_page_url。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.apis.kg import get_music_url
|
||||
from app.sdk.platforms._base import Platform
|
||||
from app.sdk.platforms.kg import (
|
||||
album,
|
||||
comment,
|
||||
hot_search,
|
||||
leaderboard,
|
||||
lyric,
|
||||
music_info,
|
||||
music_search,
|
||||
parse,
|
||||
pic,
|
||||
playlist,
|
||||
singer,
|
||||
song_list,
|
||||
)
|
||||
from app.sdk.platforms.kg.util import get_device
|
||||
|
||||
|
||||
async def _init() -> None:
|
||||
"""预先固化设备指纹(取流签名依赖 dfid/mid,首次调用时生成并持久化)。"""
|
||||
get_device()
|
||||
|
||||
|
||||
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
|
||||
hash_value = song_info.get("hash") or song_info.get("songmid")
|
||||
album_id = song_info.get("albumId")
|
||||
return f"https://www.kugou.com/song/#hash={hash_value}&album_id={album_id}"
|
||||
|
||||
|
||||
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""路由兜底调用:传 `{"hash": id, "songmid": id}`,返回含 name/singer/interval 的 dict。"""
|
||||
return await music_info.get_music_info_for_song_info(song_info)
|
||||
|
||||
|
||||
MODULE = Platform(
|
||||
id="kg",
|
||||
name="酷狗音乐",
|
||||
music_search=music_search,
|
||||
leaderboard=leaderboard,
|
||||
song_list=song_list,
|
||||
hot_search=hot_search,
|
||||
comment=comment,
|
||||
singer=singer,
|
||||
album=album,
|
||||
playlist=playlist,
|
||||
parse=parse,
|
||||
get_music_url=get_music_url,
|
||||
get_lyric=lyric.get_lyric,
|
||||
get_pic=pic.get_pic,
|
||||
get_music_info=get_music_info,
|
||||
get_music_detail_page_url=get_music_detail_page_url,
|
||||
init=_init,
|
||||
)
|
||||
@@ -0,0 +1,150 @@
|
||||
"""酷狗专辑(对应 sdk/kg/album.ts)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
|
||||
from app.sdk.platforms.kg.util import create_http_fetch, encode_uri_component
|
||||
|
||||
ALBUM_INFO_URL = (
|
||||
"http://kmrserviceretry.kugou.com/container/v1/album"
|
||||
"?dfid=1tT5He3kxrNC4D29ad1MMb6F&mid=22945702112173152889429073101964063697&userid=0&appid=1005&clientver=11589"
|
||||
)
|
||||
|
||||
ALBUM_SONG_URL = "http://mobiles.kugou.com/api/v3/album/song"
|
||||
|
||||
ALBUM_SEARCH_URL = "http://mobilecdn.kugou.com/api/v3/search/album"
|
||||
|
||||
_UA_MOBILE = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
|
||||
def _album_cover(url: Any) -> str:
|
||||
return str(url or "").replace("{size}", "240")
|
||||
|
||||
|
||||
async def _get_album_info(id: Any) -> dict[str, Any]:
|
||||
result = await create_http_fetch(
|
||||
ALBUM_INFO_URL,
|
||||
{
|
||||
"method": "POST",
|
||||
"body": {
|
||||
"appid": 1005,
|
||||
"clienttime": 1681833686,
|
||||
"clientver": 11589,
|
||||
"data": [{"album_id": id}],
|
||||
"fields": (
|
||||
"language,grade_count,intro,mix_intro,heat,category,sizable_cover,cover,album_name,type,"
|
||||
"quality,publish_company,grade,special_tag,author_name,publish_date,language_id,album_id,"
|
||||
"exclusive,is_publish,trans_param,authors,album_tag"
|
||||
),
|
||||
"isBuy": 0,
|
||||
"key": "e6f3306ff7e2afb494e89fbbda0becbf",
|
||||
"mid": "22945702112173152889429073101964063697",
|
||||
"show_album_tag": 0,
|
||||
},
|
||||
},
|
||||
)
|
||||
if not result:
|
||||
raise RuntimeError("get album info failed.")
|
||||
info = result[0] if isinstance(result, list) else result
|
||||
return {
|
||||
"name": info.get("album_name"),
|
||||
"image": str(info.get("sizable_cover") or "").replace("{size}", "240"),
|
||||
"desc": info.get("intro"),
|
||||
"authorName": info.get("author_name"),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_detail(album_id: str, page: int = 1, limit: int = 200) -> dict[str, Any]:
|
||||
album_list = await create_http_fetch(
|
||||
f"{ALBUM_SONG_URL}?version=9108&albumid={album_id}&plat=0&pagesize={limit}"
|
||||
f"&area_code=0&page={page}&with_res_tag=0"
|
||||
)
|
||||
album_list = album_list or {}
|
||||
if not album_list.get("info"):
|
||||
raise RuntimeError("Get album list failed.")
|
||||
songs = await get_music_infos_by_list(album_list.get("info") or [])
|
||||
info = await _get_album_info(album_id)
|
||||
return {
|
||||
"list": songs or [],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": album_list.get("total"),
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": info["name"],
|
||||
"img": info["image"],
|
||||
"desc": info["desc"],
|
||||
"author": info["authorName"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
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": "kg", "list": [], "page": page, "limit": limit, "total": 0}
|
||||
page_no = max(1, int(page or 1))
|
||||
page_size = max(1, int(limit or 10))
|
||||
url = f"{ALBUM_SEARCH_URL}?keyword={encode_uri_component(word)}&format=json&page={page_no}&pagesize={page_size}"
|
||||
resp = await http_fetch(url, headers={"User-Agent": _UA_MOBILE, "Referer": "http://m.kugou.com"}, timeout=15000)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
|
||||
raise RuntimeError(str(body.get("error") or "酷狗专辑搜索失败"))
|
||||
data = body.get("data") or {}
|
||||
songs: list[dict[str, Any]] = []
|
||||
for item in data.get("info") or []:
|
||||
album_id = str(item.get("albumid") or "").strip()
|
||||
if not album_id or album_id == "0":
|
||||
continue
|
||||
songs.append(
|
||||
{
|
||||
"id": album_id,
|
||||
"name": str(item.get("albumname") or "").strip(),
|
||||
"cover": _album_cover(item.get("imgurl")),
|
||||
"trackCount": int(item.get("songcount") or 0) or 0,
|
||||
"creator": str(item.get("singername") or "").strip(),
|
||||
"description": str(item.get("intro") or "").strip(),
|
||||
"publishTime": str(item.get("publishtime") or "").strip(),
|
||||
"link": f"https://www.kugou.com/album/{album_id}.html",
|
||||
"source": "kg",
|
||||
"type": "album",
|
||||
}
|
||||
)
|
||||
return {
|
||||
"source": "kg",
|
||||
"list": songs,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": int(data.get("total") or 0) or len(songs),
|
||||
}
|
||||
|
||||
|
||||
async def get_album_songs(id: Any) -> list[dict[str, Any]]:
|
||||
album_id = str(id or "").strip()
|
||||
if not album_id:
|
||||
raise RuntimeError("酷狗专辑 id 为空")
|
||||
result = await get_album_detail(album_id, 1, 10000)
|
||||
return result["list"]
|
||||
|
||||
|
||||
async def parse_album_link(link: str) -> dict[str, Any]:
|
||||
"""album/single/<id>.html、yy/album/single/<id>.html、album/<id>.html、albumid=<id>。"""
|
||||
import re
|
||||
|
||||
raw = str(link or "")
|
||||
for pattern in (
|
||||
r"album/single/(\d+)\.html",
|
||||
r"yy/album/single/(\d+)\.html",
|
||||
r"album/(\d+)\.html",
|
||||
r"albumid=(\d+)",
|
||||
):
|
||||
match = re.search(pattern, raw)
|
||||
if match:
|
||||
return await get_album_detail(match.group(1), 1)
|
||||
raise RuntimeError("无效的酷狗专辑链接")
|
||||
@@ -0,0 +1,128 @@
|
||||
"""酷狗评论(对应 sdk/kg/comment.ts)。
|
||||
|
||||
签名:`signatureParams(params)` —— 参数串按键排序拼接后以 android 盐包裹取 MD5。
|
||||
返回形状是 `{source, comments, total, page, limit, maxPage}`(不是 `list`),
|
||||
`filterComment` 还要处理 `atlist` 替换与 `pcontent`(楼中楼)两种形态。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import date_format2, decode_name
|
||||
from app.sdk.platforms.kg.util import signature_params
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/107.0.0.0 Safari/537.36 Edg/107.0.1418.24"
|
||||
)
|
||||
|
||||
|
||||
def _comment_params(hash_value: str, page: int, limit: int) -> str:
|
||||
return (
|
||||
"dfid=0&mid=16249512204336365674023395779019"
|
||||
f"&clienttime={int(time.time() * 1000)}&uuid=0&extdata={hash_value}"
|
||||
"&appid=1005&code=fc4be23b4e972707f36b8a828a93ba8a"
|
||||
f"&schash={hash_value}&clientver=11409&p={page}&clienttoken=&pagesize={limit}"
|
||||
"&ver=10&kugouid=0"
|
||||
)
|
||||
|
||||
|
||||
def _replace_at(raw: str, at_list: list[dict[str, Any]]) -> str:
|
||||
for item in at_list:
|
||||
raw = raw.replace(f"[at={item.get('id')}]", f"@{item.get('name')} ")
|
||||
return raw
|
||||
|
||||
|
||||
def _format_comment(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
content = item.get("content") or ""
|
||||
if item.get("atlist"):
|
||||
content = _replace_at(str(content), item["atlist"])
|
||||
images = [image.get("url") for image in item.get("images") or []] if item.get("images") else []
|
||||
like = item.get("like") or {}
|
||||
data = {
|
||||
"id": item.get("id"),
|
||||
"text": decode_name(content),
|
||||
"images": images,
|
||||
"location": item.get("location"),
|
||||
"time": item.get("addtime"),
|
||||
"timeStr": date_format2(item.get("addtime") or 0),
|
||||
"userName": item.get("user_name"),
|
||||
"avatar": item.get("user_pic"),
|
||||
"userId": item.get("user_id"),
|
||||
"likedCount": like.get("likenum"),
|
||||
"replyNum": item.get("reply_num"),
|
||||
"reply": [],
|
||||
}
|
||||
if item.get("pcontent"):
|
||||
# 楼中楼:父评论包一层,原评论挂在 reply 里
|
||||
result.append(
|
||||
{
|
||||
"id": item.get("id"),
|
||||
"text": decode_name(item["pcontent"]),
|
||||
"time": None,
|
||||
"userName": item.get("puser"),
|
||||
"avatar": None,
|
||||
"userId": item.get("puser_id"),
|
||||
"likedCount": None,
|
||||
"replyNum": None,
|
||||
"reply": [data],
|
||||
}
|
||||
)
|
||||
else:
|
||||
result.append(data)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_comment(hash_value: str, page: int, limit: int, hot: bool) -> dict[str, Any]:
|
||||
path = "topliked" if hot else "newest"
|
||||
params = _comment_params(hash_value, page, limit)
|
||||
resp = await http_fetch(
|
||||
f"http://m.comment.service.kugou.com/r/v1/rank/{path}?{params}&signature={signature_params(params)}",
|
||||
headers={"User-Agent": _UA},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("err_code") != 0:
|
||||
raise RuntimeError("获取热门评论失败" if hot else "获取评论失败")
|
||||
total = body.get("count") or 0
|
||||
return {
|
||||
"source": "kg",
|
||||
"comments": _format_comment(body.get("list") or []),
|
||||
"total": total,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"maxPage": math.ceil(total / limit) if limit else 1,
|
||||
}
|
||||
|
||||
|
||||
async def get_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
|
||||
return await _get_comment(hash_value, page, limit, False)
|
||||
|
||||
|
||||
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
|
||||
return await _get_comment(hash_value, page, limit, True)
|
||||
|
||||
|
||||
async def get_reply_comment(
|
||||
song_info: dict[str, Any], reply_id: Any, page: int = 1, limit: int = 100
|
||||
) -> dict[str, Any]:
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
children_id = str(song_info.get("audioId") or "").split("_")[0] if len(songmid) == 32 else songmid
|
||||
resp = await http_fetch(
|
||||
"http://comment.service.kugou.com/index.php?r=commentsv2/getReplyWithLike"
|
||||
"&code=fc4be23b4e972707f36b8a828a93ba8a"
|
||||
f"&p={page}&pagesize={limit}&ver=1.01&clientver=8373&kugouid=687373022&need_show_image=1"
|
||||
f"&appid=1001&childrenid={children_id}&tid={reply_id}",
|
||||
headers={"User-Agent": "Android712-AndroidPhone-8983-18-0-COMMENT-wifi"},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("err_code") != 0:
|
||||
raise RuntimeError("获取回复评论失败")
|
||||
return {"source": "kg", "comments": _format_comment(body.get("list") or [])}
|
||||
@@ -0,0 +1,41 @@
|
||||
"""酷狗热搜(对应 sdk/kg/hotSearch.ts)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import decode_name
|
||||
|
||||
HOT_API = (
|
||||
"http://gateway.kugou.com/api/v3/search/hot_tab"
|
||||
"?signature=ee44edb9d7155821412d220bcaf509dd&appid=1005&clientver=10026&plat=0"
|
||||
)
|
||||
|
||||
HEADERS = {
|
||||
"dfid": "1ssiv93oVqMp27cirf2CvoF1",
|
||||
"mid": "156798703528610303473757548878786007104",
|
||||
"clienttime": "1584257267",
|
||||
"x-router": "msearch.kugou.com",
|
||||
"user-agent": "Android9-AndroidPhone-10020-130-0-searchrecommendprotocol-wifi",
|
||||
"kg-rc": "1",
|
||||
}
|
||||
|
||||
|
||||
def _filter_list(raw_list: list[dict[str, Any]]) -> list[str]:
|
||||
result: list[str] = []
|
||||
for item in raw_list:
|
||||
for keyword in item.get("keywords") or []:
|
||||
result.append(decode_name(keyword.get("keyword")))
|
||||
return result
|
||||
|
||||
|
||||
async def get_list(retry_num: int = 0) -> dict[str, Any]:
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
resp = await http_fetch(HOT_API, method="get", headers=HEADERS)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("errcode") != 0:
|
||||
raise RuntimeError("获取热搜词失败")
|
||||
data = body.get("data") or {}
|
||||
return {"source": "kg", "list": _filter_list(data.get("list") or [])}
|
||||
@@ -0,0 +1,102 @@
|
||||
"""酷狗 KRC 歌词解密(对应 sdk/kg/krcDecode.ts)。
|
||||
|
||||
密文是 base64 → 去掉 4 字节头 → 与 16 字节固定 key 循环异或 → zlib inflate。
|
||||
解密后是带 `[start,duration]` 行级标签与 `<offset,duration,0>` 字级标签的 KRC 文本,
|
||||
另外 `[language:<base64>]` 行里藏着 base64(JSON) 的逐行翻译,需按行号拼回 LRC。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import json
|
||||
import re
|
||||
import zlib
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import decode_name
|
||||
|
||||
ENC_KEY = bytes([0x40, 0x47, 0x61, 0x77, 0x5E, 0x32, 0x74, 0x47, 0x51, 0x36, 0x31, 0x2D, 0xCE, 0xD2, 0x6E, 0x69])
|
||||
|
||||
_HEAD_EXP = re.compile(r"^.*\[id:\$\w+\]\n")
|
||||
_TRANS_EXP = re.compile(r"\[language:([\w=\\/+]+)\]")
|
||||
_TRANS_LINE_EXP = re.compile(r"\[language:[\w=\\/+]+\]\n")
|
||||
_LINE_EXP = re.compile(r"\[((\d+),\d+)\].*")
|
||||
_WORD_TAG_EXP = re.compile(r"<(\d+,\d+),\d+>")
|
||||
_PLAIN_TAG_EXP = re.compile(r"<\d+,\d+>")
|
||||
|
||||
|
||||
def _xor_decode(data: str) -> str:
|
||||
if not data:
|
||||
return ""
|
||||
buf = bytearray(base64.b64decode(data)[4:])
|
||||
for i in range(len(buf)):
|
||||
buf[i] ^= ENC_KEY[i % 16]
|
||||
return zlib.decompress(bytes(buf)).decode("utf-8", errors="replace")
|
||||
|
||||
|
||||
def _format_time(ms: int) -> str:
|
||||
"""毫秒 → `mm:ss.ms`(毫秒段与原文一致不补零)。"""
|
||||
minutes, rem = divmod(ms, 60000)
|
||||
return f"{minutes:02d}:{rem // 1000:02d}.{ms % 1000}"
|
||||
|
||||
|
||||
def _lrc_line(value: Any) -> str:
|
||||
"""rlyric/tlyric 的单项是字符串数组(逐字),字符串形态也容忍。"""
|
||||
if isinstance(value, list):
|
||||
return "".join(str(item) for item in value)
|
||||
return "" if value is None else str(value)
|
||||
|
||||
|
||||
def parse_lyric(text: str) -> dict[str, str]:
|
||||
text = text.replace("\r", "")
|
||||
if _HEAD_EXP.match(text):
|
||||
text = _HEAD_EXP.sub("", text, count=1)
|
||||
|
||||
rlyric: list[Any] | None = None
|
||||
tlyric: list[Any] | None = None
|
||||
trans = _TRANS_EXP.search(text)
|
||||
if trans:
|
||||
text = _TRANS_LINE_EXP.sub("", text, count=1)
|
||||
try:
|
||||
payload = json.loads(base64.b64decode(trans.group(1)).decode("utf-8", errors="replace"))
|
||||
except ValueError:
|
||||
payload = {}
|
||||
for item in payload.get("content") or []:
|
||||
if item.get("type") == 0:
|
||||
rlyric = item.get("lyricContent")
|
||||
elif item.get("type") == 1:
|
||||
tlyric = item.get("lyricContent")
|
||||
|
||||
index = 0
|
||||
|
||||
def repl(match: re.Match[str]) -> str:
|
||||
nonlocal index
|
||||
line_match = _LINE_EXP.match(match.group(0))
|
||||
time_ms = int(line_match.group(2)) if line_match else 0
|
||||
stamp = _format_time(time_ms)
|
||||
if rlyric is not None:
|
||||
rlyric[index] = f"[{stamp}]{_lrc_line(rlyric[index]) if index < len(rlyric) else ''}"
|
||||
if tlyric is not None:
|
||||
tlyric[index] = f"[{stamp}]{_lrc_line(tlyric[index]) if index < len(tlyric) else ''}"
|
||||
index += 1
|
||||
return match.group(0).replace(match.group(1), stamp, 1)
|
||||
|
||||
yrc = re.sub(r"\[((\d+),\d+)\].*", repl, text)
|
||||
rlyric_text = "\n".join(str(item) for item in rlyric) if rlyric else ""
|
||||
tlyric_text = "\n".join(str(item) for item in tlyric) if tlyric else ""
|
||||
yrc = _WORD_TAG_EXP.sub(r"<\1>", yrc)
|
||||
yrc = decode_name(yrc)
|
||||
lyric = _PLAIN_TAG_EXP.sub("", yrc)
|
||||
return {
|
||||
"lyric": lyric,
|
||||
"tlyric": decode_name(tlyric_text),
|
||||
"rlyric": decode_name(rlyric_text),
|
||||
"yrc": yrc,
|
||||
}
|
||||
|
||||
|
||||
async def decode_krc(data: str) -> dict[str, str]:
|
||||
return parse_lyric(_xor_decode(data))
|
||||
|
||||
|
||||
__all__ = ["decode_krc", "parse_lyric"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""酷狗榜单(对应 sdk/kg/leaderboard.ts)。
|
||||
|
||||
榜单 id 表是硬编码的静态表(原实现 getBoards 直接返回它,不请求上游);
|
||||
详情走 `mobilecdnbj.kugou.com/api/v3/rank/song`,返回的 hash 需再反查一次 SongInfo
|
||||
才能拿到多档音质。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import decode_name, format_play_time, size_formate
|
||||
|
||||
LIST_DETAIL_LIMIT = 100
|
||||
|
||||
BOARD_LIST: list[dict[str, str]] = [
|
||||
{"id": "kgtop500", "name": "TOP500", "bangid": "8888"},
|
||||
{"id": "kgwlhgb", "name": "网络榜", "bangid": "23784"},
|
||||
{"id": "kgbsb", "name": "飙升榜", "bangid": "6666"},
|
||||
{"id": "kgfxb", "name": "分享榜", "bangid": "21101"},
|
||||
{"id": "kgcyyb", "name": "纯音乐榜", "bangid": "33164"},
|
||||
{"id": "kggfjqb", "name": "古风榜", "bangid": "33161"},
|
||||
{"id": "kgyyjqb", "name": "粤语榜", "bangid": "33165"},
|
||||
{"id": "kgomjqb", "name": "欧美榜", "bangid": "33166"},
|
||||
{"id": "kgdyrgb", "name": "电音榜", "bangid": "33160"},
|
||||
{"id": "kgjdrgb", "name": "DJ热歌榜", "bangid": "24971"},
|
||||
{"id": "kghyxgb", "name": "华语新歌榜", "bangid": "31308"},
|
||||
]
|
||||
|
||||
RANK_API = "http://mobilecdnbj.kugou.com/api/v3/rank/song"
|
||||
|
||||
|
||||
def get_url(page: int, bangid: str, limit: int = LIST_DETAIL_LIMIT) -> str:
|
||||
return (
|
||||
f"{RANK_API}?version=9108&ranktype=1&plat=0&pagesize={limit}"
|
||||
f"&area_code=1&page={page}&rankid={bangid}&with_res_tag=0&show_portrait_mv=1"
|
||||
)
|
||||
|
||||
|
||||
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, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for quality, size_key, hash_key in (
|
||||
("128k", "filesize", "hash"),
|
||||
("320k", "320filesize", "320hash"),
|
||||
("flac", "sqfilesize", "sqhash"),
|
||||
("flac24bit", "filesize_high", "hash_high"),
|
||||
):
|
||||
if item.get(size_key, 0) == 0:
|
||||
continue
|
||||
size = size_formate(item.get(size_key))
|
||||
types.append({"type": quality, "size": size, "hash": item.get(hash_key)})
|
||||
_types[quality] = {"size": size, "hash": item.get(hash_key)}
|
||||
result.append(
|
||||
{
|
||||
"singer": _singer(item.get("authors")),
|
||||
"name": decode_name(item.get("songname")),
|
||||
"albumName": decode_name(item.get("remark")),
|
||||
"albumId": item.get("album_id"),
|
||||
"songmid": item.get("audio_id"),
|
||||
"source": "kg",
|
||||
"interval": format_play_time(item.get("duration")),
|
||||
"img": None,
|
||||
"lrc": None,
|
||||
"hash": item.get("hash"),
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _singer(authors: list[dict[str, Any]] | None) -> str:
|
||||
names = [str(author["author_name"]) for author in authors or [] if author.get("author_name")]
|
||||
return decode_name("、".join(names))
|
||||
|
||||
|
||||
async def get_boards() -> dict[str, Any]:
|
||||
return {"list": BOARD_LIST, "source": "kg"}
|
||||
|
||||
|
||||
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("try max num")
|
||||
resp: Any
|
||||
try:
|
||||
resp = await http_fetch(get_url(page, bangid))
|
||||
except Exception: # noqa: BLE001 - 可降级失败:换一次请求重试
|
||||
return await get_list(bangid, page, retry_num + 1)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("errcode") != 0:
|
||||
return await get_list(bangid, page, retry_num + 1)
|
||||
data = body.get("data") or {}
|
||||
return {
|
||||
"total": data.get("total"),
|
||||
"list": _filter_data(data.get("info") or []),
|
||||
"limit": 100,
|
||||
"page": page,
|
||||
"source": "kg",
|
||||
}
|
||||
|
||||
|
||||
def get_detail_page_url(id: Any) -> str:
|
||||
value = str(id).replace("kg__", "") if isinstance(id, str) else id
|
||||
return f"https://www.kugou.com/yy/rank/home/1-{value}.html"
|
||||
@@ -0,0 +1,91 @@
|
||||
"""酷狗歌词(对应 sdk/kg/lyric.ts)。
|
||||
|
||||
两步:`lyrics.kugou.com/search` 按歌名+hash+时长找候选 → `lyrics.kugou.com/download`
|
||||
取回 base64 内容。`fmt=krc` 需走 krc_decode 解密,`fmt=lrc` 直接 base64 解码。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.kg.krc_decode import decode_krc
|
||||
|
||||
HEADERS = {
|
||||
"KG-RC": "1",
|
||||
"KG-THash": "expand_search_manager.cpp:852736169:451",
|
||||
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
|
||||
}
|
||||
|
||||
|
||||
def get_intv(interval: Any) -> int:
|
||||
"""`mm:ss` → 毫秒(原文把秒当毫秒拼给上游,这里保持一致)。"""
|
||||
if not interval:
|
||||
return 0
|
||||
parts = str(interval).split(":")
|
||||
total = 0
|
||||
unit = 1
|
||||
while parts:
|
||||
part = parts.pop()
|
||||
try:
|
||||
total += int(float(part)) * unit
|
||||
except ValueError:
|
||||
pass
|
||||
unit *= 60
|
||||
return int(total)
|
||||
|
||||
|
||||
async def _search_lyric(name: str, hash_value: str, time: Any, try_num: int = 0) -> dict[str, str] | None:
|
||||
from app.sdk.platforms.kg.util import encode_uri_component
|
||||
|
||||
url = (
|
||||
"http://lyrics.kugou.com/search?ver=1&man=yes&client=pc"
|
||||
f"&keyword={encode_uri_component(name)}&hash={hash_value}&timelength={time}&lrctxt=1"
|
||||
)
|
||||
resp = await http_fetch(url, headers=HEADERS)
|
||||
if resp.status_code != 200:
|
||||
if try_num > 5:
|
||||
raise RuntimeError("歌词获取失败")
|
||||
return await _search_lyric(name, hash_value, time, try_num + 1)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
candidates = body.get("candidates") or []
|
||||
if not candidates:
|
||||
return None
|
||||
info = candidates[0]
|
||||
fmt = "krc" if info.get("krctype") == 1 and info.get("contenttype") != 1 else "lrc"
|
||||
return {"id": str(info.get("id")), "accessKey": str(info.get("accesskey")), "fmt": fmt}
|
||||
|
||||
|
||||
async def _get_lyric_download(id: str, access_key: str, fmt: str, try_num: int = 0) -> dict[str, str]:
|
||||
url = f"http://lyrics.kugou.com/download?ver=1&client=pc&id={id}&accesskey={access_key}&fmt={fmt}&charset=utf8"
|
||||
resp = await http_fetch(url, headers=HEADERS)
|
||||
if resp.status_code != 200:
|
||||
if try_num > 5:
|
||||
raise RuntimeError("歌词获取失败")
|
||||
# 重试必须回到本方法(搜索接口与下载接口的入参语义不同)
|
||||
return await _get_lyric_download(id, access_key, fmt, try_num + 1)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
content = str(body.get("content") or "")
|
||||
match body.get("fmt"):
|
||||
case "krc":
|
||||
return await decode_krc(content)
|
||||
case "lrc":
|
||||
return {
|
||||
"lyric": base64.b64decode(content).decode("utf-8", errors="replace"),
|
||||
"tlyric": "",
|
||||
"rlyric": "",
|
||||
"yrc": "",
|
||||
}
|
||||
case _:
|
||||
raise RuntimeError(f"未知歌词格式: {body.get('fmt')}")
|
||||
|
||||
|
||||
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
|
||||
name = str(song_info.get("name") or "")
|
||||
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "")
|
||||
time = song_info.get("_interval") or get_intv(song_info.get("interval"))
|
||||
result = await _search_lyric(name, hash_value, time)
|
||||
if not result:
|
||||
raise RuntimeError("Get lyric failed")
|
||||
return await _get_lyric_download(result["id"], result["accessKey"], result["fmt"])
|
||||
@@ -0,0 +1,138 @@
|
||||
"""按 hash 批量反查歌曲信息(对应 sdk/kg/musicInfo.ts)。
|
||||
|
||||
多个模块(榜单/歌单/专辑/歌手/解析/路由兜底)都要把 hash 换成完整 SongInfo,
|
||||
统一走这里:`http://gateway.kugou.com/v3/album_audio/audio`,每批最多 100 个 hash。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.utils import decode_name, format_play_time, size_formate
|
||||
from app.sdk.platforms.kg.util import create_http_fetch
|
||||
|
||||
MUSIC_INFO_URL = "http://gateway.kugou.com/v3/album_audio/audio"
|
||||
|
||||
MUSIC_INFO_HEADERS = {
|
||||
"KG-THash": "13a3164",
|
||||
"KG-RC": "1",
|
||||
"KG-Fake": "0",
|
||||
"KG-RF": "00869891",
|
||||
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
|
||||
"x-router": "kmr.service.kugou.com",
|
||||
}
|
||||
|
||||
_FIELDS = "album_info,author_name,audio_info,ori_audio_name,base,songname,classification"
|
||||
|
||||
|
||||
def _filter_music_info_list(raw_list: list[Any]) -> list[dict[str, Any]]:
|
||||
ids: set[Any] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
if not item:
|
||||
continue
|
||||
audio = item.get("audio_info") or {}
|
||||
if audio.get("audio_id") in ids:
|
||||
continue
|
||||
ids.add(audio.get("audio_id"))
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for quality, size_key, hash_key in (
|
||||
("128k", "filesize", "hash"),
|
||||
("320k", "filesize_320", "hash_320"),
|
||||
("flac", "filesize_flac", "hash_flac"),
|
||||
("flac24bit", "filesize_high", "hash_high"),
|
||||
):
|
||||
if str(audio.get(size_key, "0")) == "0":
|
||||
continue
|
||||
size = size_formate(int(audio.get(size_key) or 0))
|
||||
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
|
||||
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
|
||||
album = item.get("album_info") or {}
|
||||
result.append(
|
||||
{
|
||||
"singer": decode_name(item.get("author_name")),
|
||||
"name": decode_name(item.get("songname")),
|
||||
"albumName": decode_name(album.get("album_name")),
|
||||
"albumId": album.get("album_id"),
|
||||
"songmid": audio.get("audio_id"),
|
||||
"source": "kg",
|
||||
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
|
||||
"img": None,
|
||||
"lrc": None,
|
||||
"hash": audio.get("hash"),
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def _build_tasks(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
base = {
|
||||
"area_code": "1",
|
||||
"show_privilege": 1,
|
||||
"show_album_info": "1",
|
||||
"is_publish": "",
|
||||
"appid": 1005,
|
||||
"clientver": 11451,
|
||||
"mid": "1",
|
||||
"dfid": "-",
|
||||
"clienttime": int(time.time() * 1000),
|
||||
"key": "OIlwieks28dk2k092lksi2UIkp",
|
||||
"fields": _FIELDS,
|
||||
}
|
||||
tasks: list[dict[str, Any]] = []
|
||||
rest = list(hashs)
|
||||
while rest:
|
||||
tasks.append({"data": rest[:100], **base})
|
||||
if len(rest) < 100:
|
||||
break
|
||||
rest = rest[100:]
|
||||
return tasks
|
||||
|
||||
|
||||
async def get_music_infos(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""按 hash 列表批量取 SongInfo(上游按 100 个一批并发,扁平化后去重)。"""
|
||||
|
||||
async def one(task: dict[str, Any]) -> list[Any]:
|
||||
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": task, "headers": MUSIC_INFO_HEADERS})
|
||||
items = data if isinstance(data, list) else []
|
||||
return [entry[0] if entry else None for entry in items]
|
||||
|
||||
chunks = await asyncio.gather(*(one(task) for task in _build_tasks(hashs)))
|
||||
return _filter_music_info_list([item for chunk in chunks for item in chunk])
|
||||
|
||||
|
||||
async def get_music_info_raw(hash: str) -> Any:
|
||||
tasks = _build_tasks([{"hash": hash}])
|
||||
if not tasks:
|
||||
return None
|
||||
data = await create_http_fetch(MUSIC_INFO_URL, {"method": "POST", "body": tasks[0], "headers": MUSIC_INFO_HEADERS})
|
||||
items = data if isinstance(data, list) else []
|
||||
flat = [entry[0] if entry else None for entry in items]
|
||||
return flat[0] if flat else None
|
||||
|
||||
|
||||
async def get_music_info(hash: str) -> dict[str, Any] | None:
|
||||
infos = await get_music_infos([{"hash": hash}])
|
||||
return infos[0] if infos else None
|
||||
|
||||
|
||||
async def get_music_infos_by_list(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""上游返回的 {hash} 列表 → 完整 SongInfo 列表。"""
|
||||
if not items:
|
||||
return []
|
||||
return await get_music_infos([{"hash": item.get("hash")} for item in items])
|
||||
|
||||
|
||||
async def get_music_info_for_song_info(song_info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
"""顶层 `get_music_info(song_info)` 的实现:hash 优先,退回 songmid。"""
|
||||
hash_value = str(song_info.get("hash") or song_info.get("songmid") or "").strip()
|
||||
if not hash_value:
|
||||
return None
|
||||
return await get_music_info(hash_value)
|
||||
@@ -0,0 +1,97 @@
|
||||
"""酷狗搜索(对应 sdk/kg/musicSearch.ts)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import decode_name, format_play_time, format_singer_name, size_formate
|
||||
from app.sdk.platforms.kg.util import encode_uri_component
|
||||
|
||||
DEFAULT_LIMIT = 30
|
||||
|
||||
_SEARCH_API = "https://songsearch.kugou.com/song_search_v2"
|
||||
|
||||
|
||||
def _filter_data(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for quality, size_key, hash_key in (
|
||||
("128k", "FileSize", "FileHash"),
|
||||
("320k", "HQFileSize", "HQFileHash"),
|
||||
("flac", "SQFileSize", "SQFileHash"),
|
||||
("flac24bit", "ResFileSize", "ResFileHash"),
|
||||
):
|
||||
if raw.get(size_key, 0) == 0:
|
||||
continue
|
||||
size = size_formate(raw.get(size_key))
|
||||
types.append({"type": quality, "size": size, "hash": raw.get(hash_key)})
|
||||
_types[quality] = {"size": size, "hash": raw.get(hash_key)}
|
||||
return {
|
||||
"singer": decode_name(format_singer_name(raw.get("Singers"), "name")),
|
||||
"name": decode_name(raw.get("SongName")),
|
||||
"albumName": decode_name(raw.get("AlbumName")),
|
||||
"albumId": raw.get("AlbumID"),
|
||||
"songmid": raw.get("Audioid"),
|
||||
"source": "kg",
|
||||
"interval": format_play_time(raw.get("Duration")),
|
||||
"_interval": raw.get("Duration"),
|
||||
"img": None,
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"hash": raw.get("FileHash"),
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
|
||||
|
||||
def _handle_result(raw_list: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""同名同 hash 去重;`Grp` 是同一首歌的其它版本,也一并展开。"""
|
||||
ids: set[str] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw_list:
|
||||
key = f"{item.get('Audioid')}{item.get('FileHash')}"
|
||||
if key in ids:
|
||||
continue
|
||||
ids.add(key)
|
||||
result.append(_filter_data(item))
|
||||
for child in item.get("Grp") or []:
|
||||
child_key = f"{child.get('Audioid')}{child.get('FileHash')}"
|
||||
if child_key in ids:
|
||||
continue
|
||||
ids.add(child_key)
|
||||
result.append(_filter_data(child))
|
||||
return result
|
||||
|
||||
|
||||
async def search(keyword: str, page: int = 1, limit: int = DEFAULT_LIMIT, retry_num: int = 0) -> dict[str, Any]:
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("try max num")
|
||||
page_size = DEFAULT_LIMIT if limit is None else limit
|
||||
url = (
|
||||
f"{_SEARCH_API}?keyword={encode_uri_component(keyword)}&page={page}&pagesize={page_size}"
|
||||
"&userid=0&clientver=&platform=WebFilter&filter=2&iscorrection=1&privilege_filter=0&area_code=1"
|
||||
)
|
||||
try:
|
||||
resp = await http_fetch(url)
|
||||
except Exception: # noqa: BLE001 - 可降级失败:原实现同样重试同类请求
|
||||
return await search(keyword, page, limit, retry_num + 1)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("error_code") != 0:
|
||||
return await search(keyword, page, limit, retry_num + 1)
|
||||
data = body.get("data") or {}
|
||||
raw_list = data.get("lists")
|
||||
if raw_list is None:
|
||||
return await search(keyword, page, limit, retry_num + 1)
|
||||
|
||||
songs = _handle_result(raw_list)
|
||||
total = data.get("total") or 0
|
||||
return {
|
||||
"list": songs,
|
||||
"allPage": math.ceil(total / page_size) if page_size else 1,
|
||||
"limit": page_size,
|
||||
"total": total,
|
||||
"source": "kg",
|
||||
}
|
||||
@@ -0,0 +1,133 @@
|
||||
"""酷狗链接解析(对应 sdk/kg/parse.ts)。
|
||||
|
||||
歌曲:`https://www.kugou.com/song/#hash=<32位hash>`(含 &album_id= 等尾串)或裸 hash。
|
||||
元数据取 `m.kugou.com/app/i/getSongInfo.php`(songName/author_name/timeLength/album_img/bitRate),
|
||||
再并发合并音乐信息里的专辑与多档 hash;直链优先用 playInfo 的 url,仍为空时用 apis/kg
|
||||
签名通道尽力补一次 —— 失败只返回元数据(与上游付费曲语义一致)。
|
||||
"""
|
||||
|
||||
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.kg import album, playlist
|
||||
from app.sdk.platforms.kg.music_info import get_music_info
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
HASH_RE = re.compile(r"hash=([a-fA-F0-9]{32})", re.IGNORECASE)
|
||||
RAW_HASH_RE = re.compile(r"^[a-fA-F0-9]{32}$")
|
||||
|
||||
|
||||
def extract_hash(link: Any) -> str:
|
||||
"""链接/裸 hash → 32 位小写 hash;都不匹配返回空串。"""
|
||||
raw = str(link if link is not None else "").strip()
|
||||
match = HASH_RE.search(raw)
|
||||
if match:
|
||||
return match.group(1).lower()
|
||||
return raw.lower() if RAW_HASH_RE.match(raw) else ""
|
||||
|
||||
|
||||
async def _fetch_song_info_php(hash_value: str) -> dict[str, Any] | None:
|
||||
resp = await http_fetch(
|
||||
f"http://m.kugou.com/app/i/getSongInfo.php?cmd=playInfo&hash={hash_value}",
|
||||
method="get",
|
||||
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
|
||||
timeout=15000,
|
||||
)
|
||||
if resp.status_code != 200 or not isinstance(resp.body, dict):
|
||||
return None
|
||||
body = resp.body
|
||||
if int(body.get("errcode") or 0) != 0 or not body.get("hash"):
|
||||
return None
|
||||
return body
|
||||
|
||||
|
||||
async def parse_song(link: str) -> dict[str, Any]:
|
||||
import asyncio
|
||||
|
||||
hash_value = extract_hash(link)
|
||||
if not hash_value:
|
||||
raise RuntimeError("无效的酷狗歌曲链接(未找到 32 位 hash)")
|
||||
|
||||
raw, info = await asyncio.gather(
|
||||
_fetch_song_info_php(hash_value), get_music_info(hash_value), return_exceptions=True
|
||||
)
|
||||
raw = None if isinstance(raw, BaseException) else raw
|
||||
info = None if isinstance(info, BaseException) else info
|
||||
if not raw and not info:
|
||||
raise RuntimeError("酷狗歌曲不存在或已下架")
|
||||
|
||||
raw = raw or {}
|
||||
info = info or {}
|
||||
file_name = str(raw.get("fileName") or "")
|
||||
fallback_name = " - ".join(file_name.split(" - ")[1:]) if " - " in file_name else ""
|
||||
name = decode_name(str(raw.get("songName") or "").strip() or fallback_name or info.get("name") or "")
|
||||
singer = decode_name(str(raw.get("author_name") or "").strip() or info.get("singer") or "")
|
||||
duration = int(raw.get("timeLength") or 0) or 0
|
||||
|
||||
song: dict[str, Any] = {
|
||||
"songmid": str(info.get("songmid") or raw.get("audio_id") or "").strip(),
|
||||
"hash": str(raw.get("hash") or hash_value).lower(),
|
||||
"name": name,
|
||||
"singer": singer,
|
||||
"albumName": decode_name(str(info.get("albumName") or "").strip()),
|
||||
"albumId": str(info.get("albumId") or raw.get("albumid") or raw.get("req_albumid") or "").strip(),
|
||||
"interval": format_play_time(duration) if duration > 0 else (info.get("interval") or "--/--"),
|
||||
"img": str(raw.get("album_img") or "").replace("{size}", "240") or info.get("img") or None,
|
||||
"lrc": None,
|
||||
"otherSource": None,
|
||||
"types": info.get("types") or [],
|
||||
"_types": info.get("_types") or {},
|
||||
"typeUrl": {},
|
||||
"source": "kg",
|
||||
"extra": {
|
||||
"hash": str(raw.get("hash") or hash_value).lower(),
|
||||
"url": str(raw.get("url") or ""),
|
||||
"backup_url": raw.get("backup_url") or [],
|
||||
"bitrate": int(raw.get("bitRate") or 0) or 0,
|
||||
"link": f"https://www.kugou.com/song/#hash={hash_value}",
|
||||
},
|
||||
}
|
||||
|
||||
# playInfo 的 url/backup_url 是官方直链(付费曲为空),优先使用
|
||||
if not song.get("url"):
|
||||
backup = raw.get("backup_url") or []
|
||||
raw_url = str(raw.get("url") or "") or str(backup[0] if backup else "")
|
||||
if raw_url:
|
||||
song["url"] = raw_url
|
||||
song["type"] = "320k" if (int(raw.get("bitRate") or 0) or 0) >= 320000 else "128k"
|
||||
# 仍无直链时(风控/付费)用签名通道尽力补一次
|
||||
if not song.get("url"):
|
||||
try:
|
||||
from app.sdk.apis.kg import get_music_url
|
||||
|
||||
hit = await get_music_url(
|
||||
{
|
||||
"hash": song["hash"],
|
||||
"albumId": song["albumId"],
|
||||
"songmid": song["songmid"],
|
||||
"_types": song["_types"],
|
||||
},
|
||||
"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,62 @@
|
||||
"""酷狗封面(对应 sdk/kg/pic.ts)。
|
||||
|
||||
走 `media.store.kugou.com/v1/get_res_privilege`,从返回的 `info.image` 里
|
||||
把 `{size}` 占位替换为上游给的 `imgsize[0]`。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
|
||||
PIC_API = "http://media.store.kugou.com/v1/get_res_privilege"
|
||||
|
||||
HEADERS = {
|
||||
"KG-RC": "1",
|
||||
"KG-THash": "expand_search_manager.cpp:852736169:451",
|
||||
"User-Agent": "KuGou2012-9020-ExpandSearchManager",
|
||||
}
|
||||
|
||||
|
||||
def _album_audio_id(song_info: dict[str, Any]) -> Any:
|
||||
"""songmid 为 32 位时是旧形态(audioId_x),要取 `_` 前的段(修复历史存储变更)。"""
|
||||
songmid = str(song_info.get("songmid") or "")
|
||||
if len(songmid) == 32:
|
||||
return str(song_info.get("audioId") or "").split("_")[0]
|
||||
return song_info.get("songmid")
|
||||
|
||||
|
||||
async def get_pic(song_info: dict[str, Any]) -> str:
|
||||
body = {
|
||||
"appid": 1001,
|
||||
"area_code": "1",
|
||||
"behavior": "play",
|
||||
"clientver": "9020",
|
||||
"need_hash_offset": 1,
|
||||
"relate": 1,
|
||||
"resource": [
|
||||
{
|
||||
"album_audio_id": _album_audio_id(song_info),
|
||||
"album_id": song_info.get("albumId"),
|
||||
"hash": song_info.get("hash"),
|
||||
"id": 0,
|
||||
"name": f"{song_info.get('singer')} - {song_info.get('name')}.mp3",
|
||||
"type": "audio",
|
||||
}
|
||||
],
|
||||
"token": "",
|
||||
"userid": 2626431536,
|
||||
"vip": 1,
|
||||
}
|
||||
resp = await http_fetch(PIC_API, method="POST", headers=HEADERS, body=body)
|
||||
payload = resp.body if isinstance(resp.body, dict) else {}
|
||||
if payload.get("error_code") != 0:
|
||||
raise RuntimeError("图片获取失败")
|
||||
data = payload.get("data") or [{}]
|
||||
info = (data[0] or {}).get("info") or {}
|
||||
image = str(info.get("image") or "")
|
||||
img = image.replace("{size}", str(info["imgsize"][0])) if info.get("imgsize") else image
|
||||
if not img:
|
||||
raise RuntimeError("Pic get failed")
|
||||
return img
|
||||
@@ -0,0 +1,233 @@
|
||||
"""酷狗歌单模块(对应 sdk/kg/playlist.ts)—— 统一形状层。
|
||||
|
||||
解析链复用 song_list 的能力(special/single、songlist/gcid、share chain、zlist、
|
||||
global_collection_id 等),本模块只做形状归一:摘要 / 详情 / 分类 / 分类歌单 / 推荐 / 链接解析。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.platforms.kg import song_list
|
||||
|
||||
_UA = (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
|
||||
|
||||
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 _playlist_cover(url: Any) -> str:
|
||||
return str(url or "").replace("{size}", "240")
|
||||
|
||||
|
||||
def _to_summary(item: dict[str, Any]) -> dict[str, Any] | None:
|
||||
raw_id = _first_non_empty(item.get("specialid"), item.get("global_specialid"), item.get("id"))
|
||||
playlist_id = re.sub(r"^id_", "", raw_id)
|
||||
if not playlist_id:
|
||||
return None
|
||||
play_count = _finite(item.get("playcount"))
|
||||
return {
|
||||
"id": playlist_id,
|
||||
"name": _first_non_empty(item.get("specialname"), item.get("name")),
|
||||
"cover": _playlist_cover(_first_non_empty(item.get("imgurl"), item.get("img"))),
|
||||
"trackCount": int(item.get("songcount") or item.get("total") or 0) or 0,
|
||||
"playCount": play_count if play_count is not None else (item.get("play_count") or 0),
|
||||
"creator": _first_non_empty(item.get("nickname"), item.get("username"), item.get("author")),
|
||||
"description": _first_non_empty(item.get("intro"), item.get("desc")),
|
||||
"publishTime": str(item.get("publishtime") or item.get("time") or "").strip(),
|
||||
"link": f"https://www.kugou.com/yy/special/single/{playlist_id}.html",
|
||||
"source": "kg",
|
||||
}
|
||||
|
||||
|
||||
def _finite(value: Any) -> float | None:
|
||||
try:
|
||||
number = float(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
return number if number == number and number not in (float("inf"), float("-inf")) else None
|
||||
|
||||
|
||||
def _normalize_playlist_id(id: Any) -> str:
|
||||
"""数字 id / id_ 前缀 / 链接 → song_list.get_list_detail 可识别的入参。"""
|
||||
raw = str(id or "").strip()
|
||||
if not raw:
|
||||
return ""
|
||||
return f"id_{raw}" if re.fullmatch(r"\d+", raw) else raw
|
||||
|
||||
|
||||
def _parse_category_id(category_id: Any) -> tuple[str, str]:
|
||||
parts = str(category_id or "").strip().split(":")
|
||||
return str(parts[0] if parts else "").strip(), (str(parts[1]).strip() if len(parts) > 1 else "") or "0"
|
||||
|
||||
|
||||
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": "kg", "list": [], "page": page, "limit": limit, "total": 0}
|
||||
res = await song_list.search(word, page, limit)
|
||||
items = [summary for summary in (_to_summary(item) for item in res.get("list") or []) if summary]
|
||||
return {
|
||||
"source": "kg",
|
||||
"list": items,
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": int(res.get("total") or 0) or len(items),
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
|
||||
request_id = _normalize_playlist_id(playlist_id)
|
||||
if not request_id:
|
||||
raise RuntimeError("酷狗歌单 id 为空")
|
||||
page_no = max(1, int(page or 1))
|
||||
page_size = max(1, int(limit or 50))
|
||||
|
||||
res = await song_list.get_list_detail(request_id, page_no)
|
||||
if not res:
|
||||
raise RuntimeError("酷狗歌单不存在或解析失败")
|
||||
songs: list[dict[str, Any]] = res.get("list") or []
|
||||
total = int(res.get("total") or 0) or len(songs)
|
||||
if len(songs) > page_size:
|
||||
start = (page_no - 1) * page_size
|
||||
songs = songs[start : start + page_size]
|
||||
|
||||
info = res.get("info") or {}
|
||||
return {
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": _first_non_empty(info.get("name"), str(playlist_id)),
|
||||
"img": _playlist_cover(info.get("img")),
|
||||
"desc": _first_non_empty(info.get("desc")),
|
||||
"author": _first_non_empty(info.get("author")),
|
||||
"play_count": info.get("play_count") or "",
|
||||
},
|
||||
"list": songs,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": total,
|
||||
}
|
||||
|
||||
|
||||
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
|
||||
res = await song_list.get_list_detail(_normalize_playlist_id(playlist_id), 1)
|
||||
return (res or {}).get("list") or []
|
||||
|
||||
|
||||
async def get_categories() -> dict[str, Any]:
|
||||
resp = await http_fetch(
|
||||
"http://mobilecdnbj.kugou.com/api/v3/tag/list?pid=0&apiver=2&plat=0",
|
||||
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
|
||||
timeout=15000,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
|
||||
raise RuntimeError(str(body.get("error") or "酷狗歌单分类获取失败"))
|
||||
|
||||
items: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
|
||||
for group in (body.get("data") or {}).get("info") or []:
|
||||
group_name = str(group.get("name") or "").strip()
|
||||
group_id = int(group.get("id") or 0) or 0
|
||||
if group_id > 0 and group_name:
|
||||
items.append({"id": f"{group_id}:0", "name": group_name, "group": group_name})
|
||||
for child in group.get("children") or []:
|
||||
child_id = int(child.get("id") or 0) or 0
|
||||
child_name = str(child.get("name") or "").strip()
|
||||
if not child_id or not child_name:
|
||||
continue
|
||||
tag_id = int(child.get("special_tag_id") or 0) or 0
|
||||
items.append(
|
||||
{
|
||||
"id": f"{child_id}:{tag_id}",
|
||||
"name": child_name,
|
||||
"group": group_name,
|
||||
"hot": int(child.get("is_hot") or 0) == 1,
|
||||
}
|
||||
)
|
||||
return {"source": "kg", "list": items}
|
||||
|
||||
|
||||
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
page_no = max(1, int(page or 1))
|
||||
page_size = max(1, int(limit or 20))
|
||||
|
||||
category, tag_id = _parse_category_id(category_id)
|
||||
if not category:
|
||||
listing = await get_categories()
|
||||
first = next((item for item in listing["list"] if item.get("id")), None)
|
||||
if not first:
|
||||
raise RuntimeError("酷狗歌单分类为空")
|
||||
category, tag_id = _parse_category_id(first["id"])
|
||||
|
||||
api_url = "http://mobilecdnbj.kugou.com/api/v3/tag/specialList?" + "&".join(
|
||||
["plat=0", f"page={page_no}", f"tagid={tag_id}", f"pagesize={page_size}", "ugc=1", f"id={category}", "sort=2"]
|
||||
)
|
||||
resp = await http_fetch(api_url, headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"}, timeout=15000)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if resp.status_code != 200 or body.get("status") != 1 or body.get("errcode") != 0:
|
||||
raise RuntimeError(str(body.get("error") or "酷狗分类歌单获取失败"))
|
||||
|
||||
items: list[dict[str, Any]] = []
|
||||
for entry in (body.get("data") or {}).get("info") or []:
|
||||
summary = _to_summary(entry) or _to_summary({**entry, "specialid": entry.get("global_specialid")})
|
||||
if not summary:
|
||||
continue
|
||||
summary["categoryId"] = f"{category}:{tag_id}"
|
||||
items.append(summary)
|
||||
return {
|
||||
"source": "kg",
|
||||
"list": items,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": int((body.get("data") or {}).get("total") or 0) or len(items),
|
||||
}
|
||||
|
||||
|
||||
async def get_recommended(page: int = 1, limit: int = 30) -> dict[str, Any]:
|
||||
page_no = max(1, int(page or 1))
|
||||
page_size = max(1, int(limit or 30))
|
||||
resp = await http_fetch(
|
||||
f"http://m.kugou.com/plist/index&json=true&page={page_no}",
|
||||
headers={"User-Agent": _UA, "Referer": "http://m.kugou.com"},
|
||||
timeout=15000,
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else None
|
||||
if resp.status_code != 200 or not isinstance(body, dict):
|
||||
raise RuntimeError("酷狗推荐歌单获取失败")
|
||||
plist = (body.get("plist") or {}).get("list") or {}
|
||||
items = [summary for summary in (_to_summary(item) for item in plist.get("info") or []) if summary]
|
||||
if len(items) > page_size:
|
||||
items = items[:page_size]
|
||||
return {
|
||||
"source": "kg",
|
||||
"list": items,
|
||||
"page": page_no,
|
||||
"limit": page_size,
|
||||
"total": int(plist.get("total") or 0) or len(items),
|
||||
}
|
||||
|
||||
|
||||
async def parse_playlist_link(link: str) -> dict[str, Any]:
|
||||
"""special/single/<id>.html 与 songlist/<gcid_xxx>/ 优先,其余交给 song_list 通用解析。"""
|
||||
raw = str(link or "").strip()
|
||||
if not raw:
|
||||
raise RuntimeError("无效的酷狗歌单链接")
|
||||
special = re.search(r"special/single/(\d+)\.html", raw)
|
||||
if special:
|
||||
return await get_playlist_detail(special.group(1), 1)
|
||||
gcid = re.search(r"songlist/(gcid_[A-Za-z0-9]+)", raw)
|
||||
if gcid:
|
||||
return await get_playlist_detail(gcid.group(1), 1)
|
||||
return await get_playlist_detail(raw, 1)
|
||||
@@ -0,0 +1,85 @@
|
||||
"""酷狗歌手(对应 sdk/kg/singer.ts)。"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.platforms.kg.music_info import get_music_infos_by_list
|
||||
from app.sdk.platforms.kg.util import create_http_fetch
|
||||
|
||||
SINGER_INFO_URL = "http://mobiles.kugou.com/api/v5/singer/info"
|
||||
SINGER_ALBUM_URL = "http://mobiles.kugou.com/api/v5/singer/album"
|
||||
SINGER_SONG_URL = "http://mobiles.kugou.com/api/v5/singer/song"
|
||||
|
||||
|
||||
def _check_id(id: Any) -> None:
|
||||
# kg 源有些歌曲在歌手未被酷狗收录时返回的歌手 id 为 0
|
||||
if str(id) in ("0", ""):
|
||||
raise RuntimeError("歌手不存在")
|
||||
|
||||
|
||||
async def get_info(id: str) -> dict[str, Any]:
|
||||
_check_id(id)
|
||||
body = await create_http_fetch(f"{SINGER_INFO_URL}?singerid={id}")
|
||||
body = body or {}
|
||||
if not body:
|
||||
raise RuntimeError("get singer info faild.")
|
||||
imgurl = str(body.get("imgurl") or "")
|
||||
return {
|
||||
"source": "kg",
|
||||
"id": body.get("singerid"),
|
||||
"info": {
|
||||
"name": body.get("singername"),
|
||||
"desc": body.get("intro"),
|
||||
"avatar": imgurl.replace("{size}", "480") if imgurl else "",
|
||||
"gender": "man" if body.get("grade") == 1 else "woman",
|
||||
},
|
||||
"count": {"music": body.get("songcount"), "album": body.get("albumcount")},
|
||||
}
|
||||
|
||||
|
||||
def _filter_album_list(raw: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"id": item.get("albumid"),
|
||||
"count": item.get("songcount"),
|
||||
"info": {
|
||||
"name": item.get("albumname"),
|
||||
"author": item.get("singername"),
|
||||
"img": str(item.get("imgurl") or item.get("img") or "").replace("{size}", "480"),
|
||||
"desc": item.get("intro"),
|
||||
},
|
||||
}
|
||||
for item in raw
|
||||
]
|
||||
|
||||
|
||||
async def get_album_list(id: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
|
||||
_check_id(id)
|
||||
body = await create_http_fetch(f"{SINGER_ALBUM_URL}?singerid={id}&page={page}&pagesize={limit}")
|
||||
body = body or {}
|
||||
if not body.get("info"):
|
||||
raise RuntimeError("get singer album list faild.")
|
||||
return {
|
||||
"source": "kg",
|
||||
"list": _filter_album_list(body.get("info") or []),
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"total": body.get("total"),
|
||||
}
|
||||
|
||||
|
||||
async def get_song_list(id: str, page: int = 1, limit: int = 100) -> dict[str, Any]:
|
||||
_check_id(id)
|
||||
body = await create_http_fetch(f"{SINGER_SONG_URL}?singerid={id}&page={page}&pagesize={limit}")
|
||||
body = body or {}
|
||||
if not body.get("info"):
|
||||
raise RuntimeError("get singer song list faild.")
|
||||
songs = await get_music_infos_by_list(body.get("info") or [])
|
||||
return {
|
||||
"source": "kg",
|
||||
"list": songs,
|
||||
"limit": limit,
|
||||
"page": page,
|
||||
"total": body.get("total"),
|
||||
}
|
||||
@@ -0,0 +1,844 @@
|
||||
"""酷狗歌单广场与歌单解析(对应 sdk/kg/songList.ts,31KB 的最大模块)。
|
||||
|
||||
三个入口:
|
||||
get_list(sort_id, tag_id, page) —— 歌单广场(special/getSpecial + 详情 + 推荐)
|
||||
get_list_detail(id, page) —— 歌单解析,覆盖 special/single、酷狗码、gcid、
|
||||
share chain、zlist、global_collection_id、网页链接
|
||||
search(keyword, page, limit) —— 歌单搜索(msearchretry v3 search/special)
|
||||
|
||||
原实现的 `_requestObj*` 取消句柄在 Python 侧无对应物(http_fetch 自身不可取消),
|
||||
已省略;其余降级链、重试次数、错误文案与原实现保持一致。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import date_format, decode_name, format_play_count, format_play_time, size_formate, to_md5
|
||||
from app.sdk.platforms.kg.util import create_http_fetch, signature_params
|
||||
|
||||
LIST_DETAIL_LIMIT = 10000
|
||||
|
||||
SORT_LIST: list[dict[str, str]] = [
|
||||
{"name": "推荐", "id": "5"},
|
||||
{"name": "最热", "id": "6"},
|
||||
{"name": "最新", "id": "7"},
|
||||
{"name": "热藏", "id": "3"},
|
||||
{"name": "飙升", "id": "8"},
|
||||
]
|
||||
|
||||
_LIST_DATA_EXP = re.compile(r"global\.data = (\[.+\]);")
|
||||
_LIST_INFO_EXP = re.compile(r'global = {[\s\S]+?name: "(.+)"[\s\S]+?pic: "(.+)"[\s\S]+?};')
|
||||
_LIST_DETAIL_LINK_EXP = re.compile(r"^.+\/(\d+)\.html(?:\?.*|&.*$|#.*$|$)")
|
||||
|
||||
# chain → 分享信息缓存(原实现是 Map,跨请求复用同一进程缓存)
|
||||
_CACHE: dict[str, Any] = {}
|
||||
_CURRENT_TAG_INFO: dict[str, Any] = {"id": None, "info": None}
|
||||
|
||||
|
||||
def _handle_signature(id: Any, page: int, limit: int) -> str:
|
||||
"""H5 签名:业务参数按键排序拼接后以 key 包裹取 MD5(与 signature_params 同构)。"""
|
||||
params = {
|
||||
"srcappid": "2919",
|
||||
"clientver": "20000",
|
||||
"clienttime": int(time.time() * 1000),
|
||||
"mid": "0",
|
||||
"uuid": "0",
|
||||
"dfid": "-",
|
||||
"appid": 1058,
|
||||
"type": 0,
|
||||
"module": "playlist",
|
||||
"page": page,
|
||||
"pagesize": limit,
|
||||
"specialid": id,
|
||||
}
|
||||
key = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt"
|
||||
sorted_params = "".join(f"{k}={params[k]}" for k in sorted(params))
|
||||
return to_md5(f"{key}{sorted_params}{key}")
|
||||
|
||||
|
||||
def parse_html_desc(html: str) -> str | None:
|
||||
prefix = '<div class="pc_specail_text pc_singer_tab_content" id="specailIntroduceWrap">'
|
||||
index = html.find(prefix)
|
||||
if index < 0:
|
||||
return None
|
||||
after = html[index + len(prefix) :]
|
||||
end = after.find("</div>")
|
||||
if end < 0:
|
||||
return None
|
||||
return decode_name(after[:end])
|
||||
|
||||
|
||||
def _filter_info_hot_tag(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
if raw.get("status") != 1:
|
||||
return []
|
||||
return [
|
||||
{"id": tag.get("special_id"), "name": tag.get("special_name"), "source": "kg"}
|
||||
for tag in (raw.get("data") or {}).values()
|
||||
]
|
||||
|
||||
|
||||
def _filter_tag_info(raw: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"name": name,
|
||||
"list": [
|
||||
{
|
||||
"parent_id": tag.get("parent_id"),
|
||||
"parent_name": tag.get("pname"),
|
||||
"id": tag.get("id"),
|
||||
"name": tag.get("name"),
|
||||
"source": "kg",
|
||||
}
|
||||
for tag in (raw[name].get("data") or [])
|
||||
],
|
||||
}
|
||||
for name in raw
|
||||
]
|
||||
|
||||
|
||||
def _filter_list(raw_data: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
return [
|
||||
{
|
||||
"play_count": item.get("total_play_count") or format_play_count(item.get("play_count")),
|
||||
"id": "id_" + str(item.get("specialid")),
|
||||
"author": item.get("nickname"),
|
||||
"name": item.get("specialname"),
|
||||
"time": date_format(item.get("publish_time") or item.get("publishtime"), "Y-M-D"),
|
||||
"img": item.get("img") or item.get("imgurl"),
|
||||
"total": item.get("songcount"),
|
||||
"grade": item.get("grade"),
|
||||
"desc": item.get("intro"),
|
||||
"source": "kg",
|
||||
}
|
||||
for item in raw_data
|
||||
]
|
||||
|
||||
|
||||
def _create_task_body(hashs: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
base = {
|
||||
"area_code": "1",
|
||||
"show_privilege": 1,
|
||||
"show_album_info": "1",
|
||||
"is_publish": "",
|
||||
"appid": 1005,
|
||||
"clientver": 11451,
|
||||
"mid": "1",
|
||||
"dfid": "-",
|
||||
"clienttime": int(time.time() * 1000),
|
||||
"key": "OIlwieks28dk2k092lksi2UIkp",
|
||||
"fields": "album_info,author_name,audio_info,ori_audio_name,base,songname",
|
||||
}
|
||||
tasks: list[dict[str, Any]] = []
|
||||
rest = list(hashs)
|
||||
while rest:
|
||||
tasks.append({"data": rest[:100], **base})
|
||||
if len(rest) < 100:
|
||||
break
|
||||
rest = rest[100:]
|
||||
return tasks
|
||||
|
||||
|
||||
async def _create_task(hashs: list[dict[str, Any]]) -> list[list[Any]]:
|
||||
url = "http://gateway.kugou.com/v2/album_audio/audio"
|
||||
headers = {
|
||||
"KG-THash": "13a3164",
|
||||
"KG-RC": "1",
|
||||
"KG-Fake": "0",
|
||||
"KG-RF": "00869891",
|
||||
"User-Agent": "Android712-AndroidPhone-11451-376-0-FeeCacheUpdate-wifi",
|
||||
"x-router": "kmr.service.kugou.com",
|
||||
}
|
||||
|
||||
async def one(task: dict[str, Any]) -> list[Any]:
|
||||
data = await create_http_fetch(url, {"method": "POST", "body": task, "headers": headers})
|
||||
items = data if isinstance(data, list) else []
|
||||
return [entry[0] if entry else None for entry in items]
|
||||
|
||||
return await asyncio.gather(*(one(task) for task in _create_task_body(hashs)))
|
||||
|
||||
|
||||
def _de_duplication(datas: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
seen: set[Any] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in datas:
|
||||
hash_value = item.get("hash")
|
||||
if hash_value in seen:
|
||||
continue
|
||||
seen.add(hash_value)
|
||||
result.append(item)
|
||||
return result
|
||||
|
||||
|
||||
async def _get_music_infos(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
|
||||
"""hash 列表 → SongInfo(走 v2 album_audio 批量接口,与 musicInfo.ts 的 v3 不同)。"""
|
||||
deduped = _de_duplication(items)
|
||||
if not deduped:
|
||||
return []
|
||||
chunks = await _create_task([{"hash": item.get("hash")} for item in deduped])
|
||||
return _filter_data2([entry for chunk in chunks for entry in chunk])
|
||||
|
||||
|
||||
def _filter_data2(raw_list: list[Any]) -> list[dict[str, Any]]:
|
||||
ids: set[Any] = set()
|
||||
result: list[dict[str, Any]] = []
|
||||
|
||||
for item in raw_list:
|
||||
if not item:
|
||||
continue
|
||||
audio = item.get("audio_info") or {}
|
||||
if audio.get("audio_id") in ids:
|
||||
continue
|
||||
ids.add(audio.get("audio_id"))
|
||||
types: list[dict[str, Any]] = []
|
||||
_types: dict[str, dict[str, Any]] = {}
|
||||
for quality, size_key, hash_key in (
|
||||
("128k", "filesize", "hash"),
|
||||
("320k", "filesize_320", "hash_320"),
|
||||
("flac", "filesize_flac", "hash_flac"),
|
||||
("flac24bit", "filesize_high", "hash_high"),
|
||||
):
|
||||
if str(audio.get(size_key, "0")) == "0":
|
||||
continue
|
||||
size = size_formate(int(audio.get(size_key) or 0))
|
||||
types.append({"type": quality, "size": size, "hash": audio.get(hash_key)})
|
||||
_types[quality] = {"size": size, "hash": audio.get(hash_key)}
|
||||
album = item.get("album_info") or {}
|
||||
result.append(
|
||||
{
|
||||
"singer": decode_name(item.get("author_name")),
|
||||
"name": decode_name(item.get("songname")),
|
||||
"albumName": decode_name(album.get("album_name")),
|
||||
"albumId": album.get("album_id"),
|
||||
"songmid": audio.get("audio_id"),
|
||||
"source": "kg",
|
||||
"interval": format_play_time(int(audio.get("timelength") or 0) / 1000),
|
||||
"img": None,
|
||||
"lrc": None,
|
||||
"hash": audio.get("hash"),
|
||||
"otherSource": None,
|
||||
"types": types,
|
||||
"_types": _types,
|
||||
"typeUrl": {},
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
async def get_list_detail_by_special_id(id: Any, page: int | None = None, try_num: int = 0) -> dict[str, Any]:
|
||||
if try_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
resp = await http_fetch(get_song_list_detail_url(id))
|
||||
html = resp.body if isinstance(resp.body, str) else ""
|
||||
list_data = _LIST_DATA_EXP.search(html)
|
||||
list_info = _LIST_INFO_EXP.search(html)
|
||||
if not list_data:
|
||||
return await get_list_detail_by_special_id(id, page, try_num + 1)
|
||||
songs = await _get_music_infos(json.loads(list_data.group(1)))
|
||||
name = list_info.group(1) if list_info else None
|
||||
pic = list_info.group(2) if list_info else None
|
||||
return {
|
||||
"list": songs,
|
||||
"page": 1,
|
||||
"limit": LIST_DETAIL_LIMIT,
|
||||
"total": len(songs),
|
||||
"source": "kg",
|
||||
"info": {"name": name, "img": pic, "desc": parse_html_desc(html)},
|
||||
}
|
||||
|
||||
|
||||
def get_info_url(tag_id: Any = None) -> str:
|
||||
if tag_id:
|
||||
return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&cdn=cdn&t=5&c={tag_id}"
|
||||
return "http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_smarty=1&"
|
||||
|
||||
|
||||
def get_song_list_url(sort_id: Any, tag_id: Any, page: int) -> str:
|
||||
tag = "" if tag_id is None else tag_id
|
||||
return f"http://www2.kugou.kugou.com/yueku/v9/special/getSpecial?is_ajax=1&cdn=cdn&t={sort_id}&c={tag}&p={page}"
|
||||
|
||||
|
||||
def get_song_list_detail_url(id: Any) -> str:
|
||||
return f"http://www2.kugou.kugou.com/yueku/v9/special/single/{id}-5-9999.html"
|
||||
|
||||
|
||||
async def _get_song_list(sort_id: Any, tag_id: Any, page: int, try_num: int = 0) -> list[dict[str, Any]]:
|
||||
if try_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
resp = await http_fetch(get_song_list_url(sort_id, tag_id, page))
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if not body or body.get("status") != 1:
|
||||
return await _get_song_list(sort_id, tag_id, page, try_num + 1)
|
||||
return _filter_list(body.get("special_db") or [])
|
||||
|
||||
|
||||
async def _get_song_list_recommend(try_num: int = 0) -> list[dict[str, Any]]:
|
||||
if try_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
resp = await http_fetch(
|
||||
"http://everydayrec.service.kugou.com/guess_special_recommend",
|
||||
method="post",
|
||||
headers={"User-Agent": "KuGou2012-8275-web_browser_event_handler"},
|
||||
body={
|
||||
"appid": 1001,
|
||||
"clienttime": 1566798337219,
|
||||
"clientver": 8275,
|
||||
"key": "f1f93580115bb106680d2375f8032d96",
|
||||
"mid": "21511157a05844bd085308bc76ef3343",
|
||||
"platform": "pc",
|
||||
"userid": "262643156",
|
||||
"return_min": 6,
|
||||
"return_max": 15,
|
||||
},
|
||||
)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("status") != 1:
|
||||
return await _get_song_list_recommend(try_num + 1)
|
||||
return _filter_list((body.get("data") or {}).get("special_list") or [])
|
||||
|
||||
|
||||
async def _get_list_info(tag_id: Any, try_num: int = 0) -> dict[str, Any]:
|
||||
if try_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
resp = await http_fetch(get_info_url(tag_id))
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("status") != 1:
|
||||
return await _get_list_info(tag_id, try_num + 1)
|
||||
params = (body.get("data") or {}).get("params") or {}
|
||||
return {
|
||||
"limit": params.get("pagesize"),
|
||||
"page": params.get("p"),
|
||||
"total": params.get("total"),
|
||||
"source": "kg",
|
||||
}
|
||||
|
||||
|
||||
async def get_list(sort_id: Any, tag_id: Any, page: int = 1) -> dict[str, Any]:
|
||||
"""歌单广场:首屏(无标签、第 1 页、默认排序)额外混入推荐歌单。"""
|
||||
tasks: list[Any] = [_get_song_list(sort_id, tag_id, page)]
|
||||
if _CURRENT_TAG_INFO["id"] == tag_id:
|
||||
tasks.append(asyncio.sleep(0, result=_CURRENT_TAG_INFO["info"]))
|
||||
else:
|
||||
|
||||
async def load_info() -> dict[str, Any]:
|
||||
info = await _get_list_info(tag_id)
|
||||
_CURRENT_TAG_INFO["id"] = tag_id
|
||||
_CURRENT_TAG_INFO["info"] = dict(info)
|
||||
return info
|
||||
|
||||
tasks.append(load_info())
|
||||
if not tag_id and page == 1 and sort_id == SORT_LIST[0]["id"]:
|
||||
tasks.append(_get_song_list_recommend())
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
songs: list[dict[str, Any]] = results[0]
|
||||
info: dict[str, Any] = results[1]
|
||||
if len(results) > 2 and results[2]:
|
||||
songs = [*results[2], *songs]
|
||||
return {"list": songs, **info}
|
||||
|
||||
|
||||
async def get_tags(try_num: int = 0) -> dict[str, Any]:
|
||||
if try_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
resp = await http_fetch(get_info_url())
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("status") != 1:
|
||||
return await get_tags(try_num + 1)
|
||||
data = body.get("data") or {}
|
||||
return {
|
||||
"hotTag": _filter_info_hot_tag(data.get("hotTag") or {}),
|
||||
"tags": _filter_tag_info(data.get("tagids") or {}),
|
||||
"source": "kg",
|
||||
}
|
||||
|
||||
|
||||
def get_detail_page_url(id: Any) -> str:
|
||||
value = id
|
||||
if isinstance(value, str):
|
||||
if re.search(r"^https?://", value):
|
||||
return value
|
||||
value = value.replace("id_", "")
|
||||
return f"https://www.kugou.com/yy/special/single/{value}.html"
|
||||
|
||||
|
||||
async def search(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
|
||||
from app.sdk.platforms.kg.util import encode_uri_component
|
||||
|
||||
url = (
|
||||
f"http://msearchretry.kugou.com/api/v3/search/special?keyword={encode_uri_component(keyword)}"
|
||||
f"&page={page}&pagesize={limit}&showtype=10&filter=0&version=7910&sver=2"
|
||||
)
|
||||
resp = await http_fetch(url)
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("errcode") != 0:
|
||||
raise RuntimeError("filed")
|
||||
data = body.get("data") or {}
|
||||
return {
|
||||
"list": [
|
||||
{
|
||||
"play_count": format_play_count(item.get("playcount")),
|
||||
"id": "id_" + str(item.get("specialid")),
|
||||
"author": item.get("nickname"),
|
||||
"name": item.get("specialname"),
|
||||
"time": date_format(item.get("publishtime"), "Y-M-D"),
|
||||
"img": item.get("imgurl"),
|
||||
"grade": item.get("grade"),
|
||||
"desc": item.get("intro"),
|
||||
"total": item.get("songcount"),
|
||||
"source": "kg",
|
||||
}
|
||||
for item in data.get("info") or []
|
||||
],
|
||||
"limit": limit,
|
||||
"total": data.get("total"),
|
||||
"page": page,
|
||||
"source": "kg",
|
||||
}
|
||||
|
||||
|
||||
# ---- 歌单详情解析链 ----
|
||||
|
||||
|
||||
async def _decode_gcid(gcid: str) -> str | None:
|
||||
params = "dfid=-&appid=1005&mid=0&clientver=20109&clienttime=640612895&uuid=-"
|
||||
body = {"ret_info": 1, "data": [{"id": gcid, "id_type": 2}]}
|
||||
result = await create_http_fetch(
|
||||
f"https://t.kugou.com/v1/songlist/batch_decode?{params}"
|
||||
f"&signature={signature_params(params, 'android', json.dumps(body, separators=(',', ':')))}",
|
||||
{
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (Linux; Android 10; HUAWEI HMA-AL00) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/83.0.4103.106 Mobile Safari/537.36"
|
||||
),
|
||||
"Referer": "https://m.kugou.com/",
|
||||
},
|
||||
"body": body,
|
||||
},
|
||||
)
|
||||
items = (result or {}).get("list") or []
|
||||
return items[0].get("global_collection_id") if items else None
|
||||
|
||||
|
||||
async def _get_user_list_detail_2(global_collection_id: str) -> dict[str, Any]:
|
||||
if len(global_collection_id) > 1000:
|
||||
raise RuntimeError("get list error")
|
||||
params = (
|
||||
"appid=1058&specialid=0&global_specialid="
|
||||
+ global_collection_id
|
||||
+ "&format=jsonp&srcappid=2919&clientver=20000&clienttime=1586163242519"
|
||||
"&mid=1586163242519&uuid=1586163242519&dfid=-"
|
||||
)
|
||||
headers = {
|
||||
"mid": "1586163242519",
|
||||
"Referer": "https://m3ws.kugou.com/share/index.php",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
|
||||
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
|
||||
),
|
||||
"dfid": "-",
|
||||
"clienttime": "1586163242519",
|
||||
}
|
||||
info = await create_http_fetch(
|
||||
f"https://mobiles.kugou.com/api/v5/special/info_v2?{params}&signature={signature_params(params, 'web')}",
|
||||
{"headers": headers},
|
||||
)
|
||||
info = info or {}
|
||||
total = info.get("songcount") or 0
|
||||
tasks: list[Any] = []
|
||||
page = 0
|
||||
while total:
|
||||
limit = 300 if total > 300 else total
|
||||
total -= limit
|
||||
page += 1
|
||||
song_params = (
|
||||
f"appid=1058&global_specialid={global_collection_id}&specialid=0&plat=0&version=8000"
|
||||
f"&page={page}&pagesize={limit}&srcappid=2919&clientver=20000"
|
||||
"&clienttime=1586163263991&mid=1586163263991&uuid=1586163263991&dfid=-"
|
||||
)
|
||||
tasks.append(
|
||||
create_http_fetch(
|
||||
f"https://mobiles.kugou.com/api/v5/special/song_v2?{song_params}"
|
||||
f"&signature={signature_params(song_params, 'web')}",
|
||||
{
|
||||
"headers": {
|
||||
"mid": "1586163263991",
|
||||
"Referer": "https://m3ws.kugou.com/share/index.php",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
|
||||
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
|
||||
),
|
||||
"dfid": "-",
|
||||
"clienttime": "1586163263991",
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
song_rows: list[Any] = []
|
||||
for chunk in await asyncio.gather(*tasks):
|
||||
song_rows.extend((chunk or {}).get("info") or [])
|
||||
songs = await _get_music_infos(song_rows)
|
||||
img = info.get("imgurl")
|
||||
return {
|
||||
"list": songs,
|
||||
"page": 1,
|
||||
"limit": LIST_DETAIL_LIMIT,
|
||||
"total": len(songs),
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": info.get("specialname"),
|
||||
"img": img.replace("{size}", "240") if img else None,
|
||||
"desc": info.get("intro"),
|
||||
"author": info.get("nickname"),
|
||||
"play_count": format_play_count(info.get("playcount")),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_list_info_by_chain(chain: str) -> Any:
|
||||
if chain in _CACHE:
|
||||
return _CACHE[chain]
|
||||
resp = await http_fetch(
|
||||
f"https://m.kugou.com/share/?chain={chain}&id={chain}",
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 13_2_3 like Mac OS X) AppleWebKit/605.1.15 "
|
||||
"(KHTML, like Gecko) Version/13.0.3 Mobile/15E148 Safari/604.1"
|
||||
)
|
||||
},
|
||||
)
|
||||
html = resp.body if isinstance(resp.body, str) else ""
|
||||
match = re.search(r"var\sphpParam\s=\s({.+?});", html)
|
||||
result = json.loads(match.group(1)) if match else None
|
||||
_CACHE[chain] = result
|
||||
return result
|
||||
|
||||
|
||||
async def _get_user_list_detail_by_pc_chain(chain: str) -> list[dict[str, Any]]:
|
||||
resp = await http_fetch(
|
||||
f"http://www.kugou.com/share/{chain}.html",
|
||||
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"
|
||||
)
|
||||
},
|
||||
)
|
||||
html = resp.body if isinstance(resp.body, str) else ""
|
||||
match = re.search(r"var\sdataFromSmarty\s=\s(\[.+?\])", html)
|
||||
result = json.loads(match.group(1)) if match else []
|
||||
return await _get_music_infos(result)
|
||||
|
||||
|
||||
async def _get_user_list_detail_by_id(id: Any, page: int, limit: int) -> list[dict[str, Any]]:
|
||||
signature = _handle_signature(id, page, limit)
|
||||
info = await create_http_fetch(
|
||||
"https://pubsongscdn.kugou.com/v2/get_other_list_file?"
|
||||
f"srcappid=2919&clientver=20000&appid=1058&type=0&module=playlist"
|
||||
f"&page={page}&pagesize={limit}&specialid={id}&signature={signature}",
|
||||
{
|
||||
"headers": {
|
||||
"Referer": "https://m3ws.kugou.com/share/index.php",
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 11_0 like Mac OS X) AppleWebKit/604.1.38 "
|
||||
"(KHTML, like Gecko) Version/11.0 Mobile/15A372 Safari/604.1"
|
||||
),
|
||||
"dfid": "-",
|
||||
}
|
||||
},
|
||||
)
|
||||
return await _get_music_infos((info or {}).get("info") or [])
|
||||
|
||||
|
||||
async def _get_user_list_detail_4(song_info: dict[str, Any], chain: str, page: int) -> dict[str, Any]:
|
||||
limit = 100
|
||||
list_info, songs = await asyncio.gather(
|
||||
_get_list_info_by_chain(chain),
|
||||
_get_user_list_detail_by_id(song_info.get("id"), page, limit),
|
||||
)
|
||||
list_info = list_info or {}
|
||||
img = list_info.get("imgurl")
|
||||
return {
|
||||
"list": songs or [],
|
||||
"page": page,
|
||||
"limit": limit,
|
||||
"total": len(songs or []),
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": list_info.get("specialname"),
|
||||
"img": img.replace("{size}", "240") if img else None,
|
||||
"author": list_info.get("nickname"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_list_detail_5(chain: str) -> dict[str, Any]:
|
||||
list_info, songs = await asyncio.gather(_get_list_info_by_chain(chain), _get_user_list_detail_by_pc_chain(chain))
|
||||
list_info = list_info or {}
|
||||
img = list_info.get("imgurl")
|
||||
return {
|
||||
"list": songs or [],
|
||||
"page": 1,
|
||||
"limit": LIST_DETAIL_LIMIT,
|
||||
"total": len(songs or []),
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": list_info.get("specialname"),
|
||||
"img": img.replace("{size}", "240") if img else None,
|
||||
"author": list_info.get("nickname"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_list_detail_3(chain: str, page: int) -> dict[str, Any]:
|
||||
song_info = await create_http_fetch(
|
||||
f"http://m.kugou.com/schain/transfer?pagesize={LIST_DETAIL_LIMIT}&chain={chain}"
|
||||
f"&su=1&page={page}&n=0.7928855356604456",
|
||||
{
|
||||
"headers": {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
|
||||
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
song_info = song_info or {}
|
||||
if not song_info.get("list"):
|
||||
if song_info.get("global_collection_id"):
|
||||
return await _get_user_list_detail_2(song_info["global_collection_id"])
|
||||
try:
|
||||
return await _get_user_list_detail_4(song_info, chain, page)
|
||||
except Exception: # noqa: BLE001 - 降级到 PC 分享页
|
||||
return await _get_user_list_detail_5(chain)
|
||||
songs = await _get_music_infos(song_info.get("list") or [])
|
||||
info = song_info.get("info") or {}
|
||||
return {
|
||||
"list": songs,
|
||||
"page": 1,
|
||||
"limit": LIST_DETAIL_LIMIT,
|
||||
"total": len(songs),
|
||||
"source": "kg",
|
||||
"info": {"name": info.get("name"), "img": info.get("img"), "author": info.get("username")},
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_list_detail_by_code(id: str) -> dict[str, Any]:
|
||||
song_info = await create_http_fetch(
|
||||
"http://t.kugou.com/command/",
|
||||
{
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"KG-RC": 1,
|
||||
"KG-THash": "network_super_call.cpp:3676261689:379",
|
||||
"User-Agent": "",
|
||||
},
|
||||
"body": {
|
||||
"appid": 1001,
|
||||
"clientver": 9020,
|
||||
"mid": "21511157a05844bd085308bc76ef3343",
|
||||
"clienttime": 640612895,
|
||||
"key": "36164c4015e704673c588ee202b9ecb8",
|
||||
"data": id,
|
||||
},
|
||||
},
|
||||
)
|
||||
song_info = song_info or {}
|
||||
info = song_info.get("info") or {}
|
||||
# type 1 单曲 / 2 歌单 / 3 电台 / 4 酷狗码 / 5 别人的播放队列
|
||||
if info.get("type") == 2 and not info.get("global_collection_id"):
|
||||
return await get_list_detail_by_special_id(info.get("id"))
|
||||
if info.get("global_collection_id"):
|
||||
return await _get_user_list_detail_2(info["global_collection_id"])
|
||||
song_list: Any = None
|
||||
if info.get("userid") is not None:
|
||||
song_list = await create_http_fetch(
|
||||
"http://www2.kugou.kugou.com/apps/kucodeAndShare/app/",
|
||||
{
|
||||
"method": "POST",
|
||||
"headers": {
|
||||
"KG-RC": 1,
|
||||
"KG-THash": "network_super_call.cpp:3676261689:379",
|
||||
"User-Agent": "",
|
||||
},
|
||||
"body": {
|
||||
"appid": 1001,
|
||||
"clientver": 9020,
|
||||
"mid": "21511157a05844bd085308bc76ef3343",
|
||||
"clienttime": 640612895,
|
||||
"key": "36164c4015e704673c588ee202b9ecb8",
|
||||
"data": {
|
||||
"id": info.get("id"),
|
||||
"type": 3,
|
||||
"userid": info.get("userid"),
|
||||
"collect_type": 0,
|
||||
"page": 1,
|
||||
"pagesize": info.get("count"),
|
||||
},
|
||||
},
|
||||
},
|
||||
)
|
||||
rows = song_list or song_info.get("list") or []
|
||||
songs = await _get_music_infos(rows)
|
||||
img_size = info.get("img_size")
|
||||
return {
|
||||
"list": songs,
|
||||
"page": 1,
|
||||
"limit": info.get("count"),
|
||||
"total": len(songs),
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": info.get("name"),
|
||||
"img": img_size.replace("{size}", "240") if img_size else info.get("img"),
|
||||
"author": info.get("username"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_list_detail_by_link(body: dict[str, Any], link: str) -> dict[str, Any]:
|
||||
list_info = (body.get("info") or {}).get("0") or {}
|
||||
total = list_info.get("count") or 0
|
||||
tasks: list[Any] = []
|
||||
page = 0
|
||||
while total:
|
||||
limit = 90 if total > 90 else total
|
||||
total -= limit
|
||||
page += 1
|
||||
url = re.sub(r"pagesize=\d+", f"pagesize={limit}", link)
|
||||
url = re.sub(r"page=\d+", f"page={page}", url)
|
||||
tasks.append(
|
||||
create_http_fetch(
|
||||
url,
|
||||
{
|
||||
"headers": {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
|
||||
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
|
||||
),
|
||||
"Referer": link,
|
||||
}
|
||||
},
|
||||
)
|
||||
)
|
||||
rows: list[Any] = []
|
||||
for chunk in await asyncio.gather(*tasks):
|
||||
rows.extend(((chunk or {}).get("list") or {}).get("info") or [])
|
||||
songs = await _get_music_infos(rows)
|
||||
pic = list_info.get("pic")
|
||||
return {
|
||||
"list": songs,
|
||||
"page": page,
|
||||
"limit": LIST_DETAIL_LIMIT,
|
||||
"total": len(songs),
|
||||
"source": "kg",
|
||||
"info": {
|
||||
"name": list_info.get("name"),
|
||||
"img": pic.replace("{size}", "240") if pic else None,
|
||||
"author": list_info.get("list_create_username"),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
async def _get_user_list_detail(link: str, page: int, retry_num: int = 0) -> dict[str, Any]:
|
||||
if retry_num > 3:
|
||||
raise RuntimeError("link try max num")
|
||||
if "#" in link:
|
||||
link = re.sub(r"#.*$", "", link)
|
||||
if "global_collection_id" in link:
|
||||
return await _get_user_list_detail_2(re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", link))
|
||||
if "gcid_" in link:
|
||||
match = re.search(r"gcid_\w+", link)
|
||||
if match:
|
||||
gcid_id = await _decode_gcid(match.group(0))
|
||||
if gcid_id:
|
||||
return await _get_user_list_detail_2(gcid_id)
|
||||
if "chain=" in link:
|
||||
return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", link), page)
|
||||
if ".html" in link:
|
||||
if "zlist.html" in link:
|
||||
link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", link)
|
||||
if "pagesize" in link:
|
||||
link = link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace("page=1", f"page={page}")
|
||||
else:
|
||||
link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}"
|
||||
elif "song.html" not in link:
|
||||
return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", link), page)
|
||||
|
||||
resp = await http_fetch(
|
||||
link,
|
||||
headers={
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 "
|
||||
"(KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1"
|
||||
),
|
||||
"Referer": link,
|
||||
},
|
||||
)
|
||||
location = resp.header("location") or None
|
||||
if resp.status_code > 400:
|
||||
return await _get_user_list_detail(link, page, retry_num + 1)
|
||||
if location:
|
||||
if "global_collection_id" in location:
|
||||
return await _get_user_list_detail_2(
|
||||
re.sub(r"^.*?global_collection_id=(\w+)(?:&.*$|#.*$|$)", r"\1", location)
|
||||
)
|
||||
if "gcid_" in location:
|
||||
match = re.search(r"gcid_\w+", link)
|
||||
if match:
|
||||
gcid_id = await _decode_gcid(match.group(0))
|
||||
if gcid_id:
|
||||
return await _get_user_list_detail_2(gcid_id)
|
||||
if "chain=" in location:
|
||||
return await _get_user_list_detail_3(re.sub(r"^.*?chain=(\w+)(?:&.*$|#.*$|$)", r"\1", location), page)
|
||||
if ".html" in location:
|
||||
if "zlist.html" in location:
|
||||
next_link = re.sub(r"^(.*)zlist\.html", "https://m3ws.kugou.com/zlist/list", location)
|
||||
if "pagesize" in next_link:
|
||||
next_link = next_link.replace("pagesize=30", f"pagesize={LIST_DETAIL_LIMIT}").replace(
|
||||
"page=1", f"page={page}"
|
||||
)
|
||||
else:
|
||||
next_link += f"&pagesize={LIST_DETAIL_LIMIT}&page={page}"
|
||||
return await _get_user_list_detail(next_link, page, retry_num + 1)
|
||||
return await _get_user_list_detail_3(re.sub(r".+\/(\w+).html(?:\?.*|&.*$|#.*$|$)", r"\1", location), page)
|
||||
return await _get_user_list_detail(location, page, retry_num + 1)
|
||||
|
||||
if isinstance(resp.body, str):
|
||||
body_text = resp.body
|
||||
global_id = re.search(r'"global_collection_id":"(\w+)"', body_text)
|
||||
gcid_id = global_id.group(1) if global_id else None
|
||||
if not gcid_id:
|
||||
match = re.search(r'"encode_gic":"(\w+)"', body_text) or re.search(r'"encode_src_gid":"(\w+)"', body_text)
|
||||
if match:
|
||||
gcid_id = await _decode_gcid(match.group(1))
|
||||
if not gcid_id:
|
||||
raise RuntimeError("get list error")
|
||||
return await _get_user_list_detail_2(gcid_id)
|
||||
|
||||
body = resp.body if isinstance(resp.body, dict) else {}
|
||||
if body.get("errcode") != 0:
|
||||
return await _get_user_list_detail(link, page, retry_num + 1)
|
||||
return await _get_user_list_detail_by_link(body, link)
|
||||
|
||||
|
||||
async def get_list_detail(id: Any, page: int | None = None) -> dict[str, Any] | None:
|
||||
"""歌单详情入口:按 id 形态分派到特殊页 / 链接 / 酷狗码。"""
|
||||
value = str(id)
|
||||
page_no = page or 1
|
||||
if "special/single/" in value:
|
||||
value = _LIST_DETAIL_LINK_EXP.sub(r"\1", value)
|
||||
elif re.search(r"https?:", value):
|
||||
# 修复形如 https://www.kugou.com/songlist/xxx/?uid=xxx&cover=http%3A%2F%2F... 的脏链接
|
||||
return await _get_user_list_detail(re.sub(r"^.*?http", "http", value), page_no)
|
||||
elif re.fullmatch(r"\d+", value):
|
||||
return await _get_user_list_detail_by_code(value)
|
||||
elif value.startswith("id_"):
|
||||
value = value.replace("id_", "")
|
||||
return await get_list_detail_by_special_id(value, page_no)
|
||||
@@ -0,0 +1,107 @@
|
||||
"""酷狗通用请求封装与签名(对应 sdk/kg/util.ts + sdk/auth/kg.ts 的设备/签名部分)。
|
||||
|
||||
`app/sdk/auth/kg.py` 仍是登录流程的 stub(禁止修改),因此设备指纹与 android/web
|
||||
签名在这里单独实现:取流(apis/kg.py)与取流兜底都要用同一套指纹,放这里避免重复。
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
from typing import Any
|
||||
from urllib.parse import quote
|
||||
|
||||
from app.sdk.auth_store import get_credentials, set_credentials
|
||||
from app.sdk.common.request import http_fetch
|
||||
from app.sdk.common.utils import to_md5
|
||||
|
||||
ANDROID_SALT = "OIlwieks28dk2k092lksi2UIkp"
|
||||
WEB_SALT = "NVPh5oo715z5DIWAeQlhMDsWXXQV4hwt"
|
||||
|
||||
_RANDOM_CHARS = "abcdefghijklmnopqrstuvwxyz0123456789"
|
||||
|
||||
|
||||
def signature_params(params: str, platform: str = "android", body: str = "") -> str:
|
||||
"""按 key 排序拼接后以盐包裹取 MD5(android 与 web 只是盐不同)。"""
|
||||
key = WEB_SALT if platform == "web" else ANDROID_SALT
|
||||
items = params.split("&")
|
||||
items.sort()
|
||||
return to_md5(f"{key}{''.join(items)}{body}{key}")
|
||||
|
||||
|
||||
def android_sign(params: dict[str, Any], data: str = "") -> str:
|
||||
"""网关签名:`${ANDROID_SALT}排序后的k=v拼接${data}${ANDROID_SALT}` 的 MD5。"""
|
||||
import json
|
||||
|
||||
parts = []
|
||||
for key in sorted(params):
|
||||
value = params[key]
|
||||
# JSON.stringify 是紧凑格式(无空格),登录/注册签名依赖逐字节一致
|
||||
encoded = (
|
||||
json.dumps(value, ensure_ascii=False, separators=(",", ":")) if isinstance(value, (dict, list)) else value
|
||||
)
|
||||
parts.append(f"{key}={encoded}")
|
||||
return to_md5(f"{ANDROID_SALT}{''.join(parts)}{data}{ANDROID_SALT}")
|
||||
|
||||
|
||||
def encode_uri_component(value: Any) -> str:
|
||||
"""对齐 JS 的 encodeURIComponent(不编码 !'()*,Python 的 quote 会编码它们)。"""
|
||||
return quote(str(value), safe="!~*'()")
|
||||
|
||||
|
||||
def random_string(n: int) -> str:
|
||||
import random
|
||||
|
||||
return "".join(_RANDOM_CHARS[random.randrange(len(_RANDOM_CHARS))] for _ in range(n))
|
||||
|
||||
|
||||
def pkcs7_pad(data: bytes, block: int = 16) -> bytes:
|
||||
"""PKCS#7 填充 —— Node 的 createCipheriv 会自动补,cryptography 不会。"""
|
||||
remain = block - (len(data) % block)
|
||||
return data + bytes([remain]) * remain
|
||||
|
||||
|
||||
def pkcs7_unpad(data: bytes, block: int = 16) -> bytes:
|
||||
"""PKCS#7 去填充(对应 Node createDecipheriv 的自动 unpad)。"""
|
||||
if not data:
|
||||
return data
|
||||
remain = data[-1]
|
||||
if remain < 1 or remain > block or len(data) < remain:
|
||||
return data
|
||||
return data[:-remain]
|
||||
|
||||
|
||||
def get_device() -> dict[str, Any]:
|
||||
"""设备指纹持久化:guid/mid/dfid 稳定复用(取流与登录共用同一指纹)。"""
|
||||
dev = get_credentials("_kg_device")
|
||||
if not dev:
|
||||
dev = {"guid": str(uuid.uuid4()), "mid": "", "dfid": random_string(24)}
|
||||
if not dev.get("mid"):
|
||||
dev["mid"] = str(int(hashlib.md5(str(dev.get("guid") or "").encode()).hexdigest(), 16))
|
||||
set_credentials("_kg_device", dev)
|
||||
if not dev.get("dfid"):
|
||||
dev["dfid"] = random_string(24)
|
||||
return dev
|
||||
|
||||
|
||||
async def create_http_fetch(url: str, options: dict[str, Any] | None = None, retry_num: int = 0) -> Any:
|
||||
"""带重试的请求:非 200 或 errcode 非 0 一律重试(最多 3 次)。
|
||||
|
||||
成功时按原实现的取值优先级返回:body.data → body.info(数组时返回整 body) → body.info。
|
||||
"""
|
||||
if retry_num > 2:
|
||||
raise RuntimeError("try max num")
|
||||
try:
|
||||
result = await http_fetch(url, **(options or {}))
|
||||
except Exception as err: # noqa: BLE001 - 可降级失败:换一次请求重试
|
||||
print(f"[kg] 请求失败将重试({retry_num + 1}/3) {url}: {err}")
|
||||
return await create_http_fetch(url, options, retry_num + 1)
|
||||
body = result.body if isinstance(result.body, dict) else {}
|
||||
err_code = body.get("error_code", body.get("errcode", body.get("err_code")))
|
||||
if result.status_code != 200 or err_code != 0:
|
||||
return await create_http_fetch(url, options, retry_num + 1)
|
||||
if body.get("data"):
|
||||
return body["data"]
|
||||
if isinstance(body.get("info"), list):
|
||||
return body
|
||||
return body.get("info")
|
||||
Reference in new issue
Block a user