写在前面
这篇文章记录一下我给 blog-v3 新增「博友圈」页面的过程。
最终效果是:在站点新增 /circle 页面,自动读取友链配置里的 RSS / Atom 订阅源,抓取友链站点的最新文章,并以贴合 Clarity 主题风格的卡片列表展示出来。
页面包含:
- 博友圈标题区;
- 上次同步时间;
- 友链数量、活跃数量、文章数量、异常数量统计;
- 最新文章列表;
- 作者头像、标题、摘要、发布时间;
- 分页;
- 加载、失败、空数据状态。
实现思路
整体分成三部分:
- 在
app/feeds.ts里维护友链和订阅源。 - 在服务端新增
/all.json接口,抓取并解析 RSS / Atom。 - 在
app/pages/circle.vue新建前端页面,请求/all.json并渲染友圈文章。
也就是说:
app/feeds.ts
↓
server/utils/friend-circle.ts
↓
server/routes/all.json.get.ts
↓
app/pages/circle.vue
准备友链数据
blog-v3 原本就有友链配置文件:
app/feeds.ts
这里需要确保每个友链都尽量配置 feed 字段,因为博友圈要靠 RSS / Atom 抓取文章。
示例:
import type { FeedGroup } from '../app/types/feed'
export default [
{
name: '网上邻居',
desc: '哔——啵——电波通讯中,欢迎常来串门。',
entries: [
{
author: 'liseezn Blog',
desc: '分享个人学习,项目,及一些教程',
link: 'https://blog.liseezn.top/',
feed: 'https://blog.liseezn.top/',
icon: 'https://favicon.im/zh/blog.liseezn.top',
avatar: 'https://littleskin.cn/avatar/636546',
archs: ['WordPress', '服务器'],
date: '2026-05-01',
},
{
author: '朽丘秋雨',
desc: '一定会和喜欢的人在夏日夜晚牵手慢步',
link: 'https://koxiuqiu.cn',
feed: 'https://koxiuqiu.cn/atom.xml',
icon: 'https://favicon.im/zh/koxiuqiu.cn',
avatar: 'https://koxiuqiu.cn/aicon_min.png',
archs: ['Hexo', 'Vercel'],
date: '2026-05-01',
},
],
},
] satisfies FeedGroup[]
重点字段说明:
| 字段 | 说明 |
|---|---|
author | 友链名称,也是博友圈文章作者名 |
link | 友链主页 |
feed | RSS / Atom 地址,博友圈抓取文章主要靠它 |
avatar | 头像 |
desc | 友链描述 |
icon | 站点图标 |
archs | 技术栈标签 |
date | 加入日期 |
如果某些站点没有手动配置 feed,后面的服务端逻辑也会尝试自动猜测:
/feed/ /atom.xml /index.xml
但最好还是显式写好。
新增服务端工具:抓取 RSS / Atom
新建文件:
server/utils/friend-circle.ts
这个文件负责:
- 读取
app/feeds.ts; - 请求每个友链的 RSS / Atom;
- 解析 RSS 的
<item>; - 解析 Atom 的
<entry>; - 提取文章标题、链接、摘要、发布时间;
- 按时间倒序排序;
- 生成统计数据。
核心代码如下:
import feeds from '../../app/feeds'
interface FriendCircleArticle {
title: string
summary: string
created: string
link: string
author: string
avatar: string
}
interface FriendCircleData {
statistical_data: {
friends_num: number
active_num: number
error_num: number
article_num: number
last_updated_time: string
}
article_data: FriendCircleArticle[]
}
const DEFAULT_ARTICLE_COUNT = 5
const FETCH_TIMEOUT = 12_000
这里没有额外引入 RSS 解析依赖,而是使用轻量的字符串和正则处理。因为这个页面只需要标题、链接、摘要和时间,所以已经足够。
先写一个简单的标签清理函数:
function stripTags(input: string) {
return input
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
.replace(/<[^>]*>/g, '')
.replace(/&/g, '&')
.replace(/</g, '<')
.replace(/>/g, '>')
.replace(/"/g, '"')
.replace(/'/g, '\'')
.trim()
}
function getTag(source: string, tag: string) {
const match = source.match(new RegExp(`<${tag}(?:\\s[^>]*)?>([\\s\\S]*?)<\\/${tag}>`, 'i'))
return match ? stripTags(match[1]) : ''
}
摘要则优先从这些字段里找:
function getSummary(source: string) {
const summary = getTag(source, 'description')
|| getTag(source, 'summary')
|| getTag(source, 'content:encoded')
|| getTag(source, 'content')
return summary.replace(/\s+/g, ' ').slice(0, 160)
}
这里会把摘要控制在 160 个字符内,避免列表卡片被撑得太长。
处理链接和时间
RSS 和 Atom 的链接格式略有不同。
RSS 常见写法是:
<link>https://example.com/post</link>
Atom 常见写法是:
<link href="https://example.com/post" />
所以需要兼容两种情况:
function getLink(source: string) {
const href = source.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1]
if (href)
return stripTags(href)
return getTag(source, 'link')
}
时间统一格式化成上海时区:
function formatDate(value: string | Date) {
const date = value ? new Date(value) : new Date()
const validDate = Number.isNaN(date.getTime()) ? new Date() : date
const parts = new Intl.DateTimeFormat('zh-CN', {
timeZone: 'Asia/Shanghai',
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
hourCycle: 'h23',
}).formatToParts(validDate)
const partMap = Object.fromEntries(parts.map(part => [part.type, part.value]))
return `${partMap.year}-${partMap.month}-${partMap.day} ${partMap.hour}:${partMap.minute}`
}
解析 RSS / Atom 文章
接下来写 parseFeed:
function parseFeed(xml: string, author: string, avatar: string, maxCount: number): FriendCircleArticle[] {
const blocks = [...xml.matchAll(/<item(?:\s[^>]*)?>([\s\S]*?)<\/item>/gi)].map(match => match[1])
const atomBlocks = blocks.length ? [] : [...xml.matchAll(/<entry(?:\s[^>]*)?>([\s\S]*?)<\/entry>/gi)].map(match => match[1])
const entries = blocks.length ? blocks : atomBlocks
return entries.slice(0, maxCount).map((entry) => {
const title = getTag(entry, 'title') || '未命名文章'
const link = getLink(entry)
const created = getTag(entry, 'pubDate') || getTag(entry, 'published') || getTag(entry, 'updated') || getTag(entry, 'dc:date')
return {
title,
summary: getSummary(entry),
created: formatDate(created),
link,
author,
avatar,
}
}).filter(article => article.link)
}
这里兼容了常见 RSS 和 Atom 字段:
| 类型 | 字段 |
|---|---|
| RSS 文章块 | <item> |
| Atom 文章块 | <entry> |
| 标题 | <title> |
| 链接 | <link> 或 <link href=""> |
| 时间 | pubDate / published / updated / dc:date |
| 摘要 | description / summary / content:encoded / content |
请求 feed 并自动发现订阅源
有些站点的 feed 字段可能不是标准 XML 地址,而是首页地址。
所以这里做了一个简单的订阅源发现:如果返回内容不是 RSS / Atom,就从 HTML 的 <link rel="alternate"> 中找 RSS / Atom 地址。
async function fetchText(url: string) {
const controller = new AbortController()
const timeout = setTimeout(() => controller.abort(), FETCH_TIMEOUT)
try {
const response = await fetch(url, {
headers: {
'accept': 'application/rss+xml, application/atom+xml, application/xml, text/xml, text/html;q=0.8, */*;q=0.5',
'user-agent': 'MoodLog Friend-Circle/1.0',
},
signal: controller.signal,
})
if (!response.ok)
throw new Error(`${response.status} ${response.statusText}`)
return await response.text()
}
finally {
clearTimeout(timeout)
}
}
function absolutizeUrl(url: string, base: string) {
try {
return new URL(url, base).toString()
}
catch {
return url
}
}
function discoverFeedUrl(html: string, baseUrl: string) {
const alternate = html.match(/<link[^>]+(?:type=["']application\/(?:rss|atom)\+xml["'][^>]+href=["']([^"']+)["']|href=["']([^"']+)["'][^>]+type=["']application\/(?:rss|atom)\+xml["'])[^>]*>/i)
const href = alternate?.[1] || alternate?.[2]
if (href)
return absolutizeUrl(href, baseUrl)
return undefined
}
接着写单个友链的抓取逻辑:
async function fetchFeedArticles(entry: typeof feeds[number]['entries'][number]) {
const candidates = [
entry.feed,
`${entry.link.replace(/\/$/, '')}/feed/`,
`${entry.link.replace(/\/$/, '')}/atom.xml`,
`${entry.link.replace(/\/$/, '')}/index.xml`,
]
const uniqueCandidates = [...new Set(candidates.filter(Boolean))] as string[]
let lastError: unknown
for (const candidate of uniqueCandidates) {
try {
let text = await fetchText(candidate)
if (!/<(?:rss|feed|item|entry)(?:\s|>)/i.test(text)) {
const discovered = discoverFeedUrl(text, candidate)
if (!discovered)
throw new Error('未发现 RSS/Atom 地址')
text = await fetchText(discovered)
}
const articles = parseFeed(text, entry.author, entry.avatar, DEFAULT_ARTICLE_COUNT)
if (articles.length)
return articles
}
catch (error) {
lastError = error
}
}
console.warn(`[friend-circle] ${entry.author} 抓取失败`, lastError)
return []
}
这段逻辑会依次尝试:
entry.feed;站点地址/feed/;站点地址/atom.xml;站点地址/index.xml。
只要其中一个成功,就返回文章列表。
生成博友圈 JSON 数据
最后导出一个生成函数:
export async function generateFriendCircleData(): Promise<FriendCircleData> {
const entries = feeds.flatMap(group => group.entries)
const results = await Promise.all(entries.map(fetchFeedArticles))
const articleData = results.flat()
.sort((a, b) => new Date(b.created).getTime() - new Date(a.created).getTime())
return {
statistical_data: {
friends_num: entries.length,
active_num: results.filter(result => result.length > 0).length,
error_num: results.filter(result => result.length === 0).length,
article_num: articleData.length,
last_updated_time: formatDate(new Date().toISOString()),
},
article_data: articleData,
}
}
返回结构类似这样:
{
"statistical_data": {
"friends_num": 4,
"active_num": 4,
"error_num": 0,
"article_num": 20,
"last_updated_time": "2026-05-19 05:30"
},
"article_data": [
{
"title": "文章标题",
"summary": "文章摘要",
"created": "2026-05-18 20:00",
"link": "https://example.com/post",
"author": "某某博客",
"avatar": "https://example.com/avatar.png"
}
]
}
新增 /all.json 接口
新建文件:
server/routes/all.json.get.ts
内容很简单:
import { generateFriendCircleData } from '../utils/friend-circle'
export default defineEventHandler(async (event) => {
setHeader(event, 'Content-Type', 'application/json; charset=utf-8')
setHeader(event, 'Cache-Control', 'public, max-age=600, s-maxage=1800')
return generateFriendCircleData()
})
这里设置了缓存:
public, max-age=600, s-maxage=1800
含义是:
- 浏览器缓存 10 分钟;
- 边缘缓存 30 分钟。
这样可以减少每次访问页面时重复抓取友链 RSS 的压力。
访问:
/all.json
就能看到聚合后的博友圈数据。
新建博友圈页面
新建文件:
app/pages/circle.vue
先写数据类型和请求逻辑:
<script setup lang="ts">
interface FriendCircleStats {
friends_num: number
active_num: number
error_num: number
article_num: number
last_updated_time: string
}
interface FriendCircleArticle {
title: string
summary?: string
created: string
link: string
author: string
avatar?: string
}
interface FriendCircleData {
statistical_data: FriendCircleStats
article_data: FriendCircleArticle[]
}
const appConfig = useAppConfig()
const layoutStore = useLayoutStore()
layoutStore.setAside([])
const fallbackAvatar = 'https://pic.imgdb.cn/item/6695daa4d9c307b7e953ee3d.jpg'
const { data, pending, error } = await useFetch<FriendCircleData>('/all.json', {
key: 'friend-circle-data',
default: () => ({
statistical_data: {
friends_num: 0,
active_num: 0,
error_num: 0,
article_num: 0,
last_updated_time: '',
},
article_data: [],
}),
})
const stats = computed(() => data.value?.statistical_data)
const articles = computed(() => data.value?.article_data ?? [])
</script>
这里用了:
layoutStore.setAside([])
目的是让友圈页面不显示文章侧边栏,页面视觉更干净。
添加分页和统计数据
继续在 <script setup> 里添加:
const pageSize = 5
const currentPage = ref(1)
const totalPages = computed(() => Math.max(1, Math.ceil(articles.value.length / pageSize)))
const paginatedArticles = computed(() => {
const start = (currentPage.value - 1) * pageSize
return articles.value.slice(start, start + pageSize)
})
const circleStats = computed(() => [{
label: '友链',
value: formatNumber(stats.value?.friends_num ?? 0),
}, {
label: '活跃',
value: formatNumber(stats.value?.active_num ?? 0),
}, {
label: '文章',
value: formatNumber(stats.value?.article_num ?? 0),
}, {
label: '异常',
value: formatNumber(stats.value?.error_num ?? 0),
}])
watch(articles, () => {
currentPage.value = 1
})
function formatDate(date: string) {
return date?.slice(0, 10) || '未知日期'
}
useSeoMeta({
title: '博友圈',
description: `${appConfig.title}的博友圈,聚合友链站点的最新文章。`,
})
其中:
const pageSize = 5
表示每页展示 5 篇文章。
编写页面模板
完整模板如下:
<template>
<BlogHeader class="mobile-only" to="/" suffix="博友圈" tag="h1" />
<UtilHydrateSafe>
<div class="friend-circle">
<header class="circle-hero card">
<div>
<p class="circle-kicker">
<Icon name="tabler:circles-relation" />
友圈动态
</p>
<h1 class="text-creative">博友圈</h1>
<p class="circle-desc">
聚合友链站点的最新文章,发现邻居们的新鲜动态。
</p>
</div>
<div class="circle-sync">
<span>上次同步</span>
<strong>{{ stats?.last_updated_time || '—' }}</strong>
</div>
</header>
<BlogWidget v-if="stats" card title="圈子统计" class="circle-stats">
<ZDlGroup :items="circleStats" size="small" />
</BlogWidget>
<p v-if="pending" class="friend-circle-message card">
<Icon name="line-md:loading-twotone-loop" />
博友圈加载中……
</p>
<p v-else-if="error" class="friend-circle-message error card">
<Icon name="tabler:cloud-x" />
博友圈数据加载失败,请稍后重试。
</p>
<p v-else-if="!articles.length" class="friend-circle-message card">
<Icon name="tabler:mood-empty" />
暂时没有抓取到友链文章。
</p>
<template v-else>
<TransitionGroup tag="menu" class="circle-list proper-height" name="float-in">
<UtilLink
v-for="article, index in paginatedArticles"
:key="`${article.link}-${article.created}`"
class="circle-article card upraise"
:to="article.link"
target="_blank"
rel="noopener noreferrer"
:style="getFixedDelay(index * 0.05)"
>
<img
class="circle-avatar no-lightbox"
:src="article.avatar || fallbackAvatar"
:alt="article.author"
loading="lazy"
@error="($event.target as HTMLImageElement).src = fallbackAvatar"
>
<article>
<h2 class="article-title text-creative">
{{ article.title }}
</h2>
<p class="article-description">
{{ article.summary || '这篇文章暂时没有摘要。' }}
</p>
<div class="article-info">
<span>
<Icon name="tabler:user-circle" />
{{ article.author }}
</span>
<span>
<Icon name="tabler:pencil-minus" />
<time :datetime="article.created">{{ formatDate(article.created) }}</time>
</span>
<span class="read-origin">
<Icon name="tabler:external-link" />
阅读原文
</span>
</div>
</article>
</UtilLink>
</TransitionGroup>
<ZPagination
v-if="totalPages > 1"
v-model="currentPage"
sticky
avoid
:total-pages="totalPages"
/>
</template>
</div>
</UtilHydrateSafe>
</template>
这里大量复用了 Clarity 主题已有组件和样式类:
| 组件 / 类名 | 作用 |
|---|---|
BlogHeader | 移动端页面标题 |
UtilHydrateSafe | 避免水合问题 |
BlogWidget | 主题小组件卡片 |
ZDlGroup | 数据统计组 |
UtilLink | 主题链接组件 |
ZPagination | 主题分页组件 |
card | 卡片样式 |
upraise | 悬浮抬升效果 |
text-creative | 主题标题文字效果 |
proper-height | 主题动画辅助类 |
添加页面样式
继续在 circle.vue 里添加样式:
<style lang="scss" scoped>
.friend-circle {
margin: 1rem;
}
.circle-hero {
display: flex;
gap: 1rem;
align-items: flex-start;
justify-content: space-between;
margin-block: 1rem;
padding: 1rem;
border-radius: 0.8em;
color: var(--c-text);
}
.circle-kicker {
display: flex;
gap: 0.4em;
align-items: center;
margin: 0 0 0.45rem;
color: var(--c-text-2);
font-size: 0.82em;
}
.circle-hero h1 {
margin: 0;
font-size: 1.45em;
}
.circle-desc {
margin: 0.45rem 0 0;
color: var(--c-text-2);
font-size: 0.92em;
line-height: 1.7;
}
.circle-sync {
min-width: 8rem;
padding: 0.55rem 0.75rem;
border-radius: 0.7rem;
background-color: var(--c-bg-2);
text-align: end;
font-size: 0.82em;
}
</style>
文章列表样式:
.friend-circle-message {
display: flex;
gap: 0.5em;
align-items: center;
justify-content: center;
margin: 1rem 0;
padding: 1.2rem;
color: var(--c-text-2);
&.error {
color: var(--c-danger, #d33);
}
}
.circle-list {
display: grid;
gap: 1rem;
}
.circle-article {
display: grid;
grid-template-columns: 2.6rem minmax(0, 1fr);
gap: 0.85rem;
align-items: flex-start;
padding: 1rem;
border-radius: 0.8em;
color: var(--c-text);
animation: float-in 0.2s var(--delay) backwards;
}
.circle-avatar {
width: 2.6rem;
height: 2.6rem;
margin: 0;
border-radius: 50%;
box-shadow: 0 0 0 1px var(--c-border);
object-fit: cover;
}
.article-title {
display: -webkit-box;
margin: 0;
overflow: hidden;
color: var(--c-text);
font-size: 1.1em;
line-height: 1.5;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
.article-description {
display: -webkit-box;
margin: 0;
overflow: hidden;
color: var(--c-text-2);
font-size: 0.9em;
line-height: 1.7;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
}
移动端适配:
@media (max-width: 720px) {
.friend-circle {
margin: 0.8rem;
}
.circle-hero {
flex-direction: column;
}
.circle-sync {
width: 100%;
text-align: start;
}
.circle-article {
grid-template-columns: 2.2rem minmax(0, 1fr);
gap: 0.7rem;
}
.circle-avatar {
width: 2.2rem;
height: 2.2rem;
}
}
添加左侧导航入口
打开:
app/app.config.ts
找到左侧导航:
nav: [
{
title: '',
items: [
{ icon: 'tabler:files', text: '文章', url: '/' },
{ icon: 'tabler:archive', text: '归档', url: '/archive' },
{ icon: 'tabler:link', text: '友链', url: '/link' },
],
},
] satisfies Nav,
新增一项:
{ icon: 'tabler:circle-dashed', text: '友圈', url: '/circle' },
完整示例:
nav: [
{
title: '',
items: [
{ icon: 'tabler:files', text: '文章', url: '/' },
{ icon: 'tabler:archive', text: '归档', url: '/archive' },
{ icon: 'tabler:link', text: '友链', url: '/link' },
{ icon: 'tabler:circle-dashed', text: '友圈', url: '/circle' },
],
},
] satisfies Nav,
这样侧边栏就会出现「友圈」入口。
本地测试
先安装依赖:
pnpm install
启动开发服务:
pnpm dev
访问:
http://localhost:3000/circle
也可以单独看接口数据:
http://localhost:3000/all.json
如果页面正常,会看到类似数据:
友链 4 活跃 4 文章 20 异常 0
文章列表会按发布时间倒序排列。
构建检查
开发完成后执行:
pnpm build
如果构建通过,就说明 Nuxt 页面、服务端接口和类型基本没有问题。
部署到 Vercel
如果项目已经配置好 Vercel,可以直接部署:
pnpm run deploy:vercel
部署完成后访问:
https://你的域名/circle
以及:
https://你的域名/all.json
确认线上接口和页面都正常即可。
常见问题
某个友链没有文章
优先检查它的 feed 字段是否正确。
可以尝试这些常见地址:
https://example.com/feed/ https://example.com/atom.xml https://example.com/index.xml https://example.com/rss.xml
页面显示“博友圈数据加载失败”
可能原因:
- 某个 RSS 地址请求超时;
- 远程站点屏蔽服务器请求;
- RSS XML 格式异常;
- Vercel Serverless 网络请求失败。
可以查看服务端日志里是否有:
[friend-circle] xxx 抓取失败
文章时间排序不准
目前代码会把时间格式化为:
YYYY-MM-DD HH:mm
再排序时用:
new Date(b.created).getTime() - new Date(a.created).getTime()
一般情况下够用。如果遇到特殊运行环境解析不稳定,可以在内部排序时保留原始时间戳,再单独展示格式化时间。
RSS 摘要太长
目前摘要截断长度是:
slice(0, 160)
可以按需要改成:
slice(0, 100)
或者:
slice(0, 200)
想调整每页文章数
在 app/pages/circle.vue 里修改:
const pageSize = 5
例如每页 10 篇:
const pageSize = 10
总结
这次新增的「博友圈」页面没有引入额外 RSS 解析库,而是使用 Nuxt 服务端接口直接抓取友链订阅源,再在前端页面中展示。
整体改动文件主要有:
app/feeds.ts server/utils/friend-circle.ts server/routes/all.json.get.ts app/pages/circle.vue app/app.config.ts
实现后的页面和 Clarity 主题风格保持一致,同时也让友链页面不只是静态链接列表,而是变成了一个可以持续更新的「邻居动态」入口。
评论区
评论加载中...