想给博客的 about 页加一个「今日访问 / 本月访问」之类的统计区块,又不想塞一堆第三方 JS(busuanzi、51la 那种会拖慢页面、还有隐私顾虑)?如果你的博客已经托管在 Cloudflare,其实有一套最干净的做法——直接读 Cloudflare 自己的 Analytics 数据。

本文记录我是怎么用 Cloudflare Worker + KV + Cron Trigger,把真实的 zone 级 PV/UV 自动喂给 Hexo 博客的 about 页的。整套方案跑在 Cloudflare 上,零本地依赖,关了电脑数字也会每天自动刷新。

快速结论

  • 博客在 Cloudflare + 绑了自定义域名 → 用本文方案,同源、国内快、无 CORS,最稳。
  • 不想碰 Worker → 写个本地脚本定时拉 GraphQL 改 about 数据再部署,也能用,但机器得一直开着。
  • Cloudflare 没有公开的前端 API → 必须有一个服务端(Worker / 本地脚本 / Actions)拿 Token 去调 GraphQL。

为什么不用第三方统计

常见的不蒜花、51la 都是往页面里塞一段 JS 做计数或埋点。问题有两个:

  1. 侵入性强:多一个外部请求、多一段脚本,对”白嫖”静态博客来说是额外负担。
  2. 数据是「自己数自己」:不蒜花是客户端计数,刷一下就 +1,跟 Cloudflare 后台看到的真实流量对不上。

而 Cloudflare 已经在边缘节点帮你统计了所有请求,GraphQL Analytics API 能拿到真实的 PV、UV、请求数、地理分布。直接复用这份数据最准,也最省事。

三种方案对比

方案 跑在哪 优点 缺点
A. 本地脚本 + 定时 你自己机器 复用现有构建管线,Token 不出本机 机器/服务得一直开着
B. GitHub Actions GitHub 云端 免费、定时稳,Token 存 Secrets 博客源码得推到 GitHub
C. Worker + KV(本文) Cloudflare 云端 同平台、同源无 CORS、关电脑也自动跑 要写一点 Worker 代码

博客本身就在 Cloudflare Pages 上,方案 C 几乎是天作之合:Worker 和博客同域,前端 fetch 自己的接口没有跨域问题,Token 只存在 Worker Secret 里,绝不落到前端。

架构

1
2
3
4
5
6
7
每天 UTC 00:30 Cron Trigger
→ Worker 调 Cloudflare GraphQL Analytics API
→ 拉 zone 级 httpRequests1dGroups(按天聚合的 PV/UV)
→ 聚合成 6 个字段写进 KV

前端 about 页(同源)
→ fetch /api/stats/ → 从 KV 读缓存 JSON → 填进 #statistic 区块
  • Worker:负责定时拉数、写 KV、响应前端读请求
  • KV:缓存聚合后的 JSON,避免每次访问都打 GraphQL
  • Cron:每天自动刷新,KV 空时首次访问还会冷启动自动拉一次

准备工作

去 Cloudflare 后台拿两样东西:

  1. Zone ID:点进你绑定的自定义域名 → Overview 页面右下角 → 一串 32 位十六进制。
  2. 只读 API Token:右上角头像 → My Profile → API Tokens → Create Token → 选预设模板 **”Read analytics and logs”**(只有读权限,改不了任何东西)→ 复制 Token。

注意:博客必须绑了自定义域名,且域名 DNS 托管在 Cloudflare。因为 GraphQL 的 httpRequests1dGroups 是按 zone 查的,*.pages.dev 公共子域不在你自己的 zone 里,取不到数据。

步骤一:写 Worker

核心逻辑就三块:拉 GraphQL、聚合、读写 KV。完整代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/**
* Cloudflare Analytics Worker
* Cron 拉 zone 级 PV/UV → 聚合 → 写 KV;前端同源 GET 读 KV
*/
const GRAPHQL_ENDPOINT = 'https://api.cloudflare.com/client/v4/graphql';
const KV_KEY = 'stats';

function ymd(d) { return d.toISOString().slice(0, 10); }

async function fetchDailyGroups(token, zoneTag, sinceDate, untilDate) {
const query = `
query ($zoneTag: String!, $since: String!, $until: String!) {
viewer {
zones(filter: { zoneTag: $zoneTag }) {
httpRequests1dGroups(
limit: 366
filter: { date_geq: $since, date_leq: $until }
orderBy: [date_ASC]
) {
dimensions { date }
sum { pageViews requests }
uniq { uniques }
}
}
}
}`;

const res = await fetch(GRAPHQL_ENDPOINT, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ query, variables: { zoneTag, since: sinceDate, until: untilDate } }),
});

const json = await res.json();
if (json.errors && json.errors.length) throw new Error('GraphQL: ' + JSON.stringify(json.errors));
const zones = json?.data?.viewer?.zones;
if (!zones || !zones.length) throw new Error('空 zones,检查 ZONE_TAG 与 token 权限');
return zones[0].httpRequests1dGroups || [];
}

function aggregate(groups) {
const now = new Date();
const todayKey = ymd(now);
const yesterday = new Date(now);
yesterday.setUTCDate(yesterday.getUTCDate() - 1);
const yesterdayKey = ymd(yesterday);
const monthAgo = new Date(now); monthAgo.setUTCDate(monthAgo.getUTCDate() - 30);
const yearAgo = new Date(now); yearAgo.setUTCDate(yearAgo.getUTCDate() - 365);

const out = { today_pv: 0, today_uv: 0, yesterday_pv: 0, yesterday_uv: 0, last_month_pv: 0, last_year_pv: 0 };
for (const g of groups) {
const date = g.dimensions.date;
const pv = g.sum?.pageViews || 0;
const uv = g.uniq?.uniques || 0;
const d = new Date(date + 'T00:00:00Z');
if (date === todayKey) { out.today_pv = pv; out.today_uv = uv; }
if (date === yesterdayKey){ out.yesterday_pv = pv; out.yesterday_uv = uv; }
if (d >= monthAgo) out.last_month_pv += pv;
if (d >= yearAgo) out.last_year_pv += pv;
}
return out;
}

async function refresh(env) {
const groups = await fetchDailyGroups(env.CF_API_TOKEN, env.ZONE_TAG, ymd(new Date(Date.now() - 364 * 864e5)), ymd(new Date()));
const stats = aggregate(groups);
stats.updated_at = new Date().toISOString();
await env.STATS.put(KV_KEY, JSON.stringify(stats));
return stats;
}

export default {
async scheduled(event, env, ctx) {
ctx.waitUntil(refresh(env).catch((e) => console.error('refresh failed:', e.message)));
},
async fetch(request, env) {
const url = new URL(request.url);
if (url.searchParams.get('refresh') === '1') {
const key = url.searchParams.get('key');
if (!env.REFRESH_KEY || key !== env.REFRESH_KEY) return new Response('unauthorized', { status: 401 });
const stats = await refresh(env);
return new Response(JSON.stringify({ ok: true, ...stats }), { headers: { 'Content-Type': 'application/json' } });
}
const cached = await env.STATS.get(KV_KEY);
if (!cached) {
try { return new Response(JSON.stringify(await refresh(env)), { headers: { 'Content-Type': 'application/json' } }); }
catch (e) { return new Response(JSON.stringify({ error: e.message }), { status: 200 }); }
}
return new Response(cached, { headers: { 'Content-Type': 'application/json', 'Access-Control-Allow-Origin': '*' } });
},
};

步骤二:wrangler.toml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
name = "blog-analytics"
main = "src/worker.js"
compatibility_date = "2025-11-21"
workers_dev = true

account_id = ""

# 同源路由:跟博客同一个域名,前端 fetch 无 CORS
routes = ["your-domain.example.com/api/stats*"]

# 每天 UTC 00:30 拉一次
[triggers]
crons = ["30 0 * * *"]

# KV 缓存
[[kv_namespaces]]
binding = "STATS"
id = ""

[vars]
ZONE_TAG = ""

# 敏感值用 secret(不要写进 toml):
# wrangler secret put CF_API_TOKEN
# wrangler secret put REFRESH_KEY

步骤三:部署 Worker

1
2
3
4
5
6
7
8
9
# 1. 建 KV namespace,记下返回的 id 填回 wrangler.toml
wrangler kv namespace create STATS

# 2. 存两个 secret(交互式输入,不落文件)
wrangler secret put CF_API_TOKEN
wrangler secret put REFRESH_KEY

# 3. 部署(路由 + Cron 一起生效)
wrangler deploy

部署完成后,访问 https://your-domain.example.com/api/stats/ 应该返回类似:

1
2
3
4
5
6
{
"today_pv": 495, "today_uv": 53,
"yesterday_pv": 497, "yesterday_uv": 116,
"last_month_pv": 15395, "last_year_pv": 22004,
"updated_at": "2026-09-19T00:30:12Z"
}

步骤四:前端 about 页

我用的是 Solitude 主题,它的 about 页模板支持一个 tj 字段渲染访问统计区块,内置 custom 模式:指定一个返回上面 JSON 的接口地址,模板就会 fetch 它并填进 #statistic。在 source/_data/about.yml 里这样配:

1
2
3
4
5
6
7
8
9
tj:
img: /img/about/xxx.webp
desc: about.other.tj.post_tip
button: true
button_link: /stats
button_text: 详细数据
# 改成 custom 模式,指向同源 Worker 路由
provider: custom
url: /api/stats/

只要 tj 下没有 static 字段,构建时的静态注入逻辑会自动跳过,完全交给前端运行时 fetch。如果是其他主题,思路一样:在 about 页模板里加一段 fetch('/api/stats/').then(r=>r.json()).then(填充 DOM) 即可。

踩坑记录

折腾过程中几个坑,提前说能省不少时间:

  1. GraphQL 查询跨度上限是 52 周 1 天。一开始写 now - 365天 会越界报错,改成 now - 364天(即 Date.now() - 364*864e5)才正常。
  2. wrangler kv key put 默认写本地 KV,边缘节点读不到。手动写数据必须加 --remote:wrangler kv key put stats --path=stats.json --binding=STATS --remote。
  3. Token 绝不能放前端。Worker 的 fetch 在同域下不需要 Authorization 头,Token 只在 Worker 服务端用,存到 wrangler secret put CF_API_TOKEN 即可,源码和前端都看不到。
  4. 路由挂自定义域名比 workers.dev 稳。国内访问 workers.dev 域名可能被墙,挂在博客自己的域名下(your-domain/api/stats*)同源又快。

日常维护

  • 数字每天 UTC 00:30 自动刷新,不用管。
  • 想手动强刷:https://your-domain.example.com/api/stats/?refresh=1&key=。
  • KV 意外为空时,Worker 被首次访问会冷启动自动拉一次,不会空白。

结语

这套方案把”显示真实访问量”这件小事,收敛成了 Cloudflare 平台内的一个闭环:Worker 负责取数、KV 负责缓存、Cron 负责定时,前端只管读。没有第三方脚本、没有隐私顾虑、也没有本地服务要维护。博客在 Cloudflare 的朋友,强烈建议直接复用这套思路。