博客 ActivityPub 互动
长毛象兼职博客评论系统
这里是为博客增加支持 Mastodon 的第二篇文章。上一篇文章 长毛象 Mastodon / GoToSocial 做博客评论系统 所说,需要手动创建然后写入静态博客配置,本文进行了一些优化。
脚本处理
持久化存储一个博客 URL 和嘟嘟 URL 对应关系,如
[
{
"key": [
"stMap",
"/posts/2024-02-15-wkd/"
],
"value": {
"id": "111958184267524743",
"uri": "https://mastodon.social/users/kkbt/statuses/111958184267524743"
},
"versionstamp": "01000000007026f00000"
}
]
这个 json 文件保存到 Hugo 博客目录 assets/data/toot_map.json ,然后在评论区模板读取就行了。
本文使用 Shortcode 演示一下,因为 Hugo Shortcode 可以使用模板语法。
测试示例本文 {{ .Page.RelPermalink }}
<br>
{{- $toot_map := resources.Get "data/toot_map.json" | transform.Unmarshal -}}
<span>读取固定值 /posts/2024-02-15-wkd/ </span>
{{ $found := false }}
{{ range $item := $toot_map }}
{{ $key := index $item "key" }}
{{ $value := index $item "value" }}
{{ if eq (index $key 1) "/posts/2024-02-15-wkd/" }}
URI: {{ index $value "uri" }}
{{ $found = true }}
{{ end }}
{{ end }}
{{ if not $found }}
没找到 /posts/2024-02-15-wkd/
{{ end }}
<hr/>
<span>读取固定值页面 slug </span>
{{ $found := false }}
{{ range $item := $toot_map }}
{{ $key := index $item "key" }}
{{ $value := index $item "value" }}
{{ if eq (index $key 1) .Page.RelPermalink }}
URI: {{ index $value "uri" }}
{{ $found = true }}
{{ end }}
{{ end }}
{{ if not $found }}
没找到 {{ .Page.RelPermalink }}
{{ end }}
<hr/>
实际渲染效果:
测试示例本文 /posts/2024-02-20-blog-activity/读取固定值 /posts/2024-02-15-wkd/ URI: https://mastodon.social/users/kkbt/statuses/111958184267524743
读取固定值页面 slug 没找到 /posts/2024-02-20-blog-activity/
然后就是 JS 读取 Mastodon API /api/v1/statuses/${id}/context
获取回复内容了。
那么问题来了,怎么生成这个对应关系 JSON 呢?
All problems in computer science can be solved by another level of indirection – by David Wheeler
计算机领域的任何问题都可以通过增加一个间接的中间层来解决
我的解决方案稍微麻烦一些。使用的是 Deno ,增加了一个中间层解决,写了个提供博客链接-嘟嘟链接对应关系查询,嘟嘟发布的一个服务。对于访客来说,嘟嘟链接都是在 HTML 中写明的。没有的则会查询服务器或云函数找对应的嘟嘟链接。
- 配置 Hugo 生成 activity_data.json 。内容就是为 sitemap
- 运行 Github Action / Gitee Go 时,调用 Hugo 生成 activity_data.json 文件。
- 静态博客生成并上传部署完成后,Github Action 流程最后。curl POST 一个 API ,body 就是 activity_data.json
- 服务器或云函数接受获取 activity_data.json 。把最近一小时,或者最新的几个文章链接找对应嘟嘟链接,没有的创建嘟嘟,保存嘟嘟链接到 KVDB 。(不支持 KVDB 的云函数,可用 Github 私有库读写 JSON 方法保存数据)
- 用户浏览器访问博客,如果 HTML 文件没有对应嘟嘟链接。发送请求查询服务器或云函数对应关系。
- 静态博客网站维护者,定期 wget 一下服务器或云函数所有对应关系 toot_map.json 。方便 Hugo 构建博客时把博文对应嘟嘟链接写入生成的 HTML 中。
在上面步骤可以看到,不使用服务器/云函数大概也行。这些步骤可以手动运行,自动化则是放在 Github Action ,在上传部署完成静态博客文件后,运行脚本处理。
- 配置 Hugo 生成 activity_data.json
- 运行程序读取对应关系 toot_map.json
- 找不到的创建嘟嘟,获取嘟嘟链接,更新对应关系 toot_map.json
- Hugo 通过对应关系把链接写入 HTML
如果把运行程序放在 Github Action ,可以发 HTTP 把对应关系 JSON 写入自己的博客库,data 文件夹。这还是比较方便的。
activity_data.json 我是通过 Hugo 生成的,其实使用 RSS 文件效果一样的。
生成方法:
layouts/index.activity_data.json
{{- $pctx := . -}}
{{- if .IsHome -}}{{ $pctx = .Site }}{{- end -}}
{{- $pages := slice -}}
{{- if or $.IsHome $.IsSection -}}
{{- $pages = $pctx.RegularPages -}}
{{- else -}}
{{- $pages = $pctx.Pages -}}
{{- end -}}
{{- $limit := .Site.Config.Services.RSS.Limit -}}
{{- if ge $limit 1 -}}
{{- $pages = $pages | first $limit -}}
{{- end -}}
{
{{ $all := where $pages ".Draft" "!=" true }}
"totalItems": {{(len $all)}},
"orderedItems": [
{{- range $index, $element := $all -}}
{{- if ne $index 0 }},{{ end }}
{{- $summary := .Summary -}}
{{- $summary := replace $summary "<p>" "" -}}
{{- $summary := replace $summary "</p>" "" -}}
{{- $md5Hash := md5 .RelPermalink -}}
{
"id": {{ substr $md5Hash 0 16 | jsonify}},
"slug": {{ .RelPermalink | jsonify}},
"url": {{ .Permalink | jsonify }},
"title": {{ .Title | jsonify }},
"summary": {{ $summary | jsonify }},
"published": {{ dateFormat "2006-01-02T15:04:05-07:00" .Date | jsonify }}
}
{{end}}
]
}
config.toml 增加输出 outputFormats.ACTIVITY_DATA
[outputFormats.ACTIVITY_DATA]
mediaType = "application/json"
notAlternative = true
baseName = "activity_data"
[outputs]
home = ["HTML", "RSS", "JSON", "ACTIVITY_DATA"]
page = ["HTML", "MarkDown"]
section = ["HTML", "RSS"]
taxonomy = ["HTML", "RSS"]
taxonomyTerm = ["HTML"]
生成的效果就是 public/outbox_data.json
{
"totalItems": 2,
"orderedItems": [
{
"id": "4303a6ae8505b4b8",
"slug": "/posts/2024-02-20-blog-activity/",
"url": "http://localhost:1313/posts/2024-02-20-blog-activity/",
"title": "博客 ActivityPub 互动",
"summary": "这里是为博客增加支持 Mastodon 的第二篇文章。上一篇文章 <a href=\"/posts/2023-08-14-mastodon-comments/\" rel=\"\">长毛象 Mastodon / GoToSocial 做博客评论系统</a> 所说,需要手动创建然后写入静态博客配置,本文进行了一些优化。",
"published": "2024-02-20T11:38:39+08:00"
},
{
"id": "0792686290a19214",
"slug": "/posts/2024-02-15-wkd/",
"url": "http://localhost:1313/posts/2024-02-15-wkd/",
"title": "GPG Web Key Directory",
"summary": "关于 WKD 电子邮件的映射本地部分 32 位字符串计算方法",
"published": "2024-02-15T15:51:55+08:00"
}
]
}
云函数 Deno Deploy 代码
// config
const instanceUrl = "https://mastodon.social";
const accessToken = "xxxxxxxxxxx-xxxxxxxxxx";
const user = "kkbt";
const postItemsAuthHeader = "Bearer xxxxxxxxx"; // Authorization header value
interface AllItems {
totalItems: string;
orderedItems: Item[];
}
interface Item {
id: string;
slug: string;
url: string;
title: string;
summary: string;
published: string;
}
interface MastodonToot {
id: string;
uri: string;
}
function filterAllItems(allItems: AllItems): Item[] {
let result: Item[] = [];
// 计算时间差 1h 内
for (const item of allItems.orderedItems) {
const publishDate = new Date(item.published);
const timeDiff = new Date().getTime() - publishDate.getTime();
const hoursDiff = timeDiff / (1000 * 60 * 60); // 转换为小时
if (hoursDiff <= 10 * 24) {
result.push(item);
}
}
return result;
}
async function postToot(item: Item) {
const requestOptions = {
method: "POST",
headers: {
Authorization: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
body: JSON.stringify({
status: `Blog ${item.title}\n${item.summary}\n${item.url}`,
visibility: "unlisted",
}),
};
try {
const response = await fetch(
`${instanceUrl}/api/v1/statuses`,
requestOptions
);
const responseData = await response.json();
const toot: MastodonToot = {
id: responseData.id,
uri: responseData.uri,
};
console.log("Toot posted successfully:", toot);
const kv = await Deno.openKv();
const result = await kv.set(["stMap", item.slug], toot);
console.log(result);
} catch (error) {
console.error("Error posting toot:", error);
}
}
async function getToots(id: string): Promise<any> {
console.log("Get",id);
try {
const response = await fetch(
`${instanceUrl}/api/v1/statuses/${id}`
);
if (!response.ok) {
console.log(response);
throw new Error("Network response was not ok.");
}
const jsonData = await response.json();
return jsonData;
} catch (error) {
console.error("There was a problem with the fetch operation:", error);
throw error;
}
}
const handler = async (req: Request): Promise<Response> => {
const url = new URL(req.url);
const path = url.pathname;
if (path == "/json") {
if (req.headers.get("Authorization") != postItemsAuthHeader) {
return new Response("401", { status: 401 });
}
const allItems: AllItems = await req.json();
const filteredItems = filterAllItems(allItems);
let postNew: Item[] = [];
for (const item of filteredItems) {
const kv = await Deno.openKv();
const value = await kv.get(["stMap", item.slug]);
console.log("Find ", value);
if (value.value == null || value.value == undefined) {
postNew.push(item);
console.log("Post New Toot for slug", item.slug);
await postToot(item);
}
}
return new Response(JSON.stringify(postNew), {
headers: {
"content-type": "application/json",
},
});
} else if (path == "/list") {
const kv = await Deno.openKv();
const entries = kv.list({ prefix: ["stMap"] });
let result: any = [];
for await (const entry of entries) {
console.log(entry.key, entry.value);
result.push(entry);
}
return new Response(JSON.stringify(result), {
headers: {
"content-type": "application/json",
},
});
} else if (path.startsWith("/map")) {
console.log("Search", url.searchParams.get("key"));
if (!url.searchParams.get("key")) {
return new Response("404", { status: 404 });
}
const kv = await Deno.openKv();
const value = await kv.get(["stMap", url.searchParams.get("key")]);
return new Response(JSON.stringify(value), {
headers: {
"content-type": "application/json",
},
});
} else if (path.startsWith("/del")) {
console.log("Delete", url.searchParams.get("key"));
if (req.headers.get("Authorization") != postItemsAuthHeader) {
return new Response("401", { status: 401 });
}
if (!url.searchParams.get("key")) {
return new Response("404", { status: 404 });
}
const kv = await Deno.openKv();
const result = await kv.delete(["stMap", url.searchParams.get("key")]);
return new Response(JSON.stringify(result), {
headers: {
"content-type": "application/json",
},
});
} else if (path.startsWith("/toot")) {
console.log("Get", url.searchParams.get("key"));
if (url.searchParams.get("key")) {
const response = await getToots(url.searchParams.get("key")||"");
return new Response(JSON.stringify(response), {
headers: {
"content-type": "application/json",
},
})
} else {
return new Response("404", { status: 404 });
}
} else {
return new Response("404", { status: 404 });
}
return new Response("404", { status: 404 });
};
console.log(`HTTP server running. Access it at: http://localhost:1024/`);
Deno.serve({ port: 1024, hostname: "0.0.0.0", handler });
博客实现 ActivityPub
静态博客还有一些方法加入联邦评论,那就是使用 Vercel 等平台提供的云函数,动静结合,部分路径交给云函数提供,实现 ActivityPub 协议。
毕竟如果使用纯静态博客,仅仅支持 GET 可以处理 WebFinger,Actor 等 ,但是没法处理 Inbox 。也就是说 Mastodon 能搜索到文章,但是无法评论。
简单介绍下 Mastodon 使用的 ActivityPub 协议,基本由下面几个端点组成
- WebFinger 用户发现,提供 actor url
- Actor ActivityPub 参与成员,提供用户信息,Outbox、Inbox、Followers、加密公钥等。
- Outbox 对外的消息列表,也就是用户嘟嘟列表
- Inbox 处理各种事件的 WebHook,如嘟嘟点赞,回复,取消点赞,删除回复等等。(需要接口的幂等)
- Followers 关注者列表
WebFinger 外的多数端口 Mastodon 请求都会带 “Accept: application/activity+json” ,返回响应也需要返回 “Content-Type: application/activity+json” 。
相关资料
- Adding ActivityPub to your static site
- ActivityPub 协议的简单实现
- 静态博客也能和Mastodon沟通
- How to implement a basic ActivityPub server
- How to make friends and verify requests
再有就是构建一个兼容 ActivityPub 的博客,比如类似写意。虽然写意不知道为什么,虽然可以关注,转发和评论博文的介绍嘟嘟。但是都不会显示在博客页面上。ActivityPub 协议的简单实现 这个网站就是这么做的。
不过这就是比较高级的操作了,像是 静态博客也能和Mastodon沟通 这个站点,实现的 sinofp/lesspub - A Serverless ActivityPub for static blogs 算是比较容易上手的。
我也尝试用 ts 写了一个,ActivityPub 支持的也不完善。到头来倒是觉得不如用 Mastodon/GoToSocial 等现成的。毕竟如果搞得很复杂,维护起来并不是轻松的事情。
Data has shown that 99% of use cases for all developer tooling are building unnecessarily complex personal blogs. Just kidding. But seriously, if you are trying to build a blog for personal or small business use, consider just using normal html and css. You definitely do not need to be using a heavy full-stack javascript framework to make a simple blog. You’ll thank yourself later when you return to make an update in a couple years and there haven’t been 10 breaking releases to all of your dependencies.
From https://github.com/hashicorp/next-mdx-remote#how-can-i-build-a-blog-with-this
总结
最后,我还是没有使用 Mastodon 这个评论方案。最近不少 Mastodon 充满了 spam 也增加了一些顾虑,以及本身也有一些缺点,比如不能在博客上直接评论,恶意评论删除麻烦等。毕竟是兼职评论,倒也无法苛求太多。
欢迎赞赏~
赞赏