feat(sitemap): implement dynamic sitemap index and entity sitemaps

Convert sitemap.xml to a sitemap index referencing split modules
Add dynamic sitemaps for pokedex, habitats, collections, life, and threads
Fetch entity data from API to populate lastmod and priority
This commit is contained in:
2026-05-07 13:55:25 +08:00
parent 4a7309027a
commit 9db8e60f3d
9 changed files with 277 additions and 28 deletions

View File

@@ -1177,7 +1177,15 @@ API 暴露边界:
- `/threads` - `/threads`
- `/threads/:threadId` - `/threads/:threadId`
- `/project-updates` - `/project-updates`
- `sitemap.xml` 当前只包含稳定的公开顶层浏览入口实体详情页、Life Post 详情页、Thread 详情页和公开 Profile 依赖运行时数据与站内链接可达性,当前不静态写入 sitemap - `sitemap.xml` 输出 sitemap index并引用按公开模块拆分的全量 sitemap
- `/sitemap-static.xml`:稳定公开顶层浏览入口和法律页面。
- `/sitemap-pokedex.xml`Pokemon 详情页URL 使用 canonical `/pokemon/:id`
- `/sitemap-habitats.xml`Habitat 详情页URL 使用 canonical `/habitats/:id`
- `/sitemap-collections.xml`Items、Ancient Artifacts 和 Recipes 详情页URL 分别使用 canonical `/items/:id``/ancient-artifacts/:id``/recipes/:id`;带 Ancient Artifact 分类的 item 只输出 `/ancient-artifacts/:id`,避免同一内容在 sitemap 中重复提交。
- `/sitemap-life.xml`:公开可见 Life Post 详情页URL 使用 canonical `/life/:id`
- `/sitemap-threads.xml`:公开 Thread 详情页URL 使用 canonical `/threads/:threadId`
- Sitemap URL 条目输出 `lastmod``priority`;详情页 `lastmod` 优先使用公开列表数据中的 `updatedAt` 或活跃时间字段,缺失时回退到 `createdAt`,不得暴露编辑人、权限、审核原因、内部审计 payload 或调试信息。
- 当前不输出公开 Profile 全量 sitemap公开 Profile 可通过站内搜索和站内链接发现,避免将用户目录作为 sitemap 枚举面。
- Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。 - Pokemon、物品、材料单和栖息地详情页在公开详情数据加载完成后用实体名称、公开展示图片和本地化 SEO 文案更新 title、description、canonical、Open Graph 和 Twitter card。
- Threads 列表页使用 `/threads` canonical 并进入 sitemapThread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。 - Threads 列表页使用 `/threads` canonical 并进入 sitemapThread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。 - 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。

View File

@@ -0,0 +1,7 @@
import { collectionsSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return collectionsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
});

View File

@@ -0,0 +1,7 @@
import { habitatsSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return habitatsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
});

View File

@@ -0,0 +1,7 @@
import { lifeSitemapXml, normalizeApiBaseUrl, normalizeSiteUrl } from '../utils/seo-files';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return lifeSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
});

View File

@@ -0,0 +1,7 @@
import { normalizeApiBaseUrl, normalizeSiteUrl, pokedexSitemapXml } from '../utils/seo-files';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return pokedexSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
});

View File

@@ -0,0 +1,7 @@
import { normalizeSiteUrl, staticSitemapXml } from '../utils/seo-files';
export default defineEventHandler((event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return staticSitemapXml(normalizeSiteUrl(config.public.siteUrl));
});

View File

@@ -0,0 +1,7 @@
import { normalizeApiBaseUrl, normalizeSiteUrl, threadsSitemapXml } from '../utils/seo-files';
export default defineEventHandler(async (event) => {
const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return threadsSitemapXml(normalizeSiteUrl(config.public.siteUrl), normalizeApiBaseUrl(config.serverApiBaseUrl));
});

View File

@@ -1,7 +1,7 @@
import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files'; import { normalizeSiteUrl, sitemapIndexXml } from '../utils/seo-files';
export default defineEventHandler((event) => { export default defineEventHandler((event) => {
const config = useRuntimeConfig(event); const config = useRuntimeConfig(event);
setHeader(event, 'Content-Type', 'application/xml; charset=utf-8'); setHeader(event, 'Content-Type', 'application/xml; charset=utf-8');
return sitemapXml(normalizeSiteUrl(config.public.siteUrl)); return sitemapIndexXml(normalizeSiteUrl(config.public.siteUrl));
}); });

View File

@@ -1,23 +1,58 @@
const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com';
const fallbackApiBaseUrl = 'http://localhost:3001';
const staticLastmod = new Date().toISOString();
const sitemapPageSize = 72;
const sitemapPaths = [ type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never';
'/',
'/pokemon', export type SitemapUrl = {
'/event-pokemon', path: string;
'/habitats', lastmod?: string | null;
'/event-habitats', changefreq?: ChangeFrequency;
'/items', priority?: number;
'/event-items', };
'/ancient-artifacts',
'/recipes', type SitemapEntity = {
'/dish', id: number;
'/checklist', createdAt?: string | null;
'/life', updatedAt?: string | null;
'/threads', lastActiveAt?: string | null;
'/project-updates', ancientArtifactCategory?: unknown;
'/privacy-policy', };
'/terms-of-service',
'/disclaimers' type ListPage<T> = {
items: T[];
nextCursor: string | null;
hasMore: boolean;
};
const sitemapFiles = [
'/sitemap-static.xml',
'/sitemap-pokedex.xml',
'/sitemap-habitats.xml',
'/sitemap-collections.xml',
'/sitemap-life.xml',
'/sitemap-threads.xml'
];
const staticSitemapUrls: SitemapUrl[] = [
{ path: '/', changefreq: 'weekly', priority: 1 },
{ path: '/pokemon', changefreq: 'weekly', priority: 0.95 },
{ path: '/event-pokemon', changefreq: 'weekly', priority: 0.85 },
{ path: '/habitats', changefreq: 'weekly', priority: 0.9 },
{ path: '/event-habitats', changefreq: 'weekly', priority: 0.8 },
{ path: '/items', changefreq: 'weekly', priority: 0.9 },
{ path: '/event-items', changefreq: 'weekly', priority: 0.8 },
{ path: '/ancient-artifacts', changefreq: 'weekly', priority: 0.85 },
{ path: '/recipes', changefreq: 'weekly', priority: 0.85 },
{ path: '/dish', changefreq: 'weekly', priority: 0.8 },
{ path: '/checklist', changefreq: 'weekly', priority: 0.8 },
{ path: '/life', changefreq: 'daily', priority: 0.75 },
{ path: '/threads', changefreq: 'daily', priority: 0.75 },
{ path: '/project-updates', changefreq: 'weekly', priority: 0.6 },
{ path: '/privacy-policy', changefreq: 'yearly', priority: 0.3 },
{ path: '/terms-of-service', changefreq: 'yearly', priority: 0.3 },
{ path: '/disclaimers', changefreq: 'yearly', priority: 0.3 }
]; ];
const robotsDisallowPaths = [ const robotsDisallowPaths = [
@@ -51,24 +86,188 @@ export function normalizeSiteUrl(value: unknown): string {
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, ''); return (typeof value === 'string' && value.trim() ? value.trim() : fallbackSiteUrl).replace(/\/+$/, '');
} }
export function normalizeApiBaseUrl(value: unknown): string {
return (typeof value === 'string' && value.trim() ? value.trim() : fallbackApiBaseUrl).replace(/\/+$/, '');
}
export function robotsTxt(siteUrl: string): string { export function robotsTxt(siteUrl: string): string {
const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n'); const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n');
return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`; return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`;
} }
export function sitemapXml(siteUrl: string): string { export function sitemapIndexXml(siteUrl: string): string {
const urls = sitemapPaths const sitemaps = sitemapFiles
.map( .map(
(path) => ` <url> (path) => ` <sitemap>
<loc>${siteUrl}${path}</loc> <loc>${xmlEscape(siteUrl + path)}</loc>
<changefreq>weekly</changefreq> <lastmod>${formatLastmod(staticLastmod)}</lastmod>
</url>` </sitemap>`
) )
.join('\n'); .join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${sitemaps}
</sitemapindex>
`;
}
export function staticSitemapXml(siteUrl: string): string {
return sitemapXml(
siteUrl,
staticSitemapUrls.map((url) => ({ ...url, lastmod: staticLastmod }))
);
}
export async function pokedexSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const pokemon = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/pokemon');
return sitemapXml(
siteUrl,
pokemon.map((item) => ({
path: `/pokemon/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly',
priority: 0.8
}))
);
}
export async function habitatsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const habitats = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/habitats');
return sitemapXml(
siteUrl,
habitats.map((item) => ({
path: `/habitats/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly',
priority: 0.75
}))
);
}
export async function collectionsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const [items, artifacts, recipes] = await Promise.all([
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/items'),
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/ancient-artifacts'),
fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/recipes')
]);
return sitemapXml(siteUrl, [
...items
.filter((item) => !item.ancientArtifactCategory)
.map((item) => ({
path: `/items/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly' as const,
priority: 0.75
})),
...artifacts.map((item) => ({
path: `/ancient-artifacts/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly' as const,
priority: 0.75
})),
...recipes.map((item) => ({
path: `/recipes/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'weekly' as const,
priority: 0.7
}))
]);
}
export async function lifeSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const posts = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/life-posts');
return sitemapXml(
siteUrl,
posts.map((item) => ({
path: `/life/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'daily',
priority: 0.65
}))
);
}
export async function threadsSitemapXml(siteUrl: string, apiBaseUrl: string): Promise<string> {
const threads = await fetchAllPages<SitemapEntity>(apiBaseUrl, '/api/threads');
return sitemapXml(
siteUrl,
threads.map((item) => ({
path: `/threads/${item.id}`,
lastmod: entityLastmod(item),
changefreq: 'daily',
priority: 0.65
}))
);
}
function sitemapXml(siteUrl: string, urls: SitemapUrl[]): string {
const body = urls.map((url) => sitemapUrlXml(siteUrl, url)).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?> return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"> <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${urls} ${body}
</urlset> </urlset>
`; `;
} }
function sitemapUrlXml(siteUrl: string, url: SitemapUrl): string {
return [
' <url>',
` <loc>${xmlEscape(siteUrl + normalizePath(url.path))}</loc>`,
...(url.lastmod ? [` <lastmod>${formatLastmod(url.lastmod)}</lastmod>`] : []),
...(url.changefreq ? [` <changefreq>${url.changefreq}</changefreq>`] : []),
...(url.priority !== undefined ? [` <priority>${formatPriority(url.priority)}</priority>`] : []),
' </url>'
].join('\n');
}
async function fetchAllPages<T extends SitemapEntity>(apiBaseUrl: string, path: string): Promise<T[]> {
const items: T[] = [];
let cursor: string | null = null;
do {
const url = new URL(path, `${apiBaseUrl}/`);
url.searchParams.set('limit', String(sitemapPageSize));
if (cursor) {
url.searchParams.set('cursor', cursor);
}
const response = await fetch(url);
if (!response.ok) {
throw new Error(`Sitemap source request failed: ${path} (${response.status})`);
}
const page = (await response.json()) as ListPage<T>;
items.push(...page.items);
cursor = page.hasMore ? page.nextCursor : null;
} while (cursor);
return items;
}
function entityLastmod(entity: SitemapEntity): string | null {
return entity.lastActiveAt ?? entity.updatedAt ?? entity.createdAt ?? null;
}
function normalizePath(path: string): string {
return path.startsWith('/') ? path : `/${path}`;
}
function formatLastmod(value: string): string {
const date = new Date(value);
return Number.isNaN(date.getTime()) ? xmlEscape(value) : date.toISOString();
}
function formatPriority(value: number): string {
return Math.max(0, Math.min(1, value)).toFixed(2).replace(/0$/, '').replace(/\.0$/, '.0');
}
function xmlEscape(value: string): string {
return value
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&apos;');
}