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:
snowy-yang committed 2026-09-26 12:37:48 +08:00
1 parent 838d635bc5
commit 6288a98ecc
89 files changed
+5806 -5112

No files matched your search

+99
View File
@@ -0,0 +1,99 @@
# -*- coding: utf-8 -*-
"""网易云音乐API模块
提供网易云音乐相关API接口的封装,包括:
- 音乐URL获取
- 歌曲详情获取
- 歌词获取
- 搜索功能
- 歌单和专辑详情
- 二维码登录
"""
# 兼容门面:实现已按职责拆分到同包各子模块,此处原样再导出。
# 用 `__all__` 声明再导出(ruff F401 与 pyright 均据此认定「有意再导出」),
# 使既有调用点(music_api.xxx / from music_api import Y)零改动。
from .api import (
NeteaseAPI,
)
from .client import (
_MAX_UPSTREAM_JSON_BYTES,
HTTPClient,
_as_dict,
_ensure_dict,
)
from .compat import (
account_info,
album_detail,
album_subscribe,
artist_songs,
detect_vip_by_probe,
get_pic_url,
lyric_v1,
name_v1,
playlist_detail,
playlist_subscribe,
post_comment,
probe_levels,
qr_login,
search_music,
song_comments,
toplist_list,
url_v1,
user_playlists,
)
from .constants import (
APIConstants,
QualityLevel,
)
from .cookies import (
DEFAULT_COOKIE_FILE,
load_cookies,
parse_cookie_string,
)
from .crypto import (
CryptoUtils,
WeapiCrypto,
)
from .errors import (
APIException,
)
from .qr import (
QRLoginManager,
)
__all__ = [
'DEFAULT_COOKIE_FILE',
'_MAX_UPSTREAM_JSON_BYTES',
'APIConstants',
'APIException',
'CryptoUtils',
'HTTPClient',
'NeteaseAPI',
'QRLoginManager',
'QualityLevel',
'WeapiCrypto',
'_as_dict',
'_ensure_dict',
'account_info',
'album_detail',
'album_subscribe',
'artist_songs',
'detect_vip_by_probe',
'get_pic_url',
'load_cookies',
'lyric_v1',
'name_v1',
'parse_cookie_string',
'playlist_detail',
'playlist_subscribe',
'post_comment',
'probe_levels',
'qr_login',
'search_music',
'song_comments',
'toplist_list',
'url_v1',
'user_playlists',
]
+18
View File
@@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
"""命令行入口(python -m music_api)。"""
if __name__ == "__main__":
# 测试代码
print("网易云音乐API模块")
print("支持的功能:")
print("- 歌曲URL获取")
print("- 歌曲详情获取")
print("- 歌词获取")
print("- 音乐搜索")
print("- 歌单详情")
print("- 专辑详情")
print("- 二维码登录")
+903
View File
@@ -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}'
+112
View File
@@ -0,0 +1,112 @@
# -*- coding: utf-8 -*-
"""HTTP 客户端与响应体收窄工具。
(自 music_api.py 拆出,行为与原实现一致。)
"""
import asyncio
from typing import Any
from core import aiohttp_compat as requests # 异步 HTTP 垫片(requests 同形 API)
from .constants import (
APIConstants,
)
from .errors import (
APIException,
)
def _ensure_dict(result: Any, msg: str) -> dict[str, Any]:
"""上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验"""
if isinstance(result, dict):
return result
raise APIException(msg)
def _as_dict(v: Any) -> dict[str, Any]:
"""上游嵌套字段类型不稳定(dict / None / 列表都真实出现过):非 dict 一律回落空 dict。
收窄成显式函数,避免满屏 `x if isinstance(x, dict) else {}` 三元式——
那种写法读起来累,且把字段来自 `user.get(...)`(类型 Any|None)时的收窄责任
散落在各处,容易被后来的改动改漏。
"""
return v if isinstance(v, dict) else {}
class HTTPClient:
"""HTTP客户端类"""
@staticmethod
async def post_request(url: str, params: str, cookies: dict[str, str]) -> str:
"""发送POST请求并返回文本响应。
网络异常/5xx 做一次 1 秒退避重试(全站 API 调用此前零重试,
上游抖动即整首失败);4xx(429/460 风控)不重试——立即重试会加剧风控。"""
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER,
}
request_cookies = APIConstants.DEFAULT_COOKIES.copy()
request_cookies.update(cookies)
last_err = None
for attempt in range(2):
try:
response = await requests.post(url, headers=headers, cookies=request_cookies,
data={"params": params}, timeout=30)
if response.status_code >= 500 and attempt == 0:
await asyncio.sleep(1.0)
continue
response.raise_for_status()
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
raise APIException("上游响应体异常(超过大小上限)")
return response.text
except requests.HTTPError as e:
# 4xx(429/460 风控)不重试——原实现 HTTPError 落入
# 下方 RequestException 分支被重试一次,与 意图相反
raise APIException(f"HTTP请求失败: {e}") from e
except requests.RequestException as e:
last_err = e
if attempt == 0:
await asyncio.sleep(1.0)
continue
raise APIException(f"HTTP请求失败: {last_err}")
@staticmethod
async def post_request_full(url: str, params: str, cookies: dict[str, str]) -> requests.Response:
"""发送POST请求并返回完整响应对象(同 post_request 的有界重试)"""
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER,
}
request_cookies = APIConstants.DEFAULT_COOKIES.copy()
request_cookies.update(cookies)
last_err = None
for attempt in range(2):
try:
response = await requests.post(url, headers=headers, cookies=request_cookies,
data={"params": params}, timeout=30)
if response.status_code >= 500 and attempt == 0:
await asyncio.sleep(1.0)
continue
response.raise_for_status()
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
raise APIException("上游响应体异常(超过大小上限)")
return response
except requests.HTTPError as e:
# 4xx 不重试(同上)
raise APIException(f"HTTP请求失败: {e}") from e
except requests.RequestException as e:
last_err = e
if attempt == 0:
await asyncio.sleep(1.0)
continue
raise APIException(f"HTTP请求失败: {last_err}")
# 上游 JSON 响应体上限——requests 已全量入内存,此处拦截的是
# 异常/被劫持上游的超大 body 进入后续 .json() 解析与传播(音频/封面已有字节预算)
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
+128
View File
@@ -0,0 +1,128 @@
# -*- coding: utf-8 -*-
"""同步兼容函数接口(内部经 run_sync 桥接异步实现)。
(自 music_api.py 拆出,行为与原实现一致。)
"""
from typing import Any
from core.async_bridge import run_sync # 模块级同步兼容接口用的同步/异步桥
from .api import (
NeteaseAPI,
)
from .qr import (
QRLoginManager,
)
# 向后兼容的函数接口
def url_v1(song_id: int, level: str, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌曲URL(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_song_url(song_id, level, cookies))
def name_v1(song_id: int) -> dict[str, Any]:
"""获取歌曲详情(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_song_detail(song_id))
def lyric_v1(song_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌词(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_lyric(song_id, cookies))
def search_music(keywords: str, cookies: dict[str, str], limit: int = 10,
search_type: int = 1, offset: int = 0) -> list[dict[str, Any]]:
"""搜索音乐(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.search_music(keywords, cookies, limit, search_type, offset=offset))
def toplist_list() -> list[dict[str, Any]]:
"""获取官方榜单列表(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_toplist())
def artist_songs(artist_id: int) -> dict[str, Any]:
"""获取歌手信息和热门歌曲(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_artist_hot_songs(artist_id))
def song_comments(song_id: int, limit: int = 20, offset: int = 0) -> dict[str, Any]:
"""获取歌曲评论(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_song_comments(song_id, limit, offset))
def account_info(cookies: dict[str, str]) -> dict[str, Any] | None:
"""获取当前账号信息(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_account_info(cookies))
def user_playlists(user_id: int, cookies: dict[str, str]) -> list[dict[str, Any]]:
"""获取用户收藏/创建的歌单列表(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_user_playlists(user_id, cookies))
def detect_vip_by_probe(cookies: dict[str, str], probe_song_id: int = 347230) -> dict[str, Any]:
"""通过歌曲URL接口探测账号会员类型(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.detect_vip_by_probe(cookies, probe_song_id))
def playlist_subscribe(playlist_id: int, cookies: dict[str, str], action: int = 1) -> dict[str, Any]:
"""收藏/取消收藏歌单(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.subscribe_playlist(playlist_id, cookies, action))
def album_subscribe(album_id: int, cookies: dict[str, str], action: int = 1) -> dict[str, Any]:
"""收藏/取消收藏专辑(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.subscribe_album(album_id, cookies, action))
def post_comment(thread_id: str, content: str, action: int = 1,
comment_id: int | None = None,
cookies: dict[str, str] | None = None) -> dict[str, Any]:
"""发送/回复歌曲评论(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.post_comment(thread_id, content, action, comment_id, cookies))
def probe_levels(cookies: dict[str, str], probe_song_id: int = 347230) -> list[str]:
"""实测账号可解析的音质列表(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.probe_levels(cookies, probe_song_id))
def playlist_detail(playlist_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取歌单详情(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_playlist_detail(playlist_id, cookies))
def album_detail(album_id: int, cookies: dict[str, str]) -> dict[str, Any]:
"""获取专辑详情(向后兼容)"""
api = NeteaseAPI()
return run_sync(api.get_album_detail(album_id, cookies))
def get_pic_url(pic_id: int | None, size: int = 300) -> str:
"""获取图片URL(向后兼容)"""
api = NeteaseAPI()
return api.get_pic_url(pic_id, size)
def qr_login() -> str | None:
"""二维码登录(向后兼容)"""
manager = QRLoginManager()
return run_sync(manager.qr_login())
+60
View File
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""音质枚举与接口常量。
(自 music_api.py 拆出,行为与原实现一致。)
"""
from enum import Enum
class QualityLevel(Enum):
"""音质等级枚举"""
STANDARD = "standard" # 标准音质
EXHIGH = "exhigh" # 极高音质
LOSSLESS = "lossless" # 无损音质
HIRES = "hires" # Hi-Res音质
SKY = "sky" # 沉浸环绕声
JYEFFECT = "jyeffect" # 高清环绕声
JYMASTER = "jymaster" # 超清母带
DOLBY = "dolby" # 杜比全景声
# 常量定义
class APIConstants:
"""API相关常量"""
AES_KEY = b"e82ckenh8dichen8"
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; WOW64) AppleWebKit/537.36 (KHTML, like Gecko) Safari/537.36 Chrome/91.0.4472.164 NeteaseMusicDesktop/2.10.2.200154'
REFERER = 'https://music.163.com/'
# API URLs
SONG_URL_V1 = "https://interface3.music.163.com/eapi/song/enhance/player/url/v1"
SONG_DETAIL_V3 = "https://interface3.music.163.com/api/v3/song/detail"
LYRIC_API = "https://interface3.music.163.com/api/song/lyric"
SEARCH_API = 'https://music.163.com/api/cloudsearch/pc'
PLAYLIST_DETAIL_API = 'https://music.163.com/api/v6/playlist/detail'
ALBUM_DETAIL_API = 'https://music.163.com/api/v1/album/'
TOPLIST_API = 'https://music.163.com/api/toplist'
ARTIST_API = 'https://music.163.com/api/v1/artist/'
COMMENT_API = 'https://music.163.com/api/v1/resource/comments/R_SO_4_'
USER_ACCOUNT_API = 'https://music.163.com/api/nuser/account/get'
USER_PLAYLIST_API = 'https://music.163.com/api/user/playlist'
PLAYLIST_SUBSCRIBE_API = 'https://music.163.com/api/playlist/subscribe'
ALBUM_SUBSCRIBE_API = 'https://music.163.com/api/album/sub'
COMMENT_POST_API = 'https://music.163.com/weapi/comment'
QR_UNIKEY_API = 'https://interface3.music.163.com/eapi/login/qrcode/unikey'
QR_LOGIN_API = 'https://interface3.music.163.com/eapi/login/qrcode/client/login'
# 默认配置
DEFAULT_CONFIG = {
"os": "pc",
"appver": "",
"osver": "",
"deviceId": "pyncm!"
}
DEFAULT_COOKIES = {
"os": "pc",
"appver": "",
"osver": "",
"deviceId": "pyncm!"
}
+60
View File
@@ -0,0 +1,60 @@
# -*- coding: utf-8 -*-
"""Cookie 读取与解析。
(自 music_api.py 拆出,行为与原实现一致。)
"""
import os
# ============ Cookie 工具 ============
DEFAULT_COOKIE_FILE = "cookie.txt"
def load_cookies(path: str = DEFAULT_COOKIE_FILE) -> dict[str, str]:
"""从 cookie.txt 直接读取并解析为 dict
支持标准 ``NMTID=xxx; _ntes_nnid=xxx; ...`` 格式。
文件不存在或解析失败返回空 dict(不抛异常,调用方决定如何处理)。
utf-8-sig 剥记事本 BOM(BOM 会让首键变成 \\ufeffMUSIC_U 静默
丢 Cookie);解析改逐行再按 ``;`` 切(原实现 ';|\\n' 二选一,混排时丢键值对)。
"""
if not os.path.isfile(path):
return {}
try:
with open(path, encoding="utf-8-sig") as f:
content = f.read()
except OSError:
return {}
return parse_cookie_string(content)
def parse_cookie_string(cookie_string: str) -> dict[str, str]:
"""把 ``k1=v1; k2=v2`` 格式字符串解析为 dict
支持 ``;`` 或 ``\\n`` 作为分隔符。
"""
cookies: dict[str, str] = {}
if not cookie_string:
return cookies
cookie_string = cookie_string.strip()
if not cookie_string:
return cookies
# 先逐行再按 ; 切——原实现 ";|\\n" 二选一,混排时行内 \\n
# 会混进 value、后半段键值对丢失
pairs = []
for line in cookie_string.splitlines():
for part in line.split(";"):
part = part.strip()
if part:
pairs.append(part)
for pair in pairs:
pair = pair.strip()
if not pair or "=" not in pair:
continue
key, value = pair.split("=", 1)
key = key.strip()
value = value.strip()
if key and value:
cookies[key] = value
return cookies
+105
View File
@@ -0,0 +1,105 @@
# -*- coding: utf-8 -*-
"""weapi 加解密(AES + RSA)。
(自 music_api.py 拆出,行为与原实现一致。)
"""
import json
import urllib.parse
from hashlib import md5
from typing import Any
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from .constants import (
APIConstants,
)
class CryptoUtils:
"""加密工具类"""
@staticmethod
def hex_digest(data: bytes) -> str:
"""将字节数据转换为十六进制字符串"""
return "".join([hex(d)[2:].zfill(2) for d in data])
@staticmethod
def hash_digest(text: str) -> bytes:
"""计算MD5哈希值"""
return md5(text.encode("utf-8")).digest()
@staticmethod
def hash_hex_digest(text: str) -> str:
"""计算MD5哈希值并转换为十六进制字符串"""
return CryptoUtils.hex_digest(CryptoUtils.hash_digest(text))
@staticmethod
def encrypt_params(url: str, payload: dict[str, Any]) -> str:
"""加密请求参数"""
url_path = urllib.parse.urlparse(url).path.replace("/eapi/", "/api/")
digest = CryptoUtils.hash_hex_digest(f"nobody{url_path}use{json.dumps(payload)}md5forencrypt")
params = f"{url_path}-36cd479b6b5-{json.dumps(payload)}-36cd479b6b5-{digest}"
# AES加密
padder = padding.PKCS7(algorithms.AES(APIConstants.AES_KEY).block_size).padder()
padded_data = padder.update(params.encode()) + padder.finalize()
cipher = Cipher(algorithms.AES(APIConstants.AES_KEY), modes.ECB())
encryptor = cipher.encryptor()
enc = encryptor.update(padded_data) + encryptor.finalize()
return CryptoUtils.hex_digest(enc)
class WeapiCrypto:
"""网易云 weapi 接口加密(AES-CBC 双重加密 + RSA 公钥加密)
用于发送/回复评论等前端写操作接口(/weapi/comment 等)。
"""
AES_KEY = b"0CoJUm6Qyw8W8jud"
AES_IV = b"0102030405060708"
RSA_MODULUS = "00e0b509f6259df8642dbc35662901477df22677ec152b5ff68ace615bb7b725152b3ab17a876aea8a5aa76d2e417629ec4ee341f56135fccf695280104e0312ecbda92557c93870114af6c9d05c4f7f0c3685b7a46bee255932575cce10b424d813cfe4875d3e82047b97ddef52741d546b8e289dc6935b3ece0462db0a22b8e7"
RSA_EXPONENT = "010001"
# 官方随机 secKey 字符集(官方 JS 与主流逆向实现一致)
SEC_KEY_CHARSET = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
@staticmethod
def _aes_encrypt(text: bytes, key: bytes) -> bytes:
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
from cryptography.hazmat.primitives.padding import PKCS7
padder = PKCS7(128).padder()
padded = padder.update(text) + padder.finalize()
cipher = Cipher(algorithms.AES(key), modes.CBC(WeapiCrypto.AES_IV))
encryptor = cipher.encryptor()
return encryptor.update(padded) + encryptor.finalize()
@staticmethod
def encrypt(payload: dict[str, Any]) -> dict[str, str]:
"""将请求体加密为 weapi 的 params/encSecKey。
按官方协议重写——secKey 取官方字符集随机 16 位,
encSecKey = secKey 反转后做 textbook RSA(无填充)的 256 位 hex。
原实现用 os.urandom + PKCS1v15 且未反转,服务端按 textbook RSA 解出的
AES key 与 params 第二层不一致,评论等 weapi 写接口疑似从未成功过。
官方协议第二层加密的输入是第一层密文的
**base64 字符串**(两次 AES 各自输出 base64)——93 条版本漏了中间那次
base64,服务端解 params 第一层得到二进制、b64 解码必失败。"""
import base64
import secrets as _secrets
text = json.dumps(payload).encode("utf-8")
secret_key = ''.join(_secrets.choice(WeapiCrypto.SEC_KEY_CHARSET) for _ in range(16))
first = base64.b64encode(
WeapiCrypto._aes_encrypt(text, WeapiCrypto.AES_KEY)
).decode("utf-8")
params = base64.b64encode(
WeapiCrypto._aes_encrypt(first.encode("utf-8"), secret_key.encode('utf-8'))
).decode("utf-8")
n = int(WeapiCrypto.RSA_MODULUS, 16)
e = int(WeapiCrypto.RSA_EXPONENT, 16)
reversed_hex = secret_key[::-1].encode('utf-8').hex()
enc_sec_key = format(pow(int(reversed_hex, 16), e, n), '0>256x')
return {"params": params, "encSecKey": enc_sec_key}
+11
View File
@@ -0,0 +1,11 @@
# -*- coding: utf-8 -*-
"""API 异常。
(自 music_api.py 拆出,行为与原实现一致。)
"""
class APIException(Exception):
"""API异常类"""
pass
+181
View File
@@ -0,0 +1,181 @@
# -*- coding: utf-8 -*-
"""扫码登录管理器。
(自 music_api.py 拆出,行为与原实现一致。)
"""
import asyncio
import json
from random import randrange
from .client import (
HTTPClient,
_ensure_dict,
)
from .constants import (
APIConstants,
)
from .crypto import (
CryptoUtils,
)
from .errors import (
APIException,
)
class QRLoginManager:
"""二维码登录管理器"""
def __init__(self):
self.http_client = HTTPClient()
self.crypto_utils = CryptoUtils()
async def generate_qr_key(self) -> str | None:
"""生成二维码的key
Returns:
成功返回unikey,失败返回None
Raises:
APIException: API调用失败时抛出
"""
try:
config = APIConstants.DEFAULT_CONFIG.copy()
config["requestId"] = str(randrange(20000000, 30000000))
payload = {
'type': 1,
'header': json.dumps(config)
}
params = self.crypto_utils.encrypt_params(APIConstants.QR_UNIKEY_API, payload)
response = await self.http_client.post_request_full(APIConstants.QR_UNIKEY_API, params, {})
result = json.loads(response.text)
# 非 dict 响应(null/数组)不再 AttributeError 逃出捕获
_ensure_dict(result, '生成二维码key响应异常(非标准 JSON 对象)')
if result.get('code') == 200:
return result.get('unikey')
else:
raise APIException(f"生成二维码key失败: {result.get('message', '未知错误')}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析二维码key响应失败: {e}") from e
async def create_qr_login(self) -> str | None:
"""创建登录二维码并在控制台显示
Returns:
成功返回unikey,失败返回None
"""
try:
import qrcode
unikey = await self.generate_qr_key()
if not unikey:
print("生成二维码key失败")
return None
# 创建二维码
qr = qrcode.QRCode()
qr.add_data(f'https://music.163.com/login?codekey={unikey}')
qr.make(fit=True)
# 在控制台显示二维码
qr.print_ascii(tty=True)
print("\n请使用网易云音乐APP扫描上方二维码登录")
return unikey
except ImportError:
print("请安装qrcode库: pip install qrcode")
return None
except Exception as e:
print(f"创建二维码失败: {e}")
return None
async def check_qr_login(self, unikey: str) -> tuple[int, dict[str, str]]:
"""检查二维码登录状态
Args:
unikey: 二维码key
Returns:
(登录状态码, cookie字典)
Raises:
APIException: API调用失败时抛出
"""
try:
config = APIConstants.DEFAULT_CONFIG.copy()
config["requestId"] = str(randrange(20000000, 30000000))
payload = {
'key': unikey,
'type': 1,
'header': json.dumps(config)
}
params = self.crypto_utils.encrypt_params(APIConstants.QR_LOGIN_API, payload)
response = await self.http_client.post_request_full(APIConstants.QR_LOGIN_API, params, {})
result = json.loads(response.text)
# 非 dict 响应不再 AttributeError 逃出捕获(同 generate_qr_key)
_ensure_dict(result, '解析登录状态响应失败(非标准 JSON 对象)')
cookie_dict = {}
if result.get('code') == 803:
# 登录成功,提取cookie。
# 此前只提取 MUSIC_U——__csrf 从未入 Cookie 串,
# weapi 写接口(发评论等)的 csrf_token 恒为空。改用 requests 的
# cookie jar(requests 已把多条 Set-Cookie 合并解析,不受逗号合并
# 的脆弱 split 影响)补齐 MUSIC_U 与 __csrf。
for name in ('MUSIC_U', '__csrf'):
val = response.cookies.get(name)
if val:
cookie_dict[name] = val
if not cookie_dict.get('MUSIC_U'):
# 兜底:jar 异常为空时回退 Set-Cookie 头解析 MUSIC_U
for cookie_str in response.headers.get('Set-Cookie', '').split(', '):
if 'MUSIC_U=' in cookie_str:
cookie_dict['MUSIC_U'] = cookie_str.split('MUSIC_U=')[1].split(';')[0]
break
return result.get('code', -1), cookie_dict
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析登录状态响应失败: {e}") from e
async def qr_login(self) -> str | None:
"""完整的二维码登录流程
Returns:
成功返回cookie字符串,失败返回None
"""
try:
unikey = await self.create_qr_login()
if not unikey:
return None
while True:
code, cookies = await self.check_qr_login(unikey)
if code == 803:
print("\n登录成功!")
# CLI 序列化须与 qr_login 口径一致,带上 __csrf
# (此串正是写往全局 cookie.txt 的内容)
serialized = f"MUSIC_U={cookies['MUSIC_U']};os=pc;appver=8.9.70;"
if cookies.get('__csrf'):
serialized += f"__csrf={cookies['__csrf']};"
return serialized
elif code == 801:
print("\r等待扫码...", end='')
elif code == 802:
print("\r扫码成功,请在手机上确认登录...", end='')
else:
print(f"\n登录失败,错误码:{code}")
return None
await asyncio.sleep(2)
except KeyboardInterrupt:
print("\n用户取消登录")
return None
except Exception as e:
print(f"\n登录过程中发生错误: {e}")
return None