2026-01-21 23:18:35 +08:00
---
2026-01-21 23:26:08 +08:00
pid : 138
2026-01-30 21:11:05 +08:00
title : 如何hugo静态实现友联朋友圈功能
date : 2026-01-21T06:30:00.000Z
2026-03-01 11:48:36 +08:00
slug : 20260121
2026-01-21 23:18:35 +08:00
draft : false
2026-01-30 21:11:05 +08:00
categories :
- 教程
- 折腾
tags :
- Hugo
- 朋友圈
- 静态博客
author : 小赵同学
layout : post
ai_comment : >-
2026-05-24 19:58:07 +08:00
这个技术方案巧妙地用静态生成+定时更新的方式模拟了动态功能,把动态数据问题转化为静态资源构建问题。Node.js脚本的异常处理和headers设置很老练,但可以考虑加入缓存机制避免重复抓取未更新的feed。前端部分用AJAX加载JSON确实简单直接,不过如果追求更极致的静态化,也可以考虑在Hugo构建时直接注入数据。
2026-01-21 23:18:35 +08:00
---
前段时间把博客从 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 , 1 fr );
gap : 12 px ;
margin-top : 20 px ;
width : 100 % ;
}
/* 移动端一列 */
@ media ( max-width : 768px ) {
. friend-circle-list {
grid-template-columns : 1 fr ;
gap : 10 px ;
}
}
/* 卡片核心样式:毛玻璃 + 动效 */
. fc-item {
display : flex ;
flex-direction : column ;
padding : 20 px ;
border-radius : 10 px ;
position : relative ;
height : 160 px ;
justify-content : space-between ;
background : rgba ( 255 , 255 , 255 , 0.4 );
backdrop-filter : blur ( 16 px ) saturate ( 180 % );
-webkit- backdrop-filter : blur ( 16 px ) saturate ( 180 % );
border : 1 px dashed rgba ( 255 , 255 , 255 , 0.3 ); /* 虚线边框 */
transition : all 0.4 s cubic-bezier ( 0.25 , 0.8 , 0.25 , 1 );
overflow : hidden ;
opacity : 0 ;
animation : ukFadeInUp 0.8 s 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 : 1 px solid rgba ( 255 , 255 , 255 , 0.1 );
color : #fff ;
}
/* 鼠标悬停特效 */
. fc-item : hover {
transform : translateY ( -8 px ) scale ( 1.02 );
background : rgba ( 255 , 255 , 255 , 0.6 );
border-color : #000 ;
z-index : 10 ;
}
/* 标题样式 */
. fc-title {
font-size : 18 px ;
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 : 60 px ;
}
/* 背景装饰图(文章封面) */
. fc-bg-image {
position : absolute ;
right : -20 px ;
bottom : -20 px ;
width : 140 px ;
height : 140 px ;
border-radius : 50 % ;
object-fit : cover ;
opacity : 0.15 ;
transition : all 0.5 s ease ;
z-index : 0 ;
pointer-events : none ;
filter : grayscale ( 20 % );
}
. fc-item : hover . fc-bg-image {
opacity : 0.3 ;
transform : scale ( 1.1 ) rotate ( 5 deg );
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 : 4 px 10 px 4 px 4 px ;
border-radius : 20 px ;
transition : background 0.3 s ;
}
[ data-theme = "dark" ] . fc-author-pill {
background : #1a1a1a ;
border : 1 px solid #333 ;
}
. fc-avatar {
width : 28 px ;
height : 28 px ;
border-radius : 50 % ;
margin-right : 8 px ;
}
. fc-author-name {
font-size : 13 px ;
font-weight : 600 ;
}
/* 加载更多按钮 */
. fc-load-more-btn {
grid-column : 1 / -1 ;
padding : 12 px 30 px ;
margin : 30 px auto 10 px ;
background : #000 ;
border : 1 px solid rgba ( 255 , 255 , 255 , 0.1 );
border-radius : 30 px ;
color : #fff ;
cursor : pointer ;
font-size : 14 px ;
backdrop-filter : blur ( 10 px );
}
/* 动画定义 */
@ keyframes ukFadeInUp {
0 % { opacity : 0 ; transform : translateY ( 40 px ) scale ( 0.95 ); }
100 % { opacity : 1 ; transform : translateY ( 0 ) scale ( 1 ); }
}
@ keyframes spin { to { transform : rotate ( 360 deg ); } }
. loading-spinner {
width : 40 px ; height : 40 px ;
border : 3 px solid rgba ( 127 , 127 , 127 , 0.2 );
border-top-color : #3498db ;
border-radius : 50 % ;
animation : spin 1 s infinite linear ;
margin : 0 auto 15 px ;
}
</ 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 的朋友们。如果有问题,欢迎在评论区留言(虽然我也不一定能解决哈哈)。
2026-01-22 21:26:48 +08:00