@robertying/thu-learn-lib 5.0.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 (68) 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.map +1 -1
  11. package/lib/contents.js +10 -1
  12. package/lib/contents.js.map +1 -1
  13. package/lib/courses.d.ts.map +1 -1
  14. package/lib/courses.js +6 -6
  15. package/lib/courses.js.map +1 -1
  16. package/lib/discussions.js +2 -2
  17. package/lib/discussions.js.map +1 -1
  18. package/lib/favorites.d.ts.map +1 -1
  19. package/lib/favorites.js +12 -12
  20. package/lib/favorites.js.map +1 -1
  21. package/lib/files.d.ts +1 -1
  22. package/lib/files.d.ts.map +1 -1
  23. package/lib/files.js +10 -8
  24. package/lib/files.js.map +1 -1
  25. package/lib/homework.d.ts +1 -2
  26. package/lib/homework.d.ts.map +1 -1
  27. package/lib/homework.js +19 -17
  28. package/lib/homework.js.map +1 -1
  29. package/lib/index.d.ts +11 -6
  30. package/lib/index.d.ts.map +1 -1
  31. package/lib/index.js +13 -5
  32. package/lib/index.js.map +1 -1
  33. package/lib/notices.d.ts +1 -1
  34. package/lib/notices.d.ts.map +1 -1
  35. package/lib/notices.js +11 -15
  36. package/lib/notices.js.map +1 -1
  37. package/lib/questionnaires.d.ts +6 -3
  38. package/lib/questionnaires.d.ts.map +1 -1
  39. package/lib/questionnaires.js +20 -12
  40. package/lib/questionnaires.js.map +1 -1
  41. package/lib/request.d.ts +44 -14
  42. package/lib/request.d.ts.map +1 -1
  43. package/lib/request.js +120 -43
  44. package/lib/request.js.map +1 -1
  45. package/lib/types.d.ts +11 -3
  46. package/lib/types.d.ts.map +1 -1
  47. package/lib/types.js.map +1 -1
  48. package/lib/user.js +1 -1
  49. package/lib/user.js.map +1 -1
  50. package/lib/utils.d.ts.map +1 -1
  51. package/lib/utils.js +9 -3
  52. package/lib/utils.js.map +1 -1
  53. package/package.json +2 -2
  54. package/src/auth.ts +18 -5
  55. package/src/calendar.ts +28 -10
  56. package/src/contents.ts +10 -1
  57. package/src/courses.ts +11 -13
  58. package/src/discussions.ts +2 -2
  59. package/src/favorites.ts +22 -32
  60. package/src/files.ts +12 -12
  61. package/src/homework.ts +30 -40
  62. package/src/index.ts +19 -7
  63. package/src/notices.ts +12 -18
  64. package/src/questionnaires.ts +51 -37
  65. package/src/request.ts +141 -42
  66. package/src/types.ts +14 -3
  67. package/src/user.ts +1 -1
  68. package/src/utils.ts +10 -4
package/src/notices.ts CHANGED
@@ -37,12 +37,10 @@ async function getNotificationListKind(
37
37
  courseType: CourseType,
38
38
  expired: boolean,
39
39
  ): Promise<INotification[]> {
40
- const json = await (
41
- await s.fetchWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
42
- method: 'POST',
43
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
44
- })
45
- ).json();
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
+ });
46
44
  if (json.result !== 'success') {
47
45
  return Promise.reject({
48
46
  reason: FailReason.INVALID_RESPONSE,
@@ -73,7 +71,7 @@ async function getNotificationListKind(
73
71
 
74
72
  /**
75
73
  * Get the attachment of a notice from its detail page (1 request), or `undefined` without a request if
76
- * `attachmentName` says it has none.
74
+ * `attachmentName` says it has none, or if the page has no attachment link.
77
75
  */
78
76
  export async function getNotificationAttachment(
79
77
  s: Session,
@@ -85,21 +83,17 @@ export async function getNotificationAttachment(
85
83
  // const postParams = new FormData();
86
84
  // postParams.append('id', id);
87
85
  // postParams.append('wlkcid', courseID);
88
- // const metadata = await (await s.fetchWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
86
+ // const metadata = await s.fetchJsonWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
89
87
  // 'method': 'POST',
90
88
  // 'body': postParams,
91
- // })).json();
89
+ // });
92
90
  // const attachmentId = metadata.ggfjid as string;
93
91
  /// parsed from HTML
94
- const response = await s.fetchWithToken(notification.url);
95
- const result = $(await response.text());
96
- let path = '';
97
- if (courseType === CourseType.STUDENT) {
98
- path = result('.ml-10').attr('href')!;
99
- } else {
100
- path = result('#wjid').attr('href')!;
101
- }
102
- 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()) ?? '';
103
97
  const params = new URLSearchParams(path.split('?').slice(-1)[0]);
104
98
  const attachmentId = params.get('wjid')!;
105
99
  if (!path.startsWith(URLS.LEARN_PREFIX)) {
@@ -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
@@ -40,7 +40,7 @@ const urlOf = (input: Parameters<Fetch>[0]): string =>
40
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
41
  * headers arrive (or the call fails), so reading a body does not hold one.
42
42
  */
43
- export function limitConcurrency(inner: Fetch, max: number): Fetch {
43
+ export function limitConcurrency<F extends (...args: any[]) => Promise<Response>>(inner: F, max: number): F {
44
44
  if (!(max >= 1) || max === Infinity) return inner;
45
45
  let active = 0;
46
46
  const waiting: (() => void)[] = [];
@@ -57,47 +57,57 @@ export function limitConcurrency(inner: Fetch, max: number): Fetch {
57
57
  if (next) next();
58
58
  else active -= 1;
59
59
  };
60
- return async (...args) => {
60
+ return (async (...args: Parameters<F>) => {
61
61
  await acquire();
62
62
  try {
63
63
  return await inner(...args);
64
64
  } finally {
65
65
  release();
66
66
  }
67
- };
67
+ }) as F;
68
68
  }
69
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
+
70
76
  /**
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`).
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.
74
82
  */
75
- export function withTimeout(inner: Fetch, ms: number): Fetch {
76
- if (!(ms > 0) || ms === Infinity) return inner;
77
- return async (input, init) => {
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);
78
87
  const controller = new AbortController();
79
- const outer = init?.signal ?? undefined;
88
+ const outer = init.signal ?? undefined;
80
89
  const forward = () => controller.abort(outer?.reason);
81
90
  if (outer?.aborted) forward();
91
+ // kept after the headers arrive, so the caller can still cancel reading the body
82
92
  else outer?.addEventListener('abort', forward, { once: true });
83
93
  let timedOut = false;
84
94
  const timer = setTimeout(() => {
85
95
  timedOut = true;
86
96
  controller.abort();
87
- }, ms);
97
+ }, timeout);
88
98
  try {
89
99
  return await inner(input, { ...init, signal: controller.signal });
90
100
  } catch (err) {
101
+ outer?.removeEventListener('abort', forward);
91
102
  if (timedOut) {
92
103
  throw {
93
104
  reason: FailReason.REQUEST_TIMEOUT,
94
- extra: { url: urlOf(input), timeout: ms },
105
+ extra: { url: urlOf(input), timeout },
95
106
  } as ApiError;
96
107
  }
97
108
  throw err;
98
109
  } finally {
99
110
  clearTimeout(timer);
100
- outer?.removeEventListener('abort', forward);
101
111
  }
102
112
  };
103
113
  }
@@ -107,6 +117,26 @@ const discard = (res: Response) => {
107
117
  res.body?.cancel().catch(() => {});
108
118
  };
109
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
+
110
140
  /**
111
141
  * State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
112
142
  * Internal; the helper keeps it in a private field.
@@ -115,7 +145,7 @@ export class Session {
115
145
  readonly provider?: CredentialProvider;
116
146
  readonly cookieJar: CookieJar;
117
147
  /** fetch with cookies, the concurrency cap and the timeout, but no login check */
118
- readonly rawFetch: Fetch;
148
+ readonly rawFetch: TimedFetch;
119
149
  #csrfToken = '';
120
150
  /** bumped whenever the session changes (login, `csrfToken` set), so late failures from an old session don't log in again */
121
151
  #generation = 0;
@@ -123,10 +153,10 @@ export class Session {
123
153
  lang = Language.ZH;
124
154
  previewFirstPage: boolean;
125
155
 
126
- /** `login` signs in through SSO with the `CredentialProvider`; the session calls it at most once at a time */
156
+ /** `loginWithProvider` signs in through SSO with the `CredentialProvider` */
127
157
  constructor(
128
158
  config: HelperConfig | undefined,
129
- readonly login: () => Promise<void>,
159
+ readonly loginWithProvider: () => Promise<void>,
130
160
  ) {
131
161
  this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
132
162
  this.provider = config?.provider;
@@ -147,68 +177,137 @@ export class Session {
147
177
  }
148
178
 
149
179
  /**
150
- * Log in with the `CredentialProvider`. Concurrent callers share one SSO login instead of starting one each.
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).
151
183
  */
152
- relogin(): Promise<void> {
153
- this.#pendingLogin ??= this.login().finally(() => {
154
- this.#pendingLogin = undefined;
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;
155
188
  });
156
- return this.#pendingLogin;
189
+ this.#pendingLogin = current;
190
+ return current;
157
191
  }
158
192
 
159
193
  /**
160
- * Send a request built by `makeUrl` (called again for the retry, so it can pick up a new CSRF token).
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.
161
204
  * When the session has expired, it rejects with `NOT_LOGGED_IN` if there is no `CredentialProvider`; otherwise it
162
205
  * logs in again (sharing a login already in progress, or skipping it if another request already renewed the
163
206
  * session) and retries once.
164
207
  */
165
- async #send(makeUrl: () => string, init?: RequestInit): Promise<Response> {
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
+ };
166
221
  const generation = this.#generation;
167
- const res = await this.rawFetch(makeUrl(), init);
168
- if (!noLogin(res)) return res;
169
- discard(res);
222
+ const first = await attempt(false);
223
+ if (first !== EXPIRED) return first;
170
224
  if (!this.provider) {
171
225
  throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
172
226
  }
173
227
  if (this.#generation === generation) {
174
228
  await this.relogin();
175
229
  }
176
- const retry = await this.rawFetch(makeUrl(), init);
177
- if (noLogin(retry)) {
178
- discard(retry);
230
+ const second = await attempt(true);
231
+ if (second === EXPIRED) {
179
232
  throw { reason: FailReason.NOT_LOGGED_IN } as ApiError;
180
233
  }
181
- if (retry.status !== 200) {
182
- discard(retry);
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);
183
241
  throw {
184
242
  reason: FailReason.UNEXPECTED_STATUS,
185
- extra: { code: retry.status, text: retry.statusText },
243
+ extra: { code: res.status, text: res.statusText },
186
244
  } as ApiError;
187
245
  }
188
- return retry;
189
- }
246
+ return res;
247
+ };
190
248
 
191
- /** fetch with a login check (see `#send`) */
192
- readonly fetch: Fetch = (input, init) => this.#send(() => urlOf(input), init);
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;
260
+ }
261
+ };
193
262
 
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) => {
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> => {
196
271
  if (this.#csrfToken === '') {
197
272
  await this.relogin();
198
273
  }
199
274
  const url = urlOf(input);
200
- return this.#send(() => addCSRFTokenToUrl(url, this.#csrfToken), init);
275
+ return () => addCSRFTokenToUrl(url, this.#csrfToken);
201
276
  };
202
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
+
203
285
  /**
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.
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.
206
302
  */
207
303
  async ensureSession(probeUrl: string): Promise<void> {
208
304
  if (this.#csrfToken === '') {
209
305
  await this.relogin();
210
306
  return;
211
307
  }
212
- discard(await this.fetchWithToken(probeUrl));
308
+ const json = await this.fetchJsonWithToken(probeUrl);
309
+ if (json?.message !== 'success') {
310
+ throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
311
+ }
213
312
  }
214
313
  }
package/src/types.ts CHANGED
@@ -365,7 +365,8 @@ export interface IQuestionnaire {
365
365
  isFavorite: boolean;
366
366
  comment?: string;
367
367
  url: string;
368
- detail: QuestionnaireDetail[];
368
+ /** the questions; only for the questionnaires `getQuestionnaireList`'s `details` selects (by default all) */
369
+ detail?: QuestionnaireDetail[];
369
370
  }
370
371
 
371
372
  export type Questionnaire = IQuestionnaire;
@@ -394,14 +395,24 @@ export interface NotificationListOptions {
394
395
  attachments?: DetailSelector<INotification>;
395
396
  }
396
397
 
398
+ export interface QuestionnaireListOptions {
399
+ /**
400
+ * Which questionnaires get their questions (`detail`, 1 request each); default `'all'` (unlike the other lists, for
401
+ * compatibility). The same as calling `getQuestionnaireDetail` for those items.
402
+ */
403
+ details?: DetailSelector<IQuestionnaire>;
404
+ }
405
+
397
406
  export interface AllContentsOptions<T extends ContentType> {
398
407
  courseType?: CourseType;
399
- /** homework `details` or notification `attachments`, passed to each course's list */
408
+ /** homework or questionnaire `details`, or notification `attachments`, passed to each course's list */
400
409
  details?: T extends ContentType.HOMEWORK
401
410
  ? DetailSelector<IHomework>
402
411
  : T extends ContentType.NOTIFICATION
403
412
  ? DetailSelector<INotification>
404
- : never;
413
+ : T extends ContentType.QUESTIONNAIRE
414
+ ? DetailSelector<IQuestionnaire>
415
+ : never;
405
416
  }
406
417
 
407
418
  export interface AllContentsResult<T extends ContentType> {
package/src/user.ts CHANGED
@@ -8,7 +8,7 @@ import * as URLS from './urls.ts';
8
8
 
9
9
  /** get user's name and department */
10
10
  export async function getUserInfo(s: Session, courseType = CourseType.STUDENT): Promise<UserInfo> {
11
- const content = await (await s.fetchWithToken(URLS.LEARN_HOMEPAGE(courseType))).text();
11
+ const content = await s.fetchTextWithToken(URLS.LEARN_HOMEPAGE(courseType));
12
12
 
13
13
  const dom = $(content);
14
14
  const name = dom('a.user-log').text().trim();
package/src/utils.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { decodeHTML as _decodeHTML } from 'entities';
2
2
 
3
- import type { DetailSelector } from './types.ts';
3
+ import type { ApiError, DetailSelector } from './types.ts';
4
4
  import { ContentType, FailReason, HomeworkGradeLevel, QuestionnaireType, SemesterType } from './types.ts';
5
5
 
6
6
  export function parseSemesterType(n: number): SemesterType {
@@ -78,14 +78,20 @@ export const GRADE_LEVEL_MAP = new Map([
78
78
 
79
79
  export const JSONP_EXTRACTOR_NAME = 'thu_learn_lib_jsonp_extractor';
80
80
 
81
+ const invalidJSONP = (extra: unknown): ApiError => ({ reason: FailReason.INVALID_RESPONSE, extra });
82
+
81
83
  export function extractJSONPResult(jsonp: string): any {
82
84
  // check jsonp format
83
85
  if (!jsonp.startsWith(JSONP_EXTRACTOR_NAME)) {
84
- throw FailReason.INVALID_RESPONSE;
86
+ throw invalidJSONP(jsonp.slice(0, 200));
85
87
  }
86
88
  // evaluate the result
87
- // oxlint-disable-next-line typescript/no-implied-eval -- JSONP from the registrar is a JS expression, not JSON
88
- return Function(`"use strict";const ${JSONP_EXTRACTOR_NAME}=(s)=>s;return ${jsonp};`)();
89
+ try {
90
+ // oxlint-disable-next-line typescript/no-implied-eval -- JSONP from the registrar is a JS expression, not JSON
91
+ return Function(`"use strict";const ${JSONP_EXTRACTOR_NAME}=(s)=>s;return ${jsonp};`)();
92
+ } catch (err) {
93
+ throw invalidJSONP(err);
94
+ }
89
95
  }
90
96
 
91
97
  export function formatFileSize(size: number): string {