Repository navigation
Recipe Calendar
ChangYo edited this page Oct 25, 2026
·
2 revisions
目标:把教学信息服务网的课表和 Canvas 作业 DDL 合成一个 .ics,导入系统日历或订阅。
主要处理三个问题:学期开始日期、周次展开和时区。任意一项出错,日历都会偏移一周或 8 小时。
Note
下面的代码是可运行骨架。课表和 Canvas 的取数、字段映射已经写全,登录会话需要替换 getCampusSession()。Timetable 页有登录窗口和持久 Session 的完整流程。不要使用他人的 Cookie。
交大教务处公布的本科生节次时间(Dela src/shared/schedule.ts):
| 节次 | 开始 | 结束 |
|---|---|---|
| 1 | 08:00 | 08:45 |
| 2 | 08:55 | 09:40 |
| 3 | 10:00 | 10:45 |
| 4 | 10:55 | 11:40 |
| 5 | 12:00 | 12:45 |
| 6 | 12:55 | 13:40 |
| 7 | 14:00 | 14:45 |
| 8 | 14:55 | 15:40 |
| 9 | 16:00 | 16:45 |
| 10 | 16:55 | 17:40 |
| 11 | 18:00 | 18:45 |
| 12 | 18:55 | 19:40 |
| 13 | 19:50 | 20:20 |
第 13 节仅用于 11–13 节连上的课程。不同校区/学期可能有微调;用之前核对教务处最新作息表。
const PERIODS: Array<[number, number]> = [
[8 * 60, 8 * 60 + 45],
[8 * 60 + 55, 9 * 60 + 40],
[10 * 60, 10 * 60 + 45],
[10 * 60 + 55, 11 * 60 + 40],
[12 * 60, 12 * 60 + 45],
[12 * 60 + 55, 13 * 60 + 40],
[14 * 60, 14 * 60 + 45],
[14 * 60 + 55, 15 * 60 + 40],
[16 * 60, 16 * 60 + 45],
[16 * 60 + 55, 17 * 60 + 40],
[18 * 60, 18 * 60 + 45],
[18 * 60 + 55, 19 * 60 + 40],
[19 * 60 + 50, 20 * 60 + 20],
];semesterStart 表示第 1 周的周一。代码统一按周一计算。如果拿到的是「开学日」,先确认它是否为周一;若为其他星期,归一到该周周一。
const DAY = 86_400_000;
function lessonEvents(
lessons: Array<{
courseName: string;
classroom?: string | null;
weekday: number; // 1-7
startPeriod: number; // 1-13
endPeriod: number;
weekNumbers: number[];
}>,
semesterStart: string, // YYYY-MM-DD
) {
const date = new Date(`${semesterStart}T00:00:00Z`);
if (!Number.isFinite(date.getTime())) throw new Error('学期开始日期无效');
// 把“上海本地周一 00:00”换算成 UTC 时刻:
// 先取当天 UTC 零点,再减 8 小时;这样后面加分钟数就是正确的 UTC。
const monday =
date.getTime() - ((date.getUTCDay() + 6) % 7) * DAY - 8 * 3_600_000;
const events = [];
for (const lesson of lessons) {
const start = PERIODS[lesson.startPeriod - 1]?.[0];
const end = PERIODS[lesson.endPeriod - 1]?.[1];
if (
start === undefined ||
end === undefined ||
end <= start ||
lesson.weekday < 1 ||
lesson.weekday > 7
) {
console.warn(`跳过 ${lesson.courseName}:节次/星期无效`);
continue;
}
for (const week of lesson.weekNumbers) {
const base = monday + ((week - 1) * 7 + lesson.weekday - 1) * DAY;
const begin = new Date(base + start * 60_000);
const finish = new Date(base + end * 60_000);
events.push({
title: lesson.courseName,
location: lesson.classroom ?? '',
start: begin,
end: finish,
uid: `${lesson.courseName}-${week}-${lesson.weekday}-${lesson.startPeriod}@sjtu`,
});
}
}
return events;
}为什么减 8 小时:
-
new Date('2026-09-14T00:00:00Z')是 UTC 零点,不是上海零点。 - 上海本地 00:00 = UTC 前一天 16:00,也就是减 8 小时。
- 后面的
start * 60_000是「本地第几分钟」,加到 UTC 时刻上就得到了正确的 UTC 时间。
这一节把 i.sjtu 的 kbList 映射成 lessonEvents 需要的形状。登录会话是你自己的实现;这里只定义接口。
interface CampusSession {
fetch(input: string, init?: RequestInit): Promise<Response>;
}
const TIMETABLE_URL =
'https://i.sjtu.edu.cn/kbcx/xskbcx_cxXsgrkb.html?gnmkdm=N2151';
function currentSemester(now = new Date()): string {
const year = now.getFullYear();
const month = now.getMonth() + 1;
if (month >= 9) return `${year}-${year + 1}-1`;
if (month <= 2) return `${year - 1}-${year}-1`;
if (month <= 7) return `${year - 1}-${year}-2`;
return `${year - 1}-${year}-3`;
}
function semesterParams(semester: string): { xnm: string; xqm: string } {
const [year, , term] = semester.split('-');
const termCodes: Record<string, string> = { '1': '3', '2': '12', '3': '16' };
return { xnm: year!, xqm: termCodes[term!]! };
}
function parsePeriods(value: string | undefined) {
const numbers = (value ?? '').match(/\d+/g)?.map(Number) ?? [];
const start = numbers[0] ?? 1;
const end = numbers.at(-1) ?? start;
return {
start: Math.min(20, Math.max(1, start)),
end: Math.min(20, Math.max(start, end)),
};
}
function parseWeekNumbers(value: string | undefined): number[] {
if (!value) return [];
const odd = /单/.test(value);
const even = /双/.test(value);
const result = new Set<number>();
for (const part of value.replace(/[周()()单双]/g, '').split(/[,,、]/)) {
const numbers = part.match(/\d+/g)?.map(Number) ?? [];
if (numbers.length === 0) continue;
const start = numbers[0]!;
const end = numbers[1] ?? start;
for (let week = start; week <= end && week <= 30; week += 1) {
if ((odd && week % 2 === 0) || (even && week % 2 === 1)) continue;
result.add(week);
}
}
return [...result].sort((a, b) => a - b);
}
function parseSemesterStart(values: unknown[] | undefined): string | null {
const dates = (values ?? [])
.flatMap((value) => {
const text = typeof value === 'string' ? value : (JSON.stringify(value) ?? '');
return text.match(/20\d{2}[-/.]\d{1,2}[-/.]\d{1,2}/g) ?? [];
})
.map((value) => {
const [year, month, day] = value.split(/[-/.]/).map(Number);
if (!year || !month || !day) return null;
const date = new Date(year, month - 1, day);
if (
date.getFullYear() !== year ||
date.getMonth() !== month - 1 ||
date.getDate() !== day
) {
return null;
}
return `${year.toString().padStart(4, '0')}-${month
.toString()
.padStart(2, '0')}-${day.toString().padStart(2, '0')}`;
})
.filter((value): value is string => value !== null)
.sort();
return dates[0] ?? null;
}
async function fetchTimetable(session: CampusSession, semester: string) {
const { xnm, xqm } = semesterParams(semester);
const response = await session.fetch(TIMETABLE_URL, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8',
'X-Requested-With': 'XMLHttpRequest',
Referer: 'https://i.sjtu.edu.cn/kbcx/xskbcx_cxXsgrkbIndex.html?gnmkdm=N2151',
},
body: new URLSearchParams({ xnm, xqm, kzlx: 'ck', xsdm: '' }).toString(),
});
if (response.redirected) throw new Error('登录已失效,请重新登录');
const text = await response.text();
if (!text.trimStart().startsWith('{')) throw new Error('返回了 HTML,可能登录失效');
const data = JSON.parse(text) as { kbList?: unknown[]; rqazcList?: unknown[] };
if (!Array.isArray(data.kbList)) throw new Error('课表数据结构已变化');
return data;
}
function toLessons(kbList: unknown[]) {
return kbList.flatMap((value) => {
const raw = value as Record<string, unknown>;
const courseName = typeof raw.kcmc === 'string' ? raw.kcmc.trim() : '';
const weekday = Number(raw.xqj);
if (!courseName || !Number.isInteger(weekday) || weekday < 1 || weekday > 7) return [];
const periods = parsePeriods(
typeof raw.jcs === 'string' ? raw.jcs : typeof raw.jc === 'string' ? raw.jc : undefined,
);
return [
{
courseName,
classroom: typeof raw.cdmc === 'string' ? raw.cdmc.trim() : null,
weekday,
startPeriod: periods.start,
endPeriod: periods.end,
weekNumbers: parseWeekNumbers(typeof raw.zcd === 'string' ? raw.zcd : undefined),
},
];
});
}const CANVAS_API = 'https://oc.sjtu.edu.cn/api/v1';
function canvasNextPage(header: string | null): string | null {
const next = header
?.split(',')
.find((part) => /rel="next"/.test(part))
?.match(/<([^>]+)>/)?.[1];
if (!next) return null;
const url = new URL(next, CANVAS_API);
if (url.origin !== 'https://oc.sjtu.edu.cn' || !url.pathname.startsWith('/api/v1/')) {
throw new Error('Canvas 分页地址无效');
}
return url.href;
}
async function listAllCanvas(path: string, token: string): Promise<Record<string, any>[]> {
let next: string | null = new URL(path, CANVAS_API).href;
const visited = new Set<string>();
const result: Record<string, any>[] = [];
while (next) {
if (visited.has(next)) throw new Error('Canvas 返回了重复分页');
visited.add(next);
const response = await fetch(next, { headers: { Authorization: `Bearer ${token}` } });
if (!response.ok) throw new Error(`Canvas ${response.status}`);
result.push(...((await response.json()) as Record<string, any>[]));
next = canvasNextPage(response.headers.get('link'));
}
return result;
}
async function listActiveCanvasCourses(token: string) {
return (await listAllCanvas('/courses?enrollment_state=active&per_page=50', token)).map(
(course) => ({ id: course.id as number, name: String(course.name ?? course.id) }),
);
}
async function canvasAssignments(token: string, courseId: number) {
const response = await fetch(
`${CANVAS_API}/courses/${courseId}/assignments?include[]=submission&per_page=50`,
{ headers: { Authorization: `Bearer ${token}` } },
);
if (!response.ok) throw new Error(`Canvas ${response.status}`);
const assignments = (await response.json()) as Array<Record<string, any>>;
return assignments
.filter((assignment) => typeof assignment.due_at === 'string')
.map((assignment) => ({
title: assignment.name as string,
dueAt: assignment.due_at as string, // ISO UTC
url: assignment.html_url as string,
course: assignment.course_id as number,
}));
}due_at 已经是 ISO UTC,直接用它生成事件,不要再加减 8 小时。
function icsDate(date: Date): string {
return date.toISOString().replace(/[-:]/g, '').replace(/\.\d{3}Z$/, 'Z');
}
function escapeIcs(value: string): string {
return value
.replace(/\\/g, '\\\\')
.replace(/;/g, '\\;')
.replace(/,/g, '\\,')
.replace(/\r?\n/g, '\\n');
}
function buildIcs(
lessons: ReturnType<typeof lessonEvents>,
assignments: Array<{ title: string; dueAt: string; url: string }>,
): string {
const lines = [
'BEGIN:VCALENDAR',
'VERSION:2.0',
'PRODID:-//Dela Wiki//Campus Calendar//CN',
'CALSCALE:GREGORIAN',
];
for (const event of lessons) {
lines.push(
'BEGIN:VEVENT',
`UID:${escapeIcs(event.uid)}`,
`DTSTAMP:${icsDate(new Date())}`,
`DTSTART:${icsDate(event.start)}`,
`DTEND:${icsDate(event.end)}`,
`SUMMARY:${escapeIcs(event.title)}`,
`LOCATION:${escapeIcs(event.location)}`,
'END:VEVENT',
);
}
for (const assignment of assignments) {
const due = new Date(assignment.dueAt);
lines.push(
'BEGIN:VEVENT',
`UID:assignment-${escapeIcs(assignment.url)}`,
`DTSTAMP:${icsDate(new Date())}`,
`DTSTART:${icsDate(due)}`,
`DTEND:${icsDate(new Date(due.getTime() + 30 * 60_000))}`,
`SUMMARY:${escapeIcs(`[DDL] ${assignment.title}`)}`,
`URL:${escapeIcs(assignment.url)}`,
'END:VEVENT',
);
}
lines.push('END:VCALENDAR');
return lines.join('\r\n');
}import { writeFile } from 'node:fs/promises';
async function getCampusSession(): Promise<CampusSession> {
// 替换成你的持久 Session/登录窗实现;Timetable 页有完整流程。
throw new Error('请先实现校园登录会话,再运行本食谱');
}
async function main() {
const session = await getCampusSession();
const semester = process.env.SEMESTER ?? currentSemester();
const timetable = await fetchTimetable(session, semester);
const semesterStart = parseSemesterStart(timetable.rqazcList) ?? process.env.SEMESTER_START;
if (!semesterStart) {
throw new Error('缺少学期开始日期:设置 SEMESTER_START=YYYY-MM-DD,或检查 rqazcList');
}
const lessons = toLessons(timetable.kbList ?? []);
const events = lessonEvents(lessons, semesterStart);
const token = process.env.CANVAS_TOKEN;
if (!token) throw new Error('缺少 CANVAS_TOKEN');
const courses = await listActiveCanvasCourses(token);
const assignments = (
await Promise.all(courses.map((course) => canvasAssignments(token, course.id)))
).flat();
const ics = buildIcs(events, assignments);
await writeFile('campus.ics', ics);
console.log(`wrote campus.ics: ${events.length} lessons, ${assignments.length} ddl`);
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});运行:
export CANVAS_TOKEN='...'
export SEMESTER='2026-2027-1' # 可选;默认按本机月份推断
export SEMESTER_START='2026-09-14' # 可选;rqazcList 解析不到时必填
node --experimental-strip-types campus-calendar.ts- 学期开始日不在周一:先归一到周一;否则整学期会偏移几天。
-
把本地时间当 UTC:
new Date('2026-09-14T08:00:00')在不同时区结果不同。统一用 UTC 计算,或显式TZID=Asia/Shanghai。 -
周次单双周:
zcd的单/双是整个字符串属性;混合表达式要按段解析。 -
节次越界:第 13 节只用于连上课程;
startPeriod/endPeriod要在 1–13。 -
due_at为空:不要给没有 DDL 的作业造一个截止时间。 -
DDL 是 UTC:Canvas 的
due_at带Z,直接new Date();不要再减 8 小时。 -
重复事件:同一门课多个教学班/多次排课可能生成重复;用
UID去重。 - 全天事件:DDL 建议用定时事件(如 23:59),不要用全天,否则不同日历显示不一致。
-
订阅与导入:
.ics导入是一次性的;订阅需要把文件放到可访问的 URL,并定期更新。 - 不要自动提交作业:日历只做提醒,不代替用户在 Canvas 操作。
- 单课失败:Canvas 某门课 401/404 时跳过该课,不要整份日历失败。
- 只生成课表:跳过 Canvas 部分。
- 只生成 DDL:跳过课表部分。
-
加考试:如果你有考试数据(社区接口/教务系统),同样的
VEVENT形状。 - 加通知:VALARM 触发器,提前 15 分钟/1 天。
- 节次表、周次展开、时区处理:
[代码 2026-09,Dela src/shared/schedule.ts] - 课表字段与学期代码:
[实测 2026-09,Dela src/main/timetable/api.ts] - Canvas
due_at:[实测 2026-09-10]
最后复核:2026-10-25。
本 Wiki 记录的是开发实践,不是学校官方承诺。每个结论都标注证据与日期;校园平台会调整字段、权限和登录方式,动手前请按页面里的「最后验证」复核。发现过期内容请提 Issue 或直接改。