数据抓取

抓取数据中的字符编码:检测字符集与修复乱码

我们在六个国家抓取了193个顶级主页。一个常见的Python默认设置使其中37个页面的文本变成乱码。如何像浏览器一样解码抓取的页面。

Chris Collins

Chris Collins

2026年10月1日 · 3 分钟阅读

乱码(Mojibake)是指字节被用错误的字符集解码后得到的混乱文本:“Café” 而不是 “Café”,或者一个日语标题变成了一排零散的拉丁字母。在浏览器中这种情况很少见,因为浏览器遵循一套精确的、标准化的流程来判断页面的编码。但在抓取流水线中这种情况很常见,因为 HTTP 库通常采用更简单的做法。

我们测量了这在真实网站上出现的频率,结果是比大多数团队以为的更频繁。本指南将介绍我们的发现、出现原因,以及遵循浏览器相同顺序的解码代码。

关键要点

  • 2026年10月1日,我们抓取了六个国家域名(日本、韩国、中国、俄罗斯、台湾和德国)中排名前50域名的首页。其中193个返回了页面。
  • 193个页面中有9个不是 UTF-8:日本是 Shift_JIS,韩国是 EUC-KR,俄罗斯是 windows-1251,德国是 ISO-8859-1。
  • 更大的问题不在于遗留编码,而在于某个库的默认设置。Python 的 requests 将193个页面中的37个(约19%)解码为 ISO-8859-1,导致每个非 ASCII 字符都变成乱码,原因是服务器没有在其响应头中声明字符集。
  • 遗留标签的实际含义和它们的名称并不一致。浏览器会将 “ISO-8859-1” 解码为 windows-1252,将 “Shift_JIS” 解码为 Windows 变体,而严格解码器在某些字符上会出错。
  • 按浏览器的顺序进行解码:字节顺序标记、HTTP 响应头、meta 标签,然后是有效性检查和自动检测。

我们测量了什么

我们从 Tranco 热门域名列表中选取了以 .jp、.kr、.cn、.ru、.tw 和 .de 结尾的前50个域名,各抓取一次首页,保留了193个返回状态码为200的 HTML 页面。对于每个页面,我们记录了它声明的编码、实际的编码,以及 requests 库的 .text 得出的结果。

国家域名页面数非 UTF-8被 requests 的 .text 搞乱的页面
日本 (.jp)364 (Shift_JIS)8
韩国 (.kr)312 (EUC-KR)4
中国 (.cn)33010
俄罗斯 (.ru)252 (windows-1251)4
台湾 (.tw)2906
德国 (.de)391 (ISO-8859-1)5
总计193937

UTF-8 显然已经胜出:193个页面中有184个使用了它。但右侧一栏显示,编码之争的胜利并没有终结这个问题。大多数被搞乱的页面其实是 UTF-8。它们是在 <meta> 标签中声明的,而不是在 HTTP 响应头中,而该库从未去查看那里。

为什么这个库会出错

当响应头中写着 Content-Type: text/html 而没有指定字符集时,requests 会将编码设为 ISO-8859-1,这遵循了旧版 HTTP/1.1 对文本类型的默认设置。它不会读取页面的 <meta charset> 标签。此后,每个大于127的字节都会被当作 Latin-1 字符解码,因此任何语言的 UTF-8 文本都会变成乱码:

原文按 Latin-1 解码后的字节
CaféCafé
価格(日语,“价格”)ä¾¡æ ¼
JRA日本中央競馬会(一个 Shift_JIS 页面)JRA 后跟控制字符和零散的拉丁字母

不会抛出任何错误。该文本仍然是一个有效的字符串,仍然能解析为 HTML,选择器仍然能匹配。损害会在之后显现,表现为无法搜索的产品名称、去重失效,以及模型训练数据中的垃圾内容,这正是成功请求返回错误内容的典型案例。

遗留标签的实际含义和名称不符

第二个陷阱更微妙。当页面确实声明了一种遗留编码时,浏览器并不会使用该名称对应的严格标准。浏览器所实现的 WHATWG 编码标准(Encoding Standard)将若干标签映射到了超集:

声明的标签浏览器实际解码为
iso-8859-1, latin1, asciiwindows-1252
gb2312, gbkgb18030
shift_jis带 Windows 扩展的 Shift_JIS(代码页 932)
euc-kr带 Windows 扩展的 EUC-KR(代码页 949)
big5带香港增补字符的 Big5

用同名的严格编解码器解码会产生错误,或者更糟,得到不同的字符。在一个声明为 Shift_JIS 的日本娱乐网站上,Python 的严格 shift_jis 编解码器把每一个全角波浪号 ”~“(U+FF5E)都变成了波浪破折号 ”〜“(U+301C),页面中出现多少次就错多少次。编码标准的索引表将该字节对映射为全角波浪号,这才是访问者实际看到的内容。这两个字符看起来几乎一样,但比较时会被判定为不同的字符串,因此像 “1,000~2,000” 这样的价格区间会悄无声息地匹配失败。

其他映射失败得更明显。一个标注为 gb2312 但使用了该标准之外字符的页面,或者一个使用了扩展韩文音节的 euc-kr 页面,在 Python 的严格编解码器下会抛出解码错误。而一个标注为 ISO-8859-1 但使用了欧元符号的页面,得到的会是一个不可见的控制字符,因为欧元符号只存在于 windows-1252 中。

按浏览器的方式解码

HTML 标准定义了这个顺序:字节顺序标记优先,然后是传输层(HTTP 响应头)指定的编码,接着是通过扫描文档开头找到的 <meta> 声明,规范要求作者必须将其放在文档的前1024个字节以内。按照同样的顺序,结合浏览器的标签映射和检测兜底方案,可以得到如下代码:

import codecs
import re

from charset_normalizer import from_bytes

# Legacy labels mean what browsers decode them as (WHATWG Encoding Standard), not their strict namesakes.
BROWSER_DECODER = {
    "iso-8859-1": "cp1252", "latin1": "cp1252", "ascii": "cp1252", "us-ascii": "cp1252",
    "gb2312": "gb18030", "gbk": "gb18030", "x-gbk": "gb18030",
    "shift_jis": "cp932", "sjis": "cp932", "x-sjis": "cp932", "windows-31j": "cp932",
    "euc-kr": "cp949", "ks_c_5601-1987": "cp949",
    "big5": "big5hkscs",
}
HEADER_CHARSET = re.compile(r"charset\s*=\s*[\"']?([^;\"'\s]+)", re.I)
META_CHARSET = re.compile(rb"""<meta[^>]+charset\s*=\s*["']?\s*([A-Za-z0-9_.:-]+)""", re.I)


def python_codec(label):
    """Map a declared charset label to a Python codec name, or None if it is unknown."""
    label = label.strip().lower()
    label = BROWSER_DECODER.get(label, label)
    try:
        return codecs.lookup(label).name
    except LookupError:
        return None


def decode_html(body, content_type=""):
    """Decode HTML bytes in the browser's order: BOM, HTTP header, <meta>, then UTF-8, then detection."""
    if body.startswith(codecs.BOM_UTF8):
        return body[3:].decode("utf-8", "replace"), "utf-8 (BOM)"
    header = HEADER_CHARSET.search(content_type or "")
    meta = META_CHARSET.search(body[:1024])  # the HTML spec requires the declaration there
    labels = (("header", header and header.group(1)), ("meta", meta and meta.group(1).decode("ascii", "replace")))
    for source, label in labels:
        codec = label and python_codec(label)
        if codec:
            return body.decode(codec, "replace"), f"{codec} ({source})"
    try:
        return body.decode("utf-8"), "utf-8 (valid)"
    except UnicodeDecodeError:
        guess = from_bytes(body).best()
        codec = guess.encoding if guess else "cp1252"
        return body.decode(codec, "replace"), f"{codec} (detected)"

把原始字节(requests 中的 response.content)和 Content-Type 响应头传给它,绝不要用 response.text。它会返回文本以及一个说明编码是如何选定的注释,值得和每个页面一起存储,以便日后可以追溯某个错误的判断。

在全部193个页面上运行,它解码了其中192个页面,没有出现任何一个替换字符。剩下的那个页面声明了 UTF-8,但包含两个无效字节序列,浏览器对此同样会显示为替换字符。它还为库的默认设置搞乱的那37个页面,以及9个遗留编码的页面,都给出了正确的文本。在构造的边界情况上测试,它能无误地解码一个声明为 gb2312 但包含仅存在于 GBK 中字符的页面、一个包含扩展韩文音节的 euc-kr 页面、一个包含欧元符号的 ISO-8859-1 页面,以及一个未声明编码的 Shift_JIS 页面。

还发现了什么

  • 声明位置错误。 在我们第一轮抓取中,有17个页面把 <meta charset> 放在了前1024个字节之后,违反了 HTML 规范。其中13个同时在响应头中声明了字符集,另外4个本身就是有效的 UTF-8,所以没有出问题,但一个只依赖 meta 标签的解码器会漏掉它。
  • 声明相互矛盾。 一个台湾网站在响应头中声明了 UTF-8,却在 meta 标签中声明了 Big5。响应头胜出,这和浏览器的做法一致,在这个例子中也是正确的。
  • 字节顺序标记。 有4个页面以 UTF-8 字节顺序标记开头。其中一些在响应头中没有字符集信息,仍然被该库当作 Latin-1 解码;即使响应头是正确的,该库也在文本开头留下了那个不可见的标记。

实践规则

  • 自己解码字节。 保留原始响应,如果你归档原始响应,日后规则变化时你还可以重新解码。
  • 绝不要假设是 Latin-1。 把缺失响应头字符集当作”继续查找”的信号,而不是当作 ISO-8859-1。
  • 把所有内容都存成 UTF-8。 只在流水线的入口处解码一次,记录使用了哪种编码,此后一律保持 UTF-8。
  • 留意替换字符。 某个字段中 U+FFFD 突然增多,是一个成本低、可靠的报警信号,应该和抓取数据中的模式漂移一文中描述的检查放在一起。
  • 解码后再做规范化。 正确的字符仍然可能有多种写法,例如全角数字或不同的波浪号。在比较或去重之前先做规范化,具体可参考跨地区规范化价格、数字和日期一文。

结论

现在几乎整个互联网都是 UTF-8,但流水线仍然会把它搞乱,因为最常见的故障并不是什么冷门编码,而是某个库的默认设置忽略了页面自身的声明。在我们的样本中,这个默认设置搞乱了大约五分之一的页面,而且没有一次报错。

从字节出发,按照浏览器使用的顺序解码,按照浏览器映射标签的方式映射标签,并记录是哪条规则做出的判断。这样就能把一个隐形的数据质量问题变成一个已解决的问题。

来源与参考资料

准备好开始了吗?

试用 Shifter 住宅代理,205M+ 个 IP,195+ 个国家,低至 $0.10/GB。

立即开始