背景:cron 轮询的延迟困局
自动化流水线里最不起眼但也最头疼的问题:触发延迟。
我的公众号文章发布流水线之前依赖 cron 定时轮询——每 2 分钟检查一次飞书表格状态变更,发现新选题就触发写作流程。这套方案的延迟是确定的:平均 1 分钟,最长 2 分钟。看起来不高,但当你连续调试流水线时,每次改完都要等 2 分钟才能看到结果,调试节奏完全被打断。
更要命的是,轮询方案容易出现重复触发。用 cron 轮询状态变更,再叠加飞书自身的 Workflow 触发,两者如果没协调好——比如 cron agent 错误地将选题状态设为”排队中”,触发飞书 Workflow 再次调用同一脚本——就会产生两份重复的流水线。之前的巡检就发现过这个问题。
解决方案很明确:把轮询换成推送,让消息主动来通知我,而不是我定时去问。
ntfy 本身已经是一个轻量级的消息推送服务,支持 WebSocket 订阅端点 wss://ntfy.sh/{topic}/ws。问题在于怎么把这个 WebSocket 消息接到 n8n 里,让它能根据消息内容触发不同的工作流。
三种方案的权衡
在寻找方案时,我对比了三种思路:
方案一:Polling(最简单,但不够实时)
Schedule Trigger(每 1-2 分钟)→ HTTP Request(GET ntfy.sh)→ Code/IF 判断
用 n8n 内置的 Schedule 定时向 ntfy 的 JSON 缓存端点拉取新消息。优点是零额外依赖,纯 n8n 内置节点就能搞定。缺点也很明显——不是真正的实时,轮询间隔和重复消息去重要自己处理。
方案二:CLI bridge(实时且稳定,但需要额外进程)
服务器上跑 ntfy subscribe → shell → curl POST → n8n Webhook Trigger
用 ntfy 的 CLI 工具在服务器上跑 ntfy subscribe 长连接,消息到来时通过 curl 调用 n8n 的 Webhook 接口。这个方案实时性最好,n8n 侧就是标准 HTTP Webhook,最稳定。缺点是需要一台服务器保持 subscribe 进程长期运行(systemd 或 tmux),多了一层要维护的东西。
方案三:WebSocket(实时 + 纯 n8n,需要社区节点)✅
wss://ntfy.sh/{topic}/ws → n8n WebSocket Trigger → 工作流处理
n8n 的社区节点 n8n-nodes-websockets-trigger 让 n8n 可以直接建立 WebSocket 长连接,接收 ntfy 推送的消息。优点是纯 n8n 方案,不依赖额外的脚本或进程,全部在 n8n 工作流内完成。缺点是需要安装社区节点(依赖容器内 npm)。
我最终选择了方案三。 纯 n8n 免运维的优势对我来说比安装社区节点的成本更重要。
架构设计
统一 Hook 规范
所有外部消息通过一个 ntfy topic 统一入口,用 JSON 结构体携带路由信息:
{
"target": "hermes",
"hook": "gzh-article/create",
"id": "evt-xxxxx",
"payload": { "title": "...", "record_id": "..." },
"meta": { "ts": "2026-06-05T20:00:00+08:00" }
}
四个核心字段:
- target — 目标系统:hermes(AI 助手)、qinglong(青龙面板)、homeassistant(智能家居)等
- hook — 操作指令:
<domain>/<action>格式,如gzh-article/create、hermes/ping - id — 事件唯一 ID,用于去重
- payload — 业务参数,不同 hook 的结构不同
这个设计参考了 Webhook 标准的自描述命名,让每个消息一看就知道要触发什么功能。
n8n 工作流结构
WebSocket 触发后,消息流经三个处理阶段:
WebSocket Trigger(wss://ntfy.sh/{topic}/ws)
│
Code 节点(校验 JSON schema + 提取 target/hook + slugify 标题)
│
IF(target=hermes?) ── false → NoOp(其他 target 占位)
│ true
IF(hook=create?) ── true → SSH(执行创建流水线脚本)
│ false
IF(hook=ping?) ── true → Pong(返回心跳响应)
│ false
NoOp(未匹配的 hook)
工作流包含 9 个节点:1 个 WebSocket Trigger、1 个 Code 解析节点、3 个 IF 分支判断、2 个执行节点(SSH 和 Pong)、2 个 NoOp 占位节点。
Code 节点的关键逻辑
解析节点负责三件事:
1. 消息校验:解析 JSON,校验 target 和 hook 字段是否存在,不存在则填充默认值(target 默认 hermes,hook 默认 unknown/unknown)。
2. Slugify:将中文标题转为文件名友好的 slug。这里有一个大坑,后面细说。
3. SSH 命令构造:根据 target 和 hook 构造对应的执行命令。最核心的一条是触发公众号文章流水线:
const sshCmd = "bash -l -c " + shellEscape(
"cd /project/path && python3 scripts/create-pipeline.py " +
"--title " + shellEscape(标题) +
" --slug " + slug +
" --record-id " + record_id
);
注意 bash -l -c 这个包装——这也是一个踩坑得来的经验。
实施步骤
1. 安装 WebSocket 社区节点
进入 n8n 容器安装依赖:
# 进入 n8n 容器
docker exec -it n8n sh
# 安装 WebSocket 社区节点
npm install n8n-nodes-websockets-trigger@latest
# 安装 WebSocket 运行时依赖
npm install ws@^8.18.0
# 重启 n8n
docker restart n8n
安装后,在 n8n UI → Settings → Community Nodes 确认节点已加载。
重要:n8n-nodes-websockets-trigger 依赖 ws 库作为 WebSocket 客户端,但 npm 安装社区节点时不会自动安装 peerDependencies,需要手动补装。
2. 创建工作流
WebSocket Trigger 节点的配置很简单——URL 填 wss://ntfy.sh/你的topic/ws 即可。连接建立后,ntfy 推送的消息会实时到达 n8n。
可以用 n8n API 创建工作流(编写 Node.js 脚本通过 POST /api/v1/workflows 创建),也可以直接在 n8n UI 中拖拽配置。生产环境建议用 API 脚本管理,方便版本控制和重复部署。
3. 激活工作流
通过 POST /api/v1/workflows/{id}/activate 激活后,WebSocket 连接自动建立。可以在 n8n 工作流的 Execution 面板中看到连接状态。
端到端验证
测试 1:心跳检测
向 ntfy topic 发送一条 ping 消息:
curl -d '{"target":"hermes","hook":"hermes/ping","id":"evt-ping-1","payload":{"msg":"hello"}}' \
ntfy.sh/YOUR_TOPIC
n8n 工作流收到消息后,经过 target→hook 两级判断,命中 hermes/ping 分支,返回 Pong 响应。验证了整个链路是否通畅。
测试 2:触发流水线
发送公众号文章创建请求:
curl -d '{"target":"hermes","hook":"gzh-article/create","id":"evt-create-1",\
"payload":{"title":"文章标题","record_id":"rec_xxx"}}' \
ntfy.sh/YOUR_TOPIC
工作流解析消息后,通过 SSH 节点执行创建流水线脚本。SSH 返回 code=0 表示脚本执行成功,kanban 卡片自动创建。
踩坑记录
坑 1:SSH 非交互 shell 的 PATH 问题
症状:SSH 节点执行脚本时报 FileNotFoundError: No such file or directory: 'hermes'。
原因:n8n 的 SSH 节点使用非交互 shell 执行命令,不会加载 .bashrc / .bash_profile。安装在 ~/.local/bin 的 CLI 工具不在 PATH 中。
解决:SSH 命令用 bash -l -c 包装,强制加载 login shell 的环境变量:
"bash -l -c " + shellEscape("python3 /path/to/script.py --arg value")
这是最通用的解决办法,适用于任何需要在远程通过 SSH 执行 CLI 工具的场景。
坑 2:中文标题 slugify 丢失汉字
症状:"IF节点测试" 经过正则 slugify 后变成 "if",中文部分被完全移除。
原因:常用的 slugify 正则 /[^\w\s-]/g 中,\w 只匹配 [a-zA-Z0-9_],不包含 CJK 字符。中文标题全部被过滤掉。
当前处理:slug 主要用于文件名和 URL,中文标题本身通过 payload 的 title 字段保留,不依赖 slug 显示。如果后续需要中文 slug,可以使用 pinyin 库将中文转拼音。
坑 3:重复触发预防
架构层面有双重保护:
-
勿设”排队中”状态:如果 cron agent 手动将飞书选题状态设为”排队中”,会触发飞书 Workflow → ntfy → n8n 再次调用同一脚本。固定路径只用一个触发入口,避免两套机制互相干扰。
-
消息去重:ntfy 消息自带时间戳,Code 节点可以通过记录已处理的消息 ID 实现去重逻辑。
可扩展的方向
这套架构的核心优势在于路由可扩展性——只需扩展 n8n 工作流的 IF 分支,就能接入更多 target:
- 青龙面板 —
target: "qinglong"路由到 HTTP Request 节点调用 QL API - Home Assistant —
target: "homeassistant"调用 HA API 控制设备 - 通知转发 —
target: "notify"转发到飞书等其他通知渠道 - 配置热加载 —
hook: "hermes/config-reload"SSH 执行配置重载 - 取消指令 —
hook: "gzh-article/cancel"清理已创建的 kanban 卡片 - ntfy-pipeline-listener 整合 — 将现有的 cron 轮询 ntfy 监听脚本改走 WebSocket,彻底消除轮询
总结
用 n8n WebSocket 社区节点替代 cron 轮询,本质上是把”我去问”变成了”它来通知”。
这套方案的优势很明确:
- 纯 n8n:不需要额外进程或中间脚本
- 实时:消息发出到执行,延迟毫秒级
- 路由清晰:统一 Hook 规范 + IF 分支判断,新功能只需加一个分支
- 可观测性:n8n 执行日志一目了然,调试比 CLI bridge 方便得多
代价是安装了一个社区节点(注意手动补 peerDependencies),但这笔投入对于从分钟级轮询降到毫秒级触发来说,性价比很高。
如果你也有 cron 轮询的自动化场景——数据同步、定时任务、状态监控——可以考虑用 n8n WebSocket 改造一下,体验从 Polling 到 Push 的质变。