Skip to content

Commit 0bb9396

Browse files
committed
decode pages with their declared encoding in ParselParser and BeautifulSoupParser
1 parent c6fba87 commit 0bb9396

8 files changed

Lines changed: 380 additions & 2 deletions

File tree

‎src/crawlee/_utils/html.py‎

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
"""HTML utility functions for Crawlee."""
2+
3+
from __future__ import annotations
4+
5+
import codecs
6+
import re
7+
8+
from crawlee._utils.http import parse_content_type_charset
9+
10+
# Matches the `encoding` of an XML declaration, which XHTML pages may use instead of a `<meta>` tag.
11+
_XML_ENCODING_PATTERN = re.compile(rb'^\s*<\?xml[^>]*\sencoding\s*=\s*["\']([a-z0-9_:.+-]+)', re.IGNORECASE)
12+
13+
# Matches both `<meta charset="...">` and `<meta http-equiv="Content-Type" content="...; charset=...">`.
14+
_META_CHARSET_PATTERN = re.compile(rb'<meta[\s/][^>]*charset\s*=\s*["\']?\s*([a-z0-9_:.+-]+)', re.IGNORECASE)
15+
16+
# Comments are skipped, so a commented-out `<meta>` tag doesn't count.
17+
_HTML_COMMENT_PATTERN = re.compile(rb'<!--.*?-->', re.DOTALL)
18+
19+
# How much of the body is searched for a declared encoding. Browsers prescan 1024 bytes but still switch to a
20+
# `<meta>` charset found later, so a larger window catches pages with a long `<head>` before it.
21+
_PRESCAN_BYTES = 4096
22+
23+
_BOMS = (
24+
(codecs.BOM_UTF8, 'utf-8'),
25+
(codecs.BOM_UTF16_LE, 'utf-16-le'),
26+
(codecs.BOM_UTF16_BE, 'utf-16-be'),
27+
)
28+
29+
# The encoding labels of the WHATWG Encoding Standard (https://encoding.spec.whatwg.org/#names-and-labels), grouped
30+
# by the Python codec that decodes them. Labels outside it, like `utf-7` or Python-only codecs, are ignored as browsers
31+
# do. The `replacement` and `x-user-defined` encodings have no Python codec and are left out.
32+
_WHATWG_ENCODING_LABELS = {
33+
'big5hkscs': 'big5 big5-hkscs cn-big5 csbig5 x-x-big5',
34+
'cp874': 'dos-874 iso-8859-11 iso8859-11 iso885911 tis-620 windows-874',
35+
'cp932': 'csshiftjis ms932 ms_kanji shift-jis shift_jis sjis windows-31j x-sjis',
36+
'cp949': (
37+
'cseuckr csksc56011987 euc-kr iso-ir-149 korean ks_c_5601-1987 ks_c_5601-1989 ksc5601 ksc_5601 windows-949'
38+
),
39+
'euc-jp': 'cseucpkdfmtjapanese euc-jp x-euc-jp',
40+
# WHATWG decodes GBK with the gb18030 decoder, its superset.
41+
'gb18030': 'chinese csgb2312 csiso58gb231280 gb18030 gb2312 gb_2312 gb_2312-80 gbk iso-ir-58 x-gbk',
42+
'ibm866': '866 cp866 csibm866 ibm866',
43+
'iso-2022-jp': 'csiso2022jp iso-2022-jp',
44+
'iso-8859-10': 'csisolatin6 iso-8859-10 iso-ir-157 iso8859-10 iso885910 l6 latin6',
45+
'iso-8859-13': 'iso-8859-13 iso8859-13 iso885913',
46+
'iso-8859-14': 'iso-8859-14 iso8859-14 iso885914',
47+
'iso-8859-15': 'csisolatin9 iso-8859-15 iso8859-15 iso885915 iso_8859-15 l9',
48+
'iso-8859-16': 'iso-8859-16',
49+
'iso-8859-2': 'csisolatin2 iso-8859-2 iso-ir-101 iso8859-2 iso88592 iso_8859-2 iso_8859-2:1987 l2 latin2',
50+
'iso-8859-3': 'csisolatin3 iso-8859-3 iso-ir-109 iso8859-3 iso88593 iso_8859-3 iso_8859-3:1988 l3 latin3',
51+
'iso-8859-4': 'csisolatin4 iso-8859-4 iso-ir-110 iso8859-4 iso88594 iso_8859-4 iso_8859-4:1988 l4 latin4',
52+
'iso-8859-5': 'csisolatincyrillic cyrillic iso-8859-5 iso-ir-144 iso8859-5 iso88595 iso_8859-5 iso_8859-5:1988',
53+
'iso-8859-6': (
54+
'arabic asmo-708 csiso88596e csiso88596i csisolatinarabic ecma-114 iso-8859-6 iso-8859-6-e '
55+
'iso-8859-6-i iso-ir-127 iso8859-6 iso88596 iso_8859-6 iso_8859-6:1987'
56+
),
57+
'iso-8859-7': (
58+
'csisolatingreek ecma-118 elot_928 greek greek8 iso-8859-7 iso-ir-126 iso8859-7 iso88597 iso_8859-7 '
59+
'iso_8859-7:1987 sun_eu_greek'
60+
),
61+
'iso-8859-8': (
62+
'csiso88598e csiso88598i csisolatinhebrew hebrew iso-8859-8 iso-8859-8-e iso-8859-8-i iso-ir-138 '
63+
'iso8859-8 iso88598 iso_8859-8 iso_8859-8:1988 logical visual'
64+
),
65+
'koi8-r': 'cskoi8r koi koi8 koi8-r koi8_r',
66+
'koi8-u': 'koi8-ru koi8-u',
67+
'mac-cyrillic': 'x-mac-cyrillic x-mac-ukrainian',
68+
'mac-roman': 'csmacintosh mac macintosh x-mac-roman',
69+
'utf-16be': 'unicodefffe utf-16be',
70+
'utf-16le': 'csunicode iso-10646-ucs-2 ucs-2 unicode unicodefeff utf-16 utf-16le',
71+
'utf-8': 'unicode-1-1-utf-8 unicode11utf8 unicode20utf8 utf-8 utf8 x-unicode20utf8',
72+
'windows-1250': 'cp1250 windows-1250 x-cp1250',
73+
'windows-1251': 'cp1251 windows-1251 x-cp1251',
74+
'windows-1252': (
75+
'ansi_x3.4-1968 ascii cp1252 cp819 csisolatin1 ibm819 iso-8859-1 iso-ir-100 iso8859-1 iso88591 '
76+
'iso_8859-1 iso_8859-1:1987 l1 latin1 us-ascii windows-1252 x-cp1252'
77+
),
78+
'windows-1253': 'cp1253 windows-1253 x-cp1253',
79+
'windows-1254': (
80+
'cp1254 csisolatin5 iso-8859-9 iso-ir-148 iso8859-9 iso88599 iso_8859-9 iso_8859-9:1989 l5 latin5 '
81+
'windows-1254 x-cp1254'
82+
),
83+
'windows-1255': 'cp1255 windows-1255 x-cp1255',
84+
'windows-1256': 'cp1256 windows-1256 x-cp1256',
85+
'windows-1257': 'cp1257 windows-1257 x-cp1257',
86+
'windows-1258': 'cp1258 windows-1258 x-cp1258',
87+
}
88+
89+
_ENCODING_BY_LABEL = {label: codec for codec, labels in _WHATWG_ENCODING_LABELS.items() for label in labels.split()}
90+
91+
92+
def declared_html_encoding(body: bytes, content_type: str | None) -> str | None:
93+
"""Get the Python codec for the encoding an HTML response body declares.
94+
95+
The encoding comes from the BOM, then the `charset` of the `Content-Type` header, then an XML declaration or the
96+
`<meta>` tags. Labels are resolved as in the WHATWG Encoding Standard, so legacy charsets map to the supersets
97+
browsers use and unknown labels are ignored.
98+
99+
Args:
100+
body: The raw response body.
101+
content_type: The `Content-Type` header of the response.
102+
103+
Returns:
104+
The codec, or `None` if the body is empty or declares no encoding browsers know.
105+
"""
106+
if not body:
107+
return None
108+
109+
for bom, encoding in _BOMS:
110+
if body.startswith(bom):
111+
return encoding
112+
113+
header_charset = parse_content_type_charset(content_type)
114+
return resolve_encoding(header_charset) or _find_declared_encoding(body)
115+
116+
117+
def decode_html_body(body: bytes, encoding: str) -> str:
118+
"""Decode an HTML response body with the codec from `declared_html_encoding`, dropping a leading U+FEFF.
119+
120+
Undecodable bytes are replaced with U+FFFD.
121+
122+
Args:
123+
body: The raw response body.
124+
encoding: The Python codec to decode the body with.
125+
"""
126+
return body.decode(encoding, 'replace').removeprefix('\ufeff')
127+
128+
129+
def resolve_encoding(label: str | None) -> str | None:
130+
"""Get the Python codec for a WHATWG encoding label, or `None` if browsers don't know the label."""
131+
if not label:
132+
return None
133+
return _ENCODING_BY_LABEL.get(label.lower())
134+
135+
136+
def _find_declared_encoding(body: bytes) -> str | None:
137+
"""Find the encoding declared by an XML declaration or a `<meta>` tag near the start of the body."""
138+
prescan = _HTML_COMMENT_PATTERN.sub(b'', body[:_PRESCAN_BYTES])
139+
match = _XML_ENCODING_PATTERN.match(prescan) or _META_CHARSET_PATTERN.search(prescan)
140+
encoding = resolve_encoding(match.group(1).decode('ascii')) if match else None
141+
# A declaration readable as ASCII rules out UTF-16, so browsers read such pages as UTF-8.
142+
return 'utf-8' if encoding and encoding.startswith('utf-16') else encoding

‎src/crawlee/_utils/http.py‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,25 @@
22

33
from __future__ import annotations
44

5+
import re
56
from datetime import datetime, timedelta, timezone
67
from email.utils import parsedate_to_datetime
78
from logging import getLogger
89

910
logger = getLogger(__name__)
1011

12+
_CHARSET_PATTERN = re.compile(r'(?:^|;)\s*charset\s*=\s*(?:"([^"]*)"|([^;\s]*))', re.IGNORECASE)
13+
14+
15+
def parse_content_type_charset(value: str | None) -> str | None:
16+
"""Get the `charset` parameter of a `Content-Type` header value, if it has one."""
17+
if not value:
18+
return None
19+
match = _CHARSET_PATTERN.search(value)
20+
if match is None:
21+
return None
22+
return (match.group(1) or match.group(2) or '').strip() or None
23+
1124

1225
def parse_retry_after_header(value: str | None) -> timedelta | None:
1326
"""Parse the Retry-After HTTP header value.

‎src/crawlee/crawlers/_beautifulsoup/_beautifulsoup_parser.py‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing_extensions import override
88

99
from crawlee._utils.docs import docs_group
10+
from crawlee._utils.html import declared_html_encoding, decode_html_body
1011
from crawlee.crawlers._abstract_http import AbstractHttpParser
1112

1213
if TYPE_CHECKING:
@@ -25,7 +26,8 @@ def __init__(self, parser: BeautifulSoupParserType = 'lxml') -> None:
2526
@override
2627
async def parse(self, response: HttpResponse) -> BeautifulSoup:
2728
body = await response.read()
28-
return await asyncio.to_thread(BeautifulSoup, body, features=self._parser)
29+
content_type = response.headers.get('content-type')
30+
return await asyncio.to_thread(self._parse_body, body, content_type)
2931

3032
@override
3133
async def parse_text(self, text: str) -> BeautifulSoup:
@@ -49,5 +51,11 @@ def find_links(self, parsed_content: Tag, selector: str, attribute: str) -> Iter
4951
urls.append(url.strip())
5052
return urls
5153

54+
def _parse_body(self, body: bytes, content_type: str | None) -> BeautifulSoup:
55+
encoding = declared_html_encoding(body, content_type)
56+
if encoding is None:
57+
return BeautifulSoup(body, features=self._parser)
58+
return BeautifulSoup(decode_html_body(body, encoding), features=self._parser)
59+
5260

5361
BeautifulSoupParserType = Literal['html.parser', 'lxml', 'xml', 'html5lib']

‎src/crawlee/crawlers/_parsel/_parsel_parser.py‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from typing_extensions import override
88

99
from crawlee._utils.docs import docs_group
10+
from crawlee._utils.html import declared_html_encoding, decode_html_body
1011
from crawlee.crawlers._abstract_http import AbstractHttpParser
1112

1213
if TYPE_CHECKING:
@@ -22,7 +23,8 @@ class ParselParser(AbstractHttpParser[Selector, Selector]):
2223
@override
2324
async def parse(self, response: HttpResponse) -> Selector:
2425
response_body = await response.read()
25-
return await asyncio.to_thread(Selector, body=response_body)
26+
content_type = response.headers.get('content-type')
27+
return await asyncio.to_thread(self._parse_body, response_body, content_type)
2628

2729
@override
2830
async def parse_text(self, text: str) -> Selector:
@@ -45,3 +47,10 @@ def find_links(self, parsed_content: Selector, selector: str, attribute: str) ->
4547
if url:
4648
urls.append(url.strip())
4749
return urls
50+
51+
@staticmethod
52+
def _parse_body(body: bytes, content_type: str | None) -> Selector:
53+
encoding = declared_html_encoding(body, content_type)
54+
if encoding is None:
55+
return Selector(body=body)
56+
return Selector(text=decode_html_body(body, encoding))

‎tests/unit/_utils/test_html.py‎

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
from __future__ import annotations
2+
3+
import codecs
4+
5+
import pytest
6+
7+
from crawlee._utils.html import _ENCODING_BY_LABEL, declared_html_encoding, decode_html_body
8+
9+
_CZECH = 'Test dekódování znaků českého jazyka'
10+
_FRENCH = 'Test de décodage des caractères de la langue française dans une œuvre'
11+
12+
13+
@pytest.mark.parametrize(
14+
('body', 'content_type', 'expected'),
15+
[
16+
pytest.param(_CZECH.encode(), None, None, id='undeclared'),
17+
pytest.param(_CZECH.encode('cp1250'), 'text/html; charset="windows-1250"', _CZECH, id='quoted-header'),
18+
pytest.param(_FRENCH.encode('cp1252'), 'text/html; charset=ISO-8859-1', _FRENCH, id='latin-1-superset'),
19+
pytest.param(_FRENCH.encode('cp1252'), 'text/html; charset=us-ascii', _FRENCH, id='ascii-superset'),
20+
pytest.param(codecs.BOM_UTF8 + _CZECH.encode(), 'text/html; charset=ISO-8859-1', _CZECH, id='bom-beats-header'),
21+
pytest.param(codecs.BOM_UTF16_LE + _CZECH.encode('utf-16-le'), None, _CZECH, id='utf-16-le-bom'),
22+
pytest.param(codecs.BOM_UTF16_BE + _CZECH.encode('utf-16-be'), None, _CZECH, id='utf-16-be-bom'),
23+
pytest.param(
24+
f'<meta charset="utf-8"><p>{_CZECH}</p>'.encode('cp1250'),
25+
'text/html; charset=windows-1250',
26+
f'<meta charset="utf-8"><p>{_CZECH}</p>',
27+
id='header-beats-meta',
28+
),
29+
pytest.param(
30+
f"<META HTTP-EQUIV='Content-Type' CONTENT='text/html; charset=windows-1250'>{_CZECH}".encode('cp1250'),
31+
None,
32+
f"<META HTTP-EQUIV='Content-Type' CONTENT='text/html; charset=windows-1250'>{_CZECH}",
33+
id='meta-http-equiv-uppercase',
34+
),
35+
pytest.param(
36+
f'<meta charset="utf-16"><p>{_CZECH}</p>'.encode(),
37+
None,
38+
f'<meta charset="utf-16"><p>{_CZECH}</p>',
39+
id='meta-utf-16-as-utf-8',
40+
),
41+
pytest.param(_CZECH.encode(), 'text/html; charset=base64', None, id='non-text-codec'),
42+
pytest.param('中文𠀀'.encode('gb18030'), 'text/html; charset=gb2312', '中文𠀀', id='gbk-as-gb18030'),
43+
pytest.param(b'<p>a+b</p>', 'text/html; charset=utf-7', None, id='utf-7-not-a-web-encoding'),
44+
pytest.param(_CZECH.encode(), 'text/html; charset=unicode_escape', None, id='python-only-codec'),
45+
pytest.param(
46+
_CZECH.encode('cp1250'), 'text/html; charset=" Windows-1250 "', _CZECH, id='label-case-and-spaces'
47+
),
48+
pytest.param(_CZECH.encode(), 'text/html; charset=bogus', None, id='unknown-label'),
49+
pytest.param(
50+
f'<meta charset="windows-1250"><p>{_CZECH}</p>'.encode('cp1250'),
51+
'text/html; charset=bogus',
52+
f'<meta charset="windows-1250"><p>{_CZECH}</p>',
53+
id='unknown-header-label-falls-back-to-meta',
54+
),
55+
pytest.param(
56+
f'<!-- <meta charset="windows-1250"> --><p>{_CZECH}</p>'.encode(),
57+
None,
58+
None,
59+
id='commented-out-meta',
60+
),
61+
pytest.param(b'<metadata charset="windows-1250">', None, None, id='not-a-meta-tag'),
62+
pytest.param(b'', 'text/html; charset=utf-8', None, id='empty-body'),
63+
pytest.param(
64+
f'<?xml version="1.0" encoding="windows-1250"?><p>{_CZECH}</p>'.encode('cp1250'),
65+
None,
66+
f'<?xml version="1.0" encoding="windows-1250"?><p>{_CZECH}</p>',
67+
id='xml-declaration',
68+
),
69+
pytest.param(b'\xffok', 'text/html; charset=utf-8', '\ufffdok', id='invalid-bytes'),
70+
],
71+
)
72+
def test_detect_and_decode(body: bytes, content_type: str | None, expected: str | None) -> None:
73+
"""The body is decoded with the encoding the page declares, or not at all if it declares none."""
74+
encoding = declared_html_encoding(body, content_type)
75+
76+
text = None if encoding is None else decode_html_body(body, encoding)
77+
78+
assert text == expected
79+
80+
81+
@pytest.mark.parametrize(
82+
('body', 'content_type', 'expected'),
83+
[
84+
pytest.param(codecs.BOM_UTF16_LE + b'x', 'text/html; charset=utf-8', 'utf-16-le', id='bom'),
85+
pytest.param(b'<p>x</p>', 'text/html; charset=ISO-8859-1', 'windows-1252', id='header-superset'),
86+
],
87+
)
88+
def test_declared_html_encoding(body: bytes, content_type: str, expected: str) -> None:
89+
"""The Python codec of the encoding the page declares is detected."""
90+
assert declared_html_encoding(body, content_type) == expected
91+
92+
93+
def test_encoding_labels_resolve_to_python_codecs() -> None:
94+
"""Every encoding in the label table is a Python codec."""
95+
for codec in set(_ENCODING_BY_LABEL.values()):
96+
codecs.lookup(codec)

‎tests/unit/_utils/test_http.py‎

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from __future__ import annotations
2+
3+
import pytest
4+
5+
from crawlee._utils.http import parse_content_type_charset
6+
7+
8+
@pytest.mark.parametrize(
9+
('content_type', 'expected'),
10+
[
11+
pytest.param('text/html; charset=windows-1250', 'windows-1250', id='plain'),
12+
pytest.param('text/html; charset="windows-1250"', 'windows-1250', id='quoted'),
13+
pytest.param('text/html;charset = windows-1250 ; foo=bar', 'windows-1250', id='spaces'),
14+
pytest.param('text/html; CHARSET=Windows-1250', 'Windows-1250', id='uppercase'),
15+
pytest.param('multipart/form-data; boundary="charset=foo"', None, id='inside-other-parameter'),
16+
pytest.param('text/html', None, id='missing'),
17+
pytest.param(None, None, id='no-header'),
18+
],
19+
)
20+
def test_parse_content_type_charset(content_type: str | None, expected: str | None) -> None:
21+
"""The charset parameter is read from a `Content-Type` header."""
22+
assert parse_content_type_charset(content_type) == expected

0 commit comments

Comments
 (0)