運営者がブログ運用のために作っているツール(Python製)では、WordPressにある記事と、ツール側で持っている記事の一覧を、URLで突き合わせています。このサイトの記事の作り方とツールの関係は運営者情報にまとめています。
この突き合わせで、日本語スラッグのページだけ、実在するのに「見つからない」と判定されることが2回ありました。どちらも、URLのパーセントエンコードの書き方が取得元によって違っていたのが原因です。
起きていたこと
1回目は2026年8月27日です。固定ページ(運営者情報)が「WordPressに見つからない記事」として数えられていました。記事は1本も消えていません。
ツール側の一覧には %E9%81%8B… と大文字の16進で入っていて、WordPressのREST APIからは %e9%81%8b… と小文字で返ってきていました。文字列として比べると別のURLになります。
このときは原因がもう1つ重なっていて、ツールが投稿(post)しか取得しておらず、固定ページ(page)を一度も見ていませんでした。表記を揃えるだけでも、固定ページを取得するだけでも直らず、両方を直しました。
2回目は9月5日、サイトマップとWordPressの link を突き合わせたときです。サイトマップには /tag/ログインできない/ のように日本語のまま載っていて、REST APIの link は %e3%83%ad… の形でした。大文字・小文字を揃えただけではこの2つは一致せず、タグ60件すべてが「測れていない」になりました。
同じ日に、日本語のURLをそのまま urllib へ渡して UnicodeEncodeError になるのにも当たっています。
手元で再現する
2026年10月19日に、同じ状況を urllib.parse だけで作って確かめました。ドメインは example.com にしています。
from urllib.parse import quote, unquote
slug = "運営者情報"
a = "https://example.com/" + quote(slug) + "/" # 大文字の16進
b = a.replace(quote(slug), quote(slug).lower()) # 小文字の16進
c = "https://example.com/" + slug + "/" # 日本語のまま
print("quote(slug) =", quote(slug))
print("A (大文字) =", a)
print("B (小文字) =", b)
print("C (日本語のまま) =", c)
print()
print("A == B ->", a == b)
print("A == C ->", a == c)
print("unquote(A)==unquote(B) ->", unquote(a) == unquote(b))
print("unquote(B)==C ->", unquote(b) == c)
quote(slug) = %E9%81%8B%E5%96%B6%E8%80%85%E6%83%85%E5%A0%B1
A (大文字) = https://example.com/%E9%81%8B%E5%96%B6%E8%80%85%E6%83%85%E5%A0%B1/
B (小文字) = https://example.com/%e9%81%8b%e5%96%b6%e8%80%85%e6%83%85%e5%a0%b1/
C (日本語のまま) = https://example.com/運営者情報/
A == B -> False
A == C -> False
unquote(A)==unquote(B) -> True
unquote(B)==C -> True
quote() は16進を大文字で出します。WordPressが返した小文字の形とは、見た目が違うだけで同じページです。それでも == では False になります。unquote() で日本語へ戻してから比べると、3つとも一致します。
揃え方は2つある
16進を大文字に揃える
RFC 3986 の6.2.2.1には、パーセントエンコードの16進は大文字・小文字を区別せず、大文字へ揃えるべきだと書かれています(RFC 3986)。
For all URIs, the hexadecimal digits within a percent-encoding
triplet (e.g., "%3a" versus "%3A") are case-insensitive and therefore
should be normalized to use uppercase letters for the digits A-F.
ツールに入れたのは次の関数です。英数字と -._~ にあたるものだけは元の文字へ戻し、それ以外(日本語のバイト列を含む)は大文字にするだけで、デコードはしません。
import re
_UNRESERVED = frozenset(
"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~")
_PCT = re.compile(r"%[0-9A-Fa-f]{2}")
def normalize_percent_encoding(text):
def repl(m):
hexpart = m.group(0)[1:]
code = int(hexpart, 16)
if code < 128 and chr(code) in _UNRESERVED:
return chr(code)
return "%" + hexpart.upper()
return _PCT.sub(repl, text)
normalize(A) = https://example.com/%E9%81%8B%E5%96%B6%E8%80%85%E6%83%85%E5%A0%B1/
normalize(B) = https://example.com/%E9%81%8B%E5%96%B6%E8%80%85%E6%83%85%E5%A0%B1/
normalize(A)==normalize(B) -> True
normalize(B)==normalize(C) -> False
大文字と小文字の差はこれで消えます。ただし、最後の行のとおり日本語のままのURLとは一致しません。1回目はこれで足りましたが、2回目には足りませんでした。
デコードしてから比べる
サイトマップの日本語と比べるには、unquote() で戻してから比べます。ツールでは、比較だけに使うキーを作る関数を別に置きました。保存するURLや、WordPressへ送るURLはこれで書き換えません。
デコードには注意が1つあります。%2F は大文字化だけなら %2F のまま残りますが、デコードすると / になり、区切りと見分けがつかなくなります。
%7E -> /a~b/ %2F -> /a%2Fb/
unquote('/a%2Fb/') = /a/b/
記事やタグのスラッグに / が入ることはないので、このツールの照合ではデコードしてから比べています。URLの中身を問わない汎用の比較なら、大文字化だけにとどめるほうが安全です。
逆向き、つまり日本語のURLへリクエストを送るときは、quote() でエンコードしてから urllib へ渡します。
ツール側で直したこと
調べてみると、canonical の検査には大文字へ揃える処理が書いてあり、記事の突き合わせのほうには入っていませんでした。同じ処理が場所ごとにあったり無かったりしたので、片方では同じURL、もう片方では別のURLになっていました。
そこで、正規化は共通のURL正規化関数の1か所に入れ、canonical の検査もそれを読み込むようにしました。WordPressとのやり取りはREST APIで行っていて、取得の経路が増えるたびに正規化を書き足していたのが根っこだったと思います。
自分のコードで確かめること
- URLを
==やinで比べている箇所が、比べる前に同じ正規化を通っているか - サイトマップ・REST API・ブラウザのアドレス欄など、取得元ごとに表記が違わないか(日本語のまま/大文字/小文字)
- 正規化の関数が経路ごとに別々に書かれていないか
- デコードして比べるなら、
%2Fのような区切り文字を潰しても困らない対象か - 日本語を含むURLへリクエストするとき、
quote()を通しているか
検証した環境
この記事の調査・再現・執筆は、運営者の指示のもとでAIエージェント(AnthropicのClaude)が行い、公開するかどうかは運営者が決めています。載せた出力は、そのAIエージェントがこのMacで実際にコマンドを実行して得たものです。
- macOS 26.6.2
- Python 3.12.13(標準ライブラリの urllib.parse のみ)
- 再現は2026年10月19日。WordPressには接続していません