はじめに

Webサーバーのログを分析するときなど、URLエンコーディングされたままだと読みづらいので、デコードしたいことがあります。このとき、元の文字コードがUTF-8であれば urltoolsパッケージurl_decode()を使えばよいのですが、ファイル名のURLなどでShift_JIS(正確にはCP932)になっていると、正しくデコードしてくれません。

そこで、CP932専用の関数url_decode_sjis()を書き捨てで書いてみることにします。この過程で、

  • stringr(やstringi)パッケージ群
  • 無名関数
  • 関数のベクトル対応

を確認していきましょう。

url_decode()の動作確認

UTF-8の場合

まず、もともとの文字コードがUTF-8であった場合を確認します。

library(tidyverse)

urls_utf8 <- 
  tribble(
    ~url,
    "utf8.example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.xlsx",
    "utf8.example.com/PDF%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.pdf",
    "utf8.example.com/%E2%91%A0%E7%92%B0%E5%A2%83%E4%BE%9D%E5%AD%98%E6%96%87%E5%AD%97.docx"
  )

urls_utf8
## # A tibble: 3 × 1
##   url                                                                           
##   <chr>                                                                         
## 1 utf8.example.com/%E6%97%A5%E6%9C%AC%E8%AA%9E%E3%83%95%E3%82%A1%E3%82%A4%E3%83…
## 2 utf8.example.com/PDF%E3%83%95%E3%82%A1%E3%82%A4%E3%83%AB.pdf                  
## 3 utf8.example.com/%E2%91%A0%E7%92%B0%E5%A2%83%E4%BE%9D%E5%AD%98%E6%96%87%E5%AD…

このURLをデコードするのは、url_decode()を使います。この関数が引数としてベクトルを取っていることがわかります。

library(urltools)

urls_utf8 |> 
  mutate(url_decoded = url_decode(url)) |>
  select(url_decoded)
## # A tibble: 3 × 1
##   url_decoded                         
##   <chr>                               
## 1 utf8.example.com/日本語ファイル.xlsx
## 2 utf8.example.com/PDFファイル.pdf    
## 3 utf8.example.com/①環境依存文字.docx

SJISの場合

では、SJISの場合はどうでしょうか。同じように使ってみても、文字化けしています。そして残念ながらurl_decode()は、 Pythonのurllib.parseと違ってencodingオプションがありません。

urls_sjis <- 
  tribble(
    ~url,
    "sjis.example.com/%93%FA%96%7B%8C%EA%83t%83@%83C%83%8B.xlsx",
    "sjis.example.com/PDF%83t%83@%83C%83%8B.pdf",
    "sjis.example.com/%87@%8A%C2%8B%AB%88%CB%91%B6%95%B6%8E%9A.docx"
  )

urls_sjis |> 
  mutate(url_decoded = url_decode(url)) |> 
  select(url_decoded)
## # A tibble: 3 × 1
##   url_decoded                              
##   <chr>                                    
## 1 "sjis.example.com/���{��t�@�C��.xlsx"    
## 2 "sjis.example.com/PDF�t�@�C��.pdf"       
## 3 "sjis.example.com/�@�\u008b��ˑ�����.docx"

url_decode_sjis()を作る

スカラーで始める

私はあまり抽象化能力が高くないので、手始めに文字列(「日本語ファイル.xlsx」をSJISでURLエンコーディングしたもの)を 1つ取り上げ、その文字列のデコードを試みます。

url_sjis <- "%93%FA%96%7B%8C%EA%83t%83@%83C%83%8B.xlsx"

直感的には、「%93%FA」となっている部分をバイト列「\x93\xfa」に変換したうえで、iconv()や類似の関数でエンコーディング変換すればよさそうです。

ただし、たとえば「%83t」が気になります。これは「\x83\x74」で「フ」にあたりますが、\x74が7bitの範囲に収まるのでパーセント符号化されていません。実際のところは

"\x83t" |> 
  iconv(from = "cp932", to = "utf8")
## [1] "フ"

と適切に処理してくれるのですが、問題はパーセント符号と普通の文字とが混在しているときに、変換が面倒そうなところです。

そこで今回は、すべてをいちどバイト列で表現して変換する方針を採用しました。

以下にパイプの各段階での出力をコメントとして付したコード片を掲げます。

url_sjis |> 
    # [1] "%93%FA%96%7B%8C%EA%83t%83@%83C%83%8B.xlsx"
    #
  str_extract_all("(%.{2}|.)") |> 
  flatten_chr() |> 
    # ①
    # [1] "%93" "%FA" "%96" "%7B" "%8C" "%EA" "%83" "t"   "%83" 
    #     "@"   "%83" "C"   "%83" "%8B" "."   "x"   "l"   "s"   "x"
    #
  str_remove_all(fixed("%")) |>
    # ②
    # [1] "93" "FA" "96" "7B" "8C" "EA" "83" "t"  "83" 
    #     "@"  "83" "C"  "83" "8B" "."  "x"  "l"  "s"  "x" 
    #
  str_replace_all("^.$", function(x) as.hexmode(utf8ToInt(x))) |> 
    # ③
    # [1] "93" "FA" "96" "7B" "8C" "EA" "83" "74" "83" 
    #     "40" "83" "43" "83" "8B" "2e" "78" "6c" "73" "78"
    #
  as.hexmode() |> 
    # ④
    # [1] "93" "fa" "96" "7b" "8c" "ea" "83" "74" "83" 
    #     "40" "83" "43" "83" "8b" "2e" "78" "6c" "73" "78"
    #
  as.raw() |> 
    # ⑤
    # [1] 93 fa 96 7b 8c ea 83 74 83 40 83 43 83 8b 2e 78 6c 73 78
    #
  rawToChar() |> 
    # ⑥
    # [1] "\x93\xfa\x96{\x8c\xea\x83t\x83@\x83C\x83\x8b.xlsx"
    #
  stringi::stri_encode(from = "cp932", to = "utf8")
    # ⑦
    # [1] "日本語ファイル.xlsx"

「str_」で始まる関数は、stringrパッケージが提供しているものです。stringrパッケージのバックエンドは stringiパッケージで、実はこちらのほうが機能は豊富です。が、tidyverseで自動的に読み込まれるstringrパッケージで十分間に合うことが多いと思います。(今回は、最終行でだけ使用している。)

str_extract_all()では、「実質的な」1文字単位で文字列を分解して文字列ベクトルにしようとしています。正規表現の優先順位で、まずパーセントつきのマッチを試み、なければ任意の1文字をとるようにしています。これで分解できることは、たとえば Regular Expression 101のサイトで確認できるでしょう。なお、str_extract_all()はリストで戻しますから、 flatten_chr()で文字列ベクトルにしておきます。

②続いてパーセント符号をバイト列に変換するために、 str_remove_all()で除去します。ここでfixed()という関数が使われていることに気づいた方もいるかもしれません。これは、grepの-fオプションのようなもので、正規表現ではなくリテラルであることを示しています。

str_replace_all()では、文字列ベクトルの各要素のうち、1文字だけの文字(i.e. パーセント符号化されていない文字)をマッチさせて、16進表記に変換しています。

すごく便利なのは、str_replace()関数は置換文字列として関数が使える点です。パイプラインに乗せるために、無名関数を定義しています。 utf8ToInt()で文字を10進数に変換し、as.hexmode()で 16進に変換しています。(加えて、暗黙的に文字列型に変換されています。)

④⑤⑥その後パーセント符号化された文字列もas.hexmode()で16進変換し、 as.raw()でバイト列に再変換して、rawToChar()で文字列型に戻しています。

⑦最後に、stri_encode()で fromとtoとの文字コードを指定して、CP932をUTF-8に変換しています。

ベクトル関数にする

パイプラインで変換できることが確認できたので、今度はこれを関数にしましょう。

オリジナルのurl_decode()は、引数に文字列ベクトルを受け取ることができました。そこでurl_decode_sjis()でも、挙動を合わせておきます。

url_decode_sjis <- function(urls) {
  url_decode_unit <- function(url) {
    url |> 
      str_extract_all("(%.{2}|.)") |>
      flatten_chr() |> 
      str_remove_all(fixed("%")) |>
      str_replace_all("^.$", function(x) as.hexmode(utf8ToInt(x))) |> 
      as.hexmode() |> 
      as.raw() |> 
      rawToChar() |>
      stringi::stri_encode(from = "cp932", to = "utf8")
  }
  
  map_chr(urls, url_decode_unit)
}

最初に作ったパイプラインはスカラー対応なので、これをベクトル対応させるために、 map_chr()を使っているところがポイントです。Vectorize()という関数が準備されていますが、その中身はapply系の関数でラッピングしていて、似たようなことをしています。

動作確認する

期待通りの動きをするかどうか、確認します。特に問題なさそうです。

# 動作確認
urls_sjis |> 
  mutate(url_decoded = url_decode_sjis(url)) |> 
  select(url_decoded)
## # A tibble: 3 × 1
##   url_decoded                         
##   <chr>                               
## 1 sjis.example.com/日本語ファイル.xlsx
## 2 sjis.example.com/PDFファイル.pdf    
## 3 sjis.example.com/①環境依存文字.docx

おわりに

以上、url_decode()をShift_JISにも対応させる書き捨ての関数を作る過程で、いくつかのポイントを見てきました。特に、str_replace()の置換文字列に関数が使える点は、日本語のブログ記事では湯谷さんのものしかパッとは見つかりませんでしたが、憶えておいて損はないと思います。

ちなみに、Pythonでは何の苦労もいりません。下で満足いく結果が得られます。なおRMarkdownでは、ご覧の通りRとPythonとのコードを同一ページ内で共存できます。

import urllib.parse
s = '%93%FA%96%7B%8C%EA%83t%83@%83C%83%8B.xlsx'
print(urllib.parse.unquote(s, encoding='shift-jis'))
## 日本語ファイル.xlsx

追記(2022-05-22)

この記事を書き上げたあとで、もっと簡単に関数が作れることに気がつきました。 str_replace_all()の中で、もっといろいろやってしまえばよいのです。が、書き直すのも面倒なので、より簡単な例を掲げるにとどめます。

url_decode_sjis2 <- function(urls) {
  url_decode_unit <- function(url) {
    url |> 
      str_replace_all(
        "%.{2}",
        \(x) {str_sub(x, -2) |> as.hexmode() |> as.raw() |> rawToChar()}
      ) |> 
      stringi::stri_encode(from = "cp932", to = "utf8")
  }
  
  map_chr(urls, url_decode_unit)
}

urls_sjis |> 
  mutate(url_decoded = url_decode_sjis2(url)) |> 
  select(url_decoded)
## # A tibble: 3 × 1
##   url_decoded                         
##   <chr>                               
## 1 sjis.example.com/日本語ファイル.xlsx
## 2 sjis.example.com/PDFファイル.pdf    
## 3 sjis.example.com/①環境依存文字.docx