古い日本語サイトの資料をエージェントに読ませようとしたところ、実行が途中で止まってしまった——そんな報告が、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 byteIssue のメッセージとバイト位置が違うだけで、形は同じです。つまり「サーバーが文字コードを名乗らない」「受け取る側が UTF-8 だと決め打ちする」という二つが重なると、誰の環境でも起きます。
判定の順番を決める
文字コードの判定は、優先順位を決めてしまえば難しくありません。ブラウザが従っている考え方(WHATWG の仕様)を、次のように簡略化しました。
| 順位 | 手がかり | 理由 |
|---|---|---|
| 1 | HTTP ヘッダの charset | サーバーが明示しているので最優先です |
| 2 | BOM | 先頭 3 バイトが UTF-8 の印なら確定です |
| 3 | 先頭 1024 バイト内の meta charset | ヘッダが無いページの、ほぼ唯一の宣言です |
| 4 | UTF-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 という回避策も載っていました。文字コードが分かっている一回限りの取得なら、これで十分です。毎回違うサイトを読ませる場合は、判定まで含めて自動にしておくほうが、後で楽になります。
試してみる順番
- 読ませたいページを、まず
fetch_utf8.pyで保存します - 警告が出なかったら、そのまま添付します
- 警告が出たら、先頭の数行だけを目で確認してから添付します
個人開発で小さなサイトをいくつか運用していると、調べものの途中で古いページに行き当たることが何度もあります。エージェントが止まる前に、渡す側で入口を整えておきます。 私自身は、まずこのスクリプトを一つ手元に置くところから始めました。