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

行动起来,活在当下

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

目 录CONTENT

文章目录

播客中转状态页创建教程(源端 + 客户端)

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

在 http://你的VPS_IP:8089/status.html 查看播客中转的同步情况:源站有没有新一期、中转有没有抓到、iPhone 播客 App 有没有来拉取和下载。

前提

这份教程接在"用海外 VPS 中转播客 RSS"之后,需要下面这些已经就绪:

  • 目录 /srv/podrelay/,里面有 feeds.txt(每行 名字 原始RSS地址 [保留期数])和 sync.py

  • nginx 容器名是 podrelay,通过 8089 端口托管 /srv/podrelay/www

  • www/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 步:怎么读页面

源端同步表

状态

含义

处理

已同步

中转的最新一期不比源站旧,或者落后但还在宽限时间内,音频文件齐全

无

落后于源站

源站发布新一期已超过宽限时间,中转还没抓到

手动同步并看日志

源站不可达

VPS 访问不了源站(中转的旧内容仍可用)

curl -sI 源站feed地址

音频缺失

feed 里写的音频文件在磁盘上找不到

重新同步

中转无数据

feed 文件不存在

检查 feeds.txt 里的名字

宽限时间: 源站发布和你的同步任务一般会错开一段时间,这段时间里中转必然落后,所以只有超过宽限时间还没抓到,才显示"落后于源站"。宽限期内状态是"已同步",但「源站最新一期」和「中转最新一期」两列的时间会不同,想看真实差距看这两列。

  • 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 段都会变。

第 7 步:状态页排查表

现象

原因与处理

客户端表格全是 - 和 0

先看页面有没有"读取 nginx 日志失败";再执行 docker logs --since 168h podrelay 2>&1 | head -n 3;确认容器名是 podrelay;节目刚订阅或还没被客户端访问过时也是空的

某个节目的客户端数据一直是空,另一个正常

iPhone 里没有成功订阅这个节目的地址,检查订阅地址有没有多余空格、有没有用旧的节目名

status.html 一直不更新

crontab -l 看有没有 status.py;tail /var/log/podrelay-status.log 看报错

状态一直是"源站不可达"

VPS 访问源站失败,curl -sI 源站feed地址 | head -n 1

状态一直是"落后于源站"

手动同步一次:python3 /srv/podrelay/sync.py && python3 /srv/podrelay/status.py;还不行就看 tail /var/log/podrelay.log

页面上看到一条 /feeds/旧名字.xml 的 404

改名之前的旧订阅还在 iPhone 里,取消关注旧的

0

评论区