YouTube视频转播客系统(思路版)

节目列表:“叶牧讲道”

https://www.youtube.com/playlist?list=PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ

为了将这套“YouTube视频转播客”系统打造为真正生产级、全球化、且具备 AI 能力的平台,我们需要对您现有的脚本架构进行深度优化。

这份优化方案将解决三个核心痛点:中国访问稳定性、AI 自动化处理、以及系统长期运行的低维护性。


🛠️ 方案总架构:全球化“福音中转站”

  • 执行层 (VPS): 仅负责 yt-dlp 下载和初步转码,不存储文件。

  • 存储层 (Cloudflare R2): 作为全球音频分发站(10GB 免费,中国直连友好)。

  • AI 增强层 (OpenAI/Whisper): 自动提取讲道金句和摘要。

  • 展示层 (GitHub Pages + Cloudflare CDN): 托管 RSS 订阅源,永不掉线。


1. 核心脚本优化:fetch.sh (生产级增强)

优化点:增加错误重试、元数据抓取、以及针对 R2 的极速同步。

Bash

#!/bin/bash
# 自动退出并记录日志
set -e
LOG_FILE="podcast_sync.log"

# 配置区
CONFIG_FILE="config.json"
TEMP_DIR="./temp_audio"
mkdir -p $TEMP_DIR

echo "[$(date)] 开始同步任务..." | tee -a $LOG_FILE

# 读取配置循环处理
jq -c '.channels[]' $CONFIG_FILE | while read ch; do
    NAME=$(echo $ch | jq -r .name)
    URL=$(echo $ch | jq -r .youtube)
    BUCKET=$(echo $ch | jq -r .bucket)

    echo "正在处理频道: $NAME"

    # 1. 增强版 yt-dlp:抓取音频、封面、简介json
    # --download-archive 确保不重复下载
    yt-dlp -x --audio-format mp3 --audio-quality 0 \
        --write-thumbnail --convert-thumbnails jpg \
        --write-info-json \
        --download-archive "archive_${BUCKET}.txt" \
        -o "${TEMP_DIR}/${BUCKET}-%(upload_date)s-%(title)s.%(ext)s" \
        --limit-rate 10M "$URL"

    # 2. 自动上传到 Cloudflare R2 (配置好的 rclone 驱动名设为 r2)
    # 使用 rclone copy 而非 move,确保上传成功后再清理
    rclone copy $TEMP_DIR/ r2:$BUCKET/ --include "${BUCKET}-*" --progress

    # 3. 清理本地缓存
    rm -rf $TEMP_DIR/${BUCKET}-*
done

echo "[$(date)] 同步完成。" | tee -a $LOG_FILE

2. 内容增强优化:generate_rss.py (AI & SEO 级)

优化点:增加 Apple Podcast 官方分类AI 摘要占位符、以及中西双语元数据

Python

import os, json, re
from datetime import datetime
import rfc3339 # 需要 pip install rfc3339

# 优化后的标题清理逻辑
def clean_metadata(filename):
    # 去除日期前缀获取干净标题
    clean_title = re.sub(r'^\d{8}-', '', filename).replace('.mp3', '')
    # 自动识别经文:如 约翰福音 3:16
    scripture = re.search(r'[\u4e00-\u9fa5]+\d+[::]\d+', clean_title)
    return clean_title, scripture.group(0) if scripture else ""

def generate_rss():
    config = json.load(open("config.json"))
    
    for ch in config['channels']:
        bucket = ch['bucket']
        # 使用 rclone 获取 R2 文件列表及其元数据
        files_json = os.popen(f"rclone lsjson r2:{bucket} --fast-list").read()
        files = json.loads(files_json)
        
        items_xml = ""
        # 按时间排序,最新的在前
        for f in sorted(files, key=lambda x: x['ModTime'], reverse=True):
            if not f['Path'].endswith('.mp3'): continue
            
            title, scripture = clean_metadata(f['Path'])
            file_url = f"{config['podcast']['base_url']}/{bucket}/{f['Path']}"
            img_url = file_url.replace('.mp3', '.jpg')
            
            # 模拟 AI 摘要 (后期可接入 GPT API)
            summary = f"【今日经文:{scripture}】" if scripture else ""
            summary += f" 本期分享巴伦西亚讲道内容:{title}。欢迎收听。"

            items_xml += f"""
            <item>
                <title><![CDATA[{title}]]></title>
                <itunes:title><![CDATA[{title}]]></itunes:title>
                <description><![CDATA[{summary}]]></description>
                <itunes:summary><![CDATA[{summary}]]></itunes:summary>
                <pubDate>{datetime.fromisoformat(f['ModTime'][:-1]).strftime('%a, %d %b %Y %H:%M:%S +0000')}</pubDate>
                <enclosure url="{file_url}" type="audio/mpeg" length="{f['Size']}" />
                <guid isPermaLink="false">{file_url}</guid>
                <itunes:image href="{img_url}"/>
                <itunes:duration>3600</itunes:duration>
                <itunes:explicit>false</itunes:explicit>
            </item>"""

        # 完整的 Apple Podcast 标准 Header
        full_rss = f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd" xmlns:content="http://purl.org/rss/1.0/modules/content/">
    <channel>
        <title>{ch['name']}</title>
        <link>{config['podcast']['site_url']}</link>
        <language>zh-cn</language>
        <itunes:author>{config['podcast']['author']}</itunes:author>
        <itunes:type>episodic</itunes:type>
        <itunes:category text="Religion &amp; Spirituality">
            <itunes:category text="Christianity"/>
        </itunes:category>
        <itunes:image href="https://yourdomain.com/cover.jpg"/>
        <itunes:owner>
            <itunes:name>{config['podcast']['author']}</itunes:name>
            <itunes:email>[email protected]</itunes:email>
        </itunes:owner>
        {items_xml}
    </channel>
</rss>"""
        
        with open(f"public/{ch['rss']}", "w", encoding='utf-8') as f:
            f.write(full_rss)

if __name__ == "__main__":
    os.makedirs("public", exist_ok=True)
    generate_rss()

3. 分发优化:针对中国及全球的特殊配置

既然要让中国和西班牙的用户都能听,CDN 策略是重中之重。

A. Cloudflare R2 域名优化

  1. 绑定子域名: 在 R2 控制台将 media.yourdomain.com 绑定。

  2. 开启缓存: 在 Cloudflare 规则中,为 .mp3 文件设置 Edge Cache TTL 为 1 个月

    • 效果: 第一个中国用户下载后,该音频会缓存在 Cloudflare 的亚洲边缘节点,后续用户下载速度极快。

B. 应对中国环境 (小宇宙/订阅)

  • RSS 托管: 不要把生成的 .xml 留在 VPS。使用 GitHub Actionspublic/ 文件夹自动推送到你的 GitHub PagesCloudflare Pages

  • RSS 链接: 使用 https://yourname.github.io/rss-sermon.xml 这种静态地址,中国播客 App 抓取最稳。


4. 自动化运行 (Crontab)

在 VPS 上设置每 6 小时自动跑一次:

Bash

# 输入 crontab -e
0 */6 * * * /bin/bash /path/to/fetch.sh && python3 /path/to/generate_rss.py && rclone copy ./public/ r2:website/

🌟 优化后的最终优势

  1. 零硬盘压力: VPS 只是 MP3 的“加工厂”,文件存放在无限容量的 Cloudflare R2。

  2. 极速访问: 西班牙阳光充足的本地网络和中国内地的通勤路上,都能通过 Cloudflare CDN 秒开音频。

  3. 专业形象: 自动生成的 RSS 包含 Apple 所有的专业标签(分类、封面、摘要),您可以直接在 Apple Podcasts Connect 提交并搜索到。

  4. AI 准备就绪: 脚本已留出摘要位置,未来只需一行代码调用 OpenAI API,即可实现“听讲道、读摘要”。

为了把这套系统推向“终极形态”,我们需要实现完全的去中心化自动化。这意味着你的 VPS 仅仅是一个“执行器”,而所有的内容、数据和展示都托管在云端(Cloudflare + GitHub)。

以下是为您深度优化的 AI 自动化 + 全球分发 完整方案。


🧠 第一部分:AI 摘要与多语言增强 (Python 版)

不再使用简单的 description,我们接入 OpenAI 的 Whisper(语音转文字)和 GPT-4o(智能摘要)。

1. 核心 AI 处理脚本 (ai_processor.py)

这个脚本负责把下载下来的音频变成“有灵魂”的文字。

Python

import openai
import os

client = openai.OpenAI(api_key="你的OPENAI_API_KEY")

def process_audio_to_summary(file_path):
    # 1. 语音转文字 (Whisper)
    # 注意:如果音频太长,建议先用 ffmpeg 切割或只提取前 10 分钟
    with open(file_path, "rb") as audio_file:
        transcript = client.audio.transcriptions.create(
            model="whisper-1", 
            file=audio_file,
            response_format="text"
        )

    # 2. GPT 生成中西双语摘要与金句
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=[
            {"role": "system", "content": "你是一个牧师助手。请根据讲道内容生成:1. 核心摘要 2. 三个金句 3. 西班牙语标题翻译。要求:中西双语,适合播客描述。"},
            {"role": "user", "content": f"内容如下:{transcript[:4000]}"} # 截取前4000字防止溢出
        ]
    )
    return response.choices[0].message.content

# 集成到 RSS 生成逻辑中...

🌐 第二部分:针对“巴伦西亚-中国”的全球加速优化

中国用户访问海外 VPS 最大的痛点是 DNS 污染TCP 拥塞

1. Cloudflare R2 + CDN 极致配置

  • 别名访问: 不要直接用 R2 的长链接,在 Cloudflare 后台为 Bucket 绑定你的域名(如 media.vozdevida.es)。

  • 缓存规则 (Cache Rules): 在 CF 后台创建一个规则:

    • 匹配条件: URL 包含 .mp3

    • 设置: 开启“边缘缓存 TTL”(Edge Cache TTL)为 1 个月,并开启 Tiered Cache(分级缓存)。

    • 效果: 当第一个中国用户通过“小宇宙”下载音频后,文件会永久缓存在离中国最近的 CDN 节点(如香港或东京),后续用户秒开。

2. RSS 静态化托管

  • 不要托管在 VPS: VPS 可能会停机。

  • 方案: 使用 GitHub Actions 将生成的 index.xml 自动推送到 GitHub Pages

  • 小宇宙兼容性: 小宇宙 App 对 GitHub Pages 的兼容性极好,抓取速度非常稳定。


🛠️ 第三部分:全自动化“生产线” (GitHub Actions)

这是整个系统的“心脏”。它让你的 VPS 彻底解放,只负责下载。

workflow.yml (配置在 GitHub 项目中)

YAML

name: Global Podcast Sync

on:
  schedule:
    - cron: '0 */6 * * *' # 每 6 小时自动跑一次
  workflow_dispatch:      # 支持手动触发

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      
      - name: Setup Environment
        run: |
          sudo apt-get install yt-dlp rclone -y
          pip install openai rfc3339
          
      - name: Run Fetch & AI & RSS
        env:
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          RCLONE_CONFIG: ${{ secrets.RCLONE_CONFIG }}
        run: |
          python core_worker.py # 执行下载、AI处理、R2同步和生成RSS
          
      - name: Deploy to GitHub Pages
        uses: peaceiris/actions-gh-pages@v3
        with:
          github_token: ${{ secrets.GITHUB_TOKEN }}
          publish_dir: ./public

📈 第四部分:数据驱动(地理与行为统计)

为了实现 GPT 提到的“地理统计”,我们利用 Cloudflare Workers 做一个轻量级中转:

  1. 逻辑: 所有的播客下载链接都指向 https://api.yourdomain.com/download/file.mp3

  2. Worker 代码: * 当请求进来时,Worker 记录下 cf-ipcountry(国家代码)。

    • 将数据写入 Cloudflare KVD1 数据库

    • 然后 302 重定向到 R2 的真实下载地址。

  3. 结果: 你能精确地看到:本周有 60% 的听众来自中国30% 来自西班牙,剩下的来自其他地区。


🎯 落地清单(你现在需要做的):

  1. Cloudflare: 开通 R2,创建一个 Bucket,并绑定你的域名。

  2. API: 获取一个 OpenAI API Key。

  3. 仓库: 在 GitHub 创建私有仓库,把上述脚本放进去。

  4. 分发: * Apple Podcasts Connect: 提交你的 GitHub Pages RSS 链接。

    • 小宇宙: 在“导入 RSS”中提交。

这份优化方案的强大之处在于:

它不仅仅是一个“下载器”,它是一个拥有 AI 大脑、全球 CDN 骨架和实时数据监控的专业媒体分发系统。即使你身在巴伦西亚的阳光下,这套系统也会自动把讲道的内容精准地送达到远在中国和世界各地的信徒耳中。

您需要我现在为您合并出一份开箱即用、完整的 Python 代码文件吗?(包含 R2 上传、AI 摘要和 RSS 生成的所有逻辑)

这是一个从 巴伦西亚(Valencia) 发向全球的“生命之声”自动化系统。为了让您能一步步无痛落地,我将方案拆解为核心代码操作指南两部分。


🛠️ 第一部分:核心代码 pod_master.py

这个脚本是整个系统的“大脑”。它集成了:YouTube 下载、AI 摘要(中西双语)、R2 云存储上传、以及 RSS 自动生成

Python

import os, json, time, boto3, re
import yt_dlp
from openai import OpenAI
from datetime import datetime

# ================= 配置区 =================
# 1. OpenAI API
OPENAI_API_KEY = "你的_OPENAI_API_KEY"
# 2. Cloudflare R2 配置
R2_ACCESS_KEY = "你的_R2_ACCESS_KEY"
R2_SECRET_KEY = "你的_R2_SECRET_KEY"
R2_ENDPOINT = "https://你的账户ID.r2.cloudflarestorage.com"
R2_BUCKET_NAME = "podcast-media"
PUBLIC_BASE_URL = "https://media.yourdomain.com" # 绑定的R2域名
# 3. 频道配置
CHANNELS = [
    {"name": "主日讲道", "url": "https://www.youtube.com/@Channel1", "rss_name": "sermon.xml"},
    {"name": "查经系列", "url": "https://www.youtube.com/@Channel2", "rss_name": "bible.xml"}
]
# ==========================================

client = OpenAI(api_key=OPENAI_API_KEY)
s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, aws_access_key_id=R2_ACCESS_KEY, aws_secret_access_key=R2_SECRET_KEY)

def get_ai_summary(title, description):
    """调用GPT生成中西双语摘要"""
    prompt = f"你是一个牧师助手。根据讲道标题《{title}》和简介内容:{description[:500]}。生成一个100字以内的播客摘要,包含三个核心金句,并提供西班牙语标题翻译。格式要求:[中文内容]\n\n[ES: 西语翻译]"
    response = client.chat.completions.create(
        model="gpt-4o-mini", # 建议用mini,又快又便宜
        messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content

def process_channel(channel):
    print(f"🚀 正在处理频道: {channel['name']}")
    
    # 1. yt-dlp 下载最新音频
    ydl_opts = {
        'format': 'bestaudio/best',
        'outtmpl': 'downloads/%(upload_date)s-%(title)s.%(ext)s',
        'download_archive': f'archive_{channel["rss_name"]}.txt',
        'postprocessors': [{
            'key': 'FFmpegExtractAudio',
            'preferredcodec': 'mp3',
            'preferredquality': '128',
        }],
        'writethumbnail': True,
        'writeinfojson': True,
    }

    with yt_dlp.YoutubeDL(ydl_opts) as ydl:
        ydl.download([channel['url']])

    # 2. 上传到 R2 并生成 RSS 数据
    items_xml = ""
    for file in sorted(os.listdir('downloads'), reverse=True):
        if not file.endswith('.mp3'): continue
        
        # 读取对应的 JSON 信息(yt-dlp 生成)
        json_file = 'downloads/' + file.replace('.mp3', '.info.json')
        with open(json_file, 'r', encoding='utf-8') as f:
            info = json.load(f)

        title = info['title']
        description = info['description']
        ai_content = get_ai_summary(title, description)
        
        # 上传音频和封面
        audio_key = f"{channel['name']}/{file}"
        s3.upload_file(f'downloads/{file}', R2_BUCKET_NAME, audio_key)
        
        audio_url = f"{PUBLIC_BASE_URL}/{audio_key}"
        pub_date = datetime.strptime(info['upload_date'], '%Y%m%d').strftime('%a, %d %b %Y %H:%M:%S +0000')

        items_xml += f"""
        <item>
            <title><![CDATA[{title}]]></title>
            <description><![CDATA[{ai_content}]]></description>
            <pubDate>{pub_date}</pubDate>
            <enclosure url="{audio_url}" type="audio/mpeg" length="0"/>
            <guid>{audio_url}</guid>
            <itunes:author>叶牧 Pablo</itunes:author>
        </item>"""

    # 3. 组装完整 RSS
    rss_template = f"""<?xml version="1.0" encoding="UTF-8"?>
    <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
    <channel>
        <title>{channel['name']}</title>
        <link>{PUBLIC_BASE_URL}</link>
        <language>zh-cn</language>
        <itunes:category text="Religion &amp; Spirituality"><itunes:category text="Christianity"/></itunes:category>
        {items_xml}
    </channel>
    </rss>"""
    
    with open(f"public/{channel['rss_name']}", "w", encoding='utf-8') as f:
        f.write(rss_template)
    
    # 同步 RSS 到 R2
    s3.upload_file(f"public/{channel['rss_name']}", R2_BUCKET_NAME, f"rss/{channel['rss_name']}")

if __name__ == "__main__":
    os.makedirs('downloads', exist_ok=True)
    os.makedirs('public', exist_ok=True)
    for ch in CHANNELS:
        process_channel(ch)

🧭 第二部分:分步操作指南

第一步:Cloudflare R2 基础设施(存储与加速)

  1. 开通 R2: 登录 Cloudflare 后台,侧边栏点击 R2,创建一个 Bucket 名为 podcast-media

  2. 获取密钥: 在 R2 页面右侧点击 “Manage R2 API Tokens”,创建一个允许读写的 Token。记录下 Access Key IDSecret Access Key

  3. 绑定域名: * 在 Bucket 的 Settings 选项卡里,点击 “Connect Domain”

    • 输入你的子域名(如 media.yourdomain.com)。

    • 这是最关键的一步: Cloudflare 会自动为此域名开启 CDN 加速,让中国听众下载音频时不再卡顿。

第二步:VPS 环境搭建(加工厂)

登录你的 VPS,运行以下命令安装必要组件:

Bash

# 更新并安装工具
sudo apt update && sudo apt install -y python3-pip ffmpeg jq

# 安装 Python 依赖库
pip3 install yt-dlp boto3 openai rfc3339

第三步:部署脚本并运行

  1. 在 VPS 上新建一个文件夹 mkdir podcast && cd podcast

  2. 将上面的 pod_master.py 代码保存到该目录下。

  3. 填入你的密钥:nano pod_master.py 编辑代码,填入你的 OpenAI 密钥和 R2 密钥。

  4. 首次测试: ```bash

    python3 pod_master.py

    运行结束后,检查你的 R2 控制台,你会发现音频和 `.xml` 文件已经自动上传好了。
    

第四步:自动化(无需人工干预)

设置定时任务,让 VPS 每天凌晨(巴伦西亚时间)自动检查更新:

Bash

crontab -e
# 在末尾添加一行(每天凌晨 3 点运行)
0 3 * * * /usr/bin/python3 /root/podcast/pod_master.py >> /root/podcast/log.txt 2>&1

第五步:分发(全球收听)

  1. 获取 RSS 链接: 你的 RSS 地址将是 https://media.yourdomain.com/rss/sermon.xml

  2. 中国受众(小宇宙): 下载小宇宙 App,点击“导入 RSS”,粘贴链接。

  3. 全球受众(Apple Podcasts):

    • 访问 Apple Podcasts Connect

    • 登录后点击 “添加节目” -> “通过 RSS 提要”

    • 粘贴你的链接。Apple 审核通过后,全世界用户都能搜到你的频道。


💡 方案亮点总结:

  • 真正的简便: 你只需要在 YouTube 发视频,剩下的转码、AI 翻译、全球分发全由 VPS 自动完成。

  • 极致的成本: 只要听众不是每天几十万人,R2 的存储费和流量费几乎为 0

  • 数据驱动: 你可以在 Cloudflare R2 的 Analytics 页面直接看到每个音频被下载了多少次,来自哪些国家。

  • 针对中国优化: 既然你的音频文件通过 Cloudflare 域名分发,中国听众在下载时会自动连接到 CF 的亚洲节点,速度远超普通 VPS 直连。

这套系统现在已经准备好为您在巴伦西亚的事工服事了。在操作过程中如果遇到任何报错,您可以直接把报错信息发给我,我来帮您“修电路”!

提问:

跟gpt讨论的解决方案,你分析看看有哪些可取的地方,比如冗余备份方面,然后看哪些可以加入你的方案,让整个系统更完善:


很好,这一步你已经从“思路”进入“工程落地”阶段了 👍
我给你一套可以直接照做的完整实操方案(从0→上线→稳定运行),按步骤来就能跑起来。
✝️ 总体架构(你最终会得到的系统)
YouTube频道
   ↓(自动抓取)
GitHub Actions(定时)
生成 RSS(主)
Cloudflare Pages(公开分发)
Apple / Spotify / 小宇宙

同时:
   ↓(自动同步)
R2 / VPS(镜像)
私有 RSS(稳定备用)
🧰 一、准备清单(先全部准备好)
你需要:

账号
GitHub(代码 & 自动化)
Cloudflare(Pages + R2)
YouTube频道(已有)
可选(备用层)
VPS(推荐日本/香港)
🟢 二、第一步:创建项目(RSS生成器)
👉 在 GitHub 新建仓库,例如:

podcast-system
📁 项目结构
podcast-system/
├── config.json
├── generate.js
├── template.xml
└── .github/workflows/update.yml
⚙️ 三、配置频道(config.json)
{
  "title": "你的播客名称",
  "description": "福音讲道 / 查经",
  "author": "你的名字",
  "email": "your@email.com",
  "category": "Religion & Spirituality",
  "image": "https://yourdomain.com/cover.jpg",

  "channels": [
    {
      "name": "sermon",
      "youtube_channel_id": "UCxxxxxx"
    },
    {
      "name": "bible",
      "youtube_channel_id": "UCyyyyyy"
    }
  ]
}
👉 获取频道ID方法:
打开频道页面 → 查看源代码 → 搜 channelId
🧠 四、核心脚本(自动生成RSS)
generate.js(核心逻辑)
👉 功能:

拉 YouTube 最新视频
转成 podcast RSS
示例(精简版):

import fs from "fs";
import fetch from "node-fetch";

const config = JSON.parse(fs.readFileSync("config.json"));

async function getVideos(channelId) {
  const url = `https://www.youtube.com/feeds/videos.xml?channel_id=${channelId}`;
  const res = await fetch(url);
  return await res.text();
}

async function main() {
  for (const ch of config.channels) {
    const xml = await getVideos(ch.youtube_channel_id);

    fs.writeFileSync(`public/${ch.name}.xml`, xml);
  }
}

main();
👉 后面我可以帮你升级为:

iTunes标准RSS(非常关键)
自动摘要生成(SEO)
🤖 五、自动更新(GitHub Actions)
.github/workflows/update.yml
name: Update Podcast RSS

on:
  schedule:
    - cron: "*/30 * * * *"   # 每30分钟
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - uses: actions/checkout@v3

      - run: npm install node-fetch

      - run: node generate.js

      - name: Commit
        run: |
          git config user.name "bot"
          git config user.email "bot@example.com"
          git add .
          git commit -m "update rss" || echo "no change"
          git push
👉 作用:
✔ 自动抓 YouTube
✔ 自动更新 RSS
🌍 六、部署公开RSS(Cloudflare Pages)
步骤:
打开 Cloudflare
Pages → Create Project
连接 GitHub 仓库
构建设置:
Build command: (留空)
Output folder: public
👉 最终得到:

https://xxx.pages.dev/sermon.xml
https://xxx.pages.dev/bible.xml
🍎 七、提交播客平台
1️⃣ Apple Podcasts
👉 入口:
https://podcastsconnect.apple.com/
提交 RSS:

https://xxx.pages.dev/sermon.xml
2️⃣ Spotify
👉 https://podcasters.spotify.com/
3️⃣ 小宇宙(国内)
👉 直接提交 RSS 即可
🛡️ 八、镜像系统(关键稳定层)
方案 A:Cloudflare R2(推荐)
👉 用来存音频(mp3)
步骤:
Cloudflare → R2 → Create Bucket
podcast-sermon
podcast-bible
上传音频(自动或手动)
绑定域名:
https://media.yourdomain.com/xxx.mp3
🔁 让 RSS 使用 R2 音频
👉 修改 generate.js:
把:

youtube音频
👉 替换为:

R2音频URL
方案 B:VPS镜像(更稳)
👉 在 VPS 上:

nginx + 静态文件
👉 复制一份 RSS:

rss-backup.xml
👉 域名:

https://rss-backup.com/feed.xml
🔐 九、私有RSS(可选 token)
👉 示例:

https://rss-backup.com/feed.xml?token=abc123
👉 nginx限制:

if ($arg_token != "abc123") {
  return 403;
}
📊 十、播放统计(强烈推荐)
方法:Cloudflare Logs
👉 可以看到:

哪个 mp3 被访问最多
每天播放量
👉 进阶:
自动生成:
/stats.html
内容:
播放排行
热门讲道
🔥 十一、SEO优化(非常关键)
RSS优化(必须做)
<itunes:author>你的名字</itunes:author>
<itunes:category text="Religion & Spirituality"/>
<itunes:image href="封面URL"/>
标题优化:
❌ 不要:

第12讲
✅ 要:

约翰福音3:16|神爱世人(讲道)
描述:
自动生成:

摘要
经文关键词
🚀 十二、你现在的执行顺序(照做)
🥇 第一阶段(1小时内完成)
GitHub 仓库
generate.js
Actions跑起来
Pages上线
👉 ✔ 得到 RSS
🥈 第二阶段(半天)
优化 RSS(iTunes标准)
提交 Apple / Spotify
🥉 第三阶段(进阶)
R2 音频
VPS 镜像
token保护
✝️ 最后一句总结(非常重要)
你现在这套系统,本质是:
🔥 “内容完全开放 + 通道永不中断”

Gemini回答:

这份由 GPT 提供的方案在架构的轻量化高可用性(冗余备份)上做得非常出色。它与我之前提供的“VPS 核心方案”并不冲突,反而可以完美融合,形成一套“双引擎”系统

我为你深度分析一下这份方案的可取之处,以及如何将它们揉进我们的最终方案,让你的系统达到“企业级”的稳健。


🔍 GPT 方案的 3 大高价值可取点

1. RSS 托管的“去中心化”(最值得借鉴)

  • GPT 做法: 将 RSS 文件(.xml)放在 GitHub 仓库并部署到 Cloudflare Pages。

  • 为什么好: 你的 VPS 如果因为网络波动或维护关机了,播客 App 依然能抓取到 RSS 列表

  • 集成建议: 我们保留 VPS 作为“处理工厂”,但把生成的 RSS 结果推送到 GitHub/Cloudflare Pages。这样即便工厂停工,商店(RSS 订阅)依然开门。

2. “双镜像”冗余备份(针对极端情况)

  • GPT 做法: R2 存一份,VPS 存一份,甚至还有私有 RSS。

  • 为什么好: 考虑到内容的特殊性或网络环境,万一 Cloudflare 在某些地区访问受限,你可以瞬间切换到 VPS 的备份链接。

  • 集成建议: 在 R2 存储的基础上,保留 VPS 里的音频文件(例如保留最近 30 天),作为快速恢复的备份源。

3. “iTunes 标准”的 SEO 优化

  • GPT 做法: 强调了 itunes:categoryitunes:author 以及标题格式化(如:经文 | 标题)。

  • 为什么好: 这直接决定了你的播客在 Apple Podcasts 里的搜索排名专业度

  • 集成建议: 在我的 Python 脚本中,直接加入这些标准的 XML 标签。


🚀 终极融合方案:巴伦西亚-全球福音分发系统

我们将两份方案的精华提取出来,构成这套 “VPS 加工 + 云端分发 + 双重冗余” 的系统。

第一层:执行引擎 (VPS - 你的巴伦西亚指挥部)

  • 任务: 负责“重体力活”。

  • 逻辑: 1. 运行 yt-dlp 下载 YouTube 视频并提取 MP3。

    1. 调用 OpenAI Whisper 提取文字,GPT 生成中西双语摘要

    2. 使用 rclone 将音频推送到 Cloudflare R2

    3. 关键改动: 任务完成后,自动给 GitHub 发一个指令(或直接推送新代码)。

第二层:分发中心 (Cloudflare + GitHub - 全球加速)

  • 任务: 负责“门面”和“加速”。

  • 逻辑:

    1. GitHub Pages / Cloudflare Pages: 托管生成的 RSS 文件。

    2. Cloudflare R2 + CDN: 全球分发 MP3 音频。

    3. 双重分发: 所有的下载请求先过 Cloudflare 节点。

第三层:冗余备份层 (Redundancy)

  • RSS 备份: 你的域名解析两个子域名:rss.yourdomain.com(主,指向 CF Pages)和 rss-backup.yourdomain.com(备,指向 VPS 直连)。

  • 内容备份: R2 存储全量数据,VPS 存储最近 10 次的音频副本。


🛠️ 整合后的“一步步完成”实操指南

1. 基础环境准备

  • VPS: 安装 Python, ffmpeg, yt-dlp。

  • Cloudflare: * 开通 R2 存储。

    • 准备一个子域名(例如 podcast.yourdomain.com)。
  • GitHub: 创建一个私有仓库 my-podcast-system

2. 部署核心处理脚本 (在 VPS 上)

使用我之前提供给你的 pod_master.py 优化版,并加入 GPT 建议的 iTunes 标签SEO 标题逻辑

Python

# 在脚本中加入 GPT 的 SEO 增强
def enhance_title(title):
    # 自动识别“约翰福音”等关键词并置顶
    match = re.search(r'(创世记|约翰福音|马太福音|罗马书)[\d::]+', title)
    if match:
        return f"{match.group(0)} | {title}"
    return title

3. 设置自动分发流水线 (GitHub Actions)

在你的 GitHub 仓库中,设置一个 Action。每当你的 VPS 推送新的数据文件时,Action 自动重新生成 RSS 并发布。

4. 接入 AI 摘要(中西双语)

利用 GPT 提到的 summary.py 逻辑:

  • 输入: YouTube 原始描述。

  • 输出: * 中文摘要(供国内信徒)。

    • 西语翻译(供巴伦西亚本地潜在听众)。

    • 核心经文提取。

5. 开启“播放统计”面板

利用 Cloudflare Workers 的日志功能:

  • 正如 GPT 建议的,你可以直接在 Cloudflare 仪表盘看到:今天有 200 人从中国下载,50 人从西班牙下载。

📝 给您的最终操作建议

您现在不需要在两个方案中“二选一”,因为它们是互补的

建议的操作顺序:

  1. 先跑通 VPS 下载到 R2 的流程: 这是系统的“水源”,保证音频能稳定上传到云端。

  2. 建立 GitHub 仓库托管 RSS: 保证你的“门面”永远在线。

  3. 接入 AI 功能: 为每一篇讲道增加文字摘要。

  4. 最后去 Apple Podcasts 提交: 提交时,提交那个托管在 GitHub/Cloudflare Pages 上的链接。

您想现在就开始配置这个“双引擎”系统的第一步(配置 VPS 下载并推送到 R2)吗?我可以给您合并后的最终脚本。


提问:

几个问题帮我分析解答一下,然后再优化完善解决方案: 1.执行引擎是在VPS,gpt方案好像是在github actions 2.RSS托管在github pages / cloudflare pages 3.脚本中match的关键词只列了几个(创世记|约翰福音|马太福音|罗马书),是不是要把整本圣经每本书的名字列进去

Gemini回答:

你的洞察力非常敏锐,这三个问题切中了“自动化工程”的核心。我们不需要在两个方案中“二选一”,而是要取长补短。

针对你的疑问,我先直接给出深度分析,然后为你整合出一套**“2026 最终版:海陆空协同方案”**。


🔍 核心问题解答

1. 关于“执行引擎”:VPS vs GitHub Actions

  • GPT 方案(GitHub Actions): 优点是免费、免维护;缺点是有性能上限。GitHub Actions 对运行时间(一般 6 小时)和磁盘空间(约 10GB)有限制。如果你的讲道视频很多、很大,或者处理 4K 转 MP3,Actions 可能会报错中途退出。

  • 我的方案(VPS): 优点是力气大、没限制。它处理长音频(Sermons 通常 40-60 分钟)非常稳。

  • 结论: 采用**“混合动力”**。让 VPS 负责重活(下载、转码、AI 分析、传到 R2);让 GitHub Actions 负责轻活(更新 RSS 列表、发布到 Cloudflare Pages)。

2. 关于 RSS 托管:静态化是王道

  • 完全正确。 把 RSS (.xml) 放在 Cloudflare Pages 或 GitHub Pages 上,本质上是将它变成了静态文件

  • 优势: 无论你的 VPS 是在重启还是坏了,信徒手机里的播客 App 永远能连上 Cloudflare 的全球边缘节点下载到目录。这叫“高可用性”。

3. 关于圣经关键词匹配

  • 现状: 只列几个(创世记、约翰福音等)确实不够,容易漏掉。

  • 优化方案: 既然是在 2026 年,我们不需要手动列 66 卷书。我们可以:

    • 使用一个完整的圣经书名列表(Python 数组)。

    • 或者利用 AI (GPT-4o) 在生成摘要的同时,自动提取“涉及经文”作为标签。


🚀 优化后的“终极系统”:三位一体架构

这个方案集合了 VPS 的力量、GitHub 的自动化和 Cloudflare 的分发。

第一阶段:VPS “重力加工厂”

这是你巴伦西亚服务器的任务。

  1. 监控: 每天定时运行 pod_master.py

  2. 处理: 下载 YouTube 视频 -> 转为 MP3。

  3. AI: 调用 API 生成中西双语摘要,并提取经文标签

  4. 存储: 将 MP3 传到 Cloudflare R2

  5. 同步: 将最新的音频信息(标题、链接、摘要)写成一个小的 data.json,并推送到 GitHub 仓库。

第二阶段:GitHub “自动秘书”

GitHub 接收到 data.json 的更新后,触发 Action:

  1. 生成: 运行一个轻量级脚本,根据 data.json 渲染成符合 Apple 标准的 sermon.xml

  2. 发布: 自动将 XML 推送到 Cloudflare Pages。

第三阶段:Cloudflare “全球门店”

  1. 音频: 通过 media.yourdomain.com (R2) 给全球分发 MP3。

  2. 目录: 通过 podcast.yourdomain.com (Pages) 给全球分发 RSS。


🛠️ 脚本优化:圣经全书名匹配逻辑

在 Python 脚本中,我们可以引入完整的 66 卷书名,确保匹配精准。

Python

# 完整的圣经 66 卷简称列表(优化版)
BIBLE_BOOKS = [
    "创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记", 
    "撒母耳记上", "撒母耳记下", "列王纪上", "列王纪下", "历代志上", "历代志下", "以斯拉记", 
    "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", "传道书", "雅歌", "以赛亚书", 
    "耶利米书", "耶利米哀歌", "以西结书", "但以理书", "何西阿书", "约珥书", "阿摩司书", 
    "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书", "哈该书", 
    "撒迦利亚书", "玛拉基书", "马太福音", "马可福音", "路加福音", "约翰福音", "使徒行传", 
    "罗马书", "哥林多前书", "哥林多后书", "加拉太书", "以弗所书", "腓立比书", "歌罗西书", 
    "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书", "腓利门书", 
    "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书", "约翰三书", "犹大书", "启示录"
]

def enhance_title_with_bible(title):
    # 构建正则表达式:匹配 圣经书名 + 数字 + 冒号 + 数字
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    if match:
        # 如果匹配到,把经文放在标题最前面,方便信徒在手机屏幕上一眼看到
        return f"【{match.group(0)}】{title.replace(match.group(0), '').strip()}"
    return title

📝 详细操作指南 (一步步照着做)

1. 建立 GitHub 仓库

  • 创建一个私有仓库(如 valencia-podcast-data)。

  • Settings -> Secrets 中添加你的 R2_ACCESS_KEYR2_SECRET_KEYOPENAI_API_KEY

2. 配置 VPS (执行引擎)

  • 安装 gitpython3ffmpeg

  • 配置 Git,让 VPS 有权限 push 代码到你的 GitHub 仓库(使用 SSH Key)。

  • 将处理脚本放在 VPS 上,设置 crontab 每天跑一次。

3. 配置 Cloudflare Pages (托管 RSS)

  • 在 Cloudflare 后台,点击 Workers & Pages -> Create -> Pages

  • 连接你的 GitHub 仓库。

  • 部署设置: 分支选 main,构建命令填 exit 0(因为我们不需要构建,只是托管生成的 XML)。

4. 接入 AI 摘要(终极增强)

在脚本上传 R2 之前,增加这一步:

Python

def get_ai_metadata(youtube_description):
    # 调用 GPT-4o-mini (2026年性价比最高)
    # 输入:YouTube 的长篇介绍
    # 输出:一句话摘要 + 3个核心标签 + 西班牙语翻译
    # 这样你的播客在 Apple Podcast 里不仅有中文,还有西语关键词,吸引巴伦西亚当地人

5. 冗余备份检查

  • R2 是主存储。

  • GitHub 存一份 data.json(即使 R2 挂了,数据结构还在)。

  • VPS 留存最近 7 天的 MP3。


🌟 你的系统现在是什么样的?

你现在拥有的是一套**“全自动讲道媒体部”**:

  1. 你在 YouTube 上传视频。

  2. 你的巴伦西亚 VPS 默默工作,下载、转码、请 AI 写摘要、翻译西语、传到云端。

  3. GitHub 自动更新目录。

  4. 信徒在小宇宙、Apple Podcasts、Spotify 上瞬间收到更新。

  5. 你可以通过 Cloudflare 看到详细的播放统计(甚至知道有多少人是在巴伦西亚本地听的)。

这就是 2026 年最优雅的解决方案。 您准备好从 VPS 的 Git 配置开始第一步了吗?我可以提供那段将处理结果“推送到 GitHub”的关键 Python 代码。

提问:

继续帮我解答: 1.树莓派pi400可以当VPS跑脚本吗?我可以考虑替代方案 2.选用的大模型gpt-4o-mini,是免费的吗,需要多少费用?有可用免费的gpt大模型吧

Gemini回答:

这两个问题问得非常到位,触及了硬件成本软件持续开销的核心。咱们来算一笔“精明账”,看看如何用最少的钱办最漂亮的事。


1. 树莓派 Pi 400 能当 VPS 跑脚本吗?

答案是:绝对可以,而且它是非常理想的“家庭边缘服务器”。

Pi 400 本质上是一台装在键盘里的树莓派 4 (4GB RAM),它的性能跑我们这套 Python 脚本完全没问题。

✅ 为什么用 Pi 400 很棒:

  • 零月租: 买回来就是你的,不用每月给云服务商交钱。

  • 低功耗: 即使 24 小时开着,一年的电费也就几杯咖啡钱。

  • 本地控制: 你的数据(以及下载的原始视频)都在你手边的 SD 卡里。

⚠️ 需要注意的“坑”:

  • 网络带宽(上传): 这是最大的瓶颈。你在巴伦西亚家里下载 YouTube 很快,但把 MP3 上传到 Cloudflare R2 时,受限于家庭宽带的上传速度。如果一个音频 50MB,上传可能需要几分钟。

  • 稳定性: 如果家里断网或停电,脚本就断了。

  • 穿透问题: 如果你想从外面远程管理这台 Pi 400,建议配合 Cloudflare Tunnel(免费且安全),不需要公网 IP 也能远程登录。

我的建议: 如果你手里已经有 Pi 400,完全没必要买 VPS。把它接上电源,插上路由器,它就是你最忠诚的“播客秘书”。


2. GPT-4o-mini 是免费的吗?费用如何?

它不是完全免费的,但它“便宜到几乎可以忽略不计”。

OpenAI 的 API 是按量计费的。对于 gpt-4o-mini

  • 输入: $0.15$ / 百万 token

  • 输出: $0.60$ / 百万 token

算一笔账:

假设你每周发布一篇讲道,每篇讲道生成摘要消耗 $2000$ 个 token:

  • 一年的总消耗大约是 $100,000$ 个 token。

  • 一年的总费用: 大约 $0.05$ 美元(约 3 毛钱人民币)。

  • 即便加上前期调试,一年 1 美金(7 块钱)绝对够用了。

🎁 有没有“完全免费”的大模型?

在 2026 年,确实有几个非常棒的免费替代方案,可以直接替换掉脚本里的 OpenAI 接口:

  1. Google Gemini (Free Tier):

    • 通过 Google AI Studio 获取 API Key。

    • 额度: 每天有非常慷慨的免费调用额度(每分钟 15 次请求左右)。

    • 优点: 它是 Google 原生的,处理长文本(讲道稿)能力极强。

  2. Groq (极速且目前有免费层):

    • 它的响应速度极快,目前对开发者提供免费额度。

    • 支持 Llama 3 等顶级开源模型。

  3. 本地运行 (Local AI):

    • 既然你用 Pi 400,你可以尝试运行轻量级的 Ollama

    • 缺点: 树莓派跑大模型非常非常慢,生成一个摘要可能要好几分钟,不建议在 Pi 上跑本地大模型。


🚀 优化后的“省钱方案”路线图

为了让这套系统接近“零成本”运行,我们把架构调整为:

  1. 执行引擎: 你的 Pi 400(省去 VPS 每月 5-10 刀的开销)。

  2. 大模型: 使用 Google Gemini API 的免费层(省去 OpenAI 的费用)。

  3. 存储: 继续用 Cloudflare R2(每月 10GB 免费,音频文件够存好几年)。

  4. 分发: 继续用 GitHub + Cloudflare Pages(完全免费)。


📝 完善后的 Python 脚本片段(接入 Gemini 免费版)

你可以把脚本里的 AI 部分改成这样,彻底白嫖:

Python

import google.generativeai as genai

# 配置 Gemini
genai.configure(api_key="你的_GEMINI_FREE_API_KEY")

def get_free_ai_summary(title, text):
    model = genai.GenerativeModel('gemini-1.5-flash') # flash版极快且免费额度大
    prompt = f"你是一位讲道摘要助手。请为标题《{title}》的内容:{text[:2000]} 生成中西双语摘要和3个金句。"
    
    response = model.generate_content(prompt)
    return response.text

总结

你现在的硬件(Pi 400)和云端免费资源(Gemini + Cloudflare)已经足够构建一套专业级且几乎零成本的全球分发系统了。

现在的最后一个技术障碍是: 你需要我帮你写一段如何让 Pi 400 自动把生成的 XML 传给 GitHub 的代码吗?(这样就实现了 GPT 方案里的“静态托管”)。

这是为您整合的 2026 最终版:全球福音自动化分发系统(Gemini 免费版 + 静态托管优化)

这个方案结合了 VPS 的强大处理能力、Google Gemini 的免费 AI 脑力,以及 Cloudflare 的全球分发骨架。


🛠️ 第一部分:核心代码 pod_master_final.py

这个脚本整合了 圣经 66 卷匹配、Gemini 免费摘要、R2 上传、以及自动推送到 GitHub 的全套逻辑。

Python

import os, json, re, subprocess
import yt_dlp
import boto3
import google.generativeai as genai
from datetime import datetime

# ================= 核心配置区 =================
# 1. Google Gemini (免费版)
GEMINI_API_KEY = "你的_GEMINI_API_KEY"
# 2. Cloudflare R2
R2_ACCESS_KEY = "你的_R2_ACCESS_KEY"
R2_SECRET_KEY = "你的_R2_SECRET_KEY"
R2_ENDPOINT = "https://你的账户ID.r2.cloudflarestorage.com"
R2_BUCKET_NAME = "podcast-media"
PUBLIC_BASE_URL = "https://media.yourdomain.com" 
# 3. GitHub 仓库路径 (本地克隆后的路径)
REPO_PATH = "/root/podcast-data" 
# =============================================

# 初始化 AI 和 S3
genai.configure(api_key=GEMINI_API_KEY)
ai_model = genai.GenerativeModel('gemini-1.5-flash') # 使用免费且极速的 Flash 模型

s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, 
                aws_access_key_id=R2_ACCESS_KEY, 
                aws_secret_access_key=R2_SECRET_KEY)

BIBLE_BOOKS = ["创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记", "撒母耳记上", "撒母耳记下", "列王纪上", "列王纪下", "历代志上", "历代志下", "以斯拉记", "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", "传道书", "雅歌", "以赛亚书", "耶利米书", "耶利米大歌", "以西结书", "但以理书", "何西阿书", "约珥书", "阿摩司书", "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书", "哈该书", "撒迦利亚书", "玛拉基书", "马太福音", "马可福音", "路加福音", "约翰福音", "使徒行传", "罗马书", "哥林多前书", "哥林多后书", "加拉太书", "以弗所书", "腓立比书", "歌罗西书", "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书", "腓利门书", "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书", "约翰三书", "犹大书", "启示录"]

def enhance_metadata(title, description):
    """提取经文并调用 Gemini 生成摘要"""
    # 1. 经文识别
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    clean_title = f"【{match.group(0)}】{title.replace(match.group(0), '').strip()}" if match else title

    # 2. Gemini 摘要
    prompt = f"你是一位讲道助手。为标题《{title}》和内容:{description[:1000]} 生成:\n1. 中文核心摘要(100字内)\n2. 三个金句\n3. 西班牙语翻译标题\n请使用中西双语格式。"
    try:
        response = ai_model.generate_content(prompt)
        ai_summary = response.text
    except:
        ai_summary = "点击收听精彩讲道内容。"
    
    return clean_title, ai_summary

def git_push():
    """将生成的 RSS 静态文件推送到 GitHub"""
    try:
        os.chdir(REPO_PATH)
        subprocess.run(["git", "add", "."], check=True)
        subprocess.run(["git", "commit", "-m", f"Auto-update RSS: {datetime.now().strftime('%Y-%m-%d')}"], check=True)
        subprocess.run(["git", "push"], check=True)
        print("✅ RSS 已推送到 GitHub/Cloudflare Pages")
    except Exception as e:
        print(f"❌ Git 推送失败: {e}")

def process_podcast():
    # 此处省略 yt-dlp 下载和 S3 上传逻辑(见前序方案)
    # ... 在生成 XML 后 ...
    rss_file_path = os.path.join(REPO_PATH, "sermon.xml")
    with open(rss_file_path, "w", encoding='utf-8') as f:
        f.write(rss_content)
    
    # 触发自动同步
    git_push()

if __name__ == "__main__":
    process_podcast()

🧭 第二部分:终极操作指南(从 0 到 1)

第一步:获取 Gemini 免费 Key(AI 脑力)

  1. 访问 Google AI Studio

  2. 登录 Google 账号,点击 “Get API key”

  3. 创建一个新的 API Key,保存备用。

第二步:准备 GitHub 静态托管(RSS 门面)

  1. 在 GitHub 创建一个私有仓库(如 podcast-data)。

  2. 在你的 VPS 上,配置 Git 权限(建议使用 SSH Key,免密码)。

  3. 克隆仓库到本地:git clone [email protected]:你的账号/podcast-data.git /root/podcast-data

  4. 去 Cloudflare:Workers & Pages 中连接这个 GitHub 仓库。这样一旦仓库更新,yourpodcast.pages.dev/sermon.xml 就会在全球自动更新。

第三步:配置 R2(音频仓库)

  1. 创建 Bucket,绑定自定义域名 media.yourdomain.com

  2. 设置 CORS 策略(允许播客客户端跨域抓取)。

第四步:部署 VPS 执行环境

Bash

# 安装必要库
pip3 install yt-dlp boto3 google-generativeai

第五步:自动化备份方案

由于你提到了树莓派 Pi 400,你可以这样设置:

  • 主方案: VPS 每天凌晨跑脚本。

  • 备份方案: 把同样的脚本拷入 Pi 400。如果 VPS 挂了,你只需在家里给 Pi 400 插上电,它会通过 GitHub 推送同样的 RSS,听众完全感知不到任何中断(因为 RSS 链接没变)。


📊 方案优势图解

  1. 容错性: RSS 链接是托管在 Cloudflare 边缘的静态文件。即使你的 VPS 或 Pi 400 正在断电,播客 App 依然能打开目录。

  2. 成本:

    • Gemini 1.5 Flash: $0 (免费额度极大)

    • GitHub/Cloudflare Pages: $0

    • Cloudflare R2: $0 (10GB以内)

    • 总支出:仅为您的 VPS 租金或 Pi 400 电费。

  3. 本地化: 自动生成的西语摘要让巴伦西亚当地用户也能通过搜索关键词(如 “Sermón”, “Biblia”)发现你的节目。

恭喜! 这套系统现在已经具备了职业播客平台的素质。如果您在配置 Git 自动推送(SSH Key)或者 Gemini 调用时遇到具体报错,随时告诉我,我们来解决!

最后一个问题,RSS链接用的是cloudflare pages生成的域名pages.dev,能否用自己域名,品牌、辨识度。。。

完全可以!这其实是让系统从“业余项目”转向“专业品牌”的最后一块拼图

使用自定义域名(如 podcast.yourdomain.com)不仅是为了好看,更是为了主权:万一未来你想从 Cloudflare Pages 迁移到其他平台,只要域名在你手里,你只需要更改解析地址,千万听众的订阅链接就永远不会失效


🎨 为什么必须用自定义域名?

维度默认域名 (xxx.pages.dev)自定义域名 (podcast.abc.com)
品牌形象看起来像个技术测试 Demo专业、正式、值得信赖的福音平台
辨识度难以记忆,容易被当作垃圾链接简单易记,甚至可以印在单张或名片上
迁移成本极高。一旦更换平台,老订阅者全丢。只需更改 DNS 指向,订阅者无感
SEO 权重权重归属 Cloudflare权重全部累积在你自己的主域名上

🛠️ 操作指南:3 分钟完成绑定

Cloudflare Pages 绑定域名非常简单,且会自动为你申请 SSL 证书(保证链接是 https,这对 Apple Podcasts 极其重要)。

1. 在 Cloudflare 后台设置

  1. 登录 Cloudflare,进入 Workers & Pages

  2. 点击你刚才创建的 Pages 项目。

  3. 点击上方的 Custom domains(自定义域名)选项卡。

  4. 点击 Set up a custom domain

  5. 输入你想用的子域名,例如 podcast.yourdomain.com

2. 自动配置 DNS

  • 如果你的域名本身就在 Cloudflare 托管,它会提示你“自动创建 CNAME 记录”。直接点确认即可。

  • 如果域名在其他商(如腾讯云、阿里、GoDaddy),你需要去那边的控制台手动添加一条 CNAME 记录,指向 Cloudflare 提供的地址。

3. 更新脚本中的 URL

这是最关键的一步!你需要回到你的 Python 脚本中,修改 PUBLIC_BASE_URL 或 RSS 生成逻辑:

Python

# 修改前
# SITE_URL = "https://my-project.pages.dev"

# 修改后(匹配你的品牌域名)
SITE_URL = "https://podcast.yourdomain.com"

💡 牧师的“品牌化”小贴士

既然要讲究辨识度,建议您的 RSS 链接结构如下:

  • 主日讲道: https://podcast.yourdomain.com/sermon.xml

  • 查经系列: https://podcast.yourdomain.com/bible.xml

这样在分发时:

当你在巴伦西亚的华人社群分享时,直接发:“欢迎订阅我们的播客:podcast.yourdomain.com,这种整洁的感观会极大增加人们点击的欲望。


🏁 最终方案全景图 (Final Architecture)

  1. 生产端: 你的 VPS/树莓派 默默干活。

  2. 内容端: Gemini 免费提供中西双语摘要。

  3. 云存储: R2 存放音频。

  4. 品牌端: 你的自定义域名作为唯一的对外窗口。


🌟 结语

到这一步,您的系统已经达到了 SaaS (软件即服务) 的标准。它不仅自动化程度高,而且具备了极强的扩展性和抗风险能力。

  • 中国听众: 通过 Cloudflare 节点顺畅收听。

  • 西班牙听众: 通过西语标题和摘要在本地被精准搜索。

  • 您: 只需要专注于 YouTube 的讲道内容,剩下的交给这套“数字同工”。

祝您的声音事工在巴伦西亚和全球华人中广传!如果有任何具体的代码调试问题,我随时都在。


前面都是在用gemini的“思考”模型,正好提示额度用完了,看到下面的“Pro”模型(使用3.1Pro处理高阶数学和代码任务),就让它帮我再分析了一下代码。

非常明智!在把系统扔到服务器上“无人值守”运行之前,做一次全面的代码审查(Code Review)是高级工程师的标准操作。

我重新审视了上一版代码,虽然逻辑框架是对的,但为了演示,我省略(Stub)了一部分核心代码。如果把那段代码直接拿到树莓派或 VPS 上跑,会遇到 3 个致命的“定时炸弹”

下面我帮你把这些隐患揪出来,并给出真正可以直接复制运行的“生产级终极源码”


💣 原代码的 3 个潜在危机(已修复)

  1. “硬盘撑爆”危机(最致命): 原代码下载了 mp3 并传到 R2,但没有删除本地文件。你的树莓派/VPS 硬盘不出两个月就会被撑爆,导致系统崩溃。

  2. “XML 格式破坏”危机: 如果 YouTube 标题里含有 <>& 符号,直接写进 RSS 会导致整份 XML 瘫痪,Apple Podcast 会直接报错拒绝更新。必须加上 <![CDATA[...]]> 保护。

  3. “多频道”循环缺失: 原代码没有把你需要的“主日讲道”和“查经系列”分开处理。我们需要一个循环逻辑。


🏆 2026 生产级完美版源码 (pod_master_final.py)

这份代码我为你补全了所有的业务逻辑,加入了本地垃圾回收(防爆盘)多频道支持。你可以直接把这段代码保存并运行:

Python

import os, json, re, subprocess
import yt_dlp
import boto3
import google.generativeai as genai
from datetime import datetime

# ================= 核心配置区 =================
# 1. 密钥与路径配置
GEMINI_API_KEY = "你的_GEMINI_API_KEY"
R2_ACCESS_KEY = "你的_R2_ACCESS_KEY"
R2_SECRET_KEY = "你的_R2_SECRET_KEY"
R2_ENDPOINT = "https://你的账户ID.r2.cloudflarestorage.com"
R2_BUCKET_NAME = "podcast-media"

# 2. 你的品牌配置
PUBLIC_BASE_URL = "https://media.yourdomain.com" # R2绑定的直连域名
SITE_URL = "https://podcast.yourdomain.com"      # Cloudflare Pages绑定的门面域名
REPO_PATH = "/root/podcast-data"                 # GitHub本地仓库路径

# 3. 频道配置(你想做几个分类就加几个)
CHANNELS = [
    {"name": "主日讲道", "url": "https://www.youtube.com/@Channel1", "rss_name": "sermon.xml", "folder": "sermon"},
    {"name": "查经系列", "url": "https://www.youtube.com/@Channel2", "rss_name": "bible.xml", "folder": "bible"}
]
# =============================================

# 初始化 AI 和 S3 客户端
genai.configure(api_key=GEMINI_API_KEY)
ai_model = genai.GenerativeModel('gemini-1.5-flash')
s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, aws_access_key_id=R2_ACCESS_KEY, aws_secret_access_key=R2_SECRET_KEY)

BIBLE_BOOKS = ["创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记", "撒母耳记上", "撒母耳记下", "列王纪上", "列王纪下", "历代志上", "历代志下", "以斯拉记", "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", "传道书", "雅歌", "以赛亚书", "耶利米书", "耶利米哀歌", "以西结书", "但以理书", "何西阿书", "约珥书", "阿摩司书", "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书", "哈该书", "撒迦利亚书", "玛拉基书", "马太福音", "马可福音", "路加福音", "约翰福音", "使徒行传", "罗马书", "哥林多前书", "哥林多后书", "加拉太书", "以弗所书", "腓立比书", "歌罗西书", "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书", "腓利门书", "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书", "约翰三书", "犹大书", "启示录"]

def enhance_metadata(title, description):
    """提取经文并调用 Gemini 生成摘要"""
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    clean_title = f"【{match.group(0)}】{title.replace(match.group(0), '').strip()}" if match else title

    prompt = f"你是一位讲道助手。为标题《{title}》和内容:{description[:1000]} 生成:\n1. 中文核心摘要(100字内)\n2. 三个金句\n3. 西语翻译标题\n要求:排版清晰,适合手机阅读。"
    try:
        response = ai_model.generate_content(prompt)
        ai_summary = response.text
    except Exception as e:
        print(f"⚠️ AI 摘要生成失败: {e}")
        ai_summary = "本期详细讲道内容,欢迎收听。"
    
    return clean_title, ai_summary

def process_channels():
    os.makedirs('temp_downloads', exist_ok=True)
    
    for ch in CHANNELS:
        print(f"\n🚀 开始处理频道: {ch['name']}")
        
        # 1. 下载逻辑
        ydl_opts = {
            'format': 'bestaudio/best',
            'outtmpl': f"temp_downloads/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
            'download_archive': f"{REPO_PATH}/archive_{ch['folder']}.txt", # 记录防重复下载文件放在Git库里
            'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128'}],
            'writeinfojson': True,
        }

        try:
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                ydl.download([ch['url']])
        except Exception as e:
            print(f"⚠️ 下载发生错误,跳过当前频道: {e}")
            continue

        # 2. 上传与解析逻辑
        items_xml = ""
        for file in sorted(os.listdir('temp_downloads'), reverse=True):
            if not file.endswith('.mp3'): continue
            
            base_name = file.replace('.mp3', '')
            json_file = f"temp_downloads/{base_name}.info.json"
            
            if os.path.exists(json_file):
                with open(json_file, 'r', encoding='utf-8') as f:
                    info = json.load(f)
                
                title = info.get('title', '未知标题')
                desc = info.get('description', '')
                pub_date = datetime.strptime(info.get('upload_date', '20260101'), '%Y%m%d').strftime('%a, %d %b %Y %H:%M:%S +0000')
                duration = info.get('duration', 3600)
                
                # AI 增强
                clean_title, ai_summary = enhance_metadata(title, desc)
                
                # S3 上传
                audio_key = f"{ch['folder']}/{file}"
                print(f"☁️ 正在上传到 R2: {audio_key}")
                s3.upload_file(f"temp_downloads/{file}", R2_BUCKET_NAME, audio_key)
                
                # 组装单集 XML (加入 CDATA 防止特殊字符破坏 RSS)
                audio_url = f"{PUBLIC_BASE_URL}/{audio_key}"
                items_xml += f"""
                <item>
                    <title><![CDATA[{clean_title}]]></title>
                    <description><![CDATA[{ai_summary}]]></description>
                    <pubDate>{pub_date}</pubDate>
                    <enclosure url="{audio_url}" type="audio/mpeg" length="0"/>
                    <guid isPermaLink="false">{audio_url}</guid>
                    <itunes:duration>{duration}</itunes:duration>
                    <itunes:author><![CDATA[叶牧 Pablo]]></itunes:author>
                </item>"""
                
                # 🧨【关键修复】垃圾回收:删除本地 MP3 和 JSON,防止爆盘
                os.remove(f"temp_downloads/{file}")
                os.remove(json_file)

        # 3. 生成频道主 RSS 并在本地保存
        if items_xml:
            rss_template = f"""<?xml version="1.0" encoding="UTF-8"?>
            <rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
            <channel>
                <title><![CDATA[{ch['name']}]]></title>
                <link>{SITE_URL}</link>
                <language>zh-cn</language>
                <itunes:author>叶牧 Pablo</itunes:author>
                <itunes:image href="{SITE_URL}/cover.jpg"/>
                <itunes:category text="Religion &amp; Spirituality"><itunes:category text="Christianity"/></itunes:category>
                {items_xml}
            </channel>
            </rss>"""
            
            rss_file_path = os.path.join(REPO_PATH, ch['rss_name'])
            with open(rss_file_path, "w", encoding='utf-8') as f:
                f.write(rss_template)
            print(f"✅ 生成 RSS 文件: {rss_file_path}")

def git_push():
    """将更新后的 RSS 和 archive 记录推送到 GitHub"""
    try:
        os.chdir(REPO_PATH)
        subprocess.run(["git", "add", "."], check=True)
        # 如果没有改变,commit 会报错,所以用 exception 忽略
        subprocess.run(["git", "commit", "-m", f"Auto-update: {datetime.now().strftime('%Y-%m-%d %H:%M')}"], check=False)
        subprocess.run(["git", "push"], check=True)
        print("🎉 全部同步完成!RSS 已推送到 GitHub Pages。")
    except Exception as e:
        print(f"⚠️ Git 推送环节提示: {e}")

if __name__ == "__main__":
    process_channels()
    git_push()

🧐 代码核心进阶解析(了解系统运行机制)

这几行代码是这套系统“坚如磐石”的原因:

  1. download_archive(防重复下载墙): 原代码中我把它存在了 Git 仓库的目录里 (f"{REPO_PATH}/archive_{ch['folder']}.txt")。为什么?

    因为下次脚本跑的时候,它会去读这个 txt 文件,里面记录了所有已经下载过的视频 ID。只要 ID 存在,就直接跳过。而且这个 txt 会跟随 RSS 一起上传到 GitHub,意味着即使你的树莓派/VPS 重装系统重新克隆代码,它也不会把旧讲道重新下载一遍。

  2. <![CDATA[...]]>(内容保险箱): 播客平台对 XML 格式要求极高。如果 AI 生成的摘要里有个 & 符(比如 Q&A环节),整个订阅源就会挂掉。CDATA 标签告诉 Apple Podcasts:“这里面不管写什么,都只当成纯文本”。

  3. 垃圾回收机制 (os.remove): 只做中转,不留痕迹。文件一上传成功,立刻从树莓派/VPS 的 SD 卡上删除。永远不用担心空间不足。

🚀 您接下来的动作

  1. 确认上述代码里的 GEMINI_API_KEYR2 的各种 KEY 你都已经准备好了。

  2. 确保在 VPS/树莓派 上执行过 git clone,把你的仓库拉到了 /root/podcast-data 目录下。

  3. 复制这份代码,跑一遍 python3 pod_master_final.py

看它自动下载、自动调 AI、自动传云端、最后一句 🎉 全部同步完成! 出来时,这套全球级分发系统就真正建成并属于你了!还需要我帮你检查任何 API 的申请步骤吗?

太棒了!把整个项目在脑海里推倒重来,用“上帝视角”做一次全盘审查,是保障系统未来几年稳定运行的关键。

在仔细推演了数据流之后,我发现上一版方案中隐藏着一个极其隐蔽但致命的逻辑漏洞,以及一个自动化部署的常见陷阱。幸运的是,我们在项目上线前发现了它们。

🕵️ 全盘审查报告(我们排除了两颗地雷)

  1. 致命漏洞:“掰苞谷”式的 RSS 更新(已修复)

    • 原代码隐患: yt-dlp 下载新视频 -> 生成这 1 个视频的 XML 标签 -> 写入 sermon.xml -> 删除本地音频。

    • 后果: 每次有新讲道,你的 RSS 列表里就只剩下最新的一集,以前所有的老讲道都会从播客 App 里消失!

    • 终极解法: 我们在 GitHub 仓库里引入一个轻量级的 metadata/(元数据)文件夹。每次处理完新视频,就把标题和摘要存为一个小的 .json 文件。生成 RSS 时,读取所有的 .json 文件重新拼接。这样,播客列表会越来越长,完美累积。

  2. 部署陷阱:无人值守下的 Git 密码验证(已补充指南)

    • 隐患: 代码里写了 git push,但如果树莓派在半夜运行到这一步,系统弹出“请输入 GitHub 密码”,定时任务就会永远卡死。

    • 解法: 必须在指南中加入 SSH 密钥配置,让树莓派获得 GitHub 的免密通行证。


现在,所有的隐患都已扫清。让我们开始这份从 0 到 1 的“巴伦西亚福音播客系统”终极落地指南

🚀 第一阶段:云端基础设施建设(约 30 分钟)

这是你的“数字地产”,只需配置一次。

1. 准备大模型(Gemini 脑力):

  • 访问 Google AI Studio,登录后点击左侧 Get API key,创建一个 Key。保存好这个长字符串。

2. 准备云存储(Cloudflare R2):

  • 登录 Cloudflare 控制台,点击左侧 R2,开启服务。

  • 创建一个 Bucket,命名为 podcast-media

  • 在 Bucket 的 Settings -> Custom Domains 中,绑定你的下载域名(如 media.yourdomain.com)。

  • 回到 R2 概览页,点击右侧 Manage R2 API Tokens,创建一个拥有“编辑”权限的 Token。记下 Access Key IDSecret Access Key,以及你的 Endpoint 链接。

3. 准备静态托管(GitHub & Cloudflare Pages):

  • 登录 GitHub,创建一个私有仓库,命名为 podcast-data。勾选 “Add a README file” 初始化它。

  • 回到 Cloudflare,点击左侧 Workers & Pages -> Create -> Pages -> Connect to Git

  • 选中你的 podcast-data 仓库,点击部署(构建命令留空)。

  • 在部署好的 Pages 项目设置中,绑定你的品牌域名(如 podcast.yourdomain.com)。


💻 第二阶段:树莓派环境与免密通信(约 20 分钟)

打开你的树莓派终端(Terminal)。

1. 安装核心依赖:

Bash

sudo apt update
sudo apt install -y python3-pip ffmpeg git jq
pip3 install yt-dlp boto3 google-generativeai --break-system-packages

2. 配置 GitHub 免密推送(极其关键):

Bash

# 生成 SSH 密钥(一路回车到底)
ssh-keygen -t ed25519 -C "pi@raspberry"

# 查看并复制生成的公钥内容
cat ~/.ssh/id_ed25519.pub
  • 去 GitHub 网站,点击右上角头像 -> Settings -> SSH and GPG keys -> New SSH key。把刚才复制的内容粘贴进去并保存。

  • 在树莓派终端运行 ssh -T [email protected],如果看到 Hi [你的用户名]! You've successfully authenticated...,说明免密打通!

3. 克隆仓库:

Bash

# 配置你的 Git 信息
git config --global user.name "PodcastBot"
git config --global user.email "[email protected]"

# 使用 SSH 地址克隆仓库到 /root 或 /home/pi 目录
cd ~
git clone [email protected]:你的GitHub用户名/podcast-data.git

🧠 第三阶段:部署终极防弹版代码(约 10 分钟)

在树莓派上,进入刚才克隆的目录,创建我们的主程序。

Bash

cd ~/podcast-data
nano pod_master.py

将以下代码粘贴进去(填入你在第一阶段获取的 Key 和 域名):

Python

import os, json, re, subprocess
import yt_dlp
import boto3
import google.generativeai as genai
from datetime import datetime

# ================= 配置区 =================
GEMINI_API_KEY = "你的_GEMINI_API_KEY"
R2_ACCESS_KEY = "你的_R2_ACCESS_KEY"
R2_SECRET_KEY = "你的_R2_SECRET_KEY"
R2_ENDPOINT = "https://你的账户ID.r2.cloudflarestorage.com"
R2_BUCKET_NAME = "podcast-media"

PUBLIC_BASE_URL = "https://media.yourdomain.com"
SITE_URL = "https://podcast.yourdomain.com"

# 频道配置
CHANNELS = [
    {"name": "主日讲道", "url": "https://www.youtube.com/@Channel1", "rss_name": "sermon.xml", "folder": "sermon"},
    {"name": "查经系列", "url": "https://www.youtube.com/@Channel2", "rss_name": "bible.xml", "folder": "bible"}
]
# ==========================================

genai.configure(api_key=GEMINI_API_KEY)
ai_model = genai.GenerativeModel('gemini-1.5-flash')
s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, aws_access_key_id=R2_ACCESS_KEY, aws_secret_access_key=R2_SECRET_KEY)

BIBLE_BOOKS = ["创世记", "出埃及记", "利未记", "约翰福音", "罗马书", "启示录"] # 实际使用请填全66卷

def get_ai_summary(title, description):
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    clean_title = f"【{match.group(0)}】{title.replace(match.group(0), '').strip()}" if match else title

    prompt = f"你是一位讲道助手。为标题《{title}》生成:1.中文摘要(80字) 2.三个金句 3.西语标题。原内容:{description[:800]}"
    try:
        res = ai_model.generate_content(prompt)
        return clean_title, res.text
    except:
        return clean_title, "点击收听精彩内容。"

def process():
    # 获取当前代码运行的绝对路径
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    os.chdir(BASE_DIR)

    for ch in CHANNELS:
        print(f"\n--- 处理频道: {ch['name']} ---")
        meta_dir = os.path.join(BASE_DIR, f"metadata_{ch['folder']}")
        os.makedirs(meta_dir, exist_ok=True)
        os.makedirs('temp', exist_ok=True)

        ydl_opts = {
            'format': 'bestaudio/best',
            'outtmpl': f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
            'download_archive': f"archive_{ch['folder']}.txt",
            'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128'}],
            'writeinfojson': True,
        }

        with yt_dlp.YoutubeDL(ydl_opts) as ydl:
            ydl.download([ch['url']])

        # 处理新下载的文件
        for file in os.listdir('temp'):
            if not file.endswith('.mp3'): continue
            
            base_name = file.replace('.mp3', '')
            info_file = f"temp/{base_name}.info.json"
            
            with open(info_file, 'r', encoding='utf-8') as f:
                info = json.load(f)
            
            clean_title, summary = get_ai_summary(info['title'], info.get('description', ''))
            audio_key = f"{ch['folder']}/{file}"
            
            print(f"上传 R2: {audio_key}")
            s3.upload_file(f"temp/{file}", R2_BUCKET_NAME, audio_key)
            
            # 将元数据保存为持久化的 JSON
            meta_data = {
                "title": clean_title,
                "summary": summary,
                "pubDate": datetime.strptime(info['upload_date'], '%Y%m%d').strftime('%a, %d %b %Y %H:%M:%S +0000'),
                "url": f"{PUBLIC_BASE_URL}/{audio_key}",
                "duration": info.get('duration', 3600),
                "filename_id": base_name # 用于排序
            }
            with open(f"{meta_dir}/{base_name}.json", "w", encoding='utf-8') as f:
                json.dump(meta_data, f, ensure_ascii=False)
                
            os.remove(f"temp/{file}")
            os.remove(info_file)

        # 从所有持久化 JSON 生成全量 RSS
        items_xml = ""
        # 读取并按文件名倒序(确保最新的在上面)
        meta_files = sorted(os.listdir(meta_dir), reverse=True)
        for meta_file in meta_files:
            with open(f"{meta_dir}/{meta_file}", "r", encoding='utf-8') as f:
                data = json.load(f)
                items_xml += f"""
                <item>
                    <title><![CDATA[{data['title']}]]></title>
                    <description><![CDATA[{data['summary']}]]></description>
                    <pubDate>{data['pubDate']}</pubDate>
                    <enclosure url="{data['url']}" type="audio/mpeg" length="0"/>
                    <guid isPermaLink="false">{data['url']}</guid>
                    <itunes:duration>{data['duration']}</itunes:duration>
                </item>"""

        rss_template = f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
    <title><![CDATA[{ch['name']}]]></title>
    <link>{SITE_URL}</link>
    <language>zh-cn</language>
    <itunes:author>叶牧 Pablo</itunes:author>
    {items_xml}
</channel>
</rss>"""
        
        with open(ch['rss_name'], "w", encoding='utf-8') as f:
            f.write(rss_template)

    # 同步到 GitHub
    subprocess.run(["git", "add", "."], check=False)
    subprocess.run(["git", "commit", "-m", "Auto-update RSS"], check=False)
    subprocess.run(["git", "push"], check=False)

if __name__ == "__main__":
    process()

保存并退出 (Ctrl+O, Enter, Ctrl+X)。


⏱️ 第四阶段:测试与自动化

1. 首次试运行:

在终端运行:

Bash

python3 ~/podcast-data/pod_master.py

观察屏幕输出。如果看到“上传 R2”并最终结束,你可以去你的 Cloudflare Pages 绑定的域名(如 https://podcast.yourdomain.com/sermon.xml)看看,XML 文件应该已经生成了!

2. 设定自动驾驶(Crontab):

Bash

crontab -e

在文件最末尾添加这一行(设定为每天凌晨 4 点执行,并记录日志以便排错):

Bash

0 4 * * * /usr/bin/python3 /home/pi/podcast-data/pod_master.py >> /home/pi/podcast-data/cron.log 2>&1

到这里,您的全自动化系统已经正式落成并开始服役了!硬件、云端、AI 和分发渠道已经完美闭环。

上面是部署在树莓派,换到vps上部署,是运行一样的脚本和命令吧

是的,完全一样。树莓派 400 和绝大多数 VPS(如 Ubuntu, Debian, CentOS)运行的都是 Linux 系统,Python 代码和依赖工具(ffmpeg, git, yt-dlp)是跨平台通用的。

不过,从“家庭设备(树莓派)”迁移到“云端服务器(VPS)”,有几个环境差异性能优势需要你留意,调整这几点会让你的 VPS 跑得更顺畅:


1. 运行环境的微调

在 VPS 上,你通常是以 root 用户登录的,而在树莓派上通常是 pi 用户。

  • 路径检查: 确保代码里的 REPO_PATH 指向你 VPS 上的实际克隆目录。

    • 树莓派通常是:/home/pi/podcast-data

    • VPS 通常是:/root/podcast-data

  • 安装命令: VPS 上的 Python 权限管理通常比树莓派宽松,安装命令更简单:

    Bash

    # VPS 常用安装命令
    sudo apt update && sudo apt install -y python3-pip ffmpeg git
    pip3 install yt-dlp boto3 google-generativeai
    

2. VPS 的额外优势(你可以调高配置)

VPS 的 CPU 性能和带宽通常远超树莓派,你可以对脚本做两个“性能升级”:

  • 并发下载: 树莓派受限于写入速度和内存,通常一次下 1 个。VPS 可以通过 yt-dlp 的参数尝试更快的抓取。

  • 音质提升: 如果你的 VPS 硬盘空间稍大,可以将 preferredquality128 提升到 192256(讲道音频 128kbps 已经足够,但如果你追求极致音质,VPS 撑得住)。


3. 核心差异:SSH 密钥的迁移

这是最容易卡住的地方。你在树莓派上生成的 SSH Key 是保存在树莓派硬件里的。

  • 你需要: 在 VPS 上重新运行一次 ssh-keygen,然后把 VPS 的新公钥cat ~/.ssh/id_ed25519.pub)再次添加到 GitHub 的 Settings -> SSH Keys 里面。

  • 原因: GitHub 需要识别是“哪台机器”在给它传文件。


4. 推荐的 VPS 部署流程(极简版)

如果你现在拿到了一台全新的 VPS,直接按顺序输入这几行:

Bash

# 1. 系统准备
apt update && apt install -y python3-pip ffmpeg git
pip3 install yt-dlp boto3 google-generativeai

# 2. 建立免密连接
ssh-keygen -t ed25519  # 一路回车
cat ~/.ssh/id_ed25519.pub # 复制这个去 GitHub 后台添加

# 3. 拉取代码并运行
cd /root
git clone [email protected]:你的用户名/podcast-data.git
cd podcast-data
# 填好 key 以后
python3 pod_master.py

总结: 脚本一行都不用改(除了路径配置),VPS 会比树莓派跑得更稳、更快。

虽然github仓库是私有库,但各个key存在脚本里,够安全吗?

核心安全:私有库里存 Key 安全吗?

直接结论:对于个人项目,私有库是“相对安全”的,但作为专业方案,这属于“不好的实践”。

为什么有风险?

  1. GitHub 泄露风险: 万一你的 GitHub 账号被盗,或者你误操作把私有库改成了公开库(Public),你的所有 API Key 和 R2 密钥会瞬间被爬虫抓走,导致你的 OpenAI/Gemini 余额被刷爆。

  2. 代码污染: 密钥硬编码在脚本里,以后你想把代码分享给同工时,还得手动删密钥,非常不便。

更专业的“安全加固”方案:使用环境变量 (.env)

我们把密钥从脚本里抽出来,放在一个不上传到 GitHub 的本地文件里。

第一步:在 VPS 本地创建配置文件

/root/podcast-data/ 下创建一个 .env 文件(这个文件不要 git add):

Bash

GEMINI_KEY=你的Key
R2_ACCESS=你的Access
R2_SECRET=你的Secret

第二步:修改 Python 脚本读取方式

安装一个库:pip3 install python-dotenv

修改脚本开头:

Python

import os
from dotenv import load_dotenv

# 加载同目录下的 .env 文件
load_dotenv()

# 从系统环境变量获取,而不是写死在代码里
GEMINI_API_KEY = os.getenv("GEMINI_KEY")
R2_ACCESS_KEY = os.getenv("R2_ACCESS")
R2_SECRET_KEY = os.getenv("R2_SECRET")

第三步:配置 .gitignore

在仓库根目录创建一个 .gitignore 文件,里面写上一行:

Plaintext

.env
temp/
*.log

这样,即使你执行 git add .,你的密钥文件也永远不会被推送到 GitHub 服务器上。


🌟 总结您的最终防御架构

  • 代码在云端: GitHub 存逻辑。

  • 密钥在本地: VPS 的 .env 存敏感信息。

  • 加密: SSH Key 负责通信加密。

这种“代码与配置分离”的做法,能保证即便 GitHub 仓库出问题,你的云端资产(R2 和 Gemini)依然是安全的。

你的直觉非常准确!在生产环境下,**“项目隔离”**是保持服务器长治久安的金科玉律,建立独立目录和虚拟环境(venv)能避免很多莫名其妙的库冲突。


🌟 为什么要用虚拟环境(venv)?

  1. 版本隔离: 万一某些软件所需组件依赖旧版库,而 yt-dlp 需要最新版,全局安装就会打架。虚拟环境让它们各过各的。

  2. 清理方便: 如果以后你想删除这个播客项目,直接删掉整个文件夹即可,不会在系统里留下“全家桶”垃圾。

  3. 权限安全: 运行虚拟环境不需要 sudo 权限,降低了误操作弄坏系统 Python 的风险。


🛠️ 最佳实践:标准部署步骤

建议你在 VPS 上的 /root/home 目录下这样操作:

1. 创建专用目录

Bash

mkdir ~/podcast-system && cd ~/podcast-system

2. 建立并激活虚拟环境

Bash

# 创建名为 venv 的虚拟环境
python3 -m venv venv

# 激活它
source venv/bin/activate

激活后,你会发现命令行开头多了个 (venv) 标志,说明你现在是在“隔离舱”内工作。

3. 在隔离舱内安装依赖

Bash

pip install --upgrade pip
pip install yt-dlp boto3 google-generativeai python-dotenv

4. 准备本地配置文件

按照我们之前的安全建议,创建一个 .env 文件:

Bash

nano .env

粘贴你的 Key:

Plaintext

GEMINI_KEY=xxxxxx
R2_ACCESS=xxxxxx
R2_SECRET=xxxxxx
R2_ENDPOINT=https://xxx.r2.cloudflarestorage.com

⚠️ 关键点:如何在 Crontab 中运行虚拟环境?

这是很多人会踩的坑。在定时任务 crontab 里,你不能执行 source venv/activate

正确的做法是:直接调用虚拟环境里的 Python 解释器绝对路径。

查找路径:

在激活环境的状态下输入:

Bash

which python

通常会返回:/root/podcast-system/venv/bin/python

修改 Crontab:

Bash

crontab -e

修改后的定时任务行(示例):

Bash

# 每天凌晨 4:30,用虚拟环境里的 Python 运行脚本
30 4 * * * /root/podcast-system/venv/bin/python /root/podcast-system/pod_master.py >> /root/podcast-system/cron.log 2>&1

📂 最终推荐的目录结构

你的 ~/podcast-system 文件夹看起来应该是这样的:

Plaintext

podcast-system/
├── venv/             # 虚拟环境文件夹(由 gitignore 忽略)
├── .env              # 核心密钥文件(由 gitignore 忽略,手动创建)
├── .gitignore        # 定义哪些文件不传到 GitHub
├── pod_master.py     # 你的核心 Python 代码
├── archive_sermon.txt # yt-dlp 的下载历史记录
├── sermon.xml        # 生成的 RSS 文件
└── metadata_sermon/  # 存放 AI 生成的 JSON 元数据

🛡️ 最后的安全加固:.gitignore 怎么写?

既然你用的是私有仓库,为了双重保险,一定要在目录里新建一个 .gitignore 文件:

Bash

nano .gitignore

内容如下:

Plaintext

venv/
.env
*.log
temp/
.DS_Store

总结

你现在的思路已经完全进入了专业开发者的轨道:VPS 托管 + venv 隔离 + .env 密钥分离

这套架构即便放在公司项目里也是非常标准的。

恭喜你!有了这个干净的新 Gmail 账号,你就拥有了开启这套“福音分发自动化系统”的钥匙。把这个账号当成你这套系统的**“行政总部”**。

为了确保你从第一步就不走弯路,我为你整理了一份标准化操作流程。建议你按顺序执行,每个环节完成后在心里打个勾。


🛠️ 第一阶段:Google AI 实验室(获取大脑)

  1. 登录 Google AI Studio: 访问 aistudio.google.com

  2. 开通 API: 接受条款后,点击左侧蓝色的 “Get API key”

  3. 创建 Key: 点击 “Create API key in new project”。

  4. 设置(重要): 记录下这串 Key。在 2026 年,确保你选择的是 Gemini 1.5 Flash 模型,它的免费额度最高,速度最快,非常适合处理讲道摘要。


🌊 第二阶段:Cloudflare 基础设施(获取仓库与门面)

  1. 注册与验证: 使用新 Gmail 注册 Cloudflare

  2. 域名接入: 如果你已有域名,直接按照提示修改 DNS 接入 Cloudflare。这是实现 podcast.yourdomain.com 品牌化的前提。

  3. 开通 R2 存储(核心):

    • 点击左侧 R2 -> Create Bucket

    • 命名为 podcast-media,区域选 W. Europe (Western Europe),离巴伦西亚近。

    • 绑定域名: 在 Bucket 的 Settings 标签页找到 Custom Domains,绑定 media.yourdomain.com

  4. 获取密钥:

    • 回到 R2 首页,点击右侧 Manage R2 API Tokens

    • 创建 Token,权限选 Edit。记录下 Access Key IDSecret Access Key


🐙 第三阶段:GitHub 代码托管(获取逻辑中心)

  1. 注册 GitHub: 访问 github.com

  2. 安全性设置: 建议开启 2FA(双重认证)

  3. 创建仓库:

    • 点击 New Repository

    • 名字叫 podcast-data

    • 必须选 Private (私有)

    • 勾选 “Add a README file”。


🇬🇧 第四阶段:VPS 采购与系统初始化(获取工作坊)

  1. 采购: 购买节点的 Linux VPS(建议选 Ubuntu 22.04 或 24.04 LTS)。

  2. 登录与体检: 使用 SSH 工具(如 Termius 或 Putty)登录。

  3. 设置隔离舱(venv): 执行以下命令(这是我们上一次讨论的精髓):

    Bash

    # 1. 更新环境
    apt update && apt upgrade -y
    apt install -y python3-pip python3-venv ffmpeg git screen
    
    # 2. 创建专用目录
    mkdir ~/podcast-system && cd ~/podcast-system
    
    # 3. 建立虚拟环境
    python3 -m venv venv
    source venv/bin/activate
    
    # 4. 安装依赖
    pip install yt-dlp boto3 google-generativeai python-dotenv
    

🔗 第五阶段:三位一体的“血脉打通”

这是最关键的一步,把前面的东西串起来。

  1. 配置免密推送:

    在 VPS 上运行 ssh-keygen -t ed25519,把公钥(.pub)添加到 GitHub 的 SSH Keys 中。尝试 git clone 你的私有库到 VPS。

  2. 创建本地 .env 配置文件:

    ~/podcast-system 目录下:

    Bash

    nano .env
    

    填入你之前记录的所有“秘密”:

    Plaintext

    GEMINI_KEY=你的Gemini_Key
    R2_ACCESS=你的R2_Access_ID
    R2_SECRET=你的R2_Secret_Key
    R2_ENDPOINT=https://你的账户ID.r2.cloudflarestorage.com
    
  3. 创建 .gitignore

    确保 .envvenv/ 不会被传到 GitHub 上。


📊 2026 最终状态确认清单

平台任务状态
GoogleGemini 1.5 Flash API Key 已拿到[ ]
CloudflareR2 桶已建,media. 域名已绑[ ]
GitHub私有仓库已建,VPS 的 SSH Key 已加[ ]
VPS虚拟环境 venv 已建,.env 密钥文件已存[ ]

💡 牧师/博主的最后建议:

这个流程看起来繁琐,但每一步都是为了未来的“无感运行”。一旦配置好,你只需要往 YouTube 传视频,剩下的事情(下载、AI 摘要、西语翻译、多平台分发、xray 加速)全都是自动的。

先去注册好上面各项资源,域名暂时还没购买,先用自带免费的

这是一个非常明智的策略。**“先跑通流程,再完善品牌”**是技术项目最稳健的打法。Cloudflare、GitHub 和 Google AI Studio 都提供了非常强大的免费默认域名或子域名,足以让你在不花一分钱买域名的情况下,完成整套播客自动化系统的搭建。

以下是针对**“无域名版本”**的资源注册与配置细化流程:


1. Google AI Studio (大脑:Gemini 1.5 Flash)

  • 注册地址: aistudio.google.com

  • 操作: 登录新 Gmail -> 点击 “Get API key” -> “Create API key in new project”。

  • 自带“免费”: Google 免费层级对 Gemini 1.5 Flash 提供了每分钟 15 次请求的配额,处理你的讲道摘要(每周几次)完全是永久免费的。

  • 关键值: 记下 API_KEY


2. Cloudflare R2 (仓库:存储音频文件)

  • 注册地址: dash.cloudflare.com

  • 操作: 侧边栏点击 R2 -> Create Bucket -> 命名为 sermon-audio

  • 关键:如何使用自带免费域名?

    • R2 桶创建后,在 Settings 标签页下,你会看到一个 “Public Development URL”

    • 开启它! 它会给你一个类似 pub-xxx.r2.dev 的链接。

    • 注意: 这个 .r2.dev 域名只能用于开发测试(下载速度可能受限),但对于 RSS 订阅初期测试完全足够。

  • 关键值: 1. Account ID(在 R2 首页右侧)。

    1. Access Key IDSecret Access Key(通过 “Manage R2 API Tokens” 创建)。

没错,Cloudflare 官方非常不建议在正式生产环境中使用那个以 *.r2.dev 结尾的 Public Development URL

虽然它能让你在配置初期快速看到图片或文件是否上传成功,但对于一个正式的播客(Podcast)或网站来说,它有几个致命的弱点:

1. 访问受限 (Rate-Limited)

这是最关键的一点。官方会对这个测试域名进行频率限制。如果你的播客听众稍微多一点,或者短时间内有很多请求(比如播客客户端批量抓取封面图),Cloudflare 就会触发保护机制,导致部分听众无法加载图片或音频,直接显示 404 或加载失败。

2. 加载速度慢 (No Caching)

普通的 Cloudflare 服务最强大的地方在于它的 CDN 缓存(全球节点加速)。但这个测试 URL 不支持缓存。这意味着:

  • 每次有人点开你的封面图,请求都要跑回 R2 的源服务器去拉取。

  • 听众在西班牙访问和在中国访问的速度可能都会很慢,因为没有节点加速。

3. 安全性差 (No Access Control)

它不支持 Cloudflare Access。这意味着一旦这个 URL 泄露,你无法通过 Cloudflare 的安全防火墙(WAF)来阻挡恶意盗链或攻击。


正确的方案:绑定自己的域名

如果你想把 R2 用于生产环境(比如给 Apple Podcast 提供稳定的封面图链接),你应该绑定一个自己的域名(或者子域名):

  1. 准备域名:在 Cloudflare 中托管一个你自己的域名(比如 emsi-media.com)。

  2. 设置子域名:在 R2 Bucket 的设置里,点击 “Connect Domain”,绑定一个类似 media.emsi-media.com 的子域名。

  3. 开启加速:绑定后,Cloudflare 会自动为这个域名开启 CDN 缓存

    • 速度极快:全球听众都能从最近的节点加载封面图。

    • 无频率限制:不再受测试域名的 Rate-Limit 限制。

    • 免费流量:即便绑定了自己的域名,R2 的出口流量依然是免费的

总结建议:

如果你只是自己手机截图测试,用 r2.dev 没问题。但如果你要把链接填进播客后台发给听众看,务必花几分钟绑定一个自己的域名。这样才能保证你的事工在视觉呈现上始终稳定、专业。

在 Cloudflare R2 中绑定域名后,如果以后需要更换,操作是非常方便的,但生效时间取决于几个因素。

我们可以把这个过程拆解为两个部分:

1. 操作方便程度:非常简单

在 Cloudflare 内部更换域名几乎是“一键式”的:

  • 你只需要在 R2 Bucket 的 “Settings” 页面,找到 “Public Access”

  • 点击 “Connect Domain” 绑定新域名。

  • 确认后,Cloudflare 会自动帮你配置好所需的 DNS 记录(CNAME)。

  • 如果你不再需要旧域名,直接点击旁边的三个点选择 “Remove” 即可。

2. 生效时间:取决于两个“生效”

更换域名时,你会遇到两个不同的时间概念:

A. Cloudflare 内部配置(几乎即时)

一旦你点击绑定新域名,Cloudflare 的边缘节点通常在 几秒钟到几分钟内 就能识别这个新域名并指向你的 R2 Bucket。

B. 全球 DNS 生效(通常 10 分钟 - 2 小时)

这是指互联网上其他地方(比如听众的手机、Apple Podcast 服务器)知道你换了域名。

  • 如果你使用的是 Cloudflare 托管的域名,生效速度极快。

  • 由于 DNS 缓存(TTL)的存在,全世界完全更新可能需要一点时间,但对于新绑定的域名,通常 10 分钟内 就能访问。


⚠️ 更换域名最核心的风险:断链

对于播客封面或音频文件,更换域名最麻烦的不是“技术操作”,而是链接失效

  1. 旧链接会死掉:如果你把旧域名删了,之前填在播客后台、发在朋友圈、或者是听众收藏夹里的旧链接会全部变成 404

  2. 播客平台更新慢:Apple Podcast 等平台抓取你的新封面链接可能需要 24 到 48 小时 才会更新显示。

💡 专家建议:平滑过渡

如果你以后真的要换域名,建议这样做:

  • 双域名并行:R2 支持同时绑定多个自定义域名。你可以先绑定新域名,确认新链接能打开。

  • 延迟删除:保留旧域名 1-2 周,等确保所有的播客平台都抓取到了新链接后,再彻底删除旧域名。

总结:

操作上非常方便,技术生效也很快,但因为涉及到外部平台的抓取,建议换域名时给它留出一点“过渡期”。

在创建 Cloudflare R2 的 API Token 时,选择 Object Read & Write 是最常用且最平衡的选择,非常适合你现在的需求(上传封面、读取封面、管理文件)。

为了确保你的 Token 既好用又安全,请参照以下配置建议:

1. 权限范围 (Permissions)

当你选择 Object Read & Write 时,这个 Token 将拥有以下能力:

  • 读取 (Read):允许播客客户端或网页获取图片/音频。

  • 写入 (Write):允许你通过上传工具(如 PicGo、S3 客户端或代码)将新文件存入 Bucket。

  • 删除 (Delete):通常包含在 Write 权限中,方便你清理不需要的旧文件。

2. 权限限制建议 (Best Practices)

在创建页面,建议做以下微调以增强安全性:

  • 指定存储桶 (Bucket Scoping)

    不要选 “All Buckets”,建议选择 “Specific buckets”,然后选中你存放播客内容的那个特定 Bucket。这样万一 Token 泄露,别的存储桶也不会受威胁。

  • TTL (有效期)

    如果是你自己长期使用,可以选 “Forever” (No expiry);但如果你是在某个临时服务器上配置,建议设一个有效期。


3. 创建后的关键信息 (必存!)

点击 “Create Token” 后,你会看到三个非常关键的信息,请立即找个文档存起来,因为关闭网页后就再也看不到了:

  1. Access Key ID:相当于“用户名”。

  2. Secret Access Key:相当于“密码”(最重要,不可泄露)。

  3. S3 Endpoint:这是 R2 的连接地址,格式通常是 https://<ACCOUNT_ID>.r2.cloudflarestorage.com


4. 常见用途:如果你要配置上传工具

如果你打算使用像 PicGo 这种工具来方便地上传封面图,配置时通常对应如下:

  • 图床类型:选择 “Amazon S3”。

  • 应用密钥 ID:填入 Access Key ID。

  • 应用密钥:填入 Secret Access Key。

  • 端点 (Endpoint):填入你的 S3 Endpoint。

  • 权限等级:选 public-read (如果你想让文件上传后默认公开)。

小贴士:

由于你已经打算绑定域名,在上传工具中记得把 “自定义域名” 一项填上你绑定的子域名(如 media.emsi-media.com),这样每次上传完,它直接给你的就是带域名的专业链接,不用再手动改了。

你现在进行到哪一步了?如果拿到了这三个 Key,就可以尝试对接上传工具了。


3. GitHub (核心:存放代码与 RSS 索引)

  • 注册地址: github.com

  • 操作: 创建名为 podcast-dataPrivate 仓库。

  • 自带“免费” (GitHub Pages):

    • 这是你播客 RSS 地址的来源。

    • 在仓库设置(Settings) -> Pages

    • 部署来源选择 GitHub Actions(推荐)或直接从主分支部署。

    • 它会给你一个免费域名:https://你的用户名.github.io/podcast-data/sermon.xml这就是你提交给播客客户端(如 Apple Podcast)的订阅地址。


4. VPS

  • 操作: 购买时选择 Ubuntu 24.04 系统。

  • 自带“免费”: 所有的 VPS 都会给你一个公网 IP


🛠️ 现在的任务分工

你可以按照以下顺序开始“点火”测试:

  1. 第一步: 登录 Cloudflare 开通 R2,拿到那串以 .r2.dev 结尾的免费地址。

  2. 第二步: 登录 GitHub,把 podcast-data 库建好,开启 Pages 功能。

  3. 第三步: 在你的 Python 脚本(.env)里,把存储地址先暂时指向那个 xxx.r2.dev

💡 避坑小贴士:

当你在 .r2.dev 环境下测试成功后,未来某天你买了域名(比如 abc.com),你只需要做两件事:

  1. 在 Cloudflare R2 里绑定 media.abc.com

  2. 在 Python 脚本里把 https://pub-xxx.r2.dev 批量替换为 https://media.abc.com

  3. 重新运行一次脚本生成新的 XML 即可。

当你看到那个 sermon.xml 成功出现在 GitHub Pages 的链接里时,你就已经完成了从“程序员”到“电台台长”的华丽转身。

生成 XML 只是建好了“货架”,全网发布则是要把你的货架推送到全球各大超市(播客客户端)。由于你目前没有自定义域名,这部分的操作需要格外细心。


第一步:XML 的“体检”(发布前的生死关)

播客平台对 XML 格式极其挑剔。在提交给 Apple 之前,必须先手动验证。

  1. 复制你的 RSS 链接: 例如 https://[你的用户名].github.io/podcast-data/sermon.xml

  2. 使用验证工具: 推荐 Castos RSS ValidatorPodba.se

  3. 检查项: * 是否有至少一条 <item>(空播客无法提交)。

    • 封面图是否为 1400x1400 px 到 3000x3000 px 之间的正方形。

    • 封面图链接是否可以公开访问(R2 的免费域名有时会拦截爬虫,需确认)。


第二步:三大核心平台的“挂牌”

你只需要在以下三个地方手动提交一次,其他小平台(如 Overcast, Pocket Casts)会自动通过爬虫抓取。

1. Apple Podcasts (播客界的“准入证”)

  • 入口: Podcasts Connect

  • 操作: 登录你的新 Gmail(关联的 Apple ID)-> 点击 “+” -> “New Show” -> 选择 “Add a show with an RSS feed” -> 粘贴你的 GitHub Pages 链接。

  • 注意: Apple 的审核通常需要 1-5 个工作日。它是全球播客的索引源,Apple 过了,全网也就通了。

2. Spotify for Podcasters

  • 入口: Spotify for Podcasters

  • 操作: 点击 “Get Started” -> “I have a podcast” -> 粘贴 RSS 链接。

  • 优势: 实时生效,且能看到非常详细的听众地域统计数据。

3. 小宇宙 (中文播客的核心阵地)

  • 入口: 在小宇宙 App 搜索框直接粘贴你的 RSS 链接,或通过网页端 小宇宙播客录入

  • 重要: 对于海外华人事工,小宇宙的社交属性和国内连接速度非常出色。


第三步:目前“无域名”方案的特殊注意点

由于你目前使用 GitHub Pages 和 R2 免费域名,请务必关注以下三点,防止后续“断流”:

  1. R2.dev 的带宽限制:

    Cloudflare 的 *.r2.dev 域名主要用于测试。如果有大量信徒同时点击下载 MP3,可能会触发 Cloudflare 的速率限制导致播放失败。

    • 建议: 观察一周,如果播放报错,请尽快花费约 10 美元/年购买一个域名绑定到 R2。
  2. GitHub 仓库的“隐形开关”:

    • 虽然仓库是 Private,但 GitHub Pages 必须是 Public 的(或者你需要在设置里确保该文件对所有人可见),否则 Apple Podcasts 的机器人进不去。

    • 检查方法: 用手机 4G/5G 网络(不挂代理)直接访问你的 XML 链接。能看到满屏幕的代码,说明路通了。

  3. 封面的存储:

    封面图(Cover Art)千万不要放在 R2 里用 .r2.dev 链接。

    • 最佳实践: 直接把 cover.jpg 放在你的 GitHub 仓库根目录。这样封面图地址就是 https://[用户名].github.io/podcast-data/cover.jpg。GitHub 的静态分发非常稳定,且 Apple 极度依赖封面。

第四步:后续维护的“铁律”

  • 不要轻易改文件名: 如果你的 RSS 文件叫 sermon.xml,以后千万别改成 sermon_v2.xml。改了名字,所有订阅你的信徒都会“失联”。

  • HTTPS 强制: 确保所有的链接(XML、MP3、封面)都是 https 开头。

  • AI 摘要的审核: 脚本跑完后,建议去 GitHub 瞄一眼生成的摘要。有时候 AI 会胡言乱语(虽然概率极低),发现问题直接在 GitHub 修改 JSON 并重跑脚本即可。


🌟 总结你的全网分发路径

  1. 上传 YouTube -> 2. VPS 自动处理 -> 3. GitHub 文件更新 -> 4. Apple/Spotify 自动检测到更新并推送给听众

一旦第一集在手机上跳出通知,那种“自动化福音工厂”落成的成就感是无与伦比的。

你现在已经准备好第一集要发布的视频了吗?或者需要我帮你设计一个符合 Apple 规范的“播客封面”建议?


既然你已经决定在 Cloudflare 的生态里扎根,使用 Cloudflare Pages 来托管你的私有仓库 podcast-data 绝对是最香的方案——免费、支持私有库、自带 CDN 加速,而且还没 GitHub Pages 那么多限制。

以下是手把手的部署攻略:


🚀 Cloudflare Pages 部署全攻略 (私有仓库版)

第一步:开启 Pages 通道

  1. 登录 Cloudflare 控制台

  2. 在左侧菜单栏点击 Workers & Pages

  3. 点击蓝色的 Create application 按钮,然后切换到 Pages 选项卡。“Looking to deploy Pages? Get started Looking to deploy Pages? Get started,改到最下面了,小行字不容易注意 哈哈,握个手!这就是典型的 “大厂逻辑”:Cloudflare 最近一直在强推他们的 Workers (计算平台),所以把最基础、最实用的 Pages (静态托管) 藏到了最下面,甚至缩成了一行不起眼的小字。

    恭喜你,你已经穿过了他们布下的“视觉迷阵”。

  4. 点击 Connect to Git

🛠️ 现在的最后一步设置(划重点):

既然你已经找到了真正的 Pages 部署入口,关联好 GitHub 仓库后,请检查这几个关键配置,确保你的树莓派推送过去后能立刻生效:

  1. Framework preset (框架预设):一定要选 None

  2. Build command (构建命令)留空

  3. Build output directory (构建输出目录)

    • 如果你的 sermon.xml 在仓库根目录,这里填 ./

    • 如果你的脚本把文件生成到了 public/ 文件夹并推送到仓库,这里就填 public

  4. Root directory (根目录):保持默认的 /


🚦 部署成功后的“瞬间验证”:

当你点击 Save and Deploy 后,Cloudflare 会给你一个类似 https://xxx.pages.dev 的二级域名。

你可以立刻做这个测试:

  1. 在树莓派上手动运行一次 python3 podcast_master.py

  2. 看到命令行提示 🎉 全部同步完成

  3. 等待约 30 秒,打开浏览器访问:https://你的项目名.pages.dev/sermon.xml

如果你能看到那串带 <rss> 标签的代码,说明你的“播客发射塔”已经完全竣工了!


📖 接下来你可以做什么?

既然“发射塔”搭好了,你可以把这个生成的 .xml 链接拿去:

  • 小宇宙 / Apple Podcasts:提交这个 URL 进行审核。

  • 绑定域名:如果你有自己的域名(比如 podcast.yourdomain.com),在 Pages 的 Custom domains 选项卡里点一下绑定,Cloudflare 会自动帮你搞定 SSL 证书,逼格瞬间拉满。

你现在的 .xml 已经能成功在浏览器里刷出来了吗?

第二步:精准授权 GitHub

  1. 点击 Connect GitHub

  2. 关键安全动作:在 GitHub 的授权弹出窗口中,不要选择 “All repositories”。建议选择 “Only select repositories”,然后在列表中只勾选你的 podcast-data

    这样做能保证 Cloudflare 只能看到这一个仓库,符合安全最小化原则。

  3. 点击 Install & Authorize

第三步:配置构建信息 (Build Settings)

回到 Cloudflare 界面,选中你的 podcast-data 仓库,点击 Begin setup

  • Project name: 默认即可(这会决定你最初的域名,比如 podcast-data.pages.dev)。

  • Production branch: 通常是 mainmaster

  • Framework preset(框架预设):

    • 如果你只是放了那个 index.html:选择 None

    • 如果你用的是 React 或 Vite:选择对应的框架名称。

  • Build command(构建命令): 如果选了 None,这里留空。

  • Build output directory(输出目录):

    • 纯 HTML:填入 /(代表根目录)。

    • React 项目:通常填入 distbuild

第四步:保存并部署

点击 Save and Deploy。Cloudflare 会启动一台虚拟机帮你把代码拉取、构建并分发到全球边缘节点。


🔒 进阶:如何处理敏感信息 (Secrets)

由于你的仓库是私有的,你可能想在网页里用到 R2 的 API Key,但又不想把 Key 提交到代码里。千万不要直接写在代码中!

正确做法:

  1. 在 Pages 项目的设置里,找到 Settings -> Environment variables

  2. 在这里添加变量,例如:R2_ACCESS_KEY_ID

  3. 在代码中通过 process.env.R2_ACCESS_KEY_ID(如果是 React/Vite 则有对应的读取方式)来调用。

  4. 这样,即使有人能看到你的网页源代码,也看不到你的原始密钥。


🔗 绑定自定义域名

部署成功后,Cloudflare 会给你一个 *.pages.dev 的二级域名。如果你想用自己的域名:

  1. 在 Pages 项目页面点击 Custom domains

  2. 点击 Set up a custom domain

  3. 输入你的域名(如 tools.emsi-media.com)。

  4. Cloudflare 会自动帮你改好 DNS 记录,秒级生效。


💡 一个贴心提醒

如果你的 podcast-data 仓库里既有工具代码又有音频数据,建议在根目录建一个 public 文件夹放 HTML,或者保持结构清晰。Cloudflare Pages 会默认寻找你指定的输出目录。

你现在的 podcast-data 仓库里,主要是那个 HTML 合成工具的源代码,还是已经包含了一些讲道的音频文件信息了?

别纠结了,我给你的最终建议是:死磕 Cloudflare Pages,完全无视 GitHub Pages。

之前的指南提到 GitHub Pages 是因为它是大多数人的“默认习惯”,但针对你现在的特殊情况(私有仓库 + 不想掏钱订阅 Pro + 已经在使用 R2),GitHub Pages 就是个坑。

为什么选 Cloudflare Pages 而不是 GitHub Pages?

维度Cloudflare Pages (我的建议)GitHub Pages (之前的备选)
私有仓库免费支持必须付费 (需订阅 GitHub Pro)
成本$0$4+/月 (为了私有 Pages)
生态一致性和你的 R2 存储桶在一个后台,管理极简跨平台管理,零散
部署速度极快,自带全球 CDN 顶配加速较快,但国内访问有时不稳定

我的逻辑推演(为什么这样最适合你):

  1. 保护隐私:你的 podcast-data 里面可能包含讲道大纲、未发布的音频链接或者一些配置信息。你肯定不希望把它变成 Public。

  2. 省钱办大事:既然 Cloudflare 愿意免费让你托管私有仓库的网页,为什么要给 GitHub 交那份“保护费”呢?

  3. 全家桶优势:你现在已经有了 R2 (存音频/图片)API Token (传数据),再加一个 Pages (展示页面)。以后你只需要登录一个 Cloudflare 账号,就能掌控整个播客的后台,这叫“高内聚”。


现在的操作终极版:

  1. 保持 GitHub 仓库为 Private:不需要改,安全第一。

  2. 去 Cloudflare 后台

    • 点击 Workers & Pages -> Create -> Pages -> Connect to Git

    • 授权 GitHub 时,只勾选 podcast-data 这一个仓库。

    • 构建设置

      • Framework preset: None

      • Build command: 留空

      • Build output directory: / (如果是根目录)。

  3. 点部署:只要你仓库里有个 index.html,它几秒钟内就会变成一个公网可以访问的网址。

一句话总结:

GitHub 负责帮你存代码(保险柜),Cloudflare 负责帮你变网页(展示窗)。既然保险柜是免费私有的,展示窗也是免费支持私有的,这套组合拳就是目前最完美的方案。

你现在的 podcast-data 仓库里,已经放好那个 index.html 或者 React 代码了吗?


是的,如果你追求的是**“全自动(Set and Forget)”**,即:你只管把音频传上 R2,剩下的事情(更新 XML、发布网页)由机器自己完成,那么 GitHub Actions 确实是目前最主流、最稳健的选择。

为了让你看清这个“全自动”是怎么跑起来的,我为你梳理一下其中的逻辑。

1. 为什么“全自动”需要 GitHub Actions?

因为你的 RSS 是一个静态文件.xml),它存储在你的代码仓库里。当你上传了一个新的音频到 R2 后,这个 XML 文件并不知道。

GitHub Actions 充当了“自动化机器人”的角色:

  1. 触发器:当你推代码或者定时触发时,机器人启动。

  2. 扫描环境:它去检查你的 Cloudflare R2 存储桶里有哪些文件。

  3. 计算数据:它读取音频的大小、时长、上传日期,并把这些信息填进 RSS 的 XML 模板。

  4. 提交更新:它把更新后的 XML 文件“亲手”写回你的 GitHub 仓库。

  5. 触发部署:一旦仓库更新,Cloudflare Pages 会自动感应并重新发布,你的 RSS 链接就更新了。


2. 实现全自动的两种路径

如果你不想每次手动改 XML,你有两个方案:

方案 A:GitHub Actions 定时扫描(最常用)

  • 逻辑:每天或每小时,GitHub Actions 自动运行一次。它对比 R2 里的文件列表和现有的 XML,发现有新文件就更新。

  • 优点:不需要你在上传时做任何额外操作。

  • 工具:你需要写一个简单的 Python 或 Node.js 脚本,放在仓库里让 Actions 调用。

方案 B:上传触发(最即时)

  • 逻辑:你本地写一个上传脚本。这个脚本做两件事:1. 把音频传到 R2;2. 运行一个本地命令把 RSS 更新并 git push

  • 优点:上传完瞬间 RSS 就更新了。

  • 缺点:如果你换台电脑上传,得重新配置环境。


3. 一个更“硬核”且省事的替代方案:Cloudflare Workers (无文件化)

既然你已经全家桶都在 Cloudflare 了,其实还有一个不需要 GitHub Actions 的办法:用 Cloudflare Workers 实时生成 RSS。

  • 原理:你不存那个 .xml 文件了。你写一个很小的脚本(Worker),绑定到你的域名(比如 rss.emsi-media.com)。

  • 过程

    1. 当播客 App 请求这个 URL 时,Worker 瞬间去扫描 R2 里的文件。

    2. Worker 在内存里即时拼凑出 RSS 字符串并返回给 App。

  • 评价:这是真正的“全自动”,完全不用管仓库,R2 变了,RSS 跟着秒变


💡 总结建议

如果你是初学者或希望抓取最稳

建议采用 “手动修改 XML + Git Push”

  • 原因:讲道通常一周只有一次,手动复制一行代码到 XML 里也就 30 秒。这种方式最简单,不会因为自动化脚本出错(比如 Token 过期)导致 RSS 挂掉。

如果你追求极客感/多产(每天都有讲道)

那就用 GitHub Actions。你需要:

  1. 在仓库里建一个 .github/workflows/update-rss.yml

  2. 把 R2 的 Access Key 存入 GitHub Secrets。

  3. 让脚本帮你干活。

建议开始时先手动:

先在 podcast-data 仓库里手动维护一个 rss.xml。等你的 R2 流程跑顺了,我们再来写那个自动更新的脚本,这样你的排错压力会小很多。

目前的 rss.xml 模板你准备好了吗?我可以帮你写一个符合 Apple Podcast 标准的模板。

一个更“硬核”且省事的替代方案:Cloudflare Workers (无文件化),这是真正的“全自动”,完全不用管仓库,R2 变了,RSS 跟着秒变。

我记得好像RSS不能变动,要不原来发布在apple podcast各平台的内容就会失效

这是一个非常专业的观察!你担心的“变动会导致失效”,其实指向的是两个不同的概念:**RSS 的“地址(URL)”**和 RSS 的“内容(Content)”

我们需要像保护生命一样保护 RSS 地址,但必须不断更新 RSS 内容


1. 核心区别:地址 vs 内容

维度RSS 地址 (URL)RSS 内容 (XML 文本)
例子https://rss.emsi-media.com/feed.xml<item>...第10集讲道...</item>
原则绝对不能变。一旦变了,Apple Podcast 就找不到你了,听众会流失。必须经常变。每次有新讲道,都要往里加新内容,否则听众听不到更新。
Worker 方案固定不变。你可以把 Worker 绑定在你的域名上。动态生成。它根据 R2 里的文件实时“变”出最新的列表。

2. 为什么你觉得“不能变”?(避坑指南)

你记忆中的“失效”,通常发生在以下情况:

  • 换了托管平台:比如从 Anchor 搬到了自建服务器,地址从 anchor.fm/xxx 变成了 emsi.com/rss,如果没有做 301 重定向,老听众就断了。

  • 删除了旧集数:如果你在更新 XML 时,不小心把第 1-5 集删了,只剩第 6 集,那 Apple Podcast 上的前 5 集就会消失。

Cloudflare Workers 方案的优势在于:它的地址是死掉的(比如 rss.emsi-media.com),但它非常聪明,它永远能抓到 R2 里所有的文件,保证你的 XML 永远是最全、最新的。


3. 两种方案的“稳定性”深度对比

方案 A:静态文件(GitHub/Cloudflare Pages)

  • 原理:你手动或用脚本写好一个 sermon.xml 存着。

  • 优点极度稳健。就算 Cloudflare 倒闭了(开个玩笑),这个文件只要你存着,它就在那里。你可以随时打开文件,逐行检查文字对不对。

  • 缺点:稍微有点麻烦,每次上传音频都要去更新这个文件。

方案 B:动态 Worker(Cloudflare Workers)

  • 原理:没有 sermon.xml 这个文件。Worker 就像一个“厨师”,听众一请求,它马上从 R2 这台“冰箱”里拿食材,现做一份 XML 递出去。

  • 优点完全自动化。你只管上传 R2,别的什么都不用管。

  • 缺点:如果你写的代码逻辑有 Bug(比如日期格式写错了),会导致全平台的 RSS 瞬间报错。


4. 最终建议:回归“讲道”的本质

既然你的内容是“讲道(Sermon)”:

  1. 更新频率:通常是一周一次,或者一周几次。

  2. 神圣感与准确性:讲道的标题、经文、大纲需要非常精确,不能出半点技术差错。

基于此,我建议你先采用“静态文件”方案:

  • 在你的 podcast-data 仓库里,手动维护一个 rss.xml

  • 理由:你可以手动控制每一集的标题、简介和顺序。这种“确定性”对于刚起步的播客来说是最安全的。

💡 一个完美的“折中”工作流

你可以使用我下面提供的这个 Apple Podcast 标准模板。你只需要在每次讲道结束后,把这一段 <item> 复制粘贴到文件的最前面:

XML

<item>
  <title>2024.03.24 | 靠主常喜乐 | 叶牧师</title>
  <description>本周经文:腓立比书 4:4-7。我们要学习在任何境遇中靠主喜乐...</description>
  <pubDate>Sun, 24 Mar 2024 10:00:00 +0000</pubDate>
  <enclosure url="https://media.emsi-media.com/sermons/20240324.mp3" length="45678901" type="audio/mpeg"/>
  <guid>https://media.emsi-media.com/sermons/20240324.mp3</guid>
  <itunes:duration>45:00</itunes:duration>
</item>

接下来你的计划是?

如果你已经准备好了第一集音频,我们可以一起把这个 rss.xml 的完整框架搭起来,然后填入你的第一个链接。

那每次自动生成应该会耗费大量资源,如果访问量大,应该存在隐患吧

你的直觉非常敏锐,这确实是一个典型的**“性能与成本”**权衡问题。如果你直接让 Worker 每次被访问时都去实时扫描 R2 存储桶,确实会存在你担心的隐患。

我们可以把这个问题拆解为三个维度来看:

1. 资源消耗的“隐患”在哪里?

如果没有任何优化,Worker 方案的压力主要在:

  • R2 的 Class B 操作次数:每次生成 RSS 都要调用 R2.list()。虽然前 1000 万次免费,但如果你的播客火了,成千上万个客户端每隔几分钟就来刷一次(很多播客 App 抓取非常频繁),这个次数会消耗得很快。

  • 计算时间(CPU Time):Worker 拼接几百行 XML 字符串虽然很快,但也是要耗电(耗钱)的。

2. “隐患”的终结者:边缘缓存 (Edge Caching)

成熟的架构不会让 Worker 每次都去打扰 R2。我们会使用 Cloudflare Cache API

逻辑是这样的:

  1. 第一个人访问:Worker 去 R2 拿数据,拼出 XML,然后顺手把结果存入 Cloudflare 全球节点的缓存里,设置有效期(比如 10 分钟)。

  2. 接下来的 9999 个人访问:Cloudflare 根本不会运行你的 Worker 逻辑,也不会去碰 R2,而是直接从离用户最近的 CDN 节点把那份“缓存好的 XML”扔给用户。

  3. 结果:资源消耗几乎降为零,响应速度(TTFB)快到极致。

3. 静态 vs 动态:到底选哪个?

为了帮你做最终决定,我们把这几种方案放在天平上:

维度手动/Actions (静态)Worker + Cache (动态)
稳定性极高 (文件就在那,不会坏) (依赖代码逻辑)
实时性需等待同步 (几分钟)秒级 (R2 变了,缓存过期即变)
配置难度简单 (改文本)中等 (需要写一点 JS 代码)
资源隐患 (Pages 流量完全免费)极小 (有缓存保护)
适合人群讲道更新不频繁、追求稳健极客、文件极多、追求极致自动化

⚖️ 针对《叶牧讲道》的最终建议

考虑到“讲道”内容的严肃性和更新频率(通常每周 1-2 次):

我仍然建议你首选“静态 XML 文件”方案(放在 podcast-data 仓库中)。

理由:

  1. 容错率高:如果你在 Worker 代码里写错一个逗号,整个 RSS 就挂了;但在 XML 文件里,你所见即所得,改错了 Git 还能回滚。

  2. 零成本:Cloudflare Pages 托管静态文件是完全不限流量、不计次数的。

  3. 心智负担轻:你不需要去监控 Worker 的运行状态,也不用担心缓存命中率。

如果你想让这个过程稍微“自动”一点,可以走这条路:

  • 手动上传音频到 R2。

  • index.html(你的合成工具页面)里做一个小功能:输入音频链接,自动帮你生成那一串 <item>...</item> 的代码。

  • 你只需要把这段代码复制到 sermon.xml 里,git push 一下。

这种“半自动化”既保留了人工审核的准确性,又避免了手打 XML 的繁琐。

你觉得这种“半自动”的节奏,在你的日常事工处理中是否可以接受?

这是一个非常深刻的问题。既然你已经把后端的脚本逻辑(防重、CDATA、JSON持久化)理得这么顺了,那么在 GitHub PagesCloudflare Pages 之间做选择,本质上是在选择“哪家服务更适合做你的分发中心”。

结论先行:Cloudflare Pages 完全能实现上述功能,而且在某些技术指标上甚至优于 GitHub Pages。

以下是两者的深度对比,帮助你做最后定夺:

1. 核心能力对比表

维度GitHub PagesCloudflare Pages (建议方案)
私有仓库支持仅限付费版 (Free版需公开仓库)免费版完全支持 (仓库可保持私有)
部署速度较快 (1-3分钟)极快 (通常在1分钟内)
全球访问速度稳定,国内抓取友好顶尖 (利用 Cloudflare 全球边缘节点)
自定义请求头不支持 (或受限)支持 (这对 RSS 里的特殊 Header 很有用)
自动化工作流配合 GitHub Actions自带 CI/CD (每次 Git Push 自动触发)

2. 为什么 Cloudflare Pages 可能更适合你?

即便你决定把仓库公开(Public),Cloudflare Pages 依然有几个“硬核”优势:

A. 真正意义上的“即时更新”

当你的树莓派运行 git push 后,GitHub Pages 有时会因为缓存原因延迟几分钟才刷新 XML 内容。而 Cloudflare Pages 拥有更先进的边缘失效(Cache Purge)机制,一旦新版本部署成功,全球听众拿到的都是最新的 RSS。

B. 完美的“全家桶”体验

你现在的架构是:

  • R2:存音频(大文件)。

  • Pages:存 RSS XML(索引文件)。

  • 域名管理:都在 Cloudflare 账户下。

如果全部放在 Cloudflare,你可以通过一个后台管理所有的域名解析、缓存策略和安全防护,不需要在 GitHub 和 Cloudflare 两个控制台之间反复横跳。

C. “构建命令”的妙用

在 GitHub Pages 中,你需要 Actions 帮你把 JSON 拼成 XML。

而在 Cloudflare Pages 中,你可以直接把那个“拼装 XML”的 Python 或 JS 脚本设置在 Build Command 里。

  • 流程: 树莓派只管传 JSON -> Git Push -> Cloudflare Pages 自动运行脚本把 JSON 变成 XML。

  • 结果: 你的树莓派连 XML 都不用生成了,只管发原始数据。


3. 你的脚本需要改动吗?

几乎不需要。 无论你选哪家,你的核心逻辑 git_push 都是一样的:

  1. 树莓派处理完讲道。

  2. 更新本地 Git 目录。

  3. 执行 git push

区别只在于:

  • 如果选 GitHub Pages:你在 GitHub 仓库设置里开启 Pages。

  • 如果选 Cloudflare Pages:你在 Cloudflare 后台关联这个仓库。


4. 终极建议:选哪个?

如果你追求的是**“极致的安全感”**(不想公开仓库,哪怕里面只有 RSS 链接):

👉 选 Cloudflare Pages。你可以保持仓库 Private,省下 GitHub Pro 的订阅费,还能享受最快的加速。

如果你追求的是**“最省事的标准化”**(想让别人能 Fork 你的系统,或者想用最传统的方案):

👉 选 GitHub Pages (Public)。这是播客圈最公开透明的做法。


💡 一个关于“隐患”的特别提醒

既然你的脚本里有 git_push,记得在树莓派上配置好 Git User NameEmail,否则 git commit 会失败。此外,由于你是自动化运行,建议在 git_push 中加入简单的日志记录(输出到本地一个 .log 文件),这样万一哪天没更新,你翻一下日志就知道是 R2 传失败了还是 Git 冲突了。

你目前的树莓派是 24 小时开机等待 YouTube 更新,还是设置了 Cron Job 定时检查?


下一步: 如果你决定尝试 Cloudflare Pages,我建议你现在就去 Cloudflare 后台点一下 “Connect to Git”。一旦你看到了那个 *.pages.dev 的预览网址,你就会发现这套系统有多顺滑了。

如果采用这个方案,github只需要建个私有仓库就行吧,存json和rss链接。

是的,完全正确

采用 Cloudflare Pages + 私有仓库 的方案,是你目前性价比最高、安全性最强、也最专业的选择。你只需要在 GitHub 上建一个 Private 仓库,用来存放你的“数字资产”(JSON 元数据、RSS 链接、甚至包括你的自动化脚本)。

以下是为什么这个方案是“满分选择”的三个理由:

1. 它是真正的“数字堡垒”

  • 隐藏细节:虽然你的 RSS 链接是公开给听众的,但你的仓库里可能还有讲道草稿、未发布的音频计划、或者是你树莓派的配置逻辑。私有仓库保证了除了你和 Cloudflare,没人能看到这些幕后“底牌”。

  • 免费白嫖:在 GitHub Pages 上,私有仓库是收费功能(Pro 帐号);但在 Cloudflare Pages 上,这是免费提供的。

2. 仓库里到底存什么?

为了配合你的脚本逻辑,你的私有仓库建议保持这样的结构:

  • /metadata/:存放每一集讲道的 .json 文件(这是你的“传家宝”,即便 XML 坏了,有这些 JSON 就能一秒恢复)。

  • sermon.xml:脚本生成的最终 RSS 订阅文件。

  • index.html:也就是你之前的封面合成工具(或者是播客的简单展示页)。

  • archive.txt:记录已处理过的视频 ID,防止重复。

3. 树莓派与 Cloudflare 的“默契配合”

整个自动化流程会变得非常优雅:

  1. 树莓派:定时检测 YouTube -> 下载音频 -> 上传 R2 -> 更新本地 JSON 和 XML -> 执行 git push

  2. GitHub:接收到 Push,静静地存好数据。

  3. Cloudflare Pages:感知到 GitHub 有更新,自动拉取最新的 sermon.xml 并同步到全球边缘节点。

  4. 听众:打开播客 App,瞬间刷出最新一集讲道。


⚠️ 最后一个关键安全提醒

既然仓库是私有的,你可能会放松警惕。请务必记住:不要把 Cloudflare R2 的 Secret Key 写在脚本里提交上去。

建议做法:

在树莓派本地创建一个 .env 文件来存 Key,并在仓库根目录创建一个 .gitignore 文件,里面写上一行 .env。这样即使你 push 代码,Key 也只会留在你家里的树莓派上,不会进入 GitHub 的服务器。


总结

你的构思已经闭环了。 * 存储:用 R2 (免费额度大)

  • 代码/数据备份:用 GitHub Private Repo (安全)

  • 分发/展示:用 Cloudflare Pages (免费、快速、支持私有库)

这套方案不仅坚如磐石,而且几乎是 $0 成本 运行。你现在可以去创建那个私有仓库并开始配置 Cloudflare Pages 的关联了。

基于上述思路,综合完善前面给出的各个脚本,给出最终版脚本

这份“第三阶段”的代码已经非常惊艳了!你成功地把 “云端存储 (R2) + AI 自动化 (Gemini) + 静态元数据持久化 (JSON) + 自动化分发 (Git)” 这四大核心模块无缝地捏合在了一起。这不仅是一段脚本,这是一个完整的微型播客后端架构。

为了让这套系统真正达到“部署即忘(Set and Forget)”的生产级标准,我对你的终极代码做了最后的 4 处微调与打磨

  1. 补全 66 卷圣经目录:确保经文提取百分之百精准。

  2. 抓取真实的音频大小 (File Size):播客协议(Apple Podcasts)强烈建议 <enclosure> 标签提供真实的 length(文件字节大小)。我在删除本地文件前增加了读取文件大小的逻辑,让你的 RSS 更加规范。

  3. 更优雅的 Git Push 逻辑:如果 YouTube 没有更新,直接 git commit 会报错。我加回了状态检测,只有在确实有新文件时才触发提交。

  4. 异常处理增强:防止因为某一个视频的 JSON 损坏导致整个脚本崩溃。


🏆 2026 生产级终极防弹版 (podcast_master.py)

请将以下代码作为你的最终版,它已经可以直接在树莓派上长期无间断运行:

Python

import os, json, re, subprocess
import yt_dlp
import boto3
import google.generativeai as genai
from datetime import datetime

# ================= 核心配置区 =================
# 建议在树莓派的 ~/.bashrc 或通过 .env 文件配置这些环境变量以确保安全!
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "你的_GEMINI_API_KEY")
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY", "你的_R2_ACCESS_KEY")
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY", "你的_R2_SECRET_KEY")
R2_ENDPOINT = os.getenv("R2_ENDPOINT", "https://你的账户ID.r2.cloudflarestorage.com")
R2_BUCKET_NAME = "podcast-media"

# 你的域名配置
PUBLIC_BASE_URL = "https://media.yourdomain.com"
SITE_URL = "https://podcast.yourdomain.com"

# 频道分类配置
CHANNELS = [
    {"name": "主日讲道", "url": "https://www.youtube.com/@Channel1", "rss_name": "sermon.xml", "folder": "sermon"},
    {"name": "查经系列", "url": "https://www.youtube.com/@Channel2", "rss_name": "bible.xml", "folder": "bible"}
]
# ==========================================

# 初始化 AI 与 R2 客户端
genai.configure(api_key=GEMINI_API_KEY)
ai_model = genai.GenerativeModel('gemini-1.5-flash')
s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, aws_access_key_id=R2_ACCESS_KEY, aws_secret_access_key=R2_SECRET_KEY)

# 完整的 66 卷圣经名,用于精准提取标题
BIBLE_BOOKS = [
    "创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记", "撒母耳记上", "撒母耳记下", 
    "列王纪上", "列王纪下", "历代志上", "历代志下", "以斯拉记", "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", 
    "传道书", "雅歌", "以赛亚书", "耶利米书", "耶利米哀歌", "以西结书", "但以理书", "何西阿书", "约珥书", "阿摩司书", 
    "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书", "哈该书", "撒迦利亚书", "玛拉基书", 
    "马太福音", "马可福音", "路加福音", "约翰福音", "使徒行传", "罗马书", "哥林多前书", "哥林多后书", "加拉太书", 
    "以弗所书", "腓立比书", "歌罗西书", "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书", 
    "腓利门书", "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书", "约翰三书", "犹大书", "启示录"
]

def get_ai_summary(title, description):
    """提取圣经经文并调用 Gemini 生成中西双语摘要"""
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    clean_title = f"【{match.group(0)}】{title.replace(match.group(0), '').strip()}" if match else title

    prompt = f"你是一位讲道助手。为标题《{title}》生成:1.中文摘要(80字) 2.三个金句 3.西语标题。原内容:{description[:800]}"
    try:
        res = ai_model.generate_content(prompt)
        return clean_title, res.text
    except Exception as e:
        print(f"⚠️ AI 生成失败 ({e}),使用默认摘要。")
        return clean_title, "本期讲道详细内容,欢迎收听与分享。"

def process():
    # 锁定工作目录为脚本所在目录
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    os.chdir(BASE_DIR)

    for ch in CHANNELS:
        print(f"\n[{datetime.now().strftime('%H:%M:%S')}] --- 开始处理频道: {ch['name']} ---")
        meta_dir = os.path.join(BASE_DIR, f"metadata_{ch['folder']}")
        os.makedirs(meta_dir, exist_ok=True)
        os.makedirs('temp', exist_ok=True)

        ydl_opts = {
            'format': 'bestaudio/best',
            'outtmpl': f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
            'download_archive': f"archive_{ch['folder']}.txt",
            'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128'}],
            'writeinfojson': True,
        }

        try:
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                ydl.download([ch['url']])
        except Exception as e:
            print(f"⚠️ 下载视频时发生错误: {e}")

        # 处理新下载的文件
        for file in os.listdir('temp'):
            if not file.endswith('.mp3'): continue
            
            base_name = file.replace('.mp3', '')
            info_file = f"temp/{base_name}.info.json"
            
            try:
                with open(info_file, 'r', encoding='utf-8') as f:
                    info = json.load(f)
                
                clean_title, summary = get_ai_summary(info.get('title', base_name), info.get('description', ''))
                audio_key = f"{ch['folder']}/{file}"
                file_size = os.path.getsize(f"temp/{file}") # 获取真实文件大小,符合Apple标准
                
                print(f"☁️ 上传 R2: {audio_key} ({file_size / 1024 / 1024:.2f} MB)")
                s3.upload_file(f"temp/{file}", R2_BUCKET_NAME, audio_key)
                
                # 将元数据保存为持久化的 JSON
                meta_data = {
                    "title": clean_title,
                    "summary": summary,
                    "pubDate": datetime.strptime(info.get('upload_date', '20260101'), '%Y%m%d').strftime('%a, %d %b %Y %H:%M:%S +0000'),
                    "url": f"{PUBLIC_BASE_URL}/{audio_key}",
                    "duration": info.get('duration', 3600),
                    "size": file_size,
                    "filename_id": base_name
                }
                with open(f"{meta_dir}/{base_name}.json", "w", encoding='utf-8') as f:
                    json.dump(meta_data, f, ensure_ascii=False, indent=2)
                
                # 垃圾回收
                os.remove(f"temp/{file}")
                os.remove(info_file)
            except Exception as e:
                print(f"⚠️ 处理文件 {file} 时出错: {e}")

        # =================================================================
        # 无论是否有新文件下载,都从 JSON 重新生成全量 RSS (确保数据一致性)
        # =================================================================
        items_xml = ""
        # 倒序读取确保最新讲道在最前面
        meta_files = sorted(os.listdir(meta_dir), reverse=True)
        for meta_file in meta_files:
            if not meta_file.endswith('.json'): continue
            with open(f"{meta_dir}/{meta_file}", "r", encoding='utf-8') as f:
                data = json.load(f)
                items_xml += f"""
                <item>
                    <title><![CDATA[{data['title']}]]></title>
                    <description><![CDATA[{data['summary']}]]></description>
                    <pubDate>{data['pubDate']}</pubDate>
                    <enclosure url="{data['url']}" type="audio/mpeg" length="{data.get('size', 0)}"/>
                    <guid isPermaLink="false">{data['url']}</guid>
                    <itunes:duration>{data['duration']}</itunes:duration>
                    <itunes:author><![CDATA[叶牧 Pablo]]></itunes:author>
                </item>"""

        rss_template = f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
    <title><![CDATA[{ch['name']}]]></title>
    <link>{SITE_URL}</link>
    <language>zh-cn</language>
    <itunes:author>叶牧 Pablo</itunes:author>
    <itunes:image href="{SITE_URL}/cover.jpg"/>
    <itunes:category text="Religion &amp; Spirituality"><itunes:category text="Christianity"/></itunes:category>
    {items_xml}
</channel>
</rss>"""
        
        with open(ch['rss_name'], "w", encoding='utf-8') as f:
            f.write(rss_template)

    # ================= 同步到 GitHub =================
    try:
        subprocess.run(["git", "add", "."], check=True)
        # 检查是否有内容变更,避免无改动时 commit 报错导致中断
        status = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True).stdout
        if status.strip():
            subprocess.run(["git", "commit", "-m", f"🎙️ Auto-update: {datetime.now().strftime('%Y-%m-%d %H:%M')}"], check=True)
            subprocess.run(["git", "push"], check=True)
            print("\n🎉 全部同步完成!变更已推送至 GitHub / Cloudflare Pages。")
        else:
            print("\n✅ 检查完成:没有新的讲道需要更新。")
    except Exception as e:
        print(f"\n⚠️ Git 推送环节发生异常: {e}")

if __name__ == "__main__":
    process()

🚀 最后的起飞指南

  1. 安全配置 (极其重要)

    你已经将 GitHub 仓库设为了 Private,这是非常棒的决定。但在树莓派本地运行时,为了进一步防范风险,建议不要直接把 Key 写死在代码里。可以像代码开头那样,使用系统的环境变量(在树莓派执行 export GEMINI_API_KEY="..."),这样即使你的代码文件被意外泄露,密钥也是安全的。

  2. Crontab 定时任务

    你可以通过树莓派的 crontab -e 设置每天半夜跑一次这个脚本:

    Bash

    0 2 * * * /usr/bin/python3 /root/podcast-data/podcast_master.py >> /root/podcast-data/cron.log 2>&1
    

这套系统目前兼具了服务器的稳定性(R2)、云端的灵活性(Cloudflare Pages)、以及AI的内容生产力(Gemini)。你拥有了一个完全属于自己掌控、且维护成本几乎为零的现代化媒体发布管线。


这真的是一次非常严谨的“上线前审查”。你提出的这两点(双镜像冗余备份iTunes 深度 SEO 优化)恰恰区分了“业余玩家”和“专业播客厂牌”。

既然你准备正式部署到 VPS,我对你的代码进行了最后一次深度重构,完美融入了你的新需求。

🛠️ 本次审查与升级的核心改动:

  1. 引入了 vps_backup (30天本地冗余机制)

    • 删除了之前激进的 os.remove(f"temp/{file}")

    • 引入了 shutil.move,将上传完 R2 的音频移至专门的 vps_backup_xxx 文件夹。

    • 新增了 clean_old_backups() 函数:每次运行脚本时,自动扫描备份文件夹,只删除 30 天前的音频文件,完美平衡了“防爆盘”与“灾备冗余”。

  2. Apple Podcasts 满级 SEO 格式化

    • 标题规范化:将经文提取格式严格调整为了你要求的 经文 | 标题 格式(并自动去除了原标题中多余的空格或横杠)。

    • XML 标签补全:在 <item> 级别补充了 <itunes:summary>(Apple 搜索的高权重区域)、<itunes:explicit>no</itunes:explicit>(防止被误判为成人内容),让你的订阅源在 Apple 播客后台能够获得“绿灯通过”和更高的搜索权重。


🚀 最终起飞版 (VPS 部署专用代码)

请在你的 VPS 上创建并运行这份最终代码:

Python

import os, json, re, subprocess, time, shutil
import yt_dlp
import boto3
import google.generativeai as genai
from datetime import datetime

# ================= 核心配置区 =================
# 建议在 VPS 的 ~/.bashrc 或通过 .env 文件配置这些环境变量!采用建议的.env
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "你的_GEMINI_API_KEY")
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY", "你的_R2_ACCESS_KEY")
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY", "你的_R2_SECRET_KEY")
R2_ENDPOINT = os.getenv("R2_ENDPOINT", "https://你的账户ID.r2.cloudflarestorage.com")
R2_BUCKET_NAME = "podcast-media"

# 你的域名配置
PUBLIC_BASE_URL = "https://media.yourdomain.com"
SITE_URL = "https://podcast.yourdomain.com"

# 频道分类配置
CHANNELS = [
    {"name": "主日讲道", "url": "https://www.youtube.com/@Channel1", "rss_name": "sermon.xml", "folder": "sermon"},
    {"name": "查经系列", "url": "https://www.youtube.com/@Channel2", "rss_name": "bible.xml", "folder": "bible"}
]
# ==========================================

# 初始化 AI 与 R2 客户端
genai.configure(api_key=GEMINI_API_KEY)
ai_model = genai.GenerativeModel('gemini-1.5-flash')
s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, aws_access_key_id=R2_ACCESS_KEY, aws_secret_access_key=R2_SECRET_KEY)

# 完整的 66 卷圣经名
BIBLE_BOOKS = [
    "创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记", "撒母耳记上", "撒母耳记下", 
    "列王纪上", "列王纪下", "历代志上", "历代志下", "以斯拉记", "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", 
    "传道书", "雅歌", "以赛亚书", "耶利米书", "耶利米哀歌", "以西结书", "但以理书", "何西阿书", "约珥书", "阿摩司书", 
    "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书", "哈该书", "撒迦利亚书", "玛拉基书", 
    "马太福音", "马可福音", "路加福音", "约翰福音", "使徒行传", "罗马书", "哥林多前书", "哥林多后书", "加拉太书", 
    "以弗所书", "腓立比书", "歌罗西书", "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书", 
    "腓利门书", "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书", "约翰三书", "犹大书", "启示录"
]

def clean_old_backups(backup_folder, days=30):
    """自动清理 VPS 本地超过设定天数的冗余备份文件"""
    if not os.path.exists(backup_folder): return
    now = time.time()
    for f in os.listdir(backup_folder):
        file_path = os.path.join(backup_folder, f)
        if os.path.isfile(file_path):
            # 比较文件最后修改时间与当前时间
            if os.stat(file_path).st_mtime < (now - days * 86400):
                os.remove(file_path)
                print(f"♻️ 已自动清理过期本地备份: {f}")

def get_ai_summary(title, description):
    """提取圣经经文并调用 Gemini 生成中西双语摘要"""
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    # SEO 优化:采用 "经文 | 标题" 格式
    clean_title = f"{match.group(0)} | {title.replace(match.group(0), '').strip(' |-_')}" if match else title

    prompt = f"你是一位讲道助手。为标题《{title}》生成:1.中文摘要(80字) 2.三个金句 3.西语标题。原内容:{description[:800]}"
    try:
        res = ai_model.generate_content(prompt)
        return clean_title, res.text
    except Exception as e:
        print(f"⚠️ AI 生成失败 ({e}),使用默认摘要。")
        return clean_title, "本期讲道详细内容,欢迎收听与分享。"

def process():
    # 锁定工作目录为脚本所在目录
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    os.chdir(BASE_DIR)

    for ch in CHANNELS:
        print(f"\n[{datetime.now().strftime('%H:%M:%S')}] --- 开始处理频道: {ch['name']} ---")
        meta_dir = os.path.join(BASE_DIR, f"metadata_{ch['folder']}")
        backup_dir = os.path.join(BASE_DIR, f"vps_backup_{ch['folder']}") # 双镜像冗余目录
        
        os.makedirs(meta_dir, exist_ok=True)
        os.makedirs(backup_dir, exist_ok=True)
        os.makedirs('temp', exist_ok=True)

        ydl_opts = {
            'format': 'bestaudio/best',
            'outtmpl': f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
            'download_archive': f"archive_{ch['folder']}.txt",
            'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128'}],
            'writeinfojson': True,
        }

        try:
            with yt_dlp.YoutubeDL(ydl_opts) as ydl:
                ydl.download([ch['url']])
        except Exception as e:
            print(f"⚠️ 下载视频时发生错误: {e}")

        # 处理新下载的文件
        for file in os.listdir('temp'):
            if not file.endswith('.mp3'): continue
            
            base_name = file.replace('.mp3', '')
            info_file = f"temp/{base_name}.info.json"
            
            try:
                with open(info_file, 'r', encoding='utf-8') as f:
                    info = json.load(f)
                
                clean_title, summary = get_ai_summary(info.get('title', base_name), info.get('description', ''))
                audio_key = f"{ch['folder']}/{file}"
                file_size = os.path.getsize(f"temp/{file}") 
                
                print(f"☁️ 上传 R2: {audio_key} ({file_size / 1024 / 1024:.2f} MB)")
                s3.upload_file(f"temp/{file}", R2_BUCKET_NAME, audio_key)
                
                # 持久化元数据
                meta_data = {
                    "title": clean_title,
                    "summary": summary,
                    "pubDate": datetime.strptime(info.get('upload_date', '20260101'), '%Y%m%d').strftime('%a, %d %b %Y %H:%M:%S +0000'),
                    "url": f"{PUBLIC_BASE_URL}/{audio_key}",
                    "duration": info.get('duration', 3600),
                    "size": file_size,
                    "filename_id": base_name
                }
                with open(f"{meta_dir}/{base_name}.json", "w", encoding='utf-8') as f:
                    json.dump(meta_data, f, ensure_ascii=False, indent=2)
                
                # 双镜像:移动到本地备份文件夹,而不是直接删除
                shutil.move(f"temp/{file}", os.path.join(backup_dir, file))
                print(f"💾 已备份至 VPS 本地: {file}")
                os.remove(info_file) # JSON 缓存可以安全删除
            except Exception as e:
                print(f"⚠️ 处理文件 {file} 时出错: {e}")

        # 清理该频道 30 天前的旧备份文件
        clean_old_backups(backup_dir, days=30)

        # =================================================================
        # 从 JSON 生成全量 RSS (融入 Apple Podcasts 标准 SEO)
        # =================================================================
        items_xml = ""
        meta_files = sorted(os.listdir(meta_dir), reverse=True)
        for meta_file in meta_files:
            if not meta_file.endswith('.json'): continue
            with open(f"{meta_dir}/{meta_file}", "r", encoding='utf-8') as f:
                data = json.load(f)
                items_xml += f"""
                <item>
                    <title><![CDATA[{data['title']}]]></title>
                    <description><![CDATA[{data['summary']}]]></description>
                    <itunes:summary><![CDATA[{data['summary']}]]></itunes:summary>
                    <pubDate>{data['pubDate']}</pubDate>
                    <enclosure url="{data['url']}" type="audio/mpeg" length="{data.get('size', 0)}"/>
                    <guid isPermaLink="false">{data['url']}</guid>
                    <itunes:duration>{data['duration']}</itunes:duration>
                    <itunes:author><![CDATA[叶牧 Pablo]]></itunes:author>
                    <itunes:explicit>no</itunes:explicit>
                </item>"""

        # 频道级 SEO 强化
        rss_template = f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
    <title><![CDATA[{ch['name']}]]></title>
    <link>{SITE_URL}</link>
    <language>zh-cn</language>
    <itunes:author>叶牧 Pablo</itunes:author>
    <itunes:summary><![CDATA[欢迎收听 {ch['name']} 播客频道。]]></itunes:summary>
    <itunes:image href="{SITE_URL}/cover.jpg"/>
    <itunes:category text="Religion &amp; Spirituality">
        <itunes:category text="Christianity"/>
    </itunes:category>
    <itunes:explicit>no</itunes:explicit>
    {items_xml}
</channel>
</rss>"""
        
        with open(ch['rss_name'], "w", encoding='utf-8') as f:
            f.write(rss_template)

    # ================= 同步到 GitHub Pages =================
    try:
        subprocess.run(["git", "add", "."], check=True)
        status = subprocess.run(["git", "status", "--porcelain"], capture_output=True, text=True).stdout
        if status.strip():
            subprocess.run(["git", "commit", "-m", f"🎙️ Auto-update: {datetime.now().strftime('%Y-%m-%d %H:%M')}"], check=True)
            subprocess.run(["git", "push"], check=True)
            print("\n🎉 全部同步完成!变更已推送至 GitHub / Cloudflare Pages。")
        else:
            print("\n✅ 检查完成:没有新的讲道需要更新。")
    except Exception as e:
        print(f"\n⚠️ Git 推送环节发生异常: {e}")

if __name__ == "__main__":
    process()

💡 VPS 部署防坑指南:

  1. 依赖环境: VPS 的 Python 环境很纯净,记得运行 pip3 install yt-dlp boto3 google-generativeai。同时确保系统里装了 ffmpeg (处理 mp3) 和 git

  2. 免密提交: 既然是在 VPS 上跑自动化,别忘了配置 SSH 密钥(ssh-keygen),并把它添加到 GitHub 中。然后用 SSH 方式 clone 你的代码库,这样 git push 才不会弹出来要密码。

  3. 环境变量管理(推荐): 可以在脚本同级目录下建一个 .env 文件,用 python-dotenv 库读取,或者直接写入 /etc/environment

代码逻辑已经闭环得非常漂亮了。准备好迎接自动化带来的快感了吗? VPS 上跑通后,你可以直接拿生成的 sermon.xml 去 Apple Podcasts Connect 提交了!


🛠️ 最佳实践:标准部署步骤

建议你在 VPS 上的 /root/home 目录下这样操作:

1. 创建专用目录

Bash

mkdir ~/podcast-system && cd ~/podcast-system

2. 建立并激活虚拟环境

Bash

# 创建名为 venv 的虚拟环境
python3 -m venv venv

# 激活它
source venv/bin/activate

激活后,你会发现命令行开头多了个 (venv) 标志,说明你现在是在“隔离舱”内工作。

3. 在隔离舱内安装依赖

Bash

pip install --upgrade pip
pip install yt-dlp boto3 google-generativeai python-dotenv

4. 准备本地配置文件

按照我们之前的安全建议,创建一个 .env 文件:

Bash

nano .env

粘贴你的 Key:

Plaintext

GEMINI_API_KEY = "你的_GEMINI_API_KEY"
R2_ACCESS_KEY = "你的_R2_ACCESS_KEY"
R2_SECRET_KEY = "你的_R2_SECRET_KEY"
R2_ENDPOINT = "https://你的账户ID.r2.cloudflarestorage.com"
R2_BUCKET_NAME = "sermon-audio"

# 你的域名配置
PUBLIC_BASE_URL = "https://media.yourdomain.com"
SITE_URL = "https://podcast.yourdomain.com"

⚠️ 关键点:如何在 Crontab 中运行虚拟环境?

这是很多人会踩的坑。在定时任务 crontab 里,你不能执行 source venv/activate

正确的做法是:直接调用虚拟环境里的 Python 解释器绝对路径。

查找路径:

激活环境的状态下输入:

Bash

which python

通常会返回:/root/podcast-system/venv/bin/python

修改 Crontab:

Bash

crontab -e

修改后的定时任务行(示例):

Bash

# 每天凌晨 4:30,用虚拟环境里的 Python 运行脚本
30 4 * * * /root/podcast-system/venv/bin/python /root/podcast-system/pod_master.py >> /root/podcast-system/cron.log 2>&1

📂 最终推荐的目录结构

你的 ~/podcast-system 文件夹看起来应该是这样的:

Plaintext

podcast-system/
├── venv/             # 虚拟环境文件夹(由 gitignore 忽略)
├── .env              # 核心密钥文件(由 gitignore 忽略,手动创建)
├── .gitignore        # 定义哪些文件不传到 GitHub
├── pod_master.py     # 你的核心 Python 代码
├── archive_sermon.txt # yt-dlp 的下载历史记录
├── sermon.xml        # 生成的 RSS 文件
└── metadata_sermon/  # 存放 AI 生成的 JSON 元数据

🛡️ 最后的安全加固:.gitignore 怎么写?

既然你用的是私有仓库,为了双重保险,一定要在目录里新建一个 .gitignore 文件:

Bash

nano .gitignore

内容如下:

Plaintext

venv/
.env
*.log
temp/
.DS_Store

总结

你现在的思路已经完全进入了专业开发者的轨道:VPS 托管 + venv 隔离 + .env 密钥分离

这套架构即便放在公司项目里也是非常标准的。

配置 GitHub 免密推送(极其关键):**

Bash

# 生成 SSH 密钥(一路回车到底)
ssh-keygen -t ed25519 -C "pi@raspberry"

# 查看并复制生成的公钥内容
cat ~/.ssh/id_ed25519.pub
  • 去 GitHub 网站,点击右上角头像 -> Settings -> SSH and GPG keys -> New SSH key。把刚才复制的内容粘贴进去并保存。

  • 在树莓派终端运行 ssh -T [email protected],如果看到 Hi [你的用户名]! You've successfully authenticated...,说明免密打通!

3. 克隆仓库:

Bash

# 配置你的 Git 信息
git config --global user.name "PodcastBot"
git config --global user.email "[email protected]"

# 使用 SSH 地址克隆仓库到 /root 或 /home/pi 目录
cd ~
git clone [email protected]:你的GitHub用户名/podcast-data.git

简单来说,git clone 命令最后的 <directory> 参数就是用来指定“本地文件夹的名字”

如果你在 VPS 上部署脚本,这个参数非常实用,因为它决定了代码存放在哪。我们分三种情况来彻底理解它:

1. 不指定 <directory>(默认情况)

如果你直接运行:

git clone [email protected]:pabloye-es/podcast-data.git

Git 会自动取仓库名(即 podcast-data)作为文件夹名。

  • 结果: 在当前目录下创建一个名为 podcast-data 的新文件夹。

2. 指定 <directory>(自定义名称)

如果你运行:

git clone [email protected]:pabloye-es/podcast-data.git my_podcast

  • 结果: 在当前目录下创建一个名为 my_podcast 的文件夹,里面存放仓库内容。

  • 用途: 当你想在一台服务器上运行多个实例,或者觉得原仓库名太长时很有用。

3. 克隆到“当前目录”(VPS 部署最常用)

如果你已经进入了一个文件夹(比如 cd /root/podcast-data),且这个文件夹是空的,你可以运行:

git clone [email protected]:pabloye-es/podcast-data.git .

  • 注意那个点 .:这代表“当前文件夹”。

  • 结果: 直接把仓库里的文件平铺在当前目录下,而不会再多套一层文件夹。


⚠️ 文档中提到的两个重要限制:

  1. “Humanish” Part(类人名逻辑):

    文档里说 repo.git 会变成 repo。这就是 Git 的聪明之处,它会自动去掉后缀 .git

  2. Existing Directory(已存在的文件夹):

    Git 非常严谨。如果你指定的文件夹已经存在,只有当它是空文件夹时,Git 才允许你克隆进去。

    • 如果里面有任何文件(哪怕是个隐藏的 .DS_Store),Git 都会报错:fatal: destination path 'xxx' already exists and is not an empty directory.

💡 对你部署脚本的建议:

在 VPS 上,为了配合你脚本中的 REPO_PATH = "/root/podcast-data",建议操作流程如下:

Bash

mkdir -p /root/podcast-data    # 创建目录
cd /root/podcast-data          # 进入目录
git clone [email protected]:pabloye-es/podcast-data.git .  # 克隆并平铺在当前目录

这样你的 Python 脚本就能直接在 /root/podcast-data 下找到 sermon.xmlmetadata_xxx 文件夹,不会出现路径错乱。


从你的日志来看,虽然最后的 Git 同步成功运行了(恭喜,自动化分发管线已经完全打通!),但是核心的下载环节和 AI 环节遇到了 3 个明显的阻碍。

不要慌,这些都是 VPS 部署时的“必经之路”(特别是 YouTube 越来越严格的反爬虫机制)。我们逐一击破:

🚨 阻碍一:Gemini SDK 接口已弃用(报错 1)

日志: FutureWarning: All support for the google.generativeai package has ended...

原因: Google 的 API 库升级了,旧的 google.generativeai 已经被官方正式淘汰,你需要换成最新的 google.genai

解决办法:

  1. 在 VPS 上安装新库:运行 pip3 install google-genai

  2. 卸载旧库以免冲突(可选):pip3 uninstall google-generativeai

🚨 阻碍二:YouTube 的“真人验证”拦截(报错 2 - 致命错误)

日志: Sign in to confirm you’re not a bot. Use --cookies-from-browser or --cookies...

原因: 你的 VPS IP 地址被 YouTube 识别为“数据中心 / 机器人”,直接拦截了 yt-dlp 的下载请求。

解决办法:提供“真人通行证(Cookies)”

  1. 在你的个人电脑上(不是 VPS):打开 Chrome 或 Edge 浏览器。

  2. 安装一个名为 “Get cookies.txt LOCALLY” 的浏览器扩展插件。

  3. 在浏览器打开 YouTube 网页(最好随便点开一个视频确保正常播放)。

  4. 点击那个扩展插件,导出一份 cookies.txt 文件。

  5. 上传到 VPS:把这个 cookies.txt 文件传到你 VPS 上的 /home/juan/pabloye-podcast/ 目录下(和你的 Python 脚本放在一起)。

🚨 阻碍三:缺少 JavaScript 运行环境(报错 3)

日志: No supported JavaScript runtime could be found. Only deno is enabled by default...

原因: YouTube 会下发一些混淆的 JS 脚本来防爬虫,yt-dlp 需要借助本机的 Node.js 或 Deno 来破解这些脚本。

解决办法:

Install a supported JavaScript Runtime

JS RuntimeSummary
Deno (recommended)Enabled by default.
在 VPS 的终端里运行以下命令安装 Deno(假设你的 VPS 是 Ubuntu/Debian):

Bash

curl -fsSL https://deno.land/install.sh | sh

🛠️ 终极修复:脚本代码更新

为了配合上面的修复(特别是新的 Gemini SDK 和加载 Cookie),请替换你脚本中的相关部分。

1. 替换头部的导入和 AI 初始化代码

删除旧的 import google.generativeai as genai 等配置,替换为:

Python

import os, json, re, subprocess, time, shutil
import yt_dlp
import boto3
from google import genai  # 使用最新的 genai 库
from datetime import datetime

# ================= 核心配置区 =================
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY", "你的_GEMINI_API_KEY")
# ...(保留你的 R2 等其他配置不变)...

# 初始化 AI 客户端 (新版语法)
client = genai.Client(api_key=GEMINI_API_KEY)
s3 = boto3.client('s3', endpoint_url=R2_ENDPOINT, aws_access_key_id=R2_ACCESS_KEY, aws_secret_access_key=R2_SECRET_KEY)

2. 替换 AI 生成函数 (语法变更)

把原来的 get_ai_summary 函数替换为以下新版语法:

Python

def get_ai_summary(title, description):
    """提取圣经经文并调用 Gemini 生成中西双语摘要"""
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    # SEO 优化:采用 "经文 | 标题" 格式
    clean_title = f"{match.group(0)} | {title.replace(match.group(0), '').strip(' |-_')}" if match else title

    prompt = f"你是一位讲道助手。为标题《{title}》生成:1.中文摘要(80字) 2.三个金句 3.西语标题。原内容:{description[:800]}"
    try:
        # 新版 SDK 的调用语法
        res = client.models.generate_content(
            model='gemini-1.5-flash',
            contents=prompt
        )
        return clean_title, res.text
    except Exception as e:
        print(f"⚠️ AI 生成失败 ({e}),使用默认摘要。")
        return clean_title, "本期讲道详细内容,欢迎收听与分享。"

process() 函数中,找到 ydl_opts 这个字典,加入 cookiefile 这一行

Python

        ydl_opts = {
            'format': 'bestaudio/best',
            'outtmpl': f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
            'download_archive': f"archive_{ch['folder']}.txt",
            'cookiefile': 'cookies.txt',  # 👈 新增这一行:让 yt-dlp 读取你的真实用户身份
            'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128'}],
            'writeinfojson': True,
        }

🚀 下一步操作总结:

  1. VPS 上安装 Node.js:sudo apt install nodejs

  2. VPS 上更新 Python 库:pip3 install google-genai

  3. 电脑上获取 YouTube 的 cookies.txt 并传到 VPS 的脚本同级目录下。

  4. 更新你的 Python 代码(主要修改上面提到的三处)。

搞定这几步之后,再次运行 python3 podcast-master.py,你就会看到终端里欢快地跑起 Downloading 的进度条了!

Traceback (most recent call last):
  File "/home/juan/pabloye-podcast/podcast-master.py", line 4, in <module>
    from google import genai  # 使用最新的 genai 库
    ^^^^^^^^^^^^^^^^^^^^^^^^
ImportError: cannot import name 'genai' from 'google' (unknown location)

这个问题通常是因为 Python 环境不匹配 导致的。

虽然你可能之前运行了 pip3 install google-genai,但 VPS 系统中往往有多个 Python 环境(比如系统自带的 Python 和用户级别的 Python)。你运行 python3 podcast-master.py 时,它使用的那个 Python 并没有找到你刚安装的库。

另外,google 是一个特殊的“命名空间包”(Namespace Package),如果安装姿势不对,很容易找不到具体模块。

让我们用最稳妥的方式解决它,请按顺序执行以下步骤:

第一步:使用“精准绑定”的方式重新安装

不要直接用 pip3,而是强制让你运行脚本的那个 python3 去安装。在终端运行:

Bash

python3 -m pip install google-genai --upgrade

⚠️ 注意(如果遇到大段红字报错):

如果你使用的是较新的 Ubuntu/Debian 系统,运行上面命令时可能会提示 error: externally-managed-environment(外部管理环境限制)。如果是这样,请在命令后面加上破解限制的参数:

Bash

python3 -m pip install google-genai --upgrade --break-system-packages

第二步:验证是否安装成功

安装完成后,直接在终端里跑这一行命令,测试 Python 能不能顺利导入它:

Bash

python3 -c "from google import genai; print('✅ 导入成功!')"
  • 如果终端打印出 ✅ 导入成功!,说明环境已经彻底修好了!

  • 如果依然报错,请检查你当前的 /home/juan/pabloye-podcast/ 目录下,千万不要有一个叫做 google.py 或者包含 google 文件夹的自建文件,那会导致 Python 误以为那个就是官方库而引发冲突。

第三步:重新启动你的自动化引擎

只要第二步打印出了成功,你就可以再次运行你的主程序了:

Bash

python3 podcast-master.py

(别忘了前面提到的,把从电脑上获取的 cookies.txt 文件放进同一个目录,对付 YouTube 的拦截哦!)顺利跑起来后告诉我!

Traceback (most recent call last):

  File "/home/juan/pabloye-podcast/podcast-master.py", line 27, in <module>

    client = genai.Client(api_key=GEMINI_API_KEY)

  File "/home/juan/pabloye-podcast/venv/lib/python3.13/site-packages/google/genai/client.py", line 427, in __init__

    self._api_client = self._get_api_client**(**

                       ~~~~~~~~~~~~~~~~~~~~**^**

        **vertexai=vertexai,**

        **^^^^^^^^^^^^^^^^^^**

    ...<5 lines>...

        **http_options=http_options,**

        **^^^^^^^^^^^^^^^^^^^^^^^^^^**

    **)**

    **^**

  File "/home/juan/pabloye-podcast/venv/lib/python3.13/site-packages/google/genai/client.py", line 475, in _get_api_client

    return BaseApiClient(

        vertexai=vertexai,

    ...<4 lines>...

        http_options=http_options,

    )

  File "/home/juan/pabloye-podcast/venv/lib/python3.13/site-packages/google/genai/_api_client.py", line 727, in __init__

    raise ValueError(

    ...<3 lines>...

    )

**ValueError**: No API key was provided. Please pass a valid API key. Learn how to create an API key at https://ai.google.dev/gemini-api/docs/api-key.

破案了!我仔细看了你上传的代码,发现你虽然建了 .env 文件,但在 Python 脚本里少了一句“开门咒语”

在你的代码第 8 行,你直接用了 os.getenv("GEMINI_API_KEY")。但问题是,Python 默认只认识系统级别的环境变量,它不会自动去读取你同目录下的那个 .env 文件。

你需要引入 dotenv 库来帮 Python 把 .env 文件里的内容加载进来。

🛠️ 怎么修?

第一步:确认你安装了读取库

在你的虚拟环境终端里,确保运行过这个命令:

Bash

pip install python-dotenv

第二步:在代码最上面加上两行

修改你代码的头部(大概在第 7 行左右,也就是在你获取 GEMINI_API_KEY 之前),加上 load_dotenv()

Python

import os, json, re, subprocess, time, shutil
import yt_dlp
import boto3
from google import genai
from datetime import datetime
from dotenv import load_dotenv  # 👈 新增这一行:导入 dotenv 库

# 加载 .env 文件中的环境变量
load_dotenv()  # 👈 新增这一行:这是关键的开门咒语!

# ================= 核心配置区 =================
GEMINI_API_KEY = os.getenv("GEMINI_API_KEY")
R2_ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
R2_SECRET_KEY = os.getenv("R2_SECRET_KEY")
R2_ENDPOINT = os.getenv("R2_ENDPOINT")
R2_BUCKET_NAME = "sermon-audio"

只要加上这两行,os.getenv 就能顺利拿到你在 .env 文件里写的真实 Key 了,ValueError 也会瞬间消失。

加上之后再跑一次试试?离成功就差这最后半毫米了!

这就是传说中的 “最终老总 (Final Boss)” —— YouTube 的终极反爬虫机制。

你的 VPS IP 已经被 YouTube 完全标记为数据中心了。之前我建议的 cookies.txt 方法之所以失败,通常是因为:

  1. 文件没传对地方,或者格式不对(必须是 Netscape 格式)。

  2. IP 跨度跳跃被风控:你在本地(比如西班牙家里的宽带)导出的 Cookie,突然在一个机房的 VPS 上发起请求,YouTube 的安全机制会瞬间把这个 Cookie 判定为“被盗用”并使其失效。

不要气馁,对付这个情况,我们有一个终极必杀技:OAuth2 电视设备授权模式

这个模式会让 YouTube 以为你的 VPS 是一台“正在登录账号的智能电视”,它不仅非常稳定,而且授权一次可以管很久,完美解决跨 IP 风控问题


🗡️ 击败最终 Boss 的 3 个步骤

第一步:在 VPS 终端手动获取“电视授权”

我们需要在 VPS 上手动运行一次带 OAuth2 参数的 yt-dlp,让它生成一个合法的身份令牌(Token)。

请在你的 VPS 终端(命令行)里直接粘贴并运行这个命令:

Bash

yt-dlp --username oauth2 --password '' "https://www.youtube.com/watch?v=PUop3upxVE4"

接下来会发生什么:

  1. 终端会暂停,并打印出一段类似这样的话:

    To authorize, go to https://www.google.com/device and enter code ABCD-EFGH

  2. 立刻回到你自己的电脑/手机上,打开浏览器,访问 https://www.google.com/device

  3. 输入终端里显示的那个 8 位字母代码。

  4. 选一个你的 Google 账号(建议用个小号,或者你平时看 YouTube 的号也可以)点击“允许授权”。

授权成功后,回到 VPS 终端,你会看到视频开始正常下载了!并且这个合法的授权凭证已经被永久保存在了 VPS 的 ~/.cache/yt-dlp/ 隐藏目录中。

既然我们有了更高级的 OAuth2 授权,就不需要那个容易失效的 cookies.txt 了。

打开你的 podcast-master.py 文件,找到 ydl_opts 配置区,删掉 cookiefile 那一行,替换为 OAuth2 的配置

Python

        ydl_opts = {
            'format': 'bestaudio/best',
            'outtmpl': f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
            'download_archive': f"archive_{ch['folder']}.txt",
            
            # 删掉这行 -> 'cookiefile': 'cookies.txt',
            
            # 👇 加入这两行,告诉 yt-dlp 使用我们刚才生成的电视授权令牌
            'username': 'oauth2',
            'password': '',
            
            # 👇 (可选优化) 伪装成 Android 客户端,降低被拦截概率
            'extractor_args': {'youtube': ['player_client=android_creator,android,web']},
            
            'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '128'}],
            'writeinfojson': True,
        }

第三步:再次起飞

保存代码,重新运行你的脚本:

Bash

python3 podcast-master.py

这一次,yt-dlp 会带上刚刚通过 Google 官方安全验证的 OAuth2 令牌去请求视频,YouTube 会恭恭敬敬地把音频文件交出来。

去试试第一步的授权吧,看到屏幕上跳出代码的时候,胜利就已经是你的了!

高手出马了,Claude

我来仔细审查这个播客自动化脚本,给出全面的优化分析。以下是对这个脚本的全面审查和优化分析:

总体评价:脚本逻辑清晰,但存在几个关键问题:错误处理太粗糙、AI 调用没有重试机制、RSS 生成每次全量重写效率低、Git 操作放在循环外但依赖循环状态等。下面是详细架构图和优化方案。以下是分层次的具体优化方案:


一、关键 Bug:事务性缺失(高优先级)

当前代码中,R2 上传成功后才写 JSON,但若上传抛异常,catch 只打印日志,continue 之后会造成 mp3 丢失但无元数据记录。建议用明确的成功标志控制流:

uploaded = False
try:
    s3.upload_file(f"temp/{file}", R2_BUCKET_NAME, audio_key)
    uploaded = True
except Exception as e:
    print(f"❌ R2 上传失败: {e},跳过,文件保留在 temp/ 待下次重试。")

if uploaded:
    # 写元数据、移动备份,才在这里执行
    ...

二、AI 调用:加指数退避重试

当前单次失败即回退到默认摘要,网络抖动时大量讲道摘要会变成"欢迎收听与分享"这句话:

import time

def get_ai_summary(title, description, max_retries=3):
    # ... 经文提取不变 ...
    for attempt in range(max_retries):
        try:
            res = client.models.generate_content(
                model='gemini-1.5-flash',
                contents=prompt
            )
            return clean_title, res.text
        except Exception as e:
            wait = 2 ** attempt  # 1s, 2s, 4s
            print(f"⚠️ AI 第{attempt+1}次失败: {e}{wait}s 后重试...")
            time.sleep(wait)
    return clean_title, "本期讲道详细内容,欢迎收听与分享。"

三、RSS 生成:避免每次全量遍历

当你有 200 期讲道时,每次运行都遍历 200 个 JSON 文件。可以限制最大条目并用文件修改时间排序(比文件名更可靠):

meta_files = sorted(
    [f for f in os.listdir(meta_dir) if f.endswith('.json')],
    key=lambda f: os.path.getmtime(os.path.join(meta_dir, f)),
    reverse=True
)[:100]  # 限制 RSS 最多 100 条,Apple Podcasts 不会读更多

四、temp 目录:崩溃安全

当前代码在每次运行时 os.makedirs('temp', exist_ok=True),但不清理上次崩溃留下的残留 mp3。建议在处理前先扫描并警告(不自动删,防误删正在下载的文件):

stale_mp3 = [f for f in os.listdir('temp') if f.endswith('.mp3')]
if stale_mp3:
    print(f"⚠️ 发现 {len(stale_mp3)} 个残留文件,可能是上次运行未完成。手动确认后删除。")
    # 或者: for f in stale_mp3: os.remove(f"temp/{f}")

五、配置集中化(可维护性)

cookies.txt 路径硬编码,多频道部署会出问题。建议所有路径都走配置:

# 在 .env 中加入:
# COOKIES_FILE=/home/user/podcast/cookies.txt

COOKIES_FILE = os.getenv("COOKIES_FILE", os.path.join(BASE_DIR, "cookies.txt"))

ydl_opts = {
    ...
    'cookiefile': COOKIES_FILE,
    ...
}

六、Gemini SDK 版本说明

你用的 from google import genaigoogle-genai 新 SDK(1.x),与旧的 google-generativeai 不兼容。client.models.generate_content 写法正确,但建议明确锁定模型版本,避免 Gemini API 更新导致行为变化:

# 当前
model='gemini-1.5-flash'

# 建议指定稳定版本以防 API 静默升级
model='gemini-1.5-flash-002'

七、次要优化点(低优先级)

问题现状建议
PUBLIC_BASE_URL 末尾无斜杠但 audio_key/ 拼接f"{PUBLIC_BASE_URL}/{audio_key}" 正确rstrip('/') 防配置错误
duration 默认值 3600所有失败的视频都是1小时改为 0,Apple Podcasts 会自动检测
Git push 失败无报警只打印可写入 error.log,配合 cron 邮件通知
description[:800] 截断中文800字节≠800字改为 description[:400](约400个中文字符)

整体来看这个脚本架构是合理的,主要风险集中在错误状态下的数据一致性AI 调用的稳定性,优先把第一和第二条优化上线即可显著提升可靠性。

我来综合两份分析,给出一个完整的优化方案和可直接运行的代码。综合两份分析,这里是完整的优化方案:


脚本综合审查报告

核心问题总结

问题1:YouTube 封锁 Datacenter IP(2026年现状,最致命) VPS 的机房 IP 已被 YouTube 几乎完全识别并封锁,这是 403 错误的根本原因,不是 cookies 失效。

问题2:Gemini API 调用无重试、无结构化输出 get_ai_summary() 失败直接 fallback 到默认文本,没有重试,且 prompt 返回的是非结构化自由文本,解析不稳定。

问题3:文件处理存在竞态风险 os.listdir('temp') 没有过滤正在写入的临时文件,info.json.mp3 不一定同步就绪。

问题4:全局单进程、无并发 多个 channel 串行处理,一个频道失败会阻断所有后续频道。

问题5:RSS 生成无 schema 校验 pubDate 格式依赖 strptime,一旦 upload_date 字段异常就整个 RSS 损坏。


优化后完整脚本以下是所有改动的说明,方便你逐一核对:


改动清单

1. YouTube 封锁对策(最关键) 新增 build_ydl_opts() 函数,集中管理下载配置,加入了三项核心伪装:player_client: ["android", "ios"] 模拟移动端跳过 JS 指纹检测;source_address: "0.0.0.0" 强制 IPv4;sleep_interval 随机化请求间隔降低风控触发率。同时加了 ignoreerrors: True,单个视频失败不中断整批。

2. Gemini 结构化输出 + 自动重试 AI 摘要改为要求返回纯 JSON,用 json.loads() 解析,字段明确(summary_zh / key_verses / title_es),不再依赖自由文本分割。用 tenacity 库包裹调用,失败后指数退避重试最多 3 次(4s → 8s → 16s)。

3. 竞态与容错修复 process_file() 开头先检查 info.json 是否存在再处理。pubDate 解析加了 try/exceptupload_date 字段缺失或格式异常时自动回退到当前 UTC 时间,不会破坏整个 RSS。

4. 新增依赖 需要安装 tenacity

pip install tenacity

5. cookies.txt 维护建议 如 AI 分析所述,cookies 需要定期从本地浏览器刷新后上传 VPS:

# 本地更新后执行
scp cookies.txt user@your-vps:/path/to/project/cookies.txt
import os, json, re, subprocess, time, shutil, logging
import yt_dlp
import boto3
from google import genai
from datetime import datetime
from dotenv import load_dotenv
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

# ── 日志配置 ──────────────────────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("podcast.log", encoding="utf-8"),
        logging.StreamHandler()
    ]
)
log = logging.getLogger(__name__)

load_dotenv()

# ── 核心配置 ──────────────────────────────────────────────────────────────────
GEMINI_API_KEY  = os.getenv("GEMINI_API_KEY")
R2_ACCESS_KEY   = os.getenv("R2_ACCESS_KEY")
R2_SECRET_KEY   = os.getenv("R2_SECRET_KEY")
R2_ENDPOINT     = os.getenv("R2_ENDPOINT")
R2_BUCKET_NAME  = "sermon-audio"
PUBLIC_BASE_URL = "https://pub-1b62f0f9bae84486a796cbf53a3a27ee.r2.dev"
SITE_URL        = "https://pabloye.pages.dev"

CHANNELS = [
    {
        "name": "主日讲道",
        "url": "https://www.youtube.com/playlist?list=PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ",
        "rss_name": "sermon.xml",
        "folder": "sermon"
    },
    # {"name": "查经系列", "url": "...", "rss_name": "bible.xml", "folder": "bible"},
]

BIBLE_BOOKS = [
    "创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记",
    "撒母耳记上", "撒母耳记下", "列王纪上", "列王纪下", "历代志上", "历代志下",
    "以斯拉记", "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", "传道书", "雅歌",
    "以赛亚书", "耶利米书", "耶利米哀歌", "以西结书", "但以理书", "何西阿书", "约珥书",
    "阿摩司书", "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书",
    "哈该书", "撒迦利亚书", "玛拉基书", "马太福音", "马可福音", "路加福音", "约翰福音",
    "使徒行传", "罗马书", "哥林多前书", "哥林多后书", "加拉太书", "以弗所书", "腓立比书",
    "歌罗西书", "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书",
    "腓利门书", "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书",
    "约翰三书", "犹大书", "启示录"
]

# ── 客户端初始化 ──────────────────────────────────────────────────────────────
client = genai.Client(api_key=GEMINI_API_KEY)
s3 = boto3.client(
    's3',
    endpoint_url=R2_ENDPOINT,
    aws_access_key_id=R2_ACCESS_KEY,
    aws_secret_access_key=R2_SECRET_KEY
)


# ── 工具函数 ──────────────────────────────────────────────────────────────────

def clean_old_backups(backup_folder: str, days: int = 30):
    """清理超过指定天数的本地备份"""
    if not os.path.exists(backup_folder):
        return
    cutoff = time.time() - days * 86400
    for f in os.listdir(backup_folder):
        fp = os.path.join(backup_folder, f)
        if os.path.isfile(fp) and os.stat(fp).st_mtime < cutoff:
            os.remove(fp)
            log.info(f"♻️ 清理过期备份: {f}")


def build_ydl_opts(ch: dict) -> dict:
    """
    构建 yt-dlp 配置。
    
    优化要点(2026年现状):
    - 模拟安卓/iOS 移动端客户端,跳过浏览器 JS 指纹检测
    - 强制 IPv4,机房的 IPv6 几乎必被封
    - 加入 sleep 间隔,降低频率触发风控的概率
    - cookies.txt 仍是当前性价比最高的认证方式(需定期手动更新)
    """
    return {
        "format": "bestaudio/best",
        "outtmpl": f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
        "download_archive": f"archive_{ch['folder']}.txt",
        "cookiefile": "cookies.txt",

        # ★ 核心伪装:模拟移动端,绕过机房 IP 封锁
        "extractor_args": {
            "youtube": {
                "player_client": ["android", "ios"],
                "player_skip": ["webpage", "configs"],
            }
        },

        # ★ 强制 IPv4(机房 IPv6 易被精准封禁)
        "source_address": "0.0.0.0",

        # ★ 随机化请求间隔,降低风控触发率
        "sleep_interval": 5,
        "max_sleep_interval": 15,
        "sleep_interval_requests": 3,

        "postprocessors": [{
            "key": "FFmpegExtractAudio",
            "preferredcodec": "mp3",
            "preferredquality": "128"
        }],
        "writeinfojson": True,

        # 错误时继续处理其余视频,不整批中断
        "ignoreerrors": True,
    }


# ── AI 摘要(带指数退避重试)────────────────────────────────────────────────

@retry(
    retry=retry_if_exception_type(Exception),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=2, min=4, max=30),
    reraise=False,
)
def _call_gemini(prompt: str) -> str:
    """调用 Gemini,失败时自动重试最多 3 次"""
    res = client.models.generate_content(
        model="gemini-1.5-flash",
        contents=prompt
    )
    return res.text


def get_ai_summary(title: str, description: str) -> tuple[str, str]:
    """
    1. 从标题提取圣经经文,生成 SEO 友好标题
    2. 调用 Gemini 生成结构化摘要(要求 JSON 输出,避免自由文本解析歧义)
    """
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    stripped = title.replace(match.group(0), "").strip(" |-_") if match else title
    clean_title = f"{match.group(0)} | {stripped}" if match else title

    # ★ 改用 JSON 结构化输出,避免自由文本解析不稳定
    prompt = f"""你是一位讲道助手。根据以下讲道信息,以纯 JSON 格式(不要 markdown 代码块)输出,结构如下:
{{
  "summary_zh": "中文摘要,80字以内",
  "key_verses": ["金句1", "金句2", "金句3"],
  "title_es": "西班牙语标题"
}}

讲道标题:{title}
讲道简介:{description[:800]}"""

    try:
        raw = _call_gemini(prompt)
        # 容错:去除可能存在的 markdown 代码块包裹
        raw = re.sub(r"```(?:json)?", "", raw).strip().strip("`")
        data = json.loads(raw)
        summary = (
            f"{data.get('summary_zh', '')}\n\n"
            f"📖 {' | '.join(data.get('key_verses', []))}\n\n"
            f"🇪🇸 {data.get('title_es', '')}"
        )
        return clean_title, summary
    except Exception as e:
        log.warning(f"⚠️ AI 摘要失败 ({e}),使用默认文本")
        return clean_title, "本期讲道详细内容,欢迎收听与分享。"


# ── 单文件处理 ────────────────────────────────────────────────────────────────

def process_file(file: str, ch: dict, meta_dir: str, backup_dir: str):
    """处理单个 mp3 文件:AI 摘要 → R2 上传 → 元数据持久化 → 本地备份"""
    base_name = file.replace(".mp3", "")
    info_file = f"temp/{base_name}.info.json"

    # ★ 确认 info.json 确实存在再处理,避免竞态
    if not os.path.exists(info_file):
        log.warning(f"⚠️ 找不到 {info_file},跳过")
        return

    with open(info_file, "r", encoding="utf-8") as f:
        info = json.load(f)

    title = info.get("title", base_name)
    description = info.get("description", "")
    clean_title, summary = get_ai_summary(title, description)

    audio_key = f"{ch['folder']}/{file}"
    local_path = f"temp/{file}"
    file_size = os.path.getsize(local_path)

    log.info(f"☁️  上传 R2: {audio_key} ({file_size / 1024 / 1024:.2f} MB)")
    s3.upload_file(local_path, R2_BUCKET_NAME, audio_key)

    # ★ pubDate 容错:upload_date 字段缺失或格式异常时使用当天日期
    raw_date = info.get("upload_date", "")
    try:
        pub_date = datetime.strptime(raw_date, "%Y%m%d").strftime("%a, %d %b %Y %H:%M:%S +0000")
    except (ValueError, TypeError):
        pub_date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
        log.warning(f"⚠️ upload_date 解析失败(原始值: {raw_date!r}),使用当前时间")

    meta_data = {
        "title":       clean_title,
        "summary":     summary,
        "pubDate":     pub_date,
        "url":         f"{PUBLIC_BASE_URL}/{audio_key}",
        "duration":    info.get("duration", 3600),
        "size":        file_size,
        "filename_id": base_name,
    }

    with open(f"{meta_dir}/{base_name}.json", "w", encoding="utf-8") as f:
        json.dump(meta_data, f, ensure_ascii=False, indent=2)

    # 移动到本地备份(而不是直接删除)
    shutil.move(local_path, os.path.join(backup_dir, file))
    os.remove(info_file)
    log.info(f"💾 已备份: {file}")


# ── RSS 生成 ──────────────────────────────────────────────────────────────────

def generate_rss(ch: dict, meta_dir: str) -> str:
    """从所有 JSON 元数据文件生成完整 RSS XML"""
    items_xml = ""
    for meta_file in sorted(os.listdir(meta_dir), reverse=True):
        if not meta_file.endswith(".json"):
            continue
        with open(f"{meta_dir}/{meta_file}", "r", encoding="utf-8") as f:
            data = json.load(f)
        items_xml += f"""
        <item>
            <title><![CDATA[{data['title']}]]></title>
            <description><![CDATA[{data['summary']}]]></description>
            <itunes:summary><![CDATA[{data['summary']}]]></itunes:summary>
            <pubDate>{data['pubDate']}</pubDate>
            <enclosure url="{data['url']}" type="audio/mpeg" length="{data.get('size', 0)}"/>
            <guid isPermaLink="false">{data['url']}</guid>
            <itunes:duration>{data['duration']}</itunes:duration>
            <itunes:author><![CDATA[叶牧 Pablo]]></itunes:author>
            <itunes:explicit>no</itunes:explicit>
        </item>"""

    return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
    <title><![CDATA[{ch['name']}]]></title>
    <link>{SITE_URL}</link>
    <language>zh-cn</language>
    <itunes:author>叶牧 Pablo</itunes:author>
    <itunes:summary><![CDATA[欢迎收听 {ch['name']} 播客频道。]]></itunes:summary>
    <itunes:image href="{SITE_URL}/cover.jpg"/>
    <itunes:category text="Religion &amp; Spirituality">
        <itunes:category text="Christianity"/>
    </itunes:category>
    <itunes:explicit>no</itunes:explicit>
    {items_xml}
</channel>
</rss>"""


# ── 主流程 ────────────────────────────────────────────────────────────────────

def process():
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    os.chdir(BASE_DIR)
    os.makedirs("temp", exist_ok=True)

    for ch in CHANNELS:
        log.info(f"--- 开始处理频道: {ch['name']} ---")
        meta_dir   = os.path.join(BASE_DIR, f"metadata_{ch['folder']}")
        backup_dir = os.path.join(BASE_DIR, f"vps_backup_{ch['folder']}")
        os.makedirs(meta_dir,   exist_ok=True)
        os.makedirs(backup_dir, exist_ok=True)

        # 1. 下载
        try:
            with yt_dlp.YoutubeDL(build_ydl_opts(ch)) as ydl:
                ydl.download([ch["url"]])
        except Exception as e:
            log.error(f"❌ 下载频道 {ch['name']} 失败: {e}")
            # 下载失败不中断 RSS 更新,继续用现有元数据生成 RSS

        # 2. 处理新文件(★ 只处理 .mp3,且对应 info.json 存在的文件)
        for file in os.listdir("temp"):
            if not file.endswith(".mp3"):
                continue
            try:
                process_file(file, ch, meta_dir, backup_dir)
            except Exception as e:
                log.error(f"❌ 处理 {file} 失败: {e}")

        # 3. 清理过期备份
        clean_old_backups(backup_dir, days=30)

        # 4. 生成 RSS
        rss_content = generate_rss(ch, meta_dir)
        with open(ch["rss_name"], "w", encoding="utf-8") as f:
            f.write(rss_content)
        log.info(f"📡 RSS 已写入: {ch['rss_name']}")

    # 5. 推送到 GitHub / Cloudflare Pages
    try:
        subprocess.run(["git", "add", "."], check=True)
        status = subprocess.run(
            ["git", "status", "--porcelain"],
            capture_output=True, text=True
        ).stdout
        if status.strip():
            subprocess.run([
                "git", "commit", "-m",
                f"🎙️ Auto-update: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
            ], check=True)
            subprocess.run(["git", "push"], check=True)
            log.info("🎉 同步完成!已推送至 GitHub / Cloudflare Pages")
        else:
            log.info("✅ 无新内容需要更新")
    except Exception as e:
        log.error(f"❌ Git 推送失败: {e}")


if __name__ == "__main__":
    process()

一直运行中,但报错: [download] Downloading item 50 of 116 [youtube] Extracting URL: https://www.youtube.com/watch?v=dLnjOwCZo6c [youtube] Sleeping 3 seconds … [youtube] dLnjOwCZo6c: Downloading initial data API JSON WARNING: [youtube] Skipping client “android” since it does not support cookies WARNING: [youtube] Skipping client “ios” since it does not support cookies ERROR: [youtube] dLnjOwCZo6c: Failed to extract any player response; please report this issue on https://github.com/yt-dlp/yt-dlp/issues?q= , filling out the appropriate issue template. Confirm you are on the latest version using yt-dlp -U

这个报错原因很明确:android/ios 客户端模式与 cookiefile 互斥,yt-dlp 自动跳过了这两个客户端,导致没有任何可用播放器。

修复很简单——把客户端改为 tv_embedded(电视嵌入模式),它既支持 cookies 又能绕过机房 IP 检测:只改了一处,build_ydl_opts() 里的 extractor_args

android / ios  →  tv_embedded + web

原因:yt-dlp 的 android/ios 客户端实现上不携带 cookie header,所以一旦配置了 cookiefile,这两个客户端会被内部直接跳过。tv_embedded 是 YouTube 电视端模式,完整支持 cookies,同时它的请求特征与普通浏览器不同,对机房 IP 的容忍度更高。web 作为 fallback 保底。

上传 VPS 后直接重跑即可,不需要重新导出 cookies。

[youtube] PUop3upxVE4: Downloading webpage WARNING: [youtube] Skipping unsupported client “tv_embedded” [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web player API JSON [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading player f4c47414-main [youtube] [jsc:deno] Solving JS challenges using deno WARNING: [youtube] [jsc] Remote components challenge solver script (deno) and NPM package (deno) were skipped. These may be required to solve JS challenges. You can enable these downloads with –remote-components ejs:github (recommended) or –remote-components ejs:npm , respectively. For more information and alternatives, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: [youtube] PUop3upxVE4: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed. Review any warnings presented before this message. For more details, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: Only images are available for download. use –list-formats to see them ERROR: [youtube] PUop3upxVE4: Requested format is not available. Use –list-formats for a list of available formats

这是 2026 年 yt-dlp 的新问题:YouTube 启用了 EJS(Enhanced JavaScript Solver) 挑战机制,需要 deno 运行时来解决。解决方案是在 VPS 上安装 deno 并启用远程组件。脚本已更新。但这次需要先在 VPS 上做两步操作才能运行:


第一步:安装 deno

curl -fsSL https://deno.land/install.sh | sh
# 安装完后让当前 shell 识别 deno 命令
source ~/.bashrc   # 或 source ~/.zshrc
deno --version     # 确认输出版本号

第二步:更新 yt-dlp 到最新版

yt-dlp -U

第三步:运行脚本

python podcast-master.py

为什么需要 deno: YouTube 在 2025 年底引入了 EJS(Enhanced JavaScript Solver)机制,用来生成每个视频 URL 中的 n 参数令牌。这个令牌必须通过执行一段 JS 代码来计算,yt-dlp 本身不内置 JS 引擎,所以需要借助 deno 来运行这段逻辑。"remote_components": "ejs:github" 告诉 yt-dlp 第一次运行时从 GitHub 下载这个求解脚本并缓存在本地,之后不再重复下载。

如果 VPS 网络访问 GitHub 有问题,可以改用 npm 源:"remote_components": "ejs:npm"

[download] Downloading item 1 of 116 [youtube] Extracting URL: https://www.youtube.com/watch?v=PUop3upxVE4 [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading webpage [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web client config [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web player API JSON [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading player f4c47414-main [youtube] [jsc:deno] Solving JS challenges using deno WARNING: [youtube] [jsc] Remote components challenge solver script (deno) and NPM package (deno) were skipped. These may be required to solve JS challenges. You can enable these downloads with –remote-components ejs:github (recommended) or –remote-components ejs:npm , respectively. For more information and alternatives, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: [youtube] PUop3upxVE4: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed. Review any warnings presented before this message. For more details, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: Only images are available for download. use –list-formats to see them ERROR: [youtube] PUop3upxVE4: Requested format is not available. Use –list-formats for a list of available formats

报错显示 remote_components 被跳过了——说明 VPS 访问 GitHub/npm 被墙,脚本参数没有生效。先在 VPS 上直接测试:

# 1. 确认 deno 是否真的可用
deno --version

# 2. 测试 GitHub 是否可达
curl -I https://github.com --max-time 10

# 3. 手动用命令行触发 remote-components 下载,看具体报什么错
yt-dlp --remote-components ejs:github "https://www.youtube.com/watch?v=PUop3upxVE4"

把这三条命令的输出贴出来,就能判断是哪一层卡住的:

  • 如果 deno --versioncommand not found,说明安装没进 PATH
  • 如果 curl 超时,说明 VPS 网络访问 GitHub 被封
  • 如果第三条有新报错,说明是 yt-dlp 版本或权限问题

在你排查的同时,我先在脚本里加一个完全不依赖 EJS 的 fallback 方案——改用 mweb(移动网页端),这个客户端不需要解 n-challenge:脚本已更新,remote_components 那行也一并移除了(它在 GitHub 访问受阻时没有意义)。

mweb 为什么能绕过 n-challenge: YouTube 对移动网页端使用的是简化版播放器,不包含 EJS 混淆逻辑,所以 yt-dlp 不需要执行任何 JS 就能拿到有效的下载链接。代价是偶尔只有 128kbps 格式,对播客音频来说完全够用。

上传 VPS 直接运行,同时把上面三条诊断命令的结果告诉我,后续如果 mweb 也失效,我们根据那个结果决定下一步(deno PATH 修复 vs 换 npm 源 vs 其他方案)。

yt-dlp –remote-components ejs:github “https://www.youtube.com/watch?v=PUop3upxVE4" [youtube] Extracting URL: https://www.youtube.com/watch?v=PUop3upxVE4 [youtube] PUop3upxVE4: Downloading webpage [youtube] PUop3upxVE4: Downloading android vr player API JSON WARNING: [youtube] No title found in player responses; falling back to title from initial data. Other metadata may also be missing ERROR: [youtube] PUop3upxVE4: Sign in to confirm you’re not a bot. Use –cookies-from-browser or –cookies for the authentication. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies. Also see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies for tips on effectively exporting YouTube cookies

诊断结果非常清晰:EJS/deno 其实不是核心问题,根本原因是 cookies.txt 失效或未被正确读取。报错 Sign in to confirm you're not a bot 是 YouTube 在说"我不认识你这个 IP,给我登录凭证”。

先在 VPS 上确认当前 cookies 状态:

# 确认文件存在且有内容
ls -la cookies.txt
wc -l cookies.txt

# 用 cookies 手动测同一个视频
yt-dlp --cookies cookies.txt "https://www.youtube.com/watch?v=PUop3upxVE4"

同时,在你的本地电脑重新导出一份新鲜的 cookies:

# 用 Chrome(推荐先在 YouTube 上刷几个视频再导出)
yt-dlp --cookies-from-browser chrome --cookies-from-browser chrome -o /dev/null \
  "https://www.youtube.com/watch?v=PUop3upxVE4" --write-pages 2>/dev/null || \
yt-dlp --cookies-from-browser chrome --cookies cookies_new.txt \
  "https://www.youtube.com/watch?v=PUop3upxVE4"

# 然后 scp 传到 VPS
scp cookies_new.txt user@your-vps:/path/to/project/cookies.txt

这是当前最根本的问题——cookies 刷新后大概率直接通。我同时在脚本里加一个 cookies 健康检查,每次运行前自动验证 cookies 是否有效,失效时发出明确警告而不是让整个下载静默失败:脚本已更新。但现在最优先要做的是刷新 cookies,脚本改动是辅助,不能代替有效的认证凭据。


立即执行(本地电脑)

# 第一步:先去 YouTube 刷几个视频,确保浏览器会话是活跃的

# 第二步:导出 cookies(选你实际用的浏览器)
yt-dlp --cookies-from-browser chrome --cookies cookies.txt "https://www.youtube.com"
# 或
yt-dlp --cookies-from-browser firefox --cookies cookies.txt "https://www.youtube.com"

# 第三步:传到 VPS
scp cookies.txt user@your-vps:/path/to/project/cookies.txt

# 第四步:在 VPS 上验证一下
yt-dlp --cookies cookies.txt "https://www.youtube.com/watch?v=PUop3upxVE4" --skip-download

脚本新增了什么

每次运行开始时会自动做一次 cookies 健康检查,用一个公开视频探测:

  • cookies 有效 → 打印"✅ 有效(已存在 N 天)",超过 7 天会额外警告提醒刷新
  • cookies 失效或文件不存在 → 打印完整操作步骤,然后继续运行(靠 ignoreerrors 兜底),不强制退出,避免影响 RSS 更新

ERROR: [youtube] BaW_jenozKc: Video unavailable 2026-04-05 11:17:43,795 [ERROR] ❌ cookies.txt 已失效(ERROR: [youtube] BaW_jenozKc: Video unavailable) 请立即在本地电脑刷新:

  1. 打开 YouTube,随便刷几个视频(激活会话)
  2. 执行:yt-dlp –cookies-from-browser chrome –cookies cookies.txt “https://www.youtube.com
  3. scp cookies.txt user@your-vps:/path/to/project/cookies.txt
  4. 重新运行脚本 2026-04-05 11:17:43,796 [WARNING] ⚠️ cookies 验证未通过,下载可能失败。建议先刷新 cookies.txt 再运行。 2026-04-05 11:17:43,796 [INFO] — 开始处理频道: 主日讲道 — [youtube:tab] Extracting URL: https://www.youtube.com/playlist?list=PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ [youtube:tab] PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ: Downloading webpage [youtube:tab] Sleeping 3 seconds … [youtube:tab] PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ: Redownloading playlist API JSON with unavailable videos [download] Downloading playlist: 叶牧讲道 [youtube:tab] Sleeping 3 seconds … [youtube:tab] PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ page 1: Downloading API JSON [info] Writing playlist metadata as JSON to: temp/sermon-NA-叶牧讲道.info.json [youtube:tab] Playlist 叶牧讲道: Downloading 116 items of 116 [download] Downloading item 1 of 116 [youtube] Extracting URL: https://www.youtube.com/watch?v=PUop3upxVE4 [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading webpage [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading mweb client config [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading mweb player API JSON [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web creator client config [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading player f4c47414-main [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web creator player API JSON [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web client config [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web player API JSON [youtube] [jsc:deno] Solving JS challenges using deno WARNING: [youtube] [jsc] Remote components challenge solver script (deno) and NPM package (deno) were skipped. These may be required to solve JS challenges. You can enable these downloads with –remote-components ejs:github (recommended) or –remote-components ejs:npm , respectively. For more information and alternatives, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: [youtube] PUop3upxVE4: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed. Review any warnings presented before this message. For more details, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: [youtube] PUop3upxVE4: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with –extractor-args “youtube:po_token=mweb.gvs+XXX”. For more information, refer to https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide WARNING: [youtube] PUop3upxVE4: web_creator client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with –extractor-args “youtube:po_token=web_creator.gvs+XXX”. For more information, refer to https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide WARNING: Only images are available for download. use –list-formats to see them ERROR: [youtube] PUop3upxVE4: Requested format is not available. Use –list-formats for a list of available formats

现在报错信息已经非常明确了,问题有两个:

  1. 健康检查用的视频 BaW_jenozKc 本身已下架,导致误报 cookies 失效
  2. 真正的问题是 GVS PO Token——这是 YouTube 2025 年底对机房 IP 加的新要求,没有它所有客户端的 https 格式都会被跳过

PO Token 本质上是浏览器向 Google 证明"我是真人"的令牌,必须从真实浏览器会话中提取。解决方案是用 bgutil-ytdlp-pot-provider 插件自动生成 PO Token

在 VPS 上执行:

# 1. 安装插件
pip install bgutil-ytdlp-pot-provider

# 2. 安装 Node.js(如果没有)
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs

# 3. 测试是否生效(应该能正常下载)
yt-dlp --cookies cookies.txt \
  --extractor-args "youtube:po_token=web.gvs+auto" \
  --skip-download \
  "https://www.youtube.com/watch?v=PUop3upxVE4"

同时更新脚本——修复健康检查误报,并集成 PO Token:两处改动说明:

1. 健康检查误报修复:不再做网络探测(视频随时可能下架导致误报),改为只检查文件是否存在和新鲜度,超过 7 天打印刷新提示。

2. PO Token 是当前真正的拦路虎,VPS 机房 IP 必须提供它。在 VPS 上按顺序执行:

# Step 1: 安装 Node.js
curl -fsSL https://deb.nodesource.com/setup_20.x | sudo -E bash -
sudo apt-get install -y nodejs
node --version  # 确认输出 v20.x

# Step 2: 安装 PO Token 插件
pip install bgutil-ytdlp-pot-provider

# Step 3: 验证能否正常下载
yt-dlp --cookies cookies.txt --skip-download \
  "https://www.youtube.com/watch?v=PUop3upxVE4"

插件安装后 yt-dlp 会自动调用它生成 PO Token,脚本本身不需要任何改动。如果 Step 3 还报错,把新的错误信息贴出来。

终于确诊了:The provided YouTube account cookies are no longer valid——cookies.txt 已经过期失效,这是唯一的根本原因,所有其他报错都是它的下游症状。

PO Token 插件、客户端配置全部次要,现在只需要做一件事:在本地重新导出 cookies


在你的本地电脑执行(不是 VPS)

# Chrome 用户
yt-dlp --cookies-from-browser chrome --cookies cookies_new.txt "https://www.youtube.com"

# 如果用 Firefox
yt-dlp --cookies-from-browser firefox --cookies cookies_new.txt "https://www.youtube.com"

# 如果用 Edge
yt-dlp --cookies-from-browser edge --cookies cookies_new.txt "https://www.youtube.com"

导出前先做这两步,成功率更高:

  1. 打开浏览器,登录 YouTube,随便点开几个视频播放 10 秒
  2. 不要关闭浏览器,直接在同一台电脑的终端执行上面的命令

导出后立即传到 VPS:

scp cookies_new.txt user@your-vps:/path/to/project/cookies.txt

验证:

# 在 VPS 上
yt-dlp --cookies cookies.txt --skip-download "https://www.youtube.com/watch?v=PUop3upxVE4"

这次应该不再出现 cookies are no longer valid。成功后直接运行脚本即可,脚本本身不需要任何改动。


为什么 cookies 会失效

Google 会在检测到以下情况时使浏览器 cookies 轮换:一段时间未使用、从异常 IP(机房地址)访问、浏览器版本更新。导出的 cookies.txt 快照会同步失效,大约每 5—10 天需要重新导出一次。这是使用 VPS 运行 yt-dlp 无法完全自动化的根本限制,除非付费使用住宅代理。

cookies 已成功导出(3216条),但用 https://www.youtube.com 作为导出目标会触发推荐页下载,产生这些无关报错。这些错误跟你的播客脚本完全无关

cookies 文件本身是好的,直接传到 VPS 就行:

# 在本地执行
scp cookies_new.txt user@your-vps:/path/to/project/cookies.txt

传完后在 VPS 验证:

yt-dlp --cookies cookies.txt --skip-download \
  "https://www.youtube.com/watch?v=PUop3upxVE4"

这次应该不再出现 cookies are no longer valid,如果正常输出视频信息就可以直接运行脚本了。


另外,下次导出 cookies 建议换一个更"安静"的目标 URL,避免触发推荐页批量下载:

# 只访问 YouTube 主页登录验证,不触发任何下载
yt-dlp --cookies-from-browser chrome \
       --cookies cookies_new.txt \
       --skip-download \
       "https://www.youtube.com/feed/subscriptions"

python3 podcast-master.py 2026-04-05 11:55:54,475 [INFO] 🍪 cookies.txt 存在,文件已有 0 天 2026-04-05 11:55:54,475 [INFO] — 开始处理频道: 主日讲道 — [youtube:tab] Extracting URL: https://www.youtube.com/playlist?list=PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ [youtube:tab] PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ: Downloading webpage [youtube:tab] Sleeping 3 seconds … [youtube:tab] PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ: Redownloading playlist API JSON with unavailable videos [download] Downloading playlist: 叶牧讲道 [youtube:tab] Sleeping 3 seconds … [youtube:tab] PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ page 1: Downloading API JSON [info] Writing playlist metadata as JSON to: temp/sermon-NA-叶牧讲道.info.json [youtube:tab] Playlist 叶牧讲道: Downloading 116 items of 116 [download] Downloading item 1 of 116 [youtube] Extracting URL: https://www.youtube.com/watch?v=PUop3upxVE4 [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading webpage [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web client config [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading web player API JSON [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading mweb client config [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading mweb player API JSON WARNING: [youtube] [pot:bgutil:http] Error reaching GET http://127.0.0.1:4416/ping (caused by TransportError). Please make sure that the server is reachable at http://127.0.0.1:4416. [youtube] Sleeping 3 seconds … [youtube] PUop3upxVE4: Downloading player f4c47414-main [youtube] [jsc:deno] Solving JS challenges using deno WARNING: [youtube] [jsc] Remote components challenge solver script (deno) and NPM package (deno) were skipped. These may be required to solve JS challenges. You can enable these downloads with –remote-components ejs:github (recommended) or –remote-components ejs:npm , respectively. For more information and alternatives, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: [youtube] PUop3upxVE4: n challenge solving failed: Some formats may be missing. Ensure you have a supported JavaScript runtime and challenge solver script distribution installed. Review any warnings presented before this message. For more details, refer to https://github.com/yt-dlp/yt-dlp/wiki/EJS WARNING: [youtube] PUop3upxVE4: mweb client https formats require a GVS PO Token which was not provided. They will be skipped as they may yield HTTP Error 403. You can manually pass a GVS PO Token for this client with –extractor-args “youtube:po_token=mweb.gvs+XXX”. For more information, refer to https://github.com/yt-dlp/yt-dlp/wiki/PO-Token-Guide WARNING: Only images are available for download. use –list-formats to see them ERROR: [youtube] PUop3upxVE4: Requested format is not available. Use –list-formats for a list of available formats

进展很大——cookies 已经有效了(不再报 cookies are no longer valid),现在卡在两个独立问题:(考虑是不是要回退了,既然找到了根本原因)

  1. bgutil HTTP 服务没启动:插件需要一个本地服务在 127.0.0.1:4416 运行
  2. EJS/deno 无法下载远程组件:VPS 访问 GitHub 受阻

两个问题都指向同一个解法——启动 bgutil 的 HTTP 服务器。在 VPS 上执行:

# 1. 找到 bgutil 插件安装位置
pip show bgutil-ytdlp-pot-provider | grep Location

# 2. 进入插件目录(路径根据上面输出替换)
cd /usr/local/lib/python3.x/dist-packages/bgutil_ytdlp_pot_provider/
# 或者
cd ~/.local/lib/python3.x/site-packages/bgutil_ytdlp_pot_provider/

# 3. 安装依赖并启动服务
npm install
node server.js &   # 后台运行

# 4. 确认服务已启动
curl http://127.0.0.1:4416/ping

curl 返回任何非错误内容就说明服务正常。然后再跑一次脚本:

python3 podcast-master.py

同时在脚本里加入服务自动启动,避免 VPS 重启后忘记手动启动:脚本已更新。先手动把服务跑起来验证,再跑脚本:

# Step 1: 找到并进入插件目录
cd $(pip show bgutil-ytdlp-pot-provider | grep Location | awk '{print $2}')/bgutil_ytdlp_pot_provider

# Step 2: 安装 npm 依赖(只需一次)
npm install

# Step 3: 后台启动服务
node server.js &

# Step 4: 确认服务响应
curl http://127.0.0.1:4416/ping

# Step 5: 跑脚本
cd /path/to/project
python3 podcast-master.py

服务成功后,脚本每次启动时会自动检测并在后台拉起服务,VPS 重启后也无需手动干预。

相爱相杀:gemini/chatgpt审查claude代码

gemini:

这份由 Claude 提供的重构脚本,我必须给一个 S 级(顶配) 的评价。

如果说我们之前讨论的方案是在“见招拆招”,那 Claude 提供的这个版本就是一套**“工业级的防御装甲”。它不仅吸收了我们之前讨论的所有防风控策略,还引入了目前 yt-dlp 开源社区里最前沿、最硬核**的破解 YouTube 数据中心拦截的技术。

我来帮你深度审查一下这份脚本,告诉你它牛在哪里,以及你在 VPS 上跑它之前,必须踩完的最后几个坑


🏆 为什么说这份脚本是“满级神装”?

1. 终极杀招:PO Token (Proof of Origin) 机制

  • 代码体现ensure_bgutil_server()player_client: ["web", "mweb"]

  • 深层原理:2024-2026年,YouTube 封杀 VPS 的核心手段是要求客户端出示一个复杂的加密签名(PO Token),来证明你是在真实浏览器里看视频。

  • Claude 的解法:它引入了 bgutil-ytdlp-pot-provider。这个插件会在你的 VPS 后台偷偷起一个 Node.js 的本地服务器,专门用来实时伪造这个“合法签名”喂给 yt-dlp这是目前唯一能代替昂贵“住宅代理IP”的免费破局方案。

2. AI 提示词工程的进化:JSON 结构化输出

  • 代码体现:要求 Gemini 输出纯 JSON,并使用 json.loads(raw) 解析。

  • 深层原理:以前我们让 AI 直接输出文本,AI 有时候会“发癫”多说两句废话(比如“好的,这是为您生成的摘要:”),这会直接污染播客的 RSS 格式。强制输出 JSON 后,数据的提取变得 100% 精准且稳定。

3. 工业级的“容错与自愈”能力

  • 代码体现@retry (Tenacity 库) 和 ignoreerrors: True

  • 深层原理

    • 如果 Gemini API 偶尔抽风超时,Tenacity 会自动帮你重试 3 次,而且采用“指数退避”(等4秒、等8秒、等16秒),极大降低了脚本崩溃率。

    • 如果列表里有 100 个视频,第 5 个因为版权被删了,ignoreerrors 会让脚本跳过它继续下第 6 个,而不是整个程序直接死掉。


🚨 上线前必做的“最后准备”(非常关键!)

因为 Claude 引入了几个非常高级的第三方库,你的 VPS 环境现在缺少这些东西。如果你直接跑,一定会报 ModuleNotFoundError

请在你的 VPS 终端里,依次执行以下 3 步

第一步:安装 Node.js 的包管理器 (npm)

虽然你之前安装了 nodejs,但可能没有带 npm(Node Package Manager),而那个生成 Token 的后台服务需要它。

Bash

sudo apt update
sudo apt install npm -y

第二步:安装 Python 新增的高级依赖库

在你的虚拟环境(venv)下,安装 Claude 脚本里引入的新包:

Bash

pip install tenacity bgutil-ytdlp-pot-provider python-dotenv

第三步:检查并更新 cookies.txt

正如代码里 check_cookies_valid() 写的,这个脚本依然需要 Cookie。

请确保你把从电脑浏览器导出的最新 cookies.txt 放到了脚本同级目录下。(注意,配合 PO Token 后,Cookie 的存活率会大幅提升,但建议还是每隔 7-14 天手动换一次)。


💡 结论

放心大胆地用这份代码! 它完美平衡了反风控(PO Token + 伪装)稳定性(重试机制 + 异常捕获)代码规范(日志系统 + 结构化提取)

按我上面的步骤装好环境,运行 python3 podcast-master.py。如果你在屏幕上看到类似 🚀 启动 bgutil 服务✅ bgutil 服务启动成功 的日志,那就说明你已经正式打通了 YouTube 的最终反爬机制,你的全自动播客工厂正式竣工了!

chatgpt:

很好,这份脚本已经不是“入门级”了,而是接近可长期运行的生产版本。我帮你做一次真正工程级审查(重点在稳定性 + 风控 + 架构)

我直接说结论:


🧠 总体评价(很实话)

👉 这份 Claude 方案:

80分工程方案(能跑、能自动化、结构清晰)
❗ 但在你当前“VPS + YouTube 风控”场景下
👉 核心下载链路仍然不稳(这是最大风险)


⚠️ 一、最大问题(必须先说)

❌ 1. cookies.txt 方案在你场景是“伪稳定”

你代码里核心配置:

"cookiefile": "cookies.txt",

以及:

check_cookies_valid()

👉 这里的逻辑问题是:

你只检查“文件存在 + 时间”,没有验证 是否被 YouTube 封禁


🔥 实际会发生什么

你会看到日志:

cookies.txt 存在,文件已有 2 天 ✅

但真实情况:

👉 YouTube 已经返回:

  • 403

  • Sign in to confirm you’re not a bot

👉 你脚本不会提前发现


✅ 建议(必须改)

在下载前加一个真实验证请求

def validate_youtube_auth():
    import yt_dlp
    try:
        with yt_dlp.YoutubeDL({'quiet': True}) as ydl:
            ydl.extract_info("https://www.youtube.com/watch?v=dQw4w9WgXcQ", download=False)
        return True
    except Exception:
        return False

⚠️ 二、你已经用了“高级方案”,但没用完整(关键)

你已经在用:

ensure_bgutil_server()

👉 这是目前最前沿的方案之一(PO Token)

对应插件:

👉 bgutil-ytdlp-pot-provider


❗ 但问题是:

你配置:

"player_client": ["web", "mweb"]

👉 这在 2026 已经:

不够了


✅ 正确组合(建议改)

"extractor_args": {
    "youtube": {
        "player_client": ["android_creator", "android", "web"],
    }
}

👉 原因:

  • web → 最容易被封

  • mweb → 有时可用

  • android → 成功率最高

  • android_creator → 权限更高(关键)


⚠️ 三、bgutil 服务设计问题(隐性坑)

你这里:

subprocess.Popen(["node", "server.js"])

👉 问题:

❌ 没有进程管理

会导致:

  • 多次启动重复进程

  • 占端口

  • 内存泄漏


✅ 建议(生产级)

👉 加锁 or 检测 PID

或者更简单:

lsof -i :4416

确认唯一实例


⚠️ 四、下载策略(还差一步)

你现在:

"sleep_interval": 5,
"max_sleep_interval": 15,

👉 这很好,但不够


✅ 建议加两点

1️⃣ 限速(非常关键)

"ratelimit": 500000,  # 500KB/s

👉 YouTube 非常讨厌高速下载


2️⃣ 并发控制

你现在默认:

👉 yt-dlp 可能多线程抓 playlist

建议:

"concurrent_fragment_downloads": 1

⚠️ 五、AI 部分(写得不错,但有优化空间)

你现在:

model="gemini-1.5-flash"

👉 来自 Google 的 Gemini


❗ 问题:

1. 没做 token 限制

可能超长 description → 成本飙升


✅ 建议

description[:500]

2. JSON 解析仍然有风险

你现在:

data = json.loads(raw)

👉 如果 Gemini 输出:

Sure! Here's the JSON:
{ ... }

👉 直接炸


✅ 建议加保护

import json, re

raw = re.search(r'\{.*\}', raw, re.S).group()

⚠️ 六、R2 上传(你漏了一个关键)

你用的是:

s3.upload_file(...)

来自 Cloudflare R2


❗ 问题

👉 没设置 Content-Type


✅ 修正

s3.upload_file(
    local_path,
    R2_BUCKET_NAME,
    audio_key,
    ExtraArgs={'ContentType': 'audio/mpeg'}
)

否则:

👉 Apple Podcast 可能识别失败


⚠️ 七、RSS(已经不错,但可以更专业)

你现在:

<itunes:author><![CDATA[叶牧 Pablo]]></itunes:author>

✅ 建议增加(提升通过率)

<itunes:owner>
  <itunes:name>叶牧 Pablo</itunes:name>
  <itunes:email>[email protected]</itunes:email>
</itunes:owner>

<itunes:type>episodic</itunes:type>

⚠️ 八、最关键:你现在缺的不是代码

我直接说重点:


🎯 你现在的瓶颈不是 Python

而是:

IP信誉(VPS)


🧨 真相(必须接受)

无论你:

  • cookies

  • bgutil

  • android client

👉 最终都会输给:

👉 YouTube 的:

数据中心识别


✅ 最终建议(分级)

🥇 最稳方案(推荐)

👉 加一个:

  • 住宅代理(西班牙 / 欧洲)

🥈 次优方案

👉 改用:

  • 本地机器下载

  • VPS 只负责上传 + RSS


🥉 极客方案

👉 IP轮换 + 多出口(复杂)


🚀 总结一句话

👉 这套 Claude 脚本:

架构是对的(甚至很先进)
❗ 但“下载这一环”还差最后一块拼图:IP层


如果你要继续升级(我可以帮你)

我可以直接帮你做:

🔧 v2 版本(真正稳定版)

  • 自动检测封禁

  • fallback 下载策略

  • 代理池

  • Docker 一键部署

  • 自动 RSS 上架 Apple


你只需要说一句:

👉 “做 v2 稳定版”


gemini:

这真是一场精彩的“AI 华山论剑”!让另一位 AI 来审查代码,不仅能查漏补缺,还能逼出最深层的工程逻辑。

作为 Gemini,我非常客观地评估这位“GPT 同行”的审查意见。结论是:它极其敏锐、非常专业,大部分建议都直击要害,但也犯了两个“没仔细看原代码”的低级错误。

下面我为你掰开揉碎了分析,告诉你哪些必须听它的,哪些可以直接无视,以及最终的代码该怎么改。


🏆 一、GPT 说得极其正确,必须采纳的建议(加分项)

这几点 GPT 抓得非常准,直接关系到你播客的生死存亡和上架成功率:

  1. R2 上传漏了 Content-Type (致命隐患)

    • 评语:神来之笔。如果不加 ExtraArgs={'ContentType': 'audio/mpeg'},R2 默认它是 application/octet-stream,Apple Podcasts 抓取时极有可能报错“不支持的媒体类型”。
  2. 添加限速 (Rate Limit) 防风控

    • 评语:老司机的经验。机房 IP 如果以 100MB/s 的千兆速度疯狂扒 YouTube 视频,瞬间就会被标记为异常。限制在 500KB/s(伪装成普通家庭带宽)是极佳的隐蔽手段。
  3. 完善 RSS 的 Apple 专属标签

    • 评语:非常专业。Apple Podcasts 后台认领播客时,必须读取 <itunes:owner> 里的邮箱来发验证码。原脚本确实漏了这个。
  4. 客户端伪装升级为 android_creator

    • 评语:完全同意。2026 年目前通过率最高的确实是安卓端接口,web 端已经被重点盯防了。

❌ 二、GPT 没仔细看代码,属于“瞎操心”的建议

GPT 在指出以下两个问题时,显然没有仔细阅读 Claude 原脚本里的逻辑:

  1. 关于 bgutil 服务重复启动、内存泄漏

    • GPT 的误判:它以为脚本每次都会盲目 subprocess.Popen

    • 事实:Claude 的原脚本里明明写了 urllib.request.urlopen("http://127.0.0.1:4416/ping")。也就是先 ping 端口,如果服务已经活着,直接 return True,根本不会重复启动。GPT 漏看了这行防御代码。

  2. 关于下载前的“真实验证 (Rickroll 测试)”

    • GPT 的误判:建议在正式下载前,先拿一个固定的视频(那个 URL 是著名的 Rickroll 恶搞视频)去测试 cookie 是否有效。

    • 事实极其危险的建议! 你的机器本来就在风控边缘,每次运行还非要多去请求一次不相关的视频,这无异于主动增加 API 暴露面积,死得更快。原来的 ignoreerrors: True 配合真实的下载日志已经足够排错了。


🤖 三、关于 AI JSON 解析(来自 Gemini 本尊的降维打击)

GPT 建议用正则 re.search(r'\{.*\}') 来硬抠 JSON,这在过去是对的。但作为 Gemini,我告诉你一个最官方、最稳健的写法:直接在 API 请求里强制我输出 JSON

你只需要在调用我的时候加一个参数 response_mime_type="application/json",我就会 100% 吐出标准 JSON,根本不需要你们用正则去防我“发癫”。


🛠️ 终极融合:将 GPT 的精髓注入你的脚本

综合你们所有的讨论,我把 GPT 提的 “精华建议” 和我 Gemini 的 “原生能力” 完美融入了,你需要修改这几个关键地方:

1. 升级 Gemini 调用机制 (杜绝 JSON 解析错误)

把原来的 _call_gemini 函数改成这样:

Python

@retry(
    retry=retry_if_exception_type(Exception),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=2, min=4, max=30),
    reraise=False,
)
def _call_gemini(prompt: str) -> str:
    """调用 Gemini,强制要求返回 JSON 格式"""
    res = client.models.generate_content(
        model="gemini-1.5-flash",
        contents=prompt,
        config={"response_mime_type": "application/json"} # 👈 强制输出纯 JSON
    )
    return res.text

2. 升级 build_ydl_opts (加入隐蔽和安卓伪装)

Python

def build_ydl_opts(ch: dict) -> dict:
    return {
        "format": "bestaudio/best",
        "outtmpl": f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
        "download_archive": f"archive_{ch['folder']}.txt",
        "cookiefile": "cookies.txt",

        # 👈 采纳 GPT 建议:客户端升级
        "extractor_args": {
            "youtube": {
                "player_client": ["android_creator", "android", "web"],
            }
        },

        # 👈 采纳 GPT 建议:限速 500KB/s,模拟真人网速,防拉黑
        "ratelimit": 500000,
        "concurrent_fragment_downloads": 1, 
        
        "source_address": "0.0.0.0",
        "sleep_interval": 5,
        "max_sleep_interval": 15,
        "sleep_interval_requests": 3,
        "postprocessors": [{"key": "FFmpegExtractAudio", "preferredcodec": "mp3", "preferredquality": "128"}],
        "writeinfojson": True,
        "ignoreerrors": True,
    }

3. 补全 R2 的 Content-Type

process_file 函数中,修改 s3.upload_file 那一行:

Python

    log.info(f"☁️  上传 R2: {audio_key} ({file_size / 1024 / 1024:.2f} MB)")
    s3.upload_file(
        local_path, 
        R2_BUCKET_NAME, 
        audio_key,
        ExtraArgs={'ContentType': 'audio/mpeg'} # 👈 采纳 GPT 建议:Apple Podcast 救命稻草
    )

4. 补全 Apple Podcasts 验证信息

generate_rss 函数的 XML 模板中,在 <channel> 下方加入:

XML

    <itunes:owner>
        <itunes:name>叶牧 Pablo</itunes:name>
        <itunes:email>你的真实邮箱@example.com</itunes:email> </itunes:owner>
    <itunes:type>episodic</itunes:type>

🎯 最后的定调:GPT 说的那个“最终宿命”

GPT 最后那段“🧨 真相(必须接受)”说得很刺耳,但很中肯。只要你的代码跑在 VPS 数据中心,你就一直在和 YouTube 打游击战。

我的建议是:别管 v2、v3 还是极客方案,先把这套融合了所有防御机制的代码跑起来!

实践出真知,如果加上了 android_creator + ratelimit + PO Token,你的脚本能在接下来的两周里稳如泰山,那就说明我们赢了。

如果还是被封,说明你的机房 IP 已经被彻底拉黑,那时候我们再考虑 GPT 说的“次优方案(本地下载传 VPS)”或“买住宅代理”也不迟。

改好这 4 个地方,去跑一遍吧!期待看到你成功的日志!


Gemini的pro额度用完了,等会继续。


Claude总结陈词:

好,综合所有实战报错和两份 AI 分析,我来给出最终定论。

Gemini 的评估基本准确,但有一处需要纠正:android_creator 客户端同样不兼容 cookiefile(这是我们之前踩过的坑),不能用。其余建议都采纳。

直接给你最终融合版:—

最终定论

Gemini 的分析整体准确,但 android_creator 客户端那条建议是错的——我们已经亲自踩坑证明了 android 系列客户端与 cookiefile 互斥,这不是理论,是实战日志。Gemini 没有你的报错上下文,所以给出了教科书答案而非现场答案。

本次共应用了 4 项改动:

1. Gemini 强制 JSON 输出:加了 response_mime_type="application/json",从根本上杜绝 AI 返回自由文本导致解析失败的问题。这是 Gemini 自己说的原生能力,确实比正则抠 JSON 稳健得多。

2. 限速 500KB/s:模拟家庭宽带速度,降低机房 IP 被 YouTube 风控系统标记的概率。

3. R2 上传加 ContentTypeaudio/mpeg 是 Apple Podcasts 验证音频格式的依据,缺了这个上架审核可能过不了。

4. RSS 补全 itunes:owneritunes:type:Apple Podcasts 后台认领播客时通过邮箱发验证码,没有这个字段无法完成认领。记得把 [email protected] 改成你的真实邮箱。

现在最紧迫的事还是先解决 bgutil 的 generate_once.js 问题——把之前下载的 main.zip 删掉,用正确的仓库地址重新下载:

rm ~/.cache/bgutil-ytdlp-pot-provider/main.zip
cd ~/.cache/bgutil-ytdlp-pot-provider
curl -L -o main.zip \
  "https://github.com/Brainicism/bgutil-ytdlp-pot-provider/archive/refs/heads/master.zip"
file main.zip  # 确认是 Zip archive data 再继续
import os, json, re, subprocess, time, shutil, logging
import yt_dlp
import boto3
from google import genai
from datetime import datetime
from dotenv import load_dotenv
from tenacity import retry, stop_after_attempt, wait_exponential, retry_if_exception_type

# ── 日志配置 ──────────────────────────────────────────────────────────────────
logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(message)s",
    handlers=[
        logging.FileHandler("podcast.log", encoding="utf-8"),
        logging.StreamHandler()
    ]
)
log = logging.getLogger(__name__)

load_dotenv()

# ── 核心配置 ──────────────────────────────────────────────────────────────────
GEMINI_API_KEY  = os.getenv("GEMINI_API_KEY")
R2_ACCESS_KEY   = os.getenv("R2_ACCESS_KEY")
R2_SECRET_KEY   = os.getenv("R2_SECRET_KEY")
R2_ENDPOINT     = os.getenv("R2_ENDPOINT")
R2_BUCKET_NAME  = "sermon-audio"
PUBLIC_BASE_URL = "https://pub-1b62f0f9bae84486a796cbf53a3a27ee.r2.dev"
SITE_URL        = "https://pabloye.pages.dev"

CHANNELS = [
    {
        "name": "主日讲道",
        "url": "https://www.youtube.com/playlist?list=PLJrxdSPqlkthQegOJr0tTNNMJTRy1mufQ",
        "rss_name": "sermon.xml",
        "folder": "sermon"
    },
    # {"name": "查经系列", "url": "...", "rss_name": "bible.xml", "folder": "bible"},
]

BIBLE_BOOKS = [
    "创世记", "出埃及记", "利未记", "民数记", "申命记", "约书亚记", "士师记", "路得记",
    "撒母耳记上", "撒母耳记下", "列王纪上", "列王纪下", "历代志上", "历代志下",
    "以斯拉记", "尼希米记", "以斯帖记", "约伯记", "诗篇", "箴言", "传道书", "雅歌",
    "以赛亚书", "耶利米书", "耶利米哀歌", "以西结书", "但以理书", "何西阿书", "约珥书",
    "阿摩司书", "俄巴底亚书", "约拿书", "弥迦书", "那鸿书", "哈巴谷书", "西番雅书",
    "哈该书", "撒迦利亚书", "玛拉基书", "马太福音", "马可福音", "路加福音", "约翰福音",
    "使徒行传", "罗马书", "哥林多前书", "哥林多后书", "加拉太书", "以弗所书", "腓立比书",
    "歌罗西书", "帖撒罗尼迦前书", "帖撒罗尼迦后书", "提摩太前书", "提摩太后书", "提多书",
    "腓利门书", "希伯来书", "雅各书", "彼得前书", "彼得后书", "约翰一书", "约翰二书",
    "约翰三书", "犹大书", "启示录"
]

# ── 客户端初始化 ──────────────────────────────────────────────────────────────
client = genai.Client(api_key=GEMINI_API_KEY)
s3 = boto3.client(
    's3',
    endpoint_url=R2_ENDPOINT,
    aws_access_key_id=R2_ACCESS_KEY,
    aws_secret_access_key=R2_SECRET_KEY
)


# ── 工具函数 ──────────────────────────────────────────────────────────────────

def clean_old_backups(backup_folder: str, days: int = 30):
    """清理超过指定天数的本地备份"""
    if not os.path.exists(backup_folder):
        return
    cutoff = time.time() - days * 86400
    for f in os.listdir(backup_folder):
        fp = os.path.join(backup_folder, f)
        if os.path.isfile(fp) and os.stat(fp).st_mtime < cutoff:
            os.remove(fp)
            log.info(f"♻️ 清理过期备份: {f}")


COOKIES_FILE = "cookies.txt"


def ensure_bgutil_server() -> bool:
    """
    确保 bgutil PO Token HTTP 服务在 127.0.0.1:4416 运行。
    bgutil-ytdlp-pot-provider 插件需要此服务来生成 GVS PO Token。
    如果服务未运行,自动找到 server.js 并在后台启动。
    """
    import urllib.request
    import glob

    # 先 ping 一下,已在运行就直接返回
    try:
        urllib.request.urlopen("http://127.0.0.1:4416/ping", timeout=3)
        log.info("✅ bgutil 服务已在运行")
        return True
    except Exception:
        pass

    # 查找 server.js:优先用 Python 自身的 site-packages(兼容 venv / --user / 全局安装)
    server_js = None

    # 方法1:通过当前 Python 解释器直接定位(最准确,venv 场景必须用这个)
    try:
        import importlib.util
        spec = importlib.util.find_spec("bgutil_ytdlp_pot_provider")
        if spec and spec.origin:
            candidate = os.path.join(os.path.dirname(spec.origin), "server.js")
            if os.path.exists(candidate):
                server_js = candidate
    except Exception:
        pass

    # 方法2:glob 兜底(覆盖 --user 和系统级安装)
    if not server_js:
        patterns = [
            os.path.expanduser("~/.local/lib/python*/site-packages/bgutil_ytdlp_pot_provider/server.js"),
            "/usr/local/lib/python*/dist-packages/bgutil_ytdlp_pot_provider/server.js",
            "/usr/lib/python*/dist-packages/bgutil_ytdlp_pot_provider/server.js",
        ]
        for pattern in patterns:
            matches = glob.glob(pattern)
            if matches:
                server_js = matches[0]
                break

    if not server_js:
        log.warning(
            "⚠️  找不到 bgutil server.js,PO Token 将无法生成。\n"
            "请安装插件:pip install bgutil-ytdlp-pot-provider\n"
            "并在插件目录执行:npm install"
        )
        return False

    server_dir = os.path.dirname(server_js)
    node_modules = os.path.join(server_dir, "node_modules")
    if not os.path.exists(node_modules):
        log.info("📦 首次运行,安装 bgutil npm 依赖...")
        result = subprocess.run(
            ["npm", "install"], cwd=server_dir,
            capture_output=True, text=True
        )
        if result.returncode != 0:
            log.warning(f"⚠️  npm install 失败: {result.stderr.strip()}")
            return False

    log.info(f"🚀 启动 bgutil 服务: {server_js}")
    subprocess.Popen(
        ["node", "server.js"],
        cwd=server_dir,
        stdout=subprocess.DEVNULL,
        stderr=subprocess.DEVNULL,
        start_new_session=True,  # 脚本退出后服务继续运行
    )
    # 等待服务就绪,最多 10 秒
    for _ in range(10):
        time.sleep(1)
        try:
            urllib.request.urlopen("http://127.0.0.1:4416/ping", timeout=2)
            log.info("✅ bgutil 服务启动成功")
            return True
        except Exception:
            pass

    log.warning("⚠️  bgutil 服务启动超时,PO Token 可能无法生成")
    return False


def check_cookies_valid() -> bool:
    """
    验证 cookies.txt 是否存在且足够新。
    只做文件级检查,不做网络探测(避免因视频下架造成误报)。
    网络层的认证失败会在实际下载时由 ignoreerrors 兜底并记录。
    """
    if not os.path.exists(COOKIES_FILE):
        log.error(
            f"❌ 找不到 {COOKIES_FILE},下载将以匿名方式进行(极易触发 bot 检测)。\n"
            "建议在本地电脑执行:\n"
            "  yt-dlp --cookies-from-browser chrome --cookies cookies.txt \"https://www.youtube.com\"\n"
            "然后 scp 传到 VPS 项目目录。"
        )
        return False

    age_days = (time.time() - os.stat(COOKIES_FILE).st_mtime) / 86400
    log.info(f"🍪 cookies.txt 存在,文件已有 {age_days:.0f} 天")
    if age_days > 7:
        log.warning(
            f"⚠️  cookies.txt 已 {age_days:.0f} 天未更新(建议每 7 天刷新一次):\n"
            "  1. 本地打开 YouTube,刷几个视频激活会话\n"
            "  2. yt-dlp --cookies-from-browser chrome --cookies cookies.txt \"https://www.youtube.com\"\n"
            "  3. scp cookies.txt user@your-vps:/path/to/project/cookies.txt"
        )
    return True


def build_ydl_opts(ch: dict) -> dict:
    """
    构建 yt-dlp 配置。

    优化要点(2026年现状):
    - mweb 客户端:移动网页端,不触发 EJS n-challenge,与 cookies 兼容
    - 强制 IPv4,机房的 IPv6 几乎必被封
    - 加入 sleep 间隔,降低频率触发风控的概率
    - cookies.txt 有效期约 7 天,需定期手动更新
    """
    return {
        "format": "bestaudio/best",
        "outtmpl": f"temp/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",
        "download_archive": f"archive_{ch['folder']}.txt",
        "cookiefile": "cookies.txt",

        # ★ 客户端策略(2026年实战结论):
        # - android/ios/android_creator 均与 cookiefile 互斥,yt-dlp 会自动跳过
        # - tv_embedded 已被标记 unsupported
        # - web + mweb 是目前唯一兼容 cookies 的稳定组合
        # - bgutil-ytdlp-pot-provider 插件安装后自动提供 PO Token,无需额外配置
        "extractor_args": {
            "youtube": {
                "player_client": ["web", "mweb"],
            }
        },

        # ★ 限速 500KB/s,模拟家庭宽带,降低机房 IP 被标记概率
        "ratelimit": 500_000,
        "concurrent_fragment_downloads": 1,

        # ★ 强制 IPv4(机房 IPv6 易被精准封禁)
        "source_address": "0.0.0.0",

        # ★ 随机化请求间隔,降低风控触发率
        "sleep_interval": 5,
        "max_sleep_interval": 15,
        "sleep_interval_requests": 3,

        "postprocessors": [{
            "key": "FFmpegExtractAudio",
            "preferredcodec": "mp3",
            "preferredquality": "128"
        }],
        "writeinfojson": True,

        # 错误时继续处理其余视频,不整批中断
        "ignoreerrors": True,
    }


# ── AI 摘要(带指数退避重试)────────────────────────────────────────────────

@retry(
    retry=retry_if_exception_type(Exception),
    stop=stop_after_attempt(3),
    wait=wait_exponential(multiplier=2, min=4, max=30),
    reraise=False,
)
def _call_gemini(prompt: str) -> str:
    """调用 Gemini,强制 JSON 输出,失败时自动重试最多 3 次"""
    from google.genai import types
    res = client.models.generate_content(
        model="gemini-1.5-flash",
        contents=prompt,
        config=types.GenerateContentConfig(
            response_mime_type="application/json"
        )
    )
    return res.text


def get_ai_summary(title: str, description: str) -> tuple[str, str]:
    """
    1. 从标题提取圣经经文,生成 SEO 友好标题
    2. 调用 Gemini 生成结构化摘要(要求 JSON 输出,避免自由文本解析歧义)
    """
    pattern = f"({'|'.join(BIBLE_BOOKS)})\\s*\\d+[::]\\d+"
    match = re.search(pattern, title)
    stripped = title.replace(match.group(0), "").strip(" |-_") if match else title
    clean_title = f"{match.group(0)} | {stripped}" if match else title

    # ★ 改用 JSON 结构化输出,避免自由文本解析不稳定
    prompt = f"""你是一位讲道助手。根据以下讲道信息,以纯 JSON 格式(不要 markdown 代码块)输出,结构如下:
{{
  "summary_zh": "中文摘要,80字以内",
  "key_verses": ["金句1", "金句2", "金句3"],
  "title_es": "西班牙语标题"
}}

讲道标题:{title}
讲道简介:{description[:800]}"""

    try:
        raw = _call_gemini(prompt)
        # 容错:去除可能存在的 markdown 代码块包裹
        raw = re.sub(r"```(?:json)?", "", raw).strip().strip("`")
        data = json.loads(raw)
        summary = (
            f"{data.get('summary_zh', '')}\n\n"
            f"📖 {' | '.join(data.get('key_verses', []))}\n\n"
            f"🇪🇸 {data.get('title_es', '')}"
        )
        return clean_title, summary
    except Exception as e:
        log.warning(f"⚠️ AI 摘要失败 ({e}),使用默认文本")
        return clean_title, "本期讲道详细内容,欢迎收听与分享。"


# ── 单文件处理 ────────────────────────────────────────────────────────────────

def process_file(file: str, ch: dict, meta_dir: str, backup_dir: str):
    """处理单个 mp3 文件:AI 摘要 → R2 上传 → 元数据持久化 → 本地备份"""
    base_name = file.replace(".mp3", "")
    info_file = f"temp/{base_name}.info.json"

    # ★ 确认 info.json 确实存在再处理,避免竞态
    if not os.path.exists(info_file):
        log.warning(f"⚠️ 找不到 {info_file},跳过")
        return

    with open(info_file, "r", encoding="utf-8") as f:
        info = json.load(f)

    title = info.get("title", base_name)
    description = info.get("description", "")
    clean_title, summary = get_ai_summary(title, description)

    audio_key = f"{ch['folder']}/{file}"
    local_path = f"temp/{file}"
    file_size = os.path.getsize(local_path)

    log.info(f"☁️  上传 R2: {audio_key} ({file_size / 1024 / 1024:.2f} MB)")
    s3.upload_file(
        local_path, R2_BUCKET_NAME, audio_key,
        ExtraArgs={"ContentType": "audio/mpeg"}  # Apple Podcasts 要求明确的 MIME 类型
    )

    # ★ pubDate 容错:upload_date 字段缺失或格式异常时使用当天日期
    raw_date = info.get("upload_date", "")
    try:
        pub_date = datetime.strptime(raw_date, "%Y%m%d").strftime("%a, %d %b %Y %H:%M:%S +0000")
    except (ValueError, TypeError):
        pub_date = datetime.utcnow().strftime("%a, %d %b %Y %H:%M:%S +0000")
        log.warning(f"⚠️ upload_date 解析失败(原始值: {raw_date!r}),使用当前时间")

    meta_data = {
        "title":       clean_title,
        "summary":     summary,
        "pubDate":     pub_date,
        "url":         f"{PUBLIC_BASE_URL}/{audio_key}",
        "duration":    info.get("duration", 3600),
        "size":        file_size,
        "filename_id": base_name,
    }

    with open(f"{meta_dir}/{base_name}.json", "w", encoding="utf-8") as f:
        json.dump(meta_data, f, ensure_ascii=False, indent=2)

    # 移动到本地备份(而不是直接删除)
    shutil.move(local_path, os.path.join(backup_dir, file))
    os.remove(info_file)
    log.info(f"💾 已备份: {file}")


# ── RSS 生成 ──────────────────────────────────────────────────────────────────

def generate_rss(ch: dict, meta_dir: str) -> str:
    """从所有 JSON 元数据文件生成完整 RSS XML"""
    items_xml = ""
    for meta_file in sorted(os.listdir(meta_dir), reverse=True):
        if not meta_file.endswith(".json"):
            continue
        with open(f"{meta_dir}/{meta_file}", "r", encoding="utf-8") as f:
            data = json.load(f)
        items_xml += f"""
        <item>
            <title><![CDATA[{data['title']}]]></title>
            <description><![CDATA[{data['summary']}]]></description>
            <itunes:summary><![CDATA[{data['summary']}]]></itunes:summary>
            <pubDate>{data['pubDate']}</pubDate>
            <enclosure url="{data['url']}" type="audio/mpeg" length="{data.get('size', 0)}"/>
            <guid isPermaLink="false">{data['url']}</guid>
            <itunes:duration>{data['duration']}</itunes:duration>
            <itunes:author><![CDATA[叶牧 Pablo]]></itunes:author>
            <itunes:explicit>no</itunes:explicit>
        </item>"""

    return f"""<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:itunes="http://www.itunes.com/dtds/podcast-1.0.dtd">
<channel>
    <title><![CDATA[{ch['name']}]]></title>
    <link>{SITE_URL}</link>
    <language>zh-cn</language>
    <itunes:author>叶牧 Pablo</itunes:author>
    <itunes:owner>
        <itunes:name>叶牧 Pablo</itunes:name>
        <itunes:email>[email protected]</itunes:email>
    </itunes:owner>
    <itunes:type>episodic</itunes:type>
    <itunes:summary><![CDATA[欢迎收听 {ch['name']} 播客频道。]]></itunes:summary>
    <itunes:image href="{SITE_URL}/cover.jpg"/>
    <itunes:category text="Religion &amp; Spirituality">
        <itunes:category text="Christianity"/>
    </itunes:category>
    <itunes:explicit>no</itunes:explicit>
    {items_xml}
</channel>
</rss>"""


# ── 主流程 ────────────────────────────────────────────────────────────────────

def process():
    BASE_DIR = os.path.dirname(os.path.abspath(__file__))
    os.chdir(BASE_DIR)
    os.makedirs("temp", exist_ok=True)

    # ★ 启动前检查 cookies 和 bgutil 服务
    check_cookies_valid()
    ensure_bgutil_server()

    for ch in CHANNELS:
        log.info(f"--- 开始处理频道: {ch['name']} ---")
        meta_dir   = os.path.join(BASE_DIR, f"metadata_{ch['folder']}")
        backup_dir = os.path.join(BASE_DIR, f"vps_backup_{ch['folder']}")
        os.makedirs(meta_dir,   exist_ok=True)
        os.makedirs(backup_dir, exist_ok=True)

        # 1. 下载
        try:
            with yt_dlp.YoutubeDL(build_ydl_opts(ch)) as ydl:
                ydl.download([ch["url"]])
        except Exception as e:
            log.error(f"❌ 下载频道 {ch['name']} 失败: {e}")
            # 下载失败不中断 RSS 更新,继续用现有元数据生成 RSS

        # 2. 处理新文件(★ 只处理 .mp3,且对应 info.json 存在的文件)
        for file in os.listdir("temp"):
            if not file.endswith(".mp3"):
                continue
            try:
                process_file(file, ch, meta_dir, backup_dir)
            except Exception as e:
                log.error(f"❌ 处理 {file} 失败: {e}")

        # 3. 清理过期备份
        clean_old_backups(backup_dir, days=30)

        # 4. 生成 RSS
        rss_content = generate_rss(ch, meta_dir)
        with open(ch["rss_name"], "w", encoding="utf-8") as f:
            f.write(rss_content)
        log.info(f"📡 RSS 已写入: {ch['rss_name']}")

    # 5. 推送到 GitHub / Cloudflare Pages
    try:
        subprocess.run(["git", "add", "."], check=True)
        status = subprocess.run(
            ["git", "status", "--porcelain"],
            capture_output=True, text=True
        ).stdout
        if status.strip():
            subprocess.run([
                "git", "commit", "-m",
                f"🎙️ Auto-update: {datetime.now().strftime('%Y-%m-%d %H:%M')}"
            ], check=True)
            subprocess.run(["git", "push"], check=True)
            log.info("🎉 同步完成!已推送至 GitHub / Cloudflare Pages")
        else:
            log.info("✅ 无新内容需要更新")
    except Exception as e:
        log.error(f"❌ Git 推送失败: {e}")


if __name__ == "__main__":
    process()

还是同样报错:yt-dlp –cookies cookies.txt –skip-download “https://www.youtube.com/watch?v=PUop3upxVE4" [youtube] Extracting URL: https://www.youtube.com/watch?v=PUop3upxVE4 [youtube] PUop3upxVE4: Downloading webpage [youtube] PUop3upxVE4: Downloading android vr player API JSON WARNING: [youtube] No title found in player responses; falling back to title from initial data. Other metadata may also be missing ERROR: [youtube] PUop3upxVE4: Sign in to confirm you’re not a bot. Use –cookies-from-browser or –cookies for the authentication. See https://github.com/yt-dlp/yt-dlp/wiki/FAQ#how-do-i-pass-cookies-to-yt-dlp for how to manually pass cookies. Also see https://github.com/yt-dlp/yt-dlp/wiki/Extractors#exporting-youtube-cookies for tips on effectively exporting YouTube cookies 是不是要建在自家的pi400上更方便,不需要考虑cookies了,维护可以简化

对,Pi 400 是目前最优解。你说到点子上了。

为什么 Pi 跑比 VPS 根本性地好:

  • Pi 400 用的是家庭宽带 IP,YouTube 视之为普通用户,不需要 cookies、不需要 PO Token、不需要任何伪装
  • cookies 过期问题彻底消失,因为 Pi 就在你家网络里
  • bgutil、deno、EJS 这些全部不再需要
  • 脚本可以真正做到无人值守

迁移非常简单,脚本几乎不用改,只需要:

  1. Pi 上装好环境:
sudo apt update
sudo apt install python3-pip ffmpeg git -y
pip3 install yt-dlp boto3 google-genai python-dotenv tenacity
  1. 把项目文件复制过去:
scp -r ~/pabloye-podcast/ [email protected]:~/
  1. build_ydl_opts 里去掉所有伪装参数,简化成:
"cookiefile": "cookies.txt",   # 可选,家庭 IP 通常不需要
"source_address": "0.0.0.0",   # 可以删掉
  1. crontab 定时运行:
# 每天早上 6 点自动跑
0 6 * * * cd ~/pabloye-podcast && python3 podcast-master.py

唯一需要考虑的: Pi 400 需要保持开机,以及家庭网络上传带宽够不够把 mp3 传到 R2(通常没问题,文件不大)。

要我帮你把脚本精简成 Pi 版本吗?可以把所有 VPS 相关的伪装代码清理掉,整体会简洁很多。


gemini意见:

哈哈,Claude 这次说得太对了!这可以说是跳出原有框架的“降维打击”。既然我们在云端和 YouTube 玩“猫鼠游戏”这么痛苦,那直接把老鼠洞搬回家里,确实是最彻底的解法。 用你的 Raspberry Pi 400 来跑这个脚本,优势是压倒性的。但我作为一个严谨的 AI 协作者,还是要帮你泼一小瓢冷水,把真实情况和注意事项给你盘清楚。 📊 VPS vs. 树莓派 400(优劣势大比拼) 维度VPS(云端机房)Pi 400(自家客厅)IP 信誉❌ 极差。YouTube 默认你是机器人。💎 极好。家庭宽带,YouTube 视你为真人。反爬成本❌ 极高。需要 PO Token、定期更新 Cookie、甚至买代理。极低。直接裸奔或配合极简配置即可。维护频率❌ 频繁。动不动就 403 报错需要修。极低。设好定时任务(Crontab)基本不用管。上传带宽💎 极高。机房千兆带宽,传 R2 秒完成。⚠️ 受限。取决于你家宽带的上行速度(但传几十 MB 的 MP3 绰绰有余)。电费/噪音💎 无感。⚠️ 几乎无感(Pi 400 功耗极低且无风扇,但需保持开机)。 ⚠️ 纠正 Claude 的一个“过度乐观” Claude 说:“可以去掉所有伪装参数,简化代码”。 听我的,别全删! 哪怕是在家里的正常宽带,如果你突然开启“疯狂下载模式”,短时间内把 116 个视频全部拉下来,YouTube 的防刷机制依然会赏你的家庭 IP 一个临时封锁(通常是 24 小时 429 Too Many Requests)。 所以,在 Pi 400 上的脚本里,我们依然要保留以下几个“防暴躁”参数:

  1. ratelimit: 500000:限制在 500KB/s。伪装成正常人在看视频的缓冲速度。
  2. sleep_interval: 5:视频之间休息几秒,不连续轰炸 YouTube サーバー。
  3. ignoreerrors: True:万一某个视频真的和谐了,跳过去,别让整个自动化卡死。 🛠️ 怎么做? 如果你决定用 Pi 400,这绝对是正确的选择。
  4. 脚本瘦身:我们可以把 Claude 脚本里那个复杂的 bgutil(Node.js 后台生成 Token 的部分)完全删掉,Python 代码会变得非常干净,不再需要安装 npm 和处理依赖冲突。
  5. 保留精华:保留 Gemini 的 JSON 强格式输出和 R2 的 Content-Type 补丁,保证播客客户端的完美兼容。 给你的“老司机”建议(迁移时的额外提醒) 虽然代码变简单了,但在 Pi 400 上长期运行有两点需要注意:
  6. SD 卡寿命保护: Pi 400 用的是 SD 卡,频繁读写大文件容易坏。
    • 建议:在脚本里把 temp 目录设在内存里(比如 /dev/shm),或者外挂一个 SSD/优盘。或者干脆只在下载完、上传完后立即执行 os.remove(),别留垃圾。
  7. 静默运行 (Crontab): 正如 Claude 所说,用 crontab 最方便。但我建议你加一个日志重定向,方便以后查: Bash
0 6 * * * cd /home/pi/pabloye-podcast && /usr/bin/python3 podcast-master.py >> podcast.log 2>&1
既然你手头有 Pi 400 这个神器,就别在 VPS 的泥潭里和 YouTube 斗智斗勇了。

Gemini 这次说得都对,两个补充建议完全采纳。更新脚本:两处改动都已加入:

1. 保留基础限速参数ratelimit: 500KB/s + sleep_interval: 5~15秒。Gemini 说得对,首次下载 116 个视频是一次集中爆发,家庭 IP 同样会被 429 临时封锁,这几个参数成本极低但防护有效。

2. 临时文件写入 /dev/shm:Pi 400 用 SD 卡,频繁写入大文件是 SD 卡损坏的主因。/dev/shm 是 Linux 内存文件系统,读写完全不落盘,mp3 下载、处理、上传 R2 完成后自动消失,SD 卡只需要写最终的元数据 JSON 和 RSS 文件,寿命可以延长数倍。

部署指南(DEPLOY_PI.md)无需改动,按那份文件一步步来就行。


chatgpt:

很好,这版已经是接近“可长期挂机”的 Pi 版本了,而且比你之前 VPS 版明显成熟很多。我给你做一份工程级审查(偏严格),帮你把它打磨成真正“半年不动也不炸”的版本。

我会分三层讲:


🟢 一、总体评价(先给结论)

👉 这份 Pi 版:

90分(可长期运行) ⚠️ 但还有 5 个“隐性坑”,迟早会遇到


🔴 二、关键问题(必须改)


❌ 1. 一个致命 bug(你可能还没发现)

build_ydl_opts

"outtmpl": f"{temp_dir}/{ch['folder']}-%(upload_date)s-%(title)s.%(ext)s",

👉 问题:temp_dir 根本不存在这个作用域


💥 实际后果

  • 直接 NameError
  • 或 fallback 到默认路径(不可控)

✅ 修复方式(必须改)

def build_ydl_opts(ch: dict, temp_dir: str) -> dict:

然后:

with yt_dlp.YoutubeDL(build_ydl_opts(ch, temp_dir)) as ydl:

❌ 2. temp_dir 没清理 → 内存爆掉(迟早)

你用了:

temp_dir = "/dev/shm/podcast_temp"

👉 这是内存盘(很好 👍)

但问题:

你没有清理旧文件


💥 后果

  • 跑几天后 /dev/shm
  • 下载失败
  • yt-dlp 报奇怪错误

✅ 必须加

在每轮开始前:

def clean_temp(temp_dir):
    for f in os.listdir(temp_dir):
        os.remove(os.path.join(temp_dir, f))

然后:

clean_temp(temp_dir)

❌ 3. 没有“重复文件保护”(严重)

你现在:

for file in os.listdir(temp_dir):

👉 只靠:

download_archive

💥 问题

如果:

  • .info.json 丢失
  • archive 文件损坏

👉 会重复上传 → RSS 重复 → Apple 可能拒绝


✅ 必须加判断

meta_path = f"{meta_dir}/{base_name}.json"
if os.path.exists(meta_path):
    log.info("已处理过,跳过")
    return

❌ 4. AI JSON 解析不安全(会炸)

你现在:

data = json.loads(raw)

💥 真实情况

来自 Google 的 Gemini 经常返回:

Sure! Here's the JSON:
{ ... }

✅ 修复(必须)

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

❌ 5. 文件大小未校验(隐性炸弹)

你现在:

file_size = os.path.getsize(local_path)

💥 问题

  • 下载失败 → 0KB 文件
  • 仍然上传到 R2
  • RSS 出现“坏音频”

✅ 必加

if file_size < 100_000:
    log.warning("文件异常,跳过")
    return

🟡 三、优化建议(强烈推荐)


✅ 6. yt-dlp 再稳一点(Pi专属优化)

你现在:

"ratelimit": 500_000,

👉 建议改成

"ratelimit": 800_000,
"retries": 10,
"fragment_retries": 10,

✅ 7. 加 cookies fallback(建议)

Claude 说“不需要 cookies”——

👉 理论对 👉 实战:偶尔需要


推荐写法

"cookiesfrombrowser": ("chrome",),

✅ 8. Git 自动推送有风险(小坑)

你现在:

git add .

💥 问题

可能提交:

  • .env(泄露密钥)
  • backup 音频(仓库爆炸)

✅ 必做

.gitignore

.env
vps_backup_*
metadata_*
*.log

🟡 四、架构层点评(你已经做对的)


👍 1. 用 /dev/shm(非常专业)

👉 这是工程师级优化


👍 2. tenacity 重试(很好)

@retry(...)

👉 很少人会加这个


👍 3. R2 + Pages 架构

来自 Cloudflare

👉 这是目前最优组合之一


🔵 五、DEPLOY 文档点评

整体:

✅ 清晰、可执行 ⚠️ 但缺 2 个关键点


❗ 建议补充

1. yt-dlp 自动更新

pip install -U yt-dlp

2. 开机自启(比 cron 更稳)

@reboot cd /home/pi/pabloye-podcast && venv/bin/python3 podcast-master.py

🧠 六、最终评价(真实结论)


👉 这版系统已经:

  • ✅ 可以长期跑
  • ✅ 风控基本解决
  • ✅ 自动化完整

但还差一步:

“异常恢复能力”


🚀 如果你要做到真正“无人值守半年”

我建议下一步做:


🔥 v2.2(稳定增强版)

我可以帮你加:

  • 自动检测 yt-dlp 失效 → 自动升级
  • 下载失败自动重试队列
  • Telegram 通知(成功/失败)
  • RSS 健康检查(防 Apple 拒收)
  • watchdog 进程守护

你只需要说:

👉 “做 v2.2 稳定增强版”

我会把这套系统直接提升到:

🟢 接近生产级 SaaS 稳定性


Gemini 这次审查质量很高,5个必改项全部有效。逐一确认:

  • Bug 1(temp_dir 作用域):确实存在,build_ydl_opts 是独立函数,拿不到 process() 里的局部变量
  • Bug 2(/dev/shm 不清理):确实会积累,Pi 内存只有 4GB,跑几次就满
  • Bug 3(重复文件保护):有效防御,archive 损坏时的保险
  • Bug 4(JSON 解析)response_mime_type="application/json" 大幅降低概率但不能完全保证,加正则兜底是对的
  • Bug 5(文件大小校验):0KB 文件上传到 R2 是真实风险

v2.2 的 Telegram 通知等功能先不加,保持脚本简单。全部修复直接给你:这是这个脚本经历所有 AI 联合审查后的最终版本,5 个 bug 全部修复:

Bug 1 — temp_dir 作用域build_ydl_opts 改为接收 temp_dir 参数,调用处同步更新。

Bug 2 — /dev/shm 不清理:加了 clean_temp(),每个频道处理前先清空内存盘残留。

Bug 3 — 重复文件保护process_file 开头检查 meta_path 是否已存在,处理过的直接跳过。

Bug 4 — JSON 解析双重保险response_mime_type 强制输出 + re.search(r'\{.*\}') 正则兜底,两道防线。

Bug 5 — 文件大小校验:小于 100KB 直接跳过,不上传破损文件到 R2。

另外附上了 .gitignore,把 .env、备份目录、日志、元数据目录全部排除在 git 之外,密钥不会意外提交到 GitHub。

把这两个文件放到 Pi 的项目目录,按 DEPLOY_PI.md 走完剩余步骤就可以了。


🍎 Apple Podcast 上架(完整流程)

🧭 第一步:准备材料(必须)

✅ 1. RSS 地址

https://xxx.pages.dev/rss-sermon.xml

✅ 2. 封面(关键!)

要求:

  • 尺寸:3000 x 3000

  • 格式:JPG / PNG

  • 内容:清晰 + 有标题

👉 建议写:

生命堂讲道
Sermones Cristianos

✅ 3. 描述(建议优化)

我帮你一个模板👇

中文:
本播客收录主日讲道与查经内容,帮助信徒在信仰中成长。

Español:
Este podcast comparte predicaciones cristianas para el crecimiento espiritual.

🪜 第二步:提交到 Apple Podcasts Connect

👉 打开:
https://podcastsconnect.apple.com


操作:

  1. 登录 Apple ID

  2. 点击:
    👉 “+” 添加节目

  3. 粘贴 RSS


⏳ 第三步:等待审核

  • 一般:1~3天

  • 通过后:全球可搜


⚠️ Apple 常见拒绝原因(提前帮你避坑)

❌ 没封面

👉 必须有


❌ RSS 不完整

👉 你现在已经解决 ✅


❌ 内容重复 / 无描述

👉 你现在有 AI 描述 ✅


🚀 三、上线后你会得到什么?

👉 在 Apple Podcast 里:

  • 可以被搜索(关键词:约翰福音)

  • 可以被订阅

  • 有排名


🔥 四、我建议你上线后立刻做的事

🎯 1. 提交到其他平台(同步分发)

  • 小宇宙

  • Spotify

  • Google Podcasts(部分地区)


📈 2. 观察后台数据(你已经有了)

看:

  • 哪篇点击高

  • 哪个国家多


🧠 五、给你一个关键策略(非常重要)

👉 你在西班牙,这是巨大优势:

🔥 做“中文讲道 + 西语入口”

效果:

  • 华人 → 直接听

  • 西班牙人 → 被标题吸引

👉 这是一个极小竞争赛道


🎯 总结你现在的状态

你已经完成:

✅ 自动抓内容
✅ 自动生成播客
✅ 多频道
✅ SEO优化
✅ 双语系统
✅ 可上架 Apple


👉 本质上你已经拥有:

🏗️ 一个“可增长的播客平台”


👉 下一步(我建议你选一个继续进化)

1️⃣ 🎨 做专业播客封面(我可以帮你设计)

👉 提升点击率


2️⃣ 📊 图表后台升级(更直观)

👉 更好做内容决策


3️⃣ ✝️ AI讲道全文 + 网站(非常强)

👉 直接变内容站


如果你愿意,我可以下一步帮你:
👉 设计一个“专业播客封面 + 品牌名称 + SEO关键词”(这一步会直接影响传播效果)