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:
10
DESIGN.md
10
DESIGN.md
@@ -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 并进入 sitemap;Thread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。
|
- Threads 列表页使用 `/threads` canonical 并进入 sitemap;Thread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。
|
||||||
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
- 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。
|
||||||
|
|||||||
7
frontend/server/routes/sitemap-collections.xml.ts
Normal file
7
frontend/server/routes/sitemap-collections.xml.ts
Normal 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));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-habitats.xml.ts
Normal file
7
frontend/server/routes/sitemap-habitats.xml.ts
Normal 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));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-life.xml.ts
Normal file
7
frontend/server/routes/sitemap-life.xml.ts
Normal 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));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-pokedex.xml.ts
Normal file
7
frontend/server/routes/sitemap-pokedex.xml.ts
Normal 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));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-static.xml.ts
Normal file
7
frontend/server/routes/sitemap-static.xml.ts
Normal 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));
|
||||||
|
});
|
||||||
7
frontend/server/routes/sitemap-threads.xml.ts
Normal file
7
frontend/server/routes/sitemap-threads.xml.ts
Normal 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));
|
||||||
|
});
|
||||||
@@ -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));
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''');
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user