Skip to content

Recipe Course Downloader

ChangYo edited this page Oct 25, 2026 · 2 revisions

课程资料下载器

目标:用 Canvas Personal Token,把某一学期所有课程的课件下载到本地,保持「课程 / 文件夹」结构。

这条食谱覆盖了四件事:分页、单课失败隔离、302 到 S3、文件名安全。做完它,你就有了一个能用的 Canvas 工具。

前置

  • Node.js 20+
  • Canvas Personal Token(见 Canvas)
  • 环境变量:CANVAS_TOKEN、OUT_DIR
export CANVAS_TOKEN='...'
export OUT_DIR='./canvas-export'

完整脚本

import { mkdir, writeFile } from 'node:fs/promises';
import { dirname, join, resolve } from 'node:path';

const ORIGIN = 'https://oc.sjtu.edu.cn';
const API = `${ORIGIN}/api/v1`;
const TOKEN = process.env.CANVAS_TOKEN;
const OUT_DIR = resolve(process.env.OUT_DIR ?? './canvas-export');

if (!TOKEN) throw new Error('缺少 CANVAS_TOKEN');

type Json = Record<string, any>;

async function api(path: string): Promise<Response> {
  const response = await fetch(new URL(path, API), {
    headers: { Authorization: `Bearer ${TOKEN}` },
  });
  if (!response.ok) throw new Error(`${path}: HTTP ${response.status}`);
  return response;
}

function nextLink(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, API);
  if (url.origin !== ORIGIN || !url.pathname.startsWith('/api/v1/')) {
    throw new Error('Canvas 分页地址无效');
  }
  return url.href;
}

async function listAll(path: string): Promise<Json[]> {
  let next: string | null = new URL(path, API).href;
  const visited = new Set<string>();
  const result: Json[] = [];
  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(`${path}: HTTP ${response.status}`);
    const page = (await response.json()) as Json[];
    result.push(...page);
    next = nextLink(response.headers.get('link'));
  }
  return result;
}

function safeName(value: string): string {
  return value
    .replace(/[<>:"/\\|?*\u0000-\u001f]/g, '_')
    .replace(/[. ]+$/g, '')
    .slice(0, 120) || 'untitled';
}

async function downloadFile(fileId: number, target: string): Promise<void> {
  const metaResponse = await api(`/files/${fileId}`);
  const meta = (await metaResponse.json()) as Json;

  const first = await fetch(new URL(meta.url, ORIGIN), {
    headers: { Authorization: `Bearer ${TOKEN}` },
    redirect: 'manual',
  });
  if (first.status < 300 || first.status >= 400) {
    throw new Error(`期望 302,实际 ${first.status}`);
  }
  const location = first.headers.get('location');
  if (!location) throw new Error('302 缺少 Location');

  const signed = new URL(location);
  if (signed.protocol !== 'https:' || signed.hostname !== 's3.jcloud.sjtu.edu.cn') {
    throw new Error('下载地址不是预期的 S3 域');
  }

  const file = await fetch(signed, { redirect: 'error' });
  if (!file.ok) throw new Error(`下载失败:HTTP ${file.status}`);

  const bytes = new Uint8Array(await file.arrayBuffer());
  if (typeof meta.size === 'number' && bytes.length !== meta.size) {
    throw new Error(`文件大小不符:${bytes.length} != ${meta.size}`);
  }

  await mkdir(dirname(target), { recursive: true });
  await writeFile(target, bytes);
  console.log(`saved ${target}`);
}

async function main() {
  const courses = await listAll('/courses?include[]=term&per_page=50');
  const terms = [...new Set(courses.map((course) => course.term?.name).filter(Boolean))].sort();
  const latest = terms.at(-1);
  if (!latest) throw new Error('没有找到学期');

  const selected = courses.filter((course) => course.term?.name === latest);
  console.log(`学期 ${latest},${selected.length} 门课`);

  for (const course of selected) {
    try {
      const [folders, files] = await Promise.all([
        listAll(`/courses/${course.id}/folders?per_page=50`),
        listAll(`/courses/${course.id}/files?per_page=50`),
      ]);
      const folderName = new Map<number, string>(
        folders.map((folder) => [folder.id as number, folder.name as string]),
      );
      const courseDir = safeName(String(course.name ?? `course-${course.id}`));

      for (const file of files) {
        const folder = folderName.get(file.folder_id as number) ?? 'root';
        const target = join(OUT_DIR, courseDir, safeName(folder), safeName(String(file.display_name)));
        try {
          await downloadFile(file.id as number, target);
        } catch (error) {
          console.error(`skip ${file.display_name}:`, error instanceof Error ? error.message : error);
        }
      }
    } catch (error) {
      // 单课 401/404 不阻塞其他课程
      console.error(`skip course ${course.name}:`, error instanceof Error ? error.message : error);
    }
  }
}

main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

它做了什么

  1. 列课程,按 term.name 取最新学期。
  2. 每门课并发拉文件夹和文件(两页以内),失败只影响该课。
  3. 用 folder_id 把平铺文件放回文件夹。
  4. 每个文件先取元数据,再 302 到 S3,第二段不带 Token。
  5. 校验大小,写盘。

常见问题

  • 不要把整个文件读进内存:上面为了短用了 arrayBuffer();大文件(教材 PDF 可到 96 MB)应该用流式写入临时文件,校验后 rename。
  • 不要自动跟随 302:会把 Authorization 带给 S3。
  • 不要缓存 S3 URL:1 小时过期;缓存文件,不缓存链接。
  • 文件名冲突:同名文件会覆盖;生产版要加 (1) 或按 file id 建目录。
  • Windows 非法字符:<>:"/\|?*、保留名(CON、PRN、AUX、NUL、COM1…)都要处理。
  • 单课 401:可能只是无权限,不要因为一门课失败就清 Token/退出。
  • 并发:Promise.all 两门课没问题;几十门课要限速,否则 429。
  • per_page 上限 50:不要请求 100 然后奇怪为什么只有 50 条。

变体

  • 只下当前学期:把 latest 换成 enrollment_state=active 的课程。
  • 只下某门课:把 selected 过滤成指定 course.id。
  • 只下 PDF:按 file['content-type'] 或 mime_class 过滤。
  • 增量:本地记录 (file.id, file.updated_at),只下变化的文件。
  • 导出到项目:把文件复制到你的笔记/项目目录,保留课程名和文件夹。

不要做

  • 不要把下载的课件重新分发;课程资料有版权。
  • 不要用别人的 Token;Personal Token 只代表本人。
  • 不要高频全量重扫;用增量 + 限速。

证据

  • 端点、分页、302/S3、字段:[实测 2026-09-10/15,Canvas]
  • 代码形状参考 Dela src/main/canvas/api.ts。

最后复核:2026-10-25。

Clone this wiki locally