シェルスクリプトでURLエンコードをもっと簡単に行う

## はじめに

この記事では、シェルスクリプトで URL エンコードをもっと簡単に行う方法について解説しました。[手っ取り早く答えを知りたい方はここ](#url-の組み立てを簡単に行う) をクリックしてください。

シェルスクリプトで URL エンコードの仕方を調べると色々な方法が見つかります。例えばこのようなものです。

```console
$ echo "日本語" | jq -Rr @uri 
%E6%97%A5%E6%9C%AC%E8%AA%9E

$ echo "日本語" | nkf -WwMQ | sed 's/=$//g' | tr = % | tr -d '\n'
%E6%97%A5%E6%9C%AC%E8%AA%9E
```

たしかにこれは URL エンコードです。しかし、みなさん、本当にこれで満足しているのでしょうか？ 使いやすいでしょうか？ 実際に URL エンコードを使っている場面を思い出してみてください。

## URL エンコードだけでは実用的ではない

Google の検索 URL を見てみましょう。次のようになっています。

```yaml
# URL エンコードした状態
https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E&ie=UTF-8

# URL デコードした状態
https://www.google.com/search?q=日本語&ie=UTF-8
```

では、この URL エンコードした文字列作るにはどうしたら良いでしょうか？

```console
$ echo "https://www.google.com/search?q=日本語&ie=UTF-8" | jq -Rr @uri 
https%3A%2F%2Fwww.google.com%2Fsearch%3Fq%3D%E6%97%A5%E6%9C%AC%E8%AA%9E%26ie%3DUTF-8
```

できませんよね？

理由をわかっている人はすぐに正解を出せるでしょう。次のように書きます。

```console
$ echo "https://www.google.com/search?q=$(echo "日本語" | jq -Rr @uri)&ie=UTF-8"
https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E&ie=UTF-8
```

少々読みにくいので、シェルスクリプトで書くならこのように書くでしょうか？

```sh
q=$(echo "日本語" | jq -Rr @uri)
echo "https://www.google.com/search?q=${q}&ie=UTF-8"
```

このように書かなければいけない理由は、URL に含まれている `:`、`/`、`?`、`&` などが URL エンコードの対象の文字だからです。この問題に近い話は JavaScript では `encodeURI()` と `encodeURIComponent()` の違いとして知られています。

```console
$ node
Welcome to Node.js v20.5.0.
Type ".help" for more information.
> encodeURI("https://www.google.com/search?q=日本語&ie=UTF-8")
'https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E&ie=UTF-8'
> encodeURIComponent("https://www.google.com/search?q=日本語&ie=UTF-8")
'https%3A%2F%2Fwww.google.com%2Fsearch%3Fq%3D%E6%97%A5%E6%9C%AC%E8%AA%9E%26ie%3DUTF-8'
```

私達が本当に欲しいものは `encodeURI` ・・・ というわけでもなく、クエリーパラメーター自体にエンコード対象の文字が含まれている場合はエンコードをしなければなりません。つまりどの部分に含まれているかでエンコードするかしないかが変わってくるということです。今回は一つのパラメータだけをエンコードすれば十分ですが、エンコードするパラメータが複数あったとしたら・・・。

```sh
query=$(echo "日本語" | jq -Rr @uri)
genre=$(echo "和書" | jq -Rr @uri)
author=$(echo "作者" | jq -Rr @uri)
comment=$(printf '%s\n' "1行目" "2行目" | jq -sRr @uri)
url="https://www.example.com/search?q=${query}&g=${genre}&a=${author}&c=${comment}"
```

パラメータの数が増えるたびに行が増え可読性が悪くなっていきます。また `jq` コマンドを何度も呼び出しているのでパフォーマンスも悪いです。`echo` コマンドは移植性が低いから `printf "%s"` にしなければいけないとか、改行文字を扱う（HTML フォームの textarea では改行が使えます）には `-s` オプションが必要などの細かい話もあり、それらに対処していくとますます可読性は下がってしまいます。

## URL の組み立てを簡単に行う

多くの場合、本当に欲しいものは URL エンコードだけではありません。URL エンコード機能が含まれた「URL の組み立て」です。

### jq コマンドを使う方法

`jq` コマンドはパラメータを一つ一つ URL エンコードせずとも、まとめて行う方法がちゃんと用意されています。

```console:jq コマンドを使った URL の組み立て
$ jq --arg q 日本語 --arg ie UTF-8 -nr '@uri "https://www.google.com/search?q=\($q)&ie=\($ie)"'
https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E&ie=UTF-8

$ jq --arg q $'foo\nbar' --arg ie UTF-8 -nr '@uri "https://www.google.com/search?q=\($q)&ie=\($ie)"'
https://www.google.com/search?q=foo%0Abar&ie=UTF-8   ← 改行文字 %0A も扱えます
```

`jq` コマンドを使う場合、URL の組み立てにはこの方法を使うことをおすすめします。

### url コマンド（自作）を使う方法

`jq` コマンドも悪くないのですが少々冗長です。そこでもっと簡単に使える `url` コマンドを作りました（[ソースコードはこちら](https://github.com/ko1nksm/url)）。実装はシェルスクリプトです。`jq` コマンドは使用しておらず `awk` のみを使っているためどの環境でも動作するはずです。

URL の組み立てはこのように書くことができます。クエリーパラメーターが自動的に URL エンコードされます。ちなみにクエリーパラメーターのキーもエンコードの対象です。

```console:url コマンドを使った URL の組み立て
$ url 'https://www.google.com/search' -q 日本語 -ie UTF-8
https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E&ie=UTF-8

$ url 'https://example.com/' -キー 123
https://example.com/?%E3%82%AD%E3%83%BC=123
```

最初の引数（URL）もエンコード対象ですが、こちらは JavaScript でいう `encodeURI` 相当のエンコードを行い、`:` `.` `/` などはエンコードしません。

```console:URL 部分は encodeURI 相当のエンコードを行う
$ ./url 'https://ja.wikipedia.org/wiki/URLエンコード'
https://ja.wikipedia.org/wiki/URL%E3%82%A8%E3%83%B3%E3%82%B3%E3%83%BC%E3%83%89
```

クエリーパラメーターの形式になっていない変則的なクエリストリングやフラグメントにも対応しています。

```console:変則的な URL にも対応
$ url 'https://www.google.com/search' '=日本語' '#フラグメント'
https://www.google.com/search?%E6%97%A5%E6%9C%AC%E8%AA%9E#%E3%83%95%E3%83%A9%E3%82%B0%E3%83%A1%E3%83%B3%E3%83%88
```

`--printf` オプションを指定すると printf mode となり、`printf` コマンドと同じように書式を用いて URL エンコードを行うことができます。`printf` コマンドと同じように引数を多く指定するとその分繰り返して出力されます。

```console:複数の URL を同時に生成
$ url --printf 'https://www.google.com/search?q=%s&ie=%s\n' 日本語 UTF-8 英語 UTF-8
https://www.google.com/search?q=%E6%97%A5%E6%9C%AC%E8%AA%9E&ie=UTF-8
https://www.google.com/search?q=%E8%8B%B1%E8%AA%9E&ie=UTF-8
```

最初の引数は URL である必要はないので、URL エンコードのみを行うこともできます。もちろん引数に改行文字が含まれていても対応可能です。入力を標準入力（パイプ）で受け取る方法だとこうはいきません。

```console
$ url --printf '%s\n' あいうえお アイウエオ $'a\nb'
%E3%81%82%E3%81%84%E3%81%86%E3%81%88%E3%81%8A
%E3%82%A2%E3%82%A4%E3%82%A6%E3%82%A8%E3%82%AA
a%0Ab
```

これを応用すると複数の URL エンコードした文字列をシェル変数に代入することができます。エンコードしたい文字列の数が多いときに一つ一つエンコードするよりも速いです。

```console
$ url --printf '%s="%s"\n' hiragana あいうえお katakana アイウエオ
hiragana="%E3%81%82%E3%81%84%E3%81%86%E3%81%88%E3%81%8A"
katakana="%E3%82%A2%E3%82%A4%E3%82%A6%E3%82%A8%E3%82%AA"
```

```sh
encoded=$(url --printf '%s="%s"\n' hiragana あいうえお katakana アイウエオ)
eval "$encoded"

echo "$hiragana" # => %E3%81%82%E3%81%84%E3%81%86%E3%81%88%E3%81%8A
echo "$katakana" # => %E3%82%A2%E3%82%A4%E3%82%A6%E3%82%A8%E3%82%AA
```

その他に、空白を `%20` ではなく `+` に変換する `-s` オプション、改行を `\r\n` に統一する `-n` オプションを持っています。

```console
$ url --printf -s -n '%s\n' 'foo bar' $'foo\nbar'
foo+bar
foo%0D%0Abar
```


## おまけ URL デコードはどうするの？

URL のパースとしては不完全な方法ですが `nkf --url-input` を使うのが簡単です。なぜ不完全なのかというと、**URL エンコードされた文字列全体を、そのまま URL デコードしても妥当な形式にならない**からです。例えばクエリーパラメーターの値として `&` をエンコードした `%26` が含まれている時、それを URL デコードすると困った URL が生成されてしまいます。

```console:単純に nkf --url-input を使用すると URL のパースができない形式になる
$ jq --arg q 'try&error' --arg ie UTF-8 -nr '@uri "https://www.google.com/search?q=\($q)&ie=\($ie)"
https://www.google.com/search?q=try%26error&ie=UTF-8

$ echo 'https://www.google.com/search?q=try%26error&ie=UTF-8' | nkf --url-input
https://www.google.com/search?q=try&error&ie=UTF-8

【読みやすくスペースを入れると】
https://www.google.com/search ? q=try & error & ie=UTF-8
```

`error` という値なしのキー（？）が作られてしまいます。もし `error` の文字列が  `err=or` だったりしたら `err` キーの登場です。つまり **URL をパースする時は先にクエリーパラメーターを分解してから、個々のパラメータごとに URL デコードを行わなければならない**わけです。

で、対話シェルでそんなユースケースはあるんですか？と。対話シェルから URL デコードを行いたい場合の多くはおそらくログなどからどのような文字列であるかを知りたい時だと思います。URL として正しく解釈する必要はなく「大雑把に読めれば良い」という使い方で十分なら `nkf --url-input` に投げてしまえばそれで十分です。

その反対に URL としてきちんと解釈したい場合、例えば CGI やスクレイピングでクエリーパラメーターを読み取る場合は、ただの URL デコードではなく URL のパース機能が必要になります。URL エンコードされた文字列には改行が含まれることもありますし、同じ名前のパラメータが複数あったり、順番が重要だったり、さまざまな考慮事項が必要になります。

```console:URLに改行が含まれている場合の例
$ echo 'https://www.google.com/search?q=try%0Aerror&ie=UTF-8' | nkf --url-input
https://www.google.com/search?q=try
error&ie=UTF-8
```

つまり

- 対話シェルから雑に URL エンコードが含まれた URL を読み取りたい
- シェルスクリプトから URL のクエリーパラメーターを解釈したい

この 2 つで必要となるものは別なのです。`nkf --url-input` は URL デコードを行い人が読めるようにしますが、それだけでは URL を正しくパースすることはできません。

で、正しく URL をパースする方法は考えてはいるのですが、シェルスクリプトで必要な人っているのでしょうか？ 必要になるのは CGI やスクレイピングをするときぐらいで、そういう用途なら別の言語を使ったほうが良いでしょう。個人的には興味があるのでやってみたいと思ってはいますが私個人の優先順位は高くありません。URL エンコードは `curl` （`--data-urlencode` では足りない場合がある）や `wget` コマンドの呼び出しで必要になったので作りました。

URL のパースが可能な本格的なものを作るのは後回しということで、単に読みたいだけであれば `nkf --url-input` を使ってくださいというのが本記事での答えとなります。他にもシェル言語だけや POSIX コマンドだけで実現する方法も探せば見つかります。

## さいごに

以上 URL エンコードをもっと簡単に行う方法でした。[url](https://github.com/ko1nksm/url) のライセンスは 0BSD にしているのでご自由にお使いください。`url` コマンドをインストールしたくないという人もいるかもしれませんが、内部のシェル関数 `urlbuild` と `urlprintf` を再利用しやすいように作っており「あなたのシェルスクリプト」にコピーして使うことができます。