WordPress.com の DB エクスポートで日本語が文字化けする理由と直し方

最終更新日

ℹ️ この記事は Claude Code で書きました。


WordPress.com (Atomic/Pressable 基盤) から phpMyAdmin で DB をエクスポートすると、日本語を含むデータが文字化けした状態で SQL ファイルに書き出されることがある。これは単なる表示上の問題ではなく、エクスポートの時点で実際にバイト列が壊れて保存されてしまう、根の深い問題である。原因の仕組みと、正しい直し方を詳しく書く。

症状

インポート後、wp option get blogname のようなコマンドを打つと、日本語が以下のような記号の羅列になっている。

回れå³ã®å†…輪差Code language: plaintext (plaintext)

本来は 回れ右の内輪差 という正しい日本語であるべき値である。

ロケール設定 (LANG=en_US.UTF-8 など) を確認しても問題なく、ターミナルの表示不具合ではない。DB に実際に壊れたバイト列が入っている。

背景: なぜ「latin1 なのに UTF-8」という矛盾が起きるのか

WordPress (特に古い時代に作られたサイトや、MySQL のデフォルト設定を変えずに使ってきた環境) には、テーブル定義上は CHARSET=latin1 と宣言されているのに、実際には UTF-8 のマルチバイト文字列がそのまま生バイトとして格納されている、という状態がよくある。

これは事故ではなく、意図的というか、歴史的によく使われてきたテクニックに由来する。MySQL の latin1 は「1 バイト=1 文字」を保証する文字コードなので、接続時にちゃんと SET NAMES latin1 を指定してさえいれば、MySQL は受け取ったバイト列をそのまま素通しで保存する (中身が UTF-8 だろうが何だろうが関与しない)。WordPress は表示側で常に UTF-8 として解釈するように HTML の <meta charset> を出力するので、DB のラベルが何であれ、接続時の文字コード指定と、読み書き時の文字コード指定さえ一貫していれば 実害なく動いてしまう。

このダンプを見ると、実際に以下のようになっていた。

-- テーブルの構造 `wp_commentmeta`
CREATE TABLE IF NOT EXISTS `wp_commentmeta` (
  ...
) ENGINE=InnoDB ... DEFAULT CHARSET=latin1 COLLATE=latin1_swedish_ci;
Code language: SQL (Structured Query Language) (sql)

ファイル冒頭は:

/*!40101 SET NAMES utf8mb4 */;
Code language: SQL (Structured Query Language) (sql)

つまり「今から utf8mb4 で喋ります」と宣言しておきながら、テーブル自体は「私は latin1 です」と主張している。ここに矛盾がある。

何が起きたか (エクスポート時に起きた変換)

phpMyAdmin 自身が MySQL サーバーに接続する際の文字コードは、通常 utf8mb4 である (現代的な phpMyAdmin のデフォルト)。

ここで、latin1 と宣言されたカラムから SELECT でデータを取り出すと、MySQL サーバーは律儀に「このカラムは latin1 だから、まず latin1 として 1 バイトずつ文字に変換し、それを要求されている utf8mb4 に変換してから返そう」という処理をする。

具体的な流れ:

  1. 元々カラムに格納されている生バイト列 B (これは実は正しい UTF-8 のバイト列)
  2. MySQL サーバーは B を「latin1」として 1 バイト= 1 文字で解釈する。ここでの「latin1」は実際には MySQL 独自仕様で、Windows-1252 (cp1252) に近いマッピング を使っている (純粋な ISO-8859-1 ではない点に注意。詳細は後述)。この結果、文字列 U が得られる
  3. U をクライアント側が要求している utf8mb4 としてエンコードし直し、B2 としてクライアント (phpMyAdmin) に送信する
  4. phpMyAdmin は受け取った B2 をそのままダンプファイルに書き込む

出来上がった B2 は、B が持っていた本来の日本語の意味を失った、二重変換済みの壊れたバイト列である。この B2 がそのまま SQL ファイルの中に固定されてしまうため、後からどう頑張ってインポートしても、ファイル自体が壊れている以上直らない。ファイルそのものを修復する必要がある。

実例で見る変換

今回遭遇した blogname の実データで検証する。

DB から直接 HEX を取り出す:

mysql -u <user> -p'<pass>' <dbname> -e \
  "SELECT HEX(option_value) FROM wp_options WHERE option_name='blogname';"
Code language: SQL (Structured Query Language) (sql)

得られた HEX:

C3A5E280BAC5BEC3A3E2809AC592C3A5C28FC2B3C3A3C281C2AEC3A5E280A0E280A6C3A8C2BCC2AAC3A5C2B7C2AECode language: plaintext (plaintext)

このバイト列を UTF-8 としてデコードすると (Python で):

>>> b = bytes.fromhex('c3a5e280bac5bec3a3e2809ac592c3a5c28fc2b3c3a3c281c2aec3a5e280a0e280a6c3a8c2bcc2aac3a5c2b7c2ae')
>>> s = b.decode('utf-8')
>>> [hex(ord(c)) for c in s]
['0xe5', '0x203a', '0x17e', '0xe3', '0x201a', '0x152', '0xe5', '0x8f', '0xb3', '0xe3', '0x81', '0xae', '0xe5', '0x2020', '0x2026', '0xe8', '0xbc', '0xaa', '0xe5', '0xb7', '0xae']
Code language: Bash (bash)

出てきたコードポイントの並び (0xe5, 0x203a, 0x17e, 0xe3, 0x201a, 0x152, …) をよく見ると、0x203a (‹) や 0x17e (ž)、0x201a (‚)、0x152 (Œ)、0x2020 (†)、0x2026 (…) といった文字が混じっている。これらは偶然ではなく、Windows-1252 (cp1252) のバイト 0x80〜0x9F 範囲に割り当てられた文字とぴったり一致する

cp1252 のバイト値cp1252 での文字上のリストでの対応コードポイント
0x8B0x203a
0x9Ež0x17e
0x820x201a
0x8CŒ0x152
0x860x2020
0x850x2026

つまり、この文字列は「本来の 1 バイトを、MySQL が cp1252 として解釈し、それを utf8mb4 として送り出した結果」であることが、この対応表からも裏付けられる。

MySQL の「latin1」の正体は cp1252

ここが最大の罠であり、この問題を直す上で最も重要なポイントである。

多くの人は「MySQL の latin1 = ISO-8859-1」だと思い込んでいるが、実際には MySQL の latin1 はほぼ cp1252 (Windows-1252) と同じマッピングを使っている。これは MySQL 固有の仕様で、公式マニュアルにも記載がある。ISO-8859-1 の 0x80-0x9F 範囲 (C1 制御文字。通常は表示可能文字を持たない) を、MySQL は cp1252 同様、印字可能な記号 (€, ‚, „, …, †, ‡, ˆ, ‰, Š, ‹, Œ, Ž, ‘, ‘, “, “, •, –, —, ˜, ™, š, ›, œ, ž, Ÿ) に割り当てている。

ただし完全に一致するわけではなく、cp1252 側で「未定義」とされている 5 つのバイト値 (0x81, 0x8D, 0x8F, 0x90, 0x9D) については、MySQL は素直に「バイト値=コードポイント」という ISO-8859-1 的な扱いにフォールバックする。この違いが、後述する Python での変換処理を書く上での注意点になる。

直し方: 変換を逆再生する

上記のプロセスの逆をたどれば、壊れたバイト列 B2 から元の正しいバイト列 B を復元できる。

  1. B2 を UTF-8 としてデコードして U を得る (= MySQL が「cp1252 として解釈した」結果の文字列を復元する)
  2. U を cp1252 (+未定義スロットのフォールバック) でエンコードし直して B を得る (=元の生バイト列そのもの)

Python での実装:

def mysql_latin1_encode(s):
    """
    MySQLの「latin1」(実体はcp1252ベース、一部未定義スロットのみ
    素のLatin-1的バイト値=コードポイント)としてエンコードする。
    cp1252で表現できない(=U+00FFより大きい)文字は、そもそも
    今回の文字化けとは無関係の、正しいUTF-8文字である可能性が高いので
    そのままUTF-8としてエンコードして通す。
    """
    out = bytearray()
    for c in s:
        try:
            out += c.encode('cp1252')
        except UnicodeEncodeError:
            if ord(c) <= 0xFF:
                # cp1252の未定義スロット(0x81,0x8D,0x8F,0x90,0x9D)
                out.append(ord(c))
            else:
                # 元々壊れていない正しいUTF-8文字(phpMyAdmin自身が
                # 出力したヘッダーコメント中の日本語など)
                out += c.encode('utf-8')
    return bytes(out)
Code language: Python (python)

これをダンプファイル全体に適用する:

with open('dump.sql', 'r', encoding='utf-8') as f:
    data = f.read()

fixed = mysql_latin1_encode(data)

with open('dump_fixed.sql', 'wb') as f:
    f.write(fixed)
Code language: Python (python)

実際に blogname の値で検証すると:

>>> b = bytes.fromhex('c3a5e280bac5bec3a3e2809ac592c3a5c28fc2b3c3a3c281c2aec3a5e280a0e280a6c3a8c2bcc2aac3a5c2b7c2ae')
>>> s = b.decode('utf-8')
>>> fixed_bytes = mysql_latin1_encode(s)
>>> fixed_bytes
b'\xe5\x9b\x9e\xe3\x82\x8c\xe5\x8f\xb3\xe3\x81\xae\xe5\x86\x85\xe8\xbc\xaa\xe5\xb7\xae'
>>> fixed_bytes.decode('utf-8')
'回れ右の内輪差'
Code language: Python (python)

正しい日本語に戻った。

なぜ「ファイル全体を一律に変換」しても安全なのか

ダンプファイルには、DB の中身以外にも phpMyAdmin 自身が生成したコメント (-- ホスト: 127.0.0.1 のような) が含まれる。これらは最初から正しい UTF-8 であり、変換してしまうと逆に壊れる。

上記の実装では、cp1252 でエンコードできない文字 (=コードポイントが 0xFF を超える、つまり日本語の漢字・ひらがな・カタカナなど本物の CJK 文字) に遭遇した場合は、変換をスキップしてそのまま UTF-8 として書き出すようにしている。文字化けした中間文字列は必ず cp1252 の範囲 (0x00-0xFF、および前述の特殊記号群) に収まるコードポイントの並びになる一方、正しい CJK 文字はこの範囲には収まらないため、この判定だけで「壊れている部分」と「元から正しい部分」を区別できる。

CREATE TABLE 定義側の修正も忘れずに

上記の変換で実データのバイト列は正しい UTF-8 に戻るが、テーブル定義自体は CHARSET=latin1 のままである。データと定義の食い違いを解消するため、CREATE TABLE 文の宣言も utf8mb4 に書き換える。

sed -i \
  -e 's/CHARSET=latin1/CHARSET=utf8mb4/g' \
  -e 's/COLLATE=latin1_swedish_ci/COLLATE=utf8mb4_unicode_ci/g' \
  -e 's/CHARACTER SET latin1 COLLATE latin1_swedish_ci/CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci/g' \
  dump.sql
Code language: Bash (bash)

ファイル冒頭の SET NAMES utf8mb4 はそのままで良い (元々正しい)。これで「utf8mb4 接続で、正真正銘 utf8mb4 のバイト列を、utf8mb4 カラムに保存する」という一貫した状態になり、インポート時に余計な変換は一切発生しない。

(参考: 古い MySQL/MariaDB では、latin1 から utf8mb4 への変換でインデックスの最大長制限 (767 バイト) に引っかかることがあるが、モダンなバージョン (今回は MariaDB 11.8) では基本的に問題にならない。)

PHP のシリアライズ文字列は壊れないのか?

WordPress のオプションや postmeta には、PHP の serialize() 形式 (s:19:"..."; のように、文字列の バイト長 を数値で埋め込む形式) のデータが大量にある。文字コード変換でバイト長が変わってしまうと、この長さプレフィックスとズレて壊れるのでは、と心配になる。

結論としては問題ない。理由は、この長さプレフィックスが記録しているのは「文字化けが起きる前、WordPress が正常に動いていた時点での、正しい UTF-8 文字列のバイト長」だからである。文字化けは このダンプをエクスポートした瞬間 に発生したものであり、DB 内部のシリアライズ済みデータ自体を書き換える操作ではない (読み出して変換して送っただけ)。したがって、今回の手順で正しいバイト列に復元すれば、serialize() の長さプレフィックスとも自然に一致する。

まとめ: 対応の全体フロー

  1. phpMyAdmin で「エンコーディングへの変換: なし」で SQL をエクスポート
  2. SELECT HEX(...) で怪しいカラムの生バイトを確認し、症状を特定
  3. Python スクリプトで、ファイル全体に対して「UTF-8 デコード → cp1252 (+フォールバック) エンコード」を適用
  4. CREATE TABLECHARSET=latin1utf8mb4 に書き換え
  5. 修正済みのファイルをインポート

関連: 移行手順の全体像は WordPress.com (Atomic) → Hostinger 移行手順メモを参照。

1件のコメント

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

コメントする