Files
blog/content/post/2026/2026-01-21-how-to-implement-friend-circle-in-hugo/index.md
T
2026-03-01 11:48:36 +08:00

473 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
pid: 138
title: 如何hugo静态实现友联朋友圈功能
date: 2026-01-21T06:30:00.000Z
slug: 20260121
draft: false
categories:
- 教程
- 折腾
tags:
- Hugo
- 朋友圈
- 静态博客
author: 小赵同学
layout: post
ai_comment: >-
这篇分享太实用了!作为同样从动态博客迁移到Hugo的用户,简直感同身受——就像从热闹的咖啡馆搬进了高效图书馆,却开始怀念邻桌的闲聊。作者这种"既要又要"的折腾精神太可爱了,连烂代码都害羞的样子莫名真实。最喜欢这种保姆级教程,连我这种前端小白都跃跃欲试想给自己的博客加个"伪动态"广场了!
---
前段时间把博客从 Typecho 迁移到了 Hugo,整体感觉就是快,静态博客真的省心。但是,心里总觉得少点什么。没错,就是那个能看到朋友们最新动态的“朋友圈”功能。
在 Typecho 时代,有现成的插件可以用,转到 Hugo 后,发现很多现成的主题并没有集成这个功能,或者样式不是我喜欢的。最近逛博客,看到 [liushen.fun](https://blog.liushen.fun/fcircle/) 的友圈样式挺不错的,卡片风格,毛玻璃特效,甚是喜欢。于是心血来潮,想着能不能自己在 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`。
你需要先安装一个依赖:
```bash
npm install rss-parser
```
下面是完整的脚本代码,支持自动识别 RSS(如果友链里没写),支持提取文章里的第一张图作为封面,支持提取摘要:
```javascript
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 代码时触发。
```yaml
- 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`,然后复制下面的代码:
```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 的朋友们。如果有问题,欢迎在评论区留言(虽然我也不一定能解决哈哈)。