用 Claude Code 自动生成 XML 站点地图
用Claude Code生成Astro与Node站点地图,处理hreflang、lastmod、robots.txt和Search Console。
站点地图是给搜索引擎看的公开URL清单
用 Claude Code 批量维护文章、文档或商品页时,最容易被忽略的不是页面能不能渲染,而是搜索引擎能不能稳定发现这些页面。XML站点地图会告诉Google哪些是你希望出现在搜索结果中的规范URL、这些页面什么时候有过重要更新,以及不同语言版本之间是什么关系。
它不是收录保证。Google的当前文档明确说明,Google会忽略 priority 和 changefreq,只有在 lastmod 长期真实可信时才会参考它。旧的站点地图ping端点也已经废弃,所以现代流程应该使用 robots.txt、Google Search Console 提交和部署后的验证,而不是继续请求 https://www.google.com/ping?sitemap=...。
本文会介绍两种可落地的做法:Astro官方站点地图集成,以及一个只依赖Node.js内置模块的多语言MDX生成脚本。若你正在整理整体SEO流程,可以一起参考 Claude Code SEO优化 和 Claude Code CI/CD设置。
先固定官方规则
| 项目 | 实务判断 |
|---|---|
| URL | 使用 https://example.com/blog/post/ 这样的绝对URL,不使用相对路径 |
| 文件上限 | 单个站点地图最多50,000个URL,或未压缩50 MB |
| 编码 | 使用UTF-8,并对XML中的URL值做转义 |
lastmod | 只在正文、结构化数据或重要链接真实变化时更新 |
priority / changefreq | Google不使用,可以省略 |
| 多语言页面 | 每个URL都要列出自己和所有替代语言版本 |
| 提交方式 | 使用 robots.txt 和Search Console,不保留ping脚本 |
建议收藏的官方资料包括 Google站点地图指南、Google站点地图ping废弃公告、Google多语言页面指南 和 sitemaps.org协议。
场景1:Astro页面和博客路由
普通的Astro静态站点可以先使用官方集成。它会在 astro build 时生成站点地图,并且可以在路由结构稳定时输出本地化URL关系。
npx astro add sitemap
// astro.config.mjs
import { defineConfig } from 'astro/config';
import sitemap from '@astrojs/sitemap';
export default defineConfig({
site: 'https://claudecodelab.com',
integrations: [
sitemap({
filter: (page) => !page.includes('/draft/') && !page.includes('/preview/'),
i18n: {
defaultLocale: 'ja',
locales: {
ja: 'ja',
en: 'en',
zh: 'zh-CN',
ko: 'ko',
es: 'es',
fr: 'fr',
de: 'de',
pt: 'pt-BR',
hi: 'hi',
id: 'id',
},
},
}),
],
});
这里最常见的失败是 site 写错。不要让 localhost、预览域名、http 和 https 混在生成结果里。Google会按站点地图里的URL抓取,所以这里必须和你页面上的canonical URL保持一致。
场景2:用Node.js生成多语言MDX站点地图
当内容分布在 blog、blog-en、blog-zh 等集合里,或者你希望把frontmatter里的 updatedDate 精确转成 lastmod 时,自定义脚本更好维护。下面的脚本只使用Node.js内置模块,并写出 public/sitemap.xml。
// scripts/generate-sitemap.mjs
import { mkdir, readdir, readFile, stat, writeFile } from 'node:fs/promises';
import path from 'node:path';
const SITE_URL = (process.env.SITE_URL ?? 'https://example.com').replace(/\/$/, '');
const OUT_DIR = 'public';
const OUT_FILE = path.join(OUT_DIR, 'sitemap.xml');
const collections = [
{ dir: 'site/src/content/blog', prefix: '/blog', hreflang: 'ja' },
{ dir: 'site/src/content/blog-en', prefix: '/en/blog', hreflang: 'en' },
{ dir: 'site/src/content/blog-zh', prefix: '/zh/blog', hreflang: 'zh-CN' },
{ dir: 'site/src/content/blog-ko', prefix: '/ko/blog', hreflang: 'ko' },
{ dir: 'site/src/content/blog-es', prefix: '/es/blog', hreflang: 'es' },
{ dir: 'site/src/content/blog-fr', prefix: '/fr/blog', hreflang: 'fr' },
{ dir: 'site/src/content/blog-de', prefix: '/de/blog', hreflang: 'de' },
{ dir: 'site/src/content/blog-pt', prefix: '/pt/blog', hreflang: 'pt-BR' },
{ dir: 'site/src/content/blog-hi', prefix: '/hi/blog', hreflang: 'hi' },
{ dir: 'site/src/content/blog-id', prefix: '/id/blog', hreflang: 'id' },
];
function escapeXml(value) {
return String(value).replace(/[<>&'"]/g, (char) => ({
'<': '<',
'>': '>',
'&': '&',
"'": ''',
'"': '"',
})[char]);
}
async function* walk(dir) {
let items;
try {
items = await readdir(dir, { withFileTypes: true });
} catch (error) {
if (error.code === 'ENOENT') return;
throw error;
}
for (const item of items) {
const fullPath = path.join(dir, item.name);
if (item.isDirectory()) {
yield* walk(fullPath);
} else if (/\.(md|mdx)$/.test(item.name)) {
yield fullPath;
}
}
}
function frontmatterOf(source) {
return source.match(/^---\n([\s\S]*?)\n---/)?.[1] ?? '';
}
function dateField(frontmatter, key) {
return frontmatter.match(new RegExp(`^${key}:\\s*["']?(\\d{4}-\\d{2}-\\d{2})`, 'm'))?.[1];
}
function routeSlug(collectionDir, filePath) {
return path
.relative(collectionDir, filePath)
.replace(/\\/g, '/')
.replace(/\.(md|mdx)$/, '')
.replace(/\/index$/, '');
}
function encodeRoute(slug) {
return slug.split('/').map(encodeURIComponent).join('/');
}
async function collectEntries() {
const bySlug = new Map();
for (const collection of collections) {
for await (const filePath of walk(collection.dir)) {
const source = await readFile(filePath, 'utf8');
const frontmatter = frontmatterOf(source);
if (/^draft:\s*true\s*$/m.test(frontmatter)) continue;
const info = await stat(filePath);
const slug = routeSlug(collection.dir, filePath);
const lastmod =
dateField(frontmatter, 'updatedDate') ??
dateField(frontmatter, 'pubDate') ??
info.mtime.toISOString().slice(0, 10);
const route = `${collection.prefix}/${encodeRoute(slug)}/`;
const variant = {
loc: `${SITE_URL}${route}`,
hreflang: collection.hreflang,
lastmod,
};
const variants = bySlug.get(slug) ?? [];
variants.push(variant);
bySlug.set(slug, variants);
}
}
return [...bySlug.values()].flatMap((variants) =>
variants.map((variant) => ({
...variant,
alternates: variants.map(({ hreflang, loc }) => ({ hreflang, loc })),
})),
);
}
function buildSitemap(entries) {
const urls = entries.map((entry) => ` <url>
<loc>${escapeXml(entry.loc)}</loc>
<lastmod>${entry.lastmod}</lastmod>
${entry.alternates.map((alt) => ` <xhtml:link rel="alternate" hreflang="${escapeXml(alt.hreflang)}" href="${escapeXml(alt.loc)}" />`).join('\n')}
</url>`).join('\n');
return `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9"
xmlns:xhtml="http://www.w3.org/1999/xhtml">
${urls}
</urlset>
`;
}
const entries = await collectEntries();
if (entries.length === 0) {
throw new Error('没有找到可写入站点地图的公开URL。');
}
await mkdir(OUT_DIR, { recursive: true });
await writeFile(OUT_FILE, buildSitemap(entries), 'utf8');
console.log(`已将 ${entries.length} 个URL写入 ${OUT_FILE}。`);
运行方式如下:
SITE_URL=https://claudecodelab.com node scripts/generate-sitemap.mjs
场景3:文章、商品和文档分开管理
小型博客可以只有一个 sitemap.xml。页面变多后,最好按文章、静态页面、商品或帮助文档拆分。这样不仅能避开50,000 URL和50 MB上限,也能在Search Console里快速判断哪一类页面出现抓取问题。
<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<sitemap>
<loc>https://example.com/sitemap-pages.xml</loc>
<lastmod>2026-06-03</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-blog.xml</loc>
<lastmod>2026-06-03</lastmod>
</sitemap>
<sitemap>
<loc>https://example.com/sitemap-products.xml</loc>
<lastmod>2026-06-03</lastmod>
</sitemap>
</sitemapindex>
让 Claude Code 实现时,可以明确要求:每个文件输出URL数量日志,接近上限前自动分块,站点地图索引只引用同一站点上的文件。
robots.txt、Search Console和部署验证
公开后,在 robots.txt 写入站点地图地址:
User-agent: *
Allow: /
Sitemap: https://claudecodelab.com/sitemap.xml
然后在Google Search Console的“站点地图”页面提交一次。部署检查可以确认公开URL返回HTTP 200,并且响应内容确实像站点地图。
// scripts/verify-sitemap.mjs
const sitemapUrl = process.env.SITEMAP_URL ?? 'https://example.com/sitemap.xml';
const response = await fetch(sitemapUrl);
if (!response.ok) {
throw new Error(`站点地图请求失败: HTTP ${response.status}`);
}
const xml = await response.text();
if (!xml.includes('<urlset') && !xml.includes('<sitemapindex')) {
throw new Error('响应内容不像站点地图XML。');
}
console.log(`已确认 ${sitemapUrl}。大小: ${xml.length} bytes`);
发布前要抓住的失败例
第一,不能把所有 lastmod 都设成构建日期。页面没有重要修改却每天变成今天,会削弱更新信号的可信度。
第二,不要把草稿、noindex 页面、重定向来源、重复URL放进站点地图。站点地图应该只放希望进入搜索结果的规范URL。
第三,多语言关系不能只写单向链接。每个语言版本都要列出自己和所有替代版本,否则 hreflang 簇容易不完整。
第四,URL必须做XML转义。带查询参数的 & 在XML里要写成 &,否则Search Console可能报告解析错误。
收益化和实际结果
站点地图本身不会直接带来广告收入,但它能保护可变现页面的发现路径,例如教程、对比页、免费资料和咨询页。修好站点地图后,也要检查内部链接和CTA,让读者能从免费文章自然进入 Claude Code培训 或相关资源。
Masa在ClaudeCodeLab的流程里实际验证后,最有价值的改动是删除旧ping脚本、让 lastmod 跟随 updatedDate、并用相互的 hreflang 绑定10个语言版本。这样编辑审核时能直接用MDX frontmatter确认哪个语言版本已经更新,Search Console里的排查也更清楚。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。