Files
memind/mindspace-seo-discovery-service.mjs
T
john f281f15788
Memind CI / Test, build, and release guards (pull_request) Failing after 3m21s
feat(seo): 固化 m.tkmind.cn 百度验证、推送与 sitemap 去重
让 Portal 首页稳定注入站长验证 meta,并提供 sitemap 批量推送脚本与 LaunchAgent 安装入口;sitemap/llms 对同页优先保留 /u/ canonical URL。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-16 16:28:46 +08:00

225 lines
6.7 KiB
JavaScript

import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.mjs';
import { PLATFORM_STATIC_DISCOVERY_ENTRIES } from './platform-seo-html.mjs';
function escapeXml(value) {
return String(value ?? '')
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;')
.replaceAll("'", '&apos;');
}
function toAbsoluteUrl(origin, value) {
const raw = String(value ?? '').trim();
if (!raw) return '';
if (/^https?:\/\//i.test(raw)) return raw.split('#')[0];
if (raw.startsWith('/') && origin) return `${origin}${raw}`.split('#')[0];
return raw.split('#')[0];
}
export async function listIndexablePublications(
pool,
{ limit = 5000, offset = 0 } = {},
) {
if (!pool) return [];
const safeLimit = Math.min(Math.max(Number(limit) || 5000, 1), 10000);
const safeOffset = Math.max(Number(offset) || 0, 0);
const [rows] = await pool.query(
`SELECT pr.id, pr.page_id, pr.public_url, pr.access_mode, pr.status,
pr.user_confirmed_at, pr.expires_at, pr.updated_at, pr.published_at,
p.title, p.summary
FROM h5_publish_records pr
LEFT JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'
AND pr.access_mode = 'public'
AND (pr.expires_at IS NULL OR pr.expires_at > ?)
ORDER BY pr.published_at DESC
LIMIT ? OFFSET ?`,
[Date.now(), safeLimit, safeOffset],
);
return rows
.map((row) =>
normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
}),
)
.filter((entry) => isPublicationIndexable(entry));
}
export async function resolvePublicationIndexSnapshot(
pool,
{
publicationId = null,
pageId = null,
userId = null,
} = {},
) {
if (!pool) return null;
const id = String(publicationId ?? '').trim();
const page = String(pageId ?? '').trim();
const owner = String(userId ?? '').trim();
if (!id && !page) return null;
const params = [];
let sql = `SELECT pr.id, pr.page_id, pr.user_id, pr.public_url, pr.access_mode, pr.status,
pr.user_confirmed_at, pr.expires_at, pr.updated_at, pr.published_at,
p.title, p.summary
FROM h5_publish_records pr
LEFT JOIN h5_page_records p ON p.id = pr.page_id
WHERE pr.status = 'online'`;
if (id) {
sql += ' AND pr.id = ?';
params.push(id);
} else {
sql += ' AND pr.page_id = ?';
params.push(page);
if (owner) {
sql += ' AND pr.user_id = ?';
params.push(owner);
}
}
sql += ' ORDER BY pr.published_at DESC LIMIT 1';
const [rows] = await pool.query(sql, params);
const row = rows[0];
if (!row) return null;
return normalizePublicationSnapshot({
id: row.id,
pageId: row.page_id,
publicUrl: row.public_url,
accessMode: row.access_mode,
status: row.status,
userConfirmedAt: row.user_confirmed_at,
expiresAt: row.expires_at,
updatedAt: row.updated_at ?? row.published_at,
title: row.title,
summary: row.summary,
});
}
function scoreDiscoveryEntry(entry) {
const url = String(entry?.publicUrl ?? entry?.public_url ?? '').trim();
if (!url) return -100;
if (/\/_archived/i.test(url)) return -50;
if (url.includes('/u/')) return 100;
if (url.includes('/MindSpace/')) return 10;
return 20;
}
export function dedupeDiscoveryEntriesByPage(publications = []) {
const byPage = new Map();
for (const entry of publications) {
const snapshot = normalizePublicationSnapshot(entry);
if (!snapshot) continue;
const pageKey =
String(snapshot.pageId ?? '').trim() ||
String(snapshot.publicUrl ?? '').trim();
if (!pageKey) continue;
const existing = byPage.get(pageKey);
if (
!existing ||
scoreDiscoveryEntry(snapshot) > scoreDiscoveryEntry(existing)
) {
byPage.set(pageKey, snapshot);
}
}
return [...byPage.values()];
}
export function mergeDiscoveryEntries(publications = [], staticEntries = PLATFORM_STATIC_DISCOVERY_ENTRIES) {
const merged = [];
const seen = new Set();
const dedupedPublications = dedupeDiscoveryEntriesByPage(publications);
for (const entry of [...dedupedPublications, ...staticEntries]) {
const key = String(entry.publicUrl ?? entry.public_url ?? '').trim();
if (!key || seen.has(key)) continue;
seen.add(key);
merged.push(normalizePublicationSnapshot(entry) ?? entry);
}
return merged;
}
export function renderSitemapXml(entries, { origin = '' } = {}) {
const urls = entries
.map((entry) => {
const loc = toAbsoluteUrl(origin, entry.publicUrl);
if (!loc) return '';
const lastmod = entry.updatedAt
? new Date(Number(entry.updatedAt)).toISOString()
: new Date().toISOString();
return ` <url>
<loc>${escapeXml(loc)}</loc>
<lastmod>${escapeXml(lastmod)}</lastmod>
</url>`;
})
.filter(Boolean)
.join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls}
</urlset>
`;
}
export function renderRobotsTxt({
origin = '',
sitemapEnabled = false,
llmsTxtEnabled = false,
} = {}) {
const lines = [
'User-agent: *',
'Allow: /u/',
'Allow: /learn/',
'Disallow: /api/',
'Disallow: /admin-api/',
'Disallow: /mindspace/',
];
if (sitemapEnabled && origin) {
lines.push(`Sitemap: ${origin}/sitemap.xml`);
}
if (llmsTxtEnabled && origin) {
lines.push(`# AI discovery: ${origin}/llms.txt`);
}
lines.push('');
return `${lines.join('\n')}`;
}
export function renderLlmsTxt(entries, { origin = '' } = {}) {
const header = [
'# TKMind MindSpace public pages',
'# Public online publications are listed here.',
'',
];
const body = entries
.map((entry) => {
const url = toAbsoluteUrl(origin, entry.publicUrl);
if (!url) return '';
const summary = String(entry.summary ?? entry.title ?? '').trim();
return summary ? `- [${summary}](${url})` : `- ${url}`;
})
.filter(Boolean);
return `${header.concat(body).join('\n')}\n`;
}
export function createMindspaceSeoDiscoveryService(pool) {
return {
listIndexablePublications: (options) => listIndexablePublications(pool, options),
resolvePublicationIndexSnapshot: (options) =>
resolvePublicationIndexSnapshot(pool, options),
dedupeDiscoveryEntriesByPage,
mergeDiscoveryEntries,
renderSitemapXml,
renderRobotsTxt,
renderLlmsTxt,
};
}