---
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
}
```

小功能，但很适合放在侧边栏。它不会喧宾夺主，却能让网站看起来更像一个正在被维护、正在呼吸的地方。
