症状:总数对得上,单项开始偏移

我的个人主页(lucky845.top)上有一块「站点档案」和一张「技术栈」柱状图,数字来自博客。

一开始这些数字是手写死的。博客那边发一篇新文章,主页纹丝不动——要我自己想起来去改。

我以为问题不大,直到某天做了一次逐项比对:

项主页写死博客真实值状态
笔记137137一致
分类2323一致
标签172172一致
归档页1212一致
Java7475少 1
Spring Boot2021少 1
Linux1415少 1
性能优化1314少 1

这个结果很有意思:总数全对,单项开始漂移。

原因是那种「改完顺手更新一下总数」的惯性——加了一篇 Java 文章,你记得把 137 改成 138,但忘了把 Java 的 74 改成 75。

这就是手工同步的典型失效形态:不是全错,是错得刚刚好让你发现不了。

先想清楚:这不是「数字对不对」的问题

真正要解决的是:两个独立仓库(主页 / 博客)之间,数据在哪一点保持一致。

我的约束有三条:

  1. 主页是公开仓库,博客源码是私有仓库;
  2. 主页刚做完首屏优化(单文件 HTML 拆成了 Vite + Vue 3),不能为了几个数字把首屏拖回去;
  3. 博客可能临时不可达(部署中、证书、CI 抖动),任何情况下都不能让主页部署失败。

三个方案,否掉两个

方案 A:浏览器运行时抓取(否)

主页加载后 fetch 博客的接口。听起来最「实时」,但:

  • 首屏多一次跨域请求,正好踩在刚修好的白屏问题上;
  • 博客挂了或慢,主页就跟着挂或跟着慢;
  • 用户白等一个「等数据才敢渲染」的区块。

运行时实时性换来的是用户端成本。 几个静态数字不值得。

方案 B:主页构建时读博客源码(否)

主页 CI 里把私有仓库 clone 下来自己算。问题是:

  • 需要跨仓库凭据,还得维护;
  • 主页要理解 Hexo 的目录结构和分类规则——把博客的实现细节泄漏到主页,博客一改结构主页就炸;
  • 主页的构建时间从秒级变成分钟级。

耦合太重。

方案 C:构建期同步(选)

链路拆成三段,每段只做一件事:

博客 hexo generate
   └─ 顺带产出 stats.json          ← 博客自己最清楚自己的统计
        ↓ 静态文件
主页 npm run build
   └─ 构建前抓 stats.json,写进 src/data/blog-stats.json
        ↓ 静态数据
Vite 打包进产物                    ← 用户端零成本

好处很直接:

  • 网络请求只发生在构建机上,用户端零成本,不影响首屏;
  • 统计口径由博客自己定义,主页只消费,不猜;
  • 主页仓库里存着上一次成功的数据,天然就是缓存。

博客侧:把口径写进代码

博客是 Hexo。加一个 generator 就能在 hexo generate 时多吐一个文件:

hexo.extend.generator.register('stats', function (locals) {
  const posts = locals.posts.toArray();
  const perPage = Number(hexo.config.per_page) || 10;

  const categories = locals.categories.toArray();
  const tags = locals.tags.toArray();

  const stack = tags
    .map((tag) => ({ name: tag.name, count: tag.length }))
    .filter((item) => item.count > 0)
    .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name))
    .slice(0, 10);

  return {
    path: 'stats.json',
    data: JSON.stringify({
      posts: posts.length,
      categories: categories.length,
      tags: tags.length,
      archivePages: Math.max(1, Math.ceil(posts.length / perPage)),
      stack,
      generatedAt: new Date().toISOString(),
    }, null, 2),
  };
});

几个口径必须写在注释里,否则半年后没人知道 categories: 23 到底在数什么:

/**
 * 口径说明(改之前先想清楚,主页的文案是跟着这些口径写的):
 *   posts        文章总数
 *   categories   分类节点总数(多级分类的每一层都算一个页面)
 *   tags         标签总数
 *   archivePages 归档页数(按 per_page 换算)
 *   stack        出现次数前 10 的标签
 */

「分类 23 个」不是「23 个顶级分类」。 它是分类节点总数——多级分类的每一层都算一个页面。这个口径不改,是因为主页文案写的是「分类 23 个」,和 sitemap 里的 category 页面数完全对得上(24 个文件,其中 index 自身 1 个)。

这种「看起来像 bug 其实是口径」的地方,如果不写下来,下一个人一定会来「修」它。

主页侧:抓取 + 校验 + 三层兜底

主页构建前跑一个脚本,核心逻辑只有三件事:抓、验、退。

const SOURCE = process.env.STATS_URL || 'https://blog.lucky845.top/stats.json'
const TIMEOUT_MS = 10_000
const RETRIES = 2

第一层:抓,带超时和重试

const controller = new AbortController()
const timer = setTimeout(() => controller.abort(), TIMEOUT_MS)
const response = await fetch(SOURCE, { signal: controller.signal /* ... */ })
if (!response.ok) throw new Error(`HTTP ${response.status}`)

fetch 默认没有超时。不加 AbortController,博客无响应时构建会挂在那儿直到 CI 自己超时——那是最难排查的一种失败。

重试间隔递增(1.5s、3s),只重试 2 次。再多就是拿构建时间赌网络的稳定性。

第二层:验,结构不对就当没抓到

这一层是重点。「拿到了数据」和「拿到了对的数据」是两件事。

function validate(data) {
  if (!data || typeof data !== 'object') return '不是对象'
  for (const key of ['posts', 'categories', 'tags', 'archivePages']) {
    const value = data[key]
    // 0 视为无效:这个站常年上百篇笔记,任何一项为 0 都是数据不完整
    if (!Number.isInteger(value) || value <= 0) return `${key} 不是正整数:${String(value)}`
  }
  if (!Array.isArray(data.stack) || data.stack.length === 0) return 'stack 不是非空数组'
  const badItem = data.stack.find(
    (item) => !item || typeof item.name !== 'string' ||
              !Number.isInteger(item.count) || item.count <= 0,
  )
  if (badItem) return 'stack 里有非法条目'
  if (typeof data.generatedAt !== 'string' || Number.isNaN(Date.parse(data.generatedAt))) {
    return `generatedAt 不是合法时间:${String(data.generatedAt)}`
  }
  // 明显不合理的值说明博客那边口径变了,宁可不用
  if (data.posts > 100_000 || data.stack.length > 50) return '数值超出合理范围'
  return null
}

其中有两条是踩出来的:

<= 0 而不是 < 0。 我一开始写的是「必须是整数」,结果 tags: 0 通过了校验,主页渲染出「0 个标签」。一个 137 篇文章的站不可能有 0 个标签——所以 0 必须当成「数据不完整」,而不是「一个合法的统计结果」。

上限防御。 posts > 100000 或 stack.length > 50 直接拒绝。这类检查平时永远不触发,触发的时候通常意味着博客那边口径变了(比如某次重构把 posts 算成了「页面数」)。宁可显示旧数字,也不要显示一个荒谬的新数字。

第三层:退,三级兜底

// 1. 抓 https://blog.lucky845.top/stats.json(博客构建时产出)
// 2. 抓不到就沿用 src/data/blog-stats.json 里上一次成功的数据
// 3. 连文件都没有才写 FALLBACK 常量

关键顺序是:只在「能通过校验」时才覆盖本地缓存。

const cached = await readCached()   // 读缓存时也走一遍 validate
if (cached) {
  console.warn(`[stats] 抓取失败(${lastError}),沿用上次缓存:${cached.generatedAt}`)
  return
}

如果反过来——先写文件再校验——那么一次博客抖动就会把主页的缓存刷成空值,而且是静默的:构建成功、部署成功、数字归零。

最后还有一层兜底:脚本自身的异常也不能让构建挂掉。

main().catch((error) => {
  // 走到这说明是脚本自身的 bug,同样不该让部署挂掉
  console.warn('[stats] 脚本异常,跳过统计同步:', error)
})

这类「装饰性数据」的同步,原则应该是:失败就降级,不要失败就阻塞。

实测:四种坏数据

写完不能只看代码。我造了四种坏输入,各跑一次:

输入结果
博客 404沿用缓存,构建成功
posts: "abc"退回兜底值 137
stack: []数字仍更新,图表用兜底值
tags: 0退回 172

第三条是设计取舍:stack 为空时数字更新、图表兜底。因为两者独立——总数是对的,只是标签榜暂时没拿到。

第四条就是上面说的 0 值漏洞,实测才发现。

还要验证「它真的是动态的」

最容易骗过自己的一种「验证」是:改了数据源,看到页面数字变了,就以为通了。但如果那只是缓存碰巧刷新了呢?

所以做了一次真实实验:

  1. 造一篇带新标签的测试文章;
  2. 真跑一次 hexo generate;
  3. 看数字:
posts:    137 → 138
分类:      23 → 24
tags:     172 → 173
Java:      75 → 76
  1. 删掉文章,再跑一次,全部还原。

这才叫验证:不是「看起来对了」,是「我让它变了一次,它跟着变了;我让它变回去,它跟着回去了」。

一个看起来像 bug 的地方:归档页永远是 12

部署完成后线上显示「归档 12 页」。你可能觉得它没更新——毕竟文章从 137 涨到了 140。

不是 bug。归档页数按 per_page: 12 换算:

archivePages = Math.max(1, Math.ceil(posts / 12))

在文章数到 145 之前,它必然是 12。这是分页逻辑本身的性质,不是同步失效。

写在这里,是因为「数字不动」和「数字错了」在监控上看完全一样,不解释清楚下次又要重新排查一遍。

最后:还有什么是手工的

诚实交代边界——「公开项目 8 个」仍然是手写的。

GitHub 的公开仓库数量,博客的统计能力覆盖不到。要自动化得再接一次 GitHub API,引入 token 管理成本。这个成本我认为暂时不值得,所以没有擅自加。

想加的时候,链路也是同一个:构建期查一次 API,走同一套 validate + 兜底。

小结

决策选择理由
同步点构建期用户端零成本,不碰首屏
数据源博客自己产出统计口径归博客,主页不猜
失败策略降级不阻塞装饰性数据不该拖垮部署
覆盖条件通过校验才覆盖防止静默刷成空值
0 值视为无效137 篇文章不可能 0 个标签

如果只记一条:

跨仓库的一致性,要先选对「同步点」。
运行时同步是最贵的(用户付成本),构建时同步是最便宜的(构建机付一次成本)。

顺便说一句,这套链路现在反过来在帮我做验证:这篇文章发出去之后,主页的「笔记」应该从 137 自动变成 140。如果没变,说明同步链路坏了——那也是一条有用的结果。