URL 取得ゲートとは、Web スクレイピングや AI エージェントの外部情報収集において、取得したコンテンツが後続の処理に耐えうる品質を満たしているかを検証・遮断する検収機構です。

かつて私たちのチームでは、HTTP ステータスコードが 200 OK であり、抽出された本文の単語数(wordCount)が一定以上(空判定閾値を超える量)あれば、「取得は成功した」と判定してパイプラインを後続へ進めていました。ネットワークエラーや空ページの混入は防げていたため、この二重チェックでゲートとしては十分機能していると考えていたのです。

しかし、国内の老舗技術メディア(@IT、文字コード: CP932)を取得した際、この前提は脆くも崩れ去りました。Markdown 抽出ツールとして利用していた defuddle がページの文字コードを誤判定し、完全に文字化けしたテキストが生成されたにもかかわらず、wordCount ゲートを平然と素通りして後続のキャッシュに保存されてしまったのです。サーバーからのレスポンスは HTTP 200 であり、Content-Type ヘッダーは text/html のみで charset パラメータが付与されていませんでした1。

通信エラーや空コンテンツといった「取得できなかった」系の 4 症状(抽出量不足による未保存・403・402・全滅)をいくら厳密に塞いでいても、「取得自体は成功したが中身が致命的に壊れている(文字化け)」という内容面の破損状態は、ステータスコードと文字数チェックの死角を突いてすり抜けます。このインシデントを通じて私たちが学んだのは、原因の空間をいくら予測して予防を重ねても漏れは防げず、結果の空間で破損を検収し、壊れたら決定論的なフォールバック(降り先)へ垂直に降下させる規律の重要性でした。

なぜ文字数チェックの隣に「文字化けの降り先」を置いたのか:実務での選択理由

事の発端は、社内のブックマーク取り込みパイプライン(全 48 本中 1 本)で起きた障害でした。@IT の記事(元ページ文字コード: CP932 / Windows-31J)を取得した際、defuddle parse が charset を誤判定し、完全に読めない文字化けテキストを出力しました。

現場で最も時間を浪費させたのは、文字化けそのものよりも「失敗したときの降り先がルールに定義されていなかったこと」でした。明確なフォールバックが定められていなかったため、自律型エージェントは「defuddle への入力形式を工夫すれば解決するのではないか」と考え、同一レイヤーの中で以下のように横へ試行錯誤を繰り返しました。

  1. 元バイト列(CP932 + meta shift_jis)をそのまま defuddle に渡す → 文字化け
  2. iconv で UTF-8 化し、HTML 内の meta タグを charset=UTF-8 に書き換えて渡す → Latin-1 として解釈された別の文字化け
  3. iconv で UTF-8 化し、HTML 内の meta charset タグを削除して渡す → 同上(Latin-1 として解釈された別の文字化け)

試行 2・3 は、defuddle に渡す前に自分で UTF-8 へ変換しておくという、正攻法に見える前処理そのものです。それでも 3 通りのアプローチはすべて失敗に終わり、わずか 1 本の記事の取得のために 4 往復・約 10 分のやり取りを無駄に消費することになりました。

defuddle の文字コード処理には、ソースコードから読み取れる範囲で次の性質があります。

Web 標準において、shift_jis はマイクロソフト拡張文字を含む Windows-31J(CP932)相当のスーパーセットにマッピングされています2。また、Web 標準の TextDecoder はデフォルト(fatal: false)で動作するため、不正なバイトシーケンスに遭遇しても例外を投げず、置換文字(Unicode Replacement Character: U+FFFD)を出力して処理を正常終了します3。さらに defuddle の CLI 実装は標準入力やローカルファイル入力を UTF-8 として読み4、URL を直接取得する経路では先頭 1024 バイトから meta の charset を探し、見つからなければバイト列をスキャンして 0x80〜0x9F のバイトがあれば windows-1252 と判定します5。

ただし、これらの性質だけでは 3 通りの結果をすべては説明できません。CLI が UTF-8 として読むのなら、UTF-8 に変換して渡した試行 2・3 は正しく読めてよいはずだからです。原因の空間をいくら掘っても、その場で次の一手は決まりませんでした。一方、文字数チェックがこの破損を見逃した理由は、コードからはっきり読み取れます。

それが defuddle の src/utils.ts にある単語カウント関数 countWords の実装です6。

コードの目的は、defuddle の単語カウント処理において、置換文字や化けた記号列がなぜ単語としてカウントされるのかを構造から確認することです。

export function countWords(text: string): number {
	if (!text) return 0;

	let cjkCount = 0;
	let wordCount = 0;
	let inWord = false;

	for (let i = 0; i < text.length; i++) {
		const code = text.charCodeAt(i);

		// Check for CJK character ranges (BMP only — Extension B+ are
		// surrogate pairs and would need codePointAt, rare in practice)
		if (
			(code >= 0x3040 && code <= 0x309f) || // Hiragana
			(code >= 0x30a0 && code <= 0x30ff) || // Katakana
			(code >= 0x3400 && code <= 0x4dbf) || // CJK Extension A
			(code >= 0x4e00 && code <= 0x9fff) || // CJK Unified Ideographs
			(code >= 0xf900 && code <= 0xfaff) || // CJK Compatibility Ideographs
			(code >= 0xac00 && code <= 0xd7af)    // Korean Hangul
		) {
			cjkCount++;
			inWord = false;
		} else if (code <= 32) {
			inWord = false;
		} else if (!inWord) {
			wordCount++;
			inWord = true;
		}
	}

	return cjkCount + wordCount;
}

この処理では、ひらがな・カタカナ・CJK 漢字・ハングルの範囲の文字を 1 文字ずつ数え、コードポイント 32(空白)以下の空白・制御文字で単語の区切りを判定しています。それ以外の文字、つまり文字化けによって生成された置換文字(U+FFFD、コードポイント 65533 > 32)や Latin-1 の記号列は、欧文の単語として数えられます。その結果、人間から見れば完全に崩壊した文字列であっても、wordCount は加算され続けます。

私たちの一次計測ログでも、文字化けした本文に対して wordCount は空ではない正常な値を返し、未保存判定のゲートを素通りしたことが記録されています。

「降りる先が書かれていない症状に当たると、エージェントは降りずに同じ層で横に試す」。この行動特性を踏まえ、私たちは社内スキルの運用改定記録において、失敗検知テーブルに「本文が文字化けしている」という症状を追加しました。そしてその降り先として、次のように明記しました。

curl | iconv -f CP932 -t UTF-8 してから素のタグ除去で抜く。defuddle に食わせ直さない(meta を書き換えても消しても直らないと実測済み)。

さらに、検出できないゲートの隣にはその限界を明記する規律に従い、テーブル直下に「wordCount は文字化けしていても正常値を返すので、このゲートは素通りする。取れた本文は目で見る」という注記を併記しました。なお、この実測結果は Content-Type に charset が無く meta で shift_jis を指定している Web ページにおける挙動であり、あらゆる非 UTF-8 ページで常に同一の失敗が起きると過剰一般化するものではありません。しかし、同様の配信構成を持つ国内サイトにおいて、同じ落とし穴が口を開けていることは確かです。

予防(原因の空間)と検収(結果の空間)の境界:トレードオフと適用限界

この経験から得られた重要な教訓は、「予防」と「検収」の境界設計です。

文字コード誤判定の原因は、いわば無限の空間(原因の空間)に広がっています。WHATWG HTML Standard の encoding sniffing algorithm が規定しているように、HTML の文字コード判定は BOM の確認、HTTP レスポンスヘッダーの Content-Type、先頭 1024 バイトの meta 要素 prescan、各種自動判別ヒューリスティクスと多層にわたります7。

さらに歴史的な経緯も複雑さを増しています。RFC 91108 および RFC 72319 において、HTTP レベルでのデフォルト文字セット(かつての ISO-8859-1 定義)は廃止され、メディアタイプ側の仕様に委ねられることになりました。しかし、広く使われている Python の requests ライブラリ(requests/utils.py)などの実環境クライアントでは、歴史的互換性を維持するために charset 未指定時に依然として ISO-8859-1 を返す実装が残っています10。

また、バイト列スキャンによる統計的自動判別を組み込んだとしても、HTML タグなどの ASCII 文字列に日本語のバイト列が混ざる環境では判定の信頼度が低下します。Shift_JIS の第 1 バイト(0x81〜0x9F)は Windows-1252 などの Latin 系文字の制御・拡張領域と重複しており、短いスニペットや偏った記号列では誤判定をゼロにできません。原因側の予防をどれだけ積み上げても、想定外の入力パターンは必ずゲートをすり抜けます。

だからこそ、品質保証の主戦場は「結果の空間」に置かなければなりません。

検収とは、原因が何であれ「結果として出力されたテキストが破損しているか」という有限の性質に対して行う検査です。Web 標準の TextDecoder はデフォルト(fatal: false)でエラーを出さずに U+FFFD を出力するため23、ランタイム例外による受動的な検知は期待できません。したがって、パイプライン側で明示的に置換文字(\uFFFD)の混入比率や、日本語記事であるにもかかわらずひらがな・カタカナが極端に欠落しているといった状態を能動的に検収する必要があります。UTF-8 のバイト列を Latin-1 として読んだ文字化けでは置換文字が現れにくいため、置換文字の比率だけでなく文字種の比率も併せて見るのが妥当だと考えています。なお、今回私たちがスキルに入れた対策は「取れた本文は目で見る」という目視の注記までで、こうした自動判定は次の一手としての提案です。

そして、スマートな抽出器が誤判定を起こした場合は、同じツールの中でパラメータをいじるのではなく、原始的で壊れない「決定論的フォールバック」へ一気に降下します。

コードの目的は、文字化け発生時に高度なパーサーを諦め、まず文字コード変換だけをシェルパイプラインで確定させる実践例を示すことです。

# シェルスクリプトによる決定論的フォールバック(前半: CP932 から UTF-8 への変換)
curl -sL "https://atmarkit.itmedia.co.jp/ait/articles/2608/13/news049.html" \
  | iconv -f CP932 -t UTF-8

ここで重要なのは、文字コード変換ツール(iconv)に渡すエンコーディングとして、標準の Shift_JIS ではなく CP932 を指定することです。日本の Web コンテンツでは、丸数字(①)や単位記号などの Windows 機種依存文字が日常的に使用されています。JIS X 0208 に厳密なデコーダではこれらが不正シーケンスとして弾かれてしまいますが、Windows-31J(CP932)を指定することで確実に UTF-8 へ変換できます。

その後、変換済みの HTML から素のタグ除去で <script> や <style> を含むタグを落とし、本文を抜きます。見出しの階層構造やリンクといったリッチな Markdown 書式は失われますが、少なくとも「文字化けしていない正しいテキスト」を後続へ渡すことができます。なお、この降り先は元ページが CP932 だと分かっているときのものです。別の文字コードのページなら、iconv -f に渡す値も変わります。

取得ゲートの検査手法は、検知できる失敗モードとすり抜ける死角において明確なトレードオフを持っています。

検査アプローチ対象レイヤー検知できる失敗モードすり抜ける死角運用のトレードオフ
HTTP ステータス検査通信層ネットワーク切断、404 Not Found、403 ForbiddenHTTP 200 で返される中身の破損や空ページ最も軽量だが、コンテンツの品質は一切保証できない
文字数(wordCount)検査構造・量層空レスポンス、SPA の JavaScript 描画失敗、未保存文字化けした記号列・置換文字トークン実装は容易だが、文字の「意味的崩壊」は素通りする
内容検収+垂直降り先内容・質層文字コード誤判定、文字化け、置換文字の混入素のタグ除去による Markdown 書式の喪失文字化けしていない本文を救出できるが、リッチな構造化メタデータは劣化する

単一の検査に依存するのではなく、各ゲートの死角を認識した上で重ねて配置し、破損時には決定論的な降り先へ逃がす多層防御の設計が求められます。

前提が変われば結論はどう変わるか:Web の進化と LLM の自己修復

技術環境の変化によって、この結論は今後どのように変わり得るでしょうか。

現在、世界中の Web サイトの大多数は UTF-8 で配信されています。新規に立ち上がる Web サービスや API を相手にする限り、Shift_JIS や CP932 を意識する機会は年々減少しています。

しかし、AI エージェントが自律的に外部 Web を探索・リサーチする実務においては、この少数派のロングテールこそが致命傷になると考えています。老舗の技術情報サイト、官公庁の広報、学術機関のアーカイブ、レガシーなイントラネットなど、高い価値を持つ一次情報源には依然として非 UTF-8 コンテンツが数多く残されています。自律エージェントのパイプラインは、数十本の正常な UTF-8 ページを処理した後に、突如として現れる 1 本の CP932 ページによって汚染されます。今回の事例でも、パイプラインは止まらず、文字化けした本文がキャッシュに入りました。

もうひとつの論点は、LLM の自己修復能力です。最近のフロンティアモデルは非常に優秀であり、多少の文字化け(Mojibake)や壊れたトークン列が入力されても、前後の文脈から推測して一定の自己修復を行って回答を生成できます。

しかし、壊れたデータを LLM に修復させるアプローチには大きなリスクが伴います。文字化けしたテキストは、単語単位ではなく 1 バイトや置換文字ごとに細切れのトークンとして扱われやすく、入力トークン数が膨らむと私たちは見ています。推論レイテンシと API コストが増えるだけでなく、壊れた入力から LLM がもっともらしい本文を補ってしまう余地も生まれます。

可逆なものは検収で弾き、不可逆なものは予防する。LLM に渡す工程から見れば、その手前で文字化けを検収しておくことが、そのまま不可逆なコストへの予防になります。LLM を呼び出す処理は、トークン消費という不可逆な金銭的・時間的コストを伴います。だからこそ、LLM にデータを渡す前段の決定論的ゲートにおいて、文字化けを機械的に検収し、泥臭いフォールバックで健全な UTF-8 テキストへ修復しておくことが、システムの経済性と安定性の両面で極めて合理的な選択となります。

まとめ

  1. 網羅すべきは「原因の空間」ではなく「結果の空間」である: 文字コード誤判定の原因は多層かつ無限であり、事前にすべて塞ぐことはできません。「出力されたテキストが文字化けしている」という結果の破損状態を能動的に検収するゲートを設けてください。
  2. 検出できないゲートの隣には、その限界を書け: 単語数や文字数(wordCount)のチェックは空コンテンツを防ぐためのものであり、文字化けした記号列は平然と素通りします。ゲートの死角を仕様や運用ルールに明記し、盲信を防いでください。
  3. 降り先は同一レイヤーで横に試すのではなく、垂直に掘れ: 抽出ツールが charset を誤判定した際、入力形式を工夫して同じツールを呼び直す横の試行錯誤は往復を浪費します。壊れたら即座に iconv -f CP932 -t UTF-8 と素のタグ除去という、原始的で壊れない決定論的フォールバックへ垂直に降下してください。

よくある質問(FAQ)

Q. なぜ wordCount チェックを設けていても文字化けした HTML が検知をすり抜けるのですか?

A. 文字化けによって生成された置換文字(U+FFFD)や Latin-1 記号列も、単語カウント処理において空白以外の有効な文字コード(コードポイント 32 超)として認識され、単語数として加算されるためです。空コンテンツを検知する文字数チェックは、文字の意味的破損(文字化け)を判定できないため、十分な文字数を持つ文字化けデータは正常値としてゲートを素通りします。

Q. Web スクレイピングで Shift_JIS / CP932 ページの文字化けを防ぐ確実なフォールバック設計は何ですか?

A. スマートな Markdown 抽出ツールの引数調整を繰り返すのではなく、文字コード変換ツールで UTF-8 へ変換した上で、正規表現による素のタグ除去を行う決定論的なフォールバックを用意することです。その際、標準の Shift_JIS(JIS X 0208)ではなく、機種依存文字を含むスーパーセットである CP932(Windows-31J)を指定して変換することで、不正バイトによる変換エラーを防ぐことができます。

AI エージェントのデータパイプライン構築、データ品質ゲートの設計、AI Operations の導入について、ぜひ コンタクトフォーム よりお気軽にご相談ください。

Footnotes

  1. AIコーディングを「より速く省トークンに」 VS Codeチームが実証、GPT-5.5向けプロンプトの改善効果 @IT (ITmedia), 2026年8月13日公開。実測対象記事。Content-Type に charset が無く、meta shift_jis(CP932)で配信されている実例。 ↩

  2. Encoding Standard WHATWG, 2026年規格。「Shift_JIS decoder」節に Web 標準の shift_jis が Windows-31J(CP932)拡張にマッピングされる仕様と第 1 バイト(0x81〜0x9F)の定義、「TextDecoder interface」節(https://encoding.spec.whatwg.org/#interface-textdecoder)に fatal フラグが false の場合にデコードエラーで置換文字 U+FFFD が放出される仕様。 ↩ ↩2

  3. TextDecoder() constructor MDN Web Docs (Mozilla), 2026年確認。fatal オプションのデフォルトが false であり、不正バイトでも例外を投げず正常終了する仕様。 ↩ ↩2

  4. kepano/defuddle (src/cli.ts) Steph Ango (kepano), 2026年確認。readStdin で utf8 強制設定、parseSource で readFile(filePath, 'utf-8') を呼び出し外部入力を強制 UTF-8 デコードする実装。 ↩

  5. kepano/defuddle (src/fetch.ts) Steph Ango (kepano), 2026年確認。detectCharset が先頭 1024 バイトから meta の charset を探し、見つからなければバイト列スキャンで 0x80 <= b <= 0x9F を Windows-1252 と判定する実装。 ↩

  6. kepano/defuddle (src/utils.ts) Steph Ango (kepano), 2026年確認。countWords がコードポイント 32 以下の文字で単語区切りを判定し、文字化け記号列を欧文単語としてカウントする実装。 ↩

  7. Encoding sniffing algorithm WHATWG (Web Hypertext Application Technology Working Group), 2026年規格。BOM 判定、HTTP ヘッダー、先頭 1024 バイトの <meta> prescan を経る HTML 文字コード自動判別手順の仕様根拠。 ↩

  8. RFC 9110: HTTP Semantics IETF (R. Fielding et al.), 2022年6月, STD 97。Section 8.3.1 における Content-Type メディアタイプ定義と、charset 省略時のデフォルト規定(旧 ISO-8859-1)廃止の仕様。 ↩

  9. RFC 7231: Changes from RFC 2616 IETF (R. Fielding, J. Reschke), 2014年6月。Appendix B における RFC 2616 からの変更点(text/* のデフォルト文字セット ISO-8859-1 削除)の仕様。 ↩

  10. psf/requests (src/requests/utils.py) Python Software Foundation / requests contributors, 2026年確認。get_encoding_from_headers において charset 未指定の text コンテンツに依然として ISO-8859-1 を返す実装。 ↩