@robertying/thu-learn-lib 5.1.0 → 6.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/README.md +81 -248
- package/lib/auth.d.ts +14 -3
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js +56 -58
- package/lib/auth.js.map +1 -1
- package/lib/calendar.d.ts +7 -2
- package/lib/calendar.d.ts.map +1 -1
- package/lib/calendar.js +99 -14
- package/lib/calendar.js.map +1 -1
- package/lib/contents.d.ts.map +1 -1
- package/lib/contents.js +4 -9
- package/lib/contents.js.map +1 -1
- package/lib/courses.d.ts.map +1 -1
- package/lib/courses.js +11 -25
- package/lib/courses.js.map +1 -1
- package/lib/discussions.d.ts.map +1 -1
- package/lib/discussions.js +6 -17
- package/lib/discussions.js.map +1 -1
- package/lib/favorites.d.ts +1 -0
- package/lib/favorites.d.ts.map +1 -1
- package/lib/favorites.js +13 -48
- package/lib/favorites.js.map +1 -1
- package/lib/files.d.ts +3 -3
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +109 -133
- package/lib/files.js.map +1 -1
- package/lib/homework.d.ts +34 -3
- package/lib/homework.d.ts.map +1 -1
- package/lib/homework.js +135 -79
- package/lib/homework.js.map +1 -1
- package/lib/index.d.ts +39 -19
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +44 -17
- package/lib/index.js.map +1 -1
- package/lib/notices.d.ts +3 -3
- package/lib/notices.d.ts.map +1 -1
- package/lib/notices.js +4 -20
- package/lib/notices.js.map +1 -1
- package/lib/questionnaires.d.ts.map +1 -1
- package/lib/questionnaires.js +8 -14
- package/lib/questionnaires.js.map +1 -1
- package/lib/request.d.ts +27 -13
- package/lib/request.d.ts.map +1 -1
- package/lib/request.js +84 -51
- package/lib/request.js.map +1 -1
- package/lib/timetable.d.ts +26 -0
- package/lib/timetable.d.ts.map +1 -0
- package/lib/timetable.js +110 -0
- package/lib/timetable.js.map +1 -0
- package/lib/types.d.ts +108 -75
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +3 -0
- package/lib/types.js.map +1 -1
- package/lib/urls.d.ts +10 -20
- package/lib/urls.d.ts.map +1 -1
- package/lib/urls.js +11 -56
- package/lib/urls.js.map +1 -1
- package/lib/user.d.ts +7 -2
- package/lib/user.d.ts.map +1 -1
- package/lib/user.js +19 -5
- package/lib/user.js.map +1 -1
- package/lib/utils.d.ts +42 -4
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +76 -31
- package/lib/utils.js.map +1 -1
- package/package.json +8 -7
- package/src/auth.ts +62 -73
- package/src/calendar.ts +110 -16
- package/src/contents.ts +7 -9
- package/src/courses.ts +12 -26
- package/src/discussions.ts +8 -19
- package/src/favorites.ts +22 -48
- package/src/files.ts +121 -139
- package/src/homework.ts +184 -102
- package/src/index.ts +67 -48
- package/src/notices.ts +19 -26
- package/src/questionnaires.ts +18 -15
- package/src/request.ts +86 -62
- package/src/timetable.ts +121 -0
- package/src/types.ts +111 -86
- package/src/urls.ts +13 -64
- package/src/user.ts +21 -6
- package/src/utils.ts +94 -32
package/src/auth.ts
CHANGED
|
@@ -3,40 +3,35 @@
|
|
|
3
3
|
import { sm2 } from 'sm-crypto';
|
|
4
4
|
|
|
5
5
|
import type { Session } from './request.ts';
|
|
6
|
-
import {
|
|
7
|
-
import type {
|
|
8
|
-
import { FailReason, Language } from './types.ts';
|
|
6
|
+
import { discard, isConnectionError } from './request.ts';
|
|
7
|
+
import type { Credential } from './types.ts';
|
|
8
|
+
import { CourseType, FailReason, Language } from './types.ts';
|
|
9
9
|
import * as URLS from './urls.ts';
|
|
10
|
+
import { $, apiError } from './utils.ts';
|
|
11
|
+
|
|
12
|
+
/** a `Credential` with everything an SSO login needs */
|
|
13
|
+
export type CompleteCredential = Credential & { username: string; password: string; fingerPrint: string };
|
|
14
|
+
|
|
15
|
+
/** whether `credential` has a username, a password and a fingerprint */
|
|
16
|
+
export const isComplete = (credential: Credential | undefined): credential is CompleteCredential =>
|
|
17
|
+
!!credential?.username && !!credential.password && !!credential.fingerPrint;
|
|
10
18
|
|
|
11
19
|
/**
|
|
12
20
|
* If using alternative cookie management systems,
|
|
13
21
|
* be sure to clear id.tsinghua.edu.cn cookies before calling this function
|
|
14
22
|
*/
|
|
15
|
-
export async function getRoamingTicket(
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
password: string,
|
|
19
|
-
fingerPrint: string,
|
|
20
|
-
fingerGenPrint: string = '',
|
|
21
|
-
fingerGenPrint3: string = '',
|
|
22
|
-
) {
|
|
23
|
-
// Make sure we always start with the form page
|
|
24
|
-
// More code is needed to handle the case where the user is already logged in (i.e. cookies are set)
|
|
25
|
-
// It won't be a problem if it is always the same user,
|
|
26
|
-
// but if the current cookies are from a different user than the one trying to log in,
|
|
27
|
-
// it will cause problems.
|
|
23
|
+
export async function getRoamingTicket(s: Session, credential: CompleteCredential): Promise<string> {
|
|
24
|
+
const { username, password, fingerPrint, fingerGenPrint = '', fingerGenPrint3 = '' } = credential;
|
|
25
|
+
// start from the login form without an id.tsinghua.edu.cn session: one left in the cookies may be another user's
|
|
28
26
|
try {
|
|
29
27
|
await s.cookieJar.setCookie('JSESSIONID=; path=/; HttpOnly', URLS.ID_PREFIX);
|
|
30
28
|
} catch (err) {
|
|
31
|
-
throw
|
|
32
|
-
reason: FailReason.ERROR_SETTING_COOKIES,
|
|
33
|
-
extra: err,
|
|
34
|
-
} as ApiError;
|
|
29
|
+
throw apiError(FailReason.ERROR_SETTING_COOKIES, err);
|
|
35
30
|
}
|
|
36
31
|
|
|
37
32
|
let page: string;
|
|
38
33
|
try {
|
|
39
|
-
const loginForm = await s.rawFetch(URLS.ID_LOGIN
|
|
34
|
+
const loginForm = await s.rawFetch(URLS.ID_LOGIN);
|
|
40
35
|
const body = $(await loginForm.text());
|
|
41
36
|
const sm2publicKey = body('#sm2publicKey').text().trim();
|
|
42
37
|
|
|
@@ -45,10 +40,10 @@ export async function getRoamingTicket(
|
|
|
45
40
|
formData.append('i_pass', '04' + sm2.doEncrypt(password, sm2publicKey));
|
|
46
41
|
formData.append('singleLogin', 'on');
|
|
47
42
|
formData.append('fingerPrint', fingerPrint);
|
|
48
|
-
formData.append('fingerGenPrint', fingerGenPrint
|
|
49
|
-
formData.append('fingerGenPrint3', fingerGenPrint3
|
|
43
|
+
formData.append('fingerGenPrint', fingerGenPrint);
|
|
44
|
+
formData.append('fingerGenPrint3', fingerGenPrint3);
|
|
50
45
|
formData.append('i_captcha', '');
|
|
51
|
-
const checkResponse = await s.rawFetch(URLS.ID_LOGIN_CHECK
|
|
46
|
+
const checkResponse = await s.rawFetch(URLS.ID_LOGIN_CHECK, {
|
|
52
47
|
method: 'POST',
|
|
53
48
|
body: formData,
|
|
54
49
|
});
|
|
@@ -58,77 +53,71 @@ export async function getRoamingTicket(
|
|
|
58
53
|
}
|
|
59
54
|
page = await checkResponse.text();
|
|
60
55
|
} catch (err) {
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
} as ApiError;
|
|
56
|
+
// no answer at all: the device is offline or id.tsinghua.edu.cn is unreachable, not a refused login
|
|
57
|
+
if (isConnectionError(err)) throw err;
|
|
58
|
+
throw apiError(FailReason.ERROR_FETCH_FROM_ID, err);
|
|
65
59
|
}
|
|
66
60
|
|
|
67
|
-
// id.tsinghua.edu.cn answers with a link
|
|
61
|
+
// id.tsinghua.edu.cn answers with a page whose only link is the roaming URL (with the ticket), or with the login form
|
|
62
|
+
// again (with its links to the password reset, guides, phone and mail) for a wrong password
|
|
68
63
|
const body = $(page);
|
|
69
|
-
const
|
|
70
|
-
if (
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
extra: body.root().text().replace(/\s+/g, ' ').trim().slice(0, 200),
|
|
74
|
-
} as ApiError;
|
|
64
|
+
const ticket = ticketOf(body('a[href*="ticket="]').attr('href'));
|
|
65
|
+
if (ticket) return ticket;
|
|
66
|
+
if (body('#theform, form[action*="/auth/login/check"]').length > 0 && body('#i_pass').length > 0) {
|
|
67
|
+
throw apiError(FailReason.BAD_CREDENTIAL, textOf(body('#msg_note, .alert-danger')) || undefined);
|
|
75
68
|
}
|
|
76
|
-
|
|
69
|
+
// something else, e.g. a second factor, a locked account or maintenance: not a wrong password
|
|
70
|
+
throw apiError(
|
|
71
|
+
FailReason.ERROR_FETCH_FROM_ID,
|
|
72
|
+
textOf(body('body').clone().find('script, style').remove().end()).slice(0, 200),
|
|
73
|
+
);
|
|
77
74
|
}
|
|
78
75
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
): Promise<void> {
|
|
88
|
-
if (!username || !password || !fingerPrint) {
|
|
89
|
-
if (!s.provider)
|
|
90
|
-
return Promise.reject({
|
|
91
|
-
reason: FailReason.NO_CREDENTIAL,
|
|
92
|
-
});
|
|
93
|
-
const credential = await s.provider();
|
|
94
|
-
username = credential.username;
|
|
95
|
-
password = credential.password;
|
|
96
|
-
fingerPrint = credential.fingerPrint;
|
|
97
|
-
fingerGenPrint = credential.fingerGenPrint;
|
|
98
|
-
fingerGenPrint3 = credential.fingerGenPrint3;
|
|
99
|
-
if (!username || !password || !fingerPrint) {
|
|
100
|
-
return Promise.reject({
|
|
101
|
-
reason: FailReason.NO_CREDENTIAL,
|
|
102
|
-
});
|
|
103
|
-
}
|
|
76
|
+
const textOf = (el: { text(): string }) => el.text().replace(/\s+/g, ' ').trim();
|
|
77
|
+
|
|
78
|
+
const ticketOf = (href: string | undefined): string | undefined => {
|
|
79
|
+
if (!href) return undefined;
|
|
80
|
+
try {
|
|
81
|
+
return new URL(href, URLS.ID_PREFIX).searchParams.get('ticket') || undefined;
|
|
82
|
+
} catch {
|
|
83
|
+
return undefined;
|
|
104
84
|
}
|
|
85
|
+
};
|
|
105
86
|
|
|
106
|
-
|
|
107
|
-
|
|
87
|
+
/** log in with the `CredentialProvider`; `NO_CREDENTIAL` without one, or when it gives an incomplete credential */
|
|
88
|
+
export async function loginWithProvider(s: Session): Promise<void> {
|
|
89
|
+
const credential = await s.provider?.();
|
|
90
|
+
if (!isComplete(credential)) throw apiError(FailReason.NO_CREDENTIAL);
|
|
91
|
+
return login(s, credential);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** log in through id.tsinghua.edu.cn, then read the CSRF token and the website language from the homepage */
|
|
95
|
+
export async function login(s: Session, credential: CompleteCredential): Promise<void> {
|
|
96
|
+
const ticket = await getRoamingTicket(s, credential);
|
|
97
|
+
const { fingerPrint, fingerGenPrint = '', fingerGenPrint3 = '' } = credential;
|
|
98
|
+
s.fingerprints = { fingerPrint, fingerGenPrint, fingerGenPrint3 };
|
|
108
99
|
|
|
109
100
|
const loginResponse = await s.rawFetch(URLS.LEARN_AUTH_ROAM(ticket));
|
|
101
|
+
// only the status matters
|
|
102
|
+
discard(loginResponse);
|
|
110
103
|
if (!loginResponse.ok) {
|
|
111
|
-
|
|
112
|
-
reason: FailReason.ERROR_ROAMING,
|
|
113
|
-
});
|
|
104
|
+
throw apiError(FailReason.ERROR_ROAMING);
|
|
114
105
|
}
|
|
115
|
-
const courseListPageSource: string = await (await s.rawFetch(URLS.
|
|
106
|
+
const courseListPageSource: string = await (await s.rawFetch(URLS.LEARN_HOMEPAGE(CourseType.STUDENT))).text();
|
|
116
107
|
const tokenRegex = /^.*&_csrf=(\S*)".*$/gm;
|
|
117
108
|
const tokenMatches = [...courseListPageSource.matchAll(tokenRegex)];
|
|
118
109
|
if (tokenMatches.length === 0) {
|
|
119
|
-
|
|
120
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
121
|
-
extra: 'cannot fetch CSRF token from source',
|
|
122
|
-
});
|
|
110
|
+
throw apiError(FailReason.INVALID_RESPONSE, 'cannot fetch CSRF token from source');
|
|
123
111
|
}
|
|
124
112
|
s.csrfToken = tokenMatches[0][1];
|
|
125
|
-
|
|
113
|
+
// the homepage loads e.g. `/f/wlxt/common/languagejs?lang=zh_CN&v=845`
|
|
114
|
+
const langRegex = /\/f\/wlxt\/common\/languagejs\?lang=(zh|en)/g;
|
|
126
115
|
const langMatches = [...courseListPageSource.matchAll(langRegex)];
|
|
127
116
|
if (langMatches.length !== 0) s.lang = langMatches[0][1] === 'en' ? Language.EN : Language.ZH;
|
|
128
117
|
}
|
|
129
118
|
|
|
130
119
|
/** logout (to make everyone happy); the CSRF token is cleared, so the next request logs in again */
|
|
131
120
|
export async function logout(s: Session): Promise<void> {
|
|
132
|
-
await s.rawFetch(URLS.LEARN_LOGOUT
|
|
121
|
+
discard(await s.rawFetch(URLS.LEARN_LOGOUT, { method: 'POST' }));
|
|
133
122
|
s.csrfToken = '';
|
|
134
123
|
}
|
package/src/calendar.ts
CHANGED
|
@@ -1,12 +1,16 @@
|
|
|
1
|
-
// Course calendar from the registrar (
|
|
1
|
+
// Course calendar from the registrar (zhjw.cic.tsinghua.edu.cn).
|
|
2
2
|
|
|
3
3
|
import type { Session } from './request.ts';
|
|
4
|
+
import { discard } from './request.ts';
|
|
4
5
|
import type { ApiError, CalendarEvent, CalendarOptions, CalendarRange, CalendarResult } from './types.ts';
|
|
5
6
|
import { FailReason } from './types.ts';
|
|
6
7
|
import * as URLS from './urls.ts';
|
|
7
|
-
import { JSONP_EXTRACTOR_NAME, extractJSONPResult } from './utils.ts';
|
|
8
|
+
import { $, JSONP_EXTRACTOR_NAME, apiError, extractJSONPResult } from './utils.ts';
|
|
8
9
|
|
|
9
|
-
/**
|
|
10
|
+
/**
|
|
11
|
+
* The registrar often fails for ranges longer than about a month, so longer ranges are fetched in windows of this many
|
|
12
|
+
* days.
|
|
13
|
+
*/
|
|
10
14
|
export const CALENDAR_WINDOW_DAYS = 28;
|
|
11
15
|
|
|
12
16
|
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
@@ -39,18 +43,112 @@ export function splitCalendarRange(startDate: string, endDate: string, days = CA
|
|
|
39
43
|
return ranges;
|
|
40
44
|
}
|
|
41
45
|
|
|
46
|
+
const FINGERPRINT_FIELDS = ['fingerPrint', 'fingerGenPrint', 'fingerGenPrint3'] as const;
|
|
47
|
+
const isFingerprintField = (name: string): name is (typeof FINGERPRINT_FIELDS)[number] =>
|
|
48
|
+
(FINGERPRINT_FIELDS as readonly string[]).includes(name);
|
|
49
|
+
|
|
50
|
+
/** `extra.step` of an `ERROR_REGISTRAR_AUTH` that id.tsinghua.edu.cn turned down (rather than the registrar failing) */
|
|
51
|
+
const ID_STEP = 'id.tsinghua.edu.cn';
|
|
52
|
+
|
|
53
|
+
const isIdRefusal = (err: unknown) =>
|
|
54
|
+
(err as ApiError | undefined)?.reason === FailReason.ERROR_REGISTRAR_AUTH &&
|
|
55
|
+
((err as ApiError).extra as { step?: string } | undefined)?.step === ID_STEP;
|
|
56
|
+
|
|
57
|
+
/** an id.tsinghua.edu.cn login form (`/do/off/ui/auth/login/check`, `.../checkSingle`) */
|
|
58
|
+
const isIdLoginPage = (res: Response, page: string) =>
|
|
59
|
+
res.url.startsWith(URLS.ID_PREFIX) || /action="[^"]*\/do\/off\/ui\/auth\/login\//.test(page);
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* The body of a sign-in step, or '' when there is no text. The registrar's own pages are GBK, which React Native's
|
|
63
|
+
* fetch can't decode: its `text()` then gives `undefined` instead of a string. Only id.tsinghua.edu.cn pages (UTF-8)
|
|
64
|
+
* are looked into here, so an undecodable registrar page is as good as an empty one.
|
|
65
|
+
*/
|
|
66
|
+
const readText = async (res: Response): Promise<string> => {
|
|
67
|
+
const text: unknown = await res.text();
|
|
68
|
+
return typeof text === 'string' ? text : '';
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Sign in to the registrar with a Web Learning ticket.
|
|
73
|
+
*
|
|
74
|
+
* Registrar requests use `rawFetch`, without the Web Learning login check: a 403 or login page from the registrar says
|
|
75
|
+
* nothing about the Web Learning session, and logging in there again wouldn't renew the registrar ticket. They don't
|
|
76
|
+
* carry Web Learning's CSRF token either: the registrar (and WebVPN in front of it off campus) has no use for it.
|
|
77
|
+
*
|
|
78
|
+
* Off campus the registrar is only reachable through WebVPN (webvpn.tsinghua.edu.cn): the auth URL redirects there,
|
|
79
|
+
* and WebVPN signs in through id.tsinghua.edu.cn OAuth. As the id.tsinghua.edu.cn session of the Web Learning login is
|
|
80
|
+
* still there, id.tsinghua.edu.cn answers with a form (`#logined`) that a browser submits right away with its device
|
|
81
|
+
* fingerprints. Submitting it answers with a page that links (by script) to the OAuth callback, which signs in to WebVPN
|
|
82
|
+
* and redirects back to the auth URL, so following the link ends on the registrar.
|
|
83
|
+
*/
|
|
84
|
+
async function authenticateRegistrar(s: Session, ticket: string): Promise<void> {
|
|
85
|
+
let res = await s.rawFetch(URLS.REGISTRAR_AUTH(ticket));
|
|
86
|
+
let page = await readText(res);
|
|
87
|
+
|
|
88
|
+
const form = $(page)('form#logined');
|
|
89
|
+
if (form.length > 0) {
|
|
90
|
+
const fingerprints = s.fingerprints ?? (await s.provider?.());
|
|
91
|
+
const data = new FormData();
|
|
92
|
+
form.find('input[name]').each((_, input) => {
|
|
93
|
+
const name = input.attribs.name;
|
|
94
|
+
const known = isFingerprintField(name) ? fingerprints?.[name] : undefined;
|
|
95
|
+
data.append(name, known ?? input.attribs.value ?? '');
|
|
96
|
+
});
|
|
97
|
+
const action = new URL(form.attr('action') ?? '', res.url || URLS.ID_PREFIX).href;
|
|
98
|
+
const check = await s.rawFetch(action, { method: 'POST', body: data });
|
|
99
|
+
const link = check.ok ? $(await readText(check))('a').attr('href') : undefined;
|
|
100
|
+
if (!link) {
|
|
101
|
+
discard(check);
|
|
102
|
+
throw apiError(FailReason.ERROR_REGISTRAR_AUTH, { step: ID_STEP, code: check.status });
|
|
103
|
+
}
|
|
104
|
+
res = await s.rawFetch(new URL(link, check.url || action).href);
|
|
105
|
+
page = await readText(res);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
if (isIdLoginPage(res, page)) {
|
|
109
|
+
throw apiError(FailReason.ERROR_REGISTRAR_AUTH, { step: ID_STEP, code: res.status, url: res.url.split('?')[0] });
|
|
110
|
+
}
|
|
111
|
+
if (!res.ok) {
|
|
112
|
+
throw apiError(FailReason.ERROR_REGISTRAR_AUTH, { code: res.status, url: res.url.split('?')[0] });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Get a registrar ticket and sign in to the registrar with it. When id.tsinghua.edu.cn turns the sign-in down (its
|
|
118
|
+
* session from the last login has expired, e.g. in a session resumed from saved cookies) and there is a
|
|
119
|
+
* `CredentialProvider`, log in to Web Learning again (a new id.tsinghua.edu.cn session) and try once more.
|
|
120
|
+
*/
|
|
121
|
+
async function signInToRegistrar(s: Session): Promise<void> {
|
|
122
|
+
const signIn = async () => {
|
|
123
|
+
const ticket = await s.fetchTextWithToken(URLS.REGISTRAR_TICKET, {
|
|
124
|
+
method: 'POST',
|
|
125
|
+
body: URLS.REGISTRAR_TICKET_FORM_DATA(),
|
|
126
|
+
});
|
|
127
|
+
await authenticateRegistrar(s, ticket.substring(1, ticket.length - 1));
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
await signIn();
|
|
131
|
+
} catch (err) {
|
|
132
|
+
if (!s.provider || !isIdRefusal(err)) throw err;
|
|
133
|
+
await s.autoRelogin();
|
|
134
|
+
await signIn();
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
42
138
|
async function fetchWindow(s: Session, range: CalendarRange, graduate: boolean): Promise<CalendarEvent[]> {
|
|
43
|
-
const response = await s.
|
|
139
|
+
const response = await s.rawFetch(
|
|
44
140
|
URLS.REGISTRAR_CALENDAR(range.startDate, range.endDate, graduate, JSONP_EXTRACTOR_NAME),
|
|
45
141
|
);
|
|
46
142
|
|
|
47
143
|
if (!response.ok) {
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
});
|
|
144
|
+
discard(response);
|
|
145
|
+
throw apiError(FailReason.INVALID_RESPONSE, { code: response.status, text: response.statusText });
|
|
51
146
|
}
|
|
52
147
|
|
|
53
|
-
const result = extractJSONPResult(await response.text())
|
|
148
|
+
const result: unknown = extractJSONPResult(await response.text());
|
|
149
|
+
if (!Array.isArray(result)) {
|
|
150
|
+
throw apiError(FailReason.INVALID_RESPONSE, result);
|
|
151
|
+
}
|
|
54
152
|
|
|
55
153
|
return result.map<CalendarEvent>((i) => ({
|
|
56
154
|
location: i.dd,
|
|
@@ -70,7 +168,9 @@ async function fetchWindow(s: Session, range: CalendarRange, graduate: boolean):
|
|
|
70
168
|
* show partial data and retry only those; after two windows in a row time out, the rest are listed there without a
|
|
71
169
|
* request. If every window fails, this rejects with the last error instead (usually `FailReason.INVALID_RESPONSE`).
|
|
72
170
|
* The result might be empty if the period is too far away from now. Throws a `RangeError` for a date that is not a
|
|
73
|
-
* valid yyyymmdd.
|
|
171
|
+
* valid yyyymmdd, and rejects with `FailReason.ERROR_REGISTRAR_AUTH` if signing in to the registrar fails. Off campus
|
|
172
|
+
* this goes through WebVPN and needs the id.tsinghua.edu.cn session of the last `login`; when that has expired, a
|
|
173
|
+
* helper with a `CredentialProvider` logs in again and tries once more.
|
|
74
174
|
*/
|
|
75
175
|
export async function getCalendar(
|
|
76
176
|
s: Session,
|
|
@@ -82,13 +182,7 @@ export async function getCalendar(
|
|
|
82
182
|
const ranges = splitCalendarRange(startDate, endDate);
|
|
83
183
|
if (ranges.length === 0) return { events: [], failedRanges: [] };
|
|
84
184
|
|
|
85
|
-
|
|
86
|
-
method: 'POST',
|
|
87
|
-
body: URLS.REGISTRAR_TICKET_FORM_DATA(),
|
|
88
|
-
});
|
|
89
|
-
ticket = ticket.substring(1, ticket.length - 1);
|
|
90
|
-
|
|
91
|
-
await s.fetch(URLS.REGISTRAR_AUTH(ticket));
|
|
185
|
+
await signInToRegistrar(s);
|
|
92
186
|
|
|
93
187
|
const events: CalendarEvent[] = [];
|
|
94
188
|
const failedRanges: CalendarRange[] = [];
|
package/src/contents.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import type { Session } from './request.ts';
|
|
4
4
|
import type { AllContentsOptions, AllContentsResult, ContentTypeMap, CourseContent } from './types.ts';
|
|
5
|
+
import { apiError } from './utils.ts';
|
|
5
6
|
import { ContentType, CourseType, FailReason } from './types.ts';
|
|
6
7
|
import { getAnsweredQuestionList, getDiscussionList } from './discussions.ts';
|
|
7
8
|
import { getFileList } from './files.ts';
|
|
@@ -20,7 +21,7 @@ export async function getAllContents<T extends ContentType>(
|
|
|
20
21
|
options: AllContentsOptions<T> = {},
|
|
21
22
|
): Promise<AllContentsResult<T>> {
|
|
22
23
|
const courseType = options.courseType ?? CourseType.STUDENT;
|
|
23
|
-
const fetchContentForCourse = (id: string): Promise<ContentTypeMap[T][]> => {
|
|
24
|
+
const fetchContentForCourse = async (id: string): Promise<ContentTypeMap[T][]> => {
|
|
24
25
|
switch (type) {
|
|
25
26
|
case ContentType.NOTIFICATION:
|
|
26
27
|
return getNotificationList(s, id, courseType, {
|
|
@@ -31,10 +32,10 @@ export async function getAllContents<T extends ContentType>(
|
|
|
31
32
|
case ContentType.HOMEWORK:
|
|
32
33
|
if (courseType === CourseType.TEACHER) {
|
|
33
34
|
// the teacher list has another shape (`HomeworkTA`); use `getHomeworkList(id, CourseType.TEACHER)`
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
throw apiError(
|
|
36
|
+
FailReason.NOT_IMPLEMENTED,
|
|
37
|
+
'homework of a teacher course; use getHomeworkList(courseID, CourseType.TEACHER)',
|
|
38
|
+
);
|
|
38
39
|
}
|
|
39
40
|
return getHomeworkList(s, id, CourseType.STUDENT, {
|
|
40
41
|
details: (options as AllContentsOptions<ContentType.HOMEWORK>).details,
|
|
@@ -48,10 +49,7 @@ export async function getAllContents<T extends ContentType>(
|
|
|
48
49
|
details: (options as AllContentsOptions<ContentType.QUESTIONNAIRE>).details,
|
|
49
50
|
}) as Promise<ContentTypeMap[T][]>;
|
|
50
51
|
default:
|
|
51
|
-
|
|
52
|
-
reason: FailReason.NOT_IMPLEMENTED,
|
|
53
|
-
extra: 'Unknown content type',
|
|
54
|
-
});
|
|
52
|
+
throw apiError(FailReason.NOT_IMPLEMENTED, 'Unknown content type');
|
|
55
53
|
}
|
|
56
54
|
};
|
|
57
55
|
|
package/src/courses.ts
CHANGED
|
@@ -1,31 +1,25 @@
|
|
|
1
1
|
// Semesters and courses.
|
|
2
2
|
|
|
3
3
|
import type { Session } from './request.ts';
|
|
4
|
-
import type {
|
|
4
|
+
import type { CourseInfo, CourseListOptions, SemesterInfo } from './types.ts';
|
|
5
5
|
import { CourseType, FailReason } from './types.ts';
|
|
6
6
|
import * as URLS from './urls.ts';
|
|
7
|
-
import { decodeHTML, parseSemesterType } from './utils.ts';
|
|
7
|
+
import { apiError, decodeHTML, expectSuccess, parseSemesterType } from './utils.ts';
|
|
8
8
|
|
|
9
9
|
export async function getSemesterIdList(s: Session): Promise<string[]> {
|
|
10
|
-
const json = await s.fetchJsonWithToken(URLS.LEARN_SEMESTER_LIST
|
|
10
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_SEMESTER_LIST);
|
|
11
11
|
if (!Array.isArray(json)) {
|
|
12
|
-
|
|
13
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
14
|
-
extra: json,
|
|
15
|
-
});
|
|
12
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
16
13
|
}
|
|
17
14
|
const semesters = json as string[];
|
|
18
|
-
//
|
|
15
|
+
// the list can contain null entries
|
|
19
16
|
return semesters.filter((semester) => semester != null);
|
|
20
17
|
}
|
|
21
18
|
|
|
22
19
|
export async function getCurrentSemester(s: Session): Promise<SemesterInfo> {
|
|
23
|
-
const json = await s.fetchJsonWithToken(URLS.LEARN_CURRENT_SEMESTER
|
|
20
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_CURRENT_SEMESTER);
|
|
24
21
|
if (json.message !== 'success') {
|
|
25
|
-
|
|
26
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
27
|
-
extra: json,
|
|
28
|
-
});
|
|
22
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
29
23
|
}
|
|
30
24
|
const result = json.result;
|
|
31
25
|
return {
|
|
@@ -48,12 +42,9 @@ export async function getCourseList(
|
|
|
48
42
|
): Promise<CourseInfo[]> {
|
|
49
43
|
const json = await s.fetchJsonWithToken(URLS.LEARN_COURSE_LIST(semesterID, courseType, lang));
|
|
50
44
|
if (json.message !== 'success' || !Array.isArray(json.resultList)) {
|
|
51
|
-
|
|
52
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
53
|
-
extra: json,
|
|
54
|
-
});
|
|
45
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
55
46
|
}
|
|
56
|
-
const result =
|
|
47
|
+
const result = json.resultList as any[];
|
|
57
48
|
|
|
58
49
|
return Promise.all(
|
|
59
50
|
result.map(async (c) => {
|
|
@@ -62,7 +53,7 @@ export async function getCourseList(
|
|
|
62
53
|
try {
|
|
63
54
|
timeAndLocation = await getCourseTimeAndLocation(s, c.wlkcid);
|
|
64
55
|
} catch {
|
|
65
|
-
|
|
56
|
+
// left out, so a failure doesn't look like a course without a schedule; see `getCourseTimeAndLocation`
|
|
66
57
|
}
|
|
67
58
|
}
|
|
68
59
|
return {
|
|
@@ -87,7 +78,7 @@ export async function getCourseTimeAndLocation(s: Session, courseID: string): Pr
|
|
|
87
78
|
// see https://github.com/Harry-Chen/Learn-Helper/issues/145
|
|
88
79
|
const json = await s.fetchJsonWithToken(URLS.LEARN_COURSE_TIME_LOCATION(courseID));
|
|
89
80
|
if (!Array.isArray(json)) {
|
|
90
|
-
throw
|
|
81
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
91
82
|
}
|
|
92
83
|
return json as string[];
|
|
93
84
|
}
|
|
@@ -100,10 +91,5 @@ export async function sortCourses(s: Session, courseIDs: string[]): Promise<void
|
|
|
100
91
|
'Content-Type': 'application/json',
|
|
101
92
|
},
|
|
102
93
|
});
|
|
103
|
-
|
|
104
|
-
return Promise.reject({
|
|
105
|
-
reason: FailReason.OPERATION_FAILED,
|
|
106
|
-
extra: json,
|
|
107
|
-
});
|
|
108
|
-
}
|
|
94
|
+
expectSuccess(json, FailReason.OPERATION_FAILED);
|
|
109
95
|
}
|
package/src/discussions.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
import { Base64 } from 'js-base64';
|
|
4
4
|
|
|
5
5
|
import type { Session } from './request.ts';
|
|
6
|
-
import {
|
|
7
|
-
import type { Discussion, IDiscussionBase, Question } from './types.ts';
|
|
6
|
+
import type { Discussion, DiscussionBase, Question } from './types.ts';
|
|
8
7
|
import { CourseType, FailReason } from './types.ts';
|
|
9
8
|
import * as URLS from './urls.ts';
|
|
10
|
-
import { decodeHTML } from './utils.ts';
|
|
9
|
+
import { YES, decodeHTML, expectSuccess, toRequiredServerTime } from './utils.ts';
|
|
11
10
|
|
|
12
11
|
/** Get all discussions (课程讨论) of the specified course. */
|
|
13
12
|
export async function getDiscussionList(
|
|
@@ -16,12 +15,7 @@ export async function getDiscussionList(
|
|
|
16
15
|
courseType: CourseType = CourseType.STUDENT,
|
|
17
16
|
): Promise<Discussion[]> {
|
|
18
17
|
const json = await s.fetchJsonWithToken(URLS.LEARN_DISCUSSION_LIST(courseID, courseType));
|
|
19
|
-
|
|
20
|
-
return Promise.reject({
|
|
21
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
22
|
-
extra: json,
|
|
23
|
-
});
|
|
24
|
-
}
|
|
18
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
25
19
|
|
|
26
20
|
const result = (json.object?.resultsList ?? []) as any[];
|
|
27
21
|
|
|
@@ -45,12 +39,7 @@ export async function getAnsweredQuestionList(
|
|
|
45
39
|
courseType: CourseType = CourseType.STUDENT,
|
|
46
40
|
): Promise<Question[]> {
|
|
47
41
|
const json = await s.fetchJsonWithToken(URLS.LEARN_QUESTION_LIST_ANSWERED(courseID, courseType));
|
|
48
|
-
|
|
49
|
-
return Promise.reject({
|
|
50
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
51
|
-
extra: json,
|
|
52
|
-
});
|
|
53
|
-
}
|
|
42
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
54
43
|
|
|
55
44
|
const result = (json.object?.resultsList ?? []) as any[];
|
|
56
45
|
|
|
@@ -58,19 +47,19 @@ export async function getAnsweredQuestionList(
|
|
|
58
47
|
(q) =>
|
|
59
48
|
({
|
|
60
49
|
...parseDiscussionBase(q),
|
|
61
|
-
question: Base64.decode(q.wtnr),
|
|
50
|
+
question: Base64.decode(q.wtnr ?? ''),
|
|
62
51
|
url: URLS.LEARN_QUESTION_DETAIL(q.wlkcid, q.id, courseType),
|
|
63
52
|
}) satisfies Question,
|
|
64
53
|
);
|
|
65
54
|
}
|
|
66
55
|
|
|
67
|
-
function parseDiscussionBase(d: any):
|
|
56
|
+
function parseDiscussionBase(d: any): DiscussionBase {
|
|
68
57
|
return {
|
|
69
58
|
id: d.id,
|
|
70
59
|
title: decodeHTML(d.bt),
|
|
71
60
|
publisherName: d.fbrxm,
|
|
72
|
-
publishTime: d.fbsj,
|
|
73
|
-
lastReplyTime: d.zhhfsj,
|
|
61
|
+
publishTime: toRequiredServerTime(d.fbsj),
|
|
62
|
+
lastReplyTime: toRequiredServerTime(d.zhhfsj),
|
|
74
63
|
lastReplierName: d.zhhfrxm,
|
|
75
64
|
visitCount: d.djs ?? 0, // teacher cannot fetch this
|
|
76
65
|
replyCount: d.hfcs,
|