2026-10-06 · 喵窝资源铺
财务软件导出的 .csv、老系统给的 .txt、打印机存的台账——十有八九不是 UTF-8。拿到 Python 或服务器里一读,要么 UnicodeDecodeError 直接崩,要么 charset=gbk 硬读之后变成 鍏徃 这种乱码。手动一个个另存为太慢,批量转又怕剥坏字节、转错编码把内容彻底毁掉。这篇给一份先探测、再备份、后转换、拿不准就交人工的批量转码脚本,代码完整实跑过,真实输出贴在下面。
FF FE/FE FF 的 UTF-16 直接标跳过(本次不转换,见坑);decode('utf-8') 的:没 BOM 就认为已经干净、原样不动;带 EF BB BF 的剥掉 BOM;gb18030 与 big5 之间按解码后中文汉字占比打分择优,两者差距太小就判"拿不准",列进清单交人工;*.bak-enc,写入抛异常就还原。import os, sys
SKIP_EXT = {'.png','.jpg','.jpeg','.gif','.pdf','.zip','.xlsx','.docx','.exe','.mp3','.mp4'}
def cjk_ratio(s):
"""解码后文本里汉字占非空白字符的比例"""
cjk = sum(1 for ch in s if '\u4e00' <= ch <= '\u9fff')
total = sum(1 for ch in s if not ch.isspace())
return cjk / total if total else 1.0
def decode_candidates(raw):
out = []
for enc in ('gb18030', 'big5'):
try: out.append((enc, raw.decode(enc)))
except UnicodeDecodeError: pass
return out
converted, skipped, ambiguous, failed = [], [], [], []
for fn in sorted(os.listdir(sys.argv[1])):
p = os.path.join(sys.argv[1], fn)
ext = os.path.splitext(fn)[1].lower()
# 坑1:备份文件自己也是 GBK,必须显式排除,否则无限套娃
if not os.path.isfile(p) or ext in SKIP_EXT or fn.endswith('.bak-enc'):
continue
raw = open(p, 'rb').read()
if not raw:
skipped.append((fn, '空文件')); continue
if raw.startswith(b'\xff\xfe') or raw.startswith(b'\xfe\xff'):
skipped.append((fn, 'UTF-16,未处理')); continue
has_bom = raw.startswith(b'\xef\xbb\xbf')
try:
raw.decode('utf-8'); is_utf8 = True
except UnicodeDecodeError:
is_utf8 = False
if is_utf8:
if has_bom: # 坑2:BOM 要真剥掉,写回时别带着 \ufeff
with open(p, 'w', encoding='utf-8', newline='') as f:
f.write(raw[3:].decode('utf-8'))
converted.append((fn, '剥BOM', len(raw), os.path.getsize(p)))
else:
skipped.append((fn, '已是 UTF-8'))
continue
cands = decode_candidates(raw)
if not cands:
skipped.append((fn, '二进制/无法识别')); continue
ranked = sorted(cands, key=lambda c: -cjk_ratio(c[1]))
best_enc, best_txt = ranked[0]
runner = ranked[1] if len(ranked) > 1 else None
# 坑3:gb18030 和 big5 都能"解码成功",靠占比差距判断是否真的认对了
if runner and abs(cjk_ratio(best_txt) - cjk_ratio(runner[1])) < 0.15:
ambiguous.append((fn, best_enc, runner[0])); continue
open(p + '.bak-enc', 'wb').write(raw) # 先备份,可回滚
try:
with open(p, 'w', encoding='utf-8', newline='') as f: f.write(best_txt)
converted.append((fn, best_enc, len(raw), os.path.getsize(p)))
except Exception as e:
os.replace(p + '.bak-enc', p); failed.append((fn, str(e)))
print(f'转换 {len(converted)},跳过 {len(skipped)},拿不准 {len(ambiguous)},失败 {len(failed)}')
for x in converted: print(' [转]', x)
for x in skipped: print(' [跳]', x)
for x in ambiguous: print(' [?]', x)
用法:python3 to_utf8.py 目录。没有 --delete 那种危险开关——它只转文本、只备份不删原始数据,转换是覆盖写入但原文留在 .bak-enc 里。确认无误后自己删备份即可。
造了 5 类样本:GBK 员工名单、Big5 繁体客户清单、GB18030 生僻字(赵㛃)、带 BOM 的 UTF-8 导出表、纯 ASCII 的 UTF-8 正常文件,外加一个空文件和一个 png。跑完:
转换 4,跳过 2,拿不准 0,失败 0
[转] ('big5_客戶清單.csv', 'big5', 42, 60)
[转] ('gb18030_生僻字.csv', 'gb18030', 35, 43)
[转] ('gbk_员工名单.csv', 'gb18030', 67, 88)
[转] ('utf8bom_导出.csv', '剥BOM', 44, 41)
[跳] ('empty.txt', '空文件')
[跳] ('utf8_正常.txt', '已是 UTF-8')
逐个核对转换后的内容:
| 文件 | 转换后内容(截断) | 结果 |
|---|---|---|
| gbk_员工名单.csv | 姓名,部门,工资 / 张三,销售部,8000 … | ✅ |
| big5_客戶清單.csv | 姓名,備註 / 陳大文,台北客戶 … | ✅ |
| gb18030_生僻字.csv | 姓名,备注 / 赵㛃,含 GB18030 扩展字 | ✅ |
| utf8bom_导出.csv | 订单号,金额(表头第一列不再是隐形 BOM) | ✅ |
| utf8_正常.txt | 原样未动 | ✅ 跳过 |
连跑第二次:转换 0,跳过 6——所有文件都已干净,不会重复转、不会生成套娃备份。幂等通过。
ansi_台账.txt.bak-enc。这些备份本身就是原始 GBK 字节,第二次运行如果不显式排除,脚本会"贴心地"把它们也转成 UTF-8,生成 .bak-enc.bak-enc,再跑一次再套一层。修复很简单——过滤条件加 fn.endswith('.bak-enc')。但如果你用"备份到别的目录"方案,就要保证备份目录不在扫描范围内,否则同样套娃。decode('utf-8') 之后原样 encode('utf-8') 写回去了,\ufeff 还在。正确做法是读 raw[3:] 再解码,或者用 encoding='utf-8-sig' 打开。副作用值得警惕:如果按 'utf-8' 读一个带 BOM 的 CSV,第一列表头会变成 \ufeff订单号,跟代码里写的列名对不上,排查半天。gb18030 去解往往也能成功——解出来的是一串同样合法、但语义全错的汉字乱码。所以"try 一下能解就是它"完全不可靠。这份脚本的做法是:两个编码都解,比"解码后汉字占非空白字符的比例",谁高信谁;两者差距小于 0.15 就判"拿不准"、列清单交人工,绝不硬转。宁可漏转,不能转错——转错编码是内容不可逆损坏。\x00 字节会让几乎任何单字节编码都"解码成功",硬转必出垃圾,交给人工另存更稳妥。相关文章:邮箱列清洗去重 · 手机号列清洗去重 · CSV 按班级拆分(含编码自适应) · 一堆 CSV 并进一个 Excel(认编码认分隔符)