为 Hexo Fluid 接入 Umami 浏览量统计

本文最后更新于 2026年6月5日 晚上

前言

最近给博客浏览量统计又折腾了一圈。之前用过 LeanCloud,也写过一篇把 Fluid 接到 Waline 浏览量统计的文章,后来又看过 OpenKounter。几个方案都能用,但各自都有一点小尾巴。

LeanCloud 后面要停止对外提供服务,继续用的话迟早还要迁移。Waline 虽然能做文章浏览量,但全站 PV/UV 需要自己绕一层虚拟路径。OpenKounter 更偏向轻量计数,如果只是显示数字当然够用,但想看访问来源、设备、地区这些数据,就不太像一个完整统计后台了。

所以这次换成 Umami。它是一个开源的网站访问统计工具,支持自部署,也有 API,可以同时负责后台统计和 Fluid 前台显示 PV/UV。嗯,至少不用再一边补计数逻辑一边安慰自己“能跑就行”。

这篇文章记录一下:怎么用 Vercel + Neon 自部署 Umami,然后把它接到 Hexo Fluid 主题里,用来显示博客的全站 PV/UV 和文章阅读量。

准备工作

开始之前,先把需要的东西列一下:

  • 一个 GitHub 账号,用来 Fork Umami 项目。
  • 一个 Vercel 账号,用来部署 Umami。
  • 一个 Neon PostgreSQL 数据库,可以直接通过 Vercel Storage 创建。
  • 一个使用 Hexo Fluid 主题的博客。

Fluid 的 Umami 统计展示依赖自部署 Umami 的 API。如果只是想让 Umami 后台记录访问数据,只配置 tracking script 就行;但如果还想在博客页脚和文章页显示 PV/UV,就需要额外配置 website_idtokenapi_server 等字段。

Fork Umami 项目

先打开 Umami 的 GitHub 仓库:

1
https://github.com/umami-software/umami

点击右上角 Fork,把项目 Fork 到自己的 GitHub 账号下面。后面 Vercel 和 Netlify 都会从这个 Fork 后的仓库导入项目。

创建 Neon 数据库

数据库这里不用单独跑去 Neon 官网创建,直接在 Vercel 的 Storage 里新建一个 Neon 数据库就行。

  1. 打开 Vercel Dashboard。
  2. 进入 Storage
  3. 点击 Create Database
  4. 选择 Neon
  5. 数据库名称可以填 blog-umami,名字填其他的也行。
  6. 创建完成后进入数据库详情,点击 Show secret 显示隐藏的数据库信息。
  7. 找到连接信息,复制 DATABASE_URL

DATABASE_URL 大概长这样:

1
postgresql://username:password@host/database?sslmode=require

DATABASE_URL 是数据库连接密钥,不要写到博客文章、前端代码或者公开仓库里。后面只应该把它填到 Vercel / Netlify 项目的环境变量里。

部署 Umami 到 Vercel

数据库准备好后,就可以把 Umami 部署起来了。

  1. 回到 Vercel Dashboard。
  2. 点击 Add New...
  3. 选择 Project
  4. 导入刚刚 Fork 的 umami 仓库。
  5. 在环境变量里添加:
1
DATABASE_URL=刚刚复制的 Neon 数据库连接地址

然后点击部署。第一次部署时,Umami 会初始化数据库表。部署完成后,Vercel 会给出一个访问地址,例如:

1
https://your-umami.vercel.app

这个地址后面会作为 Umami 后台地址,也会作为 Fluid 配置里的 api_server。先记下来,后面会反复用到。

Umami 官方也提供了安装、Vercel 部署和 Neon 部署说明,可以配合参考:

复用数据库部署到 Netlify

如果 Vercel 部署后访问不够稳定,也可以把同一个 Umami 项目再部署到 Netlify。这里不用重新建库,直接复用前面在 Vercel Storage / Neon 中创建好的 DATABASE_URL 就行。

进入 Netlify 控制台后:

  1. 点击 Add new project
  2. 选择 Import a Git repository
  3. 选择 GitHub
  4. 选择前面 Fork 的 umami 仓库。
  5. Environment variables 中点击 Add environment variables
  6. 选择 Add key/value pairs
  7. 添加环境变量:
1
DATABASE_URL=前面复制的 Neon 数据库连接地址

然后点击 Deploy。部署完成后,Netlify 会给出一个访问地址,例如:

1
https://your-umami.netlify.app

因为 Netlify 这边复用的是同一个 Neon 数据库,所以 Umami 后台里的用户、Team、网站、统计数据都会和前面 Vercel 部署共用。换句话说,它只是多了一个服务入口,不是新开了一个 Umami 实例。

后面配置博客时,只需要把示例里的 Vercel 地址替换成 Netlify 地址即可:

1
https://your-umami.vercel.app  ->  https://your-umami.netlify.app

也就是:

  • src 改成 https://your-umami.netlify.app/script.js
  • api_server 改成 https://your-umami.netlify.app
  • 获取 token 时的 /api/auth/login 请求地址也换成 Netlify 地址

website_id 和账号信息来自同一个数据库,不需要重新创建。这个体验还是挺舒服的,至少不用再配一遍后台。

初始化 Umami 后台

接下来打开部署好的 Umami 地址:

1
https://your-umami.vercel.app

首次自部署 Umami 默认账号通常是:

1
2
username: admin
password: umami

登录后第一件事就是修改管理员账号的用户名和密码。默认密码不要继续用,不然这个后台等于半开门,实在有点刺激。

然后新建一个专门给博客前端读取统计数据用的账号:

  1. 使用管理员账号进入 Umami 后台。
  2. 进入 Settings
  3. 找到用户管理。
  4. 创建一个新账号。
  5. 角色选择 View only

例如:

1
2
3
username: blog-viewer
password: your-view-only-password
role: View only

这里建议单独创建 View only 账号,而不是直接把管理员 token 放到博客配置里。博客前端只需要读取统计数据,不应该拥有管理权限。

创建团队并加入账号

这一步比较容易漏。我一开始也以为只要账号能登录,API 就能读到网站数据,结果实际请求 /stats 时直接给了一个 401 Unauthorized

原因是:新建的 View only 账号虽然有效,但没有目标网站的访问权限。

比较稳妥的做法是把网站放到 Team 里,然后让 View only 账号加入这个 Team。

管理员账号操作:

  1. 进入 Settings
  2. 找到 Teams
  3. 创建一个 Team,例如 team1
  4. 复制 Team 的 Access code

然后退出管理员账号,登录刚刚创建的 View only 账号:

  1. 进入 Settings
  2. 找到 Teams
  3. 点击 Join team
  4. 输入刚才复制的 Access code

加入成功后,这个 View only 账号就可以读取 Team 里的站点统计数据了。后面用它获取 token,权限就比较收敛。

添加博客网站

重新使用管理员账号进入 Umami 后台,并切换到刚刚创建的 Team。

然后添加博客网站:

  1. 进入 Websites
  2. 点击 Add website
  3. Name 填博客名称,例如 My Blog
  4. Domain 填博客域名,例如 your-blog.example.com
  5. 保存。

注意这里的域名不要带 https://,只填主机名就行。

获取 Tracking Code

网站创建完成后,进入网站设置,找到 Tracking code。它大概长这样:

1
<script defer src="https://your-umami.vercel.app/script.js" data-website-id="the-website-id-is-this"></script>

这里要复制两个值,后面会填到 _config.fluid.yml

  • src:例如 https://your-umami.vercel.app/script.js
  • data-website-id:例如 the-website-id-is-this

另外还需要记住 Umami 的部署地址:

1
https://your-umami.vercel.app

这个地址后面要填到 api_server。注意 api_server 不要带 /script.js,不然拼出来的 API 地址会不对。

如果使用的是 Netlify 部署地址,后面的示例命令和配置同理把 https://your-umami.vercel.app 换成 https://your-umami.netlify.app

获取 View Only Token

接下来用刚刚创建的 View only 账号获取 API token。这个 token 是给 Fluid 前端请求统计数据用的。

1
2
3
curl -X POST 'https://your-umami.vercel.app/api/auth/login' \
-H 'Content-Type: application/json' \
-d '{"username":"blog-viewer","password":"your-view-only-password"}'

正常会返回类似下面的 JSON:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
{
"token": "token-is-this",
"user": {
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"username": "blog-viewer",
"role": "view-only",
"createdAt": "2026-06-04T16:08:46.359Z",
"isAdmin": false,
"teams": [
{
"id": "abcdef-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
"name": "team1",
"logoUrl": null
}
]
}
}

token 的值复制下来,后面要填到 Fluid 配置里。

先别急着填配置,可以先验证 token 是否有效:

1
2
3
4
5
UMAMI_TOKEN='token-is-this'

curl -X POST 'https://your-umami.vercel.app/api/auth/verify' \
-H 'Accept: application/json' \
-H "Authorization: Bearer $UMAMI_TOKEN"

如果想确认这个账号能不能访问目标网站,可以查询它能看到的网站列表:

1
2
3
4
curl -G 'https://your-umami.vercel.app/api/me/websites' \
-H 'Accept: application/json' \
-H "Authorization: Bearer $UMAMI_TOKEN" \
--data-urlencode 'includeTeams=true'

如果返回里看不到刚才创建的网站,说明 Team 权限还没配好。这个时候访问 stats 接口大概率会返回:

1
{"error":{"message":"Unauthorized","code":"unauthorized","status":401}}

配置 Fluid

后台和 token 都准备好后,就可以回到博客这边改配置了。打开 _config.fluid.yml,找到 web_analytics 里的 umami 配置。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
web_analytics:
enable: true
follow_dnt: true

umami:
# Umami tracking script 地址
src: https://your-umami.vercel.app/script.js

# Tracking code 里的 data-website-id
website_id: the-website-id-is-this

# 限制统计域名,多个域名用逗号分隔;不需要可以留空
domains:

# 统计 PV/UV 的起始时间
start_time: 2024-01-01

# view-only 账号通过 /api/auth/login 获取到的 token
token: token-is-this

# Umami 部署地址,不要带 /script.js
api_server: https://your-umami.vercel.app

然后把页脚站点统计来源改成 umami

1
2
3
4
5
6
footer:
statistics:
enable: true
source: "umami"
pv_format: "总访问量 {} 次"
uv_format: "总访客数 {} 人"

再把文章浏览量来源改成 umami

1
2
3
4
5
6
post:
meta:
views:
enable: true
source: "umami"
format: "{} 次"

配置完成后重新生成并部署博客。如果一切正常,页脚会显示全站 PV/UV,文章页也会显示当前文章浏览量。

临时修复 Umami API 兼容问题

这里有个小坑。当前 Fluid 主题里的 themes/fluid/source/js/umami-view.js 仍然使用旧版 Umami API 写法。我已经提交了修复 PR:fluid-dev/hexo-theme-fluid#1249。如果主题还没有合并新版 API 的兼容修复,那么只改配置可能会在浏览器控制台看到类似这样的错误:

1
TypeError: can't access property "value", data.visitors is undefined

原因是旧代码会读取 data.visitors.valuedata.pageviews.value,但新版 Umami API 返回的是数字形式的 data.visitorsdata.pageviews。另外,旧版页面过滤参数是 url,新版变成了 path

所以在 Fluid 合并修复前,可以先手动修改主题文件:

1
themes/fluid/source/js/umami-view.js

把这个文件替换成下面的兼容版本:

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
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
// 从配置文件中获取 umami 的配置
const website_id = CONFIG.web_analytics.umami.website_id;
// 拼接请求地址
const request_url = `${CONFIG.web_analytics.umami.api_server}/api/websites/${website_id}/stats`;

const start_time = new Date(CONFIG.web_analytics.umami.start_time).getTime();
const end_time = new Date().getTime();
const token = CONFIG.web_analytics.umami.token;

// 检查配置是否为空
if (!website_id) {
throw new Error("Umami website_id is empty");
}
if (!request_url) {
throw new Error("Umami request_url is empty");
}
if (!start_time) {
throw new Error("Umami start_time is empty");
}
if (!token) {
throw new Error("Umami token is empty");
}

// 构造请求参数
const params = new URLSearchParams({
startAt: start_time,
endAt: end_time,
});
// 构造请求头
const request_header = {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": "Bearer " + token,
},
};

// 兼容 Umami v2 的 { value } 和 v3 的数字返回格式
function getStatValue(data, key) {
const value = data && data[key];

if (typeof value === "number") {
return value;
}

if (value && typeof value.value === "number") {
return value.value;
}

return 0;
}

async function requestStats(queryParams) {
const response = await fetch(`${request_url}?${queryParams}`, request_header);
const data = await response.json();

if (!response.ok) {
throw new Error(`Umami API error ${response.status}: ${JSON.stringify(data)}`);
}

if (!data || typeof data.pageviews === "undefined") {
throw new Error(`Invalid Umami stats response: ${JSON.stringify(data)}`);
}

return data;
}

async function requestPageStats(path) {
const v3Params = new URLSearchParams(params);
v3Params.set("path", path);

try {
const data = await requestStats(v3Params);
if (typeof data.pageviews === "number") {
return data;
}
} catch (error) {
console.warn("Failed to fetch Umami stats with v3 path filter, fallback to v2 url filter.", error);
}

const v2Params = new URLSearchParams(params);
v2Params.set("url", path);
return requestStats(v2Params);
}

// 获取站点统计数据
async function siteStats() {
try {
const data = await requestStats(params);
const uniqueVisitors = getStatValue(data, "visitors"); // 获取独立访客数
const pageViews = getStatValue(data, "pageviews"); // 获取页面浏览量

let pvCtn = document.querySelector("#umami-site-pv-container");
if (pvCtn) {
let ele = document.querySelector("#umami-site-pv");
if (ele) {
ele.textContent = pageViews; // 设置页面浏览量
pvCtn.style.display = "inline"; // 将元素显示出来
}
}

let uvCtn = document.querySelector("#umami-site-uv-container");
if (uvCtn) {
let ele = document.querySelector("#umami-site-uv");
if (ele) {
ele.textContent = uniqueVisitors;
uvCtn.style.display = "inline";
}
}
} catch (error) {
console.error(error);
return "-1";
}
}

// 获取页面浏览量
async function pageStats(path) {
try {
const data = await requestPageStats(path);
const pageViews = getStatValue(data, "pageviews");

let viewCtn = document.querySelector("#umami-page-views-container");
if (viewCtn) {
let ele = document.querySelector("#umami-page-views");
if (ele) {
ele.textContent = pageViews;
viewCtn.style.display = "inline";
}
}
} catch (error) {
console.error(error);
return "-1";
}
}

siteStats();

// 获取页面容器
let viewCtn = document.querySelector("#umami-page-views-container");
// 如果页面容器存在,则获取页面浏览量
if (viewCtn) {
let path = window.location.pathname;
let target = path
.replace(/(\/[^/]+\.html)\/$/, "$1") // 如果是 '/xxxx.html/' 格式的路径,则去掉最后那个 '/'
.replace(/\/index\.html$/, "/"); // 如果是 '/index.html' 格式,则合并成 '/'
pageStats(target);
}

这段代码会优先按新版 Umami API 使用 path 参数请求页面统计,如果失败或检测到旧版返回结构,再回退到 v2 的 url 参数。同时读取统计值时也兼容 data.pageviewsdata.pageviews.value 两种格式。

等 Fluid 主题正式合并这个兼容修复后,就不用再手动替换这个文件了。

测试统计接口

先别急着看页面显示,可以先用 curl 测一下 Umami stats 接口。接口能通,前端显示才有继续排查的意义。

新版 Umami API 使用 path 过滤页面:

1
2
3
4
5
6
7
8
9
UMAMI_TOKEN='token-is-this'
END_AT="$(node -e 'console.log(Date.now())')"

curl -G 'https://your-umami.vercel.app/api/websites/the-website-id-is-this/stats' \
-H 'Accept: application/json' \
-H "Authorization: Bearer $UMAMI_TOKEN" \
--data-urlencode 'startAt=1704067200000' \
--data-urlencode "endAt=$END_AT" \
--data-urlencode 'path=/2026/06/04/example/'

如果要查全站统计,不带 path 参数即可:

1
2
3
4
5
curl -G 'https://your-umami.vercel.app/api/websites/the-website-id-is-this/stats' \
-H 'Accept: application/json' \
-H "Authorization: Bearer $UMAMI_TOKEN" \
--data-urlencode 'startAt=1704067200000' \
--data-urlencode "endAt=$END_AT"

新版返回结果里,pageviewsvisitors 通常就是数字:

1
2
3
4
5
6
7
{
"pageviews": 123,
"visitors": 45,
"visits": 50,
"bounces": 10,
"totaltime": 1000
}

常见问题

API 返回 401

如果 /api/auth/verify 能返回用户信息,但 /api/websites/:websiteId/stats 返回 401,一般不是 token 失效,而是权限问题。

重点检查:

  • token 是否来自 view-only 账号。
  • 这个账号是否加入了包含该网站的 Team。
  • 网站是否创建在正确的 Team 下。
  • website_id 是否复制错。

可以用下面命令确认账号能看到哪些网站:

1
2
3
4
curl -G 'https://your-umami.vercel.app/api/me/websites' \
-H 'Accept: application/json' \
-H "Authorization: Bearer $UMAMI_TOKEN" \
--data-urlencode 'includeTeams=true'

页面显示 TypeError

如果浏览器控制台出现类似错误:

1
TypeError: can't access property "value", data.visitors is undefined

或者:

1
TypeError: can't access property "value", data.pageviews is undefined

多半是 Umami API 版本不兼容。旧版 Umami v2 的 stats 返回结构类似:

1
2
data.pageviews.value
data.visitors.value

新版 Umami API 返回的是数字:

1
2
data.pageviews
data.visitors

同时页面过滤参数也从旧版的 url 变成了新版的 path。如果使用的 Fluid 版本还没有兼容新版 Umami API,需要更新主题或按前面的方式修改 umami-view.js,让它同时兼容 v2 和新版 API。

相关文档:

Token 不要写进公开前端代码吗

Fluid 这里的 token 会被前端请求使用,所以它确实会出现在生成后的页面配置里。这个点绕不过去,毕竟前端要直接请求 Umami API。

这也是为什么前面建议创建单独的 view-only 账号,并且只给它读取对应 Team 网站统计数据的权限。不要使用管理员账号 token。

迁移 LeanCloud 历史浏览量到 Umami

如果之前已经用 Fluid 的 LeanCloud 计数统计跑了一段时间,直接切换到 Umami 后,历史浏览量不会自动过去。啊这,旧数据丢了总觉得有点膈应,所以这里顺手把迁移方法也记一下。

LeanCloud 和 Waline 这类浏览量统计,本质上更接近聚合计数:

  • LeanCloud Counter.target 表示统计目标。
  • LeanCloud Counter.time 表示访问次数。
  • Waline wl_counter.url 表示统计目标。
  • Waline wl_counter.time 表示访问次数。

但是 Umami 不只是一个计数表。它会把访问记录写到数据库中的事件表里,核心是:

  • "session":访问会话和访客信息。
  • website_event:页面访问和事件记录。

所以迁移到 Umami 不能简单做 target -> url_path 的字段映射,而是要把旧的聚合计数展开成一条条 Umami pageview 事件。听起来麻烦一点,但写个转换脚本就还好。

先检查 LeanCloud 导出的 Counter.json。如果里面有文章路径,比如 /2024/06/14/example/,就可以迁移文章历史阅读量。如果里面只有 site-pvsite-uv,那就只能迁移全站 PV/UV,不能恢复每篇文章的历史阅读量。

例如我这份导出数据只有站点计数:

1
2
3
site-pv: 50378
site-uv: 33581
page path records: 0

这种情况下,迁移脚本会把 site-pv 导入为一个合成路径:

1
__legacy_site_pv

这样可以让 Umami 的全站 PV 包含旧数据,但不会假装这些访问属于某一篇文章。

生成迁移 SQL

这里使用一个辅助脚本 leancloud-counter-to-umami-sql.js,可以通过下面的按钮下载:

leancloud-counter-to-umami-sql.js

下载后,把它和 LeanCloud 导出的 Counter_20260530_005938.json 放到同一个目录,然后运行转换命令:

1
2
3
4
5
6
node leancloud-counter-to-umami-sql.js Counter_20260530_005938.json \
--website-id the-website-id-is-this \
--hostname your-blog.example.com \
--created-at 2024-06-10T12:43:04.830Z \
--site-pv-path __legacy_site_pv \
> umami-legacy-pageviews.sql

参数说明:

  • --website-id:Umami 网站 ID,也就是 tracking code 里的 data-website-id
  • --hostname:博客域名,例如 your-blog.example.com
  • --created-at:给旧数据使用的导入时间。
  • --site-pv-path:当导出文件只有 site-pv 时使用的合成路径。
  • --session-count:可选,用来控制导入时构造多少个历史 session;不填时优先使用 site-uv

生成的 SQL 会使用:

  • tag = 'legacy-leancloud' 标记导入的 pageview。
  • distinct_id LIKE 'legacy-leancloud-%' 标记导入的 session。

这样重复导入时,SQL 可以先删除旧的 legacy 数据,再重新插入。万一第一次参数填错了,也不至于把数据库弄成一团糟。

导入到 Umami 数据库

如果可以在本地连接数据库,可以直接执行:

1
psql "$DATABASE_URL" -f umami-legacy-pageviews.sql

如果使用的是 Vercel Storage / Neon,可以进入 Neon 的 SQL Editor,把 umami-legacy-pageviews.sql 的内容粘进去运行。

生成的 SQL 会使用 gen_random_uuid() 创建 UUID,所以会先执行:

1
CREATE EXTENSION IF NOT EXISTS pgcrypto;

导入完成后,最后应该能看到类似结果:

1
2
legacy sessions   33581
legacy pageviews 50378

也可以手动检查:

1
2
3
4
5
6
7
8
9
SELECT count(*)
FROM website_event
WHERE website_id = 'the-website-id-is-this'
AND tag = 'legacy-leancloud';

SELECT count(*)
FROM "session"
WHERE website_id = 'the-website-id-is-this'
AND distinct_id LIKE 'legacy-leancloud-%';

导入前建议先备份 Umami 数据库。虽然脚本只会清理带有 legacy-leancloud 标记的导入数据,但数据库迁移还是值得谨慎一点。

总结

到这里,Umami 自部署、后台初始化、Team 权限、网站添加、token 获取、Fluid 配置和历史 LeanCloud 数据迁移就都串起来了。

日常使用时,博客前端会加载 Umami 的 tracking script,Fluid 再通过 Umami API 读取统计值并显示到页脚和文章页。如果后面更换 Umami 部署域名,记得同时更新 srcapi_server。这两个地方一个负责统计脚本,一个负责 API 请求,少改一个都会让人排查半天。

参考资料


为 Hexo Fluid 接入 Umami 浏览量统计
https://licyk.netlify.app/2026/06/05/add-umami-pageview-to-hexo-fluid/
作者
licyk
发布于
2026年6月5日
许可协议