ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Python汉字编码乱码彻底解决:从Unicode到GBK/UTF-8实战指南

Python汉字编码乱码彻底解决:从Unicode到GBK/UTF-8实战指南 上周帮同事调一个爬虫脚本他截了张报错图发过来UnicodeDecodeErrorgbk codec整个终端里全是乱码方块。这种场景在Python处理汉字编码时太常见了——读文件乱码、写文件乱码、爬网页乱码、print中文乱码、连数据库也乱码。很多人第一反应是“换个编码试试”试到怀疑人生还是没搞定。这篇文章就把Python处理汉字编码这件事彻底讲透从编码本质、str与bytes的关系到文件读写、网络爬虫、终端输出这些高频场景的实战方案最后给出一套让乱码不再出现的工程化规范。不管你是刚装好Python的新手还是被乱码折磨许久的“老油条”照着我这个思路走基本都能把问题摁住。1. 为什么Python处理汉字总是出问题乱码根源在编码选择而不是Python本身1.1 先还原几个被乱码支配的现场我用Python写中文相关功能也有七八年了各种乱码现场基本都见过一轮。最常见的几种你可以对照一下自己遇到过哪种读文件报错File a.py, line 3, in module下面跟着一句UnicodeDecodeError: gbk codec cant decode byte 0x...这是用GBK解码了UTF-8文件的内容。文件读出来了但全是“”或者“涓嬭浇”这种火星文这是解码方向反了或者用的解码表不对。print(中文)在PyCharm里好好的一放到Windows cmd里就变乱码这是终端显示编码和程序输出编码不一致。用requests抓网页resp.text返回一堆乱码但浏览器里看明明是正常中文。把中文写进CSV用Excel打开全是乱码用记事本打开却正常。调一个接口返回的JSON里中文变成\u6c49\u5b57这种一串反斜杠u开头的数字和字母。这些局面看着各不一样但根子都是同一个数据在“字符”和“字节”两个形态之间转换时用了不一致的编码表。Python只是一个执行环境它不知道你的文件当年是用什么编码存的也不知道你的终端现在想用什么编码显示。乱码不是Python的bug是编码选择不一致的必然结果。1.2 编码的本质字符集、码点与字节序列不能混为一谈要搞明白汉字编码先把三个概念拆开字符集Charset、码点Code Point和字节序列Byte Sequence。这三者经常被混在一起说其实是不同层面的东西。字符集是“有哪些字符”的清单比如ASCII里有128个字符GB2312/GBK/GB18030里收录了几万个汉字Unicode里现在收录了十几万个字符。码点是给每个字符分配的编号Unicode里“汉”的码点是U6C49这是个十六进制编号。字节序列是把码点变成计算机能存的01串。问题恰恰出在最后一步同一个字符“汉”Unicode码点是U6C49但存成字节时UTF-8编码出来是E6 B1 89三个字节GBK或GB2312编码出来是BA BA两个字节各自都是合法的字节序列。这就是乱码产生的物理根源同一段字节用不同的解码表能解读出完全不同的字符。BA BA用GBK解码是“汉”但用UTF-8解码就会报错或者出乱码。反过来说E6 B1 89是用UTF-8存出来的你要用GBK去读自然也是一堆“涓嬭浇”。计算机永远只存字节它不记得这段字节当初是哪个字符集的产物。你打开文件时选错编码等于拿错钥匙开锁锁芯对不上自然打不开。1.3 GBK与UTF-8两套方案为什么能纠缠到今天中文编码的存量格局基本就是GBK系和UTF-8系的对峙。GBK全称是“汉字内码扩展规范”向下兼容GB2312每个汉字占两个字节在中文Windows环境下被广泛使用。很多老应用、老数据库、Windows记事本早年默认存的文件都是GBK系编码。UTF-8是Unicode的存储形态之一特点是兼容ASCII纯英文只占一个字节汉字占三个字节。为什么现代社会强烈推荐UTF-8因为它一套编码能覆盖全世界所有语言不存在“中文用GBK、日语用Shift-JIS、俄语用KOI8-R”这种割裂局面。但我必须说一个反直觉的事实UTF-8并不是在所有场景里都“更先进”。如果项目里所有文件都是中文且只在中文环境流转GBK占用空间更小解码完全没有问题。我接过一个老系统数据库是GBK存的代码注释是GBK写的这种情况下强行全部转UTF-8反而容易在迁移过程中产生新乱码。核心原则应该是编码本身没有优劣混乱才是原罪。这也解释了为什么乱码问题这么难缠——它不是单一技术缺陷而是历史遗产、环境默认值和人为选择共同造成的。Python处理汉字编码的各种技巧本质上就是在这一堆历史包袱里帮你把“进出的编码”对齐。2. str与bytesPython 3中编解码的基本盘2.1 encode是出门bytes是进门方向搞反必乱Python 3里有两个核心类型str和bytes。str是Unicode字符串它存的是逻辑字符比如汉就是一个字符跟它怎么存成字节没有关系。bytes是字节序列它是真实存在的01串比如b\xe6\xb1\x89。两者之间的转换只有两个方法str转bytes叫encode编码bytes转str叫decode解码。我习惯这么记encode是把字符“包装”出门变成字节decode是把收到的字节“拆包”还原成字符。方向搞反了就是把拆包工具用在包装好的货物上必然出错。# 编码字符 - 字节 s 汉 b s.encode(utf-8) print(b) # b\xe6\xb1\x89 # 解码字节 - 字符 s2 b.decode(utf-8) print(s2) # 汉这里有个最基本的直觉str永远是Unicode它内部是一个码点序列和编码方式无关。len(中文)等于2因为它是两个字符len(中文.encode(utf-8))等于6因为每个汉字在UTF-8里占三个字节。当你看到出错信息里的0x开头十六进制数那就是某一段字节序列的真实内容。2.2 GBK转UTF-8的正确路线中间必须经过Unicode实际项目里最常遇到的需求是把一个GBK编码的文本转成UTF-8。很多人会去找什么“GBK转UTF-8函数”其实根本不需要。Python里str本身是Unicode它是天然的“中转站”。任何编码A到编码B的转换都拆成两步先用编码A解码成str再用编码B编码成bytes。# 假设 content 是从GBK环境读出来的原始字节 gbk_bytes 中文.encode(gbk) # b\xd6\xd0\xce\xc4 text gbk_bytes.decode(gbk) # 第一步GBK字节 - Unicode字符串 utf8_bytes text.encode(utf-8) # 第二步Unicode字符串 - UTF-8字节 print(utf8_bytes) # b\xe4\xb8\xad\xe6\x96\x87text这个中间变量既是终点又是起点。它不属于GBK也不属于UTF-8它是Unicode。只要text没有损坏你可以让它变成任意目标编码的字节。这也是我在文章后面反复强调“全链路统一UTF-8”的基础——如果所有环节都承认Unicode这个中转站编码转换就是两个固定动作不需要什么黑魔法。2.3 读懂UnicodeEncodeError和UnicodeDecodeError在喊什么Python的编码报错有两大主角看懂它们的潜台词能省一半排查时间。UnicodeDecodeError发生在bytes转str的过程中意思是这一堆字节用你指定的编码表解不开。最常见的形态是gbk codec cant decode byte 0x... in position ...。翻译成人话就是你用GBK去读一段不是GBK编码的字节。这时候要思考的是字节到底用什么编码存的而不是继续换着编码瞎试。UnicodeEncodeError发生在str转bytes的过程中意思是这个字符串里有字符用你指定的编码表装不下。典型报错是ascii codec cant encode character \u6c49 in position 0。这在Python 2时代极其常见因为默认编码是ASCII存不了中文。Python 3里默认编码改成了UTF-8这种报错少了很多但在某些平台下str.encode()不传参数时还是会受locale影响在Windows上可能落到cp936GBK头上。两个报错有个共同的排查要点看报错信息里的编码名。gbk codec说明当前用的是什么0x...说明字节长什么样。知道了这两个信息你就能反推出字节到底该用什么解。真到排查的时候下面这两行是救命工具print(repr(b)) # 看原始字节长什么样 print(text.encode(utf-8).hex()) # 把结果以十六进制打印出来跟源文件比对用repr()输出字符串不容易被终端显示骗到——它能暴露出字符串里真实的转义序列和不可见字符。3. 文件读写乱码open()的编码参数才是关键3.1 open()的默认编码是个大坑Windows和Linux不一样文件读写是乱码的重灾区大坑就藏在open()的默认编码里。Python 3的open()函数encoding参数不写的时候会用locale.getpreferredencoding()的结果。Windows中文系统上这个值通常是cp936GBKLinux上通常是UTF-8。这意味着什么同一段代码open(a.txt)读一个UTF-8文件在Linux上一切正常在Windows上直接UnicodeDecodeError。我见过无数同事在Windows上写完好好的脚本部署到Linux服务器就崩或者反过来。不是你代码写错了是open()在两个平台的默认行为根本不一样。所以凡是涉及文本文件读写我从来不在open()里省掉encoding参数。读文件必须明确告诉Python“这个文件是什么编码”写文件必须明确告诉Python“请以什么编码落盘”。比如# 读一个UTF-8文件Windows上也能稳定运行 with open(data.txt, r, encodingutf-8) as f: content f.read() # 写一个UTF-8文件 with open(out.txt, w, encodingutf-8) as f: f.write(中文内容)“为什么必须显式传encoding”这个问题答案很简单locale是环境变量环境是会变的而你的文件编码不会因为部署环境变化自动改变。显式指定编码就是把不确定变成确定。3.2 BOM的困扰记事本留下的隐形字符文件读写的第二个坑是BOMByte Order Mark字节序标记。Windows记事本保存UTF-8文件时默认会往文件开头塞三个字节EF BB BF这是给编辑器自己看的标记表示“这个文件是UTF-8编码”。但Python的utf-8解码器默认不认这个标记于是你读出来的字符串开头会多一个\ufeff字符。with open(note.txt, r, encodingutf-8) as f: content f.read() print(repr(content[:5])) # 开头可能带 \ufeff...这个隐形字符肉眼看不见但它会捣乱字符串比较不相等、JSON解析报错、CSV第一列表头多一个看不见的字符。解决方法是改用utf-8-sig这个编码名。它读文件时自动剔除BOM写文件时自动加上BOMwith open(note.txt, r, encodingutf-8-sig) as f: content f.read() with open(out.csv, w, encodingutf-8-sig) as f: f.write(姓名,年龄\n张三,30\n)另一个常见坑的方向正好反过来你手动写了coding: utf-8想存成UTF-8结果用Excel打开CSV还是乱码。原因是Excel正常识别UTF-8靠的恰恰是BOM。不带BOM的UTF-8文件Excel默认用系统本地编码GBK去解中文自然全乱。所以写CSV给Excel用的场景UTF-8反而打不开utf-8-sig才是正解。3.3 日志文件和CSV的编码细节一次配错次次受影响日志文件的编码问题比较隐蔽。很多人用logging模块写日志习惯性不指定encoding结果日志文件一多Windows下打开部分含中文的日志就乱码。日志这种跨天跨月的文件一旦初始化时编码选错后面排查问题还得先破译日志等于给自己埋雷。import logging # 从一开始就把日志文件的编码锁死 logging.basicConfig( filenameapp.log, encodingutf-8, levellogging.INFO, format%(asctime)s %(message)s )CSV的坑不只在编码还在换行。Python官方文档里明确建议用open()配合csv模块写文件时要传newline否则Windows下每一行之间会多一个空行。这是Windows的\r\n换行被文本模式翻译后又叠了一次导致的。import csv with open(users.csv, w, encodingutf-8-sig, newline) as f: writer csv.writer(f) writer.writerow([姓名, 城市]) writer.writerow([张三, 北京])注意这里不能只依赖csv.writer本身的参数newline是传给open()的。两个参数配合才是完整方案newline管住换行utf-8-sig管住Excel乱码。表格和文本文件处理的通用思路是入口解码、出口编码、中间一律Unicode落盘之前想好谁来消费这个文件。4. 网络与爬虫场景中文乱码的“猜编码”实战4.1 requests的编码裁决逻辑为什么resp.text会乱码爬虫抓下来的网页乱码和文件乱码的根源不同。文件乱码是你没指定编码网页乱码是你的编码“选错了判断方式”。requests库拿到HTTP响应后会先看响应头里的Content-Type这里可能带charsetutf-8或charsetgbk。如果有明确的charsetresp.text就按它解码如果没有requests会退回到ISO-8859-1作为默认值。ISO-8859-1是个单字节编码只覆盖拉丁字符中文落到它手里每个汉字被拆成几个“拉丁字符”结果就是满屏乱码。更麻烦的是有些网页的charset声明和实际内容不一致——服务器配置漏了、程序里写死了、或者历史页面改版后编码换了但响应头没更新。你信了响应头照样乱。遇到这种情况别急着认命。resp.content永远是原始字节它不会动。基于这份字节你有两次“重新裁决”的机会。4.2 用apparent_encoding和chardet做二次裁决第一层裁决是让requests自己再猜一次。resp.apparent_encoding会调用字符集检测工具通常是charset_normalizer基于字节内容统计特征推测编码。大多数情况下这个猜测是准确的尤其是UTF-8和GBK这种差异明显的编码。import requests resp requests.get(https://example.com/xinwen, timeout10) resp.encoding resp.apparent_encoding # 手动覆盖requests的判断 print(resp.text[:200])先赋值resp.encoding再访问resp.text顺序不能反。因为resp.text是惰性解码它只在第一次被访问时根据当时的resp.encoding值解码之后你再改encodingresp.text不会重新解码。第二层裁决是引入chardet再做交叉验证。有些中小站点的页面体量小、特征不明显apparent_encoding也可能翻车。chardet的历史更长算法上对GBK等宽字符编码的支持也比较成熟。import chardet raw resp.content result chardet.detect(raw) print(result) # {encoding: GB2312, confidence: 0.99, language: Chinese} resp.encoding result[encoding]chardet.detect()返回的字典里有encoding和confidence置信度。如果置信度低于0.5基本等于瞎猜这时候宁可拿页面里的meta charset...标签内容来定编码也别相信猜测结果。这里要啰嗦一句代两个工具都存在误判可能。我的习惯是优先看apparent_encoding一旦打印出来还有乱码马上用chardet复核两个结果冲突时再结合响应头charset三选一。三个信息来源互相印证基本能锁死正确编码。4.3 网页抓取结果的“编码接力”从bytes到str再到文件抓下来的内容最终多半要落盘存成文件。这一步的编码接力最容易被忽视。我见过有人把resp.content直接open(..., wb)写进文件美其名曰“原样保存”。短期看没毛病但后续不管是自己读还是别人读一旦分不清这文件是什么编码就是个新坑。正确的接力方式是三层转换每一层都明确编码import requests import chardet resp requests.get(https://example.com, timeout10) raw_bytes resp.content # 第一层原始字节 detected chardet.detect(raw_bytes) # 第二层确定编码 text raw_bytes.decode(detected[encoding]) # 第三层转成Unicode字符串 # 落盘统一UTF-8 with open(page.txt, w, encodingutf-8) as f: f.write(text)到了第四层所有落盘文件我都统一用UTF-8。理由很直接如果每个来源的内容都“原样保存”那一个项目里就会同时出现GBK文件、UTF-8文件、BOM标记文件混合存在的状态。到时候每个文件都要单独记一笔“它是啥编码”完全是给自己添乱。统一UTF-8之后程序里所有文件相关的encodingutf-8都是同一条规则再也不用想“这个文件怎么办”。5. 终端、IDE与JSON输出换了个环境就乱码的真相5.1 Windows终端print中文乱码到底是程序的问题还是显示的问题很多人兴冲冲跑完一个脚本print(中文)在IDE里正常输出一放到Windows自带cmd就乱码当场怀疑人生。这种情况先别改代码先区分是“程序输出错了”还是“终端显示错了”。程序输出没错的情况print输出的是UTF-8的字节流但cmd默认代码页是GBK代码页936它收到UTF-8字节后按GBK解读并显示自然是一堆乱码。改法有两个在程序层面把输出编码改成GBK或者在环境层面让cmd切到UTF-8代码页。环境层面的改法最简单打开cmd先执行chcp 65001切到UTF-8代码页再运行Python脚本。如果你受够了每次手动敲可以在程序入口最前面做一次环境设置import sys import io # 把标准输出/错误流的编码强制设为UTF-8 sys.stdout io.TextIOWrapper(sys.stdout.buffer, encodingutf-8) sys.stderr io.TextIOWrapper(sys.stderr.buffer, encodingutf-8)这里有个更硬核的办法设置环境变量PYTHONIOENCODINGutf-8Python的stdout就不再依赖终端的代码页了。Windows的PowerShell或者新版Windows Terminal对大字符集支持比老cmd好得多实在不行换个终端最省心。5.2 json.dumps输出的\uXXXX是怎么回事它其实不是乱码这个现象太常见了把一个含中文的字典json.dumps之后打印出来的不是{name: 张三}而是{name: \u5f20\u4e09}。很多人以为中文丢了其实没有——\u5f20就是“张”的Unicode码点\u4e09是“三”的码点。这是JSON规范里的合法转义任何支持JSON的语言都能把它还原回中文。Python的json模块默认ensure_asciiTrue意思是序列化时不直接输出中文而是输出ASCII转义序列。这么设计的初衷是兼容老旧的传输环节怕中间哪一层不是UTF-8导致数据损坏。但现在这个默认值已经过时了绝大多数API、数据库、前端都支持UTF-8直传。import json data {name: 张三} print(json.dumps(data)) # {name: \u5f20\u4e09} print(json.dumps(data, ensure_asciiFalse)) # {name: 张三}给ensure_asciiFalse之后JSON里就是真的中文字符。这时候有个新的连带责任只有确保输出链路是UTF-8才敢这么干。如果你在Windows终端上打印且没切UTF-8代码页关闭ensure_ascii的中文反而会乱码开着ensure_ascii的反而是安全的。也就是说在终端环境没理顺之前别急着关掉这个开关。5.3 源码文件头注释的遗留问题# -- coding: utf-8 -- 还有必要写吗这个编码声明在Python 2时代是刚需。Python 2默认把.py源码当ASCII解析源码文件里出现中文而不声明编码解释器直接报SyntaxError所以当年每个文件头都要挂# -*- coding: utf-8 -*-。Python 3默认把源码当UTF-8解析这个声明理论上是多余的写不写都不影响运行。但有两个例外场合我建议保留一是你的源码文件以GBK或GB2312保存且没有其他编码声明解释器读源码时可能报错二是你需要在极小范围里兼容Python 2遗留代码虽然这种需求越来越少了。更重要的现实是源码文件用哪种编码保存比文件头注释写什么更关键。我自己处理重要项目的做法是在编辑器里统一把所有文件保存为UTF-8并开启“检测文件编码”功能。文件头注释省不省无所谓但只要出现编码问题第一件事就是看编辑器的右下角——那里显示“UTF-8”还是“GBK”就是第一线索。6. 工程化编码规范从源头消灭乱码的通用套路6.1 全链路统一UTF-8的落地清单讲完了各场景的解法最后落到工程规范。我的核心主张很简单新项目一律全链路UTF-8老项目先定现状再局部转码。全链路统一是指下面每一环节都显式指定UTF-8而不是依赖默认值源码文件编辑器保存为UTF-8不依赖文件头注释。文本文件读写open()永远带encodingutf-8。CSV文件encodingutf-8-sig同时给open()传newline。日志系统logging.basicConfig(encodingutf-8)。网络请求resp.encoding resp.apparent_encoding落盘统一UTF-8。JSON序列化json.dumps(data, ensure_asciiFalse)前提是链路已是UTF-8。数据库连接MySQL连接串里加charsetutf8mb4PostgreSQL用client_encodingutf8。终端输出Windows下切chcp 65001或设置PYTHONIOENCODINGutf-8。为什么MySQL要用utf8mb4而不是utf8MySQL的utf8字符集是个历史遗留的残次品它最多用三个字节存一个字符导致很多生僻字和emoji存不进去、读取时报错。utf8mb4才是真正的UTF-8全量支持。这个坑我已经见过好几回了连接参数写charsetutf8的同学一旦遇到emoji入库全在那挠头。6.2 排查乱码的方法论先问三个问题再动手乱码问题有个让人抓狂的地方可能原因太多挨个试编码试到天黑也未必中。我总结了一套固定顺序遇到乱码先别改代码把下面三个问题问一遍第一问是字节错还是显示错用repr()打印字符串或字节如果repr()的内容是正确的\u转义或者正常字符说明程序数据没问题是终端显示层的问题去调终端编码。如果repr()本身就是错的码点往下走。第二问这段字节最初是什么编码检查来源这个文件是哪台机器、哪个软件创建的这个网页响应头怎么写的数据库连接的charset参数是什么找到来源才能确定解码入口。实在找不到用chardet.detect()猜测。第三问我要把它变成什么编码明确目标再看需要几步转换。常见误区是直接跳步拿着GBK字节想转成UTF-8有人直接.decode(utf-8)再.encode(gbk)方向全反。永远先decode回str再encode到目标编码。按这个顺序排查90%的乱码能在五分钟内定位。剩下10%是极端情况比如字节流里混合了两种编码一个文件前半部分UTF-8后半部分GBK这种基本只能写脚本分段处理属于脏数据范畴不在常规编码问题之列。6.3 高频场景编码参数速查表下面这张表是我自己在项目里反复用到的直接抄作业即可场景推荐配置核心原因读UTF-8文本文件open(f, encodingutf-8)不依赖locale跨平台稳定读未知编码文件先chardet.detect再decode用字节内容反推编码写CSV给Excelencodingutf-8-signewline带BOM让Excel认UTF-8newline避免空行爬虫拿网页内容resp.encoding resp.apparent_encoding绕过requests默认的ISO-8859-1JSON输出含中文json.dumps(data, ensure_asciiFalse)输出可读中文字符MySQL连接charsetutf8mb4支持emoji和生僻字Windows终端显示chcp 65001或PYTHONIOENCODINGutf-8让cmd认识UTF-8字节流日志文件logging.basicConfig(encodingutf-8)避免日志里攒一堆乱码这张表的核心逻辑就一条每个环节都把编码显式定死不让任何一环落到环境默认值上。环境默认值看起来省事但它只是“当前这台机器恰好如此”不是“这段数据应该如此”。乱码的根源是所有环节的隐性默认值叠加出来的混乱把隐性默认改成显式声明问题自然消散。就我个人经验处理汉字编码问题最重要的不是背下多少编码名而是养成一个习惯每次打开外部数据先问“这是什么编码”再问“要变成什么编码”中间全部用Unicode字符串流转。把这个习惯带进项目里Python处理汉字编码就不再是你的噩梦而是像处理数字和字符串一样稀松平常的事。
返回列表