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
snowy-yang 69a50c2c31 refactor: worker 子系统全面异步化,拆掉同步转异步的桥接层
拆掉「同步调异步 / 异步桥同步」两类垫片的第二半(HTTP 线 B2 见前序提交)。
worker 侧此前是「起线程跑同步函数、函数体内部再用 asyncio.to_thread 把活塞给
另一条循环」——线程与协程互等,取消/暂停只能靠轮询。本轮把 worker 自身变成
协程,由 _start_task 直接挂到应用事件循环上,桥接层随之失去存在理由。

改动(按子系统):
- music_downloader:downloader / _aria2 / _concurrency / cover / _http /
  __main__ 全链路协程化。per-file 锁改 asyncio.Lock;计数闸改
  「active 计数 + await asyncio.sleep(0.5)」的可取消等待(原阻塞式 with 会让
  已取消任务仍要等全局名额腾出,卡死并拖后腿退款);aria2p 的同步 RPC 仍经
  to_thread,轮询改 asyncio.sleep;新增 core.http_client.iter_chunks,把**流式
  读取期**的 aiohttp.ClientError 收敛成 UpstreamNetworkError(请求建立阶段早已
  有这条路径,读取阶段此前没有,断流会绕过调用方的 UpstreamError 分类)。
  删除 get_http_session / _SESSION_LOCAL。
  models._RetryFromScratch 不再继承 requests.RequestException 而继承 Exception:
  它是「续传起点失效、残片已清、从头重下」,既不是确定性失败
  (DownloadException),也不是残片已保留的网络失败(UpstreamError),必须独立
  成类且在 except 链中先于二者捕获。
- music_api:podcast.py 全部函数协程化(直链解析内部改
  await NeteaseAPI.get_song_url);compat.py 删除——16 个死掉的同步门面早已
  清空,文件只剩空壳。
- services:_taskcore / _zipcore / batch / downloads / podcast / tasks 协程化。
  threading.Thread → loop.create_task(任务句柄字段 worker_thread →
  worker_task),ThreadPoolExecutor → asyncio.gather + asyncio.Semaphore,
  终态函数与结算回调全部改 await;监控线程里「worker 已死」判定改 task.done()。
  删除 services/streaming.py(同步流垫片,唯一消费者已迁到
  core.http_client.open)。zipfile 实例仍非线程安全,故「下载」并行、
  「入包」严格串行。
- web:路由层 130+ 处 to_thread 拆除——被包住的目标自己已是协程,再包一层只是
  把协程丢进线程里跑一圈。tasks_registry.start_task 直接 create_task。
  web/app.py 关闭段去掉 async_bridge.shutdown()(不再有第二条约循环)。
  web/routers/download.py 的播客端点补上 3 处漏掉的 await
  (extract_podcast_ref / get_radio_detail)——漏掉时不报错、只静默返回协程对象,
  靠 pyright 抓出来后修正。

留在 to_thread 上的都是**正当的阻塞卸载**:同步 SQLite 落库与账本结算、
mutagen 全量重写标签、zipfile 整段拷贝、shutil.rmtree、aria2p 的同步 RPC、
PBKDF2 的 10 万次迭代。ruff 对残留的本地元数据操作(exists/stat/os.replace/
往 .part 顺序写分块)按「微秒级、卸载的调度开销大于操作本身」放行,pyproject
逐条注明理由。

取消/暂停仍由 threading.Event 承载:置位方是请求线程/监控线程,读取方是 worker
协程,只有「置位」与「轮询读取」两种操作,既不需要跨循环唤醒也不会卡住事件
循环——比 asyncio.Event 更省心(ASYNC110 同理按此豁免)。

测试:10 个文件的替身边界同步迁移——网络替身从 core.sync_http 的
get_http_session 换成 downloader/服务模块的 _http.open + **真实** iter_chunks
(顺带钉住读取期异常收敛);`_run` 改为在独立线程里 asyncio.run(worker(...));
aria2 的假时钟从 time.sleep 换成 _POLL_INTERVAL=0;monitor 的线程句柄替身换成
done() 可控的轻量替身(asyncio.Task 的构造需要运行中的事件循环,而监控循环跑在
常驻线程里);podcast / 封面 / 结算包装等替身一律改 async。

门禁:ruff 全过 / pyright 0-0 / pytest 383 项全绿(0 失败)。

下一步 B2:auth_system 12 个模块改 aiosqlite 背板、web 层剩余 to_thread 收尾;
B3:删 core/sync_http.py、core/async_bridge.py、core/http_errors.py(现仅
sync_http 自用)。
2026-09-28 19:36:56 +08:00

556 lines
21 KiB
Python
Raw Permalink 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),
因此直链复用 NeteaseAPI.get_song_url(eapi song/enhance/player/url/v1)获取。
- 支持格式:mp3 / m4a / aac 等(以接口返回 type 为准)。
- 支持链接协议:music.163.com/djradio?id=(电台)、/program?id= 与 /sound?id=(单集)、
以及 163cn.tv 短链。
- 本模块**整体异步**:HTTP 统一走 core.http_client(进程级连接池 + 有界重试 +
8MB 响应体上限),调用方直接 await,不再需要同步门面/线程包装。
"""
import json
import re
import time
from typing import Any
from core.http_client import UpstreamError
from core.http_client import client as _http
from .api import NeteaseAPI
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]
async def _post_json(url: str, data: dict[str, Any], cookies: dict[str, str] | None = None) -> dict[str, Any]:
"""发送表单 POST 请求并返回 JSON(响应体上限由 core.http_client 统一把关)"""
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER,
}
try:
response = await _http.request('POST', url, data=data, headers=headers,
cookies=cookies or {}, timeout=30)
result = response.json()
if not isinstance(result, dict):
raise APIException('播客接口响应异常(非标准 JSON 对象)')
except UpstreamError 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] = {}
async 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 = await _http.open('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 UpstreamError:
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
async 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 = await _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 ============
async 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': []}
if search_type in ('all', 'radio'):
res = await _post_json(PodcastConstants.SEARCH_API,
{'s': keywords, 'type': 1009, 'limit': limit, 'offset': offset},
cookies)
radios = (res.get('result') or {}).get('djRadios') or []
result['radios'] = [_norm_radio(r) for r in radios]
if search_type in ('all', 'voice'):
res = await _post_json(PodcastConstants.SEARCH_API,
{'s': keywords, 'type': 2000, 'limit': limit, 'offset': offset},
cookies)
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
async 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 = await _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)
async 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 = await _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,
}
async 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 = await _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)
async def get_categories() -> list[dict[str, Any]]:
"""获取播客分类列表(GET 接口)
Returns:
[{'id': 6, 'name': '生活'}, ...]
"""
headers = {
'User-Agent': APIConstants.USER_AGENT,
'Referer': APIConstants.REFERER,
}
try:
response = await _http.request('GET', PodcastConstants.DJRADIO_CATEGORY_API,
headers=headers, timeout=30)
result = response.json()
except UpstreamError 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
async 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 = await _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)) # 去重保序
async 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
api = NeteaseAPI()
for lv in order:
try:
res = await api.get_song_url(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
# ============ 组合能力 ============
async 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 = await extract_podcast_ref(raw)
if ref['kind'] == 'radio':
radio = await get_radio_detail(ref['id'], cookies)
page = await 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': await get_program_detail(ref['id'], cookies)}
# 纯数字:先试单集,再试电台
try:
return {'kind': 'program', 'program': await get_program_detail(ref['id'], cookies)}
except APIException:
pass
radio = await get_radio_detail(ref['id'], cookies)
page = await get_radio_programs(ref['id'], cookies, limit=page_size, offset=0, asc=True)
return {'kind': 'radio', 'radio': radio, **page}
async 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 = await 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