Astro 静态博客文章推荐功能的实现思路

1189 字
6 分钟
Astro 静态博客文章推荐功能的实现思路

一、需求分析#

博客文章详情页底部通常只有”上一篇 / 下一篇”导航,用户读完后缺乏继续浏览的动力。

我希望在这里加一个推荐组件,分为两栏:

  • 左栏 — 相关文章:基于算法在构建时预计算,纯静态 HTML,零客户端 JS
  • 右栏 — 随机文章:每次刷新都不同,需要客户端 JS 实现

核心约束:这是 Astro 静态站点,没有服务端运行时。“相关”可以在构建时确定,“随机”只能在客户端实现。

这个功能使用 Claude Code 协助完成。

实现效果
实现效果

二、推荐算法设计#

相关文章的推荐采用多维度综合评分,公式如下:

totalScore = tagMatchScore + titleSimilarityScore + timeFreshnessScore + categoryBonus

1. 标签匹配分(0-100)#

使用 Jaccard 相似度衡量两篇文章标签的重叠程度:

Jaccard(A, B) = |A ∩ B| / |A ∪ B|
tagMatchScore = Jaccard(当前文章标签, 候选文章标签) × 100

标签完全相同得 100 分,完全不同得 0 分。这是最直观的相关性信号。

2. 标题相似度分(0-100)#

标签可能为空或过于宽泛,标题往往能更精确地反映文章主题。同样使用 Jaccard 相似度,但需要先对标题分词。

关键问题是中英文混合标题的分词。方案对比:

方案优点缺点
按空格分割简单中文完全失效
jieba 等分词库效果好引入重依赖
Intl.Segmenter零依赖,Node.js 内置需要 Node 16+

最终选择 Intl.Segmenter,零依赖且天然支持中英混合:

function tokenizeTitle(title: string): Set<string> {
const tokens = new Set<string>();
const segmenter = new Intl.Segmenter("zh", { granularity: "word" });
for (const { segment, isWordLike } of segmenter.segment(title)) {
if (!isWordLike) continue; // 过滤标点和空白
tokens.add(segment.toLowerCase()); // 英文统一小写
}
return tokens;
}

例如标题 "Astro 博客主题开发指南" 会被分为 {"astro", "博客", "主题", "开发", "指南"}

3. 时间新鲜度分(0-30)#

采用 6 个月半衰期的指数衰减:

timeFreshnessScore = 30 × e^(-ln2 × daysSincePublished / 180)

刚发布的文章得 30 分,6 个月前的约 15 分,一年前约 7.5 分。这样在其他维度得分接近时,新文章会排在前面。

4. 分类加成(0 或 10)#

同分类的文章额外加 10 分,作为锦上添花的信号。

5. 选取策略#

不是简单地取 Top N,而是分两轮:

  1. 优先取有标签匹配的(tagMatchScore > 0),按总分排序
  2. 不足 5 篇时,从无标签匹配的候选中按 timeFreshnessScore + categoryBonus 降序补充
  3. 跳过加密文章,加密文章不会出现在推荐列表

这样保证了:有相关标签的文章一定优先,没有标签匹配时也不会显示空列表。

三、随机文章的实现#

问题:静态站无法实现服务端随机#

Astro 构建后输出纯 HTML,构建时的 Math.random() 只执行一次,结果被固化到 HTML 中。每次刷新看到的都是同样的”随机”结果。

方案:复用已有 API + 客户端渲染#

项目中已有一个 /api/allPostMeta.json 端点(日历组件在用),返回所有文章的元信息。直接复用它:

  1. 给 API 补充 categorypassword 字段
  2. 客户端 fetch 这个 JSON,过滤掉当前文章、相关文章和加密文章
  3. Fisher-Yates 洗牌后取前 5 篇
  4. 用 DOM API 渲染列表
// 使用缓存避免重复请求
if (window.__allPostMetaCache) {
render(window.__allPostMetaCache);
} else {
fetch(apiUrl)
.then(r => r.json())
.then(data => {
window.__allPostMetaCache = data;
render(data);
});
}

缓存共享#

日历组件和随机文章组件共享同一个 window.__allPostMetaCache,无论哪个先加载,API 只请求一次。swup 页面切换时也不会重复请求,只是重新随机选取并渲染。

四、组件架构#

RecommendedPost.astro
├── 左栏:相关文章(Astro 静态渲染)
│ ├── 构建时由 getRelatedPosts() 预计算
│ └── 输出纯 HTML,零 JS
└── 右栏:随机文章(客户端渲染)
├── 只渲染卡片骨架
├── <div id="random-posts-list"> 作为挂载点
└── inline script fetch API 后 DOM 渲染

两栏各自独立的 card-base 卡片,通过 grid grid-cols-1 md:grid-cols-2 响应式布局。

五、关键设计决策总结#

决策选择原因
分词方案Intl.Segmenter零依赖,Node.js 内置
相似度算法Jaccard简单有效,适合集合比较
随机实现客户端 fetch + shuffle静态站能实现真随机的方式
数据源复用 allPostMeta API避免新建端点,与日历共享缓存
缓存策略window.__allPostMetaCache跨组件共享,swup 友好
左栏渲染构建时静态 HTMLSEO 友好,零运行时开销
右栏渲染客户端 DOM API每次刷新真随机,体积极小

feat: 添加文章推荐组件(相关文章 +随机文章)

支持与分享

如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!

赞助
Astro 静态博客文章推荐功能的实现思路
https://blog.cuteleaf.cn/posts/dev-notes/astro-blog-recommend-algorithm/
作者
夏叶
发布于
2026-02-27
许可协议
CC BY-NC-SA 4.0

评论区

Profile Image of the Author
夏叶
Hello, I'm XIAYE.
公告
欢迎来到我的博客,从2025年起,将会使用AI对文章进行润色。
音乐
封面

音乐

暂未播放

0:00 0:00
暂无歌词
分类
标签
站点统计
文章
58
分类
7
标签
39
总字数
34,520
运行时长
0
最后活动
0 天前

目录