侧边栏壁纸
博主头像
龍騰博客 博主等级

行动起来,活在当下

  • 累计撰写 194 篇文章
  • 累计创建 31 个标签
  • 累计收到 12 条评论

目 录CONTENT

文章目录

用海外 VPS 中转播客 RSS(NHK / TVBS 等)保姆级教程

管理员
2026-10-13 / 0 评论 / 0 点赞 / 0 阅读 / 0 字

目标:原始播客地址在国内打不开时,让海外 VPS 代为抓取 RSS 和音频,改写地址后自己托管,iPhone 的 Apple 播客订阅你自己的地址。

原始播客服务器 ──(VPS 定时抓取)──> VPS: sync.py ──> nginx(Docker) ──> iPhone 播客 App

本教程基于一次实际搭建过程整理。标注"未验证"的部分没有在实际环境中跑通,遇到问题请按文末排查表处理。


0. 准备

  • 一台海外 VPS(本例是东京节点),能访问 NHK、TVBS 等源站

  • VPS 已装 Docker 和 Docker Compose,有 Python 3

  • 云厂商安全组和系统防火墙放行 TCP 8089

  • iPhone 上用系统自带的「播客」App

约定:下面的命令除特别说明外都在 VPS 上以 root 执行。你的VPS_IP 换成公网 IP,不要带 http://,不要带结尾的 /。

本次踩过的坑:替换 IP 时多写了 http:// 和 /,导致 feed 里的音频地址变成 http://http://xx/:8089/...。


1. 确认 VPS 能访问源站

# NHK 中文新闻
curl -sI https://www3.nhk.or.jp/rj/podcast/rss/chinese.xml | head -n 1

# TVBS《Focus全球新聞》
curl -sI https://feeds.soundon.fm/podcasts/37ad7105-4ec9-4f8d-a4dd-95ab40c91708.xml | head -n 1

返回 HTTP/2 200 或 HTTP/1.1 200 OK 就继续。不通的话换一台出口正常的 VPS。


2. 创建目录并启动 nginx

mkdir -p /srv/podrelay/www/feeds /srv/podrelay/www/audio /srv/podrelay/www/covers

cat > /srv/podrelay/docker-compose.yml <<'EOF'
services:
  nginx:
    image: nginx:alpine
    container_name: podrelay
    restart: always
    ports:
      - "8089:80"
    volumes:
      - /srv/podrelay/www:/usr/share/nginx/html:ro
EOF

cd /srv/podrelay && docker compose up -d
docker ps | grep podrelay

放行端口:

ufw allow 8089/tcp 2>/dev/null; echo done

再到云厂商控制台的安全组放行 TCP 8089。


3. 写源列表 feeds.txt

格式:名字 原始RSS地址 [保留最新期数]。第三列不写表示全部保留。

cat > /srv/podrelay/feeds.txt <<'EOF'
# 名字 原始RSS地址 [保留最新期数]
nhk-chinese https://www3.nhk.or.jp/rj/podcast/rss/chinese.xml
tvbs-focus https://feeds.soundon.fm/podcasts/37ad7105-4ec9-4f8d-a4dd-95ab40c91708.xml 3
EOF

注意:

  • TVBS 有 800 多期,每期 75~110MB,一定要写第三列,否则会全量下载,把磁盘塞满

  • NHK 的 feed 只有最新 1 期,不用限制

  • 每行里名字、地址、数字之间用空格分隔,地址中不能有空格


4. 写同步脚本 sync.py

功能:抓 feed → 只保留最新 N 期 → 下载音频并改写地址 → 封面处理 → 清理不在 feed 里的旧音频 → 输出到 www/feeds/名字.xml。

先把 BASE 换成你自己的地址(见脚本后的 sed 命令):

cat > /srv/podrelay/sync.py <<'EOF'
#!/usr/bin/env python3
import os, re, sys, html, hashlib, urllib.request

ROOT = "/srv/podrelay"
BASE = "__BASE__"
UA = {"User-Agent": "Mozilla/5.0"}

def get(url):
    req = urllib.request.Request(url, headers=UA)
    return urllib.request.urlopen(req, timeout=300).read()

def fetch_file(url, path):
    if os.path.exists(path):
        return True
    try:
        data = get(url)
        with open(path + ".part", "wb") as f:
            f.write(data)
        os.rename(path + ".part", path)
        return True
    except Exception as e:
        print(f"下载失败 {url}: {e}", file=sys.stderr)
        return False

def hashname(url, default_ext):
    base = url.split("?")[0]
    ext = os.path.splitext(base)[1]
    if not re.fullmatch(r"\.[A-Za-z0-9]{2,5}", ext or ""):
        ext = default_ext
    return hashlib.md5(base.encode()).hexdigest()[:16] + ext

def sync(name, src, keep_n):
    xml = get(src).decode("utf-8")

    # 只保留最新 keep_n 期(假设 feed 按新到旧排列)
    if keep_n:
        items = list(re.finditer(r"<item[ >].*?</item>", xml, flags=re.S))
        for m in reversed(items[keep_n:]):
            xml = xml[:m.start()] + xml[m.end():]

    # 音频
    adir = f"{ROOT}/www/audio/{name}"
    os.makedirs(adir, exist_ok=True)
    keep = set()

    def audio_repl(m):
        url = html.unescape(m.group(2))
        fn = hashname(url, ".mp3")
        if fetch_file(url, f"{adir}/{fn}"):
            keep.add(fn)
            return f"{m.group(1)}{BASE}/audio/{name}/{fn}"
        return m.group(0)

    xml = re.sub(r'(<enclosure[^>]*?url=")([^"]+)', audio_repl, xml)

    # 封面:有自定义图就覆盖,没有就下载原图并改写地址
    cdir = f"{ROOT}/www/covers"
    os.makedirs(cdir, exist_ok=True)
    custom = f"{cdir}/{name}.jpg"
    if os.path.exists(custom):
        ver = int(os.path.getmtime(custom))
        xml = re.sub(r'<itunes:image[^>]*/>', '', xml)
        xml = re.sub(r'<itunes:image[^>]*>.*?</itunes:image>', '', xml, flags=re.S)
        xml = re.sub(r'<image>.*?</image>', '', xml, flags=re.S)
        tag = f'<itunes:image href="{BASE}/covers/{name}.jpg?v={ver}"/>'
        xml = re.sub(r'(<channel[^>]*>)', lambda m: m.group(1) + tag, xml, count=1)
    else:
        def img_repl(m):
            url = html.unescape(m.group(2))
            fn = hashname(url, ".jpg")
            if fetch_file(url, f"{cdir}/{fn}"):
                return f"{m.group(1)}{BASE}/covers/{fn}"
            return m.group(0)
        xml = re.sub(r'(<itunes:image[^>]*?href=")([^"]+)', img_repl, xml)
        xml = re.sub(r'(<image>\s*<url>)([^<]+)', img_repl, xml)

    # 清理不在 feed 里的旧音频
    for fn in os.listdir(adir):
        if fn not in keep and not fn.endswith(".part"):
            os.remove(f"{adir}/{fn}")

    os.makedirs(f"{ROOT}/www/feeds", exist_ok=True)
    with open(f"{ROOT}/www/feeds/{name}.xml", "w", encoding="utf-8") as f:
        f.write(xml)
    print(f"[{name}] OK, {len(keep)} 个音频")

for line in open(f"{ROOT}/feeds.txt", encoding="utf-8"):
    line = line.strip()
    if not line or line.startswith("#"):
        continue
    parts = line.split()
    name, src = parts[0], parts[1]
    keep_n = int(parts[2]) if len(parts) > 2 else 0
    try:
        sync(name, src, keep_n)
    except Exception as e:
        print(f"[{name}] 失败: {e}", file=sys.stderr)
EOF

替换 BASE(把 IP 换成你的):

sed -i 's#^BASE = .*#BASE = "http://你的VPS_IP:8089"#' /srv/podrelay/sync.py
chmod +x /srv/podrelay/sync.py
grep '^BASE' /srv/podrelay/sync.py
python3 -c "import ast;ast.parse(open('/srv/podrelay/sync.py').read());print('语法OK')"

grep 应输出形如 BASE = "http://1.2.3.4:8089",后面是 语法OK。


5. 手动跑一次并检查

python3 /srv/podrelay/sync.py
ls -lh /srv/podrelay/www/audio/*
grep -o '<enclosure[^>]*>' /srv/podrelay/www/feeds/nhk-chinese.xml
grep -o '<item[ >]' /srv/podrelay/www/feeds/tvbs-focus.xml | wc -l
du -sh /srv/podrelay/www

预期:

  • 输出 [nhk-chinese] OK, 1 个音频 和 [tvbs-focus] OK, 3 个音频

  • enclosure 的 url 是 http://你的VPS_IP:8089/audio/名字/哈希.mp3,不含多余的 http:// 或 /

  • TVBS 的 <item 数量是 3

  • 首次下载可能要几分钟


6. 在另一台设备上验证(Mac / 手机,不是 VPS)

curl -sI http://你的VPS_IP:8089/feeds/nhk-chinese.xml | head -n 1
A=$(curl -s http://你的VPS_IP:8089/feeds/nhk-chinese.xml | grep -o 'http://[^"]*/audio/[^"]*' | head -n 1)
echo "$A"
curl -s -o /dev/null -w "%{http_code}\n" -r 0-99 "$A"
  • 第一条 200

  • echo 输出完整地址

  • 最后一条 206(服务器支持 Range,iPhone 才能拖动进度条)

TVBS 同理,把 nhk-chinese 换成 tvbs-focus。


7. iPhone 订阅

  1. 打开「播客」App → 底部「资料库」

  2. 右上角「…」→「通过 URL 关注节目…」

  3. 粘贴(注意地址末尾不能有空格):

    http://你的VPS_IP:8089/feeds/nhk-chinese.xmlhttp://你的VPS_IP:8089/feeds/tvbs-focus.xml
    
  4. 点「关注」,播放一期并拖动进度条确认能跳转

本次踩过的坑:iPhone 提示"找不到服务器或提要",最终原因是粘贴的地址后多了一个空格。从聊天窗口或终端复制时,行尾空格容易被一起复制进去,遇到这个报错先检查这个。


8. 自定义封面(可选)

不做也行,脚本会下载并改写节目原封面。要自定义的话:

  1. 准备方形 JPG,建议 1400×1400 到 3000×3000,小于 500KB

  2. 文件名必须是 源名字.jpg(如 nhk-chinese.jpg、tvbs-focus.jpg)

  3. 在 Mac 的终端(不是 VPS)上传:

    scp ~/Downloads/nhk-chinese.jpg root@你的VPS_IP:/srv/podrelay/www/covers/nhk-chinese.jpg
    

    SSH 端口不是 22 的话加 -P 端口。

  4. 回 VPS 执行 python3 /srv/podrelay/sync.py,验证:

    file /srv/podrelay/www/covers/nhk-chinese.jpggrep -o '<itunes:image[^>]*>' /srv/podrelay/www/feeds/nhk-chinese.xml
    

    file 应显示 JPEG image data 和尺寸,feed 里应只有一条指向 covers/nhk-chinese.jpg?v=数字 的 <itunes:image>。

  5. 播客 App 缓存封面,所以要取消关注再重新添加。

  6. 以后换图:直接覆盖同名文件,下次同步时 ?v= 里的数字会变,iPhone 会重新拉取。

本次踩过的坑:scp 要在有图片的那台机器(Mac)上执行,在 VPS 上执行会报找不到文件。


9. 定时同步

先确认服务器时区(cron 按服务器时间执行):

date; timedatectl | grep "Time zone"

本例是 UTC。

各节目更新时间不同:

  • NHK 中文新闻:每天日本时间 15:10 左右播出,对应 UTC 06:10。留几次重试

  • TVBS Focus:不是严格每天固定时间,用兜底任务

crontab -l 2>/dev/null | grep -q '20,50 6 \* \* \*.*podrelay\|20,50 6 \* \* \*.*sync.py' || (crontab -l 2>/dev/null; echo '20,50 6 * * * /usr/bin/python3 /srv/podrelay/sync.py >>/var/log/podrelay.log 2>&1'; echo '30 7 * * * /usr/bin/python3 /srv/podrelay/sync.py >>/var/log/podrelay.log 2>&1') | crontab -

crontab -l | grep -q '5 \*/6 \* \* \*.*podrelay' || (crontab -l; echo '5 */6 * * * /usr/bin/python3 /srv/podrelay/sync.py >>/var/log/podrelay.log 2>&1') | crontab -

crontab -l | grep podrelay

应看到三条任务:20,50 6 * * *、30 7 * * *、5 */6 * * *。脚本每次处理 feeds.txt 里的所有源,所以任何一条触发都会同步所有节目。

不要每小时同步:对每天固定更新一次的源没有意义,只会增加无谓请求。

验证自动更新(过一两天):

ls -l --time-style=full-iso /srv/podrelay/www/audio/tvbs-focus
tail -n 10 /var/log/podrelay.log

新文件出现、旧文件被清掉就正常。文件的下载时间也能告诉你节目实际的发布时间,据此调整 cron。


10. 以后加新源

  1. 找到该节目的 RSS 地址(见下一节)

  2. 在 VPS 上追加一行,期数多的节目必须写第三列:

    echo '名字 RSS地址 3' >> /srv/podrelay/feeds.txtpython3 /srv/podrelay/sync.py
    
  3. 在 iPhone 订阅 http://你的VPS_IP:8089/feeds/名字.xml

怎么找 RSS 地址

  • 播客目录页(Podcast Addict、Podchaser、Podtail 等)通常会列出 feed 地址

  • 用 Apple 目录接口查(需要在能访问 Apple 的机器上,比如海外 VPS):

    curl -s "https://itunes.apple.com/lookup?id=节目ID" | grep -o '"feedUrl":"[^"]*"'
    

    节目 ID 是 Apple 播客网址里 id 后面的数字

  • 按节目名搜索:

    curl -s "https://itunes.apple.com/search?term=节目名&media=podcast&entity=podcast&limit=20" \ | python3 -c 'import sys,jsonfor r in json.load(sys.stdin)["results"]: print(r["collectionName"],"\n ",r["feedUrl"])'
    

已知源汇总

节目

feed 地址

状态

NHK 中文新闻

https://www3.nhk.or.jp/rj/podcast/rss/chinese.xml

已验证

TVBS《Focus全球新聞》

https://feeds.soundon.fm/podcasts/37ad7105-4ec9-4f8d-a4dd-95ab40c91708.xml

已验证,891 期,建议保留 3 期

RFI 华语「第一次播音 06:00-07:00」

https://apis.rfi.fr/products/get_product/rfi_getpodcast_by_nid?token_application=975d23b8-7a07-11e8-9f62-005056a90194&program_entrepriseId=WB8013-RFI-FR-20091008

未验证,来自 Podcast Addict 页面,其他 RFI 节目的 ID 未查到


11. 排查表

现象

原因与检查

iPhone 提示「找不到服务器或提要」

先检查地址末尾有没有多余空格;再关掉 Wi-Fi 用蜂窝试;再用 iPhone 的 Safari 直接打开 feed 地址;最后 python3 -c "import xml.dom.minidom as m;m.parse('/srv/podrelay/www/feeds/名字.xml');print('XML合法')" 检查 XML

feed 里的音频地址成了 http://http://...

BASE 填错,用第 4 步的 sed 重新设置,再同步一次

本机访问 feed 返回 200,但音频测试返回 000

多半是 grep 没取到地址,先 grep -o '<enclosure[^>]*>' 看原始输出

音频返回 404

enclosure 路径与 www/audio/名字/ 下的实际文件名不一致,重新同步

拖不动进度条

第 6 步的 Range 测试不是 206

封面不显示

取消关注后重新添加;检查 feed 里 <itunes:image> 的地址是否可访问(curl -I)

磁盘越来越大

feeds.txt 里该源没写第三列;改成保留 3~5 期再同步

脚本报「下载失败」

看报错里的 URL 能否在 VPS 上直接 curl -I;检查 VPS 出口

一直没更新

tail /var/log/podrelay.log;curl -sI 原始feed | head -n 1;确认 cron 在跑:crontab -l

流量消耗大

TVBS 每期 75~110MB;在 iPhone 设置里关掉蜂窝下自动下载


12. 进阶(未验证,仅作思路)

HTTPS

明文 http 在部分网络环境下容易有问题,iOS 对 http 也不总是友好。可以用 Nginx Proxy Manager(NPM)或 Caddy 加 HTTPS:

  1. 准备域名(自己的域名,或 152-70-98-195.sslip.io 这种形式,后者是共用域名,证书申请可能触发频率限制)

  2. 让 podrelay 容器加入 NPM 所在的 Docker 网络

  3. 在 NPM 里新建 Proxy Host:Forward Hostname 填 podrelay,端口 80,申请 Let's Encrypt 证书并开启 Force SSL

  4. 把 sync.py 里的 BASE 改成 https://你的域名,重新同步

  5. iPhone 取消关注旧订阅,用 https 地址重新添加

这台 VPS 如果同时跑着代理服务(sing-box 等),先用 ss -ltnp | grep -E ':(80|443)\s' 检查 80/443 是否被占用。

其他方向

  • 套 CDN:需要自己的域名,国内访问质量不一定比直连好,先测速再决定

  • 路径随机化:把 nhk-chinese 之类的名字换成随机字符串,减少明文传输时的特征(只在 http 下有意义,https 后路径本身是加密的)

  • 如果 feed 里的 length="1" 导致 iPhone 下载进度显示异常,可以让脚本把它改成真实文件大小


附:最终目录结构

/srv/podrelay/
├── docker-compose.yml
├── feeds.txt
├── sync.py
└── www/
    ├── feeds/
    │   ├── nhk-chinese.xml
    │   └── tvbs-focus.xml
    ├── audio/
    │   ├── nhk-chinese/哈希.mp3
    │   └── tvbs-focus/哈希.mp3 (最新 3 期)
    └── covers/
        ├── nhk-chinese.jpg
        └── tvbs-focus.jpg

订阅地址规律固定:http://你的VPS_IP:8089/feeds/名字.xml

0

评论区