169 字
1 分钟
自建Umami访问统计服务并通过分享链接进行博客公开统计
前言
我想展示umami数据,但是自托管的貌似没有api,经过探索发现可以通过分享链接拿到数据
抓包分析
发现分析界面 https://charity.dorimu.cn/share/xxx 获取数据分两步:
GET /api/share/{shareId}GET /api/websites/{websiteId}/stats?...,请求头带x-umami-share-token
第一步返回 websiteId + token,第二步返回统计数据(pageviews、visitors、visits 等)。
示例
GET https://charity.dorimu.cn/api/share/abc123响应(示例):
{ "websiteId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx", "token": "eyJhbGciOi..."}站点统计:
GET https://charity.dorimu.cn/api/websites/{websiteId}/stats?startAt=0&endAt=1730000000000x-umami-share-token: {token}拿单页面统计时,加 path 参数:
GET https://charity.dorimu.cn/api/websites/{websiteId}/stats?startAt=0&endAt=1730000000000&path=%2Fposts%2Fhello-world%2Fx-umami-share-token: {token}注意:path 要 URL 编码,而且路径要和你实际上报的路径完全一致(尤其是尾斜杠)。
umami-share.js 完整代码
我是更改 Astro & Mizuki 里面的umami-share.js
((global) => { const CACHE_PREFIX = "umami-share-cache"; const STATS_CACHE_TTL = 3600_000; // 1h const SHARE_INFO_CACHE_TTL = 3600_000; // 10min
function normalizeBaseUrl(baseUrl = "") { return String(baseUrl).trim().replace(/\/+$/, ""); }
function normalizeApiBase(baseUrl = "") { const normalized = normalizeBaseUrl(baseUrl); if (!normalized) return ""; return normalized.endsWith("/api") ? normalized : `${normalized}/api`; }
function normalizeV1Base(baseUrl = "") { const normalized = normalizeBaseUrl(baseUrl); if (!normalized) return ""; return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`; }
function getStorageItem(key) { try { return localStorage.getItem(key); } catch { return null; } }
function setStorageItem(key, value) { try { localStorage.setItem(key, value); } catch { // 忽略 localStorage 不可用场景 } }
function removeStorageItem(key) { try { localStorage.removeItem(key); } catch { // 忽略 localStorage 不可用场景 } }
function createCacheKey(parts) { return `${CACHE_PREFIX}:${parts.join(":")}`; }
function readCache(key, ttl) { const raw = getStorageItem(key); if (!raw) return null;
try { const parsed = JSON.parse(raw); if (Date.now() - parsed.timestamp < ttl) { return parsed.value; } removeStorageItem(key); } catch { removeStorageItem(key); } return null; }
function writeCache(key, value) { setStorageItem( key, JSON.stringify({ timestamp: Date.now(), value, }), ); }
function parseShareIdFromShareUrl(shareUrl = "") { if (!shareUrl) return ""; try { const url = new URL(shareUrl); const match = url.pathname.match(/\/share\/([^/?#]+)/); return match?.[1] || ""; } catch { return ""; } }
function parseBaseUrlFromUrl(value = "") { if (!value) return ""; try { return normalizeBaseUrl(new URL(value).origin); } catch { return ""; } }
function parseBaseUrlFromScripts(scripts = "") { if (typeof scripts === "string" && scripts) { const scriptSrc = scripts.match(/src="([^"]+)"/)?.[1] || ""; const parsed = parseBaseUrlFromUrl(scriptSrc); if (parsed) return parsed; }
const runtimeScript = document.querySelector( 'script[data-website-id][src*="script.js"]', ); if (runtimeScript instanceof HTMLScriptElement && runtimeScript.src) { return parseBaseUrlFromUrl(runtimeScript.src); }
return ""; }
function normalizeTimestamp(value, defaultValue) { const numeric = Number(value); return Number.isFinite(numeric) ? numeric : defaultValue; }
function buildStatsUrl(baseUrl, websiteId, urlPath, startAt, endAt) { const apiBase = normalizeApiBase(baseUrl); if (!apiBase) { throw new Error("缺少 Umami baseUrl"); }
const params = new URLSearchParams({ startAt: String(startAt), endAt: String(endAt), });
if (urlPath) { params.set("path", urlPath); }
return `${apiBase}/websites/${encodeURIComponent(websiteId)}/stats?${params.toString()}`; }
async function fetchJson(url, headers = {}) { const response = await fetch(url, { headers }); if (!response.ok) { throw new Error(`${response.status} ${response.statusText}`); } return response.json(); }
async function fetchShareInfo(baseUrl, shareId) { if (!shareId) { throw new Error("缺少 Umami shareId"); }
const normalizedBase = normalizeBaseUrl(baseUrl); if (!normalizedBase) { throw new Error("缺少 Umami baseUrl"); }
const cacheKey = createCacheKey([ "share-info", encodeURIComponent(normalizedBase), shareId, ]); const cached = readCache(cacheKey, SHARE_INFO_CACHE_TTL); if (cached?.token && cached?.websiteId) { return cached; }
const apiBase = normalizeApiBase(normalizedBase); const shareInfo = await fetchJson( `${apiBase}/share/${encodeURIComponent(shareId)}`, );
if (!shareInfo?.token || !shareInfo?.websiteId) { throw new Error("Umami 分享接口返回数据不完整"); }
writeCache(cacheKey, shareInfo); return shareInfo; }
function normalizeInputOptions(baseUrlOrOptions, apiKey, websiteId) { const defaults = { baseUrl: "", apiKey: "", websiteId: "", shareId: "", shareUrl: "", scripts: "", urlPath: "", startAt: undefined, endAt: undefined, autoRange: false, };
let options = defaults;
if ( baseUrlOrOptions && typeof baseUrlOrOptions === "object" && !Array.isArray(baseUrlOrOptions) ) { options = { ...defaults, ...baseUrlOrOptions, }; } else { options = { ...defaults, baseUrl: baseUrlOrOptions || "", apiKey: apiKey || "", websiteId: websiteId || "", }; }
options.baseUrl = normalizeBaseUrl(options.baseUrl || ""); options.apiKey = String(options.apiKey || "").trim(); options.websiteId = String(options.websiteId || "").trim(); options.shareId = String(options.shareId || "").trim(); options.shareUrl = String(options.shareUrl || "").trim(); options.scripts = String(options.scripts || ""); options.urlPath = String(options.urlPath || ""); const hasStartAt = options.startAt !== undefined && options.startAt !== null && options.startAt !== ""; const hasEndAt = options.endAt !== undefined && options.endAt !== null && options.endAt !== ""; options.startAt = hasStartAt ? normalizeTimestamp(options.startAt, 0) : 0; options.endAt = hasEndAt ? normalizeTimestamp(options.endAt, Date.now()) : Date.now(); options.autoRange = !hasStartAt && !hasEndAt;
if (!options.shareId && options.shareUrl) { options.shareId = parseShareIdFromShareUrl(options.shareUrl); }
if (!options.baseUrl) { if (options.shareUrl) { options.baseUrl = parseBaseUrlFromUrl(options.shareUrl); } if (!options.baseUrl) { options.baseUrl = parseBaseUrlFromScripts(options.scripts); } }
return options; }
function buildStatsCacheKey(mode, options) { return createCacheKey([ "stats", mode, encodeURIComponent(options.baseUrl || ""), options.websiteId || "__unknown__", options.shareId || "__none__", encodeURIComponent(options.urlPath || "__site__"), String(options.startAt), options.autoRange ? "__auto__" : String(options.endAt), ]); }
async function fetchStatsWithShare(options) { const shareInfo = await fetchShareInfo(options.baseUrl, options.shareId); const websiteId = options.websiteId || shareInfo.websiteId;
if (!websiteId) { throw new Error("分享接口未返回 websiteId"); }
const statsUrl = buildStatsUrl( options.baseUrl, websiteId, options.urlPath, options.startAt, options.endAt, );
return fetchJson(statsUrl, { "x-umami-share-token": shareInfo.token, }); }
async function fetchStatsWithApiKey(options) { if (!options.baseUrl) { throw new Error("缺少 Umami baseUrl"); } if (!options.apiKey) { throw new Error("缺少 Umami apiKey"); } if (!options.websiteId) { throw new Error("缺少 Umami websiteId"); }
const v1Base = normalizeV1Base(options.baseUrl); const params = new URLSearchParams({ startAt: String(options.startAt), endAt: String(options.endAt), });
if (options.urlPath) { params.set("path", options.urlPath); }
const statsUrl = `${v1Base}/websites/${encodeURIComponent(options.websiteId)}/stats?${params.toString()}`; return fetchJson(statsUrl, { "x-umami-api-key": options.apiKey, }); }
async function fetchStats(baseUrlOrOptions, apiKey, websiteId) { const options = normalizeInputOptions(baseUrlOrOptions, apiKey, websiteId); const mode = options.shareId ? "share" : options.apiKey ? "api-key" : "";
if (!mode) { throw new Error( "缺少 Umami 认证信息,请配置 shareId/shareUrl(推荐)或 apiKey", ); }
const cacheKey = buildStatsCacheKey(mode, options); const cached = readCache(cacheKey, STATS_CACHE_TTL); if (cached) { return cached; }
const stats = mode === "share" ? await fetchStatsWithShare(options) : await fetchStatsWithApiKey(options);
writeCache(cacheKey, stats); return stats; }
global.getUmamiWebsiteStats = async (baseUrlOrOptions, apiKey, websiteId) => { try { return await fetchStats(baseUrlOrOptions, apiKey, websiteId); } catch (err) { throw new Error(`获取Umami统计数据失败: ${err.message}`); } };
global.getUmamiPageStats = async ( baseUrlOrOptions, apiKey, websiteId, urlPath, startAt, endAt, ) => { try { let options = baseUrlOrOptions; if ( baseUrlOrOptions && typeof baseUrlOrOptions === "object" && !Array.isArray(baseUrlOrOptions) ) { options = { ...baseUrlOrOptions, }; if (typeof urlPath === "string") { options.urlPath = urlPath; } if (startAt !== undefined) { options.startAt = startAt; } if (endAt !== undefined) { options.endAt = endAt; } } else { options = { baseUrl: baseUrlOrOptions, apiKey, websiteId, urlPath, startAt, endAt, }; } return await fetchStats(options); } catch (err) { throw new Error(`获取Umami页面统计数据失败: ${err.message}`); } };
global.clearUmamiShareCache = () => { try { for (let index = localStorage.length - 1; index >= 0; index -= 1) { const key = localStorage.key(index); if (key && key.startsWith(`${CACHE_PREFIX}:`)) { localStorage.removeItem(key); } } } catch { // 忽略 localStorage 不可用场景 } };})(window);配置
环境变量推荐:
UMAMI_SHARE_ID=abc123 自建Umami访问统计服务并通过分享链接进行博客公开统计
https://blog.dorimu.cn/2026/02/16/umami-share-stats/ 部分信息可能已经过时









