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.8.0 起课程 UID 改用配方 v2,课程名不再参与身份——同一 course_id 的课程被改名会作为同一事件的新版本被客户端原地更新,不再是新事件。唯一例外: course_id 缺失的行会退回用课程名做标识(models.py 的 stable_course_key),那种行 改名仍会产生新事件,但 diff 会在发布之前先报出来。从 v1 到 v2 的一次性迁移见 「升级到 v0.8(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[http,browser]"

⚠️ 别只装裸包。 基础依赖只有 icalendar(解析与渲染够用), login 要 playwright、默认抓取路径要 httpx,两者都在 extras 里。 2026-10-14 用干净虚拟环境实测:裸 pip install 之后跑 login 直接报 「登录功能需要 playwright」,而 --version 那一步是绿的——也就是说 只装裸包会在流程中段才撞上,而不是当场。只想手工补:

pip install playwright   # login 子命令(使用系统已装的 Edge/Chrome 时无需再跑 playwright install)
pip install httpx        # 默认 HTTP 抓取路径(比浏览器路径轻量)

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

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 ".[http,browser]"   # 开发用可编辑安装;只想使用则 `pip install ".[http,browser]"`

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

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

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


快速开始

1. 登录(只需一次)

python -m xjtu_calendar login

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

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

两条容易踩的操作约束(2026-10-11 实测):

  • 这一步要由真人完成。它停在终端等一个 Enter,放进后台任务、管道或自动化脚本里 会永远等不到,表现就是"卡住",而且浏览器窗口会一直开着占住会话目录。
  • 会话过期后必须带 --force。不带时,只要 home 里存在旧的会话文件,它就打印 「已检测到本地会话」直接退出——哪怕那份文件是一个多月前的、fetch 正在返回 401。 看起来成功,其实什么都没做。
  • 弹出来的是独立的浏览器实例(专用用户数据目录)。你在平时的浏览器里登录过 eHall 与它毫无关系,必须在那个窗口里再认证一次,否则工具抓不到会话。

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 日历可直接双击打开)。

想让编码 Agent 代你走这套流程:仓库根 skills/ 下有两个技能—— xjtu-timetable-setup(部署向导:登录交回真人、校历必须由你提供、每步带完成证据, 收尾用 doctor 的退出码判定成败)与 timetable-api-archaeology(给陌生学校的 接口考古方法论,含"什么时候该停下来")。把整个目录复制进你所用 Agent 的 skills 目录即可,内容不假设任何特定产品。


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 自检。 iOS / macOS 用户还可直接打开 init 一并打印的那条 webcal:// 链接 (口径与手动路径见下面「iOS / macOS:webcal:// 订阅链接」)。

日常更新

课表有变动时(建议先用 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 的 webcal:// 链接打印出来(旧的那条随之作废,见下一节), 重订时用新的一条。 极端情况 rotate 后发布失败(网络/权限):token 已换、远端还挂着旧文件名, 补发成功前旧 URL 仍可读取——这正是需要尽快补发(或清理)的原因; 按终端提示修复后跑 subscribe push 补发即可。

iOS / macOS:webcal:// 订阅链接

subscribe init、subscribe push(含「无变化,跳过推送」那一支)、subscribe rotate、 subscribe status 四处都会额外打印一行,文案逐字如下:

iOS/macOS:通常可直接打开 webcal://<你的 Pages 前缀>/<token>.ics 完成订阅(真机未验证,失败就走 README 的手动路径)。

三点缺一不可:

  1. 它不是一个新文件。这条链接只是把同一行里那条订阅 URL 的 https:// 换成 webcal:// (webcal 是 URL scheme,不是文件格式),指向的还是同一份 <token>.ics;发布分支的 tip 上始终只有那一个 .ics。当初考虑过「在 .ics 旁边再写一个 <token>.webcal」, 因为会撞 subscribe 的 tree 护栏(根目录恰好一个 *.ics)与 rotate 只清 *.ics 的清理逻辑而否决,理由见 docs/design/2026-10-11-subscription-followups.md §3。 另外只有 url_base 是 https 时才打印这一行:非 https 的订阅地址(明文 http、 自建托管给的 file:// 一类彩排远端)会整行省略——宁可不提示,也不给一条看起来能点 其实无效的链接。

  2. 「点开就弹订阅确认」本项目没有做过真机验证。这一句按 AGENTS 红线 3 停在「通常」: iOS/Safari 拿到 webcal:// 之后的处置、弹不弹订阅确认,都没有 iPhone 实测读数。 点了没反应或没能订阅,就走手动路径,两条都能建立订阅:

    • iOS:设置 → 日历 → 账户 → 添加账户 → 其他 → 添加已订阅的日历, 粘贴那条 https:// 订阅 URL(新版 iOS 把「日历」设置项收进 App 分组,菜单名随系统版本有出入)。
    • macOS:日历 App → 文件 → 新建订阅日历(旧版本为「文件 → 添加日历 → 订阅」), 粘贴 URL,并把「自动刷新」设为一个周期。

    这两条菜单路径同样未在本项目真机验证,写出来是 Apple 一贯的入口形态,不是实测步骤。

  3. rotate 之后旧链接随之作废。换 token 换的就是 URL 本身:补发成功后旧 <token>.ics 从分支 tip 消失,旧 webcal:// 链接和旧 https:// URL 一起失效(补发成功前旧 URL 仍可读取,见上一节),iOS 侧必须用 rotate 打印的新那条重新订阅一次。

隐私口径

  1. URL 即能力:token 是 32 位十六进制随机串,猜不到;但知道完整 URL 的 人就能匿名读取你的课表——转发 URL 等于授权。
  2. 在公开仓库上,「知道 URL 才能读」这层保护并不成立:发布仓库必须是公开的 (GitHub Pages 对免费账户的私有仓库不提供托管),而仓库的分支文件列表页匿名可读, 会直接列出 <token>.ics 的文件名。也就是说不用猜 token——任何会浏览该仓库 分支的人都能拿到你的课表。token 实际挡住的是「不知道有这个仓库 / 分支」这一层。 不接受这个口径就退回「导出文件 + 手工导入」,或把导出文件放进你自己的网盘同步目录、 手机上用 ICSx⁵ 订阅本地 .ics(零公网暴露)。
  3. 产物里有什么,就是公开什么:默认并进的考试事件会把考试名称、考试时间、考场, 以及 DESCRIPTION 里的座位号一起放进那份匿名可读的文件。不愿意公开就带 --no-exams(fetch / export / subscribe push / subscribe rotate 四处都认)。
  4. 历史零残留:发布分支永远是「无父孤儿单提交 + 强推」,课表的旧版本 不进 git 历史,仓库里翻不到你上周的课表。
  5. Pages 生效延迟数分钟:push 成功 ≠ 订阅 URL 立刻是新内容;客户端要等 Pages 构建完成后的下一次自动拉取才呈现新状态。status --verify 可确认。 rotate 之后这个窗口尤其明显:git 层此刻已经只剩新文件名(旧文件已从 分支 tip 消失),但 Pages/CDN 可能仍有 1~2 分钟继续供着旧内容——这期间 旧 URL 还读得到属正常现象,稍后重查即可,不要据此判定 rotate 失败。

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

已知边界

  • 课程改名(v0.8.0 起)通常会走原地更新:课程 UID 改用配方 v2、课程名不再参与身份, 同一 course_id 的课被改名会作为同一事件的新版本被客户端原地覆盖,diff 仍会在 push 之前先把这条时段变化列出来。唯一例外是 course_id 缺失、退回用课程名做标识的行——那种行 改名仍会产生新事件。真退课、教学班换 course_id 会以 STATUS:CANCELLED 下发,客户端表现 与考试侧同(Google 网页版会移除;Outlook.com / ColorOS 会留一条「已取消的活动」占位到原定时刻 过去——整学期规模的迁移残条已在 2026-10-12 真机演练,见下一节)。
  • 换 token 后所有客户端要重贴 URL(见上)。
  • 取消事件在部分客户端不会消失:会回源的订阅客户端里,Google 日历实测会移除 STATUS:CANCELLED 条目;而 Outlook.com 与 ColorOS 系统日历实测忽略该字段, 把撤销条目当普通事件导入。它们显示的是一条标题固定为**「已取消的活动」的日程 (v0.6.1 起;此前是不带标题的「无主题」残条),至少一眼能认出是废弃条目, 但不会自动消失**,要等它的原定时刻过去后由下一次发布收掉(见下面的行为矩阵)。 反过来,Google 那一格还有更省事的一面:即使不发墓碑、条目直接从产物里消失,Google 也会把它移除 (2026-10-13 试验田实测 ⇒ 它的清理由 UID 缺席驱动,STATUS:CANCELLED 不是触发条件); 不会消失的只有 ColorOS 那一类。
  • 「导入」与「订阅」是两种功能,别混:导入型入口把 .ics 里的事件一次性写进本机, 此后不再回源,subscribe push 对它无效;订阅型入口才会定期回源。ColorOS 系统日历 两者都有(「URL 日历」是订阅,「导入日历」是快照);Google 日历只有网页版能订阅, 手机 App 里连订阅入口都没有。分辨方法:订阅型入口带「上次同步时间」,导入型只有显示开关。
  • 发布通道只有静态 URL(以 GitHub Pages 为主);不做 CalDAV 直写、 不做中心托管服务。

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

升级到 v0.8(UID 配方变更)

v0.8.0 把课程 UID 的配方从 v1 换成 v2(去掉课程名),这是每个已发布过课表的老用户都会遇到的 一次身份切换。分客户端说清楚会发生什么:

  • URL 订阅(subscribe push):自动迁移,用户零操作。 工具靠订阅状态文件 subscribe/subscribe-<学期>.json 里的 uid_recipe 字段判断「上次发布用的还是 v1」, 下一次 subscribe push 会自动走一遍迁移:发布前打印一行预告(撤销 N 条 + 原因), 把整学期旧 UID 标成 STATUS:CANCELLED 一次性下发,同时用 v2 重建 live 事件。 迁移只在 subscribe push 上发生,export 永不触发;发布没成功就不会写任何状态, 下次 push 仍会重试迁移。

  • 迁移跑完之后,同一份课表的下次 subscribe push 不会再被门 2 拦。 课程侧比例熔断只数 本次新撤销——迁移(或往后退课)随产物带出去的那批「已取消的活动」墓碑会在本地课程台账里继续 被携带,但不会被重复算成新撤销。课表没变时实测输出 无变化,跳过推送。、退出码 0。

  • 手工 export 导入者:删除旧日历、重新导入一次。 迁移撤销不在 export 下发 (export 也不读不写 uid_recipe),双击导入的旧日历不会被自动收掉。正确做法是把旧的 课表日历删掉,用 v0.8 重新 export 的 .ics 再导一次;此后改课、改名都会原地更新。

  • 忽略 STATUS:CANCELLED 的客户端(Outlook.com / ColorOS)会看到一次整学期「已取消的活动」。 迁移那一次是几百条量级的全量撤销。2026-10-12 在整学期规模(合成身份字段 + 真实时间骨架)的真机 演练给出 ColorOS 的实测表现:v1 那条 UID 被原地改写成标题「已取消的活动」、LOCATION 变空 (产物里墓碑不带该字段,ColorOS 整体替换字段,与 2026-10-10 既有实测吻合),v2 的新 UID 作为新 日程进来 ⇒ 同一时段 1:1 成对出现,整学期规模 = 96 条新日程 + 75 条占位 + 21 条已过原定时刻的旧条目。 关键更正:这批占位是产物自身携带的 STATUS:CANCELLED 事件,忽略 STATUS 的客户端退订重加时会 再导入一遍——实测重加后「已取消的活动」仍在、仍 1:1。所以此前写的「退订再重新添加是唯一确定有效 的清理手段」这句是错的:占位只能等各自原定时刻过去后由下一次发布收掉;退订重加能清掉的只有 本地重复项,清不掉产物带着的占位。至于那 21 条已过原定时刻的旧条目(被过期筛放掉、不产生墓碑), 本次是先删除该日历、再重新添加,它们仍在手机上 ⇒ 对这类"从产物里彻底消失"的条目, 退订重加同样无效。我们没记录删除时是否勾选了「同时删除事件」,所以不对 ColorOS 的删除语义下断言; 但作为面向用户的补救手段,「退订重加」在本次演练里已被证伪——真正能收掉占位的只有"原定时刻过去 后由下一次发布带上",而"消失项"只能靠手动删。 Google 那条腿的「消失项」这一半已于 2026-10-13 复验:同一份整学期规模的合成课表、隔离的试验田仓库, 产物从 96 事件换成 91 事件、抽掉 5 条且不发墓碑,Google 网页版5 条最终全部移除 (4 条截至 T+14h39m、最后一条截至 T+24h58m,同批对照条目全程健在)⇒ Google 用户不会积累这类 幽灵条目,它的清理由 UID 缺席驱动。 仍未复验的是「Google 在整学期规模会不会清掉那批『已取消的活动』占位」(v0.6 只在小规模上验过它认 STATUS:CANCELLED);按上面这个机制推断它同样会清,但那句是推断、不是实测。

  • 回滚不是免费的。 v2 事件一旦发布,退回 0.7.x 会重新发一批 v1 UID,订阅端于是出现双份事件, 补救同样只有退订重加。所以真出问题应当停在 0.8.0 往前修,而不是去手改状态文件里的 uid_recipe ——手动改写只会让迁移分支反复触发,解决不了问题。

  • 撤销通路还有一道兜底降级。 当 v2 配方下出现「同课程号 + 同一天 + 同节次、课程名却不同」的 UID 碰撞时(分层/分项教学班这类形状),工具会放弃本次课程撤销并给出一条只报数量的提示,课表产物与考试侧不受影响。

常见客户端订阅入口

  • Apple 日历:走订阅入口而不是导入(macOS 日历 App「文件 → 新建订阅日历」, 旧版本为「文件 → 添加日历 → 订阅」;iPhone「设置 → 日历 → 账户 → 添加账户 → 其他 → 添加已订阅的日历」)。subscribe 打印的那行 webcal:// 链接是给 Apple 用户抄的捷径, 未在本项目真机验证,点了没反应就回上面这两条手动路径 (完整口径见「iOS / macOS:webcal:// 订阅链接」)。
  • Google Calendar:订阅只能在网页版建立——打开 calendar.google.com,左侧「其他日历」 旁的「添加日历」→「通过 URL 创建 / 从 URL 添加」(各语言版本措辞略有差异)。 手机 App 里没有这个入口;而且实测:网页端建好之后,手机上的 Google 日历 App 仍然不显示这类「从 URL 添加」的日历(勾过 syncselect、强制同步、走代理都不行), Google App 的「同步」开关只覆盖你自己创建的日历。所以要看手机课表,请走下面的 系统日历订阅,别把希望放在 Google App 上。 当前版本的日历设置里没有「自动移除旧事件」这一项(旧文档常提到它,已不存在)—— 好消息是它不影响撤销:STATUS:CANCELLED 的条目是在场地出现在产物里的, Google 实测会据此移除日程(见下面的行为矩阵)。
  • Android 国产 ROM:先看系统日历有没有订阅入口。ColorOS 有—— 「日历 → 我的 → 添加日历 → URL 日历」,粘贴订阅地址即可,实测它会自动回源并 原地更新已改变的事件。MIUI / 鸿蒙等若只给「导入日历」,那是一次性快照、 永不自动更新;这时退到 ICSx⁵(at.bitfire.icsdroid,F-Droid 免费、 各应用商店收费),它专门做 URL / 本地 .ics 订阅并写入系统日历数据库。 注意不是 DAVx⁵——DAVx⁵ 只做 CalDAV 双向同步,其官方 FAQ 明确把 URL 订阅 指向 ICSx⁵,按 DAVx⁵ 操作是找不到入口的。两个通用坑:
    1. 给同步器关闭电池优化并允许自启动,否则后台查杀会伪装成"装了但不更新";
    2. 一次性导入产生的独立日历,在日历 App 里常常只有「隐藏」没有「删除」—— 真正的删除入口在 设置 → 用户与账号 → 账户与同步,移除导入产生的那个 账户即可。不要用「设置 → 应用 → 日历 → 清除数据」,那会连你自己的 日程一起清空。

客户端行为矩阵(2026-10 真机演练;单账户单样本,别当厂商承诺)

客户端 订阅入口 收到 STATUS:CANCELLED 条目从 feed 静默消失 自动回源 用 X-WR-CALNAME 命名
Google 日历(网页) 其他日历 → 通过 URL 创建 移除条目 也会移除(清理由 UID 缺席驱动,墓碑不是触发条件) 是;两次实测延迟分别 ≤3h31m、≤10h05m 是
Google 日历(手机 App) 无此入口 不适用——App 不显示 URL 导入型日历 不适用 不适用 不适用
Outlook.com(网页) 添加日历 → 订阅日历 忽略,当普通事件导入(当时产物无标题 ⇒ 显示「无主题」) 未验证(该面回源周期未取得) 添加时立刻拉一次;此后实测 14 小时以上未再回源(官方口径约每 3 小时) 否,对话框强制手填
ColorOS 系统日历 我的 → 添加日历 → URL 日历 忽略,同上;且它是整体替换字段,旧标题会被清掉 不删,永久留着(退订重加也清不掉) 是;实测延迟 ≤2h35m,且能原地更新已存在的事件 是
各类「导入日历」 选本地 .ics 文件 不适用(不再回源) 不适用 否,一次性快照 不适用

「条目从 feed 静默消失」这一列是 2026-10-13 在试验田仓库(不是正式订阅)上单独测出来的: 一份 96 事件的整学期规模产物换成 91 事件,抽掉 5 条不发墓碑、其余字节与 URL 都不动。 Google 网页版截至 T+14h39m 已移除其中 4 条,同批 7 处对照条目全部健在(⇒ 不是"没回源"); 最后一条(当时距今 58 天的 12-10)在 T+24h58m 的周视图复核里也已消失 ⇒ 5 条全清、对照全在。 ColorOS 那一格沿用 2026-10-12 整学期迁移演练的读数(21 条被过期筛放掉的旧条目,删除日历再重新添加后 仍在手机上);Outlook 从来没拿到过这个读数,照实写未验证。 读这个数必须用周视图:Google 的月视图单元格会静默裁切溢出条目(不给「另外 N 项」), 而且缓存滞后于服务端状态——本次首读就被它骗过一次。

表里「无主题」那一格记的是演练当时的读数,当时的撤销条目刻意不带标题。v0.6.1 起 产物给撤销条目带上固定标题「已取消的活动」,客户端"忽略取消字段"这一判定不变, 但残条从「无主题」变成一条写着该文案的日程——认不出来 vs 一眼看穿,差别就在这里。 ColorOS 的「整体替换字段」正是这条改动能在手机端直接生效的前提(10-11 真机读数)。

两点必须如实了解:① 撤销通路目前只在 Google 网页版上验证为端到端可用;② 订阅型日历 在 Google 侧是只读的——没有"删除单条事件"的入口。而那条「已取消的活动」占位是产物自身 携带的 STATUS:CANCELLED 事件,忽略该字段的客户端退订重加时会再导入一遍(2026-10-12 真机 实测:重加后占位仍在、仍与新日程 1:1)。所以退订重加清不掉占位,只能清掉本地重复项;占位要等 各自原定时刻过去后由下一次发布收掉(Outlook 与 ColorOS 的删除入口尚未逐一确认)。


考试安排

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(提示可能超过一页), 但照常落盘、不阻断——实测一页够用,这条是为将来超过一页时留警痕。

考试撤销(v0.6.0 起自动下发)

已发布到订阅端的考试,若在本次数据里消失了(改期、取消,或用户「确认本学期无考试」), 下一份 export / subscribe push 产物会把那条事件标上 STATUS:CANCELLED 再发一次, 保留到它的原定开始时刻自然过去——此后条目从产物与本地台账一并退场,不再占位。 于是 v1 那条「已发布考试不会从订阅端消失、只能手动删」的人工补救不再是主路径。 2026-10 的真机演练给出了明确分工:Google 日历(网页版)会随之移除日程; Outlook.com 与 ColorOS 系统日历会忽略 STATUS:CANCELLED,把撤销条目当普通事件导入, 于是那条日程不消失,只显示成一条标题为**「已取消的活动」**的日程(v0.6.1 起; 此前是不带标题的「无主题」,详见「常见客户端订阅入口」的行为矩阵)。

撤销由数据驱动、不是用户选项(没有独立旗标,也不新增配置项),但它有几个必须 如实了解的边界:

  • --no-exams 既不发布考试、也不发布撤销。这个旗标是「这次的渲染口径」开关, 不是「这些考试不存在」,更不删除任何数据:关掉考试时产物里既没有 live 考试事件、 也没有对应的 STATUS:CANCELLED 条目,本地台账完全不动、快照也不删。撤销的下发时机 因此顺延到开关重新打开之后,信号不会丢失。
  • 撤销需要一个 SEQUENCE 基线。撤销条目要对照上一次发布留底才能给出客户端认得的 递增序号——subscribe push / rotate 用 ~/.xjtu-timetable-calendar/subscribe/ 下的 last-<学期>.ics,export 用输出文件自身的旧版本。若这份留底里查不到某个待 撤销 UID(例如用了 --no-sequence、export -o 写到一个全新路径、或上一次发布来自 --no-exams 因而产物里根本没有考试),工具跳过那一条撤销并记一条 warning, 而不是发一个注定被客户端忽略的 SEQUENCE:0。
  • 不认取消字段的订阅客户端会留下一条「已取消的活动」。撤销条目的标题是固定的 「已取消的活动」(v0.6.1 起,见本节末尾),Outlook.com 与 ColorOS 这类忽略 STATUS:CANCELLED 的客户端会把它当普通事件导入,于是日程不消失、只是变成这条一眼可辨 的废弃条目,保留到它的原定时刻过去、条目从产物里退场。这条占位是产物自身携带的 STATUS:CANCELLED 事件,忽略该字段的客户端退订重加时会把它再导入一遍(2026-10-12 整学期 迁移演练实测:重加后「已取消的活动」仍在、仍与新日程 1:1)——所以退订重加清不掉还没到时刻的占位, 它只能等原定时刻过去、条目从产物退场后由下一次发布收掉。至于条目退场后客户端会不会顺手清理, 仍尚未验证(那条读数要等真实学期数据再看),不许承诺会自动清干净。
  • 一次性导入型客户端仍需手动删除。国产 ROM 系统日历一类「一次性导入后不再回源」 的客户端不会去重拉 URL,收不到撤销(见「URL 订阅」的已知边界);对它们撤销只提供 手动删除时的辨认线索。
  • 换设备 / 删过数据目录后本地台账为空,于是没有任何东西可撤销。台账记的是「本机 曾以 live 形态发布过、且原定时刻还没过去」的考试;全新环境没有这份记录,撤销通路只能 退回「缺席即收敛」。
  • 改了学期写法后遗留的旧台账请手动删除。台账按学期 key 分文件;若把学期从 2026-fall 改成 2026-2027-1 一类,旧 key 对应的文件再也不会被读到、也就永远不会 被剪掉——直接删除 ~/.xjtu-timetable-calendar/subscribe/last-exams-*.ics 即可。

撤销事件本身只带最小字段(UID + 起止时刻 + STATUS + SEQUENCE + DTSTAMP + 一个固定的标题「已取消的活动」),没有考场、座位、教师,也没有课程名—— 那个标题不引用台账里的任何原文,个人信息不在保留期内继续公开;全字段的名字与考场 只留在本地台账里。标题恒定还有第二个作用:内容指纹的输入不变,同一份产物重渲染时 SEQUENCE 不会漂移。台账与课表快照同等待遇:含个人信息、只落本地、以收紧权限原子写盘、 永不发布,日志也不打印其内容。

考试 UID 与改期

考试 UID 依据接口行 ID WID(缺失时依次降级到 KSRWID、再到内容组合,降级有日志)。 改期/换考场后服务器是否换 WID 尚未验证——若换,改期表现为「旧 UID 撤销 + 新 UID 新增」,旧事件由上面的撤销通路收掉;不换则同 UID 原地更新。

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

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

diff 新增「考试变更」小节,与课程小节并列,报五类:新增 / 取消 / 时间变更 / 教室变更 / 座位变更。报告里有取消项时,末尾会给一条撤销预告:一行「本次将撤销:N 条」, 加一句说明——这些取消会在下次 export / subscribe push 时以 STATUS:CANCELLED 下发, 会定期回源拉取的订阅客户端随之移除(Google 已实测生效),而不回源的一次性导入型客户端仍需 手动删除。这里的 N 数的是 diff 报出的取消条数,只是实际下发量的上界: 真正随产物发出的,是其中已发布过、且对照得上 SEQUENCE 留底的那部分(详见「考试撤销」小节)。 这句提示对"忽略该字段的客户端"说得偏乐观:Outlook.com 与 ColorOS 实测不会移除, 而是留下一条标题固定为「已取消的活动」的日程(见「常见客户端订阅入口」的行为矩阵); 把它改写成三档措辞属于文案 + 测试联动的改动,留待单独一笔处理。 考试改名不会出现在报告里(上面五类是唯一口径)——这与课程侧不同: 课程改名会报出来(按时段变化呈现),而考试行只要还带着同一个 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 都不是真实校历数据, 必须替换成你从官方校历抄录的值。

⚠️ semester.key 必须等于文件名里那个学期标识(也就是 --semester 传的值)。 它不只是个标签——它会进入每一条事件的 UID。2026-10-14 实测:文件名与日期都不动、 只把 key 改掉再导出,103 个 UID 零交集;订阅端会把这看成"旧的一整批消失 + 新的一整批出现",于是旧事件全留在日历上、再导入一份新的。 上面示例里 "key": "2026-fall" 配的文件名就是 2026-fall.json; 换成你自己的标识时两处一起改。doctor 的「学期校历」一项会在两者不一致时警告。

关键字段是 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] [--allow-mass-cancellation] 生成 .ics(默认并入考试事件;--name 不给时标题按课表自动推导,见上文「导出」;--allow-mass-cancellation 放行课程侧熔断,见「升级到 v0.8」)
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] [--allow-mass-cancellation] | subscribe rotate [--no-exams] [--allow-mass-cancellation] | subscribe status [--verify](均可带 --semester S) 把 .ics 发布到自己的 GitHub Pages,日历客户端按 URL 订阅(见上文「URL 订阅」;push/rotate 默认并入考试,--no-exams 同样生效;push 首次会自动做 v1→v2 UID 迁移,--allow-mass-cancellation 放行课程侧熔断)
inspect [--input F] [-o OUT] 对原始 JSON 做脱敏结构分析
doctor [--semester S] [--verify] 只读体检:版本 / 会话 / 校历 / 作息 / 快照 / 渲染 / 订阅留底一次看全。不写任何文件、默认不发请求(--verify 才对订阅 URL 做一次匿名 GET)。有阻塞项时退出码为 5

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

退出码

码 含义
0 成功
1 一般错误(网络、解析、导出)
2 未登录,需要先跑 login
3 登录态已失效,需要重新 login --force(不带 --force 会直接沿用这份已失效的会话)
4 当前账号无权限访问该资源
5 doctor 检出阻塞项(警告不算,仍 0)
6 课程侧疑似数据不完整的整批撤销被拒绝发布(熔断,见「升级到 v0.8」;确认无误后用 --allow-mass-cancellation 放行)
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 就能读」 这层保护在公开仓库上并不成立:分支的文件列表页匿名可读,会直接列出 <token>.ics 的文件名。转发即授权,而任何会浏览该仓库分支的人同样能读到你的课表。 为此实现上做了配套约束:发布分支恒为 孤儿单提交(课表旧版本不进 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 四处都会关掉考试)。 还留着:v0.6.0 起撤销会自动下发——改期、取消或「确认本学期无考试」让某条已发布考试 从本次数据里消失时,下次 export / subscribe push 会给它标上 STATUS:CANCELLED。 2026-10 的真机演练给出了三种结局(详见「常见客户端订阅入口」的行为矩阵): Google 日历(网页版)会随之移除;Outlook.com 与 ColorOS 系统日历会忽略该字段, 日程不消失、只是变成一条标题固定为**「已取消的活动」的条目(v0.6.1 起;演练当时它不带 标题,显示成「无主题」);一次性导入型客户端根本不回源,无从生效。 因此仍需手动处理的是三种情形: ① --no-exams 是「本次不渲染考试」的开关,它既不发布考试也不发布撤销**,靠它删不掉 已发布的旧事件(要撤销请保持考试开关打开、让数据本身显示该考试已消失后再发布一次); ② 一次性导入后不再回源的客户端(各类「导入日历」入口)收不到撤销; ③ 忽略 STATUS:CANCELLED 的订阅客户端会留下那条「已取消的活动」残条,而订阅型日历通常 是只读的、没有"删除单条事件"的入口。但这条残条是产物自身带着的:忽略该字段的客户端退订重加时 会把它再导入一遍(2026-10-12 真机实测),所以重加清不掉它、只能清本地重复项——占位要等各自原定 时刻过去、条目从产物退场后由下一次发布收掉。

Q:为什么 subscribe push 拒绝发布,还报了一个比例?

那是课程侧撤销的第二道门(熔断)。当本次随产物带出去的课程撤销条数(只数真正下发的那批,不含 留底里没有、判为 unresolvable 而留在台账里排队、等各自原定时刻自然过去的那些)同时满足「≥ 10 条」 和「≥ 本地课程台账里尚未过去的条目的 30%」时,工具判定「多半是课表没抓全,而不是真的退了一批课」, 于是拒绝发布、退出码 6——防止半份课表被实现成「课全没了」。写盘范围:在 export 与 subscribe push 路径上零写盘(留底 / 课程台账 / 状态文件字节不变);subscribe rotate 上换 token 先于 渲染,熔断触发时新 token 与状态文件已经换掉,只有产物与两份台账不写。这里数的是本次新撤销: 上次已经随产物带出去的撤销(迁移或退课留下的墓碑)不会重复计入,所以正常升级与后续发布不会撞上它—— 只有真的少抓了一批课时才会触发。处理顺序:先跑 diff 确认 课表是不是真的少了一批课;只有确认无误、确实要撤销这一整批时,才带 --allow-mass-cancellation 重跑放行。 门槛的来龙去脉见「升级到 v0.8(UID 配方变更)」。

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

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

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

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

Q:会话过期了怎么办?/ 我明明登录了,为什么 fetch 还是 401?

python -m xjtu_calendar login --force

按顺序确认四件事(2026-10-11 一次真实排障的完整判读):

  1. 在你自己的终端里跑,别丢给后台任务。它要真人在弹出的浏览器窗口里完成认证、 再回终端按 Enter 才会抓取并保存会话;放进管道或后台执行器时它等不到这个 Enter, 表现就是"卡住",还会留一个占着会话目录的浏览器窗口。
  2. 必须带 --force。不带时,只要 home 里存在会话文件,它就打印「已检测到本地会话」 直接退出,不看那份文件是否早已过期。
  3. 在浏览器里登录了 eHall 不等于工具登录了。工具只认 login 从它自己那个独立窗口 里抓下来的 cookie,与你平时用的浏览器互不相通——必须在那个窗口里扫码或输密码。
  4. 判定成功只看两件事:~/.xjtu-timetable-calendar/session/storage_state.json 的修改时间有没有被重写,以及 fetch 是否不再 401。不要信 login 打印的「会话就绪」。 如果 fetch 改报 403,那是另一回事:会话过得了认证、却没走到教务应用, 看下一条。

那个窗口打开后如果什么都不渲染,先在窗口地址栏手动重输终端打印的那条入口 URL; 仍不行就用 XJTU_CALENDAR_BROWSER 指向另一个 Chromium 系浏览器(见环境变量)。

别拿地址栏尾巴的 ?browser=no 当症状。 它是学校站点在 CAS 回跳的最后一步给落地页 拼上的固定参数(/login?service → 302 → /new/index.html?browser=no),本工具从不拼它; 2026-10-08 一次成功登录的抓包里同样有它,其后课表与考试接口照样全部正常返回。 所以带它不代表被拒绝,不带也不代表正常——判据只看第 4 条那两件事。 灰页本身的成因尚未证实:现有线索都指向"被后台执行器或管道拉起时子进程 stdio 被弄坏" (见上面第 1 条),而不是站点拒绝了这台浏览器。

Q:fetch 报的是 403(不是 401),提示「当前账号没有访问该接口的权限」,怎么办?

先别去折腾账号权限。2026-10-14 实测的成因是登录只走了一半:login 打开的是 「请选择身份」页面(select_role.html?appId=…),如果在那个页面上就按了 Enter, 抓下来的会话能过统一身份认证、却没有教务应用(/jwapp)发的 cookie, 于是课表 / 学期发现 / 考试三个接口一律 403——返回的是学校平台的异常页 HTML (scenes_abnormalPage),而不是登录页,所以工具的 401 分支抓不到它。

  • 判据:doctor 的「会话」一项会直接说「会话里没有教务侧(/jwapp)的 cookie」。 手工判据是数会话文件里 path 以 /jwapp 开头的 cookie:正常登录有 4 条 (JSESSIONID、_WEU、THEME、EMAP_LANG),半截登录是 0 条。
  • 处置:重跑 login --force,在窗口里点「学生」并等到课表真的显示出来再按 Enter。
  • 同一个会话、同一个接口,只换 cookie 组合的消融实验:把 _WEU 去掉立刻回到 403; 两份同名 JSESSIONID(门户 / 与教务 /jwapp)少掉任意一份都仍然 200。 也就是说承重的是 _WEU,不是"哪一份 JSESSIONID 被留下了"。
  • 顺带排除掉的两种猜测:换浏览器 User-Agent、补 Referer/Origin 都不改变 403, 所以这不是站点在拦非浏览器客户端。

Q:每次 fetch 都警告「eHall 域范围内出现同名 cookie:JSESSIONID」,要不要管?

不用。2026-10-11 用刚重登的干净会话实测:这条 warning 依旧出现,课表与学期发现接口 都正常返回。它是同一域下多份 JSESSIONID 的取舍提示(程序取最后一份), 只在伴随 401 时才说明会话真的坏了——那时按上面两条重登即可。 2026-10-14 的消融实验进一步说明它确实无害:两份 JSESSIONID 任取一份都能拿到 200, 前提是教务侧的 _WEU 在会话里(缺它才会 403)。

Q:我重新发布了,为什么客户端里的日历名还是旧的?

名字来自产物里的 X-WR-CALNAME,而订阅客户端通常在「添加」那一刻取一次名就不再更新: Google 日历(网页版)与 ColorOS 系统日历实测会在添加时采用它,Outlook.com 完全忽略、 强制手输。所以产物后来改了名,已经加好的日历未必跟着改——退订再重新添加通常有效, 「不重加会不会自己改名」尚未验证,别当承诺;而且 2026-10-12 在 ColorOS 上实测到 「删除日历后重新添加」仍保留旧事件,所以重加不等于一定拿到一份全新数据, 改完要确认日历确实变干净了。

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「考试变更」小节,见上文「考试安排」)
  • 课程侧撤销 + UID v2(0.8.0:课程名不再参与 UID,改名走原地更新;真退课以 STATUS:CANCELLED 下发;两道门 + 熔断拒绝发布(退出码 6、--allow-mass-cancellation 放行); subscribe push 一次性 v1→v2 迁移,见上文「升级到 v0.8」)

待完成

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

未来扩展(架构已预留)

  1. URL 订阅式 ICS —— 已交付 subscribe 手动发布通道(见上文 「URL 订阅」:本地构建 + 强推 GitHub Pages,客户端按 URL 自动拉取); 服务端定期重新生成仍未实现(当前口径是纯手动 push)
  2. 考试安排 —— 已交付(见上文「考试安排」:fetch → 同一份日历内含考试事件); 取消通路已于 v0.6.0 交付(见上文「考试撤销」:消失的已发布考试以 STATUS:CANCELLED 自动下发、保留到原定时刻过去);剩余边界只在一次性导入后不再回源的客户端与换设备后的 空台账,仍需手动删除
  3. 校历事件 —— 开学、放假、考试周、校庆、节假日
  4. 多学期管理

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


许可

MIT

Metadata

Release files for xjtu-timetable-calendar 0.8.3

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.8.3
File Size Uploaded Provenance
xjtu_timetable_calendar-0.8.3.tar.gz 416.5 kB With provenance Details

Built distribution (wheel)

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

Total release size: 623.4 kB

Release files / xjtu_timetable_calendar-0.8.3.tar.gz

Download URL xjtu_timetable_calendar-0.8.3.tar.gz
Size 416.5 kB
Tags Source
SHA-256 checksum
How to use checksums
ec7a49f5f1aa5c9aaad895c6b8737ed58677bf985c79026610b0e5153cc9a31f
BLAKE2b-256 checksum
How to use checksums
c5944a705af729957b1b46881537b6a183d45f30ffb05d8c1b0e2045a5f798de
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 14, 2026.

Transparency log

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

Download URL xjtu_timetable_calendar-0.8.3-py3-none-any.whl
Size 206.9 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d1c8d474aa9ea964adc62bcdb30e5072f5f16c3a2fe76c3274b6899485a4a792
BLAKE2b-256 checksum
How to use checksums
e1b86cb6087b2828106696de80ed62feb460ac7f7c9fc7d62a808703e038235a
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 14, 2026.

Transparency log

Security for xjtu-timetable-calendar 0.8.3

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for xjtu_timetable_calendar-0.8.3.tar.gz

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 14, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

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