@robertying/thu-learn-lib 5.0.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 +94 -227
- package/lib/auth.d.ts +15 -4
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js +65 -55
- package/lib/auth.js.map +1 -1
- package/lib/calendar.d.ts +12 -5
- package/lib/calendar.d.ts.map +1 -1
- package/lib/calendar.js +118 -20
- package/lib/calendar.js.map +1 -1
- package/lib/contents.d.ts.map +1 -1
- package/lib/contents.js +10 -6
- package/lib/contents.js.map +1 -1
- package/lib/courses.d.ts.map +1 -1
- package/lib/courses.js +15 -29
- package/lib/courses.js.map +1 -1
- package/lib/discussions.d.ts.map +1 -1
- package/lib/discussions.js +8 -19
- 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 +25 -60
- package/lib/favorites.js.map +1 -1
- package/lib/files.d.ts +4 -4
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +112 -134
- package/lib/files.js.map +1 -1
- package/lib/homework.d.ts +35 -5
- package/lib/homework.d.ts.map +1 -1
- package/lib/homework.js +150 -92
- package/lib/homework.js.map +1 -1
- package/lib/index.d.ts +45 -20
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +52 -17
- package/lib/index.js.map +1 -1
- package/lib/notices.d.ts +4 -4
- package/lib/notices.d.ts.map +1 -1
- package/lib/notices.js +13 -33
- 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 +26 -24
- package/lib/questionnaires.js.map +1 -1
- package/lib/request.d.ts +64 -20
- package/lib/request.d.ts.map +1 -1
- package/lib/request.js +174 -64
- 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 +117 -76
- 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 +20 -6
- 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 +80 -29
- package/lib/utils.js.map +1 -1
- package/package.json +9 -8
- package/src/auth.ts +72 -70
- package/src/calendar.ts +136 -24
- package/src/contents.ts +13 -6
- package/src/courses.ts +21 -37
- package/src/discussions.ts +10 -21
- package/src/favorites.ts +44 -80
- package/src/files.ts +124 -142
- package/src/homework.ts +207 -135
- package/src/index.ts +79 -48
- package/src/notices.ts +29 -42
- package/src/questionnaires.ts +62 -45
- package/src/request.ts +192 -69
- package/src/timetable.ts +121 -0
- package/src/types.ts +123 -87
- package/src/urls.ts +13 -64
- package/src/user.ts +22 -7
- package/src/utils.ts +97 -29
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,14 +24,11 @@ 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.
|
|
42
30
|
*/
|
|
43
|
-
export function limitConcurrency(inner:
|
|
31
|
+
export function limitConcurrency<F extends (...args: any[]) => Promise<Response>>(inner: F, max: number): F {
|
|
44
32
|
if (!(max >= 1) || max === Infinity) return inner;
|
|
45
33
|
let active = 0;
|
|
46
34
|
const waiting: (() => void)[] = [];
|
|
@@ -57,56 +45,88 @@ export function limitConcurrency(inner: Fetch, max: number): Fetch {
|
|
|
57
45
|
if (next) next();
|
|
58
46
|
else active -= 1;
|
|
59
47
|
};
|
|
60
|
-
return async (...args) => {
|
|
48
|
+
return (async (...args: Parameters<F>) => {
|
|
61
49
|
await acquire();
|
|
62
50
|
try {
|
|
63
51
|
return await inner(...args);
|
|
64
52
|
} finally {
|
|
65
53
|
release();
|
|
66
54
|
}
|
|
67
|
-
};
|
|
55
|
+
}) as F;
|
|
68
56
|
}
|
|
69
57
|
|
|
58
|
+
/** `RequestInit` plus a per-request `timeout` (milliseconds; 0 or `Infinity` for none) */
|
|
59
|
+
export type RequestOptions = RequestInit & { timeout?: number };
|
|
60
|
+
|
|
61
|
+
/** a `Fetch` whose `init` can also carry a per-request `timeout` */
|
|
62
|
+
export type TimedFetch = (input: Parameters<Fetch>[0], init?: RequestOptions) => Promise<Response>;
|
|
63
|
+
|
|
70
64
|
/**
|
|
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
|
|
65
|
+
* Abort a call of `inner` that has no response headers after `ms` milliseconds (or `init.timeout`, which overrides it
|
|
66
|
+
* for one request) and reject it with `FailReason.REQUEST_TIMEOUT`. A caller's own `signal` still works, also while
|
|
67
|
+
* the body is read. Uses only `AbortController` and `setTimeout`, which React Native has (unlike
|
|
68
|
+
* `AbortSignal.timeout` / `AbortSignal.any`). Note that React Native's fetch resolves only after the whole body has
|
|
69
|
+
* arrived, so there the timeout covers the download too.
|
|
74
70
|
*/
|
|
75
|
-
export function withTimeout(inner: Fetch, ms: number):
|
|
76
|
-
|
|
77
|
-
|
|
71
|
+
export function withTimeout(inner: Fetch, ms: number): TimedFetch {
|
|
72
|
+
return async (input, options) => {
|
|
73
|
+
const { timeout = ms, ...init } = options ?? {};
|
|
74
|
+
if (!(timeout > 0) || timeout === Infinity) return inner(input, init);
|
|
78
75
|
const controller = new AbortController();
|
|
79
|
-
const outer = init
|
|
76
|
+
const outer = init.signal ?? undefined;
|
|
80
77
|
const forward = () => controller.abort(outer?.reason);
|
|
81
78
|
if (outer?.aborted) forward();
|
|
79
|
+
// kept after the headers arrive, so the caller can still cancel reading the body
|
|
82
80
|
else outer?.addEventListener('abort', forward, { once: true });
|
|
83
81
|
let timedOut = false;
|
|
84
82
|
const timer = setTimeout(() => {
|
|
85
83
|
timedOut = true;
|
|
86
84
|
controller.abort();
|
|
87
|
-
},
|
|
85
|
+
}, timeout);
|
|
88
86
|
try {
|
|
89
87
|
return await inner(input, { ...init, signal: controller.signal });
|
|
90
88
|
} catch (err) {
|
|
89
|
+
outer?.removeEventListener('abort', forward);
|
|
91
90
|
if (timedOut) {
|
|
92
|
-
throw {
|
|
93
|
-
reason: FailReason.REQUEST_TIMEOUT,
|
|
94
|
-
extra: { url: urlOf(input), timeout: ms },
|
|
95
|
-
} as ApiError;
|
|
91
|
+
throw apiError(FailReason.REQUEST_TIMEOUT, { url: urlOf(input), timeout });
|
|
96
92
|
}
|
|
97
93
|
throw err;
|
|
98
94
|
} finally {
|
|
99
95
|
clearTimeout(timer);
|
|
100
|
-
outer?.removeEventListener('abort', forward);
|
|
101
96
|
}
|
|
102
97
|
};
|
|
103
98
|
}
|
|
104
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
|
+
|
|
105
122
|
/** free the connection of a response we won't read */
|
|
106
|
-
const discard = (res: Response) => {
|
|
123
|
+
export const discard = (res: Response) => {
|
|
107
124
|
res.body?.cancel().catch(() => {});
|
|
108
125
|
};
|
|
109
126
|
|
|
127
|
+
/** returned by a read step when the response turned out to be from an expired session */
|
|
128
|
+
const EXPIRED = Symbol('expired');
|
|
129
|
+
|
|
110
130
|
/**
|
|
111
131
|
* State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
|
|
112
132
|
* Internal; the helper keeps it in a private field.
|
|
@@ -115,24 +135,37 @@ export class Session {
|
|
|
115
135
|
readonly provider?: CredentialProvider;
|
|
116
136
|
readonly cookieJar: CookieJar;
|
|
117
137
|
/** fetch with cookies, the concurrency cap and the timeout, but no login check */
|
|
118
|
-
readonly rawFetch:
|
|
138
|
+
readonly rawFetch: TimedFetch;
|
|
119
139
|
#csrfToken = '';
|
|
120
140
|
/** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
|
|
121
141
|
#generation = 0;
|
|
122
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;
|
|
123
149
|
lang = Language.ZH;
|
|
124
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 };
|
|
125
156
|
|
|
126
|
-
/** `
|
|
157
|
+
/** `loginWithProvider` signs in through SSO with the `CredentialProvider` */
|
|
127
158
|
constructor(
|
|
128
159
|
config: HelperConfig | undefined,
|
|
129
|
-
readonly
|
|
160
|
+
readonly loginWithProvider: () => Promise<void>,
|
|
130
161
|
) {
|
|
131
162
|
this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
|
|
132
163
|
this.provider = config?.provider;
|
|
133
164
|
this.cookieJar = config?.cookieJar ?? new CookieJar();
|
|
134
165
|
this.rawFetch = limitConcurrency(
|
|
135
|
-
|
|
166
|
+
withNetworkError(
|
|
167
|
+
withTimeout(config?.fetch ?? makeFetch(this.cookieJar), config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT),
|
|
168
|
+
),
|
|
136
169
|
config?.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
|
|
137
170
|
);
|
|
138
171
|
}
|
|
@@ -144,71 +177,161 @@ export class Session {
|
|
|
144
177
|
set csrfToken(token: string) {
|
|
145
178
|
this.#csrfToken = token;
|
|
146
179
|
this.#generation += 1;
|
|
180
|
+
this.#badCredential = undefined;
|
|
147
181
|
}
|
|
148
182
|
|
|
149
183
|
/**
|
|
150
|
-
*
|
|
184
|
+
* Run one login (`login`) at a time. It starts after a login already in progress has settled, and requests that
|
|
185
|
+
* find the session expired meanwhile wait for it instead of starting their own (two SSO logins at once reset each
|
|
186
|
+
* other's id.tsinghua.edu.cn cookie).
|
|
151
187
|
*/
|
|
152
|
-
|
|
153
|
-
this.#pendingLogin
|
|
154
|
-
|
|
188
|
+
runLogin(login: () => Promise<void>): Promise<void> {
|
|
189
|
+
const previous = this.#pendingLogin?.catch(() => {}) ?? Promise.resolve();
|
|
190
|
+
const current: Promise<void> = previous.then(login).finally(() => {
|
|
191
|
+
if (this.#pendingLogin === current) this.#pendingLogin = undefined;
|
|
155
192
|
});
|
|
156
|
-
|
|
193
|
+
this.#pendingLogin = current;
|
|
194
|
+
return current;
|
|
157
195
|
}
|
|
158
196
|
|
|
159
197
|
/**
|
|
160
|
-
*
|
|
198
|
+
* Log in with the `CredentialProvider`, or wait for the login already in progress (also one with explicit
|
|
199
|
+
* credentials) instead of starting another. Called by `login()`, so it tries again after a `BAD_CREDENTIAL`.
|
|
200
|
+
*/
|
|
201
|
+
relogin(): Promise<void> {
|
|
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));
|
|
215
|
+
}
|
|
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
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token) and read it
|
|
228
|
+
* with `read`, which returns `EXPIRED` for a body that shows the session has expired.
|
|
161
229
|
* When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
|
|
162
230
|
* logs in again (sharing a login already in progress, or skipping it if another request already renewed the
|
|
163
|
-
* 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`.
|
|
164
233
|
*/
|
|
165
|
-
async #send
|
|
234
|
+
async #send<T>(
|
|
235
|
+
makeUrl: () => string,
|
|
236
|
+
init: RequestOptions | undefined,
|
|
237
|
+
read: (res: Response, retry: boolean) => Promise<T | typeof EXPIRED>,
|
|
238
|
+
): Promise<T> {
|
|
239
|
+
const attempt = async (retry: boolean): Promise<T | typeof EXPIRED> => {
|
|
240
|
+
const res = await this.rawFetch(makeUrl(), init);
|
|
241
|
+
if (noLogin(res)) {
|
|
242
|
+
discard(res);
|
|
243
|
+
return EXPIRED;
|
|
244
|
+
}
|
|
245
|
+
return read(res, retry);
|
|
246
|
+
};
|
|
166
247
|
const generation = this.#generation;
|
|
167
|
-
const
|
|
168
|
-
if (
|
|
169
|
-
discard(res);
|
|
248
|
+
const first = await attempt(false);
|
|
249
|
+
if (first !== EXPIRED) return first;
|
|
170
250
|
if (!this.provider) {
|
|
171
|
-
throw
|
|
251
|
+
throw apiError(FailReason.NOT_LOGGED_IN);
|
|
172
252
|
}
|
|
173
253
|
if (this.#generation === generation) {
|
|
174
|
-
await this.
|
|
254
|
+
await this.autoRelogin();
|
|
175
255
|
}
|
|
176
|
-
const
|
|
177
|
-
if (
|
|
178
|
-
|
|
179
|
-
throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
|
|
256
|
+
const second = await attempt(true);
|
|
257
|
+
if (second === EXPIRED) {
|
|
258
|
+
throw apiError(FailReason.NOT_LOGGED_IN);
|
|
180
259
|
}
|
|
181
|
-
|
|
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;
|
|
260
|
+
return second;
|
|
189
261
|
}
|
|
190
262
|
|
|
191
|
-
/**
|
|
192
|
-
readonly
|
|
263
|
+
/** the response, but after a re-login only one with status 200 */
|
|
264
|
+
static readonly #response = async (res: Response, retry: boolean): Promise<Response> => {
|
|
265
|
+
if (retry && res.status !== 200) {
|
|
266
|
+
discard(res);
|
|
267
|
+
throw apiError(FailReason.UNEXPECTED_STATUS, { code: res.status, text: res.statusText });
|
|
268
|
+
}
|
|
269
|
+
return res;
|
|
270
|
+
};
|
|
193
271
|
|
|
194
|
-
/**
|
|
195
|
-
|
|
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);
|
|
278
|
+
const text = await res.text();
|
|
279
|
+
try {
|
|
280
|
+
return JSON.parse(text);
|
|
281
|
+
} catch {
|
|
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
|
+
});
|
|
287
|
+
}
|
|
288
|
+
};
|
|
289
|
+
|
|
290
|
+
/** the text body; a short login / redirect page with status 200 means the session has expired */
|
|
291
|
+
static readonly #text = async (res: Response, retry: boolean): Promise<string | typeof EXPIRED> => {
|
|
292
|
+
await Session.#response(res, retry);
|
|
293
|
+
const text = await res.text();
|
|
294
|
+
return res.status === 200 && looksLikeLoginPage(text) ? EXPIRED : text;
|
|
295
|
+
};
|
|
296
|
+
|
|
297
|
+
#withToken = async (input: Parameters<Fetch>[0]): Promise<() => string> => {
|
|
196
298
|
if (this.#csrfToken === '') {
|
|
197
|
-
await this.
|
|
299
|
+
await this.autoRelogin();
|
|
198
300
|
}
|
|
199
301
|
const url = urlOf(input);
|
|
200
|
-
return
|
|
302
|
+
return () => addCSRFTokenToUrl(url, this.#csrfToken);
|
|
201
303
|
};
|
|
202
304
|
|
|
305
|
+
/** fetch with a login check (see `#send`); logs in first if there is no CSRF token yet and adds it to the URL */
|
|
306
|
+
readonly fetchWithToken: TimedFetch = async (input, init) =>
|
|
307
|
+
this.#send(await this.#withToken(input), init, Session.#response);
|
|
308
|
+
|
|
203
309
|
/**
|
|
204
|
-
*
|
|
205
|
-
* in
|
|
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`.
|
|
312
|
+
*/
|
|
313
|
+
readonly fetchJsonWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<any> =>
|
|
314
|
+
this.#send(await this.#withToken(input), init, Session.#json);
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Like `fetchWithToken`, but returns the text body (an HTML page). A short login or redirect page with status 200
|
|
318
|
+
* counts as an expired session (log in again and retry once, as for a 403).
|
|
319
|
+
*/
|
|
320
|
+
readonly fetchTextWithToken = async (input: Parameters<Fetch>[0], init?: RequestOptions): Promise<string> =>
|
|
321
|
+
this.#send(await this.#withToken(input), init, Session.#text);
|
|
322
|
+
|
|
323
|
+
/**
|
|
324
|
+
* Make sure the session works: if there is a CSRF token, fetch the current semester (`probeUrl`) and log in only if
|
|
325
|
+
* the session has expired; otherwise log in.
|
|
206
326
|
*/
|
|
207
327
|
async ensureSession(probeUrl: string): Promise<void> {
|
|
208
328
|
if (this.#csrfToken === '') {
|
|
209
|
-
await this.
|
|
329
|
+
await this.autoRelogin();
|
|
210
330
|
return;
|
|
211
331
|
}
|
|
212
|
-
|
|
332
|
+
const json = await this.fetchJsonWithToken(probeUrl);
|
|
333
|
+
if (json?.message !== 'success') {
|
|
334
|
+
throw apiError(FailReason.INVALID_RESPONSE, json);
|
|
335
|
+
}
|
|
213
336
|
}
|
|
214
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
|
+
}
|