Archived
refactor: 拆分 4 个巨石模块为包,清理过时注释并删除作废文档
为什么:单文件 1100~1900 行的巨石模块让「改一处要通读全文」;同时注释里残留 大量已删台账的编号,以及「Flask/FastAPI 双栈迁移期」这类已失实的架构描述, 会误导后来者按不复存在的架构去改代码。 拆分(对外导入路径不变,行为零变更): - music_downloader.py -> music_downloader/(models/downloader/cover/_http/_log/_concurrency) - auth_system.py -> auth_system/(db/security/users/credits/ledger/mail/cards/backup...) - music_api.py -> music_api/(api/client/crypto/qr/cookies/compat/constants/errors) - services/runtime.py -> services/(api_service/_shared/_taskcore/downloads/batch/podcast/ streaming/tasks);services/runtime.py 保留为兼容门面 兼容性做法:各包用 __init__.py 以 __all__ 原样再导出全部符号,既有调用点 (auth_system.xxx / services.xxx / from music_api import Y)零改动;路径常量改为经包属性 动态读取(auth_system.DB_PATH),否则测试的 monkeypatch 会静默失效并写到生产库。 修复 9 处代码损伤(上一轮注释清理把 `()` 当「空括号残留」删掉所致;用「与拆分前逐字 等价」校验逐一定位,静态检查只会报出其中 4 处): - core/config.py ).strip() or '*' - core/state.py threading.Lock() x2 - qr_login.py datetime.now() / .strip() - music_downloader/downloader.py .lower() - services/podcast.py set() / time.time() x2 清理与删除: - 删除 部署文档.md / 功能介绍.md / README_私有包警告.txt - 清理指向已删台账与文档的过时编号注释;把「迁移期 / 两栈 / 双框架 / 已迁移」等 失实描述改写为现状描述(保留 `<-> Flask /path` 映射表等仍有价值的契约来源说明) - _mkpkg.py:公开包剔除清单去掉已不存在的文档名;私有包警告改为自包含 - pyproject.toml:补 pythonpath = ["."],使裸跑 pytest 不再 import core 失败 验证:ruff 全通过;pyright 0 errors / 0 warnings(102 文件);pytest 144 passed; 真实 uvicorn 冒烟 6 条路由符合预期且生产 data/ 指纹未变;忽略注释的逐字等价性校验通过。
This commit is contained in:
1 parent
838d635bc5
commit
6288a98ecc
89 files changed
+5806
-5112
No files matched your search
@@ -0,0 +1,903 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""网易云 API 主类。
|
||||
|
||||
(自 music_api.py 拆出,行为与原实现一致。)
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
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 {}
|
||||
items: list[dict[str, Any]] = []
|
||||
|
||||
if search_type == 1:
|
||||
# 歌曲(id 改 .get——上游单条残缺(下架/屏蔽)不再拖死整页搜索)
|
||||
for item in result_data.get('songs', []):
|
||||
if not item.get('id'):
|
||||
continue
|
||||
items.append({
|
||||
'type': 'song',
|
||||
'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 ''
|
||||
})
|
||||
elif search_type == 10:
|
||||
# 专辑(同上)
|
||||
for item in result_data.get('albums', []):
|
||||
if not item.get('id'):
|
||||
continue
|
||||
items.append({
|
||||
'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')
|
||||
})
|
||||
elif search_type == 100:
|
||||
# 歌手(同上)
|
||||
for item in result_data.get('artists', []):
|
||||
if not item.get('id'):
|
||||
continue
|
||||
items.append({
|
||||
'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')
|
||||
})
|
||||
elif search_type == 1000:
|
||||
# 歌单(同上)
|
||||
for item in result_data.get('playlists', []):
|
||||
if not item.get('id'):
|
||||
continue
|
||||
items.append({
|
||||
'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')
|
||||
})
|
||||
|
||||
return items
|
||||
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:
|
||||
歌单详情信息
|
||||
|
||||
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': []
|
||||
}
|
||||
|
||||
# 获取所有trackIds并分批获取详细信息(明细批次的业务 code 也要校验,
|
||||
# 失败批次记入 failed_batches,不再静默缺歌)
|
||||
track_ids = [str(t['id']) for t in (playlist.get('trackIds') or []) if t.get('id')]
|
||||
failed_batches = 0
|
||||
for i in range(0, len(track_ids), 100):
|
||||
batch_ids = track_ids[i:i+100]
|
||||
# trackIds 混入非数字脏 ID 时跳过该条(原实现 int 抛 ValueError
|
||||
# 不在捕获列表,整歌单 500);明细缺 id 的歌跳过不拖整批
|
||||
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
|
||||
info['tracks'].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': (song.get('al') or {}).get('picUrl') or ''
|
||||
})
|
||||
# trackIds 数与实际拿到的不一致时显式标记"不完整",不冒充完整结果
|
||||
if len(info['tracks']) != len(track_ids):
|
||||
info['incomplete'] = True
|
||||
info['missing'] = len(track_ids) - len(info['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}'
|
||||
Reference in new issue
Block a user