@robertying/thu-learn-lib 5.0.0 → 5.1.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/README.md +41 -7
- package/lib/auth.d.ts +1 -1
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js +17 -5
- package/lib/auth.js.map +1 -1
- package/lib/calendar.d.ts +6 -4
- package/lib/calendar.d.ts.map +1 -1
- package/lib/calendar.js +21 -8
- package/lib/calendar.js.map +1 -1
- package/lib/contents.d.ts.map +1 -1
- package/lib/contents.js +10 -1
- package/lib/contents.js.map +1 -1
- package/lib/courses.d.ts.map +1 -1
- package/lib/courses.js +6 -6
- package/lib/courses.js.map +1 -1
- package/lib/discussions.js +2 -2
- package/lib/discussions.js.map +1 -1
- package/lib/favorites.d.ts.map +1 -1
- package/lib/favorites.js +12 -12
- package/lib/favorites.js.map +1 -1
- package/lib/files.d.ts +1 -1
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +10 -8
- package/lib/files.js.map +1 -1
- package/lib/homework.d.ts +1 -2
- package/lib/homework.d.ts.map +1 -1
- package/lib/homework.js +19 -17
- package/lib/homework.js.map +1 -1
- package/lib/index.d.ts +11 -6
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +13 -5
- package/lib/index.js.map +1 -1
- package/lib/notices.d.ts +1 -1
- package/lib/notices.d.ts.map +1 -1
- package/lib/notices.js +11 -15
- package/lib/notices.js.map +1 -1
- package/lib/questionnaires.d.ts +6 -3
- package/lib/questionnaires.d.ts.map +1 -1
- package/lib/questionnaires.js +20 -12
- package/lib/questionnaires.js.map +1 -1
- package/lib/request.d.ts +44 -14
- package/lib/request.d.ts.map +1 -1
- package/lib/request.js +120 -43
- package/lib/request.js.map +1 -1
- package/lib/types.d.ts +11 -3
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/user.js +1 -1
- package/lib/user.js.map +1 -1
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +9 -3
- package/lib/utils.js.map +1 -1
- package/package.json +2 -2
- package/src/auth.ts +18 -5
- package/src/calendar.ts +28 -10
- package/src/contents.ts +10 -1
- package/src/courses.ts +11 -13
- package/src/discussions.ts +2 -2
- package/src/favorites.ts +22 -32
- package/src/files.ts +12 -12
- package/src/homework.ts +30 -40
- package/src/index.ts +19 -7
- package/src/notices.ts +12 -18
- package/src/questionnaires.ts +51 -37
- package/src/request.ts +141 -42
- package/src/types.ts +14 -3
- package/src/user.ts +1 -1
- package/src/utils.ts +10 -4
package/src/notices.ts
CHANGED
|
@@ -37,12 +37,10 @@ async function getNotificationListKind(
|
|
|
37
37
|
courseType: CourseType,
|
|
38
38
|
expired: boolean,
|
|
39
39
|
): Promise<INotification[]> {
|
|
40
|
-
const json = await (
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
})
|
|
45
|
-
).json();
|
|
40
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
|
|
41
|
+
method: 'POST',
|
|
42
|
+
body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
|
|
43
|
+
});
|
|
46
44
|
if (json.result !== 'success') {
|
|
47
45
|
return Promise.reject({
|
|
48
46
|
reason: FailReason.INVALID_RESPONSE,
|
|
@@ -73,7 +71,7 @@ async function getNotificationListKind(
|
|
|
73
71
|
|
|
74
72
|
/**
|
|
75
73
|
* Get the attachment of a notice from its detail page (1 request), or `undefined` without a request if
|
|
76
|
-
* `attachmentName` says it has none.
|
|
74
|
+
* `attachmentName` says it has none, or if the page has no attachment link.
|
|
77
75
|
*/
|
|
78
76
|
export async function getNotificationAttachment(
|
|
79
77
|
s: Session,
|
|
@@ -85,21 +83,17 @@ export async function getNotificationAttachment(
|
|
|
85
83
|
// const postParams = new FormData();
|
|
86
84
|
// postParams.append('id', id);
|
|
87
85
|
// postParams.append('wlkcid', courseID);
|
|
88
|
-
// const metadata = await
|
|
86
|
+
// const metadata = await s.fetchJsonWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
|
|
89
87
|
// 'method': 'POST',
|
|
90
88
|
// 'body': postParams,
|
|
91
|
-
// })
|
|
89
|
+
// });
|
|
92
90
|
// const attachmentId = metadata.ggfjid as string;
|
|
93
91
|
/// parsed from HTML
|
|
94
|
-
const
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
99
|
-
} else {
|
|
100
|
-
path = result('#wjid').attr('href')!;
|
|
101
|
-
}
|
|
102
|
-
const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text())!;
|
|
92
|
+
const result = $(await s.fetchTextWithToken(notification.url));
|
|
93
|
+
let path = (courseType === CourseType.STUDENT ? result('.ml-10') : result('#wjid')).attr('href');
|
|
94
|
+
// the page has no attachment link (removed since the list was fetched, or the page changed)
|
|
95
|
+
if (!path) return undefined;
|
|
96
|
+
const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text()) ?? '';
|
|
103
97
|
const params = new URLSearchParams(path.split('?').slice(-1)[0]);
|
|
104
98
|
const attachmentId = params.get('wjid')!;
|
|
105
99
|
if (!path.startsWith(URLS.LEARN_PREFIX)) {
|
package/src/questionnaires.ts
CHANGED
|
@@ -2,25 +2,36 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Session } from './request.ts';
|
|
4
4
|
import { YES } from './request.ts';
|
|
5
|
-
import type { Questionnaire, QuestionnaireDetail } from './types.ts';
|
|
5
|
+
import type { ApiError, Questionnaire, QuestionnaireDetail, QuestionnaireListOptions } from './types.ts';
|
|
6
6
|
import { FailReason, QuestionnaireType } from './types.ts';
|
|
7
7
|
import * as URLS from './urls.ts';
|
|
8
|
-
import { QNR_TYPE_MAP, decodeHTML } from './utils.ts';
|
|
8
|
+
import { QNR_TYPE_MAP, decodeHTML, isSelected } from './utils.ts';
|
|
9
9
|
|
|
10
10
|
/**
|
|
11
|
-
* Get all questionnaires (课程问卷/QNR) of the specified course.
|
|
11
|
+
* Get all questionnaires (课程问卷/QNR) of the specified course: ongoing and ended lists.
|
|
12
|
+
* Questions (`detail`, 1 request each) only for the questionnaires `options.details` selects (default `'all'`).
|
|
12
13
|
*/
|
|
13
|
-
export async function getQuestionnaireList(
|
|
14
|
-
|
|
14
|
+
export async function getQuestionnaireList(
|
|
15
|
+
s: Session,
|
|
16
|
+
courseID: string,
|
|
17
|
+
options: QuestionnaireListOptions = {},
|
|
18
|
+
): Promise<Questionnaire[]> {
|
|
19
|
+
const details = options.details ?? 'all';
|
|
20
|
+
const lists = await Promise.all([
|
|
15
21
|
getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ONGOING),
|
|
16
22
|
getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ENDED),
|
|
17
|
-
])
|
|
23
|
+
]);
|
|
24
|
+
return Promise.all(
|
|
25
|
+
lists
|
|
26
|
+
.flat()
|
|
27
|
+
.map(async (q) =>
|
|
28
|
+
isSelected(details, q) ? { ...q, detail: await getQuestionnaireDetail(s, courseID, q.id) } : q,
|
|
29
|
+
),
|
|
30
|
+
);
|
|
18
31
|
}
|
|
19
32
|
|
|
20
33
|
async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: string): Promise<Questionnaire[]> {
|
|
21
|
-
const json = await (
|
|
22
|
-
await s.fetchWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) })
|
|
23
|
-
).json();
|
|
34
|
+
const json = await s.fetchJsonWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) });
|
|
24
35
|
if (json.result !== 'success') {
|
|
25
36
|
return Promise.reject({
|
|
26
37
|
reason: FailReason.INVALID_RESPONSE,
|
|
@@ -28,36 +39,39 @@ async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: stri
|
|
|
28
39
|
});
|
|
29
40
|
}
|
|
30
41
|
const result = (json.object?.aaData ?? []) as any[];
|
|
31
|
-
return
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
} satisfies Questionnaire;
|
|
49
|
-
}),
|
|
50
|
-
);
|
|
42
|
+
return result.map((e) => {
|
|
43
|
+
const type = QNR_TYPE_MAP.get(e.wjlx) ?? QuestionnaireType.SURVEY;
|
|
44
|
+
return {
|
|
45
|
+
id: e.wjid,
|
|
46
|
+
type,
|
|
47
|
+
title: decodeHTML(e.wjbt),
|
|
48
|
+
startTime: e.kssj,
|
|
49
|
+
endTime: e.jssj,
|
|
50
|
+
uploadTime: e.scsj,
|
|
51
|
+
uploaderId: e.scr,
|
|
52
|
+
uploaderName: e.scrxm,
|
|
53
|
+
submitTime: e.tjsj ? e.tjsj : undefined,
|
|
54
|
+
isFavorite: e.sfsc === YES,
|
|
55
|
+
comment: e.bznr ?? undefined,
|
|
56
|
+
url: URLS.LEARN_QNR_SUBMIT_PAGE(e.wlkcid, e.wjid, type),
|
|
57
|
+
} satisfies Questionnaire;
|
|
58
|
+
});
|
|
51
59
|
}
|
|
52
60
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
+
/** Get the questions of one questionnaire (1 request). */
|
|
62
|
+
export async function getQuestionnaireDetail(
|
|
63
|
+
s: Session,
|
|
64
|
+
courseID: string,
|
|
65
|
+
qnrID: string,
|
|
66
|
+
): Promise<QuestionnaireDetail[]> {
|
|
67
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_QNR_DETAIL, {
|
|
68
|
+
method: 'POST',
|
|
69
|
+
body: URLS.LEARN_QNR_DETAIL_FORM(courseID, qnrID),
|
|
70
|
+
});
|
|
71
|
+
if (!Array.isArray(json)) {
|
|
72
|
+
throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
|
|
73
|
+
}
|
|
74
|
+
return json.map(
|
|
61
75
|
(e) =>
|
|
62
76
|
({
|
|
63
77
|
id: e.wtid,
|
package/src/request.ts
CHANGED
|
@@ -40,7 +40,7 @@ const urlOf = (input: Parameters<Fetch>[0]): string =>
|
|
|
40
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
41
|
* headers arrive (or the call fails), so reading a body does not hold one.
|
|
42
42
|
*/
|
|
43
|
-
export function limitConcurrency(inner:
|
|
43
|
+
export function limitConcurrency<F extends (...args: any[]) => Promise<Response>>(inner: F, max: number): F {
|
|
44
44
|
if (!(max >= 1) || max === Infinity) return inner;
|
|
45
45
|
let active = 0;
|
|
46
46
|
const waiting: (() => void)[] = [];
|
|
@@ -57,47 +57,57 @@ export function limitConcurrency(inner: Fetch, max: number): Fetch {
|
|
|
57
57
|
if (next) next();
|
|
58
58
|
else active -= 1;
|
|
59
59
|
};
|
|
60
|
-
return async (...args) => {
|
|
60
|
+
return (async (...args: Parameters<F>) => {
|
|
61
61
|
await acquire();
|
|
62
62
|
try {
|
|
63
63
|
return await inner(...args);
|
|
64
64
|
} finally {
|
|
65
65
|
release();
|
|
66
66
|
}
|
|
67
|
-
};
|
|
67
|
+
}) as F;
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
/** `RequestInit` plus a per-request `timeout` (milliseconds; 0 or `Infinity` for none) */
|
|
71
|
+
export type RequestOptions = RequestInit & { timeout?: number };
|
|
72
|
+
|
|
73
|
+
/** a `Fetch` whose `init` can also carry a per-request `timeout` */
|
|
74
|
+
export type TimedFetch = (input: Parameters<Fetch>[0], init?: RequestOptions) => Promise<Response>;
|
|
75
|
+
|
|
70
76
|
/**
|
|
71
|
-
* Abort a call of `inner` that has no response headers after `ms` milliseconds
|
|
72
|
-
* `FailReason.REQUEST_TIMEOUT`. A caller's own `signal` still works
|
|
73
|
-
* which React Native has (unlike
|
|
77
|
+
* Abort a call of `inner` that has no response headers after `ms` milliseconds (or `init.timeout`, which overrides it
|
|
78
|
+
* for one request) and reject it with `FailReason.REQUEST_TIMEOUT`. A caller's own `signal` still works, also while
|
|
79
|
+
* the body is read. Uses only `AbortController` and `setTimeout`, which React Native has (unlike
|
|
80
|
+
* `AbortSignal.timeout` / `AbortSignal.any`). Note that React Native's fetch resolves only after the whole body has
|
|
81
|
+
* arrived, so there the timeout covers the download too.
|
|
74
82
|
*/
|
|
75
|
-
export function withTimeout(inner: Fetch, ms: number):
|
|
76
|
-
|
|
77
|
-
|
|
83
|
+
export function withTimeout(inner: Fetch, ms: number): TimedFetch {
|
|
84
|
+
return async (input, options) => {
|
|
85
|
+
const { timeout = ms, ...init } = options ?? {};
|
|
86
|
+
if (!(timeout > 0) || timeout === Infinity) return inner(input, init);
|
|
78
87
|
const controller = new AbortController();
|
|
79
|
-
const outer = init
|
|
88
|
+
const outer = init.signal ?? undefined;
|
|
80
89
|
const forward = () => controller.abort(outer?.reason);
|
|
81
90
|
if (outer?.aborted) forward();
|
|
91
|
+
// kept after the headers arrive, so the caller can still cancel reading the body
|
|
82
92
|
else outer?.addEventListener('abort', forward, { once: true });
|
|
83
93
|
let timedOut = false;
|
|
84
94
|
const timer = setTimeout(() => {
|
|
85
95
|
timedOut = true;
|
|
86
96
|
controller.abort();
|
|
87
|
-
},
|
|
97
|
+
}, timeout);
|
|
88
98
|
try {
|
|
89
99
|
return await inner(input, { ...init, signal: controller.signal });
|
|
90
100
|
} catch (err) {
|
|
101
|
+
outer?.removeEventListener('abort', forward);
|
|
91
102
|
if (timedOut) {
|
|
92
103
|
throw {
|
|
93
104
|
reason: FailReason.REQUEST_TIMEOUT,
|
|
94
|
-
extra: { url: urlOf(input), timeout
|
|
105
|
+
extra: { url: urlOf(input), timeout },
|
|
95
106
|
} as ApiError;
|
|
96
107
|
}
|
|
97
108
|
throw err;
|
|
98
109
|
} finally {
|
|
99
110
|
clearTimeout(timer);
|
|
100
|
-
outer?.removeEventListener('abort', forward);
|
|
101
111
|
}
|
|
102
112
|
};
|
|
103
113
|
}
|
|
@@ -107,6 +117,26 @@ const discard = (res: Response) => {
|
|
|
107
117
|
res.body?.cancel().catch(() => {});
|
|
108
118
|
};
|
|
109
119
|
|
|
120
|
+
/** returned by a read step when the response turned out to be from an expired session */
|
|
121
|
+
const EXPIRED = Symbol('expired');
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* Whether a 200 body that should have been JSON is an HTML page instead. Web Learning answers some requests of an
|
|
125
|
+
* expired session with a login or redirect page and status 200, not with a 403 or a `login_timeout` redirect.
|
|
126
|
+
*/
|
|
127
|
+
const looksLikeHtml = (text: string) => text.trimStart().startsWith('<');
|
|
128
|
+
|
|
129
|
+
/** pages longer than this are taken as real pages, never as the short login / redirect page of an expired session */
|
|
130
|
+
const MAX_LOGIN_PAGE_LENGTH = 4096;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Whether a 200 page is the short login or redirect page of an expired session rather than the page asked for. A
|
|
134
|
+
* heuristic (not verified against the real server): short, and pointing at the login timeout page or the SSO login.
|
|
135
|
+
*/
|
|
136
|
+
export const looksLikeLoginPage = (text: string) =>
|
|
137
|
+
text.length < MAX_LOGIN_PAGE_LENGTH &&
|
|
138
|
+
/login_timeout|id\.tsinghua\.edu\.cn\/do\/off\/ui\/auth\/login|j_spring_security_thauth_roaming_entry/.test(text);
|
|
139
|
+
|
|
110
140
|
/**
|
|
111
141
|
* State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
|
|
112
142
|
* Internal; the helper keeps it in a private field.
|
|
@@ -115,7 +145,7 @@ export class Session {
|
|
|
115
145
|
readonly provider?: CredentialProvider;
|
|
116
146
|
readonly cookieJar: CookieJar;
|
|
117
147
|
/** fetch with cookies, the concurrency cap and the timeout, but no login check */
|
|
118
|
-
readonly rawFetch:
|
|
148
|
+
readonly rawFetch: TimedFetch;
|
|
119
149
|
#csrfToken = '';
|
|
120
150
|
/** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
|
|
121
151
|
#generation = 0;
|
|
@@ -123,10 +153,10 @@ export class Session {
|
|
|
123
153
|
lang = Language.ZH;
|
|
124
154
|
previewFirstPage: boolean;
|
|
125
155
|
|
|
126
|
-
/** `
|
|
156
|
+
/** `loginWithProvider` signs in through SSO with the `CredentialProvider` */
|
|
127
157
|
constructor(
|
|
128
158
|
config: HelperConfig | undefined,
|
|
129
|
-
readonly
|
|
159
|
+
readonly loginWithProvider: () => Promise<void>,
|
|
130
160
|
) {
|
|
131
161
|
this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
|
|
132
162
|
this.provider = config?.provider;
|
|
@@ -147,68 +177,137 @@ export class Session {
|
|
|
147
177
|
}
|
|
148
178
|
|
|
149
179
|
/**
|
|
150
|
-
*
|
|
180
|
+
* Run one login (`login`) at a time. It starts after a login already in progress has settled, and requests that
|
|
181
|
+
* find the session expired meanwhile wait for it instead of starting their own (two SSO logins at once reset each
|
|
182
|
+
* other's id.tsinghua.edu.cn cookie).
|
|
151
183
|
*/
|
|
152
|
-
|
|
153
|
-
this.#pendingLogin
|
|
154
|
-
|
|
184
|
+
runLogin(login: () => Promise<void>): Promise<void> {
|
|
185
|
+
const previous = this.#pendingLogin?.catch(() => {}) ?? Promise.resolve();
|
|
186
|
+
const current: Promise<void> = previous.then(login).finally(() => {
|
|
187
|
+
if (this.#pendingLogin === current) this.#pendingLogin = undefined;
|
|
155
188
|
});
|
|
156
|
-
|
|
189
|
+
this.#pendingLogin = current;
|
|
190
|
+
return current;
|
|
157
191
|
}
|
|
158
192
|
|
|
159
193
|
/**
|
|
160
|
-
*
|
|
194
|
+
* Log in with the `CredentialProvider`, or wait for the login already in progress (also one with explicit
|
|
195
|
+
* credentials) instead of starting another.
|
|
196
|
+
*/
|
|
197
|
+
relogin(): Promise<void> {
|
|
198
|
+
return this.#pendingLogin ?? this.runLogin(this.loginWithProvider);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token) and read it
|
|
203
|
+
* with `read`, which returns `EXPIRED` for a body that shows the session has expired.
|
|
161
204
|
* When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
|
|
162
205
|
* logs in again (sharing a login already in progress, or skipping it if another request already renewed the
|
|
163
206
|
* session) and retries once.
|
|
164
207
|
*/
|
|
165
|
-
async #send
|
|
208
|
+
async #send<T>(
|
|
209
|
+
makeUrl: () => string,
|
|
210
|
+
init: RequestOptions | undefined,
|
|
211
|
+
read: (res: Response, retry: boolean) => Promise<T | typeof EXPIRED>,
|
|
212
|
+
): Promise<T> {
|
|
213
|
+
const attempt = async (retry: boolean): Promise<T | typeof EXPIRED> => {
|
|
214
|
+
const res = await this.rawFetch(makeUrl(), init);
|
|
215
|
+
if (noLogin(res)) {
|
|
216
|
+
discard(res);
|
|
217
|
+
return EXPIRED;
|
|
218
|
+
}
|
|
219
|
+
return read(res, retry);
|
|
220
|
+
};
|
|
166
221
|
const generation = this.#generation;
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
discard(res);
|
|
222
|
+
const first = await attempt(false);
|
|
223
|
+
if (first !== EXPIRED) return first;
|
|
170
224
|
if (!this.provider) {
|
|
171
225
|
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
172
226
|
}
|
|
173
227
|
if (this.#generation === generation) {
|
|
174
228
|
await this.relogin();
|
|
175
229
|
}
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
discard(retry);
|
|
230
|
+
const second = await attempt(true);
|
|
231
|
+
if (second === EXPIRED) {
|
|
179
232
|
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
180
233
|
}
|
|
181
|
-
|
|
182
|
-
|
|
234
|
+
return second;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** the response, but after a re-login only one with status 200 */
|
|
238
|
+
static readonly #response = async (res: Response, retry: boolean): Promise<Response> => {
|
|
239
|
+
if (retry && res.status !== 200) {
|
|
240
|
+
discard(res);
|
|
183
241
|
throw {
|
|
184
242
|
reason: FailReason.UNEXPECTED_STATUS,
|
|
185
|
-
extra: { code:
|
|
243
|
+
extra: { code: res.status, text: res.statusText },
|
|
186
244
|
} as ApiError;
|
|
187
245
|
}
|
|
188
|
-
return
|
|
189
|
-
}
|
|
246
|
+
return res;
|
|
247
|
+
};
|
|
190
248
|
|
|
191
|
-
/**
|
|
192
|
-
readonly
|
|
249
|
+
/** the parsed JSON body; an HTML page with status 200 means the session has expired */
|
|
250
|
+
static readonly #json = async (res: Response): Promise<any> => {
|
|
251
|
+
const text = await res.text();
|
|
252
|
+
try {
|
|
253
|
+
return JSON.parse(text);
|
|
254
|
+
} catch {
|
|
255
|
+
if (res.status === 200 && looksLikeHtml(text)) return EXPIRED;
|
|
256
|
+
throw {
|
|
257
|
+
reason: res.status === 200 ? FailReason.INVALID_RESPONSE : FailReason.UNEXPECTED_STATUS,
|
|
258
|
+
extra: { code: res.status, text: text.slice(0, 200) },
|
|
259
|
+
} as ApiError;
|
|
260
|
+
}
|
|
261
|
+
};
|
|
193
262
|
|
|
194
|
-
/**
|
|
195
|
-
readonly
|
|
263
|
+
/** the text body; a short login / redirect page with status 200 means the session has expired */
|
|
264
|
+
static readonly #text = async (res: Response, retry: boolean): Promise<string | typeof EXPIRED> => {
|
|
265
|
+
await Session.#response(res, retry);
|
|
266
|
+
const text = await res.text();
|
|
267
|
+
return res.status === 200 && looksLikeLoginPage(text) ? EXPIRED : text;
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
#withToken = async (input: Parameters<Fetch>[0]): Promise<() => string> => {
|
|
196
271
|
if (this.#csrfToken === '') {
|
|
197
272
|
await this.relogin();
|
|
198
273
|
}
|
|
199
274
|
const url = urlOf(input);
|
|
200
|
-
return
|
|
275
|
+
return () => addCSRFTokenToUrl(url, this.#csrfToken);
|
|
201
276
|
};
|
|
202
277
|
|
|
278
|
+
/** fetch with a login check (see `#send`) */
|
|
279
|
+
readonly fetch: TimedFetch = (input, init) => this.#send(() => urlOf(input), init, Session.#response);
|
|
280
|
+
|
|
281
|
+
/** like `fetch`, but logs in first if there is no CSRF token yet and adds the current token to the URL */
|
|
282
|
+
readonly fetchWithToken: TimedFetch = async (input, init) =>
|
|
283
|
+
this.#send(await this.#withToken(input), init, Session.#response);
|
|
284
|
+
|
|
203
285
|
/**
|
|
204
|
-
*
|
|
205
|
-
* in
|
|
286
|
+
* Like `fetchWithToken`, but returns the parsed JSON body. A 200 HTML page instead of JSON counts as an expired
|
|
287
|
+
* session (log in again and retry once, as for a 403).
|
|
288
|
+
*/
|
|
289
|
+
readonly fetchJsonWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<any> =>
|
|
290
|
+
this.#send(await this.#withToken(input), init, Session.#json);
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Like `fetchWithToken`, but returns the text body (an HTML page). A short login or redirect page with status 200
|
|
294
|
+
* counts as an expired session (log in again and retry once, as for a 403).
|
|
295
|
+
*/
|
|
296
|
+
readonly fetchTextWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<string> =>
|
|
297
|
+
this.#send(await this.#withToken(input), init, Session.#text);
|
|
298
|
+
|
|
299
|
+
/**
|
|
300
|
+
* Make sure the session works: if there is a CSRF token, fetch the current semester (`probeUrl`) and log in only if
|
|
301
|
+
* the session has expired; otherwise log in.
|
|
206
302
|
*/
|
|
207
303
|
async ensureSession(probeUrl: string): Promise<void> {
|
|
208
304
|
if (this.#csrfToken === '') {
|
|
209
305
|
await this.relogin();
|
|
210
306
|
return;
|
|
211
307
|
}
|
|
212
|
-
|
|
308
|
+
const json = await this.fetchJsonWithToken(probeUrl);
|
|
309
|
+
if (json?.message !== 'success') {
|
|
310
|
+
throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
|
|
311
|
+
}
|
|
213
312
|
}
|
|
214
313
|
}
|
package/src/types.ts
CHANGED
|
@@ -365,7 +365,8 @@ export interface IQuestionnaire {
|
|
|
365
365
|
isFavorite: boolean;
|
|
366
366
|
comment?: string;
|
|
367
367
|
url: string;
|
|
368
|
-
|
|
368
|
+
/** the questions; only for the questionnaires `getQuestionnaireList`'s `details` selects (by default all) */
|
|
369
|
+
detail?: QuestionnaireDetail[];
|
|
369
370
|
}
|
|
370
371
|
|
|
371
372
|
export type Questionnaire = IQuestionnaire;
|
|
@@ -394,14 +395,24 @@ export interface NotificationListOptions {
|
|
|
394
395
|
attachments?: DetailSelector<INotification>;
|
|
395
396
|
}
|
|
396
397
|
|
|
398
|
+
export interface QuestionnaireListOptions {
|
|
399
|
+
/**
|
|
400
|
+
* Which questionnaires get their questions (`detail`, 1 request each); default `'all'` (unlike the other lists, for
|
|
401
|
+
* compatibility). The same as calling `getQuestionnaireDetail` for those items.
|
|
402
|
+
*/
|
|
403
|
+
details?: DetailSelector<IQuestionnaire>;
|
|
404
|
+
}
|
|
405
|
+
|
|
397
406
|
export interface AllContentsOptions<T extends ContentType> {
|
|
398
407
|
courseType?: CourseType;
|
|
399
|
-
/** homework `details
|
|
408
|
+
/** homework or questionnaire `details`, or notification `attachments`, passed to each course's list */
|
|
400
409
|
details?: T extends ContentType.HOMEWORK
|
|
401
410
|
? DetailSelector<IHomework>
|
|
402
411
|
: T extends ContentType.NOTIFICATION
|
|
403
412
|
? DetailSelector<INotification>
|
|
404
|
-
:
|
|
413
|
+
: T extends ContentType.QUESTIONNAIRE
|
|
414
|
+
? DetailSelector<IQuestionnaire>
|
|
415
|
+
: never;
|
|
405
416
|
}
|
|
406
417
|
|
|
407
418
|
export interface AllContentsResult<T extends ContentType> {
|
package/src/user.ts
CHANGED
|
@@ -8,7 +8,7 @@ import * as URLS from './urls.ts';
|
|
|
8
8
|
|
|
9
9
|
/** get user's name and department */
|
|
10
10
|
export async function getUserInfo(s: Session, courseType = CourseType.STUDENT): Promise<UserInfo> {
|
|
11
|
-
const content = await
|
|
11
|
+
const content = await s.fetchTextWithToken(URLS.LEARN_HOMEPAGE(courseType));
|
|
12
12
|
|
|
13
13
|
const dom = $(content);
|
|
14
14
|
const name = dom('a.user-log').text().trim();
|
package/src/utils.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { decodeHTML as _decodeHTML } from 'entities';
|
|
2
2
|
|
|
3
|
-
import type { DetailSelector } from './types.ts';
|
|
3
|
+
import type { ApiError, DetailSelector } from './types.ts';
|
|
4
4
|
import { ContentType, FailReason, HomeworkGradeLevel, QuestionnaireType, SemesterType } from './types.ts';
|
|
5
5
|
|
|
6
6
|
export function parseSemesterType(n: number): SemesterType {
|
|
@@ -78,14 +78,20 @@ export const GRADE_LEVEL_MAP = new Map([
|
|
|
78
78
|
|
|
79
79
|
export const JSONP_EXTRACTOR_NAME = 'thu_learn_lib_jsonp_extractor';
|
|
80
80
|
|
|
81
|
+
const invalidJSONP = (extra: unknown): ApiError => ({ reason: FailReason.INVALID_RESPONSE, extra });
|
|
82
|
+
|
|
81
83
|
export function extractJSONPResult(jsonp: string): any {
|
|
82
84
|
// check jsonp format
|
|
83
85
|
if (!jsonp.startsWith(JSONP_EXTRACTOR_NAME)) {
|
|
84
|
-
throw
|
|
86
|
+
throw invalidJSONP(jsonp.slice(0, 200));
|
|
85
87
|
}
|
|
86
88
|
// evaluate the result
|
|
87
|
-
|
|
88
|
-
|
|
89
|
+
try {
|
|
90
|
+
// oxlint-disable-next-line typescript/no-implied-eval -- JSONP from the registrar is a JS expression, not JSON
|
|
91
|
+
return Function(`"use strict";const ${JSONP_EXTRACTOR_NAME}=(s)=>s;return ${jsonp};`)();
|
|
92
|
+
} catch (err) {
|
|
93
|
+
throw invalidJSONP(err);
|
|
94
|
+
}
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
export function formatFileSize(size: number): string {
|