feat: 添加根据课表和行政历自动生成ICS日历文件的功能

This commit is contained in:
2026-03-28 17:26:04 +08:00
commit dadb27771d
2 changed files with 1440 additions and 0 deletions

201
generate_ics.py Normal file
View File

@@ -0,0 +1,201 @@
import sys
import re
import argparse
from datetime import datetime, timedelta
import pandas as pd
def get_start_date_and_row(df_admin):
"""
在行政历中找到代表第一周的那一行,并解析出“第一周星期一”的日期。
"""
start_row = -1
for i in range(len(df_admin)):
val = str(df_admin.iloc[i, 0]).strip()
if val == '':
start_row = i
break
if start_row == -1:
raise Exception("无法在行政历中找到 '周次''' 的行")
# 在第一周这一行从第1列星期一到第7列星期日找到第一个带有完整日期的单元格
for j in range(1, 8):
cell = df_admin.iloc[start_row, j]
if pd.notna(cell):
if hasattr(cell, 'date'):
# 如果pandas解析为了datetime对象
date_val = cell.date()
return date_val - timedelta(days=j-1), start_row
elif isinstance(cell, str) and len(str(cell)) >= 10 and '-' in cell:
# 可能是形如 '2026-03-09' 的字符串
date_val = pd.to_datetime(cell[:10]).date()
return date_val - timedelta(days=j-1), start_row
raise Exception("在行政历的第一周中未找到明确的开学日期(带年月日格式的单元格)")
def is_holiday(df_admin, start_row, week_num, weekday_idx):
"""
检查某周某天是否为节假日(行政历的单元格内包含中文字符,如'4清明'
"""
row_idx = start_row + (week_num - 1)
if row_idx >= len(df_admin):
return False
col_idx = weekday_idx + 1 # 星期一从第1列开始
cell = df_admin.iloc[row_idx, col_idx]
if pd.isna(cell):
return False
cell_str = str(cell)
# 只要包含任意中文字符,就认为是节假日调休等特殊情况,跳过该日课程
if re.search(r'[\u4e00-\u9fa5]', cell_str):
return True
return False
def parse_schedule(df_schedule):
"""
解析课表,返回数组格式,每个元素表示一节课的基础信息
"""
col_names = list(df_schedule.columns)
schedule_data = []
for i in range(len(df_schedule)):
period_str = str(df_schedule.iloc[i, 0])
# 用正则提取例如 8:30~10:00 的时间段
match = re.search(r'(\d{1,2}:\d{2})\s*(?:~|-)\s*(\d{1,2}:\d{2})', period_str)
if not match:
continue
start_time_str = match.group(1).zfill(5)
end_time_str = match.group(2).zfill(5)
# 遍历星期一到星期五查找课程
for day_idx, day_name in enumerate(['星期一', '星期二', '星期三', '星期四', '星期五']):
# 动态获取星期的列索引
c_idx = -1
for idx, c in enumerate(col_names):
if day_name in str(c):
c_idx = idx
break
if c_idx == -1:
# 兼容处理:兜底默认索引
c_idx = day_idx + 1
cell = df_schedule.iloc[i, c_idx]
if pd.isna(cell):
continue
text = str(cell).strip()
if not text:
continue
# 以换行符切割 课程名称、班级、教室
lines = [l.strip() for l in text.split('\n') if l.strip()]
if not lines:
continue
title = lines[0]
# 中间的是描述(班级等),最后一行是地点
desc = " ".join(lines[1:-1]) if len(lines) >= 3 else ""
location = lines[-1] if len(lines) >= 2 else ""
schedule_data.append({
'weekday': day_idx,
'start_time': start_time_str,
'end_time': end_time_str,
'title': title,
'description': desc,
'location': location
})
return schedule_data
def generate_ics(start_date, start_row, df_admin, schedule_data, output_file, total_weeks=18):
"""
根据课表模板生成1-18周的日历事件跳过节假日
"""
ics_lines = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Schedule Generator//CN",
"CALSCALE:GREGORIAN",
"X-WR-TIMEZONE:Asia/Shanghai" # 设置全局国内时区
]
# 转换为 ICS 所需的时间格式: 20260309T083000
def fmt_dt(d, time_str):
return d.strftime("%Y%m%d") + "T" + time_str.replace(":", "") + "00"
uid_counter = 1
for week in range(1, total_weeks + 1):
for course in schedule_data:
wd = course['weekday']
# 计算该周该天上课的具体日期
event_date = start_date + timedelta(weeks=week-1, days=wd)
# 若这天在行政历里有中文(比如 “清明”),则表明是节假日,跳过
if is_holiday(df_admin, start_row, week, wd):
continue
dtstart = fmt_dt(event_date, course['start_time'])
dtend = fmt_dt(event_date, course['end_time'])
now_dt = datetime.now().strftime("%Y%m%dT%H%M%SZ")
ics_lines.extend([
"BEGIN:VEVENT",
f"UID:schevt_{week}_{wd}_{uid_counter}@schedule",
f"DTSTAMP:{now_dt}",
f"DTSTART;TZID=Asia/Shanghai:{dtstart}",
f"DTEND;TZID=Asia/Shanghai:{dtend}",
f"SUMMARY:{course['title']}",
f"LOCATION:{course['location']}",
f"DESCRIPTION:{course['description']}",
"END:VEVENT"
])
uid_counter += 1
ics_lines.append("END:VCALENDAR")
with open(output_file, 'w', encoding='utf-8') as f:
# ICS格式规范要求每行以 \r\n 结束
f.write("\r\n".join(ics_lines) + "\r\n")
print(f"==> 成功生成日历文件: {output_file}")
print(f"==> 共生成了 {uid_counter - 1} 节日历事件(已自动跳过行政历上标注的节假日)。")
def main():
parser = argparse.ArgumentParser(description="根据课表和行政历生成 .ics 日历文件")
parser.add_argument('schedule_file', help="课表Excel文件路径 (如 课表.xlsx)")
parser.add_argument('admin_cal_file', help="行政历Excel文件路径 (如 行政历.xls)")
parser.add_argument('-o', '--output', default="schedule.ics", help="输出的日历文件名,默认为 schedule.ics")
parser.add_argument('-w', '--weeks', type=int, default=18, help="默认生成前18周可通过本参数调整如 16")
args = parser.parse_args()
try:
# 1. 读取行政历和课表
print(f"读取行政历:{args.admin_cal_file}")
df_admin = pd.read_excel(args.admin_cal_file)
print(f"读取课表:{args.schedule_file}")
df_schedule = pd.read_excel(args.schedule_file)
# 2. 找到开学日期
start_date, start_row = get_start_date_and_row(df_admin)
print(f"解析到 第 {args.weeks} 周的开课周期第1周星期一为 {start_date}")
# 3. 提取基础课表模板
schedule_data = parse_schedule(df_schedule)
print(f"成功萃取 {len(schedule_data)} 条基础周课表数据")
# 4. 生成 ICS 日历
generate_ics(start_date, start_row, df_admin, schedule_data, args.output, total_weeks=args.weeks)
except Exception as e:
print(f"发生错误: {e}")
sys.exit(1)
if __name__ == "__main__":
main()