正在探索朋友们的新动态...
---
pid: 138
title: 如何hugo静态实现友联朋友圈功能
date: 2026-01-21T06:30:00.000Z
slug: 20260121
draft: false
categories:
- 教程
- 折腾
tags:
- Hugo
- 朋友圈
- 静态博客
author: 小赵同学
layout: post
ai_comment: >-
这个技术方案巧妙地用静态生成+定时更新的方式模拟了动态功能,把动态数据问题转化为静态资源构建问题。Node.js脚本的异常处理和headers设置很老练,但可以考虑加入缓存机制避免重复抓取未更新的feed。前端部分用AJAX加载JSON确实简单直接,不过如果追求更极致的静态化,也可以考虑在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(/]+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" }}
正在探索朋友们的新动态...