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

+61
View File
@@ -0,0 +1,61 @@
"""网易音乐(wy)平台模块 —— 移植自 sdk/wy/index.ts + sdk/apis/wy.ts。
能力位与原实现对齐:
music_search / leaderboard / song_list / hot_search / comment / singer /
album / playlist / parse / user_playlist / get_music_url / get_lyric / get_pic /
get_music_info / get_music_detail_page_url
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms._base import Platform
from app.sdk.platforms.wy import (
album,
comment,
hot_search,
leaderboard,
lyric,
music_search,
parse,
playlist,
singer,
song_list,
user_playlist,
)
from app.sdk.platforms.wy.music_info import get_music_info
from app.sdk.platforms.wy.pic import get_pic
def get_music_detail_page_url(song_info: dict[str, Any]) -> str:
return f"https://music.163.com/#/song?id={song_info.get('songmid')}"
async def get_music_url(song_info: dict[str, Any], quality: str, retry_num: int = 0) -> dict[str, Any]:
"""取流入口。延迟导入 apis.wy:apis 模块反向依赖 platforms.wy.utils,
模块级互相 import 会在「先导入 apis」的入口下拿到半初始化的模块。"""
from app.sdk.apis import wy as wy_api
return await wy_api.get_music_url(song_info, quality, retry_num)
MODULE = Platform(
id="wy",
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,
user_playlist=user_playlist,
get_music_url=get_music_url,
get_lyric=lyric.get_lyric,
get_pic=get_pic,
get_music_info=get_music_info,
get_music_detail_page_url=get_music_detail_page_url,
)
+172
View File
@@ -0,0 +1,172 @@
"""网易云专辑(移植自 sdk/wy/album.ts,上游 music-lib/netease/album.go + fetchAlbumDetail)。
搜索:cloudsearch pc type=10(eapi 通道)
详情/曲目:weapi/v1/album/<id>(匿名可用,登录 cookie 可选)
链接解析:#/album?id= / /album?id= / /album/<id>(上游 parseNeteaseLink)
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
from app.sdk.platforms.wy.link import parse_netease_link
from app.sdk.platforms.wy.utils import eapi_request, weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
ALBUM_LINK = "https://music.163.com/#/album?id={}"
def album_link(album_id: str) -> str:
return ALBUM_LINK.format(album_id)
def convert_song(item: dict[str, Any]) -> dict[str, Any]:
"""网易云歌曲条目 → SongInfo(与 song_list.filter_list_detail 对齐)。
这里只在尺寸为正数时声明档位(专辑接口不带 privilege,只能按资源块推断)。
"""
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
def push(kind: str, size_raw: Any) -> None:
size = size_formate(size_raw) if isinstance(size_raw, (int, float)) and size_raw > 0 else None
if not size:
return
types.append({"type": kind, "size": size})
_types[kind] = {"size": size}
push("128k", (item.get("l") or {}).get("size") if isinstance(item.get("l"), dict) else None)
push("320k", (item.get("h") or {}).get("size") if isinstance(item.get("h"), dict) else None)
push("flac", (item.get("sq") or {}).get("size") if isinstance(item.get("sq"), dict) else None)
push("flac24bit", (item.get("hr") or {}).get("size") if isinstance(item.get("hr"), dict) else None)
album = item.get("al") if isinstance(item.get("al"), dict) else {}
if not album:
album = item.get("album") if isinstance(item.get("album"), dict) else {}
album = album if isinstance(album, dict) else {}
return {
"singer": format_singer_name(item.get("ar"), "name"),
"name": item.get("name") or "",
"albumName": album.get("name") or "",
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl") or "",
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
async def fetch_album_detail(album_id: str) -> dict[str, Any]:
"""专辑详情 + 全量曲目。失败文案与原文一致(含上游 code)。"""
album_key = str(album_id or "").strip()
if not album_key:
raise RuntimeError("wy 专辑 id 为空")
resp = await http_fetch(
f"https://music.163.com/weapi/v1/album/{album_key}",
method="post",
headers=weapi_headers(),
form=weapi({"csrf_token": ""}),
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)})")
album = body.get("album") or {}
artist = album.get("artist") or {}
artist_name = artist.get("name") or "、".join(
str(a.get("name")) for a in album.get("artists") or [] if isinstance(a, dict) and a.get("name")
)
songs = [convert_song(song) for song in body.get("songs") or [] if isinstance(song, dict)]
return {
"source": "wy",
"info": {
"name": album.get("name") or "",
"img": album.get("picUrl") or "",
"desc": album.get("description") or album.get("briefDesc") or "",
"author": artist_name or "",
"albumId": str(album.get("id") or album_key),
"publishTime": album.get("publishTime"),
"company": album.get("company") or "",
},
"list": songs,
"total": int(album.get("size") or 0) or len(songs),
}
async def get_album_detail(album_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""专辑详情(本地按 page/limit 切片)。"""
detail = await fetch_album_detail(album_id)
start = max(0, (page - 1) * limit)
return {
"source": "wy",
"info": detail["info"],
"list": detail["list"][start : start + limit],
"page": page,
"limit": limit,
"total": detail["total"] or len(detail["list"]),
}
async def get_album_songs(album_id: str) -> list[dict[str, Any]]:
return (await fetch_album_detail(album_id))["list"]
async def search_album(keyword: str, page: int = 1, limit: int = 10) -> dict[str, Any]:
"""专辑搜索(eapi cloudsearch pc type=10)。"""
resp = await eapi_request(
"/api/cloudsearch/pc",
{"s": str(keyword or ""), "type": 10, "limit": limit, "total": page == 1, "offset": limit * (page - 1)},
)
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)})")
result = body.get("result") or {}
rows: list[dict[str, Any]] = []
for item in result.get("albums") or []:
if not isinstance(item, dict):
continue
id_ = str(item.get("id") or "").strip()
if not id_:
continue
artist = item.get("artist") or {}
artist_name = artist.get("name") or "、".join(
str(a.get("name")) for a in item.get("artists") or [] if isinstance(a, dict) and a.get("name")
)
rows.append(
{
"id": id_,
"name": item.get("name") or "",
"cover": item.get("picUrl") or "",
"trackCount": int(item.get("size") or 0),
"creator": artist_name,
"description": item.get("description") or item.get("briefDesc") or "",
"link": album_link(id_),
"type": "album",
"company": item.get("company") or "",
"publishTime": item.get("publishTime"),
"source": "wy",
}
)
total = int(result.get("albumCount") or 0) or len(rows)
return {"list": rows, "page": page, "limit": limit, "total": total, "source": "wy"}
async def parse_album_link(link: str) -> dict[str, Any]:
"""专辑链接解析(裸数字 id 兜底,上游 ParseAlbum 只接受链接)。"""
raw = str(link or "").strip()
if raw.isdigit():
return await get_album_detail(raw)
kind, id_ = parse_netease_link(raw)
if kind != "album":
raise RuntimeError("无效的网易云专辑链接")
return await get_album_detail(id_)
+258
View File
@@ -0,0 +1,258 @@
"""网易云评论(移植自 sdk/wy/comment.ts)。
网易云的评论接口是游标式(orderType + cursor + offset),不是页码式。原实现用
`cursor_tools` 缓存每首歌最近一次的游标:向后翻页用旧游标 + offset,向前翻页用 orderType=0,
重刷同一页则回退到 prevCursor。这里逐行保留该逻辑,否则翻页会跳条。
注意请求头里的 `Refere` 是原实现的拼写,上游不校验该头,保留以免引入行为差异。
"""
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
from app.sdk.platforms.wy.utils import weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
EMOJIS: list[tuple[str, str]] = [
("大笑", "😃"),
("可爱", "😊"),
("憨笑", "☺️"),
("色", "😍"),
("亲亲", "😙"),
("惊恐", "😱"),
("流泪", "😭"),
("亲", "😚"),
("呆", "😳"),
("哀伤", "😔"),
("呲牙", "😁"),
("吐舌", "😝"),
("撇嘴", "😒"),
("怒", "😡"),
("奸笑", "😏"),
("汗", "😓"),
("痛苦", "😖"),
("惶恐", "😰"),
("生病", "😨"),
("口罩", "😷"),
("大哭", "😂"),
("晕", "😵"),
("发怒", "👿"),
("开心", "😄"),
("鬼脸", "😜"),
("皱眉", "😞"),
("流感", "😢"),
("爱心", "❤️"),
("心碎", "💔"),
("钟情", "💘"),
("星星", "⭐️"),
("生气", "💢"),
("便便", "💩"),
("强", "👍"),
("弱", "👎"),
("拜", "🙏"),
("牵手", "👫"),
("跳舞", "👯‍♀️"),
("禁止", "🙅‍♀️"),
("这边", "💁‍♀️"),
("爱意", "💏"),
("示爱", "👩‍❤️‍👨"),
("嘴唇", "👄"),
("狗", "🐶"),
("猫", "🐱"),
("猪", "🐷"),
("兔子", "🐰"),
("小鸡", "🐤"),
("公鸡", "🐔"),
("幽灵", "👻"),
("圣诞", "🎅"),
("外星", "👽"),
("钻石", "💎"),
("礼物", "🎁"),
("男孩", "👦"),
("女孩", "👧"),
("蛋糕", "🎂"),
("18", "🔞"),
("圈", "⭕"),
("叉", "❌"),
]
def apply_emoji(text: str) -> str:
for name, emoji in EMOJIS:
text = text.replace(f"[{name}]", emoji)
return text
class _CursorTools:
"""按歌曲缓存游标。原实现是模块级单例对象,这里用类实例保持同一语义。"""
def __init__(self) -> None:
self.cache: dict[str, dict[str, Any]] = {}
def get_cursor(self, id_: str, page: int, limit: int) -> dict[str, Any]:
data = self.cache.setdefault(id_, {})
order_type: int | None = None
cursor: int | None = None
offset: int | None = None
if page == 1:
data["page"] = 1
cursor = int(time.time() * 1000)
data["cursor"] = cursor
data["prevCursor"] = cursor
order_type = 1
offset = 0
elif data.get("page"):
cursor = data.get("cursor")
if page > data["page"]:
order_type = 1
offset = (page - data["page"] - 1) * limit
elif page < data["page"]:
order_type = 0
offset = (data["page"] - page - 1) * limit
else:
cursor = data.get("prevCursor")
data["cursor"] = cursor
offset = data.get("offset")
order_type = data.get("orderType")
return {"orderType": order_type, "cursor": cursor, "offset": offset}
def set_cursor(self, id_: str, cursor: Any, order_type: Any, offset: Any, page: int) -> None:
data = self.cache.setdefault(id_, {})
data["prevCursor"] = data.get("cursor")
data["cursor"] = cursor
data["orderType"] = order_type
data["offset"] = offset
data["page"] = page
cursor_tools = _CursorTools()
def filter_comment(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
if not isinstance(item, dict):
continue
user = item.get("user") or {}
raw_time = item.get("time")
content = item.get("content")
data = {
"id": item.get("commentId"),
"text": apply_emoji(str(content)) if content else "",
"time": raw_time or "",
"timeStr": date_format2(raw_time) if raw_time else "",
"location": (item.get("ipLocation") or {}).get("location"),
"userName": user.get("nickname"),
"avatar": user.get("avatarUrl"),
"userId": user.get("userId"),
"likedCount": item.get("likedCount"),
"reply": [],
}
replied = item.get("beReplied") or []
reply_data = replied[0] if replied else None
if not isinstance(reply_data, dict):
rows.append(data)
continue
reply_user = reply_data.get("user") or {}
reply_content = reply_data.get("content")
rows.append(
{
"id": item.get("commentId"),
"rootId": reply_data.get("beRepliedCommentId"),
"text": apply_emoji(str(reply_content)) if reply_content else "",
"time": item.get("time"),
"timeStr": None,
"location": (reply_data.get("ipLocation") or {}).get("location"),
"userName": reply_user.get("nickname"),
"avatar": reply_user.get("avatarUrl"),
"userId": reply_user.get("userId"),
"likedCount": None,
"reply": [data],
}
)
return rows
def _max_page(total: int, limit: int) -> int:
return 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]:
"""歌曲评论。失败文案与原文一致:获取评论失败。"""
song_mid = str(song_info.get("songmid"))
rid = f"R_SO_4_{song_mid}"
cursor_info = cursor_tools.get_cursor(song_mid, page, limit)
resp = await http_fetch(
"https://music.163.com/weapi/comment/resource/comments/get",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi(
{
"cursor": cursor_info["cursor"],
"offset": cursor_info["offset"],
"orderType": cursor_info["orderType"],
"pageNo": page,
"pageSize": limit,
"rid": rid,
"threadId": rid,
}
),
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取评论失败")
data = body.get("data") or {}
cursor_tools.set_cursor(song_mid, data.get("cursor"), cursor_info["orderType"], cursor_info["offset"], page)
total = int(data.get("totalCount") or 0)
return {
"source": "wy",
"comments": filter_comment(data.get("comments")),
"total": data.get("totalCount"),
"page": page,
"limit": limit,
"maxPage": _max_page(total, limit) or 1,
}
async def get_hot_comment(song_info: dict[str, Any], page: int = 1, limit: int = 100) -> dict[str, Any]:
"""热门评论。失败文案与原文一致:获取热门评论失败。"""
song_mid = str(song_info.get("songmid"))
rid = f"R_SO_4_{song_mid}"
page = page - 1
resp = await http_fetch(
f"https://music.163.com/weapi/v1/resource/hotcomments/{rid}",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi(
{
"rid": rid,
"limit": limit,
"offset": limit * page,
"beforeTime": str(int(time.time() * 1000)),
}
),
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取热门评论失败")
total = int(body.get("total") or 0)
return {
"source": "wy",
"comments": filter_comment(body.get("hotComments")),
"total": total,
"page": page,
"limit": limit,
"maxPage": _max_page(total, limit) or 1,
}
+25
View File
@@ -0,0 +1,25 @@
"""网易云热搜词(移植自 sdk/wy/hotSearch.ts)。"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.wy.utils import eapi_request
def filter_list(raw_list: Any) -> list[str]:
return [str(item.get("searchWord")) for item in raw_list or [] if isinstance(item, dict) and item.get("searchWord")]
async def get_list(retry_num: int = 0) -> dict[str, Any]:
"""热搜词列表。失败文案与原文一致:获取热搜词失败。"""
if retry_num > 2:
raise RuntimeError("try max num")
resp = await eapi_request("/api/search/chart/detail", {"id": "HOT_SEARCH_SONG#@#"})
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取热搜词失败")
data = body.get("data") or {}
return {"source": "wy", "list": filter_list(data.get("itemList"))}
+117
View File
@@ -0,0 +1,117 @@
"""网易云榜单(移植自 sdk/wy/leaderboard.ts)。
榜单列表是硬编码的 top_list(原实现的 get_boards 直接返回它,不再请求 weapi/toplist);
详情走 weapi/v3/playlist/detail 拿 trackIds,再用 song/detail 批量补全曲目。
id 必须是 `wy__<bangid>` 形态:路由 `_board_handler` 会剥掉 `wy__` 前缀得到裸 bangid,
前端拿到的 id 也只有带前缀时才和详情接口闭环。
"""
from __future__ import annotations
import asyncio
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy import music_detail
from app.sdk.platforms.wy.utils import weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
LIMIT = 100000
TOP_LIST: list[dict[str, str]] = [
{"id": "wy__19723756", "name": "飙升榜", "bangid": "19723756"},
{"id": "wy__3779629", "name": "新歌榜", "bangid": "3779629"},
{"id": "wy__2884035", "name": "原创榜", "bangid": "2884035"},
{"id": "wy__3778678", "name": "热歌榜", "bangid": "3778678"},
{"id": "wy__991319590", "name": "说唱榜", "bangid": "991319590"},
{"id": "wy__71384707", "name": "古典榜", "bangid": "71384707"},
{"id": "wy__1978921795", "name": "电音榜", "bangid": "1978921795"},
{"id": "wy__5453912201", "name": "黑胶VIP爱听榜", "bangid": "5453912201"},
{"id": "wy__71385702", "name": "ACG榜", "bangid": "71385702"},
{"id": "wy__745956260", "name": "韩语榜", "bangid": "745956260"},
{"id": "wy__10520166", "name": "国电榜", "bangid": "10520166"},
{"id": "wy__180106", "name": "UK排行榜周榜", "bangid": "180106"},
{"id": "wy__60198", "name": "美国Billboard榜", "bangid": "60198"},
{"id": "wy__3812895", "name": "Beatport全球电子舞曲榜", "bangid": "3812895"},
{"id": "wy__21845217", "name": "KTV唛榜", "bangid": "21845217"},
{"id": "wy__60131", "name": "日本Oricon榜", "bangid": "60131"},
{"id": "wy__2809513713", "name": "欧美热歌榜", "bangid": "2809513713"},
{"id": "wy__2809577409", "name": "欧美新歌榜", "bangid": "2809577409"},
{"id": "wy__27135204", "name": "法国 NRJ Vos Hits 周榜", "bangid": "27135204"},
{"id": "wy__3001835560", "name": "ACG动画榜", "bangid": "3001835560"},
{"id": "wy__3001795926", "name": "ACG游戏榜", "bangid": "3001795926"},
{"id": "wy__3001890046", "name": "ACG VOCALOID榜", "bangid": "3001890046"},
{"id": "wy__3112516681", "name": "中国新乡村音乐排行榜", "bangid": "3112516681"},
{"id": "wy__5059644681", "name": "日语榜", "bangid": "5059644681"},
{"id": "wy__5059633707", "name": "摇滚榜", "bangid": "5059633707"},
{"id": "wy__5059642708", "name": "国风榜", "bangid": "5059642708"},
{"id": "wy__5338990334", "name": "潜力爆款榜", "bangid": "5338990334"},
{"id": "wy__5059661515", "name": "民谣榜", "bangid": "5059661515"},
{"id": "wy__6688069460", "name": "听歌识曲榜", "bangid": "6688069460"},
{"id": "wy__6723173524", "name": "网络热歌榜", "bangid": "6723173524"},
{"id": "wy__6732051320", "name": "俄语榜", "bangid": "6732051320"},
{"id": "wy__6732014811", "name": "越南语榜", "bangid": "6732014811"},
{"id": "wy__6886768100", "name": "中文DJ榜", "bangid": "6886768100"},
{"id": "wy__6939992364", "name": "俄罗斯top hit流行音乐榜", "bangid": "6939992364"},
{"id": "wy__7095271308", "name": "泰语榜", "bangid": "7095271308"},
{"id": "wy__7356827205", "name": "BEAT排行榜", "bangid": "7356827205"},
{"id": "wy__7325478166", "name": "编辑推荐榜VOL.44 天才女子摇滚乐队boygenius剖白卑微心迹", "bangid": "7325478166"},
{"id": "wy__7603212484", "name": "LOOK直播歌曲榜", "bangid": "7603212484"},
{"id": "wy__7775163417", "name": "赏音榜", "bangid": "7775163417"},
{"id": "wy__7785123708", "name": "黑胶VIP新歌榜", "bangid": "7785123708"},
{"id": "wy__7785066739", "name": "黑胶VIP热歌榜", "bangid": "7785066739"},
{"id": "wy__7785091694", "name": "黑胶VIP爱搜榜", "bangid": "7785091694"},
]
def filter_boards_data(raw_list: Any) -> list[dict[str, str]]:
"""weapi/toplist 响应 → 榜单列表(原实现的 filterBoardsData;当前 get_boards 不走它)。"""
return [
{"id": f"wy__{board['id']}", "name": str(board.get("name")), "bangid": str(board["id"])}
for board in raw_list or []
if isinstance(board, dict) and board.get("id") is not None
]
async def get_boards() -> dict[str, Any]:
"""榜单列表。原实现直接返回硬编码 top_list,不请求上游。"""
return {"list": TOP_LIST, "source": "wy"}
async def get_list(bangid: str, page: int = 1, retry_num: int = 0) -> dict[str, Any]:
"""榜单详情。失败递归重试最多 6 次,超出抛 try max num(文案与原文一致)。"""
retry_num += 1
if retry_num > 6:
raise RuntimeError("try max num")
resp = await http_fetch(
"https://music.163.com/weapi/v3/playlist/detail",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi({"id": bangid, "n": LIMIT, "p": 1}),
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
# 让出事件循环:上游持续 5xx 时递归重试不应霸占单线程事件循环
await asyncio.sleep(0)
return await get_list(bangid, page, retry_num)
playlist = body.get("playlist") or {}
ids = [track.get("id") for track in playlist.get("trackIds") or [] if isinstance(track, dict)]
try:
detail = await music_detail.get_list(ids)
except Exception as err:
print(f"[wy] 榜单歌曲详情获取失败将重试({retry_num + 1}): {err}")
if str(err) == "try max num":
raise
await asyncio.sleep(0)
return await get_list(bangid, page, retry_num)
rows = detail["list"]
return {"total": len(rows), "list": rows, "limit": LIMIT, "page": page, "source": "wy"}
def get_detail_page_url(bangid: str) -> str:
bare = bangid.replace("wy__", "") if isinstance(bangid, str) else bangid
return f"https://music.163.com/#/discover/toplist?id={bare}"
+84
View File
@@ -0,0 +1,84 @@
"""网易云链接解析(移植自 sdk/wy/link.ts,上游 music-lib/netease/netease.go 的 parseNeteaseLink)。
支持:
歌曲 https://music.163.com/#/song?id=29732995 /song?id= / /song/29732995
专辑 https://music.163.com/#/album?id=32311 /album?id= / /album/32311
歌单 https://music.163.com/#/playlist?id=7507256008 /playlist?id= / /playlist/<id>/<n>/...
裸数字 id 由各模块自行兜底(上游 Parse 不接受裸 id)。
顺序上依次尝试原链接、path+query、hash 片段 —— 网页版把真实路径放在 # 后面,
因此必须逐候选尝试,只看 pathname 会漏掉 `#/song?id=` 这类主流形态。
"""
from __future__ import annotations
import re
from typing import Any, Literal
from urllib.parse import parse_qs, urljoin, urlparse
NeteaseLinkKind = Literal["song", "album", "playlist"]
BASE = "https://music.163.com/"
_KINDS = ("song", "album", "playlist")
_DIGITS = re.compile(r"^\d+$")
def _is_digits(value: str) -> bool:
return value != "" and _DIGITS.match(value) is not None
def _parse_candidate(candidate: str) -> tuple[str, str]:
"""解析单个候选串(JS 需给相对候选补 base;Go 的 url.Parse 直接接受相对路径)。"""
try:
parsed = urlparse(urljoin(BASE, candidate))
except ValueError:
return "", ""
kind = ""
segments = [seg for seg in parsed.path.lower().split("/") if seg]
# 取最后一个出现的类型段:/playlist/xxx 之类多余段不影响判断
for segment in segments:
if segment in _KINDS:
kind = segment
id_ = parse_qs(parsed.query).get("id", [""])[0]
if not _is_digits(id_):
id_ = ""
if not kind and len(segments) >= 2:
last, prev = segments[-1], segments[-2]
if _is_digits(last) and prev in _KINDS:
kind, id_ = prev, last
if not kind or not id_:
return "", ""
return kind, id_
def parse_netease_link(link: Any) -> tuple[NeteaseLinkKind, str]:
"""链接 → (类型, id)。无法识别时抛 ValueError(文案与原文一致)。"""
raw = str(link or "").strip()
candidates = [raw]
try:
parsed = urlparse(urljoin(BASE, raw))
except ValueError:
parsed = None
if parsed is not None:
if parsed.path and parsed.path != "/":
candidates.append(parsed.path + (f"?{parsed.query}" if parsed.query else ""))
fragment = re.sub(r"^#!?", "", parsed.fragment).strip()
if fragment:
candidates.append(fragment)
for candidate in candidates:
kind, id_ = _parse_candidate(candidate)
if kind and id_:
return kind, id_ # type: ignore[return-value]
raise ValueError("无效的网易云链接")
def bare_id(link: Any) -> str:
"""裸 id 兜底:非 URL 且全数字时视为对应类型的 id。"""
raw = str(link or "").strip()
return raw if _DIGITS.match(raw) else ""
+247
View File
@@ -0,0 +1,247 @@
"""网易云歌词(移植自 sdk/wy/lyric.ts,参考 Binaryify/NeteaseCloudMusicApi 的 module/lyric_new.js)。
上游返回 yrc(逐字)+ lrc(逐行)+ 各语种翻译/rlrc。逐字歌词的时间标签是
`[起始ms,时长ms](词起始ms,词时长ms,0)词` 形态,需要转换成标准 LRC + 富文本 `<偏移,时长>` 标签。
"""
from __future__ import annotations
import json
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME
from app.sdk.platforms.wy.utils.crypto import eapi
RX_INFO = re.compile(r'^{"')
RX_LINE_TIME = re.compile(r"^\[(\d+),\d+\]")
RX_WORD_TIME = re.compile(r"\(\d+,\d+,\d+\)")
RX_WORD_TIME_ALL = re.compile(r"(\(\d+,\d+,\d+\))")
RX_WORD_ITEM = re.compile(r"\((\d+),(\d+),\d+\)")
LYRIC_HOST = "https://interface3.music.163.com/eapi/song/lyric/v1"
def ms_format(time_ms: Any) -> str:
"""毫秒 → `[mm:ss.SSS]`。非数字返回空串(原实现用 Number.isNaN 判定)。"""
try:
total = int(float(time_ms))
except (TypeError, ValueError):
return ""
if total < 0:
return ""
ms = total % 1000
seconds = total // 1000
# 与原实现一致:毫秒不补齐(JS 的 `timeMs % 1000` 直接插值)
return f"[{seconds // 60:02d}:{seconds % 60:02d}.{ms}]"
def parse_lyric(lines: list[str]) -> dict[str, str]:
yrc_lines: list[str] = []
lrc_lines: list[str] = []
for raw_line in lines:
line = raw_line.strip()
match = RX_LINE_TIME.match(line)
if not match:
if line.startswith("[offset"):
yrc_lines.append(line)
lrc_lines.append(line)
continue
start_ms = int(match.group(1))
start_str = ms_format(start_ms)
if not start_str:
continue
words = RX_LINE_TIME.sub("", line)
lrc_lines.append(start_str + RX_WORD_TIME_ALL.sub("", words))
word_times = RX_WORD_TIME_ALL.findall(words)
if not word_times:
continue
tags = []
for chunk in word_times:
item = RX_WORD_ITEM.search(chunk)
if not item:
continue
tags.append(f"<{max(int(item.group(1)) - start_ms, 0)},{item.group(2)}>")
word_arr = RX_WORD_TIME.split(words)[1:] # 首段是第一个时间标签前的内容
new_words = "".join(
f"{tag}{word_arr[index] if index < len(word_arr) else ''}" for index, tag in enumerate(tags)
)
yrc_lines.append(f"{start_str}{new_words}")
return {"lyric": "\n".join(lrc_lines), "yrc": "\n".join(yrc_lines)}
def parse_header_info(text: Any) -> list[str] | None:
"""清洗歌词文本:去掉 \\r,把 `{"t":ms,"c":[{"tx":...}]}` 形式的逐字行转成标准 LRC 行。"""
if text is None:
return None
raw = str(text).strip().replace("\r", "")
if not raw:
return None
out: list[str] = []
for line in raw.split("\n"):
if not RX_INFO.match(line):
out.append(line)
continue
try:
info = json.loads(line)
except (ValueError, TypeError):
out.append("")
continue
tag = ms_format(info.get("t"))
if not tag:
out.append("")
continue
out.append(tag + "".join(str(chunk.get("tx", "")) for chunk in info.get("c") or []))
return out
def get_intv(interval: Any) -> int:
"""`mm:ss.SSS`(或 `hh:mm:ss.SSS`)→ 毫秒。"""
if not interval:
return 0
text = str(interval)
if "." not in text:
text += ".0"
parts = re.split(r"[:.]", text)
while len(parts) < 3:
parts.insert(0, "0")
minute, second, milli = parts[0], parts[1], parts[2]
def to_int(value: str) -> int:
try:
return int(value)
except ValueError:
return 0
return to_int(minute) * 3_600_000 + to_int(second) * 1000 + to_int(milli)
def fix_time_tag(lrc: str, target_lrc: str) -> str:
"""把 target 的文本按时间接近(<100ms)对齐到 lrc 的时间标签上。"""
rx_time = re.compile(r"^\[([\d:.]+)\]")
lrc_lines = lrc.split("\n")
new_lrc: list[str] = []
temp: list[str] = []
for line in target_lrc.split("\n"):
match = rx_time.match(line)
if not match:
continue
words = rx_time.sub("", line)
if not words.strip():
continue
t1 = get_intv(match.group(1))
while lrc_lines:
lrc_line = lrc_lines.pop(0)
lrc_match = rx_time.match(lrc_line)
if not lrc_match:
continue
t2 = get_intv(lrc_match.group(1))
if abs(t1 - t2) < 100:
merged = rx_time.sub(lrc_match.group(0), line).strip()
if not merged:
continue
new_lrc.append(merged)
break
temp.append(lrc_line)
lrc_lines = [*temp, *lrc_lines]
temp = []
return "\n".join(new_lrc)
def parse(y_lrc: Any, yt_lrc: Any, yr_lrc: Any, lrc: Any, tlrc: Any, rlrc: Any) -> dict[str, str]:
"""优先用逐字歌词(yrc)合成,缺失时退回普通 LRC。"""
info = {"lyric": "", "tlyric": "", "rlyric": "", "yrc": ""}
if y_lrc:
lines = parse_header_info(y_lrc)
if lines:
result = parse_lyric(lines)
if yt_lrc:
t_lines = parse_header_info(yt_lrc)
if t_lines:
info["tlyric"] = fix_time_tag(result["lyric"], "\n".join(t_lines))
if yr_lrc:
r_lines = parse_header_info(yr_lrc)
if r_lines:
info["rlyric"] = fix_time_tag(result["lyric"], "\n".join(r_lines))
headers = "\n".join(line for line in lines if re.match(r"^\[[\d:.]+\]", line))
info["lyric"] = f"{headers}\n{result['lyric']}"
info["yrc"] = result["yrc"]
return info
for key, value in (("lyric", lrc), ("tlyric", tlrc), ("rlyric", rlrc)):
if not value:
continue
parsed_lines = parse_header_info(value)
if parsed_lines:
info[key] = "\n".join(parsed_lines)
return info
def fix_time_label(lrc: Any, tlrc: Any = None, romalrc: Any = None) -> tuple[Any, Any, Any]:
"""修正 `[mm:ss:SS]` 这类把毫秒写成第二冒号的错误时间标签(社区通行做法)。"""
if not lrc:
return lrc, tlrc, romalrc
text = str(lrc)
new_lrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", text)
new_tlrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2})\]", r"[\1.\2]", tlrc) if isinstance(tlrc, str) else tlrc
if new_lrc != text or new_tlrc != tlrc:
lrc, tlrc = new_lrc, new_tlrc
if isinstance(romalrc, str):
romalrc = re.sub(r"\[(\d{2}:\d{2}):(\d{2,3})\]", r"[\1.\2]", romalrc)
romalrc = re.sub(r"\[(\d{2}:\d{2}\.\d{2})0\]", r"[\1]", romalrc)
return lrc, tlrc, romalrc
def _pick_lyric(block: Any) -> Any:
return block.get("lyric") if isinstance(block, dict) else None
async def get_lyric(song_info: dict[str, Any]) -> dict[str, str]:
"""取歌词。原文失败文案:Get lyric failed。"""
resp = await http_fetch(
LYRIC_HOST,
method="post",
headers={"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"},
form=eapi(
"/api/song/lyric/v1",
{
"id": song_info.get("songmid"),
"cp": False,
"tv": 0,
"lv": 0,
"rv": 0,
"kv": 0,
"yv": 0,
"ytv": 0,
"yrv": 0,
},
),
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != 200 or not _pick_lyric(body.get("lrc")):
raise RuntimeError("Get lyric failed")
lrc, tlrc, romalrc = fix_time_label(
_pick_lyric(body.get("lrc")), _pick_lyric(body.get("tlyric")), _pick_lyric(body.get("romalrc"))
)
info = parse(
_pick_lyric(body.get("yrc")),
_pick_lyric(body.get("ytlrc")),
_pick_lyric(body.get("yromalrc")),
lrc,
tlrc,
romalrc,
)
if not info["lyric"]:
raise RuntimeError("Get lyric failed")
return info
+141
View File
@@ -0,0 +1,141 @@
"""歌曲详情与音质档位换算(移植自 sdk/wy/musicDetail.ts)。
上游 weapi/v3/song/detail 返回 songs + privileges,音质档位由 privilege.maxbr/maxBrLevel 决定,
档位声明顺序与 TS 版一致(flac24bit 先 push,switch 里依次追加,最后 reverse)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_time, size_formate
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
def _size_of(block: Any) -> str | None:
"""{size: N} → '1.23 MB';缺块返回 None(与 TS 的 `item.x ? sizeFormate(item.x.size) : null` 一致)。"""
if isinstance(block, dict) and block.get("size"):
return size_formate(block["size"])
return None
def build_types(item: dict[str, Any], privilege: dict[str, Any], *, flac_size: bool = True) -> tuple[list[dict], dict]:
"""按 privilege 生成 types/_types。`flac_size=False` 用于歌单详情(原实现那里 flac 的 size 恒为 null)。"""
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
if privilege.get("maxBrLevel") == "hires":
size = _size_of(item.get("hr"))
types.append({"type": "flac24bit", "size": size})
_types["flac24bit"] = {"size": size}
maxbr = privilege.get("maxbr")
# 无 break 的 switch 直落是有意为之:高码率同时声明向下兼容的档位
if maxbr in (999000, 320000, 192000, 128000):
if maxbr == 999000:
size = _size_of(item.get("sq")) if flac_size else None
types.append({"type": "flac", "size": size})
_types["flac"] = {"size": size}
if maxbr in (999000, 320000):
size = _size_of(item.get("h"))
types.append({"type": "320k", "size": size})
_types["320k"] = {"size": size}
size = _size_of(item.get("l"))
types.append({"type": "128k", "size": size})
_types["128k"] = {"size": size}
types.reverse()
return types, _types
def _match_privilege(privileges: list[dict[str, Any]], item: dict[str, Any], index: int) -> dict[str, Any] | None:
"""优先按下标取,id 不符时按 id 查;都找不到返回 None(原实现会跳过该曲目)。"""
candidate = privileges[index] if index < len(privileges) else None
if isinstance(candidate, dict) and candidate.get("id") == item.get("id"):
return candidate
for privilege in privileges:
if isinstance(privilege, dict) and privilege.get("id") == item.get("id"):
return privilege
return None
def get_singer(singers: Any) -> str:
return "、".join(str(singer.get("name")) for singer in singers or [] if isinstance(singer, dict))
def filter_list(songs: list[dict[str, Any]], privileges: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""song/detail 响应 → SongInfo 列表。"""
rows: list[dict[str, Any]] = []
for index, item in enumerate(songs):
if not isinstance(item, dict):
continue
privilege = _match_privilege(privileges, item, index)
if privilege is None:
continue
types, _types = build_types(item, privilege)
album = item.get("al") or {}
pc = item.get("pc")
if pc:
# 云盘/翻唱曲目走 pc 字段(歌手/歌名/专辑为纯文本)
rows.append(
{
"singer": pc.get("ar") or "",
"name": pc.get("sn") or "",
"albumName": pc.get("alb") or "",
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl") or "",
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
else:
rows.append(
{
"singer": get_singer(item.get("ar")),
"name": item.get("name") or "",
"albumName": album.get("name"),
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return rows
async def get_list(ids: list[Any] | None = None, retry_num: int = 0) -> dict[str, Any]:
"""按 id 批量取歌曲详情(weapi/v3/song/detail)。"""
ids = ids or []
if retry_num > 2:
raise RuntimeError("try max num")
form = weapi(
{
"c": "[" + ",".join('{"id":' + str(id_) + "}" for id_ in ids) + "]",
"ids": "[" + ",".join(str(id_) for id_ in ids) + "]",
}
)
resp = await http_fetch(
"https://music.163.com/weapi/v3/song/detail",
method="post",
headers={**weapi_headers(with_cookie=False), "User-Agent": UA_LINUX_CHROME},
form=form,
)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != 200:
raise RuntimeError("获取歌曲详情失败")
return {"source": "wy", "list": filter_list(body.get("songs") or [], body.get("privileges") or [])}
+34
View File
@@ -0,0 +1,34 @@
"""网易云歌曲信息(移植自 sdk/wy/musicInfo.ts,走 weapi/v3/song/detail 取原始 song 对象)。
与 music_detail.py 的差别:这里返回上游原始 `songs[0]`(含 al/ar/hr/sq 等),供封面等场景直接取值。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
async def get_music_info(song_info: dict[str, Any]) -> dict[str, Any]:
"""取原始 song 对象。失败文案与原文一致:获取歌曲信息失败。"""
song_mid = song_info.get("songmid")
resp = await http_fetch(
"https://music.163.com/weapi/v3/song/detail",
method="post",
headers={
**weapi_headers(f"https://music.163.com/song?id={song_mid}", with_cookie=False),
"User-Agent": UA_LINUX_CHROME,
},
form=weapi({"c": f'[{{"id":{song_mid}}}]', "ids": f"[{song_mid}]"}),
)
body = resp.body if isinstance(resp.body, dict) else {}
songs = body.get("songs") or []
if body.get("code") != 200 or not songs:
raise RuntimeError("获取歌曲信息失败")
song = songs[0]
if not isinstance(song, dict):
raise RuntimeError("获取歌曲信息失败")
return song
+125
View File
@@ -0,0 +1,125 @@
"""网易云搜索(移植自 sdk/wy/musicSearch.ts)。
走 eapi 通道 /api/search/song/list/page(cloudsearch pc 已被注释掉,保留原样)。
响应结构是 resources[].baseInfo.simpleSongData,与 song/detail 的扁平结构不同,
因此音质解析单独一份(privilege 内嵌在 simpleSongData 里)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time, size_formate
from app.sdk.platforms.wy.utils import eapi_request
DEFAULT_LIMIT = 30
# 模块级状态与原 TS 一致(路由不读这些字段,但 allPage/total 由它们推导)
_state: dict[str, Any] = {"total": 0, "page": 0, "all_page": 1, "limit": DEFAULT_LIMIT}
def get_singer(singers: Any) -> str:
return "、".join(str(singer.get("name")) for singer in singers or [] if isinstance(singer, dict))
def _size_of(block: Any) -> str | None:
if isinstance(block, dict) and block.get("size"):
return size_formate(block["size"])
return None
def handle_result(raw_list: Any) -> list[dict[str, Any]]:
if not raw_list:
return []
rows: list[dict[str, Any]] = []
for raw in raw_list:
item = ((raw or {}).get("baseInfo") or {}).get("simpleSongData") or {}
privilege = item.get("privilege") or {}
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
if privilege.get("maxBrLevel") == "hires":
size = _size_of(item.get("hr"))
types.append({"type": "flac24bit", "size": size})
_types["flac24bit"] = {"size": size}
maxbr = privilege.get("maxbr")
# 无 break 的 switch 直落:高码率同时声明向下兼容档位
if maxbr in (999000, 320000, 192000, 128000):
if maxbr == 999000:
size = _size_of(item.get("sq"))
types.append({"type": "flac", "size": size})
_types["flac"] = {"size": size}
if maxbr in (999000, 320000):
size = _size_of(item.get("h"))
types.append({"type": "320k", "size": size})
_types["320k"] = {"size": size}
size = _size_of(item.get("l"))
types.append({"type": "128k", "size": size})
_types["128k"] = {"size": size}
types.reverse()
album = item.get("al") or {}
rows.append(
{
"singer": get_singer(item.get("ar")),
"name": item.get("name"),
"albumName": album.get("name"),
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl"),
"lrc": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return rows
async def _music_search(keyword: str, page: int, limit: int) -> Any:
resp = await eapi_request(
"/api/search/song/list/page",
{
"keyword": keyword,
"needCorrect": "1",
"channel": "typing",
"offset": limit * (page - 1),
"scene": "normal",
"total": page == 1,
"limit": limit,
},
)
return resp.body
async def search(keyword: str, page: int = 1, limit: int = 20, retry_num: int = 0) -> dict[str, Any]:
"""搜索单曲。失败重试上限 3 次(文案与原文一致:try max num)。"""
retry_num += 1
if retry_num > 3:
raise RuntimeError("try max num")
if not limit:
limit = DEFAULT_LIMIT
result = await _music_search(keyword, page, limit)
if not isinstance(result, dict) or result.get("code") != 200:
return await search(keyword, page, limit, retry_num)
data = result.get("data") or {}
rows = handle_result(data.get("resources") or [])
_state["total"] = data.get("totalCount") or 0
_state["page"] = page
_state["all_page"] = -(-int(_state["total"]) // DEFAULT_LIMIT) # ceil 除法
_state["limit"] = DEFAULT_LIMIT
return {
"list": rows,
"allPage": _state["all_page"],
"limit": DEFAULT_LIMIT,
"total": _state["total"],
"source": "wy",
}
+80
View File
@@ -0,0 +1,80 @@
"""网易云链接解析(移植自 sdk/wy/parse.ts,上游 music-lib/netease/song.go 的 Parse)。
歌曲链接形态:
https://music.163.com/#/song?id=29732995
https://music.163.com/song?id=29732995
https://music.163.com/song/29732995
裸数字 id 也接受(补充便利;上游仅接受链接)。
直链 url/type 为尽力附带,失败不影响元数据返回(与上游一致);
歌单/专辑链接抛清晰错误,提示改用 parse_playlist / parse_album。
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.platforms.wy.album import convert_song, parse_album_link
from app.sdk.platforms.wy.link import parse_netease_link
from app.sdk.platforms.wy.playlist import parse_playlist_link
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, weapi_headers
from app.sdk.platforms.wy.utils.crypto import weapi
async def fetch_song_by_id(song_id: str) -> dict[str, Any]:
"""上游 fetchSongsBatch 的单曲版(weapi/v3/song/detail)。"""
resp = await http_fetch(
"https://music.163.com/weapi/v3/song/detail",
method="post",
headers={**weapi_headers(), "User-Agent": UA_LINUX_CHROME},
form=weapi({"c": f'[{{"id":{song_id}}}]', "ids": f"[{song_id}]"}),
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)})")
songs = body.get("songs") or []
item = songs[0] if songs else None
if not isinstance(item, dict) or not item.get("id"):
raise RuntimeError("netease song not found")
return convert_song(item)
async def parse_song(link: str) -> dict[str, Any]:
"""歌曲链接 → SongInfo(尽力附带直链 url/type)。"""
raw = str(link or "").strip()
if not raw:
raise RuntimeError("无效的网易云歌曲链接")
if raw.isdigit():
song_id = raw
else:
try:
kind, parsed_id = parse_netease_link(raw)
except ValueError as err:
raise RuntimeError("无效的网易云歌曲链接") from err
if kind == "playlist":
raise RuntimeError("检测到网易云歌单链接,请使用 parsePlaylist")
if kind == "album":
raise RuntimeError("检测到网易云专辑链接,请使用 parseAlbum")
song_id = parsed_id
song = await fetch_song_by_id(song_id)
try:
# 延迟导入:apis.wy 反向依赖 platforms.wy.utils,模块级互相 import 会成环
from app.sdk.apis import wy as wy_api
hit = await wy_api.get_music_url(song, "320k")
song["url"] = hit["url"]
song["type"] = hit["type"]
except Exception as err: # noqa: BLE001 - 与上游一致:直链失败忽略(VIP/下架曲目仍返回元数据)
print(f"[wy] 解析歌曲时取流失败(已忽略): {err}")
return song
async def parse_album(link: str) -> dict[str, Any]:
return await parse_album_link(link)
async def parse_playlist(link: str) -> dict[str, Any]:
return await parse_playlist_link(link)
+19
View File
@@ -0,0 +1,19 @@
"""网易云封面(移植自 sdk/wy/index.ts 的 getPic → musicInfo.ts 的 song_detail)。
原文行为:取 song/detail 的 `al.picUrl`,失败即报错(无降级链)。
"""
from __future__ import annotations
from typing import Any
from app.sdk.platforms.wy.music_info import get_music_info
async def get_pic(song_info: dict[str, Any]) -> str:
info = await get_music_info(song_info)
album = info.get("al") or {}
url = album.get("picUrl")
if not url:
raise RuntimeError("获取封面失败")
return str(url)
+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_)
+148
View File
@@ -0,0 +1,148 @@
"""网易云歌手(移植自 sdk/wy/singer.ts)。
三个 eapi 接口:
* /api/artist/head/info/get → 歌手信息
* /api/v2/artist/songs → 歌曲列表(音质档位来自 privilege.chargeInfoList)
* /api/artist/albums/<id> → 专辑列表
"""
from __future__ import annotations
from typing import Any
from app.sdk.common.utils import format_play_time, format_singer_name, size_formate
from app.sdk.platforms.wy.utils import eapi_request
async def get_info(singer_id: str) -> dict[str, Any]:
"""歌手信息。失败文案与原文一致:get singer info faild.。"""
resp = await eapi_request("/api/artist/head/info/get", {"id": singer_id})
body = resp.body if isinstance(resp.body, dict) else {}
if not body or body.get("code") != 200:
raise RuntimeError("get singer info faild.")
artist = (body.get("data") or {}).get("artist")
if not isinstance(artist, dict):
raise RuntimeError("get singer info faild.")
return {
"source": "wy",
"id": artist.get("id"),
"info": {
"name": artist.get("name"),
"desc": artist.get("briefDesc"),
"avatar": artist.get("avatar") or artist.get("cover") or None,
"gender": None,
},
"count": {
"music": artist.get("musicSize"),
"album": artist.get("albumSize"),
},
}
def _size_of(block: Any) -> str | None:
if isinstance(block, dict) and block.get("size"):
return size_formate(block["size"])
return None
def filter_song_list(raw: Any) -> list[dict[str, Any]]:
"""歌手歌曲 → SongInfo。档位来自 privilege.chargeInfoList 的 rate(无直落,逐条 break)。"""
rows: list[dict[str, Any]] = []
for item in raw or []:
if not isinstance(item, dict) or not item.get("id"):
continue
types: list[dict[str, Any]] = []
_types: dict[str, dict[str, Any]] = {}
privilege = item.get("privilege") or {}
for info in privilege.get("chargeInfoList") or []:
if not isinstance(info, dict):
continue
rate = info.get("rate")
if rate == 128000:
size = _size_of(item.get("lMusic"))
types.append({"type": "128k", "size": size})
_types["128k"] = {"size": size}
elif rate == 320000:
size = _size_of(item.get("hMusic"))
types.append({"type": "320k", "size": size})
_types["320k"] = {"size": size}
elif rate == 999000:
size = _size_of(item.get("sqMusic"))
types.append({"type": "flac", "size": size})
_types["flac"] = {"size": size}
elif rate == 1999000:
size = _size_of(item.get("hrMusic"))
types.append({"type": "flac24bit", "size": size})
_types["flac24bit"] = {"size": size}
raw_album = item.get("album")
album: dict[str, Any] = raw_album if isinstance(raw_album, dict) else {}
rows.append(
{
"singer": format_singer_name(item.get("artists")),
"name": item.get("name"),
"albumName": album.get("name"),
"albumId": album.get("id"),
"songmid": item.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or item.get("duration") or 0) / 1000),
"img": album.get("picUrl"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return rows
async def get_song_list(singer_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌手歌曲列表。失败文案:get singer song list faild.。"""
resp = await eapi_request("/api/v2/artist/songs", {"id": singer_id, "limit": limit, "offset": (page - 1) * limit})
body = resp.body if isinstance(resp.body, dict) else {}
if not body.get("songs") or body.get("code") != 200:
raise RuntimeError("get singer song list faild.")
return {
"list": filter_song_list(body.get("songs")),
"limit": limit,
"page": page,
"total": body.get("total"),
"source": "wy",
}
def filter_album_list(raw: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw or []:
if not isinstance(item, dict) or not item.get("id"):
continue
rows.append(
{
"id": item.get("id"),
"count": item.get("size"),
"info": {
"name": item.get("name"),
"author": format_singer_name(item.get("artists")),
"img": item.get("picUrl"),
"desc": None,
},
}
)
return rows
async def get_album_list(singer_id: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌手专辑列表。失败文案:get singer album list faild.。"""
resp = await eapi_request(f"/api/artist/albums/{singer_id}", {"limit": limit, "offset": (page - 1) * limit})
body = resp.body if isinstance(resp.body, dict) else {}
if not body.get("hotAlbums") or body.get("code") != 200:
raise RuntimeError("get singer album list faild.")
artist = body.get("artist") or {}
return {
"source": "wy",
"list": filter_album_list(body.get("hotAlbums")),
"limit": limit,
"page": page,
"total": artist.get("albumSize"),
}
+335
View File
@@ -0,0 +1,335 @@
"""网易云歌单(移植 sdk/wy/songList.ts)。
四条数据源:
* playlist/list(weapi) → get_list:分类歌单列表
* playlist/catalogue + hottags → get_tag / get_hot_tag:分类与热门标签
* linux/forward + v3/playlist/detail → get_list_detail:歌单详情与曲目
* cloudsearch pc(eapi,type=1000) → search:歌单搜索
`getListId` 负责把「歌单链接 / 裸 id / id###token」归一成纯 id。
"""
from __future__ import annotations
import asyncio
import re
from typing import Any
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import date_format, format_play_count, format_play_time, format_singer_name, size_formate
from app.sdk.platforms.wy import music_detail
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME, eapi_request, weapi_headers
from app.sdk.platforms.wy.utils.crypto import linuxapi, weapi
LIMIT_LIST = 30
LIMIT_SONG = 100000
SUCCESS_CODE = 200
DEFAULT_COOKIE = "MUSIC_U="
SORT_LIST = [{"name": "最热", "id": "hot"}]
# 路由读 module.song_list.sort_list 取默认排序,必须与路由约定同名(小写)
sort_list = SORT_LIST
RX_LIST_DETAIL_LINK = re.compile(r"^.+(?:\?|&)id=(\d+)(?:&.*$|#.*$|$)")
RX_LIST_DETAIL_LINK2 = re.compile(r"^.+/playlist/(\d+)/\d+/.+$")
# 歌单详情用的 MUSIC_U(原实现把它存在模块状态上:id 里带 ###token 时会被记住)
_state: dict[str, str] = {"cookie": DEFAULT_COOKIE}
async def handle_parse_id(link: str, retry_num: int = 0) -> str:
"""跟随重定向(最多 2 次)后从最终 URL 里取出歌单 id。"""
if retry_num > 2:
raise RuntimeError("link try max num")
resp = await http_fetch(link)
if resp.status_code > 400:
return await handle_parse_id(link, retry_num + 1)
location = resp.header("location")
url = location if location else link
if RX_LIST_DETAIL_LINK.match(url):
return RX_LIST_DETAIL_LINK.sub(r"\1", url)
return RX_LIST_DETAIL_LINK2.sub(r"\1", url)
async def get_list_id(raw_id: str) -> dict[str, str | None]:
"""把歌单链接/裸 id/`id###token` 归一成 {id, cookie}。"""
cookie: str | None = None
id_ = raw_id
if "###" in id_:
url, _, token = id_.partition("###")
id_ = url
cookie = f"MUSIC_U={token}"
if re.search(r"[?&:/]", id_):
if RX_LIST_DETAIL_LINK.match(id_):
id_ = RX_LIST_DETAIL_LINK.sub(r"\1", id_)
elif RX_LIST_DETAIL_LINK2.match(id_):
id_ = RX_LIST_DETAIL_LINK2.sub(r"\1", id_)
else:
id_ = await handle_parse_id(id_)
return {"id": id_, "cookie": cookie}
def _size_of(block: Any) -> str | None:
if isinstance(block, dict) and block.get("size"):
return size_formate(block["size"])
return None
def filter_list_detail(playlist: dict[str, Any], privileges: list[dict[str, Any]]) -> list[dict[str, Any]]:
"""歌单详情曲目 → SongInfo。原实现此处 flac 的 size 恒为 None,保持一样。"""
tracks = playlist.get("tracks") or []
rows: list[dict[str, Any]] = []
for index, item in enumerate(tracks):
if not isinstance(item, dict):
continue
privilege = privileges[index] if index < len(privileges) else None
if not isinstance(privilege, dict) or privilege.get("id") != item.get("id"):
privilege = next((p for p in privileges if isinstance(p, dict) and p.get("id") == item.get("id")), None)
if privilege is None:
continue
types, _types = music_detail.build_types(item, privilege, flac_size=False)
album = item.get("al") or {}
pc = item.get("pc")
if pc:
rows.append(
{
"singer": pc.get("ar") or "",
"name": pc.get("sn") or "",
"albumName": pc.get("alb") or "",
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl") or "",
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
else:
rows.append(
{
"singer": format_singer_name(item.get("ar"), "name"),
"name": item.get("name") or "",
"albumName": album.get("name"),
"albumId": album.get("id"),
"source": "wy",
"interval": format_play_time((item.get("dt") or 0) / 1000),
"songmid": item.get("id"),
"img": album.get("picUrl"),
"lrc": None,
"otherSource": None,
"types": types,
"_types": _types,
"typeUrl": {},
}
)
return rows
async def get_list_detail(raw_id: str, page: int = 1, try_num: int = 0) -> dict[str, Any]:
"""歌单详情(linux/forward 转发 v3/playlist/detail)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
resolved = await get_list_id(str(raw_id or ""))
id_ = str(resolved["id"] or "")
if resolved["cookie"]:
_state["cookie"] = str(resolved["cookie"])
headers = {"User-Agent": UA_LINUX_CHROME, "Cookie": _state["cookie"]}
form = linuxapi(
{
"method": "POST",
"url": "https://music.163.com/api/v3/playlist/detail",
"params": {"id": id_, "n": LIMIT_SONG, "s": 8},
}
)
resp = await http_fetch("https://music.163.com/api/linux/forward", method="post", headers=headers, form=form)
body = resp.body if isinstance(resp.body, dict) else {}
if resp.status_code != 200 or body.get("code") != SUCCESS_CODE:
return await get_list_detail(id_, page, try_num + 1)
limit = 1000
playlist = body.get("playlist") or {}
track_ids = [track.get("id") for track in playlist.get("trackIds") or [] if isinstance(track, dict)]
privileges = body.get("privileges") or []
if len(track_ids) == len(privileges):
rows = filter_list_detail(playlist, privileges)
else:
range_start = (page - 1) * limit
slice_ids = track_ids[range_start : limit * page]
try:
rows = (await music_detail.get_list(slice_ids))["list"]
except Exception as err:
print(f"[wy] 歌单歌曲详情获取失败将重试({try_num + 1}): {err}")
if str(err) == "try max num":
raise
return await get_list_detail(id_, page, try_num + 1)
creator = playlist.get("creator") or {}
return {
"list": rows,
"page": page,
"limit": limit,
"total": len(track_ids),
"source": "wy",
"info": {
"play_count": format_play_count(playlist.get("playCount")),
"name": playlist.get("name"),
"img": playlist.get("coverImgUrl"),
"desc": playlist.get("description"),
"author": creator.get("nickname"),
},
}
def filter_list(raw_data: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_data or []:
if not isinstance(item, dict):
continue
creator = item.get("creator") or {}
rows.append(
{
"play_count": format_play_count(item.get("playCount")),
"id": str(item.get("id")),
"author": creator.get("nickname"),
"name": item.get("name"),
"time": date_format(item.get("createTime"), "Y-M-D") if item.get("createTime") else "",
"img": item.get("coverImgUrl"),
"grade": item.get("grade"),
"total": item.get("trackCount"),
"desc": item.get("description"),
"source": "wy",
}
)
return rows
async def get_list(sort_id: Any, tag_id: Any, page: int = 1, try_num: int = 0) -> dict[str, Any]:
"""分类歌单列表(weapi/playlist/list)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
form = weapi(
{
"cat": tag_id or "全部",
"order": sort_id,
"limit": LIMIT_LIST,
"offset": LIMIT_LIST * (page - 1),
"total": True,
}
)
resp = await http_fetch(
"https://music.163.com/weapi/playlist/list", method="post", headers=weapi_headers(with_cookie=False), form=form
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_list(sort_id, tag_id, page, try_num + 1)
try:
total = int(body.get("total") or 0)
except (TypeError, ValueError):
total = 0
return {
"list": filter_list(body.get("playlists")),
"total": total,
"page": page,
"limit": LIMIT_LIST,
"source": "wy",
}
def filter_tag_info(body: dict[str, Any]) -> list[dict[str, Any]]:
categories = body.get("categories") or {}
grouped: dict[Any, list[dict[str, Any]]] = {}
for item in body.get("sub") or []:
if not isinstance(item, dict):
continue
category = item.get("category")
grouped.setdefault(category, []).append(
{
"parent_id": categories.get(category),
"parent_name": categories.get(category),
"id": item.get("name"),
"name": item.get("name"),
"source": "wy",
}
)
return [{"name": categories[key], "list": grouped.get(key), "source": "wy"} for key in categories]
async def get_tag(try_num: int = 0) -> list[dict[str, Any]]:
"""歌单分类树(weapi/playlist/catalogue)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
"https://music.163.com/weapi/playlist/catalogue",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi({}),
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
return await get_tag(try_num + 1)
return filter_tag_info(body)
def filter_hot_tag_info(raw_list: Any) -> list[dict[str, Any]]:
rows: list[dict[str, Any]] = []
for item in raw_list or []:
tag = (item or {}).get("playlistTag") or {}
if not tag.get("name"):
continue
rows.append({"id": tag.get("name"), "name": tag.get("name"), "source": "wy"})
return rows
async def get_hot_tag(try_num: int = 0) -> list[dict[str, Any]]:
"""热门标签(weapi/playlist/hottags)。失败重试 3 次。"""
if try_num > 2:
raise RuntimeError("try max num")
resp = await http_fetch(
"https://music.163.com/weapi/playlist/hottags",
method="post",
headers=weapi_headers(with_cookie=False),
form=weapi({}),
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
# 原实现此处误调 get_tag(返回形状不同),这里同样退回分类接口以保持行为可预期
return await get_tag(try_num + 1)
return filter_hot_tag_info(body.get("tags"))
async def get_tags() -> dict[str, Any]:
tags, hot_tag = await asyncio.gather(get_tag(), get_hot_tag())
return {"tags": tags, "hotTag": hot_tag, "source": "wy"}
async def get_detail_page_url(raw_id: str) -> str:
resolved = await get_list_id(str(raw_id or ""))
return f"https://music.163.com/#/playlist?id={resolved['id']}"
async def search(text: str, page: int = 1, limit: int = 20) -> dict[str, Any]:
"""歌单搜索(eapi cloudsearch pc type=1000)。失败文案与原文一致:filed。"""
resp = await eapi_request(
"/api/cloudsearch/pc",
{"s": text, "type": 1000, "limit": limit, "total": page == 1, "offset": limit * (page - 1)},
)
body = resp.body if isinstance(resp.body, dict) else {}
if body.get("code") != SUCCESS_CODE:
raise RuntimeError("filed")
result = body.get("result") or {}
return {
"list": filter_list(result.get("playlists")),
"limit": limit,
"total": result.get("playlistCount"),
"source": "wy",
}
+117
View File
@@ -0,0 +1,117 @@
"""网易云个人歌单(移植自 sdk/wy/userPlaylist.ts,上游 music-lib/netease/user_playlist.go)。
需登录 cookie MUSIC_U(app/sdk/auth_store.py 的 get_credentials('wy')):
未登录直接抛清晰错误,不返回空列表 —— 空列表会被前端当成「用户没有歌单」。
1) weapi/nuser/account/get → 当前用户 userId
2) weapi/user/playlist → 用户歌单列表(分页 limit/offset)
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_credentials, get_wy_cookie
from app.sdk.common.request import http_fetch
from app.sdk.common.utils import format_play_count
from app.sdk.platforms.wy.utils import UA_LINUX_CHROME
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 read_cookie() -> str:
"""优先用 musicU 拼 MUSIC_U,其次退回已存 cookie / 通用 cookie。"""
cred = get_credentials("wy") or {}
music_u = str(cred.get("musicU") or "").strip()
if music_u:
return f"MUSIC_U={music_u}"
return str(cred.get("cookie") or get_wy_cookie()).strip()
async def _weapi_post(url: str, data: dict[str, Any], cookie: str) -> dict[str, Any]:
resp = await http_fetch(
url,
method="post",
headers={
"User-Agent": UA_LINUX_CHROME,
"Referer": "https://music.163.com/",
"origin": "https://music.163.com",
"Content-Type": "application/x-www-form-urlencoded",
"Cookie": cookie,
},
form=weapi(data),
timeout=15000,
)
body = resp.body
if resp.status_code != 200 or not isinstance(body, dict):
raise RuntimeError("wy 个人歌单接口请求失败")
return body
async def get_list(page: int = 1, limit: int = 30) -> dict[str, Any]:
"""个人歌单列表。未登录 / 登录态失效抛 RuntimeError,不返回空列表。"""
cookie = read_cookie()
if not cookie:
raise RuntimeError("wy 个人歌单需要登录 cookie(MUSIC_U),请先登录网易云")
safe_page = max(1, int(page or 1))
safe_limit = int(limit or 30)
if safe_limit <= 0:
safe_limit = 30
if safe_limit > 100:
safe_limit = 100
account = await _weapi_post("https://music.163.com/weapi/nuser/account/get", {"csrf_token": ""}, cookie)
profile = account.get("profile") or {}
if account.get("code") != 200 or not profile.get("userId"):
raise RuntimeError(f"wy 登录态无效或已过期 (code {account.get('code', 'unknown')})")
uid = profile.get("userId")
nickname = profile.get("nickname") or ""
body = await _weapi_post(
"https://music.163.com/weapi/user/playlist",
{
"uid": uid,
"limit": safe_limit,
"offset": (safe_page - 1) * safe_limit,
"includeVideo": True,
"csrf_token": "",
},
cookie,
)
if body.get("code") != 200:
raise RuntimeError(f"wy 个人歌单获取失败 (code {body.get('code')})")
rows: list[dict[str, Any]] = []
for item in body.get("playlist") 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)
creator = str((item.get("creator") or {}).get("nickname") or "").strip() or nickname
rows.append(
{
"id": id_,
"name": item.get("name") or "",
"cover": item.get("coverImgUrl") or "",
"trackCount": int(item.get("trackCount") or 0),
"playCount": play_count,
"play_count": format_play_count(play_count),
"creator": creator,
"description": item.get("description") or "",
"subscribed": bool(item.get("subscribed")),
"link": playlist_link(id_),
"source": "wy",
}
)
return {"list": rows, "page": safe_page, "limit": safe_limit, "total": len(rows), "source": "wy"}
get_user_playlists = get_list
+54
View File
@@ -0,0 +1,54 @@
"""网易云 eapi 请求通道(移植自 sdk/wy/utils/index.ts)。
登录后携带 MUSIC_U(见 app/sdk/auth/wy.py),未登录不附加 —— 匿名接口同样要求
eapi 签名,区别只在 cookie 决定的可访问范围/音质。
`weapi_headers(with_cookie=False)` 用于原实现里本就不带 cookie 的 weapi 请求
(playlist/list、catalogue、hottags、comment、toplist),避免登录态意外改变返回内容。
"""
from __future__ import annotations
from typing import Any
from app.sdk.auth_store import get_wy_cookie
from app.sdk.common.request import HttpResponse, http_fetch
from app.sdk.platforms.wy.utils.crypto import eapi
UA_LINUX_CHROME = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/60.0.3112.90 Safari/537.36"
)
UA_WIN_CHROME = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36"
)
def weapi_headers(referer: str = "https://music.163.com/", *, with_cookie: bool = True) -> dict[str, str]:
headers = {
"User-Agent": UA_LINUX_CHROME,
"Referer": referer,
"origin": "https://music.163.com",
}
if with_cookie:
cookie = get_wy_cookie()
if cookie:
headers["cookie"] = cookie
return headers
async def eapi_request(
url: str,
data: dict[str, Any],
*,
host: str = "https://interface.music.163.com/eapi/batch",
timeout_ms: int = 60_000,
headers: dict[str, str] | None = None,
) -> HttpResponse:
"""POST 到 eapi 通道。`url` 是业务路径(如 /api/song/enhance/player/url/v1)。"""
merged = {"User-Agent": UA_LINUX_CHROME, "origin": "https://music.163.com"}
cookie = get_wy_cookie()
if cookie:
merged["cookie"] = cookie
if headers:
merged.update(headers)
return await http_fetch(host, method="post", headers=merged, form=eapi(url, data), timeout=timeout_ms)
+99
View File
@@ -0,0 +1,99 @@
"""eapi / weapi / linuxapi 加密 —— 移植自 sdk/wy/utils/crypto.ts。
算法与上游 Binaryify/NeteaseCloudMusicApi 的 util/crypto.js 逐字节一致:
* weapi 两层 AES-128-CBC(内层固定 presetKey,外层随机 secretKey),再用 RSA_NO_PADDING 包 secretKey
* linuxapi / eapi 走 AES-128-ECB
* Node 的 createCipheriv 默认 PKCS#7 自动补位,这里手工补齐;ECB 忽略 iv
改这里会让所有 eapi 请求的签名失效,必须与上游保持一致。
"""
from __future__ import annotations
import base64
import hashlib
import json
import os
from typing import Any
from cryptography.hazmat.primitives import padding as _sym_padding
from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
IV = b"0102030405060708"
PRESET_KEY = b"0CoJUm6Qyw8W8jud"
LINUXAPI_KEY = b"rFgB&h#%2?^eDg:Q"
EAPI_KEY = b"e82ckenh8dichen8"
BASE62 = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"
PUBLIC_KEY = (
"-----BEGIN PUBLIC KEY-----\n"
"MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDgtQn2JZ34ZC28NWYpAUd98iZ37BUrX/aKzmFbt7clFSs6sXqHauqKWqdtLkF2"
"KexO40H1YTX8z2lSgBBOAxLsvaklV8k4cBFK9snQXE9/DDaFt6Rr7iVZMldczhC0JNgTz+SHXT6CBHuX3e9SdB1Ua44oncaTWz7OBG"
"LbCiK45wIDAQAB\n"
"-----END PUBLIC KEY-----\n"
)
def _aes_encrypt(data: bytes, key: bytes, iv: bytes = b"") -> bytes:
"""AES 加密;iv 为空走 ECB,否则 CBC。补位方式与 Node 默认一致(PKCS#7)。"""
padder = _sym_padding.PKCS7(128).padder()
padded = padder.update(data) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(iv) if iv else modes.ECB())
encryptor = cipher.encryptor()
return encryptor.update(padded) + encryptor.finalize()
def _aes_decrypt(data: bytes, key: bytes) -> bytes:
cipher = Cipher(algorithms.AES(key), modes.ECB())
decryptor = cipher.decryptor()
plain = decryptor.update(data) + decryptor.finalize()
unpadder = _sym_padding.PKCS7(128).unpadder()
return unpadder.update(plain) + unpadder.finalize()
def _rsa_encrypt(data: bytes, key: str = PUBLIC_KEY) -> bytes:
"""RSA_NO_PADDING:调用方需自行左补零到模长(Node 版同样是手工 Buffer.alloc 补齐)。
cryptography 不提供 NoPadding(其 RSA 加密强制 PKCS#1 填充),而网易 weapi 的
secretKey 必须裸加密才与上游一致,因此这里直接做 c = m^e mod n。
"""
public_key = serialization.load_pem_public_key(key.encode())
if not isinstance(public_key, rsa.RSAPublicKey):
raise RuntimeError("网易云 RSA 公钥解析失败")
numbers = public_key.public_numbers()
block_size = public_key.key_size // 8
if len(data) > block_size:
raise RuntimeError("网易云 RSA 明文超过模长")
block = b"\x00" * (block_size - len(data)) + data
return pow(int.from_bytes(block, "big"), numbers.e, numbers.n).to_bytes(block_size, "big")
def _js_json(data: Any) -> str:
"""对齐 JS 的 JSON.stringify(紧凑、保留非 ASCII)。"""
return json.dumps(data, ensure_ascii=False, separators=(",", ":"))
def weapi(data: Any) -> dict[str, str]:
text = _js_json(data)
# Node 的 randomBytes 是密码学随机,用 os.urandom 对齐;取模 62 后映射到 base62 字符的 ASCII 码
secret_key = bytes(ord(BASE62[byte % 62]) for byte in os.urandom(16))
inner = base64.b64encode(_aes_encrypt(text.encode(), PRESET_KEY, IV))
params = base64.b64encode(_aes_encrypt(inner, secret_key, IV)).decode()
return {"params": params, "encSecKey": _rsa_encrypt(secret_key[::-1]).hex()}
def linuxapi(data: Any) -> dict[str, str]:
text = _js_json(data)
return {"eparams": _aes_encrypt(text.encode(), LINUXAPI_KEY).hex().upper()}
def eapi(url: str, data: Any) -> dict[str, str]:
text = data if isinstance(data, str) else _js_json(data)
digest = hashlib.md5(f"nobody{url}use{text}md5forencrypt".encode()).hexdigest()
raw = f"{url}-36cd479b6b5-{text}-36cd479b6b5-{digest}"
return {"params": _aes_encrypt(raw.encode(), EAPI_KEY).hex().upper()}
def eapi_decrypt(cipher_buffer: bytes) -> str:
return _aes_decrypt(cipher_buffer, EAPI_KEY).decode("utf-8", errors="replace")