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.py
T
snowy-yang d209af847a chore: 基线快照 —— 纳入 git 版本控制(站点 v2.4.0 / Flask 单体)
在框架迁移与模块拆分前保存当前可运行状态:
- 后端:main.py/auth_routes.py/auth_system.py/music_api.py/music_downloader.py/podcast_api.py/qr_login.py
- 前端:templates/index.html + static/site_auth.js
- 部署:Dockerfile/docker-compose.yml/entrypoint.sh/requirements.txt
敏感数据(data/、cookie.txt)与内部文档(交接文档/新会话引导)按 .gitignore 排除。
2026-09-26 00:13:05 +08:00

1469 lines
59 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模块
提供网易云音乐相关API接口的封装,包括:
- 音乐URL获取
- 歌曲详情获取
- 歌词获取
- 搜索功能
- 歌单和专辑详情
- 二维码登录
"""
import json
import os
import urllib.parse
import time
from random import randrange
from typing import Dict, List, Optional, Tuple, Any
from hashlib import md5
from enum import Enum
import requests
from cryptography.hazmat.primitives import padding
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
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!"
}
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"
# 第 91 条(P2-5):官方随机 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。
第 91 条(P2-5):按官方协议重写——secKey 取官方字符集随机 16 位,
encSecKey = secKey 反转后做 textbook RSA(无填充)的 256 位 hex。
原实现用 os.urandom + PKCS1v15 且未反转,服务端按 textbook RSA 解出的
AES key 与 params 第二层不一致,评论等 weapi 写接口疑似从未成功过。
第 94 条(P1-1,复审实锤):官方协议第二层加密的输入是第一层密文的
**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}
def _ensure_dict(result: Any, msg: str) -> Dict[str, Any]:
"""第 91 条(P2-8):上游 200 但返回 null/数组等非 dict 体(风控/抖动)时统一前置校验"""
if isinstance(result, dict):
return result
raise APIException(msg)
class HTTPClient:
"""HTTP客户端类"""
@staticmethod
def post_request(url: str, params: str, cookies: Dict[str, str]) -> str:
"""发送POST请求并返回文本响应。
R5-20:网络异常/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 = requests.post(url, headers=headers, cookies=request_cookies,
data={"params": params}, timeout=30)
if response.status_code >= 500 and attempt == 0:
time.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:
# 第 91 条(P2-6):4xx(429/460 风控)不重试——原实现 HTTPError 落入
# 下方 RequestException 分支被重试一次,与 R5-20 意图相反
raise APIException(f"HTTP请求失败: {e}")
except requests.RequestException as e:
last_err = e
if attempt == 0:
time.sleep(1.0)
continue
raise APIException(f"HTTP请求失败: {last_err}")
@staticmethod
def post_request_full(url: str, params: str, cookies: Dict[str, str]) -> requests.Response:
"""发送POST请求并返回完整响应对象(R5-20:同 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 = requests.post(url, headers=headers, cookies=request_cookies,
data={"params": params}, timeout=30)
if response.status_code >= 500 and attempt == 0:
time.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:
# 第 91 条(P2-6):4xx 不重试(同上)
raise APIException(f"HTTP请求失败: {e}")
except requests.RequestException as e:
last_err = e
if attempt == 0:
time.sleep(1.0)
continue
raise APIException(f"HTTP请求失败: {last_err}")
# 第 97 条(P3):上游 JSON 响应体上限——requests 已全量入内存,此处拦截的是
# 异常/被劫持上游的超大 body 进入后续 .json() 解析与传播(音频/封面已有字节预算)
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
class APIException(Exception):
"""API异常类"""
pass
class NeteaseAPI:
"""网易云音乐API主类"""
def __init__(self):
self.http_client = HTTPClient()
self.crypto_utils = CryptoUtils()
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 = self.http_client.post_request(APIConstants.SONG_URL_V1, params, cookies)
result = json.loads(response_text)
# R5-19:合法 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}")
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}])}
# R5-18:补齐 UA/Referer——本方法是全站唯一裸 UA 请求,风控/460 单点偏高
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = requests.post(APIConstants.SONG_DETAIL_V3, data=data,
headers=headers, timeout=30)
response.raise_for_status()
result = response.json()
# R5-19:合法 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}")
except json.JSONDecodeError as e:
raise APIException(f"解析歌曲详情响应失败: {e}")
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 = 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}")
except json.JSONDecodeError as e:
raise APIException(f"解析歌词响应失败: {e}")
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: 分页偏移(F45:透传上游,翻页生效)
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 = requests.post(APIConstants.SEARCH_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
# R5-P3:非 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:
# 歌曲(P2-12: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:
# 专辑(P2-12 同上)
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:
# 歌手(P2-12 同上)
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:
# 歌单(P2-12 同上)
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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析搜索响应失败: {e}")
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 = requests.post(APIConstants.PLAYLIST_DETAIL_API, data=data,
headers=headers, cookies=cookies, timeout=30)
response.raise_for_status()
result = response.json()
# R5-P2:非 dict 响应(null/数组)给明确错误
if not isinstance(result, dict):
raise APIException("获取歌单详情失败: 响应格式异常")
if result.get('code') != 200:
raise APIException(f"获取歌单详情失败: {result.get('message', '未知错误')}")
# R5-P2:上游对不存在/私密/无权限歌单返回 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并分批获取详细信息(F72:明细批次的业务 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]
# P3-18:trackIds 混入非数字脏 ID 时跳过该条(原实现 int() 抛 ValueError
# 不在捕获列表,整歌单 500);P2-12:明细缺 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 = 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):
# 第 91 条(P2-8):上游 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 ''
})
# F72: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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析歌单详情响应失败: {e}")
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 = 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'): # P2-12:残缺条目跳过
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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析专辑详情响应失败: {e}")
def get_toplist(self) -> List[Dict[str, Any]]:
"""获取网易云官方榜单列表
Returns:
榜单列表信息
Raises:
APIException: API调用失败时抛出
"""
try:
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = 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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析榜单响应失败: {e}")
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 = 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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析歌手信息响应失败: {e}")
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 = 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 = user.get('vipRights') if isinstance(user.get('vipRights'), dict) else {}
associator = vip_rights.get('associator') if isinstance(vip_rights.get('associator'), dict) else {}
location_info = user.get('locationInfo') if isinstance(user.get('locationInfo'), dict) else {}
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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析评论响应失败: {e}")
def get_account_info(self, cookies: Dict[str, str]) -> Optional[Dict[str, Any]]:
"""获取当前账号信息(用于个人登录状态展示)
Args:
cookies: 用户cookies
Returns:
账号信息字典,获取失败返回 None
"""
try:
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER
}
response = 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
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调用失败时抛出
F63:超过 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 = 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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析用户歌单响应失败: {e}")
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 = 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}
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 = []
# 第 100 条:探测总预算 60s——上游黑洞时 8 级串行最坏 ~16 分钟线程滞留,
# 超预算即返回已测得的结果(调用方 5 分钟缓存内复用)
_deadline = time.monotonic() + 60
for level in ladder:
if time.monotonic() > _deadline:
break
try:
result = 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
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 = 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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析收藏歌单响应失败: {e}")
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 = 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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析收藏专辑响应失败: {e}")
def post_comment(self, thread_id: str, content: str, action: int = 1,
comment_id: Optional[int] = None,
cookies: Dict[str, str] = 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
# 第 91 条(P2-5):官方 /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 = 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}")
except (json.JSONDecodeError, KeyError) as e:
raise APIException(f"解析评论响应失败: {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: Optional[int], 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}'
class QRLoginManager:
"""二维码登录管理器"""
def __init__(self):
self.http_client = HTTPClient()
self.crypto_utils = CryptoUtils()
def generate_qr_key(self) -> Optional[str]:
"""生成二维码的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 = self.http_client.post_request_full(APIConstants.QR_UNIKEY_API, params, {})
result = json.loads(response.text)
# 第 97 条(P3):非 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}")
def create_qr_login(self) -> Optional[str]:
"""创建登录二维码并在控制台显示
Returns:
成功返回unikey,失败返回None
"""
try:
import qrcode
unikey = 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
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 = self.http_client.post_request_full(APIConstants.QR_LOGIN_API, params, {})
result = json.loads(response.text)
# 第 97 条(P3):非 dict 响应不再 AttributeError 逃出捕获(同 generate_qr_key)
_ensure_dict(result, '解析登录状态响应失败(非标准 JSON 对象)')
cookie_dict = {}
if result.get('code') == 803:
# 登录成功,提取cookie。
# 第 94 条(P1-2):此前只提取 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}")
def qr_login(self) -> Optional[str]:
"""完整的二维码登录流程
Returns:
成功返回cookie字符串,失败返回None
"""
try:
unikey = self.create_qr_login()
if not unikey:
return None
while True:
code, cookies = self.check_qr_login(unikey)
if code == 803:
print("\n登录成功!")
# 第 97 条(P2-8):CLI 序列化同 qr_login 口径带上 __csrf(94 条只修
# 了 web 路径;此串正是写往全局 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
time.sleep(2)
except KeyboardInterrupt:
print("\n用户取消登录")
return None
except Exception as e:
print(f"\n登录过程中发生错误: {e}")
return None
# 向后兼容的函数接口
def url_v1(song_id: int, level: str, cookies: Dict[str, str]) -> Dict[str, Any]:
"""获取歌曲URL(向后兼容)"""
api = NeteaseAPI()
return api.get_song_url(song_id, level, cookies)
def name_v1(song_id: int) -> Dict[str, Any]:
"""获取歌曲详情(向后兼容)"""
api = NeteaseAPI()
return api.get_song_detail(song_id)
def lyric_v1(song_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
"""获取歌词(向后兼容)"""
api = NeteaseAPI()
return 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 api.search_music(keywords, cookies, limit, search_type, offset=offset)
def toplist_list() -> List[Dict[str, Any]]:
"""获取官方榜单列表(向后兼容)"""
api = NeteaseAPI()
return api.get_toplist()
def artist_songs(artist_id: int) -> Dict[str, Any]:
"""获取歌手信息和热门歌曲(向后兼容)"""
api = NeteaseAPI()
return 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 api.get_song_comments(song_id, limit, offset)
def account_info(cookies: Dict[str, str]) -> Optional[Dict[str, Any]]:
"""获取当前账号信息(向后兼容)"""
api = NeteaseAPI()
return api.get_account_info(cookies)
def user_playlists(user_id: int, cookies: Dict[str, str]) -> List[Dict[str, Any]]:
"""获取用户收藏/创建的歌单列表(向后兼容)"""
api = NeteaseAPI()
return 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 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 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 api.subscribe_album(album_id, cookies, action)
def post_comment(thread_id: str, content: str, action: int = 1,
comment_id: Optional[int] = None,
cookies: Dict[str, str] = None) -> Dict[str, Any]:
"""发送/回复歌曲评论(向后兼容)"""
api = NeteaseAPI()
return 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 api.probe_levels(cookies, probe_song_id)
def playlist_detail(playlist_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
"""获取歌单详情(向后兼容)"""
api = NeteaseAPI()
return api.get_playlist_detail(playlist_id, cookies)
def album_detail(album_id: int, cookies: Dict[str, str]) -> Dict[str, Any]:
"""获取专辑详情(向后兼容)"""
api = NeteaseAPI()
return api.get_album_detail(album_id, cookies)
def get_pic_url(pic_id: Optional[int], size: int = 300) -> str:
"""获取图片URL(向后兼容)"""
api = NeteaseAPI()
return api.get_pic_url(pic_id, size)
def qr_login() -> Optional[str]:
"""二维码登录(向后兼容)"""
manager = QRLoginManager()
return manager.qr_login()
# ============ 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(不抛异常,调用方决定如何处理)。
第 94 条(P3):utf-8-sig 剥记事本 BOM(BOM 会让首键变成 \\ufeffMUSIC_U 静默
丢 Cookie);解析改逐行再按 ``;`` 切(原实现 ';|\\n' 二选一,混排时丢键值对)。
"""
if not os.path.isfile(path):
return {}
try:
with open(path, "r", 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
# 第 94 条(P3):先逐行再按 ; 切——原实现 ";|\\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
if __name__ == "__main__":
# 测试代码
print("网易云音乐API模块")
print("支持的功能:")
print("- 歌曲URL获取")
print("- 歌曲详情获取")
print("- 歌词获取")
print("- 音乐搜索")
print("- 歌单详情")
print("- 专辑详情")
print("- 二维码登录")