mobile wallpaper 1mobile wallpaper 2mobile wallpaper 3mobile wallpaper 4mobile wallpaper 5mobile wallpaper 6
169 字
1 分钟
自建Umami访问统计服务并通过分享链接进行博客公开统计
2026-02-16
统计加载中...

前言#

我想展示umami数据,但是自托管的貌似没有api,经过探索发现可以通过分享链接拿到数据

抓包分析#

发现分析界面 https://charity.dorimu.cn/share/xxx 获取数据分两步:

  1. GET /api/share/{shareId}
  2. GET /api/websites/{websiteId}/stats?...,请求头带 x-umami-share-token

第一步返回 websiteId + token,第二步返回统计数据(pageviewsvisitorsvisits 等)。

示例#

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=1730000000000
x-umami-share-token: {token}

拿单页面统计时,加 path 参数:

GET https://charity.dorimu.cn/api/websites/{websiteId}/stats?startAt=0&endAt=1730000000000&path=%2Fposts%2Fhello-world%2F
x-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/posts/umami-share-stats/
作者
Dorimu
发布于
2026-02-16
许可协议
CC BY-NC-SA 4.0

部分信息可能已经过时

封面
Sample Song
Sample Artist
封面
Sample Song
Sample Artist
0:00 / 0:00