umami 实现网站流量统计模块

umami 实现网站流量统计模块
amiracle前言
建站之后,很难没有统计网站访问人数的想法,本篇在 hexo 框架 + anzhiyu 主题环境下,利用 umami 0 成本实现网站流量统计,并实现统计模块。顺便说一下模块开发历程吧 (真是一波三折啊)
umami 统计工具感觉很 nice 啊,首先是 0 成本,然后你还能直接用它的 Umami Cloud 而不用部署自己的数据库,展示的数据也很全面,网页的 GUI 看着也是蛮舒服的
GUI 参考
写 umami 模块的过程也是挺坎坷的,遇到了很多坑。
准备工作
为了使用 umami,要先得到三样东西,website id,tracking code 和 api key,简单带过
首先在 umami 官网 注册账号,之后到控制台新增网站,然后打开右侧设置页面
可以看到 Website ID 和 Tracking code
为了统计网站数据,我们需要将 <script> 代码片段插入到网页的 <head> 里面,当有人访问网站时,该脚本会自动向 umami 发送数据以进行统计。在 anzhiyu 主题,主题的 _config.yml 文件中内置了插入接口,可以直接放在 inject.head。
inject:
head:
- <script defer src="https://cloud.umami.is/script.js" data-website-id="xxxx"></script>
# 自定义css
# - <link rel="stylesheet" href="/css/custom.css" media="defer" onload="this.media='all'">
bottom:
# 自定义js
# - <script src="/js/xxx"></script>测试是否可用,文件保存后,打开网站随便刷新几下。还是相当灵敏,这是在 umami 控制台看到的访问数据

api key
上述流程非常之简单啊,于是来到了下面的关键部分
获取数据
我不止想从 umami 网页上看到数据,还想在博客展示,那肯定要先拉取统计数据。然而 GPT 说要写 js,向服务器请求数据。啊?可我只是编程菜鸡啊,没学过 js 根本看不懂呐,怎么办 QAQ,那我只能浅学恶补一下 js 了
JavaScript 基础
网页三剑客之一的 JavaScript (js),是动态,解释型编程语言,负责网页的行为/交互逻辑,同时也常用于服务器和移动端的开发
机制
事件循环(Event loop) 是 JavaScript 的核心机制,解释了单线程 JS 如何处理同步任务和异步任务。在此机制下,任务执行顺序大致是 同步任务 -> 微任务 -> 一个宏任务 -> 微任务 -> 一个宏任务 … ,注意每个宏任务执行后都会清空微任务队列且同步任务优先级最高,如此直到队列为空
主线程遇到同步任务时会立刻执行直到完成,遇到异步任务时不会等待完成,即不会被其阻塞,会把任务交给游览器或 node.js 后台等外部环境处理(比如 I/O,定时器,网络请求),然后自己去执行后续任务。异步任务完成后,其回调被加入微任务或宏任务队列等待主进程去执行,这也就是异步执行的逻辑
运行
Node.js 是执行 javascript 代码的工具,在终端使用 node 执行 js 代码
node file.js发起请求
通过 fetch 可以向服务器发起请求
let res = await fetch("https://example.com");GET 请求完整示例,比如 fetch octocat(据说是吉祥物) 的用户信息
let URL = "https://api.github.com/users/octocat";
fetch(URL)
.then(res => res.json())
.then(data => console.log(data));
console.log("主线程已走到此处")调用 fetch 会立刻执行网络请求并返回一个 promise 对象,而且主线程并不会等待请求的完成,会将等待网络请求的任务丢给别人,之后其走到 .then() 时会注册回调,.then() 里面的东西会也会异步执行
promise 对象有三种状态 pending,fulfilled 或 rejected,当状态变成 fulfilled/rejected 时,注册的 .then()/.catch() 回调会加入微任务队列,等待主线程执行
若能成功 fetch,会输出 JSON 格式内容,大概是
主线程已走到此处
{
login: 'octocat',
id: 583231,
url: 'https://api.github.com/users/octocat',
name: 'The Octocat',
blog: 'https://github.blog',
location: 'San Francisco',
public_repos: 8,
followers: 20890,
following: 9,
}可以发现很不合理的现象 —— 下面的语句更先执行。这正是因为主线程没有等待 fetch 执行完成,先去执行了后续任务也就是 console.log("主线程已走到此处"),当 promise 状态改变后才执行了 fetch 中的回调,输出了 json 数据
更现代化的写法是用 async funtion,在此函数中允许使用 await 来暂停函数内部的执行。具体来说,为何需要异步执行,因为服务器返回数据需要时间,为了不阻塞主进程,fetch 发起请求之后,函数的剩余部分会进入微任务队列,主线程会先去执行别的任务,等 promise 改变即服务器响应后,再执行该函数剩余部分,这样就有了更高的工作效率。
(async () => {
let res = await fetch(URL);
let data = await res.json();
console.log(data);
})();res.json() 前也需要 await ,此解析操作也是异步执行,没有会导致过早的执行 console.log(data)
此外,( lambda )(); 是立即执行的匿名函数表达式(IIFE),=> 是 js 里匿名函数的写法,上述代码等价于
async function work (){
let res = await fetch(URL);
let data = await res.json();
console.log(data);
}
work()拉取网站统计信息
详细接口可以参考 umami docs,获取相关数据我们可以使用 Endpoint : ../websites/<website_Id>/stats
umami cloud 是官方提供所有用户的存储服务,若要使用则前缀设为 https://api.umami.is/v1 即可
接着要在请求头带上 api_key,另外可以带上 startAt & endAt 参数指定时间范围,js 代码如下,stAt/edAt 为时间戳(ms)
const URL = "https://api.umami.is/v1";
const WEBSITE_ID = "xxx";
const API_KEY = "xxx";
async function getStats(stAt, edAt){
const url = `${URL}/websites/${WEBSITE_ID}/stats?startAt=${stAt}&endAt=${edAt}`;
try{
const res = await fetch(url, {
headers: {
// 说明期望数据格式是 json 格式
"Accept": "application/json",
"x-umami-api-key": API_KEY
}
});
if(!res.ok) throw new Error(`Status: ${res.status}`);
return await res.json();
}
catch(error){
console.error("获取数据出错 QAQ", error);
return {pageviews: -1, visitors: -1};
}
}
(async () => {
let tm1 = new Date();
let tm2 = new Date(); // 获取当前时间
tm1.setHours(0, 0, 0, 0); // 设 tm1 为 00:00:00
let res = await getStats(tm1.getTime(), tm2.getTime());
console.log(res);
})();若是自建数据库,并部署了 umami 实例,为获取数据库中的数据,上述代码中 URL 改成 http://<your-umami-instance>/api 即可,从 umami 获取到的 JSON 数据参考
{
pageviews: 29,
visitors: 2,
visits: 6,
bounces: 1,
totaltime: 3674,
comparison: { pageviews: 4, visitors: 2, visits: 2, bounces: 1, totaltime: 341 }
}一开始我理解错了文档,导致一直 fetch 不到信息,状态码 500。
官网写的是 Endpoint : GET /api/websites/:websiteId/stats,于是我就通过 Umami Cloud 的 https://api.umami.is/vi 去 fetch("https://api.umami.is/v1/api/websites/:websiteId/stats),但是根本就 fetch 不到啊,我一堆问号,问 ai 才知道,原来不应该写 /api,果然,去掉之后成功 fetch。
后来发现文档原来有 curl 的示例,然而因为 crul 不熟,所以我直接略过,导致调了半天,没仔细阅读文档导致的。
anzhiyu 统计模块
有了上述代码,我们就可以得到任意时间段的数据。我需要的是 今日人数, 今日访问, 昨日人数, 昨日访问, 本月访问, 本年访问,还要处理一下数据,代码如下
const URL = "https://api.umami.is/v1";
const WEBSITE_ID = "xxx";
const API_KEY = "xxx";
async function getStats(stAt, edAt){
const url = `${URL}/websites/${WEBSITE_ID}/stats?startAt=${stAt}&endAt=${edAt}`;
try{
const res = await fetch(url, {
headers: {
// 说明期望数据格式是 json 格式
"Accept": "application/json",
"x-umami-api-key": API_KEY
}
});
if(!res.ok) throw new Error(`Status: ${res.status}`);
return await res.json();
}
catch(error){
console.error("获取数据出错 QAQ", error);
return {pageviews: -1, visitors: -1};
}
}
async function getAll(){
// 今日, 昨日, 本月, 本年
let tday0 = new Date(), tday1 = new Date();
tday0.setHours(0, 0, 0, 0);
let yday0 = new Date(), yday1 = new Date();
yday0.setDate(tday0.getDate() - 1);
yday1.setDate(tday0.getDate() - 1);
yday0.setHours(0, 0, 0, 0);
yday1.setHours(23, 59, 59, 999)
let mo0 = new Date(), mo1 = new Date();
mo0.setDate(1);
mo0.setHours(0, 0, 0, 0);
let year0 = new Date(), year1 = new Date();
year0.setMonth(0, 1); // 1 月 1 日
// 并行处理
const [tday_res, yday_res, mo_res, year_res] = await Promise.all([
getStats(tday0.getTime(), tday1.getTime()),
getStats(yday0.getTime(), yday1.getTime()),
getStats(mo0.getTime(), mo1.getTime()),
getStats(year0.getTime(), year1.getTime())
]);
const result = {
"今日人数" : tday_res.visitors,
"今日访问" : tday_res.pageviews,
"昨日人数" : yday_res.visitors,
"昨日访问" : yday_res.pageviews,
"本月访问" : mo_res.pageviews,
"本年访问" : year_res.pageviews
};
// console.log(result);
return result;
};
(async () => {
const res = await getAll();
console.log(res);
})();node file.js 运行,输出如下
{
'今日人数': 5,
'今日访问': 56,
'昨日人数': 4,
'昨日访问': 43,
'本月访问': 56,
'本年访问': 841
}接着我在 themes\anzhiyu\layout\includes\page\about.pug 中找到了主题自带的 LA 统计模块,初看很晦涩,然而仔细研究一下代码,发现确实很晦涩,在 ai 的辅助下也是有点难看懂,不过没关系,只需要看懂部分关键逻辑,其他直接沿用就好,于是就有了下面的代码,主要是插入了上述代码,然后对原代码稍加修改最后加上 if/else 就大功告成了。
补充一下,pug 是一种生成 html 的模板语言,在 script(). 里引用 pug 变量 需要使用 #{},在修改 pug 文件后需要 hexo clean && hexo s 重新部署后网页才会改动。
注意 : 用户可以读到前端代码,因而 API_KEY 之类的东西不能直接明文写在前端比如 pug 里,应该定义在后端,在前端引用。
( 严格来说其实下面是 pug 代码,因为 highlight 不支持 pug,我搞了半天也没搞懂怎么弄,只能暂用 js 标签了 TAT )
.author-content
if theme.LA.enable || theme.umami.enable // +
- let cover = item.statistic.cover
.about-statistic.author-content-item(style=`background: url(${cover}) top / cover no-repeat;`)
.card-content
.author-content-item-tips 数据
span.author-content-item-title 访问统计
#statistic
.post-tips
| 统计信息来自
// ++
if(theme.umami.enable)
a(href='https://cloud.umami.is/analytics/us/websites', target='_blank', rel='noopener nofollow') umami 统计工具
else if(theme.LA.enable)
a(href='https://invite.51.la/1NzKqTeb?target=V6', target='_blank', rel='noopener nofollow') 51la 网站统计
// ++
.banner-button-group
- let link = item.statistic.link
- let text = item.statistic.text
a.banner-button(onclick=`pjax.loadUrl("${link}")`)
i.anzhiyufont.anzhiyu-icon-arrow-circle-right
|
span.banner-button-text=text
// ... 无关代码省略 ... //
script(defer).
function initAboutPage() {
// LA 部分
if(#{theme.LA.enable}){
fetch("https://v6-widget.51.la/v6/#{ck}/quote.js")
.then(res => res.text())
.then(data => {
let title = ["最近活跃", "今日人数", "今日访问", "昨日人数", "昨日访问", "本月访问", "总访问量"];
let num = data.match(/(<\/span><span>).*?(\/span><\/p>)/g);
num = num.map(el => {
let val = el.replace(/(<\/span><span>)/g, "");
let str = val.replace(/(<\/span><\/p>)/g, "");
return str;
});
let statisticEl = document.getElementById("statistic");
// 自定义不显示哪个或者显示哪个,如下为不显示 最近活跃访客 和 总访问量
let statistic = [];
for (let i = 0; i < num.length; i++) {
if (!statisticEl) return;
if (i == 0) continue;
statisticEl.innerHTML +=
"<div><span>" + title[i] + "</span><span id=" + title[i] + ">" + num[i] + "</span></div>";
queueMicrotask(() => {
statistic.push(
new CountUp(title[i], 0, num[i], 0, 2, {
useEasing: true,
useGrouping: true,
separator: ",",
decimal: ".",
prefix: "",
suffix: "",
})
);
});
}
let statisticElement = document.querySelector(".about-statistic.author-content-item");
function statisticUP() {
if (!statisticElement) return;
const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
for (let i = 0; i < num.length; i++) {
if (i == 0) continue;
queueMicrotask(() => {
statistic[i - 1].start();
});
}
observer.disconnect(); // 停止观察元素,因为不再需要触发此回调
}
});
};
const options = {
root: null,
rootMargin: "0px",
threshold: 0
};
const observer = new IntersectionObserver(callback, options);
observer.observe(statisticElement);
}
const selfInfoContentYear = new CountUp("selfInfo-content-year", 0, #{selfInfoContentYear}, 0, 2, {
useEasing: true,
useGrouping: false,
separator: ",",
decimal: ".",
prefix: "",
suffix: "",
});
let selfInfoContentYearElement = document.querySelector(".author-content-item.selfInfo.single");
function selfInfoContentYearUp() {
if (!selfInfoContentYearElement) return;
const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
selfInfoContentYear.start();
observer.disconnect(); // 停止观察元素,因为不再需要触发此回调
}
});
};
const options = {
root: null,
rootMargin: "0px",
threshold: 0
};
const observer = new IntersectionObserver(callback, options);
observer.observe(selfInfoContentYearElement);
}
selfInfoContentYearUp();
statisticUP()
});
}
// 新增的 umami 部分
else if (#{theme.umami.enable}){
(async () => {
const URL = "#{theme.umami.api_url}";
const WEBSITE_ID = "#{theme.umami.website_id}";
const API_KEY = "#{theme.umami.api_key}";
let statistic = [];
async function getStats(stAt, edAt){
const url = `${URL}/websites/${WEBSITE_ID}/stats?startAt=${stAt}&endAt=${edAt}`;
try{
const res = await fetch(url, {
headers: {
// 说明期望数据格式是 json 格式
"Accept": "application/json",
"x-umami-api-key": API_KEY
}
});
if(!res.ok) throw new Error(`Status: ${res.status}`);
return await res.json();
}
catch(error){
console.error("获取数据出错 QAQ", error);
return {pageviews: -1, visitors: -1};
}
}
async function getAll(){
// 今日, 昨日, 本月, 本年
let tday0 = new Date(), tday1 = new Date();
tday0.setHours(0, 0, 0, 0);
let yday0 = new Date(), yday1 = new Date();
yday0.setDate(tday0.getDate() - 1);
yday1.setDate(tday0.getDate() - 1);
yday0.setHours(0, 0, 0, 0);
yday1.setHours(23, 59, 59, 999)
let mo0 = new Date(), mo1 = new Date();
mo0.setDate(1);
mo0.setHours(0, 0, 0, 0);
let year0 = new Date(), year1 = new Date();
year0.setMonth(0, 1); // 1 月 1 日
// 并行处理
const [tday_res, yday_res, mo_res, year_res] = await Promise.all([
getStats(tday0.getTime(), tday1.getTime()),
getStats(yday0.getTime(), yday1.getTime()),
getStats(mo0.getTime(), mo1.getTime()),
getStats(year0.getTime(), year1.getTime())
]);
const result = {
"今日人数" : tday_res.visitors,
"今日访问" : tday_res.pageviews,
"昨日人数" : yday_res.visitors,
"昨日访问" : yday_res.pageviews,
"本月访问" : mo_res.pageviews,
"本年访问" : year_res.pageviews
};
// console.log(result);
return result;
};
//- let result = {
//- "A" : 1,
//- "B" : 2,
//- "C" : 3,
//- "D" : 4
//- };
let result = await getAll();
let statisticEl = document.getElementById("statistic");
for(const [key, value] of Object.entries(result)){
statisticEl.innerHTML +=
"<div><span>" + key + "</span><span id=" + key + ">" + value + "</span></div>";
queueMicrotask(() => {
statistic.push(
new CountUp(key, 0, value, 0, 2, {
useEasing: true,
useGrouping: true,
separator: ",",
decimal: ".",
prefix: "",
suffix: "",
})
);
});
}
let statisticElement = document.querySelector(".about-statistic.author-content-item");
function statisticUP() {
if (!statisticElement) return;
const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
for (let i = 0; i < statistic.length; i++) {
queueMicrotask(() => {
statistic[i].start();
});
}
observer.disconnect(); // 停止观察元素,因为不再需要触发此回调
}
});
};
const options = {
root: null,
rootMargin: "0px",
threshold: 0
};
const observer = new IntersectionObserver(callback, options);
observer.observe(statisticElement);
}
const selfInfoContentYear = new CountUp("selfInfo-content-year", 0, #{selfInfoContentYear}, 0, 2, {
useEasing: true,
useGrouping: false,
separator: ",",
decimal: ".",
prefix: "",
suffix: "",
});
let selfInfoContentYearElement = document.querySelector(".author-content-item.selfInfo.single");
function selfInfoContentYearUp() {
if (!selfInfoContentYearElement) return;
const callback = (entries, observer) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
selfInfoContentYear.start();
observer.disconnect(); // 停止观察元素,因为不再需要触发此回调
}
});
};
const options = {
root: null,
rootMargin: "0px",
threshold: 0
};
const observer = new IntersectionObserver(callback, options);
observer.observe(selfInfoContentYearElement);
}
selfInfoContentYearUp();
statisticUP()
})();
}
// ... 无关代码省略 ...现在这个模块就差不多完工了,我们还需要在 themes\anzhiyu\_config.yml 中添加相应字段来启用
# 51a统计配置
LA:
enable: false
ck:
LingQueMonitorID:
# 新增的 umami 统计配置
umami:
enable: true # 是否启用 Umami 统计
# 若用 umami cloud 则为 "https://api.umami.is/v1",自建数据库则为 "http://<your-umami-instance>/api"
api_url: "https://api.umami.is/v1"
website_id: "xxx"
api_key: "xxx"最后还需要借用 anzhiyu 佬的 LA 统计模块的前端,我最终在 themes\anzhiyu\source\css\_page\about.styl 找到了当 LA.enable 为 true 时会调用的前端代码,加上三行即可
if (hexo-config('LA.enable')) {
body: 1;
}
// ++
else if (hexo-config('umami.enable')) {
body: 1;
}
// ++
else {
#about-page {
.author-content-item-group.column.mapAndInfo {
width: 100%;
}
.author-content-item-group.column {
flex-direction: row;
}
.author-content-item.map {
width: 50%;
}
.author-content-item.selfInfo {
height: 100%;
width: 49%;
}
}
}终于! 到这里就完工了,hexo clean && hexo s 重新部署就可以,效果图如下
注意 : Umami Cloud 数据存储时限大概为 6 个月,有需求可以自行部署数据库
写在最后
插曲
写 about.pug 时被 vsc 坑了,它竟然没标出语法错误,我没意识到直到肉眼观察到一个极其离谱的语法错误。调试这些错误,也是直接将我 about 页的 pv 干到了 500 多。要四了,愿天堂有能显示 pug 语法错误的 vsc
还遇到了一个非常诡异的情况,服务器端网站竟然和本地网站显示不一样?我无可奈何,hexo clean && hexo g 后重新上传也是不行。想到可能是代码写史了,没办法只能新建一个 TEMPblog 文件夹,然后重装一遍 HEXO&ANZHIYU 了,不过轻车熟路啊,几分钟就搞好了,重新 new page about 后发现,开启 LA 统计模块咋也有相同问题,百思不得解。
实在没办法了,只能去问万能的群 u 了,没想到啊没想到,群 u 直接把问题秒了
大概就是这样,好玄学啊,我从控制台没看到网页有缓存任何数据啊,不理解,不过群 u 真是见多识广%%%
于是不知道搞了多少小时 umami 统计模块终于完工了,最后实现的效果还是很好的🎉















