diff --git a/mindspace-seo-geo-admin-catalog.mjs b/mindspace-seo-geo-admin-catalog.mjs index 00448a8..ee15672 100644 --- a/mindspace-seo-geo-admin-catalog.mjs +++ b/mindspace-seo-geo-admin-catalog.mjs @@ -1,5 +1,13 @@ import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs'; import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs'; +import { + appendUnmatchedUmamiWorkspaceRows, + buildCatalogUmamiMatchKeys, + fetchUmamiDiscoveryStatsByPath, + mergePublicationStatsWithUmami, + pathnameFromPublicUrl, + resolveUmamiStatsForMatchKeys, +} from './mindspace-seo-geo-umami-stats.mjs'; const DEFAULT_PUBLIC_HOST = 'm.tkmind.cn'; const SORT_COLUMNS = new Set([ @@ -68,6 +76,22 @@ function aggregateViewStats(rows = []) { return stats; } +function matchesCatalogSearch(row, searchTerm) { + const term = String(searchTerm ?? '').trim().toLowerCase(); + if (!term) return true; + const haystack = [ + row.pageTitle, + row.pageSummary, + row.ownerLabel, + row.username, + row.publicUrl, + pathnameFromPublicUrl(row.publicUrl), + ] + .join(' ') + .toLowerCase(); + return haystack.includes(term); +} + export async function listAdminSeoGeoPublicationCatalog( pool, { @@ -80,6 +104,9 @@ export async function listAdminSeoGeoPublicationCatalog( search = '', publicHost = DEFAULT_PUBLIC_HOST, now = Date.now(), + env = process.env, + analyticsConfig = null, + fetchUmamiStats = fetchUmamiDiscoveryStatsByPath, } = {}, ) { if (!pool) { @@ -100,12 +127,14 @@ export async function listAdminSeoGeoPublicationCatalog( if (searchTerm) { whereSql += ` AND ( p.title LIKE ? + OR p.summary LIKE ? + OR p.workspace_relative_path LIKE ? OR u.username LIKE ? OR u.display_name LIKE ? OR pr.public_url LIKE ? )`; const like = `%${searchTerm}%`; - params.push(like, like, like, like); + params.push(like, like, like, like, like, like); } const [countRows] = await pool.query( @@ -132,6 +161,7 @@ export async function listAdminSeoGeoPublicationCatalog( pr.view_count AS lifetimeViewCount, p.title AS pageTitle, p.summary AS pageSummary, + p.workspace_relative_path AS workspaceRelativePath, u.username, u.display_name AS displayName FROM h5_publish_records pr @@ -141,6 +171,17 @@ export async function listAdminSeoGeoPublicationCatalog( params, ); + const umamiResult = hasRange + ? await fetchUmamiStats({ + startAt: rangeStart, + endAt: rangeEnd, + env, + analyticsConfig: analyticsConfig ?? undefined, + }) + : { statsByPath: new Map(), available: false, reason: 'no_range' }; + const statsByPath = umamiResult.statsByPath ?? new Map(); + const matchedUmamiPaths = new Set(); + const publicationIds = publicationRows.map((row) => row.publicationId); const statsByPublication = new Map(); if (publicationIds.length > 0 && hasRange) { @@ -177,7 +218,7 @@ export async function listAdminSeoGeoPublicationCatalog( title: row.pageTitle, summary: row.pageSummary, }); - const stats = hasRange + const publicationStats = hasRange ? aggregateViewStats(statsByPublication.get(String(row.publicationId)) ?? []) : { totalViews: 0, @@ -185,6 +226,15 @@ export async function listAdminSeoGeoPublicationCatalog( geoViews: 0, crawlerViews: 0, }; + const matchKeys = buildCatalogUmamiMatchKeys({ + userId: row.userId, + publicUrl: row.publicUrl, + workspaceRelativePath: row.workspaceRelativePath, + publicHost, + }); + for (const key of matchKeys) matchedUmamiPaths.add(key); + const umamiStats = resolveUmamiStatsForMatchKeys(matchKeys, statsByPath); + const stats = mergePublicationStatsWithUmami(publicationStats, umamiStats); const ownerLabel = String(row.displayName ?? '').trim() || String(row.username ?? '').trim() || @@ -202,14 +252,20 @@ export async function listAdminSeoGeoPublicationCatalog( indexable: isPublicationIndexable(publication, { now }), publishedAt: Number(row.publishedAt) || null, lifetimeViewCount: Number(row.lifetimeViewCount) || 0, + statsSource: umamiStats ? 'umami+publication' : 'publication', ...stats, }; }); + const umamiOnlyRows = appendUnmatchedUmamiWorkspaceRows([], statsByPath, matchedUmamiPaths, { publicHost }) + .filter((row) => matchesCatalogSearch(row, searchTerm)); + rows.push(...umamiOnlyRows); + const catalogTotal = total + umamiOnlyRows.length; + rows.sort((a, b) => { const primary = compareRows(a, b, normalizedSortBy, normalizedSortOrder); if (primary !== 0) return primary; - return compareRows(a, b, 'pageTitle', 'asc'); + return compareRows(a, b, 'publishedAt', 'desc'); }); const offset = (safePage - 1) * safePageSize; @@ -226,10 +282,14 @@ export async function listAdminSeoGeoPublicationCatalog( return { data: pageRows, - total, + total: catalogTotal, page: safePage, pageSize: safePageSize, totals, + umami: { + available: Boolean(umamiResult.available), + reason: String(umamiResult.reason ?? ''), + }, }; } diff --git a/mindspace-seo-geo-admin-catalog.test.mjs b/mindspace-seo-geo-admin-catalog.test.mjs index 2811e4a..1949237 100644 --- a/mindspace-seo-geo-admin-catalog.test.mjs +++ b/mindspace-seo-geo-admin-catalog.test.mjs @@ -111,3 +111,138 @@ test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stat assert.equal(result.data[1].indexable, true); assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//); }); + +test('listAdminSeoGeoPublicationCatalog search matches workspace path and defaults to publishedAt desc', async () => { + const pool = { + async query(sql, params = []) { + const normalized = String(sql).replace(/\s+/g, ' ').trim(); + if (normalized.startsWith('SELECT COUNT(*) AS total')) { + assert.match(normalized, /workspace_relative_path LIKE \?/); + assert.equal(params[0], '%daily-news-0917%'); + return [[{ total: 1 }]]; + } + if (normalized.includes('FROM h5_publish_records pr') && normalized.includes('JOIN h5_users')) { + return [[ + { + publicationId: 'pub-news', + pageId: 'page-news', + userId: 'user-1', + publicUrl: '/u/john/pages/news', + accessMode: 'public', + status: 'online', + userConfirmedAt: 1000, + expiresAt: null, + publishedAt: 5000, + lifetimeViewCount: 99, + pageTitle: '每日新闻早报', + pageSummary: 'Summary', + username: 'john', + displayName: 'John', + }, + { + publicationId: 'pub-old', + pageId: 'page-old', + userId: 'user-1', + publicUrl: '/u/john/pages/old', + accessMode: 'public', + status: 'online', + userConfirmedAt: 1000, + expiresAt: null, + publishedAt: 1000, + lifetimeViewCount: 999, + pageTitle: 'Old', + pageSummary: '', + username: 'john', + displayName: 'John', + }, + ]]; + } + return [[]]; + }, + }; + + const result = await listAdminSeoGeoPublicationCatalog(pool, { + page: 1, + pageSize: 10, + startAt: 1, + endAt: 9_999_999_999_999, + search: 'daily-news-0917', + sortBy: 'publishedAt', + sortOrder: 'desc', + }); + + assert.equal(result.total, 1); + assert.equal(result.data[0].publicationId, 'pub-news'); +}); + +test('listAdminSeoGeoPublicationCatalog merges Umami stats and appends workspace-only pages', async () => { + const pool = { + async query(sql) { + const normalized = String(sql).replace(/\s+/g, ' ').trim(); + if (normalized.startsWith('SELECT COUNT(*) AS total')) { + return [[{ total: 1 }]]; + } + if (normalized.includes('FROM h5_publish_records pr') && normalized.includes('JOIN h5_users')) { + return [[ + { + publicationId: 'pub-1', + pageId: 'page-1', + userId: 'user-1', + publicUrl: '/u/john/pages/news', + accessMode: 'public', + status: 'online', + userConfirmedAt: 1000, + expiresAt: null, + publishedAt: 2000, + lifetimeViewCount: 1, + pageTitle: 'Published News', + pageSummary: '', + workspaceRelativePath: 'public/daily-news-0916.html', + username: 'john', + displayName: 'John', + }, + ]]; + } + if (normalized.includes('FROM h5_publication_views')) { + return [[]]; + } + return [[]]; + }, + }; + + const umamiPath = '/MindSpace/user-1/public/daily-news-0916.html'; + const umamiOnlyPath = '/MindSpace/user-1/public/daily-news-0917.html'; + const statsByPath = new Map([ + [umamiPath, { totalViews: 20, seoViews: 12, geoViews: 2 }], + [umamiOnlyPath, { + totalViews: 9, + seoViews: 7, + geoViews: 0, + pageTitle: '每日新闻早报 · 2026年9月17日', + ownerLabel: 'John', + generatedAt: 9000, + }], + ]); + + const result = await listAdminSeoGeoPublicationCatalog(pool, { + page: 1, + pageSize: 10, + startAt: 1, + endAt: 9_999_999_999_999, + sortBy: 'seoViews', + sortOrder: 'desc', + fetchUmamiStats: async () => ({ + statsByPath, + available: true, + reason: 'ok', + }), + }); + + assert.equal(result.total, 2); + assert.equal(result.umami.available, true); + assert.equal(result.data[0].publicationId, 'pub-1'); + assert.equal(result.data[0].seoViews, 12); + assert.equal(result.data[0].statsSource, 'umami+publication'); + assert.equal(result.data[1].publicationId, `umami:${umamiOnlyPath}`); + assert.equal(result.data[1].seoViews, 7); +}); diff --git a/mindspace-seo-geo-umami-stats.mjs b/mindspace-seo-geo-umami-stats.mjs new file mode 100644 index 0000000..09f6b8d --- /dev/null +++ b/mindspace-seo-geo-umami-stats.mjs @@ -0,0 +1,338 @@ +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); +} diff --git a/mindspace-seo-geo-umami-stats.test.mjs b/mindspace-seo-geo-umami-stats.test.mjs new file mode 100644 index 0000000..ad1868c --- /dev/null +++ b/mindspace-seo-geo-umami-stats.test.mjs @@ -0,0 +1,68 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; + +import { + buildCatalogUmamiMatchKeys, + buildUmamiOnlyCatalogRow, + mergePublicationStatsWithUmami, + parseMindSpaceWorkspacePublicPath, + resolveUmamiStatsForMatchKeys, +} from './mindspace-seo-geo-umami-stats.mjs'; + +test('buildCatalogUmamiMatchKeys includes MindSpace workspace path and public url', () => { + const keys = buildCatalogUmamiMatchKeys({ + userId: 'user-1', + publicUrl: '/u/john/pages/news', + workspaceRelativePath: 'public/daily-news-0917.html', + publicHost: 'm.tkmind.cn', + }); + assert.ok(keys.includes('/MindSpace/user-1/public/daily-news-0917.html')); + assert.ok(keys.includes('/u/john/pages/news')); +}); + +test('mergePublicationStatsWithUmami prefers higher counts from either source', () => { + assert.deepEqual( + mergePublicationStatsWithUmami( + { totalViews: 1, seoViews: 0, geoViews: 0, crawlerViews: 2 }, + { totalViews: 8, seoViews: 5, geoViews: 1 }, + ), + { totalViews: 8, seoViews: 5, geoViews: 1, crawlerViews: 2 }, + ); +}); + +test('resolveUmamiStatsForMatchKeys merges multiple path aliases', () => { + const map = new Map([ + ['/MindSpace/user-1/public/daily-news-0917.html', { totalViews: 10, seoViews: 6, geoViews: 1 }], + ]); + const stats = resolveUmamiStatsForMatchKeys( + ['/u/john/pages/news', '/MindSpace/user-1/public/daily-news-0917.html'], + map, + ); + assert.deepEqual(stats, { totalViews: 10, seoViews: 6, geoViews: 1 }); +}); + +test('buildUmamiOnlyCatalogRow creates workspace-only catalog entry', () => { + const row = buildUmamiOnlyCatalogRow( + '/MindSpace/user-1/public/daily-news-0917.html', + { + totalViews: 12, + seoViews: 8, + geoViews: 1, + pageTitle: '每日新闻早报 · 2026年9月17日', + ownerLabel: 'John', + generatedAt: 5000, + }, + ); + assert.equal(row.publicationId, 'umami:/MindSpace/user-1/public/daily-news-0917.html'); + assert.equal(row.seoViews, 8); + assert.equal(row.statsSource, 'umami'); + assert.match(row.publicUrl, /daily-news-0917\.html$/); +}); + +test('parseMindSpaceWorkspacePublicPath rejects non-workspace paths', () => { + assert.deepEqual(parseMindSpaceWorkspacePublicPath('/u/john/pages/news'), null); + assert.deepEqual(parseMindSpaceWorkspacePublicPath('/MindSpace/u1/public/a.html'), { + userId: 'u1', + workspaceRelativePath: 'public/a.html', + }); +});