fix(tools): decode saved non-UTF-8 pages by their declared charset (#690)

* fix(tools): decode saved non-UTF-8 pages by their declared charset

fetch_page.py saves a body exactly as the server sent it, but its readers,
posting_guard.py --html and fetch_page.py --text, decoded the file as UTF-8
with replacement. A posting served in EUC-KR, Shift_JIS or windows-1252
reached the integrity gate and the scorer as replacement characters.

Valid UTF-8 still wins, so any page that decoded cleanly before decodes to the
same string. Bytes that are not valid UTF-8 now fall back to the charset the
page declares (UTF-16 BOM, then <meta charset> or http-equiv Content-Type in
the first 1024 bytes), mapped to the codec browsers use for that label. Missing
or unknown labels keep the previous UTF-8-with-replacement result.

* changelog: place the #690 entry under [Unreleased]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Mads Lorentzen <madslorentzen_17@hotmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
authored and GitHub committed 2026-10-20 08:28:58 +02:00
1 parent 8fb34c6cb7
commit f97a759945
7 files changed
+227 -11

No files matched your search

+4 -2
View File
@@ -31,7 +31,8 @@ Prints `HTTP <code> size=<bytes> saved=<path>` and exits 0 whatever the HTTP
status - the status is information for the escalation order, not a failure.
With --text, also prints the tag-stripped page text (script/style/noscript/svg
and comments removed, entities unescaped, whitespace folded), capped at
--max-chars (default 6000), after a `--- text ---` line.
--max-chars (default 6000), after a `--- text ---` line. A saved body that is
not valid UTF-8 is decoded by the charset the page declares (page_decode.py).
Exit codes: 0 fetched; 1 refused by robots policy (go to escalation step 4);
2 usage or transport error.
@@ -53,6 +54,7 @@ from urllib.parse import urlsplit
from urllib.request import HTTPRedirectHandler, Request, build_opener
sys.path.insert(0, str(Path(__file__).resolve().parent))
from page_decode import decode_page # noqa: E402
from robots_check import BROWSER, gate # noqa: E402
ROOT = Path(__file__).resolve().parent.parent
@@ -213,7 +215,7 @@ def main(argv=None) -> int:
print(f"{status} saved={out}")
if args.text:
print("--- text ---")
print(strip_tags(out.read_text(encoding="utf-8", errors="replace"), args.max_chars))
print(strip_tags(decode_page(out.read_bytes()), args.max_chars))
return 0
+64
View File
@@ -0,0 +1,64 @@
"""Decode a saved web page by the charset it declares, the way a browser does.
fetch_page.py saves the body exactly as the server sent it. Its readers,
`posting_guard.py --html` and `fetch_page.py --text`, decoded that file as
UTF-8 with replacement, so a posting served in EUC-KR, Shift_JIS or
windows-1252 reached the gate and the scorer as replacement characters.
Valid UTF-8 always wins, so a page that decoded cleanly before decodes to the
same string. Only bytes that are not valid UTF-8 fall back to what the page
declares: a UTF-16 byte order mark, then `<meta charset>` or the http-equiv
Content-Type in the first 1024 bytes (the window the HTML prescan reads). A
label is mapped to the codec browsers use for it ("euc-kr" is read as CP949,
"iso-8859-1" as windows-1252). A missing or unknown label keeps UTF-8 with
replacement, and only the labels below are honoured, so a page cannot select
a codec such as UTF-7.
"""
import re
PRESCAN_BYTES = 1024
META_CHARSET = re.compile(rb"<meta\b[^>]*?charset\s*=\s*[\"']?\s*([A-Za-z0-9._:-]+)", re.I)
_CODECS = {
"cp949": ("euc-kr", "cp949", "windows-949", "ks_c_5601-1987", "ks_c_5601-1989",
"ksc5601", "ksc_5601", "korean", "iso-ir-149", "csksc56011987"),
"cp932": ("shift_jis", "shift-jis", "sjis", "x-sjis", "ms_kanji", "ms932",
"windows-31j", "csshiftjis"),
"euc_jp": ("euc-jp", "x-euc-jp", "cseucpkdfmtjapanese"),
"iso2022_jp": ("iso-2022-jp", "csiso2022jp"),
"gb18030": ("gb18030", "gbk", "gb2312", "x-gbk", "chinese", "csgb2312", "iso-ir-58"),
"big5hkscs": ("big5", "big5-hkscs", "x-x-big5", "cn-big5", "csbig5"),
"cp1252": ("windows-1252", "cp1252", "x-cp1252", "iso-8859-1", "iso8859-1",
"iso_8859-1", "latin1", "l1", "ascii", "us-ascii", "cp819", "ibm819"),
"cp1250": ("windows-1250", "cp1250", "x-cp1250"),
"cp1251": ("windows-1251", "cp1251", "x-cp1251"),
"cp1253": ("windows-1253", "cp1253"),
"cp1254": ("windows-1254", "cp1254", "iso-8859-9", "latin5"),
"cp1257": ("windows-1257", "cp1257"),
"iso8859_2": ("iso-8859-2", "latin2", "l2"),
"iso8859_5": ("iso-8859-5", "cyrillic"),
"iso8859_15": ("iso-8859-15", "latin9", "l9"),
"koi8_r": ("koi8-r", "koi8", "koi"),
"koi8_u": ("koi8-u",),
}
LABEL_TO_CODEC = {label: codec for codec, labels in _CODECS.items() for label in labels}
def declared_codec(raw: bytes):
"""The codec a page declares through a UTF-16 BOM or a <meta> tag, or None."""
if raw.startswith((b"\xff\xfe", b"\xfe\xff")):
return "utf-16"
match = META_CHARSET.search(raw[:PRESCAN_BYTES])
if match is None:
return None
return LABEL_TO_CODEC.get(match.group(1).decode("ascii").lower())
def decode_page(raw: bytes) -> str:
"""Text of a saved page: UTF-8 when valid, else its declared charset, else UTF-8 with replacement."""
try:
return raw.decode("utf-8")
except UnicodeDecodeError:
pass
return raw.decode(declared_codec(raw) or "utf-8", errors="replace")
+18 -8
View File
@@ -33,7 +33,10 @@ for the terminal.
Usage:
python3 tools/posting_guard.py [--in FILE] [--html|--text] [--out FILE] [--json]
Input is `--in FILE` or stdin, decoded as UTF-8 with replacement. Pass --html
Input is `--in FILE` or stdin, decoded as UTF-8 with replacement. With --html,
bytes that are not valid UTF-8 are decoded by the charset the page declares
(see page_decode.py), so a page saved in EUC-KR, Shift_JIS or windows-1252 is
read as text instead of replacement characters. Pass --html
for a saved page and --text for portal CLI output, pasted or archived text;
without either the mode is guessed from the content, which is a fallback, not
a documented path. Output: the verdict line, a `--- findings ---` block (JSON
@@ -48,6 +51,9 @@ import re
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from page_decode import decode_page # noqa: E402
EXCERPT = 120
LINE_MAX = 200
@@ -331,13 +337,17 @@ def _force_utf8_output() -> None:
reconfigure(encoding="utf-8")
def _read_input(path):
def _read_input(path, mode):
if path is not None:
return path.read_bytes().decode("utf-8", errors="replace")
buffer = getattr(sys.stdin, "buffer", None)
if buffer is not None:
return buffer.read().decode("utf-8", errors="replace")
return sys.stdin.read()
data = path.read_bytes()
else:
buffer = getattr(sys.stdin, "buffer", None)
if buffer is None:
return sys.stdin.read()
data = buffer.read()
if mode == "html":
return decode_page(data)
return data.decode("utf-8", errors="replace")
def main(argv=None) -> int:
@@ -353,7 +363,7 @@ def main(argv=None) -> int:
args = parser.parse_args(argv)
try:
raw = _read_input(args.infile)
raw = _read_input(args.infile, args.mode)
except OSError as exc:
print(f"posting_guard: cannot read input: {exc}", file=sys.stderr)
return 2