注:本文首发于少数派,您可以 前往此处 阅读修订后版本。

前言

本文介绍了基于 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。如果云服务器有防火墙,需要把对应的端口打开,这里需要打开5678的 TCP 端口。

# 利用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 的运行页面了,初次进入需要创建账号密码。

Untitled

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

这里以搭建一个 RSS 更新自动推送到飞书的机器人为例。

搭建飞书 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
          }
        ]
      ]
    }
  }
}

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

Untitled

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

Untitled

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

Untitled

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

Untitled

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

Untitled

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

Untitled

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

Untitled

这里使用了卡片的形式展示消息,若是想调整消息展示样式,可以参考少数派文章 手把手教你用飞书 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
          }
        ]
      ]
    }
  }
}

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

Untitled

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

Untitled

接下来将 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,再分别点击选择传入的两个对应字段的值。

Untitled

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

配置密钥验证一图流

配置密钥验证一图流

后记

本文介绍了如何用 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 的工具中。

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