博客 / 詳情

返回

自建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
user avatar
0 位用戶收藏了這個故事!

發佈 評論

Some HTML is okay.