This repository has been archived on 2026-10-02. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
NeteaseUrl/music_api/api.py
T
snowy-yang d2e6856899 refactor: get_playlist_detail 批次拉取拆解(94 行 / 圈 24 / 嵌套 3 → 60 行 / 圈 9 / 嵌套 1)
为什么:歌单解析的唯一上游通道,94 行单函数把「歌单头解析 / trackIds
分批拉明细 / 失败批次记账 / incomplete 标记 / 错误映射」五件事揉在
一个 try 里;曲目字段映射与搜索歌曲路逐字段重复。

拆出(模块级,与 search_music 拆解同风格):
- _song_base_fields:歌曲条目公共字段(搜索/歌单共用同一容错口径),
  _map_song_item 改为其加 type 键的包装
- _playlist_track_ids:trackIds → 字符串 ID 列表
- _collect_playlist_tracks:批次循环整体(失败批次计数、脏 ID 跳批、
  明细缺 id 跳过、incomplete 判定输入),嵌套 3 是批次语义固有,
  集中在单一职责函数内

保留语义:脏 ID('①'.isdigit() 为真但 int() 抛 ValueError)跳过整批
且不计 failed_batches;批次阶段网络错误仍映射「获取歌单详情请求失败」。

先写测试后动代码:新增 tests/test_playlist_detail.py 11 例,在未改动
实现上跑绿(修正 1 例测试自身的替身消费顺序),覆盖此前零测试路径——
playlist:null 防枚举、业务 code 错误、非 dict 响应、批次失败记账、
脏 ID 跳批、100/批分批、错误映射。
门禁:ruff 全过 · pyright 0/0 · pytest 359 passed(348→359)。
2026-09-27 10:53:48 +08:00

937 lines
36 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# -*- coding: utf-8 -*-
"""网易云 API 主类。
(自 music_api.py 拆出,行为与原实现一致。)
"""
import json
import time
from collections.abc import Callable
from random import randrange
from typing import Any
from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API)
from .client import (
HTTPClient,
_as_dict,
_ensure_dict,
)
from .constants import (
APIConstants,
)
from .crypto import (
CryptoUtils,
WeapiCrypto,
)
from .errors import (
APIException,
)
def _song_base_fields(item: dict[str, Any]) -> dict[str, Any]:
"""歌曲条目公共字段(搜索四路与歌单详情共用同一容错口径)。"""
return {
'id': item['id'],
'name': item.get('name') or '未知歌曲',
'artists': '/'.join(filter(None, (a.get('name') for a in item.get('ar') or []))) or '未知歌手',
'album': (item.get('al') or {}).get('name') or '未知专辑',
'picUrl': (item.get('al') or {}).get('picUrl') or ''
}
def _map_song_item(item: dict[str, Any]) -> dict[str, Any]:
"""歌曲条目映射。"""
return {
'type': 'song',
**_song_base_fields(item)
}
def _playlist_track_ids(playlist: dict[str, Any]) -> list[str]:
"""trackIds → 字符串 ID 列表(缺 id 的条目跳过)。"""
return [str(t['id']) for t in (playlist.get('trackIds') or []) if t.get('id')]
async def _collect_playlist_tracks(track_ids: list[str], headers: dict[str, str],
cookies: dict[str, str]) -> tuple[list[dict[str, Any]], int]:
"""分批拉取歌单全部曲目明细,返回 (tracks, failed_batches)。
明细批次的业务 code 也要校验,失败批次记入 failed_batches,不再静默缺歌。
脏 ID 跳过整批('①'.isdigit() 为真但 int() 抛 ValueError——原实现此处
未捕获会整歌单 500,现为跳批);明细缺 id 的歌跳过不拖整批。
"""
tracks: list[dict[str, Any]] = []
failed_batches = 0
for i in range(0, len(track_ids), 100):
batch_ids = track_ids[i:i + 100]
try:
song_data = {'c': json.dumps([{'id': int(sid), 'v': 0} for sid in batch_ids
if str(sid).isdigit()])}
except (TypeError, ValueError):
continue
song_resp = await requests.post(APIConstants.SONG_DETAIL_V3, data=song_data,
headers=headers, cookies=cookies, timeout=30)
song_resp.raise_for_status()
song_result = song_resp.json()
if not isinstance(song_result, dict):
# 上游 null/数组体按失败批次计,不让 AttributeError 失真
failed_batches += 1
continue
if song_result.get('code') != 200:
# 本批次业务失败:计为失败批次,交付方可见残缺状态
failed_batches += 1
continue
for song in (song_result.get('songs') or []):
if not song.get('id'):
continue
tracks.append(_song_base_fields(song))
return tracks, failed_batches
def _map_album_item(item: dict[str, Any]) -> dict[str, Any]:
"""专辑条目映射(artists 为空**不**兜底成"未知歌手"——与歌曲路不同口径)。"""
return {
'type': 'album',
'id': item['id'],
'name': item.get('name') or '未知名称',
'artists': '/'.join(filter(None, (a.get('name') for a in item.get('artists') or []))),
'alias': '',
'album': '',
'picUrl': item.get('picUrl')
}
def _map_artist_item(item: dict[str, Any]) -> dict[str, Any]:
"""歌手条目映射(alias 缺失/为空 → '')。"""
return {
'type': 'artist',
'id': item['id'],
'name': item.get('name') or '未知名称',
'artists': '',
'alias': '/'.join(item.get('alias', [])) if item.get('alias') else '',
'album': '',
'picUrl': item.get('picUrl')
}
def _map_playlist_item(item: dict[str, Any]) -> dict[str, Any]:
"""歌单条目映射。"""
return {
'type': 'playlist',
'id': item['id'],
'name': item.get('name') or '未知名称',
'artists': '',
'creator': (item.get('creator') or {}).get('nickname', ''),
'trackCount': item.get('trackCount', 0),
'album': '',
'picUrl': item.get('coverImgUrl')
}
# search_type → (上游结果键, 条目映射);未知类型不在表内 → 空结果
_SEARCH_RESULT_KEYS: dict[int, str] = {
1: 'songs', 10: 'albums', 100: 'artists', 1000: 'playlists',
}
_SEARCH_ITEM_MAPPERS: dict[int, Callable[[dict[str, Any]], dict[str, Any]]] = {
1: _map_song_item, 10: _map_album_item, 100: _map_artist_item, 1000: _map_playlist_item,
}
def _collect_search_items(result_data: dict[str, Any], search_type: int) -> list[dict[str, Any]]:
"""按类型取上游结果键并逐条映射;残缺条目(无 id,下架/屏蔽)跳过,不拖死整页。"""
key = _SEARCH_RESULT_KEYS.get(search_type)
mapper = _SEARCH_ITEM_MAPPERS.get(search_type)
if key is None or mapper is None:
return []
items: list[dict[str, Any]] = []
for item in result_data.get(key, []):
if not item.get('id'):
continue
items.append(mapper(item))
return items
class NeteaseAPI:
"""网易云音乐API主类"""
def __init__(self):
self.http_client = HTTPClient()
self.crypto_utils = CryptoUtils()
async def get_song_url(self, song_id: int, quality: str, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌曲播放URL
Args:
song_id: 歌曲ID
quality: 音质等级 (standard, exhigh, lossless, hires, sky, jyeffect, jymaster)
cookies: 用户cookies
Returns:
包含歌曲URL信息的字典
Raises:
APIException: API调用失败时抛出
"""
try:
config = APIConstants.DEFAULT_CONFIG.copy()
config["requestId"] = str(randrange(20000000, 30000000))
payload = {
'ids': [song_id],
'level': quality,
'encodeType': 'mp4' if quality == 'dolby' else 'flac',
'header': json.dumps(config),
}
if quality == 'sky':
payload['immerseType'] = 'c51'
params = self.crypto_utils.encrypt_params(APIConstants.SONG_URL_V1, payload)
response_text = await self.http_client.post_request(APIConstants.SONG_URL_V1, params, cookies)
result = json.loads(response_text)
# 合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真
if not isinstance(result, dict):
raise APIException("获取歌曲URL失败: 响应格式异常")
if result.get('code') != 200:
raise APIException(f"获取歌曲URL失败: {result.get('message', '未知错误')}")
return result
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析响应数据失败: {e}") from e
async def get_song_detail(self, song_id: int) -> dict[str, Any]:
"""获取歌曲详细信息
Args:
song_id: 歌曲ID
Returns:
包含歌曲详细信息的字典
Raises:
APIException: API调用失败时抛出
"""
try:
data = {'c': json.dumps([{"id": song_id, "v": 0}])}
# 补齐 UA/Referer——本方法是全站唯一裸 UA 请求,风控/460 单点偏高
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.post(APIConstants.SONG_DETAIL_V3, data=data,
headers=headers, timeout=30)
response.raise_for_status()
result = response.json()
# 合法 JSON 但非对象(null/数组)时给明确错误,不让 AttributeError 失真
if not isinstance(result, dict):
raise APIException("获取歌曲详情失败: 响应格式异常")
if result.get('code') != 200:
raise APIException(f"获取歌曲详情失败: {result.get('message', '未知错误')}")
return result
except requests.RequestException as e:
raise APIException(f"获取歌曲详情请求失败: {e}") from e
except json.JSONDecodeError as e:
raise APIException(f"解析歌曲详情响应失败: {e}") from e
async def get_lyric(self, song_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌词信息
Args:
song_id: 歌曲ID
cookies: 用户cookies
Returns:
包含歌词信息的字典
Raises:
APIException: API调用失败时抛出
"""
try:
data = {
'id': song_id,
'cp': 'false',
'tv': '0',
'lv': '0',
'rv': '0',
'kv': '0',
'yv': '1',
'ytv': '0',
'yrv': '0'
}
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.post(APIConstants.LYRIC_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_lyric 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"获取歌词失败: {result.get('message', '未知错误')}")
return result
except requests.RequestException as e:
raise APIException(f"获取歌词请求失败: {e}") from e
except json.JSONDecodeError as e:
raise APIException(f"解析歌词响应失败: {e}") from e
async def search_music(self, keywords: str, cookies: dict[str, str], limit: int = 10,
search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]:
"""搜索音乐
Args:
keywords: 搜索关键词
cookies: 用户cookies
limit: 返回数量限制
search_type: 搜索类型 (1-歌曲, 10-专辑, 100-歌手, 1000-歌单)
offset: 分页偏移(透传上游,翻页生效)
Returns:
搜索结果列表
Raises:
APIException: API调用失败时抛出
"""
try:
data = {'s': keywords, 'type': search_type, 'limit': limit,
'offset': max(0, int(offset or 0))}
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.post(APIConstants.SEARCH_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
# 非 dict 响应(null/数组)给明确错误
if not isinstance(result, dict):
raise APIException("搜索失败: 响应格式异常")
if result.get('code') != 200:
raise APIException(f"搜索失败: {result.get('message', '未知错误')}")
result_data = result.get('result') or {}
return _collect_search_items(result_data, search_type)
except requests.RequestException as e:
raise APIException(f"搜索请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析搜索响应失败: {e}") from e
async def get_playlist_detail(self, playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌单详情
Args:
playlist_id: 歌单ID
cookies: 用户cookies
Returns:
歌单详情信息(曲目数与 trackIds 不一致时附 incomplete/missing/failed_batches)
Raises:
APIException: API调用失败时抛出
"""
try:
data = {'id': playlist_id}
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.post(APIConstants.PLAYLIST_DETAIL_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
# 非 dict 响应(null/数组)给明确错误
if not isinstance(result, dict):
raise APIException("获取歌单详情失败: 响应格式异常")
if result.get('code') != 200:
raise APIException(f"获取歌单详情失败: {result.get('message', '未知错误')}")
# 上游对不存在/私密/无权限歌单返回 code:200 + playlist:null ——
# 显式转为 APIException(四条路由统一转"歌单不存在"语义,不再 500)
playlist = result.get('playlist') or {}
if not playlist:
raise APIException("歌单不存在或无权访问")
info = {
'id': playlist.get('id'),
'name': playlist.get('name'),
'coverImgUrl': playlist.get('coverImgUrl'),
'creator': (playlist.get('creator') or {}).get('nickname', ''),
'trackCount': playlist.get('trackCount'),
'description': playlist.get('description', ''),
'tracks': []
}
track_ids = _playlist_track_ids(playlist)
tracks, failed_batches = await _collect_playlist_tracks(track_ids, headers, cookies)
info['tracks'] = tracks
# trackIds 数与实际拿到的不一致时显式标记"不完整",不冒充完整结果
if len(tracks) != len(track_ids):
info['incomplete'] = True
info['missing'] = len(track_ids) - len(tracks)
info['failed_batches'] = failed_batches
return info
except requests.RequestException as e:
raise APIException(f"获取歌单详情请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析歌单详情响应失败: {e}") from e
async def get_album_detail(self, album_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取专辑详情
Args:
album_id: 专辑ID
cookies: 用户cookies
Returns:
专辑详情信息
Raises:
APIException: API调用失败时抛出
"""
try:
url = f'{APIConstants.ALBUM_DETAIL_API}{album_id}'
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.get(url, headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_album_detail 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"获取专辑详情失败: {result.get('message', '未知错误')}")
album = result.get('album') or {}
info = {
'id': album.get('id'),
'name': album.get('name'),
'coverImgUrl': self.get_pic_url(album.get('pic')),
'artist': (album.get('artist') or {}).get('name', ''),
'publishTime': album.get('publishTime'),
'description': album.get('description', ''),
'songs': []
}
for song in result.get('songs', []):
if not song.get('id'): # 残缺条目跳过
continue
info['songs'].append({
'id': song['id'],
'name': song.get('name') or '未知歌曲',
'artists': '/'.join(filter(None, (a.get('name') for a in song.get('ar') or []))) or '未知歌手',
'album': (song.get('al') or {}).get('name') or '未知专辑',
'picUrl': self.get_pic_url((song.get('al') or {}).get('pic'))
})
return info
except requests.RequestException as e:
raise APIException(f"获取专辑详情请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析专辑详情响应失败: {e}") from e
async def get_toplist(self) -> list[dict[str, Any]]:
"""获取网易云官方榜单列表
Returns:
榜单列表信息
Raises:
APIException: API调用失败时抛出
"""
try:
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.get(APIConstants.TOPLIST_API, headers=headers, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_toplist 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"获取榜单失败: {result.get('message', '未知错误')}")
toplists = []
for item in (result.get('list') or []):
toplists.append({
'id': item.get('id'),
'name': item.get('name'),
'coverImgUrl': item.get('coverImgUrl'),
'description': item.get('description', ''),
'updateFrequency': item.get('updateFrequency', ''),
'playCount': item.get('playCount', 0),
'trackCount': item.get('trackCount', 0),
'topTitle': item.get('ToplistType', '')
})
return toplists
except requests.RequestException as e:
raise APIException(f"获取榜单请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析榜单响应失败: {e}") from e
async def get_artist_hot_songs(self, artist_id: int) -> dict[str, Any]:
"""获取歌手信息和热门歌曲
Args:
artist_id: 歌手ID
Returns:
歌手信息和热门歌曲列表
Raises:
APIException: API调用失败时抛出
"""
try:
url = f'{APIConstants.ARTIST_API}{artist_id}'
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_artist_hot_songs 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"获取歌手信息失败: {result.get('message', '未知错误')}")
artist = result.get('artist') or {}
songs = []
for song in (result.get('hotSongs') or []):
songs.append({
'id': song.get('id'),
'name': song.get('name'),
'artists': '/'.join(a.get('name', '') for a in (song.get('ar') or [])),
'album': ((song.get('al') or {}).get('name', '')),
'picUrl': (song.get('al') or {}).get('picUrl', '')
})
return {
'artist': {
'id': artist.get('id'),
'name': artist.get('name'),
'picUrl': artist.get('picUrl'),
'alias': '/'.join(artist.get('alias', [])) if artist.get('alias') else '',
'briefDesc': artist.get('briefDesc', ''),
'musicSize': artist.get('musicSize', len(songs))
},
'songs': songs
}
except requests.RequestException as e:
raise APIException(f"获取歌手信息请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析歌手信息响应失败: {e}") from e
async def get_song_comments(self, song_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
"""获取歌曲评论
Args:
song_id: 歌曲ID
limit: 每页数量
offset: 偏移量
Returns:
评论数据(热门评论 + 全部评论)
Raises:
APIException: API调用失败时抛出
"""
try:
url = f'{APIConstants.COMMENT_API}{song_id}?limit={limit}&offset={offset}'
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_song_comments 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"获取评论失败: {result.get('message', '未知错误')}")
def _comment_dict(c):
c = c or {}
user = c.get('user') or {}
be_replied = c.get('beReplied') or []
replied = (be_replied[0] or {}) if be_replied else {}
replied_user = replied.get('user') or {}
vip_rights = _as_dict(user.get('vipRights'))
associator = _as_dict(vip_rights.get('associator'))
location_info = _as_dict(user.get('locationInfo'))
return {
'id': c.get('commentId'),
'content': c.get('content', ''),
'time': c.get('time', 0),
'likedCount': c.get('likedCount', 0),
'user': {
'nickname': user.get('nickname', ''),
'avatarUrl': user.get('avatarUrl', ''),
'vipType': user.get('vipType', 0),
'vipLevel': associator.get('level', 0),
'location': location_info.get('location', '')
},
'beReplied': [{
'nickname': replied_user.get('nickname', '') or '',
'content': replied.get('content') or ''
}]
}
return {
'total': result.get('total', 0),
'hasMore': result.get('hasMore', False),
'hotComments': [_comment_dict(c) for c in (result.get('hotComments') or [])],
'comments': [_comment_dict(c) for c in (result.get('comments') or [])],
}
except requests.RequestException as e:
raise APIException(f"获取评论请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析评论响应失败: {e}") from e
async def get_account_info(self, cookies: dict[str, str]) -> dict[str, Any] | None:
"""获取当前账号信息(用于个人登录状态展示)
Args:
cookies: 用户cookies
Returns:
账号信息字典,获取失败返回 None
"""
try:
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.get(APIConstants.USER_ACCOUNT_API, headers=headers,
cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_account_info 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
return None
profile = result.get('profile') or {}
# 无 userId 说明 Cookie 未关联有效登录态(可能已过期)
if not profile.get('userId'):
return None
return {
'userId': profile.get('userId'),
'nickname': profile.get('nickname', ''),
'avatarUrl': profile.get('avatarUrl', ''),
'signature': profile.get('signature', ''),
'vipType': profile.get('vipType', 0),
'vipLevel': profile.get('vipLevel', 0)
}
except Exception:
return None
async def get_user_playlists(self, user_id: int, cookies: dict[str, str]) -> list[dict[str, Any]]:
"""获取用户收藏/创建的歌单列表
Args:
user_id: 用户ID
cookies: 用户cookies
Returns:
歌单列表(含「我喜欢的音乐」等个人歌单)
Raises:
APIException: API调用失败时抛出
超过 100 个歌单时按上游 more 标志续页(上限 20 页 = 2000 个),
不再只取第一页就静默截断。
"""
try:
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
playlists = []
offset = 0
page_size = 100
for _ in range(20): # 有界翻页,防上游 more 异常导致无限循环
params = {'uid': user_id, 'limit': page_size, 'offset': offset}
response = await requests.get(APIConstants.USER_PLAYLIST_API, params=params,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'get_user_playlists 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"获取用户歌单失败: {result.get('message', '未知错误')}")
page_items = result.get('playlist') or []
seen_ids = {p.get('id') for p in playlists}
for item in page_items:
if item.get('id') in seen_ids:
continue # 上游列表变动导致重复时跳过
creator = item.get('creator') or {}
playlists.append({
'id': item.get('id'),
'name': item.get('name', ''),
'coverImgUrl': item.get('coverImgUrl'),
'trackCount': item.get('trackCount', 0),
'playCount': item.get('playCount', 0),
'creator': creator.get('nickname', ''),
'is_own': creator.get('userId') == user_id,
'specialType': item.get('specialType', 0),
'subscribed': item.get('subscribed', False),
'description': item.get('description', '')
})
more = bool(result.get('more'))
if not more or len(page_items) < page_size:
break
offset += page_size
return playlists
except requests.RequestException as e:
raise APIException(f"获取用户歌单请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析用户歌单响应失败: {e}") from e
async def detect_vip_by_probe(self, cookies: dict[str, str],
probe_song_id: int = 347230) -> dict[str, Any]:
"""通过歌曲URL接口探测账号可用的最高音质,推断会员类型
直接询问网易云接口「这个账号能把歌解析到什么音质」,
比 account/get 更可靠(旧 Cookie 可能查不到 profile)。
Args:
cookies: 用户cookies
probe_song_id: 用于探测的歌曲ID(默认《海阔天空》,支持全音质)
Returns:
{'vip_type': int, 'highest_level': str, 'levels_ok': List[str]}
"""
levels_ok = await self.probe_levels(cookies, probe_song_id)
highest = levels_ok[0] if levels_ok else 'standard'
if highest in ('jymaster', 'jyeffect', 'sky'):
vip_type = 110 # 黑胶SVIP(可解析沉浸/母带级)
elif highest in ('hires', 'lossless'):
vip_type = 11 # 黑胶VIP
else:
vip_type = 0 # 普通用户
return {'vip_type': vip_type, 'highest_level': highest, 'levels_ok': levels_ok}
async def probe_levels(self, cookies: dict[str, str],
probe_song_id: int = 347230) -> list[str]:
"""实测账号对每个音质级别能否真正解析
只有接口返回的 level 与请求一致且带 URL,才算该音质可解析。
返回按音质从高到低排序的可解析列表。
Args:
cookies: 用户cookies
probe_song_id: 探测歌曲ID
Returns:
可解析音质列表(从高到低)
"""
ladder = ['jymaster', 'jyeffect', 'sky', 'hires', 'lossless', 'exhigh', 'standard', 'dolby']
levels_ok = []
# 探测总预算 60s——上游黑洞时 8 级串行最坏 ~16 分钟线程滞留,
# 超预算即返回已测得的结果(调用方 5 分钟缓存内复用)
_deadline = time.monotonic() + 60
for level in ladder:
if time.monotonic() > _deadline:
break
try:
result = await self.get_song_url(probe_song_id, level, cookies)
if result and result.get('data'):
d = result['data'][0] or {}
# 请求与返回一致且带直链,才说明该音质真正可用
if d.get('url') and d.get('level') == level:
levels_ok.append(level)
except Exception:
continue
return levels_ok
async def subscribe_playlist(self, playlist_id, cookies: dict[str, str],
action: int = 1) -> dict[str, Any]:
"""收藏/取消收藏歌单(官方接口)
Args:
playlist_id: 歌单ID
cookies: 个人账号cookies
action: 1=收藏, 2=取消收藏
Returns:
接口响应
Raises:
APIException: 操作失败时抛出
"""
try:
data = {'id': playlist_id, 't': action}
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.post(APIConstants.PLAYLIST_SUBSCRIBE_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'subscribe_playlist 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"收藏歌单失败: {result.get('message', '未知错误')}")
return result
except requests.RequestException as e:
raise APIException(f"收藏歌单请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析收藏歌单响应失败: {e}") from e
async def subscribe_album(self, album_id, cookies: dict[str, str],
action: int = 1) -> dict[str, Any]:
"""收藏/取消收藏专辑(官方接口)
Args:
album_id: 专辑ID
cookies: 个人账号cookies
action: 1=收藏, 2=取消收藏
Returns:
接口响应
Raises:
APIException: 操作失败时抛出
"""
try:
data = {'id': album_id, 't': action}
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = await requests.post(APIConstants.ALBUM_SUBSCRIBE_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
_ensure_dict(result, 'subscribe_album 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"收藏专辑失败: {result.get('message', '未知错误')}")
return result
except requests.RequestException as e:
raise APIException(f"收藏专辑请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析收藏专辑响应失败: {e}") from e
async def post_comment(self, thread_id: str, content: str, action: int = 1,
comment_id: int | None = None,
cookies: dict[str, str] | None = None) -> dict[str, Any]:
"""发送/回复歌曲评论(官方 weapi 接口)
Args:
thread_id: 资源线程ID,如 R_SO_4_{songId}
content: 评论内容
action: 1=发送评论, 2=回复评论
comment_id: 回复目标评论ID(action=2 时必填)
cookies: 个人账号cookies
Returns:
接口响应
Raises:
APIException: 操作失败时抛出
"""
try:
payload: dict[str, Any] = {
'threadId': thread_id,
'content': content,
't': action,
'type': 6,
}
if comment_id:
payload['commentId'] = comment_id
# 官方 /weapi/comment 要求携带 csrf_token(取自 Cookie
# 的 __csrf)——原实现缺失,与加密偏差叠加导致评论疑似从未成功
csrf = (cookies or {}).get('__csrf') or ''
if csrf:
payload['csrf_token'] = csrf
body = WeapiCrypto.encrypt(payload)
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER,
}
response = await requests.post(APIConstants.COMMENT_POST_API, data=body,
headers=headers, cookies=cookies or {}, timeout=30)
response.raise_for_status()
text = response.text.strip()
if not text:
# weapi 被网络层吞掉(数据中心 IP 风控),无法确认是否成功
raise APIException("网易云未返回操作确认(可能被网络风控拦截),请稍后查看评论区确认")
result = response.json()
_ensure_dict(result, 'post_comment 接口响应异常(非标准 JSON 对象)')
if result.get('code') != 200:
raise APIException(f"评论失败: {result.get('message', '未知错误')}")
return result
except requests.RequestException as e:
raise APIException(f"评论请求失败: {e}") from e
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析评论响应失败: {e}") from e
def netease_encrypt_id(self, id_str: str) -> str:
"""网易云加密图片ID算法
Args:
id_str: 图片ID字符串
Returns:
加密后的字符串
"""
import base64
import hashlib
magic = list('3go8&$8*3*3h0k(2)2')
song_id = list(id_str)
for i in range(len(song_id)):
song_id[i] = chr(ord(song_id[i]) ^ ord(magic[i % len(magic)]))
m = ''.join(song_id)
md5_bytes = hashlib.md5(m.encode('utf-8')).digest()
result = base64.b64encode(md5_bytes).decode('utf-8')
result = result.replace('/', '_').replace('+', '-')
return result
def get_pic_url(self, pic_id: int | None, size: int = 300) -> str:
"""获取网易云加密歌曲/专辑封面直链
Args:
pic_id: 封面ID
size: 图片尺寸
Returns:
图片URL
"""
if pic_id is None:
return ''
enc_id = self.netease_encrypt_id(str(pic_id))
return f'https://p3.music.126.net/{enc_id}/{pic_id}.jpg?param={size}y{size}'