IT博客汇
  • 首页
  • 精华
  • 技术
  • 设计
  • 资讯
  • 扯淡
  • 权利声明
  • 登录 注册

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

    @1900\'Blog发表于 2025-05-24 09:42:47
    love 0

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


沪ICP备19023445号-2号
友情链接