連載
» 2015年01月13日 18時17分 UPDATE

.NET TIPS:HttpClientクラスでシフトJISのWebページを取得するには?[C#、VB]

HttpClientクラスを使ってWebページを取得する際に、文字化けが発生しないよう、Webページのエンコーディングを推測/設定して取得する方法を解説する。

[山本康彦,BluewaterSoft/Microsoft MVP for Windows Platform Development]
.NET TIPS
Insider.NET

 

「.NET TIPS」のインデックス

連載目次

対象:.NET 4.5以降


 .NET Framework 4.5で新設されたHttpClientクラス(System.Net.Http名前空間)のGetStringAsyncメソッドを使うと、簡単にWebページの内容を文字列として取得できる。しかし、文字コードにシフトJISを使っているWebサイトでは文字化けしてしまう。どうすれば文字化けさせることなく取得できるだろうか? 本稿では、そのような文字化けが発生する条件を説明し、そんな場合にWebページの内容を文字列として取得する方法を解説する。

文字化けが発生する条件

 HttpClientクラスのGetStringAsyncメソッドを使ってWebページの内容を文字列として取得する方法は、「.NET TIPS:HttpClientクラスでWebページを取得するには?[C#、VB]」で紹介した(そのコードを、以降では「以前のコード」と呼ぶ)。なお、その記事で解説していることは本稿であらためて説明しないので、併せてお読みいただきたい。

 以前のコードでURLを書き換えて、シフトJISのWebサイトにアクセスしてみよう。例えば「http://www.atmarkit.co.jp/」にアクセスすると、次の画像のように文字化けが発生する。

以前のコードでシフトJISのWebサイトにアクセスすると文字化けする 以前のコードでシフトJISのWebサイトにアクセスすると文字化けする
日本語の部分が文字化けしている(赤枠内)。

 UTF-8などのUnicodeを使ったWebサイト以外では、必ず文字化けしてしまうのだろうか? そうではない。文字コードにシフトJISやEUCやJISを使っているWebページでも、正常に取得できる場合があるのだ。例えば「http://www.shugiin.go.jp/」(このサイトはシフトJIS)のページは文字化けさせることなく取得できる(次の画像)。

以前のコードでアクセスしても文字化けしないシフトJISのWebサイトもある 以前のコードでアクセスしても文字化けしないシフトJISのWebサイトもある

 両者の違いがどこにあるのかというと、Webサーバーからのレスポンスに含まれているHTTPヘッダー(HTMLのヘッダーではない)の「Content-Type」フィールドである(次の画像)。「Content-Type」フィールドにcharsetパラメーターが設定されていると、HttpClientクラスのGetStringAsyncメソッドで正常に取得できるのだ。

以前のコードで正常に取得できるシフトJISのWebサイトのHTTPヘッダーの例(Internet Explorer 11) 以前のコードで正常に取得できるシフトJISのWebサイトのHTTPヘッダーの例(Internet Explorer 11)
これはInternet Explorer 11の「F12開発者ツール」の中の「Networkツール」を使って、WebサーバーからのレスポンスのHTTPヘッダーを表示させたものだ。
HTTPヘッダーの「Content-Type」フィールドに「text/html; charset=Shift-JIS」とあるのが見える(赤枠内)。前述の文字化けしたサイトでは、「Content-Type」フィールドに「charset=Shift-JIS」の部分がなく、「text/html」だけである。
なお、「http://www.shugiin.go.jp/」にアクセスすると、この画面のページにリダイレクトされる(以前のコードでアクセスした前述の結果もこのページにリダイレクトされている)。

 以上のことから、HttpClientクラスのGetStringAsyncメソッドは、HTTPヘッダーの「Content-Type」フィールドでエンコーディングが指定されていればそれに従い、そうでなければUnicodeとして解釈していると推定できる*1

 そのような仕様になっているので、Webサーバーの管理者がきちんとHTTPヘッダーの設定をしていれば、HttpClientクラスのGetStringAsyncメソッドで文字化けは発生しないはずなのだ。ところが、本稿を書くためにあらためていくつかのWebサイトに当たってみたところ、文字コードにシフトJISを使っているサイトのほとんどでcharsetの指定がされていなかった。せっかくの仕様も、日本の現実では役に立たないのである。

 そうなると、プログラムの側で何とかするしかない。従来のWebClientクラス(System.Net名前空間)ならば、Encodingプロパティを設定してからDownloadStringメソッドを使えばよかった。しかし、HttpClientクラスにはそのようなプロパティが存在しない。どのようにすればよいだろうか? 以降で解説する。

文字エンコーディングを指定してWebページを取得するには?

 Webページへのアクセスには、HttpClientクラスのGetAsyncメソッドを使う。そして得られたHttpResponseMessageオブジェクトからストリームを読み出すときに、文字エンコーディングを指定したTextReaderオブジェクトを使えばよい。例えば、文字コードがシフトJISだと分かっているなら、次のコードのようになる。

// ファイルの冒頭に次のインポート文を追加する
using System.IO;
using System.Text;

……省略……

//return await client.GetStringAsync(uri); // ←以前のコード

// HttpClientクラスのGetAsyncメソッドを使ってWebページにアクセスする
HttpResponseMessage res = await client.GetAsync(uri);

// もしも取得に失敗していたら、GetStringAsyncメソッドのときと同じ例外を投げる
res.EnsureSuccessStatusCode();

// 文字エンコーディングはシフトJIS固定とする
Encoding enc = Encoding.GetEncoding("shift_jis");

// 文字エンコーディングを指定してTextReaderオブジェクトを作り、ストリームから読み取る
using (var stream = (await res.Content.ReadAsStreamAsync()))
using (var reader = (new StreamReader(stream, enc, true)) as TextReader)
{
  return await reader.ReadToEndAsync();
}

' ファイルの冒頭に次のインポート文を追加する
Imports System.IO
Imports System.Text

……省略……

'Return Await client.GetStringAsync(uri) ' ←以前のコード

' HttpClientクラスのGetAsyncメソッドを使ってWebページにアクセスする
Dim res As HttpResponseMessage = Await client.GetAsync(uri)

' もしも失敗していたら、GetStringAsyncメソッドのときと同じ例外を投げる
res.EnsureSuccessStatusCode()

' 文字エンコーディングはシフトJIS固定とする
Dim enc As Encoding = Encoding.GetEncoding("shift_jis")

' 文字エンコーディングを指定してTextReaderオブジェクトを作り、ストリームから読み取る
Using stream = Await res.Content.ReadAsStreamAsync()
  Using reader = DirectCast(New StreamReader(stream, enc, True), TextReader)
    Return Await reader.ReadToEndAsync()
  End Using
End Using

シフトJISのWebページを取得するコード例(上:C#、下:VB)
以前のコードと異なる部分のみを示す。以前のコードは「.NET TIPS:HttpClientクラスでWebページを取得するには?[C#、VB]」を参照してほしい。
HttpClientクラスのGetAsyncメソッドでWebページの取得に失敗した場合、このコードでは以前のコードと同じ例外が出るようにしている。この部分は、HttpResponseMessageオブジェクトのIsSuccessStatusCodeプロパティを調べて独自に処理の流れを制御してもよい。
Encodingクラス(System.Text名前空間)のGetEncodingメソッドの引数に指定できるエンコーディング名については、「.NET TIPS:Encodingクラスで扱えるエンコーディング名は?[C#、VB]」を参照してほしい。

 これで、以前のコードでは文字化けしていた「http://www.atmarkit.co.jp/」にアクセスしてみると、今度は正しく表示される(次の画像)。

シフトJISのWebサイトが正しく表示された シフトJISのWebサイトが正しく表示された

文字エンコーディングを推定するには?

 アクセスするWebページの文字コードがあらかじめ分かっている場合は、上記のコードでよい。しかし任意のWebページにアクセスする場合には、文字エンコーディングを決め打ちできない。そのWebページの文字エンコーディングを推定する必要があるのだ。

 ここで注意してほしいのは、文字エンコーディングを確実に判断できるロジックは存在しないということだ。今でもWebブラウザーでたまに文字化けが起きることがある。絶対確実な判定方法はないのである。以下で説明する方法も、あくまでもっともらしい文字エンコーディングを推定する方法の一つである。

 W3Cの勧告によれば、文字エンコーディングの推定は次の順に行うよう推奨されている*2

  1. HTTPヘッダーの「Content-Type」フィールドのcharsetパラメーター
  2. HTMLの<meta>要素で、http-equiv属性値がContent-Typeかつcontent属性の値にcharset情報があるもの
  3. HTMLのその他の外部リソースを指している要素に設定されているcharset属性値

 HttpClientクラスを使う場合、1.のHTTPヘッダーは、HttpResponseMessageオブジェクトのContentプロパティのHeadersプロパティで取得できる。そして、2.と3.は、HTMLのコンテンツそのものを読み取って解析することになる。読み取るための文字エンコーディングを判断するためには、一度読み取らねばならないのである。文字エンコーディングが分からないのに読み取らねばならないというのは矛盾しているようだが、HTMLのタグ/属性と文字エンコーディング名はASCIIコードの範囲内で書かれているのだから、ASCIIかUTF-8として読み込んでみればよいのだ。それもやはりHttpResponseMessageオブジェクトのContentプロパティから読み込める。すなわち、HttpClientクラスのGetAsyncメソッドで得られたHttpResponseMessageオブジェクトから、文字エンコーディングの推定が可能である。

 HttpResponseMessageオブジェクトを受け取って文字エンコーディングを推定するメソッドは、次のコードのように書ける。

// ファイルの冒頭に次のインポート文を追加する
using System.Text.RegularExpressions;

……省略……

static async Task<Encoding> DetermineEncodingAsync(HttpResponseMessage res)
{
  // まず、HTTPヘッダーのContent-Typeフィールドを見る
  string charset = res.Content.Headers.ContentType.CharSet;
  if (!string.IsNullOrWhiteSpace(charset))
  {
    try
    {
      // Content-TypeフィールドのcharsetパラメーターからEncodingの生成に成功したら、それを返す
      return Encoding.GetEncoding(charset);
    }
    catch { }
  }

  // 次に、HTMLの中でcharset属性を探す

  // 取りあえずUTF-8だとして読んでみる
  string html = null;
  using (var ms = new MemoryStream())
  {
    // ここでReadAsStreamAsyncメソッドを使ってしまうと、
    // HttpResponseMessageオブジェクトのストリーム自体が消えてしまう。
    // そのため、ここではストリームのコピーを作る
    await res.Content.LoadIntoBufferAsync();
    await res.Content.CopyToAsync(ms);
    ms.Position = 0;
    using (var reader = (new StreamReader(ms, Encoding.UTF8, true)) as TextReader)
    {
      html = await reader.ReadToEndAsync();
    }
  }

  // charset属性を探す
  // HTML4の <meta http-equiv="Content-Type" content="text/html; charset={エンコーディング名}">
  // HTML5の <meta charset="{エンコーディング名}">
  // HTML4/5の <{任意の要素名} charset="{エンコーディング名}">
  var charsetEx = new Regex(@"<[^>]*\bcharset\s*=\s*[""']?(?<charset>\w+)\b",
                            RegexOptions.CultureInvariant
                            | RegexOptions.IgnoreCase
                            | RegexOptions.Singleline);
  Match charsetMatch = charsetEx.Match(html);
  if (charsetMatch.Success)
  {
    try
    {
      // 発見した最初のcharset属性からEncodingの生成に成功したら、それを返す
      return Encoding.GetEncoding(charsetMatch.Groups["charset"].Value);
    }
    catch { }
  }

  // 以上で決定できなかったときは、既定値としてUTF-8を返す
  return Encoding.UTF8;
}

// ファイルの冒頭に次のインポート文を追加する
Imports System.Text.RegularExpressions

……省略……

Async Function DetermineEncodingAsync(res As HttpResponseMessage) As Task(Of Encoding)

  ' まず、HTTPヘッダーのContent-Typeフィールドを見る
  Dim charset As String = res.Content.Headers.ContentType.CharSet
  If (Not String.IsNullOrWhiteSpace(charset)) Then
    Try
      ' Content-TypeフィールドのcharsetパラメーターからEncodingの生成に成功したら、それを返す
      Return Encoding.GetEncoding(charset)
    Catch ex As Exception
    End Try
  End If

  ' 次に、HTMLの中でcharset属性を探す

  ' 取りあえずUTF-8だとして読んでみる
  Dim html As String = Nothing
  Using ms = New MemoryStream()
    ' ここでReadAsStreamAsyncメソッドを使ってしまうと、
    ' HttpResponseMessageオブジェクトのストリーム自体が消えてしまう。
    ' そのため、ここではストリームのコピーを作る
    Await res.Content.LoadIntoBufferAsync()
    Await res.Content.CopyToAsync(ms)
    ms.Position = 0
    Using reader = DirectCast(New StreamReader(ms, Encoding.UTF8, True), TextReader)
      html = Await reader.ReadToEndAsync()
    End Using
  End Using

  ' charset属性を探す
  ' HTML4の <meta http-equiv="Content-Type" content="text/html; charset={エンコーディング名}">
  ' HTML5の <meta charset="{エンコーディング名}">
  ' HTML4/5の <{任意の要素名} charset="{エンコーディング名}">
  Dim charsetEx = New Regex("<[^>]*\bcharset\s*=\s*[""']?(?<charset>\w+)\b",
                            RegexOptions.CultureInvariant _
                            Or RegexOptions.IgnoreCase _
                            Or RegexOptions.Singleline)
  Dim charsetMatch As Match = charsetEx.Match(html)
  If (charsetMatch.Success) Then
    Try
      ' 発見した最初のcharset属性からEncodingの生成に成功したら、それを返す
      Return Encoding.GetEncoding(charsetMatch.Groups("charset").Value)
    Catch ex As Exception
    End Try
  End If

  ' 以上で決定できなかったときは、既定値としてUTF-8を返す
  Return Encoding.UTF8
End Function

HttpResponseMessageオブジェクトから文字エンコーディングを推定するメソッドの例(上:C#、下:VB)
W3C勧告の2.と3.の優先順位は、このコードでは無視している。
前述の「シフトJISのWebページを取得するコード例」を大きく変えたくなかったので、メソッド内でHTMLコンテンツのストリームの複製を作っている。この部分は、HttpClientクラスのGetAsyncメソッドを呼び出した直後にストリームをbyte配列に書き込んでしまって、それを使い回した方が効率がよい。
コード中の正規表現(charsetEx変数に代入しているもの)は、次のような意味になっている。「『 <』で始まり、『> 』以外の任意の文字が続き(改行は無視)、単語境界から始まる『charset』(大文字/小文字は無視)という文字列があって、その次に『=』(前後に空白があってもよい)が続き、その後ろに『"』または『'』が1個あってもよく、それに続く英数字の文字列があったらそれを『charset』という名前で保存するが、それは単語境界で終了する」(「charset」という名前で保存した英数字の文字列は、その後のコードで「metaMatch.Groups["charset"].Value」として取り出している)
なお、この正規表現では、例えば「」(HTMLとしては正しい)なども拾ってしまう。これはW3C勧告の2.と3.をまとめた正規表現にしたためである。2.と3.は別々に検索した方がよさそうである。

 このDetermineEncodingAsyncメソッドを使って、先ほどの「シフトJISのWebページを取得するコード例」を、次のコードのように汎用的に書き直せる。

// HttpClientクラスのGetAsyncメソッドを使ってWebページにアクセスする
HttpResponseMessage res = await client.GetAsync(uri);

// もしも取得に失敗していたら、GetStringAsyncメソッドのときと同じ例外を投げる
res.EnsureSuccessStatusCode();

// 文字エンコーディングを決める
// Encoding enc = Encoding.GetEncoding("shift_jis");
Encoding enc = await DetermineEncodingAsync(res);

// 文字エンコーディングを指定してTextReaderオブジェクトを作り、ストリームから読み取る
using (var stream = (await res.Content.ReadAsStreamAsync()))
using (var reader = (new StreamReader(stream, enc, true)) as TextReader)
{
  return await reader.ReadToEndAsync();
}

' HttpClientクラスのGetAsyncメソッドを使ってWebページにアクセスする
Dim res As HttpResponseMessage = Await client.GetAsync(uri)

' もしも失敗していたら、GetStringAsyncメソッドのときと同じ例外を投げる
res.EnsureSuccessStatusCode()

' 文字エンコーディングを決める
' Dim enc As Encoding = Encoding.GetEncoding("shift_jis")
Dim enc As Encoding = Await DetermineEncodingAsync(res)

' 文字エンコーディングを指定してTextReaderオブジェクトを作り、ストリームから読み取る
Using stream = Await res.Content.ReadAsStreamAsync()
  Using reader = DirectCast(New StreamReader(stream, enc, True), TextReader)
    Return Await reader.ReadToEndAsync()
  End Using
End Using

任意の文字コードのWebページを取得するコード例(上:C#、下:VB)
先述した「シフトJISのWebページを取得するコード例」で文字エンコーディングを決めている部分を変更した。

 これで、ほとんどのWebサイトで文字化けなくWebページを取得できるだろう。前述したように、文字エンコーディングを推定する完璧な方法はないので、これでも文字化けする可能性は残っている。プログラムには、エンドユーザーが補助的に文字コードを指定できる仕組みを用意しておくべきであろう。

*1 HTTPヘッダーの「Content-Type」フィールドのcharsetパラメーターに従って文字コードを解釈するという仕様は、.NET Framework 4のWebClientクラス(System.Net名前空間)のDownloadStringメソッド以来のものだ。このメソッドは、.NET Framework 3.5まではcharasetパラメーターを参照しなかったのである。この変更には筆者も少し関わった

*2 W3C HTML4.01 Specificationの5.2.2を参照(原文、「HTML 4仕様書邦訳計画補完委員会」による日本語訳)。なお、HTTPヘッダーを優先すべきなのは、文字コードを変換するプロキシサーバーへの配慮からだといわれている。


利用可能バージョン:.NET Framework 4.5以降
カテゴリ:クラスライブラリ 処理対象:ネットワーク
使用ライブラリ:HttpClientクラス(System.Net.Http名前空間)
使用ライブラリ:Regexクラス(System.Text.RegularExpressions名前空間)
関連TIPS:HttpClientクラスでWebページを取得するには?[C#、VB]
関連TIPS:Encodingクラスで扱えるエンコーディング名は?[C#、VB]


「.NET TIPS」のインデックス

.NET TIPS

Copyright© 1999-2017 Digital Advantage Corp. All Rights Reserved.

@IT Special

- PR -

TechTargetジャパン

この記事に関連するホワイトペーパー

RSSについて

アイティメディアIDについて

メールマガジン登録

@ITのメールマガジンは、 もちろん、すべて無料です。ぜひメールマガジンをご購読ください。