在 http://你的VPS_IP:8089/status.html 查看播客中转的同步情况:源站有没有新一期、中转有没有抓到、iPhone 播客 App 有没有来拉取和下载。
前提
这份教程接在"用海外 VPS 中转播客 RSS"之后,需要下面这些已经就绪:
目录
/srv/podrelay/,里面有feeds.txt(每行名字 原始RSS地址 [保留期数])和sync.pynginx 容器名是
podrelay,通过 8089 端口托管/srv/podrelay/wwwwww/feeds/名字.xml已由sync.py生成,iPhone 已订阅过VPS 上有
python3和docker,以 root 执行命令你的VPS_IP换成公网 IP,不要带http://
下面的命令除特别说明外都在 VPS 上执行。
在 http://你的VPS_IP:8089/status.html 看到:
源端:每个节目的源站最新一期、中转最新一期、中转上次同步时间、音频文件是否齐全
客户端:iPhone 播客 App 最近一次拉取 feed、最近一次下载音频、24 小时内拉取次数、24 小时内音频 404 次数
最近 20 条客户端请求和事件记录(源站出现新一期、中转更新、状态变化)
原理:新增一个 status.py,每 15 分钟运行一次,对比源站和中转的最新一期,解析 nginx 访问日志,生成静态页面 www/status.html,由现有的 nginx 直接托管。不需要改 sync.py。
第 1 步:写脚本
cat > /srv/podrelay/status.py <<'EOF'
#!/usr/bin/env python3
import os, re, json, html, subprocess, urllib.request
from datetime import datetime, timedelta, timezone
from email.utils import parsedate_to_datetime
from urllib.parse import urlparse
ROOT = "/srv/podrelay"
WWW = f"{ROOT}/www"
OUT = f"{WWW}/status.html"
STATE = f"{ROOT}/state.json"
HIST = f"{ROOT}/history.jsonl"
CONTAINER = "podrelay"
CST = timezone(timedelta(hours=8))
NOW = datetime.now(timezone.utc)
E = html.escape
def iso(dt): return dt.isoformat() if dt else None
def fmt(dt):
if not dt: return "-"
s = max(0, int((NOW - dt).total_seconds()))
if s < 3600: a = f"{s // 60} 分钟前"
elif s < 86400: a = f"{s // 3600} 小时前"
else: a = f"{s // 86400} 天前"
return f"{dt.astimezone(CST).strftime('%m-%d %H:%M')}({a})"
def mask(ip):
if ":" in ip: return ":".join(ip.split(":")[:2]) + ":*"
p = ip.split(".")
return ".".join(p[:2]) + ".*.*" if len(p) == 4 else ip
def to_utc(s):
try:
dt = parsedate_to_datetime(s.strip())
if dt.tzinfo is None: dt = dt.replace(tzinfo=timezone.utc)
return dt.astimezone(timezone.utc)
except Exception:
return None
def first_pub(xml):
m = re.search(r"<item[ >].*?<pubDate>([^<]+)</pubDate>", xml, re.S) \
or re.search(r"<pubDate>([^<]+)</pubDate>", xml)
return to_utc(m.group(1)) if m else None
def first_title(xml):
m = re.search(r"<item[ >].*?<title>\s*(?:<!\[CDATA\[)?(.*?)(?:\]\]>)?\s*</title>", xml, re.S)
return html.unescape(m.group(1).strip()) if m else "-"
def read_feeds():
out = []
for line in open(f"{ROOT}/feeds.txt", encoding="utf-8"):
line = line.strip()
if line and not line.startswith("#"):
p = line.split()
out.append((p[0], p[1]))
return out
def fetch_src(url):
try:
req = urllib.request.Request(url, headers={"User-Agent": "Mozilla/5.0"})
return urllib.request.urlopen(req, timeout=30).read().decode("utf-8"), None
except Exception as e:
return None, str(e)[:80]
# ---------- 源端 ----------
GRACE_MIN = {"nhk-chinese": 90} # 单位:分钟,按节目单独设置,名字要和 feeds.txt 一致
DEFAULT_GRACE = 380 # 其他节目的默认值,要大于该节目同步任务的最大间隔
def check_feed(name, src):
r = {"name": name}
sx, err = fetch_src(src)
r["src_err"] = err
r["src_pub"] = first_pub(sx) if sx else None
fp = f"{WWW}/feeds/{name}.xml"
r.update(relay_pub=None, title="-", synced=None, audio_ok=0, audio_total=0)
if os.path.exists(fp):
rx = open(fp, encoding="utf-8").read()
r["relay_pub"] = first_pub(rx)
r["title"] = first_title(rx)
r["synced"] = datetime.fromtimestamp(os.path.getmtime(fp), timezone.utc)
urls = re.findall(r'<enclosure[^>]*?url="([^"]+)"', rx)
r["audio_total"] = len(urls)
r["audio_ok"] = sum(1 for u in urls
if os.path.exists(WWW + urlparse(html.unescape(u)).path))
if r["synced"] is None: r["state"] = "中转无数据"
elif r["audio_ok"] < r["audio_total"]: r["state"] = "音频缺失"
elif r["src_err"]: r["state"] = "源站不可达"
elif (r["src_pub"] and r["relay_pub"] and r["src_pub"] > r["relay_pub"]
and NOW - r["src_pub"] > timedelta(minutes=GRACE_MIN.get(name, DEFAULT_GRACE))):
r["state"] = "落后于源站"
else: r["state"] = "已同步"
return r
# ---------- 客户端 ----------
LOGRE = re.compile(r'^(\S+) - \S+ \[([^\]]+)\] "(\S+) (\S+) [^"]*" (\d{3}) (\d+) "[^"]*" "([^"]*)"')
def client_logs():
try:
p = subprocess.run(["docker", "logs", "--since", "168h", CONTAINER],
capture_output=True, text=True, timeout=60)
if p.returncode != 0:
return [], False
lines = (p.stdout + "\n" + p.stderr).splitlines()
except Exception:
return [], False
rows = []
for l in lines:
m = LOGRE.match(l)
if not m: continue
ip, t, meth, path, st, size, ua = m.groups()
try:
dt = datetime.strptime(t, "%d/%b/%Y:%H:%M:%S %z").astimezone(timezone.utc)
except ValueError:
continue
rows.append({"dt": dt, "ip": ip, "path": path.split("?")[0],
"status": int(st), "ua": ua})
return rows, True
def client_stats(name, rows):
since = NOW - timedelta(hours=24)
fp, ap = f"/feeds/{name}.xml", f"/audio/{name}/"
app = [r for r in rows if "Podcasts/" in r["ua"]]
feed = [r for r in app if r["path"] == fp and r["status"] in (200, 304)]
aud = [r for r in app if r["path"].startswith(ap) and r["status"] in (200, 206)]
miss = [r for r in rows if r["path"].startswith(ap) and r["status"] == 404 and r["dt"] >= since]
return {
"feed_last": feed[-1]["dt"] if feed else None,
"feed_24h": sum(1 for r in feed if r["dt"] >= since),
"aud_last": aud[-1]["dt"] if aud else None,
"miss_24h": len(miss),
}
# ---------- 事件 ----------
def update_events(results):
state = json.load(open(STATE)) if os.path.exists(STATE) else {}
new = []
for r in results:
n = r["name"]
old = state.get(n)
cur = {"state": r["state"], "relay_pub": iso(r["relay_pub"]),
"src_pub": iso(r["src_pub"]) or (old or {}).get("src_pub")}
if old is None:
new.append((n, f"开始监控,当前状态:{cur['state']}"))
else:
if cur["src_pub"] and cur["src_pub"] != old.get("src_pub"):
new.append((n, "源站出现新一期"))
if cur["relay_pub"] != old.get("relay_pub"):
new.append((n, "中转已更新到新一期"))
if cur["state"] != old.get("state"):
new.append((n, f"状态变化:{old.get('state')} → {cur['state']}"))
state[n] = cur
json.dump(state, open(STATE, "w"), ensure_ascii=False)
hist = []
if os.path.exists(HIST):
hist = [l for l in open(HIST, encoding="utf-8").read().splitlines() if l.strip()]
for n, msg in new:
hist.append(json.dumps({"ts": iso(NOW), "name": n, "msg": msg}, ensure_ascii=False))
hist = hist[-300:]
open(HIST, "w", encoding="utf-8").write("\n".join(hist) + ("\n" if hist else ""))
return [json.loads(l) for l in hist][-30:][::-1]
# ---------- 页面 ----------
CSS = """
:root{--bg:#fff;--fg:#1d1d1f;--mut:#6e6e73;--line:#e5e5ea;--ok:#1a7f37;--warn:#b26a00;--bad:#c62828}
@media(prefers-color-scheme:dark){:root{--bg:#1c1c1e;--fg:#f2f2f7;--mut:#98989d;--line:#38383a;--ok:#4cc26b;--warn:#f0b34a;--bad:#ff6b6b}}
body{background:var(--bg);color:var(--fg);font:14px/1.5 -apple-system,system-ui,sans-serif;margin:0;padding:16px;max-width:1000px}
h1{font-size:20px;margin:0 0 4px}h2{font-size:16px;margin:24px 0 8px}
.mut{color:var(--mut)}.wrap{overflow-x:auto}
table{border-collapse:collapse;width:100%;min-width:560px}
th,td{text-align:left;padding:6px 8px;border-bottom:1px solid var(--line);white-space:nowrap}
th{color:var(--mut);font-weight:500}
.ok{color:var(--ok)}.warn{color:var(--warn)}.bad{color:var(--bad)}
"""
CLS = {"已同步": "ok", "落后于源站": "warn", "源站不可达": "warn",
"中转无数据": "bad", "音频缺失": "bad"}
def table(head, rows):
h = "".join(f"<th>{E(x)}</th>" for x in head)
b = "".join("<tr>" + "".join(f"<td>{c}</td>" for c in r) + "</tr>" for r in rows)
return f'<div class="wrap"><table><tr>{h}</tr>{b}</table></div>'
def parse(s): return datetime.fromisoformat(s)
def render(results, rows, log_ok, events):
src_rows = [[E(r["name"]), f'<span class="{CLS[r["state"]]}">{E(r["state"])}</span>',
fmt(r["src_pub"]) if not r["src_err"] else f'<span class="warn">{E(r["src_err"])}</span>',
fmt(r["relay_pub"]), fmt(r["synced"]),
f'{r["audio_ok"]}/{r["audio_total"]}', E(r["title"][:70])] for r in results]
cli_rows = []
for r in results:
s = client_stats(r["name"], rows)
miss = f'<span class="bad">{s["miss_24h"]}</span>' if s["miss_24h"] else "0"
cli_rows.append([E(r["name"]), fmt(s["feed_last"]), str(s["feed_24h"]),
fmt(s["aud_last"]), miss])
recent = [r for r in rows if r["path"].startswith(("/feeds/", "/audio/"))][-20:][::-1]
rec_rows = [[fmt(r["dt"]), "iPhone 播客" if "Podcasts/" in r["ua"] else "其他",
E(mask(r["ip"])), E(r["path"][:48]), str(r["status"])] for r in recent]
ev_rows = [[fmt(parse(e["ts"])), E(e["name"]), E(e["msg"])] for e in events]
note = "" if log_ok else '<p class="bad">读取 nginx 日志失败,客户端数据为空</p>'
return f"""<!doctype html><html lang="zh"><head><meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<meta http-equiv="refresh" content="300"><title>播客中转状态</title><style>{CSS}</style></head><body>
<h1>播客中转状态</h1>
<div class="mut">更新于 {NOW.astimezone(CST).strftime('%Y-%m-%d %H:%M:%S')}(北京时间),每 15 分钟刷新一次</div>
<h2>源端同步</h2>
{table(["节目","状态","源站最新一期","中转最新一期","中转上次同步","音频文件","最新标题"], src_rows)}
<h2>客户端(iPhone 播客 App,近 7 天日志)</h2>{note}
{table(["节目","最近拉取 feed","24h 拉取次数","最近下载音频","24h 音频 404"], cli_rows)}
<h2>最近 20 条客户端请求</h2>
{table(["时间","客户端","来源 IP","请求","状态"], rec_rows)}
<h2>事件记录</h2>
{table(["时间","节目","事件"], ev_rows)}
</body></html>"""
def main():
results = [check_feed(n, s) for n, s in read_feeds()]
rows, log_ok = client_logs()
events = update_events(results)
page = render(results, rows, log_ok, events)
open(OUT + ".tmp", "w", encoding="utf-8").write(page)
os.replace(OUT + ".tmp", OUT)
print(NOW.strftime("%F %T"), "Z", " ".join(f"{r['name']}={r['state']}" for r in results))
main()
EOF
chmod +x /srv/podrelay/status.py
python3 -c "import ast;ast.parse(open('/srv/podrelay/status.py').read());print('语法OK')"
GRACE_MIN 里的名字要和 feeds.txt 里的名字一致。如果你的 NHK 源叫别的名字(比如 nhk-news),这里也要改成同一个。
第 2 步:运行一次并检查
python3 /srv/podrelay/status.py
ls -l /srv/podrelay/www/status.html
cat /srv/podrelay/history.jsonl
预期:输出形如 2026-10-14 ... Z nhk-chinese=已同步 tvbs-focus=已同步,status.html 已生成,history.jsonl 里每个节目有一条"开始监控"。
在另一台设备上确认页面能打开:
curl -s -o /dev/null -w "%{http_code}\n" http://你的VPS_IP:8089/status.html
返回 200 后用浏览器打开 http://你的VPS_IP:8089/status.html。
第 3 步:加定时任务
crontab -l | grep -q 'podrelay/status.py' || (crontab -l; echo '*/15 * * * * /usr/bin/python3 /srv/podrelay/status.py >>/var/log/podrelay-status.log 2>&1') | crontab -
crontab -l | grep podrelay
加完应该有四条任务:三条同步任务,一条每 15 分钟刷新状态页。过了下一个整刻钟之后检查:
tail -n 2 /var/log/podrelay-status.log
ls -l --time-style=full-iso /srv/podrelay/www/status.html
日志文件不存在说明还没触发过,不是故障。
第 4 步:怎么读页面
源端同步表
宽限时间: 源站发布和你的同步任务一般会错开一段时间,这段时间里中转必然落后,所以只有超过宽限时间还没抓到,才显示"落后于源站"。宽限期内状态是"已同步",但「源站最新一期」和「中转最新一期」两列的时间会不同,想看真实差距看这两列。
NHK 默认 90 分钟,要覆盖到每天最后一次同步(UTC 07:30)
其他节目默认 380 分钟,要比每 6 小时一次的兜底同步间隔长
宽限越长,真实故障发现得越晚。想让某个节目更早报警,就给它加更密的 cron,再把它的宽限在
GRACE_MIN里单独调小
客户端表
「最近拉取 feed」「24h 拉取次数」:iPhone 播客 App 刷新节目的记录。很久没有,说明 App 没在后台刷新,手动下拉刷新就会出现
「最近下载音频」:最近一次真的下载或播放这个节目音频的时间。有拉取但没有下载,说明新一期还没被播放或下载
「24h 音频 404」:任何客户端请求了已被清理的旧音频。不为 0 说明 iPhone 里有指向旧地址的记录(比如改过文件名之后)
客户端统计只算 User-Agent 含
Podcasts/的请求,也就是 iPhone 自带的播客 App。你在电脑上curl测试的请求会在「最近 20 条客户端请求」里标成「其他」
第 5 步:手动同步
不做网页按钮(需要额外的后台服务,复杂度不值得)。在 VPS 上一条命令搞定,顺便刷新状态页:
python3 /srv/podrelay/sync.py && python3 /srv/podrelay/status.py
设一个别名,以后只敲 podsync:
echo "alias podsync='python3 /srv/podrelay/sync.py && python3 /srv/podrelay/status.py'" >> ~/.bashrc
source ~/.bashrc
第 6 步:注意事项和踩过的坑
docker logs --since不认d单位。 写成7d会报invalid value for "since",脚本里要写168h。最终脚本已经按168h写,并且命令失败时页面会显示"读取 nginx 日志失败",不会静默显示为空。验证命令:docker logs --since 168h podrelay 2>&1 | grep -c Podcasts,大于 0 说明日志里有 iPhone 的请求。重建容器会清空客户端历史。 客户端数据来自
docker logs,docker compose up -d后容器被重新创建,日志随之清零。源端的事件记录存在history.jsonl里,不受影响。页面会显示脱敏后的来源 IP(只保留前两段),没有做访问限制。想减少被扫到的可能,把脚本里的
OUT改成不好猜的文件名,比如status-a3f09c.html。页面本身每 5 分钟自动刷新一次,内容每 15 分钟重新生成一次。 需要立刻更新时手动跑
python3 /srv/podrelay/status.py。来源 IP 变化是正常的。 手机在 Wi-Fi 和蜂窝之间切换,或者蜂窝网络换基站,IP 段都会变。
评论区