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

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+,仅标准库(pathlibjsondatetimexml.sax.saxutils),数据源是一份导出的 JSONL,每行含 urllastmod 两个真实字段。脚本按 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 又被批量刷新了。这套配置不依赖任何平台特性,纯靠协议层信号,欢迎在评论区交流各自站点的分片粒度和阈值怎么定。

参考与延伸

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

相关推荐
码农学院2 小时前
停产的设备还在被 AI 推荐:软 404、410 状态码与结构化数据清理的排查记录
网络爬虫·geo·geo优化·ai优化aio
码农学院2 天前
门店信息三方一致性审计:站内、地图平台与 AI 回答口径差异的量化与修复前后数据
geo·geo优化·ai优化aio
码农学院3 天前
企业官网GEO实战:用 Organization 与 Person Schema 构建作者实体,让 AI 引擎把内容归到可信来源
人工智能·geo·ai优化aio
狗都不学爬虫_3 天前
AI逆向 - 天御无感点击验证(补+纯)
爬虫·python·网络爬虫
码农学院3 天前
制造业B2B官网GEO实战:用 Python 向量相似度给老产品手册自动补语义内链,让 AI 爬虫抓得更深
运维·人工智能·爬虫·ai优化aio
码农学院3 天前
本地服务业GEO架构方案:用 ASP.NET Core 中间件按门店动态注入 LocalBusiness Schema,营业时间改一次全站生效
人工智能·geo优化·ai优化aio
码农学院3 天前
零售电商GEO踩坑复盘:商品列表页懒加载让 AI 爬虫只抓到八分之一的商品,前端改造全程记录
人工智能·geo优化·ai优化aio
隐擎fox4 天前
解构无感人机验证底层机制:行为生物轨迹采样、环境评分模型与自动化对抗实战
自动化测试·python·网络协议·tcp/ip·网络爬虫
码农学院6 天前
外贸独立站GEO实战:用 Python 自动生成多语言 llms.txt 和 Schema 站点地图
人工智能·python·chatgpt·geo·geo优化·ai优化aio