Repository navigation
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;
});- 列课程,按
term.name取最新学期。 - 每门课并发拉文件夹和文件(两页以内),失败只影响该课。
- 用
folder_id把平铺文件放回文件夹。 - 每个文件先取元数据,再 302 到 S3,第二段不带 Token。
- 校验大小,写盘。
-
不要把整个文件读进内存:上面为了短用了
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。
本 Wiki 记录的是开发实践,不是学校官方承诺。每个结论都标注证据与日期;校园平台会调整字段、权限和登录方式,动手前请按页面里的「最后验证」复核。发现过期内容请提 Issue 或直接改。