前言
我想展示umami數據,但是自託管的貌似沒有api,經過探索發現可以通過分享鏈接拿到數據
我的blogblog.dorimu.cn-umami-share-stats
抓包分析
發現分析界面 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=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