博客迁移到 Astro 和 Cloudflare Pages 后,页面已经足够轻,也能完整静态部署。但一个长期写作的站点还需要回答另外两个问题:文章是否真的有人看,以及搜索引擎能否正确理解这些内容。

最开始我考虑过在项目里内嵌一个内存数据库。这个方案在普通服务器程序上或许可以作为临时缓存使用,却不适合静态站点和无服务器运行时:静态文件本身没有常驻进程,Pages Functions 的实例也可能随时创建和回收,内存中的计数既不能持久化,也无法在多个实例之间共享。

最终我使用 Cloudflare Web Analytics + Pages Functions + D1:Web Analytics 负责站点级访问趋势,D1 负责文章阅读量和自定义点击事件;同时把 SEO 元数据、结构化数据和构建检查补齐。这篇文章记录完整的设计、实现、上线和排错过程。

如果想先了解博客的选型、双仓库发布和域名迁移,可以阅读上一篇:从零重写个人博客:Astro + React + MDX + Cloudflare Pages。

最终架构

这套统计并没有把博客改成动态站点。文章、首页、归档和标签仍由 Astro 构建为静态 HTML,只有 /api/analytics 交给 Pages Functions 处理。

flowchart LR
  A[访问 lucaslz.com] --> B[Cloudflare CDN]
  B --> C[Astro 静态页面]
  B --> D[Web Analytics]
  C --> E[前端统计脚本]
  E --> F[/api/analytics<br/>Pages Functions]
  F --> G[(Cloudflare D1)]
  H[GitHub Actions] --> I[构建与 SEO 检查]
  I --> B

两套统计各自解决不同问题:

能力Cloudflare Web AnalyticsD1 自定义统计
页面访问、访客趋势是可记录页面浏览
来源、国家和性能指标是不采集
文章累计阅读量不直接展示到页面是
标签、搜索、目录点击不适合自定义事件是
自己控制数据模型否是
客户端 Cookie不需要不需要

Web Analytics 用来观察全站,D1 用来回答与内容和交互有关的具体问题。它们不是重复建设,而是互补。

为什么不使用内存数据库

Cloudflare Pages 的主体仍然是静态文件。即使增加 Pages Functions,请求也运行在按需创建的 Worker 隔离环境中,不能假设某个 JavaScript 变量会一直存在。

如果这样记录阅读量:

let views = 0;

export function onRequest() {
  views += 1;
  return Response.json({ views });
}

它会遇到三个根本问题:

  1. 实例重启后数据归零。
  2. 多个实例各自维护一份计数。
  3. 新部署也会丢失原来的状态。

LocalStorage 中的数据只能保存在访客自己的浏览器里,也无法形成全站汇总。因此,需要一个真正的持久化存储。D1 是 Cloudflare 托管的 SQLite 数据库,能够直接绑定到 Pages Functions,部署结构最简单。

用每日聚合控制写入量

统计系统很容易采用“每次访问保存一条明细”的设计,但个人博客通常不需要保存访客级日志。我更关心某天某篇文章有多少次浏览、某个入口被点击多少次。

因此表结构采用每日聚合:

CREATE TABLE IF NOT EXISTS event_daily (
  event TEXT NOT NULL,
  path TEXT NOT NULL,
  target TEXT NOT NULL DEFAULT '',
  stat_date TEXT NOT NULL,
  count INTEGER NOT NULL DEFAULT 0 CHECK (count >= 0),
  updated_at TEXT NOT NULL DEFAULT (datetime('now')),
  PRIMARY KEY (event, path, target, stat_date)
) WITHOUT ROWID;

CREATE INDEX IF NOT EXISTS idx_event_daily_date
  ON event_daily (stat_date, event);

复合主键由 event + path + target + stat_date 组成。同一天内,同一事件在相同路径与目标下只更新一行,不保存 IP、User-Agent、完整 Referer 或用户身份。

这种设计的好处是:

  • 数据量随“页面 × 事件 × 天数”增长,而不是随访问次数增长。
  • 查询文章累计阅读量只需要对每日计数求和。
  • 不需要维护访客画像,隐私边界更清晰。
  • 即使访问量增加,存储和查询成本也比较可控。

目前允许的事件包括:

page_view
article_click
article_impression
tag_click
search_click
nav_click
toc_click
cta_click
outbound_click

Pages Functions 统计接口

在项目根目录增加 functions/api/analytics.js 后,Cloudflare Pages 会把它映射为:

GET  /api/analytics?event=page_view&path=/article
POST /api/analytics

GET 用于读取某篇文章的累计阅读量。POST 接收事件并使用 Upsert 更新当天计数:

await env.BLOG_ANALYTICS.prepare(
  `INSERT INTO event_daily
     (event, path, target, stat_date, count, updated_at)
   VALUES (?1, ?2, ?3, ?4, 1, datetime('now'))
   ON CONFLICT (event, path, target, stat_date)
   DO UPDATE SET
     count = count + 1,
     updated_at = datetime('now')`,
)
  .bind(event, path, target, statDate)
  .run();

接口还做了几项必要的约束:

  • 只接受白名单中的事件。
  • 统一清除路径尾部斜杠和查询参数。
  • 请求体最大为 2 KB。
  • POST 只接受同源请求。
  • 外部链接只保存域名和路径,不保存查询参数。
  • 所有响应都设置 Cache-Control: no-store。

这些限制不能阻止所有恶意流量,但能避免接口变成任意字符串收集器,也能减少把敏感查询参数写入数据库的风险。

前端如何记录和展示

页面加载后,统计组件会向 /api/analytics 写入一次 page_view。接口在完成写入后直接返回累计值,文章页再显示“多少次阅读”。

const response = await fetch("/api/analytics", {
  method: "POST",
  headers: { "content-type": "application/json" },
  body: JSON.stringify({
    event: "page_view",
    path: window.location.pathname,
  }),
  credentials: "same-origin",
});

const data = await response.json();
showPageViews(data.total);

为了避免在同一会话内刷新页面时计数不断增加,前端使用 sessionStorage 对 page_view 和曝光事件去重。这里的阅读量不是严格的独立访客数,而是一个轻量、可解释的会话级阅读计数。

点击类事件使用 navigator.sendBeacon() 发送。访客点击链接并立即离开当前页面时,Beacon 比普通异步请求更适合完成最后一次小数据上报。

所有埋点通过 HTML 的 data-* 属性声明,组件不需要直接依赖统计实现:

<a
  href={postHref}
  data-analytics-event="article_click"
  data-analytics-target={postHref}
>
  阅读文章
</a>

在 Cloudflare 创建并绑定 D1

代码完成后,还要在 Cloudflare 控制台配置真实数据库。

1. 创建数据库

进入:

Storage & databases
→ D1 SQL Database
→ Create database

数据库名称使用:

lucaslz-blog-analytics

创建完成后进入 D1 Console,执行项目中的 migrations/0001_analytics.sql,确认表和索引创建成功。

2. 绑定 Pages Functions

进入:

Workers & Pages
→ lucaslz-blog
→ Settings
→ Bindings
→ Add

选择 D1 database,变量名填写:

BLOG_ANALYTICS

再选择刚创建的 lucaslz-blog-analytics 数据库并保存。变量名必须与代码中的 env.BLOG_ANALYTICS 完全一致。

3. 重新部署

这里有一个容易忽略的细节:新绑定只对之后的部署生效。保存绑定后,已有 Deployment 不会自动获得新环境。

绑定完成但没有重新部署时,接口返回:

HTTP/2 503

{"enabled":false}

触发一次 GitHub Actions 部署后,Pages Functions 才能读到 D1 Binding。可以提交正常改动,也可以在没有文件变化时创建一次空提交:

git commit --allow-empty -m "chore: redeploy with D1 analytics"
git push origin main

开启 Cloudflare Web Analytics

D1 不适合重新实现一整套访客来源、地理分布、设备和性能分析。Cloudflare Web Analytics 已经提供这些站点级指标,而且不需要为每个访客创建客户端标识。

在 Cloudflare Dashboard 中进入:

Analytics
→ Web Analytics
→ Add a site

输入 lucaslz.com。由于域名已经属于当前 Cloudflare 账号,可以选择 Automatic setup,不需要手动把 Beacon 代码写入 Astro Layout。

完成后控制台会显示:

Web Analytics are now set up

数据不会立即出现在仪表盘中,首次启用后需要等待 Cloudflare 收集和处理访问数据。官方文档可参考:Cloudflare Web Analytics。

系统补齐 SEO

统计只能告诉我内容是否被访问,SEO 则决定搜索引擎和社交平台如何理解、收录和展示页面。这次不是只增加几个 <meta> 标签,而是由 BaseLayout 统一生成页面语义。

基础元数据

每个页面现在包含:

  • 唯一的 <title> 和 description。
  • 指向 https://lucaslz.com 的绝对 Canonical。
  • robots 索引指令。
  • zh-CN 和 x-default hreflang。
  • RSS 和 Web App Manifest 链接。
  • Open Graph 与 Twitter Card。
  • 1200 × 630 的绝对分享图片地址。

Canonical 使用当前路径和固定生产域名生成,避免 pages.dev 预览地址被当成另一份内容:

const canonical = new URL(Astro.url.pathname, Astro.site ?? SITE.url);

JSON-LD 结构化数据

普通页面输出 Person 和 WebSite。文章页额外输出:

  • BlogPosting
  • BreadcrumbList
  • 发布时间和更新时间
  • 作者、标签、关键词和字数
  • 主页面与分享图片

JSON-LD 由对象序列化生成,而不是手写 JSON 字符串;序列化后再转义 <,避免文章内容破坏脚本边界。

Sitemap、RSS 与 robots.txt

Astro 在构建阶段生成 Sitemap,文章集合同时生成 RSS。robots.txt 指向生产 Sitemap。这样新增 Markdown 文章时,不需要再手动维护 URL 列表。

把 SEO 变成构建门槛

SEO 最怕“这次修好了,下次新增页面又漏掉”。因此我增加 scripts/check-seo.mjs,在 dist/ 中逐个扫描 HTML,并检查:

  • 页面标题与 description。
  • Canonical 是否使用正式域名。
  • robots 指令。
  • 绝对 Open Graph 图片。
  • 页面是否只有一个 H1。
  • JSON-LD 是否为有效 JSON。
  • 文章页是否具有 BlogPosting 和 BreadcrumbList。

统计接口也有独立测试,覆盖正常写入、读取、无效事件、跨域请求和 OPTIONS 响应。

GitHub Actions 中的发布顺序变为:

Astro check
→ Astro build
→ 站内链接检查
→ SEO 检查
→ Analytics 接口测试
→ 同步 GitHub Pages 镜像
→ 部署 Cloudflare Pages

只要其中一项失败,生产部署就不会继续。

上线验证

部署完成后,先读取一个尚无数据的页面:

curl \
  "https://lucaslz.com/api/analytics?event=page_view&path=/howtostudy"

接口正常时返回:

{
  "event": "page_view",
  "path": "/howtostudy",
  "total": 0
}

再打开一篇文章,或发送一次同源 POST,响应状态应为 201:

{
  "ok": true,
  "event": "page_view",
  "path": "/howtostudy",
  "total": 1
}

刷新文章页后,标题下方会显示:

7 分钟阅读 · 1 次阅读

最后在 D1 Console 中检查聚合结果:

SELECT
  event,
  path,
  target,
  SUM(count) AS total
FROM event_daily
GROUP BY event, path, target
ORDER BY total DESC;

常见问题

接口返回 503

{"enabled":false} 表示 Function 已经上线,但当前 Deployment 没有拿到 BLOG_ANALYTICS。检查生产环境 Binding,并在保存后重新部署。

接口返回 500

通常是数据库已经绑定,但迁移尚未执行,导致 event_daily 表不存在。进入 D1 Console 执行迁移,再查看 Function 日志确认 SQL 错误。

页面没有显示阅读量

依次检查:

  1. 当前域名是否为 lucaslz.com、www.lucaslz.com 或 pages.dev。
  2. GET 接口是否返回 200 和数字类型的 total。
  3. 文章页是否存在 data-page-views 元素。
  4. 浏览器控制台是否有 CSP、网络或脚本错误。

阅读量和 Web Analytics 不完全一致

这是预期结果。D1 的 page_view 在同一浏览器会话内去重,Web Analytics 有自己的访客、机器人过滤和数据处理规则。两者定义不同,不应该强行追求数字完全相等。

隐私与成本边界

这套实现刻意不保存:

  • IP 地址
  • User-Agent
  • Cookie 标识
  • 用户账号
  • 完整 Referer
  • URL 查询参数

D1 中只有事件、规范化路径、目标、日期和次数。Web Analytics 与自定义事件的职责分开后,也不需要把第三方重型分析 SDK 放进每个页面。

D1 的免费额度和具体限制可能调整,应该以 Cloudflare D1 定价文档 为准。相比记录每次访问的明细,每日聚合可以显著减少行数和读取范围,也更适合个人博客。

最终结果

这次改造后,博客仍然保持静态优先:

Astro 静态内容
  ├─ Cloudflare Web Analytics:全站趋势与性能
  ├─ Pages Functions:轻量统计 API
  ├─ D1:文章阅读量与自定义事件
  └─ SEO:元数据、结构化数据与自动检查

最重要的不是多了一个访问数字,而是建立了一套可长期维护的反馈闭环:搜索引擎能更准确地理解文章,我可以看到哪些内容真正被阅读,构建流程则保证后续新页面不会轻易丢失 SEO 基线。

对静态个人博客来说,这已经覆盖了绝大多数实际需求,同时没有引入常驻服务器、内存数据库或复杂的用户追踪系统。