把B站变成你的私人播客!BiliRSS 项目原理与实现全解析
把B站变成你的私人播客!BiliRSS 项目原理与实现全解析
项目地址:Github、Gitee
项目采用 MIT License 开源。(https://gitee.com/jiajin0920/BRSS)
在线演示:项目在线演示
Bug 反馈:Bug收集表单
前言
你是否想过,把喜欢的B站UP主的视频变成播客,在通勤路上用 Apple Podcasts、小宇宙等播客客户端随时收听?
今天给大家介绍一个开源项目——BiliRSS,它能自动从B站视频提取音频,生成标准的 RSS 播客订阅源,配合一个现代化的 Web 管理面板,实现从下载到订阅的全流程管理。
本文不仅会介绍功能和使用方法,更会深入项目原理,剖析核心代码实现——包括 yt-dlp 下载引擎的调用方式、RSS 播客源的生成逻辑、B站 Polymer API 的对接方案,以及零数据库架构的设计考量。

一、整体架构
BiliRSS 的架构非常清晰,核心是一个 Flask 单文件应用,围绕三条主线展开:
用户请求 → Flask 路由
├── 下载引擎 (yt-dlp + ffmpeg)
├── RSS 生成器 (手动拼接 XML)
└── JSON 数据层 (读写 db.json)
技术选型一览:
| 层次 | 技术 | 为什么选它 |
|---|---|---|
| Web 框架 | Flask | 轻量,适合小型项目 |
| 下载引擎 | yt-dlp | B站支持最好,活跃维护 |
| 音频转码 | ffmpeg | 业界标准,格式覆盖全 |
| 数据存储 | JSON 文件 | 零运维,个人项目够用 |
| 前端 | Vue 3 (CDN) | 组件化但无需构建 |
| 部署 | systemd + Nginx | Linux 标准运维方案 |
二、核心原理:从视频到播客的完整链路
整个流程可以拆解为 4 个步骤:
B站视频 → yt-dlp 提取音频 → ffmpeg 转码 → 生成 RSS XML → 播客客户端订阅
2.1 下载引擎:yt-dlp 的调用
yt-dlp 是 YouTube-dl 的社区 fork,对B站的支持远超原版。BiliRSS 通过 Python subprocess 调用 yt-dlp,核心下载函数如下:
def download_audio(task_id, urls, cat_id, ip, cookie_str=None, audio_format='mp3'):
fmt_info = AUDIO_FORMATS.get(audio_format, AUDIO_FORMATS['mp3'])
cookie_file = resolve_cookie_file(ip, cookie_str)
for url in urls:
cmd = [
'yt-dlp',
'-x', # 仅提取音频,丢弃视频
*fmt_info['cmd'], # 音频格式参数
'-o', str(AUDIO_DIR / '%(id)s.%(ext)s'), # 输出路径模板
'--write-info-json', # 保存视频元数据
'--write-thumbnail', # 下载封面缩略图
'--convert-thumbnails', 'jpg', # 缩略图转 JPG
'--no-check-certificates',
]
if cookie_file:
cmd.extend(['--cookies', cookie_file])
result = subprocess.run(cmd, capture_output=True, text=True, timeout=600)
关键设计点:
-x参数:告诉 yt-dlp 只提取音频轨道,不下载视频,大幅节省带宽和存储--write-info-json:这是 RSS 生成的关键——yt-dlp 会将视频标题、UP主、时长、上传日期等元数据保存为.info.json文件,后续生成 RSS 时直接读取%(id)s.%(ext)s命名模板:以 BV 号作为文件名,保证唯一性,方便后续查找和管理- Cookie 机制:部分视频需要登录态才能下载,yt-dlp 通过
--cookies参数加载 Netscape 格式的 Cookie 文件
音频格式配置:
AUDIO_FORMATS = {
'mp3': {'label': 'MP3(通用压缩)', 'cmd': ['--audio-format', 'mp3', '--audio-quality', '0'], 'ext': '.mp3'},
'flac': {'label': 'FLAC(无损)', 'cmd': ['--audio-format', 'flac', '--audio-quality', '0'], 'ext': '.flac'},
'm4a': {'label': 'AAC/M4A(苹果兼容)', 'cmd': ['--audio-format', 'm4a', '--audio-quality', '0'], 'ext': '.m4a'},
'opus': {'label': 'Opus(高效压缩)', 'cmd': ['--audio-format', 'opus', '--audio-quality', '0'], 'ext': '.opus'},
'wav': {'label': 'WAV(原始无损)', 'cmd': ['--audio-format', 'wav', '--audio-quality', '0'], 'ext': '.wav'},
'best': {'label': '最佳质量(自动)', 'cmd': ['--audio-format', 'best', '--audio-quality', '0'], 'ext': ''},
}
--audio-quality 0 表示最高质量(对于有损格式如 MP3,意味着最高比特率;对于无损格式如 FLAC 则忽略此参数)。
2.2 UP 主整站下载:对接B站 Polymer API
这是项目中最有意思的部分。要下载一个 UP 主的全部视频,需要先获取视频列表,但B站的接口需要特定的请求格式:
def fetch_up_video_list(uid, ip, cookie_str=None):
bv_ids = []
offset = ''
headers = {
'User-Agent': 'Mozilla/5.0 ...',
'Referer': f'https://space.bilibili.com/{uid}/video',
'Origin': 'https://space.bilibili.com',
'Accept': 'application/json, text/plain, */*',
}
if cookie_str:
headers['Cookie'] = cookie_str
# 使用B站 Polymer 动态 API
while True:
url = f'https://api.bilibili.com/x/polymer/web-dynamic/v1/feed/space?host_mid={uid}'
if offset:
url += f'&offset={offset}'
req = urllib.request.Request(url)
for k, v in headers.items():
req.add_header(k, v)
with urllib.request.urlopen(req, timeout=15) as resp:
data = json.loads(resp.read().decode())
items = data.get('data', {}).get('items', [])
if not items:
break
# 从动态列表中提取视频 BV 号
for item in items:
modules = item.get('modules', {})
major = modules.get('module_dynamic', {}).get('major', {})
archive = major.get('archive', {})
bvid = archive.get('bvid', '')
if bvid and bvid.startswith('BV'):
bv_ids.append(bvid)
# 分页处理
has_more = data.get('data', {}).get('has_more', False)
offset = data.get('data', {}).get('offset', '')
if not has_more or not offset:
break
time.sleep(1) # 请求间隔,避免触发限流
return bv_ids
原理解析:
- API 选型:B站有多种获取用户视频的 API(如
x/space/arc/search),但 Polymer 动态 API(x/polymer/web-dynamic/v1/feed/space)返回的数据结构更稳定,且能获取到完整的动态内容 - 分页机制:API 使用
offset字段分页(不是传统的 page/size),每次请求返回has_more和下一页的offset值 - 增量下载:获取到全部 BV 号后,与已有文件对比,只下载新增的:
already_exist = [bv for bv in bv_ids if audio_file_exists(bv)]
to_download = [bv for bv in bv_ids if not audio_file_exists(bv)]
- 请求限流:每次请求间隔 1 秒,避免被B站反爬
2.3 RSS 播客源生成:手动拼接 XML
这是让音频文件变成"播客"的关键。BiliRSS 手动拼接 RSS 2.0 XML,兼容 iTunes 播客规范:
def build_rss_xml(title, description, items, base_url, image_url=''):
lines = ['<?xml version="1.0" encoding="UTF-8"?>']
lines.append('<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">')
lines.append(' <channel>')
lines.append(f' <title>{escape_xml(title)}</title>')
lines.append(f' <description>{escape_xml(description)}</description>')
lines.append(f' <link>{base_url}</link>')
lines.append(f' <language>zh-cn</language>')
# iTunes 扩展字段
lines.append(f' <itunes:category text="Music"/>')
if image_url:
lines.append(f' <itunes:image href="{escape_xml(image_url)}"/>')
for item in items:
lines.append(' <item>')
lines.append(f' <title>{escape_xml(item["title"])}</title>')
# 音频文件封装——这是播客客户端实际播放的地址
lines.append(f' <enclosure url="{escape_xml(item["audio_url"])}" '
f'type="{item["mime"]}" length="{item["size"]}"/>')
# iTunes 扩展
lines.append(f' <itunes:duration>{item["duration"]}</itunes:duration>')
lines.append(f' <itunes:author>{escape_xml(item.get("uploader"))}</itunes:author>')
if item.get("thumbnail"):
lines.append(f' <itunes:image href="{escape_xml(item["thumbnail"])}"/>')
lines.append(' </item>')
lines.append(' </channel>')
lines.append('</rss>')
return '\n'.join(lines)
为什么不用模板引擎? 因为 RSS 结构相对固定,手动拼接更直观,也避免了引入额外依赖。
每个 RSS item 的数据来源:
def build_rss_item_by_bv(bv_id, base_url):
# 1. 从 yt-dlp 生成的 info.json 读取元数据
info_file = AUDIO_DIR / f'{bv_id}.info.json'
with open(info_file, 'r', encoding='utf-8') as f:
info = json.load(f)
# 2. 查找实际音频文件(可能存在多种格式)
audio_path = AUDIO_DIR / f'{bv_id}.mp3' # 等按优先级遍历
return {
'title': info.get('title'), # 视频标题
'uploader': info.get('uploader'), # UP主名称
'duration': int(info.get('duration')), # 时长(秒)
'audio_url': f'{base_url}/audio/{audio_path.name}', # 音频访问地址
'size': audio_path.stat().st_size, # 文件大小
'mime': 'audio/mpeg', # MIME 类型
'thumbnail': f'{base_url}/audio/{bv_id}.jpg', # 封面图
}
关键点: <enclosure> 标签是播客协议的核心——url 指向音频文件的实际 HTTP 地址,type 声明 MIME 类型,length 声明文件大小。播客客户端通过解析这个标签来下载和播放音频。
2.4 Cookie 管理:全局共享机制
B站的会员视频需要登录态。BiliRSS 设计了一个基于 IP 的全局 Cookie 共享机制:
def ip_cookie_key(ip):
"""IP → 固定 hash,同一IP共享cookie"""
return hashlib.md5(f'bili_cookie_{ip}'.encode()).hexdigest()[:12]
def cookie_str_to_netscape_file(cookie_str, name):
"""将浏览器 Cookie 字符串转换为 yt-dlp 需要的 Netscape 格式"""
lines = ['# Netscape HTTP Cookie File', '']
for pair in cookie_str.split(';'):
name_part, _, value = pair.partition('=')
lines.append(f'.bilibili.com\tTRUE\t/\tTRUE\t0\t{name_part}\t{value}')
with open(COOKIE_DIR / f'{name}.txt', 'w') as f:
f.write('\n'.join(lines))
return str(COOKIE_DIR / f'{name}.txt')
def resolve_cookie_file(ip, cookie_str=None):
"""优先级:IP专属 > 本次提供 > 无 Cookie"""
key = ip_cookie_key(ip)
f = COOKIE_DIR / f'{key}.txt'
if f.exists():
return str(f)
if cookie_str:
return cookie_str_to_netscape_file(cookie_str, key)
return None
设计思路: 用户提交一次 Cookie 后,按 IP 存储为全局文件。之后该 IP 的所有下载任务自动使用这个 Cookie,无需重复输入。同时,用户也可以在单个任务中临时覆盖。
三、零数据库架构:JSON 文件方案
BiliRSS 没有使用任何数据库,而是用一个 db.json 文件存储所有数据:
def load_db():
if DB_FILE.exists():
with open(DB_FILE, 'r', encoding='utf-8') as f:
return json.load(f)
return {'categories': [], 'collections': [], 'tasks': []}
def save_db(db):
with open(DB_FILE, 'w', encoding='utf-8') as f:
json.dump(db, f, ensure_ascii=False, indent=2)
数据结构:
{
"categories": [
{"id": "a1b2c3d4", "name": "科技UP主", "description": "科技类视频"}
],
"collections": [
{
"id": "e5f6g7h8",
"name": "精选合集",
"bv_ids": ["BV1xx", "BV2yy"],
"delete_password": "sha256_hash",
"cover": "e5f6g7h8.jpg"
}
],
"tasks": [
{
"id": "i9j0k1l2",
"category_id": "a1b2c3d4",
"url": "https://www.bilibili.com/video/BV1xx",
"url_type": "video",
"bv_ids": ["BV1xx"],
"status": "completed",
"created_at": "2025-01-15T10:30:00+08:00"
}
]
}
为什么不选数据库? 对于个人播客服务来说,数据量通常在几百到几千条,JSON 文件完全够用。好处是:
- 零安装:不需要 MySQL/PostgreSQL/SQLite
- 零运维:不需要备份脚本、连接池、迁移工具
- 可读性强:直接打开就能看到和编辑数据
- 部署简单:拷贝一个文件就能迁移所有数据
当然,代价是并发写入时可能有竞争问题——但对于单用户场景,这不是问题。
四、异步下载:线程模型
下载是耗时操作,不能阻塞 Flask 主线程。BiliRSS 使用 Python 原生线程:
@app.route('/api/task', methods=['POST'])
def api_create_task():
# ... 参数解析 ...
if url_type == 'up':
t = threading.Thread(
target=download_up_videos,
args=(task_id, uid, cat_id, client_ip, cookie, audio_format)
)
else:
t = threading.Thread(
target=download_audio,
args=(task_id, [dl_url], cat_id, client_ip, cookie, audio_format)
)
t.daemon = True
t.start()
return jsonify({'ok': True, 'task_id': task_id})
实时状态追踪: 通过全局字典 DOWNLOAD_STATUS 记录每个任务的进度:
DOWNLOAD_STATUS = {}
# 在下载过程中更新
DOWNLOAD_STATUS[task_id] = {
'status': 'running',
'progress': 3, # 当前第几个
'total': 10, # 总共多少个
'message': '下载中 3/10: BV1xx...'
}
# 前端通过轮询获取状态
@app.route('/api/status')
def api_status():
return jsonify(DOWNLOAD_STATUS)
前端每秒轮询 /api/status,实现下载进度的实时展示。
五、安全机制
5.1 删除密钥(SHA256)
删除音频是危险操作,BiliRSS 使用密钥保护:
# 密钥明文只在配置中存在
DELETE_SECRET_KEY = 'your-secret-key-here'
# 启动时计算 SHA256 哈希,只存储哈希值
DELETE_SECRET_KEY_HASH = hashlib.sha256(DELETE_SECRET_KEY.encode()).hexdigest()
# 验证时对比哈希
@app.route('/api/audio/<bv_id>', methods=['DELETE'])
def api_delete_audio(bv_id):
secret_key = request.form.get('secret_key', '')
if hashlib.sha256(secret_key.encode()).hexdigest() != DELETE_SECRET_KEY_HASH:
return jsonify({'ok': False, 'error': '密钥错误'}), 403
# ... 执行删除 ...
5.2 合集密码保护
创建合集时可选设置删除密码,同样使用 SHA256 存储:
@app.route('/api/collection/<col_id>', methods=['DELETE'])
def api_delete_collection(col_id):
col = get_collection(db, col_id)
if col.get('delete_password'):
provided = request.form.get('password', '')
hashed = hashlib.sha256(provided.encode()).hexdigest()
if hashed != col['delete_password']:
return jsonify({'ok': False, 'error': '密码错误'}), 403
六、本地/服务器双模式设计
这是一个很实用的设计——本地开发用 Python 包结构,部署时展平为单文件:
本地开发入口 (run_local.py):
LOCAL_DATA = Path(__file__).parent / 'local_data'
# monkey-patch 服务器路径为本地路径
import bili_rss.app as app_module
app_module.BASE_DIR = LOCAL_DATA
app_module.AUDIO_DIR = LOCAL_DATA / 'audio'
app_module.DB_FILE = LOCAL_DATA / 'db.json'
# ...
app_module.app.run(host='127.0.0.1', port=5000, debug=True)
服务器部署:deploy.py 自动将 Python 包展平为服务器上的扁平结构:
| 本地文件 | 服务器路径 |
|---|---|
bili_rss/app.py |
/opt/bili-rss/app.py |
bili_rss/templates/index.py |
/opt/bili-rss/templates_index.py |
这样做的好处是开发时享受包结构带来的代码组织优势,部署时只需 python app.py 即可运行,不依赖包导入。
七、快速开始
环境要求
# Python 3.8+
python --version
# ffmpeg(必需)
ffmpeg -version
# 安装 Python 依赖
pip install -r requirements.txt
启动服务
python run_local.py
# 访问 http://localhost:5000
服务器一键部署
python deploy/deploy.py
八、使用指南
下载单个视频
- 打开管理面板 → 「下载管理」
- 选择分类,粘贴视频链接
- 选择音频格式(默认 MP3)
- 点击「创建任务」
UP 主整站下载
- 类型切换为「UP 主」
- 粘贴空间链接或 UID
- 服务自动增量下载新视频
导入播客客户端
将 RSS 地址粘贴到播客客户端即可:
| 客户端 | 支持情况 |
|---|---|
| Apple Podcasts | ✅ 完整支持 |
| 小宇宙 | ✅ 完整支持 |
| Pocket Casts | ✅ 完整支持 |
| Overcast | ✅ 完整支持 |
| AntennaPod | ✅ 完整支持 |
九、总结
BiliRSS 是一个设计精巧的个人工具项目,几个亮点:
- 架构简洁:Flask 单文件 + JSON 存储,零数据库零构建
- 技术实用:yt-dlp + ffmpeg 是业界标准组合,稳定可靠
- RSS 标准:兼容 iTunes 播客规范,开箱即用
- 双模式部署:本地开发与服务器部署无缝切换
适合人群:B站重度用户、播客爱好者、想要搭建个人音频服务的开发者。
觉得有用的话,点个赞支持一下吧~
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)