Skip to content

Recipe Calendar

ChangYo edited this page Oct 25, 2026 · 2 revisions

课表与 DDL 日历

目标:把教学信息服务网的课表和 Canvas 作业 DDL 合成一个 .ics,导入系统日历或订阅。

主要处理三个问题:学期开始日期、周次展开和时区。任意一项出错,日历都会偏移一周或 8 小时。

Note

下面的代码是可运行骨架。课表和 Canvas 的取数、字段映射已经写全,登录会话需要替换 getCampusSession()。Timetable 页有登录窗口和持久 Session 的完整流程。不要使用他人的 Cookie。

前置

1. 节次时间表

交大教务处公布的本科生节次时间(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],
];

2. 周次展开

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 时间。

3. 读课表并适配

这一节把 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),
      },
    ];
  });
}

4. 读 Canvas DDL

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 小时。

5. 生成 ICS

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');
}

6. 拼起来

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

7. 常见问题

  1. 学期开始日不在周一:先归一到周一;否则整学期会偏移几天。
  2. 把本地时间当 UTC:new Date('2026-09-14T08:00:00') 在不同时区结果不同。统一用 UTC 计算,或显式 TZID=Asia/Shanghai。
  3. 周次单双周:zcd 的单/双是整个字符串属性;混合表达式要按段解析。
  4. 节次越界:第 13 节只用于连上课程;startPeriod/endPeriod 要在 1–13。
  5. due_at 为空:不要给没有 DDL 的作业造一个截止时间。
  6. DDL 是 UTC:Canvas 的 due_at 带 Z,直接 new Date();不要再减 8 小时。
  7. 重复事件:同一门课多个教学班/多次排课可能生成重复;用 UID 去重。
  8. 全天事件:DDL 建议用定时事件(如 23:59),不要用全天,否则不同日历显示不一致。
  9. 订阅与导入:.ics 导入是一次性的;订阅需要把文件放到可访问的 URL,并定期更新。
  10. 不要自动提交作业:日历只做提醒,不代替用户在 Canvas 操作。
  11. 单课失败:Canvas 某门课 401/404 时跳过该课,不要整份日历失败。

8. 变体

  • 只生成课表:跳过 Canvas 部分。
  • 只生成 DDL:跳过课表部分。
  • 加考试:如果你有考试数据(社区接口/教务系统),同样的 VEVENT 形状。
  • 加通知:VALARM 触发器,提前 15 分钟/1 天。

9. 证据

  • 节次表、周次展开、时区处理:[代码 2026-09,Dela src/shared/schedule.ts]
  • 课表字段与学期代码:[实测 2026-09,Dela src/main/timetable/api.ts]
  • Canvas due_at:[实测 2026-09-10]

最后复核:2026-10-25。

Clone this wiki locally