Files
memind/mindspace-seo-geo-umami-stats.mjs
T
john 0de33ad541
Memind CI / Test, build, and release guards (push) Successful in 4m9s
feat(analytics): SEO/GEO catalog 合并 Umami 统计并补工作区页
看板筛选期内的总访问/SEO/GEO 与 Umami memind-pages 取较大值,MindSpace 直链-only 的 public 页也会追加到目录。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-17 15:15:26 +08:00

339 lines
12 KiB
JavaScript

import crypto from 'node:crypto';
import { resolveMindSpaceAnalyticsConfig } from './mindspace-analytics.mjs';
import { resolveAdminCatalogPublicUrl } from './mindspace-seo-geo-admin-catalog.mjs';
const TOKEN_TTL_MS = 55 * 60 * 1000;
const DEFAULT_PAGE_SIZE = 100;
const MAX_PAGES = 50;
let cachedToken = '';
let cachedTokenAt = 0;
let cachedBaseUrl = '';
function normalizeBaseUrl(value = '') {
return String(value ?? '').trim().replace(/\/$/, '');
}
function resolveUmamiCredentials(env = process.env, analyticsConfig = resolveMindSpaceAnalyticsConfig(env)) {
const baseUrl = normalizeBaseUrl(
analyticsConfig?.analyticsUrl || env.UMAMI_URL || env.MEMIND_ANALYTICS_URL || 'http://127.0.0.1:3100',
);
const username = String(env.UMAMI_SSO_USERNAME || env.UMAMI_ADMIN_USERNAME || 'admin').trim();
const password = String(env.UMAMI_ADMIN_PASSWORD || '').trim();
const sharedSecret = String(env.MEMIND_UMAMI_SSO_SECRET || '').trim();
const websiteId = String(analyticsConfig?.websiteId || env.UMAMI_WEBSITE_ID || env.MEMIND_ANALYTICS_WEBSITE_ID || '').trim();
return { baseUrl, username, password, sharedSecret, websiteId };
}
async function loginUmamiViaSso({ baseUrl, username, sharedSecret }) {
const encoded = Buffer.from(
JSON.stringify({
username,
exp: Math.floor(Date.now() / 1000) + 60,
nonce: crypto.randomUUID(),
}),
).toString('base64url');
const signature = crypto.createHmac('sha256', sharedSecret).update(encoded).digest('base64url');
const ticket = `${encoded}.${signature}`;
const response = await fetch(
`${baseUrl}/api/auth/memind?ticket=${encodeURIComponent(ticket)}`,
{ signal: AbortSignal.timeout(10_000) },
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.error?.message || body?.message || `Umami SSO 登录失败 (${response.status})`);
}
const token = String(body?.token ?? '').trim();
if (!token) throw new Error('Umami SSO 登录响应缺少 token');
return token;
}
async function loginUmami(credentials) {
if (credentials.sharedSecret) {
return loginUmamiViaSso(credentials);
}
if (!credentials.password) {
throw new Error('未配置 Umami 凭据');
}
const response = await fetch(`${credentials.baseUrl}/api/auth/login`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ username: credentials.username, password: credentials.password }),
signal: AbortSignal.timeout(10_000),
});
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.message || `Umami 登录失败 (${response.status})`);
}
const token = String(body?.token ?? '').trim();
if (!token) throw new Error('Umami 登录响应缺少 token');
return token;
}
async function getUmamiAuthToken(credentials) {
const now = Date.now();
if (cachedToken && cachedBaseUrl === credentials.baseUrl && now - cachedTokenAt < TOKEN_TTL_MS) {
return cachedToken;
}
cachedToken = await loginUmami(credentials);
cachedTokenAt = now;
cachedBaseUrl = credentials.baseUrl;
return cachedToken;
}
export function pathnameFromPublicUrl(value, fallbackHost = 'm.tkmind.cn') {
const raw = String(value ?? '').trim();
if (!raw) return '';
try {
return new URL(raw, `https://${fallbackHost}`).pathname.replace(/\/$/, '') || '/';
} catch {
return raw.startsWith('/') ? raw.replace(/\/$/, '') || '/' : '';
}
}
export function buildCatalogUmamiMatchKeys({
userId = '',
publicUrl = '',
workspaceRelativePath = '',
publicHost = 'm.tkmind.cn',
} = {}) {
const keys = new Set();
const workspacePath = String(workspaceRelativePath ?? '').trim().replace(/^\/+/, '');
const normalizedUserId = String(userId ?? '').trim();
if (normalizedUserId && workspacePath) {
keys.add(`/MindSpace/${normalizedUserId}/${workspacePath}`.replace(/\/$/, '') || '/');
}
const absoluteUrl = resolveAdminCatalogPublicUrl(publicUrl, { publicHost });
const absolutePath = pathnameFromPublicUrl(absoluteUrl, publicHost);
if (absolutePath) keys.add(absolutePath);
const rawPublic = String(publicUrl ?? '').trim();
if (rawPublic.startsWith('/')) {
keys.add(rawPublic.replace(/\/$/, '') || '/');
}
return [...keys];
}
export function mergePublicationStatsWithUmami(publicationStats, umamiStats) {
if (!umamiStats) return publicationStats;
return {
totalViews: Math.max(Number(publicationStats?.totalViews) || 0, Number(umamiStats.totalViews) || 0),
seoViews: Math.max(Number(publicationStats?.seoViews) || 0, Number(umamiStats.seoViews) || 0),
geoViews: Math.max(Number(publicationStats?.geoViews) || 0, Number(umamiStats.geoViews) || 0),
crawlerViews: Number(publicationStats?.crawlerViews) || 0,
};
}
export function resolveUmamiStatsForMatchKeys(matchKeys, statsByPath) {
let resolved = null;
for (const key of matchKeys) {
const stats = statsByPath.get(key);
if (!stats) continue;
resolved = resolved
? {
totalViews: Math.max(resolved.totalViews, stats.totalViews),
seoViews: Math.max(resolved.seoViews, stats.seoViews),
geoViews: Math.max(resolved.geoViews, stats.geoViews),
}
: { ...stats };
}
return resolved;
}
function recordPathStats(map, pathKey, patch) {
const key = String(pathKey ?? '').trim().replace(/\/$/, '') || '/';
if (!key) return;
const current = map.get(key) ?? { totalViews: 0, seoViews: 0, geoViews: 0 };
map.set(key, {
totalViews: Math.max(current.totalViews, Number(patch.totalViews) || 0),
seoViews: Math.max(current.seoViews, Number(patch.seoViews) || 0),
geoViews: Math.max(current.geoViews, Number(patch.geoViews) || 0),
ownerLabel: patch.ownerLabel || current.ownerLabel || '',
pageTitle: patch.pageTitle || current.pageTitle || '',
generatedAt: patch.generatedAt ?? current.generatedAt ?? null,
urlPath: patch.urlPath || current.urlPath || key,
});
}
function indexUmamiRow(map, row, channel) {
const views = Number(row?.views) || 0;
if (views <= 0) return;
const patch = {
totalViews: channel === 'all' ? views : 0,
seoViews: channel === 'seo' ? views : 0,
geoViews: channel === 'geo' ? views : 0,
ownerLabel: String(row?.ownerLabel ?? '').trim(),
pageTitle: String(row?.pageTitle ?? '').trim(),
generatedAt: row?.generatedAt ? Date.parse(String(row.generatedAt)) || null : null,
urlPath: String(row?.urlPath ?? '').trim(),
};
const urlPath = pathnameFromPublicUrl(row?.urlPath);
const pageUrlPath = pathnameFromPublicUrl(row?.pageUrl);
if (urlPath) recordPathStats(map, urlPath, patch);
if (pageUrlPath) recordPathStats(map, pageUrlPath, patch);
}
async function fetchMemindPagesPage({
credentials,
token,
startAt,
endAt,
page,
pageSize,
discoveryChannel = '',
}) {
const params = new URLSearchParams();
params.set('page', String(page));
params.set('pageSize', String(pageSize));
params.set('sortBy', 'generatedAt');
params.set('sortOrder', 'desc');
params.set('startAt', String(startAt));
params.set('endAt', String(endAt));
if (discoveryChannel) params.set('discoveryChannel', discoveryChannel);
const response = await fetch(
`${credentials.baseUrl}/api/websites/${encodeURIComponent(credentials.websiteId)}/memind-pages?${params.toString()}`,
{
headers: {
authorization: `Bearer ${token}`,
'content-type': 'application/json',
},
signal: AbortSignal.timeout(20_000),
},
);
const body = await response.json().catch(() => ({}));
if (!response.ok) {
throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`);
}
return body;
}
async function fetchMemindPagesForChannel({
credentials,
token,
startAt,
endAt,
discoveryChannel = '',
pageSize = DEFAULT_PAGE_SIZE,
maxPages = MAX_PAGES,
}) {
const rows = [];
let page = 1;
let total = Infinity;
while (page <= maxPages && rows.length < total) {
const body = await fetchMemindPagesPage({
credentials,
token,
startAt,
endAt,
page,
pageSize,
discoveryChannel,
});
const batch = Array.isArray(body?.data) ? body.data : [];
rows.push(...batch);
total = Number(body?.total) || rows.length;
if (batch.length < pageSize) break;
page += 1;
}
return rows;
}
export async function fetchUmamiDiscoveryStatsByPath({
startAt,
endAt,
env = process.env,
analyticsConfig = resolveMindSpaceAnalyticsConfig(env),
logger = console,
} = {}) {
const rangeStart = Number(startAt);
const rangeEnd = Number(endAt);
if (!Number.isFinite(rangeStart) || !Number.isFinite(rangeEnd) || rangeEnd < rangeStart) {
return { statsByPath: new Map(), available: false, reason: 'invalid_range' };
}
const credentials = resolveUmamiCredentials(env, analyticsConfig);
if (!credentials.websiteId) {
return { statsByPath: new Map(), available: false, reason: 'missing_website_id' };
}
if (!credentials.sharedSecret && !credentials.password) {
return { statsByPath: new Map(), available: false, reason: 'missing_credentials' };
}
try {
const token = await getUmamiAuthToken(credentials);
const [allRows, seoRows, geoRows] = await Promise.all([
fetchMemindPagesForChannel({ credentials, token, startAt: rangeStart, endAt: rangeEnd, discoveryChannel: '' }),
fetchMemindPagesForChannel({ credentials, token, startAt: rangeStart, endAt: rangeEnd, discoveryChannel: 'seo' }),
fetchMemindPagesForChannel({ credentials, token, startAt: rangeStart, endAt: rangeEnd, discoveryChannel: 'geo' }),
]);
const statsByPath = new Map();
for (const row of allRows) indexUmamiRow(statsByPath, row, 'all');
for (const row of seoRows) indexUmamiRow(statsByPath, row, 'seo');
for (const row of geoRows) indexUmamiRow(statsByPath, row, 'geo');
return { statsByPath, available: true, reason: 'ok' };
} catch (error) {
logger?.warn?.(
'[SEO/GEO catalog] Umami stats unavailable:',
error instanceof Error ? error.message : error,
);
return {
statsByPath: new Map(),
available: false,
reason: error instanceof Error ? error.message : 'umami_fetch_failed',
};
}
}
export function parseMindSpaceWorkspacePublicPath(urlPath) {
const normalized = String(urlPath ?? '').trim().replace(/\/$/, '');
const match = normalized.match(/^\/MindSpace\/([^/]+)\/(public\/[^/]+\.html)$/i);
if (!match) return null;
return {
userId: match[1],
workspaceRelativePath: match[2],
};
}
export function buildUmamiOnlyCatalogRow(pathKey, stats, { publicHost = 'm.tkmind.cn' } = {}) {
const parsed = parseMindSpaceWorkspacePublicPath(pathKey);
if (!parsed) return null;
const publicUrl = `https://${publicHost}${pathKey}`;
const generatedAt = Number(stats?.generatedAt) || null;
return {
publicationId: `umami:${pathKey}`,
pageId: '',
userId: parsed.userId,
ownerLabel: String(stats?.ownerLabel ?? '').trim() || '未命名用户',
username: '',
pageTitle: String(stats?.pageTitle ?? '').trim() || pathKey.split('/').pop() || '未命名页面',
pageSummary: '',
publicUrl,
accessMode: 'public',
indexable: true,
publishedAt: generatedAt,
lifetimeViewCount: Number(stats?.totalViews) || 0,
totalViews: Number(stats?.totalViews) || 0,
seoViews: Number(stats?.seoViews) || 0,
geoViews: Number(stats?.geoViews) || 0,
crawlerViews: 0,
statsSource: 'umami',
};
}
export function appendUnmatchedUmamiWorkspaceRows(rows, statsByPath, matchedPaths, { publicHost = 'm.tkmind.cn' } = {}) {
const existingKeys = new Set(matchedPaths);
const appended = [];
for (const [pathKey, stats] of statsByPath.entries()) {
if (existingKeys.has(pathKey)) continue;
if (!/^\/MindSpace\/[^/]+\/public\/[^/]+\.html$/i.test(pathKey)) continue;
const row = buildUmamiOnlyCatalogRow(pathKey, stats, { publicHost });
if (!row) continue;
appended.push(row);
existingKeys.add(pathKey);
}
return rows.concat(appended);
}