目标:原始播客地址在国内打不开时,让海外 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"
第一条
200echo输出完整地址最后一条
206(服务器支持 Range,iPhone 才能拖动进度条)
TVBS 同理,把 nhk-chinese 换成 tvbs-focus。
7. iPhone 订阅
打开「播客」App → 底部「资料库」
右上角「…」→「通过 URL 关注节目…」
粘贴(注意地址末尾不能有空格):
http://你的VPS_IP:8089/feeds/nhk-chinese.xmlhttp://你的VPS_IP:8089/feeds/tvbs-focus.xml点「关注」,播放一期并拖动进度条确认能跳转
本次踩过的坑:iPhone 提示"找不到服务器或提要",最终原因是粘贴的地址后多了一个空格。从聊天窗口或终端复制时,行尾空格容易被一起复制进去,遇到这个报错先检查这个。
8. 自定义封面(可选)
不做也行,脚本会下载并改写节目原封面。要自定义的话:
准备方形 JPG,建议 1400×1400 到 3000×3000,小于 500KB
文件名必须是
源名字.jpg(如nhk-chinese.jpg、tvbs-focus.jpg)在 Mac 的终端(不是 VPS)上传:
scp ~/Downloads/nhk-chinese.jpg root@你的VPS_IP:/srv/podrelay/www/covers/nhk-chinese.jpgSSH 端口不是 22 的话加
-P 端口。回 VPS 执行
python3 /srv/podrelay/sync.py,验证:file /srv/podrelay/www/covers/nhk-chinese.jpggrep -o '<itunes:image[^>]*>' /srv/podrelay/www/feeds/nhk-chinese.xmlfile应显示JPEG image data和尺寸,feed 里应只有一条指向covers/nhk-chinese.jpg?v=数字的<itunes:image>。播客 App 缓存封面,所以要取消关注再重新添加。
以后换图:直接覆盖同名文件,下次同步时
?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. 以后加新源
找到该节目的 RSS 地址(见下一节)
在 VPS 上追加一行,期数多的节目必须写第三列:
echo '名字 RSS地址 3' >> /srv/podrelay/feeds.txtpython3 /srv/podrelay/sync.py在 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"])'
已知源汇总
11. 排查表
12. 进阶(未验证,仅作思路)
HTTPS
明文 http 在部分网络环境下容易有问题,iOS 对 http 也不总是友好。可以用 Nginx Proxy Manager(NPM)或 Caddy 加 HTTPS:
准备域名(自己的域名,或
152-70-98-195.sslip.io这种形式,后者是共用域名,证书申请可能触发频率限制)让
podrelay容器加入 NPM 所在的 Docker 网络在 NPM 里新建 Proxy Host:Forward Hostname 填
podrelay,端口80,申请 Let's Encrypt 证书并开启 Force SSL把
sync.py里的BASE改成https://你的域名,重新同步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
评论区