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

+198
View File
@@ -0,0 +1,198 @@
"""网易云歌单能力(移植自 sdk/wy/playlist.ts,上游 music-lib/netease/playlist.go)。
搜索/分类:复用 song_list(search=cloudsearch type1000、get_tag=catalogue、get_list=playlist/list)
推荐:weapi/personalized/playlist(匿名可用)
详情/曲目:复用 song_list.get_list_detail(linuxapi forward),按 page/limit 切片
链接解析:#/playlist?id= / /playlist?id= / /playlist/<id>/<n>/... / 裸数字 id
"""
from __future__ import annotations
import asyncio
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
from app.sdk.platforms.wy import song_list
from app.sdk.platforms.wy.link import parse_netease_link
from app.sdk.platforms.wy.utils import weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
PLAYLIST_LINK = "https://music.163.com/#/playlist?id={}"
def playlist_link(playlist_id: str) -> str:
return PLAYLIST_LINK.format(playlist_id)
def api_limit() -> int:
"""catalogue/list 接口固定页大小(song_list.LIMIT_LIST 运行时读取,避免模块环上的顺序问题)。"""
try:
return int(song_list.LIMIT_LIST) or 30
except (TypeError, ValueError):
return 30
def to_summary(item: Any, category_id: str | None = None) -> dict[str, Any] | None:
"""歌单摘要:兼容 song_list 既有字段并补 spec 形状(id/name/cover/trackCount/creator/...)。"""
if not isinstance(item, dict):
return None
id_ = str(item.get("id") or "").strip()
if not id_:
return None
track_count = int(item.get("trackCount") or item.get("total") or 0)
play_count = int(item.get("playCount") or 0)
summary: dict[str, Any] = {
**item,
"id": id_,
"name": item.get("name") or "",
"cover": item.get("cover") or item.get("img") or "",
"trackCount": track_count,
"playCount": play_count,
"play_count": item.get("play_count") or format_play_count(play_count),
"creator": item.get("creator") or item.get("author") or "",
"description": item.get("description") or item.get("desc") or "",
"link": playlist_link(id_),
"source": "wy",
}
if category_id:
summary["categoryId"] = category_id
return summary
async def search_playlist(keyword: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(复用 song_list.search)。"""
result = await song_list.search(str(keyword or ""), page, limit)
rows = [summary for item in result.get("list") or [] if (summary := to_summary(item))]
return {"list": rows, "page": page, "limit": limit, "total": result.get("total") or len(rows), "source": "wy"}
async def get_playlist_detail(playlist_id: str, page: int = 1, limit: int = 50) -> dict[str, Any]:
"""歌单详情(复用 song_list.get_list_detail,按 page/limit 切片)。"""
detail = await song_list.get_list_detail(str(playlist_id or ""), 1)
start = max(0, (page - 1) * limit)
songs = detail.get("list") or []
return {
"source": "wy",
"info": detail.get("info"),
"list": songs[start : start + limit],
"page": page,
"limit": limit,
"total": detail.get("total") or len(songs),
}
async def get_playlist_songs(playlist_id: str) -> list[dict[str, Any]]:
"""歌单曲目全量(路由在详情签名不匹配时也会用这条兜底)。"""
return list((await song_list.get_list_detail(str(playlist_id or ""), 1)).get("list") or [])
async def _safe(coro: Any) -> Any:
"""吞掉分类接口异常并退回空列表(原实现是 .catch(() => []))。"""
try:
return await coro
except Exception as err: # noqa: BLE001 - 分类是可选增强,不应让整页 502
print(f"[wy] 歌单分类接口失败(已降级为空): {err}")
return []
async def get_categories() -> dict[str, Any]:
"""歌单分类:摊平 catalogue + hottags,首项固定「全部」。单个接口失败不拖垮整体。"""
groups, hot_tags = await asyncio.gather(_safe(song_list.get_tag()), _safe(song_list.get_hot_tag()))
hot_set = {str(item.get("id")) for item in hot_tags or [] if isinstance(item, dict)}
rows: list[dict[str, Any]] = [{"id": "", "name": "全部", "group": "全部"}]
seen = {""}
for group in groups or []:
if not isinstance(group, dict):
continue
for item in group.get("list") or []:
if not isinstance(item, dict):
continue
id_ = str(item.get("id") or "").strip()
name = str(item.get("name") or "").strip()
if not id_ or not name or id_ in seen:
continue
seen.add(id_)
rows.append({"id": id_, "name": name, "group": group.get("name") or "", "hot": id_ in hot_set})
return {"source": "wy", "list": rows}
async def get_category_playlists(category_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""分类歌单:接口每页 30 条,按入参 limit 重新切片。"""
cat = str(category_id or "").strip() or "全部"
start = max(0, (page - 1) * limit)
page_size = api_limit()
api_pages = max(1, -(-(start + limit) // page_size)) # ceil 除法
items: list[dict[str, Any]] = []
total = 0
for api_page in range(1, api_pages + 1):
result = await song_list.get_list("hot", cat, api_page)
total = int(result.get("total") or total) or total
batch = result.get("list") or []
items.extend(batch)
if len(batch) < page_size:
break
rows = [summary for item in items[start : start + limit] if (summary := to_summary(item, cat))]
return {"list": rows, "page": page, "limit": limit, "total": total or len(items), "source": "wy"}
async def get_recommended(page: int = 1, limit: int = 20) -> dict[str, Any]:
"""推荐歌单(weapi/personalized/playlist)。"""
resp = await http_fetch(
"https://music.163.com/weapi/personalized/playlist",
method="post",
headers=weapi_headers(),
form=weapi({"limit": 30, "total": True, "n": 1000}),
timeout=15000,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError(f"wy 推荐歌单获取失败 (code {body.get('code', resp.status_code)})")
rows: list[dict[str, Any]] = []
for item in body.get("result") or []:
if not isinstance(item, dict):
continue
id_ = str(item.get("id") or "").strip()
if not id_:
continue
play_count = int(item.get("playCount") or 0)
copywriter = item.get("copywriter") or ""
rows.append(
{
"id": id_,
"name": item.get("name") or "",
"cover": item.get("picUrl") or "",
"trackCount": int(item.get("trackCount") or 0),
"playCount": play_count,
"play_count": format_play_count(play_count),
"creator": copywriter or "网易云推荐",
"description": copywriter,
"link": playlist_link(id_),
"alg": item.get("alg") or "",
"source": "wy",
}
)
if not rows:
raise RuntimeError("no recommended playlists found")
start = max(0, (page - 1) * limit)
return {"list": rows[start : start + limit], "page": page, "limit": limit, "total": len(rows), "source": "wy"}
get_recommended_playlists = get_recommended
async def parse_playlist_link(link: str) -> dict[str, Any]:
"""歌单链接解析(裸数字 id 兜底)。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的网易云歌单链接")
if raw.isdigit():
return await get_playlist_detail(raw)
kind, id_ = parse_netease_link(raw)
if kind != "playlist":
raise RuntimeError("无效的网易云歌单链接")
return await get_playlist_detail(id_)