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,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)
|
||||
Reference in new issue
Block a user