Folo + N8N + 云日历实现简易社媒热图

头图

简单讲,就是把小红书、抖音、微博、𝕏等等平台发布的动态展示到热图中,类似于 github contributions。点我直达。下面动图是日历 app 中的效果。

:google calendar
LIVE

❓起因

很久之前,因受到 contributions 颜值吸引,仿照开源项目做了一个活动展示组件。后端直接用Google Calendar API,除了国内不能直连外都是优点💯:多端增删改查,安全稳定,有 api key(仅限查询)和 OAuth 两种鉴权方式,可用字段多,免费🤩。

然而热乎劲儿只持续了月余,反思一下,主要问题是手动录入太繁琐✍️,体验不好。比如刚在 B 站刷到了宝藏视频,我第一个想法是马上分享到社交媒体账号,而不是打开谷歌日历新增一条活动。另外,选择活动时间也让人既纠结又头疼🤯。

手头一堆账号都在活跃使用,也渐渐摸出自己的选择标准:

  • 跟学术和科技相关的统统发到𝕏,因为跟现实世界没有交集,也符合马哥平台的气质。
  • :rednote:带娃日常、风景照、线下活动,衣食住行等等发到小红书,原因你懂的。
  • 偶尔兴致来了拍个短视频发到抖音
  • 其他杂七杂八、吃根冰棍、各种无病呻吟发到微博
  • :coolapk:玩机交流发到酷安
  • :rednote:NAS、PT、数码、好物等等发到什么值得买
  • :rednote::rednote:想要分享或者提问的内容发到这两个平台

当然,最重要的分享内容还是会放到博客。至于朋友圈,那肯定发工作相关的了。

⚙️流程

回归正题。首先准备一个 folo 账号,部署 RSShubN8N 并能正常访问。RSShub 是因为有些网站需要指定 cookies 之类的环境变量,或者官方渠道不稳定。经过尝试,什么值得买、酷安、抖音和小红书最好用自己的实例。在 folo 中完成目标账号的订阅,确保能正常更新,顺便还可以发个动态认证订阅源

接下来就是通过 N8N 把 folo、google 日历和 cloudflare worker 串联起来。参考官方文档,必要时借助Apifox调试接口。

folo 的设置比较简单,点侧栏头像👉自动化👉新规则。条件是所有需要聚合的订阅源所在的分类,动作是 n8n 的 webhook 地址。⚠️注意 1:payload 中的 feed title 还是平台提供的默认值,而非你自定义的订阅源名称。关于这个问题我也提交了 issue。⚠️注意 2:Webhook 需要填写 Production URL,而非 Test URL,后者只能手动接收一次网络请求。

n8n 这边略微复杂,因为源标题无法修改,需要加一层 Data transformation,并选择 Edit Fields。

Edit Fields

{{ (()=>{
let alt = new Map([["original title", "new title"], ["original title2", "new title2"]]);
return alt.get($json.body.feed.title) ?? $json.body.feed.title;
})() }}

接下来搜索并添加 google calendar 节点,选择 Create an event,去谷歌云后台根据引导配置好 credential。🚨这里有一个小坑,你需要在OAuth 权限请求页面下的目标对象中把你的谷歌账号添加到测试账户列表中,否则在 n8n 发起认证请求时会弹出 403 错误:禁止访问:“xxxx.com”尚未完成 Google 验证流程。

create event

填入各项,这里{{ $json.body.entry.publishedAt?.toDateTime() ?? $now }}添加了一个$now的 fallback 是为了方便用 apifox 手动发起无publishedAt字段的请求。比如 linuxdo 这类反爬措施太强以至拿不到 rss 的站。持续时间设定为 15 分钟🕰️,当然也可以根据不同的源灵活设定。ColorId 纯粹为了在日历 app 中更好看一些,代码对应的🎨网上很好查。

CF worker 和博客前端都是之前做好的,完全兼容,一个括号都不用改。这里贴一下关键代码:

worker.js
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
url.host='www.googleapis.com'
url.pathname += '/xxxxxxxxxxx@group.calendar.google.com/events'
url.searchParams.set('key', 'xxxxxxxxxxxx')
url.searchParams.set('timeMin', new Date(new Date().setFullYear(new Date().getFullYear() - 1)).toISOString())
return proxyJson(url.href, request)

async function proxyJson(url, request) {
let resp = await proxyRequest(url, request)
const jsonRaw = await resp.json();
const outJson = [];
if (!jsonRaw.items) return new Response(JSON.stringify(jsonRaw), options)
for (let item of jsonRaw.items) {
outJson.push({
type: item.summary,
desc: item.description,
time: item.start.dateTime,
dur: (new Date(item.end.dateTime) - new Date(item.start.dateTime)) / 1000 / 60
})
}
return new Response(JSON.stringify(outJson), options)
}

最后还是放个图吧,以备将来参考

截图

就在整理社媒账号时,偶然发现饭否还活着,只是转变成封闭小圈子。正好作为我的私密树洞🌳,哈哈。