日本語のデータを返すAPIをrequestsで叩いたら、レスポンスが文字化けしました。原因はresponse.encodingがサーバーの指定と食い違っていたこと。
requestsライブラリのエンコーディング決定ロジックと、Shift_JIS/CP932混在環境での対処法をまとめます。
問題:response.textが文字化けする
症状
1import requests2
3resp = requests.get("https://example.jp/api/items")4print(resp.text)5# => '繝ヲ繝シ繧カ繝シ繝ェ繧ケ繝' (意味不明な文字列)期待していたのは「ユーザーリスト」。いわゆるUTF-8のバイト列をShift_JISとして解釈、あるいはその逆で起きる文字化けです。
調査
レスポンスヘッダと、requestsが判定したエンコーディングを確認します。
1resp = requests.get("https://example.jp/api/items")2print("Content-Type:", resp.headers.get("Content-Type"))3print("encoding:", resp.encoding)4print("apparent_encoding:", resp.apparent_encoding)5print("先頭bytes:", resp.content[:100])よくある出力例:
1Content-Type: text/html2encoding: ISO-8859-13apparent_encoding: Shift_JIS4先頭bytes: b'<!DOCTYPE html>\n<meta charset="Shift_JIS">\n...'サーバーはShift_JISで返しているのに、requestsはISO-8859-1と判定している。これが文字化けの原因です。
なぜrequestsはISO-8859-1と判定するのか
requestsは、Content-Typeヘッダにcharset=が含まれない場合、HTTP RFCに従って**ISO-8859-1をデフォルトにします**。
1Content-Type: text/html → encoding = ISO-8859-1 (RFCデフォルト)2Content-Type: text/html; charset=UTF-8 → encoding = UTF-83Content-Type: text/html; charset=Shift_JIS → encoding = Shift_JIS問題は、多くのサーバーがcharsetを指定しないこと。その場合、レスポンスbody内の<meta charset="...">やBOMを見ないと正しいエンコーディングはわかりません。
解決策の段階
段階1: apparent_encodingに切り替える
1resp = requests.get(url)2resp.encoding = resp.apparent_encoding3text = resp.textapparent_encodingはchardetライブラリがbodyの内容から推測したエンコーディング。多くのケースではこれで解決します。
注意:chardetは推測なので、短いレスポンスや混在したコンテンツでは誤判定します。英数字だけのレスポンスをISO-8859-1と判定することもあります。
段階2: Content-Typeとmetaを自分で解析
より堅牢にするなら、ヘッダ→body内のmeta→apparent_encodingの優先順で判定します。
1import re2
3def detect_encoding(resp: requests.Response) -> str:4 # 1. Content-Typeヘッダのcharset5 ct = resp.headers.get("Content-Type", "")6 m = re.search(r"charset=([\w-]+)", ct)7 if m:8 return m.group(1)9
10 # 2. body内の <meta charset="...">11 body_head = resp.content[:2048]12 m = re.search(13 rb'<meta[^>]+charset=["\']?([\w-]+)',14 body_head, re.IGNORECASE15 )14 collapsed lines
16 if m:17 return m.group(1).decode("ascii", errors="ignore")18
19 # 3. BOMで判定20 if resp.content.startswith(b"\xef\xbb\xbf"):21 return "utf-8"22 if resp.content.startswith(b"\xff\xfe"):23 return "utf-16-le"24
25 # 4. chardetに委ねる26 return resp.apparent_encoding or "utf-8"27
28resp = requests.get(url)29resp.encoding = detect_encoding(resp)段階3: 既知のサイトはドメイン別にハードコード
相手が固定なら、推測せずに決め打ちが最も確実です。
1DOMAIN_ENCODING = {2 "example.jp": "shift_jis",3 "legacy.co.jp": "cp932",4 "modern.com": "utf-8",5}6
7def fetch(url: str) -> str:8 from urllib.parse import urlparse9 host = urlparse(url).netloc10 encoding = DOMAIN_ENCODING.get(host)11
12 resp = requests.get(url)13 if encoding:14 resp.encoding = encoding15 else:2 collapsed lines
16 resp.encoding = detect_encoding(resp)17 return resp.textShift_JIS vs CP932 の落とし穴
**Shift_JISと名乗っているサーバーの大半は、実はCP932(Windows拡張)**を使っています。
1# 標準Shift_JISでは扱えない文字2text = "①②③" # 丸数字はCP932独自の拡張領域3
4resp.encoding = "shift_jis"5resp.text # → UnicodeDecodeError または文字化け6
7resp.encoding = "cp932"8resp.text # → 正常にデコード| 名前 | 範囲 | 使い分け |
|---|---|---|
shift_jis | JIS X 0208の範囲 | 厳密な規格データ |
cp932 / ms932 | Shift_JIS + Windows拡張(丸数字、ローマ数字、機種依存文字) | 実務では基本こちら |
shift_jisx0213 | JIS X 0213(2004年改訂) | 人名・地名の特殊字 |
迷ったらcp932を使うのが安全です。Shift_JISで読めるデータは全てCP932でも読めます(上位互換)。
1# 堅牢な置き換え2resp.encoding = "cp932" if "shift" in resp.encoding.lower() else resp.encoding実際に遭遇した事故
事故1: errors=“strict”で例外が出て止まる
1resp.encoding = "shift_jis"2text = resp.text # UnicodeDecodeError: 丸数字を読めないCP932独自文字が含まれていると、厳密なShift_JISでは落ちます。
対処:
1# 1) CP932に切り替える2resp.encoding = "cp932"3
4# 2) それでもダメなら errors="replace" で不正バイトを置換5decoded = resp.content.decode("cp932", errors="replace")事故2: Content-Typeに嘘が書かれている
1Content-Type: text/html; charset=UTF-82(実際のbodyは Shift_JIS)サーバー側のバグや設定ミスで、ヘッダと実体が食い違う。この場合、ヘッダを信じると文字化けします。
対処:ヘッダのcharsetを信じる前に、bodyのmetaやchardetの結果と突き合わせます。
1def cross_check_encoding(resp):2 header_enc = resp.encoding3 body_enc = resp.apparent_encoding4
5 if header_enc and body_enc and header_enc.lower() != body_enc.lower():6 logger.warning(7 f"エンコーディング不一致: header={header_enc}, body={body_enc}"8 )9 # 一般には body を優先した方が安全10 return body_enc11 return header_enc or body_enc or "utf-8"事故3: BOM付きUTF-8の最初の文字が化ける
1text = resp.text2print(text[0]) # => '\ufeff' (BOMが残っている)requestsはUTF-8のBOM(\xef\xbb\xbf)を自動除去しません。先頭に\ufeffが残り、JSON解析で落ちたりします。
対処:
1text = resp.text2if text.startswith("\ufeff"):3 text = text[1:]または encoding="utf-8-sig" を指定(BOMを自動的に取り除く)。
1resp.encoding = "utf-8-sig" if resp.content.startswith(b"\xef\xbb\xbf") else "utf-8"テスト戦略
文字化けバグは「実環境では出るがユニットテストでは出ない」になりがちです。実データの先頭バイト列を固定したテストを書きます。
1def test_decode_shift_jis_response():2 mock_resp = mock.Mock()3 mock_resp.headers = {"Content-Type": "text/html"}4 mock_resp.content = "ユーザーリスト".encode("cp932")5 mock_resp.apparent_encoding = "shift_jis"6
7 encoding = detect_encoding(mock_resp)8 assert encoding.lower() in ("cp932", "shift_jis")過去に事故ったサンプルをtests/fixtures/に置いて回帰テストにしておきます。
まとめ
| 症状 | 原因 | 対処 |
|---|---|---|
ISO-8859-1と判定される | Content-Typeにcharsetがない | apparent_encodingに切り替える |
| Shift_JISで丸数字が化ける | CP932拡張文字 | cp932を指定 |
ヘッダはUTF-8なのに文字化け | サーバー設定ミス | bodyのmeta/chardetと突き合わせる |
先頭が\ufeff | UTF-8 BOM | utf-8-sigまたは手動除去 |
教訓: 日本語API連携では、response.textをそのまま信じません。必ずencodingを明示的に設定します。ドメインが固定なら、決め打ちが最も事故が少ないです。
Pythonの標準HTTPライブラリは国際化対応が完全ではありません。特に日本語のレガシーAPIとの連携では、エンコーディングは自分でコントロールするというスタンスが必要です。