@robertying/thu-learn-lib 0.0.0-stage → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +324 -2
- package/lib/auth.d.ts +11 -0
- package/lib/auth.d.ts.map +1 -0
- package/lib/auth.js +99 -0
- package/lib/auth.js.map +1 -0
- package/lib/calendar.d.ts +16 -0
- package/lib/calendar.d.ts.map +1 -0
- package/lib/calendar.js +81 -0
- package/lib/calendar.js.map +1 -0
- package/lib/contents.d.ts +9 -0
- package/lib/contents.d.ts.map +1 -0
- package/lib/contents.js +51 -0
- package/lib/contents.js.map +1 -0
- package/lib/courses.d.ts +11 -0
- package/lib/courses.d.ts.map +1 -0
- package/lib/courses.js +94 -0
- package/lib/courses.js.map +1 -0
- package/lib/discussions.d.ts +11 -0
- package/lib/discussions.d.ts.map +1 -0
- package/lib/discussions.js +56 -0
- package/lib/discussions.js.map +1 -0
- package/lib/favorites.d.ts +35 -0
- package/lib/favorites.d.ts.map +1 -0
- package/lib/favorites.js +150 -0
- package/lib/favorites.js.map +1 -0
- package/lib/files.d.ts +13 -0
- package/lib/files.d.ts.map +1 -0
- package/lib/files.js +167 -0
- package/lib/files.js.map +1 -0
- package/lib/homework.d.ts +17 -0
- package/lib/homework.d.ts.map +1 -0
- package/lib/homework.js +194 -0
- package/lib/homework.js.map +1 -0
- package/lib/index.d.ts +134 -0
- package/lib/index.d.ts.map +1 -0
- package/lib/index.js +219 -0
- package/lib/index.js.map +1 -0
- package/lib/notices.d.ts +14 -0
- package/lib/notices.d.ts.map +1 -0
- package/lib/notices.js +92 -0
- package/lib/notices.js.map +1 -0
- package/lib/questionnaires.d.ts +7 -0
- package/lib/questionnaires.d.ts.map +1 -0
- package/lib/questionnaires.js +62 -0
- package/lib/questionnaires.js.map +1 -0
- package/lib/request.d.ts +57 -0
- package/lib/request.d.ts.map +1 -0
- package/lib/request.js +197 -0
- package/lib/request.js.map +1 -0
- package/lib/types.d.ts +433 -0
- package/lib/types.d.ts.map +1 -0
- package/lib/types.js +98 -0
- package/lib/types.js.map +1 -0
- package/lib/urls.d.ts +78 -0
- package/lib/urls.d.ts.map +1 -0
- package/lib/urls.js +160 -0
- package/lib/urls.js.map +1 -0
- package/lib/user.d.ts +7 -0
- package/lib/user.d.ts.map +1 -0
- package/lib/user.js +22 -0
- package/lib/user.js.map +1 -0
- package/lib/utils.d.ts +16 -0
- package/lib/utils.d.ts.map +1 -0
- package/lib/utils.js +117 -0
- package/lib/utils.js.map +1 -0
- package/package.json +67 -4
- package/src/auth.ts +121 -0
- package/src/calendar.ts +102 -0
- package/src/contents.ts +63 -0
- package/src/courses.ts +111 -0
- package/src/discussions.ts +80 -0
- package/src/favorites.ts +167 -0
- package/src/files.ts +197 -0
- package/src/homework.ts +284 -0
- package/src/index.ts +345 -0
- package/src/notices.ts +115 -0
- package/src/questionnaires.ts +76 -0
- package/src/request.ts +214 -0
- package/src/types.ts +494 -0
- package/src/urls.ts +257 -0
- package/src/user.ts +28 -0
- package/src/utils.ts +125 -0
package/src/request.ts
ADDED
|
@@ -0,0 +1,214 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
2
|
+
import type { Element } from 'cheerio';
|
|
3
|
+
import fetchCookie from 'fetch-cookie';
|
|
4
|
+
import { fetch } from 'node-fetch-native';
|
|
5
|
+
import { CookieJar } from 'tough-cookie';
|
|
6
|
+
|
|
7
|
+
import type { ApiError, CredentialProvider, Fetch, HelperConfig } from './types.ts';
|
|
8
|
+
import { FailReason, Language } from './types.ts';
|
|
9
|
+
|
|
10
|
+
const CHEERIO_CONFIG: cheerio.CheerioOptions = { xml: true };
|
|
11
|
+
|
|
12
|
+
export const $ = (html: string | Element | Element[]): cheerio.CheerioAPI => {
|
|
13
|
+
return cheerio.load(html, CHEERIO_CONFIG);
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export const noLogin = (res: Response) => res.url.includes('login_timeout') || res.status === 403;
|
|
17
|
+
|
|
18
|
+
export const YES = '是';
|
|
19
|
+
|
|
20
|
+
/** default for `HelperConfig.maxConcurrentRequests` */
|
|
21
|
+
export const DEFAULT_MAX_CONCURRENT_REQUESTS = 6;
|
|
22
|
+
/** default for `HelperConfig.requestTimeout`, in milliseconds */
|
|
23
|
+
export const DEFAULT_REQUEST_TIMEOUT = 60_000;
|
|
24
|
+
|
|
25
|
+
/** add CSRF token to any request URL as parameters */
|
|
26
|
+
export const addCSRFTokenToUrl = (url: string, token: string): string => {
|
|
27
|
+
const newUrl = new URL(url);
|
|
28
|
+
newUrl.searchParams.set('_csrf', token);
|
|
29
|
+
return newUrl.toString();
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
function makeFetch(jar?: CookieJar): typeof fetch {
|
|
33
|
+
return fetchCookie(fetch, jar);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const urlOf = (input: Parameters<Fetch>[0]): string =>
|
|
37
|
+
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Let at most `max` calls of `inner` run at once; the rest wait in FIFO order. A slot is freed when the response
|
|
41
|
+
* headers arrive (or the call fails), so reading a body does not hold one.
|
|
42
|
+
*/
|
|
43
|
+
export function limitConcurrency(inner: Fetch, max: number): Fetch {
|
|
44
|
+
if (!(max >= 1) || max === Infinity) return inner;
|
|
45
|
+
let active = 0;
|
|
46
|
+
const waiting: (() => void)[] = [];
|
|
47
|
+
const acquire = (): Promise<void> => {
|
|
48
|
+
if (active < max) {
|
|
49
|
+
active += 1;
|
|
50
|
+
return Promise.resolve();
|
|
51
|
+
}
|
|
52
|
+
return new Promise((resolve) => waiting.push(resolve));
|
|
53
|
+
};
|
|
54
|
+
const release = () => {
|
|
55
|
+
const next = waiting.shift();
|
|
56
|
+
// hand the slot straight to the next caller, so `active` stays the same
|
|
57
|
+
if (next) next();
|
|
58
|
+
else active -= 1;
|
|
59
|
+
};
|
|
60
|
+
return async (...args) => {
|
|
61
|
+
await acquire();
|
|
62
|
+
try {
|
|
63
|
+
return await inner(...args);
|
|
64
|
+
} finally {
|
|
65
|
+
release();
|
|
66
|
+
}
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Abort a call of `inner` that has no response headers after `ms` milliseconds and reject it with
|
|
72
|
+
* `FailReason.REQUEST_TIMEOUT`. A caller's own `signal` still works. Uses only `AbortController` and `setTimeout`,
|
|
73
|
+
* which React Native has (unlike `AbortSignal.timeout` / `AbortSignal.any`).
|
|
74
|
+
*/
|
|
75
|
+
export function withTimeout(inner: Fetch, ms: number): Fetch {
|
|
76
|
+
if (!(ms > 0) || ms === Infinity) return inner;
|
|
77
|
+
return async (input, init) => {
|
|
78
|
+
const controller = new AbortController();
|
|
79
|
+
const outer = init?.signal ?? undefined;
|
|
80
|
+
const forward = () => controller.abort(outer?.reason);
|
|
81
|
+
if (outer?.aborted) forward();
|
|
82
|
+
else outer?.addEventListener('abort', forward, { once: true });
|
|
83
|
+
let timedOut = false;
|
|
84
|
+
const timer = setTimeout(() => {
|
|
85
|
+
timedOut = true;
|
|
86
|
+
controller.abort();
|
|
87
|
+
}, ms);
|
|
88
|
+
try {
|
|
89
|
+
return await inner(input, { ...init, signal: controller.signal });
|
|
90
|
+
} catch (err) {
|
|
91
|
+
if (timedOut) {
|
|
92
|
+
throw {
|
|
93
|
+
reason: FailReason.REQUEST_TIMEOUT,
|
|
94
|
+
extra: { url: urlOf(input), timeout: ms },
|
|
95
|
+
} as ApiError;
|
|
96
|
+
}
|
|
97
|
+
throw err;
|
|
98
|
+
} finally {
|
|
99
|
+
clearTimeout(timer);
|
|
100
|
+
outer?.removeEventListener('abort', forward);
|
|
101
|
+
}
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** free the connection of a response we won't read */
|
|
106
|
+
const discard = (res: Response) => {
|
|
107
|
+
res.body?.cancel().catch(() => {});
|
|
108
|
+
};
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
|
|
112
|
+
* Internal; the helper keeps it in a private field.
|
|
113
|
+
*/
|
|
114
|
+
export class Session {
|
|
115
|
+
readonly provider?: CredentialProvider;
|
|
116
|
+
readonly cookieJar: CookieJar;
|
|
117
|
+
/** fetch with cookies, the concurrency cap and the timeout, but no login check */
|
|
118
|
+
readonly rawFetch: Fetch;
|
|
119
|
+
#csrfToken = '';
|
|
120
|
+
/** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
|
|
121
|
+
#generation = 0;
|
|
122
|
+
#pendingLogin?: Promise<void>;
|
|
123
|
+
lang = Language.ZH;
|
|
124
|
+
previewFirstPage: boolean;
|
|
125
|
+
|
|
126
|
+
/** `login` signs in through SSO with the `CredentialProvider`; the session calls it at most once at a time */
|
|
127
|
+
constructor(
|
|
128
|
+
config: HelperConfig | undefined,
|
|
129
|
+
readonly login: () => Promise<void>,
|
|
130
|
+
) {
|
|
131
|
+
this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
|
|
132
|
+
this.provider = config?.provider;
|
|
133
|
+
this.cookieJar = config?.cookieJar ?? new CookieJar();
|
|
134
|
+
this.rawFetch = limitConcurrency(
|
|
135
|
+
withTimeout(config?.fetch ?? makeFetch(this.cookieJar), config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT),
|
|
136
|
+
config?.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
get csrfToken(): string {
|
|
141
|
+
return this.#csrfToken;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
set csrfToken(token: string) {
|
|
145
|
+
this.#csrfToken = token;
|
|
146
|
+
this.#generation += 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Log in with the `CredentialProvider`. Concurrent callers share one SSO login instead of starting one each.
|
|
151
|
+
*/
|
|
152
|
+
relogin(): Promise<void> {
|
|
153
|
+
this.#pendingLogin ??= this.login().finally(() => {
|
|
154
|
+
this.#pendingLogin = undefined;
|
|
155
|
+
});
|
|
156
|
+
return this.#pendingLogin;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token).
|
|
161
|
+
* When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
|
|
162
|
+
* logs in again (sharing a login already in progress, or skipping it if another request already renewed the
|
|
163
|
+
* session) and retries once.
|
|
164
|
+
*/
|
|
165
|
+
async #send(makeUrl: () => string, init?: RequestInit): Promise<Response> {
|
|
166
|
+
const generation = this.#generation;
|
|
167
|
+
const res = await this.rawFetch(makeUrl(), init);
|
|
168
|
+
if (!noLogin(res)) return res;
|
|
169
|
+
discard(res);
|
|
170
|
+
if (!this.provider) {
|
|
171
|
+
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
172
|
+
}
|
|
173
|
+
if (this.#generation === generation) {
|
|
174
|
+
await this.relogin();
|
|
175
|
+
}
|
|
176
|
+
const retry = await this.rawFetch(makeUrl(), init);
|
|
177
|
+
if (noLogin(retry)) {
|
|
178
|
+
discard(retry);
|
|
179
|
+
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
180
|
+
}
|
|
181
|
+
if (retry.status !== 200) {
|
|
182
|
+
discard(retry);
|
|
183
|
+
throw {
|
|
184
|
+
reason: FailReason.UNEXPECTED_STATUS,
|
|
185
|
+
extra: { code: retry.status, text: retry.statusText },
|
|
186
|
+
} as ApiError;
|
|
187
|
+
}
|
|
188
|
+
return retry;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** fetch with a login check (see `#send`) */
|
|
192
|
+
readonly fetch: Fetch = (input, init) => this.#send(() => urlOf(input), init);
|
|
193
|
+
|
|
194
|
+
/** like `fetch`, but logs in first if there is no CSRF token yet and adds the current token to the URL */
|
|
195
|
+
readonly fetchWithToken: Fetch = async (input, init) => {
|
|
196
|
+
if (this.#csrfToken === '') {
|
|
197
|
+
await this.relogin();
|
|
198
|
+
}
|
|
199
|
+
const url = urlOf(input);
|
|
200
|
+
return this.#send(() => addCSRFTokenToUrl(url, this.#csrfToken), init);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Make sure the session works: if there is a CSRF token, make one cheap authenticated request (`probeUrl`) and log
|
|
205
|
+
* in only if it fails; otherwise log in.
|
|
206
|
+
*/
|
|
207
|
+
async ensureSession(probeUrl: string): Promise<void> {
|
|
208
|
+
if (this.#csrfToken === '') {
|
|
209
|
+
await this.relogin();
|
|
210
|
+
return;
|
|
211
|
+
}
|
|
212
|
+
discard(await this.fetchWithToken(probeUrl));
|
|
213
|
+
}
|
|
214
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,494 @@
|
|
|
1
|
+
import type { CookieJar } from 'tough-cookie';
|
|
2
|
+
|
|
3
|
+
export type Fetch = typeof globalThis.fetch;
|
|
4
|
+
export type Credential = {
|
|
5
|
+
username?: string;
|
|
6
|
+
password?: string;
|
|
7
|
+
fingerPrint?: string;
|
|
8
|
+
fingerGenPrint?: string;
|
|
9
|
+
fingerGenPrint3?: string;
|
|
10
|
+
};
|
|
11
|
+
export type CredentialProvider = () => Credential | Promise<Credential>;
|
|
12
|
+
export type HelperConfig = {
|
|
13
|
+
provider?: CredentialProvider;
|
|
14
|
+
fetch?: Fetch;
|
|
15
|
+
cookieJar?: CookieJar;
|
|
16
|
+
generatePreviewUrlForFirstPage?: boolean;
|
|
17
|
+
/** at most this many requests in flight at once, including login (default 6; `Infinity` for no limit) */
|
|
18
|
+
maxConcurrentRequests?: number;
|
|
19
|
+
/** milliseconds to wait for a response before rejecting with `FailReason.REQUEST_TIMEOUT` (default 60000; 0 for none) */
|
|
20
|
+
requestTimeout?: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export enum FailReason {
|
|
24
|
+
NO_CREDENTIAL = 'no credential provided',
|
|
25
|
+
ERROR_FETCH_FROM_ID = 'could not fetch ticket from id.tsinghua.edu.cn',
|
|
26
|
+
BAD_CREDENTIAL = 'bad credential',
|
|
27
|
+
ERROR_ROAMING = 'could not roam to learn.tsinghua.edu.cn',
|
|
28
|
+
NOT_LOGGED_IN = 'not logged in or login timeout',
|
|
29
|
+
NOT_IMPLEMENTED = 'not implemented',
|
|
30
|
+
INVALID_RESPONSE = 'invalid response',
|
|
31
|
+
UNEXPECTED_STATUS = 'unexpected status',
|
|
32
|
+
OPERATION_FAILED = 'operation failed',
|
|
33
|
+
ERROR_SETTING_COOKIES = 'could not set cookies',
|
|
34
|
+
REQUEST_TIMEOUT = 'request timed out',
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ApiError {
|
|
38
|
+
reason: FailReason;
|
|
39
|
+
extra?: unknown;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export enum SemesterType {
|
|
43
|
+
FALL = 'fall',
|
|
44
|
+
SPRING = 'spring',
|
|
45
|
+
SUMMER = 'summer',
|
|
46
|
+
UNKNOWN = '',
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export enum ContentType {
|
|
50
|
+
NOTIFICATION = 'notification',
|
|
51
|
+
FILE = 'file',
|
|
52
|
+
HOMEWORK = 'homework',
|
|
53
|
+
DISCUSSION = 'discussion',
|
|
54
|
+
QUESTION = 'question',
|
|
55
|
+
QUESTIONNAIRE = 'questionnaire',
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface IUserInfo {
|
|
59
|
+
name: string;
|
|
60
|
+
department: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export type UserInfo = IUserInfo;
|
|
64
|
+
|
|
65
|
+
interface ISemesterInfo {
|
|
66
|
+
id: string;
|
|
67
|
+
startDate: string;
|
|
68
|
+
endDate: string;
|
|
69
|
+
startYear: number;
|
|
70
|
+
endYear: number;
|
|
71
|
+
type: SemesterType;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export type SemesterInfo = ISemesterInfo;
|
|
75
|
+
|
|
76
|
+
export enum CourseType {
|
|
77
|
+
STUDENT = 'student',
|
|
78
|
+
TEACHER = 'teacher',
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
interface ICourseInfo {
|
|
82
|
+
id: string;
|
|
83
|
+
name: string;
|
|
84
|
+
chineseName: string;
|
|
85
|
+
englishName: string;
|
|
86
|
+
/** only with `getCourseList(..., { timeAndLocation: true })`; see `getCourseTimeAndLocation` */
|
|
87
|
+
timeAndLocation?: string[];
|
|
88
|
+
url: string;
|
|
89
|
+
teacherName: string;
|
|
90
|
+
teacherNumber: string;
|
|
91
|
+
courseNumber: string;
|
|
92
|
+
courseIndex: number;
|
|
93
|
+
courseType: CourseType;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export type CourseInfo = ICourseInfo;
|
|
97
|
+
|
|
98
|
+
interface IRemoteFile {
|
|
99
|
+
id: string;
|
|
100
|
+
name: string;
|
|
101
|
+
downloadUrl: string;
|
|
102
|
+
previewUrl: string;
|
|
103
|
+
size: string;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export type RemoteFile = IRemoteFile;
|
|
107
|
+
|
|
108
|
+
export interface INotification {
|
|
109
|
+
id: string;
|
|
110
|
+
title: string;
|
|
111
|
+
content: string;
|
|
112
|
+
hasRead: boolean;
|
|
113
|
+
url: string;
|
|
114
|
+
markedImportant: boolean;
|
|
115
|
+
publishTime: string;
|
|
116
|
+
publisher: string;
|
|
117
|
+
expireTime?: string;
|
|
118
|
+
isFavorite: boolean;
|
|
119
|
+
comment?: string;
|
|
120
|
+
/** name of the attachment, if the notice has one; fetch it with `getNotificationAttachment` */
|
|
121
|
+
attachmentName?: string;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
export interface INotificationDetail {
|
|
125
|
+
attachment?: RemoteFile;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
export type Notification = INotification & INotificationDetail;
|
|
129
|
+
|
|
130
|
+
interface IFileCategory {
|
|
131
|
+
id: string;
|
|
132
|
+
title: string;
|
|
133
|
+
creationTime: Date;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export type FileCategory = IFileCategory;
|
|
137
|
+
|
|
138
|
+
interface IFile {
|
|
139
|
+
/** previously `id2` */
|
|
140
|
+
id: string;
|
|
141
|
+
/** previously `id` */
|
|
142
|
+
fileId: string;
|
|
143
|
+
/** note: will be unset when calling `getFileListByCategory` */
|
|
144
|
+
category?: FileCategory;
|
|
145
|
+
/** size in byte */
|
|
146
|
+
rawSize: number;
|
|
147
|
+
/** inaccurate size description (like '1M') */
|
|
148
|
+
size: string;
|
|
149
|
+
title: string;
|
|
150
|
+
description: string;
|
|
151
|
+
uploadTime: string;
|
|
152
|
+
publishTime: string;
|
|
153
|
+
downloadUrl: string;
|
|
154
|
+
/** preview is not supported on all types of files, check before use */
|
|
155
|
+
previewUrl: string;
|
|
156
|
+
isNew: boolean;
|
|
157
|
+
markedImportant: boolean;
|
|
158
|
+
visitCount: number;
|
|
159
|
+
downloadCount: number;
|
|
160
|
+
fileType: string;
|
|
161
|
+
/** for compatibility */
|
|
162
|
+
remoteFile: RemoteFile;
|
|
163
|
+
/** could not get favorite or comment info when using `getFileList` or in TA mode */
|
|
164
|
+
isFavorite?: boolean;
|
|
165
|
+
comment?: string;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
export type File = IFile;
|
|
169
|
+
|
|
170
|
+
export interface IHomeworkStatus {
|
|
171
|
+
submitted: boolean;
|
|
172
|
+
graded: boolean;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export enum HomeworkGradeLevel {
|
|
176
|
+
/** 已阅 */
|
|
177
|
+
CHECKED = 'checked',
|
|
178
|
+
A_PLUS = 'A+',
|
|
179
|
+
A = 'A',
|
|
180
|
+
A_MINUS = 'A-',
|
|
181
|
+
B_PLUS = 'B+',
|
|
182
|
+
/** 优秀 */
|
|
183
|
+
DISTINCTION = 'distinction',
|
|
184
|
+
B = 'B',
|
|
185
|
+
B_MINUS = 'B-',
|
|
186
|
+
C_PLUS = 'C+',
|
|
187
|
+
C = 'C',
|
|
188
|
+
C_MINUS = 'C-',
|
|
189
|
+
G = 'G',
|
|
190
|
+
D_PLUS = 'D+',
|
|
191
|
+
D = 'D',
|
|
192
|
+
/** 免课 */
|
|
193
|
+
EXEMPTED_COURSE = 'exempted course',
|
|
194
|
+
P = 'P',
|
|
195
|
+
EX = 'EX',
|
|
196
|
+
/** 免修 */
|
|
197
|
+
EXEMPTION = 'exemption',
|
|
198
|
+
/** 通过 */
|
|
199
|
+
PASS = 'pass',
|
|
200
|
+
/** 不通过 */
|
|
201
|
+
FAILURE = 'failure',
|
|
202
|
+
W = 'W',
|
|
203
|
+
I = 'I',
|
|
204
|
+
/** 缓考 */
|
|
205
|
+
INCOMPLETE = 'incomplete',
|
|
206
|
+
NA = 'NA',
|
|
207
|
+
F = 'F',
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
export interface IHomework extends IHomeworkStatus {
|
|
211
|
+
id: string;
|
|
212
|
+
/** @deprecated use `id` */
|
|
213
|
+
studentHomeworkId: string;
|
|
214
|
+
baseId: string;
|
|
215
|
+
title: string;
|
|
216
|
+
deadline: string;
|
|
217
|
+
lateSubmissionDeadline?: string;
|
|
218
|
+
url: string;
|
|
219
|
+
completionType: HomeworkCompletionType;
|
|
220
|
+
submissionType: HomeworkSubmissionType;
|
|
221
|
+
submitUrl: string;
|
|
222
|
+
submitTime?: string;
|
|
223
|
+
isLateSubmission: boolean;
|
|
224
|
+
grade?: number;
|
|
225
|
+
/** some homework has levels but not grades, like A/B/.../F */
|
|
226
|
+
gradeLevel?: HomeworkGradeLevel;
|
|
227
|
+
gradeTime?: string;
|
|
228
|
+
graderName?: string;
|
|
229
|
+
gradeContent?: string;
|
|
230
|
+
isFavorite: boolean;
|
|
231
|
+
favoriteTime?: string;
|
|
232
|
+
comment?: string;
|
|
233
|
+
excellentHomeworkList?: ExcellentHomework[];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export interface IHomeworkDetail {
|
|
237
|
+
description?: string;
|
|
238
|
+
/** attachment from teacher */
|
|
239
|
+
attachment?: RemoteFile;
|
|
240
|
+
/** answer from teacher */
|
|
241
|
+
answerContent?: string;
|
|
242
|
+
answerAttachment?: RemoteFile;
|
|
243
|
+
/** submitted content from student */
|
|
244
|
+
submittedContent?: string;
|
|
245
|
+
submittedAttachment?: RemoteFile;
|
|
246
|
+
/** grade from teacher */
|
|
247
|
+
gradeAttachment?: RemoteFile;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export type Homework = IHomework & IHomeworkDetail;
|
|
251
|
+
|
|
252
|
+
export enum HomeworkCompletionType {
|
|
253
|
+
INDIVIDUAL = 1,
|
|
254
|
+
GROUP = 2,
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export enum HomeworkSubmissionType {
|
|
258
|
+
WEB_LEARNING = 2,
|
|
259
|
+
OFFLINE = 0,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
export interface IExcellentHomework {
|
|
263
|
+
id: string;
|
|
264
|
+
baseId: string;
|
|
265
|
+
title: string;
|
|
266
|
+
url: string;
|
|
267
|
+
completionType: HomeworkCompletionType;
|
|
268
|
+
author: {
|
|
269
|
+
id: string;
|
|
270
|
+
name: string;
|
|
271
|
+
anonymous: boolean;
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
export type ExcellentHomework = IExcellentHomework & IHomeworkDetail;
|
|
276
|
+
|
|
277
|
+
export interface IHomeworkTA {
|
|
278
|
+
id: string;
|
|
279
|
+
index: number;
|
|
280
|
+
title: string;
|
|
281
|
+
description: string;
|
|
282
|
+
publisherId: string;
|
|
283
|
+
publishTime: string;
|
|
284
|
+
startTime: string;
|
|
285
|
+
deadline: string;
|
|
286
|
+
lateSubmissionDeadline?: string;
|
|
287
|
+
url: string;
|
|
288
|
+
completionType: HomeworkCompletionType;
|
|
289
|
+
submissionType: HomeworkSubmissionType;
|
|
290
|
+
gradedCount: number;
|
|
291
|
+
submittedCount: number;
|
|
292
|
+
unsubmittedCount: number;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export type HomeworkTA = IHomeworkTA;
|
|
296
|
+
|
|
297
|
+
export interface IHomeworkSubmitAttachment {
|
|
298
|
+
filename: string;
|
|
299
|
+
content: Blob;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface IDiscussionBase {
|
|
303
|
+
id: string;
|
|
304
|
+
title: string;
|
|
305
|
+
publisherName: string;
|
|
306
|
+
publishTime: string;
|
|
307
|
+
lastReplierName: string;
|
|
308
|
+
lastReplyTime: string;
|
|
309
|
+
visitCount: number;
|
|
310
|
+
replyCount: number;
|
|
311
|
+
isFavorite: boolean;
|
|
312
|
+
comment?: string;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
interface IDiscussion extends IDiscussionBase {
|
|
316
|
+
url: string;
|
|
317
|
+
boardId: string;
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export type Discussion = IDiscussion;
|
|
321
|
+
|
|
322
|
+
interface IQuestion extends IDiscussionBase {
|
|
323
|
+
url: string;
|
|
324
|
+
question: string;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export type Question = IQuestion;
|
|
328
|
+
|
|
329
|
+
export enum QuestionnaireDetailType {
|
|
330
|
+
SINGLE = 'dnx',
|
|
331
|
+
MULTI = 'dox',
|
|
332
|
+
TEXT = 'wd',
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export interface QuestionnaireDetail {
|
|
336
|
+
id: string;
|
|
337
|
+
index: number;
|
|
338
|
+
type: QuestionnaireDetailType;
|
|
339
|
+
required: boolean;
|
|
340
|
+
title: string;
|
|
341
|
+
score?: number;
|
|
342
|
+
options?: {
|
|
343
|
+
id: string;
|
|
344
|
+
index: number;
|
|
345
|
+
title: string;
|
|
346
|
+
}[];
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
export enum QuestionnaireType {
|
|
350
|
+
VOTE = 'tp',
|
|
351
|
+
FORM = 'tb',
|
|
352
|
+
SURVEY = 'wj',
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
export interface IQuestionnaire {
|
|
356
|
+
id: string;
|
|
357
|
+
type: QuestionnaireType;
|
|
358
|
+
title: string;
|
|
359
|
+
startTime: string;
|
|
360
|
+
endTime: string;
|
|
361
|
+
uploadTime: string;
|
|
362
|
+
uploaderId: string;
|
|
363
|
+
uploaderName: string;
|
|
364
|
+
submitTime?: string;
|
|
365
|
+
isFavorite: boolean;
|
|
366
|
+
comment?: string;
|
|
367
|
+
url: string;
|
|
368
|
+
detail: QuestionnaireDetail[];
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
export type Questionnaire = IQuestionnaire;
|
|
372
|
+
|
|
373
|
+
/** which items get their extra per-item requests: none (default), all, or those the predicate accepts */
|
|
374
|
+
export type DetailSelector<T> = 'none' | 'all' | ((item: T) => boolean);
|
|
375
|
+
|
|
376
|
+
export interface CourseListOptions {
|
|
377
|
+
/** fetch each course's time and location (one request per course; default false) */
|
|
378
|
+
timeAndLocation?: boolean;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export interface HomeworkListOptions {
|
|
382
|
+
/**
|
|
383
|
+
* Which homework gets its detail (page + description JSON, 2 requests each); default `'none'`.
|
|
384
|
+
* The same as calling `getHomeworkDetail` for those items.
|
|
385
|
+
*/
|
|
386
|
+
details?: DetailSelector<IHomework>;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
export interface NotificationListOptions {
|
|
390
|
+
/**
|
|
391
|
+
* Which notices with an attachment get it (1 request each); default `'none'`.
|
|
392
|
+
* The same as calling `getNotificationAttachment` for those items.
|
|
393
|
+
*/
|
|
394
|
+
attachments?: DetailSelector<INotification>;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
export interface AllContentsOptions<T extends ContentType> {
|
|
398
|
+
courseType?: CourseType;
|
|
399
|
+
/** homework `details` or notification `attachments`, passed to each course's list */
|
|
400
|
+
details?: T extends ContentType.HOMEWORK
|
|
401
|
+
? DetailSelector<IHomework>
|
|
402
|
+
: T extends ContentType.NOTIFICATION
|
|
403
|
+
? DetailSelector<INotification>
|
|
404
|
+
: never;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
export interface AllContentsResult<T extends ContentType> {
|
|
408
|
+
/** content of each course that succeeded */
|
|
409
|
+
contents: CourseContent<T>;
|
|
410
|
+
/** the error of each course that failed */
|
|
411
|
+
errors: { [courseID: string]: unknown };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export type ContentTypeMap = {
|
|
415
|
+
[ContentType.NOTIFICATION]: Notification;
|
|
416
|
+
[ContentType.FILE]: File;
|
|
417
|
+
[ContentType.HOMEWORK]: Homework;
|
|
418
|
+
[ContentType.DISCUSSION]: Discussion;
|
|
419
|
+
[ContentType.QUESTION]: Question;
|
|
420
|
+
[ContentType.QUESTIONNAIRE]: Questionnaire;
|
|
421
|
+
};
|
|
422
|
+
|
|
423
|
+
interface ICourseContent<T extends ContentType> {
|
|
424
|
+
[id: string]: ContentTypeMap[T][];
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export type CourseContent<T extends ContentType = ContentType> = ICourseContent<T>;
|
|
428
|
+
|
|
429
|
+
interface IFavoriteItem {
|
|
430
|
+
id: string;
|
|
431
|
+
type: ContentType;
|
|
432
|
+
title: string;
|
|
433
|
+
time: string;
|
|
434
|
+
state: string;
|
|
435
|
+
/** extra message. For homework, this will be deadline (plus score if graded). It's too flexible and hard to parse so we leave it as is. */
|
|
436
|
+
extra?: string;
|
|
437
|
+
semesterId: string;
|
|
438
|
+
courseId: string;
|
|
439
|
+
pinned: boolean;
|
|
440
|
+
pinnedTime?: string;
|
|
441
|
+
comment?: string;
|
|
442
|
+
addedTime: string;
|
|
443
|
+
/** for reference */
|
|
444
|
+
itemId: string;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
export type FavoriteItem = IFavoriteItem;
|
|
448
|
+
|
|
449
|
+
interface ICommentItem {
|
|
450
|
+
id: string;
|
|
451
|
+
type: ContentType;
|
|
452
|
+
content: string;
|
|
453
|
+
contentHTML: string;
|
|
454
|
+
title: string;
|
|
455
|
+
semesterId: string;
|
|
456
|
+
courseId: string;
|
|
457
|
+
commentTime: string;
|
|
458
|
+
/** for reference */
|
|
459
|
+
itemId: string;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
export type CommentItem = ICommentItem;
|
|
463
|
+
|
|
464
|
+
export interface CalendarEvent {
|
|
465
|
+
location: string;
|
|
466
|
+
status: string;
|
|
467
|
+
startTime: string;
|
|
468
|
+
endTime: string;
|
|
469
|
+
date: string;
|
|
470
|
+
courseName: string;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
export interface CalendarOptions {
|
|
474
|
+
/** use the graduate calendar instead of the undergraduate one */
|
|
475
|
+
graduate?: boolean;
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
/** an inclusive date range, in yyyymmdd format */
|
|
479
|
+
export interface CalendarRange {
|
|
480
|
+
startDate: string;
|
|
481
|
+
endDate: string;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
export interface CalendarResult {
|
|
485
|
+
/** events of every window that succeeded, in date order */
|
|
486
|
+
events: CalendarEvent[];
|
|
487
|
+
/** windows that failed even after a retry; empty when the whole range was fetched */
|
|
488
|
+
failedRanges: CalendarRange[];
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
export enum Language {
|
|
492
|
+
ZH = 'zh',
|
|
493
|
+
EN = 'en',
|
|
494
|
+
}
|