你每周部署一次。sitemap 生成器在 build time 用 new Date().toISOString() 给 lastmod 盖戳。Google 看到的结果是:你站点上每个页面昨天都变过 —— 那就等于没一个真变过。这就是 sitemap 被忽略的教科书签名。Googlebot 对真正更新过的页面的重抓速率反而下降,因为 Google 没办法再用 lastmod 来排优先级。最坏的情况是”已发现 — 当前未编入索引”越堆越多。修法是让 lastmod 来自真实的内容变更时间,而不是 build time。
常见原因
按命中率排序。
1. lastmod 在 build time 用 new Date() 算出来
sitemap 插件或自研生成器每次 build 跑一次 new Date().toISOString(),把这个值塞给每一条。每周 build 一次 → 每个 URL 都”每周变一次”。
怎么判断:抓在线 sitemap,grep lastmod,所有值都在几秒内,并且匹配你上次部署的时间。
2. lastmod 取自文件 mtime,但 build 步骤会把每个文件重写一遍
build 前的某一步(格式化、转换、压缩)会原地重写所有内容文件,mtime 全部更新。生成器拿 mtime 当 lastmod。每次构建每个文件都看起来刚改过。
怎么判断:ls -lt src/content/ 显示所有文件都在最近一次 build 窗口内被改过,包括你几个月没碰过的。
3. lastmod 取自 CI 任务的时间戳
CI 用 --depth=1 或在某层 Docker 里 checkout,mtime 被重置。生成器拿不到 git commit 时间,fallback 到 “now”。
怎么判断:所有 lastmod 都落在一个 30 秒窗口内,每次部署是不同的统一值。
4. 源里 lastmod 是对的,被 CDN / 边缘 Worker 改写了
你从 frontmatter 正确生成了 lastmod,但边缘 Worker 重写 sitemap 响应(加缓存破坏字段、规范化格式)时把值覆盖了。
怎么判断:对比直连源站和走 CDN 拿到的 sitemap。lastmod 不一致就是它。
5. lastmod 取整到日,但每个条目落在同一天
生成器把时间戳”规范化”到 UTC 0 点。如果大部分编辑都集中在部署日的某个窗口,所有条目压成同一天,Google 会判定可疑。
怎么判断:所有 lastmod 是同一天的 00:00:00Z。
6. 新增的 URL 继承”现在”而不是真实发布日期
新文章加入 sitemap 时 lastmod 直接写今天,哪怕它其实是两年前发布的。同样的逻辑在每次回填跑批时错误地 bump 所有老 URL。
怎么判断:多年没动过的老归档 URL lastmod 是最近的。
开始前
- 改之前先快照一份当前 sitemap(或顶层索引)。修完用来对照。
- 看 Search Console → Sitemaps → 报告详情,是否有 “valid but warning” 之类信息 —— Google 有时会明确警告 lastmod 不可靠。
- 决定
lastmod的真相来源:通常是 frontmatter 的modifiedAt(缺失时 fallback 到publishedAt)。 - 如果你已经按 发布日期与 JSON-LD 日期不一致 审过日期,跟那里用同一个来源。
需要收集的信息
- 当前
lastmod值的分布:是不是大多数落在 5 分钟窗口内,还是跨越好几个月? - 输出 sitemap 的生成器代码或插件。
- 每种内容类型有哪些 frontmatter 字段可用(
publishedAt、modifiedAt、updatedAt)。 - Search Console “Crawl stats” 最近的趋势 —— 是不是新增内容多,但总抓取请求数反而降了?
- sitemap 是按内容类型拆分的,还是一份大单文件。
一步步修
按代价排序。
第 1 步:先看一眼当前 sitemap
curl -s https://example.com/sitemap.xml | \
grep -oE '<lastmod>[^<]+</lastmod>' | sort | uniq -c | sort -rn | head -10
最大那一组覆盖几千个 URL 且匹配上次部署时间 —— bug 坐实。
第 2 步:让 lastmod 来自真实内容日期
替换 build-time 生成逻辑:
// before
{ loc: url, lastmod: new Date().toISOString() }
// after
{
loc: url,
lastmod: (article.modifiedAt ?? article.publishedAt),
}
没有真实时间戳的,宁可整段不写 lastmod,也别造一个。
第 3 步:frontmatter 缺日期就从 git history 回填
git log --diff-filter=AM --follow --format=%aI -- "src/content/articles/en/${slug}.mdx" \
| head -1
这返回最近一次新增或修改这个文件的 commit 时间。对没有显式日期的文章拿来当 modifiedAt。
第 4 步:让 build 前的步骤不要乱动 mtime
如果有格式化工具每次 build 都把所有文件重写一遍,改成内存里转换,或者用内容 hash 守门:
const before = await readFile(path);
const after = format(before);
if (after !== before) await writeFile(path, after);
writeFile 只在真有变化时触发。mtime 不再无意义地翻动。
第 5 步:确认 CI 保留了 git 时间戳
如果你依赖 commit 日期,确保 CI clone 拿完整历史:
- uses: actions/checkout@v4
with:
fetch-depth: 0
然后让 sitemap 生成器读 commit 时间而不是 mtime。
第 6 步:检查边缘层有没有改写
diff <(curl -s https://origin.example.com/sitemap.xml) \
<(curl -s https://example.com/sitemap.xml)
lastmod 有差异,就是 CDN 或边缘 Worker 在改写。要么绕过,要么修掉。
第 7 步:重新提交并观察抓取响应
在 Search Console 重新提交 sitemap。2-3 周内观察 “Crawl stats” 曲线,看抓取是否倾向于真正变化的 URL。“已发现 — 当前未编入索引”对真正更新的页面应该慢慢减少。
验证
- Sitemap
lastmod的分布跨越周和月,有真实方差,不是每次部署一个统一值。 - 新文章的
lastmod匹配publishedAt,不是今天。 - Search Console → Crawl stats 显示真正编辑过的 URL 重抓被优先。
- 重要内容上的”已发现 — 当前未编入索引”开始回落。
- CMS 编辑动作在一个部署周期内能流到
lastmod上。
长期预防
- 团队范围明确:
lastmod反映真实内容变化,永远不取 build time。 - CI 断言:sitemap 里超过 30% 的条目共享同一个
lastmod分钟,构建失败。 lastmod来源跟驱动 JSON-LDdateModified的来源是同一个 —— 一个真相来源。- 真正不会变的静态归档页,宁可不写
lastmod,也别挂一个过期的值。 - 每季度审一次边缘 / CDN 的响应转换,盯住静默改写。
常见坑
- “保险起见”把
lastmod完全删掉。真实的lastmod是有用的优先级信号,只有在没靠谱来源时才不写。 - 改了导航或 footer 之后给每个页面 bump
lastmod。站点级模板改不是单页内容变化。 - 修个错别字就把
lastmod改成”现在”。琐碎编辑应该不动lastmod;跟你给dateModified定的”有意义的编辑”规则保持一致。 lastmod跟dateModified分别由两个独立生成器算 —— 一定会漂移;合并。- 一天内重交 5 次 sitemap “逼” Google 重抓。完全没效果,反而可能给 sitemap 上标记。
FAQ
Q:Google 真的会惩罚刷 lastmod 这种模式吗?
Google 公开说过,不可靠的 lastmod 值会被忽略不用于抓取优先级。“惩罚”就是当你没提供任何信号 —— 损失的是真正更新时的重抓机会。
Q:要给每个 URL 都写 lastmod 吗,还是只有变更过的写?
有可靠时间戳就写,没就别写。诚实的缺失好过编造的值。
Q:CMS 只跟踪”编辑日期”,够吗?
够,只要”编辑日期”只在真正编辑时变,不在自动保存或模板改动时变。一段时间不编辑然后看这个值有没有变,能验证。
Q:lastmod 精度应该到哪一级?日、小时、秒?
跟你的跟踪精度一致。内容站点到日(2026-05-24)够用。快变 feed 用到秒合适。别造你本没有的精度。