@robertying/thu-learn-lib 4.1.0 → 5.1.0

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