bigywater.tech
爬虫工程 · 16

编码识别:Content-Type、meta、chardet 谁说了算,乱码从哪来

爬虫拿到的网页乱码,往往不是页面坏了,而是你和服务器对“这段字节是什么编码”有不同的答案。这篇用本机服务器逐一复现:响应头、meta、实际字节三者不一致时 requests 和 lxml 分别怎么选,猜编码的库有多准。

ISO-8859-1
Content-Type: text/html 没写 charset 时,r.encoding 的默认值(UTF-8 页面因此变乱码)
13 / 36 与 3 / 36
charset_normalizer 猜得名字不同的组合数,和解码后文字真正出错的组合数
镕
GB2312 里没有的字:页面声明 gb2312 时,lxml 解码在这里中断

谁说了算:响应头、meta、检测

实测 · 本机执行输出
  UTF-8 页面,Content-Type: text/html; charset=utf-8
      Content-Type='text/html; charset=utf-8';r.encoding='utf-8';r.apparent_encoding='utf-8'
      r.text 的正文:开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确;lxml.html.fromstring(r.content):开头 'ç\x88¬è\x99«å·' 结尾 '\x95ã\x80\x82' → 乱码/不一致
  UTF-8 页面,Content-Type: text/html(没写 charset)
      Content-Type='text/html';r.encoding='ISO-8859-1';r.apparent_encoding='utf-8'
      r.text 的正文:开头 'ç\x88¬è\x99«å·' 结尾 '\x95ã\x80\x82' → 乱码/不一致;lxml.html.fromstring(r.content):开头 'ç\x88¬è\x99«å·' 结尾 '\x95ã\x80\x82' → 乱码/不一致
  UTF-8 页面,Content-Type: application/octet-stream(不是 text/*,没写 charset)
      Content-Type='application/octet-stream';r.encoding=None;r.apparent_encoding='utf-8'
      r.text 的正文:开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确;lxml.html.fromstring(r.content):开头 'ç\x88¬è\x99«å·' 结尾 '\x95ã\x80\x82' → 乱码/不一致
  UTF-8 页面,有 <meta charset="utf-8">,Content-Type 没写 charset
      Content-Type='text/html';r.encoding='ISO-8859-1';r.apparent_encoding='utf-8'
      r.text 的正文:开头 'ç\x88¬è\x99«å·' 结尾 '\x95ã\x80\x82' → 乱码/不一致;lxml.html.fromstring(r.content):开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确
  GBK 页面,<meta charset="gbk">,Content-Type 没写 charset
      Content-Type='text/html';r.encoding='ISO-8859-1';r.apparent_encoding='gbk'
      r.text 的正文:开头 'ÅÀ³æ¹¤³Ì' 结尾 'éF¡£' → 乱码/不一致;lxml.html.fromstring(r.content):开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确
  GBK 页面,Content-Type: text/html; charset=gbk
      Content-Type='text/html; charset=gbk';r.encoding='gbk';r.apparent_encoding='GB18030'
      r.text 的正文:开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确;lxml.html.fromstring(r.content):开头 'ÅÀ³æ¹¤³Ì' 结尾 'éF¡£' → 乱码/不一致

requests 的 r.encoding 来自响应头;text/* 类型没写 charset 时是 ISO-8859-1,于是 UTF-8 页面成了乱码,尽管 r.apparent_encoding 已经猜出了 utf-8。非 text/* 类型(如 application/octet-stream)的 r.encoding 是 None,r.text 反而会用猜测值。requests 不读 HTML 里的 <meta charset>,lxml 解析字节时读 meta、不看响应头。

声明与实际不一致

实测 · 本机执行输出
  响应头说 gbk,页面实际是 UTF-8(<meta charset="utf-8">)
      Content-Type='text/html; charset=gbk';r.encoding='gbk';r.apparent_encoding='utf-8'
      r.text 的正文:开头 '鐖�铏�宸ョ▼:' 结尾 '闀曘��' → 乱码/不一致;lxml.html.fromstring(r.content):开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确
  响应头说 utf-8,页面实际是 GBK(<meta charset="gbk">)
      Content-Type='text/html; charset=utf-8';r.encoding='utf-8';r.apparent_encoding='gbk'
      r.text 的正文:开头 '���湤��:�' 结尾 '�F��' → 乱码/不一致;lxml.html.fromstring(r.content):开头 '爬虫工程:编码识' 结尾 '8,镕。' → 正确
  响应头没写,<meta charset="gb2312">,页面实际是 GBK 且含 GB2312 里没有的字
      Content-Type='text/html';r.encoding='ISO-8859-1';r.apparent_encoding='GB18030'
      r.text 的正文:开头 'ÅÀ³æ¹¤³Ì' 结尾 'éF¡£' → 乱码/不一致;lxml.html.fromstring(r.content):开头 '爬虫工程:编码识' 结尾 'F-8,' → 乱码/不一致
      用 gb2312 解码这段 GBK 字节(errors="replace"):结尾 ',�F。',是否与原文一致:False
      用 gbk 解码这段 GBK 字节(errors="replace"):结尾 '8,镕。',是否与原文一致:True
      用 gb18030 解码这段 GBK 字节(errors="replace"):结尾 '8,镕。',是否与原文一致:True

响应头与实际字节不一致时 r.text 乱码;而用 lxml 解字节、页面有 meta 时能解对。GB2312 的字符集比 GBK 小:页面声明 gb2312 但包含“镕”这样的 GBK 字符,Python 的 gb2312 解码会把它替换掉,lxml 则在该字符处截断了后面的内容。把声明为 gb2312 的页面当 gbk 或 gb18030 解码更安全。

怎么修

实测 · 本机执行输出
  默认 r.text → 'ç\x88¬è\x99«å·¥ç¨\x8b:ç¼\x96ç\xa0\x81è'
  r.encoding = r.apparent_encoding('utf-8') 之后 → '爬虫工程:编码识别示例,中文网页常见的 '
  自己对字节解码 r.content.decode("utf-8") → '爬虫工程:编码识别示例,中文网页常见的 '
  交给 lxml 字节(它自己看 meta;这个页面没有 meta)→ 'ç\x88¬è\x99«å·¥ç¨\x8b:ç¼\x96ç\xa0\x81è'
  把 r.text(字符串)交给 lxml,如果文档带 XML 编码声明 → ValueError: Unicode strings with encoding declaration are not supported.

最直接的办法:自己对 r.content 解码(比如先试 utf-8,失败再试 gb18030),或把 r.encoding 设成 r.apparent_encoding。给 lxml 传字节而不是字符串,但页面没有 meta 时它也会乱码;给 lxml 传字符串且文档带 XML 编码声明会抛 ValueError。

UTF-8 BOM

实测 · 本机执行输出
  响应体以 BOM 开头:r.encoding='utf-8',r.text 的前 3 个字符 '\ufeff<h'(\ufeff 是 BOM),r.content.decode("utf-8-sig")[:3]='<ht'

响应体开头的 BOM 在 utf-8 解码后变成 \ufeff 字符,留在 r.text 开头;用 utf-8-sig 解码才会去掉。

猜编码有多准

实测 · 本机执行输出
  requests 当前用的检测库:charset_normalizer 3.4.9
  语言          真实编码       1 句     3 句    10 句    
  简体中文      utf-8       utf_8     utf_8     utf_8     
  简体中文      gbk         gb18030 ✗ gb18030 ✗ gb18030 ✗ 
  简体中文      gb18030     gb18030   gb18030   gb18030   
  繁体中文      utf-8       utf_8     utf_8     utf_8     
  繁体中文      big5        big5      big5      big5      
  日文        utf-8       utf_8     utf_8     utf_8     
  日文        shift_jis   cp932 ✗   cp932 ✗   cp932 ✗   
  日文        euc_jp      cp949 ✗   euc_jis_2004 ✗euc_jis_2004 ✗
  韩文        utf-8       utf_8     utf_8     utf_8     
  韩文        euc_kr      cp949 ✗   cp949 ✗   cp949 ✗   
  法文(带重音)   utf-8       utf_8     utf_8     utf_8     
  法文(带重音)   latin-1     cp1250    big5 ✗    cp1252    
  共 36 个组合,猜得和真实编码不同的有 13 个(✗ 标出;GBK 文本被猜成 gb18030 之类的父集不一定造成乱码,下面再检查解出来的文字是否一致)
  检测出编码后用它解码,得到的文字与原文不一致的有 3 个(共 36 个):日文/euc_jp/1 句 被猜成 cp949; 法文(带重音)/latin-1/1 句 被猜成 cp1250; 法文(带重音)/latin-1/3 句 被猜成 big5

用 charset_normalizer 对简体/繁体中文、日文、韩文、法文各取 1/3/10 句,36 个组合里 UTF-8 全对;名字不同的 13 个多是超集(GBK→gb18030、Shift_JIS→cp932、EUC-KR→cp949),文字没错;真正解错的 3 个是 EUC-JP 的 1 句(被猜成 cp949)和 latin-1 的 1 句、3 句(被猜成 cp1250、big5)。样本是我手写的十句,不代表真实网页的统计。

分步演示

编码识别与乱码
本步的对象

    本文用到的工具与边界

    • 本机 macOS 27 arm64 / Python 3.14.8 / requests 2.34.2 / aiohttp 3.14.3 / lxml 6.1.1(系统 Python);所有请求只发给本机自己起的测试服务器(127.0.0.1,端口 9960 以上,不访问任何真实网站);服务器可以按实验需要制造延迟、断连、压缩、重定向等行为,并记录收到的原始请求;计时用 time.perf_counter,数字每次运行会有小幅抖动。页面由本机服务器按指定 Content-Type 和字节发出;lxml 6.1.1 的 html.fromstring;检测用 charset_normalizer 3.4.9(requests 2.34.2 当前使用的检测库);五种语言各 10 句手写句子,取前 1、3、10 句拼接后用指定编码编码再检测
    • 没有验证:chardet、cchardet 等其它检测库的准确率;真实网页(含大量标签、脚本、英文混杂)的检测准确率;浏览器自己的编码嗅探规则(HTML 标准里的优先级与回退);BeautifulSoup、parsel 等库的编码处理(没有安装);meta 里 http-equiv 写法;GBK 之外 Big5-HKSCS、GB18030 四字节字符的行为
    京ICP备2026067502号-1京公网安备11010502063487号