mirror of
https://github.com/MadsLorentzen/ai-job-search.git
synced 2026-11-02 13:12:50 +00:00
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:
7 files changed
+227
-11
No files matched your search
@@ -30,6 +30,11 @@ PAGE = (
|
||||
"<noscript>noscript text</noscript><svg><text>svg text</text></svg>"
|
||||
"</body></html>"
|
||||
)
|
||||
KOREAN_PAGE = (
|
||||
'<html><head><meta http-equiv="Content-Type" content="text/html; charset=euc-kr">'
|
||||
"<title>채용공고</title></head><body><h1>백엔드 개발자 채용</h1></body></html>"
|
||||
)
|
||||
CONTENT_TYPES = {"/euc-kr.html": "text/html; charset=euc-kr"}
|
||||
|
||||
|
||||
class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
@@ -37,6 +42,9 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
if self.path == "/ok.html":
|
||||
body = PAGE.encode("utf-8")
|
||||
self.send_response(200)
|
||||
elif self.path == "/euc-kr.html":
|
||||
body = KOREAN_PAGE.encode("cp949")
|
||||
self.send_response(200)
|
||||
elif self.path == "/forbidden":
|
||||
body = b"<html><body>denied</body></html>"
|
||||
self.send_response(403)
|
||||
@@ -54,7 +62,7 @@ class _Handler(http.server.BaseHTTPRequestHandler):
|
||||
else:
|
||||
body = b"nope"
|
||||
self.send_response(404)
|
||||
self.send_header("Content-Type", "text/html; charset=utf-8")
|
||||
self.send_header("Content-Type", CONTENT_TYPES.get(self.path, "text/html; charset=utf-8"))
|
||||
self.send_header("Content-Length", str(len(body)))
|
||||
self.end_headers()
|
||||
self.wfile.write(body)
|
||||
@@ -152,6 +160,14 @@ class Fetching(FetchPageFixture):
|
||||
for leaked in ("alert(", "color:red", "hidden comment", "noscript text", "svg text", "<p>"):
|
||||
self.assertNotIn(leaked, text)
|
||||
|
||||
def test_text_decodes_a_page_by_its_declared_charset(self):
|
||||
code, out, err = self.run_main(f"{self.base}/euc-kr.html", "--out", str(self.out), "--text")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(self.out.read_bytes(), KOREAN_PAGE.encode("cp949"))
|
||||
text = out.split("--- text ---", 1)[1]
|
||||
self.assertIn("백엔드 개발자 채용", text)
|
||||
self.assertNotIn("\ufffd", text)
|
||||
|
||||
def test_max_chars_caps_text(self):
|
||||
code, out, err = self.run_main(
|
||||
f"{self.base}/ok.html", "--out", str(self.out), "--text", "--max-chars", "7"
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""tools/page_decode.py decodes a saved page by the charset it declares.
|
||||
|
||||
Why this test exists: fetch_page.py saves bodies as the server sent them, and
|
||||
posting_guard.py --html and fetch_page.py --text used to decode every file as
|
||||
UTF-8 with replacement. A posting served in EUC-KR (job.incruit.com serves its
|
||||
postings that way) reached the gate and the scorer as replacement characters.
|
||||
These tests pin that declared legacy charsets are honoured, and that anything
|
||||
which decoded cleanly before still decodes to exactly the same string.
|
||||
"""
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(REPO_ROOT / "tools"))
|
||||
from page_decode import decode_page, declared_codec # noqa: E402
|
||||
|
||||
|
||||
def page(body: str, head: str = "") -> str:
|
||||
return f"<!doctype html><html><head>{head}<title>Posting</title></head><body>{body}</body></html>"
|
||||
|
||||
|
||||
class LegacyCharsets(unittest.TestCase):
|
||||
def test_http_equiv_euc_kr_is_read_as_cp949(self):
|
||||
# 똠 is outside strict EUC-KR; browsers read the euc-kr label as CP949.
|
||||
text = page("<h1>백엔드 개발자 채용</h1><p>똠방각하 우대</p>",
|
||||
'<meta http-equiv="Content-Type" content="text/html; charset=euc-kr">')
|
||||
decoded = decode_page(text.encode("cp949"))
|
||||
self.assertIn("백엔드 개발자 채용", decoded)
|
||||
self.assertIn("똠방각하", decoded)
|
||||
self.assertNotIn("�", decoded)
|
||||
|
||||
def test_meta_charset_shift_jis(self):
|
||||
text = page("<h1>データエンジニア募集</h1>", '<meta charset="Shift_JIS">')
|
||||
self.assertIn("データエンジニア募集", decode_page(text.encode("cp932")))
|
||||
|
||||
def test_iso_8859_1_label_is_read_as_windows_1252(self):
|
||||
text = page("<p>Løn: 50.000 € om måneden</p>", "<meta charset=iso-8859-1>")
|
||||
self.assertIn("Løn: 50.000 € om måneden", decode_page(text.encode("cp1252")))
|
||||
|
||||
def test_windows_1251_cyrillic(self):
|
||||
text = page("<p>Инженер данных</p>", '<meta charset="windows-1251">')
|
||||
self.assertIn("Инженер данных", decode_page(text.encode("cp1251")))
|
||||
|
||||
def test_utf16_byte_order_mark(self):
|
||||
text = page("<p>Ingeniør</p>")
|
||||
self.assertIn("Ingeniør", decode_page(text.encode("utf-16")))
|
||||
|
||||
|
||||
class UnchangedBehaviour(unittest.TestCase):
|
||||
def test_valid_utf8_is_unchanged(self):
|
||||
raw = page("<p>Ingeniør søges, 백엔드, データ</p>").encode("utf-8")
|
||||
self.assertEqual(decode_page(raw), raw.decode("utf-8", errors="replace"))
|
||||
|
||||
def test_valid_utf8_wins_over_a_wrong_meta_label(self):
|
||||
# Browsers would follow a utf-8 HTTP header here; the saved file does not
|
||||
# keep headers, so valid UTF-8 has to win over a stale <meta>.
|
||||
raw = page("<p>Café</p>", '<meta charset="iso-8859-1">').encode("utf-8")
|
||||
self.assertIn("Café", decode_page(raw))
|
||||
|
||||
def test_utf8_bom_is_kept_for_the_invisible_character_count(self):
|
||||
raw = b"\xef\xbb\xbf" + page("<p>Plain posting.</p>").encode("utf-8")
|
||||
self.assertTrue(decode_page(raw).startswith(""))
|
||||
|
||||
def test_undeclared_legacy_bytes_fall_back_to_utf8_with_replacement(self):
|
||||
raw = page("<p>Café</p>").encode("latin-1")
|
||||
self.assertEqual(decode_page(raw), raw.decode("utf-8", errors="replace"))
|
||||
|
||||
def test_unknown_and_unsafe_labels_are_ignored(self):
|
||||
for label in ("x-unknown", "utf-7", "utf-16"):
|
||||
raw = page("<p>Café</p>", f'<meta charset="{label}">').encode("latin-1")
|
||||
with self.subTest(label=label):
|
||||
self.assertIsNone(declared_codec(raw))
|
||||
self.assertEqual(decode_page(raw), raw.decode("utf-8", errors="replace"))
|
||||
|
||||
def test_meta_after_the_prescan_window_is_ignored(self):
|
||||
raw = (" " * 1100 + page("<p>Café</p>", '<meta charset="windows-1252">')).encode("cp1252")
|
||||
self.assertIsNone(declared_codec(raw))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -228,6 +228,37 @@ class InputHandling(GuardFixture):
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertIn("Caf� manager wanted", self.text_of(out))
|
||||
|
||||
def test_html_mode_reads_a_page_saved_in_its_declared_charset(self):
|
||||
# job.incruit.com serves postings as EUC-KR; fetch_page.py saves the bytes as sent.
|
||||
page = (
|
||||
'<html><head><meta http-equiv="Content-Type" content="text/html; charset=euc-kr">'
|
||||
"</head><body><h1>백엔드 개발자 채용</h1><p>Python, SQL 경력 3년 이상</p></body></html>"
|
||||
)
|
||||
p = self.write("page.html", page.encode("cp949"))
|
||||
code, out, err = self.run_main("--in", str(p), "--html", "--json")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertIn("백엔드 개발자 채용", self.text_of(out))
|
||||
self.assertNotIn("\ufffd", self.text_of(out))
|
||||
|
||||
def test_html_mode_scans_a_legacy_cyrillic_page_for_homoglyphs(self):
|
||||
# Cyrillic о and а inside Latin words, saved as windows-1251.
|
||||
page = (
|
||||
'<html><head><meta charset="windows-1251"></head>'
|
||||
"<body><p>Join Nоvo Nаrdisk as an engineer.</p></body></html>"
|
||||
)
|
||||
p = self.write("page.html", page.encode("cp1251"))
|
||||
code, out, err = self.run_main("--in", str(p), "--html", "--json")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertEqual(len(self.findings_of(out)["homoglyph_runs"]), 2)
|
||||
self.assertIn("⚠", out.splitlines()[0])
|
||||
|
||||
def test_html_mode_keeps_valid_utf8_when_meta_names_another_charset(self):
|
||||
page = '<html><head><meta charset="iso-8859-1"></head><body><p>Café manager</p></body></html>'
|
||||
p = self.write("page.html", page)
|
||||
code, out, err = self.run_main("--in", str(p), "--html", "--json")
|
||||
self.assertEqual(code, 0, err)
|
||||
self.assertIn("Café manager", self.text_of(out))
|
||||
|
||||
def test_missing_in_file_exits_2(self):
|
||||
code, out, err = self.run_main("--in", str(self.tmp / "nope.txt"))
|
||||
self.assertEqual(code, 2)
|
||||
|
||||
Reference in new issue
Block a user