Astro 静态博客文章推荐功能的实现思路
一、需求分析
博客文章详情页底部通常只有”上一篇 / 下一篇”导航,用户读完后缺乏继续浏览的动力。
我希望在这里加一个推荐组件,分为两栏:
- 左栏 — 相关文章:基于算法在构建时预计算,纯静态 HTML,零客户端 JS
- 右栏 — 随机文章:每次刷新都不同,需要客户端 JS 实现
核心约束:这是 Astro 静态站点,没有服务端运行时。“相关”可以在构建时确定,“随机”只能在客户端实现。
这个功能使用 Claude Code 协助完成。

二、推荐算法设计
相关文章的推荐采用多维度综合评分,公式如下:
totalScore = tagMatchScore + titleSimilarityScore + timeFreshnessScore + categoryBonus1. 标签匹配分(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,而是分两轮:
- 优先取有标签匹配的(tagMatchScore > 0),按总分排序
- 不足 5 篇时,从无标签匹配的候选中按
timeFreshnessScore + categoryBonus降序补充 - 跳过加密文章,加密文章不会出现在推荐列表
这样保证了:有相关标签的文章一定优先,没有标签匹配时也不会显示空列表。
三、随机文章的实现
问题:静态站无法实现服务端随机
Astro 构建后输出纯 HTML,构建时的 Math.random() 只执行一次,结果被固化到 HTML 中。每次刷新看到的都是同样的”随机”结果。
方案:复用已有 API + 客户端渲染
项目中已有一个 /api/allPostMeta.json 端点(日历组件在用),返回所有文章的元信息。直接复用它:
- 给 API 补充
category和password字段 - 客户端 fetch 这个 JSON,过滤掉当前文章、相关文章和加密文章
- Fisher-Yates 洗牌后取前 5 篇
- 用 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 友好 |
| 左栏渲染 | 构建时静态 HTML | SEO 友好,零运行时开销 |
| 右栏渲染 | 客户端 DOM API | 每次刷新真随机,体积极小 |
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!