从 Polling 到 WebSocket:用 n8n 搭建实时消息触发流水线

2026-06-05

背景: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 RequestGET 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/createhermes/ping
  • id — 事件唯一 ID,用于去重
  • payload — 业务参数,不同 hook 的结构不同

这个设计参考了 Webhook 标准的自描述命名,让每个消息一看就知道要触发什么功能。

n8n 工作流结构

WebSocket 触发后,消息流经三个处理阶段:

WebSocket Triggerwss://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 字符。中文标题全部被过滤掉。

当前处理:slu​g 主要用于文件名和 URL,中文标题本身通过 payload 的 title 字段保留,不依赖 slug 显示。如果后续需要中文 slug,可以使用 pinyin 库将中文转拼音。

坑 3:重复触发预防

架构层面有双重保护:

  1. 勿设”排队中”状态:如果 cron agent 手动将飞书选题状态设为”排队中”,会触发飞书 Workflow → ntfy → n8n 再次调用同一脚本。固定路径只用一个触发入口,避免两套机制互相干扰。

  2. 消息去重:ntfy 消息自带时间戳,Code 节点可以通过记录已处理的消息 ID 实现去重逻辑。

可扩展的方向

这套架构的核心优势在于路由可扩展性——只需扩展 n8n 工作流的 IF 分支,就能接入更多 target:

  • 青龙面板target: "qinglong" 路由到 HTTP Request 节点调用 QL API
  • Home Assistanttarget: "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 的质变。