Archived
refactor: 两个根级游离模块并入 music_api 包,消除分层倒置
为什么:podcast_api.py(560 行) 与 qr_login.py(397 行) 既不属于任何包、也不是 入口脚本,却同时被 services 层与 web 层引用——`services/downloads.py` 直接 `from qr_login import ...`、`services/podcast.py` 直接 `import podcast_api`, 业务层反向依赖顶层游离模块,是本次架构体检里唯一的无争议分层倒置。 两者本就依赖 music_api(前者取 url_v1/APIConstants/APIException,后者取 QRLoginManager),与 music_api 下已有的 qr.py / compat.py 是同一层的同类物。 迁移(git 保留重命名历史): - podcast_api.py -> music_api/podcast.py - qr_login.py -> music_api/qr_client.py **命名避让**:不叫 music_api/qr_login.py——music_api/__init__ 已经从 .compat 导出了一个**同名函数** qr_login,同名子模块会与之互相遮蔽。改叫 qr_client (其核心导出即 QRLoginClient),并在验证里专门确认 music_api.qr_login 仍是 可调用函数、music_api.podcast 是模块,两者不冲突。 内部导入顺带改为精确子模块,不再绕道包门面: - podcast.py:from music_api import APIConstants/APIException/url_v1 → from .compat import url_v1 / from .constants import ... / from .errors import ... - qr_client.py:删掉那层 try/except ImportError 包装(它给的是「请确保 music_api.py 文件存在」这类顶层脚本时代的提示,作为包内模块已无意义), 改 from .cookies/.errors/.qr 相对导入 - CLI 用法文本 python qr_login.py → python -m music_api.qr_client (相对导入后直接跑脚本会失败,必须走 -m;全仓确认无脚本/文档引用旧入口) - 路径安全性已核对:qr_client 用的是 CWD 相对的 cookie.txt,不是 __file__ 相对, 迁移不改其解析结果 引用点全部更新(AST 扫描确认覆盖全集,替换后残留 0): - podcast:services/podcast.py(1 + 5 处调用) · web/routers/podcast.py(1 + 6) · web/routers/download.py(2 处函数内 + 3),导入名统一为 `podcast` - qr_login:services/downloads.py · web/routers/admin.py · web/routers/user.py(×2) - services/__init__.py 依赖自检列表去掉这两个名字(已并入 music_api) - pyproject include 去掉两个顶层文件名(music_api 目录已覆盖) 验证: - 11 + 3 个符号在新位置全部 hasattr 通过;podcast_api / qr_login 已 ModuleNotFoundError;6 个调用方模块均可 import;services 依赖自检仍生效 - 门禁:ruff 0 · pyright 0 errors/0 warnings · pytest 184 passed - 真实 uvicorn 冒烟 10/10,含 /podcast/parse 与 /podcast/hot 返回 401 (模块导入失败会是 500,路由缺失会是 404,401 证明两者都正常) - 生产 data/ 指纹 before == after
This commit is contained in:
1 parent
1d86ec87ec
commit
095171e0b7
13 files changed
+37
-41
No files matched your search
@@ -0,0 +1,563 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""网易云音乐播客(电台 / 单集声音)API 模块
|
||||
|
||||
提供网易云"播客"板块相关接口封装,包括:
|
||||
- 播客搜索(电台 / 单集声音)
|
||||
- 播客电台详情与单集列表(分页)
|
||||
- 单集(program)详情
|
||||
- 热门播客推荐
|
||||
- 单集音频直链解析(音质自动降级)
|
||||
|
||||
说明:
|
||||
- 播客单集的音频本质上是网易云曲库中的 song(program.mainSong),
|
||||
因此直链复用 music_api.url_v1(eapi song/enhance/player/url/v1)获取。
|
||||
- 支持格式:mp3 / m4a / aac 等(以接口返回 type 为准)。
|
||||
- 支持链接协议:music.163.com/djradio?id=(电台)、/program?id= 与 /sound?id=(单集)、
|
||||
以及 163cn.tv 短链。
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
from core import sync_http as requests # 同步门面(底层 aiohttp)
|
||||
|
||||
from .compat import url_v1
|
||||
from .constants import APIConstants
|
||||
from .errors import APIException
|
||||
|
||||
|
||||
class PodcastConstants:
|
||||
"""播客接口相关常量"""
|
||||
|
||||
# 搜索(cloudsearch:type=1009 播客电台 / 2000 单集声音)
|
||||
SEARCH_API = 'https://music.163.com/api/cloudsearch/pc'
|
||||
# 电台(播客)详情
|
||||
DJRADIO_GET_API = 'https://music.163.com/api/djradio/get'
|
||||
# 电台下的单集列表(分页)
|
||||
DJ_PROGRAM_BYRADIO_API = 'https://music.163.com/api/dj/program/byradio'
|
||||
# 单集详情
|
||||
DJ_PROGRAM_DETAIL_API = 'https://music.163.com/api/dj/program/detail'
|
||||
# 热门播客(必须携带有效 cateId)
|
||||
DJRADIO_HOT_API = 'https://music.163.com/api/djradio/hot'
|
||||
# 播客分类列表
|
||||
DJRADIO_CATEGORY_API = 'https://music.163.com/api/djradio/category/get'
|
||||
|
||||
# 播客音频常用音质降级阶梯(无损对播客基本无意义)
|
||||
QUALITY_LADDER = ['exhigh', 'standard']
|
||||
|
||||
# 热门接口的兜底分类ID(生活 / 情感 / 脱口秀 / 有声书)
|
||||
DEFAULT_HOT_CATE_IDS = [6, 3, 8, 10001]
|
||||
|
||||
|
||||
# 与 music_api 同口径的上游 JSON 响应体上限
|
||||
_MAX_UPSTREAM_JSON_BYTES = 8 * 1024 * 1024
|
||||
|
||||
|
||||
def _post_json(url: str, data: dict[str, Any], cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""发送表单 POST 请求并返回 JSON"""
|
||||
headers = {
|
||||
'User-Agent': APIConstants.USER_AGENT,
|
||||
'Referer': APIConstants.REFERER,
|
||||
}
|
||||
try:
|
||||
response = requests.post(url, data=data, headers=headers,
|
||||
cookies=cookies or {}, timeout=30)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||||
raise APIException('播客接口响应体异常(超过大小上限)')
|
||||
result = response.json()
|
||||
if not isinstance(result, dict):
|
||||
raise APIException('播客接口响应异常(非标准 JSON 对象)')
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"播客接口请求失败: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析播客接口响应失败: {e}") from e
|
||||
if result.get('code') not in (200, None):
|
||||
raise APIException(f"播客接口返回错误: {result.get('message') or result.get('code')}")
|
||||
return result
|
||||
|
||||
|
||||
def _to_int(v: Any, default: int | None = None) -> int | None:
|
||||
"""安全转 int:垃圾值/None 回退默认值(上游字段常为 null/字符串)。"""
|
||||
try:
|
||||
return int(v)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _ts_to_date(ts: int | None) -> str:
|
||||
"""毫秒时间戳 → 日期字符串"""
|
||||
try:
|
||||
if not ts:
|
||||
return ''
|
||||
return time.strftime('%Y-%m-%d', time.localtime(int(ts) / 1000))
|
||||
except (TypeError, ValueError, OSError, OverflowError):
|
||||
return ''
|
||||
|
||||
|
||||
def _fmt_duration(ms: int | None) -> str:
|
||||
"""毫秒 → mm:ss / hh:mm:ss"""
|
||||
try:
|
||||
sec = int((ms or 0) / 1000)
|
||||
except (TypeError, ValueError):
|
||||
return ''
|
||||
if sec <= 0:
|
||||
return ''
|
||||
h, rem = divmod(sec, 3600)
|
||||
m, s = divmod(rem, 60)
|
||||
return f"{h}:{m:02d}:{s:02d}" if h else f"{m:02d}:{s:02d}"
|
||||
|
||||
|
||||
def _fmt_count(n: int | None) -> str:
|
||||
"""数量 → 万单位简写"""
|
||||
try:
|
||||
n = int(n or 0)
|
||||
except (TypeError, ValueError):
|
||||
return '0'
|
||||
if n >= 100000000:
|
||||
v = n / 100000000
|
||||
return f"{v:.1f}亿".replace('.0', '')
|
||||
if n >= 10000:
|
||||
v = n / 10000
|
||||
return f"{v:.1f}万".replace('.0', '')
|
||||
return str(n)
|
||||
|
||||
|
||||
# ============ 数据规范化 ============
|
||||
|
||||
def _norm_radio(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""规范化电台(播客)信息"""
|
||||
dj = raw.get('dj') or {}
|
||||
return {
|
||||
'id': raw.get('id'),
|
||||
'name': raw.get('name', ''),
|
||||
'pic_url': raw.get('picUrl', ''),
|
||||
'desc': str(raw.get('desc') or raw.get('description') or '').strip(),
|
||||
'category': raw.get('category', ''),
|
||||
'sub_count': raw.get('subCount', 0),
|
||||
'sub_count_text': _fmt_count(raw.get('subCount')),
|
||||
'program_count': raw.get('programCount', 0),
|
||||
'play_count': raw.get('playCount', 0),
|
||||
'play_count_text': _fmt_count(raw.get('playCount')),
|
||||
'dj_name': dj.get('nickname', ''),
|
||||
'dj_avatar': dj.get('avatarUrl', ''),
|
||||
'create_date': _ts_to_date(raw.get('createTime')),
|
||||
'second_category': raw.get('secondCategory', ''),
|
||||
}
|
||||
|
||||
|
||||
def _norm_program(raw: dict[str, Any]) -> dict[str, Any]:
|
||||
"""规范化单集(program)信息"""
|
||||
radio = raw.get('radio') or {}
|
||||
dj = raw.get('dj') or radio.get('dj') or {}
|
||||
main_song = raw.get('mainSong') or raw.get('mainMusic') or {}
|
||||
return {
|
||||
'id': raw.get('id'),
|
||||
'name': raw.get('name', ''),
|
||||
'cover_url': raw.get('coverUrl') or raw.get('cover') or radio.get('picUrl', ''),
|
||||
'description': str(raw.get('description') or raw.get('desc') or '').strip(),
|
||||
'duration': raw.get('duration', 0),
|
||||
'duration_text': _fmt_duration(raw.get('duration') or main_song.get('duration')),
|
||||
'create_date': _ts_to_date(raw.get('createTime')),
|
||||
'serial_num': raw.get('serialNum', 0),
|
||||
'listener_count': raw.get('listenerCount', 0),
|
||||
'listener_count_text': _fmt_count(raw.get('listenerCount')),
|
||||
'liked_count': raw.get('likedCount', 0),
|
||||
'comment_count': raw.get('commentCount', 0),
|
||||
'main_song_id': main_song.get('id'),
|
||||
'main_song_name': main_song.get('name', ''),
|
||||
'radio_id': radio.get('id'),
|
||||
'radio_name': radio.get('name', ''),
|
||||
'dj_name': dj.get('nickname', ''),
|
||||
'category': raw.get('category') or radio.get('category', ''),
|
||||
}
|
||||
|
||||
|
||||
# ============ 链接 / ID 解析 ============
|
||||
|
||||
_SHORTLINK_CACHE: dict[str, str] = {}
|
||||
|
||||
|
||||
def _resolve_short_link(url: str) -> str:
|
||||
"""解析 163cn.tv 短链,返回跳转后的真实链接(仅接受整串短链,子串不触发请求)"""
|
||||
if not re.match(r'^https?://(www\.)?163cn\.tv/\S+$', str(url or '').strip(), re.IGNORECASE):
|
||||
return url
|
||||
# 带浏览器 UA(短链 CDN 对默认 UA 区别对待)+ 模块级缓存
|
||||
#(此前仅音乐路径有缓存,播客同一分享链接每次解析都发真实 GET)
|
||||
global _SHORTLINK_CACHE
|
||||
cached = _SHORTLINK_CACHE.get(url)
|
||||
if cached is not None:
|
||||
return cached
|
||||
resp = None
|
||||
try:
|
||||
resp = requests.get(url, allow_redirects=False, timeout=10,
|
||||
headers={'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'})
|
||||
real = resp.headers.get('Location', url)
|
||||
except requests.RequestException:
|
||||
return url
|
||||
finally:
|
||||
if resp is not None:
|
||||
resp.close()
|
||||
# 短链展开口径——展开失败(200 无 Location/5xx,
|
||||
# real==url)不写缓存,一次 CDN 抖动不再让该链接整个缓存存续期必然失败
|
||||
if real == url:
|
||||
return real
|
||||
if len(_SHORTLINK_CACHE) > 500:
|
||||
_SHORTLINK_CACHE.clear()
|
||||
_SHORTLINK_CACHE[url] = real
|
||||
return real
|
||||
|
||||
|
||||
def extract_podcast_ref(raw: str) -> dict[str, str]:
|
||||
"""从用户输入中提取播客引用(正规 URL 解析,替代子串匹配与非数字剔除)
|
||||
|
||||
Returns:
|
||||
{'kind': 'radio'|'program'|'unknown', 'id': '...'}
|
||||
- radio: djradio?id= / radio?id= 链接
|
||||
- program: program?id= / sound?id= 链接
|
||||
- unknown: 纯数字 ID(需依次尝试单集/电台)
|
||||
"""
|
||||
from urllib.parse import parse_qs, urlsplit
|
||||
text = str(raw or '').strip()
|
||||
# 短链先还原(仅当整串就是一个短链 URL)
|
||||
if '163cn.tv' in text.lower():
|
||||
text = _resolve_short_link(text)
|
||||
|
||||
# 有 URL 结构:按路径判断类型,query 正规解析 id(参数顺序无关)
|
||||
if '://' in text or text.startswith('//'):
|
||||
try:
|
||||
parts = urlsplit(text if '://' in text else 'https:' + text)
|
||||
q = parse_qs(parts.query)
|
||||
# 兼容老式 #/program?id= 片段路由
|
||||
if not q.get('id'):
|
||||
m = re.search(r'[?&#]id=(\d+)', text)
|
||||
if m:
|
||||
q = {'id': [m.group(1)]}
|
||||
pid = (q.get('id') or [''])[0]
|
||||
if pid.isdigit():
|
||||
path_l = parts.path.lower()
|
||||
if 'djradio' in path_l or 'radio' in path_l:
|
||||
return {'kind': 'radio', 'id': pid}
|
||||
if 'program' in path_l or 'sound' in path_l or 'song' in path_l:
|
||||
return {'kind': 'program', 'id': pid}
|
||||
# 路径无类型线索(如 y2027.d.popchan.cn 之类播客域名):按 unknown 走探测
|
||||
return {'kind': 'unknown', 'id': pid}
|
||||
except ValueError:
|
||||
pass
|
||||
raise APIException('无法识别的播客链接,请粘贴电台/单集链接或直接输入纯数字 ID')
|
||||
|
||||
# 纯数字 ID
|
||||
if text.isdigit():
|
||||
return {'kind': 'unknown', 'id': text}
|
||||
raise APIException('无法识别的播客链接或ID,支持 电台链接(djradio?id=)、单集链接(program?id=) 或纯数字ID')
|
||||
|
||||
|
||||
# ============ 核心 API ============
|
||||
|
||||
def search_podcast(keywords: str, cookies: dict[str, str] | None = None,
|
||||
limit: int = 20, offset: int = 0,
|
||||
search_type: str = 'all') -> dict[str, Any]:
|
||||
"""搜索播客
|
||||
|
||||
Args:
|
||||
keywords: 关键词
|
||||
cookies: 请求 Cookie(可选)
|
||||
limit: 每类返回数量(1-50)
|
||||
offset: 偏移量
|
||||
search_type: all=电台+单集 / radio=仅电台 / voice=仅单集
|
||||
|
||||
Returns:
|
||||
{'radios': [...], 'voices': [...]}
|
||||
"""
|
||||
keywords = str(keywords or '').strip()
|
||||
if not keywords:
|
||||
raise APIException('搜索关键词不能为空')
|
||||
limit = max(1, min(int(limit or 20), 50))
|
||||
|
||||
result: dict[str, Any] = {'radios': [], 'voices': []}
|
||||
|
||||
def _do_search(stype: int) -> dict[str, Any]:
|
||||
return _post_json(PodcastConstants.SEARCH_API,
|
||||
{'s': keywords, 'type': stype, 'limit': limit, 'offset': offset},
|
||||
cookies)
|
||||
|
||||
if search_type in ('all', 'radio'):
|
||||
res = _do_search(1009)
|
||||
radios = (res.get('result') or {}).get('djRadios') or []
|
||||
result['radios'] = [_norm_radio(r) for r in radios]
|
||||
|
||||
if search_type in ('all', 'voice'):
|
||||
res = _do_search(2000)
|
||||
rdata = res.get('result') or {}
|
||||
voices = rdata.get('songs') or rdata.get('voices') or rdata.get('programs') or []
|
||||
result['voices'] = [_norm_program(v) for v in voices]
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def get_radio_detail(radio_id: Any, cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""获取播客电台(djradio)详情"""
|
||||
try:
|
||||
rid = int(str(radio_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的电台ID') from None
|
||||
result = _post_json(PodcastConstants.DJRADIO_GET_API, {'id': rid}, cookies)
|
||||
# 正常返回键为 djRadio;不存在时 code=404(_post_json 已抛错)或 data 为空
|
||||
data = result.get('djRadio') or result.get('data') or {}
|
||||
if not data:
|
||||
raise APIException('未找到该播客电台(可能已下架或ID错误)')
|
||||
return _norm_radio(data)
|
||||
|
||||
|
||||
def get_radio_programs(radio_id: Any, cookies: dict[str, str] | None = None,
|
||||
limit: int = 30, offset: int = 0, asc: bool = True) -> dict[str, Any]:
|
||||
"""获取电台下的单集列表(分页)
|
||||
|
||||
Args:
|
||||
radio_id: 电台ID
|
||||
limit: 每页数量(1-100)
|
||||
offset: 偏移量
|
||||
asc: True=从第1集开始(正序),False=最新在前
|
||||
|
||||
Returns:
|
||||
{'programs': [...], 'count': 总数, 'has_more': bool}
|
||||
"""
|
||||
try:
|
||||
rid = int(str(radio_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的电台ID') from None
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
result = _post_json(PodcastConstants.DJ_PROGRAM_BYRADIO_API,
|
||||
{'radioId': rid, 'limit': limit, 'offset': max(0, int(offset or 0)),
|
||||
'asc': 'true' if asc else 'false'},
|
||||
cookies)
|
||||
programs = [_norm_program(p) for p in (result.get('programs') or [])]
|
||||
count = _to_int(result.get('count'))
|
||||
offset_val = max(0, int(offset or 0))
|
||||
return {
|
||||
'programs': programs,
|
||||
'count': count,
|
||||
'has_more': (offset_val + len(programs)) < count if count else len(programs) >= limit,
|
||||
}
|
||||
|
||||
|
||||
def get_program_detail(program_id: Any, cookies: dict[str, str] | None = None) -> dict[str, Any]:
|
||||
"""获取单集(program)详情"""
|
||||
try:
|
||||
pid = int(str(program_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
raise APIException('无效的单集ID') from None
|
||||
result = _post_json(PodcastConstants.DJ_PROGRAM_DETAIL_API, {'id': pid}, cookies)
|
||||
program = result.get('program') or {}
|
||||
if not program:
|
||||
raise APIException('未找到该播客单集(可能已下架或ID错误)')
|
||||
return _norm_program(program)
|
||||
|
||||
|
||||
def get_categories() -> list[dict[str, Any]]:
|
||||
"""获取播客分类列表(GET 接口)
|
||||
|
||||
Returns:
|
||||
[{'id': 6, 'name': '生活'}, ...]
|
||||
"""
|
||||
headers = {
|
||||
'User-Agent': APIConstants.USER_AGENT,
|
||||
'Referer': APIConstants.REFERER,
|
||||
}
|
||||
try:
|
||||
response = requests.get(PodcastConstants.DJRADIO_CATEGORY_API,
|
||||
headers=headers, timeout=30)
|
||||
response.raise_for_status()
|
||||
if len(response.content) > _MAX_UPSTREAM_JSON_BYTES:
|
||||
raise APIException('播客接口响应体异常(超过大小上限)')
|
||||
result = response.json()
|
||||
except requests.RequestException as e:
|
||||
raise APIException(f"获取播客分类失败: {e}") from e
|
||||
except json.JSONDecodeError as e:
|
||||
raise APIException(f"解析播客分类响应失败: {e}") from e
|
||||
if not isinstance(result, dict):
|
||||
# 非 dict 响应(null/数组)显式报错,不再 AttributeError
|
||||
raise APIException('解析播客分类响应失败(非标准 JSON 对象)')
|
||||
cats = []
|
||||
for c in (result.get('categories') or []):
|
||||
if not isinstance(c, dict):
|
||||
continue
|
||||
try:
|
||||
cid = int(c.get('id') or 0)
|
||||
except (TypeError, ValueError):
|
||||
continue # 垃圾 id 跳过该条,不再拖死整页分类
|
||||
name = c.get('name')
|
||||
if cid and name:
|
||||
cats.append({'id': cid, 'name': str(name)})
|
||||
if not cats:
|
||||
raise APIException('播客分类列表为空')
|
||||
return cats
|
||||
|
||||
|
||||
def get_hot_radios(cookies: dict[str, str] | None = None,
|
||||
limit: int = 30, offset: int = 0,
|
||||
cate_id: int | None = None) -> list[dict[str, Any]]:
|
||||
"""获取分类下热门播客
|
||||
|
||||
Args:
|
||||
cate_id: 播客分类ID;用户明确指定分类时只查该分类
|
||||
(即便为空也不悄悄混入其他分类的内容),未指定时按默认分类依次尝试
|
||||
|
||||
Returns:
|
||||
电台列表(已规范化)
|
||||
"""
|
||||
limit = max(1, min(int(limit or 30), 100))
|
||||
cate_ids: list[int] = []
|
||||
user_cate = False
|
||||
if cate_id:
|
||||
try:
|
||||
cate_ids.append(int(cate_id))
|
||||
user_cate = True
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if not user_cate:
|
||||
for cid in PodcastConstants.DEFAULT_HOT_CATE_IDS:
|
||||
if cid not in cate_ids:
|
||||
cate_ids.append(cid)
|
||||
|
||||
for cid in cate_ids:
|
||||
result = _post_json(PodcastConstants.DJRADIO_HOT_API,
|
||||
{'limit': limit, 'offset': max(0, int(offset or 0)), 'cateId': cid},
|
||||
cookies)
|
||||
radios = [_norm_radio(r) for r in (result.get('djRadios') or [])]
|
||||
if radios:
|
||||
return radios
|
||||
if user_cate:
|
||||
break # 指定分类为空就返回空,不注入默认分类内容
|
||||
return []
|
||||
|
||||
|
||||
def resolve_episode_url(song_id: Any, cookies: dict[str, str] | None = None,
|
||||
level: str = 'auto') -> dict[str, Any] | None:
|
||||
"""解析单集音频直链(带音质降级)
|
||||
|
||||
Args:
|
||||
song_id: 单集 mainSong 的歌曲ID
|
||||
cookies: 全局账号 Cookie
|
||||
level: 期望音质(auto=按阶梯自动尝试)
|
||||
|
||||
Returns:
|
||||
{'url','level','type','size','br','md5'} 或 None(全部失败)
|
||||
"""
|
||||
# 降级阶梯只向下不向上——原实现指定 standard 会回退到更高的 exhigh
|
||||
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
|
||||
order: list[str] = []
|
||||
level = str(level or 'auto').strip()
|
||||
if level and level != 'auto':
|
||||
order.append(level)
|
||||
# 追加"低于"请求音质的等级(hires > lossless > exhigh > standard)
|
||||
if level in _FALLDOWN_LADDER:
|
||||
idx = _FALLDOWN_LADDER.index(level)
|
||||
for lv in _FALLDOWN_LADDER[idx + 1:]:
|
||||
order.append(lv)
|
||||
else:
|
||||
# 未知等级:退到通用阶梯
|
||||
order.extend(PodcastConstants.QUALITY_LADDER)
|
||||
else:
|
||||
order.extend(PodcastConstants.QUALITY_LADDER)
|
||||
# 去重保序
|
||||
dedup: list[str] = []
|
||||
for lv in order:
|
||||
if lv not in dedup:
|
||||
dedup.append(lv)
|
||||
order = dedup
|
||||
|
||||
for lv in order:
|
||||
try:
|
||||
# song_id 可能是 None/垃圾值(防御:int 失败按无音频处理,不再 TypeError 500)
|
||||
sid = int(str(song_id).strip())
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
try:
|
||||
res = url_v1(sid, lv, cookies or {})
|
||||
except (APIException, ValueError):
|
||||
continue
|
||||
data = (res or {}).get('data') or []
|
||||
if data and data[0] and data[0].get('url'):
|
||||
d = data[0]
|
||||
return {
|
||||
'url': d.get('url'),
|
||||
'level': d.get('level') or lv,
|
||||
'type': d.get('type') or 'mp3',
|
||||
'size': _to_int(d.get('size')),
|
||||
'br': d.get('br'),
|
||||
'md5': d.get('md5', ''),
|
||||
'requested_level': lv,
|
||||
}
|
||||
return None
|
||||
|
||||
|
||||
# ============ 组合能力 ============
|
||||
|
||||
def parse_podcast_ref(raw: str, cookies: dict[str, str] | None = None,
|
||||
page_size: int = 30) -> dict[str, Any]:
|
||||
"""智能解析用户输入:电台链接 → 电台详情+单集列表;单集链接 → 单集详情
|
||||
|
||||
Returns:
|
||||
{'kind': 'radio', 'radio': {...}, 'programs': [...], 'count': n, 'has_more': bool}
|
||||
或 {'kind': 'program', 'program': {...}}
|
||||
"""
|
||||
ref = extract_podcast_ref(raw)
|
||||
|
||||
if ref['kind'] == 'radio':
|
||||
radio = get_radio_detail(ref['id'], cookies)
|
||||
page = get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
|
||||
return {'kind': 'radio', 'radio': radio, **page}
|
||||
|
||||
if ref['kind'] == 'program':
|
||||
return {'kind': 'program', 'program': get_program_detail(ref['id'], cookies)}
|
||||
|
||||
# 纯数字:先试单集,再试电台
|
||||
try:
|
||||
return {'kind': 'program', 'program': get_program_detail(ref['id'], cookies)}
|
||||
except APIException:
|
||||
pass
|
||||
radio = get_radio_detail(ref['id'], cookies)
|
||||
page = get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
|
||||
return {'kind': 'radio', 'radio': radio, **page}
|
||||
|
||||
|
||||
def list_radio_all_program_ids(radio_id: Any, cookies: dict[str, str] | None = None,
|
||||
max_count: int = 200) -> list[dict[str, Any]]:
|
||||
"""拉取电台全部单集(用于整台打包,正序),返回 [{'id','name','main_song_id','serial_num','cover_url'}]
|
||||
|
||||
最多拉取 max_count 集,防止超大电台拖垮磁盘/任务。
|
||||
按节目 ID 去重;一页没有任何新增(短页/重复页/列表变动)即有界停止,
|
||||
不再固定 offset+=100 盲目翻页。
|
||||
"""
|
||||
out: list[dict[str, Any]] = []
|
||||
seen_ids = set()
|
||||
offset = 0
|
||||
page_size = 100
|
||||
max_pages = 30 # 有界保护:即使上游 has_more 恒真也最多翻 30 页
|
||||
for _ in range(max_pages):
|
||||
page = get_radio_programs(radio_id, cookies, limit=page_size, offset=offset, asc=True)
|
||||
new_count = 0
|
||||
for p in page['programs']:
|
||||
if p['id'] in seen_ids:
|
||||
continue
|
||||
seen_ids.add(p['id'])
|
||||
out.append({
|
||||
'id': p['id'],
|
||||
'name': p['name'],
|
||||
'main_song_id': p['main_song_id'],
|
||||
'serial_num': p['serial_num'],
|
||||
'cover_url': p['cover_url'],
|
||||
})
|
||||
new_count += 1
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
if len(out) >= max_count:
|
||||
break
|
||||
# 本页无新增(重复页/短页)或上游明确说没有更多 → 停止
|
||||
if new_count == 0 or not page['has_more'] or not page['programs']:
|
||||
break
|
||||
offset += page_size
|
||||
return out
|
||||
@@ -0,0 +1,392 @@
|
||||
"""网易云音乐二维码登录模块
|
||||
|
||||
提供网易云音乐二维码登录功能,包括:
|
||||
- 二维码生成和显示
|
||||
- 登录状态检查
|
||||
- Cookie 获取和保存(直接读写 cookie.txt)
|
||||
- 用户友好的交互界面
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import datetime
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from .cookies import load_cookies
|
||||
from .errors import APIException
|
||||
from .qr import QRLoginManager
|
||||
|
||||
# 判断 cookie 是否"有效":包含任一关键字段即可
|
||||
IMPORTANT_COOKIE_KEYS = ("MUSIC_U", "__csrf", "NMTID")
|
||||
|
||||
|
||||
class QRLoginClient:
|
||||
"""二维码登录客户端"""
|
||||
|
||||
def __init__(self, cookie_file: str = "cookie.txt"):
|
||||
"""
|
||||
Args:
|
||||
cookie_file: Cookie 保存文件路径
|
||||
"""
|
||||
self.cookie_file = Path(cookie_file)
|
||||
self.qr_manager = QRLoginManager()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
if not self.logger.handlers:
|
||||
handler = logging.StreamHandler()
|
||||
formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
|
||||
handler.setFormatter(formatter)
|
||||
self.logger.addHandler(handler)
|
||||
self.logger.setLevel(logging.INFO)
|
||||
|
||||
# ----------- Cookie 文件直接操作 -----------
|
||||
|
||||
def _read_cookie(self) -> str:
|
||||
if not self.cookie_file.exists():
|
||||
return ""
|
||||
try:
|
||||
return self.cookie_file.read_text(encoding="utf-8").strip()
|
||||
except OSError as e:
|
||||
self.logger.error(f"读取 Cookie 失败: {e}")
|
||||
return ""
|
||||
|
||||
def _write_cookie(self, cookie: str) -> bool:
|
||||
"""临时文件 + 原子替换写入;失败自动恢复备份,杜绝半截文件毁掉在用 Cookie。
|
||||
临时文件名带随机后缀:同进程多线程并发写同一 Cookie 文件不再互相覆盖 .tmp"""
|
||||
if not cookie or not cookie.strip():
|
||||
self.logger.error("Cookie 内容不能为空")
|
||||
return False
|
||||
backup = self._backup_cookie(suffix="prewrite")
|
||||
tmp = None
|
||||
try:
|
||||
import tempfile
|
||||
fd, tmpname = tempfile.mkstemp(
|
||||
prefix=self.cookie_file.name + '.tmp-', dir=str(self.cookie_file.parent))
|
||||
os.close(fd)
|
||||
tmp = Path(tmpname)
|
||||
tmp.write_text(cookie.strip() + "\n", encoding="utf-8")
|
||||
tmp.replace(self.cookie_file)
|
||||
return True
|
||||
except OSError as e:
|
||||
self.logger.error(f"写入 Cookie 失败: {e}")
|
||||
# 写失败:恢复备份,保证在用文件仍是旧的有效内容
|
||||
try:
|
||||
if tmp is not None:
|
||||
tmp.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
if backup is not None and backup.exists() and not self.cookie_file.exists():
|
||||
shutil.copy2(backup, self.cookie_file)
|
||||
return False
|
||||
|
||||
def _backup_cookie(self, suffix: str = "backup") -> Path | None:
|
||||
if not self.cookie_file.exists():
|
||||
return None
|
||||
ts = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f") # 微秒防同秒覆盖
|
||||
backup_path = self.cookie_file.with_name(
|
||||
f"{self.cookie_file.stem}.{suffix}.{ts}{self.cookie_file.suffix}"
|
||||
)
|
||||
try:
|
||||
shutil.copy2(self.cookie_file, backup_path)
|
||||
except OSError as e:
|
||||
self.logger.warning(f"备份 Cookie 失败: {e}")
|
||||
return None
|
||||
# 备份保留上限:同一 suffix 只留最近 3 份——备份含真实 MUSIC_U,
|
||||
# 无限累积既占磁盘也会跟着部署包外泄(_mkpkg ignore 已同步排除)
|
||||
try:
|
||||
siblings = sorted(
|
||||
self.cookie_file.parent.glob(
|
||||
f"{self.cookie_file.stem}.{suffix}.*{self.cookie_file.suffix}"),
|
||||
key=lambda p: p.stat().st_mtime)
|
||||
for old in siblings[:-3]:
|
||||
old.unlink(missing_ok=True)
|
||||
# 顺手清理崩溃遗留的原子写临时文件(cookie.txt.tmp-xxxx,超过 1 小时的必是残骸)
|
||||
now = time.time()
|
||||
for stale in self.cookie_file.parent.glob(f"{self.cookie_file.name}.tmp-*"):
|
||||
try:
|
||||
if now - stale.stat().st_mtime > 3600:
|
||||
stale.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
except OSError:
|
||||
pass
|
||||
return backup_path
|
||||
|
||||
def _clear_cookie(self) -> bool:
|
||||
if not self.cookie_file.exists():
|
||||
return True
|
||||
try:
|
||||
self.cookie_file.unlink()
|
||||
return True
|
||||
except OSError as e:
|
||||
self.logger.error(f"清除 Cookie 失败: {e}")
|
||||
return False
|
||||
|
||||
def _is_cookie_valid(self) -> bool:
|
||||
# 登录态凭据是 MUSIC_U 且必须非空——
|
||||
# 只有 __csrf/NMTID 这类匿名 cookie 也存在时不能算"有效登录"
|
||||
cookies = load_cookies(str(self.cookie_file))
|
||||
return bool((cookies.get('MUSIC_U') or '').strip())
|
||||
|
||||
def _get_cookie_info(self) -> dict:
|
||||
cookies = load_cookies(str(self.cookie_file))
|
||||
present = [k for k in IMPORTANT_COOKIE_KEYS
|
||||
if k == 'MUSIC_U' or (cookies.get(k) or '').strip()]
|
||||
valid = bool((cookies.get('MUSIC_U') or '').strip()) # 以 MUSIC_U 为准
|
||||
info = {
|
||||
"file_path": str(self.cookie_file.absolute()),
|
||||
"file_exists": self.cookie_file.exists(),
|
||||
"cookie_count": len(cookies),
|
||||
"is_valid": valid,
|
||||
"important_cookies_present": present,
|
||||
"missing_important_cookies": [k for k in IMPORTANT_COOKIE_KEYS
|
||||
if not (cookies.get(k) or '').strip()],
|
||||
}
|
||||
if self.cookie_file.exists():
|
||||
try:
|
||||
mtime = self.cookie_file.stat().st_mtime
|
||||
info["last_modified"] = datetime.datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S")
|
||||
except OSError:
|
||||
pass
|
||||
return info
|
||||
|
||||
# ----------- 业务方法 -----------
|
||||
|
||||
def check_existing_login(self) -> bool:
|
||||
"""检查是否已有有效登录"""
|
||||
try:
|
||||
if self._is_cookie_valid():
|
||||
self.logger.info("检测到有效的登录 Cookie")
|
||||
return True
|
||||
self.logger.info("未检测到有效的登录 Cookie")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"检查登录状态失败: {e}")
|
||||
return False
|
||||
|
||||
async def interactive_login(self) -> tuple[bool, str | None]:
|
||||
"""交互式二维码登录"""
|
||||
try:
|
||||
print("\n=== 网易云音乐二维码登录 ===")
|
||||
|
||||
if self.check_existing_login():
|
||||
choice = input("检测到已有有效登录,是否重新登录?(y/N): ").strip().lower()
|
||||
if choice not in ('y', 'yes', '是'):
|
||||
print("使用现有登录状态")
|
||||
return True, None
|
||||
|
||||
print("\n开始二维码登录流程...")
|
||||
print("正在生成二维码...")
|
||||
# create_qr_login 返回 unikey 字符串(失败为 None),并已在控制台打印二维码
|
||||
qr_key = await self.qr_manager.create_qr_login()
|
||||
|
||||
if not qr_key:
|
||||
error_msg = "生成二维码失败"
|
||||
self.logger.error(error_msg)
|
||||
return False, error_msg
|
||||
|
||||
print("\n二维码已生成!")
|
||||
print("请使用网易云音乐手机 APP 扫描二维码进行登录")
|
||||
print("二维码有效期: 3 分钟")
|
||||
print("\n等待扫码中...")
|
||||
|
||||
max_attempts = 60 # 最多 5 分钟
|
||||
attempt = 0
|
||||
while attempt < max_attempts:
|
||||
try:
|
||||
# check_qr_login 返回 (状态码, cookie字典):801等待/802已扫/803成功/800过期
|
||||
code, cookie_dict = await self.qr_manager.check_qr_login(qr_key)
|
||||
|
||||
if code == 803:
|
||||
musu_u = cookie_dict.get('MUSIC_U', '')
|
||||
if not musu_u:
|
||||
return False, "登录成功但未获取到 Cookie"
|
||||
# 序列化带上 __csrf——历史上只修了 web 的两条路径,
|
||||
# 漏了 CLI 换号:会导致全站 __csrf 恒空、weapi 发评论必败
|
||||
serialized = f"MUSIC_U={musu_u};os=pc;appver=8.9.70;"
|
||||
csrf = cookie_dict.get('__csrf', '')
|
||||
if csrf:
|
||||
serialized += f"__csrf={csrf};"
|
||||
if self.save_cookie(serialized):
|
||||
# 注:不用 emoji——中文 Windows 控制台默认 GBK 编码不了它,
|
||||
# print 抛 UnicodeEncodeError 会把"保存成功"误报成"未知错误"
|
||||
print("\n[成功] 登录成功!Cookie 已保存")
|
||||
return True, None
|
||||
return False, "登录成功但 Cookie 保存失败"
|
||||
|
||||
if code == 802:
|
||||
print("二维码已扫描,请在手机上确认登录")
|
||||
elif code == 800:
|
||||
print("\n[过期] 二维码已过期,请重新尝试")
|
||||
return False, "二维码已过期"
|
||||
elif code != 801:
|
||||
print(f"\n登录状态异常({code})")
|
||||
|
||||
if code == 801 and attempt % 10 == 0:
|
||||
print(f"等待扫码中... ({attempt + 1}/{max_attempts})")
|
||||
|
||||
await asyncio.sleep(5)
|
||||
attempt += 1
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n用户取消登录")
|
||||
return False, "用户取消登录"
|
||||
except Exception as e:
|
||||
self.logger.error(f"检查登录状态时发生错误: {e}")
|
||||
await asyncio.sleep(5)
|
||||
attempt += 1
|
||||
|
||||
print("\n[超时] 登录超时,请重新尝试")
|
||||
return False, "登录超时"
|
||||
|
||||
except APIException as e:
|
||||
self.logger.error(f"API 调用失败: {e}")
|
||||
return False, f"API 调用失败: {e}"
|
||||
except Exception as e:
|
||||
self.logger.error(f"登录过程中发生未知错误: {e}")
|
||||
return False, f"登录过程中发生未知错误: {e}"
|
||||
|
||||
def save_cookie(self, cookie: str) -> bool:
|
||||
"""保存 Cookie 到文件"""
|
||||
try:
|
||||
if self.cookie_file.exists():
|
||||
backup = self._backup_cookie()
|
||||
if backup:
|
||||
self.logger.info(f"已备份现有 Cookie 到: {backup}")
|
||||
|
||||
if not self._write_cookie(cookie):
|
||||
return False
|
||||
|
||||
if self._is_cookie_valid():
|
||||
self.logger.info("Cookie 保存并验证成功")
|
||||
return True
|
||||
|
||||
self.logger.warning("Cookie 保存成功但验证失败(缺少关键字段)")
|
||||
return False
|
||||
except Exception as e:
|
||||
self.logger.error(f"保存 Cookie 时发生错误: {e}")
|
||||
return False
|
||||
|
||||
def show_login_info(self) -> None:
|
||||
"""显示登录信息"""
|
||||
try:
|
||||
info = self._get_cookie_info()
|
||||
print("\n=== 登录状态信息 ===")
|
||||
print(f"Cookie 文件: {info['file_path']}")
|
||||
print(f"文件存在: {'是' if info['file_exists'] else '否'}")
|
||||
print(f"Cookie 数量: {info['cookie_count']}")
|
||||
print(f"登录状态: {'有效' if info['is_valid'] else '无效'}")
|
||||
if info.get("last_modified"):
|
||||
print(f"最后更新: {info['last_modified']}")
|
||||
if info["is_valid"]:
|
||||
present = info.get("important_cookies_present", [])
|
||||
print(f"重要 Cookie: {', '.join(present)}")
|
||||
else:
|
||||
missing = info.get("missing_important_cookies", [])
|
||||
if missing:
|
||||
print(f"缺少 Cookie: {', '.join(missing)}")
|
||||
except Exception as e:
|
||||
print(f"获取登录信息失败: {e}")
|
||||
|
||||
def logout(self) -> bool:
|
||||
"""登出(清除 Cookie)"""
|
||||
try:
|
||||
if self.cookie_file.exists():
|
||||
backup = self._backup_cookie("logout")
|
||||
if backup:
|
||||
print(f"Cookie 已备份到: {backup}")
|
||||
if self._clear_cookie():
|
||||
print("已成功登出")
|
||||
return True
|
||||
print("登出失败")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"登出时发生错误: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def main():
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
||||
)
|
||||
|
||||
client = QRLoginClient()
|
||||
|
||||
if len(sys.argv) > 1:
|
||||
command = sys.argv[1].lower()
|
||||
|
||||
if command == 'login':
|
||||
success, error = asyncio.run(client.interactive_login())
|
||||
if success:
|
||||
print("\n登录完成!")
|
||||
client.show_login_info()
|
||||
sys.exit(0)
|
||||
print(f"\n登录失败: {error}")
|
||||
sys.exit(1)
|
||||
|
||||
elif command in ('status', 'info'):
|
||||
client.show_login_info()
|
||||
sys.exit(0)
|
||||
|
||||
elif command == 'logout':
|
||||
sys.exit(0 if client.logout() else 1)
|
||||
|
||||
elif command in ('help', '-h', '--help'):
|
||||
print("网易云音乐二维码登录工具")
|
||||
print("\n用法: python -m music_api.qr_client [命令]")
|
||||
print("\n命令:")
|
||||
print(" login - 执行二维码登录")
|
||||
print(" status - 显示登录状态")
|
||||
print(" logout - 登出(清除 Cookie)")
|
||||
print(" help - 显示此帮助信息")
|
||||
print("\n如果不提供命令,将进入交互模式")
|
||||
sys.exit(0)
|
||||
|
||||
else:
|
||||
print(f"未知命令: {command}")
|
||||
print("使用 'python -m music_api.qr_client help' 查看帮助")
|
||||
sys.exit(1)
|
||||
|
||||
# 交互模式
|
||||
try:
|
||||
while True:
|
||||
print("\n=== 网易云音乐登录工具 ===")
|
||||
print("1. 二维码登录")
|
||||
print("2. 查看登录状态")
|
||||
print("3. 登出")
|
||||
print("4. 退出")
|
||||
|
||||
choice = input("\n请选择操作 (1-4): ").strip()
|
||||
|
||||
if choice == '1':
|
||||
success, error = asyncio.run(client.interactive_login())
|
||||
if success:
|
||||
print("\n登录成功!")
|
||||
client.show_login_info()
|
||||
else:
|
||||
print(f"\n登录失败: {error}")
|
||||
elif choice == '2':
|
||||
client.show_login_info()
|
||||
elif choice == '3':
|
||||
client.logout()
|
||||
elif choice == '4':
|
||||
print("再见!")
|
||||
break
|
||||
else:
|
||||
print("无效选择,请重试")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\n\n程序已退出")
|
||||
sys.exit(0)
|
||||
except Exception as e:
|
||||
print(f"\n程序运行出错: {e}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in new issue
Block a user