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/podcast.py
T
snowy-yang 4d3204213e refactor: resolve_episode_url 阶梯构造拆解(58 行 / 圈 19 / 嵌套 3 → 36 行 / 圈 12 / 嵌套 2)
为什么:播客单集直链解析的唯一入口,前 20 行是降级阶梯的嵌套构造
(if 套 for 套 if),与尝试循环混在同一个函数里,改音质口径要通读全函数。

拆出:
- _falldown_order(模块级纯函数):降级尝试序列——auto→通用阶梯原序 /
  已知音质→向下后缀 / 未知音质→该音质优先+通用阶梯,去重保序收敛为
  dict.fromkeys;_FALLDOWN_LADDER 从函数内上移模块级
- resolve_episode_url 只剩尝试循环;song_id 的 int 防御上提循环外
  (循环不变量:原实现首轮即判、失败直接 None,url_v1 从未收到过坏 sid)

先写测试后动代码:新增 tests/test_resolve_episode_url.py 11 例,在未改动
实现上一次跑绿,钉住核心口径——「只向下不向上」(standard 绝不回退到
exhigh,历史坑)、未知音质前置、垃圾 song_id 零请求、APIException 降级、
data[0] 无 url 降级、结果映射缺省值。
门禁:ruff 全过 · pyright 0/0 · pytest 370 passed(359→370)。
2026-09-27 11:00:50 +08:00

563 lines
21 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 模块
提供网易云"播客"板块相关接口封装,包括:
- 播客搜索(电台 / 单集声音)
- 播客电台详情与单集列表(分页)
- 单集(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 []
_FALLDOWN_LADDER = ['hires', 'lossless', 'exhigh', 'standard']
def _falldown_order(level: str) -> list[str]:
"""音质降级尝试序列:只向下不向上(原实现指定 standard 会回退到更高的 exhigh)。
- auto/空 → 通用阶梯原序
- 已知音质 → 该音质 + 阶梯中低于它的部分
- 未知音质 → 该音质优先 + 通用阶梯(去重保序)
"""
level = str(level or 'auto').strip()
if not level or level == 'auto':
order = list(PodcastConstants.QUALITY_LADDER)
elif level in _FALLDOWN_LADDER:
idx = _FALLDOWN_LADDER.index(level)
order = [level, *_FALLDOWN_LADDER[idx + 1:]]
else:
order = [level, *PodcastConstants.QUALITY_LADDER]
return list(dict.fromkeys(order)) # 去重保序
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(全部失败)
"""
order = _falldown_order(level)
# song_id 可能是 None/垃圾值(防御:int 失败按无音频处理,不再 TypeError 500)
try:
sid = int(str(song_id).strip())
except (TypeError, ValueError):
return None
for lv in order:
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