找回密码
 立即注册
首页 资源区 代码 自建Umami访问统计服务并通过分享链接进行博客公开统计 ...

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

敕码 4 天前
前言

我想展示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 等)。
示例
  1. GET https://charity.dorimu.cn/api/share/abc123
复制代码
响应(示例):
  1. {
  2.   "websiteId": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
  3.   "token": "eyJhbGciOi..."
  4. }
复制代码
站点统计:
  1. GET https://charity.dorimu.cn/api/websites/{websiteId}/stats?startAt=0&endAt=1730000000000
  2. x-umami-share-token: {token}
复制代码
拿单页面统计时,加 path 参数:
  1. GET https://charity.dorimu.cn/api/websites/{websiteId}/stats?startAt=0&endAt=1730000000000&path=%2Fposts%2Fhello-world%2F
  2. x-umami-share-token: {token}
复制代码
注意:path 要 URL 编码,而且路径要和你实际上报的路径完全一致(尤其是尾斜杠)。
umami-share.js 完整代码

我是更改 Astro & Mizuki 里面的umami-share.js
  1. ((global) => {
  2.         const CACHE_PREFIX = "umami-share-cache";
  3.         const STATS_CACHE_TTL = 3600_000; // 1h
  4.         const SHARE_INFO_CACHE_TTL = 3600_000; // 10min
  5.         function normalizeBaseUrl(baseUrl = "") {
  6.                 return String(baseUrl).trim().replace(/\/+$/, "");
  7.         }
  8.         function normalizeApiBase(baseUrl = "") {
  9.                 const normalized = normalizeBaseUrl(baseUrl);
  10.                 if (!normalized) return "";
  11.                 return normalized.endsWith("/api") ? normalized : `${normalized}/api`;
  12.         }
  13.         function normalizeV1Base(baseUrl = "") {
  14.                 const normalized = normalizeBaseUrl(baseUrl);
  15.                 if (!normalized) return "";
  16.                 return normalized.endsWith("/v1") ? normalized : `${normalized}/v1`;
  17.         }
  18.         function getStorageItem(key) {
  19.                 try {
  20.                         return localStorage.getItem(key);
  21.                 } catch {
  22.                         return null;
  23.                 }
  24.         }
  25.         function setStorageItem(key, value) {
  26.                 try {
  27.                         localStorage.setItem(key, value);
  28.                 } catch {
  29.                         // 忽略 localStorage 不可用场景
  30.                 }
  31.         }
  32.         function removeStorageItem(key) {
  33.                 try {
  34.                         localStorage.removeItem(key);
  35.                 } catch {
  36.                         // 忽略 localStorage 不可用场景
  37.                 }
  38.         }
  39.         function createCacheKey(parts) {
  40.                 return `${CACHE_PREFIX}:${parts.join(":")}`;
  41.         }
  42.         function readCache(key, ttl) {
  43.                 const raw = getStorageItem(key);
  44.                 if (!raw) return null;
  45.                 try {
  46.                         const parsed = JSON.parse(raw);
  47.                         if (Date.now() - parsed.timestamp < ttl) {
  48.                                 return parsed.value;
  49.                         }
  50.                         removeStorageItem(key);
  51.                 } catch {
  52.                         removeStorageItem(key);
  53.                 }
  54.                 return null;
  55.         }
  56.         function writeCache(key, value) {
  57.                 setStorageItem(
  58.                         key,
  59.                         JSON.stringify({
  60.                                 timestamp: Date.now(),
  61.                                 value,
  62.                         }),
  63.                 );
  64.         }
  65.         function parseShareIdFromShareUrl(shareUrl = "") {
  66.                 if (!shareUrl) return "";
  67.                 try {
  68.                         const url = new URL(shareUrl);
  69.                         const match = url.pathname.match(/\/share\/([^/?#]+)/);
  70.                         return match?.[1] || "";
  71.                 } catch {
  72.                         return "";
  73.                 }
  74.         }
  75.         function parseBaseUrlFromUrl(value = "") {
  76.                 if (!value) return "";
  77.                 try {
  78.                         return normalizeBaseUrl(new URL(value).origin);
  79.                 } catch {
  80.                         return "";
  81.                 }
  82.         }
  83.         function parseBaseUrlFromScripts(scripts = "") {
  84.                 if (typeof scripts === "string" && scripts) {
  85.                         const scriptSrc = scripts.match(/src="([^"]+)"/)?.[1] || "";
  86.                         const parsed = parseBaseUrlFromUrl(scriptSrc);
  87.                         if (parsed) return parsed;
  88.                 }
  89.                 const runtimeScript = document.querySelector(
  90.                         'script[data-website-id][src*="script.js"]',
  91.                 );
  92.                 if (runtimeScript instanceof HTMLScriptElement && runtimeScript.src) {
  93.                         return parseBaseUrlFromUrl(runtimeScript.src);
  94.                 }
  95.                 return "";
  96.         }
  97.         function normalizeTimestamp(value, defaultValue) {
  98.                 const numeric = Number(value);
  99.                 return Number.isFinite(numeric) ? numeric : defaultValue;
  100.         }
  101.         function buildStatsUrl(baseUrl, websiteId, urlPath, startAt, endAt) {
  102.                 const apiBase = normalizeApiBase(baseUrl);
  103.                 if (!apiBase) {
  104.                         throw new Error("缺少 Umami baseUrl");
  105.                 }
  106.                 const params = new URLSearchParams({
  107.                         startAt: String(startAt),
  108.                         endAt: String(endAt),
  109.                 });
  110.                 if (urlPath) {
  111.                         params.set("path", urlPath);
  112.                 }
  113.                 return `${apiBase}/websites/${encodeURIComponent(websiteId)}/stats?${params.toString()}`;
  114.         }
  115.         async function fetchJson(url, headers = {}) {
  116.                 const response = await fetch(url, { headers });
  117.                 if (!response.ok) {
  118.                         throw new Error(`${response.status} ${response.statusText}`);
  119.                 }
  120.                 return response.json();
  121.         }
  122.         async function fetchShareInfo(baseUrl, shareId) {
  123.                 if (!shareId) {
  124.                         throw new Error("缺少 Umami shareId");
  125.                 }
  126.                 const normalizedBase = normalizeBaseUrl(baseUrl);
  127.                 if (!normalizedBase) {
  128.                         throw new Error("缺少 Umami baseUrl");
  129.                 }
  130.                 const cacheKey = createCacheKey([
  131.                         "share-info",
  132.                         encodeURIComponent(normalizedBase),
  133.                         shareId,
  134.                 ]);
  135.                 const cached = readCache(cacheKey, SHARE_INFO_CACHE_TTL);
  136.                 if (cached?.token && cached?.websiteId) {
  137.                         return cached;
  138.                 }
  139.                 const apiBase = normalizeApiBase(normalizedBase);
  140.                 const shareInfo = await fetchJson(
  141.                         `${apiBase}/share/${encodeURIComponent(shareId)}`,
  142.                 );
  143.                 if (!shareInfo?.token || !shareInfo?.websiteId) {
  144.                         throw new Error("Umami 分享接口返回数据不完整");
  145.                 }
  146.                 writeCache(cacheKey, shareInfo);
  147.                 return shareInfo;
  148.         }
  149.         function normalizeInputOptions(baseUrlOrOptions, apiKey, websiteId) {
  150.                 const defaults = {
  151.                         baseUrl: "",
  152.                         apiKey: "",
  153.                         websiteId: "",
  154.                         shareId: "",
  155.                         shareUrl: "",
  156.                         scripts: "",
  157.                         urlPath: "",
  158.                         startAt: undefined,
  159.                         endAt: undefined,
  160.                         autoRange: false,
  161.                 };
  162.                 let options = defaults;
  163.                 if (
  164.                         baseUrlOrOptions &&
  165.                         typeof baseUrlOrOptions === "object" &&
  166.                         !Array.isArray(baseUrlOrOptions)
  167.                 ) {
  168.                         options = {
  169.                                 ...defaults,
  170.                                 ...baseUrlOrOptions,
  171.                         };
  172.                 } else {
  173.                         options = {
  174.                                 ...defaults,
  175.                                 baseUrl: baseUrlOrOptions || "",
  176.                                 apiKey: apiKey || "",
  177.                                 websiteId: websiteId || "",
  178.                         };
  179.                 }
  180.                 options.baseUrl = normalizeBaseUrl(options.baseUrl || "");
  181.                 options.apiKey = String(options.apiKey || "").trim();
  182.                 options.websiteId = String(options.websiteId || "").trim();
  183.                 options.shareId = String(options.shareId || "").trim();
  184.                 options.shareUrl = String(options.shareUrl || "").trim();
  185.                 options.scripts = String(options.scripts || "");
  186.                 options.urlPath = String(options.urlPath || "");
  187.                 const hasStartAt =
  188.                         options.startAt !== undefined && options.startAt !== null && options.startAt !== "";
  189.                 const hasEndAt =
  190.                         options.endAt !== undefined && options.endAt !== null && options.endAt !== "";
  191.                 options.startAt = hasStartAt ? normalizeTimestamp(options.startAt, 0) : 0;
  192.                 options.endAt = hasEndAt
  193.                         ? normalizeTimestamp(options.endAt, Date.now())
  194.                         : Date.now();
  195.                 options.autoRange = !hasStartAt && !hasEndAt;
  196.                 if (!options.shareId && options.shareUrl) {
  197.                         options.shareId = parseShareIdFromShareUrl(options.shareUrl);
  198.                 }
  199.                 if (!options.baseUrl) {
  200.                         if (options.shareUrl) {
  201.                                 options.baseUrl = parseBaseUrlFromUrl(options.shareUrl);
  202.                         }
  203.                         if (!options.baseUrl) {
  204.                                 options.baseUrl = parseBaseUrlFromScripts(options.scripts);
  205.                         }
  206.                 }
  207.                 return options;
  208.         }
  209.         function buildStatsCacheKey(mode, options) {
  210.                 return createCacheKey([
  211.                         "stats",
  212.                         mode,
  213.                         encodeURIComponent(options.baseUrl || ""),
  214.                         options.websiteId || "__unknown__",
  215.                         options.shareId || "__none__",
  216.                         encodeURIComponent(options.urlPath || "__site__"),
  217.                         String(options.startAt),
  218.                         options.autoRange ? "__auto__" : String(options.endAt),
  219.                 ]);
  220.         }
  221.         async function fetchStatsWithShare(options) {
  222.                 const shareInfo = await fetchShareInfo(options.baseUrl, options.shareId);
  223.                 const websiteId = options.websiteId || shareInfo.websiteId;
  224.                 if (!websiteId) {
  225.                         throw new Error("分享接口未返回 websiteId");
  226.                 }
  227.                 const statsUrl = buildStatsUrl(
  228.                         options.baseUrl,
  229.                         websiteId,
  230.                         options.urlPath,
  231.                         options.startAt,
  232.                         options.endAt,
  233.                 );
  234.                 return fetchJson(statsUrl, {
  235.                         "x-umami-share-token": shareInfo.token,
  236.                 });
  237.         }
  238.         async function fetchStatsWithApiKey(options) {
  239.                 if (!options.baseUrl) {
  240.                         throw new Error("缺少 Umami baseUrl");
  241.                 }
  242.                 if (!options.apiKey) {
  243.                         throw new Error("缺少 Umami apiKey");
  244.                 }
  245.                 if (!options.websiteId) {
  246.                         throw new Error("缺少 Umami websiteId");
  247.                 }
  248.                 const v1Base = normalizeV1Base(options.baseUrl);
  249.                 const params = new URLSearchParams({
  250.                         startAt: String(options.startAt),
  251.                         endAt: String(options.endAt),
  252.                 });
  253.                 if (options.urlPath) {
  254.                         params.set("path", options.urlPath);
  255.                 }
  256.                 const statsUrl = `${v1Base}/websites/${encodeURIComponent(options.websiteId)}/stats?${params.toString()}`;
  257.                 return fetchJson(statsUrl, {
  258.                         "x-umami-api-key": options.apiKey,
  259.                 });
  260.         }
  261.         async function fetchStats(baseUrlOrOptions, apiKey, websiteId) {
  262.                 const options = normalizeInputOptions(baseUrlOrOptions, apiKey, websiteId);
  263.                 const mode = options.shareId ? "share" : options.apiKey ? "api-key" : "";
  264.                 if (!mode) {
  265.                         throw new Error(
  266.                                 "缺少 Umami 认证信息,请配置 shareId/shareUrl(推荐)或 apiKey",
  267.                         );
  268.                 }
  269.                 const cacheKey = buildStatsCacheKey(mode, options);
  270.                 const cached = readCache(cacheKey, STATS_CACHE_TTL);
  271.                 if (cached) {
  272.                         return cached;
  273.                 }
  274.                 const stats =
  275.                         mode === "share"
  276.                                 ? await fetchStatsWithShare(options)
  277.                                 : await fetchStatsWithApiKey(options);
  278.                 writeCache(cacheKey, stats);
  279.                 return stats;
  280.         }
  281.         global.getUmamiWebsiteStats = async (baseUrlOrOptions, apiKey, websiteId) => {
  282.                 try {
  283.                         return await fetchStats(baseUrlOrOptions, apiKey, websiteId);
  284.                 } catch (err) {
  285.                         throw new Error(`获取Umami统计数据失败: ${err.message}`);
  286.                 }
  287.         };
  288.         global.getUmamiPageStats = async (
  289.                 baseUrlOrOptions,
  290.                 apiKey,
  291.                 websiteId,
  292.                 urlPath,
  293.                 startAt,
  294.                 endAt,
  295.         ) => {
  296.                 try {
  297.                         let options = baseUrlOrOptions;
  298.                         if (
  299.                                 baseUrlOrOptions &&
  300.                                 typeof baseUrlOrOptions === "object" &&
  301.                                 !Array.isArray(baseUrlOrOptions)
  302.                         ) {
  303.                                 options = {
  304.                                         ...baseUrlOrOptions,
  305.                                 };
  306.                                 if (typeof urlPath === "string") {
  307.                                         options.urlPath = urlPath;
  308.                                 }
  309.                                 if (startAt !== undefined) {
  310.                                         options.startAt = startAt;
  311.                                 }
  312.                                 if (endAt !== undefined) {
  313.                                         options.endAt = endAt;
  314.                                 }
  315.                         } else {
  316.                                 options = {
  317.                                         baseUrl: baseUrlOrOptions,
  318.                                         apiKey,
  319.                                         websiteId,
  320.                                         urlPath,
  321.                                         startAt,
  322.                                         endAt,
  323.                                 };
  324.                         }
  325.                         return await fetchStats(options);
  326.                 } catch (err) {
  327.                         throw new Error(`获取Umami页面统计数据失败: ${err.message}`);
  328.                 }
  329.         };
  330.         global.clearUmamiShareCache = () => {
  331.                 try {
  332.                         for (let index = localStorage.length - 1; index >= 0; index -= 1) {
  333.                                 const key = localStorage.key(index);
  334.                                 if (key && key.startsWith(`${CACHE_PREFIX}:`)) {
  335.                                         localStorage.removeItem(key);
  336.                                 }
  337.                         }
  338.                 } catch {
  339.                         // 忽略 localStorage 不可用场景
  340.                 }
  341.         };
  342. })(window);
复制代码
配置

环境变量推荐:
  1. UMAMI_SHARE_ID=abc123
复制代码
来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册