把B站变成你的私人播客!BiliRSS 项目原理与实现全解析

项目地址:GithubGitee
项目采用 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)

关键设计点:

  1. -x 参数:告诉 yt-dlp 只提取音频轨道,不下载视频,大幅节省带宽和存储
  2. --write-info-json:这是 RSS 生成的关键——yt-dlp 会将视频标题、UP主、时长、上传日期等元数据保存为 .info.json 文件,后续生成 RSS 时直接读取
  3. %(id)s.%(ext)s 命名模板:以 BV 号作为文件名,保证唯一性,方便后续查找和管理
  4. 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

八、使用指南

下载单个视频

  1. 打开管理面板 → 「下载管理」
  2. 选择分类,粘贴视频链接
  3. 选择音频格式(默认 MP3)
  4. 点击「创建任务」

UP 主整站下载

  1. 类型切换为「UP 主」
  2. 粘贴空间链接或 UID
  3. 服务自动增量下载新视频

导入播客客户端

将 RSS 地址粘贴到播客客户端即可:

客户端 支持情况
Apple Podcasts ✅ 完整支持
小宇宙 ✅ 完整支持
Pocket Casts ✅ 完整支持
Overcast ✅ 完整支持
AntennaPod ✅ 完整支持

九、总结

BiliRSS 是一个设计精巧的个人工具项目,几个亮点:

  • 架构简洁:Flask 单文件 + JSON 存储,零数据库零构建
  • 技术实用:yt-dlp + ffmpeg 是业界标准组合,稳定可靠
  • RSS 标准:兼容 iTunes 播客规范,开箱即用
  • 双模式部署:本地开发与服务器部署无缝切换

适合人群:B站重度用户、播客爱好者、想要搭建个人音频服务的开发者。


觉得有用的话,点个赞支持一下吧~

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐