新文章+渲染鲁棒性
This commit is contained in:
@@ -0,0 +1,388 @@
|
||||
---
|
||||
title: 用 Umami 分享链接给网站信息卡提供统计数据
|
||||
abbrlink: 4c91a8f2
|
||||
date: 2026-06-19 08:58:00
|
||||
categories: 建站手札
|
||||
tags:
|
||||
- 网站
|
||||
- Umami
|
||||
- Cloudflare Worker
|
||||
summary: 这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。
|
||||
---
|
||||
|
||||
最近给博客侧边栏加了一个“网站信息”卡片,里面除了文章数量、总字数、最后更新时间,还想放上本站访客数和访问量。
|
||||
|
||||
一开始想到的是直接调用 Umami API,但现在的 Umami 后台里不一定能很直观地找到 API key。如果为了这两个数字再专门创建一个用户、保存账号密码,又有点重。后来发现 Umami 自带的“分享链接”刚好可以解决这个问题。
|
||||
|
||||
这篇文章记录一下完整做法:用 Umami 分享链接拿公开 token,再用 Cloudflare Worker 请求统计接口并缓存,最后给前端信息卡使用。
|
||||
|
||||
## 整体思路
|
||||
|
||||
流程大概是这样:
|
||||
|
||||
1. 在 Umami 后台给站点创建分享链接。
|
||||
2. Worker 请求 `/api/share/{shareId}`,拿到 `websiteId` 和分享 token。
|
||||
3. Worker 带上分享 token 请求 `/api/websites/{websiteId}/stats`。
|
||||
4. Worker 把结果整理成前端需要的 JSON,并用 Cloudflare 缓存 10 分钟。
|
||||
5. 博客前端请求 Worker 地址,把访客数和访问量填到信息卡。
|
||||
|
||||
这样做的好处是不用暴露后台账号,也不用在前端直接请求 Umami。前端只会看到 Worker 返回的简洁数据。
|
||||
|
||||
## 创建 Umami 分享链接
|
||||
|
||||
进入 Umami 后台,找到对应网站的分享设置,打开需要公开的模块。我这里只需要概览数据,所以勾选“概览”就够了。
|
||||
|
||||
保存后会得到一个类似这样的分享链接:
|
||||
|
||||
```txt
|
||||
https://umami.example.com/share/Hqx6lIBhIHBR13RY
|
||||
```
|
||||
|
||||
最后这一段 `Hqx6lIBhIHBR13RY` 就是分享 ID,后面 Worker 会用到。
|
||||
|
||||
## 分享链接背后的请求
|
||||
|
||||
分享页本身不是一个静态 HTML 页面,直接读取页面源码一般拿不到统计数字。它会先请求:
|
||||
|
||||
```txt
|
||||
/api/share/Hqx6lIBhIHBR13RY
|
||||
```
|
||||
|
||||
这个接口会返回类似这样的数据:
|
||||
|
||||
```json
|
||||
{
|
||||
"shareId": "...",
|
||||
"shareType": 1,
|
||||
"websiteId": "3d97b310-b241-4fc6-bc9c-68e317fc7d42",
|
||||
"token": "..."
|
||||
}
|
||||
```
|
||||
|
||||
然后访问统计接口时,需要带两个请求头:
|
||||
|
||||
```txt
|
||||
x-umami-share-context: 1
|
||||
x-umami-share-token: 上一步返回的 token
|
||||
```
|
||||
|
||||
统计接口是:
|
||||
|
||||
```txt
|
||||
/api/websites/{websiteId}/stats?startAt=0&endAt=当前时间戳
|
||||
```
|
||||
|
||||
返回结果大概是:
|
||||
|
||||
```json
|
||||
{
|
||||
"pageviews": 2,
|
||||
"visitors": 1,
|
||||
"visits": 1,
|
||||
"bounces": 0,
|
||||
"totaltime": 470
|
||||
}
|
||||
```
|
||||
|
||||
其中:
|
||||
|
||||
- `visitors` 是访客数
|
||||
- `pageviews` 是页面访问量
|
||||
- `visits` 是访问会话数
|
||||
|
||||
网站信息卡里我只用 `visitors` 和 `pageviews`。
|
||||
|
||||
## 编写 Cloudflare Worker
|
||||
|
||||
新建一个 Worker,把下面这份代码放进去:
|
||||
|
||||
```js
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 600;
|
||||
|
||||
function jsonResponse(data, init = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getCorsHeaders(request, env) {
|
||||
const origin = request.headers.get('Origin') || '';
|
||||
const allowedOrigin = env.ALLOWED_ORIGIN || 'https://blog.example.com';
|
||||
const allowOrigin = origin === allowedOrigin ? origin : allowedOrigin;
|
||||
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
Vary: 'Origin',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || 'https://umami.example.com').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getShareId(env) {
|
||||
if (env.UMAMI_SHARE_ID) return env.UMAMI_SHARE_ID;
|
||||
if (!env.UMAMI_SHARE_URL) return '';
|
||||
|
||||
try {
|
||||
const url = new URL(env.UMAMI_SHARE_URL);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const shareIndex = parts.indexOf('share');
|
||||
return shareIndex >= 0 ? parts[shareIndex + 1] || '' : parts.at(-1) || '';
|
||||
} catch {
|
||||
return env.UMAMI_SHARE_URL.split('/').filter(Boolean).at(-1) || '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getShareData(env) {
|
||||
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
|
||||
const shareId = getShareId(env);
|
||||
|
||||
if (!shareId) {
|
||||
throw new Error('Missing UMAMI_SHARE_ID or UMAMI_SHARE_URL');
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/share/${shareId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Umami share lookup failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.websiteId || !data.token) {
|
||||
throw new Error('Umami share response did not include websiteId or token');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchUmamiStats(env, shareData) {
|
||||
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
|
||||
const websiteId = env.UMAMI_WEBSITE_ID || shareData.websiteId;
|
||||
const startAt = Number(env.STATS_START_AT || 0);
|
||||
const endAt = Date.now();
|
||||
const url = `${baseUrl}/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`;
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'x-umami-share-context': '1',
|
||||
'x-umami-share-token': shareData.token,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getStats(env) {
|
||||
const shareData = await getShareData(env);
|
||||
const response = await fetchUmamiStats(env, shareData);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Umami stats failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
|
||||
return {
|
||||
visitors: stats.visitors ?? 0,
|
||||
pageviews: stats.pageviews ?? 0,
|
||||
visits: stats.visits ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const corsHeaders = getCorsHeaders(request, env);
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const cacheTtl = Number(env.CACHE_TTL_SECONDS || DEFAULT_CACHE_TTL_SECONDS);
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(
|
||||
`https://site-stats-cache.local/umami-site-stats/${getShareId(env) || 'default'}`,
|
||||
);
|
||||
const cached = await cache.match(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers: {
|
||||
...Object.fromEntries(cached.headers),
|
||||
...corsHeaders,
|
||||
'X-Stats-Cache': 'HIT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getStats(env);
|
||||
const response = jsonResponse(stats, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Cache-Control': `public, max-age=${cacheTtl}`,
|
||||
'X-Stats-Cache': 'MISS',
|
||||
},
|
||||
});
|
||||
|
||||
ctx.waitUntil(cache.put(cacheKey, response.clone()));
|
||||
return response;
|
||||
} catch (error) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'Failed to fetch stats',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 502, headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
```
|
||||
|
||||
## 配置 Worker 变量
|
||||
|
||||
在 Cloudflare Worker 的设置里添加变量:
|
||||
|
||||
```txt
|
||||
UMAMI_BASE_URL = https://umami.example.com
|
||||
UMAMI_SHARE_ID = Hqx6lIBhIHBR13RY
|
||||
ALLOWED_ORIGIN = https://blog.example.com
|
||||
CACHE_TTL_SECONDS = 600
|
||||
```
|
||||
|
||||
如果你更想直接填完整分享链接,也可以不用 `UMAMI_SHARE_ID`,改用:
|
||||
|
||||
```txt
|
||||
UMAMI_SHARE_URL = https://umami.example.com/share/Hqx6lIBhIHBR13RY
|
||||
```
|
||||
|
||||
`CACHE_TTL_SECONDS = 600` 表示缓存 10 分钟。这样访客每次打开页面时,不会都去打 Umami 接口。
|
||||
|
||||
如果你只想统计某个建站时间之后的数据,可以额外设置:
|
||||
|
||||
```txt
|
||||
STATS_START_AT = 1735660800000
|
||||
```
|
||||
|
||||
这个值是毫秒时间戳。不设置的话默认从 `0` 开始,也就是统计全部数据。
|
||||
|
||||
## 部署方式
|
||||
|
||||
如果只是单个 Worker,最简单的方法是在 Cloudflare 后台新建 Worker,然后把 JS 代码直接粘到在线编辑器里。
|
||||
|
||||
如果遇到这样的提示:
|
||||
|
||||
```txt
|
||||
此上传程序暂不支持需要构建过程的项目。至少找到了一个 JavaScript 文件。请改用 wrangler deploy 以获得完整功能支持。
|
||||
```
|
||||
|
||||
通常是因为你上传了整个项目目录。这个场景不需要上传整个博客项目,只需要把 Worker 的 JS 内容粘进去即可。
|
||||
|
||||
如果想用 Wrangler,也可以写一个配置文件:
|
||||
|
||||
```toml
|
||||
name = "umami-site-stats"
|
||||
main = "umami-site-stats.js"
|
||||
compatibility_date = "2026-06-19"
|
||||
|
||||
[vars]
|
||||
UMAMI_BASE_URL = "https://umami.example.com"
|
||||
UMAMI_SHARE_ID = "Hqx6lIBhIHBR13RY"
|
||||
ALLOWED_ORIGIN = "https://blog.example.com"
|
||||
CACHE_TTL_SECONDS = "600"
|
||||
```
|
||||
|
||||
然后执行:
|
||||
|
||||
```bash
|
||||
wrangler deploy
|
||||
```
|
||||
|
||||
## 测试 Worker
|
||||
|
||||
部署好之后,直接访问 Worker 地址,正常会看到:
|
||||
|
||||
```json
|
||||
{
|
||||
"visitors": 1,
|
||||
"pageviews": 2,
|
||||
"visits": 1,
|
||||
"updatedAt": 1781829905467
|
||||
}
|
||||
```
|
||||
|
||||
如果返回 502,可以先看 `message` 字段。常见原因有:
|
||||
|
||||
- `UMAMI_SHARE_ID` 写错
|
||||
- `UMAMI_BASE_URL` 写错
|
||||
- 分享链接没有保存成功
|
||||
- Umami 的分享权限里没有勾选“概览”
|
||||
|
||||
## 接入网站信息卡
|
||||
|
||||
前端信息卡里,静态数据可以直接由博客生成:
|
||||
|
||||
- 文章数目:读取文章列表长度
|
||||
- 本站总字数:遍历文章内容统计
|
||||
- 最后更新时间:取最新文章发布时间
|
||||
|
||||
访客数和访问量则用 Worker 动态填充。页面里先渲染占位:
|
||||
|
||||
```html
|
||||
<dd data-stat-key="visitors">--</dd>
|
||||
<dd data-stat-key="pageviews">--</dd>
|
||||
```
|
||||
|
||||
再用 JS 请求 Worker:
|
||||
|
||||
```js
|
||||
const endpoint = 'https://your-worker.example.workers.dev';
|
||||
const formatter = new Intl.NumberFormat('en-US');
|
||||
|
||||
fetch(endpoint, { headers: { Accept: 'application/json' } })
|
||||
.then((response) => response.json())
|
||||
.then((stats) => {
|
||||
document.querySelector('[data-stat-key="visitors"]').textContent = formatter.format(stats.visitors);
|
||||
document.querySelector('[data-stat-key="pageviews"]').textContent = formatter.format(stats.pageviews);
|
||||
});
|
||||
```
|
||||
|
||||
我这里还在浏览器端加了一层 `localStorage` 缓存,缓存 key 里带上 Worker 地址,防止以后更换接口后还读到旧数据:
|
||||
|
||||
```js
|
||||
const cacheKey = `site-info-stats:${endpoint}`;
|
||||
const cacheDuration = 10 * 60 * 1000;
|
||||
```
|
||||
|
||||
这样前端和 Worker 都有缓存,访问体验会稳一些。
|
||||
|
||||
## 最后
|
||||
|
||||
这个方案的核心是:不要把 Umami 后台账号放到前端,也不需要专门创建只读用户。既然 Umami 分享页本身已经提供了公开数据访问方式,就让 Worker 代替前端去请求它,并顺手做缓存和字段整理。
|
||||
|
||||
最终网站信息卡只需要关心这几个字段:
|
||||
|
||||
```json
|
||||
{
|
||||
"visitors": 1,
|
||||
"pageviews": 2,
|
||||
"visits": 1,
|
||||
"updatedAt": 1781829905467
|
||||
}
|
||||
```
|
||||
|
||||
小功能,但很适合放在侧边栏。它不会喧宾夺主,却能让网站看起来更像一个正在被维护、正在呼吸的地方。
|
||||
Reference in New Issue
Block a user