xlsx课表转ics日历:Python脚本自动处理单双周与调休

发布时间:2026/10/23 19:59:37
xlsx课表转ics日历:Python脚本自动处理单双周与调休

简介:这份资源面向需要将Excel课表批量导入日历的学生与教育工作者,解决手动逐条录入课程、跨平台日程难以同步的问题。包内共4个文件,包含可执行程序、课表样例、JSON配置与说明文档,压缩包约9.8MB,其中exe负责核心转换,xlsx提供课程数据模板,json用于定制上课时间与日期规则,txt则给出运行与配置指引。已有7304人学习下载,说明该方案在课表迁移场景中具备一定实用参考价值。读者可借助它把课程名称、教师、教室、起止时间等信息解析为ics日历事件,直接导入Google日历、Apple日历或Outlook,实现提醒与时间规划;同时可通过配置文件适配不同作息安排,并参考说明文档排查格式不兼容等问题,适合希望提升时间管理效率、减少重复录入的初、中级用户。

1. 从 xlsx 课表到 ics 日历:为什么我宁愿写脚本也不手动录

每学期开学第一周,总有人对着 Excel 课表一行行往手机日历里敲,敲到第三门课就发现单双周忘了区分,再回头改已经录错的时段。xlsx 课表转 ics 日历格式文件这件事,本质是把二维表格里的时间、地点、周次规则翻译成 iCalendar 标准能识别的 VEVENT 事件流。它解决的不是"能不能看课表",而是"课表能不能自动进日历、自动提醒、自动处理单双周和调休"。适合谁?手里有一份教务系统导出的 xlsx、又不想每周手动核对的人;也适合需要把课程表批量同步到多个日历客户端的场景。我拆过好几份不同学校导出的课表,字段名五花八门,但转换逻辑是通的。

2. 拆解 xlsx 课表结构:先看懂字段再谈转换

2.1 课表 xlsx 的三种典型布局

教务系统导出的课表,布局逃不出这三类。第一种是"格子表",行是节次、列是星期,单元格里塞着"高等数学 张三 教一101 1-16周"。第二种是"列表表",每行一条记录,字段拆成课程名、教师、周次、节次、地点。第三种是"混合表",上半部分是格子,下半部分藏着调休说明。转换前必须先判断属于哪种,因为格子表需要先做单元格解析,列表表可以直接按行读。

我一般先用 pandas 把前 10 行打出来看结构,别急着写转换逻辑。很多翻车都发生在没看清表头有几行合并单元格。

import pandas as pd # header=None 先不指定表头,把原始网格打出来 raw = pd.read_excel("schedule.xlsx", header=None) print(raw.shape) print(raw.iloc[:10, :8].to_string())

这段代码的作用是"侦察"。header=None避免 pandas 把合并单元格的第一行误当表头;shape告诉你表格多大;iloc[:10, :8]只看左上角区域,足够判断布局类型。参数上,如果你的课表第一行确实是干净的表头,可以改成header=0,但我不建议一上来就这么干。

2.2 时间、周次、节次三要素的归一化

不管哪种布局,最终都要归一到三个要素:日期时间、周次规则、地点。节次到具体时间的映射每个学校不同,常见做法是维护一张节次时间表:

节次开始时间结束时间
第1节08:0008:45
第2节08:5509:40
第3节10:0010:45
第4节10:5511:40
第5节14:0014:45

周次规则要处理"1-16周"、"1-8,10-16周"、"单周"、"双周"这几种写法。我一般写一个解析函数,把周次字符串转成整数列表,后面生成事件时按周循环。

import re def parse_weeks(week_str): """把 '1-8,10-16周' 或 '单周' 解析成周次整数列表""" week_str = str(week_str).replace("周", "").strip() if "单" in week_str: return list(range(1, 17, 2)) # 单周:1,3,5...15 if "双" in week_str: return list(range(2, 17, 2)) # 双周:2,4,6...16 weeks = set() for part in week_str.split(","): if "-" in part: start, end = part.split("-") weeks.update(range(int(start), int(end) + 1)) elif part.isdigit(): weeks.add(int(part)) return sorted(weeks)

逻辑说明:先去掉"周"字,判断单双周直接返回固定序列;否则按逗号切分,遇到a-b展开成区间,遇到单个数字直接加入集合。参数上,range(1, 17, 2)里的 17 是学期总周数上限,如果你的学校是 18 周或 20 周,改这个值。这个函数是整个转换的地基,周次解析错了,后面全错。

2.3 用 openpyxl 读取合并单元格的真实值

格子表最坑的地方是合并单元格。pandas 读合并单元格时,只有左上角有值,其余是 NaN。这时候得换 openpyxl,它能告诉你哪些格子属于同一个合并区域。

from openpyxl import load_workbook wb = load_workbook("schedule.xlsx") ws = wb.active # 建立合并单元格映射:每个坐标 -> 合并区域左上角的值 merged_map = {} for merged_range in ws.merged_cells.ranges: top_left = ws.cell(merged_range.min_row, merged_range.min_col).value for row in range(merged_range.min_row, merged_range.max_row + 1): for col in range(merged_range.min_col, merged_range.max_col + 1): merged_map[(row, col)] = top_left # 读取时优先查映射表 def get_cell(row, col): return merged_map.get((row, col), ws.cell(row, col).value)

这段的关键是merged_cells.ranges拿到所有合并区域,然后把左上角的值铺满整个区域。get_cell先查映射表,查不到再读原始单元格。参数上,min_row/min_col/max_row/max_col是 openpyxl 合并区域对象自带的边界属性,不用自己算。有了这个,格子表里跨行跨列的课程名就不会丢了。

3. 生成 ics 文件:VEVENT 结构、时区和重复规则

3.1 iCalendar 最小可用结构

ics 文件本质是纯文本,遵循 RFC 5545。一个能被日历客户端识别的最小文件长这样:

BEGIN:VCALENDAR VERSION:2.0 PRODID:-//MySchedule//CN CALSCALE:GREGORIAN BEGIN:VEVENT UID:course-001@local DTSTAMP:20250101T000000Z DTSTART;TZID=Asia/Shanghai:20250224T080000 DTEND;TZID=Asia/Shanghai:20250224T084500 SUMMARY:高等数学 LOCATION:教一101 DESCRIPTION:教师:张三 END:VEVENT END:VCALENDAR

几个硬性要求:UID必须全局唯一,否则日历客户端会去重或报错;DTSTAMP是创建时间戳,用 UTC;DTSTART和DTEND带TZID参数指定时区。我一般用课程名加节次加星期拼 UID,保证同一门课不同时段不冲突。

3.2 用 icalendar 库组装事件

手拼字符串容易在换行和转义上翻车,用icalendar库更稳。它自动处理 75 字节折行和特殊字符转义。

from icalendar import Calendar, Event from datetime import datetime, timedelta import pytz tz = pytz.timezone("Asia/Shanghai") cal = Calendar() cal.add("prodid", "-//MySchedule//CN") cal.add("version", "2.0") def add_course(cal, name, teacher, location, start_dt, end_dt, weeks, weekday): """为一门课生成多个 VEVENT,每个上课周一个""" for week in weeks: # 计算该周对应的实际日期 event_date = start_dt + timedelta(weeks=week - 1) ev = Event() ev.add("uid", f"{name}-{weekday}-{week}@local") ev.add("summary", name) ev.add("location", location) ev.add("description", f"教师:{teacher}") ev.add("dtstart", tz.localize(event_date)) ev.add("dtend", tz.localize(event_date + (end_dt - start_dt))) cal.add_component(ev) return cal

逻辑说明:add_course接收课程基础信息和第一周的起始时间,然后按周次循环,每次用timedelta(weeks=week-1)推算出该周的实际日期。tz.localize把 naive datetime 转成带时区的时间,这一步不能省,否则日历客户端可能按 UTC 解释导致时间偏移 8 小时。参数上,start_dt传第一周该星期几的日期时间,end_dt传对应的下课时间,两者差值就是单次课时长。

3.3 用 RRULE 还是逐周展开

iCalendar 支持RRULE重复规则,理论上可以用一条规则表示"每周重复 16 次"。但课表有单双周、有调休、有期中停课,逐周展开反而更可控。我两种都试过,逐周展开生成的 ics 文件大一些(16 周就是 16 个 VEVENT),但导入后不会出现"某周该停课却还在"的玄学问题。

如果你坚持用 RRULE,写法是ev.add("rrule", {"freq": "weekly", "count": 16}),但单双周就得拆成两条规则,维护成本反而高。我的建议是:周次规则简单且无调休时用 RRULE,否则逐周展开。

# RRULE 写法示例(仅适用于无调休的连续周) ev.add("rrule", {"freq": "weekly", "count": len(weeks)})

3.4 写出文件与编码处理

最后一步把 Calendar 对象序列化写盘。注意用二进制模式写,icalendar的to_ical()返回 bytes。

with open("schedule.ics", "wb") as f: f.write(cal.to_ical())

如果课程名里有逗号、分号、反斜杠,icalendar会自动转义,不用手动处理。但如果你是自己拼字符串,这些字符必须转义,否则日历客户端解析会截断。写完文件后,我一般用文本编辑器打开看一眼,确认BEGIN:VEVENT和END:VEVENT成对出现。

4. 避坑与排查:那些让我重导三次日历的坑

4.1 时区没带导致所有课程偏移 8 小时

现象:导入日历后,早上 8 点的课显示成下午 4 点。原因:DTSTART写成了不带TZID的 UTC 时间,或者用了 naive datetime 被客户端按本地时区又转了一次。解决:所有时间字段统一用TZID=Asia/Shanghai,代码里用pytz.timezone("Asia/Shanghai").localize()处理。别用datetime.now()直接塞进去。

4.2 UID 重复导致事件被吞

现象:导入后课程数量比预期少,某些课不见了。原因:不同课程生成了相同的 UID,日历客户端按 UID 去重,只保留一条。解决:UID 里拼上课程名、星期、周次、节次,保证唯一。我习惯再加一个随机后缀或时间戳。

4.3 合并单元格读取为 None 导致课程丢失

现象:格子表里某些课程完全没进 ics。原因:pandas 读合并单元格时非左上角是 NaN,直接遍历会跳过。解决:用 openpyxl 的merged_cells.ranges建立映射,如 2.3 节所示。这个坑我踩过两次,第二次才想起来合并单元格这回事。

4.4 周次解析把"1-16周"当成字符串比较

现象:所有课程都只生成了一周的事件。原因:周次字段没做类型转换,"1-16"被当成字符串,int()转换失败后静默跳过。解决:解析前先str()再strip(),对每个分支做异常捕获并打印原始值,方便定位。我一般会在解析函数里加一行print(f"解析周次: {week_str} -> {weeks}"),调试完再删。

4.5 ics 文件换行符不兼容

现象:某些日历客户端提示文件格式错误。原因:Windows 下写文件默认\r\n,而 iCalendar 规范要求\r\n作为行分隔符——这本来是对的,但如果你用文本模式写入又经过某些编辑器转换,可能变成\n。解决:用wb二进制模式写,icalendar库会输出规范要求的\r\n。别用文本模式手动拼。

5. 进阶:批量转换、去重与日历订阅的实用技巧

5.1 一次转换整个学期的多份课表

如果你有多个学期的 xlsx,可以写一个批量入口,遍历目录下所有 xlsx,各自生成 ics,再合并成一个总日历。合并时注意 UID 要加学期前缀,否则跨学期同名课程会冲突。

import glob from icalendar import Calendar def merge_ics(input_files, output_file): """把多个 ics 合并成一个,UID 加文件前缀避免冲突""" master = Calendar() master.add("prodid", "-//MySchedule//CN") master.add("version", "2.0") for path in input_files: prefix = path.split("/")[-1].replace(".ics", "") with open(path, "rb") as f: sub = Calendar.from_ical(f.read()) for component in sub.walk("VEVENT"): component["UID"] = f"{prefix}-{component['UID']}" master.add_component(component) with open(output_file, "wb") as f: f.write(master.to_ical())

逻辑说明:Calendar.from_ical解析已有 ics,walk("VEVENT")遍历所有事件,给 UID 加前缀后塞进主日历。参数上,input_files是 ics 路径列表,output_file是合并结果。这个函数对"每学期一个文件、想在一个日历里看全部"的场景很实用。

5.2 用 EXDATE 处理调休和停课

遇到国庆调休或期中停课,可以在对应 VEVENT 上加EXDATE排除特定日期。逐周展开的写法下,直接不生成那一周的事件即可;RRULE 写法下才需要 EXDATE。

from datetime import datetime # 排除第 5 周(假设因调休停课) ev.add("exdate", tz.localize(datetime(2025, 3, 24, 8, 0)))

5.3 验证 ics 是否正确的三个检查点

写完 ics 别急着导入,先做三个检查。第一,用文本编辑器搜索BEGIN:VEVENT和END:VEVENT数量是否相等。第二,检查DTSTART和DTEND的时区参数是否都在。第三,随机挑一条事件,手动算一下日期是否落在正确的周次和星期上。我一般会写个校验脚本,统计事件总数和预期课程数是否一致。

from icalendar import Calendar with open("schedule.ics", "rb") as f: cal = Calendar.from_ical(f.read()) events = list(cal.walk("VEVENT")) print(f"事件总数: {len(events)}") for ev in events[:3]: print(ev["SUMMARY"], ev["DTSTART"].dt, ev["LOCATION"])

这段校验代码打印事件总数和前三条的摘要、开始时间、地点。如果总数对不上,回去查周次解析;如果时间偏移,查时区;如果地点为空,查合并单元格映射。三个检查点走完,基本能拦住 90% 的问题。

从那以后我每次转换完课表,都强制走一遍"事件数核对 + 时区抽查 + 首条事件手动验算"这三步,再导入日历。希望帮到你。

本文还有配套的精品资源,点击获取