前段时间把博客从 Typecho 迁移到了 Hugo,整体感觉就是快,静态博客真的省心。但是,心里总觉得少点什么。没错,就是那个能看到朋友们最新动态的“朋友圈”功能。

在 Typecho 时代,有现成的插件可以用,转到 Hugo 后,发现很多现成的主题并没有集成这个功能,或者样式不是我喜欢的。最近逛博客,看到 liushen.fun 的友圈样式挺不错的,卡片风格,毛玻璃特效,甚是喜欢。于是心血来潮,想着能不能自己在 Hugo 上也整一个。

本来想着直接把仓库开源给大家参考,但因为仓库里有一些私密的配置和魔改的烂代码,实在不好意思(也不方便)公开。所以,既然大家想要,那我就把完整的代码实现步骤都贴在这里,主打一个“喂饭级”教程,大家直接复制粘贴就能用!

实现思路

因为 Hugo 是静态博客,没有数据库,所以不能像 Typecho 那样实时读取数据库。我的思路是:

  1. 后端抓取:利用 GitHub Actions 定时运行一个 Node.js 脚本。
  2. 数据处理:脚本读取 links.yaml(或者你的友链 JSON),抓取每个朋友的 RSS Feed,提取文章标题、链接、时间、摘要和封面图。
  3. 数据生成:将处理好的数据保存为 friend_circle_data.json 文件。
  4. 前端渲染:在 Hugo 的页面模板中,通过 AJAX 请求这个 JSON 文件,动态渲染成卡片列表。

这样既保持了博客的静态特性,又实现了动态更新。

第一步:后端脚本 (Node.js)

首先,我们需要一个脚本来干脏活累活。我在项目根目录的 scripts 文件夹下新建了一个 generate_circle_data.js

你需要先安装一个依赖:

npm install rss-parser

下面是完整的脚本代码,支持自动识别 RSS(如果友链里没写),支持提取文章里的第一张图作为封面,支持提取摘要:

const fs = require('fs');
const path = require('path');
const Parser = require('rss-parser');

// 你的友链数据文件路径,根据实际情况修改
const LINK_LITE_PATH = path.join(__dirname, '../themes/Ying/static/json/link_lite.json');
// 输出文件路径
const OUTPUT_PATH = path.join(__dirname, '../themes/Ying/static/json/friend_circle_data.json');

const MAX_POSTS_PER_FRIEND = 5; // 每个朋友取最近5篇
const MAX_TOTAL_POSTS = 100; // 总共展示100篇

const parser = new Parser({
    timeout: 10000,
    headers: {
        'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'
    }
});

async function fetchFeed(url) {
    try {
        const feed = await parser.parseURL(url);
        return feed;
    } catch (error) {
        return null;
    }
}

async function main() {
    console.log('Starting Friend Circle generation...');
    
    if (!fs.existsSync(LINK_LITE_PATH)) {
        console.error('link_lite.json not found!');
        process.exit(1);
    }
    
    const data = JSON.parse(fs.readFileSync(LINK_LITE_PATH, 'utf8'));
    // 假设 data.friends 是一个数组: [name, url, avatar, rss?]
    // 如果你的格式不一样,请自行调整下面的 map 逻辑
    
    let allPosts = [];
    const BATCH_SIZE = 5; // 并发控制
    const friends = data.friends;
    
    for (let i = 0; i < friends.length; i += BATCH_SIZE) {
        const batch = friends.slice(i, i + BATCH_SIZE);
        const promises = batch.map(async (friend) => {
            const name = friend[0];
            const blogUrl = friend[1];
            const avatar = friend[2];
            let rssUrl = friend.length >= 4 ? friend[3] : null;
            
            // 如果没有提供 RSS,尝试盲猜
            const candidates = [];
            if (rssUrl) {
                candidates.push(rssUrl);
            } else {
                const cleanUrl = blogUrl.replace(/\/$/, '');
                candidates.push(`${cleanUrl}/atom.xml`);
                candidates.push(`${cleanUrl}/rss.xml`);
                candidates.push(`${cleanUrl}/feed`);
                candidates.push(`${cleanUrl}/index.xml`);
            }
            
            let feed = null;
            for (const url of candidates) {
                feed = await fetchFeed(url);
                if (feed) break;
            }
            
            if (!feed) {
                console.log(`[${name}] No valid RSS found.`);
                return;
            }
            
            // 提取文章
            const posts = feed.items.slice(0, MAX_POSTS_PER_FRIEND).map(item => {
                let img = null;
                // 优先从 content 中提取图片
                const content = item['content:encoded'] || item.content || item.description || '';
                const imgMatch = content.match(/<img[^>]+src=['"]([^'"]+)['"]/i);
                
                if (imgMatch) {
                    img = imgMatch[1];
                } else if (item.enclosure && item.enclosure.url && item.enclosure.type && item.enclosure.type.startsWith('image')) {
                     img = item.enclosure.url;
                }

                // 提取摘要
                let snippet = '';
                if (content) {
                    snippet = content.replace(/<[^>]+>/g, '');
                    snippet = snippet.replace(/\s+/g, ' ').trim();
                    if (snippet.length > 120) {
                        snippet = snippet.substring(0, 120) + '...';
                    }
                }

                return {
                    title: item.title,
                    link: item.link,
                    date: item.isoDate || item.pubDate,
                    author: name,
                    avatar: avatar,
                    blogUrl: blogUrl,
                    image: img,
                    description: snippet
                };
            });
            
            allPosts = allPosts.concat(posts);
        });
        
        await Promise.all(promises);
    }
    
    // 按时间倒序排序
    allPosts.sort((a, b) => new Date(b.date) - new Date(a.date));
    
    const finalPosts = allPosts.slice(0, MAX_TOTAL_POSTS);
    
    const output = {
        updated: new Date().toISOString(),
        posts: finalPosts
    };
    
    fs.writeFileSync(OUTPUT_PATH, JSON.stringify(output, null, 2));
    console.log(`Saved ${finalPosts.length} posts to ${OUTPUT_PATH}`);
}

main();

第二步:自动化 (GitHub Actions)

脚本写好了,总不能每天自己手动跑吧?这时候 GitHub Actions 就派上用场了。

.github/workflows 下创建一个 upy.yml (或者其他名字),设置定时任务。我是设置每天跑一次,或者每次 push 代码时触发。

    - name: Install Node.js dependencies
      run: npm install rss-parser

    - name: Generate Friend Circle Data
      run: node scripts/generate_circle_data.js

把这两步加到你现有的构建流程里,放在 hugo 命令之前就行。

第三步:前端颜值 (HTML & CSS)

这一步是重头戏!为了达到那个卡片式、毛玻璃、还有丝滑动效的效果,我可是调了半天 CSS。

我直接把我的 themes/Ying/layouts/_default/circles.html 文件内容贴出来。这个文件集成了 HTML 结构、CSS 样式和 JS 逻辑(包括 PJAX 适配和加载更多功能)。

你可以直接在你的 Hugo 主题里新建一个 layouts/_default/circles.html,然后复制下面的代码:

{{ define "main" }}
<div id="pjax-container">
    <div class="post-content" itemprop="articleBody">
        {{ .Content }}
        <div id="friend-circle-container" class="friend-circle-list">
            <div class="loading-container">
                <div class="loading-spinner"></div>
                <p>正在探索朋友们的新动态...</p>
            </div>
        </div>
    </div>
    
    <style>
        /* 布局:PC端两列,间距紧凑 */
        .friend-circle-list { 
            display: grid;
            grid-template-columns: repeat(2, 1fr);
            gap: 12px;
            margin-top: 20px;
            width: 100%;
        }

        /* 移动端一列 */
        @media (max-width: 768px) {
            .friend-circle-list {
                grid-template-columns: 1fr;
                gap: 10px;
            }
        }

        /* 卡片核心样式:毛玻璃 + 动效 */
        .fc-item { 
            display: flex; 
            flex-direction: column; 
            padding: 20px; 
            border-radius: 10px; 
            position: relative; 
            height: 160px; 
            justify-content: space-between; 
            background: rgba(255, 255, 255, 0.4); 
            backdrop-filter: blur(16px) saturate(180%); 
            -webkit-backdrop-filter: blur(16px) saturate(180%); 
            border: 1px dashed rgba(255, 255, 255, 0.3); /* 虚线边框 */
            transition: all 0.4s cubic-bezier(0.25, 0.8, 0.25, 1); 
            overflow: hidden; 
            opacity: 0; 
            animation: ukFadeInUp 0.8s cubic-bezier(0.165, 0.84, 0.44, 1) forwards; 
            text-decoration: none; 
            color: #333;
        }

        /* 暗黑模式适配 */
        [data-theme="dark"] .fc-item {
            background: rgba(35, 35, 35, 0.65);
            border: 1px solid rgba(255, 255, 255, 0.1);
            color: #fff;
        }

        /* 鼠标悬停特效 */
        .fc-item:hover { 
            transform: translateY(-8px) scale(1.02);
            background: rgba(255, 255, 255, 0.6);
            border-color: #000;
            z-index: 10;
        }

        /* 标题样式 */
        .fc-title { 
            font-size: 18px; 
            font-weight: 700; 
            line-height: 1.4;
            z-index: 2;
            display: -webkit-box;
            -webkit-line-clamp: 2;
            -webkit-box-orient: vertical;
            overflow: hidden;
            margin-right: 60px;
        }

        /* 背景装饰图(文章封面) */
        .fc-bg-image {
            position: absolute;
            right: -20px;
            bottom: -20px;
            width: 140px;
            height: 140px;
            border-radius: 50%;
            object-fit: cover;
            opacity: 0.15;
            transition: all 0.5s ease;
            z-index: 0;
            pointer-events: none;
            filter: grayscale(20%);
        }

        .fc-item:hover .fc-bg-image {
            opacity: 0.3;
            transform: scale(1.1) rotate(5deg);
            filter: grayscale(0%);
        }

        /* 底部信息栏 */
        .fc-bottom {
            display: flex;
            justify-content: space-between;
            align-items: flex-end;
            z-index: 2;
        }

        /* 作者胶囊标签 */
        .fc-author-pill {
            display: flex;
            align-items: center;
            background: rgba(0, 0, 0, 0.05);
            padding: 4px 10px 4px 4px;
            border-radius: 20px;
            transition: background 0.3s;
        }
        
        [data-theme="dark"] .fc-author-pill {
            background: #1a1a1a;
            border: 1px solid #333;
        }

        .fc-avatar { 
            width: 28px; 
            height: 28px; 
            border-radius: 50%; 
            margin-right: 8px;
        }

        .fc-author-name {
            font-size: 13px;
            font-weight: 600;
        }

        /* 加载更多按钮 */
        .fc-load-more-btn {
            grid-column: 1 / -1;
            padding: 12px 30px;
            margin: 30px auto 10px;
            background: #000;
            border: 1px solid rgba(255,255,255,0.1);
            border-radius: 30px;
            color: #fff;
            cursor: pointer;
            font-size: 14px;
            backdrop-filter: blur(10px);
        }

        /* 动画定义 */
        @keyframes ukFadeInUp {
            0% { opacity: 0; transform: translateY(40px) scale(0.95); }
            100% { opacity: 1; transform: translateY(0) scale(1); }
        }
        @keyframes spin { to { transform: rotate(360deg); } }
        
        .loading-spinner {
            width: 40px; height: 40px;
            border: 3px solid rgba(127,127,127,0.2);
            border-top-color: #3498db;
            border-radius: 50%;
            animation: spin 1s infinite linear;
            margin: 0 auto 15px;
        }
    </style>

    <script>
        // 时间格式化:YYYY-MM-DD
        function timeAgo(dateStr) {
            const date = new Date(dateStr);
            const year = date.getFullYear();
            const month = String(date.getMonth() + 1).padStart(2, '0');
            const day = String(date.getDate()).padStart(2, '0');
            return `${year}-${month}-${day}`;
        }

        let allPosts = [];
        let currentIndex = 0;
        const BATCH_SIZE = 10;

        function renderPosts(posts) {
            const container = document.getElementById('friend-circle-container');
            const existingBtn = document.getElementById('fc-load-more-btn');
            if (existingBtn) existingBtn.remove();

            let html = '';
            posts.forEach((post, index) => {
                const dateStr = timeAgo(post.date);
                const delay = index * 0.05; // 瀑布流延迟动画
                const bgImageSrc = post.image || post.avatar; // 有封面图用封面,没封面用头像
                
                html += `
                    <a href="${post.link}" target="_blank" class="fc-item" style="animation-delay: ${delay}s">
                        <div class="fc-title" title="${post.title}">${post.title}</div>
                        <img src="${bgImageSrc}" class="fc-bg-image" loading="lazy" onerror="this.style.opacity=0">
                        <div class="fc-bottom">
                            <div class="fc-author-pill">
                                <img src="${post.avatar}" class="fc-avatar" loading="lazy">
                                <span class="fc-author-name">${post.author}</span>
                            </div>
                            <div class="fc-date">${dateStr}</div>
                        </div>
                    </a>
                `;
            });

            container.insertAdjacentHTML('beforeend', html);

            if (currentIndex < allPosts.length) {
                const btn = document.createElement('button');
                btn.id = 'fc-load-more-btn';
                btn.className = 'fc-load-more-btn';
                btn.innerText = '加载更多';
                btn.onclick = loadMore;
                container.appendChild(btn);
            }
        }

        function loadMore() {
            const nextBatch = allPosts.slice(currentIndex, currentIndex + BATCH_SIZE);
            currentIndex += BATCH_SIZE;
            renderPosts(nextBatch);
        }

        function loadFriendCircle() {
            const container = document.getElementById('friend-circle-container');
            if (!container) return;
            
            fetch('/json/friend_circle_data.json?t=' + new Date().getTime())
                .then(res => res.json())
                .then(data => {
                    allPosts = data.posts;
                    currentIndex = 0;
                    container.innerHTML = ''; // 清除 loading
                    loadMore();
                })
                .catch(err => {
                    console.error('Error:', err);
                    container.innerHTML = '加载失败';
                });
        }

        // 初始化加载
        document.addEventListener('DOMContentLoaded', loadFriendCircle);
        // 适配 PJAX
        document.addEventListener('pjax:complete', loadFriendCircle);
    </script>
</div>
{{ end }}

避坑指南

  1. CORS 问题:因为我们是请求同域名的 JSON 文件,所以一般不会有跨域问题。但如果你把 JSON 传到了其他 CDN,记得配置 CORS。
  2. PJAX 白屏:如果你像我一样用了 PJAX,一定要加 document.addEventListener('pjax:complete', ...) 这一行,否则跳转回来会白屏。
  3. 样式冲突:CSS 里的 .friend-circle-list 使用了 Grid 布局,如果你的主题有全局 CSS 冲突,可能需要微调。

总结

这一套下来,其实核心就三块:Node 脚本抓数据、Github Action 跑脚本、HTML 渲染页面。

虽然代码看起来有点多,但逻辑是很清晰的。把这些文件放到对应位置,基本上就能跑起来了。如果你也喜欢这种卡片风格,不妨试一试!

好了,代码都毫无保留地交出来了,希望能帮到同样在折腾 Hugo 的朋友们。如果有问题,欢迎在评论区留言(虽然我也不一定能解决哈哈)。