Skip to main content

xjtu-timetable-calendar

将西安交通大学 eHall「我的课表」中当前登录账号本人有权访问的个人课表,解析并导出为 标准 iCalendar(.ics)文件,可直接导入 Apple 日历、Google 日历、 Microsoft Outlook 等应用。

本项目为非官方工具,与西安交通大学无隶属关系。

本项目不绕过统一身份认证、不绕过权限控制、不访问其他学生的课表、 不包含任何个人课表数据、不包含任何登录凭据。 只处理当前登录用户正常能够访问的数据。


目录


这个工具解决什么问题

教务系统里的课表,本质上是一组规则:

某门课,星期五、第 1-2 节、第 1-8 教学周、在 A-1001 上课。

而日历应用需要的是事件:

2026-09-11(周五)08:00–09:50,示例课程甲,创新港校区 A-1001。

从前者到后者的转换有两个容易做错的地方:

  1. 教学周要换算成具体日期 —— 需要知道「第 1 教学周的星期一是哪天」。
  2. 节次要换算成具体钟点 —— 需要知道「那一天学校执行的是哪套作息」。 西安交大有冬/夏季作息调整,同一节课在不同时期钟点不同。

本项目把这两件事显式建模、分离处理,而不是把结果写死。


核心设计:为什么不是简单的「课表转 ICS」

一、课程节次不提前转成固定钟点

原始课表事实只有三样:

星期(weekday) + 节次(periods) + 教学周(weeks)

08:00 / 09:50 / 14:00 这些不属于课表事实,而属于 「该日期当天学校执行的是哪套作息规则」。

所以内部模型长这样:

CourseMeeting(
    course_name="示例课程甲",
    weekday=5,  # 星期五
    periods=[1, 2],  # 第 1-2 节
    weeks=[1, 2, 3, 4, 5, 6, 7, 8],
    location="A-1001",
    teacher="…",
)

而不是:

CourseMeeting(..., start_time="08:00", end_time="09:50")  # ✗ 错误设计

钟点在导出阶段由 ScheduleTable 现算。这样学期中途切换作息时, 数据模型完全不受影响。

⚠️ 澄清:这不是时区 DST

中国时区恒为 Asia/Shanghai,全年无夏令时。本项目从不读写系统时区 (你可能在东京或纽约运行本程序)。

真正变化的是**「第 N 节课对应的实际钟点」**。这是学校的作息规则问题, 与时区无关。两者必须彻底解耦。

导出的 ICS 内嵌 Asia/Shanghai 的 VTIMEZONE 定义(RFC 5545 §3.2.19 要求每个被引用的唯一 TZID 都要有对应时区组件),而不只是给一个 X-WR-TIMEZONE 提示。

二、不为整学期课程使用单个 RRULE

天真的做法是生成一条重复规则:

DTSTART:20260911T080000
RRULE:FREQ=WEEKLY;COUNT=16

这在西安交大是错的。 如果第 8 周后切换冬季作息,RRULE 展开出的后续事件 仍然沿用第一次的钟点,与实际课表不符。

学校还可能存在:单双周、不连续周、节假日停课、调课、补课、换教室、作息切换。

因此本项目采用**「每一次实际上课生成一个独立 VEVENT」**:

第 1 周 周五 -> VEVENT  (08:00-09:50)
第 2 周 周五 -> VEVENT  (08:00-09:50)
...
第 6 周 周五 -> VEVENT  (08:30-10:20)   ← 已切换冬季作息

一学期几百个事件对现代日历应用完全不是负担,换来的是逻辑可靠性。

三、UID 稳定,可做增量更新

UID 由课程标识 + 实际上课日期 + 节次派生(sha256),不含时间和地点:

  • 同一门课的同一节课反复导出得到相同 UID → 重新导入时原地更新,不产生重复;
  • 换教室或作息调整 → 视为同一事件的新版本,客户端自动覆盖;
  • 不同日期的课 → UID 不同,不会互相覆盖。

已知边界(为保持 v0.1.x 兼容,本版不改 UID 算法):课程名也参与 UID, 因此同一 course_id 的课程若被改名(如「大学英语(2)」→「大学英语Ⅱ」), 其事件会被视为新事件而非原事件的新版本。将来若引入 UID v2, 会配套一次性显式迁移方案,而不是直接改历史 UID。

四、SEQUENCE / LAST-MODIFIED:让「重新导入」真正生效

部分日历客户端在重新导入 ICS 时,靠 UID + SEQUENCE 判断事件是「没变」还是 「变了」。本项目每次导出都会写入这两个属性:

  • 默认行为:自动把输出文件的旧版本当作基线——内容未变的事件保留原 SEQUENCE / LAST-MODIFIED(客户端跳过,不产生噪音);内容变了(换教室、 调时间、改课)则 SEQUENCE 递增、LAST-MODIFIED 刷新,客户端原地更新;
  • --sequence-from PATH:显式指定基线 ICS(比如导出到新文件名时);
  • --no-sequence:关闭基线比对,全部按新增处理;
  • LAST-MODIFIED 恒为 UTC(RFC 5545 要求),SEQUENCE 基线解析失败时 显式指定会报错终止、自动探测则降级为警告。

数据流水线

eHall
  ↓  登录 / 会话(用户本人在浏览器完成认证)
  ↓  获取原始课表 JSON
  ↓  Parser                    eHall 字段 → 标准化模型(接口变更的唯一适配点)
  ↓  Course / CourseMeeting    (星期 + 节次 + 教学周)
  ↓  AcademicCalendar          教学周 → 实际日期
  ↓  ScheduleTable             日期 → 冬/夏季作息 → 节次 → 实际 HH:MM
  ↓  CalendarEvent             逐次实际上课
  ↓  iCalendar (.ics)

安装

需要 Python 3.11+。

方式一(推荐):从 PyPI 安装(v0.3.0 起已发布):

pip install xjtu-timetable-calendar

方式二:源码安装(需要跟踪最新开发进度时):

git clone https://github.com/XLJFZ/xjtu-timetable-calendar.git
cd xjtu-timetable-calendar

python -m venv .venv
# Windows
.venv\Scripts\activate
# macOS / Linux
source .venv/bin/activate

pip install -e .   # 开发用可编辑安装;只想使用则 `pip install .`

安装即自包含:eHall 接口定义随包分发在 src/xjtu_calendar/data/ehall_endpoints.json(通过 importlib.resources 读取),pip install . 与 pip install -e . 之后都无需手工复制任何配置文件。CI 会在干净虚拟环境里实测这一点。 需要覆盖时,把同名文件放到 ~/.xjtu-timetable-calendar/ehall_endpoints.json 即可。

可选依赖:

# 浏览器登录(login 子命令需要)
pip install playwright
# 若使用系统已装的 Edge / Chrome,无需再执行 playwright install

# HTTP 抓取路径(比浏览器路径轻量)
pip install httpx

提示:本项目会自动探测本机已安装的 Edge / Chrome,不需要额外下载浏览器内核。 如需指定浏览器,设置环境变量 XJTU_CALENDAR_BROWSER。

PyPI:包已正式发布(pip install xjtu-timetable-calendar),发布流程见「开发 → 发布」。


快速开始

1. 登录(只需一次)

python -m xjtu_calendar login

会弹出一个浏览器窗口。请你自己完成统一身份认证——本程序不读取、不记录、 不上传任何凭据。登录后进入「我的课表」页面,确认课表正常显示后回到终端按 Enter。

会话保存到 ~/.xjtu-timetable-calendar/session/,该目录已在 .gitignore 中排除, 登录态会跨进程保留。

2. 配置校历与作息表

这一步必须由你完成,本项目刻意不内置猜测值——见下一节。

3. 获取课表

python -m xjtu_calendar fetch --semester 2026-fall

HTTP 路径会同时抓考试安排(默认行为,见下文「考试安排」); 只想抓课表加 --no-exams。

4. 导出

python -m xjtu_calendar export --semester 2026-fall --output timetable.ics

产物默认同时含本学期的考试事件(单场绝对时刻、无提醒;座位号在 DESCRIPTION 里), --no-exams 可关闭——细节与已知限制见「考试安排」。

日历标题(X-WR-CALNAME,也就是客户端里显示的名字)默认按课表自动推导为 「西安交通大学课表 · 大三-上」这样的形式:年级来自课表的入学年级字段(NJDM, 按 学年起始年 − 入学年 + 1 换算,只在大一…大五范围内显示),上/下来自学期代码 YYYY-YYYY-N 的尾号。凡是不敢定的情况一律退回基础名「西安交通大学课表」—— 自定义的学期 key(如 2026-fall)、课表里没有 NJDM、年级分布出现并列,都不追加 后缀,宁可少信息也不给一个错的年级。跨年级(插班、跨年级选课、留级)时取多数,并在 日志里留一句提示。用 --name 显式命名则完全照用、不追加任何后缀。 年级只影响标题,不参与 UID 计算、也不写进事件,所以订阅端不会因为换了标题而重收课程。 至于已建立的订阅会不会跟着改显示名,各家客户端行为不同(有的会一直保留首次订阅时的名字, 需要重新添加才更新)——这一点本仓库未在真机上验证;可以确定的是事件的 UID 与 SEQUENCE 都不变,课程不会被重收一遍。

输出示例:

Semester:
  2026-2027 学年秋季学期

Courses:
  8

Meetings:
  126

Events:
  1248

Date range:
  2026-09-08 ~ 2026-12-22

Output:
  timetable.ics

把 timetable.ics 导入日历应用即可(Apple 日历可直接双击打开)。


URL 订阅(subscribe)

「快速开始」的交付方式是「拿到文件 → 手工导入」:课表一变就要重新导出、 重新导入,多端各来一遍。subscribe 增加一条 URL 订阅通道——把同一份 .ics 发布到你自己的 GitHub Pages(公开仓库 + 不可猜的 token 文件名), 日历客户端按 URL 订阅;之后每次课表调整只需本地跑一次 subscribe push, 客户端自动拉取,不再需要导入。

口径先说清:刷新是纯手动的——本工具不做定时任务、不常驻进程, 也不会替你 fetch。「手机 24 小时内自动呈现新状态」的前提是你自己 push 过。 一个学期一个 token、一个订阅 URL;多学期合并进同一 URL 是明确不做的事。

一次性设置

  1. 在 GitHub 建一个公开仓库(例如 cal-alice)。它里面只会出现一个 <token>.ics 文件。

  2. 开启 Pages:仓库 Settings → Pages → Deploy from branch, 分支选 cal、目录选 /(root)。 (cal 分支由首次 subscribe push 创建;下拉里选不到它时,先完成下面 两步再回来开启,顺序不影响结果。)

  3. 登记发布目标,生成 token:

    python -m xjtu_calendar subscribe init --repo https://github.com/<用户名>/cal-alice.git --semester 2026-fall
    

    init 会打印订阅 URL 和上面那条 Pages 指引。GitHub 远端的 URL 前缀自动推导; 非 GitHub 远端必须用 --url-base 显式给出公开访问前缀,否则 init 直接拒绝。 每学期一个状态文件(subscribe/subscribe-<学期>.json),重复 init 会报错, 重新登记需先删除该状态文件(换新 token)。

  4. 发布第一版:

    python -m xjtu_calendar subscribe push --semester 2026-fall
    
  5. 把订阅 URL 复制到日历客户端(入口见下)。Pages 首次生效有数分钟延迟, 可运行 subscribe status --verify 对 URL 做一次匿名 GET 自检。

日常更新

课表有变动时(建议先用 diff 确认):

python -m xjtu_calendar fetch --semester 2026-fall
python -m xjtu_calendar subscribe push --semester 2026-fall
  • push 与 export 共用同一条构建管线,但只暴露两个数据源参数: --input(可直接指定课表 JSON,默认用 fetch 缓存)与 --semester。
  • 内容与上次发布完全一致时,工具会尽量跳过推送;即便重复推送,效果也是 幂等的——远端始终只有一个提交,课表没变就不会产生新的变化。
  • raw 快照超过 7 天会警告「建议先 fetch」,但不阻断。
  • SEQUENCE 基线来自本地留底 subscribe/last-<学期>.ics:未变动的事件在客户端 保持安静,变动的事件原地更新——与重新导入的行为同一口径。
  • subscribe status 汇总 URL、上次发布时间、本地留底与上次发布记录是否一致、 快照年龄。

换 token(rotate)

把 URL 转发给了不该给的人、换了课表环境、或有任何泄露疑虑时:

python -m xjtu_calendar subscribe rotate --semester 2026-fall

换新 token 后 rotate 会顺手重新发布(若之前从未 push 过,则提示你跑 subscribe push)。新文件挂上分支、旧文件名从 tip 消失,旧 URL 自此 404; 所有日历客户端都要重新粘贴新 URL——这是换 token 的固有成本。 极端情况 rotate 后发布失败(网络/权限):token 已换、远端还挂着旧文件名, 补发成功前旧 URL 仍可读取——这正是需要尽快补发(或清理)的原因; 按终端提示修复后跑 subscribe push 补发即可。

隐私口径

  1. URL 即能力:token 是 32 位十六进制随机串,猜不到;但知道完整 URL 的 人就能匿名读取你的课表——转发 URL 等于授权。
  2. 历史零残留:发布分支永远是「无父孤儿单提交 + 强推」,课表的旧版本 不进 git 历史,仓库里翻不到你上周的课表。
  3. Pages 生效延迟数分钟:push 成功 ≠ 订阅 URL 立刻是新内容;客户端要等 Pages 构建完成后的下一次自动拉取才呈现新状态。status --verify 可确认。 rotate 之后这个窗口尤其明显:git 层此刻已经只剩新文件名(旧文件已从 分支 tip 消失),但 Pages/CDN 可能仍有 1~2 分钟继续供着旧内容——这期间 旧 URL 还读得到属正常现象,稍后重查即可,不要据此判定 rotate 失败。

token 状态文件按收紧的文件权限写盘(与 storage_state.json 同等待遇); 日志不落订阅 URL / token。

已知边界

  • 课程改名会表现为新事件:UID 含课程名(见「核心设计」的 UID 冻结边界), 改名前后是两个事件,订阅端与手工导入的表现一致;diff 能在 push 之前 先看到改名条目。
  • 换 token 后所有客户端要重贴 URL(见上)。
  • 部分客户端只能「导入」,不能「订阅」:国产 ROM 的系统日历(ColorOS 等) 通常只给一次性导入,事件落地后不再回源拉取,subscribe push 对它们无效; 这类设备上需要 DAVx⁵ 之类的同步器中转,见下文「常见客户端订阅入口」。
  • 发布通道只有静态 URL(以 GitHub Pages 为主);不做 CalDAV 直写、 不做中心托管服务。

⚠️ ~/.xjtu-timetable-calendar/subscribe/work-<学期>/ 是工具专用的 git 工作区:手工放进去的文件会被下一次 subscribe push 原样发布出去 (记住仓库是公开的!),此后分支 tip 不再满足「根目录仅一个 .ics」的 tree 护栏,后续发布被 fail-closed 拒绝,直到你清空该目录并自行清空 (或换名重建)那个分支。不要往这个目录放任何东西。

常见客户端订阅入口

  • Apple 日历:「通过订阅添加日历」(macOS 文件 → 添加日历 → 订阅; iPhone 设置 → 日历 → 添加日历)。
  • Google Calendar:订阅只能在网页版建立——打开 calendar.google.com,左侧「其他日历」 旁的「添加日历」→「通过 URL 创建 / 从 URL 添加」(各语言版本措辞略有差异)。 手机 App 里没有这个入口;不过订阅建立在 Google 服务器端,添加完成后任何登录 同一账户的设备(含手机 App)都会同步到。添加后进该日历的「设置和权限」,勾上 「自动移除旧事件」:本工具刻意不把时间和地点纳入 UID,所以换教室、调作息都能 原地更新;但课程名在 UID 里,改课名等于「旧事件消失 + 新事件出现」,不勾就会 在日历里残留旧课名的幽灵事件。
  • Android 国产 ROM(ColorOS / MIUI 等):多数没有订阅,只有导入。 系统日历的「通过 URL 导入日历」是把 .ics 里的事件一次性写进本机, 此后与那个 URL 再无关系,subscribe push 不会让它更新。分辨方法很简单: 订阅型入口带「上次同步时间 / 同步频率 / 立即同步」,导入型只有显示开关。 要在这类 ROM 上拿到自动更新,用 DAVx⁵(F-Droid 或其 GitHub Releases 提供 APK,国内机型无 Google Play 需侧载)把订阅 URL 挂成一个会定时同步的 账户,事件仍然显示在系统日历里。两个实测坑:
    1. 必须给 DAVx⁵ 关闭电池优化并允许自启动,否则后台查杀会造成"装好了 但几小时不动"的假故障;
    2. 一次性导入产生的独立日历,在日历 App 里常常只有「隐藏」没有「删除」—— 真正的删除入口在 设置 → 用户与账号 → 账户与同步,移除导入产生的那个 账户即可。不要用「设置 → 应用 → 日历 → 清除数据」,那会连你自己的 日程一起清空。

考试安排

fetch 在抓课表的同时抓 eHall 的「我的考试安排」,export / subscribe push 把考试 事件并进同一份 .ics、同一个订阅 URL——不需要第二份日历,期末也不必再手抄。 考试事件是单场绝对时刻的 VEVENT(带 DTSTART;TZID=Asia/Shanghai),不用 RRULE、 不写 VALARM(提醒交给客户端设置);SUMMARY 为「课程名(类型词)」,类型词取自考试 名称原文(实测见过 期中/结课/期末考试;出现「补考」「缓考」也原样带出,不做白名单)。

默认包含,--no-exams 关闭

fetch / export / subscribe push / subscribe rotate 四条路径都接受 --no-exams: fetch 侧是不抓,export/push/rotate 侧是不并入。两种情况本地快照 raw/exams-<学期>.json 都不删除——回滚只是「这个功能没用」,不是抹掉数据; 重新打开后无需重抓。

只有 HTTP 路径能拿到考试

只有 fetch 的 HTTP 分支会抓考试,另外两条路径明确不抓(日志会说明原因, 不是静默失败):

  • --from-file:离线导入,没有会话,考试安排需要在线获取;
  • --source browser:浏览器拦截按课表关键词过滤捕获的 URL(考试接口路径 wdksap 不在关键词表内),且只导航课表应用页面。

要拿到考试,就用默认的 HTTP 路径 fetch。若考试端点缺失或配置里仍是占位符, fetch 只跳过考试并记 warning,课表照常落盘、照常导出。

三态判定:状态未知绝不覆盖已有快照

考试接口「没有考试」和「查询没成功」返回的行数都是空,光看 rows 区分不了, 所以判定落在响应的 extParams.code 上,分三态:

状态 行为
有考试 写快照,导出考试事件
确认无考试 写空快照(覆盖旧的),日志说明
状态未知(非 200 / 会话失效 / 登录页 / extParams 判不出 / 抓取抛异常) 不覆盖已有快照。有旧快照 → 本次继续沿用,warning 里报出那份快照是哪一天的;没有旧快照 → warning 明说「本地没有考试快照,本次导出不含考试」

两条不变量:

  • 考试侧任何失败都不会让 fetch / export 非零退出——课程是主功能,考试是增量;
  • 状态未知时宁可沿用旧数据也不覆盖:误覆盖会把已有考试信息抹掉, 多留一条 warning 最坏只是「考试晚一天更新」,方向上不可逆的操作选后者。

翻页护栏:响应行数与 totalSize 不一致时记 warning(提示可能超过一页), 但照常落盘、不阻断——实测一页够用,这条是为将来超过一页时留警痕。

已发布的考试事件,关掉开关不会撤销(v1 已知限制)

render_ics 只发 method: PUBLISH 的普通事件,全仓没有 STATUS:CANCELLED / METHOD:CANCEL 通路。所以:

  • 已经发布出去的考试事件,不会因 --no-exams 或考试被取消而从订阅端消失。 客户端对「URL 内容里少了一条事件」的处理各家不一致(Google/Outlook 多为保留), 必要时请在客户端手动删除旧考试事件。
  • 「宁缺毋滥」只保证不新增、不保证撤销已发布的。真正的取消通路 (发布后保留一段 STATUS:CANCELLED)留到 v2 单独立项。
  • 考试 UID 依据接口行 ID WID(缺失时依次降级到 KSRWID、再到内容组合, 降级有日志)。改期/换考场后服务器是否换 WID 尚未验证——若换, 改期会表现为「取消 + 新增」而非原地更新,由 diff 的「考试变更」小节先看到。

座位号在哪看、diff 能报什么

座位号在事件的 DESCRIPTION 里(与考试全称、课程号、学分、主考教师并列), 不在 LOCATION;--from-date / --to-date 会连考试一起裁剪,不特殊放行。

diff 新增「考试变更」小节,与课程小节并列,报五类:新增 / 取消 / 时间变更 / 教室变更 / 座位变更。被取消的考试在报告末尾会再提示一次「不会从已订阅的日历里 自动消失」。考试改名不会出现在报告里(上面五类是唯一口径)——这与课程侧不同: 课程改名会报出来(按时段变化呈现),而考试行只要还带着同一个 WID,改名在 diff 里是静默的。收尾提示「UID 稳定,原地更新」同样只对携带 WID 的行成立; 降级到内容组合键的行改了日期会被视为新事件。

数据与日志隐私

  • 考试快照 raw/exams-*.json 与 .prev.json 含学号、姓名、教师姓名, 只落在本地数据目录(已被 .gitignore 排除),并且和课表快照一样以收紧权限 (private=True)原子落盘;
  • 日志不输出原始响应体;主考教师姓名(ZJJSXM)与 SJBH 已加入 按键打码名单。

考试快照的陈旧提示

export 并入考试时,若考试快照落后于同学期课表快照超过 7 天,会给一条 warning (「考试数据来自 X 日的课表同期快照」——考试排期跟着课表一起变,比较口径刻意取 相对值而非「距今多少天」)。--no-exams 不并入考试,此提示自然不出现。


必须由你配置的内容

本项目刻意不编造任何学校信息。以下两项必须由你提供:

1. 教学日历(校历)

位置:~/.xjtu-timetable-calendar/semesters/<学期标识>.json

模板:examples/academic_calendar.example.json

{
  "semester": {
    "key": "2026-fall",
    "name": "2026-2027 学年秋季学期",
    "first_week_monday": "2026-09-07",
    "total_weeks": 16
  },
  "excluded_dates": ["2026-10-01"],
  "overrides": {
    "2026-10-10": {
      "source_date": "2026-10-06",
      "note": "(示例)本日按 10-06 的课表上课"
    }
  }
}

📌 上面这段是格式示例,其中的日期与 total_weeks 都不是真实校历数据, 必须替换成你从官方校历抄录的值。

关键字段是 first_week_monday —— 第 1 教学周星期一的日期。

⚠️ 注意:第 1 教学周的星期一不等于开学报到日,也不一定是学期首日。 请从教务处发布的官方校历确认后填写。填错会导致全部事件系统性偏移。

total_weeks 可以省略。 它是可选的校验上限,不决定生成多少周 —— 导出的周次完全来自课表自身:

配置 行为
省略 / null 按课表自身的周次原样展开,不做上限过滤
填了数字,课表出现更大周次 直接报错终止(fail-closed),并提示核对校历
填了数字,课表周次都在范围内 正常导出

⚠️ 没有官方依据时宁可留空。 填一个猜来的数字,一旦真实课表超出它, 导出会报错;反过来若工具静默丢弃越界周次,你会拿到一份 看起来正常、实则缺课的日历 —— 后者危险得多,所以本工具选择报错。

excluded_dates 是全校停课日(节假日),overrides 是调课日。 两者都可以从教务处的停课/调课通知自动解析合并 —— 见下文 3. 停课/调课通知自动获取; 也可以手工从校历抄录。

overrides 支持两类校历例外。 工具不把节假日、调课混为一谈:

字段 含义
excluded_dates 停课日:该日不生成任何事件。
overrides[date].source_date 调课日:该日原课程停上,改为按 source_date 所在教学周 + 星期的课表上课。
overrides[date].cancel 仅停课(不调入其他课表)。
overrides[date].note / location 附加备注 / 覆盖教室。

调课日语义(对应学校通知里的「某日上原本某日的课」):

"overrides": {
  "2026-10-10": { "source_date": "2026-10-06" }
}
  • 10-10 原有的星期六课程停上;
  • 生成的事件来自 10-06 所在教学周 + 星期二 的课程安排 (单双周 / 周次位掩码按 source 教学周解释,不按 10-10 自己的周次);
  • 事件的日期是 10-10 本身,钟点按 10-10 当天适用作息解析 (例如冬春季作息切换后补课自动用新钟点);
  • UID 与普通事件同一规则,稳定可复现。

配置校验(fail-closed):source_date 等于自身、落在学期范围外、 目标日又在 excluded_dates 中、或同一日期残留 unsupported_adjustments 旧声明 —— 加载时即报错。

unsupported_adjustments(可选):声明「已知但本工具无法表达」的调课。

"unsupported_adjustments": [
  { "date": "2026-11-14", "description": "运动会停课,补课安排未公布" }
]

能用 overrides[source_date] 表达的调课不要写在这里 —— 同一日期两种声明并存会在加载时报错。这个字段只用于声明真正表达不了的 安排(例如「某日按某周几课表上课,但具体哪周未知」),导出行为是:

  • 默认直接报错,逐条列出调课明细 —— 绝不静默产出缺课的日历;
  • 你确认可以接受缺失后,加 --allow-unsupported-adjustments 继续导出(每条都会以警告形式再次出现)。

description 必填:没有说明的「无法表达」只会让人困惑。

2. 作息表

位置:~/.xjtu-timetable-calendar/schedules/schedule.json

模板:examples/schedule.example.json

{
  "profiles": {
    "xjtu-summer-autumn": {
      "name": "夏秋季作息(05-01 起)",
      "periods": {
        "1": ["08:00", "08:50"],
        "2": ["09:00", "09:50"],
        "3": ["10:10", "11:00"],
        "4": ["11:10", "12:00"],
        "5": ["14:30", "15:20"],
        "6": ["15:30", "16:20"],
        "7": ["16:40", "17:30"],
        "8": ["17:40", "18:30"],
        "9": ["19:40", "20:30"],
        "10": ["20:40", "21:30"]
      }
    },
    "xjtu-winter-spring": {
      "name": "冬春季作息(10-01 起)",
      "periods": {
        "1": ["08:00", "08:50"],
        "2": ["09:00", "09:50"],
        "3": ["10:10", "11:00"],
        "4": ["11:10", "12:00"],
        "5": ["14:00", "14:50"],
        "6": ["15:00", "15:50"],
        "7": ["16:10", "17:00"],
        "8": ["17:10", "18:00"],
        "9": ["19:10", "20:00"],
        "10": ["20:10", "21:00"]
      }
    }
  },
  "periods": [
    { "start": "2026-05-01", "end": "2026-09-30", "profile": "xjtu-summer-autumn" },
    { "start": "2026-10-01", "end": "2027-04-30", "profile": "xjtu-winter-spring" }
  ]
}
  • profiles:每套作息定义「第 N 节 → 起止 HH:MM」
  • periods:每套作息的生效日期区间(闭区间)

📌 上面这套节次时间按西安交通大学当前公开作息时间填写(夏秋季 05-01 起、 冬春季 10-01 起)。学校如调整作息,请以最新官方通知为准。 这张表本身可以由 schedule 子命令从教务处官方页自动解析并合并 —— 见下文 4. 作息表自动获取。

注意生效区间是按切换日划分,不是按学期:学校的作息切换不是时区夏令时 (Asia/Shanghai 全年不变),切换点会落在学期中间——一个学期完全可能跨越两套作息。 把 profile 绑死在学期上会导致学期中途的钟点整体错位。

缺少配置时的行为:明确报错并给出提示,不会猜测或回退到某个默认值。 这是刻意的设计——错误的静默默认值比明确的报错危险得多。

3. 停课/调课通知自动获取(notice 子命令,可选)

教务处会在假期前后发布结构化的停课/调课通知(日期 | 周次星期 | 调休及教学安排)。 notice 子命令可以解析这类通知页,并(在你确认后)把停课日与调课日 合并进学期配置 —— 只新增,绝不覆盖你已有的条目:

# 预览:只打印解析结果,不写任何文件
python -m xjtu_calendar notice --url <通知页地址> --semester 2026-2027-1

# 也可以离线解析本地保存的 HTML
python -m xjtu_calendar notice --from-file notice.html --semester 2026-2027-1

# 确认无误后真正合并进学期配置
python -m xjtu_calendar notice --url <通知页地址> --semester 2026-2027-1 --apply

解析规则与安全边界:

  • 通知里的「第 N 周星期 X」会与配置里的 first_week_monday 交叉校验 (日期对不上即报错),借此同时解决「10 月 1 日属于哪一年」这类年份推断;
  • 「某日停课、上某日(第 N 周星期 X)的课」→ 识别为调课(overrides[source_date]);
  • 「停课 / 放假 / 法定节假日 / 调休」→ 识别为停课日(excluded_dates);
  • 识别不了的行不会自动写入,而是逐条列出请你人工确认;
  • fail-closed:只要存在任何无法可靠解析的行,--apply 就整体拒绝写入 (配置文件字节级不变),而不是「写进去能解析的那一半」; 预览模式仍可运行,但会明确标注结果不完整、不可直接应用;
  • 页面结构变化导致「找得到表头却一行都解析不出」时直接报错, 绝不把「解析不到」伪装成「本次没有调课」;
  • 写回前 / 写回后都会用正式领域模型重新校验配置, 并以同目录临时文件 + 原子替换落盘,不会留下截断的 JSON;
  • --apply 合并是只新增操作:配置中已有的值一律保留,解析结果与现有 条目冲突时会给出警告。

📌 通知页是普通公开网页,notice 只发匿名 GET 请求,不携带任何登录凭据, 且 --url 只接受 http/https 地址(本地保存的 HTML 请走 --from-file)。

4. 作息表自动获取(schedule 子命令,可选)

教务处公开页「学生作息时间表」(due.xjtu.edu.cn/xxfw/zxsj.htm)以表格列出 夏/冬两套作息第 1~10 节课的起止钟点,切换点就写在列表头原文里 (「5月1日开始实行」「10月1日开始实行」)。schedule 自动解析并(在你确认后) 合并进 schedules/schedule.json:

# 预览:抓取官方页打印解析结果,不写任何文件
python -m xjtu_calendar schedule

# 离线解析本地保存的 HTML
python -m xjtu_calendar schedule --from-file zxsj.html

# 合并进作息配置(生效区间的覆盖范围来自学期校历)
python -m xjtu_calendar schedule --semester 2026-2027-1 --apply

解析规则与安全边界(与 notice 同口径):

  • 只把「第N节课」行提取为 profiles;早餐/午餐/午休/预备铃等行仅作说明、不写入; 新增的行类别会进 unresolved 并让 --apply 整体拒绝写入——不做静默猜测;
  • 切换点取自列表头原文(月-日),年份覆盖区间来自学期校历 (first_week_monday ~ 学期末);校历没有 total_weeks / end_date 时 --apply 拒绝——不猜截止日期;
  • 合并只新增:已有 profile 键 / 已有区间一律保留现值并警告; 无任何新增时文件字节不变(幂等,可重复执行);
  • 写前 / 写后都过 ScheduleTable 正式校验(区间重叠、引用未定义 → 拒绝), 并以原子替换落盘;
  • 页面是普通公开网页,匿名 GET、无凭据,且 --url 只接受 http/https。

命令参考

命令 作用
login [--force] 浏览器手动登录,保存本地会话
status 查看会话与配置状态(不发起网络请求)
fetch [--semester S] [--source auto|http|browser] [--from-file F] [--no-exams] 获取课表原始 JSON(HTTP 路径同时抓考试安排;覆盖前自动把上一份轮转为 *.prev.json,作为 diff 基线)
diff [--semester S] [--old F] [--new F] 比对新旧课表快照:新增/删除课程、时段增减、周次/教室/教师变化;并列「考试变更」小节(新增/取消/时间/教室/座位;纯本地;--old/--new 只对课表生效)
export [--semester S] [-o OUT] [--input F] [--name N] [--calendar-config F] [--schedule-config F] [--from-date D] [--to-date D] [--sequence-from ICS] [--no-sequence] [--no-exams] 生成 .ics(默认并入考试事件;--name 不给时标题按课表自动推导,见上文「导出」)
notice --url U | --from-file F [--semester S] [--apply] 解析停课/调课通知,预览或合并进学期配置
schedule [--url U | --from-file F] [--semester S] [--apply] 解析官方「学生作息时间表」页,预览或合并进作息表配置
subscribe init --repo U [--branch B] [--url-base U] | subscribe push [--input F] [--no-exams] | subscribe rotate [--no-exams] | subscribe status [--verify](均可带 --semester S) 把 .ics 发布到自己的 GitHub Pages,日历客户端按 URL 订阅(见上文「URL 订阅」;push/rotate 默认并入考试,--no-exams 同样生效)
inspect [--input F] [-o OUT] 对原始 JSON 做脱敏结构分析

全局参数:--debug(详细异常)、-q(静默)、--version

退出码

码 含义
0 成功
1 一般错误(网络、解析、导出)
2 未登录,需要先跑 login
3 登录态已失效,需要重新 login
4 当前账号无权限访问该资源
130 用户中断

环境变量

变量 作用
XJTU_EHALL_BASE eHall 域名,默认 https://ehall.xjtu.edu.cn
XJTU_CALENDAR_HOME 数据目录,默认 ~/.xjtu-timetable-calendar
XJTU_CALENDAR_BROWSER 强制指定浏览器可执行文件
XJTU_SEMESTER 默认学期标识

项目结构

xjtu-timetable-calendar/
├── README.md
├── LICENSE
├── .gitignore
├── pyproject.toml
├── CHANGELOG.md                        # 版本变更记录
├── docs/
│   ├── design-v0.1.md                  # 技术设计 v0.1(设计基准与漂移记录)
│   ├── design/                         # 功能设计规格(spec,评审后的权威口径)
│   └── plans/                          # 对应实现计划(plan,含任务拆分与验收口径)
├── .github/workflows/
│   └── ci.yml                          # CI:pytest / mypy / ruff + wheel 自包含自检
├── config/
│   └── ehall_endpoints.example.json    # 接口定义模板(真实定义随包分发)
├── src/xjtu_calendar/
│   ├── __init__.py                     # __version__:importlib.metadata + pyproject 回退
│   ├── __main__.py                     # python -m xjtu_calendar
│   ├── cli.py                          # 命令行入口
│   ├── config.py                       # 集中配置(URL / appId / 目录)
│   ├── errors.py                       # 异常体系 + 退出码
│   ├── fileutil.py                     # 原子替换写入(校历配置 / 作息表 / 会话文件共用)
│   ├── logging_setup.py                # 日志与脱敏工具
│   ├── models.py                       # Semester / Course / CourseMeeting / CalendarEvent
│   ├── weeks.py                        # 教学周文本解析(含 SKZC 位掩码)
│   ├── periods.py                      # 节次文本解析
│   ├── academic_calendar.py            # 教学周 → 日期
│   ├── schedules.py                    # 作息表 / 节次 → 实际时间
│   ├── parser.py                       # eHall JSON → 标准化模型(接口变更唯一适配点)
│   ├── exams.py                        # 考试安排:解析 / 三态判定 / 事件构建 / 考试变更比对
│   ├── auth.py                         # 会话管理
│   ├── fetcher.py                      # 课表抓取(HTTP / 浏览器双路径)
│   ├── exporter.py                     # → CalendarEvent → .ics
│   ├── naming.py                       # 日历标题推导(大X-上/下;不确定就退回基础名)
│   ├── diff.py                         # 新旧课表快照比对(调课检测)
│   ├── notices.py                      # 停课/调课通知解析(HTML 表格 → 配置条目)
│   ├── schedule_notice.py              # 官方作息页解析(表格 → schedule.json 合并方案)
│   ├── sequence.py                     # SEQUENCE / LAST-MODIFIED 版本管理
│   ├── subscribe.py                    # URL 订阅:状态文件 + git 孤儿提交发布
│   ├── timeutil.py                     # 时区常量(Asia/Shanghai)
│   └── data/
│       └── ehall_endpoints.json        # 接口定义(真实观测,随 wheel 分发)
├── scripts/
│   └── probe_ehall.py                  # eHall 接口分析工具
├── tests/
│   ├── conftest.py
│   ├── test_weeks.py                   # 连续/离散/混合/单双周/全角/位掩码
│   ├── test_periods.py                 # 连排/逗号/单节/括号噪声
│   ├── test_calendar.py                # 日期换算 / 作息解析 / 停课覆盖
│   ├── test_parser.py                  # 字段映射 / 容错 / 脱敏
│   ├── test_parser_real.py             # 基于真实响应脱敏固件的校准测试
│   ├── test_fetcher.py                 # 响应分类 / 端点校验(MockTransport,不发真实请求)
│   ├── test_exporter.py                # UID / 时间 / ICS 合规
│   ├── test_makeup.py                  # 停课日 / 调课日(source_date 语义)
│   ├── test_packaging.py               # 版本号单一来源 + 包内资源随 wheel 分发
│   ├── test_sequence.py                # SEQUENCE / LAST-MODIFIED 版本管理
│   ├── test_notices.py                 # 通知解析 / 周次交叉校验 / 只新增合并
│   ├── test_schedule_notice.py         # 作息页解析 / 区间铺排 / 只新增合并 / 幂等
│   ├── test_cli_schedule.py            # schedule 子命令 CLI 级 e2e(fail-closed 契约)
│   ├── test_diff.py                    # 课表快照比对(课程/时段/字段三级口径)
│   ├── test_cli_diff.py                # diff 子命令 e2e + fetch 快照轮转联动
│   ├── test_cli_notice.py              # notice 子命令 CLI 级 e2e
│   ├── test_exams.py                   # 考试解析(四种实测时刻形态)/ UID 降级 / 红线
│   ├── test_exams_fetch.py             # 三态落盘:状态未知绝不覆盖已有快照
│   ├── test_exporter_exams.py          # 课程+考试合并 / 全局 UID 唯一性 / SEQUENCE 基线
│   ├── test_diff_exams.py              # 考试变更五类(新增/取消/时间/教室/座位)
│   ├── test_cli_exams.py               # --no-exams 在 fetch/export/push/rotate 的透传
│   └── fixtures/
│       ├── timetable_sample.json           # 手工构造的脱敏样例
│       ├── ehall_timetable_real_sanitized.json  # 真实结构脱敏固件
│       ├── notice_holiday_2026.html        # 真实停课/调课通知固件(样式已剥离)
│       └── schedule_zxsj.html              # 官方「学生作息时间表」页固件(2026-10-07 抓取)
└── examples/
    ├── academic_calendar.example.json
    └── schedule.example.json

数据模型

CourseMeeting —— 项目的中心

@dataclass(frozen=True)
class CourseMeeting:
    course_id: str | None
    course_name: str
    weekday: int  # 1 = 星期一 … 7 = 星期日
    periods: list[int]  # 节次**序号**,不是时间
    weeks: list[int]  # 教学周
    teacher: str | None
    location: str | None
    campus: str | None
    raw_week_text: str | None  # 原始文本留档,便于排查
    raw_period_text: str | None

不存在任何钟点字段。 这一点由测试 test_course_meeting_has_no_time_fields 守住。

支持的周次写法

输入 输出
1-16周 [1..16]
1,3,5,7周 [1,3,5,7]
1-4,7-12周 [1,2,3,4,7..12]
2,5-8周 [2,5,6,7,8]
1-16周(单) [1,3,5,…,15]
1-16周(双) [2,4,6,…,16]
单周 / 双周 全学期奇数周 / 偶数周
第1-8周 / 1-8 / 1~8周 [1..8]

支持的节次写法

1-2节 · 3-4节 · 5-8节 · 1,2节 · 1-4节 · 1-2,5-6节 · 1-2节 · 1~2节


隐私与安全

绝不提交的内容

.gitignore 已排除:

  • 会话文件:.cookies/、state_*.json、storage_state.json、session*.json、profile/、.env
  • 个人数据:timetable*.json、schedule*.json、*.ics
  • 运行期目录:.xjtu-timetable-calendar/

.ics 文件也在排除列表里。 它包含你的完整课程表、教师姓名和上课地点。

日志脱敏

日志绝不输出 Cookie、Authorization、ticket、token、学号、姓名、 以及完整统一身份认证 URL 的参数。

logging_setup.redact() / redact_url() 提供递归脱敏,供需要记录 URL 或响应结构时使用。

网络行为准则

本项目的实现严格遵守:

  1. 只访问当前账号正常有权限的接口
  2. 不绕过统一身份认证
  3. 不绕过权限控制
  4. 不枚举学号
  5. 不枚举课程
  6. 不抓取其他用户数据
  7. 不使用高并发(所有请求串行)
  8. 不暴力重试 401 / 403
  9. API 异常采用有限次数指数退避
  10. 不把 Cookie 写入日志
  11. 不把 token 打印到终端

遇到 401 / 403 会明确提示「登录状态失效」或「当前账号无权限」, 而不是继续重试。

订阅 URL 的风险口径(subscribe)

subscribe 是对上述准则的例外通道,风险模型也不同:它把你的课表发布到 一个匿名可读的公开地址上。安全边界只有一层——URL 里的随机 token 猜不到, 但知道 URL 就能读,转发即授权。为此实现上做了配套约束:发布分支恒为 孤儿单提交(课表旧版本不进 git 历史)、状态文件收紧权限写盘、日志不含 URL/token、强推前有 tree 护栏(拒绝覆盖非本工具产物)。 完整口径与操作方式见上文「URL 订阅(subscribe)」。

测试数据

tests/fixtures/ 中的全部数据均为虚构的脱敏样例,不含任何真实个人信息:

文件 来源 说明
timetable_sample.json 手工构造 英文兼容键路径的样例,课程名为 示例课程甲/乙/丙/丁,地点为 A-1001…A-1004
ehall_timetable_real_sanitized.json 真实响应脱敏 层级/键名/类型/SKZC 周次位掩码为真实结构;姓名、学号、教师、教室、课程名、机构代码全部替换为占位值

文档(README、docstring)中出现的课程名与地点同样使用上述同一套占位值, 不引用任何真实课程或教室。


开发

pip install -e ".[dev]"
pytest                      # 全量测试(含 doctest;条数以本地运行为准)
pytest --cov=xjtu_calendar  # 带覆盖率
ruff check .                # 代码风格(含 scripts/ 与 tests/)
mypy src                    # 类型检查(strict)

上面四条在 main 上全部为零输出:ruff 无告警、mypy 无错误、测试全通过。 仓库已配置 GitHub Actions(.github/workflows/ci.yml):每次 push / PR 在 Python 3.11 / 3.12 / 3.13 上跑上述三条;另有一个 wheel 任务会真正构建 wheel 并在干净虚拟环境里安装,验证 pip install . 得到的包是自包含的 (含包内默认接口定义、CLI 可运行)—— 避免「从源码目录碰巧找到配置」的假通过。

版本变更记录在 CHANGELOG.md;版本号单一来源为 pyproject.toml (importlib.metadata 读取,未安装时回退解析同一份文件,代码里没有第二处常量)。

升版后请重装 editable 包。 importlib.metadata 读的是安装时生成的元数据, 只改 pyproject.toml 而不重装,--version 仍会报旧版本 (回退分支只在「压根没装」时才会走):

python -m pip install -e . --no-deps

另有一个易踩的坑:python -m build 会在源码树里留下 src/*.egg-info, 而 pytest 配了 pythonpath = ["src"],会让它先于 site-packages 被 importlib.metadata 命中。若它滞后于 pyproject.toml(升版后既没重装、 也没重建),就会出现「命令行 --version 说 0.2.0,import xjtu_calendar 说 0.1.1」这种同一份代码两个答案——tests/test_packaging.py 的版本守卫 会因此变红。它只是构建残留(*.egg-info/ 已在 .gitignore 内,且 editable 安装实际靠 site-packages 的 __editable__*.pth,并不依赖它), 处置任选其一:重装 editable、python -m build 重建、或直接把该目录移走。 该目录不要提交,也不需要改测试去迁就它。

发布

  • GitHub Release 全自动挂资产:Release 一旦发布(published), .github/workflows/release.yml 构建 sdist + wheel、过 twine check, 并上传到该 Release。CI 的质量门禁仍由 ci.yml 独立负责,两者互不干扰。
  • PyPI 走手动可信发布:.github/workflows/publish-pypi.yml 需在 Actions 页手动触发(workflow_dispatch),使用 OIDC trusted publishing, 仓库不保存任何 token。一次性前置已完成(2026-10-07):已在 pypi.org 账号级 Publishing 页登记待定发布者(Project name xjtu-timetable-calendar、Publisher type GitHub Actions、owner XLJFZ、repository xjtu-timetable-calendar、workflow name publish-pypi.yml、Environment 留空),v0.3.0 首传已自动建项目并绑定。 之后每次发布只需:合入升版提交 → 打 tag → 发 GitHub Release → 在 Actions 里点 Run workflow。PyPI 拒绝重复版本号,误发有保险。
  • 版本号仍是 pyproject.toml 单一来源;升版后记得按上文重装 editable。

若你在自己的分支上看到大量 RUF001/002/003,那是规则的已知误报 —— 它把中文全角标点当成「易混淆 Unicode」,而本项目的 docstring、注释与 面向用户的文案本身就是中文。这些规则已在 pyproject.toml 里显式关闭, 请不要为了通过 lint 去改写全角标点。

测试覆盖重点

  • 周次解析:连续 / 离散 / 混合 / 单双周 / 全角 / 无「周」字 / 去重乱序 / 越界报错 / SKZC 位掩码
  • 节次解析:连排 / 逗号 / 单节 / 全角 / 括号噪声 / 结构化 KSJC+JSJC 优先
  • 日期换算:周一与周日边界、跨周、逆向换算
  • 作息解析:夏季→summer、冬季→winter、未配置时明确报错
  • UID:重复导出不变、不同日期不同、不含地点(换教室不产生新事件)
  • SEQUENCE / LAST-MODIFIED:无基线归零、内容未变保留、内容变更递增、UTC 规范、基线解析容错
  • 通知解析:表格网格展开(rowspan/colspan)、周次交叉校验、停课/调课分类、只新增合并、无法识别行不落盘
  • 作息获取:官方作息页网格解析(第 1~10 节课、切换点取自表头原文)、非教学行仅作说明、 未知行类别进 unresolved 并拒写、生效区间跨切换点/跨年铺排、幂等(无新增时文件字节不变)
  • ICS:必需属性齐全、课程 VEVENT 不使用 RRULE(时区组件内部的规则不受此约束)、 CRLF、时区 Asia/Shanghai 且内嵌 VTIMEZONE(TZID 引用完整)、特殊字符转义
  • 接口层:响应分类(含「200 + 登录页 HTML」判为会话失效)、占位符端点拒绝、401/403 不重试
  • 考试安排:四种实测时刻形态解析 / 三态判定与「状态未知不覆盖快照」/ UID 降级链与同日两场不撞 / 考试事件必须带 TZID 的 DATE-TIME(红线)/ 课程+考试全局 UID 唯一性 / --no-exams 四路径透传 / 考试变更五类

eHall 接口分析

python scripts/probe_ehall.py

打开浏览器让你手动登录并进入课表页,脚本记录前端自己调用的结构化 JSON 接口, 输出脱敏结构报告到 _notes/ehall-probe.md(该目录已排除)。

包内默认接口定义(src/xjtu_calendar/data/ehall_endpoints.json,随 wheel 分发) 中已填入经真实网络观测确认的端点:

端点 方法 路径 作用
current_semester POST /jwapp/sys/wdkb/modules/jshkcb/dqxnxq.do 当前学年学期(无参数)
timetable POST /jwapp/sys/wdkb/modules/xskcb/xskcb.do 学生课表(form 参数 XNXQDM)
exam_schedule POST /jwapp/sys/studentWdksapApp/modules/wdksap/wdksap.do 我的考试安排(form 参数 XNXQDM;成功标志在 extParams.code == 1)

因此 fetch --source http 可直接走轻量路径。若端点配置缺失或仍是占位符, fetch 会明确报 EndpointNotConfigured 而不是拿模板去发请求。

不要猜接口。 以网页实际网络请求为准。学校改版后需重新运行 probe 校准。


常见问题

Q:为什么我的课表里有些课没导出?

运行 export --debug 查看解析报告,其中会列出跳过的记录及原因 (如「无法识别星期」「周次解析失败」)。再用 inspect 查看脱敏结构, 把实际字段名补进 src/xjtu_calendar/parser.py 的 FIELD_CANDIDATES。

Q:可以自动处理节假日和调课吗?

可以 —— 见第 3 节:停课/调课通知自动获取。 notice 子命令解析教务处发布的结构化通知页,把「停课日」与「按某日课表上课」 的调课整理进学期配置(周次与学期起始日交叉校验);解析不了的行逐条列出且不落盘, 只要存在无法可靠解析的行,--apply 就整体拒绝写入(fail-closed)。 当然也可以随时手工录 excluded_dates 与 overrides。 作息表的自动获取同样已经实现 —— 见上文 4. 作息表自动获取(schedule 子命令, 官方作息页,切换点取自页面原文,fail-closed 同口径)。

Q:怎么发现课表被调过(换教室、改时间、加减课)?

重复 fetch 即可:每次 fetch 会把上一份快照轮转为 timetable-<学期>.prev.json, 然后 python -m xjtu_calendar diff --semester <学期> 列出新增/删除课程、 时段增减与周次/教室/教师变化(纯本地比对,不发网络请求)。 确认变化后重新 export,UID 稳定所以日历会原地更新。 用订阅通道的话,把「重新 export」换成 subscribe push 即可。 也可以用 --old/--new 显式指定任意两份 raw JSON 做比较。

Q:考试怎么没进我的日历?/ 关掉或取消后,旧考试事件为什么还留在日历里?

没进:按顺序检查三件事——① fetch 是否走的 HTTP 路径(--from-file 与 --source browser 不抓考试,见「考试安排」);② 本学期是否已排考 (未排考时接口返回「查询失败」,属三态里的状态未知,本地没有旧快照则产物 不含考试,日志会说明);③ 是否带了 --no-exams(fetch/export/push/rotate 四处都会关掉考试)。 还留着:v1 没有取消通路(不发布 STATUS:CANCELLED),--no-exams 回滚与 考试取消都只保证「不再发布」,已发布的考试事件需要在客户端手动删除。

Q:为什么不用一个 RRULE 简化 ICS?

见上文核心设计。简单说:作息切换会让 RRULE 展开出错误的时间,而这是西安交大每年都会发生的事。

Q:上课钟点错了怎么办?

检查 schedule.json 的 profiles 钟点,以及 periods 里 夏秋季 / 冬春季的切换日期是否正确。export 运行时会校验配置并把问题以警告形式列出。

Q:会话过期了怎么办?

python -m xjtu_calendar login --force

Q:timetable.ics 会不会被误提交?

.gitignore 已包含 *.ics。但仍建议把导出文件放在仓库外, 或放在 _notes/ 等已排除目录。


路线图

已完成(MVP)

  • 标准化数据模型(星期 + 节次 + 教学周,零钟点)
  • 周次 / 节次文本解析(含单双周、混合区间)
  • 教学周 → 实际日期
  • 冬/夏季作息解耦,节次 → 实际钟点
  • 停课 / 调课覆盖机制(source_date 调课日:按来源教学日课表生成事件)
  • 逐次上课生成独立事件,UID 稳定
  • RFC 5545 .ics 导出(Asia/Shanghai,内嵌同 TZID 的 VTIMEZONE)
  • CLI、错误体系、日志脱敏、测试
  • 用真实网络请求确认 eHall 课表接口,固化到随包分发的接口定义
  • 包自包含(接口定义随 wheel 分发)+ 版本号单一来源 + CI 门禁
  • 按真实响应校准 parser.py 的字段候选(含 SKZC 位掩码、结构化节次优先)
  • fetch 以真实账号跑通:真实课表成功落盘并逐行校验
  • 产出首份真实 .ics
  • SEQUENCE / LAST-MODIFIED 版本管理(重新导入可正确更新)
  • 停课/调课通知自动解析合并(notice 子命令,含周次交叉校验)
  • 作息表自动获取(schedule 子命令,官方作息页;切换点取自表头原文, 覆盖范围来自学期校历,fail-closed + 只新增 + 幂等)
  • 调课检测(diff 子命令 + fetch 快照轮转:新增/删除课程、时段增减、 周次/教室/教师/课程名变化,纯本地比对)
  • URL 订阅发布(subscribe 子命令:.ics 以孤儿单提交强推到个人 GitHub Pages 分支,客户端按不可猜的 token URL 订阅,rotate 一键换链接)
  • 考试安排接入(fetch HTTP 路径同时抓考试快照,考试事件并入同一份 .ics / 同一个订阅 URL;三态判定「状态未知绝不覆盖快照」、--no-exams 四路径开关、 diff「考试变更」小节,见上文「考试安排」)

待完成

  • (本条路线图的待办已全部完成;下方「未来扩展」为架构已预留的新一批方向)

未来扩展(架构已预留)

  1. URL 订阅式 ICS —— 已交付 subscribe 手动发布通道(见上文 「URL 订阅」:本地构建 + 强推 GitHub Pages,客户端按 URL 自动拉取); 服务端定期重新生成仍未实现(当前口径是纯手动 push)
  2. 考试安排 —— 已交付(见上文「考试安排」:fetch → 同一份日历内含考试事件); 取消通路未实现——已发布的考试事件不会因 --no-exams 或考试取消而从客户端 消失,v2 拟单独立项发布 STATUS:CANCELLED
  3. 校历事件 —— 开学、放假、考试周、校庆、节假日
  4. 多学期管理

第一版明确不做:Web UI、手机 App、小程序、服务器账号系统、数据库、 Google / Apple 日历 API、CalDAV 双向同步、自动后台登录。


许可

MIT

Metadata

Release files for xjtu-timetable-calendar 0.5.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for xjtu-timetable-calendar 0.5.0
File Size Uploaded
xjtu_timetable_calendar-0.5.0.tar.gz 282.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for xjtu-timetable-calendar 0.5.0
File Interpreter ABI Platform
xjtu_timetable_calendar-0.5.0-py3-none-any.whl Python 3 none any Details

Total release size: 441.3 kB

Release files / xjtu_timetable_calendar-0.5.0.tar.gz

Download URL xjtu_timetable_calendar-0.5.0.tar.gz
Size 282.6 kB
Tags Source
SHA-256 checksum
How to use checksums
d3a18ea5fcce84097da43fe35fa90ac6d9a20f3785cc752da6704a4c76f9374d
BLAKE2b-256 checksum
How to use checksums
c2d1db71bf6c287cec7c80a639c397287f3f875394581302224130867df96904
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release files / xjtu_timetable_calendar-0.5.0-py3-none-any.whl

Download URL xjtu_timetable_calendar-0.5.0-py3-none-any.whl
Size 158.7 kB
Tags Python 3
SHA-256 checksum
How to use checksums
28960734376dfca5a9697dadba71c69ee87a12e1fcdfbe388aa7ff96b42bb839
BLAKE2b-256 checksum
How to use checksums
4b5c77faea6b1e8d145d66e19eb8aa5ee8e9ea9c79998e48b0043a9857de03ea
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 9, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.5.0 This release

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page