给 blog-v3 新建一个博友圈页面:聚合友链最新文章

记录在 Nuxt 4 + Nuxt Content 的 blog-v3 / Clarity 主题中新增博友圈页面的完整过程,包括友链数据、RSS/Atom 聚合接口、页面渲染、分页、导航入口和部署检查。

写在前面

这篇文章记录一下我给 blog-v3 新增「博友圈」页面的过程。

最终效果是:在站点新增 /circle 页面,自动读取友链配置里的 RSS / Atom 订阅源,抓取友链站点的最新文章,并以贴合 Clarity 主题风格的卡片列表展示出来。

页面包含:

  • 博友圈标题区;
  • 上次同步时间;
  • 友链数量、活跃数量、文章数量、异常数量统计;
  • 最新文章列表;
  • 作者头像、标题、摘要、发布时间;
  • 分页;
  • 加载、失败、空数据状态。

实现思路

整体分成三部分:

  1. app/feeds.ts 里维护友链和订阅源。
  2. 在服务端新增 /all.json 接口,抓取并解析 RSS / Atom。
  3. app/pages/circle.vue 新建前端页面,请求 /all.json 并渲染友圈文章。

也就是说:

txt
app/feeds.ts
    ↓
server/utils/friend-circle.ts
    ↓
server/routes/all.json.get.ts
    ↓
app/pages/circle.vue

准备友链数据

blog-v3 原本就有友链配置文件:

txt
app/feeds.ts

这里需要确保每个友链都尽量配置 feed 字段,因为博友圈要靠 RSS / Atom 抓取文章。

示例:

ts
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友链主页
feedRSS / Atom 地址,博友圈抓取文章主要靠它
avatar头像
desc友链描述
icon站点图标
archs技术栈标签
date加入日期

如果某些站点没有手动配置 feed,后面的服务端逻辑也会尝试自动猜测:

txt
/feed/
/atom.xml
/index.xml

但最好还是显式写好。

新增服务端工具:抓取 RSS / Atom

新建文件:

txt
server/utils/friend-circle.ts

这个文件负责:

  • 读取 app/feeds.ts
  • 请求每个友链的 RSS / Atom;
  • 解析 RSS 的 <item>
  • 解析 Atom 的 <entry>
  • 提取文章标题、链接、摘要、发布时间;
  • 按时间倒序排序;
  • 生成统计数据。

核心代码如下:

ts
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 解析依赖,而是使用轻量的字符串和正则处理。因为这个页面只需要标题、链接、摘要和时间,所以已经足够。

先写一个简单的标签清理函数:

ts
function stripTags(input: string) {
	return input
		.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
		.replace(/<[^>]*>/g, '')
		.replace(/&amp;/g, '&')
		.replace(/&lt;/g, '<')
		.replace(/&gt;/g, '>')
		.replace(/&quot;/g, '"')
		.replace(/&#39;/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]) : ''
}

摘要则优先从这些字段里找:

ts
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 常见写法是:

xml
<link>https://example.com/post</link>

Atom 常见写法是:

xml
<link href="https://example.com/post" />

所以需要兼容两种情况:

ts
function getLink(source: string) {
	const href = source.match(/<link[^>]+href=["']([^"']+)["'][^>]*>/i)?.[1]
	if (href)
		return stripTags(href)

	return getTag(source, 'link')
}

时间统一格式化成上海时区:

ts
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

ts
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 地址。

接着写单个友链的抓取逻辑:

这段逻辑会依次尝试:

  1. entry.feed
  2. 站点地址/feed/
  3. 站点地址/atom.xml
  4. 站点地址/index.xml

只要其中一个成功,就返回文章列表。

生成博友圈 JSON 数据

最后导出一个生成函数:

ts
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,
	}
}

返回结构类似这样:

json
{
  "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 接口

新建文件:

txt
server/routes/all.json.get.ts

内容很简单:

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()
})

这里设置了缓存:

txt
public, max-age=600, s-maxage=1800

含义是:

  • 浏览器缓存 10 分钟;
  • 边缘缓存 30 分钟。

这样可以减少每次访问页面时重复抓取友链 RSS 的压力。

访问:

txt
/all.json

就能看到聚合后的博友圈数据。

新建博友圈页面

新建文件:

txt
app/pages/circle.vue

先写数据类型和请求逻辑:

这里用了:

ts
layoutStore.setAside([])

目的是让友圈页面不显示文章侧边栏,页面视觉更干净。

添加分页和统计数据

继续在 <script setup> 里添加:

其中:

ts
const pageSize = 5

表示每页展示 5 篇文章。

编写页面模板

完整模板如下:

这里大量复用了 Clarity 主题已有组件和样式类:

组件 / 类名作用
BlogHeader移动端页面标题
UtilHydrateSafe避免水合问题
BlogWidget主题小组件卡片
ZDlGroup数据统计组
UtilLink主题链接组件
ZPagination主题分页组件
card卡片样式
upraise悬浮抬升效果
text-creative主题标题文字效果
proper-height主题动画辅助类

添加页面样式

继续在 circle.vue 里添加样式:

文章列表样式:

移动端适配:

scss
@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;
	}
}

添加左侧导航入口

打开:

txt
app/app.config.ts

找到左侧导航:

ts
nav: [
	{
		title: '',
		items: [
			{ icon: 'tabler:files', text: '文章', url: '/' },
			{ icon: 'tabler:archive', text: '归档', url: '/archive' },
			{ icon: 'tabler:link', text: '友链', url: '/link' },
		],
	},
] satisfies Nav,

新增一项:

ts
{ icon: 'tabler:circle-dashed', text: '友圈', url: '/circle' },

完整示例:

ts
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,

这样侧边栏就会出现「友圈」入口。

本地测试

先安装依赖:

bash
pnpm install

启动开发服务:

bash
pnpm dev

访问:

txt
http://localhost:3000/circle

也可以单独看接口数据:

txt
http://localhost:3000/all.json

如果页面正常,会看到类似数据:

txt
友链 4
活跃 4
文章 20
异常 0

文章列表会按发布时间倒序排列。

构建检查

开发完成后执行:

bash
pnpm build

如果构建通过,就说明 Nuxt 页面、服务端接口和类型基本没有问题。

部署到 Vercel

如果项目已经配置好 Vercel,可以直接部署:

bash
pnpm run deploy:vercel

部署完成后访问:

txt
https://你的域名/circle

以及:

txt
https://你的域名/all.json

确认线上接口和页面都正常即可。

常见问题

某个友链没有文章

优先检查它的 feed 字段是否正确。

可以尝试这些常见地址:

txt
https://example.com/feed/
https://example.com/atom.xml
https://example.com/index.xml
https://example.com/rss.xml

页面显示“博友圈数据加载失败”

可能原因:

  • 某个 RSS 地址请求超时;
  • 远程站点屏蔽服务器请求;
  • RSS XML 格式异常;
  • Vercel Serverless 网络请求失败。

可以查看服务端日志里是否有:

txt
[friend-circle] xxx 抓取失败

文章时间排序不准

目前代码会把时间格式化为:

txt
YYYY-MM-DD HH:mm

再排序时用:

ts
new Date(b.created).getTime() - new Date(a.created).getTime()

一般情况下够用。如果遇到特殊运行环境解析不稳定,可以在内部排序时保留原始时间戳,再单独展示格式化时间。

RSS 摘要太长

目前摘要截断长度是:

ts
slice(0, 160)

可以按需要改成:

ts
slice(0, 100)

或者:

ts
slice(0, 200)

想调整每页文章数

app/pages/circle.vue 里修改:

ts
const pageSize = 5

例如每页 10 篇:

ts
const pageSize = 10

总结

这次新增的「博友圈」页面没有引入额外 RSS 解析库,而是使用 Nuxt 服务端接口直接抓取友链订阅源,再在前端页面中展示。

整体改动文件主要有:

txt
app/feeds.ts
server/utils/friend-circle.ts
server/routes/all.json.get.ts
app/pages/circle.vue
app/app.config.ts

实现后的页面和 Clarity 主题风格保持一致,同时也让友链页面不只是静态链接列表,而是变成了一个可以持续更新的「邻居动态」入口。

新故事即将发生
从 Typecho 迁移到 blog-v3:一次把博客搬进 Nuxt Content 的完整记录

评论区

评论加载中...