Files
memind/mindspace-seo-discovery-service.mjs
T
john 4e789663a1
Memind CI / Test, build, and release guards (push) Failing after 5m19s
feat(seo): index public online pages without confirmation
Drop the user_confirmed_at gate so public, online, unexpired pages can enter sitemap/llms and receive SEO/GEO tags. Defaults now enable all discovery switches; stored all-off config is still preserved until admin saves.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-13 15:51:48 +08:00

179 lines
5.1 KiB
JavaScript

import { isPublicationIndexable, normalizePublicationSnapshot } from './mindspace-index-policy.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,
});
}
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/',
'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),
renderSitemapXml,
renderRobotsTxt,
renderLlmsTxt,
};
}