个人博客需要多少访问统计?对 EchoBlog 来说,答案不是完整用户画像,也不是一套复杂分析后台,而是三个足够回答维护问题的信号:站点最近是否有人访问、哪些内容被打开、最近七天的变化是否异常。
这组需求看似只需要一个计数器,真正实现时却会遇到预取请求、搜索爬虫、SPA 路由、重复刷新、隐私偏好和历史样例数据。本文记录当前实现怎样在“有用”和“克制”之间取舍,也明确它还不能替代专业分析服务的部分。
先把读取和记录拆开
旧版接口在读取统计时同时增加计数。浏览器预取、服务端渲染、健康检查和搜索爬虫都可能发起 GET,于是一个看似普通的读取动作会悄悄改变数据。
RFC 9110 对安全方法的定义强调,GET 的语义应当基本只读,自动抓取和预取也正依赖这个约束。现在接口被拆成两种职责:
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,服务端只保存摘要键和聚合数字。
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: 1 和 navigator.globalPrivacyControl。当浏览器表达该偏好时,EchoBlog 只读取公开统计,不写入会话或访客记录。
DNT 已经被弃用并由 GPC 接替,但 MDN 的 DNT 文档仍建议兼容已有的 DNT: 1 信号。因此客户端和服务端都会检查两者,同时过滤:
Purpose或Sec-Purpose中的预取请求。- 常见爬虫、Lighthouse、预览器和命令行客户端 User-Agent。
- 不在站点真实路由集合中的路径。
这里的目标不是声称自动满足所有地区法律,而是让实现的默认行为与站点的实际需求相称。法律合规仍需要结合部署地区、隐私声明和具体数据用途单独判断。
路由白名单控制数据边界
只用正则限制路径格式还不够。攻击者仍可以不断提交看起来合法但不存在的 slug,让本地 KV 文件持续增长。
Nitro 第一次处理记录请求时,会从 Nuxt Content 文章、项目集合和 taxonomy 注册表建立真实路由集合。POST 中的路径必须同时通过格式规范化和集合校验:
静态页面 + 已发布文章 + 已登记项目 + 标签页 + 分类页
查询参数、hash、API 地址和不存在的内容 slug 都不会创建统计键。部署新内容时 Node 进程会重新启动,路由缓存也会随新版本重建。
文章阅读量属于运行时数据
过去每篇 Markdown 都写着一个 views 数字。它让列表看起来完整,却无法反映真实阅读,也要求作者手工维护运行时数据。
现在文章 Frontmatter 只描述内容事实,阅读量从 /articles/:slug 的路径计数派生。内容创建脚本不再生成 views,发布校验也会拒绝手写该字段。统计尚未加载时,界面显示破折号而不是假装为零;加载完成后,文章列表、详情页和热门排序共用同一份状态。
这使内容和运行时数据各自拥有明确的来源:
| 数据 | 来源 | 生命周期 |
|---|---|---|
| 标题、摘要、标签 | Markdown Frontmatter | 随内容版本发布 |
| 总访问与七日趋势 | Nitro storage | 随线上访问更新 |
| 文章阅读量 | 真实路径计数 | 随会话访问更新 |
| 每日 UV | 每日匿名摘要 | 摘要键保留 8 天,聚合数字保留 |
迁移不能把样例数字当历史
原型阶段首页曾以 12,048 作为展示基线。如果直接上线新逻辑,这个数字会永久混入真实总数。
存储因此带有版本号。第一次写入 v2 统计时,迁移函数只移除一次演示基线,并保留之后已经产生的真实增量。升级后的首次记录还会扫描旧版每日数据:visitors[] 只用于恢复已有 UV 数字,随后整批移除,历史记录只留下日期、PV 和 UV。
测试关注口径而不只是返回值
统计逻辑至少需要覆盖以下不变量:
- GET 不写数据、不创建 Cookie。
- 同一会话不会重复增加站点访问和同一路径计数。
- 每日盐值变化后,摘要不能跨日相等。
- 只有来自本机反向代理的转发地址可以被信任。
- GPC、DNT、预取和爬虫请求不会进入统计。
- 不存在的路由不能污染存储。
项目把纯摘要、路径规范化和隐私信号判断放进单元测试,再用生产构建后的接口冒烟验证 Cookie、状态码和路径拒绝行为。相比只断言一个数字增加,这些测试更能保护统计口径。
什么时候应该换成专业分析服务
当前实现使用 Nitro 文件存储和进程内串行写入,适合单机、低流量个人站。它不提供来源分析、转化事件、运营后台或多实例一致性。
出现以下条件时,就应迁移到 SQLite、PostgreSQL,或者使用 Umami、Plausible 等专门服务:
- Node 服务开始多实例运行。
- 需要可靠的来源、地区或转化分析。
- 本地 KV 写入和清理开始影响响应时间。
- 需要可审计的数据保留和删除策略。
在此之前,少采集、短保留、清楚表达口径,比提前搭建一套复杂分析平台更符合这个个人站的实际目标。
