feat(admin): add SEO/GEO publication catalog with crawler stats
Memind CI / Test, build, and release guards (push) Successful in 3m38s
Memind CI / Test, build, and release guards (push) Successful in 3m38s
Expose a DB-backed catalog of all online publications with SEO, GEO, total, and bot view counts from h5_publication_views for the memind_adm dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -62,6 +62,22 @@ function matchHostRule(hostname, rules = []) {
|
||||
return '';
|
||||
}
|
||||
|
||||
export function classifyReferrerHost(hostname) {
|
||||
const host = normalizeHostname(hostname);
|
||||
if (!host) {
|
||||
return { discovery_channel: 'direct', discovery_source: 'direct' };
|
||||
}
|
||||
const geoSource = matchHostRule(host, GEO_HOST_RULES);
|
||||
if (geoSource) {
|
||||
return { discovery_channel: 'geo', discovery_source: geoSource };
|
||||
}
|
||||
const seoSource = matchHostRule(host, SEO_HOST_RULES);
|
||||
if (seoSource) {
|
||||
return { discovery_channel: 'seo', discovery_source: seoSource };
|
||||
}
|
||||
return { discovery_channel: 'referral', discovery_source: 'other' };
|
||||
}
|
||||
|
||||
function normalizeDiscoveryChannel(value) {
|
||||
const channel = String(value ?? '').trim().toLowerCase();
|
||||
return channel === 'seo' || channel === 'geo' ? channel : '';
|
||||
|
||||
@@ -0,0 +1,243 @@
|
||||
import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs';
|
||||
import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
|
||||
|
||||
const DEFAULT_PUBLIC_HOST = 'm.tkmind.cn';
|
||||
const SORT_COLUMNS = new Set([
|
||||
'publishedAt',
|
||||
'pageTitle',
|
||||
'ownerLabel',
|
||||
'totalViews',
|
||||
'seoViews',
|
||||
'geoViews',
|
||||
'crawlerViews',
|
||||
'lifetimeViewCount',
|
||||
]);
|
||||
|
||||
function asPositiveInt(value, fallback) {
|
||||
const parsed = Number(value);
|
||||
if (!Number.isFinite(parsed) || parsed < 1) return fallback;
|
||||
return Math.floor(parsed);
|
||||
}
|
||||
|
||||
function normalizeSortBy(value, fallback = 'publishedAt') {
|
||||
const sortBy = String(value ?? '').trim();
|
||||
return SORT_COLUMNS.has(sortBy) ? sortBy : fallback;
|
||||
}
|
||||
|
||||
function normalizeSortOrder(value, fallback = 'desc') {
|
||||
return String(value ?? '').trim().toLowerCase() === 'asc' ? 'asc' : fallback;
|
||||
}
|
||||
|
||||
export function resolveAdminCatalogPublicUrl(publicUrl, { publicHost = DEFAULT_PUBLIC_HOST } = {}) {
|
||||
const raw = String(publicUrl ?? '').trim();
|
||||
if (!raw) return '';
|
||||
if (/^https?:\/\//i.test(raw)) return raw.split('#')[0];
|
||||
if (raw.startsWith('/')) return `https://${publicHost}${raw}`.split('#')[0];
|
||||
return raw.split('#')[0];
|
||||
}
|
||||
|
||||
function compareRows(a, b, sortBy, sortOrder) {
|
||||
const direction = sortOrder === 'asc' ? 1 : -1;
|
||||
const left = a[sortBy];
|
||||
const right = b[sortBy];
|
||||
if (typeof left === 'string' || typeof right === 'string') {
|
||||
return String(left ?? '').localeCompare(String(right ?? ''), 'zh-CN') * direction;
|
||||
}
|
||||
return ((Number(left) || 0) - (Number(right) || 0)) * direction;
|
||||
}
|
||||
|
||||
function aggregateViewStats(rows = []) {
|
||||
const stats = {
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
};
|
||||
for (const row of rows) {
|
||||
const views = Number(row.views) || 0;
|
||||
if (views <= 0) continue;
|
||||
stats.totalViews += views;
|
||||
if (String(row.device_type ?? '') === 'bot') {
|
||||
stats.crawlerViews += views;
|
||||
continue;
|
||||
}
|
||||
const discovery = classifyReferrerHost(row.referrer_host);
|
||||
if (discovery.discovery_channel === 'seo') stats.seoViews += views;
|
||||
if (discovery.discovery_channel === 'geo') stats.geoViews += views;
|
||||
}
|
||||
return stats;
|
||||
}
|
||||
|
||||
export async function listAdminSeoGeoPublicationCatalog(
|
||||
pool,
|
||||
{
|
||||
page = 1,
|
||||
pageSize = 20,
|
||||
sortBy = 'publishedAt',
|
||||
sortOrder = 'desc',
|
||||
startAt = null,
|
||||
endAt = null,
|
||||
search = '',
|
||||
publicHost = DEFAULT_PUBLIC_HOST,
|
||||
now = Date.now(),
|
||||
} = {},
|
||||
) {
|
||||
if (!pool) {
|
||||
return { data: [], total: 0, page: 1, pageSize: 20, totals: emptyTotals() };
|
||||
}
|
||||
|
||||
const safePage = asPositiveInt(page, 1);
|
||||
const safePageSize = Math.min(asPositiveInt(pageSize, 20), 100);
|
||||
const normalizedSortBy = normalizeSortBy(sortBy);
|
||||
const normalizedSortOrder = normalizeSortOrder(sortOrder);
|
||||
const searchTerm = String(search ?? '').trim();
|
||||
const rangeStart = Number(startAt);
|
||||
const rangeEnd = Number(endAt);
|
||||
const hasRange = Number.isFinite(rangeStart) && Number.isFinite(rangeEnd) && rangeEnd >= rangeStart;
|
||||
|
||||
const params = [];
|
||||
let whereSql = `WHERE pr.status = 'online'`;
|
||||
if (searchTerm) {
|
||||
whereSql += ` AND (
|
||||
p.title LIKE ?
|
||||
OR u.username LIKE ?
|
||||
OR u.display_name LIKE ?
|
||||
OR pr.public_url LIKE ?
|
||||
)`;
|
||||
const like = `%${searchTerm}%`;
|
||||
params.push(like, like, like, like);
|
||||
}
|
||||
|
||||
const [countRows] = await pool.query(
|
||||
`SELECT COUNT(*) AS total
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_page_records p ON p.id = pr.page_id
|
||||
JOIN h5_users u ON u.id = pr.user_id
|
||||
${whereSql}`,
|
||||
params,
|
||||
);
|
||||
const total = Number(countRows[0]?.total ?? 0);
|
||||
|
||||
const [publicationRows] = await pool.query(
|
||||
`SELECT
|
||||
pr.id AS publicationId,
|
||||
pr.page_id AS pageId,
|
||||
pr.user_id AS userId,
|
||||
pr.public_url AS publicUrl,
|
||||
pr.access_mode AS accessMode,
|
||||
pr.status,
|
||||
pr.user_confirmed_at AS userConfirmedAt,
|
||||
pr.expires_at AS expiresAt,
|
||||
pr.published_at AS publishedAt,
|
||||
pr.view_count AS lifetimeViewCount,
|
||||
p.title AS pageTitle,
|
||||
p.summary AS pageSummary,
|
||||
u.username,
|
||||
u.display_name AS displayName
|
||||
FROM h5_publish_records pr
|
||||
JOIN h5_page_records p ON p.id = pr.page_id
|
||||
JOIN h5_users u ON u.id = pr.user_id
|
||||
${whereSql}`,
|
||||
params,
|
||||
);
|
||||
|
||||
const publicationIds = publicationRows.map((row) => row.publicationId);
|
||||
const statsByPublication = new Map();
|
||||
if (publicationIds.length > 0 && hasRange) {
|
||||
const placeholders = publicationIds.map(() => '?').join(', ');
|
||||
const [viewRows] = await pool.query(
|
||||
`SELECT publish_id AS publicationId,
|
||||
device_type,
|
||||
referrer_host,
|
||||
COUNT(*) AS views
|
||||
FROM h5_publication_views
|
||||
WHERE publish_id IN (${placeholders})
|
||||
AND viewed_at >= ?
|
||||
AND viewed_at <= ?
|
||||
GROUP BY publish_id, device_type, referrer_host`,
|
||||
[...publicationIds, rangeStart, rangeEnd],
|
||||
);
|
||||
for (const row of viewRows) {
|
||||
const key = String(row.publicationId);
|
||||
const bucket = statsByPublication.get(key) ?? [];
|
||||
bucket.push(row);
|
||||
statsByPublication.set(key, bucket);
|
||||
}
|
||||
}
|
||||
|
||||
const rows = publicationRows.map((row) => {
|
||||
const publication = normalizePublicationSnapshot({
|
||||
id: row.publicationId,
|
||||
pageId: row.pageId,
|
||||
publicUrl: row.publicUrl,
|
||||
accessMode: row.accessMode,
|
||||
status: row.status,
|
||||
userConfirmedAt: row.userConfirmedAt,
|
||||
expiresAt: row.expiresAt,
|
||||
title: row.pageTitle,
|
||||
summary: row.pageSummary,
|
||||
});
|
||||
const stats = hasRange
|
||||
? aggregateViewStats(statsByPublication.get(String(row.publicationId)) ?? [])
|
||||
: {
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
};
|
||||
const ownerLabel =
|
||||
String(row.displayName ?? '').trim() ||
|
||||
String(row.username ?? '').trim() ||
|
||||
'未命名用户';
|
||||
return {
|
||||
publicationId: row.publicationId,
|
||||
pageId: row.pageId,
|
||||
userId: row.userId,
|
||||
ownerLabel,
|
||||
username: String(row.username ?? '').trim(),
|
||||
pageTitle: String(row.pageTitle ?? '').trim() || '未命名页面',
|
||||
pageSummary: String(row.pageSummary ?? '').trim(),
|
||||
publicUrl: resolveAdminCatalogPublicUrl(row.publicUrl, { publicHost }),
|
||||
accessMode: row.accessMode,
|
||||
indexable: isPublicationIndexable(publication, { now }),
|
||||
publishedAt: Number(row.publishedAt) || null,
|
||||
lifetimeViewCount: Number(row.lifetimeViewCount) || 0,
|
||||
...stats,
|
||||
};
|
||||
});
|
||||
|
||||
rows.sort((a, b) => {
|
||||
const primary = compareRows(a, b, normalizedSortBy, normalizedSortOrder);
|
||||
if (primary !== 0) return primary;
|
||||
return compareRows(a, b, 'pageTitle', 'asc');
|
||||
});
|
||||
|
||||
const offset = (safePage - 1) * safePageSize;
|
||||
const pageRows = rows.slice(offset, offset + safePageSize);
|
||||
const totals = rows.reduce(
|
||||
(acc, row) => ({
|
||||
totalViews: acc.totalViews + row.totalViews,
|
||||
seoViews: acc.seoViews + row.seoViews,
|
||||
geoViews: acc.geoViews + row.geoViews,
|
||||
crawlerViews: acc.crawlerViews + row.crawlerViews,
|
||||
}),
|
||||
emptyTotals(),
|
||||
);
|
||||
|
||||
return {
|
||||
data: pageRows,
|
||||
total,
|
||||
page: safePage,
|
||||
pageSize: safePageSize,
|
||||
totals,
|
||||
};
|
||||
}
|
||||
|
||||
function emptyTotals() {
|
||||
return {
|
||||
totalViews: 0,
|
||||
seoViews: 0,
|
||||
geoViews: 0,
|
||||
crawlerViews: 0,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { classifyReferrerHost } from './mindspace-analytics-discovery.mjs';
|
||||
import {
|
||||
listAdminSeoGeoPublicationCatalog,
|
||||
resolveAdminCatalogPublicUrl,
|
||||
} from './mindspace-seo-geo-admin-catalog.mjs';
|
||||
|
||||
test('classifyReferrerHost maps search and ai referrers', () => {
|
||||
assert.deepEqual(classifyReferrerHost('www.google.com'), {
|
||||
discovery_channel: 'seo',
|
||||
discovery_source: 'google',
|
||||
});
|
||||
assert.deepEqual(classifyReferrerHost('chatgpt.com'), {
|
||||
discovery_channel: 'geo',
|
||||
discovery_source: 'chatgpt',
|
||||
});
|
||||
});
|
||||
|
||||
test('resolveAdminCatalogPublicUrl normalizes relative public urls', () => {
|
||||
assert.equal(
|
||||
resolveAdminCatalogPublicUrl('/u/john/pages/demo'),
|
||||
'https://m.tkmind.cn/u/john/pages/demo',
|
||||
);
|
||||
});
|
||||
|
||||
test('listAdminSeoGeoPublicationCatalog merges zero-traffic pages with view stats', async () => {
|
||||
const calls = [];
|
||||
const pool = {
|
||||
async query(sql, params = []) {
|
||||
calls.push({ sql, params });
|
||||
const normalized = String(sql).replace(/\s+/g, ' ').trim();
|
||||
if (normalized.startsWith('SELECT COUNT(*) AS total')) {
|
||||
return [[{ total: 2 }]];
|
||||
}
|
||||
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/demo',
|
||||
accessMode: 'public',
|
||||
status: 'online',
|
||||
userConfirmedAt: 1000,
|
||||
expiresAt: null,
|
||||
publishedAt: 2000,
|
||||
lifetimeViewCount: 12,
|
||||
pageTitle: 'Demo',
|
||||
pageSummary: 'Summary',
|
||||
username: 'john',
|
||||
displayName: 'John',
|
||||
},
|
||||
{
|
||||
publicationId: 'pub-2',
|
||||
pageId: 'page-2',
|
||||
userId: 'user-2',
|
||||
publicUrl: '/u/jane/pages/empty',
|
||||
accessMode: 'public',
|
||||
status: 'online',
|
||||
userConfirmedAt: null,
|
||||
expiresAt: null,
|
||||
publishedAt: 1500,
|
||||
lifetimeViewCount: 0,
|
||||
pageTitle: 'Empty',
|
||||
pageSummary: '',
|
||||
username: 'jane',
|
||||
displayName: 'Jane',
|
||||
},
|
||||
]];
|
||||
}
|
||||
if (normalized.includes('FROM h5_publication_views')) {
|
||||
return [[
|
||||
{
|
||||
publicationId: 'pub-1',
|
||||
device_type: 'desktop',
|
||||
referrer_host: 'www.google.com',
|
||||
views: 3,
|
||||
},
|
||||
{
|
||||
publicationId: 'pub-1',
|
||||
device_type: 'bot',
|
||||
referrer_host: null,
|
||||
views: 2,
|
||||
},
|
||||
]];
|
||||
}
|
||||
return [[]];
|
||||
},
|
||||
};
|
||||
|
||||
const result = await listAdminSeoGeoPublicationCatalog(pool, {
|
||||
page: 1,
|
||||
pageSize: 10,
|
||||
startAt: 1,
|
||||
endAt: 9_999_999_999_999,
|
||||
sortBy: 'totalViews',
|
||||
sortOrder: 'desc',
|
||||
});
|
||||
|
||||
assert.equal(result.total, 2);
|
||||
assert.equal(result.data.length, 2);
|
||||
assert.equal(result.data[0].publicationId, 'pub-1');
|
||||
assert.equal(result.data[0].seoViews, 3);
|
||||
assert.equal(result.data[0].crawlerViews, 2);
|
||||
assert.equal(result.data[0].totalViews, 5);
|
||||
assert.equal(result.data[0].indexable, true);
|
||||
assert.equal(result.data[1].publicationId, 'pub-2');
|
||||
assert.equal(result.data[1].totalViews, 0);
|
||||
assert.equal(result.data[1].indexable, false);
|
||||
assert.match(result.data[1].publicUrl, /^https:\/\/m\.tkmind\.cn\//);
|
||||
});
|
||||
Reference in New Issue
Block a user