multibyte corruption

HTTP取得の1行が原因で、日本語ページを56本壊した記録

Node.jsでページを取得するコードを何年も同じ形で書いていました。res.on('data', c => d += c)。2026年8月8日、この1行が原因で取り込んだ208ページのうち56ページに壊れた文字(U+FFFD)が入りました。原因の仕組みと、壊れた後の切り分け、修復で守ったルールを記録します。

このサイトはAnthropicの公式サイトではありません。Claude Codeの機能・料金・提供範囲は変わる可能性があるため、重要な判断の前には公式のドキュメントと自分のアカウント設定を確認してください。

先に結論:チャンクは配列に溜めて、最後に一度だけ変換する

壊れる形と壊れない形の差は1行です。

// 壊れることがある
let d = '';
res.on('data', c => d += c);            // チャンクごとに文字列化される
res.on('end', () => use(d));

// 壊れない
const chunks = [];
res.on('data', c => chunks.push(c));     // Bufferのまま溜める
res.on('end', () => use(Buffer.concat(chunks).toString('utf8')));

UTF-8の日本語は1文字が3バイトです。HTTPのチャンクはバイト位置で切れるので、3バイトの文字がチャンクの境目をまたぐことがあります。文字列連結の形だと、またいだ文字の前半と後半が別々に文字列化され、それぞれが不正なバイト列として置換文字(U+FFFD、画面では「�」)になります。

質の悪いことに、これは毎回は起きません。チャンクの切れ目が文字の境目と一致していれば正常に取れます。だから小さいテストでは通り、量を流すと数十ページに1回の割合で混入します。実測では208ページ中56ページでした。

どう気づいたか:審査前の全数点検で出た

壊した当日は気づきませんでした。見つかったのは、広告審査の前に全ページを機械で点検したときです。置換文字を含むページが56本ありました。

ここで重要だったのは、56本すべてが取得コードのせいだと決めつけなかったことです。本番から同じページを取り直して比べると、2種類に割れました。

種類本数意味
本番は正常、手元だけ壊れている36本取得コードのバグ。取り直せば直る
本番にも壊れた文字がある20本別の欠陥。過去のどこかで壊れたまま公開されていた

もし全部を「取得バグ」として取り直しだけで済ませていたら、本番に実在する20本の壊れた文字を見逃していました。逆に全部を手で直していたら、36本ぶんの無駄な作業でした。壊れたファイルを見たら、まず原本と突き合わせて壊れた場所を確定する。これが一番効いた手順です。

修復のルール:文脈から確定できる文字だけ直す

本番にも実在した20本は、前後の文脈から欠けた文字を復元しました。このとき決めたルールは1つです。推測が入る箇所は直さない。

  • 「ネ��トワーク」→「ネットワーク」のように、前後から一意に確定する箇所だけ置換する
  • 置換は「壊れた部分を含む一意な文字列」で行い、同じパターンの一括置換はしない(別の箇所を巻き込むため)
  • 確定できない箇所が残った場合は、その文を書き直すのではなく作業を止めて原本の履歴を探す

実測では22箇所の修復で、置換文字の残りをゼロにできました。壊れ方は「サ��ト」「イン��トール」のようにほぼ全てが日本語の3バイト文字で、これは上の仕組みから見て整合的です。

自分のファイルが壊れていないか、今すぐ確かめる

過去に取得・生成したファイルが手元に溜まっているなら、置換文字の有無だけでも先に見ておく価値があります。判定は1行です。

grep -rl $'�' --include="*.html" .

これはU+FFFDのUTF-8表現(EF BF BD)を含むファイルを列挙します。1件でも出たら、上の切り分け(原本と比べる)から始めてください。

注意点として、この検査は「置換文字になった壊れ方」しか拾いません。文字コードの取り違えで別の読める文字に化けた場合(いわゆる文字化けの多く)は、置換文字を経由しないので出ません。Windowsの文字コード起因の壊れ方は別ページの記録のとおり、壊れても正常なファイルに見えることがあります。検査は壊れ方の種類ごとに要る、というのが実測から言えることです。

再発防止:取得コードを直すだけでは足りなかった

取得コードはBuffer.concatに直しました。ただ、この手のバグは別の場所でまた書きます。実際、翌週に別の検査スクリプトを書いたときも同じ形を書きかけました。残した防止策は2つです。

  • ページを書き出す処理の直前に、置換文字とハングル・キリル文字の検査を入れる。検査に落ちたら1文字も書き出さない。生成・取得・修復のどの経路で壊れても、公開の手前で止まります
  • バッチ作業の後は毎回、対象ファイル全体に文字検査を流す。1回の検査は数秒で、56ページの手当ては半日でした
const bad = html.match(/[\uAC00-\uD7AF\u0400-\u04FF\uFFFD]/g);
if (bad) throw new Error('混入 ' + bad.length + '件');

なお、日本語の本文をAIに大量に書かせると、取得バグとは別の経路でハングルが単発で混入することがあります(実測で複数回)。検査を「置換文字だけ」にせず他言語文字まで広げているのはそのためです。

よくある質問

fetchやaxiosなら起きませんか?

fetchのresponse.text()やaxiosは内部でバイト列を正しく結合するので、この形の壊れ方はしません。素のhttp/httpsモジュールでストリームを自前処理するときだけ踏む穴です。ただし公開前の文字検査は取得手段に関係なく入れています。壊れる経路は取得だけではないからです。

壊れたまま公開されていた20本は、いつ壊れたのですか?

確定できませんでした。数か月前の生成時から壊れていたページもあり、原因の特定より「今後は公開の手前で必ず検査に掛かる」ことを優先しました。

次に読むページ