文字化け(Mojibake)とは、バイト列が誤った文字セットでデコードされた際に生じる崩れたテキストのことだ。たとえば「Café」が「Café」になったり、日本語のタイトルが意味不明なラテン文字の羅列に変わったりする。ブラウザでは文字化けはまれにしか起きない。ブラウザはページのエンコーディングを判定するための、精密で標準化された手順に従っているからだ。一方スクレイピングパイプラインでは文字化けがよく起こる。HTTPライブラリは通常、もっと単純な処理しか行わないためだ。
実際のサイトでこの問題がどの程度重要になるかを計測したところ、ほとんどのチームが想定しているよりも頻繁に起きているという結果が得られた。本ガイドでは、判明した内容、その原因、そしてブラウザと同じ順序に従うデコード用コードを紹介する。
要点
- 2026年10月1日、6つの国別ドメイン(日本、韓国、中国、ロシア、台湾、ドイツ)それぞれの上位50ドメインのホームページを取得した。193件がページを返した。
- 193件のうち9件はUTF-8ではなかった。日本ではShift_JIS、韓国ではEUC-KR、ロシアではwindows-1251、ドイツではISO-8859-1だった。
- より大きな問題はレガシーなエンコーディングではなく、ライブラリのデフォルト動作だった。Pythonのrequestsはサーバーがヘッダーでcharsetを指定していなかったため、193件中37件(約19%)をISO-8859-1としてデコードし、非ASCII文字をすべて文字化けさせていた。
- レガシーなラベルは、その名前が示す意味と一致しない。ブラウザは「ISO-8859-1」をwindows-1252として、「Shift_JIS」をWindows版としてデコードしており、厳密なデコーダーでは一部の文字が誤って変換されてしまう。
- デコードはブラウザと同じ順序で行う。バイトオーダーマーク、HTTPヘッダー、metaタグ、そして妥当性チェックと検出の順だ。
計測した内容
人気ドメインのリストであるTrancoから、.jp、.kr、.cn、.ru、.tw、.deで終わる上位50ドメインを取得し、各ホームページを1回ずつ取得して、ステータス200でHTMLページを返した193件を対象とした。各ページについて、エンコーディングがどのように宣言されていたか、実際には何であったか、requestsライブラリの.textが何を出力したかを記録した。
| 国別ドメイン | ページ数 | UTF-8以外 | requestsの.textで文字化け |
|---|---|---|---|
| 日本(.jp) | 36 | 4(Shift_JIS) | 8 |
| 韓国(.kr) | 31 | 2(EUC-KR) | 4 |
| 中国(.cn) | 33 | 0 | 10 |
| ロシア(.ru) | 25 | 2(windows-1251) | 4 |
| 台湾(.tw) | 29 | 0 | 6 |
| ドイツ(.de) | 39 | 1(ISO-8859-1) | 5 |
| 合計 | 193 | 9 | 37 |
UTF-8は明らかに主流になっている。193件中184件がUTF-8を使用していた。しかし右端の列が示すように、エンコーディング戦争に勝利したからといって問題が終わったわけではない。文字化けしたページの大半はUTF-8だった。これらはHTTPヘッダーではなく<meta>タグでUTF-8を宣言しており、ライブラリはそこを参照していなかった。
ライブラリが誤った判断をした理由
レスポンスがcharsetを指定せずにContent-Type: text/htmlとだけ返した場合、requestsは古いHTTP/1.1のテキスト型のデフォルトに従ってエンコーディングをISO-8859-1に設定する。ページの<meta charset>タグは読み取らない。その結果、127を超えるすべてのバイトがLatin-1文字としてデコードされ、どの言語のUTF-8テキストも文字化けする。
| 元のテキスト | Latin-1としてデコードされたバイト |
|---|---|
| Café | Café |
| 価格(日本語で「price」) | ä¾¡æ ¼ |
| JRA日本中央競馬会(Shift_JISのページ) | JRAの後に制御文字と意味不明なラテン文字が続く |
何もエラーを発生させない。テキストは依然として有効な文字列であり、HTMLとして問題なくパースされ、セレクタも一致し続ける。被害はのちになって、検索できなくなった商品名、壊れた重複排除、モデルの学習データに混入するノイズといった形で表れる。これはまさに成功したリクエストが誤ったコンテンツを返す典型例だ。
レガシーなラベルは、その名前が示す意味とは異なる
第二の落とし穴はより巧妙だ。ページがレガシーなエンコーディングを宣言していても、ブラウザはその名前が示す厳密な規格をそのまま使うわけではない。ブラウザが実装しているWHATWG Encoding Standardは、いくつかのラベルをその上位互換のエンコーディングにマッピングしている。
| 宣言されたラベル | ブラウザが実際にデコードする方式 |
|---|---|
| iso-8859-1, latin1, ascii | windows-1252 |
| gb2312, gbk | gb18030 |
| shift_jis | Windows拡張付きのShift_JIS(コードページ932) |
| euc-kr | Windows拡張付きのEUC-KR(コードページ949) |
| big5 | 香港補助文字付きのBig5 |
同じ名前の厳密なコーデックでデコードすると、エラーが発生するか、さらに悪いことに別の文字に変換されてしまう。Shift_JISを宣言していたある日本のエンターテインメントサイトでは、Pythonの厳密なshift_jisコーデックが、全角チルダ「~」(U+FF5E)をすべて波ダッシュ「〜」(U+301C)に変換してしまい、ページ内でその文字が使われた回数だけこれが起きた。Encoding Standardのインデックスでは、そのバイト列は全角チルダにマッピングされており、これが実際に訪問者が目にする文字だ。この2つはほぼ同一に見えるが文字列としては別物として比較されるため、「1,000~2,000」のような価格帯の表記が、気づかれないまま一致しなくなる。
他のマッピングはもっとはっきりと失敗する。gb2312と表示されたページがその規格の範囲外の文字を使っている場合や、euc-krのページが拡張ハングル音節のいずれかを使っている場合、Pythonの厳密なコーデックではデコードエラーが発生する。また、ISO-8859-1と表示されたページがユーロ記号を使っている場合は、ユーロ記号がwindows-1252にしか存在しないため、代わりに目に見えない制御文字になってしまう。
ブラウザと同じ方法でデコードする
HTML Standardはその順序を定めている。バイトオーダーマークが最優先され、次にトランスポート層(HTTPヘッダー)で指定されたエンコーディング、その次にドキュメント先頭を走査して見つかる<meta>宣言が続く。著者はこの宣言を先頭1024バイト以内に配置することが求められている。この順序に、ブラウザのラベルマッピングと検出によるフォールバックを組み合わせると、以下のようになる。
import codecs
import re
from charset_normalizer import from_bytes
# レガシーなラベルは、ブラウザがデコードする方式(WHATWG Encoding Standard)を意味し、厳密な同名の規格を意味しない。
BROWSER_DECODER = {
"iso-8859-1": "cp1252", "latin1": "cp1252", "ascii": "cp1252", "us-ascii": "cp1252",
"gb2312": "gb18030", "gbk": "gb18030", "x-gbk": "gb18030",
"shift_jis": "cp932", "sjis": "cp932", "x-sjis": "cp932", "windows-31j": "cp932",
"euc-kr": "cp949", "ks_c_5601-1987": "cp949",
"big5": "big5hkscs",
}
HEADER_CHARSET = re.compile(r"charset\s*=\s*[\"']?([^;\"'\s]+)", re.I)
META_CHARSET = re.compile(rb"""<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9_.:-]+)""", re.I)
def python_codec(label):
"""宣言されたcharsetラベルをPythonのコーデック名にマッピングする。不明な場合はNoneを返す。"""
label = label.strip().lower()
label = BROWSER_DECODER.get(label, label)
try:
return codecs.lookup(label).name
except LookupError:
return None
def decode_html(body, content_type=""):
"""ブラウザと同じ順序でHTMLバイト列をデコードする:BOM、HTTPヘッダー、<meta>、そしてUTF-8、最後に検出。"""
if body.startswith(codecs.BOM_UTF8):
return body[3:].decode("utf-8", "replace"), "utf-8 (BOM)"
header = HEADER_CHARSET.search(content_type or "")
meta = META_CHARSET.search(body[:1024]) # HTML仕様ではこの宣言はここに置くことが求められている
labels = (("header", header and header.group(1)), ("meta", meta and meta.group(1).decode("ascii", "replace")))
for source, label in labels:
codec = label and python_codec(label)
if codec:
return body.decode(codec, "replace"), f"{codec} ({source})"
try:
return body.decode("utf-8"), "utf-8 (valid)"
except UnicodeDecodeError:
guess = from_bytes(body).best()
codec = guess.encoding if guess else "cp1252"
return body.decode(codec, "replace"), f"{codec} (detected)"
これには生のバイト列(requestsではresponse.content)とContent-Typeヘッダーを渡すこと。response.textは絶対に使わない。この関数はテキストと、エンコーディングがどのように選ばれたかを示す記録を返す。これは各ページと一緒に保存しておく価値があり、後で誤った判断を追跡できるようにする。
193件すべてのページに適用したところ、192件は置換文字を一つも出さずにデコードできた。残りの1件はUTF-8を宣言していたが、2つの無効なバイト列を含んでおり、これはブラウザでも置換文字として表示される。また、ライブラリのデフォルトが文字化けさせていた37件、およびレガシーなエンコーディングの9件についても、正しいテキストを生成した。意図的に構築したエッジケースでテストしたところ、GBK専用文字を含むgb2312表示のページ、拡張ハングル音節を含むeuc-krのページ、ユーロ記号を含むISO-8859-1のページ、未宣言のShift_JISのページを、いずれもエラーなくデコードできた。
そのほかに判明したこと
- 誤った位置にある宣言。 最初の調査では、17件のページがHTML仕様に反して
<meta charset>を先頭1024バイトより後ろに配置していた。そのうち13件はヘッダーでもcharsetを指定しており、残りの4件は有効なUTF-8だったため、実害はなかった。しかし、metaタグのみに依存するデコーダーであれば見逃していただろう。 - 矛盾する宣言。 ある台湾のサイトは、ヘッダーではUTF-8を、metaタグではBig5を送信していた。ブラウザと同様にヘッダーが優先され、この場合はそれが正しかった。
- バイトオーダーマーク。 4件のページがUTF-8のバイトオーダーマークから始まっていた。ヘッダーにcharsetがないものはライブラリによってLatin-1としてデコードされてしまい、ヘッダーが正しい場合でも、ライブラリはテキストの先頭に目に見えないマークを残してしまった。
実践的なルール
- バイト列は自分でデコードする。 生のレスポンスを保持しておけば、生のレスポンスをアーカイブしている場合、ルールが変わった際に後から再デコードできる。
- Latin-1だと決めつけない。 ヘッダーにcharsetがない場合は「さらに調べる」と扱い、ISO-8859-1だとは判断しない。
- すべてUTF-8として保存する。 パイプラインの入口で一度だけデコードし、使用したエンコーディングを記録し、それ以降はUTF-8のまま扱う。
- 置換文字に注意する。 あるフィールドでU+FFFDが急増するのは、安価で信頼できる警告であり、スクレイピングデータにおけるスキーマドリフトで説明されているチェックと並べて実施すべきものだ。
- デコード後に正規化する。 正しくデコードされた文字であっても、全角数字や異なるチルダなど、複数の書き方が存在しうる。比較や重複排除を行う前に正規化すること。これはロケール間での価格・数値・日付の正規化で扱っている内容だ。
結論
現在ではウェブのほぼ全体がUTF-8になっているにもかかわらず、パイプラインは依然として文字化けを起こしている。最もよくある原因は、珍しいエンコーディングではなく、ページ自身の宣言を無視するライブラリのデフォルト動作だからだ。今回のサンプルでは、このデフォルト動作によって5件に1件程度のページが文字化けしており、そのいずれもエラーを発生させなかった。
バイト列から、ブラウザが用いる順序でデコードし、ラベルもブラウザと同じようにマッピングし、どのルールで判断したかを記録すること。それによって、目に見えないデータ品質の問題が解決済みの問題に変わる。
出典と参考資料
- WHATWG、Encoding Standard。ラベル一覧表とShift_JISのインデックスを含む。
- WHATWG、HTML Standard: parsing HTML documents。文字エンコーディングの判定について。
- Tranco、list Q2K34。2026年9月1日から30日までのドメインランキングを集計したもの。
- charset_normalizerバージョン3.5.2、およびrequests 2.32.5をテストで使用した。
- ホームページは2026年10月1日にShifterが上記のコードを使って取得した。