更换到Astro #9

Merged
biss merged 30 commits from switch-to-astro into master 2026-06-19 13:23:31 +08:00
3 changed files with 395 additions and 1 deletions
Showing only changes of commit 2f834a0eb7 - Show all commits
+1
View File
@@ -8,6 +8,7 @@ const posts = defineCollection({
date: z.any().optional(),
abbrlink: z.any().optional(),
tags: z.any().optional(),
category: z.any().optional(),
categories: z.any().optional(),
summary: z.any().optional(),
description: z.any().optional(),
@@ -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
}
```
小功能,但很适合放在侧边栏。它不会喧宾夺主,却能让网站看起来更像一个正在被维护、正在呼吸的地方。
+6 -1
View File
@@ -30,6 +30,11 @@ function asArray(value: unknown): string[] {
return [];
}
function getCategories(entry: CollectionEntry<'posts'>): string[] {
const data = entry.data as Record<string, unknown>;
return [...asArray(data.categories), ...asArray(data.category)];
}
function toDate(value: unknown): Date {
if (value instanceof Date) return value;
if (typeof value === 'string' || typeof value === 'number') {
@@ -84,7 +89,7 @@ export async function getAllPosts(): Promise<BlogPost[]> {
date,
dateText: formatDate(date),
tags: asArray(entry.data.tags),
categories: asArray(entry.data.categories),
categories: getCategories(entry),
summary: makeSummary(entry),
comments: entry.data.comments !== false,
cover: typeof entry.data.cover === 'string' ? entry.data.cover : undefined,