スクレイピング

発見ソースとしてのサイトマップ:サイト全体をクロールせずにすべてのURLを見つける方法

XMLサイトマップはサイトのURL一覧を示し、変更日時が含まれることもあります。それを正しく読み取る方法と、lastmodを確認せずに信用してはいけない理由。

James Meadow

James Meadow

2026年9月29日 · 3 分で読める

多くのクローラーは、コストの高い方法でページを発見している。ページを取得し、リンクを抽出し、キューに入れ、繰り返す。これは機能するが、予算の大部分をナビゲーション、リスト一覧、すでに見たページに費やしてしまい、それでもどこからもリンクされていないページを見逃すことがある。

多くのサイトは意図的にURLの一覧を公開している。XMLサイトマップは検索エンジンが効率的にページを見つけられるように存在しており、同じファイルはデータチームに対しても、サイトに何が存在するか、それがどう組織されているか、そして時には最近何が変更されたかを教えてくれる。本ガイドでは、サイトマップを正しく見つけて読む方法と、その中で最も有用なフィールドであるlastmodを信頼する前になぜ確認が必要かを扱う。実際の2つのサイトで確認したところ、2つの異なる形で失敗した。

要点

  • サイトマップは1ファイルあたり最大50,000件のURLを掲載でき、サイトマップインデックスは最大50,000個のサイトマップを一覧できるため、非常に大規模なサイトでも完全な一覧を公開できる。
  • サイトマップは通常、robots.txt内にSitemap:行として宣言されている。場所を推測する前にそこを確認すること。
  • lastmodは、最も多くの取得を節約できる可能性のあるフィールドであり、同時に最も信頼性が低い。Googleは、lastmodが「一貫して検証可能な形で」正確である場合にのみ使用すると述べている。
  • 今回の確認では、ある大手ニュース発行元のニュースサイトマップは、すべてのエントリに同じタイムスタンプ、つまりファイルが生成された瞬間の時刻を付与していた。ある大規模な文書アーカイブは、10,000件を超えるURLを公開していたが、lastmodが一切なかった。
  • 発見のためにサイトマップを使い、スケジューリングに使う前にサイトごとにlastmodを検証し、リンクをたどるクロールを安全網として維持すること。

プロトコルが許容すること

サイトマッププロトコルは短く、正確に把握しておく価値がある。

ルール詳細
サイズ制限1つのサイトマップファイルにつき、非圧縮で「50,000件を超えないURL数」かつ「50MB(52,428,800バイト)を超えない」こと
サイトマップインデックス他のサイトマップを一覧するファイルで、同じ50,000エントリと50MBの制限を持つ
圧縮ファイルはgzip圧縮されていてもよいが、非圧縮の状態で制限内である必要がある
lastmod任意項目で、W3C Datetime形式。YYYY-MM-DDのような日付のみでもよい
発見robots.txt内のSitemap:行。1つのサイトが複数を列挙してもよい

changefreqとpriorityという、目にすることのある2つの任意フィールドは無視してよい。Googleは「<priority>と<changefreq>の値は無視する」とはっきり述べており、他の誰かがそれらを信頼する理由もほとんどない。

ニュースサイトはしばしば、最近の記事のみを対象とした別のニュースサイトマップを、公開日などの追加フィールド付きで公開している。これらは小さく、頻繁に再生成され、最近のコンテンツを監視するのに非常に役立つ。

サイトマップを正しく読む

堅牢なリーダーは4つのことを行う必要がある。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}

このインデックス走査は、インデックスが日付を提供している場合、子サイトマップを新しいものから順に訪問するため、上限付きの実行でも大規模サイトの最近更新された部分から優先的に読み込むことになる。安全性のための工夫が2つある。サイトマップは他者のサーバーから来た信頼できない入力であるため、このコードはdefusedxmlでパースしており、これは標準的なXMLパーサーが小さなファイルをギガバイト単位に膨張させてしまうようなエンティティのトリックを拒否する。またダウンロードと展開後のサイズの両方をプロトコルの50MB制限で上限としているため、圧縮ファイルが無制限に膨張することはない。openerパラメータを使えば、市場ごとに異なるバージョンのサイトを確認したい場合に、他の取得と同じ方法でリクエストをプロキシ経由に振り分けられる。

lastmodは確認した後でのみ信頼する

lastmodは、増分収集に必要なものをまさに約束している。前回の実行以降に何が変更されたかの一覧だ。もしこれがどこでも信頼できるなら、クローラーは変更されたページだけを取得し、それ以外をすべてスキップできるだろう。しかしどこでも信頼できるわけではなく、テストした2つのサイトは両方のよくある失敗を示している。

失敗その1: lastmodが生成時刻になっている。 ある大手ニュース発行元のニュースサイトマップを読んだ。日付が入っているすべてのエントリは、1秒違いの2つのタイムスタンプのどちらかを持っていた。それは各記事が変更された瞬間ではなく、ファイルが生成された瞬間だった。「過去6時間以内に変更された」でフィルタすると、ファイル内のすべてのURLが返ってきた。素朴に使うと、そのlastmodは毎回すべてを再取得させてしまうことになる。

失敗その2: lastmodがまったくない。 ある大規模な技術文書アーカイブのサイトマップを読んだ。10,236件のURLが列挙されていたが、どれもlastmodを持っていなかった。これは発見のソースとしては非常に優れているが、何を再取得すべきかを判断するのにはまったく役立たない。

これが、Google自身のガイダンスがlastmodを「一貫して検証可能な形で(例えばページの最終更新と比較することで)正確である場合にのみ」使用すると述べている理由であり、自分自身でも同じテストを適用すべき理由だ。サイトのlastmodに基づいてスケジューリングする前に、以下を行うこと。

  1. 分布を確認する。 ほとんどのエントリが1つのタイムスタンプを共有している場合、あるいは値がファイルの生成時刻に追随している場合、そのフィールドはページの変更を表していない。
  2. サンプルを取り、比較する。 ページのサンプルを取得し、lastmodを、構造化データ内のページ自身のdateModifiedやコンテンツのフィンガープリントといった独立したシグナルと比較する。dateModifiedの抽出方法はHTMLパースをやめるで、ノイズに埋もれずにコンテンツを比較する方法は大規模な変更検知で扱っている。
  3. サイトを評価する。 サイトごとに、コンテンツが変更されたときにlastmodが変更された頻度と、lastmodが変更されなかったのにコンテンツが変更された頻度を記録する。合格したサイトについてのみスケジューリングにlastmodを使い、その後も確認を続けること。サイトのサイトマップ生成方法は予告なく変わりうるからだ。

サイトマップが収集パイプラインのどこに位置するか

サイトマップは、唯一のステップとしてではなく、最初のステップとして最も強力だ。

  • 発見。 サイトマップは在庫を直接与えてくれる。リンクが乏しいページも含めてだ。カタログ系やコンテンツ系のサイトでは、これがしばしば最も網羅的なURL一覧になる。
  • セグメント分け。 サイトマップは商品、カテゴリ、記事、動画といったコンテンツタイプやセクションで分割されていることが多い。その分割は、1ページも取得する前にサイトがどう組織されているかを教えてくれる。
  • 増分収集。 上記の確認に合格したサイトのlastmodを使い、最近の記事についてはニュースサイトマップを使う。
  • カバレッジの確認。 クローラーが見つけたものとサイトマップが一覧するものを比較すると、何を見逃しているかがわかる。

リンクベースのクロールは安全網として維持すること。サイトマップは古くなっていたり、不完全だったり、意図的にインデックスさせたいものだけに限定されていたりする。そしてページ自体についてはサイトのrobots.txtを尊重すること。サイトマップにURLが載っていることは、検索エンジンにそれをインデックスするよう招待しているだけであり、サイトが求めている他の何かを放棄したものではない。これはrobots.txt、AIオプトアウト、留保シグナルで扱っている。

このように使えば、サイトマップは通常無駄になりがちな2つの箇所での取得予算を削減できる。ページを見つけるためのナビゲーションと、変更されていないページの再取得だ。2つ目の節約は完全にlastmodが信頼できるかどうかにかかっており、それがコストを意識したクロールスケジューリングが、未検証のlastmodをヒントとして扱うべきであり、事実として扱うべきではない理由だ。

結論

サイトマップは、ウェブ上で最も安価なURL在庫だ。サイトが標準フォーマットで、何を見つけてほしいかを教えてくれている。robots.txtから読み込み、gzipとネストしたインデックスを処理し、取得するファイル数に上限を設け、まずは発見のために使うこと。

そしてlastmodは、Googleと同じように扱うこと。そのサイトについて正確だと示された場合にのみ有用だ。今回確認した1つのサイトでは、それは単にファイルが生成された時刻にすぎなかった。もう1つのサイトでは、それは存在すらしていなかった。うまく機能しているサイトでは、再取得を大幅に削減できる。どちらの種類のサイトを扱っているかを知る唯一の方法は、確認することだ。

出典と参考文献

始める準備はできていますか?

Shifterのレジデンシャルプロキシをお試しください。IP 205M+件、195+カ国、$0.10/GBから。

始める