做一个 RSS 推送 bot

2.3k 4 mins
... ...

  上篇提到的 0 代码基础却做了 discord 推送 bot 的朋友非常慷慨地分享了她和 gemini 的聊天记录,于是我也有自己的推送 bot 了!效果如图:
  顺便因为想放封面重写了首页的 css 样式,这下舒服了(舒服了)

  使用的工具是 pipedream,此外 hexo 和 write.as 的 RSS 格式是不一样的,所以代码也相应做了一些修改。

Discord 部分

  首先在 discord 服务器中新建一个 webhook,位置在服务器设置→APP→整合中,头像和名称都可以随便写因为后续的信息推送可以覆盖,重点是记得把所属频道改成测试频道,不然会喜提管理员毒打【
  完成之后先放着备用,注册 pipedream 后新建一个 workflow,进入正式的创建流程。

Pipedream 部分

  注册 pipedream 后似乎会自动新建一个 workflow,如果不小心关掉了好像需要先新建一个 project 才可以新建 workflow,我已经不太记得了但是这部分不重要,创建的时候除了名字以外其它的都用默认选项就可以。

获取 RSS 订阅

img-right

  在 workflow 页面点击 Add Trigger,在弹出菜单中搜索 RSS,添加 New Item in RSS Feed
  建议将 Timer 设置为 5 分钟,这是免费用户的最快更新频率。然后填入你的 rss 订阅地址,pipedream 会生成一些测试用的 events。
  需要注意的是这个 events 似乎是从 rss 文件中最早的文件开始抓的,所以有一定概率捕获不到最新的文章,但是这个只是影响测试不影响正式运行,问题不大。
  选中测试文件后在 Exports 选项卡中会出现右图的信息,这就是从 RSS 中获取的数据,点击最下面的 more 可以找到更多。
  Hexo 的 rss 不知道为什么有很多重复的信息,可以手动挑选目录结构最简单的值采用。

编辑推送消息

img-right

  在成功读取 rss 订阅后我们就可以做一个最基础的 webhook 推送了。点击 Trigger 节点下的加号,搜索 webhook,新建一个 Send any HTTP Request 节点。
  回到 discord 页面复制 webhook 链接,然后粘贴到图中的位置,记得将链接前的选项改为 POST
  然后切换至 Body 选项卡,点击右下角的蓝字 Edit Raw JSON,按照图中的格式输入代码创建推送卡片。
  代码框中的浅蓝色部分可以替换成 RSS Trigger 中获得的任意信息,鼠标移动到相应值上后点击 Copy Path 再粘贴过来即可。
  懒人可以直接复制下面的代码(Hexo 限定),只要改动一下头像链接即可:

1
2
3
4
5
6
7
8
9
10
11
12
{
"username": "{{steps.trigger.event.meta.author}}",
"avatar_url": "头像链接",
"embeds": [{
"title": "{{steps.trigger.event.title}}",
"url": "{{steps.trigger.event.link}}",
"footer": {
"text": "{{steps.trigger.event.meta.title}}"
},
"timestamp": "{{steps.trigger.event.meta.pubdate}}"
}]
}

  此时点击 Test 按钮,discord 频道中就会出现一个基础的推送卡片了,推送 bot 的头像为 blog 头像,bot 名为作者名,然后包含文章的标题和发布时间。
  如果想要显示文章摘要,则需要在 Trigger 和 Webhook 之间添加一个代码节点。

处理摘要

  在处理摘要前首先要有两个基本认知:

  1. Discord 卡片无法显示 html 标签
    所以需要将 RSS 中的 html 标签转换回 markdown
  2. Webhook 推送的字符串里不能包含未转译的换行符
    简而言之输出中需要用\n代替换行

  点击 Trigger 和 Webhook 中间的加号,搜索 node,选择 Run Node code,然后直接复制以下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import { axios } from '@pipedream/platform';

export default defineComponent({
async run({ steps, $ }) {

const rssItem = steps.trigger.event;
let htmlSummary = rssItem.summary;
// 获取 rss 中的文章摘要

let cleanedSummary = htmlSummary;

cleanedSummary = cleanedSummary.replace(/<br\s*\/?\s*>/gi, '\\n');
// 处理换行符
cleanedSummary = cleanedSummary.replace(/<del\s*>/gi, '~~');
cleanedSummary = cleanedSummary.replace(/<\/del\s*>/gi, '~~');
cleanedSummary = cleanedSummary.replace(/<strong\s*>/gi, '**');
cleanedSummary = cleanedSummary.replace(/<\/strong\s*>/gi, '**');
cleanedSummary = cleanedSummary.replace(/<code\s*>/gi, '\`');
cleanedSummary = cleanedSummary.replace(/<\/code\s*>/gi, '\`');
// 处理 markdown
cleanedSummary = cleanedSummary.replace(/<\/p\s*>/gi, '');
cleanedSummary = cleanedSummary.replace(/<p>(\s*)/gi, '');
// 直接移除 <p> 标签,也可以视情况转换为换行符 `\\n`

cleanedSummary = cleanedSummary.replace(/<[^>]*>/g, '');
// 移除其它未处理的 html 标签
cleanedSummary = cleanedSummary.replace(/\n{3,}/g, '');
// 移除三个及以上的连续换行符
cleanedSummary = cleanedSummary.trim();
// 移除开头和结尾的空白字符

return {
...rssItem,
description_cleaned: cleanedSummary
};

},
});

  这部分代码大部分是 gemini 生成的,我手工加了注释和更多的 markdown 格式,移除了一些多余的部分。需要注意的是替换的换行符不能直接用 \n(gemini 会直接生成给你的版本)而是要使用 \\n,不然在将返回的值填入 webhook 推送的时候就会生成带换行的字符串,从而导致推送失败
  复制完代码后点击 Test 测试,确认没有问题之后在推送消息后将文章摘要添加上去即可:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
"username": "{{steps.trigger.event.meta.author}}",
"avatar_url": "头像链接",
"embeds": [{
"title": "{{steps.trigger.event.title}}",
"url": "{{steps.trigger.event.link}}",

"description": "{{steps.code.$return_value.description_cleaned}}",

"footer": {
"text": "{{steps.trigger.event.meta.title}}"
},
"timestamp": "{{steps.trigger.event.meta.pubdate}}"
}]
}

推送卡片美化

  饿了,晚上回来再写【真的会回来吗
  主要是目前这个卡片还有一些功能没捣鼓出来等我全搞完了再说……

修改卡片颜色

  在推送信息的 footer 上方新建一行可以定义推送卡片的边框颜色:

1
"color":15658734,

  讲实话我到现在都还没搞清楚这个颜色到底是个什么格式,但总之用任意方法把颜色发给 gemini 让他帮你转写就好,也不用非得搞懂这个【

添加文章标签

  已完成,回来再写居然真的回来了!
  观察 RSS 输出可得分类和标签存储在同一个数组中,凑合一下直接用也行,但是我的推送格式是把分类和标题放在了一起,所以要把分类过滤掉留下单独的标签列表。
  具体代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
let categoryTerms = rssItem.categories;
let tagString = "";

if (categoryTerms.length > 1) {
const remainingCategories = categoryTerms.slice(1);
tagString = remainingCategories
.map(term => '`#' + term + '`')
// 将标签内容转写成行内代码框
.join(' ');
// 在两个标签中添加空格
} else {
tagString = "";
}

  把处理完毕的 tagString 添加到 return 中,前往推送设置里调用即可。

添加文章封面

  未完成,也不知道能不能实现总之先新建文件夹做出来了,但效果不太理想,discord 的推送卡片好像没有封面这个功能,只能在描述里添加图片。
  总之不知道为什么我的 Hexo RSS 既没输出网站 icon 也不输出封面图,只能从文章简介部分获取第一个图片标签,代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
import * as cheerio from 'cheerio';
// 这个要添加到最开头
...
let imageUrl = "";
try {
const $ = cheerio.load(htmlSummary);
const firstImage = $('img').first();
if (firstImage.length > 0) {
imageUrl = firstImage.attr('src');
}
} catch (error) {
console.error("Error parsing HTML with cheerio:", error);
}

  然后还是把 imageUrl 添加到 return 中,这个时候会发现获取的 imgUrl 是相对地址,可以手动输入字符串拼接出完整的图片链接:

1
2
3
4
5
6
return {
...rssItem,
description_cleaned: cleanedSummary,
cover: "https://..." + imageUrl
};

  也可以在 RSS 里找到输出网站主页的链接进行拼接,但 RSS 里的主页链接一般是以斜杠结尾的,belike https://rei.eterfinal.ink/,而我的所有图片都是放在同一个目录下用绝对路径引用的,belike /img/250517-rss/cover.avif,这个时候把两个字符串直接拼在一起就会发现中间多了一个斜杠,变成了:

1
2
cover:
https://rei.eterfinal.ink//img/250517-rss/cover.avif

  为了解决这个问题需要去掉 imageUrl 开头的斜线,修改后的 return 部分代码如下:

1
2
3
4
5
return {
...rssItem,
description_cleaned: cleanedSummary,
cover: rssItem.meta.link + imageUrl.replace(/^\/+/, '')
};

  5.19 更新|直接在 return 值里拼接图片链接会导致没有封面时输出一个 src 为 blog 主页链接且显示为一坨答辩的空图片,正确的做法应该在前面的 if{} 条件里拼接图片链接,如果找不到图片则继续输出空字符。

后记

  嗯这里本来有一些碎碎念但是被我挪到单独的一篇里去了所以就这样吧 x