自建Umami访问统计服务并通过分享链接进行博客公开统计
前言我想展示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?. || "";
} 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="([^"]+)"/)?. || "";
const parsed = parseBaseUrlFromUrl(scriptSrc);
if (parsed) return parsed;
}
const runtimeScript = document.querySelector(
'script',
);
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
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!
页:
[1]