0de33ad541
Memind CI / Test, build, and release guards (push) Successful in 4m9s
看板筛选期内的总访问/SEO/GEO 与 Umami memind-pages 取较大值,MindSpace 直链-only 的 public 页也会追加到目录。 Co-authored-by: Cursor <cursoragent@cursor.com>
339 lines
12 KiB
JavaScript
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);
|
|
}
|