Google Search Console 上周突然开始报警,说我们站点的索引率从 92% 掉到了 65%,一排查才发现,前阵子重构路由时把 URL 末尾的斜杠搞得一团糟。SEO 最忌讳的事情之一就是爬虫访问同一个资源可以通过 /about/about/ 两个地址,而这两个地址没有做 301 重定向,甚至在 sitemap.xml 里的路径跟页面中的 Canonical 标签指向不一致。结果就是搜索引擎认为你存在大量重复内容,直接惩罚性地把页面从索引库里干掉了。

大部分人写 Sitemap 都是顺手用一个第三方框架自带的插件。对于小型静态网站来说,插件往往只会机械地获取当前的系统时间作为 lastmod(最后修改时间)。一旦你在 CI/CD 流程里进行构建,所有的文件创建时间都会被重置为拉取代码的那一刻,这就导致每次部署都会向爬虫宣告:“我整个站的几千个页面全部更新了!”爬虫疯狂爬了一圈发现内容根本没变,久而久之它就不信任你的 lastmod,进而大幅度降低抓取频率。

为了彻底摆脱这个大坑,我直接写了一个 Node.js 脚本,在构建生成静态页面时运行。它做了两件事:第一,强制统一 URL 的斜杠后缀,避免 canonical URL 不一致;第二,通过 git log 直接读取每个 markdown 文件的真实提交记录,提取真正的最后修改日期作为 lastmod

import fs from 'fs';
import path from 'path';
import { execSync } from 'child_process';

const SITE_URL = 'https://example.com';
const CONTENT_DIR = path.resolve('./src/content/articles');
const OUTPUT_FILE = path.resolve('./public/sitemap.xml');

// 统一 URL 规范:强制添加末尾斜杠
function formatUrl(subPath) {
  const cleanPath = subPath.replace(/\/+$/, '').replace(/^\/+/, '');
  return cleanPath ? `${SITE_URL}/${cleanPath}/` : `${SITE_URL}/`;
}

// 获取 Git 文件最后一次提交的 ISO 时间戳
function getGitLastMod(filePath) {
  try {
    const gitDate = execSync(`git log -1 --format=%cI -- "${filePath}"`, { encoding: 'utf-8' }).trim();
    return gitDate ? gitDate.split('T')[0] : new Date().toISOString().split('T')[0];
  } catch (err) {
    // 降级处理,获取系统文件修改时间
    const stat = fs.statSync(filePath);
    return stat.mtime.toISOString().split('T')[0];
  }
}

function generateSitemap() {
  const files = fs.readdirSync(CONTENT_DIR).filter(file => file.endsWith('.md') || file.endsWith('.mdx'));
  
  let xmlItems = [];

  // 1. 添加首页
  xmlItems.push(`  <url>
    <loc>${formatUrl('')}</loc>
    <lastmod>${new Date().toISOString().split('T')[0]}</lastmod>
    <changefreq>daily</changefreq>
    <priority>1.0</priority>
  </url>`);

  // 2. 遍历内容目录,动态生成 URL 节点
  files.forEach(file => {
    const filePath = path.join(CONTENT_DIR, file);
    const slug = file.replace(/\.mdx?$/, '');
    const lastMod = getGitLastMod(filePath);
    const pageUrl = formatUrl(`articles/${slug}`);

    xmlItems.push(`  <url>
    <loc>${pageUrl}</loc>
    <lastmod>${lastMod}</lastmod>
    <changefreq>weekly</changefreq>
    <priority>0.8</priority>
  </url>`);
  });

  const sitemapXml = `<?xml version="1.0" encoding="UTF-8"?>
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
${xmlItems.join('\n')}
</urlset>`;

  fs.writeFileSync(OUTPUT_FILE, sitemapXml, 'utf-8');
  console.log(`[Success] Sitemap generated at ${OUTPUT_FILE}`);
}

generateSitemap();

如果你是在传统的 CMS 系统(比如基于 MySQL 的 WordPress 或 Strapi)里运行,那就更不能信任框架默认的 Sitemap 生成逻辑了。它们的做法通常是实时查一遍数据库把数据吐出来,每次爬虫请求都伴随着沉重的数据库查询,在高并发扫描下直接把数据库 CPU 拖垮到 100%。更好的做法是利用 Webhook 机制:只有在后台触发内容更新时,才异步构建一个静态的 sitemap.xml 到内存或者公共静态目录中,直接通过 Web 服务器(如 Nginx)直出。

把这套基于 Git 提交历史生成 lastmod 且统一后缀斜杠的方案上线两周后,Google 蜘蛛的抓取频率明显稳定了下来,Search Console 中的“已编入索引,未在站点地图中提交”的报错数量也成功清零。

你们的站点在配置 Sitemap 时是选择无脑信任框架插件,还是用过别的自定义生成脚本?有没有踩过 canonical tag 和 sitemap 冲突的坑?