feat(analytics): SEO/GEO catalog 合并 Umami 统计并补工作区页
Memind CI / Test, build, and release guards (push) Successful in 4m9s
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:
@@ -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 ?? ''),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user