@robertying/thu-learn-lib 4.1.0 → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/notices.ts CHANGED
@@ -4,21 +4,31 @@ import { Base64 } from 'js-base64';
4
4
 
5
5
  import type { Session } from './request.ts';
6
6
  import { $, YES } from './request.ts';
7
- import type { INotification, INotificationDetail, Notification } from './types.ts';
7
+ import type { INotification, Notification, NotificationListOptions, RemoteFile } from './types.ts';
8
8
  import { ContentType, CourseType, FailReason } from './types.ts';
9
9
  import * as URLS from './urls.ts';
10
- import { decodeHTML, trimAndDefine } from './utils.ts';
10
+ import { decodeHTML, isSelected, trimAndDefine } from './utils.ts';
11
11
 
12
- /** Get all notifications (课程公告) of the specified course. */
12
+ /**
13
+ * Get all notifications (课程公告) of the specified course: current and expired lists.
14
+ * Attachments only for the notices `options.attachments` selects.
15
+ */
13
16
  export async function getNotificationList(
14
17
  s: Session,
15
18
  courseID: string,
16
19
  courseType: CourseType = CourseType.STUDENT,
20
+ options: NotificationListOptions = {},
17
21
  ): Promise<Notification[]> {
18
- return Promise.all([
22
+ const lists = await Promise.all([
19
23
  getNotificationListKind(s, courseID, courseType, false),
20
24
  getNotificationListKind(s, courseID, courseType, true),
21
- ]).then((r) => r.flat());
25
+ ]);
26
+ return Promise.all(
27
+ lists.flat().map(async (n): Promise<Notification> => {
28
+ if (!n.attachmentName || !isSelected(options.attachments, n)) return n;
29
+ return { ...n, attachment: await getNotificationAttachment(s, n, courseType) };
30
+ }),
31
+ );
22
32
  }
23
33
 
24
34
  async function getNotificationListKind(
@@ -26,7 +36,7 @@ async function getNotificationListKind(
26
36
  courseID: string,
27
37
  courseType: CourseType,
28
38
  expired: boolean,
29
- ): Promise<Notification[]> {
39
+ ): Promise<INotification[]> {
30
40
  const json = await (
31
41
  await s.fetchWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
32
42
  method: 'POST',
@@ -42,38 +52,35 @@ async function getNotificationListKind(
42
52
 
43
53
  const result = (json.object?.aaData ?? json.object?.resultsList ?? []) as any[];
44
54
 
45
- return Promise.all(
46
- result.map(async (n) => {
47
- const notification: INotification = {
48
- id: n.ggid,
49
- content: Base64.decode(n.ggnr ?? ''),
50
- title: decodeHTML(n.bt),
51
- url: URLS.LEARN_NOTIFICATION_DETAIL(courseID, n.ggid, courseType),
52
- publisher: n.fbrxm,
53
- hasRead: n.sfyd === YES,
54
- markedImportant: Number(n.sfqd) === 1, // n.sfqd could be string '1' (teacher mode) or number 1 (student mode)
55
- publishTime: n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr,
56
- expireTime: n.jzsj ?? undefined,
57
- isFavorite: n.sfsc === YES,
58
- comment: n.bznr ?? undefined,
59
- };
60
- let detail: INotificationDetail = {};
61
- const attachmentName = courseType === CourseType.STUDENT ? n.fjmc : n.fjbt;
62
- if (attachmentName) {
63
- detail = await parseNotificationDetail(s, courseID, notification.id, courseType, attachmentName);
64
- }
65
- return { ...notification, ...detail } satisfies Notification;
66
- }),
67
- );
55
+ return result.map((n) => {
56
+ const attachmentName: unknown = courseType === CourseType.STUDENT ? n.fjmc : n.fjbt;
57
+ return {
58
+ id: n.ggid,
59
+ content: Base64.decode(n.ggnr ?? ''),
60
+ title: decodeHTML(n.bt),
61
+ url: URLS.LEARN_NOTIFICATION_DETAIL(courseID, n.ggid, courseType),
62
+ publisher: n.fbrxm,
63
+ hasRead: n.sfyd === YES,
64
+ markedImportant: Number(n.sfqd) === 1, // n.sfqd could be string '1' (teacher mode) or number 1 (student mode)
65
+ publishTime: n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr,
66
+ expireTime: n.jzsj ?? undefined,
67
+ isFavorite: n.sfsc === YES,
68
+ comment: n.bznr ?? undefined,
69
+ attachmentName: typeof attachmentName === 'string' && attachmentName ? attachmentName : undefined,
70
+ } satisfies INotification;
71
+ });
68
72
  }
69
73
 
70
- async function parseNotificationDetail(
74
+ /**
75
+ * Get the attachment of a notice from its detail page (1 request), or `undefined` without a request if
76
+ * `attachmentName` says it has none.
77
+ */
78
+ export async function getNotificationAttachment(
71
79
  s: Session,
72
- courseID: string,
73
- id: string,
74
- courseType: CourseType,
75
- attachmentName: string,
76
- ): Promise<INotificationDetail> {
80
+ notification: Pick<INotification, 'url' | 'attachmentName'>,
81
+ courseType: CourseType = CourseType.STUDENT,
82
+ ): Promise<RemoteFile | undefined> {
83
+ if (!notification.attachmentName) return undefined;
77
84
  /// from JSON (backup, currently not used)
78
85
  // const postParams = new FormData();
79
86
  // postParams.append('id', id);
@@ -84,7 +91,7 @@ async function parseNotificationDetail(
84
91
  // })).json();
85
92
  // const attachmentId = metadata.ggfjid as string;
86
93
  /// parsed from HTML
87
- const response = await s.fetchWithToken(URLS.LEARN_NOTIFICATION_DETAIL(courseID, id, courseType));
94
+ const response = await s.fetchWithToken(notification.url);
88
95
  const result = $(await response.text());
89
96
  let path = '';
90
97
  if (courseType === CourseType.STUDENT) {
@@ -99,12 +106,10 @@ async function parseNotificationDetail(
99
106
  path = URLS.LEARN_PREFIX + path;
100
107
  }
101
108
  return {
102
- attachment: {
103
- name: attachmentName,
104
- id: attachmentId,
105
- downloadUrl: path,
106
- previewUrl: URLS.LEARN_FILE_PREVIEW(ContentType.NOTIFICATION, attachmentId, courseType, s.previewFirstPage),
107
- size,
108
- },
109
+ name: notification.attachmentName,
110
+ id: attachmentId,
111
+ downloadUrl: path,
112
+ previewUrl: URLS.LEARN_FILE_PREVIEW(ContentType.NOTIFICATION, attachmentId, courseType, s.previewFirstPage),
113
+ size,
109
114
  };
110
115
  }
package/src/request.ts CHANGED
@@ -4,7 +4,7 @@ import fetchCookie from 'fetch-cookie';
4
4
  import { fetch } from 'node-fetch-native';
5
5
  import { CookieJar } from 'tough-cookie';
6
6
 
7
- import type { CredentialProvider, Fetch, HelperConfig } from './types.ts';
7
+ import type { ApiError, CredentialProvider, Fetch, HelperConfig } from './types.ts';
8
8
  import { FailReason, Language } from './types.ts';
9
9
 
10
10
  const CHEERIO_CONFIG: cheerio.CheerioOptions = { xml: true };
@@ -17,6 +17,11 @@ export const noLogin = (res: Response) => res.url.includes('login_timeout') || r
17
17
 
18
18
  export const YES = '是';
19
19
 
20
+ /** default for `HelperConfig.maxConcurrentRequests` */
21
+ export const DEFAULT_MAX_CONCURRENT_REQUESTS = 6;
22
+ /** default for `HelperConfig.requestTimeout`, in milliseconds */
23
+ export const DEFAULT_REQUEST_TIMEOUT = 60_000;
24
+
20
25
  /** add CSRF token to any request URL as parameters */
21
26
  export const addCSRFTokenToUrl = (url: string, token: string): string => {
22
27
  const newUrl = new URL(url);
@@ -28,32 +33,80 @@ function makeFetch(jar?: CookieJar): typeof fetch {
28
33
  return fetchCookie(fetch, jar);
29
34
  }
30
35
 
31
- function withReAuth(rawFetch: Fetch, login: () => Promise<void>): Fetch {
32
- return async function wrappedFetch(...args) {
33
- const retryAfterLogin = async () => {
34
- await login();
35
- return await rawFetch(...args).then((res: Response) => {
36
- if (noLogin(res)) {
37
- return Promise.reject({
38
- reason: FailReason.NOT_LOGGED_IN,
39
- });
40
- } else if (res.status !== 200) {
41
- return Promise.reject({
42
- reason: FailReason.UNEXPECTED_STATUS,
43
- extra: {
44
- code: res.status,
45
- text: res.statusText,
46
- },
47
- });
48
- } else {
49
- return res;
50
- }
51
- });
52
- };
53
- return await rawFetch(...args).then((res: Response) => (noLogin(res) ? retryAfterLogin() : res));
36
+ const urlOf = (input: Parameters<Fetch>[0]): string =>
37
+ typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
38
+
39
+ /**
40
+ * Let at most `max` calls of `inner` run at once; the rest wait in FIFO order. A slot is freed when the response
41
+ * headers arrive (or the call fails), so reading a body does not hold one.
42
+ */
43
+ export function limitConcurrency(inner: Fetch, max: number): Fetch {
44
+ if (!(max >= 1) || max === Infinity) return inner;
45
+ let active = 0;
46
+ const waiting: (() => void)[] = [];
47
+ const acquire = (): Promise<void> => {
48
+ if (active < max) {
49
+ active += 1;
50
+ return Promise.resolve();
51
+ }
52
+ return new Promise((resolve) => waiting.push(resolve));
53
+ };
54
+ const release = () => {
55
+ const next = waiting.shift();
56
+ // hand the slot straight to the next caller, so `active` stays the same
57
+ if (next) next();
58
+ else active -= 1;
59
+ };
60
+ return async (...args) => {
61
+ await acquire();
62
+ try {
63
+ return await inner(...args);
64
+ } finally {
65
+ release();
66
+ }
67
+ };
68
+ }
69
+
70
+ /**
71
+ * Abort a call of `inner` that has no response headers after `ms` milliseconds and reject it with
72
+ * `FailReason.REQUEST_TIMEOUT`. A caller's own `signal` still works. Uses only `AbortController` and `setTimeout`,
73
+ * which React Native has (unlike `AbortSignal.timeout` / `AbortSignal.any`).
74
+ */
75
+ export function withTimeout(inner: Fetch, ms: number): Fetch {
76
+ if (!(ms > 0) || ms === Infinity) return inner;
77
+ return async (input, init) => {
78
+ const controller = new AbortController();
79
+ const outer = init?.signal ?? undefined;
80
+ const forward = () => controller.abort(outer?.reason);
81
+ if (outer?.aborted) forward();
82
+ else outer?.addEventListener('abort', forward, { once: true });
83
+ let timedOut = false;
84
+ const timer = setTimeout(() => {
85
+ timedOut = true;
86
+ controller.abort();
87
+ }, ms);
88
+ try {
89
+ return await inner(input, { ...init, signal: controller.signal });
90
+ } catch (err) {
91
+ if (timedOut) {
92
+ throw {
93
+ reason: FailReason.REQUEST_TIMEOUT,
94
+ extra: { url: urlOf(input), timeout: ms },
95
+ } as ApiError;
96
+ }
97
+ throw err;
98
+ } finally {
99
+ clearTimeout(timer);
100
+ outer?.removeEventListener('abort', forward);
101
+ }
54
102
  };
55
103
  }
56
104
 
105
+ /** free the connection of a response we won't read */
106
+ const discard = (res: Response) => {
107
+ res.body?.cancel().catch(() => {});
108
+ };
109
+
57
110
  /**
58
111
  * State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
59
112
  * Internal; the helper keeps it in a private field.
@@ -61,15 +114,16 @@ function withReAuth(rawFetch: Fetch, login: () => Promise<void>): Fetch {
61
114
  export class Session {
62
115
  readonly provider?: CredentialProvider;
63
116
  readonly cookieJar: CookieJar;
64
- /** plain fetch with cookies, no login check */
117
+ /** fetch with cookies, the concurrency cap and the timeout, but no login check */
65
118
  readonly rawFetch: Fetch;
66
- /** rejects with `NOT_LOGGED_IN`, or logs in again and retries once when a `CredentialProvider` is set */
67
- readonly fetch: Fetch;
68
- csrfToken = '';
119
+ #csrfToken = '';
120
+ /** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
121
+ #generation = 0;
122
+ #pendingLogin?: Promise<void>;
69
123
  lang = Language.ZH;
70
124
  previewFirstPage: boolean;
71
125
 
72
- /** `login` is called with no arguments when a request needs a (new) session */
126
+ /** `login` signs in through SSO with the `CredentialProvider`; the session calls it at most once at a time */
73
127
  constructor(
74
128
  config: HelperConfig | undefined,
75
129
  readonly login: () => Promise<void>,
@@ -77,25 +131,84 @@ export class Session {
77
131
  this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
78
132
  this.provider = config?.provider;
79
133
  this.cookieJar = config?.cookieJar ?? new CookieJar();
80
- this.rawFetch = config?.fetch ?? makeFetch(this.cookieJar);
81
- this.fetch = this.provider
82
- ? withReAuth(this.rawFetch, login)
83
- : async (...args) => {
84
- const result = await this.rawFetch(...args);
85
- if (noLogin(result))
86
- return Promise.reject({
87
- reason: FailReason.NOT_LOGGED_IN,
88
- });
89
- return result;
90
- };
134
+ this.rawFetch = limitConcurrency(
135
+ withTimeout(config?.fetch ?? makeFetch(this.cookieJar), config?.requestTimeout ?? DEFAULT_REQUEST_TIMEOUT),
136
+ config?.maxConcurrentRequests ?? DEFAULT_MAX_CONCURRENT_REQUESTS,
137
+ );
91
138
  }
92
139
 
93
- /** like `fetch`, but logs in first if there is no CSRF token yet and adds the token to the URL */
94
- readonly fetchWithToken: Fetch = async (...args) => {
95
- if (this.csrfToken === '') {
96
- await this.login();
140
+ get csrfToken(): string {
141
+ return this.#csrfToken;
142
+ }
143
+
144
+ set csrfToken(token: string) {
145
+ this.#csrfToken = token;
146
+ this.#generation += 1;
147
+ }
148
+
149
+ /**
150
+ * Log in with the `CredentialProvider`. Concurrent callers share one SSO login instead of starting one each.
151
+ */
152
+ relogin(): Promise<void> {
153
+ this.#pendingLogin ??= this.login().finally(() => {
154
+ this.#pendingLogin = undefined;
155
+ });
156
+ return this.#pendingLogin;
157
+ }
158
+
159
+ /**
160
+ * Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token).
161
+ * When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
162
+ * logs in again (sharing a login already in progress, or skipping it if another request already renewed the
163
+ * session) and retries once.
164
+ */
165
+ async #send(makeUrl: () => string, init?: RequestInit): Promise<Response> {
166
+ const generation = this.#generation;
167
+ const res = await this.rawFetch(makeUrl(), init);
168
+ if (!noLogin(res)) return res;
169
+ discard(res);
170
+ if (!this.provider) {
171
+ throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
172
+ }
173
+ if (this.#generation === generation) {
174
+ await this.relogin();
175
+ }
176
+ const retry = await this.rawFetch(makeUrl(), init);
177
+ if (noLogin(retry)) {
178
+ discard(retry);
179
+ throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
97
180
  }
98
- const [url, ...remaining] = args;
99
- return this.fetch(addCSRFTokenToUrl(url as string, this.csrfToken), ...remaining);
181
+ if (retry.status !== 200) {
182
+ discard(retry);
183
+ throw {
184
+ reason: FailReason.UNEXPECTED_STATUS,
185
+ extra: { code: retry.status, text: retry.statusText },
186
+ } as ApiError;
187
+ }
188
+ return retry;
189
+ }
190
+
191
+ /** fetch with a login check (see `#send`) */
192
+ readonly fetch: Fetch = (input, init) => this.#send(() => urlOf(input), init);
193
+
194
+ /** like `fetch`, but logs in first if there is no CSRF token yet and adds the current token to the URL */
195
+ readonly fetchWithToken: Fetch = async (input, init) => {
196
+ if (this.#csrfToken === '') {
197
+ await this.relogin();
198
+ }
199
+ const url = urlOf(input);
200
+ return this.#send(() => addCSRFTokenToUrl(url, this.#csrfToken), init);
100
201
  };
202
+
203
+ /**
204
+ * Make sure the session works: if there is a CSRF token, make one cheap authenticated request (`probeUrl`) and log
205
+ * in only if it fails; otherwise log in.
206
+ */
207
+ async ensureSession(probeUrl: string): Promise<void> {
208
+ if (this.#csrfToken === '') {
209
+ await this.relogin();
210
+ return;
211
+ }
212
+ discard(await this.fetchWithToken(probeUrl));
213
+ }
101
214
  }
package/src/types.ts CHANGED
@@ -14,6 +14,10 @@ export type HelperConfig = {
14
14
  fetch?: Fetch;
15
15
  cookieJar?: CookieJar;
16
16
  generatePreviewUrlForFirstPage?: boolean;
17
+ /** at most this many requests in flight at once, including login (default 6; `Infinity` for no limit) */
18
+ maxConcurrentRequests?: number;
19
+ /** milliseconds to wait for a response before rejecting with `FailReason.REQUEST_TIMEOUT` (default 60000; 0 for none) */
20
+ requestTimeout?: number;
17
21
  };
18
22
 
19
23
  export enum FailReason {
@@ -27,6 +31,7 @@ export enum FailReason {
27
31
  UNEXPECTED_STATUS = 'unexpected status',
28
32
  OPERATION_FAILED = 'operation failed',
29
33
  ERROR_SETTING_COOKIES = 'could not set cookies',
34
+ REQUEST_TIMEOUT = 'request timed out',
30
35
  }
31
36
 
32
37
  export interface ApiError {
@@ -78,7 +83,8 @@ interface ICourseInfo {
78
83
  name: string;
79
84
  chineseName: string;
80
85
  englishName: string;
81
- timeAndLocation: string[];
86
+ /** only with `getCourseList(..., { timeAndLocation: true })`; see `getCourseTimeAndLocation` */
87
+ timeAndLocation?: string[];
82
88
  url: string;
83
89
  teacherName: string;
84
90
  teacherNumber: string;
@@ -111,6 +117,8 @@ export interface INotification {
111
117
  expireTime?: string;
112
118
  isFavorite: boolean;
113
119
  comment?: string;
120
+ /** name of the attachment, if the notice has one; fetch it with `getNotificationAttachment` */
121
+ attachmentName?: string;
114
122
  }
115
123
 
116
124
  export interface INotificationDetail {
@@ -362,6 +370,47 @@ export interface IQuestionnaire {
362
370
 
363
371
  export type Questionnaire = IQuestionnaire;
364
372
 
373
+ /** which items get their extra per-item requests: none (default), all, or those the predicate accepts */
374
+ export type DetailSelector<T> = 'none' | 'all' | ((item: T) => boolean);
375
+
376
+ export interface CourseListOptions {
377
+ /** fetch each course's time and location (one request per course; default false) */
378
+ timeAndLocation?: boolean;
379
+ }
380
+
381
+ export interface HomeworkListOptions {
382
+ /**
383
+ * Which homework gets its detail (page + description JSON, 2 requests each); default `'none'`.
384
+ * The same as calling `getHomeworkDetail` for those items.
385
+ */
386
+ details?: DetailSelector<IHomework>;
387
+ }
388
+
389
+ export interface NotificationListOptions {
390
+ /**
391
+ * Which notices with an attachment get it (1 request each); default `'none'`.
392
+ * The same as calling `getNotificationAttachment` for those items.
393
+ */
394
+ attachments?: DetailSelector<INotification>;
395
+ }
396
+
397
+ export interface AllContentsOptions<T extends ContentType> {
398
+ courseType?: CourseType;
399
+ /** homework `details` or notification `attachments`, passed to each course's list */
400
+ details?: T extends ContentType.HOMEWORK
401
+ ? DetailSelector<IHomework>
402
+ : T extends ContentType.NOTIFICATION
403
+ ? DetailSelector<INotification>
404
+ : never;
405
+ }
406
+
407
+ export interface AllContentsResult<T extends ContentType> {
408
+ /** content of each course that succeeded */
409
+ contents: CourseContent<T>;
410
+ /** the error of each course that failed */
411
+ errors: { [courseID: string]: unknown };
412
+ }
413
+
365
414
  export type ContentTypeMap = {
366
415
  [ContentType.NOTIFICATION]: Notification;
367
416
  [ContentType.FILE]: File;
package/src/utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { decodeHTML as _decodeHTML } from 'entities';
2
2
 
3
+ import type { DetailSelector } from './types.ts';
3
4
  import { ContentType, FailReason, HomeworkGradeLevel, QuestionnaireType, SemesterType } from './types.ts';
4
5
 
5
6
  export function parseSemesterType(n: number): SemesterType {
@@ -118,3 +119,7 @@ export const QNR_TYPE_MAP = new Map([
118
119
  ['填表', QuestionnaireType.FORM],
119
120
  ['问卷', QuestionnaireType.SURVEY],
120
121
  ]);
122
+
123
+ /** whether `item` is selected by a `DetailSelector` (`undefined` means `'none'`) */
124
+ export const isSelected = <T>(selector: DetailSelector<T> | undefined, item: T): boolean =>
125
+ selector === 'all' || (typeof selector === 'function' && selector(item));