AI 爬虫的抓取预算从哪来:Crawl Budget 机制与 sitemap index 分片实践
适用读者:维护外贸独立站的 SEO 工程师、管理大中型站点的运维、负责生成式引擎优化(Generative Engine Optimization, GEO)落地的技术负责人。
凌晨一点看 access log 是接手外贸站的例行动作,那天记下的数字很扎眼:GPTBot 全天抓了 3127 页,而站点一共有 8.2 万个 URL。ClaudeBot 更少,一千出头;Google-Extended 基本只碰产品列表页。新品页发布两周后才被 AI 引擎引用,运营已经催过两轮。
痛点就在这里:站点不缺内容,缺的是爬虫每天分给这个站的抓取预算(Crawl Budget)。 8.2 万个 URL 对三千多的日抓取量,全站刷一遍要一个多月,新品页天然排在队列尾端。这篇文章把预算的来源讲清楚,再给出我们实际的改法------sitemap index 按内容类型分片,配合 lastmod 真实化,让爬虫把预算花在新页面上。
一、先看日志:预算被谁吃掉了
接手第一周,我们没有动任何配置,先把 30 天的 access log 按 User-Agent 和路径聚合了一遍。结论很直观:
- 老款的 SKU 页(约 6.8 万条,半年内内容几乎没变)吃掉了六成以上的抓取次数;
- 翻页、筛选参数这类带 query 的组合 URL 被反复抓取,去重后有效页面不足 4000;
- 新品目录下两周内新发布的页面,日均抓取只有 140 多次。
同时我们检查了当时的 sitemap:一个 8.2 万条的单文件,每周五全量重新生成,所有条目的 lastmod 都被写成生成当天的时间戳。等于每周对爬虫喊一遍"全部都更新了",但内容实际没动。这就是预算错配的根源------爬虫拿不到区分新旧页面的信号,只能按自己的优先级评分慢慢爬。
二、原理剖析:调度器怎么分配抓取预算
各家的实现细节不同,但调度框架大同小异,可以拆成四步。
第一步是 URL 发现。 入口有三类:sitemap、站内链接、外部链接。sitemap 是三类入口里仅有的由站点主动声明"这些 URL 存在"的通道,所以它的组织方式直接决定爬虫的第一印象。RFC 9309(robots.txt 协议)本身不定义 sitemap,但允许在 robots.txt 里用 Sitemap: 行声明位置,多数爬虫都认这个声明。
第二步是重要性评分。 爬虫给每个已知 URL 打分,信号通常包括:站内链接指向数、距首页的点击深度、外部链接质量、以及该 URL 历史上的更新频率。一个被首页大改版后三天就被遗弃到五层深度的页面,和一个挂在新品板块、有列表页和详情页双向内链的页面,分数完全不是一个量级。
第三步是排队列。 抓取频率大致等于"单 URL 重要性 × 站点整体信誉"。站点信誉来自历史抓取的表现:响应速度、错误率、是否老实遵守 robots 与重试节奏。一个持续返回 5xx、或者 lastmod 说更新了实际内容没变的站点,信誉分会被拉低,全站日抓取量跟着缩水。
第四步才是执行抓取。 队列按分数出队,同时受并发上限和当前预算约束。
在这个模型里,单个巨型 sitemap 有两个隐蔽的害处。其一,8.2 万条不分层的列表,对新爬虫来说信号密度极低------它无法从中区分"该优先"和"可缓缓",只能退化成按自身评分均匀分配,结果就是新品页和老 SKU 页抢同一份预算。其二,单文件接近 50MB 上限时,部分爬虫会主动降低拉取频率,甚至只取文件前段,排后面的条目实际等于不可见。
lastmod 造假为什么会被降权?爬虫会做历史校验:某 URL 的 lastmod 说三天前更新过,抓回来和上一次的正文指纹一致,这个"假信号"就被记一笔。累计几次后,站点 lastmod 的整体可信度下降,后续就算某天真的更新了,爬虫也可能按低优先级处理。更新信号一旦失信,比没有信号更糟。
下面这张图是调度循环的简化版:
#mermaid-svg-Hkhmxkhd1j6QrnST{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-Hkhmxkhd1j6QrnST .error-icon{fill:#552222;}#mermaid-svg-Hkhmxkhd1j6QrnST .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-Hkhmxkhd1j6QrnST .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-Hkhmxkhd1j6QrnST .marker{fill:#333333;stroke:#333333;}#mermaid-svg-Hkhmxkhd1j6QrnST .marker.cross{stroke:#333333;}#mermaid-svg-Hkhmxkhd1j6QrnST svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-Hkhmxkhd1j6QrnST p{margin:0;}#mermaid-svg-Hkhmxkhd1j6QrnST .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST .cluster-label text{fill:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST .cluster-label span{color:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST .cluster-label span p{background-color:transparent;}#mermaid-svg-Hkhmxkhd1j6QrnST .label text,#mermaid-svg-Hkhmxkhd1j6QrnST span{fill:#333;color:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST .node rect,#mermaid-svg-Hkhmxkhd1j6QrnST .node circle,#mermaid-svg-Hkhmxkhd1j6QrnST .node ellipse,#mermaid-svg-Hkhmxkhd1j6QrnST .node polygon,#mermaid-svg-Hkhmxkhd1j6QrnST .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-Hkhmxkhd1j6QrnST .rough-node .label text,#mermaid-svg-Hkhmxkhd1j6QrnST .node .label text,#mermaid-svg-Hkhmxkhd1j6QrnST .image-shape .label,#mermaid-svg-Hkhmxkhd1j6QrnST .icon-shape .label{text-anchor:middle;}#mermaid-svg-Hkhmxkhd1j6QrnST .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-Hkhmxkhd1j6QrnST .rough-node .label,#mermaid-svg-Hkhmxkhd1j6QrnST .node .label,#mermaid-svg-Hkhmxkhd1j6QrnST .image-shape .label,#mermaid-svg-Hkhmxkhd1j6QrnST .icon-shape .label{text-align:center;}#mermaid-svg-Hkhmxkhd1j6QrnST .node.clickable{cursor:pointer;}#mermaid-svg-Hkhmxkhd1j6QrnST .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-Hkhmxkhd1j6QrnST .arrowheadPath{fill:#333333;}#mermaid-svg-Hkhmxkhd1j6QrnST .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-Hkhmxkhd1j6QrnST .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-Hkhmxkhd1j6QrnST .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hkhmxkhd1j6QrnST .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-Hkhmxkhd1j6QrnST .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hkhmxkhd1j6QrnST .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-Hkhmxkhd1j6QrnST .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-Hkhmxkhd1j6QrnST .cluster text{fill:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST .cluster span{color:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-Hkhmxkhd1j6QrnST .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-Hkhmxkhd1j6QrnST rect.text{fill:none;stroke-width:0;}#mermaid-svg-Hkhmxkhd1j6QrnST .icon-shape,#mermaid-svg-Hkhmxkhd1j6QrnST .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-Hkhmxkhd1j6QrnST .icon-shape p,#mermaid-svg-Hkhmxkhd1j6QrnST .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-Hkhmxkhd1j6QrnST .icon-shape .label rect,#mermaid-svg-Hkhmxkhd1j6QrnST .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-Hkhmxkhd1j6QrnST .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-Hkhmxkhd1j6QrnST .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-Hkhmxkhd1j6QrnST :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} 是
否
URL 发现
sitemap / 内链 / 外链
合并去重
进入 URL 库
重要性评分
内链数 · 链接深度
外链 · 历史更新频率
优先级队列
站点信誉
响应质量 · 信号可信度
今日预算
还有余量?
出队抓取
校验 lastmod 与内容指纹
更新频率画像
信誉加减分
次日继续
队列保留
三、分片方案:sitemap index 按内容类型拆
明确了机制,改法就顺理成章:让 sitemap 自己携带优先级信息。 我们把单文件拆成四个分片,按内容类型各一片,新品页单独一片是整个方案的关键------它给爬虫一个"这部分更新最频繁、最值得常来"的明确信号。
分片结构如下:
sitemap-new.xml:新品页,约 2400 条,发布后实时追加;sitemap-products.xml:存量 SKU 页,约 6.8 万条,拆成多个物理文件由 index 汇总;sitemap-news.xml:资讯页,约 5200 条,每日更新;sitemap-help.xml:帮助文档,约 6400 条,仅在文档修订时更新。
顶层是一个 sitemap index 文件。按 sitemaps.org 协议,index 文件本身不超过 5 万个条目,指向的分片文件同样受"单文件 5 万条、50MB"限制,所以 SKU 那片在 index 里直接列了两个物理文件。
xml
<?xml version="1.0" encoding="UTF-8"?>
<!-- sitemap index:只放分片入口,不放具体 URL -->
<!-- 协议上限:每个分片 5 万条、50MB,index 本身不超过 5 万个条目 -->
<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
<!-- index 内条目数远低于 5 万上限,无需再分层 -->
<!-- 新品页单独一片:更新最频繁,让爬虫形成高频回访习惯 -->
<sitemap>
<loc>https://example.com/sitemap-new.xml</loc>
<lastmod>2026-09-18T03:12:00+08:00</lastmod>
</sitemap>
<!-- 存量 SKU 超过 5 万条,拆成两个物理文件 -->
<sitemap>
<loc>https://example.com/sitemap-products-1.xml</loc>
<lastmod>2026-09-15T08:00:00+08:00</lastmod>
</sitemap>
<!-- SKU 两片的 lastmod 取各自分片内真实最新变更时间 -->
<sitemap>
<loc>https://example.com/sitemap-products-2.xml</loc>
<lastmod>2026-09-15T08:00:00+08:00</lastmod>
</sitemap>
<!-- 资讯页每日增量更新 -->
<sitemap>
<loc>https://example.com/sitemap-news.xml</loc>
<lastmod>2026-09-17T22:40:00+08:00</lastmod>
</sitemap>
<!-- 帮助文档只在修订时刷新 lastmod -->
<sitemap>
<loc>https://example.com/sitemap-help.xml</loc>
<lastmod>2026-09-10T14:00:00+08:00</lastmod>
</sitemap>
</sitemapindex>
robots.txt 里补一行声明(依赖:无,纯静态文件;协议依据 RFC 9309 的 Sitemap 行,路径不限):
text
User-agent: *
Disallow: /cart/
Disallow: /*?sort=
# Sitemap 行允许重复,可按爬虫分组声明,这里放全局入口
# Disallow 规则按协议匹配路径前缀,* 为通配符
Sitemap: https://example.com/sitemap-index.xml
分片之后的架构关系:
#mermaid-svg-DSvN0ulpEApDObtw{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-DSvN0ulpEApDObtw .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-DSvN0ulpEApDObtw .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-DSvN0ulpEApDObtw .error-icon{fill:#552222;}#mermaid-svg-DSvN0ulpEApDObtw .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-DSvN0ulpEApDObtw .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-DSvN0ulpEApDObtw .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-DSvN0ulpEApDObtw .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-DSvN0ulpEApDObtw .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-DSvN0ulpEApDObtw .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-DSvN0ulpEApDObtw .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-DSvN0ulpEApDObtw .marker{fill:#333333;stroke:#333333;}#mermaid-svg-DSvN0ulpEApDObtw .marker.cross{stroke:#333333;}#mermaid-svg-DSvN0ulpEApDObtw svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-DSvN0ulpEApDObtw p{margin:0;}#mermaid-svg-DSvN0ulpEApDObtw .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-DSvN0ulpEApDObtw .cluster-label text{fill:#333;}#mermaid-svg-DSvN0ulpEApDObtw .cluster-label span{color:#333;}#mermaid-svg-DSvN0ulpEApDObtw .cluster-label span p{background-color:transparent;}#mermaid-svg-DSvN0ulpEApDObtw .label text,#mermaid-svg-DSvN0ulpEApDObtw span{fill:#333;color:#333;}#mermaid-svg-DSvN0ulpEApDObtw .node rect,#mermaid-svg-DSvN0ulpEApDObtw .node circle,#mermaid-svg-DSvN0ulpEApDObtw .node ellipse,#mermaid-svg-DSvN0ulpEApDObtw .node polygon,#mermaid-svg-DSvN0ulpEApDObtw .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-DSvN0ulpEApDObtw .rough-node .label text,#mermaid-svg-DSvN0ulpEApDObtw .node .label text,#mermaid-svg-DSvN0ulpEApDObtw .image-shape .label,#mermaid-svg-DSvN0ulpEApDObtw .icon-shape .label{text-anchor:middle;}#mermaid-svg-DSvN0ulpEApDObtw .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-DSvN0ulpEApDObtw .rough-node .label,#mermaid-svg-DSvN0ulpEApDObtw .node .label,#mermaid-svg-DSvN0ulpEApDObtw .image-shape .label,#mermaid-svg-DSvN0ulpEApDObtw .icon-shape .label{text-align:center;}#mermaid-svg-DSvN0ulpEApDObtw .node.clickable{cursor:pointer;}#mermaid-svg-DSvN0ulpEApDObtw .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-DSvN0ulpEApDObtw .arrowheadPath{fill:#333333;}#mermaid-svg-DSvN0ulpEApDObtw .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-DSvN0ulpEApDObtw .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-DSvN0ulpEApDObtw .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DSvN0ulpEApDObtw .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-DSvN0ulpEApDObtw .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DSvN0ulpEApDObtw .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-DSvN0ulpEApDObtw .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-DSvN0ulpEApDObtw .cluster text{fill:#333;}#mermaid-svg-DSvN0ulpEApDObtw .cluster span{color:#333;}#mermaid-svg-DSvN0ulpEApDObtw div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-DSvN0ulpEApDObtw .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-DSvN0ulpEApDObtw rect.text{fill:none;stroke-width:0;}#mermaid-svg-DSvN0ulpEApDObtw .icon-shape,#mermaid-svg-DSvN0ulpEApDObtw .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-DSvN0ulpEApDObtw .icon-shape p,#mermaid-svg-DSvN0ulpEApDObtw .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-DSvN0ulpEApDObtw .icon-shape .label rect,#mermaid-svg-DSvN0ulpEApDObtw .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-DSvN0ulpEApDObtw .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-DSvN0ulpEApDObtw .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-DSvN0ulpEApDObtw :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} robots.txt
声明 Sitemap 行
sitemap-index.xml
sitemap index 顶层文件
sitemap-new.xml
新品页 · 实时追加
sitemap-products-1.xml
SKU 前半
sitemap-products-2.xml
SKU 后半
sitemap-news.xml
资讯页 · 每日增量
sitemap-help.xml
帮助文档 · 修订才更新
爬虫调度器
按分片更新频率
分配抓取预算
四、lastmod 真实化与生成脚本
分片只是骨架,lastmod 真实化才是让信号可信的血肉。我们的规则定得很死:lastmod 只在正文、价格、库存状态三类字段之一变更时刷新,模板改版、样式调整一律不算;时间取自数据库的变更时间戳,带时区,不用生成本次文件的当前时间。
生成脚本的依赖与环境:Python 3.10+,仅标准库(pathlib、json、datetime、xml.sax.saxutils),数据源是一份导出的 JSONL,每行含 url 和 lastmod 两个真实字段。脚本按 4 万条一片切分并输出 index:
python
# -*- coding: utf-8 -*-
# 依赖:Python 3.10+,仅标准库,无需安装任何第三方包
# 数据源:urls.jsonl,每行 {"url": "...", "lastmod": "ISO8601 带时区"}
# lastmod 必须来自真实变更时间戳,禁止用脚本运行时间填充
import json
# sys 只用来向 stderr 打印告警和读取命令行参数
import sys
from datetime import datetime, timezone
from pathlib import Path
from xml.sax.saxutils import escape
# 单分片条目上限设 4 万,低于协议的 5 万留出安全余量
CHUNK_SIZE = 40_000
# 分片前缀与站点根,按实际部署修改
SITE_ROOT = "https://example.com"
OUTPUT_DIR = Path("dist")
def load_entries(path: Path) -> list[dict]:
# 逐行解析 JSONL,跳过缺字段或时间不合法的行
# 返回的列表保持文件原有顺序,不做排序
entries = []
for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
# 空行直接跳过
if not raw.strip():
continue
try:
item = json.loads(raw)
# 校验 lastmod 可解析,非法时间戳宁可丢弃也不让假信号上线
# fromisoformat 在 3.11 起支持 Z 后缀,3.10 需自行替换
datetime.fromisoformat(item["lastmod"].replace("Z", "+00:00"))
entries.append({"url": item["url"], "lastmod": item["lastmod"]})
except (KeyError, ValueError, json.JSONDecodeError):
print(f"skip line {line_no}: bad record", file=sys.stderr)
return entries
def write_chunk(entries: list[dict], filename: str) -> None:
# 写单个分片文件,逐条拼接 url 节点
# 不用 ElementTree,是为了控制缩进与换行格式便于 diff 审查
lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for e in entries:
# loc 需做 XML 转义,& 等字符在 URL 里很常见
# lastmod 原样输出,调用方保证已是 ISO8601 格式
lines.append(" <url>")
lines.append(f" <loc>{escape(e['url'])}</loc>")
lines.append(f" <lastmod>{e['lastmod']}</lastmod>")
lines.append(" </url>")
lines.append("</urlset>")
# 输出文件带 BOM 反而兼容性差,统一 utf-8 无 BOM
(OUTPUT_DIR / filename).write_text("\n".join(lines), encoding="utf-8")
def main(source: Path) -> None:
# 输出目录不存在则创建,已存在则复用
OUTPUT_DIR.mkdir(exist_ok=True)
entries = load_entries(source)
chunk_names = []
# 按 CHUNK_SIZE 切片,逐片落盘
for i in range(0, len(entries), CHUNK_SIZE):
name = f"sitemap-{i // CHUNK_SIZE + 1}.xml"
write_chunk(entries[i : i + CHUNK_SIZE], name)
chunk_names.append(name)
# 生成 sitemap index,lastmod 取各分片内最新一条的真实值
latest = max((e["lastmod"] for e in entries), default=None)
# 空数据源时 latest 为 None,index 只列分片不写 lastmod
index_lines = [
'<?xml version="1.0" encoding="UTF-8"?>',
'<sitemapindex xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">',
]
for name in chunk_names:
# 每个分片占一个 sitemap 节点,loc 必须是可公开访问的完整地址
index_lines.append(" <sitemap>")
index_lines.append(f" <loc>{SITE_ROOT}/{name}</loc>")
# 分片级 lastmod 只在分片内容变化时变化,避免整体信号失真
if latest:
index_lines.append(f" <lastmod>{latest}</lastmod>")
index_lines.append(" </sitemap>")
index_lines.append("</sitemapindex>")
# index 文件同样用 utf-8 无 BOM 写盘
(OUTPUT_DIR / "sitemap-index.xml").write_text("\n".join(index_lines), encoding="utf-8")
# 打印分片统计,方便接入发布流水线做断言
print(f"{len(chunk_names)} chunks, {len(entries)} urls")
if __name__ == "__main__":
# 用法:python build_sitemap.py urls.jsonl
# 参数缺失时抛 IndexError,属预期行为,不额外做容错
main(Path(sys.argv[1]))
上线时把这个脚本挂进发布流水线:新品页入库即触发 sitemap-new.xml 重建,其余分片走每日定时任务。
五、分片前后的数据对比
方案是第 3 周上线 sitemap 分片,第 4 周完成 lastmod 真实化清理,之后连续观察四周取平均。预算总量变化不大------爬虫没有因为整改立刻加量,但分配方式完全变了:
| 内容类型 | URL 数量 | 分片前日均抓取 | 分片前抓取深度 | 分片后日均抓取 | 分片后抓取深度 |
|---|---|---|---|---|---|
| 新品页 | 约 2400 | 145 | 6% | 985 | 41% |
| 存量 SKU 页 | 约 68000 | 1780 | 2.6% | 1520 | 2.2% |
| 资讯页 | 约 5200 | 310 | 6.0% | 640 | 12.3% |
| 帮助文档 | 约 6400 | 892 | 13.9% | 730 | 11.4% |
| 合计 | 约 82000 | 3127 | --- | 3875 | --- |
三个变化值得说透。新品页日均抓取从 145 涨到 985,深度从 6% 到 41%,新品页进索引的时间从两周缩到三天以内。SKU 页抓取量下降是主动设计的预期结果:lastmod 不再乱刷新,爬虫学到这些页面低频变更,自然减少回访,省下的预算流向了新品。总量从 3127 涨到 3875,涨幅两成出头,更多来自爬虫对站点信誉的回补------假 lastmod 清掉之后,信号可信度恢复。
顺带整理了我们排查中见过的 sitemap 典型错误,每一条都在真实日志里对应过后果:
| 常见错误 | 直接后果 |
|---|---|
| 单文件超过 5 万条或 50MB | 超出部分被截断丢弃,排尾部的 URL 长期不可见 |
| lastmod 全部填同一时间 | 更新信号失效,爬虫按低优先级均匀分配预算 |
| lastmod 与实际内容不符 | 历史校验记为失信,站点级信号可信度降权 |
| sitemap 含 404 / 重定向条目 | 抓取次数浪费在无效 URL 上,站点信誉扣分 |
| 未在 robots.txt 声明 | 只能依赖手动提交,发现和更新延迟明显 |
六、误区澄清与趋势
两个常见误区先说清楚。有人认为把 robots.txt 的 Disallow 收紧就能"省预算给重要页面",实际上过严的规则会切断内链发现路径,爬虫发现不了入口,再高的优先级评分也无从谈起------该禁的是参数组合页,不是导航路径。另一个是 lastmod 越新越好的幻觉,前文已经验证过,假信号的代价是站点级降权,比老实不填更亏。
趋势上看,AI 爬虫正在向传统搜索引擎的调度逻辑靠拢:lastmod 与内容指纹的交叉校验会越来越普遍,靠大规模重写时间戳换抓取的路子会越走越窄。llms.txt 这类新提案目前还没有形成事实标准,短期内 sitemap 仍是所有爬虫(包括 GPTBot、ClaudeBot、Google-Extended)共同认账的入口。
工程上最后收个尾:分片解决的是信号密度问题,lastmod 真实化解决的是信号可信问题,两者一起做,爬虫的调度器才能把预算按你的意图重新分配。日志要持续看,重点盯每个分片的抓取次数曲线------一旦新品分片的抓取量回落,先查是不是 lastmod 又被批量刷新了。这套配置不依赖任何平台特性,纯靠协议层信号,欢迎在评论区交流各自站点的分片粒度和阈值怎么定。
参考与延伸
- Sitemaps 协议(sitemaps.org):sitemap 与 sitemap index 的格式定义、条目与文件大小上限。
- RFC 9309:Robots Exclusion Protocol:robots.txt 的正式规范,含
Sitemap:行的定义。 - Google 爬虫概览(Google Search Central):Google 各类爬虫的行为与识别方式。
- What Crawl Budget Means for Googlebot:Google 官方对抓取预算构成的说明。
关键词:Crawl Budget, sitemap index, lastmod, AI爬虫, 抓取预算, 生成式引擎优化, AI优化AIO