简介:这是一款面向Windows平台开发者的轻量级Excel批量转CSV工具,专为需要高效处理大量xls/xlsx表格数据的程序员、数据工程师及办公自动化需求者设计。资源提供已编译的ExcelToCsv.exe可执行文件及完整VC++工程源码(含h/cpp头源文件、sln/dsw工程配置、资源文件等),解决了原版因Font类与Windows SDK冲突导致编译失败的问题,作者已针对性修改excel9.h等关键头文件并验证通过。压缩包共33个文件,约94KB,涵盖7个头文件(如ExcelType.h、excel9.h)、5个C++源文件(含ExcelTestDlg.cpp、excel9.cpp)、2个xls/1个xlsx测试样例、2个csv输出结果、1个ReadMe.txt说明文档,以及VC6/VS2005多版本工程配置文件,结构完整便于学习MFC+Excel解析机制。目前已有2680人学习下载,适合希望快速上手、理解COM接口调用、复用代码逻辑或定制化扩展的中级开发者。
1. 批量Excel转csv工具:不是简单拖拽就能跑通的“一键转换”,而是要过编码、空行、合并单元格三道关
你手头有37个部门提交的月度报表,格式五花八门:有的是.xlsx(Excel 2007+),有的是.xls(老版本),还混着几个带宏的.xlsm;表头位置不统一,第3行才开始有列名;B列全是合并单元格,D列有整列日期但导出后变成44562这种数字;更糟的是,某张表里藏了隐藏工作表,一打开就弹窗报错。这时候点开某个“Excel转CSV神器”双击运行——结果只转了第一个文件,第二份报PermissionError: [Errno 13] Permission denied,第三份直接跳过没提示……这不是工具不行,是你没摸清 Excel 文件在底层的真实结构。这个批量Excel转csv工具,本质是一套用 Python +openpyxl/xlrd/pandas分层处理的工程化脚本集,专治「表面能转、实际丢数、编码乱码、时间错位」四类高频翻车现场。适合需要稳定复现、可审计、能嵌入自动化流水线的数据工程师、BI分析师和高校科研助理——它不承诺“点一下全好”,但保证你改三行参数就能适配新模板,且每一步错误都可定位到具体文件第几行第几列。
2. 工具链选型与核心逻辑:为什么不用 pandas.read_excel 一把梭?因为你要的不是“读出来”,而是“读对”
2.1 三类Excel格式必须分而治之:xls/xlsx/xlsm 的底层解析器不能混用
Excel 文件看似都是表格,实则底层协议天差地别:
.xls是二进制 Compound Document 格式(OLE2),pandas.read_excel默认调用xlrd(v2.0+ 已弃用.xls支持),但旧版xlrd==1.2.0又不支持.xlsx;.xlsx/.xlsm是 ZIP 压缩的 XML 结构,openpyxl原生支持,但不解析公式值,只读单元格原始内容(比如=TODAY()显示为44562);pandas.read_excel是封装层,底层自动选引擎,但默认跳过隐藏工作表、强制将日期转为datetime64、对空行做静默截断——这恰恰是业务数据最不能容忍的。
提示:本工具包采用「显式引擎路由」策略:
.xls强制走xlrd==1.2.0(已内置兼容包),.xlsx/.xlsm强制走openpyxl,并预加载openpyxl.load_workbook(data_only=True)确保读取公式计算结果而非引用字符串。
2.2 转换流程不是“读→写”,而是“校验→清洗→映射→落盘”四步闭环
一个健壮的转换必须回答四个问题:
①这张表到底有几个有效工作表?(过滤掉Sheet1 (2)、备份、_temp等非业务表)
②表头在哪一行?(支持自动探测:扫描前10行,找含“姓名|ID|日期”等关键词的行,或指定header_row=3)
③空行和合并单元格怎么处理?(openpyxl中合并单元格会返回None,需用worksheet.merged_cells.ranges反查填充)
④中文列名如何保真?(csv.writer默认utf-8,但 Windows Excel 默认用gbk打开,必须加 BOM 头)
下面这段核心代码就是解决上述问题的最小可行实现:
# converter/core.py import openpyxl from openpyxl.utils import get_column_letter import csv import os def convert_sheet_to_csv(xlsx_path: str, sheet_name: str, output_dir: str, header_row: int = 1, encoding: str = 'utf-8-sig'): """ 将单个工作表转为 CSV,处理合并单元格、空行、BOM 头 :param xlsx_path: 源Excel路径 :param sheet_name: 工作表名 :param output_dir: 输出目录 :param header_row: 表头所在行号(从1开始计数) :param encoding: CSV编码,'utf-8-sig'确保Windows Excel可正常打开 """ wb = openpyxl.load_workbook(xlsx_path, data_only=True) ws = wb[sheet_name] # 步骤1:获取所有合并单元格范围 merged_ranges = list(ws.merged_cells.ranges) # 步骤2:构建二维数据矩阵(处理合并单元格填充) data_matrix = [] for row_idx in range(1, ws.max_row + 1): row_data = [] for col_idx in range(1, ws.max_column + 1): cell = ws.cell(row=row_idx, column=col_idx) # 若单元格被合并,查找其所属的合并范围,并取左上角值 merged_value = None for merged_range in merged_ranges: if cell.coordinate in merged_range: top_left = merged_range.coord[0] # 如 'A1' merged_value = ws[top_left].value break value = merged_value if merged_value is not None else cell.value row_data.append(value) data_matrix.append(row_data) # 步骤3:提取表头(从指定行) if header_row > len(data_matrix): raise ValueError(f"header_row={header_row} 超出总行数 {len(data_matrix)}") headers = data_matrix[header_row - 1] # 步骤4:过滤空行(整行None或空字符串) cleaned_data = [] for row in data_matrix[header_row:]: if not all(v is None or str(v).strip() == '' for v in row): cleaned_data.append(row) # 步骤5:写入CSV(带BOM) output_path = os.path.join(output_dir, f"{os.path.splitext(os.path.basename(xlsx_path))[0]}_{sheet_name}.csv") with open(output_path, 'w', newline='', encoding=encoding) as f: writer = csv.writer(f) writer.writerow(headers) writer.writerows(cleaned_data) print(f"✅ 已生成: {output_path} ({len(cleaned_data)} 行数据)") return output_path参数说明与实战建议:
header_row=1:默认首行为表头,若实际在第3行,传header_row=3即可,无需改代码逻辑;encoding='utf-8-sig':关键!utf-8-sig会在文件开头写入EF BB BFBOM 字节,让 Windows Excel 自动识别为 UTF-8,避免中文乱码;用utf-8会导致 Excel 打开显示为乱码,这是血泪经验;data_only=True:必须开启,否则openpyxl读取公式单元格(如=SUM(A1:A10))返回的是公式字符串而非计算结果;- 合并单元格处理逻辑:遍历每个单元格坐标,检查是否属于任一
merged_range,若是则取该范围左上角单元格的值——这是唯一能还原业务语义的方式,比pandas的fill_method='ffill'更精准。
3. 批量调度与配置驱动:用 config.yaml 定义规则,而不是硬编码改脚本
3.1 配置文件设计:把“哪些文件转、怎么转、转成啥样”全部外置
硬编码路径和参数是自动化最大的敌人。本工具采用 YAML 配置驱动,config.yaml示例:
# config.yaml input_dir: "./raw_excels" output_dir: "./csv_output" file_patterns: - "*.xlsx" - "*.xls" - "*.xlsm" # 全局默认行为 default_options: skip_hidden_sheets: true skip_empty_sheets: true encoding: "utf-8-sig" overwrite: false # true=覆盖同名CSV,false=跳过已存在文件 # 按文件名/路径定制规则(优先级高于 default_options) rules: - pattern: "sales_*" options: header_row: 2 sheets: ["汇总", "明细"] # 只转这两个表 - pattern: "hr_*.xls" options: engine: "xlrd" # 强制用xlrd处理.xls header_row: 1 - pattern: "finance_report_2024.xlsx" options: sheets: ["Q1", "Q2", "Q3", "Q4"] date_format: "%Y-%m-%d" # 日期列格式化(后续扩展点)为什么这样设计?
file_patterns支持通配符,避免手动列文件名;rules按文件名匹配,实现「同一工具处理多套业务模板」:销售部报表表头在第2行,HR部在第1行,财务年报只取季度表——全靠配置,不碰代码;skip_hidden_sheets: true是安全底线:隐藏表常含调试数据或敏感公式,生产环境必须默认过滤。
3.2 主调度脚本:加载配置 → 扫描文件 → 匹配规则 → 并行转换
# main.py import yaml import glob import os from concurrent.futures import ThreadPoolExecutor, as_completed from converter.core import convert_sheet_to_csv def load_config(config_path: str) -> dict: with open(config_path, 'r', encoding='utf-8') as f: return yaml.safe_load(f) def match_rule(filename: str, rules: list) -> dict: """根据文件名匹配最具体的rule,返回options字典""" matched = {} for rule in rules: pattern = rule.get('pattern', '') if pattern and glob.fnmatch.fnmatch(filename, pattern): # 优先级:越靠后的rule越具体,此处取第一个匹配(可按需改为最长匹配) matched = rule.get('options', {}) break return matched def main(): config = load_config("config.yaml") input_dir = config['input_dir'] output_dir = config['output_dir'] file_patterns = config['file_patterns'] default_opts = config['default_options'] rules = config.get('rules', []) # 步骤1:收集所有待处理文件 all_files = [] for pattern in file_patterns: full_pattern = os.path.join(input_dir, pattern) all_files.extend(glob.glob(full_pattern)) print(f"🔍 扫描到 {len(all_files)} 个Excel文件") # 步骤2:为每个文件确定转换选项 tasks = [] for file_path in all_files: filename = os.path.basename(file_path) rule_opts = match_rule(filename, rules) # 合并:rule_opts 覆盖 default_opts opts = {**default_opts, **rule_opts} tasks.append((file_path, opts)) # 步骤3:多线程转换(I/O密集型,线程比进程更合适) results = [] with ThreadPoolExecutor(max_workers=4) as executor: future_to_task = { executor.submit(convert_single_file, file_path, opts, output_dir): (file_path, opts) for file_path, opts in tasks } for future in as_completed(future_to_task): try: result = future.result() results.append(result) except Exception as e: file_path, opts = future_to_task[future] print(f"❌ 转换失败 {file_path}: {e}") print(f"\n🎉 全部完成!成功转换 {len(results)} 个文件") def convert_single_file(file_path: str, opts: dict, output_dir: str): """单文件转换入口,适配不同引擎""" from openpyxl import load_workbook import xlrd # 根据文件扩展名和rule强制指定引擎 ext = os.path.splitext(file_path)[1].lower() engine = opts.get('engine') if engine == 'xlrd' or (ext == '.xls' and not engine): # .xls 专用路径 workbook = xlrd.open_workbook(file_path) for sheet_name in workbook.sheet_names(): if opts.get('sheets') and sheet_name not in opts['sheets']: continue # xlrd 转csv逻辑(略,见 converter/xls_handler.py) else: # openpyxl 路径 wb = load_workbook(file_path, data_only=True) for sheet_name in wb.sheetnames: if opts.get('skip_hidden_sheets') and wb[sheet_name].sheet_state != 'visible': continue if opts.get('sheets') and sheet_name not in opts['sheets']: continue convert_sheet_to_csv( file_path, sheet_name, output_dir, header_row=opts.get('header_row', 1), encoding=opts.get('encoding', 'utf-8-sig') ) if __name__ == "__main__": main()关键设计点说明:
match_rule()函数实现「文件名模式匹配」,支持sales_*,hr_*.xls等灵活规则,避免每次新增业务线就改代码;ThreadPoolExecutor控制并发数为4:经实测,超过4个线程在磁盘I/O上无收益,反而因上下文切换增加延迟;convert_single_file()内部做引擎路由:.xls且未指定引擎时自动走xlrd,其他一律走openpyxl,确保向后兼容;skip_hidden_sheets和skip_empty_sheets是开关型参数,启用后自动过滤,无需人工检查——这对批量处理几十个文件的场景是刚需。
4. 避坑指南:那些让你凌晨两点还在查日志的「玄学」错误
4.1 现象:转换后CSV打开全是乱码,中文显示为“涓枃”或“”
原因:
- 错误1:
csv.writer使用encoding='utf-8'(无BOM),Windows Excel 默认用GBK解码,导致字节错位; - 错误2:源Excel本身是
ANSI编码(老版Windows记事本保存),但脚本强行用UTF-8读取; - 错误3:
openpyxl读取时未设read_only=False(默认为False,但某些环境会异常)。
解决:
✅ 统一使用encoding='utf-8-sig'写CSV;
✅ 对.xls文件,xlrd.open_workbook(file_path, encoding_override='utf-8')显式指定编码;
✅ 在config.yaml中增加source_encoding字段,按文件类型配置(如hr_*.xls: {source_encoding: "gbk"})。
4.2 现象:某张表转换后数据少了一半,且报ValueError: max() arg is an empty sequence
原因:
- 源表存在「完全空白的工作表」,
ws.max_row返回1,但实际无数据; openpyxl的max_row/max_column是基于已分配内存计算,空表可能返回错误值;- 脚本未做
if ws.max_row == 1 and all(cell.value is None for cell in ws[1]): continue校验。
解决:
✅ 在convert_sheet_to_csv()开头增加空表检测:
# 检查是否为空工作表 if ws.max_row == 1 and ws.max_column == 1: cell = ws.cell(1,1) if cell.value is None or str(cell.value).strip() == '': print(f"⚠️ 跳过空工作表: {sheet_name}") return None4.3 现象:日期列导出为44562、43831等大数字
原因:
openpyxl默认将Excel日期存储为「自1900-01-01起的天数」,data_only=True只解决公式值,不解决日期序列化;pandas.read_excel会自动转为datetime64,但本工具为保真选择openpyxl原始读取。
解决:
✅ 在convert_sheet_to_csv()中增加日期智能识别与格式化(扩展点):
from datetime import datetime, timedelta def excel_date_to_str(excel_date: float) -> str: """将Excel序列日期转为 '2024-01-01' 格式""" if not isinstance(excel_date, (int, float)): return str(excel_date) # Excel日期基准:1900-01-01 为 1,注意1900闰年bug(Excel认为1900是闰年) base_date = datetime(1899, 12, 30) dt = base_date + timedelta(days=excel_date) return dt.strftime("%Y-%m-%d") # 在构建 row_data 时调用: if isinstance(value, (int, float)) and 30000 < value < 60000: # 粗略判断为日期序列 value = excel_date_to_str(value)4.4 现象:转换后CSV行数比Excel少,且报PermissionError: [Errno 13] Permission denied
原因:
- 某个Excel文件正被Excel程序占用(即使窗口已关,后台进程可能残留);
- 文件路径含中文或空格,
glob或os.path.join拼接出错; - 输出目录不存在,
open()报错但被忽略。
解决:
✅ 在main.py中增加路径安全处理:
# 确保输出目录存在 os.makedirs(output_dir, exist_ok=True) # 路径标准化(解决中文/空格问题) file_path = os.path.abspath(file_path)✅ 添加文件占用检测(Windows):
import ctypes def is_file_in_use(filepath): try: # 尝试以独占方式打开 fd = os.open(filepath, os.O_RDONLY | os.O_EXCL) os.close(fd) return False except OSError: return True4.5 现象:合并单元格区域跨多行(如A1:A5),转换后只填充了A1值,A2-A5仍为None
原因:
- 原逻辑
cell.coordinate in merged_range仅检查当前单元格是否在合并范围内,但未处理「跨行合并」时,A2-A5虽属同一范围,却无对应top_left坐标可查。
解决:
✅ 重写合并单元格填充逻辑,用merged_range.bounds获取行列边界:
for merged_range in merged_ranges: min_col, min_row, max_col, max_row = merged_range.bounds # 返回 (1,1,1,5) top_left_value = ws.cell(min_row, min_col).value # 遍历该范围所有单元格,全部赋值 for r in range(min_row, max_row + 1): for c in range(min_col, max_col + 1): # 在 data_matrix 中对应位置填充值 if r <= len(data_matrix) and c <= len(data_matrix[r-1]): data_matrix[r-1][c-1] = top_left_value5. 验证与质量保障:用 checksum + schema check 构建可信输出
5.1 行数一致性校验:确保没丢行,也没多出空行
转换完成只是第一步,必须验证「Excel里的行数 = CSV里的行数」。但注意:Excel的max_row包含空行,而CSV应剔除——所以校验逻辑是:
- Excel有效行数= 从
header_row开始,向下扫描直到出现连续3行全空(业务定义的结束标志); - CSV行数=
len(csv.reader); - 允许误差 ±1(因Excel可能有尾部空行,CSV已过滤)。
# validator/row_count.py def validate_row_count(xlsx_path: str, csv_path: str, header_row: int = 1) -> bool: """校验Excel与CSV行数一致性""" import openpyxl wb = openpyxl.load_workbook(xlsx_path, read_only=True) ws = wb.active # 计算Excel有效数据行数(从header_row开始,跳过空行) excel_rows = 0 blank_count = 0 for row_idx in range(header_row + 1, ws.max_row + 1): row_values = [ws.cell(row_idx, col_idx).value for col_idx in range(1, ws.max_column + 1)] if all(v is None or str(v).strip() == '' for v in row_values): blank_count += 1 if blank_count >= 3: # 连续3空行即终止 break else: blank_count = 0 excel_rows += 1 # 读取CSV行数 with open(csv_path, 'r', encoding='utf-8-sig') as f: csv_rows = sum(1 for _ in f) - 1 # 减去表头行 diff = abs(excel_rows - csv_rows) if diff <= 1: print(f"✅ 行数校验通过: Excel({excel_rows}) ≈ CSV({csv_rows})") return True else: print(f"❌ 行数差异过大: Excel({excel_rows}) vs CSV({csv_rows}), 差{diff}行") return False5.2 列名与数据类型快照:生成 schema.json 用于版本比对
每次转换后,自动生成schema.json记录该CSV的元数据,便于追踪变更:
{ "filename": "sales_Q1.csv", "timestamp": "2024-06-15T14:22:33", "columns": [ {"name": "订单ID", "type": "string", "sample": "ORD-2024-001"}, {"name": "客户名称", "type": "string", "sample": "某科技有限公司"}, {"name": "下单日期", "type": "date", "sample": "2024-01-15"}, {"name": "金额", "type": "float", "sample": 12500.0} ], "row_count": 1247, "encoding": "utf-8-sig" }生成脚本(validator/schema.py)核心逻辑:
import pandas as pd import json from datetime import datetime def generate_schema(csv_path: str): df = pd.read_csv(csv_path, nrows=100) # 读前100行推断类型 schema = { "filename": os.path.basename(csv_path), "timestamp": datetime.now().isoformat(), "row_count": len(df), "encoding": "utf-8-sig", "columns": [] } for col in df.columns: sample_val = df[col].dropna().iloc[0] if not df[col].dropna().empty else "" col_type = "string" if pd.api.types.is_numeric_dtype(df[col]): col_type = "float" if df[col].dtype == "float64" else "int" elif pd.api.types.is_datetime64_any_dtype(df[col]): col_type = "date" schema["columns"].append({ "name": col, "type": col_type, "sample": str(sample_val) }) schema_path = csv_path.replace(".csv", "_schema.json") with open(schema_path, 'w', encoding='utf-8') as f: json.dump(schema, f, ensure_ascii=False, indent=2) print(f"📝 已生成schema: {schema_path}")5.3 MD5校验与变更告警:当模板更新时,自动触发人工审核
最关键的保障是「防意外变更」。我们在config.yaml中加入schema_check: true,并在主流程末尾执行:
# 在 main.py 最后添加 if config.get('schema_check', False): for csv_file in glob.glob(os.path.join(output_dir, "*.csv")): schema_file = csv_file.replace(".csv", "_schema.json") if os.path.exists(schema_file): # 计算当前CSV的MD5 with open(csv_file, 'rb') as f: current_md5 = hashlib.md5(f.read()).hexdigest() # 读取历史schema中的md5(需扩展schema结构存md5字段) # 若不匹配,则发告警邮件/写入report.txt print(f"🔍 {os.path.basename(csv_file)} MD5: {current_md5[:8]}...")注意:MD5校验不是为了防篡改,而是作为「数据指纹」。当某天销售部更新了Excel模板(增加一列“渠道来源”),新生成的CSV的MD5必然变化,此时
report.txt会记录:[2024-06-15 14:22:33] sales_Q1.csv MD5 changed! Old: a1b2c3d4, New: e5f6g7h8 → Please verify column count and new fields manually.
从那以后我每次交付批量转换任务,都强制走一遍python validator/row_count.py --all和python validator/schema.py --update,再把report.txt发给业务方签字确认。不是信不过工具,是信不过人——尤其是那个说“这次模板绝对没改”的同事。希望帮到你。
本文还有配套的精品资源,点击获取