Sitemap 工程化:把“内容路由 + 静态路由 + 运行时模板路由”合并为一个 sitemap.xml
Sitemap 工程化:把“内容路由 + 静态路由 + 运行时模板路由”合并为一个 sitemap.xml
这篇是一个“落地案例”:不只讲方法论,而是把一个真实项目里 sitemap.xml 的生成链路拆开给你看—— 它如何把三类数据源(内容 / 静态 / 运行时)合并成一个稳定的 sitemap,并通过工程约束解决最常见的四类问题: 漏路由、重复路由、语言不全、以及数据源抖动导致 sitemap 500。
TL;DR(30 秒讲清楚)
- 问题:路由来源分散(内容/静态/运行时),sitemap 很容易漏/重复/语言不全。
- 方案:用集中式
sitemap.xml生成器合并三类数据源,并加入“URL 规范化去重 + 生产环境语言齐全 gate + 降级输出”。 - 验证:用 entries 数量趋势、重复率(去重命中)、语言齐全率、抽样抓取 200/301/404 分布与站长工具覆盖率回归。
适用读者与前置知识
- 适合:站点路由来自多系统(CMS/代码/运行时 API),且需要稳定 sitemap 的团队。
- 不适合:纯静态少量路由、无需 i18n 的站点。
- 前置:了解 sitemap 基础规范,理解 i18n 页面与 hreflang 的关系。
背景与约束
为什么要把三类数据源合并为单一 sitemap?因为它们各自只覆盖了“路由宇宙”的一部分:
- 内容路由(CMS):覆盖内容落地页,但容易出现“缺语言”“内容无效”“未激活”等脏数据。
- 静态路由(代码清单):稳定可靠,但容易“新增页忘了加”,并与 rewrites/server 分叉。
- 运行时路由(API):模板/标签类路由必须运行时拉取,但 API 抖动会导致生成失败。
如果你不做合并与约束,最终一定会出现:sitemap 与真实可访问路由不一致(收录/抓取/软 404 问题随之而来)。
问题定义(Problem Statement)
生成一个稳定的 sitemap.xml:合并三类数据源,规范化去重,生产环境过滤缺语言内容,运行时数据源失败时仍可降级输出,且可验证可回归。
目标与非目标(Goals / Non-goals)
目标
- 覆盖所有应收录入口(内容 + 静态 + 运行时),并保持一致去重。
- 生产环境避免输出会 404 的语言版本(语言齐全 gate)。
- 任何数据源异常不影响整体 sitemap 可用性(降级策略)。
非目标
- 不在本文展开 sitemap index(拆分多文件)与 lastmod 维护策略。
- 不在本文实现全量抓取平台,只给出最小验证闭环与建议。
方案与权衡(Solution & Trade-offs)
方案概览:三数据源合并 + 三条工程约束
- 合并:静态清单 + 内容清单(manifest)+ 运行时模板路由 → 统一 entries。
- 去重:以规范化后的 pathname 作为去重维度,避免重复输出同一页面。
- 语言齐全 gate:生产环境只放“覆盖全部 requiredLocales”的内容页。
- 降级:任何动态数据源失败都不 500;至少输出静态集合(必要时输出最小根 URL)。
真实链路:生成 sitemap 的 9 步(按源码可复现)
- 初始化生成器:确定
baseUrl与 entries 容器。 - 写入根页面:
/(多语言 + hreflang)。 - 拉取内容路由:从 CMS/DB 聚合 slug 与 locale。
- 生产环境过滤:启用语言齐全 gate,过滤“缺语言/无效内容/未激活”。
- 写入内容 entries:为每个 path 生成
loc+ alternates。 - 写入静态路由:复用路由表清单,避免与 rewrites/server 分叉。
- 拉取运行时路由:模板/标签 API(分页拉取)。
- 运行时降级:API 失败时仍至少输出关键入口(如
/templates)。 - 渲染 XML:xmlEscape +
xhtml:linkalternates,输出给爬虫。
文件:your-project/src/pages/sitemap.xml.tsx
符号:SitemapGenerator.generateXml / buildSitemapEntries
// 伪代码:集中式 sitemap 生成器(按层构建,再统一渲染)
class SitemapGenerator {
constructor(baseUrl) {
this.baseUrl = baseUrl;
this.entries = [];
}
async generateXml() {
await this.buildSitemapEntries();
return this.renderSitemapXml();
}
async buildSitemapEntries() {
this.pushLocalizedEntry('/', ALL_LOCALES);
await this.fetchAndAddManifestRoutes(/* requireAllLocales */);
this.addStaticToolRoutes();
await this.fetchAndAddTemplates(); // 失败要降级
}
}
关键实现细节 1:去重与规范化(避免重复收录)
这类 sitemap 最常见的 bug 是重复:同一个 path 从两个数据源进来,或者同一个 URL 因为规范不一致(尾斜杠/大小写)变成两条。 推荐的做法是:以 pathname 做去重,而不是完整 URL 字符串。
文件:your-project/src/pages/sitemap.xml.tsx
符号:hasPath
// 伪代码:以 pathname 做去重(忽略 baseUrl 与 locale 前缀差异)
function hasPath(entries, path) {
return entries.some((e) => new URL(e.loc).pathname === path);
}
关键实现细节 2:语言齐全 gate(生产环境)
生产环境最重要的约束之一:不要输出缺语言的内容页。否则爬虫会抓到 404,覆盖率下降。 典型实现是:按 slug 聚合 locale,并要求覆盖 requiredLocales 才进入 sitemap。
文件:your-project/src/modules/landing/slugLanding/routes.ts
符号:fetchLandingRoutesFromSupabase
// 伪代码:requireAllLocales 只在生产环境开启
const requireAllLocales = isProduction;
const requiredLocales = SITE_LOCALES;
// slug -> Set(locales)
const aggregated = new Map();
for (const row of rows) {
if (isProduction && row.isActive !== true) continue;
if (!isValidLandingPageData(row.pageData)) continue;
aggregated.get(row.slug).add(mapDbLangToSiteLang(row.language));
}
const entries = [];
for (const [slug, localesSet] of aggregated) {
if (requireAllLocales && !coversAll(localesSet, requiredLocales)) continue;
entries.push({ slug, locales: Array.from(localesSet) });
}
关键实现细节 3:运行时数据源失败时的降级
运行时模板路由(如 /templates)通常依赖 API 拉取列表,且可能分页、可能超时。工程化的要求是: sitemap 永远可用,局部失败不影响整体输出。
文件:your-project/src/pages/sitemap.xml.tsx
符号:fetchAndAddTemplates / getServerSideProps
// 伪代码:运行时路由失败也要兜底输出关键入口
async function fetchAndAddTemplates() {
const result = await fetchTemplatesSafely();
if (result.ok) {
push('/templates');
for (const t of result.templates) push(`/templates/${t.openId}`);
} else {
// 降级:至少输出 /templates
push('/templates');
}
}
// 全局兜底:异常时输出根 URL
try {
const xml = await generator.generateXml();
return xml;
} catch {
return minimalXml(['/']);
}
实践步骤(Step-by-step)
- 定义数据源:列清静态/内容/运行时三类路由来源与边界。
- 建立路由表:静态清单与 legacy 模式沉淀为单一真相,sitemap 与 rewrites/server 复用。
- 实现合并器:统一 entry 结构(loc + alternates),按层构建。
- 实现去重:以规范化后的 pathname 去重,避免重复输出。
- 加语言 gate:生产环境过滤缺语言内容页;非生产环境允许部分输出。
- 加降级:动态数据源失败不阻断;全局异常输出最小 sitemap。
- 上线回归:抽样抓取 + 站长工具覆盖率 + 404/软 404 监控。
指标与验证(Metrics & Validation)
- entries 数量:按层统计(静态/内容/运行时),观察异常波动。
- 重复率:去重命中次数(突然升高通常意味着分叉或规范化变化)。
- 语言齐全率:内容路由中满足 requiredLocales 的比例(生产环境应接近 100%)。
- 抓取回归:抽样访问 sitemap URL,检查 200/301/404 与重定向链长。
通过标准(建议):
- canonical:每页只出现 1 个,不包含 query/hash,尽量指向最终态 URL(避免 301 链)。
- alternates:包含
x-default(如适用),只链接真实存在的语言 URL(不 404/不软 404)。 - sitemap:抽样 URL 的 200 比例接近 100%,301 链长 ≤ 1;不应包含
noindex或黑名单路径。
最小可复现验证:
# 1) sitemap 必须永远可用(200)
curl -I https://your-domain.com/sitemap.xml
# 2) 检查是否包含 xhtml:link alternates(多语言)
curl -s https://your-domain.com/sitemap.xml | head -n 80
# 3) 抽样抓取若干 URL
curl -I https://your-domain.com/some-url-from-sitemap
预期与判定(建议):
- 可用性:
/sitemap.xml返回200,且在数据源抖动时仍能降级输出(不 500、不返回空)。 - alternates:如启用多语言,能看到
xhtml:link;缺语言页面不应被输出。 - 抽样抓取:200 占绝大多数;允许少量 301(迁移期),但最终应落到 200,且链长 ≤ 1。
不通过先查:三类数据源是否存在“集合分叉”;URL 规范化(尾斜杠/大小写/locale 前缀)是否一致; 去重是否以规范化后的 pathname 做基准;以及语言齐全 gate 与降级策略是否真的生效。
常见坑与规避(Pitfalls)
- 漏路由:静态清单没有复用路由表,新增页忘了加。
- 重复路由:同一路径来自两个数据源;或尾斜杠/大小写不一致。
- 缺语言:生产环境没做语言齐全 gate,把缺内容语言页输出到 sitemap。
- alternate 错误:对仅默认语言存在的页面输出全语言 hreflang。
- sitemap 500:动态数据源异常未降级,导致爬虫入口不可用。
FAQ
Q:为什么不直接在 CMS 里维护 sitemap?
A:因为 sitemap 的集合往往来自多个系统(静态路由、运行时模板页、历史兼容规则)。把它工程化到代码层更容易做一致性、去重与降级,也更容易和 rewrites/server 保持同一口径。
Q:为什么要以 pathname 去重,而不是完整 URL?
A:完整 URL 会包含 baseUrl、locale 前缀等信息,容易导致同一页面被视为不同条目。以 pathname 去重更接近“页面唯一性”,也更容易保持稳定。
Q:生产环境语言齐全 gate 会不会让新内容迟迟不上线?
A:gate 约束的是“进入 sitemap 的资格”,不一定影响页面可访问。你可以让内容先上线、先在非生产环境验证,再在语言补齐后进入生产 sitemap,这样更稳。
Q:运行时路由失败时,为什么不直接返回空 sitemap?
A:sitemap 是爬虫入口,返回空会造成覆盖率骤降。正确做法是输出静态集合与可用的部分动态集合;全局异常也至少输出根 URL,保证永远可抓取。
Q:如何让 sitemap 与 rewrites/Custom Server 的路由一致?
A:坚持“路由表单一真相”,静态清单与 legacy 模式只维护一份,并被 sitemap 与治理层复用;发布前做抽样抓取校验 200/301/404 分布与链长。
Q:sitemap 里要不要包含 query 参数?
A:通常不要。query 很容易引入重复页面与碎片化;除非你非常确定 query 是内容标识的一部分且具有 canonical 规范,否则建议全部排除。