◉ANTIGRAVITY LABEN
記事一覧/アプリ開発
▣ アプリ開発/2026-10-28中級

サイトマップの 155 URL を全部取りに行きました。全件 200 でしたが、lastmod の 93% が同じ日付でした

サイトマップの全 URL を実際に取得し、ステータス・canonical・hreflang・lastmod を突き合わせた記録です。標準ライブラリだけの Python 全文と、見つかったずれを載せました。

サイトマップhreflanglastmodPython25Cloudflare6個人開発121サイト運用

✦ プレミアム記事

サイトマップに載せた URL は、本当に全部、意図どおりの姿で開くのだろうか。個人開発の公式サイト dolice.net で、ふと確かめたくなりました。

これまでは、ブラウザで 5〜6 ページを開いて「大丈夫そうです」と済ませていました。抜き取りで見ているのは、全体の数 % です。

そこで、サイトマップを読み、載っている URL を全部取りに行く小さなスクリプトを書きました。エージェントに点検を任せる場合も、先に自分で一度通しておくと、返ってきた報告が正しいかを判断できます。

結果を先にお伝えします。155 本すべてが 200 を返し、hreflang の相互参照にも壊れはありませんでした。直す場所は見つからないはずでしたが、lastmod を並べたところで手が止まりました。

確認する項目を、先に 6 つ決めました

スクリプトを書く前に、何を確かめるかだけを決めておきます。あとから項目を足すと、結果に合わせて基準を動かしてしまいがちだからです。

項目見る場所ずれると起きること
ステータスURL を GET した結果404 や転送が載っていると、クロールの予算を捨てます
canonicalページの link タグサイトマップと別の URL を正と宣言していると、どちらかが無視されます
html の langhtml 要素パスは英語なのに lang が ja、という取り違えを拾います
noindexmeta robots載せた URL が検索に出さない設定だと、宣言が矛盾します
hreflangサイトマップとページの両方片方向だけの宣言は、言語版の紐づけごと無視されることがあります
lastmodサイトマップと Last-Modified ヘッダ更新日が信用できないと、再クロールの合図になりません

hreflang だけは、サイトマップの宣言とページ側のタグを別々に取って比べます。同じ内容を 2 か所に書いている以上、ずれる余地が 2 倍あるためです。

取得スクリプトの全文

標準ライブラリだけで動きます。サイトマップを読み、6 並列で全 URL を取得し、結果を JSON に書き出します。

作りで気を配ったのは 2 点です。1 つめは、転送を追わないこと。Python の urllib は既定で 301 や 302 を黙って追いかけるので、そのままだと「200 でした」という報告の裏に転送が隠れます。NoRedirect を差し込み、転送は転送として数えます。

2 つめは、取得に失敗した URL を捨てないことです。status: 0 と理由を残しておけば、あとで「数が合わない」と悩まずに済みます。

#!/usr/bin/env python3
"""sitemap_audit.py — サイトマップの全 URL を実際に取得し、宣言と実体のずれを数える。
使い方: python3 sitemap_audit.py https://example.com/sitemap.xml > report.json
"""
import json, re, sys, time, urllib.request, urllib.error
import xml.etree.ElementTree as ET
from concurrent.futures import ThreadPoolExecutor
 
NS = {"s": "http://www.sitemaps.org/schemas/sitemap/0.9",
      "x": "http://www.w3.org/1999/xhtml"}
UA = "sitemap-audit/1.0 (own-site check)"
 
class NoRedirect(urllib.request.HTTPRedirectHandler):
    def redirect_request(self, *a, **k):
        return None  # 301/302 を自分で観測する(追従しない)
 
opener = urllib.request.build_opener(NoRedirect)
 
def fetch(url):
    req = urllib.request.Request(url, headers={"User-Agent": UA})
    t0 = time.time()
    try:
        r = opener.open(req, timeout=20)
        status, headers, body = r.status, r.headers, r.read()
    except urllib.error.HTTPError as e:
        status, headers, body = e.code, e.headers, b""
    except Exception as e:
        return {"url": url, "status": 0, "error": str(e)[:80]}
    ms = int((time.time() - t0) * 1000)
    html = body.decode("utf-8", "replace")
    def first(p):
        m = re.search(p, html, re.I | re.S)
        return m.group(1).strip() if m else None
    canon = first(r'<link[^>]+rel=["\']canonical["\'][^>]+href=["\']([^"\']+)')
    lang = first(r'<html[^>]+lang=["\']([^"\']+)')
    robots = first(r'<meta[^>]+name=["\']robots["\'][^>]+content=["\']([^"\']+)')
    alts = dict(re.findall(
        r'<link[^>]+rel=["\']alternate["\'][^>]+hreflang=["\']([^"\']+)["\'][^>]+href=["\']([^"\']+)', html))
    return {"url": url, "status": status, "ms": ms,
            "location": headers.get("Location"), "canonical": canon,
            "html_lang": lang, "robots": robots, "page_hreflang": alts,
            "bytes": len(body)}
 
def main(sm_url):
    xml = urllib.request.urlopen(
        urllib.request.Request(sm_url, headers={"User-Agent": UA}), timeout=20).read()
    root = ET.fromstring(xml)
    entries = {}
    for u in root.findall("s:url", NS):
        loc = u.find("s:loc", NS).text.strip()
        alts = {l.get("hreflang"): l.get("href")
                for l in u.findall("x:link", NS) if l.get("rel") == "alternate"}
        entries[loc] = {"sm_hreflang": alts,
                        "lastmod": (u.find("s:lastmod", NS).text if u.find("s:lastmod", NS) is not None else None)}
    with ThreadPoolExecutor(6) as ex:
        results = {r["url"]: r for r in ex.map(fetch, entries)}
    out = []
    for url, e in entries.items():
        r = results[url]; r.update(e); out.append(r)
    json.dump(out, sys.stdout, ensure_ascii=False, indent=1)
 
if __name__ == "__main__":
    main(sys.argv[1])

実行は次のとおりです。155 本で 1 分かかりませんでした。取得時間は中央値 170ms、最大 267ms で、自分のサイトに 6 並列でも負荷は気になりませんでした。

python3 sitemap_audit.py https://dolice.net/sitemap.xml > report.json
✦

ここまでお読みいただきありがとうございます。

この記事の続きを読む

この先には、実装コードやベンチマーク結果など、実務でお役に立てる内容をご用意しています。このサイトは広告を掲載しておらず、サーバーや開発にかかる費用はメンバーの皆様のご支援で成り立っています。もしお役に立てていましたら、ご支援いただけますと大変ありがたいです。

この記事で得られること
✦自分のサイトのサイトマップに載る全 URL を、ステータス・canonical・hreflang まで 1 回のスクリプトで突き合わせ、手作業の抜き取り確認を終わらせられるようになります
✦hreflang の相互参照とページ側のタグのずれを機械で確かめ、検索結果で言語版が入れ替わる事故を公開前に防げるようになります
✦lastmod が 1 日に集中していないかを終了コードで見張り、更新の合図が効かなくなった状態に早く気づけるようになります
Stripe による安全な決済 · いつでもキャンセル可能
✦

この記事を購入する

この先の内容をすべてお読みいただけます。一度のご購入で、いつでも何度でもアクセスできます。このサイトは広告を掲載しておらず、皆さまのご支援がサーバー費用などの運営を支えています。

または
メンバーシップなら全記事が読み放題 →
シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

関連記事

▣ アプリ開発2026-10-20
PHP の API が no-store を返し、Cloudflare は素通しでした。エッジの TTL だけを上書きした設計メモ
壁紙アプリの一覧 API が no-store を返していたため、Cloudflare の前に置いても毎回オリジンまで届いていました。端末側の挙動は変えずにエッジの TTL だけを上書きする Cache Rule、効いたかを確かめる検査スクリプト、古い応答を出さないための「アップロードとパージを一組にする」手順までを書き残します。
▣ アプリ開発2026-10-18
エージェントに設定を任せる前に、.env* を無視していても .dev.vars が素通りだと分かり、点検スクリプトで先に塞ぎました
Cloudflare Workers のリポジトリを自分で点検すると、.gitignore の .env* では .dev.vars を守れていませんでした。git check-ignore を使い、エージェントに触らせる前の秘密の置き場を確かめる Python スクリプトの全文を残します。
▣ アプリ開発2026-10-15
immutable を付けた資産を同じ URL で差し替えたら、利用者のブラウザだけが古いままでした
長期キャッシュを付けた JS を同じ URL で差し替えても、CDN をパージしてもブラウザは取り直しません。同じ URL・no-cache・クエリ付き・内容ハッシュ名の 4 方式を Chromium で並べて比べ、ビルドの最後にハッシュを付ける完全なスクリプトまでをまとめました。