给文章引入联邦宇宙嘟文互动记录
去年还是前年就在思考如何在 Ghost 中集成 Activitypub 的互动,还找了一个国内博客大佬的实现 ActivityPub 协议的简单实现 - Lawrence Li ,不过这位大佬的方案是完全自己实现协议部分,对我来说有点太复杂了。
后来Ghost官方也发布了一个Ghost和Activitypub的继承方案,不过我看了下项目的Docker-compose文件,感觉太臃肿了(毕竟是商业化产品,性能各方面都要考虑到)。
而且开发一年多了,现在也只在官方付费服务里Beta,所以现在兴致缺缺。
因为最近用Cloudflare Worker实现了很多有意思的玩意,
所已,今天忽然灵光一闪,想到了一个非常有意思的点子,通过和AI的几轮互动,感觉应该能完整实现大佬博客里的那种效果。
其实原理无非就是利用Cloudflare Worker和KV功能,对文章ID和嘟文ID进行储存,在页面展示时再去请求数据进行展示,整个逻辑大概如下:
文章和嘟文同步
- Worker定时请求Ghost博客中最新一篇的数据(我这边是用Ghost的唯一文章ID做Key,你的博客系统没有API的可以请求RSS,但是ID必须是唯一的,可以自己截取slug出来应该也是可行的)。
- 拿到Key后在KV中进行查找,如果录入过就跳过。
- 没录入就拿文章的数据根据长毛象或GTS的API要求组装嘟文进行发布。
- 获取到嘟文唯一ID后和文章ID一起存入KV。
嘟文数据获取
- 博客文章详情页面加载完后通过文章ID请求Worker。
- Worker拿到ID去KV中查找嘟文ID。
- 找到话通过嘟文ID去长毛象或者GTS获取嘟文互动数据。
- 进行展示。
实践
进入Cloudflare Worker直接新建一个Worker,模板选Hello World,然后下面代码覆盖原有代码,我这边Ghost获取文章的部分你们用AI改成获取RSS,并截取文章slug作为文章ID。
// 配置常量
const GTS_INSTANCE = "https://social.gts.com";
const GTS_TOKEN = "ZTU5YTZLZMQTNWRJFSAFAXG3NDQ3MWQZOWRK";
const CACHE_TTL = 600; // 互动数据缓存时间(秒)
const BLOG_URL = "https://blog.com"; // Ghost博客地址
const BLOG_API_KEY = "78eb22fbf6260dcc3a1de7cf82"; // Ghost Admin API Key
// 在 Worker 代码开头添加 CORS 处理函数
const handleCORS = (response, origin) => {
const headers = new Headers(response.headers);
headers.set('Access-Control-Allow-Origin', origin || '*');
headers.set('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
headers.set('Access-Control-Allow-Headers', 'Content-Type, Authorization');
return new Response(response.body, {
status: response.status,
headers
});
};
export default {
async fetch(request, env) {
// 处理预检请求 (OPTIONS)
if (request.method === 'OPTIONS') {
return handleCORS(new Response(null), request.headers.get('Origin'));
}
const url = new URL(request.url);
const path = url.pathname;
// 处理定时触发的自动发布
if (path === '/api/sync') {
return handleAutoPublish(env);
}
// 提供互动数据API
if (path === '/api/interactions' && request.method === 'GET') {
return getInteractions(url.searchParams, env);
}
return new Response('Not Found', { status: 404 });
},
// 添加定时触发器配置
async scheduled(event, env, ctx) {
ctx.waitUntil(handleAutoPublish(env));
}
};
// 自动发布最新文章
async function handleAutoPublish(env) {
try {
// 从Ghost获取最新文章
const postsResp = await fetch(`${BLOG_URL}/ghost/api/content/posts/?limit=1&order=published_at%20desc&key=${BLOG_API_KEY}`, {
headers: {
'Accept-Version': 'v5.0',
'Content-Type': 'application/json'
}
});
if (!postsResp.ok) {
throw new Error('Failed to fetch posts from Ghost');
}
const postsData = await postsResp.json();
const latestPost = postsData.posts[0];
if (!latestPost) {
return new Response('No posts found', { status: 200 });
}
// 检查是否已经发布过
const existingMapping = await env.BLOG_TOOT_MAPPING.get(`post:${latestPost.id}`);
if (existingMapping) {
return new Response('Post already published', { status: 200 });
}
// 发布到GoToSocial
const tootContent = `${latestPost.title}\n${BLOG_URL+'/'+latestPost.slug}\n\nfrom 1900's Blog.(auto sync)\n\n#博客`;
const tootResp = await fetch(`${GTS_INSTANCE}/api/v1/statuses`, {
method: 'POST',
headers: {
'Authorization': `Bearer ${GTS_TOKEN}`,
'Content-Type': 'application/json',
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36'
},
body: JSON.stringify({
status: tootContent,
visibility: "public"
})
});
const tootData = await tootResp.json();
// 存储映射关系到KV
await env.BLOG_TOOT_MAPPING.put(
`post:${latestPost.id}`,
JSON.stringify({
toot_id: tootData.id,
toot_uri: tootData.uri,
created_at: Date.now()
})
);
return new Response('Auto publish success', { status: 200 });
} catch (err) {
return new Response(err.message, { status: 500 });
}
}
async function getInteractions(params, env) {
const postId = params.get('post_id');
if (!postId) return new Response('Missing post_id', { status: 400 });
// 从KV获取Toot信息
const tootData = await env.BLOG_TOOT_MAPPING.get(`post:${postId}`);
if (!tootData) return new Response('Mapping not found', { status: 404 });
const { toot_id } = JSON.parse(tootData);
// 并发获取回复和点赞数据
const [contextResp, favouritesResp] = await Promise.all([
fetch(`${GTS_INSTANCE}/api/v1/statuses/${toot_id}/context`, {
headers: {
'Authorization': `Bearer ${GTS_TOKEN}`,
'CF-Cache-Tag': `context_${toot_id}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36'
},
cf: { cacheTtl: CACHE_TTL }
}),
fetch(`${GTS_INSTANCE}/api/v1/statuses/${toot_id}/favourited_by`, {
headers: {
'Authorization': `Bearer ${GTS_TOKEN}`,
'CF-Cache-Tag': `favs_${toot_id}`,
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/118.0.0.0 Safari/537.36'
},
cf: { cacheTtl: CACHE_TTL }
})
]);
if (!contextResp.ok || !favouritesResp.ok) {
return new Response('Failed to fetch interactions', { status: 502 });
}
// 处理数据
const [contextData, favouritesData] = await Promise.all([
contextResp.json(),
favouritesResp.json()
]);
// 格式化响应
const formatted = {
post_id: postId,
toot_id: toot_id,
replies: contextData.descendants.map(item => ({
id: item.id,
author: {
name: item.account.display_name,
avatar: item.account.avatar
},
content: item.content,
created_at: item.created_at
})),
favourites: favouritesData.map(user => ({
id: user.id,
name: user.display_name,
avatar: user.avatar,
username: user.acct
})),
stats: {
replies_count: contextData.descendants.length,
favourites_count: favouritesData.length
}
};
return new Response(JSON.stringify(formatted), {
headers: {
'Content-Type': 'application/json',
'Access-Control-Allow-Origin': '*'
}
});
}
Worker定时执行
进入Worker的设置页面,绑定KV命名空间和设置Cron执行间隔。
这里KV空间需要提前建好,路径为 储存和数据库 > KV > 创建 > 录入名称 BLOG_TOOT_MAPPING
,然后再去设置页面绑定。

前端渲染
有了API提供数据,前端只需要在页面加载时获取数据进行渲染即可,我这里做了简单的展示,带红心的头像是点赞用户,没带红心的是用户评论,鼠标悬浮在头像上即可展示。

目前暂时还没想好如何更好的实现,之后有想法了再进行完善。
我这边相关代码剥离到了一个单独的js文件里,原理是一样的,你也可以直接写在页面上。具体代码可以用AI帮你生成一个就行。
import tippy from 'tippy.js';
import 'tippy.js/dist/tippy.css';
import 'tippy.js/themes/light.css';
// 配置常量
const API_ENDPOINT = 'https://your.workers.dev/api/interactions';
// 主入口函数
export default async function initActivityPubInteractions() {
try {
const container = document.querySelector('#activitypub');
if (!container) {
console.error('未找到#activitypub元素');
return;
}
const postId = container.dataset.postid;
if (!postId) {
console.error('缺少data-postid属性');
return;
}
const data = await fetchInteractions(postId);
renderAllInteractions(data, container);
// 如果有互动数据则显示容器
if (data.stats.replies_count > 0 || data.stats.favourites_count > 0) {
container.style.display = 'block';
}
} catch (error) {
console.error('加载互动数据失败:', error);
}
}
// 获取互动数据
async function fetchInteractions(postId) {
const response = await fetch(`${API_ENDPOINT}?post_id=${postId}`);
if (!response.ok) throw new Error('API请求失败');
return await response.json();
}
// 渲染所有互动(混合点赞和评论)
function renderAllInteractions(data, container) {
const avatarList = container.querySelector('.discussion-avatar-list');
if (!avatarList) return;
avatarList.innerHTML = '';
// 合并点赞和评论数据
const allInteractions = [...data.favourites.map((user) => ({ ...user, type: 'like' })), ...data.replies.map((user) => ({ ...user, type: 'reply' }))];
// 按时间排序(最新的在前)
allInteractions.sort((a, b) => new Date(b.created_at) - new Date(a.created_at));
allInteractions.forEach((user) => {
const li = document.createElement('li');
li.innerHTML = `
<div class="comment-user-avatar ${user.type}">
<img src="${user.avatar || user.author.avatar}"
alt="${user.name || user.username}"
class="avatar avatar-60 photo"
loading="lazy"
data-user-id="${user.id}"
data-type="${user.type}">
</div>
`;
avatarList.appendChild(li);
// 直接在这里初始化 Tippy
const img = li.querySelector('img');
if (user.type === 'reply') {
// 评论工具提示
tippy(img, {
theme: 'light',
allowHTML: true,
interactive: true,
maxWidth: 350,
delay: [100, 0],
content: '加载中...',
onShow(instance) {
instance.setContent(user.content);
}
});
} else {
// 点赞工具提示
tippy(img, {
content: '💖',
delay: [100, 0]
});
}
});
// 更新统计信息
}
HTML 代码部分
<!--- 其他代码 --->
<div class="social-interactions">
<ol class="discussion-avatar-list"></ol>
</div>
<script>
// 引入上面的函数文件
import loadInteractions from '../utils/acitivitypub';
// 适配Astro的PWA加载
document.addEventListener('astro:page-load', () => {
loadInteractions();
});
</script>
<!--- 其他代码 --->
加入评论