自建节点订阅中转站:自动抓取、三层校验与企业微信提醒
一个跑在 Docker 里的 200 行 Python 服务,把「别人的文章」变成「自己的订阅地址」,并且能自己证明拿到的是最新资源。
为什么需要中转
节点分享类站点通常每天发一篇聚合文章,正文里挂着两个附件:.txt(v2ray/base64)和 .yaml(Clash 配置)。直接收藏文章链接有两个麻烦:
- 每天要手动点进去找附件,文章多了很难找
- 客户端订阅要求一个固定不变的 URL,文章地址却天天变
于是我做了个很薄的中转层:定时去读文章,把附件扒下来,用固定地址对外提供订阅。
架构
RSS 源 ──► 解析条目(按 pubDate 倒序)
│
├─► 从正文提取 .txt / .yaml 链接
├─► 下载并落盘
├─► 三层校验
└─► 输出固定接口 + 变化时才推送整个服务只有一个文件、只用 Python 标准库,镜像用 python:3.12-alpine,不装任何依赖。
关键设计
这一节是整个服务的核心。三个问题必须回答清楚:拿到的是不是最新的、网络异常时会不会断流、什么时候该打扰人。
1. 三层校验:怎么知道拿到的是最新的
只依赖「feed 第一条」并不可靠,所以做了三层交叉验证。
| 层级 | 判断依据 | 能发现的问题 |
|---|---|---|
| 文章时间 | 显式按 pubDate 倒序排序,不依赖 feed 默认顺序 | 拿到了旧文章 |
| 链接日期 | 从文件名解析日期(如 11.02xxx.txt、1102Clash.yaml),与文章日期比对 | 文章是新的,附件却是旧的 |
| 内容指纹 | 每个文件计算 SHA256 前 12 位 | 内容到底变没变 |
对外暴露 /verify,三个条件同时成立才返回 ok: true,不通过时返回 HTTP 409,方便监控直接按状态码告警。
{
"ok": true,
"fresh": true,
"dateMatch": true,
"article": { "date": "2026-11-02", "ageHours": 1.3 },
"files": [
{ "kind": "txt", "size": 52700, "sha256": "a7c353a914ed", "linkDate": "2026-11-02" },
{ "kind": "yaml", "size": 98701, "sha256": "e814e74c04b1", "linkDate": "2026-11-02" }
]
}一个容易翻车的细节:文件名格式并不统一,
11.0264bagty.txt带小数点,1102Clashiqs.yaml不带。日期解析必须同时兼容两种,否则会误报「日期不一致」——我第一次跑就踩了这个坑,dateMatch直接判错。
2. IPv4 优先
容器内常常没有 IPv6 路由,而 DNS 可能优先返回 AAAA 记录,导致请求先连 IPv6 然后超时,报出来只有一句没头没尾的 fetch failed。
解决办法是在进程启动时接管域名解析,强制走 IPv4:
_orig = socket.getaddrinfo
def _ipv4_first(host, port, family=0, type=0, proto=0, flags=0):
try:
return _orig(host, port, socket.AF_INET, type, proto, flags)
except socket.gaierror:
return _orig(host, port, family, type, proto, flags)
socket.getaddrinfo = _ipv4_first3. 重试与缓存回退
下载带两次重试,间隔 1.5 秒、3 秒,偶发抖动不该让一次刷新失败。
刷新彻底失败时不报错退出,而是继续用上一次的缓存文件对外服务,同时在 meta.json 里标记 stale: true。源站挂了,订阅不能跟着挂。
4. 通知去重与失败告警
推送的前提是「文章链接 + 文件 SHA256」组成的签名发生变化。每 30 分钟刷新一次,但一天只会在真正更新时打扰你一次。
失败则相反,要「限流地吵」:连续失败 3 次才推一次企业微信,之后 6 小时内不再重复,恢复后计数清零。这样既不会被瞬时抖动惊到,也不会在真出问题时毫无声息。
接口清单
| 接口 | 用途 |
|---|---|
/sub.txt | v2ray 订阅(原始 base64) |
/sub.plain.txt | 解码后的明文节点,方便肉眼检查 |
/sub.yaml | Clash 配置 |
/latest.json | 当前文章信息、更新时间、上次检查结果 |
/verify | 三层校验报告 |
/refresh | 手动立即刷新 |
/diag | 网络诊断:DNS、源站、文件站分别测 |
/diag 是排障时最有用的一个。它把「RSS 所在域名」和「附件所在域名」分开测试,一眼就能看出是 DNS 问题、单个域名被挡,还是容器整体没出网:
{
"checks": {
"dns_www": { "ok": true, "ips": ["64.233.187.121"] },
"feed": { "ok": true, "ms": 1573, "bytes": 32412 },
"file_host": { "ok": true, "ms": 335, "bytes": 52700 }
}
}部署配置
目录结构
/mnt/docker/1panel/apps/node-sub/
├── app/ # 脚本落盘位置(容器重建不丢)
│ └── node_sub.py
└── data/ # 缓存、校验结果、密钥
├── sub.txt
├── sub.yaml
├── meta.json
└── .wecom-webhook.url两个目录都挂到宿主机,容器删了重建,缓存和密钥都还在。
docker-compose.yml
services:
node-sub:
image: python:3.12-alpine
container_name: node-sub
restart: always
ports:
- "8097:8097"
dns:
- 223.5.5.5
- 114.114.114.114
volumes:
- /mnt/docker/1panel/apps/node-sub/data:/data
- /mnt/docker/1panel/apps/node-sub/app:/app
environment:
- TZ=Asia/Shanghai
- PYTHONUNBUFFERED=1
- REFRESH_MINUTES=30
- BASE_URL=http://192.168.12.4:8097
command:
- sh
- -c
- >
mkdir -p /app /data &&
wget -q --no-check-certificate -O /app/node_sub.py '<脚本地址>' &&
wget -q --no-check-certificate -O /data/.wecom-webhook.url '<密钥地址>' &&
python3 /app/node_sub.py启动流程设计成「先拉脚本,再运行」。这样更新只需要替换远端的脚本文件、在面板点一次重建,不用进服务器手动传文件,也不需要在镜像里 bake 任何业务代码。
环境变量
| 变量 | 默认值 | 说明 |
|---|---|---|
PORT | 8097 | 监听端口 |
DATA_DIR | /data | 缓存目录 |
REFRESH_MINUTES | 30 | 刷新间隔 |
MAX_AGE_HOURS | 48 | 超过多久判为「不新鲜」 |
BASE_URL | 空 | 通知里拼可点击链接用 |
FEED_URL | 内置 | 可换成别的 RSS 源 |
WECOM_URL | 读文件 | 企业微信机器人地址 |
关于
WECOM_URL有个坑值得单独说:它必须指向机器人地址本身,不能填「存放机器人地址的那个文件」的下载链接。我一开始就这么干的,通知全部静默失败——服务照跑,日志也不报错,只是消息一条都没发出去。
部署注意
- Alpine 自带的 BusyBox wget 不支持
--user/--password,需要认证时只能用--header传Authorization,或把凭据放在 URL 里 - 凭据不要写进文章和公开文档,配置里一律用占位符,密钥单独存文件、权限 600
- 给容器显式配 DNS,配合代码里的 IPv4 优先,能避免大部分「明明能上网却拉不到」的怪问题
它解决的核心问题
不是「怎么下文件」,而是「怎么确信自己下的是最新的,并且在出问题时能被及时告知」。
多花的那点代码,换来的是:早上打开客户端,订阅里永远是当天最新的节点;源站改版或网络抽风时,收到的是一条告警,而不是一个空订阅。
评论区