用 Umami 分享链接给网站信息卡提供统计数据

这篇文章记录如何通过 Umami 的分享链接获取公开统计数据,再用 Cloudflare Worker 做一层缓存代理,最后把访客数和访问量显示到博客侧边栏的网站信息卡里。

AI摘要 QWEN

最近给博客侧边栏加了一个“网站信息”卡片,里面除了文章数量、总字数、最后更新时间,还想放上本站访客数和访问量。

一开始想到的是直接调用 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 后台,找到对应网站的分享设置,打开需要公开的模块。我这里只需要概览数据,所以勾选“概览”就够了。

保存后会得到一个类似这样的分享链接:

https://umami.example.com/share/Hqx6lIBhIHBR13RY

最后这一段 Hqx6lIBhIHBR13RY 就是分享 ID,后面 Worker 会用到。

分享链接背后的请求

分享页本身不是一个静态 HTML 页面,直接读取页面源码一般拿不到统计数字。它会先请求:

/api/share/Hqx6lIBhIHBR13RY

这个接口会返回类似这样的数据:

{
  "shareId": "...",
  "shareType": 1,
  "websiteId": "3d97b310-b241-4fc6-bc9c-68e317fc7d42",
  "token": "..."
}

然后访问统计接口时,需要带两个请求头:

x-umami-share-context: 1
x-umami-share-token: 上一步返回的 token

统计接口是:

/api/websites/{websiteId}/stats?startAt=0&endAt=当前时间戳

返回结果大概是:

{
  "pageviews": 2,
  "visitors": 1,
  "visits": 1,
  "bounces": 0,
  "totaltime": 470
}

其中:

  • visitors 是访客数
  • pageviews 是页面访问量
  • visits 是访问会话数

网站信息卡里我只用 visitorspageviews

编写 Cloudflare Worker

新建一个 Worker,把下面这份代码放进去:

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 的设置里添加变量:

UMAMI_BASE_URL = https://umami.example.com
UMAMI_SHARE_ID = Hqx6lIBhIHBR13RY
ALLOWED_ORIGIN = https://blog.example.com
CACHE_TTL_SECONDS = 600

如果你更想直接填完整分享链接,也可以不用 UMAMI_SHARE_ID,改用:

UMAMI_SHARE_URL = https://umami.example.com/share/Hqx6lIBhIHBR13RY

CACHE_TTL_SECONDS = 600 表示缓存 10 分钟。这样访客每次打开页面时,不会都去打 Umami 接口。

如果你只想统计某个建站时间之后的数据,可以额外设置:

STATS_START_AT = 1735660800000

这个值是毫秒时间戳。不设置的话默认从 0 开始,也就是统计全部数据。

部署方式

如果只是单个 Worker,最简单的方法是在 Cloudflare 后台新建 Worker,然后把 JS 代码直接粘到在线编辑器里。

如果遇到这样的提示:

此上传程序暂不支持需要构建过程的项目。至少找到了一个 JavaScript 文件。请改用 wrangler deploy 以获得完整功能支持。

通常是因为你上传了整个项目目录。这个场景不需要上传整个博客项目,只需要把 Worker 的 JS 内容粘进去即可。

如果想用 Wrangler,也可以写一个配置文件:

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"

然后执行:

wrangler deploy

测试 Worker

部署好之后,直接访问 Worker 地址,正常会看到:

{
  "visitors": 1,
  "pageviews": 2,
  "visits": 1,
  "updatedAt": 1781829905467
}

如果返回 502,可以先看 message 字段。常见原因有:

  • UMAMI_SHARE_ID 写错
  • UMAMI_BASE_URL 写错
  • 分享链接没有保存成功
  • Umami 的分享权限里没有勾选“概览”

接入网站信息卡

前端信息卡里,静态数据可以直接由博客生成:

  • 文章数目:读取文章列表长度
  • 本站总字数:遍历文章内容统计
  • 最后更新时间:取最新文章发布时间

访客数和访问量则用 Worker 动态填充。页面里先渲染占位:

<dd data-stat-key="visitors">--</dd>
<dd data-stat-key="pageviews">--</dd>

再用 JS 请求 Worker:

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 地址,防止以后更换接口后还读到旧数据:

const cacheKey = `site-info-stats:${endpoint}`;
const cacheDuration = 10 * 60 * 1000;

这样前端和 Worker 都有缓存,访问体验会稳一些。

最后

这个方案的核心是:不要把 Umami 后台账号放到前端,也不需要专门创建只读用户。既然 Umami 分享页本身已经提供了公开数据访问方式,就让 Worker 代替前端去请求它,并顺手做缓存和字段整理。

最终网站信息卡只需要关心这几个字段:

{
  "visitors": 1,
  "pageviews": 2,
  "visits": 1,
  "updatedAt": 1781829905467
}

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

用 Umami 分享链接给网站信息卡提供统计数据

/posts/4c91a8f2/
作者
biss
发布于
许可协议
CC BY-NC-SA 4.0
网站UmamiCloudflare Worker
订阅 打赏

Comments

用 Umami 分享链接给网站信息卡提供统计数据