























居然一年没更新博客了… ( ̄ ‘i  ̄;) 博主还活着
![图片[1]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260126170414890.png)
最近得知 Umami 推出了 V3 版本,又听说 V2 版本有严重漏洞 ,遂决定花点时间升级 Umami,并重写一下博客底部 Umami 数据挂件的代码。
![图片[2]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260122144309847-618x800.png)
刚要开工,发现 Umami V3 居然不再支持 MySQL 了,必须使用 PostgreSQL 作为数据库…(气)
花费了一晚上的时间,跟着官方文档《Migrate MySQL to PostgreSQL|将 MySQL 迁移至 PostgreSQL》折腾,可惜最后还是因为众多奇奇怪怪的小问题未能够成功迁移,只好转向全新部署。
![图片[3]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260122150301486-1024x672.png)
本文记录了把 Umami 升级到 V3 版本后,根据官方文档重写数据挂件的过程,希望对其他使用 Umami 有相关需求的博主有所帮助。
Umami V3 的 API 有一定改动,主要体现在:
V2 返回的是:
{
uniques: {value: 123},
pageviews: {value: 456}
}
V3 直接简化成了:
{
visitors: 123,
pageviews: 456
}
![图片[1]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260126170414890.png)
这个挂件很简单,就是在博客底部显示四个关键数据:
![图片[5]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260122153502359.png)
另外,之前的数据因为全新部署丢失了,所以我的代码里加了个 ”历史数据补偿“ 的功能(见上图),不需要可注释掉,或者直接在相关脚本处填 0。
为了安全考虑,我们不直接用管理员账号的 Token,而是创建一个只读权限的用户。这样即使 Token 泄露,别人也没法修改你的统计数据。
Blog Viewer现在这个团队就可以访问你的网站统计数据了。
blog_viewerblog_viewer 账号登录现在这个 blog_viewer 用户就可以查看你博客的统计数据了,但没有修改权限。
![图片[6]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260122153626563.png)
继续在无痕窗口中操作(保持 blog_viewer 账号登录状态):
fetch('https://你的umami域名/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({
username: 'blog_viewer', // 刚才创建的用户名
password: '你设置的密码'
})
}).then(r => r.json()).then(d => console.log('Token:', d.token))
Token: eQTMjUzIiwiY... 这样一长串字符Token: 这几个字),保存好注意:这个 Token 会一直有效,除非你修改了密码或者 Umami 服务器重启。所以获取一次就够了。
还是在无痕窗口(blog_viewer 登录状态):
https://你的域名/websites/a6541980-4e87-4633-8eb9-0e6774b9760ea6541980-4e87-4633-8eb9-0e6774b9760e),复制保存到这里,准备工作就完成了。你应该有三样东西:
把下面的代码复制到你的博客模板文件中:
<script>
(function() {
'use strict';
const CONFIG = {
websiteId: '你的Website ID', // 第六步获取的
apiBase: 'https://你的umami域名', // 比如 https://analytics-v3.baiyuyu.com
token: '第五步获取的Token', // 那一长串字符
startDate: '2023-01-01', // 统计开始日期(从哪一天开始获取数据)
legacyData: {
totalUV: 0, // 博主加的数据补偿功能,如果之前有旧数据,填在这里(没有就填 0)
totalPV: 0
},
selectors: {
totalPV: '#total-pv',
totalUV: '#total-uv',
onlineUser: '#online-user',
todayUV: '#today-uv'
},
cache: {
enabled: true,
duration: 60000
}
};
const cache = new Map();
function getTodayRange() {
const now = new Date();
const start = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const end = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999).getTime();
return { start, end };
}
async function fetchUmamiAPI(endpoint, params = {}) {
const cacheKey = `${endpoint}-${JSON.stringify(params)}`;
if (CONFIG.cache.enabled) {
const cached = cache.get(cacheKey);
if (cached && Date.now() - cached.time < CONFIG.cache.duration) {
return cached.data;
}
}
const url = new URL(`${CONFIG.apiBase}/api/websites/${CONFIG.websiteId}${endpoint}`);
Object.entries(params).forEach(([k, v]) => {
if (v !== undefined && v !== null) {
url.searchParams.append(k, v);
}
});
try {
const response = await fetch(url, {
method: 'GET',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${CONFIG.token}`
}
});
if (!response.ok) {
throw new Error(`API 请求失败: ${response.status}`);
}
const data = await response.json();
if (CONFIG.cache.enabled) {
cache.set(cacheKey, { data, time: Date.now() });
}
return data;
} catch (error) {
console.error(`Umami API 错误 (${endpoint}):`, error);
throw error;
}
}
function updateElement(selector, value) {
const el = document.querySelector(selector);
if (el) el.textContent = value;
}
async function fetchTotalStats() {
try {
const startTime = new Date(CONFIG.startDate).getTime();
const endTime = Date.now();
const data = await fetchUmamiAPI('/stats', { startAt: startTime, endAt: endTime });
const totalPV = (data.pageviews || 0) + CONFIG.legacyData.totalPV;
const totalUV = (data.visitors || 0) + CONFIG.legacyData.totalUV;
updateElement(CONFIG.selectors.totalPV, totalPV.toLocaleString());
updateElement(CONFIG.selectors.totalUV, totalUV.toLocaleString());
} catch (error) {
console.error('获取累计统计失败:', error);
updateElement(CONFIG.selectors.totalPV, 'Error');
updateElement(CONFIG.selectors.totalUV, 'Error');
}
}
async function fetchOnlineUsers() {
try {
const data = await fetchUmamiAPI('/active');
const count = data.visitors || 0;
updateElement(CONFIG.selectors.onlineUser, count);
} catch (error) {
console.error('获取在线用户失败:', error);
updateElement(CONFIG.selectors.onlineUser, 'Error');
}
}
async function fetchTodayStats() {
try {
const { start, end } = getTodayRange();
const data = await fetchUmamiAPI('/stats', { startAt: start, endAt: end });
const visitors = data.visitors || 0;
updateElement(CONFIG.selectors.todayUV, visitors.toLocaleString());
} catch (error) {
console.error('获取今日统计失败:', error);
updateElement(CONFIG.selectors.todayUV, 'Error');
}
}
function throttle(func, delay) {
let lastCall = 0;
return function(...args) {
const now = Date.now();
if (now - lastCall >= delay) {
lastCall = now;
return func.apply(this, args);
}
};
}
async function initStats() {
await Promise.allSettled([
fetchTotalStats(),
fetchOnlineUsers(),
fetchTodayStats()
]);
}
const throttledRefresh = throttle(() => {
fetchOnlineUsers();
fetchTodayStats();
}, 30000);
function enableAutoRefresh() {
setInterval(throttledRefresh, 30000);
setInterval(fetchTotalStats, 300000);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initStats);
} else {
initStats();
}
enableAutoRefresh();
})();
</script>
配置说明:
https://)0在你博客模板中想显示统计数据的位置(比如页脚),加上这些 HTML 代码:
<div class="stats-widget">
<p>
📊 累计访问:<strong id="total-pv">加载中...</strong> PV / <strong id="total-uv">加载中...</strong> UV
</p>
<p>
👥 在线人数:<strong id="online-user">加载中...</strong> 人
</p>
<p>
🎉 今日第 <strong id="today-uv">加载中...</strong> 位访客
</p>
</div>
你可以根据自己的喜好调整样式和布局。重要的是保持这四个 ID:
#total-pv – 累计 PV#total-uv – 累计 UV#online-user – 在线人数#today-uv – 今日访客保存代码后,刷新你的博客页面:
如果看到类似这样的输出,说明成功了:
使用缓存数据: /stats
使用缓存数据: /active
使用缓存数据: /stats
![图片[1]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260126170414890.png)
如果看到 401 Unauthorized 错误,检查:
如果看到 404 Not Found 错误,检查:
缓存机制
cache: {
enabled: true,
duration: 60000 // 60秒 = 1分钟
}
同样的数据 1 分钟内会直接用缓存,不会重复请求 API。这样既省流量又提速,还能减轻 Umami 服务器压力。
自动刷新
setInterval(throttledRefresh, 30000); // 在线人数和今日访客,30秒刷新
setInterval(fetchTotalStats, 300000); // 累计数据,5分钟刷新
不同类型的数据刷新频率不同:
节流控制
const throttledRefresh = throttle(() => {
fetchOnlineUsers();
fetchTodayStats();
}, 30000);
即使刷新函数被多次触发,节流机制也会保证最快 30 秒才执行一次,避免高频请求。
历史数据补偿
const totalPV = (data.pageviews || 0) + CONFIG.legacyData.totalPV;
const totalUV = (data.visitors || 0) + CONFIG.legacyData.totalUV;
如果你之前有统计数据但换了新系统,可以把旧数据填在 legacyData 里,代码会自动累加。比如我之前有 43100 UV 和 34800 PV,就这样配置:
legacyData: {
totalUV: 43100,
totalPV: 34800
}
性能问题
有人可能担心累计访问量查询会不会很慢。实测下来,Umami 的聚合查询还是挺快的,基本都在 100ms 以内。而且有了缓存机制,即使访问量到了几十万,性能也完全没问题。
如果你的数据量特别大(百万级以上),可以考虑把刷新间隔调大一点:
setInterval(fetchTotalStats, 600000); // 改成 10 分钟刷新一次
Q1:Token 会过期吗?
不会。Token 会一直有效,除非:
所以获取一次就够了,不需要每次都重新获取。
Q2:可以在多个网站用同一个脚本吗?
可以,但要注意修改 websiteId。每个网站的 ID 不同,需要单独配置。
Q3:显示 “Error” 怎么办?
打开浏览器控制台(F12),看具体的错误信息:
401 错误:Token 或权限问题404 错误:Website ID 错误或用户没有权限Network Error:检查 Umami 服务器是否正常运行![图片[8]|升级到 Umami V3 并重写博客底部的“数据挂件”脚本,显示博客 UV/PV 访问数据](https://cdn.baiyuyu.com/2026/01/20260122143108784-1024x757.png)
Umami V3 的 API 设计比 V2 简洁,响应格式也更直观。虽然升级需要改代码,但改完之后维护起来反而更方便。通过创建只读用户,可以安全地在博客上展示统计数据,不用担心 Token 泄露的问题。
整个流程下来可能需要 10-15 分钟,但设置一次就能一直用,还是很值得的。这个挂件脚本我自己用了几天,目前似乎运行稳定,若遇到问题欢迎留言讨论~
相关资源
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。