Claude Media
Claude CodeでShift_JISを取得して文字化けさせない — requestsの文字コード判定

Claude CodeでShift_JISを取得して文字化けさせない — requestsの文字コード判定

requestsでShift_JISやEUC-JPのサイトを取得すると化ける原因を、fixtureで5ケース再現して切り分けます。cp932の扱いとBOM付きCSVの読み方、Claude Codeに任せる指示も載せます。

官公庁のサイトをClaude Codeにスクレイピングさせると、r.text の日本語が � だらけになることがあります。原因はたいてい2つです。requestsが本文の文字コードを読み違えているか、shift_jis と cp932 の違いで一部の文字だけが欠けているかです。どちらも手元に小さなHTTPサーバーを立て、同じ本文を別々のヘッダで返せば再現できます。

この記事では、Python 3.12.2とrequests 2.34.2で5種類のfixtureを作って挙動を確かめた結果と、それを踏まえた判定関数、BOM付きCSVの読み方、Claude Codeへの指示の書き方をまとめます。Claude Codeが読む側のファイルがShift-JISで編集を拒否される問題は、Claude CodeでShift-JISを編集できない原因とUTF-8変換の手順にあります。ここで扱うのは、ネットワークから取得した側の文字コードです。

なぜ取得側で化けるのか — fixtureで5ケースを再現する

本文は同じ文字列にします。cp932にしか無い文字を意図的に混ぜます。

㈱東京 ①番 髙橋 ~ - 令和の予算表

Pythonの標準ライブラリだけでサーバーを立て、ルートごとにヘッダと本文のエンコードを変えます。抜粋は次のとおりです。

from http.server import BaseHTTPRequestHandler, HTTPServer
 
BODY = "<html><head>{meta}<title>予算</title></head><body><p>㈱東京 ①番 髙橋 ~ - 令和の予算表</p></body></html>"
 
def page(meta, enc):
    return BODY.format(meta=meta).encode(enc)
 
ROUTES = {
    "/a": ("text/html", page('<meta charset="Shift_JIS">', "cp932")),
    "/b": ("text/html; charset=Shift_JIS", page('<meta charset="Shift_JIS">', "cp932")),
    "/d": ("text/html; charset=UTF-8", page('<meta charset="Shift_JIS">', "cp932")),
    "/e": ("application/octet-stream", page("", "cp932")),
}
 
class H(BaseHTTPRequestHandler):
    def do_GET(self):
        ctype, body = ROUTES[self.path]
        self.send_response(200)
        self.send_header("Content-Type", ctype)
        self.send_header("Content-Length", str(len(body)))
        self.end_headers()
        self.wfile.write(body)
 
HTTPServer(("127.0.0.1", 8791), H).serve_forever()

/c はEUC-JPで、本文から ㈱ などを除いています。EUC-JPのコーデックはこれらを符号化できないためです(後述します)。この5本に requests.get して、r.encoding、r.apparent_encoding、r.text の先頭を並べると次の結果でした。

ルートContent-Typeヘッダmetar.encodingapparent_encodingr.text
/aContent-Typeヘッダtext/htmlmetaShift_JISr.encodingISO-8859-1apparent_encodingCP932r.text全文字化け
/bContent-Typeヘッダcharset=Shift_JISmetaShift_JISr.encodingShift_JISapparent_encodingCP932r.text㈱ ① 髙 だけ化ける
/cContent-Typeヘッダcharset=EUC-JPmetaEUC-JPr.encodingEUC-JPapparent_encodingEUC-JPr.text正常
/dContent-Typeヘッダcharset=UTF-8metaShift_JISr.encodingUTF-8apparent_encodingCP932r.text全文字化け
/eContent-Typeヘッダapplication/octet-streammetaなしr.encodingNoneapparent_encodingCP932r.text正常

見るべき点は3つあります。/a と /d は本文の中身が同じなのに、ヘッダの書き方だけで結果が変わります。/b はヘッダが正しいのに化けます。/e は r.encoding が None なのに読めています。順に見ます。

ヘッダにcharsetが無いとISO-8859-1になる

/a の r.encoding は ISO-8859-1 でした。Content-Type に charset が無いtext系のレスポンスを、requestsがラテン文字として読んだことになります。meta要素に Shift_JIS と書いてあっても、requestsはHTMLの本文を見て直すことはしません。

requestsのドキュメントも、推測の根拠はHTTPヘッダだと書いています。そのうえで、HTMLやXMLのように本文に文字コードを持てる形式では、r.content から文字コードを調べて r.encoding に入れてから r.text を使うよう勧めています。

つまり対処は次の2行です。

r.encoding = "cp932"      # 自分で決めた文字コードを入れる
print(r.text)             # 以降の r.text はこの値で decode される

ここで問題になるのは、何を入れるかです。meta要素を読む、apparent_encoding を使う、取得先ごとの既知の値を決め打ちする、の3通りがあります。同じ省庁のサイトを定期取得する用途なら、決め打ちが素直です。

charset=Shift_JISでも ㈱ ① 髙 が化ける

/b は、ヘッダに charset=Shift_JIS があるので r.encoding も Shift_JIS になります。それでも ㈱ ① 髙 が文字化けしました。残りの日本語は読めるので、気づきにくい化け方です。

原因は、Pythonの shift_jis と cp932 が別のコーデックであることです。同じバイト列を両方で読むと、記号の一部が変わります。

バイト列cp932 で読むとshift_jis で読むと
81 60cp932 で読むと~(U+FF5E)shift_jis で読むと〜(U+301C)
81 7Ccp932 で読むと-(U+FF0D)shift_jis で読むと−(U+2212)
87 8A(㈱)cp932 で読むと㈱shift_jis で読むとデコード不能

さらに、㈱①髙~− を shift_jis で符号化しようとすると illegal multibyte sequence で止まります。同じ文字列を cp932 で符号化すると 878a8740eee08160817c になりました。euc_jp も ㈱ で同じエラーです。

Pythonのcodecsドキュメントでは、cp932 の別名に windows-31j や ms932 が並んでいます。一方、ブラウザ向けの文字コード仕様(WHATWG Encoding Standard)は、shift_jis、sjis、windows-31j、x-sjis などのラベルをすべて1つのShift_JISとして扱います。ラベル違いを区別する側と、区別しない側があるわけです。ブラウザでは読めるのにPythonでは化ける、という場面では、まず shift_jis を cp932 に読み替えてみます。

ALIAS = {"shift_jis": "cp932", "shift-jis": "cp932", "sjis": "cp932",
         "x-sjis": "cp932", "windows-31j": "cp932"}

記号の差が問題になる用途(全角チルダと波ダッシュの区別が必要な突合など)では、取得後に正規化する工程を別に置きます。読み替えで変わるのは、上の表のような記号の対応です。

なお、unicodedata.normalize("NFKC", ...) を通すと、~ は半角の ~、- は -、㈱ は (株)、① は 1 に変わります。髙 はそのままです。名寄せのために正規化するなら、元の表記を別の列に残しておくと、後で戻せます。

ヘッダが嘘をつくとき — 厳密デコードで候補を試す

/d は、ヘッダが UTF-8 なのに本文はcp932です。ヘッダを信じると全文が化けます。このfixtureで r.content.decode("utf-8") を厳密に(エラーを無視せず)実行すると、45バイト目で UnicodeDecodeError になりました。euc_jp も同じ位置で失敗し、cp932 だけが通りました。

そこで、候補を優先順位つきで並べ、厳密デコードが最初に通ったものを採用します。

import re
import requests
 
META = re.compile(rb'<meta[^>]+charset\s*=\s*["\']?\s*([\w\-]+)', re.I)
 
def candidates(r):
    m = re.search(r"charset=([\w\-]+)", r.headers.get("Content-Type", ""), re.I)
    if m:
        yield m.group(1)                     # 1. ヘッダ
    m = META.search(r.content[:2048])
    if m:
        yield m.group(1).decode("ascii")     # 2. 本文先頭の meta
    if r.apparent_encoding:
        yield r.apparent_encoding            # 3. 推定
 
def decode_html(r):
    seen = set()
    for name in candidates(r):
        enc = ALIAS.get(name.lower(), name)
        if enc.lower() in seen:
            continue
        seen.add(enc.lower())
        try:
            return r.content.decode(enc), enc   # 厳密に decode
        except (UnicodeDecodeError, LookupError):
            continue
    return r.content.decode("cp932", errors="replace"), "cp932(replace)"

5ケースに適用した結果です。

a cp932  <p>㈱東京 ①番 髙橋 ~ - 令和の予算表
b cp932  <p>㈱東京 ①番 髙橋 ~ - 令和の予算表
c EUC-JP <p>令和の予算表
d cp932  <p>㈱東京 ①番 髙橋 ~ - 令和の予算表
e CP932  <p>㈱東京 ①番 髙橋 ~ - 令和の予算表

/d もヘッダのUTF-8を厳密デコードで落とし、metaのShift_JISに進んで救えています。最後の errors="replace" は、どの候補も通らなかったときの退避です。ここに到達したら置換文字 U+FFFD が混じっているはずなので、件数を数えてログに出してください。

meta要素の読み取りについても、手元で確かめた範囲を書いておきます。上の正規表現は、<meta charset="Shift_JIS"> の形と、<meta http-equiv="Content-Type" content="text/html; charset=EUC-JP"> の形の両方で文字コード名を取れました。一方、meta要素の前に3,000字ほどのコメントを置いたHTMLでは、先頭2,048バイトに切った時点で見つからなくなります。切らずに全体へ当てれば取れますが、バイナリが混じる本文に正規表現を当てる処理は重くなりがちです。取得先のHTMLを数ページ見て、metaの位置が先頭付近に収まっていることを確かめてから、範囲を決めるのが現実的です。

apparent_encoding は、requestsのAPIリファレンスによるとcharset_normalizerまたはchardetが返す推定値です。/e はこれだけで読めましたが、本文が数十文字あったからです。数文字だけの本文では推定が外れる余地が大きくなります。推定は最後の候補として置き、採用した enc を必ず記録しておけば、外れたときに原因を追えます。

BOM付きCSVはutf-8-sigで読む

CSVも、Shift_JISで配られるものとBOM付きUTF-8で配られるものがあります。Shift_JISのCSVは、HTMLと同じく cp932 で読みます。

import csv, io, requests
 
r = requests.get("http://127.0.0.1:8791/csv")        # charset=Shift_JIS のCSV
rows = list(csv.reader(io.StringIO(r.content.decode("cp932"), newline="")))
# [['年度', '金額'], ['令和6', '①123']]

問題はBOM付きUTF-8です。/csv8 は Content-Type: text/csv で、本文が ef bb bf から始まります。fixtureで3通りに読んだ結果は次のとおりです。

読み方先頭行
r.text(r.encoding はISO-8859-1)先頭行['年度', ...](全体が化ける)
decode("utf-8")先頭行['\ufeff年度', '金額'](先頭の列名にBOMが付く)
decode("utf-8-sig")先頭行['年度', '金額']

2行目が厄介です。見た目は正常なのに、列名が 年度 ではなく \ufeff年度 になっているので、row["年度"] が KeyError になります。Pythonのcodecsドキュメントによれば、utf-8-sig は復号時に先頭3バイトのBOMを読み飛ばします。BOMの有無が分からないUTF-8のCSVは、utf-8-sig で読めばどちらも同じ結果になります。csv モジュールに渡す文字列は newline="" で作る、とcsvのドキュメントにあります。上のコードの io.StringIO(..., newline="") はその指定です。

CSV用の判定も、HTMLと同じ考え方でまとめられます。

def decode_csv(content: bytes) -> tuple[str, str]:
    for enc in ("utf-8-sig", "cp932"):       # UTF-8 を先に厳密に試す
        try:
            return content.decode(enc), enc
        except UnicodeDecodeError:
            continue
    raise ValueError("utf-8-sig でも cp932 でも読めない")

UTF-8の厳密デコードは、cp932のCSVで失敗しやすい、という前提の順序です。順序を入れ替えたい場合や、全角文字が少ないCSVでは、両方向のfixtureで結果を確かめてください。

落とし穴

EUC-JPを経由すると ㈱ や ① が消える

サイトがEUC-JPだからといって、取得後の保存や変換にEUC-JPを使うと、㈱ や ① などの文字を符号化できずに例外になります(上のfixtureで確かめた挙動です)。デコードした時点でテキストはPythonのstrなので、保存はUTF-8で行います。

Claude Codeに任せるときの指示

ここまでの内容は、Claude Codeにスクレイパーを書かせる前提で、2つの形で渡せます。

1つ目は、プロジェクトのCLAUDE.mdに規約として書く方法です。筆者が考えた記述例で、パスや関数名は読み替えてください。

# 文字コード(スクレイピング)
- HTTP取得は requests。r.text を直接使わず、decode_html(r) を通す。
- shift_jis と書かれていても cp932 で読む。厳密デコードに失敗したら次の候補へ進む。
- CSV は utf-8-sig を先に試し、だめなら cp932。BOM が列名に混じっていないか確認する。
- 保存は UTF-8。decode に使った文字コードを必ずログに残す。

2つ目は、再現手順を渡して直させる方法です。Claude Codeのcommon workflowsは、バグ修正のコツとして、再現するコマンドを伝えること、再現手順を伝えること、毎回起きるのか断続的かを伝えることを挙げています。文字化けにも同じことが当てはまります。次のように、fixtureと期待値を先に渡します。

tests/fixtures/server.py を起動し、tests/test_decode.py の4ケースが全部通るまで
decode_html を直してください。cp932 固有の ㈱ ① 髙 が欠けたら失敗とします。

反復の終了条件には、置換文字が残っていないことも入れておくと確実です。assert "\ufffd" not in text を各ケースに足せば、errors="replace" の退避に落ちた場合もテストが赤になります。Claudeに直させるときは、pytestの出力をそのまま読ませて、どのケースの enc が何だったかを表にして報告させると、修正の筋が見えます。

テスト側は、期待する文字列を直接書くだけで足ります。

import pytest, requests
from scraper import decode_html
 
EXPECTED = "㈱東京 ①番 髙橋 ~ - 令和の予算表"
 
@pytest.mark.parametrize("path", ["a", "b", "d", "e"])
def test_cp932_pages(path):
    r = requests.get(f"http://127.0.0.1:8791/{path}")
    text, _ = decode_html(r)
    assert EXPECTED in text

実際の取得先に対してテストを書くより、fixtureを用意するほうが、Claudeが何度も試す反復に向いています。外部サイトへのアクセスが増えず、結果も毎回同じだからです。fixtureで通った後に、実サイトを1回だけ取得して判定結果の enc を確かめる、という順序にすると、相手サイトに負荷をかけずに済みます。

スクレイピングそのものの作り方(待機、リトライ、スキーマ検証)は、Claude CodeでPlaywrightのスクレイピングを作るにあります。文字化けの原因がBOMだった別の事例は、Claude Codeが作るps1が文字化けして止まる原因とBOMの対処で見られます。

切り分けの早見表

文字化けを見つけたときの順序を、症状別に並べます。

症状疑うもの確認コマンド
全文が意味の無い記号の列になる疑うものヘッダにcharsetが無く ISO-8859-1確認コマンドprint(r.encoding)
全文が � になる疑うものヘッダのcharsetが本文と違う確認コマンドr.content.decode("utf-8") が失敗するか
大半は読めるが ㈱ ① 髙 だけ欠ける疑うものshift_jis で読んでいる確認コマンドr.encoding = "cp932" を試す
CSVの先頭列だけキーが合わない疑うもの列名にBOMが付いている確認コマンドrepr(rows[0][0])
短い本文だけ判定がぶれる疑うものapparent_encoding の推定ミス確認コマンド推定を使わず決め打ちにする

最後の行は、推定を信じすぎない、という方針です。決め打ちできる取得先は決め打ちにして、推定は初めて取得するサイトの当たりをつける用途にとどめると、ぶれが減ります。

まとめ

requestsの r.text は、ヘッダの charset を基準に読みます。ヘッダが無ければ ISO-8859-1、あっても本文と食い違えば化けます。日本語のサイトでは、shift_jis を cp932 に読み替え、厳密デコードで候補を順に試す関数を1つ置くと、このfixtureの5ケースは収まります。CSVは utf-8-sig を先に試します。Claude Codeには、この関数とfixtureを渡して、テストが通るまで直させます。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/common-workflows
  2. requests.readthedocs.io/en/latest/user/quickstart
  3. requests.readthedocs.io/en/latest/api
  4. docs.python.org/3/library/codecs.html
  5. docs.python.org/3/library/csv.html
  6. encoding.spec.whatwg.org/
この記事を共有:XはてブLinkedIn