博客 ActivityPub 互动

长毛象兼职博客评论系统

警告
本文最后更新于 2024-02-24,文中内容可能已过时。

这里是为博客增加支持 Mastodon 的第二篇文章。上一篇文章 长毛象 Mastodon / GoToSocial 做博客评论系统 所说,需要手动创建然后写入静态博客配置,本文进行了一些优化。

持久化存储一个博客 URL 和嘟嘟 URL 对应关系,如

json

[
    {
        "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 可以使用模板语法。

html

测试示例本文 {{ .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 中写明的。没有的则会查询服务器或云函数找对应的嘟嘟链接。

  1. 配置 Hugo 生成 activity_data.json 。内容就是为 sitemap
  2. 运行 Github Action / Gitee Go 时,调用 Hugo 生成 activity_data.json 文件。
  3. 静态博客生成并上传部署完成后,Github Action 流程最后。curl POST 一个 API ,body 就是 activity_data.json
  4. 服务器或云函数接受获取 activity_data.json 。把最近一小时,或者最新的几个文章链接找对应嘟嘟链接,没有的创建嘟嘟,保存嘟嘟链接到 KVDB 。(不支持 KVDB 的云函数,可用 Github 私有库读写 JSON 方法保存数据)
  5. 用户浏览器访问博客,如果 HTML 文件没有对应嘟嘟链接。发送请求查询服务器或云函数对应关系。
  6. 静态博客网站维护者,定期 wget 一下服务器或云函数所有对应关系 toot_map.json 。方便 Hugo 构建博客时把博文对应嘟嘟链接写入生成的 HTML 中。

在上面步骤可以看到,不使用服务器/云函数大概也行。这些步骤可以手动运行,自动化则是放在 Github Action ,在上传部署完成静态博客文件后,运行脚本处理。

  1. 配置 Hugo 生成 activity_data.json
  2. 运行程序读取对应关系 toot_map.json
  3. 找不到的创建嘟嘟,获取嘟嘟链接,更新对应关系 toot_map.json
  4. Hugo 通过对应关系把链接写入 HTML

如果把运行程序放在 Github Action ,可以发 HTTP 把对应关系 JSON 写入自己的博客库,data 文件夹。这还是比较方便的。

activity_data.json 我是通过 Hugo 生成的,其实使用 RSS 文件效果一样的。

生成方法:

layouts/index.activity_data.json

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

toml

[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

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 代码

ts

// 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 });

静态博客还有一些方法加入联邦评论,那就是使用 Vercel 等平台提供的云函数,动静结合,部分路径交给云函数提供,实现 ActivityPub 协议。
毕竟如果使用纯静态博客,仅仅支持 GET 可以处理 WebFinger,Actor 等 ,但是没法处理 Inbox 。也就是说 Mastodon 能搜索到文章,但是无法评论。

简单介绍下 Mastodon 使用的 ActivityPub 协议,基本由下面几个端点组成

  1. WebFinger 用户发现,提供 actor url
  2. Actor ActivityPub 参与成员,提供用户信息,Outbox、Inbox、Followers、加密公钥等。
  3. Outbox 对外的消息列表,也就是用户嘟嘟列表
  4. Inbox 处理各种事件的 WebHook,如嘟嘟点赞,回复,取消点赞,删除回复等等。(需要接口的幂等)
  5. Followers 关注者列表

WebFinger 外的多数端口 Mastodon 请求都会带 “Accept: application/activity+json” ,返回响应也需要返回 “Content-Type: application/activity+json” 。

再有就是构建一个兼容 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 也增加了一些顾虑,以及本身也有一些缺点,比如不能在博客上直接评论,恶意评论删除麻烦等。毕竟是兼职评论,倒也无法苛求太多。