From 2f834a0eb7b91d8786aa99bec909e4f02281e3fe Mon Sep 17 00:00:00 2001 From: biss Date: Fri, 19 Jun 2026 09:12:01 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E6=96=87=E7=AB=A0+=E6=B8=B2=E6=9F=93?= =?UTF-8?q?=E9=B2=81=E6=A3=92=E6=80=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/content.config.ts | 1 + .../2026.6/umami-share-worker-site-card.md | 388 ++++++++++++++++++ src/lib/posts.ts | 7 +- 3 files changed, 395 insertions(+), 1 deletion(-) create mode 100644 src/content/posts/2026/2026.6/umami-share-worker-site-card.md diff --git a/src/content.config.ts b/src/content.config.ts index 51dfff7..9ded44a 100644 --- a/src/content.config.ts +++ b/src/content.config.ts @@ -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(), diff --git a/src/content/posts/2026/2026.6/umami-share-worker-site-card.md b/src/content/posts/2026/2026.6/umami-share-worker-site-card.md new file mode 100644 index 0000000..53f8887 --- /dev/null +++ b/src/content/posts/2026/2026.6/umami-share-worker-site-card.md @@ -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 +
--
+
--
+``` + +再用 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 +} +``` + +小功能,但很适合放在侧边栏。它不会喧宾夺主,却能让网站看起来更像一个正在被维护、正在呼吸的地方。 diff --git a/src/lib/posts.ts b/src/lib/posts.ts index 0e34590..247786c 100644 --- a/src/lib/posts.ts +++ b/src/lib/posts.ts @@ -30,6 +30,11 @@ function asArray(value: unknown): string[] { return []; } +function getCategories(entry: CollectionEntry<'posts'>): string[] { + const data = entry.data as Record; + 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 { 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,