Repository navigation
Practice Network
ChangYo edited this page Oct 26, 2026
·
3 revisions
校园接口的差异主要集中在三处:翻页、限流和错误判断。这页归纳多个平台的通用模式,各平台页只写差异。
相关:Canvas、Timetable、Shuiyuan、Classroom-Video、Open API、选课社区、第二课堂、传承·交大。
| 平台 | 分页方式 | 关键字段 | 注意 |
|---|---|---|---|
| Canvas |
Link 响应头 |
rel="next"、rel="last"
|
per_page 被限制为 50;必须跟随 Link |
| Open API |
limit + start 或 pageToken
|
nextToken、total
|
start 和 pageToken 同时给时忽略 pageToken
|
| 数据资源 API |
first + offset
|
first、offset、total
|
offset 最大 200;first 是起始位置,默认 1 |
| 课堂视频 |
page.pageIndex + page.pageSize
|
pageCount、records
|
空页可能返回 records: null;后续页不能用零总数清空 |
| 水源 |
more_topics_url 或 page
|
more_topics_url、grouped_search_result
|
URL 要补 .json;搜索用 page,主题流用 more URL |
| 选课社区 |
page + page_size
|
items、total、page、page_size
|
默认 20;数组参数重复 key |
| 第二课堂 |
page + per_page
|
data[] + 顶层 total
|
总数不在分页对象里 |
| 传承·交大 | 无分页 |
data.classes、data.unarchived
|
一次返回整门课,约 780 KB;按班级切片 |
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, 'https://oc.sjtu.edu.cn/api/v1/');
if (url.origin !== 'https://oc.sjtu.edu.cn' || !url.pathname.startsWith('/api/v1/')) {
throw new Error('分页地址无效');
}
return url.href;
}function nextTopicsPath(moreUrl: string | null): string | null {
if (!moreUrl) return null;
const url = new URL(moreUrl, 'https://shuiyuan.sjtu.edu.cn');
if (url.origin !== 'https://shuiyuan.sjtu.edu.cn') throw new Error('分页地址无效');
return `${url.pathname.replace(/\.json$/, '')}.json${url.search}`;
}async function listAll<T>(
firstUrl: string,
fetchPage: (url: string) => Promise<{ items: T[]; next: string | null }>,
): Promise<T[]> {
let url: string | null = firstUrl;
const visited = new Set<string>();
const result: T[] = [];
while (url) {
if (visited.has(url)) throw new Error('服务端返回了重复分页');
visited.add(url);
const page = await fetchPage(url);
result.push(...page.items);
url = page.next;
}
return result;
}三条通用规则:
-
不要自己拼
page,除非文档明确说可以。 - 记录已访问 URL,防止重复分页死循环。
- 限制总页数/总条数,防止异常响应把内存打爆。
校园接口比 500 更容易误判的情况,是「看起来成功但没有数据」。
| 表现 | 可能含义 | 处理 |
|---|---|---|
HTTP 200 + entities: []
|
真的没有数据 | 显示空状态,保留旧缓存 |
HTTP 200 + errno != 0
|
业务错误 | 按 errno 处理,不要看 HTTP 200 就当成功 |
HTTP 200 + records: null
|
课堂视频空页 | 只在第一页且明确零记录时当空;否则报错 |
HTTP 200 + success: false
|
传承业务失败 | 读 message,不要看 HTTP |
HTTP 200 + code: 401
|
第二课堂未登录 | 按登录失效处理 |
| HTTP 200 + HTML | 登录页/错误页 | 按登录失效或接口变化处理 |
| HTTP 302 | 登录失效/重定向 | 不要自动跟随到外域;重新登录 |
| HTTP 401 | 未认证或无权访问 | 先探活;区分全局失效和单资源无权限 |
| HTTP 403 | 禁止 | 可能是权限、scope、CSRF、重复操作、限流 |
| HTTP 404 | 资源/功能不存在 | 分支跳过;不要当整站失败 |
| HTTP 429 | 限流 | 读 Retry-After,退避 |
原则:同步失败不清空缓存。空响应只有在你能证明「服务端明确说零条」时才当空;否则保留旧数据并标错误。
| 平台 | 成功 | 业务错误 | 备注 |
|---|---|---|---|
| Open API | errno: 0 |
errno != 0 + error
|
403 常见 10002
|
| 数据资源 API | errno: 0 |
errno != 0 |
1002 权限、1004 访问限制;没有 1007 |
| InfoPlus | errno: 0 |
errno + ecode + error
|
语法正确也可能 HTTP 200 + errno |
| Canvas | HTTP 2xx + JSON | HTTP 4xx/5xx + JSON errors | 401 body 是中文 |
| 水源 | HTTP 2xx + JSON | HTTP 4xx + errors[]
|
403 多义 |
| 课堂视频 |
ok: true / code: null
|
ok: false / dataOk: false / status != 200
|
未登录可能是空列表 |
| 选课社区 | HTTP 2xx + JSON | HTTP 4xx + {"error": "..."}
|
401/403/429 语义不同 |
| 第二课堂 | code: 200 |
code: 401/404 + message_key
|
HTTP 始终 200 |
| 传承·交大 | success: true |
success: false + message
|
HTTP 401 也是这个信封 |
写一个统一的 ApiError:
class ApiError extends Error {
constructor(
message: string,
readonly status: number,
readonly code?: string | number,
readonly retryable = false,
) {
super(message);
}
}| 情况 | 重试 | 说明 |
|---|---|---|
| 网络超时、连接重置 | 是 | 1 次,250ms 起 |
| 408、500、502、503、504 | 是 | 1–2 次,指数退避 |
| 429 | 是 | 优先 Retry-After
|
| 401 / 403 | 否 | 重新登录或检查权限,不要盲目重试 |
| 400 / 404 / 422 | 否 | 参数/资源错误,重试无意义 |
| 写操作超时 | 谨慎 | 可能已经成功;用幂等键/查询确认 |
async function withRetry<T>(
task: () => Promise<T>,
options: { attempts?: number; baseMs?: number } = {},
): Promise<T> {
const attempts = options.attempts ?? 2;
const baseMs = options.baseMs ?? 250;
let lastError: unknown;
for (let attempt = 0; attempt < attempts; attempt++) {
try {
return await task();
} catch (error) {
lastError = error;
if (attempt === attempts - 1) break;
const retryable = error instanceof ApiError && error.retryable;
if (!retryable) break;
await new Promise((resolve) => setTimeout(resolve, baseMs * 2 ** attempt + Math.random() * 100));
}
}
throw lastError;
}- Canvas:
X-Rate-Limit-Remaining600,X-Request-Cost按请求扣减。[实测] - 水源:没有暴露
X-RateLimit-*;触发时看Retry-After。[实测] - 课堂视频:未观察到限流;不要压测。
- 选课社区:投票有每日上限,超限 429;其他接口未观察到明确限流。
[实测 2026-10-26] - 第二课堂、传承:未观察到明确限流;按小时级刷新,不要轮询。
- 通用:同步多门课串行或 2–3 并发;不要每门课开 10 个请求。
- 点赞:重复点赞返回 403,视为「已赞」,不要报错。
- 书签:创建前先查收藏列表,避免重复。
- 发信:SMTP 超时不等于失败;用
Message-ID去重,查已发送。 - 回复/私信:客户端生成幂等键(如果站点支持);否则用户手动重试。
- 写操作要有用户确认;不要在后台自动重放。
日志只记:
- 平台、方法、路径(去掉 query 里的 token)
- 状态码、业务错误码、耗时
- 重试次数、最终结果
- 请求 ID(如果响应有)
不要记:
-
Authorization、Cookie、User-Api-Key、签名 URL - 邮件正文、私信、成绩、表单字段
- 完整响应体(除非脱敏)
-
per_page被静默限制:不要相信请求参数,跟Link。 - 自己拼
page:可能和站点的 page 起点不一致(水源page=1是首页,more URL 从 page=0 起算)。 - 空页清空列表:后续页返回空/零总数时,不要清掉已取结果。
- 重复分页死循环:记录 URL。
- HTTP 200 就当成功:Open API/InfoPlus 的业务错误在
errno。 - 401 就清 Token:先探活,区分单资源无权限。
- 403 就重新授权:可能是 CSRF、scope、重复操作、限流。
- 写超时当失败:可能已经成功;先查状态。
- 并发轰炸:串行/小并发,读
Retry-After。 - 日志泄露凭据:路径 query、错误对象、堆栈都可能带 token。
- 只看 HTTP 状态:第二课堂 200 +
code: 401,传承 200 +success: false,都要读 body。 - 把对象当数组:传承
/material返回{classes, unarchived},不是数组。 - 没有分页却按页拉:传承一次返回整门课,不要自己拼
page。
- Canvas 分页/限流:
[实测 2026-09-10] - 水源 more URL/搜索分页/403 多义:
[实测 2026-09-15/21] - 课堂视频
records: null、分页:[实测 2026-10-06] - Open API/数据资源 API/InfoPlus 错误码与分页:
[官方 2026-10-25] - 选课社区分页/错误码、第二课堂
total、传承无分页:[实测 2026-10-26]
- 2026-10-25:首版。抽取五个平台的分页、限流、错误模式。
- 2026-10-26:补选课社区、第二课堂、传承·交大的分页与错误信封。
本 Wiki 记录的是开发实践,不是学校官方承诺。每个结论都标注证据与日期;校园平台会调整字段、权限和登录方式,动手前请按页面里的「最后验证」复核。发现过期内容请提 Issue 或直接改。