本文首发于我的博客,原文链接《Hexo 博客接入 Fediverse —— Hatsu + Vercel 踩坑记》
版权声明请参阅原文。前往博客提高阅读体验。
终于想起了我还有奶昔账号……
Fediverse 这个东西,我个人是真的非常喜欢。在之前的杂谈里面,曾经提到过我到底是怎么接触到这个去中心化社交媒体的。
其实给博客接 Fedi 的想法已经由来已久了。几个月前,找到了 Hatsu 这个非常厉害的工具,于是接入了 Fediverse,每次博客更新的时候,Hatsu 都会自动把 Feed 里面的内容转换为 Fediverse 贴文。不过,在之前因为对文档的忽视和「能用就行」的想法,导致我只是部署了 Hatsu 后端,实现了博客 -> Fedi 的单向转换,有两个重要的问题没有解决:
- Fedi 收到评论后,怎么把评论显示回来博客?
- 读者知道一篇文章的 URL,怎么找到这个 URL 对应的 Fedi 帖子?由于去中心化的特点,在本地查看远程实例信息会出现帖子的遗漏,除非直接输入远程实例 URL 。但是 Hatsu 贴文的 URL 和博客文章的不是一个,它的格式是
https://feed.clanna.dev/posts/${url} 后面的 URL 是文章的原始 URL。也就是说,直接查询博文的原始 URL 会直接失败
所以这篇文章就来聊聊到底怎么实现双向互通。从 Hatsu 部署开始,到彻底互通,来说说一路上的坑。
在开始之前,先来说说我这套架构是什么:
- 博客是部署在 Vercel 上的静态博客,接入的是 github 仓库,当我们推送更新时,vercel 拉取并执行 npm 命令,生成静态网站。
- Hatsu 部署在 VPS 上,作为 Fedi 后端。上面注册了一个机器人用户,对应的就是博客的 Feed。
此外,如果你也想复刻这套方案,我强烈建议你先读一遍 Hatsu 官方文档,再来看跟着操作,否则……你懂得,出问题了别找我。
Feed 准备
在一切开始之前,你得确保你的博客有 RSS,并且 RSS 可被自动发现。
也就是,html head 里面要有 link 指向你的 feed,比如下面。
注:大部分情况下都有,hexo rss 插件会帮你处理好这个。如果你的博客没有,请检查配置。
<head>
<link rel="alternate" type="application/feed+json" href="https://example.com/feed.json" />
<link rel="alternate" type="application/atom+xml" href="https://example.com/atom.xml" />
<link rel="alternate" type="application/rss+xml" href="https://example.com/rss.xml" />
</head>
Hatsu 部署
准备好 RSS 之后,首先,咱们来讲讲 hatsu 的部署。
和官方文档不同,准备工作后面再讲,先讲部署,因为部署不是今天的重点。
打开配置示例,在你的 VPS 上创建一个文件夹来存储内容,然后创建 yml 的 compose。这个不多说,如果你不知道 docker,我建议你读一下之前的看番教程系列,详细介绍了如何用 docker。
# 这是写作本文时的版本。请点击上面的「配置示例」查看最新版本,切勿照抄!!!
version: "3"
services:
hatsu:
container_name: hatsu
image: ghcr.io/importantimport/hatsu:nightly
restart: unless-stopped
ports:
- 3939:3939
# env_file:
# - .env
environment:
- HATSU_DATABASE_URL=sqlite://hatsu.sqlite3
- HATSU_DOMAIN=hatsu.example.com
- HATSU_LISTEN_HOST=0.0.0.0
- HATSU_PRIMARY_ACCOUNT=blog.example.com
volumes:
# - ./.env:/app/.env
- ./hatsu.sqlite3:/app/hatsu.sqlite3
要改的不多,就两处:
HATSU_DOMAIN=hatsu.example.com 改成你的域名,这是 hatsu 所在的域名,是一个独立的域用于 fedi 交换
HATSU_PRIMARY_ACCOUNT=blog.example.com 改成你的博客域名
此外,你还需要生成 access token。
echo "\nHATSU_ACCESS_TOKEN = \"$(cat /proc/sys/kernel/random/uuid)\"" >> .env
然后取消上面配置文件中的注释加载环境变量。
现在启动 docker:
docker compose up -d && docker compose logs -f
不出意外的话就出意外了容器就起来了。
然后创建用户(记得改变量):
NAME="example.com" curl -X POST "http://localhost:$(echo $HATSU_LISTEN_PORT)/api/v0/admin/create-account?name=$(echo $NAME)&token=$(echo $HATSU_ACCESS_TOKEN)"
然后,用你各种手段,无论是 nginx 还是 caddy 还是 cf 的 tunnel,把 3939 端口反代出去到你的域名。这个不多说了,我相信看这篇文章的不是来学这个的。
现在后端 Fedi 部分已经部署完毕,接下来我们来看看前端吧~
重定向跳转
根据文档,设置自定义跳转内容。
让用户名可搜索
这个最简单,文档里都有现成的内容可以用,我给翻译到中文。
在你的博客项目根目录下,创建 vercel.json,填入下面内容。记得把 hatsu.local 改成你的 hatsu 域名!
{
"redirects": [
{
"source": "/.well-known/host-meta",
"destination": "https://hatsu.local/.well-known/host-meta"
},
{
"source": "/.well-known/host-meta.json",
"destination": "https://hatsu.local/.well-known/host-meta.json"
},
{
"source": "/.well-known/nodeinfo",
"destination": "https://hatsu.local/.well-known/nodeinfo"
},
{
"source": "/.well-known/webfinger",
"destination": "https://hatsu.local/.well-known/webfinger"
}
]
}
提交,等待 vercel 部署。
现在在你的 fedi 软件上搜索账号 @blog.samhou.moe@feed.clanna.dev 应该就能请求成功了(这个示例是我的博客的 hatsu 用户,如果你搜索的话应该可以看到博客的简介)。因为上面创建了重定向,也可以直接查询 @catch-all@blog.samhou.moe,得到的账号是一样的~
AS2 Alternate
细心的读者肯定发现了,我给出来的 hatsu 原版文档里面,可不止有用户名重定向,还有个叫 AS2 的重定向。
是的我几个月前没看到,搭建了个残废的 hatsu……
Redirects file only applies to .well-known. for AS2 redirects, you need to use AS2 Alternate.
点开,你会发现,你需要在你的博客文章的 head 中注入更多内容:
<link rel="alternate" type="application/activity+json" href="https://hatsu.local/posts/https://example.com/foo/bar" />
hmmm,看起来这个就有点难了,要按照 url,给每个页面增加独立的 href。对于接入 fedi 这种小众的事情,hexo 不可能原版有,你的主题也不一定有。
所以,我们来{魔改主题源码|鞭打LLM黑奴}吧!我的 hexo 主题用的是 hexo-theme-butterfly,于是 fork 一份到自己这里,然后用 subtree 集成到我的博客里面进行开发。
首先我们找一找就不难发现,处理 head 的内容在 themes/butterfly/layout/includes/head.pug。简单搜索一下就知道,这个语法是 jade,一种用于生成 html 内容的模板。
那就好办了,直接加上 include:
include ./head/fediverse.pug
然后,创建这个 fediverse.pug:
if theme.fediverse
link(rel="alternate"
type="application/activity+json"
href=new URL(`/posts/${urlNoIndex(null,config.pretty_urls.trailing_index,config.pretty_urls.trailing_html)}`,
theme.hatsu.instance).href)
link(rel="alternate"
type="application/ld+json"
href=new URL(`/posts/${urlNoIndex(null,config.pretty_urls.trailing_index,config.pretty_urls.trailing_html)}`,
theme.hatsu.instance).href)
OK,然后配置文件稍微写一下:
# Hatsu
# https://hatsu.local/
hatsu:
instance: https://feed.clanna.dev
fediverse: true
完成!再次 commit 之后推送。
现在在 fediverse 上搜索框直接键入文章 URL……
不出意外的话,就出意外了。
如果你用的是 mastodon misskey 这类软件,确实可以。但是很可惜,我用的是 sharkey 这个 misskey 分支,并没有成功识别这个 link 标签,而是直接报错。
细心的读者肯定也发现了,文档里面这么说:
Only Mastodon and Misskey (and their forks) is known to support auto-discovery, other software requires redirection to search correctly. w3c/activitypub#310
行,看来这条路是走通一半了,但没有完全走通。
根据请求头进行重定向
在 @skyone 的提醒下,我决定不止靠上面的 link 元素,而是从根源入手:
当收到来自 Fedi 软件的 activity pub 请求时,把请求交给 hatsu 处理。
请求头 Accept 中会包含:application/ld+json 或 application/activity+json。
简单翻翻 vercel 的控制台,这个 Routing Rules 引起了我的注意。于是我们可以直接简单写个规则,当收到来自 activity pub 客户端的请求时,自动将请求交给 hatsu 处理。

一切都很顺利……才怪勒!
我的 Sharkey 还是报错。仔细研究才发现:
由于使用的是 rewrite,所以 fedi 应用请求我的博客文章 https://blog.samhou.moe/aqua-surf-3/ 时,vercel 会代理请求,重写请求发送到 hatsu,此时 hatsu 返回一串 json。这个 json 是从 hatsu https://feed.clanna.dev/posts/https://blog.samhou.moe/aqua-surf-3/ 这个 URL 返回的 JSON 内容,发送回 vercel ,再发回给 fedi 应用。
示例如下:
{"@context":"https://www.w3.org/ns/activitystreams","id":"https://feed.clanna.dev/posts/https://blog.samhou.moe/aqua-surf-3/","type":"Note","published":"2026-05-24T07:35:00Z","attributedTo":"https://feed.clanna.dev/users/blog.samhou.moe","to":["https://www.w3.org/ns/activitystreams#Public"],"cc":["https://feed.clanna.dev/users/blog.samhou.moe/followers"],"content":"<p>水星冲浪日志 3 —— Fediverse、Arch Linux 和写作风格</p>\n<p>《水星冲浪日志》的第三期,记录了博主加入 Fediverse 社交媒体的经历,Arch Linux 安装、磁盘加密和桌面环境的折腾,以及回首写博客旅途,自己文风的变化。</p>\n<p><a href=\"https://blog.samhou.moe/aqua-surf-3/\">https://blog.samhou.moe/aqua-surf-3/</a></p>\n\n<a href=\"https://feed.clanna.dev/t/%E6%9D%82%E8%B0%88\" rel=\"tag\">#<span>杂谈</span></a> <a href=\"https://feed.clanna.dev/t/fediverse\" rel=\"tag\">#<span>fediverse</span></a> <a href=\"https://feed.clanna.dev/t/arch\" rel=\"tag\">#<span>arch</span></a> <a href=\"https://feed.clanna.dev/t/linux\" rel=\"tag\">#<span>linux</span></a> <a href=\"https://feed.clanna.dev/t/writing\" rel=\"tag\">#<span>writing</span></a> <a href=\"https://feed.clanna.dev/t/misskey\" rel=\"tag\">#<span>misskey</span></a> <a href=\"https://feed.clanna.dev/t/sharkey\" rel=\"tag\">#<span>sharkey</span></a>","contentMap":null,"source":{"content":"水星冲浪日志 3 —— Fediverse、Arch Linux 和写作风格\n\n《水星冲浪日志》的第三期,记录了博主加入 Fediverse 社交媒体的经历,Arch Linux 安装、磁盘加密和桌面环境的折腾,以及回首写博客旅途,自己文风的变化。\n\nhttps://blog.samhou.moe/aqua-surf-3/\n\n#杂谈 #fediverse #arch #linux #writing #misskey #sharkey","mediaType":"text/markdown"},"tag":[{"type":"Hashtag","href":"https://feed.clanna.dev/t/%E6%9D%82%E8%B0%88","name":"#杂谈"},{"type":"Hashtag","href":"https://feed.clanna.dev/t/fediverse","name":"#fediverse"},{"type":"Hashtag","href":"https://feed.clanna.dev/t/arch","name":"#arch"},{"type":"Hashtag","href":"https://feed.clanna.dev/t/linux","name":"#linux"},{"type":"Hashtag","href":"https://feed.clanna.dev/t/writing","name":"#writing"},{"type":"Hashtag","href":"https://feed.clanna.dev/t/misskey","name":"#misskey"},{"type":"Hashtag","href":"https://feed.clanna.dev/t/sharkey","name":"#sharkey"}],"url":{"type":"Link","rel":"canonical","href":"https://blog.samhou.moe/aqua-surf-3/"}}
响应头是这样的:
HTTP/2 200
age: 0
cache-control: public, max-age=0, must-revalidate
content-type: application/activity+json; charset=utf-8
date: Fri, 05 Jun 2026 09:09:49 GMT
你有没有注意到一个根本性的异常?
请求的 URL 是 https://blog.samhou.moe/aqua-surf-3/,返回了 200,但是返回的 JSON 帖子却显示这个帖子的 URL 是 https://feed.clanna.dev/posts/https://blog.samhou.moe/aqua-surf-3/。
虽然 sharkey 报错信息没见着,但是直接把后面帖子 URL 贴进去是可以识别的。
这说明什么?请求 URL 必须和贴文本身 URL 匹配!不能用 rewrite 这种类似「反向代理」的方式来返回内容给 fedi 软件。
不过这个也好解决,我们可以直接不用 rewrite 了,直接用 redirect 嘛。

此时你看了一眼这篇博客右边的滚动条和左边的目录,发现事情并没有这么简单。
完成这样的部署之后,Sharkey 依旧报错。
是的,这次 hatsu 也在报错了。
仔细一看一堆 404:
uri: /posts/https:/blog.samhou.moe/aqua-surf-3/
不是我斜杠怎么就剩下一个了?赶紧 curl 看看:
{ "redirect": "https://feed.clanna.dev/posts/https:/blog.samhou.moe/aqua-surf-3/", "status": "302" }
不是,哥们?由于 vercel 的妙妙处理,跳转的 url 直接干没了一个斜杠。
求助了{狗屁通|ChatGPT}老师之后,才知道这次是遇到面板极限了。
进行了一番深入的探讨,G 老师建议我写一个边缘函数,结合 vercel.json 完成两次跳转。
也就是说,当检测到 fedi 软件请求时的请求路径:
request /blog-post 重定向 -> /api/apub?url=xxx 302 重定向 -> https://feed.clanna.dev/posts/xxxx
先增加第一个重定向:
"routes": [
{
"src": "/(.*)",
"has": [
{
"type": "header",
"key": "Accept",
"value": ".*(application/activity\\+json|application/ld\\+json).*"
}
],
"dest": "/api/apub?url=https://blog.samhou.moe/$1"
}
]
很好!稍微写点边缘函数:
export default async function handler(req, res) {
const url = req.query.url
res.redirect(
302,
`https://feed.clanna.dev/posts/${url}`
)
}
完美。现在提交并推送等待 vercel 构建。
试一试……完美!输入文章 URL,就可以直接跳转到目标帖子了!

自定义评论系统
在 Hatsu 的文档里面,还提到了你可以把来自 fedi 的评论集成回你的网站中。
在文档里面,提到可以用 kkna(作者写的轻量加载器)或者 Mastodon Comments 来实现。
前者搞半天都失败,所以我就选择了后者。
直接把文档和 butterfly 主题的源码塞给 AI,让它来写。
在无数次 Vibe 出 Bug 之后,终于……
完成了下面的大作:

是的,按一下右上角的切换按钮就可以找到原来的 Artalk 了,而默认展示来自 Fediverse 的评论!
如果你也想用这个的话,我已经把修改版主题开源了,你可以也集成到你的 hexo 博客里面,也可以自己魔改。
经过我和 AI 几天的改造,整个博客优化了几处小细节,增加了几个好用的新功能,还是挺不错的。
那么这篇文章就到这里结束了,希望各位能有所收获~