2067 字
10 分钟
静态博客展示文章浏览量
2025-09-16
2025-10-12
统计加载中...

引言#

如果你用过WordPress,Halo等动态博客框架,你大概会在用户视角访问博文的时候看到浏览量这个信息。

这个原理很简单,因为动态博客依赖于一个VPS,只需要让用户每次访问的时候给浏览量+1即可。

那么如果我们是静态博客呢?

我们可以依赖一些第三方服务,比如Umami Cloud。在你的静态博客的head注入一个js,这样你就可以看到你的站点分析了,类似下图

image-20250916234245274

现在我们可以看到网站整体的访问量和每个文章(即/posts/xxx)的访问量了,但是我们要如何展示给用户呢?

1. 分析实现过程#

因为我采用的是免费方案,Umami Cloud这个是免费,因此不能新建一个用户,同时博客部署都是采用免费方案。

为了稍微安全一点,不建议采用Umami Cloud账号登录

当然如果只是用于测试,那么无所谓啦~

image-20250916235617225

注意这里的 IrVVYJ4qc2J3tc7s ,每个站点都不一样

接着我们请求 https://us.umami.is/api/share/IrVVYJ4qc2J3tc7s,得到tokenwebsiteId

这个是2025年10月12日修改的,直接采用官网的,之前的方案不能用了

接着我们请求https://cloud.umami.is/analytics/us/api/share/IrVVYJ4qc2J3tc7s,得到tokenwebsiteId

账号token和websiteId
{
"websiteId": "77083547-4c2e-4183-b842-520d9de3d82b",
"token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ3ZWJzaXRlSWQiOiI3NzA4MzU0Ny00YzJlLTQxODMtYjg0Mi01MjBkOWRlM2Q4MmIiLCJpYXQiOjE3NTgwMzgyMzh9.7RNrqCaOvgKYFifvpbmtsZR1ykAETgqqOrPgFUpWtng"
}

这个时候可以通过/api/websites/:websiteId/stats 获取网站统计汇总。

image-20250917000714829

携带请求头 x-umami-share-token 值为上一步获得的Token

2.0:每次访问都需要携带token才能正常使用,虽然访问https://us.umami.is/api/share/IrVVYJ4qc2J3tc7sx-umami-share-token是undefined,但是需要数据的请求都是有值的,

3.0:现在请求链接:https://cloud.umami.is/analytics/us/share/i6f3UwPY4n0w1LJa

image-20250917001706410

最新的Umami数据API
https://us.umami.is/api/websites/77083547-4c2e-4183-b842-520d9de3d82b/stats?startAt=0&endAt=1757937599999&unit=hour&timezone=Asia/Hong_Kong&url=/posts/cf-fastip/&compare=false
https://cloud.umami.is/analytics/us/api/websites/77083547-4c2e-4183-b842-520d9de3d82b/stats?startAt=0&endAt=1757937599999&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&path=eq.%2Fposts%2F${slug}%2F&compare=false`;

这里解释几个关键Params,其他的照搬

  • startAt:统计开始时间。Unix时间戳,我们填写为0让Umami从1970年开始统计
  • endAt:统计结束时间。Unix时间戳,我们可以使用 Date.now() ,即当前时间,和startAt参数联动即可实现统计总浏览量
  • url(弃用):要查询的路径,填写为你的文章页去除了Host的路径,如 /posts/hello 。注意!Umami会将 /posts/hello/posts/hello/ 视为两个不同的路径,请注意你的博客框架是否使用 /
  • path=eq.:通过 path 参数配合 eq. 前缀(表示 “等于”)筛选路径,这是 Umami 支持的 “高级筛选语法”(类似 SQL 的条件表达式),功能上与 url 参数等效,但格式更规范(支持更多条件如 contains 包含、ne 不等于等)。

你会得到

浏览统计
// Umami2.0版本
{
"pageviews": {
"value": 26,
"prev": 0
},
"visitors": {
"value": 5,
"prev": 0
},
"visits": {
"value": 9,
"prev": 0
},
"bounces": {
"value": 3,
"prev": 0
},
"totaltime": {
"value": 2885,
"prev": 0
}
}
// Umami3.0版本
{
"pageviews": 125,
"visitors": 2,
"visits": 4,
"bounces": 1,
"totaltime": 5713,
"comparison": {
"pageviews": 0,
"visitors": 0,
"visits": 0,
"bounces": 0,
"totaltime": 0
}
}

2.0版本:pageviews.vlaue 即浏览量。 visits.value 即访问次数。

3.0版本:pageviews 即浏览量。 visits 即访问次数。

Tips:浏览量记录为任意用户只要访问了则计数一次。而访问数记录不会记录单IP多次重复访问和同一时间段的多次请求不同页面

2. 代码展示#

src\config.ts中增加UmamiConfig 和 statsConfig

src\config.ts UmamiConfig 和 statsConfig
export const umamiConfig: UmamiConfig = {
enable: true,
baseUrl: "https://us.umami.is",
baseUrl: "https://cloud.umami.is",
shareId: "IrVVYJ4qc2J3tc7s",
timezone: "Asia/Shanghai",
};
export const statsConfig = {
viewsText: "浏览量",
visitsText: "访客",
loadingText: "统计加载中...",
unavailableText: "统计不可用。",
getStatsText: (pageViews: number, visits: number) =>
`${statsConfig.viewsText} ${pageViews} · ${statsConfig.visitsText} ${visits}`,
};

src\types\config.ts配置UmamiConfig类别

src\types\config.ts
export type UmamiConfig = {
enable: boolean;
baseUrl: string;
shareId: string;
timezone: string;
};

新增public\js\umami-share.js

public\js\umami-share.js
((global) => {
const cacheKey = "umami-share-cache";
const cacheTTL = 3600_000; // 1h
async function fetchShareData(baseUrl, shareId) {
const cached = localStorage.getItem(cacheKey);
if (cached) {
try {
const parsed = JSON.parse(cached);
if (Date.now() - parsed.timestamp < cacheTTL) {
return parsed.value;
}
} catch {
localStorage.removeItem(cacheKey);
}
}
const res = await fetch(`${baseUrl}/api/share/${shareId}`);
const res = await fetch(`${baseUrl}/analytics/us/api/share/${shareId}`);
if (!res.ok) {
throw new Error("获取 Umami 分享信息失败");
}
const data = await res.json();
localStorage.setItem(
cacheKey,
JSON.stringify({ timestamp: Date.now(), value: data }),
);
return data;
}
/**
* 获取 Umami 分享数据(websiteId、token)
* 在缓存 TTL 内复用;并用全局 Promise 避免并发请求
* @param {string} baseUrl
* @param {string} shareId
* @returns {Promise<{websiteId: string, token: string}>}
*/
global.getUmamiShareData = (baseUrl, shareId) => {
if (!global.__umamiSharePromise) {
global.__umamiSharePromise = fetchShareData(baseUrl, shareId).catch(
(err) => {
delete global.__umamiSharePromise;
throw err;
},
);
}
return global.__umamiSharePromise;
};
global.clearUmamiShareCache = () => {
localStorage.removeItem(cacheKey);
delete global.__umamiSharePromise;
};
})(window);

文章页面src\components\PostMeta.astro增加显示浏览量

image-20250917005701565

src\components\PostMeta.astro
import { Icon } from "astro-icon/components";
// 导入statsConfig, umamiConfig配置
import { statsConfig, umamiConfig } from "../config";
import I18nKey from "../i18n/i18nKey";
import { i18n } from "../i18n/translation";
import { formatDateToYYYYMMDD } from "../utils/date-utils";
import { getCategoryUrl, getTagUrl } from "../utils/url-utils";
interface Props {
class: string;
published: Date;
updated?: Date;
tags: string[];
category: string | null;
hideTagsForMobile?: boolean;
hideUpdateDate?: boolean;
//当前页面
slug?: string;
}
const {
published,
updated,
tags,
category,
hideTagsForMobile = false,
hideUpdateDate = false,
//当前页面
slug,
} = Astro.props;
const className = Astro.props.class;
--------------------------------------------------------------------------------------------
<!-- page views / 页面显示 -->
{slug && (
<div class="flex items-center">
<div class="meta-icon">
<Icon name="material-symbols:visibility-outline-rounded" class="text-xl"></Icon>
</div>
<span class="text-50 text-sm font-medium" id="page-views-display">{statsConfig.loadingText}</span>
</div>
)}
</div>
--------------------------------------------------------------------------------------------
{slug && (
<script define:vars={{ slug, umamiConfig, unavailableText: statsConfig.unavailableText, viewsText: statsConfig.viewsText, visitsText: statsConfig.visitsText }}>
// 客户端统计文案生成函数
function generateStatsText(pageViews, visits) {
return `${viewsText} ${pageViews} · ${visitsText} ${visits}`;
}
// 获取访问量统计
async function fetchPageViews(isRetry = false) {
if (!umamiConfig.enable) {
return;
}
try {
// 调用全局工具获取 Umami 分享数据
const { websiteId, token } = await getUmamiShareData(umamiConfig.baseUrl, umamiConfig.shareId);
// 第二步:获取统计数据
const currentTimestamp = Date.now();
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
const statsUrl = `${umamiConfig.baseUrl}/analytics/us/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&path=eq.%2Fposts%2F${slug}%2F&compare=false`;
const statsResponse = await fetch(statsUrl, {
headers: {
'x-umami-share-token': token
}
});
if (!statsResponse.ok) {
if (statsResponse.status === 401 && !isRetry) {
clearUmamiShareCache();
return fetchPageViews(true);
}
throw new Error('获取统计数据失败');
}
const statsData = await statsResponse.json();
const pageViews = statsData.pageviews?.value || 0;
const visits = statsData.visits?.value || 0;
const pageViews = statsData.pageviews || 0;
const visits = statsData.visits || 0;
const displayElement = document.getElementById('page-views-display');
if (displayElement) {
displayElement.textContent = generateStatsText(pageViews, visits);
}
} catch (error) {
console.error('Error fetching page views:', error);
const displayElement = document.getElementById('page-views-display');
if (displayElement) {
displayElement.textContent = unavailableText;
}
}
}
// 页面加载完成后获取统计数据
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', fetchPageViews);
} else {
fetchPageViews();
}
</script>
)}

首页卡片文章src\components\PostCard.astro 增加显示浏览量

image-20250917005643777

src\components\PostCard.astro
import { statsConfig, umamiConfig } from "../config";
--------------------------------------------------------------------------------------------
<!-- word count and read time and 浏览量 -->
<div class="text-sm text-black/30 dark:text-white/30 flex gap-4 transition">
<div>
{remarkPluginFrontmatter.words} {" " + i18n(remarkPluginFrontmatter.words === 1 ? I18nKey.wordCount : I18nKey.wordsCount)}
</div>
<div>|</div>
<div>
{remarkPluginFrontmatter.minutes} {" " + i18n(remarkPluginFrontmatter.minutes === 1 ? I18nKey.minuteCount : I18nKey.minutesCount)}
</div>
<div>|</div>
<div>
<span class="text-50 text-sm font-medium" id={`page-views-${entry.slug}`}>{statsConfig.loadingText}</span>
</div>
</div>
--------------------------------------------------------------------------------------------
<script define:vars={{ entry, umamiConfig, unavailableText: statsConfig.unavailableText, viewsText: statsConfig.viewsText, visitsText: statsConfig.visitsText }}>
// 客户端统计文案生成函数
function generateStatsText(pageViews, visits) {
return `${viewsText} ${pageViews} · ${visitsText} ${visits}`;
}
// 获取文章浏览量统计
async function fetchPostCardViews(slug) {
if (!umamiConfig.enable) {
return;
}
try {
// 调用全局工具获取 Umami 分享数据
const { websiteId, token } = await getUmamiShareData(umamiConfig.baseUrl, umamiConfig.shareId);
// 第二步:获取统计数据
const currentTimestamp = Date.now();
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&url=%2Fposts%2F${slug}%2F&compare=false`;
const statsUrl = `${umamiConfig.baseUrl}/analytics/us/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&path=eq.%2Fposts%2F${slug}%2F&compare=false`;
const statsResponse = await fetch(statsUrl, {
headers: {
'x-umami-share-token': token
}
});
if (statsResponse.status === 401) {
clearUmamiShareCache();
return await fetchPostCardViews(slug);
}
if (!statsResponse.ok) {
throw new Error('获取统计数据失败');
}
const statsData = await statsResponse.json();
const pageViews = statsData.pageviews?.value || 0;
const visits = statsData.visits?.value || 0;
const pageViews = statsData.pageviews || 0;
const visits = statsData.visits || 0;
const displayElement = document.getElementById(`page-views-${slug}`);
if (displayElement) {
displayElement.textContent = generateStatsText(pageViews, visits);
}
} catch (error) {
console.error('Error fetching page views for', slug, ':', error);
const displayElement = document.getElementById(`page-views-${slug}`);
if (displayElement) {
displayElement.textContent = unavailableText;
}
}
}
// 页面加载完成后获取统计数据
function initPostCardStats() {
const slug = entry.slug;
if (slug) {
fetchPostCardViews(slug);
}
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initPostCardStats);
} else {
initPostCardStats();
}
</script>
<style define:vars={{coverWidth}}>
</style>

显示总的浏览量src\components\widget\Profile.astro

image-20250917005622652

src\components\widget\Profile.astro
import { profileConfig, statsConfig, umamiConfig } from "../../config";
--------------------------------------------------------------------------------------------
<!-- 全站访问量统计 -->
<div class="text-center text-sm text-neutral-500 dark:text-neutral-400 mt-3 pt-3 border-t border-neutral-200 dark:border-neutral-700">
<div class="flex items-center justify-center gap-1">
<Icon name="material-symbols:visibility-outline" class="text-base"></Icon>
<span id="site-stats">{statsConfig.loadingText}</span>
</div>
</div>
--------------------------------------------------------------------------------------------
<script define:vars={{ umamiConfig, unavailableText: statsConfig.unavailableText, viewsText: statsConfig.viewsText, visitsText: statsConfig.visitsText }}>
// 获取全站访问量统计
// 客户端统计文案生成函数
function generateStatsText(pageViews, visits) {
return `${viewsText} ${pageViews} · ${visitsText} ${visits}`;
}
async function loadSiteStats() {
if (!umamiConfig.enable) {
return;
}
try {
// 调用全局工具获取 Umami 分享数据
const { websiteId, token } = await getUmamiShareData(umamiConfig.baseUrl, umamiConfig.shareId);
// 第二步:获取全站统计数据(不指定url参数获取全站数据)
const currentTimestamp = Date.now();
const statsUrl = `${umamiConfig.baseUrl}/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&compare=false`;
const statsUrl = `${umamiConfig.baseUrl}/analytics/us/api/websites/${websiteId}/stats?startAt=0&endAt=${currentTimestamp}&unit=hour&timezone=${encodeURIComponent(umamiConfig.timezone)}&compare=false`;
const statsResponse = await fetch(statsUrl, {
headers: {
'x-umami-share-token': token
}
});
if (statsResponse.status === 401) {
// token 失效,清理缓存后重新获取一次
clearUmamiShareCache();
return await loadSiteStats();
}
if (!statsResponse.ok) {
throw new Error('获取统计数据失败');
}
const statsData = await statsResponse.json();
const pageviews = statsData.pageviews?.value || 0;
const visitors = statsData.visits?.value || 0;
const pageviews = statsData.pageviews || 0;
const visitors = statsData.visits || 0;
const statsElement = document.getElementById('site-stats');
if (statsElement) {
statsElement.textContent = generateStatsText(pageviews, visitors);
}
} catch (error) {
console.error('获取全站统计失败:', error);
const statsElement = document.getElementById('site-stats');
if (statsElement) {
statsElement.textContent = unavailableText;
}
}
}
// 页面加载完成后获取统计数据
document.addEventListener('DOMContentLoaded', loadSiteStats);
</script>

3. 代码引用#

src\layouts\Layout.astro
<!DOCTYPE html>
<html lang={siteLang} class="bg-[var(--page-bg)] transition text-[14px] md:text-[16px]"
data-overlayscrollbars-initialize
>
<head>
<title>{pageTitle}</title>
// ....
<script defer src="https://cloud.umami.is/script.js" data-website-id="f11536ad-eac1-434b-a0c7-fc78faa63405"></script>
<script src="/js/umami-share.js" defer></script>
{favicons.map(favicon => (
<link rel="icon"
href={favicon.src.startsWith('/') ? url(favicon.src) : favicon.src}
sizes={favicon.sizes}
media={favicon.theme && `(prefers-color-scheme: ${favicon.theme})`}
/>
))}
静态博客展示文章浏览量
https://pengxing.dpdns.org/posts/静态博客展示文章浏览量/
作者
鹏星
发布于
2025-09-16
许可协议
CC BY-NC-SA 4.0