前言

本文介绍了基于开源自动化平台 n8n 搭建自动化流程,实现监控 RSS 更新并推送到飞书消息的功能。

文末会列举一些实现此工作流的其他方式,包括发送请求和接收提醒的手段。与此同时,n8n 还可以通过模块组合,实现更多更复杂的功能,本文只作为抛砖引玉。

阅读本文可能需要一定 Linux 基础知识。

n8n 是什么

n8n 是一个开源的自动化流程搭建工具,可以实现类似 IFTTT 的效果,比如「如果明天下雨,就推送要带伞的消息」。优点是开源、可以自己部署并将信息都储存在本地,同时可以与 Github、Telegram、Slack 等各种服务实现联动,以搭建自动化工作流。

利用 Docker 安裝 n8n

n8n 可以直接下载 Win 或是 Mac 版本,快速在本地使用,但如果想更稳定地长期运行,更适合部署在云服务器、树莓派或 NAS 等工具上。

这里以在云服务器上使用 Docker 进行部署为例,更多安装方式可参考 Installation guides for n8n

假设已经安装好了 Docker,那么 n8n 的部署就非常简单,先新建一个文件夹储存数据。

# 创建数据储存文件夹
mkdir ~/n8n-data

复制运行下面的代码,利用 Docker 安装 n8n。如果云服务器有防火墙,需要把对应的端口打开,这里需要打开云服务器的 TCP 端口 5678

# 利用Docker安装运行n8n
docker run -d \
--name n8n --restart unless-stopped \
-p 5678:5678 \
-v ~/n8n-data:/home/node/.n8n \
-e GENERIC_TIMEZONE="Asia/Shanghai" \
n8nio/n8n 

稍作等待,等 Docker 安装完成后,如果一切顺利,访问 服务器ip地址:5678 就能看到 n8n 的运行页面了,初次进入需要创建账号密码。

n8n 主界面

点击右上角的 New blank workflow 即可开始创建,也可以从软件提供的 Workflow 示例中,选择自己想部署的自动化流程。

这里以搭建一个 RSS 更新自动推送到飞书的机器人为例,展示 n8n 的一些使用方式。

搭建飞书 RSS 推送机器人

以下是我配置好的一个流程模板,复制以下内容粘贴到 n8n 新建 workflow 的页面。

{
  "nodes": [
    {
      "parameters": {
        "url": "https://sspai.com/feed"
      },
      "name": "RSS Feed Read",
      "type": "n8n-nodes-base.rssFeedRead",
      "typeVersion": 1,
      "position": [
        160.5,
        440
      ]
    },
    {
      "parameters": {
        "conditions": {
          "number": [
            {
              "value1": "={{new Date($node[\"Latest Read\"].data[\"latestRead\"]).getTime()}}",
              "value2": "={{new Date($node[\"RSS Feed Read\"].data[\"isoDate\"]).getTime()}}"
            }
          ],
          "boolean": [],
          "string": [
            {
              "value1": "={{$json[\"title\"]}}",
              "operation": "contains"
            }
          ]
        }
      },
      "name": "IF",
      "type": "n8n-nodes-base.if",
      "typeVersion": 1,
      "position": [
        560,
        440
      ]
    },
    {
      "parameters": {
        "functionCode": "const staticData = this.getWorkflowStaticData('global');\n\nif (items.length > 0) {\n  staticData.latestRead = items[0].json.isoDate || staticData.latestRead;\n}\n\n\nreturn items;"
      },
      "name": "Write Latest Read",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        760,
        340
      ]
    },
    {
      "parameters": {},
      "name": "NoOp",
      "type": "n8n-nodes-base.noOp",
      "typeVersion": 1,
      "position": [
        750,
        580
      ]
    },
    {
      "parameters": {
        "triggerTimes": {
          "item": [
            {
              "mode": "everyX",
              "value": 1
            }
          ]
        }
      },
      "name": "Cron",
      "type": "n8n-nodes-base.cron",
      "typeVersion": 1,
      "position": [
        -40,
        440
      ]
    },
    {
      "parameters": {
        "requestMethod": "POST",
        "options": {
          "batchInterval": 3000,
          "batchSize": 1
        },
        "bodyParametersUi": {
          "parameter": [
            {
              "name": "msg_type",
              "value": "interactive"
            },
            {
              "name": "card",
              "value": "={\n  \"config\": {\n    \"wide_screen_mode\": true\n  },\n  \"header\": {\n    \"template\": \"black\",\n    \"title\": {\n      \"content\": \"{{$json[\"title\"]}}\",\n      \"tag\": \"plain_text\"\n    }\n  },\n  \"elements\": [\n    {\n      \"tag\": \"div\",\n      \"text\": {\n        \"content\": \"{{$json[\"contentSnippet\"]}}\",\n        \"tag\": \"lark_md\"\n      }\n    },\n    {\n      \"tag\": \"hr\"\n    },\n    {\n      \"elements\": [\n        {\n          \"content\": \"[阅读原文]({{$json[\"link\"]}})\",\n          \"tag\": \"lark_md\"\n        }\n      ],\n      \"tag\": \"note\"\n    }\n  ]\n}"
            }
          ]
        },
        "headerParametersUi": {
          "parameter": [
            {
              "name": "Content-Type",
              "value": "application/json"
            }
          ]
        }
      },
      "name": "HTTP Request",
      "type": "n8n-nodes-base.httpRequest",
      "typeVersion": 1,
      "position": [
        1000,
        340
      ]
    },
    {
      "parameters": {
        "functionCode": "const staticData = this.getWorkflowStaticData('global');\n\nlatestRead = staticData.latestRead;\n\nfor (let item of items) {\n  item.json.latestRead = latestRead || '2022-05-05';\n  //item.json[\"content:encodedSnippet\"] = item.json[\"content:encodedSnippet\"].replace(/[\\r\\n]/g,\"\\\\n\");\n}\n\nreturn items;"
      },
      "name": "Latest Read",
      "type": "n8n-nodes-base.function",
      "typeVersion": 1,
      "position": [
        360,
        440
      ]
    }
  ],
  "connections": {
    "RSS Feed Read": {
      "main": [
        [
          {
            "node": "Latest Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "IF": {
      "main": [
        [
          {
            "node": "Write Latest Read",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "NoOp",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Write Latest Read": {
      "main": [
        [
          {
            "node": "HTTP Request",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Cron": {
      "main": [
        [
          {
            "node": "RSS Feed Read",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },
    "Latest Read": {
      "main": [
        [
          {
            "node": "IF",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

粘贴后可以看到如下的界面:

粘贴代码得到的 RSS 推送工作流

这里有几处可以配置,第一处是 Cron,设置自动化流程触发的频率,每隔 X 时间间隔运行一次,图中设置为每隔一小时运行。在获取 RSS 时,运行频率不宜过高。如果访问过于频繁,一方面会给对方服务器造成较大负担,同时可能被服务器禁止访问。

Cron 模块设置触发频率

第二个是在 RSS Feed Read 处,填写想订阅的 RSS 地址,这里以少数派 RSS 为例,填写完后点击Excute node,先运行一次获取数据,方便后续设置。

在 URL 处填写想订阅的 RSS 源

第三处(可选)IF 处,设置是否需要针对标题或内容等进行过滤,默认不过滤。

IF 模块进行条件判断

这时先转到飞书,在飞书桌面端,打开一个群(建议先创建一个单人的群进行调试),打开设置,找到群机器人,并点击添加机器人,选择自定义机器人加入群聊,详细操作可以参照 飞书自定义机器人指南

取得飞书自定义机器人 webhook 地址

最后在 HTTP Request 处填入飞书机器人 webhook 地址。

HTTP Request 模块发送 HTTP 请求

填写完成后 Excute node 尝试运行,一切顺利的话就能在飞书中看到推送来的RSS消息了。

机器人在飞书中推送了消息卡片

这里使用了卡片的形式展示消息,若是想调整消息展示样式,可以参考少数派文章 手把手教你用飞书 Webhook 打造一个消息推送 Bot

配置过程一图流

消息机器人安全设置

由于采用 Webhook 的形式,请务必保管好 Webhook 链接,如果泄露可能会导致被推送垃圾信息。为了进一步加道保险,飞书提供了三种安全设置方式,分别是自定义关键词IP 白名单签名校验

前两种方式非常好理解,也都很好设置。自定义关键词是只有当消息中至少含有一个预设的关键词时,才会进行消息推送;IP 白名单则是只推送名单中来源的 IP 所发送的请求。但是这两种方式也有一定的局限性:

  • 关键词有时使消息不够简洁
  • 部署在本地树莓派等设备上时,IP 地址不固定,无法指定
  • 关键词和 IP 白名单各自最多只能添加十个条目

因此这里详细介绍一下在 n8n 中进行签名校验的配置方式。飞书的签名需要将「timestamp + "\n" + 密钥」组合起来当作签名密钥,采用 Hmac SHA256 算法计算签名,再进行 Base64 编码。在发送消息请求时,需要增加对应的 timestampsign  字段。

// 开启签名验证后发送文本消息的请求示例
{
        "timestamp": "1599360473",
        "sign": "xxxxxxxxxxxxxxxxxxxxx",
        "msg_type": "text",
        "content": {
                "text": "The message content is here"
        }
}

在 n8n 中,可以使用 Crypto 模块利用密钥生成签名,复制以下代码粘贴到配置界面,可以得到生成飞书签名用的模块组合。

{
  "nodes": [
    {
      "parameters": {
        "action": "hmac",
        "type": "SHA256",
        "value": "={{''}}",
        "dataPropertyName": "sign",
        "secret": "={{$json[\"timestamp\"]+'\\n'+$json[\"secret\"]}}",
        "encoding": "base64"
      },
      "name": "Crypto",
      "type": "n8n-nodes-base.crypto",
      "typeVersion": 1,
      "position": [
        -80,
        440
      ]
    },
    {
      "parameters": {
        "values": {
          "string": [
            {
              "name": "timestamp",
              "value": "={{Math.round(new Date().getTime()/1000)}}"
            },
            {
              "name": "secret"
            }
          ]
        },
        "options": {}
      },
      "name": "Set",
      "type": "n8n-nodes-base.set",
      "typeVersion": 1,
      "position": [
        -280,
        440
      ]
    }
  ],
  "connections": {
    "Set": {
      "main": [
        [
          {
            "node": "Crypto",
            "type": "main",
            "index": 0
          }
        ]
      ]
    }
  }
}

将上面新增的两个模块按下图方式进行拖拽连接:

拖拽模块两端的点即可将模块进行连接

从飞书机器人设置界面中,勾选签名校验得到密钥,填写在 Set 模块中。

勾选签名校验后记得点保存

接下来将 Latest Read 模块中的代码替换为以下内容,储存计算出的签名,方便在请求的时候调用。

// JS code in the Latest Read Module
const staticData = this.getWorkflowStaticData('global');

latestRead = staticData.latestRead;

for (let item of items) {
  item.json.latestRead = latestRead || '2022-05-05';
  item.json.timestamp = $item("0").$node["Crypto"].json["timestamp"];
  item.json.sign = $item("0").$node["Crypto"].json["sign"];
}

return items;

最后在 HTTP Request 模块中增加校验用的字段:Body Parameters → Add Parameter,添加两个参数,Name 分别为 timestampsign,Value 处点击右侧 Add Expression,分别选择两个对应字段的值。

选择变量的时候可以逐级展开列表,找到目标字段

这样一番倒腾,给飞书机器人模块增加了签名校验,使得信息推送更加安全。当一切配置妥当后,别忘了点击界面右上角的激活,让工作流开始自动运行。

n8n 配置飞书密钥验证一图流

后记

本文介绍了如何用 n8n 打造一个飞书 RSS 推送机器人。订阅什么样的 RSS 来源,可以是网站自身提供的 RSS 地址,也可以利用 RSShub 将各种奇怪的网站转化为 RSS,甚至是利用 kill-the-newsletter 将任意 Newsletter 邮件转化为 RSS 进行追踪。

同时,实现类似工作流的手段还有很多。对于 n8n 这部分,可以使用 IFTTT、Integrately,或是 Github Action 等,实现工作流中「监控 RSS 更新并发送 Webhook 请求」这部分;对于接收提醒,文中利用了飞书作为展示消息的界面,而 n8n 也支持连接到 Telegram、Slack 等通讯软件,或是通过 Send Email 模块实现邮件通知,以及发送到 Cubox、flomo 等各种支持 Webhook 的工具中。

更多功能,更多组合,尽请探索,把闲置的云服务器或是积灰的树莓派等折腾起来吧。

参考资料