サイトマップに載せた URL は、本当に全部、意図どおりの姿で開くのだろうか。個人開発の公式サイト dolice.net で、ふと確かめたくなりました。
これまでは、ブラウザで 5〜6 ページを開いて「大丈夫そうです」と済ませていました。抜き取りで見ているのは、全体の数 % です。
そこで、サイトマップを読み、載っている URL を全部取りに行く小さなスクリプトを書きました。エージェントに点検を任せる場合も、先に自分で一度通しておくと、返ってきた報告が正しいかを判断できます。
結果を先にお伝えします。155 本すべてが 200 を返し、hreflang の相互参照にも壊れはありませんでした。直す場所は見つからないはずでしたが、lastmod を並べたところで手が止まりました。
確認する項目を、先に 6 つ決めました
スクリプトを書く前に、何を確かめるかだけを決めておきます。あとから項目を足すと、結果に合わせて基準を動かしてしまいがちだからです。
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
突き合わせのスクリプト
取得と判定を分けています。取得は通信を伴うので 1 回で済ませ、判定は手元の JSON に対して何度でも書き直せるようにするためです。
次のスクリプトは、前節の 6 項目のうち lastmod 以外を見ます。ずれを種類ごとに数え、各種類の例を 3 件まで表示します。
import json, collections, sys
d = json.load( open (sys.argv[ 1 ])); by = {r[ "url" ]: r for r in d}
c = collections.Counter()
ex = collections.defaultdict( list )
def flag (k, u, note = "" ):
c[k] += 1
if len (ex[k]) < 3 : ex[k].append((u, note))
for r in d:
u = r[ "url" ]
if r[ "canonical" ] != u: flag( "canonical≠loc" , u, str (r[ "canonical" ]))
en = "/en/" in u or u.endswith( "/en" )
want = "en" if en else "ja"
if (r[ "html_lang" ] or "" )[: 2 ] != want: flag( "html_lang≠path" , u, str (r[ "html_lang" ]))
if r[ "robots" ] and "noindex" in r[ "robots" ].lower(): flag( "noindex-in-sitemap" , u, r[ "robots" ])
if not r[ "sm_hreflang" ]: flag( "sm:no-hreflang" , u)
for lang, href in r[ "sm_hreflang" ].items():
t = by.get(href)
if t is None : flag( "sm:alt-not-in-sitemap" , u, f " { lang } -> { href } " ); continue
back = t[ "sm_hreflang" ].get(lang)
if back != href: flag( "sm:not-reciprocal" , u, f " { lang } -> { href } back= { back } " )
if r[ "sm_hreflang" ] and r[ "page_hreflang" ] != r[ "sm_hreflang" ]:
flag( "sitemap≠page-hreflang" , u, f "sm= { sorted (r[ 'sm_hreflang' ]) } page= { sorted (r[ 'page_hreflang' ]) } " )
if r[ "sm_hreflang" ] and not r[ "page_hreflang" ]: flag( "page:no-hreflang-tags" , u)
if r[ "ms" ] > 1500 : flag( "slow>1.5s" , u, str (r[ "ms" ]))
print ( "URLs" , len (d), "ms median" , sorted (r[ "ms" ] for r in d)[ len (d) // 2 ], "max" , max (r[ "ms" ] for r in d))
for k, v in c.most_common(): print (k, v, ex[k][: 2 ])
print ( "with-hreflang" , sum ( 1 for r in d if r[ "sm_hreflang" ]), "without" , sum ( 1 for r in d if not r[ "sm_hreflang" ]))
hreflang の相互参照は、サイトマップ内で「ja から en へ宣言したなら、en 側も ja を同じ URL で宣言しているか」を見ています。ここは、書き手が 1 か所だけ直して片側が古いまま残る、よくある破損の入り口です。
結果は、拍子抜けするほどきれいでした
hreflang の宣言がない 1 件は /privacy/ でした。英語版の /en/privacy/ は実際に 404 で、サイトマップも言語版の宣言を持たずに載せています。宣言と実体が一致しているので、問題ではありません。
もし最初の確認が目視だけだったら、私はここで「問題なし」と書いて終えていました。きれいな結果は、確認の網が粗くないかを疑うきっかけにもなります。そこで、点検の対象にしていなかった lastmod を見に行きました。
lastmod の 93% が、同じ日付でした
lastmod は、クローラーに「この URL は前回から変わりました」と伝える合図です。ほぼ全部が同じ日付なら、合図として機能しません。
その偏りを数える 12 行のスクリプトです。最も多い日付が全体の何割かを出し、割合が上限を超えたら終了コード 1 で終わります。
#!/usr/bin/env python3
"""lastmod_check.py — sitemap の lastmod が 1 日に集中していないかを見る。
usage: python3 lastmod_check.py report.json [--max-share 0.5]
終了コード 0=問題なし / 1=集中(最多の日付が全体の max-share を超えた)"""
import collections, json, sys
rows = json.load( open (sys.argv[ 1 ]))
limit = float (sys.argv[sys.argv.index( "--max-share" ) + 1 ]) if "--max-share" in sys.argv else 0.5
days = collections.Counter((r[ "lastmod" ] or "none" )[: 10 ] for r in rows)
day, n = days.most_common( 1 )[ 0 ]
share = n / len (rows)
print ( f " { len (rows) } URLs / 最多の日付 { day } が { n } 件 ( { share :.0% } ) / 日付の種類 { len (days) } " )
sys.exit( 1 if share > limit else 0 )
155 URLs / 最多の日付 2026-10-28 が 144 件 (93%) / 日付の種類 3
日付は 3 種類しかありませんでした。内訳は、/art/ 配下の 144 件がすべて取得当日の 2026-10-28、残る 11 件(トップ・アプリの紹介ページ・プライバシーポリシー)が 2026-07-25 か 07-26 です。
さらに、サイトマップ先頭のコメントには「全 11 URL を掲載」と書いたままでした。手書きだった時期の記述が残っていて、現在の 155 件とは合っていません。コメントは検索には影響しませんが、保守する側が実態を取り違える原因になります。
Last-Modified ヘッダで裏を取ろうとして、外れました
「当日」が本当なのか確かめるため、ページ自身が返す Last-Modified ヘッダと比べました。ヘッダが正しければ、lastmod の真偽が分かるはずだと考えたからです。
#!/usr/bin/env python3
"""lastmod_vs_header.py — sitemap の lastmod と、実際の Last-Modified ヘッダを日単位で比べる。
usage: python3 lastmod_vs_header.py report.json"""
import datetime, email.utils, json, statistics, sys, urllib.request
from concurrent.futures import ThreadPoolExecutor
rows = json.load( open (sys.argv[ 1 ]))
def head (row):
req = urllib.request.Request(row[ "url" ], method = "HEAD" ,
headers = { "User-Agent" : "sitemap-audit/1.0 (own-site check)" })
try :
return row[ "url" ], urllib.request.urlopen(req, timeout = 20 ).headers.get( "Last-Modified" )
except Exception :
return row[ "url" ], None
with ThreadPoolExecutor( 6 ) as ex:
header = dict (ex.map(head, rows))
gaps, skipped = [], 0
for r in rows:
h, lm = header[r[ "url" ]], r[ "lastmod" ]
if not h or not lm:
skipped += 1 # どちらかが無い URL は比べない
continue
h_day = email.utils.parsedate_to_datetime(h).date()
s_day = datetime.date.fromisoformat(lm[: 10 ])
gaps.append(((h_day - s_day).days, r[ "url" ]))
newer = [g for g in gaps if g[ 0 ] > 0 ]
print ( f "比較 { len (gaps) } 件 / 比べられず { skipped } 件" )
print ( f "ヘッダのほうが新しい: { len (newer) } 件 / 差の中央値: { statistics.median(g[ 0 ] for g in gaps) } 日 / 最大: { max (g[ 0 ] for g in gaps) } 日" )
比較 154 件 / 比べられず 1 件
ヘッダのほうが新しい: 10 件 / 差の中央値: 0.0 日 / 最大: 95 日
/art/ の 144 件は、ヘッダも当日でした。手書きの 11 件のうち比べられた 10 件では、ヘッダのほうが最大 95 日新しくなっています。lastmod は 7 月のままなのに、ページは今日配信されたものです。
ここで私が思い違いをしていた点があります。Last-Modified を「中身が最後に変わった日」と読んでいましたが、このサイトでは再デプロイのたびに全ページで更新されているように見えます。手書きの 11 件は、7 月から内容が変わっていないのに、ヘッダだけが新しくなっていました。
つまり、ヘッダは裏取りの基準になりません。外から見える情報だけでは、/art/ の 144 件が今日本当に変わったのか、ビルドの日付が入っただけなのかは区別できませんでした。区別できるのは、生成側の元データ、つまり各ページの更新日を持っている場所だけです。
直すなら、日付の出どころをページごとに持たせます
私のサイトでは、まだ直していません。方針だけを決めています。lastmod にビルドの日付を入れるのをやめ、各ページが自分の更新日を持つ元データから取ります。
Git の履歴から取る方法を最初に思いつきますが、落とし穴があります。CI は速さのために git clone --depth 1 を使うことが多く、浅い履歴ではどのファイルの最終コミット日も、先頭コミットの日付になります。
git log -1 --format=%cs -- content/articles/ja/tips/mdx-body-link-orphans-vs-related-widget-link-audit.mdx
# 2026-10-28 ← 実際の最終更新は 10-23。浅いクローンでは先頭コミットの日付が返る
このサイトのリポジトリを浅くクローンして、10/23 に書いた別の記事で試したところ、ファイルを最後に触った日ではなく、クローンした当日の日付が返りました。lastmod を Git から取るなら履歴を深く取得するか、フロントマターに更新日を書いて、そちらを正本にするほうが安全です。
つまずきやすい所
転送を追わせない : 既定の挙動のまま取得すると、301 の先の 200 が「正常」として数えられます。サイトマップに載せた URL が転送されるのは、それ自体が修正対象です。関連する切り分けは Search Console の「ページにリダイレクトがあります」を言語別に切り分ける にも書きました。
HEAD だけで済ませない : canonical や hreflang は本文の中にあります。ヘッダだけの確認では見えません。lastmod の比較だけが HEAD です。
他人のサイトに 6 並列をかけない : このスクリプトは自分のサイトを前提にしています。User-Agent に点検であることを書き、並列度は 1 桁に抑えてください。
キャッシュ設定を疑う : ヘッダの Last-Modified や Cache-Control は、CDN が上書きしていることがあります。エッジ側の挙動は no-store を返す API と Cloudflare のエッジ TTL の扱い で整理しています。
きれいな結果を疑う : 項目を決めてすべて通った場合は、「決めていない項目に何があるか」を 1 つだけ探してください。今回の lastmod は、その問いから見つかりました。
エージェントに頼むときの依頼文
点検スクリプトを Antigravity のようなエージェントに書かせる場合、次の依頼文をそのまま渡せます。ポイントは、確認項目と「やらないこと」を先に書くことです。
サイトマップ(https://example.com/sitemap.xml)の全 URL を点検する Python スクリプトを書いてください。
- 標準ライブラリのみ。並列は 6 まで。User-Agent に点検であることを入れる
- 転送は追わず、3xx は 3xx として記録する。取得失敗は status 0 と理由を残す
- 見る項目: ステータス / canonical / html の lang / meta robots / hreflang(サイトマップとページの両方)/ lastmod
- 取得と判定は別のスクリプトに分け、取得結果は JSON に保存する
- 判定では、違反の種類ごとの件数と、各 3 件までの例を出す
やらないこと: 自分のサイト以外への実行、結果に合わせた判定基準の変更
返ってきたスクリプトの検算には、記事のスクリプトを使えます。同じサイトで件数が一致するか、転送を追っていないかを見るだけで、手戻りの大半は拾えます。内部リンクの側の数え直しは 本文リンクの入り口がない記事を数え直した記録 にまとめています。
次にやること
まず、自分のサイトで sitemap_audit.py を 1 回だけ流してみてください。155 本でも 1 分かかりません。
次に、lastmod_check.py の終了コードを、公開前の確認に 1 行加えます。lastmod の出どころを各ページの更新日へ切り替えたあとは、同じ 2 本のスクリプトで日付が分散したかを確かめる予定です。
結果が全部きれいでも、きれいなままで終わらせないこと。今回はそれが、いちばんの収穫でした。