敕码 发表于 4 天前

自建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]
查看完整版本: 自建Umami访问统计服务并通过分享链接进行博客公开统计