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

+65
View File
@@ -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,
)
+150
View File
@@ -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("无效的酷狗专辑链接")
+128
View File
@@ -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 [])}
+41
View File
@@ -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 [])}
+102
View File
@@ -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"]
+110
View File
@@ -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"
+91
View File
@@ -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"])
+138
View File
@@ -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)
+97
View File
@@ -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",
}
+133
View File
@@ -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)
+62
View File
@@ -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
+233
View File
@@ -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)
+85
View File
@@ -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"),
}
+844
View File
@@ -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)
+107
View File
@@ -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")