diff --git a/DESIGN.md b/DESIGN.md index 4e22262..06833cd 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -1177,7 +1177,15 @@ API 暴露边界: - `/threads` - `/threads/:threadId` - `/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。 - Threads 列表页使用 `/threads` canonical 并进入 sitemap;Thread 详情页在公开 Thread summary 加载完成后,用 Thread 标题、公开消息数、语言、标签、作者展示名和活跃时间更新 title、description、canonical、Open Graph 和 `DiscussionForumPosting` 结构化数据。 - 认证、管理、新建、编辑和开发中入口必须设置 `noindex`,避免搜索引擎索引受保护、低价值或临时流程页面。 diff --git a/frontend/server/routes/sitemap-collections.xml.ts b/frontend/server/routes/sitemap-collections.xml.ts new file mode 100644 index 0000000..3325fa5 --- /dev/null +++ b/frontend/server/routes/sitemap-collections.xml.ts @@ -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)); +}); diff --git a/frontend/server/routes/sitemap-habitats.xml.ts b/frontend/server/routes/sitemap-habitats.xml.ts new file mode 100644 index 0000000..866d943 --- /dev/null +++ b/frontend/server/routes/sitemap-habitats.xml.ts @@ -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)); +}); diff --git a/frontend/server/routes/sitemap-life.xml.ts b/frontend/server/routes/sitemap-life.xml.ts new file mode 100644 index 0000000..29f6893 --- /dev/null +++ b/frontend/server/routes/sitemap-life.xml.ts @@ -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)); +}); diff --git a/frontend/server/routes/sitemap-pokedex.xml.ts b/frontend/server/routes/sitemap-pokedex.xml.ts new file mode 100644 index 0000000..84c34a6 --- /dev/null +++ b/frontend/server/routes/sitemap-pokedex.xml.ts @@ -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)); +}); diff --git a/frontend/server/routes/sitemap-static.xml.ts b/frontend/server/routes/sitemap-static.xml.ts new file mode 100644 index 0000000..f29f8f4 --- /dev/null +++ b/frontend/server/routes/sitemap-static.xml.ts @@ -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)); +}); diff --git a/frontend/server/routes/sitemap-threads.xml.ts b/frontend/server/routes/sitemap-threads.xml.ts new file mode 100644 index 0000000..0cfd97c --- /dev/null +++ b/frontend/server/routes/sitemap-threads.xml.ts @@ -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)); +}); diff --git a/frontend/server/routes/sitemap.xml.ts b/frontend/server/routes/sitemap.xml.ts index f971aca..5487c96 100644 --- a/frontend/server/routes/sitemap.xml.ts +++ b/frontend/server/routes/sitemap.xml.ts @@ -1,7 +1,7 @@ -import { normalizeSiteUrl, sitemapXml } from '../utils/seo-files'; +import { normalizeSiteUrl, sitemapIndexXml } from '../utils/seo-files'; export default defineEventHandler((event) => { const config = useRuntimeConfig(event); setHeader(event, 'Content-Type', 'application/xml; charset=utf-8'); - return sitemapXml(normalizeSiteUrl(config.public.siteUrl)); + return sitemapIndexXml(normalizeSiteUrl(config.public.siteUrl)); }); diff --git a/frontend/server/utils/seo-files.ts b/frontend/server/utils/seo-files.ts index 062fedc..857725c 100644 --- a/frontend/server/utils/seo-files.ts +++ b/frontend/server/utils/seo-files.ts @@ -1,23 +1,58 @@ const fallbackSiteUrl = 'https://pokopiawiki.tootaio.com'; +const fallbackApiBaseUrl = 'http://localhost:3001'; +const staticLastmod = new Date().toISOString(); +const sitemapPageSize = 72; -const sitemapPaths = [ - '/', - '/pokemon', - '/event-pokemon', - '/habitats', - '/event-habitats', - '/items', - '/event-items', - '/ancient-artifacts', - '/recipes', - '/dish', - '/checklist', - '/life', - '/threads', - '/project-updates', - '/privacy-policy', - '/terms-of-service', - '/disclaimers' +type ChangeFrequency = 'always' | 'hourly' | 'daily' | 'weekly' | 'monthly' | 'yearly' | 'never'; + +export type SitemapUrl = { + path: string; + lastmod?: string | null; + changefreq?: ChangeFrequency; + priority?: number; +}; + +type SitemapEntity = { + id: number; + createdAt?: string | null; + updatedAt?: string | null; + lastActiveAt?: string | null; + ancientArtifactCategory?: unknown; +}; + +type ListPage = { + 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 = [ @@ -51,24 +86,188 @@ export function normalizeSiteUrl(value: unknown): string { 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 { const disallowLines = robotsDisallowPaths.map((path) => `Disallow: ${path}`).join('\n'); return `User-agent: *\nAllow: /\n${disallowLines}\nSitemap: ${siteUrl}/sitemap.xml\n`; } -export function sitemapXml(siteUrl: string): string { - const urls = sitemapPaths +export function sitemapIndexXml(siteUrl: string): string { + const sitemaps = sitemapFiles .map( - (path) => ` - ${siteUrl}${path} - weekly - ` + (path) => ` + ${xmlEscape(siteUrl + path)} + ${formatLastmod(staticLastmod)} + ` ) .join('\n'); + return ` + +${sitemaps} + +`; +} + +export function staticSitemapXml(siteUrl: string): string { + return sitemapXml( + siteUrl, + staticSitemapUrls.map((url) => ({ ...url, lastmod: staticLastmod })) + ); +} + +export async function pokedexSitemapXml(siteUrl: string, apiBaseUrl: string): Promise { + const pokemon = await fetchAllPages(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 { + const habitats = await fetchAllPages(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 { + const [items, artifacts, recipes] = await Promise.all([ + fetchAllPages(apiBaseUrl, '/api/items'), + fetchAllPages(apiBaseUrl, '/api/ancient-artifacts'), + fetchAllPages(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 { + const posts = await fetchAllPages(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 { + const threads = await fetchAllPages(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 ` -${urls} +${body} `; } + +function sitemapUrlXml(siteUrl: string, url: SitemapUrl): string { + return [ + ' ', + ` ${xmlEscape(siteUrl + normalizePath(url.path))}`, + ...(url.lastmod ? [` ${formatLastmod(url.lastmod)}`] : []), + ...(url.changefreq ? [` ${url.changefreq}`] : []), + ...(url.priority !== undefined ? [` ${formatPriority(url.priority)}`] : []), + ' ' + ].join('\n'); +} + +async function fetchAllPages(apiBaseUrl: string, path: string): Promise { + 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; + 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, '''); +}