HaoWordTool

站点地图 · Case 03

少一个斜杠,189 条 URL 一条都没送出去

2026-10-25 实测 · 对象 haowordtool.com/sitemap-index.xml

这是我知道的最难自查的一类故障:页面全部 200,站内导航正常,用户访问没有任何异常,唯一的症状是「提交了站点地图,但没搜到任何网址」。而这句话只在搜索引擎的后台里能看到。

现象:线上文件长这样

<sitemap><loc>https://haowordtool.comsitemap.xml</loc></sitemap>
<sitemap><loc>https://haowordtool.comde/sitemap.xml</loc></sitemap>
<sitemap><loc>https://haowordtool.comja/sitemap.xml</loc></sitemap>

六条 <loc>,全部缺一个斜杠。https://haowordtool.com 直接拼上了 sitemap.xml,域名和文件名连在一起,成了六个不存在的地址。

为什么没被发现

因为它不产生任何错误。请求 /sitemap-index.xml 返回 200,内容是合法 XML,格式完全正确——只是里面的 URL 打不开。整条链路里没有任何环节会报错:

搜索引擎那边看到的是「索引文件里六个子图都取不到」,最终表现为「已提交,未发现任何网址」。这个描述不会指向「你的 URL 拼错了」,因为从它那一侧看,只是一个取不到的资源。

根因:两段字符串拼接,谁都没加斜杠

const SITE = 'https://haowordtool.com';   // 结尾没有 /
`${SITE}${p}`                             // p 开头也没有 /

这种 bug 的特征是:写的时候两边看起来都对,跑起来也不报错,只有把输出打印出来才看得见。而且它藏在生成器里,手改 sitemap 文件没用——下次生成会覆盖回去。

修复:统一拼接函数 + 落盘前硬校验

改法不是加一个斜杠,而是让「拼绝对 URL」只有一个入口:

const abs = (p) => `${SITE}/${String(p).replace(/^\/+/, '')}`;

所有生成 loc 的地方都走它,包括子 sitemap 列表和索引文件。然后加一道落盘前校验:任何一条 loc 不以 https://haowordtool.com/ 开头,直接抛错,文件不写。

校验逻辑本身也要有:

function assertLocs(xml, where) {
  const bad = [...xml.matchAll(/<loc>([^<]*)<\/loc>/g)]
    .map(m => m[1])
    .filter(u => !u.startsWith('https://haowordtool.com/'));
  if (bad.length) throw new Error(`${where}: ${bad.length} 条 loc 非法,例如 ${bad[0]}`);
  return count;
}

更深的一层:谁有权写这个文件

排查时发现有两个脚本都在写 sitemap.xml:一个按页面状态生成,一个在「SEO 优化」流程里顺手重写。两者输出的条目集不同,互相覆盖。表现是 CI 的 --check 永远报 pending——因为它比对的基准一直在被另一个脚本改。

处理方式是收权:build-sitemap 独占 sitemap 的写入,负责主站 + 各语种 + 索引三件套;另一个脚本只改 head 信号,不再碰 sitemap。「哪个脚本负责哪个产物」必须明确到单点,否则两个都对的实现会互相破坏。

顺带去掉 lastmod

之前每个 URL 都带 <lastmod>,值取文件修改时间。问题在于 CI 每次检出,文件时间都会变——于是每天生成的 sitemap 都在告诉搜索引擎「全站 189 个页面今天全更新了」。这属于制造信号,不如省略:没有 lastmod 是合法的,虚假的 lastmod 会稀释可信度。

让这类 bug 进不了主干

修一次不够,同类问题还会再发生。所以加了两道 CI 检查:

  1. 结构校验:sitemap-index.xml 引用的每个子 sitemap 必须存在,每条 loc 必须是合法绝对 URL。
  2. 一致性校验:磁盘上的 sitemap 必须与页面实际状态完全一致(哪些页面可索引、canonical 是否自指),不一致就失败。

第二道的价值在于它不是「格式对不对」,而是「内容和现实是否同步」。sitemap 最容易腐坏的方式不是写错,而是页面加了 noindex、改了 canonical 之后它没跟着变。

复验与一个操作细节

修完线上逐条跟随验证:

子图状态条数
/sitemap.xml200149
/de/sitemap.xml20013
/es/sitemap.xml20014
/ja/sitemap.xml20011
/fr/sitemap.xml2001
/zh/sitemap.xml2001

操作细节:推送后立刻请求会拿到上一版。这次前四次探测全是 404,第五次才生效,窗口约 40 秒。验收一律带 ?cb=$RANDOM 之类的破缓存参数,否则会把「还在部署」误判成「没上线」——然后再去排查一个不存在的故障。

这条最值得记:凡是「由脚本生成的线上文件」,都必须有一条独立校验能在 CI 里跑。不能靠人肉 curl,因为没人会每天去 curl 一个 sitemap-index——它坏了三个月也不会有人发现,直到收录数据对不上。