Skip to content
ChangYo edited this page Oct 25, 2026 · 2 revisions

Canvas

项 值
平台 oc.sjtu.edu.cn
认证 个人 Access Token(Bearer)
数据方向 只读为主;讨论回复可写;作业提交未验证
难度 低
最后验证 2026-09-15
证据等级 A(Canvas 官方文档 + Dela 实测)

Canvas 是交大教学平台,底层为 Instructure Canvas LMS。它提供标准 REST API,个人 Access Token 几分钟即可取得,适合作为第一个校园工具的数据源。

要点

  • 可获取:课程、学期、作业、提交、文件与文件夹、模块、讨论与公告、测验、页面、教学大纲。
  • 前置:个人 Access Token。头像 → 设置 → 「允许融入使用的外部软件」→「+ 创建新的访问许可证」。不同 Canvas 版本的中文翻译可能不同,以实际界面为准。
  • 注意:per_page 上限为 50;401 可能是单课无权限,不一定是 Token 失效;文件下载经 302 跳转到 S3,第二段不能带 Authorization。

1. 数据清单

对象 粒度 内容 历史范围 权限
课程 Course 每门课 id、名称、课程代码、学期、选课状态、成绩字段、颜色 全部历史课程 本人选课
学期 Term 每学期 名称(如 2026-2027 Fall)、起止 全部 随课程返回
作业 Assignment 每份作业 名称、描述 HTML、due_at、分值、提交类型、分组、quiz 关联 全部 本人课程
提交 Submission 每份作业 状态、分数、提交时间、批改时间、附件、评语、迟交/缺交 本人 本人
文件 File 每个文件 id、uuid、文件夹、显示名、MIME、大小、更新时间、下载 URL 全部 本人课程
文件夹 Folder 每个文件夹 id、名称、父文件夹、文件数、子文件夹数 全部 本人课程
模块 Module 每个模块 名称、位置、item 列表 全部 本人课程
讨论/公告 每个主题 标题、正文 HTML、公告标记、回复数、最后回复时间 全部 本人课程
测验 Quiz 每个测验 类型、due_at、题数、时限、可尝试次数 部分课程 本人课程
页面 Page 每个页面 标题、正文 HTML、URL 全部 本人课程
教学大纲 每门课 HTML,多为空或一个 PDF 链接 全部 本人课程

无法获取:

  • 课堂录像不在 Canvas 文件接口里,走独立的课堂视频平台。
  • 其他同学的提交、成绩、讨论草稿。
  • 课程文件的历史版本(只能看到当前文件)。

2. 认证

获取个人 Token

  1. 浏览器打开 https://oc.sjtu.edu.cn/,用 jAccount 登录。
  2. 点头像 → 设置。
  3. 找到「允许融入使用的外部软件」→「+ 创建新的访问许可证」。
  4. 用途随便填,过期时间可以留空。
  5. 复制生成的字符串。只显示一次,丢了只能重建。

[实测 2026-09,Dela 引导流程]

验证 Token

curl 'https://oc.sjtu.edu.cn/api/v1/users/self/profile' \
  -H 'Authorization: Bearer <token>'

返回本人 profile 就说明 Token 有效。Dela 在保存 Token 前会先调这个接口。[实测]

请求头

Authorization: Bearer <canvas_access_token>
Accept: application/json

个人 Token 默认带写入 scope(2026-09-15 实测:无实体的写探测没有被 scope 层以 401 拒绝)。但「有写权限」不等于「所有写接口都能用」,具体看第 6 节。

不需要 OAuth

个人自用工具用 Personal Access Token 就够了。只有你要做一个应用、替多个用户统一承载授权时,才需要联系 Canvas 管理方申请 Developer Key + OAuth。[官方]


3. 端点表

Base URL:https://oc.sjtu.edu.cn/api/v1

用途 Method Path 关键参数
验证 Token GET /users/self/profile -
课程列表 GET /courses include[]=term、include[]=enrollments、per_page=50
当前在读课程 GET /courses enrollment_state=active、per_page=50
课程详情 GET /courses/:id include[]=syllabus_body
作业列表 GET /courses/:id/assignments include[]=submission、per_page=50
单份提交 GET /courses/:id/assignments/:aid/submissions/self include[]=attachments、include[]=submission_comments
作业分组 GET /courses/:id/assignment_groups include[]=assignments
文件夹 GET /courses/:id/folders per_page=50
课程文件 GET /courses/:id/files per_page=50
按文件夹列文件 GET /folders/:fid/files per_page=50
文件元数据 GET /files/:fid -
模块 GET /courses/:id/modules per_page=50
模块 item GET /courses/:id/modules/:mid/items per_page=50
讨论/公告 GET /courses/:id/discussion_topics per_page=50
跨课程公告 GET /announcements context_codes[]=course_<course_id>;active_only=true 时还要日期
测验 GET /courses/:id/quizzes per_page=50
页面列表 GET /courses/:id/pages per_page=50
页面正文 GET /courses/:id/pages/:url -
本人待办 GET /users/self/todo -
即将发生 GET /users/self/upcoming_events -
活动流 GET /users/self/activity_stream -
Planner GET /planner/items 日期窗口、context_codes[]
日历事件 GET /calendar_events context_codes[]、日期窗口
回复讨论 POST /courses/:id/discussion_topics/:topic_id/entries JSON {"message":"..."}

4. 字段与语义

Course

{
  "id": 12345,
  "name": "机器学习",
  "course_code": "本-(2026-2027-1)-CS3308-01-机器学习",
  "term": { "name": "2026-2027 Fall" },
  "workflow_state": "available",
  "enrollments": [{ "type": "student", "computed_current_score": 92.5 }],
  "storage_quota_mb": 2048,
  "friendly_name": "机器学习",
  "calendar": { "ics": "..." }
}
  • course_code 中段可解析出课程号(如 CS3308)和班号(01),用于跨学期聚合同名课。
  • term.name 是学期分类的正确依据,不要用 workflow_state。
  • 历史课程长期保持 available(教师未归档),所以「可用」不等于「当前学期」。
  • enrollments[].computed_current_score/final_score 只在部分课程有值;不要用它判断是否完成。

Assignment

{
  "id": 1001,
  "name": "作业 1",
  "description": "<p>要求...</p>",
  "due_at": "2026-10-01T15:59:59Z",
  "unlock_at": null,
  "lock_at": null,
  "points_possible": 100,
  "submission_types": ["online_upload"],
  "assignment_group_id": 12,
  "html_url": "https://oc.sjtu.edu.cn/courses/12345/assignments/1001",
  "allowed_attempts": 1,
  "is_quiz_assignment": false,
  "published": true,
  "submission": { "...": "见下" }
}
  • due_at 是 DDL 的唯一可靠来源。有些课全部不填 due,不能靠日历/planner 补。
  • description 是 HTML,渲染前要清洗。
  • submission_types 常见 online_upload、none(纯成绩项)、online_quiz;online_text_entry、media_recording 结构上要保留处理位,但本人数据里没出现。

Submission

{
  "workflow_state": "submitted",
  "score": null,
  "grade": null,
  "submitted_at": "2026-09-30T12:00:00Z",
  "graded_at": null,
  "attempt": 1,
  "late": false,
  "missing": false,
  "seconds_late": 0,
  "excused": false,
  "redo_request": false,
  "attachments": [],
  "submission_comments": []
}

判断「完成」不要只看分数:

  • submitted_at / workflow_state=submitted|pending_review:已提交。
  • workflow_state=graded 且 missing !== true:已批改。
  • excused=true:已豁免。
  • redo_request=true:需重新提交。
  • submission_types 全是 on_paper/none:线下提交,不要标成「未交」。
  • 分数为 0 不代表没交;分数为空也不代表没交。

File / Folder

{
  "id": 9001,
  "uuid": "abc...",
  "folder_id": 3001,
  "display_name": "lecture01.pdf",
  "filename": "lecture01.pdf",
  "content-type": "application/pdf",
  "size": 1048576,
  "created_at": "...",
  "updated_at": "...",
  "mime_class": "pdf",
  "hidden": false,
  "locked": false,
  "url": "/files/9001/download?download_frd=1&verifier=..."
}
  • folders 返回全课程扁平列表。用 parent_folder_id 组树;根为 null,根名固定为 course files。
  • files 可以整课平铺,也可以按文件夹列;字段一致。
  • display_name 是界面名(中文常见),filename 是原始文件名。
  • thumbnail_url、media_entry_id 实测基本为 null。

Module item

  • type 实测出现 File、Discussion;代码已按 Assignment、Page、SubHeader、ExternalUrl 处理,但这四种未逐一实测。
  • content_id 指向具体资源;page_url 指向页面。
  • 模块是教师组织的「课程目录」,约 1/3 的课在用;没有模块的课只有文件树。把模块当可选视图,不要当硬性层级。

5. 分页、限流、错误

分页

Canvas 用 Link 响应头分页:

Link: <https://oc.sjtu.edu.cn/api/v1/courses?...&page=2>; rel="next",
      <https://oc.sjtu.edu.cn/api/v1/courses?...&page=5>; rel="last"

关键事实:

  • per_page 被本实例静默限制为 50。请求 100 也只返回 50,Link 头里显示 per_page=50。[实测 2026-09-10]
  • 必须跟随 Link 的 rel="next",不能自己拼 page。
  • 分页 URL 必须校验 origin 和 /api/v1/ 前缀,防止被恶意响应带去别的域。
  • 记录已访问 URL,防止服务端返回重复分页导致死循环。
function nextPage(linkHeader: string | null): string | null {
  const next = linkHeader
    ?.split(',')
    .find((part) => /rel="next"/.test(part))
    ?.match(/<([^>]+)>/)?.[1];
  if (!next) return null;
  const url = new URL(next, 'https://oc.sjtu.edu.cn/api/v1/');
  if (url.origin !== 'https://oc.sjtu.edu.cn' || !url.pathname.startsWith('/api/v1/')) {
    throw new Error('Canvas 分页地址无效');
  }
  return url.href;
}

async function listAll(path: string, token: string): Promise<Record<string, any>[]> {
  let next: string | null = new URL(path, 'https://oc.sjtu.edu.cn/api/v1/').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 HTTP ${response.status}`);
    result.push(...((await response.json()) as Record<string, any>[]));
    next = nextPage(response.headers.get('link'));
  }
  return result;
}

限流

  • X-Rate-Limit-Remaining: 600.0(令牌桶)+ X-Request-Cost(每请求按成本扣减)。[实测]
  • 触发限流通常返回 429;读 Retry-After,没有就指数退避。[官方] [社区]
  • 同步多门课时串行或小并发,不要每门课并发轰炸。

错误

状态 可能含义 处理
401 + JSON {"status":"未授权","errors":[{"message":"用户无权执行该操作"}]} 可能是 Token 失效,也可能是单课无权限 先调 /users/self/profile 判断 Token;单课 401 跳过并标记
404 功能未开/资源不存在 该分支跳过,不要当成整站失败
429 限流 退避重试
5xx 服务端临时错误 重试一次,仍失败则报错

实测:/courses 返回 48 门课,其中 5 门列表可见、详情 401。Token 本身仍然有效。[实测 2026-09-10]


文件下载链路

这是 Canvas 最容易写错的部分。

GET /api/v1/files/{fid}                         Bearer Token
  → metadata.url = /files/{fid}/download?download_frd=1&verifier=<uuid>
GET metadata.url                                Bearer Token, redirect: manual
  → 302 Location: https://s3.jcloud.sjtu.edu.cn/...?X-Amz-Expires=3600
GET Location                                    不要带 Authorization
  → 200 文件流;支持 Range

关键点:

  • 用 redirect: 'manual' 拿 Location。这样签名 URL 不经过 fetch 的自动重定向、不进入渲染层,也方便流式/重试。不要依赖不同运行时对跨源重定向的 Authorization 处理。不同运行时的实现并不一致。
  • 第二段请求不带 Canvas Token。预签名 URL 自己就是凭据。
  • 预签名 URL 有效期 1 小时(X-Amz-Expires=3600),不要存库、不要落日志、不要发给模型。
  • verifier 下载 URL 长期稳定,可以缓存;S3 链接不能缓存。
  • 单文件实测最大约 96 MB(教材 PDF);下载后校验 Content-Length/size。
  • 大文件下载放主进程/后台,不要阻塞 UI;并发下载要限速。
  • 文件名冲突要处理;Windows 下还要过滤非法字符和保留名。
import { writeFile } from 'node:fs/promises';

async function downloadCanvasFile(fileId: number, token: string, target: string) {
  const meta = await fetch(`https://oc.sjtu.edu.cn/api/v1/files/${fileId}`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  if (!meta.ok) throw new Error(`metadata ${meta.status}`);
  const info = (await meta.json()) as { url: string; size: number; 'content-type': string };

  const first = await fetch(new URL(info.url, 'https://oc.sjtu.edu.cn'), {
    headers: { Authorization: `Bearer ${token}` },
    redirect: 'manual',
  });
  if (first.status < 300 || first.status >= 400) {
    throw new Error(`expected redirect, got ${first.status}`);
  }
  const location = first.headers.get('location');
  if (!location) throw new Error('missing Location');
  const signed = new URL(location);
  if (signed.protocol !== 'https:' || signed.hostname !== 's3.jcloud.sjtu.edu.cn') {
    throw new Error('unexpected download host');
  }

  const file = await fetch(signed, { redirect: 'error' });
  if (!file.ok) throw new Error(`download ${file.status}`);
  const bytes = new Uint8Array(await file.arrayBuffer());
  if (info.size && bytes.length !== info.size) throw new Error('size mismatch');
  await writeFile(target, bytes);
}

生产代码应流式写入临时文件,校验后再 rename。上面的示例只展示协议形状,没有处理大文件内存占用。


6. 写操作

个人 Token 默认带写入 scope,但只验证了很少的写端点:

  • 讨论回复可用:POST /courses/:id/discussion_topics/:topic_id/entries,JSON body {"message":"..."},Content-Type: application/json; charset=utf-8,返回 201。学生回复公告也可以。[实测 2026-09-15]
  • 作业提交、上传文件、改成绩等未验证。不要因为「scope 允许」就假设端点可用。
  • 写操作要有用户确认;不要在同步/后台任务里偷偷写。

7. 常见问题

  1. per_page 超过 50 无效:必须跟 Link,不能信任请求参数。
  2. 401 不一定是 Token 失效:单课 401 可能只是无权限;先验证 profile。
  3. 历史课长期 available:按 term 分类,不要按 workflow_state。
  4. planner / todo / upcoming_events / calendar_events 基本为空:交大教师不用这些功能;DDL 要自己遍历 assignments.due_at 聚合。[实测 2026-09-10/15]
  5. 跨课程公告必须传 context_codes[]:不传会 400 Missing context_codes;active_only=true 还要日期。
  6. 文件夹是扁平列表:必须按 parent_folder_id 组树。
  7. 模块和文件会指向同一个资源:按 source(file id / assignment id / discussion id / page url)去重,不要重复展示。
  8. course_code 是复合字符串:解析课程号/班号/学期时写健壮的回退,不要按固定分隔符硬切。
  9. due_at 是唯一 DDL:没有 due 的作业就是没有 DDL,不要用课程结束时间或考试时间猜。
  10. 文件下载 302 到 S3:第二段不能带 Authorization,预签名 URL 是凭据。
  11. 大文件:教材 PDF 可到 96 MB;要流式、校验、限速。
  12. quizzes 可能返回 404:功能未开启,不要视为整站错误。
  13. activity_stream 收录时效不明:不要拿它做实时通知。

8. 最小示例

列课程

curl 'https://oc.sjtu.edu.cn/api/v1/courses?include[]=term&include[]=enrollments&per_page=50' \
  -H "Authorization: Bearer $CANVAS_TOKEN"

列作业

curl 'https://oc.sjtu.edu.cn/api/v1/courses/12345/assignments?include[]=submission&per_page=50' \
  -H "Authorization: Bearer $CANVAS_TOKEN"

按学期筛选

const token = process.env.CANVAS_TOKEN!;
const courses = await listAll('/courses?include[]=term&per_page=50', token);
const latestTerm = [...new Set(courses.map((c) => c.term?.name).filter(Boolean))]
  .sort()
  .at(-1);
const current = courses.filter((c) => c.term?.name === latestTerm);

更完整的下载器见课程资料下载器。


9. 已过时 / 不要用

  • 不要依赖 planner/todo/calendar_events 做 DDL 聚合:交大实例基本为空。用作业 due_at。
  • 不要用 workflow_state 判断学期:历史课仍然是 available。
  • 不要用 per_page 控制页大小:本实例上限 50。
  • 不要把 Canvas 当课堂录像来源:录像在 v.sjtu.edu.cn,Canvas 里只是 LTI 入口。

10. 证据与来源

  • 课程/作业/提交/文件/模块/讨论字段与行为:[实测 2026-09-10、2026-09-15,本人账号,5 学期 48 门课]
  • per_page=50、401 的两种含义、S3 302 与 1 小时签名、96MB:[实测 2026-09-10]
  • 讨论回复 201:[实测 2026-09-15]
  • Canvas REST API 上游文档:[官方 2026-10-25] https://canvas.instructure.com/doc/api/
  • 交大实例文档:[官方 2026-10-25] https://oc.sjtu.edu.cn/doc/api/index.html

变更记录

  • 2026-10-25:首版。基于 docs/sjtu-apis.md 的 Canvas 实测记录重写,补齐端点表、字段语义、下载链路和常见问题。

Clone this wiki locally