ARTICLE DETAIL

资讯详情

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

Python批量编码转换实战:GBK/GB2312/GB18030一键转UTF-8

Python批量编码转换实战:GBK/GB2312/GB18030一键转UTF-8 前阵子接手一个老项目几十个 C 和 Python 源文件全是 GBK 编码一放到新编辑器或者 CI 环境里就乱码编译报错信息读不懂日志文件里中文全变成“锟斤拷”和“鍙橀噺”这种鬼东西。手动一个个转编码实在太痛苦而且分散在多个子目录里手动操作不光慢还容易漏。于是我抽了一个下午用 Python 写了一个批量编码转换脚本指定目录递归扫描找到 .py、.cpp、.h、.md 这些常见源码文件检测出 GBK、GB2312、GB18030 编码的先备份再统一转成 UTF-8。这篇笔记把整个思路、完整代码和踩坑记录都整理出来给同样被编码问题折磨的朋友一份可以直接抄作业的方案。1. 为什么会有这个需求编码混乱的老项目是时候治理了1.1 一个乱码引发的血案时间拉回到项目交接那天。代码仓库里清一色的 GBK 编码源文件用 VS Code 打开全是方块用 source insight 打开则是乱码git diff 一对比中文字符全变成非法字符差异。刚开始我还怀疑是编辑器配置问题后来用file命令查了文件编码才发现是老的 Windows 开发习惯留下的历史包袱老一代开发者在简体中文 Windows 环境下Visual C 6.0、早期 Dev-C 等工具默认把源码存成 GBK这习惯一直延续下来。如果文件只有三五个用编辑器另存为 UTF-8 也就解决了。但真实项目里源代码文件动辄几十上百个分布在src、include、tools、docs等不同层级目录里。手动一个个打开、另存不仅慢而且很容易漏掉某个子目录最后转换到一半项目里两种编码并存问题比原来更复杂。所以我把需求定成写一个脚本递归扫描指定目录自动识别旧编码文件备份后批量转成 UTF-8。这就是整个工具的核心目标。1.2 为什么选择 Python 来写转换工具选择 Python 而不是 C、Go 或者 Shell有非常现实的理由。第一Python 内置的字符串编解码机制对中文编码支持极其成熟GBK、GB2312、GB18030、UTF-8 这些编码体系全部靠标准库就能搞定不需要额外装任何第三方依赖。第二os.walk递归遍历目录是标准库里非常好用的工具十几行代码就能扫完整个项目树比自己在 C 里折腾目录迭代器方便太多。第三跨平台Windows、Linux、macOS 全都能跑不需要为不同系统各写一版。其实更底层的理由是编码转换本质上是字节序列的重新映射Python 的 Unicode 处理模型恰好把这层抽象做得很干净。所有编码转换最后都收敛成两步byte_data.decode(old_encoding)得到 Unicode 字符串再text.encode(utf-8)得到新字节串。代码可读性高逻辑清晰出问题也容易排查。我也考虑过用编辑器插件批量转比如 Notepad 里的 ConvertToUTF8但它不是命令行工具不好自动化和引入到持续集成流程里考虑过用chardet库做编码识别但老项目的编码范围很明确自己写一个简单检测器就足够没必要引入一个几百 KB 的依赖。综上所述Python 是这类一次性工具最稳妥的选择。2. 整体设计思路先想清楚再动手写2.1 需求拆解四个环节各司其职在写第一行代码之前我先把整个需求拆成了四个环节扫描、检测、备份、转换。这个拆分本身是最重要的设计决策。递归扫描给定一个根目录使用os.walk遍历所有子目录过滤出指定扩展名的源文件。编码检测读取文件原始字节判断它到底是什么编码。这一步要足够谨慎因为检测错了后续转换就会把文件搞坏。备份对需要转换的文件在改动前先把原始文件复制到备份目录并且保留相对路径结构。备份不是可选项是必须项。转换把旧编码的字节解码成 Unicode 字符串再编码成 UTF-8 写回原路径。很多人写这类脚本容易一上来就写读文件、转码的代码把目录遍历、检测、备份、转换全部塞进一个函数最后改起来非常痛苦。我的建议是每个环节独立成函数调用流程像流水线一样清晰。后期想加日志、加过滤规则、加并发处理只要改对应的函数就行完全不用动其他部分。2.2 编码检测的核心逻辑编码检测是整个脚本的灵魂也是最容易翻车的地方。我的核心逻辑是按优先级依次用不同编码尝试解码原始字节能成功解码的那个编码就是文件的真实编码。这里有一个非常关键的顺序问题UTF-8 必须放在最前面试。因为 UTF-8 多字节序列有严格的自同步规则一个 GBK 编码的中文字节串大多数情况下不会构成合法的 UTF-8 序列。反过来如果文件本来就是 UTF-8你用 GBK 去解码反而大概率是成功的因为 UTF-8 多字节序列里的每一个字节都落在 GBK 可解码的范围内这会把 UTF-8 文件误判成 GBK然后“转换”成 UTF-8内容在底层其实没变但会造成不必要的 IO 和备份。GB2312、GBK、GB18030 三者是向下兼容的包含关系。GB2312 是最早的中文编码标准覆盖 6763 个常用汉字GBK 是 GB2312 的扩展增加了大量生僻字和少数民族文字基本覆盖 GB2312 全部字符GB18030 是最新的国家标准兼容 GBK 并扩展到全部 Unicode 码位。所以检测顺序我做成先试 UTF-8避免误判原生 UTF-8 文件。再试 GB2312这是最严格的编码能通过的通常是比较纯正的简体中文编码文件。然后试 GBK覆盖绝大多数 Windows 老项目。最后试 GB18030最宽松作为兜底。这种顺序能相对准确地判断文件到底属于哪一种编码日志里展示的信息也比较有价值。如果反过来先试 GB18030虽然也能成功解码 GBK 文件但你无法区分它原来是 GBK 还是 GB18030只能笼统显示为 gb18030不够精确。2.3 备份策略的选择备份策略是另一个值得提前想清楚的问题。网上很多类似脚本会把原文件直接重命名成.bak再生成新文件。这种方式有两个坑一是如果项目在 git 或其他版本控制下一堆.bak文件会让git status变得非常混乱而且它们跟源码混在一起也会让后续的扫描程序再次处理到二是如果转换逻辑有 bug想整体回滚就很麻烦靠.bak文件手动改回文件名效率极低。我采用的方案是把需要转换的原始文件按相对路径复制到备份目录。备份目录里保留跟原项目一致的目录结构一旦转换后出现任何问题直接对比或整体恢复都很方便。备份用shutil.copy2能保留文件的修改时间和权限属性这对时间戳敏感的项目很重要。另外要注意备份目录如果放在目标根目录内部遍历时必须主动跳过否则扫描过程会把刚刚备份出去的副本再次加入处理队列造成冗余扫描而且如果备份目录里的副本还是旧的 GBK 文件就可能被二次转换彻底破坏备份数据的原始性。这个坑我在第一版脚本里踩过后面详细讲。3. 完整实现一步步写出转换脚本3.1 递归遍历与扩展名过滤递归遍历我不打算自己写递推函数直接用 Python 内置的os.walk简单可靠。os.walk每次返回三个值当前目录路径、子目录列表、文件列表配合for循环就能自上而下扫描整棵目录树。扩展名过滤使用Path(filename).suffix取得后缀然后统一转小写这样避免大小写问题比如.CPP和.cpp都能被覆盖。脚本支持的扩展名集合如下Python.pyC/C.cpp、.c、.cc、.h、.hppMarkdown.md、.markdown如果你需要加入其他类型直接往SOURCE_EXTENSIONS集合里加就行。在博文后面我会介绍怎么扩展成更通用的版本。关键点在于备份目录在扫描时必须跳过。我的做法是在os.walk的每一层用os.path.normcase将当前目录和备份目录统一格式化后比较如果相同就清空子目录列表并continue让os.walk不再往下进入备份目录内部。这一步在第一版里没有做结果备份目录里的文件一遍又一遍被扫描后来加了这段逻辑才稳定。3.2 编码检测与转换核心函数核心函数我拆成三个detect_encoding、backup_file、convert_file。detect_encoding接收二进制数据按DETECT_ORDER列表顺序依次尝试解码。这里用errorsstrict严格模式遇到非法字节直接抛UnicodeDecodeError正好满足检测需求。不要用errorsignore或errorsreplace否则非法字节被静默吞掉会把坏文件误判成好文件。backup_file负责把文件复制到备份目录逻辑很简单用os.path.relpath计算原文件相对于根目录的路径再拼接到备份目录下创建必要的父目录后shutil.copy2复制。convert_file是核心流程控制器步骤依次是以二进制模式读取文件全部内容。检查是否带 UTF-8 BOM字节开头是\xef\xbb\xbf是则跳过。调用detect_encoding检测编码。如果检测结果是utf-8直接跳过。如果检测结果是None说明编码无法识别跳过并记录。先备份再把原始字节解码成 Unicode 字符串编码成 UTF-8写回原文件。为什么备份放在解码转换之前因为转换操作本身是不可逆的如果解码或编码逻辑有问题原文件可能被覆盖成错误内容先备份可以在任何异常发生时用备份目录恢复。而且备份后即使转换失败原始字节依然完好方便事后分析。3.3 主流程与统计输出主流程用os.walk逐层扫描对每个目标扩展名文件调用convert_file末尾统一输出统计信息。我在第一版里没有统计输出结果跑完根本不知道哪些文件成功、哪些跳过、哪些失败心里完全没底。后来加上统计和逐文件日志体验完全不同。这里贴出完整的脚本代码我在 Python 3.8 到 3.11 下都跑过Windows 10 和 Ubuntu 20.04 都能正常运行#!/usr/bin/env python3 # -*- coding: utf-8 -*- 批量编码转换工具将 GBK/GB2312/GB18030 编码的源文件转换为 UTF-8。 用法 python convert_encoding.py 目标目录 [备份目录] import os import shutil import sys from datetime import datetime from pathlib import Path from typing import Optional # 需要处理的扩展名 SOURCE_EXTENSIONS {.py, .cpp, .c, .cc, .h, .hpp, .md, .markdown} # 检测顺序UTF-8 优先GB2312 最严格GBK 常见GB18030 兜底 DETECT_ORDER [utf-8, gb2312, gbk, gb18030] stats { scanned: 0, converted: 0, skipped: 0, failed: 0, } def is_target_file(filename: str) - bool: 判断文件名后缀是否是需要处理的源文件类型 return Path(filename).suffix.lower() in SOURCE_EXTENSIONS def detect_encoding(data: bytes) - Optional[str]: 尝试用多种编码解码字节数据返回第一个能成功解码的编码名。 全部失败则返回 None。 for encoding in DETECT_ORDER: try: data.decode(encoding) return encoding except (UnicodeDecodeError, LookupError): continue return None def backup_file(file_path: str, root_dir: str, backup_dir: str) - str: 将文件复制到备份目录保持相对路径结构 rel_path os.path.relpath(file_path, root_dir) target_path os.path.join(backup_dir, rel_path) os.makedirs(os.path.dirname(target_path), exist_okTrue) shutil.copy2(file_path, target_path) return target_path def convert_file(file_path: str, root_dir: str, backup_dir: str) - None: 处理单个文件检测编码、备份、转换 stats[scanned] 1 with open(file_path, rb) as f: raw_data f.read() # 如果带 UTF-8 BOM已经是可读的 UTF-8直接跳过 if raw_data.startswith(b\xef\xbb\xbf): stats[skipped] 1 print(f[跳过] {file_path} 已带 UTF-8 BOM) return encoding detect_encoding(raw_data) if encoding utf-8: stats[skipped] 1 print(f[跳过] {file_path} 已经是 UTF-8) return if encoding is None: stats[skipped] 1 print(f[跳过] {file_path} 无法识别编码) return # 先备份再转换防止意外损坏 backup_path backup_file(file_path, root_dir, backup_dir) print(f[备份] {file_path} - {backup_path}) try: text raw_data.decode(encoding) new_data text.encode(utf-8, errorsstrict) with open(file_path, wb) as f: f.write(new_data) stats[converted] 1 print(f[转换] {file_path} ({encoding} - utf-8)) except Exception as exc: stats[failed] 1 print(f[失败] {file_path} {exc}, filesys.stderr) def main() - None: if len(sys.argv) 2: print(用法: python convert_encoding.py 目标目录 [备份目录]) sys.exit(1) root_dir os.path.abspath(sys.argv[1]) if len(sys.argv) 3: backup_dir os.path.abspath(sys.argv[2]) else: timestamp datetime.now().strftime(%Y%m%d_%H%M%S) backup_dir os.path.join(root_dir, fencoding_backup_{timestamp}) os.makedirs(backup_dir, exist_okTrue) print(f扫描目录: {root_dir}) print(f备份目录: {backup_dir}) print( * 60) for current_dir, sub_dirs, files in os.walk(root_dir): # 跳过备份目录本身避免备份文件被重复处理 if os.path.normcase(os.path.abspath(current_dir)) os.path.normcase(backup_dir): sub_dirs[:] [] continue for filename in files: if not is_target_file(filename): continue file_path os.path.join(current_dir, filename) rel_path os.path.relpath(file_path, root_dir) print(f\n[扫描] {rel_path}) convert_file(file_path, root_dir, backup_dir) print(\n * 60) print(转换完成统计结果) print(f 扫描文件数{stats[scanned]}) print(f 转换文件数{stats[converted]}) print(f 跳过文件数{stats[skipped]}) print(f 失败文件数{stats[failed]}) print(f 备份目录{backup_dir}) if __name__ __main__: main()这段代码不长但已经覆盖了前面提到的所有设计要点。我在写的时候刻意保持逻辑平直没有做过度封装因为工具脚本的核心价值是“一眼就能看明白在干什么”而不是展示设计模式。如果读者用的 Python 版本比较老确保 3.6 以上即可我用了f-string和typing.Optional3.6 完全支持。3.4 脚本参数设计与默认行为脚本支持两个参数目标目录和备份目录。备份目录是可选的如果不传会在目标目录下自动创建形如encoding_backup_20250101_120000的目录以时间戳命名避免多次运行互相覆盖。这个设计非常适合第一次使用的朋友可以少担一份心跑完脚本后去备份目录确认原始文件是否完整。关于参数校验我保持了最简原则目标目录不存在时os.walk会静默返回空不会报错所以最好在调用前用os.path.isdir检查一下。如果你不想改代码可以在命令行先确认目录存在或者使用pathlib.Path(root_dir).exists()做一次判空。我这版没有加因为大部分场景都是直接在已有项目根目录下执行路径敲错的概率很低真敲错了看输出没有文件被扫描也能反应过来这个成本可以接受。4. 实操验证跑一个真实项目试试4.1 准备样例工程为了验证脚本效果我手动构造了一个测试项目目录模拟老项目的典型结构。目录如下legacy/ ├── src/ │ ├── main.py # GBK 编码含中文注释 │ ├── utils.py # UTF-8 编码含中文 │ └── model/ │ └── base.cpp # GB2312 编码含中文常量 ├── include/ │ └── legacy.h # GBK 编码含中文宏定义 ├── docs/ │ ├── readme.md # GB18030 编码含中文说明 │ └── api.md # ASCII 纯英文无需转换 └── build/ └── temp.py # 无法识别编码的二进制伪造文件构造方法很简单在 Notepad 或 VS Code 里新建文件写入中文内容然后用“编码”菜单手动切换成对应编码保存。也可以直接用 Python 生成测试文件比如open(main.py, w, encodinggbk).write(print(你好))这里千万注意 Windows 下要用二进制模式或文本模式指定编码避免 Python 自己写成默认的 UTF-8。4.2 运行脚本与结果分析在命令行执行python convert_encoding.py legacy legacy_backup输出大致会是这样的格式扫描目录: /path/to/legacy 备份目录: /path/to/legacy_backup [扫描] src\main.py [备份] /path/to/legacy/src/main.py - /path/to/legacy_backup/src/main.py [转换] /path/to/legacy/src/main.py (gbk - utf-8) [扫描] src\utils.py [跳过] /path/to/legacy/src/utils.py 已经是 UTF-8 [扫描] src\model\base.cpp [备份] /path/to/legacy/src/model/base.cpp - /path/to/legacy_backup/src/model/base.cpp [转换] /path/to/legacy/src/model/base.cpp (gb2312 - utf-8) [扫描] include\legacy.h [备份] /path/to/legacy/include/legacy.h - /path/to/legacy_backup/include/legacy.h [转换] /path/to/legacy/include/legacy.h (gbk - utf-8) [扫描] docs\readme.md [备份] /path/to/legacy/docs/readme.md - /path/to/legacy_backup/docs/readme.md [转换] /path/to/legacy/docs/readme.md (gb18030 - utf-8) [扫描] docs\api.md [跳过] /path/to/legacy/docs/api.md 已经是 UTF-8 [扫描] build\temp.py [跳过] /path/to/legacy/build/temp.py 无法识别编码 转换完成统计结果 扫描文件数7 转换文件数4 跳过文件数3 失败文件数0 备份目录/path/to/legacy_backup从输出能明显看到4 个旧编码文件被准确识别并转换UTF-8 文件被跳过无法识别的伪造二进制文件被跳过。备份目录里保留了 4 个原始文件结构跟原目录一致。整个流程符合预期。4.3 转换后的常见验证方法转换完成后我一般会用三个方法验证结果是否可靠。第一个方法用file命令查看编码类型。在 Linux 或 Windows Git Bash 里执行file -i src/main.py如果显示charsetutf-8说明转换成功。如果还是charsetiso-8859-1或unknown-8bit说明文件可能已经损坏或者有特殊字节序列。第二个方法直接在编辑器里打开文件目测中文注释和字符串是否正常。这一步虽然原始但最直观。重点检查有没有“锟斤拷”“”这类典型乱码特征。也可以用编辑器自带的编码切换功能比如 VS Code 右下角点击编码看是否显示“UTF-8”并确认没有“通过编码重新打开”的提示。第三个方法在项目里搜索乱码特征字符。比如grep -rn 锟斤拷 src/如果历史项目里存在之前反复转码导致的乱码残留这个搜索能一次定位。如果搜索结果为空说明当前仓库内的文本基本干净。5. 踩坑总结这些细节能省你半天时间5.1 编码误判问题我在测试时遇到过最诡异的情况一个实际上是 GBK 编码的中文文件居然被检测成 UTF-8然后被跳过没有转换。检查后发现是因为文件里的中文注释正好是某些生僻字生成的 GBK 双字节序列恰好跟某个合法 UTF-8 两字节序列匹配。这种情况概率很低但不是零。怎么应对我的策略是把脚本当成一个“尽力而为”的工具碰到这种极低概率误判从备份目录恢复原文件手动转换即可。只要备份机制稳定误判造成的损失就完全可控。所以备份设计得越可靠编码检测的激进策略就越安全。我宁可多备份一些文件也不愿意因为检测太保守而漏掉该转换的文件。5.2 文件权限与符号链接在 Linux 下跑脚本时曾遇到只读文件导致写入失败的问题。解决方案是转换前判断os.access(file_path, os.W_OK)不可写就跳过并计数。如果你希望强制转换可以用os.chmod先加上写权限但这依赖具体场景默认我选择跳过。符号链接是另一个坑。os.walk默认不跟随目录符号链接但文件符号链接会直接被处理。如果你的项目里有指向外部目录的符号链接文件脚本可能把外部文件也改掉造成事故。稳妥做法是在convert_file开头加一句if os.path.islink(file_path): stats[skipped] 1 print(f[跳过] {file_path} 是符号链接) return这个细节对普通项目可能用不上但如果你的仓库结构比较复杂加上肯定没坏处。5.3 大文件与性能优化当前实现用read()一次性读入全部字节对几百 KB 到几 MB 的源码文件完全没问题。但如果哪天你扫描的是生成的大型数据文件一次性读入可能造成内存高峰。更优雅的做法是分块读取但分块处理多字节字符会遇到跨块边界的问题处理起来要细心为了一个临时脚本去做分块解码性价比不高。如果文件数量特别多上千个你可以考虑用concurrent.futures做多进程处理。但要注意两点一是多个进程同时写入同一个备份目录时要注意目录创建竞争问题做好exist_okTrue保护二是打印日志的顺序会乱可以用lock保护或者接受输出乱序。我实际跑过几百个文件的转换单线程版本也就几秒钟源码文件体积都不大没必要为这个额外引入并发复杂度。5.4 换行符问题我第一版脚本差点顺手把 CRLF 统一转成 LF准备在编码转换的同时顺便规范化换行符。多看了一分钟才意识到这是个大坑编码转换和换行符是两件事如果一起做了git diff 会看到每个文件的所有行都有变更代码审查的人会被淹没在无关的差异里。这里特意保留原始换行符让改动最小化。转换后如果用 Git 看 diff只有真正发生编码变化的文件才会有二进制级差异这才符合预期。另外GBK 转 UTF-8 之后很多老的代码编辑器仍然按 GBK 打开文件会导致显示乱码。这不算脚本的问题但值得在团队里同步转换之后所有开发者的编辑器都统一设置成 UTF-8问题才算彻底解决。5.5 BOM 问题的处理策略脚本里对“带 UTF-8 BOM 的文件”是直接跳过的。原因是 BOM 虽然会带来兼容问题但文件本身已经是 UTF-8解码读取基本没问题不是“编码转换”需要解决的范畴。如果你希望顺手把 BOM 去掉可以单独用一段代码处理比如if raw_data.startswith(b\xef\xbb\xbf): new_data raw_data[3:] with open(file_path, wb) as f: f.write(new_data)但注意这样做会把文件的“签名”去掉某些 Windows 工具可能因此无法自动识别 UTF-8反而引入新问题。我建议把去掉 BOM 单独做成一个可选参数或独立脚本不要混在编码转换里。我在实际项目里是把“转码”和“去 BOM”分开跑的先用上面的脚本统一转成 UTF-8无 BOM再用另一条命令检查没有残留 BOM 文件。这样每一步的职责单一出问题也容易定位。5.6 运行日志与回滚流程虽然没有专门写日志文件但强烈建议你跑完脚本后把终端输出重定向到一份文本里python convert_encoding.py legacy legacy_backup | tee convert_log.txt这样哪几个文件被转换过、跳过了哪几个都有据可查。回滚时直接对比备份目录和当前目录先确认清单无误再把备份目录整体复制回去即可。如果有 Git更推荐的做法是转换前先git add -A git commit一次把老状态完整提交一个 commit转换后再提交一次这样回滚只需要git revert或git checkout永远不会真的“丢失”老版本。6. 这个
返回列表