博客迁移到 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 Analytics D1 自定义统计
页面访问、访客趋势 可记录页面浏览
来源、国家和性能指标 不采集
文章累计阅读量 不直接展示到页面
标签、搜索、目录点击 不适合自定义事件
自己控制数据模型
客户端 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);

为了避免一次会话内刷新页面不断增加计数,前端使用 sessionStoragepage_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-CNx-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 结构化数据

普通页面输出 PersonWebSite。文章页额外输出:

  • 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。
  • 文章页是否具有 BlogPostingBreadcrumbList

统计接口也有独立测试,覆盖正常写入、读取、无效事件、跨域请求和 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.comwww.lucaslz.compages.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 基线。

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