AI 爬虫的抓取预算从哪来:Crawl Budget 机制与 sitemap index 分片实践

2026-09-18 08:34:28 14 次浏览
Crawl Budgetsitemap indexlastmodAI爬虫GEO

适用读者:维护外贸独立站的 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 的整体可信度下降,后续就算某天真的更新了,爬虫也可能按低优先级处理。更新信号一旦失信,比没有信号更糟。

下面这张图是调度循环的简化版:

flowchart TD
    A[URL 发现<br/>sitemap / 内链 / 外链] --> B[合并去重<br/>进入 URL 库]
    B --> C{重要性评分<br/>内链数 · 链接深度<br/>外链 · 历史更新频率}
    C --> D[优先级队列]
    E[站点信誉<br/>响应质量 · 信号可信度] --> C
    D --> F{今日预算<br/>还有余量?}
    F -->|是| G[出队抓取<br/>校验 lastmod 与内容指纹]
    G --> H[更新频率画像<br/>信誉加减分]
    H --> B
    F -->|否| I[次日继续<br/>队列保留]

三、分片方案: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 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 行,路径不限):

User-agent: *
Disallow: /cart/
Disallow: /*?sort=

# Sitemap 行允许重复,可按爬虫分组声明,这里放全局入口
# Disallow 规则按协议匹配路径前缀,* 为通配符
Sitemap: https://example.com/sitemap-index.xml

分片之后的架构关系:

flowchart LR
    R[robots.txt<br/>声明 Sitemap 行] --> I[sitemap-index.xml<br/>sitemap index 顶层文件]
    I --> N[sitemap-new.xml<br/>新品页 · 实时追加]
    I --> P1[sitemap-products-1.xml<br/>SKU 前半]
    I --> P2[sitemap-products-2.xml<br/>SKU 后半]
    I --> W[sitemap-news.xml<br/>资讯页 · 每日增量]
    I --> H[sitemap-help.xml<br/>帮助文档 · 修订才更新]
    N --> C[爬虫调度器<br/>按分片更新频率<br/>分配抓取预算]
    P1 --> C
    P2 --> C
    W --> C
    H --> C

四、lastmod 真实化与生成脚本

分片只是骨架,lastmod 真实化才是让信号可信的血肉。我们的规则定得很死:lastmod 只在正文、价格、库存状态三类字段之一变更时刷新,模板改版、样式调整一律不算;时间取自数据库的变更时间戳,带时区,不用生成本次文件的当前时间。

生成脚本的依赖与环境:Python 3.10+,仅标准库(pathlibjsondatetimexml.sax.saxutils),数据源是一份导出的 JSONL,每行含 urllastmod 两个真实字段。脚本按 4 万条一片切分并输出 index:

# -*- 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 又被批量刷新了。这套配置不依赖任何平台特性,纯靠协议层信号,欢迎在评论区交流各自站点的分片粒度和阈值怎么定。

参考与延伸

关键词:Crawl Budget, sitemap index, lastmod, AI爬虫, 抓取预算, 生成式引擎优化, AI优化AIO

🤖
本内容由 AI 辅助生成,经人工校对审核;部分素材、资料来源于公开网络,仅作个人观点分享与交流使用,无任何商业侵权意图。若内容、图片、文字涉及您的合法著作权、版权权益,请联系本人,核实后将第一时间删除、修改相关内容。