叶牧讲道播客自动化生产线:从云端方案到树莓派全自动生产线的完整记录

本文记录了「叶牧讲道」播客自动化系统从最初的云端构想,到最终在 Raspberry Pi 400 上稳定运行的完整过程:架构如何演进、踩过哪些坑、每个坑是怎么修的。原始素材来自与 GPT、Gemini、Claude 三个 AI 反复讨论、审查、修 bug 的散乱记录,这里按主题重新整理成一篇完整文档。


一、项目概述

目标

将 YouTube 播放列表中的讲道视频,自动转换为符合 Apple Podcasts / Spotify 标准的播客节目,并全自动发布,全程无需人工干预。

最终技术栈

  • 硬件:Raspberry Pi 400(家用键盘一体机,4GB RAM)
  • 下载:yt-dlp(配合 Deno 作为 JS Runtime 解 YouTube 签名挑战,详见第七节)
  • 音质优化:RNNoise(ffmpeg arnndn 滤镜)+ ffmpeg loudnorm(EBU R128 响度标准化)
  • 语音转录:whisper.cpp(本地,零成本,比 Python 版快约 3 倍)/ openai-whisper(Python 版,兜底)
  • AI 摘要:Google Gemini API(gemini-2.5-flash,免费层)
  • 存储:Cloudflare R2
  • 发布:GitHub + Cloudflare Pages(自动部署 RSS)

完整处理流水线

YouTube 播放列表(支持多个,自动去重)
    ↓
yt-dlp 下载(bestaudio 192k,限速 + 随机休眠,模拟正常用户)
    ↓
RNNoise 降噪(arnndn,sh.rnnn 模型)+ ffmpeg loudnorm(-16 LUFS,双声道 96k)
    ↓
whisper.cpp / openai-whisper 本地转录(描述文字不足 50 字时触发)
    ↓
Gemini 2.5 Flash 生成摘要(中文摘要 + 经文金句 + 西班牙语标题)
    ↓
上传 Cloudflare R2
    ↓
生成 RSS(sermon.xml,三重保护写入)
    ↓
git push → Cloudflare Pages 自动部署

这套流水线和最初的设想差别很大——最初的方案是纯云端架构,走了不少弯路才收敛成现在这个"零成本、自维护"的家用版本,详见下一节。


二、架构演进:从云端 VPS 到自家树莓派

最初的构想(云端全家桶)

项目最早的方案是纯云端架构:VPS 负责 yt-dlp 下载和转码,OpenAI Whisper API + GPT-4o 做转录和摘要,Cloudflare R2 存储音频,GitHub Actions 定时触发、推送到 GitHub Pages。这个方案理论上很完整,但落地后暴露出两个核心痛点。

痛点 1:成本

GPT-4o-mini 按量计费,单看每周一集讲道的摘要消耗,一年成本大约只要几毛钱人民币,本身不算贵。但既然 Google Gemini 免费层(每分钟约 15 次请求)和树莓派本地算力都能满足需求,就没有必要为 AI 调用付费——这是后来把 AI 摘要从 GPT-4o 切换到 Gemini 免费层的直接原因。

痛点 2:YouTube 反爬(真正的致命伤)

VPS 的 IP 段被 YouTube 识别为"数据中心 / 机器人",yt-dlp 频繁触发:

ERROR: Sign in to confirm you're not a bot. Use --cookies-from-browser or --cookies...

为了解决这个问题,先后尝试了一整套"伪装成真人"的方案,一层套一层:

  1. 导出浏览器 Cookies:本地 Chrome 装 “Get cookies.txt LOCALLY” 插件导出 cookies.txt,上传到 VPS 供 yt-dlp 使用——但 Cookies 会过期,过期后凌晨的定时任务静默失败。
  2. 安装 JS 运行时(Deno):YouTube 下发混淆 JS 反爬,yt-dlp 需要本地 JS 引擎(Deno/Node.js)配合破解。
  3. PO Token 机制 + bgutil 插件:Cookies 过期后进一步升级到 PO Token(Proof of Origin)方案,需要额外启动一个 Node.js 后台服务(bgutil)生成 Token,配合 Python 脚本调用。

这套组合拳能跑通,但极其脆弱:任何一环(Cookies 过期、Deno 版本、bgutil 服务掉线)出问题都会让整条流水线在无人值守时静默失败。

决定性的转向:搬回家里

关键的转折点是意识到——如果 yt-dlp 跑在家庭宽带 IP 上,YouTube 会把它当作普通用户,上面那一整套 Cookies / PO Token / Deno / bgutil 的反爬对抗全部不再需要。

于是把执行引擎从 VPS 迁移到手头现有的 Raspberry Pi 400:

维度VPS(云端机房)Pi 400(家里)
IP 信誉差,YouTube 默认视为机器人好,家庭宽带 IP 视为真人
反爬成本高(Cookies/PO Token/代理,且需持续维护)极低,基本裸奔
维护频率频繁,动不动 403低,设好定时任务基本不用管
月租有无(已有硬件)
上传带宽机房带宽充裕受限于家庭上行带宽,但音频文件不大(几十 MB),影响可忽略

迁移后脚本大幅简化,bgutil、Deno、PO Token 相关代码全部删除。但没有完全"裸奔"——即使是家庭 IP,如果短时间内集中下载上百个视频(首次全量处理),仍然可能触发 YouTube 的临时限流(429 Too Many Requests,通常封锁 24 小时)。因此保留了几个"防止过于激进"的参数:

"ratelimit": 800_000,          # 限速 800KB/s,模拟正常观看的缓冲速度
"retries": 10,
"fragment_retries": 10,
"sleep_interval": 3,           # 视频之间随机休息 3~8 秒
"max_sleep_interval": 8,
"concurrent_fragment_downloads": 1,

另外,Pi 400 用的是 SD 卡存储,频繁读写大文件(下载的原始音频、处理中间产物)是 SD 卡损坏的主因,因此把整个处理过程的临时目录设在内存文件系统 /dev/shm 中,读写完全不落盘,处理完成后自动清空;SD 卡只需要写最终的元数据 JSON 和 RSS 文件,寿命可以延长数倍。

后续变化:JS Runtime 变成硬性要求(与反爬无关)

架构稳定运行一段时间后,又遇到一次新的下载失败,但这次和上面的反爬对抗没有关系——yt-dlp 从 2025.11.12 版本起,把执行一段 YouTube 下发的混淆 JS(用来算 nsig 签名参数)变成了解析下载地址的硬性前置步骤,默认需要用 Deno 来跑这段 JS。这是 yt-dlp 自身机制升级,跟 IP 是不是家庭宽带、账号有没有登录都无关——所有人都需要装。缺了它不会完全下载失败,但会拿到错误或已失效的签名,最终请求媒体文件时被 403。具体的排查过程和安装方式见第七节和第九节。


三、核心 Bug 修复

早期分析 podcast.log 时发现三个核心问题,逐一修复:

问题 1:Gemini 模型下线(404)

HTTP/1.1 404 Not Found
models/gemini-1.5-flash is not found

gemini-1.5-flash 下线导致全部讲道的 AI 摘要失败,回退为占位文本。模型名的变化历程:

  • gemini-1.5-flash → 已下线(404)
  • gemini-2.0-flash → 计划于 2026 年 6 月关闭
  • gemini-3-flash-preview → Preview 状态不稳定
  • gemini-2.5-flash → 当前使用,已 GA,稳定
GEMINI_MODEL = os.getenv("GEMINI_MODEL", "gemini-2.5-flash")  # 从环境变量读取,切换模型只改 .env

经验:只用已 GA 的稳定版模型。Preview 模型通常只提前两周通知下线。

问题 2:跳过逻辑失效

原代码用文件名匹配 video_id:

meta_exists = any(video_id in f for f in os.listdir(meta_dir) if f.endswith(".json"))

但元数据文件名格式是 sermon-{date}-{title}.json,不包含 video_id,导致这个判断永远失败,每次重启都会从头重新处理所有视频。

修复为读取 JSON 内容匹配,并在元数据里新增 video_id 字段:

def meta_has_video(video_id: str, meta_dir: str) -> bool:
    for fn in os.listdir(meta_dir):
        if not fn.endswith(".json"):
            continue
        try:
            with open(os.path.join(meta_dir, fn), "r", encoding="utf-8") as f:
                d = json.load(f)
            if d.get("video_id") == video_id:
                return True
        except Exception:
            pass
    return False

跳过条件改为双重保险(archive 记录 + JSON 内容都命中才跳过)。旧版本处理过的 116 集元数据没有 video_id 字段,写了一次性迁移脚本 migrate_add_video_id.py:从 YouTube 播放列表拉取 {title: video_id} 映射,按标题匹配旧 JSON 补写字段,约 10 秒跑完。

问题 3:Git 推送失败无详细信息

Command '['git', 'push']' returned non-zero exit status 128

原代码没有捕获 stderr,无法判断是认证问题还是网络问题。修复:

push_res = subprocess.run(["git", "push"], capture_output=True, text=True)
if push_res.returncode != 0:
    log.error(f"❌ Git 推送失败 (exit {push_res.returncode}): {push_res.stderr.strip()}")

附带修复:RSS 格式问题

  • xmlns URL 损坏:脚本中混入了 Markdown 超链接格式([url](url))污染了 XML 属性,改回纯 URL。
  • 频道级 <description> 缺失:Apple Podcasts 要求频道必须有标准 RSS <description> 标签,只有 <itunes:summary> 不够(详见第八节)。

四、AI 摘要方案

原始方案的问题

最初的摘要只依赖 YouTube 视频描述文字:

prompt = f"讲道简介:{description[:800]}"

但很多视频描述为空或只有几个字,摘要质量很差。

升级方案:智能分级摘要

描述 ≥ 50字 → 文本模式(快,几秒)
描述 < 50字 → 本地转录(whisper.cpp / whisper)→ 用转录文本生成摘要
转录失败    → 回退文本模式
全部失败    → 占位文本

对比过直传音频给 Gemini Files API 和本地 Whisper 转录两种路线:

方案成本速度稳定性
直传音频给 Gemini Files API按分钟计费,1 小时讲道成本较高快依赖网络
本地 Whisper 转录零成本较慢完全离线

选择了本地转录路线,转录文本同时存入元数据 JSON,可复用于搜索、字幕等场景。

Whisper 内存管理

批量处理上百集时防止 OOM,模型用完立即释放:

finally:
    if model is not None:
        del model
        gc.collect()

Gemini 免费层限速与容错

免费 tier 限制:每分钟最多 5 次请求。补跑历史摘要时遇到过 429 RESOURCE_EXHAUSTED:

429 RESOURCE_EXHAUSTED
Quota exceeded: GenerateRequestsPerMinutePerProjectPerModel-FreeTier, limit: 5
Please retry in 48s

修复方案:

  • 请求间隔从 1 秒改为 15 秒(留足余量)
  • 重试次数 3 → 5,等待上限 30 → 120 秒
  • 捕获 429 时解析错误信息里的 retry in Ns,精确等待后重试,而不是盲目重试

Gemini 返回空内容 / 非纯 JSON

res.text 在 finish_reason 不是 STOP(如 MAX_TOKENS、NO_CANDIDATES)时会返回 None,直接传入下游解析会崩溃:

def _call_gemini_text(prompt: str) -> str:
    res = client.models.generate_content(...)
    if res.text is None:
        finish = getattr(res.candidates[0], "finish_reason", "UNKNOWN") \
                 if res.candidates else "NO_CANDIDATES"
        raise ValueError(f"Gemini 返回空内容(finish_reason={finish})")
    return res.text

抛出异常后交给 @retry 装饰器自动重试,不把 None 往下传。经排查,NO_CANDIDATES 通常是服务端临时异常,不是安全过滤器拦截——讲道内容触发安全过滤的概率极低,不需要设置 BLOCK_NONE。

另外,Gemini 有时不会严格遵守 response_mime_type="application/json",偶尔会在 JSON 前后夹杂说明文字,加一道正则兜底:

match = re.search(r'\{.*\}', raw, re.DOTALL)
if match:
    raw = match.group()
data = json.loads(raw)

备选方案评估:NVIDIA NIM

调研过 NVIDIA Build(build.nvidia.com)作为 Gemini 的备选——H100 算力、速度快、并发宽松,兼容 OpenAI API 格式,推荐 Qwen2.5-72B-Instruct(支持长上下文、中文能力强)。但切换需要额外处理 JSON 输出(NVIDIA 接口没有强制 JSON 参数,需在 prompt 里要求并加强解析容错)。结论:Gemini 免费层当前够用,503 只是临时过载,暂不切换,等真正遇到额度瓶颈再考虑双引擎 fallback。


五、语音转录:openai-whisper 迁移到 whisper.cpp

为什么升级

指标openai-whisper(Python)whisper.cpp
转录耗时(1 小时音频)~30 分钟812 分钟
内存峰值~500 MB(PyTorch)~150 MB
PyTorch 依赖是(~2GB 安装包)无
OOM 风险偶发(小内存设备)极低
中文识别质量相同相同(同一模型权重)
安装难度pip install 一行需编译(约 10 分钟)

模型权重完全一样,识别质量无差别,速度优势纯粹来自 C++ 实现 + ARM NEON 指令集,速度快 3 倍、内存减少 70%,是比降噪升级优先级更高的改造。

安装步骤(树莓派 4,ARM Cortex-A72,4/8GB)

sudo apt update
sudo apt install -y git cmake build-essential ffmpeg

cd ~
git clone https://github.com/ggerganov/whisper.cpp
cd whisper.cpp

# 开启 ARM NEON + OpenMP 多核并行,树莓派4 必选
cmake -B build \
  -DGGML_NATIVE=ON \
  -DWHISPER_BUILD_EXAMPLES=ON \
  -DWHISPER_OPENBLAS=OFF
cmake --build build --config Release -j4
# 编译约 5~8 分钟

# 推荐 base 模型(中文识别质量 vs 速度的最佳平衡点)
./models/download-ggml-model.sh base
# 可选 small:质量更好,约慢 2.5x,内存占用约 500MB

# 验证
./build/bin/whisper-cli -m models/ggml-base.bin -f /path/to/test.wav -l zh

配置写入 .env(脚本自动检测,优先使用 whisper.cpp,找不到则回退 Python whisper):

WHISPER_CPP_BIN=/home/pi/whisper.cpp/build/bin/whisper-cli
WHISPER_CPP_MODEL=/home/pi/whisper.cpp/models/ggml-base.bin

踩坑:非 UTF-8 输出

whisper.cpp 偶尔输出非 UTF-8 字节(Big5/GB2312 编码残留),需要加容错,否则整条转录会因为一个字节崩掉:

# 不用 text=True,避免 subprocess 内部解码直接失败
result = subprocess.run([...], capture_output=True)
stderr = result.stderr.decode("utf-8", errors="replace")[:200]

# 读取 txt 转录文件时同样加容错
with open(txt_path, "r", encoding="utf-8", errors="replace") as f:
    transcript = f.read().strip()

屏蔽 SDK 噪音日志

以下两行是 Gemini SDK 的正常日志,不是报错:

AFC is enabled with max remote calls: 10.
HTTP Request: POST ... "HTTP/1.1 200 OK"
logging.getLogger("google").setLevel(logging.WARNING)

六、音频质量优化

会场录制的典型问题

教堂现场录音混响重、人声距离感强、底噪明显(空调、话筒本底)、音量不稳定。

基础处理:ffmpeg loudnorm

无论选哪种降噪方案,音量标准化都是必做的基础步骤:

ffmpeg -i input.mp3 \
  -af "loudnorm=I=-16:TP=-1.5:LRA=11" \
  -ac 2 -b:a 96k \
  output.mp3
  • loudnorm=I=-16:EBU R128 标准,-16 LUFS,播客行业标准响度
  • -ac 2:双声道(修复 Spotify 单耳 bug,见第八节)
  • -b:a 96k:讲道人声足够,有诗歌敬拜段落时建议用 96k stereo 而非 64k mono

降噪方案对比

先后测试过四种方案:

方案CPU 占用1 小时音频耗时降噪效果结论
A. 纯 ffmpeg(highpass+loudnorm+acompressor+lowpass)极低2~3 分钟★★★☆☆混响基本无效,底噪轻微改善
B. DeepFilterNet(AI 降噪)高,4 核满载Python 版 40~90 分钟;Rust CLI 约 34 分钟★★★★★效果最好,但会阻塞 Pi 400 后续转录/上传,不适合放入主流水线
C. RNNoise(arnndn 滤镜)极低约 5~8 分钟★★★★☆速度快、效果好,最终采用为主流水线
D. Demucs(人声分离)极高3~6 小时效果天花板树莓派上不可用于生产,留作未来有 GPU 时的选项

关于众人齐声读经的场景:降噪算法处理不好"众人朗读 + 牧师讲道"同时存在的录音,因为二者声音频率重叠,算法无法区分"主声"和"背景声"。这是录音硬件层面的限制,后期处理无法根本解决,根本解法是录音端使用定向麦克风或领夹麦。

DeepFilterNet:绕开 PyTorch 的尝试

Python 版 deepfilternet 依赖 PyTorch(约 800MB),在 ARM 上安装慢、启动慢、内存占用高。改用官方 Rust CLI,推理走 ONNX(tract 后端),不依赖 Python:

curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source ~/.cargo/env

git clone https://github.com/Rikorose/DeepFilterNet
cd DeepFilterNet/libDF
cargo build --release --bin deep-filter --features "bin,tract,wav-utils,transforms"
# 编译约 28 分钟(Pi 400)

sudo cp target/release/deep-filter /usr/local/bin/

踩坑记录:

  • cargo install deep-filter / -p deep-filter 均失败,包名实为下划线 deep_filter,crates.io 上也搜不到,必须从源码编译
  • --bins 无效,bin target 有 feature 依赖,必须显式指定 --features
  • 编译产物在项目根 target/,不在子目录 libDF/target/
  • deep-filter input.wav -o /dir/ 的 -o 是目录不是文件名,会同名覆盖

实测 Pi 400 上约 0.5× 实时速度(1 小时音频约 34 分钟),效果确实是几个方案里最好的,但会让主流水线阻塞,因此没有放进日常自动运行的主流水线,而是做成独立脚本 reprocess.py,只用来单独重跑音质特别差的几集,处理完自动覆盖 R2 上的文件。

最终决策

主流水线:RNNoise(sh.rnnn 模型,mix=0.6)+ loudnorm。

RNNOISE_MODEL = "/home/juan/pabloye-podcast/arnndn-models/sh.rnnn"

af = (
    f"arnndn=m={RNNOISE_MODEL}:mix=0.6,"
    "loudnorm=I=-16:TP=-1.5:LRA=11"
)
subprocess.run([
    "ffmpeg", "-i", input_path, "-af", af,
    "-ac", "2", "-b:a", "96k", "-y", output_path
], check=True, capture_output=True)

RNNoise 模型确认已内置于 ffmpeg(ffmpeg -filters | grep rnndn 有输出即可用,无需重新编译);arnndn-models 仓库提供了 6 个模型,教堂/办公室场景用 sh.rnnn 效果最接近;mix 参数从 0.6(保守,人声最自然)到 1.0(激进,可能损伤人声)可调。测试中曾经出现 mix 偏高时有轻微哨叫声副作用,最终固定在 mix=0.6 作为保守起点,兼顾降噪效果与人声自然度;找不到 RNNoise 模型文件时自动回退到原始 v3 滤波链(highpass + loudnorm + acompressor + lowpass)。

决策树

录音质量好吗?(背景噪声轻,无明显混响)
│
├── 是 → ffmpeg loudnorm only 已经够用
│
└── 否(有底噪/混响)
    │
    ├── 主流水线(自动运行,稳定优先)
    │   └── RNNoise(sh.rnnn, mix=0.6)+ loudnorm
    │
    ├── 差音频单独处理(reprocess.py,手动触发)
    │   └── DeepFilterNet Rust CLI,效果更好但耗时更长
    │
    └── 众人齐声读经场景
        └── 降噪算法无法区分主声/背景声,后期处理无法根本解决

七、部署与运维(Pi 400)

系统准备

sudo apt update && sudo apt upgrade -y
sudo apt install -y python3-pip python3-venv ffmpeg git

mkdir ~/pabloye-podcast && cd ~/pabloye-podcast
git init
git remote add origin https://github.com/你的用户名/你的仓库.git

python3 -m venv venv
source venv/bin/activate
pip install yt-dlp boto3 google-genai python-dotenv tenacity

.env 配置:

cat > .env << 'ENVEOF'
GEMINI_API_KEY=你的密钥
R2_ACCESS_KEY=你的密钥
R2_SECRET_KEY=你的密钥
R2_ENDPOINT=https://你的账户ID.r2.cloudflarestorage.com
WHISPER_CPP_BIN=/home/pi/whisper.cpp/build/bin/whisper-cli
WHISPER_CPP_MODEL=/home/pi/whisper.cpp/models/ggml-base.bin
ENVEOF

安装 Deno(yt-dlp 的 JS Runtime 依赖)

先确认架构(Deno 官方只提供 64 位 ARM 构建,32 位 armv7l 没有官方二进制):

uname -m   # aarch64 → 官方支持;armv7l → 官方不支持,见下方替代方案

aarch64(64 位系统)安装步骤:

sudo apt install -y unzip   # 安装脚本依赖,缺了会直接报错退出
curl -fsSL https://deno.land/install.sh | sh
~/.deno/bin/deno --version   # 正常应输出版本号,如 deno 2.7.11 (aarch64-unknown-linux-gnu)

# 挪到系统级路径,避免 systemd 的精简 PATH 找不到(cron/systemd 触发时和登录 shell 的 PATH 不一样)
sudo mv ~/.deno/bin/deno /usr/local/bin/deno
sudo chmod +x /usr/local/bin/deno
which deno   # 确认输出 /usr/local/bin/deno

踩坑:bash 哈希缓存——如果同一个 SSH 会话里,mv 之前已经跑过一次 deno --version,bash 会把旧路径缓存进内部哈希表,mv 之后再执行 deno --version 会报 No such file or directory,即使 which deno 显示的已经是新路径。这不是安装失败,执行 hash -r 清一下缓存,或者开个新终端 / 重连一次 SSH 就正常了。systemd 触发的是全新进程,不会有这个问题,纯粹是当前 shell 会话的历史遗留。

armv7l(32 位系统)—— Deno 没有官方构建,换用 Node.js:

curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash -
sudo apt install -y nodejs
node --version

yt-dlp 支持 Deno / Node.js / Bun / QuickJS 四种 JS Runtime,Node 官方有 armv7l 构建,32 位系统这条路走得通,后面命令里把 deno 换成 node 即可。

验证 + 补一个常被忽略的开关(--remote-components ejs:github):

cd ~/pabloye-podcast && source venv/bin/activate
yt-dlp --js-runtimes deno --remote-components ejs:github \
  -f 251 "https://www.youtube.com/watch?v=xxxxxxxxxxx" -o test.webm

只装 Deno 只解决了"有没有 JS 引擎可以执行代码",--remote-components ejs:github 解决的是另一半——授权 yt-dlp 去下载实际的解题脚本(yt-dlp-ejs)。两者都装了,日志里才不会再出现 n challenge solving failed 这类警告;只有 Deno 没有这个开关时,多数情况能凑合跑,但 “n challenge”(YouTube 的一种防滥用/限速参数混淆)解不出来,随时可能变回 403 或被限速。

接入 Python 脚本(build_download_opts() 里加两个键即可,其他参数不用动):

"js_runtimes": {"deno": {}},          # 注意是 dict,不是 list
"remote_components": ["ejs:github"],  # 这个是 list

踩坑:js_runtimes 和 remote_components 格式不一样,容易顺手写错。js_runtimes 要求是字典 {运行时名: 配置字典},写成列表 ["deno"] 会直接报错退出:

Invalid js_runtimes format, expected a dict of {runtime: {config}}

这是查 yt-dlp 源码 YoutubeDL.py 里的初始化逻辑确认的:

self.params['js_runtimes'] = self.params.get('js_runtimes', {'deno': {}})

remote_components 才是列表,两个键的写法不能对称记忆。如果 deno 不在默认 PATH 里,字典还能顺带指定可执行文件路径:{"deno": {"path": "/usr/local/bin/deno"}}。

Git 推送:SSH 密钥而非 Token

部署文档里曾经考虑过用 GitHub Token 写进 remote URL,权衡后选择 SSH 密钥,理由很实际:

Token 的问题:有过期时间(GitHub 默认最长 1 年),到期后凌晨的 cron 任务会静默失败而不自知;且 Token 明文写在 remote URL 里,git remote -v、ps aux 都能直接看到。

SSH 密钥的优势:永不过期;私钥只存在 Pi 本地,从不传输;cron 任务和手动推送行为完全一致。

# 1. 生成密钥(cron 无人值守必须无密码,一路回车)
ssh-keygen -t ed25519 -C "pi-podcast" -f ~/.ssh/github_podcast

# 2. 配置 SSH 指定这个密钥连 GitHub
cat >> ~/.ssh/config << 'EOF'
Host github.com
    IdentityFile ~/.ssh/github_podcast
    User git
EOF

在 GitHub 仓库 Settings → Deploy keys → Add deploy key 里粘贴公钥内容(cat ~/.ssh/github_podcast.pub),勾选 Allow write access。然后:

git remote set-url origin git@github.com:你的用户名/你的仓库名.git
ssh -T git@github.com   # 看到 "Hi xxx! You've successfully authenticated" 即成功

之后 git push 和 cron 任务都不再需要任何密码或 Token。

定时运行:systemd timer 优于 cron + screen

早期方案是简单的 crontab:

crontab -e
# 每天早6点自动运行
0 6 * * * cd /home/pi/pabloye-podcast && /home/pi/pabloye-podcast/venv/bin/python3 podcast-master.py >> /home/pi/pabloye-podcast/cron.log 2>&1

后来改用 systemd service + timer,原因是:断线重连不影响运行;失败自动重试;Persistent=true 能在系统重启后自动补跑错过的任务;Nice=10 让 Whisper 满载 CPU 时 Pi 仍能正常使用。

podcast-pipeline.service:

[Unit]
Description=Podcast Pipeline
After=network-online.target
Wants=network-online.target
StartLimitBurst=3
StartLimitIntervalSec=3600

[Service]
Type=oneshot
User=juan
WorkingDirectory=/home/juan/pabloye-podcast
ExecStart=/home/juan/pabloye-podcast/venv/bin/python3 /home/juan/pabloye-podcast/podcast-master-pi-v4-final.py
TimeoutStartSec=0        # 0=永不超时,首次全量116集约需80小时
Restart=on-failure
RestartSec=300
Nice=10
StandardOutput=append:/home/juan/pabloye-podcast/podcast.log
StandardError=append:/home/juan/pabloye-podcast/podcast.log

podcast-pipeline.timer:

[Timer]
OnCalendar=*-*-* 03:00:00   # 每天凌晨3点
Persistent=true              # 错过后补跑
RandomizedDelaySec=900       # 随机延迟0~15分钟

[Install]
WantedBy=timers.target
sudo cp podcast-pipeline.service podcast-pipeline.timer /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable --now podcast-pipeline.timer

# 手动触发一次
sudo systemctl start podcast-pipeline.service
# 查看日志
sudo journalctl -u podcast-pipeline.service -f

踩坑:StartLimitBurst/StartLimitIntervalSec 必须放在 [Unit] 段,放进 [Service] 会被静默忽略并报警告;TimeoutStartSec 不支持行内 # 注释。

让 Pi 不休眠

sudo raspi-config
# Display Options → Screen Blanking → No

sudo systemctl mask sleep.target suspend.target hibernate.target hybrid-sleep.target

Pi 400 WiFi 断连问题

现象:SSH 隔一段时间无响应,需要先 ping 才能重新连通,ping 连续超时后突然 1000ms+ 才恢复。

原因:不是系统休眠,是 WiFi 网卡省电模式独立生效——即使禁了系统休眠,网卡本身仍会定期断开重连。新版内核下 iwconfig wlan0 power off 已失效,需要用:

# 方法1:NetworkManager 配置(推荐)
sudo tee /etc/NetworkManager/conf.d/wifi-powersave-off.conf << 'CONF'
[connection]
wifi.powersave = 2
CONF
sudo systemctl restart NetworkManager

# 方法2:systemd 服务永久生效
sudo tee /etc/systemd/system/wifi-powersave-off.service << 'SVC'
[Unit]
Description=Disable WiFi Power Save
After=network.target
[Service]
Type=oneshot
ExecStart=/sbin/iw dev wlan0 set power_save off
RemainAfterExit=yes
[Install]
WantedBy=multi-user.target
SVC
sudo systemctl enable --now wifi-powersave-off.service

根本解决:Pi 400 有网口,接有线后 WiFi 问题彻底消失,长时间运行更稳定。无显示器时断网恢复步骤:拔电源等 5 秒重插 → 等 1 分钟系统启动 → ping pi400 验证 → SSH 连入后立即接网线。数据全在 SD 卡,断电重启不会丢失。

常见问题

# yt-dlp 报 "Sign in to confirm you're not a bot":家庭 IP 不应出现,若出现先更新 yt-dlp
pip install -U yt-dlp

# 手动触发一次运行
cd ~/pabloye-podcast && source venv/bin/activate && python3 podcast-master-pi-v4-final.py

# 第二天检查日志
cat ~/pabloye-podcast/cron.log
cat ~/pabloye-podcast/podcast.log

八、发布问题排查(Apple Podcasts / Spotify)

封面图不符合要求

Apple Podcasts 要求:精确 3000×3000 像素,JPG/PNG,RGB 色彩空间(不能是 CMYK),服务器要支持 HTTP HEAD 请求。原始封面只有 972×971,用 ImageMagick 处理:

convert cover.jpg \
  -filter Lanczos -resize 3000x3000^ \
  -gravity center -extent 3000x3000 \
  -colorspace sRGB -type TrueColor -quality 95 \
  cover_new.jpg

identify cover_new.jpg  # 验证:3000x3000 sRGB

频道级 description 缺失

错误提示:This field is required, enter it in your RSS。只有 <itunes:summary> 不够,Apple 要求 <channel> 内必须有标准 RSS <description>:

<description><![CDATA[欢迎收听叶牧讲道播客频道。基督教生命堂 · Valencia, Spain]]></description>
<itunes:type>episodic</itunes:type>
<itunes:summary><![CDATA[欢迎收听叶牧讲道播客频道。基督教生命堂 · Valencia, Spain]]></itunes:summary>

Spotify 单声道 Bug

现象:用 AirPods Pro 在 Spotify 听,只有一个耳机有声音;Apple Podcasts 和手机喇叭正常。

原因:脚本曾输出 -ac 1(单声道),Spotify 客户端有 bug,会把单声道 mp3 只路由到左声道。

修复:ffmpeg 输出统一改为双声道 -ac 2。由于历史备份文件的声道情况混乱(1、2 交替出现),写了 reencode_stereo.py 全量重处理:用 ffprobe 检测每个文件声道数,双声道且不需降噪则跳过,单声道则降噪 + 重编码为双声道后上传覆盖 R2,每处理一个更新一次 RSS。

ffprobe -v error -select_streams a:0 -show_entries stream=channels -of csv=p=0 input.mp3

RSS 安全写入机制(三重保护)

reencode_stereo.py 运行时曾发生 RSS 内容被意外覆盖的情况,之后所有写 RSS 的操作都必须经过:

def safe_write_rss(rss_path: str, content: str) -> bool:
    # 保护1:验证集数,空内容或比现有少10集以上 → 拒绝写入
    item_count = content.count("<item>")
    if item_count == 0:
        return False
    if existing_count > 0 and item_count < existing_count - 10:
        return False

    # 保护2:备份现有文件为 .bak
    shutil.copy2(rss_path, rss_path + ".bak")

    # 保护3:先写 .tmp 再原子替换(断电不损坏原文件)
    with open(rss_path + ".tmp", "w") as f:
        f.write(content)
    os.replace(rss_path + ".tmp", rss_path)

紧急恢复:

cp sermon.xml.bak sermon.xml
git add sermon.xml && git commit -m "restore RSS" && git push

播客名称/域名更新

改名批量替换(如"主日讲道"→“叶牧讲道”):

sed -i 's/"name": "主日讲道"/"name": "叶牧讲道"/g' podcast-master-pi-v4-final.py
sed -i 's/主日讲道/叶牧讲道/g' regenerate_summaries.py sermon.xml

Spotify/Apple Podcasts 会在 24 小时内自动同步新名称。换域名(例如迁移到自有域名 pabloye.es):

sed -i 's|pabloye.pages.dev|pabloye.es|g' \
  podcast-master-pi-v4-final.py regenerate_summaries.py reencode_stereo.py sermon.xml
sed -i 's|pub-xxx.r2.dev|cdn.pabloye.es|g' \
  podcast-master-pi-v4-final.py regenerate_summaries.py sermon.xml metadata_sermon/*.json

grep -r "pages.dev\|r2.dev" podcast-master-pi-v4-final.py sermon.xml | head -5

Cloudflare R2 支持直接绑定自定义域名(CNAME),不需要迁移文件,改 DNS 记录即可。

macOS 测试环境踩坑:BSD sed 的 -i 必须带备份后缀参数(哪怕是空字符串 ''),Linux(树莓派)版 GNU sed 不需要。跨平台更可靠的写法是 perl -pi -e 's|old|new|g' file。


九、运维踩坑合集

元数据补全(backfill)

问题发现:sermon.xml 只有 32 集,metadata_sermon/ 目录也只有 32 个 JSON,但本地备份目录 vps_backup_sermon 却有 111 个音频文件。排查发现:旧脚本在 Gemini 大量 404 失败期间多次中断,音频下载并备份了,但"生成摘要 → 写元数据"这一步失败,导致近 80 集有音频无元数据。

ls metadata_sermon/*.json | wc -l        # 元数据数量
ls vps_backup_sermon/*.mp3 | wc -l       # 备份音频数量
grep -c "<item>" sermon.xml              # RSS 集数

for f in vps_backup_sermon/*.mp3; do
  base=$(basename "$f" .mp3)
  [ ! -f "metadata_sermon/${base}.json" ] && echo "$base"
done | wc -l

backfill_metadata.py 从本地备份直接读取音频,走完整的 Whisper → Gemini → 写 JSON → 更新 RSS 流水线补全缺口。

AI 摘要补跑

历史集的 summary 全是占位文本"本期讲道详细内容,欢迎收听与分享"(因为当时 Gemini 1.5-flash 已下线,全部 503/404 失败)。regenerate_summaries.py 的关键设计:

  1. 优先使用已存的转录文本,无需重新下载和转录
  2. 描述不足时从 R2 下载音频临时转录,不需要重跑整条流水线
  3. 每处理 10 集自动保存一次 RSS,可随时 Ctrl+C 中断续跑
  4. 503 走指数退避自动重试
  5. 支持 --dry-run(预览待处理文件)和 --file(单条调试)
# 删掉占位文本的 json,让脚本重新生成高质量摘要
for f in metadata_sermon/*.json; do
  grep -q "欢迎收听与分享" "$f" && echo "删除: $f" && rm "$f"
done

流程中断自动修复

问题背景:R2 上传偶发连接失败(Cloudflare 故障)时,音频已下载/转录/生成摘要完毕,但上传失败抛出异常,JSON 未写入;而 yt-dlp 的 download_archive 已经记录了这个 video_id。下次运行时:archive 有记录 → yt-dlp 跳过下载 → 临时目录里没有 mp3 → 静默返回 False。结果:这一集永久丢失,JSON 和 R2 都没有。

修复为两层校验:

# 外层(主循环):archive 有记录但 JSON 无 → 自动从 archive 移除该 video_id,触发重新下载
if is_in_archive(archive_file, video_id):
    if meta_has_video(video_id, meta_dir):
        continue  # 正常跳过
    else:
        with open(archive_file, "r") as f:
            lines = f.readlines()
        with open(archive_file, "w") as f:
            f.writelines(l for l in lines if video_id not in l)
        # 不 continue,继续往下处理

# 内层(单集处理):JSON 存在但 R2 无文件 → 尝试从本地备份补传
if os.path.exists(meta_path):
    try:
        s3.head_object(Bucket=R2_BUCKET_NAME, Key=audio_key_check)
        continue
    except Exception:
        if os.path.exists(backup_path):
            s3.upload_file(backup_path, ...)
        continue

播放列表多源支持

YouTube 频道无管理权限,无法直接合并播放列表,因此支持在 playlists.txt 中配置多个外部播放列表 URL(# 开头为注释),合并时用 seen_ids 去重,避免同一视频被处理两次;所有视频共用同一套 archive_sermon.txt、metadata_sermon/、sermon.xml 和 R2 目录。playlists.txt 已加入 .gitignore,不提交到公开仓库。

yt-dlp 版本滞后 → android_vr 客户端 403

装好 Deno 之后一度仍然全部下载失败,日志里能看到 JS runtime 的警告已经消失,但换成了新的报错:

[youtube] c4xd1tdQi-8: Downloading android vr player API JSON
[info] c4xd1tdQi-8: Downloading 1 format(s): 251
ERROR: unable to download video data: HTTP Error 403: Forbidden

排查发现 android_vr 这个 yt-dlp 内部用来请求视频信息的"模拟客户端"最近几个月一直不稳定,YouTube 时不时针对它的纯音频流(251 这类 itag)做临时封锁或 A/B 测试,yt-dlp 官方靠"换一个模拟客户端"或者"发新版"来应对,属于两边拉锯的常态。真正的线索其实在报错最上面:

WARNING: Your yt-dlp version (2026.03.17) is older than 90 days!

版本停在半年前,中间 yt-dlp 已经发布过好几个专门修 android_vr/android_sdkless 403 的版本。升级到最新版后问题直接消失:

pip install -U yt-dlp

教训:YouTube 端的这类调整几乎按周甚至按天在变,yt-dlp 版本滞后是最容易被忽视、但影响面最大的故障源——比 Deno、Cookies 这些"一次性装好就不用管"的依赖更需要持续维护。建议在流水线每次运行前自动升级一次:

/home/juan/pabloye-podcast/venv/bin/pip install -U yt-dlp --quiet

放进 systemd service 触发脚本最前面,或者主脚本里用 subprocess 调一下,避免再因为版本滞后半年导致大批量视频集中失败。如果升级到最新版后仍然 403,再考虑显式排除有问题的客户端:

yt-dlp --extractor-args "youtube:player_client=default,-android_vr" ...

git add . 误提交测试文件,Cloudflare Pages 部署失败

排查 Deno/yt-dlp 问题期间,为了单独验证下载是否正常,在 ~/pabloye-podcast 目录(也就是 Git 仓库本身)里手动跑过:

yt-dlp --js-runtimes deno -f 251 "https://www.youtube.com/watch?v=xxxxxxxxxxx" -o test.webm

测试完文件没删。当天自动流水线照常运行,git_push() 里原来的写法是:

subprocess.run(["git", "add", "."], check=True)

git add . 不分青红皂白把工作目录里所有文件都加了进去,连同这个测试遗留的 test.webm 一起提交推送。Cloudflare Pages 构建时报错:

✘ [ERROR] Error: Pages only supports files up to 25 MiB in size
  test.webm is 40.4 MiB in size

单文件超过 Cloudflare Pages 25MiB 的硬限制,整个部署直接失败。

修复分两层:

  1. 立即止血——把已经提交的文件删掉:

    cd ~/pabloye-podcastgit rm test.webmgit commit -m "🧹 移除误提交的测试文件 test.webm"git push
    
  2. 根治——git add . 换成白名单,只添加流水线真正会产出的文件,用 CHANNELS 循环生成路径而不是写死频道名(以后加新频道也不用改这段):

    tracked_paths = ["cover.jpg"]for c in CHANNELS:    tracked_paths.append(c["rss_name"])    tracked_paths.append(f"metadata_{c['folder']}/")    tracked_paths.append(f"archive_{c['folder']}.txt")existing_paths = [p for p in tracked_paths if os.path.exists(p.rstrip("/"))]if existing_paths:    subprocess.run(["git", "add", "--"] + existing_paths, check=True)
    

    过滤掉不存在的路径再传给 git add,避免某个文件还没生成时直接报错中断整个提交。

再补一份 .gitignore 作为第二道保险,即使以后又不小心手滑用了 git add .,也能先挡住测试文件和本该保密的文件:

test*.webm
test*.mp3
test*.wav
*.tmp
.env
venv/
playlists.txt
vps_backup_*/

教训:手动调试时不要在 Git 仓库目录里直接跑测试命令,改到 /tmp 或 /dev/shm 下跑,和流水线本身处理临时文件的习惯保持一致,就不会把调试留下的文件带进仓库。

_safe_remove 缺失

主脚本用到的 _safe_remove 函数一度未定义,导致 NameError,需放在工具函数区最前面(clean_old_backups 之前):

def _safe_remove(path: str):
    try:
        if path and os.path.exists(path):
            os.remove(path)
    except Exception:
        pass

十、最终架构总结

脚本清单

文件用途状态
podcast-master-pi-v4-final.py主脚本,日常自动运行长期使用(systemd timer,每天凌晨触发)
podcast-pipeline.service / .timersystemd 服务与定时器长期使用
regenerate_summaries.py补跑 AI 摘要按需使用
reprocess.py差音频单条重新处理,支持 DeepFilterNet,覆盖 R2按需使用
reencode_stereo.py音频重处理(降噪+双声道)已完成使命,保留备用
backfill_metadata.py补全缺失元数据保留存档
migrate_add_video_id.py迁移旧元数据补写 video_id已完成使命
playlists.txt播放列表 URL 配置(.gitignore)长期使用

目录结构

/home/juan/pabloye-podcast/
├── podcast-master-pi-v4-final.py   # 主脚本
├── regenerate_summaries.py         # 摘要补跑
├── reencode_stereo.py              # 音频重处理
├── migrate_add_video_id.py         # 元数据迁移
├── sermon.xml                      # RSS feed
├── sermon.xml.bak                  # RSS 自动备份
├── cover.jpg                       # 播客封面 3000x3000
├── archive_sermon.txt              # yt-dlp 下载记录
├── podcast.log                     # 主运行日志
├── metadata_sermon/                # 每集元数据 JSON
│   └── sermon-YYYYMMDD-标题.json
└── vps_backup_sermon/              # 已处理音频本地备份
    └── sermon-YYYYMMDD-标题.mp3

元数据 JSON 结构

{
  "title": "约翰福音3:16 | 神爱世人",
  "summary": "中文摘要\n\n📖 约翰福音3:16\n\n🇪🇸 Dios amó al mundo",
  "pubDate": "Sun, 03 May 2020 00:00:00 +0000",
  "url": "https://cdn.pabloye.es/sermon/sermon-20200503-xxx.mp3",
  "duration": 3591,
  "size": 32961357,
  "filename_id": "sermon-20200503-xxx",
  "video_id": "Fu3SrzykW9g",
  "transcript": "转录文字前5000字..."
}

常用运维命令

# 实时日志
tail -f ~/pabloye-podcast/podcast.log
sudo journalctl -u podcast-pipeline.service -f

# 查看定时任务、已处理集数
systemctl list-timers podcast-pipeline.timer
ls ~/pabloye-podcast/metadata_sermon/ | wc -l

# 手动立即运行一次
sudo systemctl start podcast-pipeline.service

# RSS 被意外覆盖时恢复
cp sermon.xml.bak sermon.xml
git add sermon.xml && git commit -m "restore" && git push

# 检查音频声道
ffprobe -v error -select_streams a:0 -show_entries stream=channels -of csv=p=0 input.mp3

十一、关键经验总结

  1. 模型选择只用已 GA 的稳定版:Preview 模型通常只提前两周通知下线,会导致所有摘要失败。当前用 gemini-2.5-flash,从环境变量读取,切换模型不改代码。
  2. 跳过逻辑要用内容匹配,不能只信文件名:元数据文件名不含 video_id,必须读 JSON 内容判断是否已处理。
  3. Whisper 处理完立即释放模型:del model; gc.collect(),批量处理时防止 OOM。
  4. 音频统一双声道输出:Spotify 有 bug 会把单声道 mp3 只路由到左声道,始终输出 -ac 2。
  5. RSS 写入要有三重保护:验证集数 + 原子写入 + 自动备份,一次错误覆盖会影响所有播客客户端。
  6. systemd 优于 cron + screen:Persistent=true 保证错过的任务会补跑,Nice=10 让系统保持响应。
  7. Gemini 免费层限速要精确等待:每分钟 5 次请求上限,429 错误信息里有精确等待时间,解析后 sleep 而不是盲目重试。
  8. 备份音频数量 ≠ 元数据完整:两者数量要定期核对,不一致说明中途处理失败,需要跑补全脚本。
  9. DeepFilterNet 可以绕开 PyTorch:用官方 Rust CLI 预编译二进制(aarch64),是在树莓派上运行 SOTA 降噪的可行路径,但耗时太长不适合放进自动化主流水线。
  10. RNNoise 已内置 ffmpeg:arnndn 滤镜无需额外安装,效果优于纯 loudnorm,速度也够快,是当前主流水线的降噪方案(mix=0.6 是教堂场景的保守起点)。
  11. whisper.cpp 优先于降噪升级:同一模型权重,速度快 3 倍,内存减少 70%,无 PyTorch 依赖,是投入产出比最高的一次改造。
  12. 降噪对众人齐声读经场景无效:主声和背景声频率重叠,算法无法区分,这是录音硬件层面的限制,后期处理解决不了,根本解法在录音端(定向麦克风/领夹麦)。
  13. 家庭 IP 是解决 YouTube 反爬最彻底的方案:与其在云端和 Cookies/PO Token/JS 运行时的对抗中反复维护,不如把执行引擎搬到家庭网络——但即便如此,首次批量下载仍要保留基础限速参数,避免触发临时限流。
  14. /dev/shm 内存盘保护 SD 卡寿命:临时文件全部读写在内存文件系统中,处理完自动清空,SD 卡只需要写最终产物。
  15. SSH 密钥优于 Token 做 Git 自动推送:Token 会过期且明文可见,SSH 密钥永不过期、私钥不出本机,cron 任务和手动推送行为完全一致。
  16. whisper.cpp 输出要做编码容错:subprocess.run 不加 text=True,读取转录文件用 errors="replace",避免偶发的非 UTF-8 字节导致整条转录失败。
  17. 多播放列表要去重:多个播放列表可能包含同一视频,合并时用 seen_ids set 去重,否则同一视频会被处理两次。
  18. 敏感文件不进 Git:.env、playlists.txt、备份音频目录、元数据目录、日志全部加入 .gitignore。
  19. 流程中断要能自愈:archive 有记录但元数据缺失,说明流程中途断了,脚本应自动从 archive 移除该记录并重新处理,而不是让这一集永久丢失。
  20. Gemini res.text 可能为 None:finish_reason 不是 STOP 时会返回空,加 None 检查并抛出异常交给 @retry 处理,不要让空值往下传导致崩溃。
  21. macOS 与 Linux 的 sed -i 语法不同:跨平台测试脚本时用 perl -pi -e 更可靠,避免踩 BSD sed 的坑。
  22. JS Runtime 是 yt-dlp 的硬性依赖,和反爬对抗无关:2025.11.12 之后所有人都需要装 Deno/Node 才能正常解析 YouTube 下载地址,装好后还要额外开 --remote-components ejs:github 才算完整,只装一半日志会有 n challenge solving failed 的隐性警告;接入 Python 时 js_runtimes 是 dict({"deno": {}}),remote_components 是 list,两者格式不对称,写混了会直接报错退出。
  23. Deno 官方只支持 64 位 ARM:armv7l(32 位)没有官方构建,装之前先 uname -m 确认架构,32 位系统改用 Node.js(yt-dlp 同样支持)。
  24. yt-dlp 要跟着 YouTube 的节奏持续升级:android_vr 等模拟客户端会被 YouTube 不定期针对性封锁,版本滞后几个月就可能大批量 403,建议流水线每次运行前自动 pip install -U yt-dlp。
  25. systemd 触发的进程和交互式 shell 环境不完全一样:bash 的命令路径哈希缓存、精简过的 PATH,都只在当前登录 shell 里起作用,systemd 服务是全新进程,排查"命令行测试正常但定时任务失败"这类问题时要分清是哪一层的环境差异。
  26. git add . 在自动化脚本里很危险:仓库目录如果同时也是手动调试的工作目录,任何测试遗留文件都会被下一次自动提交一起带上,而且往往在体积超限(如 Cloudflare Pages 25MiB 单文件限制)触发部署失败时才会被发现。自动化流水线里的 git 提交步骤应该用白名单显式列出要追踪的文件,配合 .gitignore 双保险;手动调试也尽量避开 Git 仓库目录本身,改用 /tmp、/dev/shm 这类临时目录。

硬件:Raspberry Pi 400 | 软件栈:Python 3.11 + yt-dlp + RNNoise + whisper.cpp + Gemini 2.5 Flash + ffmpeg