feat(analytics): SEO/GEO catalog 合并 Umami 统计并补工作区页
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>
This commit is contained in:
john
2026-09-17 15:15:26 +08:00
parent a15cc66f31
commit 0de33ad541
4 changed files with 605 additions and 4 deletions
+64 -4
View File
@@ -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 ?? ''),
},
};
}
+135
View File
@@ -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);
});
+338
View File
@@ -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);
}
+68
View File
@@ -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',
});
});