import crypto from 'node:crypto'; const TOKEN_TTL_MS = 55 * 60 * 1000; let cachedToken = ''; let cachedTokenAt = 0; let cachedBaseUrl = ''; function normalizeBaseUrl(value = '') { return String(value ?? '').trim().replace(/\/$/, ''); } function resolveUmamiCredentials(env = process.env, analyticsConfig = {}) { const baseUrl = normalizeBaseUrl( analyticsConfig?.analyticsUrl || env.UMAMI_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 || '').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({ baseUrl, username, password, sharedSecret }) { if (sharedSecret) { return loginUmamiViaSso({ baseUrl, username, sharedSecret }); } if (!password) { throw new Error('未配置 UMAMI_ADMIN_PASSWORD 或 MEMIND_UMAMI_SSO_SECRET,无法拉取 SEO/GEO 页面统计'); } const response = await fetch(`${baseUrl}/api/auth/login`, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ username, 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 async function fetchMemindDiscoveryPages( query = {}, { env = process.env, analyticsConfig = {} } = {}, ) { const credentials = resolveUmamiCredentials(env, analyticsConfig); if (!credentials.websiteId) { throw new Error('未配置 Umami Website ID'); } const channel = String(query.discoveryChannel ?? query.channel ?? '').trim().toLowerCase(); if (channel !== 'seo' && channel !== 'geo') { throw new Error('discoveryChannel 必须是 seo 或 geo'); } const params = new URLSearchParams(); params.set('discoveryChannel', channel); params.set('page', String(Math.max(Number(query.page) || 1, 1))); params.set('pageSize', String(Math.min(Math.max(Number(query.pageSize) || 10, 1), 100))); params.set('sortBy', String(query.sortBy || 'views')); params.set('sortOrder', String(query.sortOrder || 'desc')); if (query.startAt) params.set('startAt', String(query.startAt)); if (query.endAt) params.set('endAt', String(query.endAt)); if (query.timezone) params.set('timezone', String(query.timezone)); if (query.generatedStartAt) params.set('generatedStartAt', String(query.generatedStartAt)); if (query.generatedEndAt) params.set('generatedEndAt', String(query.generatedEndAt)); const token = await getUmamiAuthToken(credentials); 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(15_000), }, ); const body = await response.json().catch(() => ({})); if (!response.ok) { throw new Error(body?.message || `Umami memind-pages 请求失败 (${response.status})`); } return { discoveryChannel: channel, ...body, }; } export function resolvePublicPageUrl(row = {}, fallbackHost = 'm.tkmind.cn') { const exact = String(row.pageUrl ?? '').trim(); if (/^https?:\/\//i.test(exact)) return exact.split('#')[0]; const hostname = String(row.hostname ?? '').trim(); if (hostname === '127.0.0.1' || hostname === 'localhost') { return `http://${hostname}:8081${row.urlPath}`; } const host = hostname || fallbackHost; return `https://${host}${row.urlPath}`; }