给文章引入联邦宇宙嘟文互动记录

给文章引入联邦宇宙嘟文互动记录

<span style="white-space: pre-wrap;">Photo by </span><a href="https://unsplash.com/@pankajpatel?utm_source=ghost&amp;utm_medium=referral&amp;utm_campaign=api-credit"><span style="white-space: pre-wrap;">Pankaj Patel</span></a><span style="white-space: pre-wrap;"> / </span><a href="https://unsplash.com/?utm_source=ghost&amp;utm_medium=referral&amp;utm_campaign=api-credit"><span style="white-space: pre-wrap;">Unsplash</span></a>
Photo by Pankaj Patel / Unsplash

去年还是前年就在思考如何在 Ghost 中集成 Activitypub 的互动,还找了一个国内博客大佬的实现 ActivityPub 协议的简单实现 - Lawrence Li ,不过这位大佬的方案是完全自己实现协议部分,对我来说有点太复杂了。

后来Ghost官方也发布了一个Ghost和Activitypub的继承方案,不过我看了下项目的Docker-compose文件,感觉太臃肿了(毕竟是商业化产品,性能各方面都要考虑到)。

而且开发一年多了,现在也只在官方付费服务里Beta,所以现在兴致缺缺。

因为最近用Cloudflare Worker实现了很多有意思的玩意,

所已,今天忽然灵光一闪,想到了一个非常有意思的点子,通过和AI的几轮互动,感觉应该能完整实现大佬博客里的那种效果。

其实原理无非就是利用Cloudflare Worker和KV功能,对文章ID和嘟文ID进行储存,在页面展示时再去请求数据进行展示,整个逻辑大概如下:

文章和嘟文同步

  1. Worker定时请求Ghost博客中最新一篇的数据(我这边是用Ghost的唯一文章ID做Key,你的博客系统没有API的可以请求RSS,但是ID必须是唯一的,可以自己截取slug出来应该也是可行的)。
  2. 拿到Key后在KV中进行查找,如果录入过就跳过。
  3. 没录入就拿文章的数据根据长毛象或GTS的API要求组装嘟文进行发布。
  4. 获取到嘟文唯一ID后和文章ID一起存入KV。

嘟文数据获取

  1. 博客文章详情页面加载完后通过文章ID请求Worker。
  2. Worker拿到ID去KV中查找嘟文ID。
  3. 找到话通过嘟文ID去长毛象或者GTS获取嘟文互动数据。
  4. 进行展示。

实践

进入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>
<!--- 其他代码 --->

联邦互动

加入评论