urllib.unquoteとunicode

urllib.unquoteはURLエンコードされた文字列を元に戻す関数だ。よくURLで使われている「%E6%97%A5%E6%9C%AC%E8%AA%9E」みたいな文字を元に戻す時に使う。

# Python 2.5.2 on win32
>>> a = urllib.unquote('%E6%97%A5%E6%9C%AC%E8%AA%9E')
>>> print a.decode('utf-8')
日本語

URLエンコード文字コードに関して何も規定していない。だから、デコードした結果を画面に出力する場合は適当な文字コードで変換してやる必要がある。上の例ではUTF-8を使っているが、サイトによっては、以下のようにShift_JISEUC-JPかもしれない。

>>> a = urllib.unquote('%93%FA%96%7B%8C%EA')
>>> print a.decode('shift_jis')
日本語
>>> a = urllib.unquote('%C6%FC%CB%DC%B8%EC')
>>> print a.decode('euc-jp')
日本語

ここで、もしunquoteの引数をunicodeで渡したら戻り値はどうなるだろうか? 似たような他の関数から言って、unicodeを返すのが妥当のような気がするし、実際そうなっている。

>>> a = urllib.unquote(u'%E6%97%A5%E6%9C%AC%E8%AA%9E')
>>> type(a)
<type 'unicode'>

しかし、この動作はおかしい。unquote関数は、渡されたデータがどの文字コードで符号化されているのか知らない。知らないのにunicodeにできるはずがない。

実際、変数aの中身をprintするとエラーになる。

>>> print a
Traceback (most recent call last):
  File "<stdin>", line 1, in <module>
UnicodeEncodeError: 'cp932' codec can't encode character u'\xe6' in position 0: illegal multibyte sequence

変数aの中身はこうである。

>>> a
u'\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e'

どこもおかしくないように見えるが、UTF-8で符号化されたままになっているので正しいunicodeではない。

unquoteのソースはこうだ。

# lib/urllib.py より引用

_hextochr = dict(('%02x' % i, chr(i)) for i in range(256))
_hextochr.update(('%02X' % i, chr(i)) for i in range(256))

def unquote(s):
    """unquote('abc%20def') -> 'abc def'."""
    res = s.split('%')
    for i in xrange(1, len(res)):
        item = res[i]
        try:
            res[i] = _hextochr[item[:2]] + item[2:]
        except KeyError:
            res[i] = '%' + item
        except UnicodeDecodeError:
            res[i] = unichr(int(item[:2], 16)) + item[2:]
    return "".join(res)

UnicodeDecodeErrorをキャッチしているので、一見するとunicodeに対応しているように見える。しかし、1バイトずつunichrをかけるという、よく意味のわかない処理が行われているので、UTF-8のままunicodeにされてしまう。

>>> a = urllib.unquote(u'%E6%97%A5%E6%9C%AC%E8%AA%9E')
>>> print a.encode('raw_unicode_escape').decode('utf-8')
日本語

のように二重変換すれば上手くいくが、このコードは何をしているのがすぐ理解できない。

自前で実装(改造)してもいいかもしれない。例えばこんなふうに。

def unquote_u(uni, enc='ascii'):
    # 変数 uni はURLエンコードされているのだから、
    # ascii以外入っていない。入っていたらおかしい。
    s = uni.encode('ascii')
    res = s.split('%')
    buf = [res[0],]
    for i in xrange(1, len(res)):
        item = res[i]
        buf.append(chr(int(item[:2], 16)))
        buf.append(item[2:])
    return ''.join(buf).decode(enc)

print unquote_u(u'%E6%97%A5%E6%9C%AC%E8%AA%9E', 'utf-8')
print unquote_u(u'%93%FA%96%7B%8C%EA', 'shift_jis')
print unquote_u(u'%C6%FC%CB%DC%B8%EC', 'euc-jp')

この方法は事前に変換テーブルを作らないので、オリジナルよりも遅い。それに今書いたので、ちゃんとテストしていない。

オリジナルのコードにあった split('%') はとてもいいアイデアだと思う。