站点地图索引:按类型拆分与自动生成

为什么站点地图要拆成索引

小站不拆索引没问题,一个 sitemap.xml 塞进去几十条 URL 就完了。但当文章数超过几千、同时有标签页、分类页、日期归档页、图片时,单文件方案会同时踩三个限制:单文件最多 50000 条 URL、未压缩体积不能超过 50MB,以及更重要的一点——搜索引擎抓取一个文件的资源是有限的,把未收录的低价值页面混在高价值页面里,会拖累整份文件的处理效率。

站点地图索引(sitemap index)就是解决这个问题的:索引文件本身只列「有哪些子地图」,每个子地图按类型分别组织。它不是什么高级技巧,但对收录慢的站来说,往往是「提交了但没反应」和「三天内收录」之间的差别。下面讲具体怎么组织、怎么生成、以及最容易出错的几个地方。

注意一个前提:站点地图不是收录的保证,它只是告知。如果你的页面本身没有内链、内容重复、或者被 robots.txt 挡住,再精细的地图也没用。先确保「能被抓到」,再谈「怎么告诉它」。

按类型拆分,而不是按数量硬切

最常见的错误做法是每 5000 条切一个文件,叫 sitemap1.xml、sitemap2.xml。这样切虽然合规,但丢失了最重要的信息:类型。搜索引擎在索引层是按子地图整体评估的,文章页和标签页混在一起,等于让低价值页面共享了文章页的权重。

推荐的组织方式是按内容类型,每类一个子地图,每类内部再按数量分片:

https://example.com/sitemap.xml                 ← 索引文件
https://example.com/sitemap-posts-1.xml         ← 文章,按 cid 倒序,每片 5000
https://example.com/sitemap-posts-2.xml
https://example.com/sitemap-categories.xml      ← 分类归档(数量少,单片)
https://example.com/sitemap-tags.xml            ← 标签,只放文章数 >= 3 的
https://example.com/sitemap-images.xml          ← 图片(需要 image 命名空间)

索引文件的结构必须严格遵循协议,标签顺序不能乱,这是最容易出现「格式对了但被忽略」的地方:

<?xml version="1.0" encoding="UTF-8"?>
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
  <sitemap>
    <loc>https://example.com/sitemap-posts-1.xml</loc>
    <lastmod>2026-10-28T03:00:00+00:00</lastmod>
  </sitemap>
  <sitemap>
    <loc>https://example.com/sitemap-categories.xml</loc>
    <lastmod>2026-10-27T12:00:00+00:00</lastmod>
  </sitemap>
</sitemapindex>

要点:sitemapindex 里每个 <sitemap> 只能有 loc 和 lastmod,绝不能塞 priority 或 changefreq——这两个标签只在 URL 集合里有效,混进索引会被判格式错误。另外索引文件本身最多容纳 50000 个子地图,实际用到几十个就该考虑是不是切太碎了。

lastmod 应该说实话

lastmod 是地图里唯一被搜索引擎普遍采信的字段,而它也最容易被造假:很多程序每次生成就把所有 URL 的 lastmod 都写成当前时间。这么做的后果不是「被抓得更勤」,而是搜索引擎发现 lastmod 与实际内容变更不符之后,彻底不再采信这个字段,你就失去了唯一一个有效的信号。

正确做法是从数据本身取真实修改时间。以 Typecho 为例,取 modified 字段(没改过就是 created):

$db = new PDO('mysql:host=127.0.0.1;dbname=blog;charset=utf8mb4', 'user', 'pass');
$q = $db->query("SELECT cid, slug, modified, created, type
                 FROM typecho_contents
                 WHERE type='post' AND status='publish'
                 ORDER BY cid DESC");
foreach ($q as $r) {
    $lm = $r['modified'] > $r['created'] ? $r['modified'] : $r['created'];
    printf("<url><loc>https://example.com/%d.html</loc>"
         . "<lastmod>%s</lastmod></url>\n",
        $r['cid'], gmdate('c', $lm));
}

还有一点:lastmod 时区必须带偏移(+00:00 或 Z),只写 2026-10-28 只有日期也合规,但精度不够,一天内多次更新就看不出差别。用 W3C Datetime 格式最保险。

顺便说一句:不要给同一个 URL 反复改 lastmod 来「刷新关注度」。同一个 URL 在短时间内被标记为多次修改而内容无实际变化,长期看只会降低可信度。改样式、改广告位这类对读者内容无实质影响的改动,不值得更新 lastmod。

标签与分类页要不要进地图

这是站长最纠结的一条。结论是:有独立价值、且内容不重复的归档页可以进;只有一个条目的标签页坚决不进。

理由很直接:一个标签下只有一篇文章的归档页,与那篇文章本身几乎完全重复。大量这种页面进地图,会让搜索引擎判断为低质量聚合页,连带影响整份文件里其它 URL 的抓取预算。所以生成标签地图时加一个 HAVING COUNT(*) >= 3 的过滤,是最简单有效的做法:

SELECT m.name, m.slug, COUNT(c.cid) AS n, MAX(c.modified) AS lm
FROM typecho_metas m
JOIN typecho_relationships r ON r.mid = m.mid
JOIN typecho_contents c ON c.cid = r.cid
WHERE m.type = 'tag' AND c.type = 'post' AND c.status = 'publish'
GROUP BY m.mid
HAVING n >= 3
ORDER BY n DESC;

分类页(type=category)一般条目都多,可以直接全放。但如果你的分类页只有列表没有独立描述文字,价值也有限,那就只放文章数最多的前几个,其余的靠内链被发现就够了。

还有一类页面必须排除:搜索页、分页的第 2 页之后、带 ?replyTo= 之类参数的评论跳转页、以及任何会 302 跳走的 URL。地图里出现跳转或参数页,是「格式对但没效果」的最常见原因。

分页地图:不能只放前 5000 条

分片时最容易漏的一件事:分片之间的 URL 要不重不漏,而且分片数量要随内容增长自动变化。手工切分的代码往往写死了 LIMIT 5000,文章到 6000 篇时后 1000 篇就永远进不了地图。用 OFFSET 分页也会有问题——数据量大的时候越来越慢。

按主键游标分片是最干净的方式,每片独立生成,索引文件每次重新计算总数:

<?php
// sitemap-posts-1.xml?page=3  风格的实现
$perPage = 5000;
$page = max(1, (int)($_GET['page'] ?? 1));
$offset = ($page - 1) * $perPage;
$stmt = $db->prepare("SELECT cid, modified, created FROM typecho_contents
                      WHERE type='post' AND status='publish'
                      ORDER BY cid DESC LIMIT :lim OFFSET :off");
$stmt->bindValue(':lim', $perPage, PDO::PARAM_INT);
$stmt->bindValue(':off', $offset, PDO::PARAM_INT);
$stmt->execute();

索引文件里列出的是静态化的 sitemap-posts-1.xml、-2、-3,所以每次发布后要重新生成索引,把新增的分片补进去。这一步必须放在发布流程里自动执行,靠人工记得加必然忘。

还有一个隐藏坑:自定义 URL 使用 ?page= 参数的分片地图,搜索引擎也能读,但静态文件名更好。另外确认 Nginx 对 .xml 不做压缩以外的改写,别让 CDN 把旧的 sitemap-posts-1.xml 缓存一个月。

gzip 与 Content-Type:两个技术性失分点

协议允许 sitemap.xml.gz,但限制是:解压后不能超过 50MB,压缩文件本身不能超过 50MB,且一个 gz 文件里只能有一个 sitemap。如果你的站点每月上百万 URL,这一层是有意义的;几千条的文章,不压缩也行,反而省得踩编码坑。

更常见的问题是服务器返回的文件头不对。用 curl 检查,两个字段必须都对:

curl -sI https://example.com/sitemap.xml | grep -iE 'content-type|content-encoding'
# 期望:Content-Type: application/xml; charset=utf-8
# 若不是 .gz:不应有 Content-Encoding: gzip(除非你的服务器动态压缩且客户端声明支持)

如果返回 text/html,说明请求被伪静态规则重写到了首页或 404 页面——地图文件变成了 HTML,搜索引擎会直接报「格式错误」。这类问题的典型原因是 Nginx 的 try_files 或 PHP 框架的全捕获路由把 .xml 也吃掉了。修法是在路由前显式放行:

location ~* \.xml(\.gz)?$ {
    add_header Cache-Control "public, max-age=3600";
    try_files $uri =404;
}
location = /sitemap.xml {
    fastcgi_pass unix:/run/php/php8.1-fpm.sock;
    include fastcgi_params;
    fastcgi_param SCRIPT_FILENAME /var/www/html/sitemap.php;
}

注意 Cache-Control 别设太长。地图需要反映新内容,设一天还行,设一个月就等于自己关掉了新文章的通知渠道。

提交与验证:不要只看「提交成功」

提交到 Google Search Console 和百度搜索资源平台之后,真正的验证点不是「提交成功」的绿色提示,而是三个数字:已发现 URL 数、已抓取、已编入索引。很多人只看第一个,看到「已发现 5000」就以为完成,其实抓取可能长期停在几十。

提交前的自查用最小成本就能做完:

  • xmllint --noout sitemap.xml 确认 XML 合法(很多手拼字符串的代码少个闭合标签);
  • 检查索引里每个子地图 URL 都返回 200 且 Content-Type 正确;
  • 确认 robots.txt 里有 Sitemap: https://example.com/sitemap.xml 指向索引而不是旧文件;
  • 确认子地图里没有 404、没有重定向、没有 noindex 的页面(矛盾的信号会让整份被降权)。
# 一键检查子地图里所有 URL 的状态码
curl -s https://example.com/sitemap-posts-1.xml \
  | grep -oE '<loc>[^<]+' | sed 's/<loc>//' \
  | while read -r u; do
      printf '%s %s\n' "$(curl -s -o /dev/null -w '%{http_code}' "$u")" "$u"
    done | sort | uniq -c -w3

如果输出里出现大量 301 或者 404,先修页面再谈提交。一个常见情况是地图里写的是 http:// 而站点已经全站 301 到 https://,这种「全部重定向」的地图是白提交的。

把地图生成放进发布流程

站点地图最大的敌人不是技术难度,是「发完文章忘了更新」。所以最后这一步比前面所有细节都重要:生成必须自动化,且必须可验证。

建议的顺序是:文章入库 → 触发地图生成 → 生成后校验 XML 合法性与 URL 数量 → 只有校验通过才通知搜索引擎(IndexNow 或站长平台的 API 推送)。任何一步失败就报警,别静默通过。

#!/bin/bash
set -euo pipefail
cd /var/www/html
php bin/gen_sitemap.php > /tmp/sitemap.xml
xmllint --noout /tmp/sitemap.xml
n=$(grep -c '<url>' /tmp/sitemap.xml)
[ "$n" -gt 0 ] || { echo "sitemap empty"; exit 1; }
mv /tmp/sitemap.xml sitemap-posts-1.xml
php bin/gen_index.php
xmllint --noout sitemap.xml
logger -t sitemap "regenerated, posts=$n"
# 可选:IndexNow 推送本次变更的 URL

把这段挂到发布后的钩子上,只在内存里生成再原子替换(mv 是原子的),避免搜索引擎恰好在你写文件的中途来抓、拿到半份地图。这个细节很小,但配上 CDN 的负缓存,一次半份地图可能就换来几天「无法解析」的状态。

最后总结一下优先级:先保证地图里的 URL 都能正常访问、无重复、无重定向;再按类型拆分让高价值页面单独成册;最后才是 lastmod 准确与自动提交。顺序反了,先折腾扩展字段,收录照样不动。

Last modification:October 28th, 2026 at 12:24 pm

Leave a Comment