APIをたたくコードを書いていて、URLの後ろに検索キーワードをつなげたくなったことはありませんか。文字列の足し算でなんとか作ってみたものの、日本語やスペースが入ったとたんに動かなくなる。そんな経験をした人も多いと思います。
逆に、ブラウザのアドレス欄からコピーしたURLが %E3%83%91%E3%82%A4... のような記号だらけになっていて、何が書いてあるのか読めないこともありますよね。
こうしたURLまわりの面倒ごとを引き受けてくれるのが、Pythonの標準ライブラリに入っているurllib.parseです。pipでのインストールは不要で、Pythonさえあればすぐに使えます。
今回は、URLの分解、組み立て、日本語の変換、相対パスの結合という4つの場面で使い方を見ていきます。実行結果は手元のPython 3.11で確かめたものです。
urllib.parseとは何か¶
まずは、このモジュールがどんな役割を持っているのかを整理しておきましょう。ひとことで言うと、URLという文字列を扱うための道具箱です。
urllibパッケージのうち、通信を担当するのはurllib.requestです。urllib.parseは通信をせず、URLの文字列を分けたり、つなげたり、変換したりするだけです。
通信そのものは、外部ライブラリのrequestsを使う人が多いでしょう。requestsの基本は、こちらの記事でまとめています。【関連記事】Pythonのrequestsとは?WebからデータをとってくるHTTP通信の基本を初心者向けに解説
URLはどんな部品でできているのか¶
関数の話に入る前に、URLの中身を分解して見てみましょう。部品の名前がわかると、関数の結果が一気に読みやすくなります。
例として、次のようなURLを考えます。
https://example.com:8080/blog/python?q=urllib&page=2#top
このURLは、次の表のような部品に分かれています。urllib.parseで取り出したときの名前もあわせて載せておきます。
| 部品 | この例での値 | urllib.parseでの名前 | 役割 |
|---|---|---|---|
| スキーム | https | scheme | 通信の方式 |
| ホストとポート | example.com:8080 | netloc | 接続先のサーバー |
| パス | /blog/python | path | サーバー内の場所 |
| クエリ | q=urllib&page=2 | query | 追加で渡す条件 |
| フラグメント | top | fragment | ページ内の位置 |
URLの書き方は、RFC 3986という仕様で決められています。部品の名前もこの仕様から来ています。
URLを分解するurlparseとurlsplit¶
いよいよ関数を使ってみます。まずは、URLを部品に分けるurlparseです。
次のコードは、さきほどのURLを分解して、それぞれの部品を表示します。
from urllib.parse import urlparse
url = "https://example.com:8080/blog/python?q=urllib&page=2#top"
result = urlparse(url)
print(result.scheme) # https
print(result.netloc) # example.com:8080
print(result.hostname) # example.com
print(result.port) # 8080
print(result.query) # q=urllib&page=2
結果はnamedtupleという形で返ってきます。result.scheme のように、名前で部品を取り出せるのが便利なところです。
netlocはホスト名とポート番号がくっついた状態ですが、hostnameやportを使えば別々に取り出せます。portは文字列ではなく整数の8080で返ってくるので、そのまま計算や比較に使えます。
urlsplitとの違い¶
よく似た関数に、urlsplitがあります。違いは、paramsという部品を分けるかどうかだけです。
paramsはパスの後ろにセミコロンで付ける古い書き方で、いまはほとんど見かけません。新しいコードではurlsplitで十分なことが多いです。
クエリ文字列を辞書にするparse_qs¶
URLの後ろに付いている ?q=urllib&page=2 の部分は、クエリ文字列と呼ばれます。これを辞書に変えてくれるのがparse_qsです。
次のコードでは、同じキーが2回出てくるクエリを読み取っています。
from urllib.parse import parse_qs, parse_qsl
query = "q=python&tag=a&tag=b"
print(parse_qs(query))
# {'q': ['python'], 'tag': ['a', 'b']}
print(parse_qsl(query))
# [('q', 'python'), ('tag', 'a'), ('tag', 'b')]
ここで注目してほしいのは、値がすべてリストになっている点です。qの値は1つしかないのに、'python' ではなく ['python'] で返ってきます。
なぜでしょうか。クエリでは同じキーを何度も書けるので、値の数を事前に決められないからです。
実は、私もこれで失敗したことがあります。エンジニアになって数年目のころ、params["page"] をそのまま数値に変換しようとしてエラーを出し、リストで返ってくることに気づくまでしばらく悩みました。
値が1つだとわかっている場合は、params["page"][0] のように先頭を取り出す癖をつけておくとよいでしょう。
もうひとつの注意点は、値が空のキーです。a=&b=1 を読み取ると、標準ではaが消えてしまいます。空の値も残したいときは、keep_blank_values=True を指定します。
辞書からクエリを組み立てるurlencode¶
今度は逆向きの作業です。辞書からクエリ文字列を作るには、urlencodeを使います。
次のコードは、日本語のキーワードとページ番号を組み合わせて、検索用のURLを作っています。
from urllib.parse import urlencode
params = {"q": "パイソン 入門", "page": 2}
query = urlencode(params)
url = "https://example.com/search?" + query
print(url)
# https://example.com/search?q=%E3%83%91%E3%82%A4%E3%82%BD%E3%83%B3+%E5%85%A5%E9%96%80&page=2
日本語は %E3%83%91 のような形に、スペースは + に置き換わりました。数値の2も、自動で文字列に変換されています。
文字列の足し算で "?q=" + keyword と書くと、この変換が行われません。& が混ざったキーワードは、URLの区切りと勘違いされて壊れます。クエリを作るときは、自分で文字列をつながず、urlencodeに任せるのが鉄則です。
リストを渡すときはdoseqを付ける¶
同じキーに複数の値を持たせたいときは、少し注意が必要です。値にリストを渡しただけでは、期待どおりになりません。
urlencode({"tag": ["a", "b"]}) を実行すると、tag=%5B%27a%27%2C+%27b%27%5D という結果になりました。リストがまるごと1つの文字列として扱われてしまったのです。
doseq=True を付けると、tag=a&tag=b のように、リストの要素ごとにキーが展開されます。parse_qsで読み取ったデータを戻すときは、このオプションを忘れないようにしましょう。
日本語を安全に変換するquoteとunquote¶
URLに使える文字は限られていて、日本語やスペースはそのままでは使えません。そこで、文字をUTF-8のバイト列にして、1バイトずつ % と16進数で表します。これをパーセントエンコーディングと呼びます。
文字とバイト列の関係がピンとこない人は、先にこちらを読んでおくと理解が深まります。【関連記事】Pythonの文字コードとは?文字化けとUnicodeDecodeErrorの直し方を初心者向けに解説
文字列をひとつだけ変換したいときは、quoteとquote_plusを使います。2つの違いは、スペースをどう表すかです。
次の表は、同じ文字列をそれぞれの関数に通した結果です。
| 関数 | パイソン 入門 の変換結果 | 主な使いどころ |
|---|---|---|
| quote | %E3%83%91%E3%82%A4%E3%82%BD%E3%83%B3%20%E5%85%A5%E9%96%80 | パスの一部 |
| quote_plus | %E3%83%91%E3%82%A4%E3%82%BD%E3%83%B3+%E5%85%A5%E9%96%80 | クエリの値 |
| unquote | 元の日本語に戻す | ログやURLを読むとき |
| unquote_plus | +もスペースに戻す | フォームの値を読むとき |
quoteはスペースを %20 に、quote_plusは + に変えます。urlencodeは標準でquote_plusを使っているので、さきほどの例でもスペースが + になっていました。
quoteはスラッシュを変換しない¶
quoteには、知らないとハマる仕様があります。標準では、スラッシュ / を変換せずにそのまま残すのです。
パス全体を変換しても区切りが壊れないための配慮ですが、IDの中にスラッシュがあると別の階層として解釈されてしまいます。
スラッシュも変換したいときは、quote("a/b", safe="") のように、safeを空にして呼び出します。結果は a%2Fb になりました。
相対パスを絶対URLに直すurljoin¶
Webページから集めたリンクには、/about や ../img/logo.png のような相対パスがよく混ざっています。これを完全なURLに直してくれるのがurljoinです。
スクレイピングでリンクを集めるときに必ずと言っていいほど使うので、こちらの記事でも登場しています。【関連記事】PythonのBeautifulSoupとは?Webページから情報を取り出すスクレイピングの基本を初心者向けに解説
urljoinの結果は、元になるURLの末尾にスラッシュがあるかどうかで変わります。次の表で、実際の動きを確かめてみましょう。
| 元のURL | つなぐもの | 結果 |
|---|---|---|
| https://example.com/blog/ | python | https://example.com/blog/python |
| https://example.com/blog | python | https://example.com/python |
| https://example.com/blog/a | /about | https://example.com/about |
| https://example.com/blog/a | ../x | https://example.com/x |
| https://example.com/a | https://other.com/b | https://other.com/b |
2行目を見てください。末尾のスラッシュがないと、blogはファイル名とみなされ、同じ階層のpythonに置き換わってしまいます。
APIのベースURLを設定ファイルに書くときは、末尾にスラッシュを付けるかどうかをチーム内で決めておくと、事故を防げます。
10年近く開発をしてきましたが、ステージング環境だけAPIが404を返すという調査で、原因が設定ファイルの末尾スラッシュ1文字だったことがありました。それ以来、URLの結合は必ずurljoinを通し、テストで結果を確かめるようにしています。
URLの一部だけを書き換える¶
最後に、ここまでの関数を組み合わせた実用的な例を紹介します。既存のURLから、クエリのページ番号だけを差し替えるコードです。
namedtupleの _replace で一部の値だけを入れ替え、urlunsplitで組み立て直すのがポイントです。
from urllib.parse import urlsplit, urlunsplit, parse_qs, urlencode
def set_page(url, page):
"""URLのクエリにあるpageだけを書き換えて返す"""
parts = urlsplit(url)
params = parse_qs(parts.query)
params["page"] = [str(page)]
new_query = urlencode(params, doseq=True)
return urlunsplit(parts._replace(query=new_query))
url = "https://example.com/search?q=python&page=1#result"
print(set_page(url, 3))
# https://example.com/search?q=python&page=3#result
分解して、辞書で書き換えて、組み立て直す。この流れを覚えておくと、文字列の置換に頼らずにURLを編集できます。
parse_qsの結果は値がリストなので、urlencodeには doseq=True を付けている点に注目してください。ここを忘れると、さきほど見たように %5B%27 のような文字が混ざります。
使うときに気をつけたいこと¶
便利なurllib.parseですが、万能ではありません。最後に、実務で意識しておきたい点を2つ挙げておきます。
ひとつ目は、urlparseやurlsplitは入力が正しいURLかどうかを検証しないことです。おかしな文字列を渡してもエラーにならず、それらしい結果を返してきます。公式ドキュメントにも、入力の検証は行わないという注意書きがあります。
ユーザーが入力したURLを使ってリダイレクトしたり、サーバーから別のサイトへアクセスしたりする場合は、分解した結果のスキームやホスト名を許可リストと照らし合わせましょう。これを怠ると、意図しないサイトへ誘導される脆弱性につながります。
2つ目は、Webフレームワークを使っているなら、URLを組み立てる専用の機能を優先することです。
Flaskでの書き方が気になる人は、こちらの記事で全体像をつかめます。【関連記事】PythonのFlaskとは?WebアプリをPythonで動かす仕組みを初心者向けに解説
まとめ¶
ここまで、urllib.parseの主な関数を見てきました。最後に、目的ごとに使う関数を振り返っておきましょう。
| やりたいこと | 使う関数 |
|---|---|
| URLを部品に分ける | urlsplit、urlparse |
| クエリを辞書にする | parse_qs、parse_qsl |
| 辞書からクエリを作る | urlencode |
| 文字列をひとつ変換する | quote、quote_plus |
| 変換された文字を戻す | unquote、unquote_plus |
| 相対パスをつなぐ | urljoin |
どれも文字列の足し算で済ませたくなる作業ですが、自分で書くと日本語や記号が入ったときに壊れやすくなります。
URLを触るときは、まずurllib.parseに任せられないかを考えてみてください。標準ライブラリなので、今日書くコードからすぐに使えます。
ここまでお読みいただきありがとうございました。