网站信息卡
This commit is contained in:
@@ -0,0 +1,155 @@
|
||||
const DEFAULT_CACHE_TTL_SECONDS = 600;
|
||||
|
||||
function jsonResponse(data, init = {}) {
|
||||
return new Response(JSON.stringify(data), {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json; charset=utf-8',
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function getCorsHeaders(request, env) {
|
||||
const origin = request.headers.get('Origin') || '';
|
||||
const allowedOrigin = env.ALLOWED_ORIGIN || 'https://blog.biss.click';
|
||||
const allowOrigin = origin === allowedOrigin ? origin : allowedOrigin;
|
||||
|
||||
return {
|
||||
'Access-Control-Allow-Origin': allowOrigin,
|
||||
'Access-Control-Allow-Methods': 'GET, OPTIONS',
|
||||
'Access-Control-Allow-Headers': 'Content-Type',
|
||||
'Vary': 'Origin',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeBaseUrl(value) {
|
||||
return String(value || 'https://umami.biss.click').replace(/\/+$/, '');
|
||||
}
|
||||
|
||||
function getShareId(env) {
|
||||
if (env.UMAMI_SHARE_ID) return env.UMAMI_SHARE_ID;
|
||||
if (!env.UMAMI_SHARE_URL) return '';
|
||||
|
||||
try {
|
||||
const url = new URL(env.UMAMI_SHARE_URL);
|
||||
const parts = url.pathname.split('/').filter(Boolean);
|
||||
const shareIndex = parts.indexOf('share');
|
||||
return shareIndex >= 0 ? parts[shareIndex + 1] || '' : parts.at(-1) || '';
|
||||
} catch {
|
||||
return env.UMAMI_SHARE_URL.split('/').filter(Boolean).at(-1) || '';
|
||||
}
|
||||
}
|
||||
|
||||
async function getShareData(env) {
|
||||
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
|
||||
const shareId = getShareId(env);
|
||||
|
||||
if (!shareId) {
|
||||
throw new Error('Missing UMAMI_SHARE_ID or UMAMI_SHARE_URL');
|
||||
}
|
||||
|
||||
const response = await fetch(`${baseUrl}/api/share/${shareId}`, {
|
||||
headers: { Accept: 'application/json' },
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Umami share lookup failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
if (!data.websiteId || !data.token) {
|
||||
throw new Error('Umami share response did not include websiteId or token');
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
async function fetchUmamiStats(env, shareData) {
|
||||
const baseUrl = normalizeBaseUrl(env.UMAMI_BASE_URL);
|
||||
const websiteId = env.UMAMI_WEBSITE_ID || shareData.websiteId;
|
||||
|
||||
const startAt = Number(env.STATS_START_AT || 0);
|
||||
const endAt = Date.now();
|
||||
const url = `${baseUrl}/api/websites/${websiteId}/stats?startAt=${startAt}&endAt=${endAt}`;
|
||||
|
||||
return fetch(url, {
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'x-umami-share-context': '1',
|
||||
'x-umami-share-token': shareData.token,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getStats(env) {
|
||||
const shareData = await getShareData(env);
|
||||
const response = await fetchUmamiStats(env, shareData);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Umami stats failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const stats = await response.json();
|
||||
|
||||
return {
|
||||
visitors: stats.visitors ?? 0,
|
||||
pageviews: stats.pageviews ?? 0,
|
||||
visits: stats.visits ?? 0,
|
||||
updatedAt: Date.now(),
|
||||
};
|
||||
}
|
||||
|
||||
export default {
|
||||
async fetch(request, env, ctx) {
|
||||
const corsHeaders = getCorsHeaders(request, env);
|
||||
|
||||
if (request.method === 'OPTIONS') {
|
||||
return new Response(null, { status: 204, headers: corsHeaders });
|
||||
}
|
||||
|
||||
if (request.method !== 'GET') {
|
||||
return jsonResponse({ error: 'Method not allowed' }, { status: 405, headers: corsHeaders });
|
||||
}
|
||||
|
||||
const cacheTtl = Number(env.CACHE_TTL_SECONDS || DEFAULT_CACHE_TTL_SECONDS);
|
||||
const cache = caches.default;
|
||||
const cacheKey = new Request(
|
||||
`https://site-stats-cache.local/umami-site-stats/${getShareId(env) || 'default'}`,
|
||||
);
|
||||
const cached = await cache.match(cacheKey);
|
||||
|
||||
if (cached) {
|
||||
return new Response(cached.body, {
|
||||
status: cached.status,
|
||||
headers: {
|
||||
...Object.fromEntries(cached.headers),
|
||||
...corsHeaders,
|
||||
'X-Stats-Cache': 'HIT',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const stats = await getStats(env);
|
||||
const response = jsonResponse(stats, {
|
||||
headers: {
|
||||
...corsHeaders,
|
||||
'Cache-Control': `public, max-age=${cacheTtl}`,
|
||||
'X-Stats-Cache': 'MISS',
|
||||
},
|
||||
});
|
||||
|
||||
ctx.waitUntil(cache.put(cacheKey, response.clone()));
|
||||
return response;
|
||||
} catch (error) {
|
||||
return jsonResponse(
|
||||
{
|
||||
error: 'Failed to fetch stats',
|
||||
message: error instanceof Error ? error.message : String(error),
|
||||
},
|
||||
{ status: 502, headers: corsHeaders },
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user