◉ANTIGRAVITY LABEN
記事一覧/連携・プラグイン
⬡ 連携・プラグイン/2026-10-22中級

文字コードを宣言しないページをエージェントに読ませる前に、UTF-8 のテキストにして渡す小さなスクリプト

ReadURL が UnicodeDecodeError で止まる報告を手がかりに、Python 標準ライブラリだけで文字コードを判定して UTF-8 に変換する 60 行のスクリプトを作り、ローカルで確かめます。

ReadURL文字コードPython21エージェント75チュートリアル4

古い日本語サイトの資料をエージェントに読ませようとしたところ、実行が途中で止まってしまった——そんな報告が、Antigravity CLI の GitHub に上がっています(Issue 818)。

報告によると、Content-Type: text/html に charset が付いておらず、本文側の <meta charset=iso-8859-1> だけが文字コードを示しているページで、ReadURL が本文を UTF-8 として読もうとします。そこで UnicodeDecodeError: 'utf-8' codec can't decode byte 0xf1 in position 387 が出て、エージェントの実行そのものが終了する、という内容です。私が取得した時点では、修正済みという記載は確認できませんでした。

ツール側の修正を待つあいだ、自分の手元でできることがあります。読ませる前に、こちらで UTF-8 に直して渡すことです。ここからは、そのための小さなスクリプトを作っていきます。

症状を先に再現する

ローカルに小さな HTTP サーバーを立て、charset を宣言しない Latin-1 のページを返してみます。手元の Python 3.10 で、素朴に UTF-8 として読むと次のとおりです。

UnicodeDecodeError: 'utf-8' codec can't decode byte 0xed in position 50: invalid continuation byte

Issue のメッセージとバイト位置が違うだけで、形は同じです。つまり「サーバーが文字コードを名乗らない」「受け取る側が UTF-8 だと決め打ちする」という二つが重なると、誰の環境でも起きます。

判定の順番を決める

文字コードの判定は、優先順位を決めてしまえば難しくありません。ブラウザが従っている考え方(WHATWG の仕様)を、次のように簡略化しました。

順位手がかり理由
1HTTP ヘッダの charsetサーバーが明示しているので最優先です
2BOM先頭 3 バイトが UTF-8 の印なら確定です
3先頭 1024 バイト内の meta charsetヘッダが無いページの、ほぼ唯一の宣言です
4UTF-8 として厳密に読めるか読めたなら UTF-8 とみなして問題ありません
5それでも不明なら cp1252 と仮定仮定したことは必ず警告に出します

ここで一つ、意外だった点があります。meta に iso-8859-1 と書かれていても、Python の latin-1 では読みません。cp1252 として読みます。 ブラウザがそう扱うからで、違いは 0x80〜0x9f の範囲に出ます。

b = "<p>“quoted”</p>".encode("cp1252")
print(repr(b.decode("latin-1")))   # '<p>\x93quoted\x94</p>'
print(repr(b.decode("cp1252")))    # '<p>“quoted”</p>'

latin-1 で読むと、引用符が制御文字になってしまいます。エラーにはならないので、気づかないまま壊れたテキストをエージェントに渡すことになります。

スクリプト本体

標準ライブラリだけで書きました。URL を受け取り、UTF-8 のテキストファイルに保存します。

#!/usr/bin/env python3
"""fetch_utf8.py — 文字コード不明のページを UTF-8 のテキストにして保存する。"""
import codecs, re, sys, urllib.request
from html.parser import HTMLParser
 
MAX_BYTES = 2_000_000
META = re.compile(rb'''<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9_\-:.]+)''', re.I)
HDR = re.compile(r'charset\s*=\s*["\']?([A-Za-z0-9_\-:.]+)', re.I)
 
def normalize(label):
    """ラベルを Python のコーデック名へ。WHATWG 流に latin-1 は cp1252 として扱う。"""
    try:
        name = codecs.lookup(label.strip()).name
    except LookupError:
        return None
    return "cp1252" if name in ("iso8859-1", "latin-1") else name
 
def detect(body, content_type):
    m = HDR.search(content_type or "")
    if m and normalize(m.group(1)):
        return normalize(m.group(1)), "http-header"
    if body.startswith(codecs.BOM_UTF8):
        return "utf-8-sig", "bom"
    m = META.search(body[:1024])
    if m and normalize(m.group(1).decode("ascii", "ignore")):
        return normalize(m.group(1).decode("ascii", "ignore")), "meta"
    try:
        body.decode("utf-8")
        return "utf-8", "utf8-valid"
    except UnicodeDecodeError:
        return "cp1252", "fallback"
 
class Text(HTMLParser):
    def __init__(self):
        super().__init__(); self.out = []; self.skip = 0
    def handle_starttag(self, tag, attrs):
        if tag in ("script", "style"): self.skip += 1
    def handle_endtag(self, tag):
        if tag in ("script", "style") and self.skip: self.skip -= 1
        if tag in ("p", "div", "li", "h1", "h2", "h3", "br", "tr", "title"): self.out.append("\n")
    def handle_data(self, d):
        if not self.skip: self.out.append(d)
 
def main(url, dest):
    req = urllib.request.Request(url, headers={"User-Agent": "fetch-utf8/1.0"})
    with urllib.request.urlopen(req, timeout=20) as r:
        body = r.read(MAX_BYTES + 1)
        ctype = r.headers.get("Content-Type", "")
    if len(body) > MAX_BYTES:
        sys.exit("too large: " + url)
    enc, why = detect(body, ctype)
    html = body.decode(enc, errors="replace")
    p = Text(); p.feed(html)
    text = re.sub(r"\n{3,}", "\n\n", "".join(p.out)).strip()
    with open(dest, "w", encoding="utf-8") as f:
        f.write(text + "\n")
    if why == "fallback":
        print("warning: 文字コードを判定できず cp1252 と仮定しました。結果を目で確認してください", file=sys.stderr)
    print(f"encoding={enc} via={why} replaced={html.count(chr(0xFFFD))} -> {dest}")
 
if __name__ == "__main__":
    if len(sys.argv) != 3: sys.exit("usage: fetch_utf8.py URL OUT.txt")
    main(*sys.argv[1:])

HTML のタグは HTMLParser で落とし、script と style の中身は捨てています。表やリンクの構造は失われますが、「文字化けせずに内容を渡す」という目的には足りると判断しました。

ローカルで確かめる

判定ごとに一つずつページを用意した、テスト用のサーバーです。/nometa は、宣言がまったく無いページを意図しています。

from http.server import BaseHTTPRequestHandler, HTTPServer
PAGES = {
 "/latin1": ("text/html", "<html><head><meta charset=iso-8859-1><title>Asesoría</title></head><body><p>Año nuevo, señor.</p></body></html>".encode("iso-8859-1")),
 "/sjis":   ("text/html", "<html><head><meta http-equiv='Content-Type' content='text/html; charset=Shift_JIS'></head><body><p>壁紙アプリの更新履歴です。</p></body></html>".encode("shift_jis")),
 "/utf8":   ("text/html", "<html><body><p>日本語 UTF-8 そのまま</p></body></html>".encode("utf-8")),
 "/hdr":    ("text/html; charset=euc-jp", "<html><body><p>ヘッダ優先の確認</p></body></html>".encode("euc-jp")),
 "/nometa": ("text/html", "<html><body><p>Año sin宣言</p></body></html>".encode("cp1252", "replace")),
}
class H(BaseHTTPRequestHandler):
    def do_GET(self):
        ct, b = PAGES[self.path]
        self.send_response(200); self.send_header("Content-Type", ct); self.send_header("Content-Length", str(len(b))); self.end_headers(); self.wfile.write(b)
    def log_message(self, *a): pass
HTTPServer(("127.0.0.1", 8765), H).serve_forever()
python3 serve.py &
for p in latin1 sjis utf8 hdr nometa; do
  python3 fetch_utf8.py http://127.0.0.1:8765/$p out_$p.txt
done

実行結果です。

[latin1]  encoding=cp1252 via=meta        -> Asesoría / Año nuevo, señor.
[sjis]    encoding=shift_jis via=meta     -> 壁紙アプリの更新履歴です。
[utf8]    encoding=utf-8 via=utf8-valid   -> 日本語 UTF-8 そのまま
[hdr]     encoding=euc_jp via=http-header -> ヘッダ優先の確認
[nometa]  encoding=cp1252 via=fallback    -> 警告を表示

五つとも期待どおりに分岐しました。/hdr は本文に宣言が無く、ヘッダだけが euc-jp を名乗るページで、ヘッダが最優先になることを確かめています。

落とし穴は「判定できなかった」ときです

/nometa の出力は Año sin?? でした。これは判定の失敗ではなく、私が作ったテストページ自体が、変換できない文字を ? に置き換えて書かれていたためです。ただ、ここから分かる大事なことがあります。宣言が無く、UTF-8 でも読めないページは、どんなコードを使っても正解を保証できません。

だからスクリプトは、5 番目の手がかりまで来たときに警告を出します。警告が出たら、保存されたテキストを目で一度確認してから、エージェントに渡してください。

使うときの注意

このスクリプトは、認証が必要なページや、ログイン後にしか見えない内容は取得できません。取得サイズも 2MB までに絞っています(MAX_BYTES)。大きな PDF や画像を誤って流し込まないための線引きです。

また、取得は相手のサーバーへのアクセスになります。利用規約や robots.txt で自動取得が禁じられていないかは、読ませる前に確認してください。最初のうち、私はこの確認を後回しにしがちでした。いまは「保存する前に一度見る」を手順の一行目に置いています。

エージェントへの渡し方

あとは、保存したファイルを会話に添付し、「この内容を読んで要約して」と頼むだけです。URL を直接渡さないので、ReadURL の文字コード処理を通らずに済みます。

Issue には、curl -s URL | iconv -f iso-8859-1 -t utf-8 という回避策も載っていました。文字コードが分かっている一回限りの取得なら、これで十分です。毎回違うサイトを読ませる場合は、判定まで含めて自動にしておくほうが、後で楽になります。

試してみる順番

  1. 読ませたいページを、まず fetch_utf8.py で保存します
  2. 警告が出なかったら、そのまま添付します
  3. 警告が出たら、先頭の数行だけを目で確認してから添付します

個人開発で小さなサイトをいくつか運用していると、調べものの途中で古いページに行き当たることが何度もあります。エージェントが止まる前に、渡す側で入口を整えておきます。 私自身は、まずこのスクリプトを一つ手元に置くところから始めました。

シェア

お読みいただきありがとうございます

Antigravity Lab は広告なしで運営しており、サーバー費用などの運営コストはメンバーシップのご支援で賄っています。実装コード・ベンチマーク・本番設計パターンなど、実務でお役立ていただける記事を毎日更新しています。もし読んでよかったと感じていただけましたら、ぜひご覧ください。

  • ✦コピー&ペーストで使える実装コード付き
  • ✦毎日新しい上級ガイドを追加
  • ✦¥580/月 または ¥1,480 の永久アクセス
メンバーシップを見る →

もしこの記事がお役に立ちましたら、チップ(¥150)で応援いただけると大変励みになります。広告なしでの運営を続けるため、皆さまのご支援が大きな力になっています。

関連記事

⬡ 連携・プラグイン2026-10-07
画像モデルの終了日は 10月29日 — エージェント構成の「画像を呼ぶ箇所」を洗い出して、戻り先つきで切り替える
Gemini API の deprecations 表で、画像モデル gemini-3.1-flash-image に 10月29日の終了日が付いていました。設定ファイルやコードに散らばった呼び出し箇所を台帳と突き合わせ、切り替え日と戻り先を決めておくまでを、動くスクリプトで残します。
⬡ 連携・プラグイン2026-09-20
10月5日に止まる antigravity-preview-05-2026、差し替えで済む構成と済まない構成
antigravity-preview-05-2026 の停止日が10月5日に決まりました。公式が示すのは日付と後継名だけです。output_text だけを読む構成なら差し替えで終わり、steps を読む構成なら改修が要ります。その分かれ目を切り替え前に確かめる手順です。
◈ Agents & Manager2026-10-16
エージェントに .zip を渡す前に、.git を除いて 29 MB を 15 MB にし、秘密情報の警告 28 件を見分けました
Antigravity アプリ v2.23.0 で .zip を添付できるようになりました。自分のリポジトリを実際に固めて .git の重さと秘密情報検査の誤検知を数え、除外・検査・マニフェストを一度に行う Python スクリプトの全文を残します。