|
| 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 |
0 commit comments