Claude Codeでsitemap.xmlから取得対象のURL一覧を作る手順
sitemap indexを辿ってlastmodで更新分に絞り、robots.txtのDisallowと突き合わせた取得リストをClaude Codeに作らせる手順です。
サイト全体を巡回するとき、最初に作るべきものはクローラーではなく「どのURLを取りに行くか」の一覧です。sitemap.xmlはその出発点になります。ただし、sitemap indexを辿り、更新のあったURLだけに絞り、robots.txtで禁止されたURLを外すところまでやって、初めて取得リストとして使えます。
この記事では、Claude Codeにその絞り込みスクリプトを書かせ、標準ライブラリだけで動くことを手元で確かめるところまでを説明します。巡回そのものは別の話です。Scrapyで間隔と契約テストを固定する例はClaude CodeでScrapyのスクレイパーを作る、Crawleeで並列数を固定する例はClaude CodeでCrawleeのクローラーを組むにあります。
取得リストはどの順で絞り込むのか
絞り込みは次の4段です。順番を変えると、無駄な取得が増えるか、禁止されたURLが紛れ込みます。
取得リストができるまでの4段
- 1
robots.txtを読む
Sitemap:行からsitemapの場所を拾い、DisallowとAllowの規則を手元に持ちます。 - 2
sitemap indexを辿る
子sitemapごとの
lastmodが基準日より古ければ、その子は開きません。 - 3
urlsetのlastmodで絞る
基準日以降のURLと、
lastmodを持たないURLを残します。 - 4
robots.txtで落とす
禁止されたURLと、別ホストのURLを外して出力します。
sitemapの仕様で押さえる3点
仕様はsitemaps.orgのプロトコルに書かれています。取得リストに関わるのは次の3点です。
- 1ファイルは50,000 URL以下、非圧縮で50MB以下。超える場合は複数ファイルに分け、gzip圧縮も使える
- sitemap indexの
lastmodは、子sitemapファイルが更新された時刻を指す。中に載るページの更新時刻ではない - robots.txtに
Sitemap: <完全なURL>を書くと場所を伝えられ、この行はUser-agent行と無関係に置ける
2点目が絞り込みの根拠です。プロトコルは、lastmod を使えば前回以降に更新された子sitemapだけを取得でき、巨大なサイトでも新しいURLを素早く見つけられると説明しています。
robots.txtはどこまで従うのか
robots.txtの形式はRFC 9309が定めています。取得リストを作る側として知っておく点は3つです。
| 状況 | RFC 9309の扱い |
|---|---|
| 取得に成功 | RFC 9309の扱い解釈できる規則に従う(MUST) |
| 4xxで取得できない | RFC 9309の扱い「利用不可」。どのリソースにもアクセスしてよい(MAY) |
| 5xxやネットワーク障害 | RFC 9309の扱い「到達不能」。全面的に禁止と見なす(MUST) |
規則の照合は、一致した規則のうち最も具体的なもの、つまり文字数が最も長いものを使います。同じ長さの Allow と Disallow が並ぶ場合は Allow を使うことになっています。Sitemap のような仕様外の記録は、クローラーが解釈してもよいものの、規則グループの終端にはなりません。
取得結果を使い回すときの上限
スクリプトを定期実行するなら、robots.txtを毎回取り直すかどうかも決めておきます。RFC 9309には、使い回しに関する次の目安があります。
- キャッシュは許されているが、robots.txtが到達不能な場合を除き、24時間を超えて使うべきではない(SHOULD NOT)
- 取得時にリダイレクトされたら、少なくとも5回連続までは辿るべきで、辿り着いた規則は元のホストのものとして扱う
- 解析の上限は500キビバイト以上にしなければならない
sitemap側にも、リストの取りこぼしに直結する制約があります。sitemapに載るURLは、そのsitemapと同じプロトコル、同じホストに限られ、条件に合わないURLは考慮から外されます。sitemap indexが指せるのも、同じサイト上のsitemapだけです。上のスクリプトがホストの違うURLを除いているのは、この仕様に沿った動きです。
Claude Codeにスクリプトを書かせる
ここからが実作業です。プロジェクトの CLAUDE.md に、取得リストを作る際の決め事を先に書いておくと、毎回の指示が短くなります。
## 取得リスト作成の決め事
- 標準ライブラリだけで書く(外部パッケージを足さない)
- 先に robots.txt を読み、4xx は規則なし、5xx は中止にする
- sitemap index は lastmod で子を絞り、再帰は深さ3まで
- 出力は取得対象 URL を1行1件。除外した URL は標準エラーに理由つきで出す
- User-Agent は "my-research-bot" に固定するそのうえで、次のように頼みます。
sitemap_targets.py を作って。引数は対象サイトのオリジンと --since。
robots.txt の Sitemap 行から sitemap index を辿り、lastmod が
--since 以降のURLだけを、robots.txt で許可されたものに限って出力して。例えば次のような形のスクリプトになります。以下は、手元のPython 3.12で動作を確かめたものです。
import argparse, gzip, sys, urllib.error, urllib.request
import xml.etree.ElementTree as ET
from datetime import datetime, timezone
from urllib.parse import urlsplit
from urllib.robotparser import RobotFileParser
NS = "{http://www.sitemaps.org/schemas/sitemap/0.9}"
UA = "my-research-bot"
MAX_DEPTH = 3
def fetch(url):
req = urllib.request.Request(url, headers={"User-Agent": UA})
with urllib.request.urlopen(req, timeout=20) as r:
body = r.read()
return gzip.decompress(body) if url.endswith(".gz") else body
def parse_time(text):
if not text:
return None
t = datetime.fromisoformat(text.strip().replace("Z", "+00:00"))
return t if t.tzinfo else t.replace(tzinfo=timezone.utc)
def walk(url, since, depth=0, seen=None):
seen = set() if seen is None else seen
if url in seen or depth > MAX_DEPTH:
return
seen.add(url)
root = ET.fromstring(fetch(url))
if root.tag == NS + "sitemapindex":
for sm in root.findall(NS + "sitemap"):
loc = sm.findtext(NS + "loc", "").strip()
mod = parse_time(sm.findtext(NS + "lastmod"))
if mod and mod < since:
continue # 子 sitemap ごと読まない
yield from walk(loc, since, depth + 1, seen)
else:
for u in root.findall(NS + "url"):
loc = u.findtext(NS + "loc", "").strip()
mod = parse_time(u.findtext(NS + "lastmod"))
if mod is None or mod >= since: # lastmod 無しは残す
yield loc
def load_robots(origin):
rp = RobotFileParser()
try:
body = fetch(origin + "/robots.txt").decode("utf-8", "replace")
rp.parse(body.splitlines())
except urllib.error.HTTPError as e:
if 400 <= e.code < 500:
rp.parse([]) # RFC 9309: 4xx は robots.txt 無し扱い
else:
sys.exit(f"robots.txt が {e.code}: 全面不可として中止")
return rp
def main():
ap = argparse.ArgumentParser()
ap.add_argument("origin")
ap.add_argument("--since", required=True)
a = ap.parse_args()
since = parse_time(a.since)
rp = load_robots(a.origin)
roots = rp.site_maps() or [a.origin + "/sitemap.xml"]
kept = skipped = 0
for root in roots:
for loc in walk(root, since):
if urlsplit(loc).netloc != urlsplit(a.origin).netloc:
skipped += 1
continue
if rp.can_fetch(UA, loc):
print(loc)
kept += 1
else:
skipped += 1
print("skip(robots):", loc, file=sys.stderr)
print(f"kept={kept} skipped={skipped}", file=sys.stderr)
if __name__ == "__main__":
main()指定がない限り、--since に渡す日付はUTCとして扱われます。lastmod が日付だけのURLも、同じ規則でUTCの0時として比べます。
実行は次のとおりです。出力が取得リスト、標準エラーが除外の記録になります。
python3 sitemap_targets.py https://example.com --since 2026-10-01 \
> targets.txt 2> excluded.log手元で確かめると、robots.txtの判定が食い違った
動作確認には、次のrobots.txtと、2つの子sitemapを持つsitemap indexを用意しました。子の1つは lastmod が9月、もう1つは10月25日です。10月25日の子には5件のURLが載っています。
User-agent: *
Disallow: /private/
Allow: /private/public-note.html
Disallow: /search
Sitemap: http://127.0.0.1:8765/sitemap_index.xml基準日を10月1日にして実行すると、9月の子sitemapは開かれず、10月25日の子から5件のうち残ったのは1件だけでした。
skip(robots): http://127.0.0.1:8765/private/secret.html
skip(robots): http://127.0.0.1:8765/private/public-note.html
skip(robots): http://127.0.0.1:8765/search?q=x
kept=1 skipped=3
http://127.0.0.1:8765/docs/a.htmlb.html は lastmod が8月で、基準日より古いため、そもそも候補に入っていません。注目すべきは2行目です。Allow: /private/public-note.html と明記してあるのに、除外されています。
原因はPython標準の urllib.robotparser の照合順です。手元で規則の並びだけを入れ替えて試すと、結果が変わりました。
| 規則の並び | /private/public-note.html の判定 |
|---|---|
Disallow: /private/ の後に Allow: /private/public-note.html | /private/public-note.html の判定取得不可 |
Allow: /private/public-note.html の後に Disallow: /private/ | /private/public-note.html の判定取得可 |
RFC 9309は最長一致を求めるので、どちらの並びでも取得可になるはずです。標準ライブラリは先に一致した規則を採るため、並び順に左右されます。ここで確かめたのはPython 3.12.2での挙動です。
除外側に倒れる誤りなので、礼儀の面では安全です。ただし、取りこぼしが困るなら最長一致を自前で書きます。ワイルドカードを扱わない最小の形は次のとおりです。
def allowed(rules, path):
best_len, best_allow = -1, True
for kind, value in rules: # ("allow" | "disallow", パス接頭辞)
if value and path.startswith(value):
is_allow = kind == "allow"
if len(value) > best_len or (
len(value) == best_len and is_allow
):
best_len, best_allow = len(value), is_allow
return best_allow同じrobots.txtで試すと、/private/public-note.html は取得可、/private/secret.html と /search?q=x は取得不可になりました。* や $ を含む規則を扱うなら、この関数を伸ばすより専用のパーサーを入れるほうが確実です。
Claudeに実行させるときの権限を絞る
スクリプトができたら、実行と取得をClaude Codeに任せることもできます。権限は広げすぎないようにします。
- ネットワーク取得をスクリプトに閉じ込め、
Bash(python3 sitemap_targets.py *)だけを許可する。Bashのパターンで引数を制限する書き方は迂回されやすい、とドキュメント自身が注意している curlやwgetを使わせたくないときは、Bashのcurlを拒否し、取得はWebFetch(domain:example.com)で許可するドメインに限る。ただしdenyルールはsh -cの内側や絶対パスでの呼び出しには一致しないため、確実に止めたいときはサンドボックスのネットワーク許可リストも併用するWebFetch(domain:example.com)はそのホストだけに一致し、サブドメインを含めるには*.example.comの規則を別に足す
定期的に更新分だけを取りたいなら、claude -p で非対話実行できます。--allowedTools で自動承認するツールを列挙する形が基本です。
claude -p "targets.txt の先頭20件のタイトルを一覧にして" \
--allowedTools "WebFetch(domain:example.com)"取得の頻度や並列数は、ここでは決めません。それは巡回側の設定で固定する領域です。
つまずきやすい点
落とし穴
lastmodが無いURLを落としてしまう
lastmod は任意の項目です。無いURLを「古い」と見なして落とすと、サイト全体が空になることがあります。上のスクリプトは、lastmod の無いURLを残す側に倒しています。
- 子sitemapの
lastmodは、載っているページの更新日ではありません。子が更新されていても、個別URLのlastmodが無ければ、そのsitemapの全件が候補に残ります - sitemap indexが別のindexを指す入れ子は、プロトコルの説明にありません。スクリプトに深さ3の上限と訪問済みの記録を置いているのは、循環や想定外の構造に備えるためです
.gz圧縮のsitemapは、プロトコルが認めています。上のスクリプトはURLの末尾が.gzのときだけ展開するので、拡張子と圧縮の有無が食い違うサイトでは失敗します- robots.txtに
Sitemap:行が無いサイトでは、スクリプトは/sitemap.xmlを試します。プロトコルはsitemapをWebサーバーのルート直下に置くことを強く勧めていますが、必須ではないため、別の場所にあるサイトでは見つかりません - sitemapに載るURLが、robots.txtで禁止されていることは普通にあります。載っていることは、取得してよい根拠になりません
- 別ホストのURLがsitemapに混ざる場合があります。スクリプトはオリジンと同じホストだけを残します
サイト側から見て、AIクローラーを許可するか止めるかの設計はClaudeBotをブロックするrobots.txtの書き方に詳しくあります。URLの絞り込みから本文の取得までをサービスに任せるなら、Firecrawl MCPをClaude Codeにつなぐ手順が選択肢になります。
取得対象を絞る作業の分担
sitemapとrobots.txtの読み方は仕様で決まっているので、ここは人が判断を挟む場所が少なく、Claude Codeに書かせやすい部分です。人が決めるのは、基準日の置き方、User-Agent名、lastmod が無いURLの扱いの3つです。
最も見落としやすいのは、標準ライブラリとRFCの食い違いです。一覧が小さくなったとき、サイトが静かだったのか、規則の解釈で落としたのかは、除外ログを見ないと区別できません。excluded.log を残す理由はそこにあります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。