数据抓取

站点地图作为发现来源:无需爬取整站即可找到每个URL

XML站点地图列出了网站的URL,有时还包括它们的更改时间。如何正确读取它们,以及为什么不应在未加以核实的情况下信任lastmod。

James Meadow

James Meadow

2026年9月29日 · 3 分钟阅读

大多数爬虫以代价高昂的方式发现页面:抓取一个页面,提取其中的链接,将其加入队列,然后重复这个过程。这种方法可行,但它把大部分预算都花在了导航、列表页以及你已经见过的页面上,而且仍然可能漏掉那些没有任何页面链接到的页面。

许多网站会主动发布一份包含自身 URL 的列表。XML 站点地图的存在是为了让搜索引擎能够高效地找到页面,而同一个文件也能告诉数据团队一个网站上存在哪些内容、它是如何组织的,有时还能说明最近发生了什么变化。本指南将介绍如何正确地找到并读取站点地图,以及为什么其中最有用的字段 lastmod 在你信任它之前需要先做核实。我们在两个真实网站上做了核实,结果发现了两种不同的失败方式。

关键要点

  • 一个站点地图文件最多可列出 50,000 个 URL,一个站点地图索引最多可列出 50,000 个站点地图,因此即使是非常大的网站也能发布完整的清单。
  • 站点地图通常通过 robots.txt 中的一行 Sitemap: 声明;在猜测位置之前应先检查这里。
  • lastmod 是最能节省抓取次数的字段,同时也是最不可靠的。Google 表示,只有在“持续且可验证地”准确时才会使用 lastmod。
  • 在我们的核实中,某家大型新闻出版商的新闻站点地图给每一条记录都标注了相同的时间戳,即文件生成的那一刻。一个大型文档档案库发布了超过 10,000 个 URL,却完全没有 lastmod。
  • 将站点地图用于发现环节,在按其安排调度前需逐站点核实 lastmod,并保留基于链接的爬取作为安全网。

该协议允许的内容

站点地图协议篇幅不长,值得精确了解:

规则详情
大小限制每个站点地图文件“不超过 50,000 个 URL”,且未压缩时“不超过 50MB(52,428,800 字节)”
站点地图索引一个列出其他站点地图的文件,同样受 50,000 条目和 50MB 限制约束
压缩文件可以使用 gzip 压缩,只要解压后仍在限制之内
lastmod可选,采用 W3C Datetime 格式,可以只写日期,例如 YYYY-MM-DD
发现方式robots.txt 中的一行 Sitemap:;一个网站可以列出多个

你会看到的另外两个可选字段 changefreq 和 priority 可以忽略。Google 明确表示它“忽略 <priority> 和 <changefreq> 的值”,其他任何人也没有太多理由去信任它们。

新闻网站通常会发布一个单独的新闻站点地图,只覆盖最近的文章,并带有额外字段,例如发布日期。这类文件体积小、重新生成频繁,对于监控近期内容非常有用。

正确读取站点地图

一个健壮的读取器需要做四件事:从 robots.txt 中找到站点地图、处理 gzip、递归地跟随站点地图索引,并将 lastmod 解析为可比较的形式。它还应该限制自己抓取的文件数量,因为一个索引可能指向数千个文件。

import gzip
import io
import re
import urllib.request
from datetime import datetime, timezone

from defusedxml.ElementTree import fromstring  # pip install defusedxml

NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
UA = "Mozilla/5.0 (compatible; sitemap-reader)"
MAX_BYTES = 52_428_800  # the protocol's 50MB limit, uncompressed


def fetch(url, opener=None):
    opener = opener or urllib.request.build_opener()
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    with opener.open(req, timeout=45) as r:
        body = r.read(MAX_BYTES + 1)
    if body[:2] == b"\x1f\x8b":
        body = gzip.GzipFile(fileobj=io.BytesIO(body)).read(MAX_BYTES + 1)
    if len(body) > MAX_BYTES:
        raise ValueError(f"{url} exceeds the 50MB sitemap limit")
    return body


def sitemap_roots(origin, opener=None):
    """Sitemaps declared in robots.txt, falling back to /sitemap.xml."""
    try:
        robots = fetch(origin.rstrip("/") + "/robots.txt", opener).decode("utf-8", "replace")
        found = re.findall(r"(?im)^\s*sitemap:\s*(\S+)", robots)
    except OSError:
        found = []
    return found or [origin.rstrip("/") + "/sitemap.xml"]


def parse_lastmod(value):
    if not value:
        return None
    value = value.strip().replace("Z", "+00:00")
    try:
        dt = datetime.fromisoformat(value)
    except ValueError:
        return None
    return dt if dt.tzinfo else dt.replace(tzinfo=timezone.utc)


def walk(url, opener=None, max_files=20, _seen=None):
    """Yield (loc, lastmod) from a sitemap or sitemap index, following nested indexes."""
    seen = _seen if _seen is not None else set()
    if url in seen or len(seen) >= max_files:
        return
    seen.add(url)
    root = fromstring(fetch(url, opener))
    if root.tag == NS + "sitemapindex":
        children = sorted(root.findall(NS + "sitemap"),
                          key=lambda s: parse_lastmod(s.findtext(NS + "lastmod")) or datetime.min.replace(tzinfo=timezone.utc),
                          reverse=True)
        for child in children:
            yield from walk(child.findtext(NS + "loc").strip(), opener, max_files, seen)
    else:
        for u in root.findall(NS + "url"):
            yield u.findtext(NS + "loc").strip(), parse_lastmod(u.findtext(NS + "lastmod"))


def changed_since(origin, since, opener=None, max_files=20):
    """URLs whose sitemap lastmod is newer than `since`, plus those with no lastmod at all."""
    changed, undated, total = [], [], 0
    for root in sitemap_roots(origin, opener):
        for loc, lastmod in walk(root, opener, max_files):
            total += 1
            if lastmod is None:
                undated.append(loc)
            elif lastmod > since:
                changed.append((loc, lastmod))
    return {"total": total, "changed": changed, "undated": undated}

在索引提供了日期的情况下,索引遍历器会优先访问最新的子站点地图,因此一次有上限的运行会优先读取大型网站中最近更新的部分。代码中有两处细节是为了安全考虑。站点地图是来自他人服务器的不可信输入,因此该代码使用 defusedxml 来解析它,它会拒绝那些可能使标准 XML 解析器将一个小文件膨胀到数 GB 的实体攻击手段,并且将下载和解压后的大小都限制在协议规定的 50MB 以内,这样压缩文件就不会无限膨胀。opener 参数让你可以在需要查看某个网站针对特定市场的版本时,通过代理路由请求,这与其他任何抓取的方式相同。

只有在核实之后才能信任 lastmod

lastmod 承诺的正是增量采集所需要的:一份列出自上次运行以来发生变化内容的清单。如果它在各处都可靠,爬虫就可以只抓取有变化的页面,跳过其余部分。但它并非在各处都可靠,我们的两个测试网站展示了两种常见的失败情况。

失败一:lastmod 就是生成时间。 我们读取了某家大型新闻出版商的新闻站点地图。每一条带日期的记录都带有相隔一秒的两个时间戳之一:这是文件生成的那一刻,而不是每篇文章真正发生变化的那一刻。筛选“最近六小时内发生变化”的条目,结果返回了文件中的每一个 URL。如果不加分辨地使用,这样的 lastmod 每次都会触发对全部内容的重新抓取。

失败二:完全没有 lastmod。 我们读取了一个大型技术文档档案库的站点地图。它列出了 10,236 个 URL,没有一个带有 lastmod。这是一个非常好的发现来源,但对于判断该重新抓取什么完全没有帮助。

这就是为什么 Google 自己的指导说明它只有在 lastmod “持续且可验证地准确(例如通过与页面最后修改时间进行比对)”时才会使用它,也是为什么你应该对自己进行同样的测试。在按某个网站的 lastmod 安排调度之前:

  1. 检查分布情况。 如果大多数条目共享同一个时间戳,或者这些值只是跟随文件生成时间变化,那么该字段并没有描述页面的真实变化。
  2. 抽样比对。 抓取一部分页面样本,将 lastmod 与一个独立信号进行比对,例如页面自身结构化数据中的 dateModified,或者内容指纹。如何提取 dateModified 在停止解析 HTML中有介绍,如何在不被噪音淹没的情况下比对内容则在大规模变更检测中有介绍。
  3. 为网站打分。 针对每个网站记录 lastmod 变化时内容是否也随之变化,以及内容变化时 lastmod 是否保持不变的频率。只在通过检验的网站上使用 lastmod 进行调度,并持续核实,因为一个网站的站点地图生成器可能在没有任何通知的情况下发生变化。

站点地图在采集流程中的位置

站点地图最强的用途是作为第一步,而不是唯一的一步:

  • 发现。 站点地图能直接给你完整清单,包括那些链接指向很差的页面。对于目录类和内容类网站,它往往是可获得的最完整的 URL 列表。
  • 分段。 站点地图通常按内容类型或板块拆分,例如产品、分类、文章和视频。这种拆分能在你抓取任何一个页面之前,就告诉你一个网站是如何组织的。
  • 增量采集,适用于那些 lastmod 通过了上述检验的网站,以及通过新闻站点地图获取近期文章。
  • 覆盖率检查。 将你的爬虫找到的内容与站点地图列出的内容进行比对,能看出你遗漏了什么。

将基于链接的爬取保留为安全网。站点地图可能是过时的、不完整的,或者被网站刻意限制为只包含它希望被索引的内容。并且对于页面本身,仍要遵守网站的 robots.txt:一个 URL 出现在站点地图中,只是对搜索引擎发出索引它的邀请,并不能免除网站提出的其他任何要求,这一点在robots.txt、AI 退出选项与保留信号中有详细介绍。

以这种方式使用时,站点地图能削减通常被浪费的两处抓取预算:为寻找页面而进行的导航,以及重新抓取那些并未发生变化的页面。第二项节省完全取决于 lastmod 是否值得信赖,这也是为什么成本感知的爬取调度应该把未经核实的 lastmod 当作一个提示,而不是一个事实。

结论

站点地图是网络上最廉价的 URL 清单:一个网站以标准格式告诉你它希望被找到的内容。从 robots.txt 中读取它们,处理 gzip 和嵌套索引,限制抓取数量,并首先将它们用于发现环节。

然后,像 Google 那样对待 lastmod:只有在证明它对该网站准确时才有用。在我们核实的一个网站上,它只是文件生成的时间。在另一个网站上,它根本不存在。而在一个它确实可靠的网站上,它能大幅削减重新抓取的次数。要知道你面对的是哪一种网站,唯一的办法就是去核实。

来源与参考资料

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始