惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Simon Willison's Weblog
Simon Willison's Weblog
Help Net Security
Help Net Security
P
Privacy International News Feed
T
Threat Research - Cisco Blogs
C
Cisco Blogs
C
CERT Recently Published Vulnerability Notes
NISL@THU
NISL@THU
L
LINUX DO - 热门话题
Security Latest
Security Latest
A
Arctic Wolf
G
GRAHAM CLULEY
月光博客
月光博客
S
Securelist
D
Docker
J
Java Code Geeks
T
Troy Hunt's Blog
T
Tenable Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
SecWiki News
SecWiki News
S
Security @ Cisco Blogs
量子位
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
L
LINUX DO - 最新话题
Recent Commits to openclaw:main
Recent Commits to openclaw:main
aimingoo的专栏
aimingoo的专栏
博客园 - 【当耐特】
H
Heimdal Security Blog
The Hacker News
The Hacker News
博客园 - 三生石上(FineUI控件)
Application and Cybersecurity Blog
Application and Cybersecurity Blog
N
Netflix TechBlog - Medium
Vercel News
Vercel News
Forbes - Security
Forbes - Security
B
Blog RSS Feed
H
Hackread – Cybersecurity News, Data Breaches, AI and More
IT之家
IT之家
B
Blog
MongoDB | Blog
MongoDB | Blog
博客园 - 聂微东
Google DeepMind News
Google DeepMind News
S
Secure Thoughts
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
C
Check Point Blog
云风的 BLOG
云风的 BLOG
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
T
The Blog of Author Tim Ferriss
L
Lohrmann on Cybersecurity
F
Full Disclosure
D
Darknet – Hacking Tools, Hacker News & Cyber Security
P
Proofpoint News Feed

心记|Mood

给 blog-v3 添加一个侧边栏音乐播放器模块 | 心记|Mood 从 Typecho 迁移到 blog-v3:一次把博客搬进 Nuxt Content 的完整记录 | 心记|Mood 组件样式示例 | 心记|Mood
给 blog-v3 新建一个博友圈页面:聚合友链最新文章 | 心记|Mood
MoodLog, admin@moodlog.cn · 2026-05-19 · via 心记|Mood

记录在 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

但最好还是显式写好。

新建文件:

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}`
}

接下来写 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 地址。

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

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

ts
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 []
}

这段逻辑会依次尝试:

  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

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

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>

这里用了:

ts
layoutStore.setAside([])

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

添加分页和统计数据

继续在 <script setup> 里添加:

ts
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}的博友圈,聚合友链站点的最新文章。`,
})

其中:

ts
const pageSize = 5

表示每页展示 5 篇文章。

编写页面模板

完整模板如下:

vue
<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 里添加样式:

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>

文章列表样式:

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

移动端适配:

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

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

目前摘要截断长度是:

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 主题风格保持一致,同时也让友链页面不只是静态链接列表,而是变成了一个可以持续更新的「邻居动态」入口。