☰
用Python搞定不规则Excel课表汇总与冲突校验
2026/10/3 15:01:41 网站建设 项目流程

1. 教师课表汇总:为什么动手前先要认清“不规则”这件事

先说个真实场景。学期初,教务处同事把一个文件夹丢给我,里面是四十几位老师发来的课表,有.xls也有.xlsx,命名从“张三2024春课表”到“新课表(3).xlsx”五花八门。任务很简单:把这些课表合并成一张总表,按教师、按星期、按节次列出所有课程。听起来不难,但打开一看就头大了——有横向排版的,有纵向排版的,第一行是学院的,第二行是周次的,有的表头在第三行,有的合并单元格占了好几列,有的空格里藏了换行符,有的甚至直接在备注栏里写“本表不含实验课”。

这就是标题里“不规则”三个字的真实含义。很多Excel教程教的都是规整表格的提取方法,字段对齐、行列有序,但现实里的课表几乎不可能长得那么规矩。如果手工复制粘贴,四十几份课表足够让人忙活一整天,而且容易漏课、串行,核对起来非常痛苦。所以这次我选择用代码来做自动汇总,把“读表、清洗、提取、校验、输出”整条链路跑通。

在不规则数据提取这条路上,最需要先想清楚的不是用什么工具,而是先搞清楚“不规则”到底有哪些类型。我大致归纳成下面几类,后面所有的处理逻辑都是围绕这几类问题设计的:

  • 结构不规则:横版竖版混用、表头行不固定、合并单元格导致行列错位
  • 内容不规则:课程名称带空格或换行,教师姓名重复,时间格式混用(“1-2节”和“周一二第1节”并存)
  • 来源不规则:不同老师填表的习惯不同,留空方式不同,备注信息插入位置不同

如果你一上来就写一个固定的读取脚本,只针对其中一份课表调通,那恭喜你,这只是万里长征第一步。真正能扛住四十份课表的脚本,必须把每一种不规则情况都考虑进去。我这里说的“考虑进去”,不是预先写死每个分支,而是设计一套容错机制,让代码在处理未知情况时至少能给出明确警告,而不是静默吞掉数据然后输出一份残缺汇总。

2. 技术选型复盘:Python配openpyxl,而不是死磕Excel公式

处理课表汇总,市面上大致有三条路:一是纯Excel操作,用VLOOKUP、INDEX、MATCH加各种数组公式硬凑;二是用Power Query清洗汇总;三是用Python做批处理。三条路我都试过,讲一下各自的边界,方便你根据自身情况选。

纯Excel公式方案,优点是上手门槛低,适合一次性处理一份课表,或者课表格式高度统一的情况。但面对一堆不规则课表,公式写到最后会变成一坨谁也看不懂的嵌套逻辑——比如判断表头位置要用MATCH嵌套ISNUMBER,提取星期几要用数组公式配合IF,处理合并单元格得手动补上空值,处理完一份再拖到下一份,公式还得调整区域。最麻烦的是,Excel公式对单元格内容的“脏值”非常敏感,一个不可见字符就能让VLOOKUP匹配失败。调公式的时间足够写完整个Python脚本。

Power Query是微软官方的ETL工具,Excel 2016以上版本自带,处理不规则表格的能力很强,尤其擅长“逆透视”和“填充合并单元格”。如果所有课表都在同一个Excel文件的不同工作表里,PQ会是首选。但我们的场景是几十个独立文件,且文件名和内部结构都无规律,PQ操作起来反而繁琐:要么逐个文件选择导入,要么把文件夹整个作为数据源,然后靠M函数去猜每个文件的表头结构。M语言写多了,一样有维护成本。我并不是否定PQ,它适合表格数量少、变体可控的场景。

Python的方案,主要用的是pandas加openpyxl引擎。pandas负责DataFrame层面的清洗和合并,openpyxl负责底层单元格读取。实际测试下来,四十几份课表全量处理的时间在两秒以内,这还不算优化空间。Python方案最大的优势不是快,而是可以把“读取-清洗-校验-输出”完全拆分,任何一个环节出错都能定位到具体文件和具体单元格。对不规则数据的处理,这种细颗粒度控制非常重要。

还有一个容易被忽略的点是环境依赖。纯Excel公式是跟着文件走的,发给别人一份表就能用;Python脚本则需要目标机器有Python环境。可以打包成exe,但涉及pandas这种体积较大的依赖,打包后动辄一两百MB。所以我最后是写了一个脚本文件,配合requirements.txt,在教务处的机器上装了个精简环境。如果你只是自己用,或者团队里有懂技术的人,这个方案完全够用。

工具选型的底层逻辑其实很简单:数据量小、格式相对统一,怎么省事怎么来;数据量大、格式乱、需要重复运行,直接上代码。课表汇总恰恰属于后者——每个学期都要跑一次,而且每次都会有新的不规则情况冒出来。

3. 核心实现:一份能扛住脏数据与异形结构的课表汇总脚本

这一节是整篇的干货核心。我不会贴一个“完整成品”让你直接复制就跑,因为你的课表格式大概率和我这边的不一样。我会把脚本拆成几个独立模块,逐个解释每个模块在解决什么问题,以及为什么这么写。

3.1 第一步:文件级扫描与格式嗅探

拿到一个文件夹,第一件事不是读表,而是看一下里面到底有哪些文件、每个文件有几个工作表。这一步用openpyxl就能完成,不需要pandas加载整个文件。

from openpyxl import load_workbook from pathlib import Path folder = Path("课表集合") for file in folder.glob("*.xls*"): if file.suffix == ".xls": # 旧格式需要先转换为xlsx或用xlrd continue wb = load_workbook(file, read_only=True, data_only=True) print(file.name, "->", wb.sheetnames) wb.close()

这里有个细节:.xls和.xlsx的读取库不一样。openpyxl只支持.xlsx,老版.xls需要用xlrd,但xlrd2.0以上版本不再支持.xls以外的格式读取。稳妥的做法是先用脚本把.xls统一另存为.xlsx。可以用Excel的“另存为”宏,也可以直接用pywin32调用Excel应用批量转换:

import win32com.client as win32 from pathlib import Path app = win32.gencache.EnsureDispatch("Excel.Application") app.Visible = False folder = Path("课表集合") for xls_file in folder.glob("*.xls"): xlsx_file = xls_file.with_suffix(".xlsx") wb = app.Workbooks.Open(str(xls_file)) wb.SaveAs(str(xlsx_file), FileFormat=51) # 51代表xlsx wb.Close() app.Quit()

这一步属于预处理,不属于日常读取流程。顺手提醒一句:app.Visible = False只是让Excel窗口不显示,但不代表程序不会弹对话框。如果某份文件有兼容性问题,脚本可能卡住,加个try...except包住单文件转换比较稳妥。

文件级扫描之后,还要做一次“表头嗅探”。所谓嗅探,就是遍历工作表的前几行,找到像“星期”、“节次”、“班级”、“课程名称”这样的关键词在哪一行、哪一列。原理是,大多数课表不管横版竖版,总归要包含这几类语义字段。我们先扫描前5行所有单元格文本,建立关键词到坐标的映射:

def sniff_keys(sheet): keys = {} for row in sheet.iter_rows(min_row=1, max_row=5): for cell in row: if not cell.value: continue text = str(cell.value).strip() if "星期" in text or "周次" in text: keys["week"] = (cell.row, cell.column) elif "节" in text and ("第" in text or "时间" in text): keys["period"] = (cell.row, cell.column) elif "教师" in text: keys["teacher"] = (cell.row, cell.column) elif "课程" in text or "科目" in text: keys["course"] = (cell.row, cell.column) return keys

这个函数的输出就是整份课表的“地图”。有了地图之后,后续提取逻辑就可以围绕坐标展开,不用管表头在第几行——这就是处理不规则结构的关键思路。不是强行把每份课表都“掰成”标准格式,而是动态适配每份课表自己的结构。

3.2 第二步:读入工作表与无缝合并

嗅探出表头坐标之后,把课表的有效数据区域读入pandas DataFrame。这里推荐直接用pandas.read_excel配合header参数指定表头行。因为我们已经嗅探出表头在第几行,所以可以动态传入header=row-1(pandas的行号从0开始)。

import pandas as pd df = pd.read_excel(file, sheet_name=0, header=header_row - 1)

但这里有个大坑:read_excel会把单元格的“真实值”读进来,遇到合并单元格会得到NaN。合并单元格在课表里很常见,比如课程名跨两行、上午的1-2节和3-4节合并展示。pandas读取时,合并区域只有左上角有值,其余是NaN。如果直接处理,会丢失大量课程信息。解决办法就是“向下向前填充”:

df = df.ffill(axis=0) # 向下填充 df = df.ffill(axis=1) # 向右填充

这里必须axis=0和axis=1都做一次。因为课表里既可能纵向合并(课程跨节次),也可能横向合并(课程跨列,比如连上两天的实训)。两种填充逻辑都跑一遍,才能把合并单元格展开成完整的网格。

填充完之后,把表头行过滤掉,剩下的每一行就是一条课程记录候选。这就是“读入”阶段的基本逻辑。但要注意,ffill对数值类型的列也可能生效,如果某列本身是编号列,合并单元格时也会被反复填充,这需要后面清洗阶段再处理。

3.3 第三步:单元格级清洗函数——被忽略的重头戏

很多人处理Excel数据,把精力都放在结构转换上,忽略了单元格内容本身的清洗。但课表汇总里最常见的翻车事故,恰恰来自那些肉眼看不见的字符。我列几个实际遇到过的脏值情况:

  • 单元格里包含换行符,比如“高等数学A\n(双语)”,打印出来会折行,提取时如果不处理,课程名里就带着一个换行符
  • 全角/半角空格混用,比如“C语言 程序设计”中间有两个空格,有的还是全角空格
  • 课程名后面藏着“\u3000”这类不间断空格
  • 用“O”或“○”表示空格,比如没课的格子填的是“O”,提取时如果不过滤会被当成课程

所以我专门写了一个clean_cell函数,规则如下:

import re def clean_cell(value): if value is None: return "空" text = str(value) text = text.replace("\u3000", " ") # 全角空格转半角 text = text.replace("\n", " ").replace("\r", " ") text = text.replace("␣", "").replace("□", "").replace("○", "") text = re.sub(r"\s+", " ", text).strip() if text in {"", "-", "--", "null", "None", "N/A", "O", "o"}: return "空" return text

这个函数挂在哪一层?我建议在读入Excel时就对DataFrame里的所有单元格做一次全量清洗,而不是在提取阶段逐个单元格处理。pandas里可以这样:

df = df.map(clean_cell)

pandas 2.0以上版本建议用map而不是applymap,后者会报警告。清洗后的DataFrame,课程名、教师名、时间文本都是干净状态,后续提取逻辑就不必再关心格式问题。

实际工作时,我还会额外用一个函数检测单元格内是否包含课程代码,比如“B0803”之类的字符串,如果有就单独提取出来作为一列。这个细节在后续排课时很有用,但并不是每份课表都有课程代码,所以代码里要加一个条件判断。

3.4 第四步:纵横结构的统一提取逻辑

课表的两种基本结构,分别是横版和竖版:

横版课表:行是节次,列是星期几。每个交叉点是一个“星期X+第Y节”的课程。竖版课表:行是星期几,列是节次。每行的值代表当天的课程列表。

汇总逻辑的本质,是把这两种结构都转换成“一行一条课程记录”的窄表,统一字段为“教师、星期、节次、课程”。横版转窄表,思路是遍历所有行,对每一行的每个非空单元格,生成一条记录。竖版反过来,遍历所有列。这两种结构怎么区分?看嗅探出的坐标:

week_col = keys.get("week")[1] if "week" in keys else None period_row = keys.get("period")[0] if "period" in keys else None if period_row < week_row: # 表头区里,节次所在行在星期所在行的上方,说明是横版 format_type = "horizontal" else: format_type = "vertical"

判断逻辑不够严谨的时候,可以加入第二种判定:横版课表里,星期几通常作为列标题出现;竖版课表里,星期几通常作为行标题出现。所以嗅探函数返回的坐标就够用了——week_col和period_row的相对位置直接反映版式。

横版的提取逻辑如下:

records = [] for row in df.iterrows(): period_label = row[period_col] # 节次列的值,可能是“1-2节” for col in range(week_start_col, df.shape[1]): cell_val = row[col] if cell_val == "空": continue weekday = df.columns[col] # 列名就是星期几 records.append([weekday, period_label, cell_val])

竖版的提取逻辑,把行和列对调即可。提取完所有记录之后,整合成新的DataFrame。这里有一个细节:节次列的值不确定,可能是“1-2”,也可能是“第一节”,也可能是“08:00-09:40”。所以节次解析也要做一个归一化函数,把各种写法统一成“数字-数字”的形式:

def norm_period(text): if "节" in text and "-" not in text: return text nums = re.findall(r"\d+", text) if len(nums) >= 2: return f"{nums[0]}-{nums[1]}" if len(nums) == 1: return f"{nums[0]}" return text

归一化节次,不只是为了汇总后好看,更重要的是后续做冲突检测时,需要把“1-2节”和“第1,2节”这两种写法识别成同一时段。如果这一层不做归一化,冲突检测就会漏报。

3.5 第五步:汇总输出与冲突校验

提取后的统一窄表,按教师分组输出到一张Excel总表即可。输出逻辑很简单:

total_df = pd.DataFrame(records, columns=["教师", "星期", "节次", "课程"]) summary_pivot = total_df.pivot_table( index=["教师", "星期"], columns=["节次"], values="课程", aggfunc=lambda x: " / ".join(x), fill_value="" ) summary_pivot.to_excel("课表汇总.xlsx")

透视表的列可能是1-2节、3-4节,行是教师和星期。这样教务处的同事一眼就能看出每位老师的整体排课情况。但这只是“汇总”,如果汇总结果直接交出去,大概率会被质疑——同一时间同一老师出现两门课怎么办?所以输出前必须加一道校验。

冲突校验的原理很简单:按“教师+星期+节次”分组,统计记录数,大于1的就是冲突。这里需要重点考虑的是,节次可能跨多个时段,比如“1-4节”实际上包含1-2、3-4两个时段,如果另一门课出现在“3-4节”,应该判定为冲突。这就要把“1-4”拆分成离散时段集合:

def expand_periods(text): nums = [int(n) for n in re.findall(r"\d+", text)] if len(nums) < 2: return {nums[0]} if nums else set() return set(range(nums[0], nums[1] + 1))

然后两两比较时段集合是否有交集,有交集且都在同一教师同一星期,就说明冲突。冲突检测的代码不复杂,但价值很大。我见过很多人汇总完就直接发出去,结果排课冲突到上课当天才发现,非常被动。自动检测能在第一时间暴露问题,而且能把原始文件名和单元格坐标也打印出来,方便溯源。

4. 实测翻车现场:那些资料文档里不会写明的隐藏坑

这一段是我想重点分享的。整个脚本我写了三版,第二版在模拟数据上已经跑得很顺,但一到真实课表立刻翻车。下面这几个坑,如果你以后也要处理类似任务,建议直接写进设计文档里。

4.1 坑一:Excel日期单元格变成五位数字

课表里偶尔有老师直接在日期栏里填“2024/2/26”,Excel会把单元格格式识别为日期。openpyxl读出来的值是一个datetime.datetime对象,pandas的to_excel默认会把日期写成整数序列号——就是那种“45283”的诡异数字。这些内容一旦进入汇总表,不管谁看都会懵。

我处理的办法是,在读入阶段把所有datetime对象统一转成字符串格式:

from datetime import datetime def clean_cell_value(v): if isinstance(v, datetime): return v.strftime("%Y-%m-%d") return v

更根本的做法是要求上传课表用文本格式填日期,但现实中不可能每次都能跟所有老师对齐。所以代码里做转换是必须的。

4.2 坑二:合并单元格填充后产生幽灵行

前面说了用ffill处理合并单元格,但填充之后会带来一个副作用:原本算是“表头”的区域,比如左上角“课程表”三个大字合并了七八个单元格,填充后变成一排重复的“课程表”文本。这些幽灵数据如果不清理,会出现在最终汇总表里。

解决方案是在提取阶段增加关键词过滤。汇总表里出现的课程名有固定的候选范围,比如“大学英语”“高等数学”“操作系统”等等。可以做一个课程关键词列表,凡是不在列表里的值,统统当作无效记录丢弃。但是课程名单靠人工维护不现实,所以我换了个思路:反方向过滤。凡是包含“课程表”“教师”“星期”“节次”“时间”“备注”“页码”这些词的,直接丢弃;剩下的非空值才进入候选记录集合。

这会误删一些极端的课程名,比如课程恰好叫“教案设计”,但概率极低。相比之下,误删比留脏好处理,因为误删的结果是汇总表里少一条记录,至少能被校验发现;留脏则是把假数据混进结果里,很难排查。

4.3 坑三:单元格里的空格和换行符导致文本比较失败

这个坑之前提过,但实际危害值得单独说。举个例子,某老师的课表里课程名写成“大学英语(A班)”,另一个老师写的是“大学英语(A班)”。前一个是中文括号,后一个是英文括号;还有的全角半角空格混用。如果汇总后发现两位老师课程标准名不一致,后面做任课归属统计时就会拆成两门课。

我的处理办法是在清洗阶段统一括号和引号:

text = text.replace("(", "(").replace(")", ")") text = text.replace("【", "[").replace("】", "]")

实际应用中,这步能合并掉的课程名差异比我预想的多得多。千万别小看这个替换逻辑。

4.4 坑四:跨Sheet课表混排

还有种常见情况,一个Excel文件里有多个工作表,第一个Sheet是“理论课表”,第二个Sheet是“实验课表”,第三个Sheet是“备注”。如果只读取第一个Sheet,实验课就全丢了。

踩过这个坑之后,我改成遍历所有Sheet,把每个Sheet都执行一遍嗅探和提取,最后把所有Sheet的提取结果合并到一起。如果某个Sheet嗅探不到关键词,就跳过去并打印警告。这样既能处理多Sheet的课表,也能顺带发现表头结构完全不同的异常文件。

4.5 坑五:Excel加载项与VBA宏的干扰

整理过程中还遇到一个诡异现象:同一台电脑上,脚本跑着跑着就报PermissionError,提示Excel文件被占用。查到最后发现,是某台机器上装了Office加载项,打开文件时会自动执行宏,导致文件句柄没有及时释放。使用win32com批量转换时遇到这问题尤其频繁。

解决办法是转换前先检查当前目录下所有Excel进程,转换完强制wb.Close()和app.Quit(),再做一次文件占用检测。对纯openpyxl读取来说,这个问题发生率较低,但一旦文件被WPS这类软件锁定,读取时也会报错,需要在代码里加个重试机制:

import time for attempt in range(3): try: wb = load_workbook(file, read_only=True, data_only=True) break except PermissionError: time.sleep(1) else: print(f"{file} 文件被占用,跳过")

这个重试逻辑不复杂,但能大大减少批处理时因为单文件锁死导致整个任务中断的情况。

5. 进阶玩法:从“汇总课表”到“一键生成教师个人课表与空课查询”

汇总只是第一步。把课表变成干净的统一窄表之后,很多后续需求就可以水到渠成地做出来。比如“空课查询”——哪位老师周三下午第一节没课?标准做法是:

先构造一个“教师×星期×节次”的全量笛卡尔组合,再减去实际有课的记录,剩下的就是空课。用pandas实现很轻松:

teachers = total_df["教师"].unique() weekdays = ["周一", "周二", "周三", "周四", "周五", "周六", "周日"] periods = ["1-2", "3-4", "5-6", "7-8"] import itertools all_slots = pd.DataFrame( itertools.product(teachers, weekdays, periods), columns=["教师", "星期", "节次"] ) free_slots = all_slots.merge( total_df[["教师", "星期", "节次"]], on=["教师", "星期", "节次"], how="left", indicator=True ) free_slots = free_slots[free_slots["_merge"] == "left_only"].drop(columns="_merge")

生成的这张空课表,可以直接作为“预约答疑时间”或“党建会议时间协调”的参考表,实用性很高。另一个进阶功能是按课程名称聚合,生成“课程-教师-班级-周次”的四维排课总账。这类需求看起来复杂,但数据统一之后,背后都是groupby加pivot_table的组合。

再说一个容易被忽略的点:如果课表里含有多班级合班上课的情况,比如说“计算机1班和2班合上”,我们需要把班级字段也拆出来。常见做法是读取课程名里的班级编号,正则提取,存成单独的“班级”列。但实践中可能会出现“1-2班”这种范围写法,需要展开成多个班级。这些细节完全取决于学校排课规则,没有通用解法,但思路是一致的:先统一窄表,再逐字段拆分。

最终的输出可以做成一个带筛选器的Excel总表,也可以用openpyxl给汇总表加颜色标记,冲突单元格标红,空闲时段标绿。这些美化操作不影响数据本身,但能极大提升交付物的使用体验。交付给教务处的时候,一张带自动格式的Excel总表,比一张干干净净的纯白表格更有说服力。

6. 个人经验总结:这套方案延续了两个学期,我改动最多的几个地方

最后聊几句实操层面的体会。

从第一版脚本到现在,我改动最多的模块是清洗函数。每次收到一批新课表,总能发现一两种之前没见过的脏值写法。所以建议把清洗函数独立成文件,定期根据新的脏值扩充规则。用正则表达式做替换时,尽量用固定的替换规则集而不是写一堆if,维护起来清爽很多。

冲突检测模块是我第二版才加的。第一版把汇总表交出去之后,教务处同事反馈有两位老师的课在周五下午重叠了。数据提取本身没错,但没做校验就把结果交出去,这事责任在我的流程设计。所以“汇总”和“校验”这两件事,从一开始就应该分开做。

还有一点,处理这类数据不要在原始文件上直接改。我最初犯过这个错,在read_excel后直接改DataFrame,最后发现原文件被落盘覆盖了——不对,pandas并不回写源文件,但如果用openpyxl操作原文件,确实存在误写风险。我的建议是始终以副本模式工作,所有中间结果都另存为_clean.xlsx或者_records.csv。宁可磁盘上多几个临时文件,也不能冒着污染源数据的风险。

接触这种需求比较多之后,我发现很多人对Excel数据处理的认知停留在“用函数解决”,一旦函数解决不了就归类为“需要人工处理”。但课表汇总这类任务,本质上就是“读多份文档、抽字段、合并、校验、导出”的批处理流程,完全可以标准化。只要先把“不规则”这个问题拆解清楚,再针对每一种不规则设计对应的处理逻辑,剩下的工作量就是敲代码而已。

如果你也正在处理类似的批量Excel提取任务,不管是课表、工资表、体检数据还是库存清单,我的经验是:先花半天时间搞清楚所有可能的不规则形式,再动手写代码。这一半的时间花得绝对值得。最后分享一个实用小工具习惯:把所有课表先统一转到同一个文件夹,打印出一份文件清单和Sheet清单,处理任何异常都能快速定位。这个做法帮我少走了很多弯路。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询