自建Umami访问统计服务并通过分享链接进行博客公开统计

前言

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

我的blogblog.dorimu.cn-umami-share-stats

抓包分析

发现分析界面 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
posted @ 2026-02-16 13:14  Dorimui  阅读(52)  评论(0)    收藏  举报