EchoBlog

整理首屏资源

8%
EchoBlog
返回文章
前端开发

在 Nuxt 中实现克制的访问统计

复盘 EchoBlog 如何区分只读查询与访问记录,用短期会话、每日匿名摘要和路由白名单得到足够有用的站点趋势。

在 Nuxt 中实现克制的访问统计

个人博客需要多少访问统计?对 EchoBlog 来说,答案不是完整用户画像,也不是一套复杂分析后台,而是三个足够回答维护问题的信号:站点最近是否有人访问、哪些内容被打开、最近七天的变化是否异常。

这组需求看似只需要一个计数器,真正实现时却会遇到预取请求、搜索爬虫、SPA 路由、重复刷新、隐私偏好和历史样例数据。本文记录当前实现怎样在“有用”和“克制”之间取舍,也明确它还不能替代专业分析服务的部分。

先把读取和记录拆开

旧版接口在读取统计时同时增加计数。浏览器预取、服务端渲染、健康检查和搜索爬虫都可能发起 GET,于是一个看似普通的读取动作会悄悄改变数据。

RFC 9110 对安全方法的定义强调,GET 的语义应当基本只读,自动抓取和预取也正依赖这个约束。现在接口被拆成两种职责:

HTTPrequest
GET /api/visits

POST /api/visits
Content-Type: application/json

{ "path": "/articles/privacy-friendly-nuxt-analytics" }

GET 只返回统计快照,不写存储,也不创建 Cookie。只有浏览器完成挂载或发生真实 SPA 路由切换后,客户端插件才发送 POST。这样构建、SSR 和巡检不会再制造访问量。

统计的是会话,不是每一次请求

首页显示的“总访问次数”以 30 分钟活动会话为口径。同一会话中刷新页面或继续浏览,不会重复增加站点总访问;不同路径则各自最多记录一次,用于文章阅读量与热门排序。

服务端只设置一个短期 HttpOnly 会话 Cookie,并具备以下属性:

  • Max-Age 为 30 分钟,每次有效浏览会刷新窗口。
  • SameSite=Lax,不参与跨站请求。
  • HTTPS 下启用 Secure
  • 不保存姓名、账户、邮箱或业务身份。

客户端也使用同一个 30 分钟常量抑制重复上报,避免前后端各自维护一个容易漂移的时间窗口。

每日 UV 不需要长期访客 ID

一开始的实现使用一年期随机访客 Cookie,再把它哈希后写入服务端。它已经比保存原始标识更克制,但仍然允许跨日关联同一浏览器。

参考 Plausible 公布的数据策略,当前版本改成按日匿名摘要:请求中的客户端地址与 User-Agent 只在内存中参与 HMAC,服务端只保存摘要键和聚合数字。

TEXTformula
dailyDigest = HMAC-SHA256(
  dailySalt,
  clientAddress + userAgent
)

每日盐值随机生成,并在下一天清理。即使两个日期来自同一设备,摘要也无法直接关联;原始地址和完整 User-Agent 不会写入统计存储。无法获得客户端地址时,则使用短期会话 ID 作为降级输入。

反向代理头不能无条件信任。EchoBlog 的 Node 进程只监听 127.0.0.1,代码仅在连接来自本机 Nginx 时读取 X-Forwarded-For;如果以后把 Node 端口直接暴露到公网,转发头不会参与摘要计算。

每日 UV 是按天去重后的访客数,七日 UV 是七个每日数字之和,并不尝试跨日建立用户身份。这正是刻意保留的边界。

明确尊重隐私信号

Global Privacy Control 规范定义了 Sec-GPC: 1navigator.globalPrivacyControl。当浏览器表达该偏好时,EchoBlog 只读取公开统计,不写入会话或访客记录。

DNT 已经被弃用并由 GPC 接替,但 MDN 的 DNT 文档仍建议兼容已有的 DNT: 1 信号。因此客户端和服务端都会检查两者,同时过滤:

  • PurposeSec-Purpose 中的预取请求。
  • 常见爬虫、Lighthouse、预览器和命令行客户端 User-Agent。
  • 不在站点真实路由集合中的路径。

这里的目标不是声称自动满足所有地区法律,而是让实现的默认行为与站点的实际需求相称。法律合规仍需要结合部署地区、隐私声明和具体数据用途单独判断。

路由白名单控制数据边界

只用正则限制路径格式还不够。攻击者仍可以不断提交看起来合法但不存在的 slug,让本地 KV 文件持续增长。

Nitro 第一次处理记录请求时,会从 Nuxt Content 文章、项目集合和 taxonomy 注册表建立真实路由集合。POST 中的路径必须同时通过格式规范化和集合校验:

TEXT
静态页面 + 已发布文章 + 已登记项目 + 标签页 + 分类页

查询参数、hash、API 地址和不存在的内容 slug 都不会创建统计键。部署新内容时 Node 进程会重新启动,路由缓存也会随新版本重建。

文章阅读量属于运行时数据

过去每篇 Markdown 都写着一个 views 数字。它让列表看起来完整,却无法反映真实阅读,也要求作者手工维护运行时数据。

现在文章 Frontmatter 只描述内容事实,阅读量从 /articles/:slug 的路径计数派生。内容创建脚本不再生成 views,发布校验也会拒绝手写该字段。统计尚未加载时,界面显示破折号而不是假装为零;加载完成后,文章列表、详情页和热门排序共用同一份状态。

这使内容和运行时数据各自拥有明确的来源:

数据来源生命周期
标题、摘要、标签Markdown Frontmatter随内容版本发布
总访问与七日趋势Nitro storage随线上访问更新
文章阅读量真实路径计数随会话访问更新
每日 UV每日匿名摘要摘要键保留 8 天,聚合数字保留

迁移不能把样例数字当历史

原型阶段首页曾以 12,048 作为展示基线。如果直接上线新逻辑,这个数字会永久混入真实总数。

存储因此带有版本号。第一次写入 v2 统计时,迁移函数只移除一次演示基线,并保留之后已经产生的真实增量。升级后的首次记录还会扫描旧版每日数据:visitors[] 只用于恢复已有 UV 数字,随后整批移除,历史记录只留下日期、PV 和 UV。

测试关注口径而不只是返回值

统计逻辑至少需要覆盖以下不变量:

  1. GET 不写数据、不创建 Cookie。
  2. 同一会话不会重复增加站点访问和同一路径计数。
  3. 每日盐值变化后,摘要不能跨日相等。
  4. 只有来自本机反向代理的转发地址可以被信任。
  5. GPC、DNT、预取和爬虫请求不会进入统计。
  6. 不存在的路由不能污染存储。

项目把纯摘要、路径规范化和隐私信号判断放进单元测试,再用生产构建后的接口冒烟验证 Cookie、状态码和路径拒绝行为。相比只断言一个数字增加,这些测试更能保护统计口径。

什么时候应该换成专业分析服务

当前实现使用 Nitro 文件存储和进程内串行写入,适合单机、低流量个人站。它不提供来源分析、转化事件、运营后台或多实例一致性。

出现以下条件时,就应迁移到 SQLite、PostgreSQL,或者使用 Umami、Plausible 等专门服务:

  • Node 服务开始多实例运行。
  • 需要可靠的来源、地区或转化分析。
  • 本地 KV 写入和清理开始影响响应时间。
  • 需要可审计的数据保留和删除策略。

在此之前,少采集、短保留、清楚表达口径,比提前搭建一套复杂分析平台更符合这个个人站的实际目标。