@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/notices.ts
CHANGED
|
@@ -3,11 +3,19 @@
|
|
|
3
3
|
import { Base64 } from 'js-base64';
|
|
4
4
|
|
|
5
5
|
import type { Session } from './request.ts';
|
|
6
|
-
import {
|
|
7
|
-
import type { INotification, Notification, NotificationListOptions, RemoteFile } from './types.ts';
|
|
6
|
+
import type { CourseNotification, CourseNotificationBase, NotificationListOptions, RemoteFile } from './types.ts';
|
|
8
7
|
import { ContentType, CourseType, FailReason } from './types.ts';
|
|
9
8
|
import * as URLS from './urls.ts';
|
|
10
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
$,
|
|
11
|
+
YES,
|
|
12
|
+
decodeHTML,
|
|
13
|
+
expectSuccess,
|
|
14
|
+
isSelected,
|
|
15
|
+
toRequiredServerTime,
|
|
16
|
+
toServerTime,
|
|
17
|
+
trimAndDefine,
|
|
18
|
+
} from './utils.ts';
|
|
11
19
|
|
|
12
20
|
/**
|
|
13
21
|
* Get all notifications (课程公告) of the specified course: current and expired lists.
|
|
@@ -18,13 +26,13 @@ export async function getNotificationList(
|
|
|
18
26
|
courseID: string,
|
|
19
27
|
courseType: CourseType = CourseType.STUDENT,
|
|
20
28
|
options: NotificationListOptions = {},
|
|
21
|
-
): Promise<
|
|
29
|
+
): Promise<CourseNotification[]> {
|
|
22
30
|
const lists = await Promise.all([
|
|
23
31
|
getNotificationListKind(s, courseID, courseType, false),
|
|
24
32
|
getNotificationListKind(s, courseID, courseType, true),
|
|
25
33
|
]);
|
|
26
34
|
return Promise.all(
|
|
27
|
-
lists.flat().map(async (n): Promise<
|
|
35
|
+
lists.flat().map(async (n): Promise<CourseNotification> => {
|
|
28
36
|
if (!n.attachmentName || !isSelected(options.attachments, n)) return n;
|
|
29
37
|
return { ...n, attachment: await getNotificationAttachment(s, n, courseType) };
|
|
30
38
|
}),
|
|
@@ -36,17 +44,12 @@ async function getNotificationListKind(
|
|
|
36
44
|
courseID: string,
|
|
37
45
|
courseType: CourseType,
|
|
38
46
|
expired: boolean,
|
|
39
|
-
): Promise<
|
|
47
|
+
): Promise<CourseNotificationBase[]> {
|
|
40
48
|
const json = await s.fetchJsonWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
|
|
41
49
|
method: 'POST',
|
|
42
50
|
body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
|
|
43
51
|
});
|
|
44
|
-
|
|
45
|
-
return Promise.reject({
|
|
46
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
47
|
-
extra: json,
|
|
48
|
-
});
|
|
49
|
-
}
|
|
52
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
50
53
|
|
|
51
54
|
const result = (json.object?.aaData ?? json.object?.resultsList ?? []) as any[];
|
|
52
55
|
|
|
@@ -60,12 +63,12 @@ async function getNotificationListKind(
|
|
|
60
63
|
publisher: n.fbrxm,
|
|
61
64
|
hasRead: n.sfyd === YES,
|
|
62
65
|
markedImportant: Number(n.sfqd) === 1, // n.sfqd could be string '1' (teacher mode) or number 1 (student mode)
|
|
63
|
-
publishTime: n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr,
|
|
64
|
-
expireTime: n.jzsj
|
|
66
|
+
publishTime: toRequiredServerTime(n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr),
|
|
67
|
+
expireTime: toServerTime(n.jzsj),
|
|
65
68
|
isFavorite: n.sfsc === YES,
|
|
66
69
|
comment: n.bznr ?? undefined,
|
|
67
70
|
attachmentName: typeof attachmentName === 'string' && attachmentName ? attachmentName : undefined,
|
|
68
|
-
} satisfies
|
|
71
|
+
} satisfies CourseNotificationBase;
|
|
69
72
|
});
|
|
70
73
|
}
|
|
71
74
|
|
|
@@ -75,20 +78,10 @@ async function getNotificationListKind(
|
|
|
75
78
|
*/
|
|
76
79
|
export async function getNotificationAttachment(
|
|
77
80
|
s: Session,
|
|
78
|
-
notification: Pick<
|
|
81
|
+
notification: Pick<CourseNotificationBase, 'url' | 'attachmentName'>,
|
|
79
82
|
courseType: CourseType = CourseType.STUDENT,
|
|
80
83
|
): Promise<RemoteFile | undefined> {
|
|
81
84
|
if (!notification.attachmentName) return undefined;
|
|
82
|
-
/// from JSON (backup, currently not used)
|
|
83
|
-
// const postParams = new FormData();
|
|
84
|
-
// postParams.append('id', id);
|
|
85
|
-
// postParams.append('wlkcid', courseID);
|
|
86
|
-
// const metadata = await s.fetchJsonWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
|
|
87
|
-
// 'method': 'POST',
|
|
88
|
-
// 'body': postParams,
|
|
89
|
-
// });
|
|
90
|
-
// const attachmentId = metadata.ggfjid as string;
|
|
91
|
-
/// parsed from HTML
|
|
92
85
|
const result = $(await s.fetchTextWithToken(notification.url));
|
|
93
86
|
let path = (courseType === CourseType.STUDENT ? result('.ml-10') : result('#wjid')).attr('href');
|
|
94
87
|
// the page has no attachment link (removed since the list was fetched, or the page changed)
|
package/src/questionnaires.ts
CHANGED
|
@@ -1,11 +1,19 @@
|
|
|
1
1
|
// Questionnaires (课程问卷).
|
|
2
2
|
|
|
3
3
|
import type { Session } from './request.ts';
|
|
4
|
-
import {
|
|
5
|
-
import type { ApiError, Questionnaire, QuestionnaireDetail, QuestionnaireListOptions } from './types.ts';
|
|
4
|
+
import type { Questionnaire, QuestionnaireDetail, QuestionnaireListOptions } from './types.ts';
|
|
6
5
|
import { FailReason, QuestionnaireType } from './types.ts';
|
|
7
6
|
import * as URLS from './urls.ts';
|
|
8
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
QNR_TYPE_MAP,
|
|
9
|
+
YES,
|
|
10
|
+
apiError,
|
|
11
|
+
decodeHTML,
|
|
12
|
+
expectSuccess,
|
|
13
|
+
isSelected,
|
|
14
|
+
toRequiredServerTime,
|
|
15
|
+
toServerTime,
|
|
16
|
+
} from './utils.ts';
|
|
9
17
|
|
|
10
18
|
/**
|
|
11
19
|
* Get all questionnaires (课程问卷/QNR) of the specified course: ongoing and ended lists.
|
|
@@ -32,12 +40,7 @@ export async function getQuestionnaireList(
|
|
|
32
40
|
|
|
33
41
|
async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: string): Promise<Questionnaire[]> {
|
|
34
42
|
const json = await s.fetchJsonWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) });
|
|
35
|
-
|
|
36
|
-
return Promise.reject({
|
|
37
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
38
|
-
extra: json,
|
|
39
|
-
});
|
|
40
|
-
}
|
|
43
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
41
44
|
const result = (json.object?.aaData ?? []) as any[];
|
|
42
45
|
return result.map((e) => {
|
|
43
46
|
const type = QNR_TYPE_MAP.get(e.wjlx) ?? QuestionnaireType.SURVEY;
|
|
@@ -45,12 +48,12 @@ async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: stri
|
|
|
45
48
|
id: e.wjid,
|
|
46
49
|
type,
|
|
47
50
|
title: decodeHTML(e.wjbt),
|
|
48
|
-
startTime: e.kssj,
|
|
49
|
-
endTime: e.jssj,
|
|
50
|
-
uploadTime: e.scsj,
|
|
51
|
+
startTime: toRequiredServerTime(e.kssj),
|
|
52
|
+
endTime: toRequiredServerTime(e.jssj),
|
|
53
|
+
uploadTime: toRequiredServerTime(e.scsj),
|
|
51
54
|
uploaderId: e.scr,
|
|
52
55
|
uploaderName: e.scrxm,
|
|
53
|
-
submitTime: e.tjsj
|
|
56
|
+
submitTime: toServerTime(e.tjsj),
|
|
54
57
|
isFavorite: e.sfsc === YES,
|
|
55
58
|
comment: e.bznr ?? undefined,
|
|
56
59
|
url: URLS.LEARN_QNR_SUBMIT_PAGE(e.wlkcid, e.wjid, type),
|
|
@@ -69,7 +72,7 @@ export async function getQuestionnaireDetail(
|
|
|
69
72
|
body: URLS.LEARN_QNR_DETAIL_FORM(courseID, qnrID),
|
|
70
73
|
});
|
|
71
74
|
if (!Array.isArray(json)) {
|
|
72
|
-
throw
|
|
75
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
73
76
|
}
|
|
74
77
|
return json.map(
|
|
75
78
|
(e) =>
|
|
@@ -79,7 +82,7 @@ export async function getQuestionnaireDetail(
|
|
|
79
82
|
type: e.type,
|
|
80
83
|
required: e.require === YES,
|
|
81
84
|
title: decodeHTML(e.wtbt),
|
|
82
|
-
score: e.wtfz ? Number(e.wtfz) : undefined, //
|
|
85
|
+
score: e.wtfz ? Number(e.wtfz) : undefined, // a number or a string of digits; none when missing or 0
|
|
83
86
|
options: (e.list as any[])?.map((o) => ({
|
|
84
87
|
id: o.xxid,
|
|
85
88
|
index: Number(o.xxbh),
|
package/src/request.ts
CHANGED
|
@@ -1,22 +1,13 @@
|
|
|
1
|
-
import * as cheerio from 'cheerio';
|
|
2
|
-
import type { Element } from 'cheerio';
|
|
3
1
|
import fetchCookie from 'fetch-cookie';
|
|
4
2
|
import { fetch } from 'node-fetch-native';
|
|
5
3
|
import { CookieJar } from 'tough-cookie';
|
|
6
4
|
|
|
7
5
|
import type { ApiError, CredentialProvider, Fetch, HelperConfig } from './types.ts';
|
|
8
6
|
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
|
-
};
|
|
7
|
+
import { apiError, looksLikeLoginPage, urlOf } from './utils.ts';
|
|
15
8
|
|
|
16
9
|
export const noLogin = (res: Response) => res.url.includes('login_timeout') || res.status === 403;
|
|
17
10
|
|
|
18
|
-
export const YES = '是';
|
|
19
|
-
|
|
20
11
|
/** default for `HelperConfig.maxConcurrentRequests` */
|
|
21
12
|
export const DEFAULT_MAX_CONCURRENT_REQUESTS = 6;
|
|
22
13
|
/** default for `HelperConfig.requestTimeout`, in milliseconds */
|
|
@@ -33,9 +24,6 @@ function makeFetch(jar?: CookieJar): typeof fetch {
|
|
|
33
24
|
return fetchCookie(fetch, jar);
|
|
34
25
|
}
|
|
35
26
|
|
|
36
|
-
const urlOf = (input: Parameters<Fetch>[0]): string =>
|
|
37
|
-
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
38
|
-
|
|
39
27
|
/**
|
|
40
28
|
* Let at most `max` calls of `inner` run at once; the rest wait in FIFO order. A slot is freed when the response
|
|
41
29
|
* headers arrive (or the call fails), so reading a body does not hold one.
|
|
@@ -100,10 +88,7 @@ export function withTimeout(inner: Fetch, ms: number): TimedFetch {
|
|
|
100
88
|
} catch (err) {
|
|
101
89
|
outer?.removeEventListener('abort', forward);
|
|
102
90
|
if (timedOut) {
|
|
103
|
-
throw {
|
|
104
|
-
reason: FailReason.REQUEST_TIMEOUT,
|
|
105
|
-
extra: { url: urlOf(input), timeout },
|
|
106
|
-
} as ApiError;
|
|
91
|
+
throw apiError(FailReason.REQUEST_TIMEOUT, { url: urlOf(input), timeout });
|
|
107
92
|
}
|
|
108
93
|
throw err;
|
|
109
94
|
} finally {
|
|
@@ -112,31 +97,36 @@ export function withTimeout(inner: Fetch, ms: number): TimedFetch {
|
|
|
112
97
|
};
|
|
113
98
|
}
|
|
114
99
|
|
|
100
|
+
/**
|
|
101
|
+
* Reject a call of `inner` that fails without any response (the fetch implementation's own error: `TypeError: fetch
|
|
102
|
+
* failed` in Node, `Network request failed` in React Native) with `FailReason.NETWORK_ERROR`, keeping the error in
|
|
103
|
+
* `extra`. An `ApiError` (such as `REQUEST_TIMEOUT`) and the caller's own abort pass through unchanged.
|
|
104
|
+
*/
|
|
105
|
+
export function withNetworkError(inner: TimedFetch): TimedFetch {
|
|
106
|
+
return async (input, init) => {
|
|
107
|
+
try {
|
|
108
|
+
return await inner(input, init);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
if ((err as ApiError | undefined)?.reason !== undefined || init?.signal?.aborted) throw err;
|
|
111
|
+
throw apiError(FailReason.NETWORK_ERROR, err);
|
|
112
|
+
}
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** whether an error means the server was not reached: `NETWORK_ERROR`, or `REQUEST_TIMEOUT` (no answer in time) */
|
|
117
|
+
export const isConnectionError = (err: unknown): boolean => {
|
|
118
|
+
const reason = (err as ApiError | undefined)?.reason;
|
|
119
|
+
return reason === FailReason.NETWORK_ERROR || reason === FailReason.REQUEST_TIMEOUT;
|
|
120
|
+
};
|
|
121
|
+
|
|
115
122
|
/** free the connection of a response we won't read */
|
|
116
|
-
const discard = (res: Response) => {
|
|
123
|
+
export const discard = (res: Response) => {
|
|
117
124
|
res.body?.cancel().catch(() => {});
|
|
118
125
|
};
|
|
119
126
|
|
|
120
127
|
/** returned by a read step when the response turned out to be from an expired session */
|
|
121
128
|
const EXPIRED = Symbol('expired');
|
|
122
129
|
|
|
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
|
-
|
|
140
130
|
/**
|
|
141
131
|
* State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
|
|
142
132
|
* Internal; the helper keeps it in a private field.
|
|
@@ -150,8 +140,19 @@ export class Session {
|
|
|
150
140
|
/** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
|
|
151
141
|
#generation = 0;
|
|
152
142
|
#pendingLogin?: Promise<void>;
|
|
143
|
+
/**
|
|
144
|
+
* The `BAD_CREDENTIAL` of the last failed `CredentialProvider` login. Automatic logins (an expired session, no CSRF
|
|
145
|
+
* token yet) reject with it instead of trying the same credentials again, which could lock the account; an explicit
|
|
146
|
+
* `login`, or a new CSRF token, clears it.
|
|
147
|
+
*/
|
|
148
|
+
#badCredential?: ApiError;
|
|
153
149
|
lang = Language.ZH;
|
|
154
150
|
previewFirstPage: boolean;
|
|
151
|
+
/**
|
|
152
|
+
* The device fingerprints of the last SSO login. id.tsinghua.edu.cn asks for them again when another site (WebVPN,
|
|
153
|
+
* for the registrar) signs in with the same id.tsinghua.edu.cn session.
|
|
154
|
+
*/
|
|
155
|
+
fingerprints?: { fingerPrint: string; fingerGenPrint: string; fingerGenPrint3: string };
|
|
155
156
|
|
|
156
157
|
/** `loginWithProvider` signs in through SSO with the `CredentialProvider` */
|
|
157
158
|
constructor(
|
|
@@ -162,7 +163,9 @@ export class Session {
|
|
|
162
163
|
this.provider = config?.provider;
|
|
163
164
|
this.cookieJar = config?.cookieJar ?? new CookieJar();
|
|
164
165
|
this.rawFetch = limitConcurrency(
|
|
165
|
-
|
|
166
|
+
withNetworkError(
|
|
167
|
+
withTimeout(config?.fetch ?? makeFetch(this.cookieJar), config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT),
|
|
168
|
+
),
|
|
166
169
|
config?.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
167
170
|
);
|
|
168
171
|
}
|
|
@@ -174,6 +177,7 @@ export class Session {
|
|
|
174
177
|
set csrfToken(token: string) {
|
|
175
178
|
this.#csrfToken = token;
|
|
176
179
|
this.#generation += 1;
|
|
180
|
+
this.#badCredential = undefined;
|
|
177
181
|
}
|
|
178
182
|
|
|
179
183
|
/**
|
|
@@ -192,18 +196,40 @@ export class Session {
|
|
|
192
196
|
|
|
193
197
|
/**
|
|
194
198
|
* Log in with the `CredentialProvider`, or wait for the login already in progress (also one with explicit
|
|
195
|
-
* credentials) instead of starting another.
|
|
199
|
+
* credentials) instead of starting another. Called by `login()`, so it tries again after a `BAD_CREDENTIAL`.
|
|
196
200
|
*/
|
|
197
201
|
relogin(): Promise<void> {
|
|
198
|
-
|
|
202
|
+
this.#badCredential = undefined;
|
|
203
|
+
return this.autoRelogin();
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* `relogin` for a login nobody asked for (an expired session, no CSRF token yet, the registrar wanting a new
|
|
208
|
+
* id.tsinghua.edu.cn session), unless the last provider login was turned down with `BAD_CREDENTIAL`: then reject with
|
|
209
|
+
* that again
|
|
210
|
+
*/
|
|
211
|
+
autoRelogin(): Promise<void> {
|
|
212
|
+
return this.#badCredential
|
|
213
|
+
? Promise.reject(this.#badCredential)
|
|
214
|
+
: (this.#pendingLogin ?? this.runLogin(this.#providerLogin));
|
|
199
215
|
}
|
|
200
216
|
|
|
217
|
+
#providerLogin = async (): Promise<void> => {
|
|
218
|
+
try {
|
|
219
|
+
await this.loginWithProvider();
|
|
220
|
+
} catch (err) {
|
|
221
|
+
if ((err as ApiError | undefined)?.reason === FailReason.BAD_CREDENTIAL) this.#badCredential = err as ApiError;
|
|
222
|
+
throw err;
|
|
223
|
+
}
|
|
224
|
+
};
|
|
225
|
+
|
|
201
226
|
/**
|
|
202
227
|
* Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token) and read it
|
|
203
228
|
* with `read`, which returns `EXPIRED` for a body that shows the session has expired.
|
|
204
229
|
* When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
|
|
205
230
|
* logs in again (sharing a login already in progress, or skipping it if another request already renewed the
|
|
206
|
-
* session) and retries once.
|
|
231
|
+
* session) and retries once. After a provider login failed with `BAD_CREDENTIAL` it rejects with that instead, until
|
|
232
|
+
* the next explicit `login`.
|
|
207
233
|
*/
|
|
208
234
|
async #send<T>(
|
|
209
235
|
makeUrl: () => string,
|
|
@@ -222,14 +248,14 @@ export class Session {
|
|
|
222
248
|
const first = await attempt(false);
|
|
223
249
|
if (first !== EXPIRED) return first;
|
|
224
250
|
if (!this.provider) {
|
|
225
|
-
throw
|
|
251
|
+
throw apiError(FailReason.NOT_LOGGED_IN);
|
|
226
252
|
}
|
|
227
253
|
if (this.#generation === generation) {
|
|
228
|
-
await this.
|
|
254
|
+
await this.autoRelogin();
|
|
229
255
|
}
|
|
230
256
|
const second = await attempt(true);
|
|
231
257
|
if (second === EXPIRED) {
|
|
232
|
-
throw
|
|
258
|
+
throw apiError(FailReason.NOT_LOGGED_IN);
|
|
233
259
|
}
|
|
234
260
|
return second;
|
|
235
261
|
}
|
|
@@ -238,25 +264,26 @@ export class Session {
|
|
|
238
264
|
static readonly #response = async (res: Response, retry: boolean): Promise<Response> => {
|
|
239
265
|
if (retry && res.status !== 200) {
|
|
240
266
|
discard(res);
|
|
241
|
-
throw {
|
|
242
|
-
reason: FailReason.UNEXPECTED_STATUS,
|
|
243
|
-
extra: { code: res.status, text: res.statusText },
|
|
244
|
-
} as ApiError;
|
|
267
|
+
throw apiError(FailReason.UNEXPECTED_STATUS, { code: res.status, text: res.statusText });
|
|
245
268
|
}
|
|
246
269
|
return res;
|
|
247
270
|
};
|
|
248
271
|
|
|
249
|
-
/**
|
|
250
|
-
|
|
272
|
+
/**
|
|
273
|
+
* The parsed JSON body (after a re-login only from a response with status 200); the login page with status 200
|
|
274
|
+
* instead means the session has expired.
|
|
275
|
+
*/
|
|
276
|
+
static readonly #json = async (res: Response, retry: boolean): Promise<any> => {
|
|
277
|
+
await Session.#response(res, retry);
|
|
251
278
|
const text = await res.text();
|
|
252
279
|
try {
|
|
253
280
|
return JSON.parse(text);
|
|
254
281
|
} catch {
|
|
255
|
-
if (res.status === 200 &&
|
|
256
|
-
throw {
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
}
|
|
282
|
+
if (res.status === 200 && looksLikeLoginPage(text)) return EXPIRED;
|
|
283
|
+
throw apiError(res.status === 200 ? FailReason.INVALID_RESPONSE : FailReason.UNEXPECTED_STATUS, {
|
|
284
|
+
code: res.status,
|
|
285
|
+
text: text.slice(0, 200),
|
|
286
|
+
});
|
|
260
287
|
}
|
|
261
288
|
};
|
|
262
289
|
|
|
@@ -269,22 +296,19 @@ export class Session {
|
|
|
269
296
|
|
|
270
297
|
#withToken = async (input: Parameters<Fetch>[0]): Promise<() => string> => {
|
|
271
298
|
if (this.#csrfToken === '') {
|
|
272
|
-
await this.
|
|
299
|
+
await this.autoRelogin();
|
|
273
300
|
}
|
|
274
301
|
const url = urlOf(input);
|
|
275
302
|
return () => addCSRFTokenToUrl(url, this.#csrfToken);
|
|
276
303
|
};
|
|
277
304
|
|
|
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 */
|
|
305
|
+
/** fetch with a login check (see `#send`); logs in first if there is no CSRF token yet and adds it to the URL */
|
|
282
306
|
readonly fetchWithToken: TimedFetch = async (input, init) =>
|
|
283
307
|
this.#send(await this.#withToken(input), init, Session.#response);
|
|
284
308
|
|
|
285
309
|
/**
|
|
286
|
-
* Like `fetchWithToken`, but returns the parsed JSON body.
|
|
287
|
-
* session (log in again and retry once, as for a 403)
|
|
310
|
+
* Like `fetchWithToken`, but returns the parsed JSON body. The short login page with status 200 instead of JSON
|
|
311
|
+
* counts as an expired session (log in again and retry once, as for a 403); any other page is `INVALID_RESPONSE`.
|
|
288
312
|
*/
|
|
289
313
|
readonly fetchJsonWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<any> =>
|
|
290
314
|
this.#send(await this.#withToken(input), init, Session.#json);
|
|
@@ -302,12 +326,12 @@ export class Session {
|
|
|
302
326
|
*/
|
|
303
327
|
async ensureSession(probeUrl: string): Promise<void> {
|
|
304
328
|
if (this.#csrfToken === '') {
|
|
305
|
-
await this.
|
|
329
|
+
await this.autoRelogin();
|
|
306
330
|
return;
|
|
307
331
|
}
|
|
308
332
|
const json = await this.fetchJsonWithToken(probeUrl);
|
|
309
333
|
if (json?.message !== 'success') {
|
|
310
|
-
throw
|
|
334
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
311
335
|
}
|
|
312
336
|
}
|
|
313
337
|
}
|
package/src/timetable.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// Reading a course's time and location (`Course.timeAndLocation`) and counting teaching weeks, for working out a
|
|
2
|
+
// timetable without the registrar's calendar.
|
|
3
|
+
|
|
4
|
+
import type { CourseMeeting } from './types.ts';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The start and end (Beijing time, HH:mm) of each class period (大节), which is what a course's time and location
|
|
8
|
+
* counts in.
|
|
9
|
+
*/
|
|
10
|
+
export const CLASS_PERIOD_TIMES: Readonly<Record<number, readonly [string, string]>> = {
|
|
11
|
+
1: ['08:00', '09:35'],
|
|
12
|
+
2: ['09:50', '12:15'],
|
|
13
|
+
3: ['13:30', '15:05'],
|
|
14
|
+
4: ['15:20', '16:55'],
|
|
15
|
+
5: ['17:05', '18:40'],
|
|
16
|
+
6: ['19:20', '21:45'],
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
/** weeks in a regular term, for "every other week", "last eight weeks" and the like */
|
|
20
|
+
const TERM_WEEKS = 16;
|
|
21
|
+
|
|
22
|
+
const range = (from: number, to: number, step = 1): number[] => {
|
|
23
|
+
const weeks: number[] = [];
|
|
24
|
+
for (let w = from; w <= to; w += step) weeks.push(w);
|
|
25
|
+
return weeks;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
const WEEKDAYS: Record<string, number> = { 一: 1, 二: 2, 三: 3, 四: 4, 五: 5, 六: 6, 日: 7, 天: 7 };
|
|
29
|
+
|
|
30
|
+
/** English weekdays by their first three letters (the server writes `Mon`, `Tues`, `Thur`, …) */
|
|
31
|
+
const ENGLISH_WEEKDAYS: Record<string, number> = { mon: 1, tue: 2, wed: 3, thu: 4, fri: 5, sat: 6, sun: 7 };
|
|
32
|
+
|
|
33
|
+
/** week specs that aren't a list of weeks, in Chinese and in English (spaces removed, lower case) */
|
|
34
|
+
const NAMED_WEEKS: Record<string, () => number[] | null> = {
|
|
35
|
+
全周: () => null,
|
|
36
|
+
all: () => null,
|
|
37
|
+
every: () => null,
|
|
38
|
+
full: () => null,
|
|
39
|
+
前八周: () => range(1, 8),
|
|
40
|
+
first8: () => range(1, 8),
|
|
41
|
+
firsteight: () => range(1, 8),
|
|
42
|
+
后八周: () => range(9, TERM_WEEKS),
|
|
43
|
+
last8: () => range(9, TERM_WEEKS),
|
|
44
|
+
lasteight: () => range(9, TERM_WEEKS),
|
|
45
|
+
单周: () => range(1, TERM_WEEKS, 2),
|
|
46
|
+
odd: () => range(1, TERM_WEEKS, 2),
|
|
47
|
+
双周: () => range(2, TERM_WEEKS, 2),
|
|
48
|
+
even: () => range(2, TERM_WEEKS, 2),
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The teaching weeks a meeting happens in, from the part of its time and location in parentheses: `全周`, `1-8周`,
|
|
53
|
+
* `9,11周`, `单周`, or in English `Week all`, `Week 1-8`, `Odd weeks`. `null` means every week, also for text it
|
|
54
|
+
* doesn't know.
|
|
55
|
+
*/
|
|
56
|
+
export function parseWeeks(text: string): number[] | null {
|
|
57
|
+
const spec = text
|
|
58
|
+
.replace(/\s/g, '')
|
|
59
|
+
.toLowerCase()
|
|
60
|
+
.replace(/^(?:第|weeks?|wks?)/, '')
|
|
61
|
+
.replace(/(?:周|weeks?|wks?)$/, '');
|
|
62
|
+
const named = NAMED_WEEKS[spec] ?? NAMED_WEEKS[`${spec}周`];
|
|
63
|
+
if (named) return named();
|
|
64
|
+
const weeks: number[] = [];
|
|
65
|
+
for (const part of spec.split(/[,,、;;]/)) {
|
|
66
|
+
const match = /^(\d+)(?:[-~~–—](\d+))?$/.exec(part);
|
|
67
|
+
if (!match) return null;
|
|
68
|
+
const from = Number(match[1]);
|
|
69
|
+
weeks.push(...range(from, match[2] ? Number(match[2]) : from));
|
|
70
|
+
}
|
|
71
|
+
return weeks;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const toMeeting = (weekday: number | undefined, period: number, weeks: string, location: string) =>
|
|
75
|
+
weekday && CLASS_PERIOD_TIMES[period]
|
|
76
|
+
? { weekday, period, weeks: parseWeeks(weeks), location: location.trim() }
|
|
77
|
+
: null;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Read one entry of a course's time and location (`Course.timeAndLocation`): `星期六第1节(9,11周),六教6A414`, or
|
|
81
|
+
* `Section 1 of Sat (Week 9,11 ), 六教6A414` when the website is in English. Entries without a day or a known period
|
|
82
|
+
* (`星期第0节(全周),`, `Section 0 (Week all ), `) give `null`.
|
|
83
|
+
*/
|
|
84
|
+
export function parseCourseMeeting(text: string): CourseMeeting | null {
|
|
85
|
+
const chinese = /星期([一二三四五六日天])第(\d+)节\s*[((]([^))]*)[))]\s*[,,]?\s*(.*)$/.exec(text);
|
|
86
|
+
if (chinese) return toMeeting(WEEKDAYS[chinese[1]], Number(chinese[2]), chinese[3], chinese[4]);
|
|
87
|
+
const english = /section\s*(\d+)\s*(?:of\s*([a-z]+)\.?)?\s*[((]([^))]*)[))]\s*[,,]?\s*(.*)$/i.exec(text);
|
|
88
|
+
if (english) {
|
|
89
|
+
const day = english[2]?.slice(0, 3).toLowerCase();
|
|
90
|
+
return toMeeting(day ? ENGLISH_WEEKDAYS[day] : undefined, Number(english[1]), english[3], english[4]);
|
|
91
|
+
}
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
96
|
+
|
|
97
|
+
/** a YYYY-MM-DD date (anything after it, like a time, is ignored) as days since the epoch */
|
|
98
|
+
const dayNumber = (date: string): number => {
|
|
99
|
+
const match = /^(\d{4})-(\d{2})-(\d{2})/.exec(date);
|
|
100
|
+
const time = match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : NaN;
|
|
101
|
+
if (Number.isNaN(time) || new Date(time).toISOString().slice(0, 10) !== date.slice(0, 10)) {
|
|
102
|
+
throw new RangeError(`invalid date ${date}, expected YYYY-MM-DD`);
|
|
103
|
+
}
|
|
104
|
+
return time / DAY_MS;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** days since the epoch of the Monday of the week of `day` (days since the epoch; day 0 was a Thursday) */
|
|
108
|
+
const mondayOf = (day: number) => day - ((day + 3) % 7);
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* The teaching week `date` falls in, from 1 (0 or less before the term), for a term whose first day
|
|
112
|
+
* (`SemesterInfo.startDate`) is `semesterStartDate`. Both are YYYY-MM-DD Beijing dates. Week 1 starts on the Monday
|
|
113
|
+
* of the first day, or on the Monday after it when the first day is a weekend (fall 2026 starts on Saturday Sep 12;
|
|
114
|
+
* week 1 is Sep 14–20). Throws a `RangeError` for a date that isn't YYYY-MM-DD.
|
|
115
|
+
*/
|
|
116
|
+
export function semesterWeek(semesterStartDate: string, date: string): number {
|
|
117
|
+
const start = dayNumber(semesterStartDate);
|
|
118
|
+
const weekday = (start + 3) % 7; // 0 Monday … 6 Sunday
|
|
119
|
+
const firstMonday = weekday >= 5 ? start + 7 - weekday : start - weekday;
|
|
120
|
+
return (mondayOf(dayNumber(date)) - firstMonday) / 7 + 1;
|
|
121
|
+
}
|