@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/auth.ts CHANGED
@@ -34,6 +34,7 @@ export async function getRoamingTicket(
34
34
  } as ApiError;
35
35
  }
36
36
 
37
+ let page: string;
37
38
  try {
38
39
  const loginForm = await s.rawFetch(URLS.ID_LOGIN());
39
40
  const body = $(await loginForm.text());
@@ -52,16 +53,27 @@ export async function getRoamingTicket(
52
53
  body: formData,
53
54
  });
54
55
 
55
- const anchor = $(await checkResponse.text())('a');
56
- const redirectUrl = anchor.attr('href') as string;
57
- const ticket = redirectUrl.split('=').slice(-1)[0];
58
- return ticket;
56
+ if (!checkResponse.ok) {
57
+ throw { code: checkResponse.status, text: checkResponse.statusText };
58
+ }
59
+ page = await checkResponse.text();
59
60
  } catch (err) {
60
61
  throw {
61
62
  reason: FailReason.ERROR_FETCH_FROM_ID,
62
63
  extra: err,
63
64
  } as ApiError;
64
65
  }
66
+
67
+ // id.tsinghua.edu.cn answers with a link to the roaming URL (with the ticket), or with the form and an error
68
+ const body = $(page);
69
+ const redirectUrl = body('a').attr('href');
70
+ if (!redirectUrl) {
71
+ throw {
72
+ reason: FailReason.BAD_CREDENTIAL,
73
+ extra: body.root().text().replace(/\s+/g, ' ').trim().slice(0, 200),
74
+ } as ApiError;
75
+ }
76
+ return redirectUrl.split('=').slice(-1)[0];
65
77
  }
66
78
 
67
79
  /** login is necessary if you do not provide a `CredentialProvider` */
@@ -115,7 +127,8 @@ export async function login(
115
127
  if (langMatches.length !== 0) s.lang = langMatches[0][1] === 'en' ? Language.EN : Language.ZH;
116
128
  }
117
129
 
118
- /** logout (to make everyone happy) */
130
+ /** logout (to make everyone happy); the CSRF token is cleared, so the next request logs in again */
119
131
  export async function logout(s: Session): Promise<void> {
120
132
  await s.rawFetch(URLS.LEARN_LOGOUT(), { method: 'POST' });
133
+ s.csrfToken = '';
121
134
  }
package/src/calendar.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  // Course calendar from the registrar (academic.tsinghua.edu.cn).
2
2
 
3
3
  import type { Session } from './request.ts';
4
- import type { CalendarEvent, CalendarOptions, CalendarRange, CalendarResult } from './types.ts';
4
+ import type { ApiError, CalendarEvent, CalendarOptions, CalendarRange, CalendarResult } from './types.ts';
5
5
  import { FailReason } from './types.ts';
6
6
  import * as URLS from './urls.ts';
7
7
  import { JSONP_EXTRACTOR_NAME, extractJSONPResult } from './utils.ts';
@@ -11,11 +11,16 @@ export const CALENDAR_WINDOW_DAYS = 28;
11
11
 
12
12
  const DAY_MS = 24 * 60 * 60 * 1000;
13
13
 
14
+ /** after this many windows in a row time out, `getCalendar` gives up on the rest (they go to `failedRanges`) */
15
+ const MAX_CONSECUTIVE_TIMEOUTS = 2;
16
+
17
+ const isTimeout = (err: unknown) => (err as ApiError | undefined)?.reason === FailReason.REQUEST_TIMEOUT;
18
+
14
19
  const parseDate = (date: string): number => {
15
20
  const match = /^(\d{4})(\d{2})(\d{2})$/.exec(date);
16
21
  const time = match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : NaN;
17
22
  if (Number.isNaN(time) || formatDate(time) !== date) {
18
- throw { reason: FailReason.INVALID_RESPONSE, extra: `invalid date ${date}, expected yyyymmdd` };
23
+ throw new RangeError(`invalid date ${date}, expected yyyymmdd`);
19
24
  }
20
25
  return time;
21
26
  };
@@ -60,10 +65,12 @@ async function fetchWindow(s: Session, range: CalendarRange, graduate: boolean):
60
65
  /**
61
66
  * Get calendar items during the specified period (in yyyymmdd format, both inclusive).
62
67
  *
63
- * Ranges longer than `CALENDAR_WINDOW_DAYS` are fetched in consecutive windows (one at a time, each retried once) and
64
- * merged in date order. Windows that still fail are listed in `failedRanges`, so callers can show partial data and
65
- * retry only those. If every window fails, this rejects with the last error instead (usually
66
- * `FailReason.INVALID_RESPONSE`). The result might be empty if the period is too far away from now.
68
+ * Ranges longer than `CALENDAR_WINDOW_DAYS` are fetched in consecutive windows (one at a time, each retried once
69
+ * unless it timed out) and merged in date order. Windows that still fail are listed in `failedRanges`, so callers can
70
+ * show partial data and retry only those; after two windows in a row time out, the rest are listed there without a
71
+ * request. If every window fails, this rejects with the last error instead (usually `FailReason.INVALID_RESPONSE`).
72
+ * The result might be empty if the period is too far away from now. Throws a `RangeError` for a date that is not a
73
+ * valid yyyymmdd.
67
74
  */
68
75
  export async function getCalendar(
69
76
  s: Session,
@@ -75,12 +82,10 @@ export async function getCalendar(
75
82
  const ranges = splitCalendarRange(startDate, endDate);
76
83
  if (ranges.length === 0) return { events: [], failedRanges: [] };
77
84
 
78
- const ticketResponse = await s.fetchWithToken(URLS.REGISTRAR_TICKET(), {
85
+ let ticket = await s.fetchTextWithToken(URLS.REGISTRAR_TICKET(), {
79
86
  method: 'POST',
80
87
  body: URLS.REGISTRAR_TICKET_FORM_DATA(),
81
88
  });
82
-
83
- let ticket = await ticketResponse.text();
84
89
  ticket = ticket.substring(1, ticket.length - 1);
85
90
 
86
91
  await s.fetch(URLS.REGISTRAR_AUTH(ticket));
@@ -88,12 +93,25 @@ export async function getCalendar(
88
93
  const events: CalendarEvent[] = [];
89
94
  const failedRanges: CalendarRange[] = [];
90
95
  let lastError: unknown;
96
+ let timeouts = 0;
91
97
  for (const range of ranges) {
98
+ // after this many timed-out windows in a row the registrar is taken as down, and the rest are not tried
99
+ if (timeouts >= MAX_CONSECUTIVE_TIMEOUTS) {
100
+ failedRanges.push(range);
101
+ continue;
102
+ }
92
103
  try {
93
- events.push(...(await fetchWindow(s, range, graduate).catch(() => fetchWindow(s, range, graduate))));
104
+ // a timed-out window is not retried: the registrar is slow, not flaky, and each try takes `requestTimeout`
105
+ events.push(
106
+ ...(await fetchWindow(s, range, graduate).catch((err: unknown) =>
107
+ isTimeout(err) ? Promise.reject(err) : fetchWindow(s, range, graduate),
108
+ )),
109
+ );
110
+ timeouts = 0;
94
111
  } catch (e) {
95
112
  lastError = e;
96
113
  failedRanges.push(range);
114
+ timeouts = isTimeout(e) ? timeouts + 1 : 0;
97
115
  }
98
116
  }
99
117
 
package/src/contents.ts CHANGED
@@ -29,6 +29,13 @@ export async function getAllContents<T extends ContentType>(
29
29
  case ContentType.FILE:
30
30
  return getFileList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
31
31
  case ContentType.HOMEWORK:
32
+ if (courseType === CourseType.TEACHER) {
33
+ // the teacher list has another shape (`HomeworkTA`); use `getHomeworkList(id, CourseType.TEACHER)`
34
+ return Promise.reject({
35
+ reason: FailReason.NOT_IMPLEMENTED,
36
+ extra: 'homework of a teacher course; use getHomeworkList(courseID, CourseType.TEACHER)',
37
+ });
38
+ }
32
39
  return getHomeworkList(s, id, CourseType.STUDENT, {
33
40
  details: (options as AllContentsOptions<ContentType.HOMEWORK>).details,
34
41
  }) as Promise<ContentTypeMap[T][]>;
@@ -37,7 +44,9 @@ export async function getAllContents<T extends ContentType>(
37
44
  case ContentType.QUESTION:
38
45
  return getAnsweredQuestionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
39
46
  case ContentType.QUESTIONNAIRE:
40
- return getQuestionnaireList(s, id) as Promise<ContentTypeMap[T][]>;
47
+ return getQuestionnaireList(s, id, {
48
+ details: (options as AllContentsOptions<ContentType.QUESTIONNAIRE>).details,
49
+ }) as Promise<ContentTypeMap[T][]>;
41
50
  default:
42
51
  return Promise.reject({
43
52
  reason: FailReason.NOT_IMPLEMENTED,
package/src/courses.ts CHANGED
@@ -7,7 +7,7 @@ import * as URLS from './urls.ts';
7
7
  import { decodeHTML, parseSemesterType } from './utils.ts';
8
8
 
9
9
  export async function getSemesterIdList(s: Session): Promise<string[]> {
10
- const json = await (await s.fetchWithToken(URLS.LEARN_SEMESTER_LIST())).json();
10
+ const json = await s.fetchJsonWithToken(URLS.LEARN_SEMESTER_LIST());
11
11
  if (!Array.isArray(json)) {
12
12
  return Promise.reject({
13
13
  reason: FailReason.INVALID_RESPONSE,
@@ -20,7 +20,7 @@ export async function getSemesterIdList(s: Session): Promise<string[]> {
20
20
  }
21
21
 
22
22
  export async function getCurrentSemester(s: Session): Promise<SemesterInfo> {
23
- const json = await (await s.fetchWithToken(URLS.LEARN_CURRENT_SEMESTER())).json();
23
+ const json = await s.fetchJsonWithToken(URLS.LEARN_CURRENT_SEMESTER());
24
24
  if (json.message !== 'success') {
25
25
  return Promise.reject({
26
26
  reason: FailReason.INVALID_RESPONSE,
@@ -46,7 +46,7 @@ export async function getCourseList(
46
46
  lang = s.lang,
47
47
  options: CourseListOptions = {},
48
48
  ): Promise<CourseInfo[]> {
49
- const json = await (await s.fetchWithToken(URLS.LEARN_COURSE_LIST(semesterID, courseType, lang))).json();
49
+ const json = await s.fetchJsonWithToken(URLS.LEARN_COURSE_LIST(semesterID, courseType, lang));
50
50
  if (json.message !== 'success' || !Array.isArray(json.resultList)) {
51
51
  return Promise.reject({
52
52
  reason: FailReason.INVALID_RESPONSE,
@@ -85,7 +85,7 @@ export async function getCourseList(
85
85
  /** get the time and location of one course (1 request) */
86
86
  export async function getCourseTimeAndLocation(s: Session, courseID: string): Promise<string[]> {
87
87
  // see https://github.com/Harry-Chen/Learn-Helper/issues/145
88
- const json = await (await s.fetchWithToken(URLS.LEARN_COURSE_TIME_LOCATION(courseID))).json();
88
+ const json = await s.fetchJsonWithToken(URLS.LEARN_COURSE_TIME_LOCATION(courseID));
89
89
  if (!Array.isArray(json)) {
90
90
  throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
91
91
  }
@@ -93,15 +93,13 @@ export async function getCourseTimeAndLocation(s: Session, courseID: string): Pr
93
93
  }
94
94
 
95
95
  export async function sortCourses(s: Session, courseIDs: string[]): Promise<void> {
96
- const json = await (
97
- await s.fetchWithToken(URLS.LEARN_SORT_COURSES, {
98
- method: 'POST',
99
- body: JSON.stringify(courseIDs.map((id, index) => ({ wlkcid: id, xh: index + 1 }))),
100
- headers: {
101
- 'Content-Type': 'application/json',
102
- },
103
- })
104
- ).json();
96
+ const json = await s.fetchJsonWithToken(URLS.LEARN_SORT_COURSES, {
97
+ method: 'POST',
98
+ body: JSON.stringify(courseIDs.map((id, index) => ({ wlkcid: id, xh: index + 1 }))),
99
+ headers: {
100
+ 'Content-Type': 'application/json',
101
+ },
102
+ });
105
103
  if (json.result !== 'success') {
106
104
  return Promise.reject({
107
105
  reason: FailReason.OPERATION_FAILED,
@@ -15,7 +15,7 @@ export async function getDiscussionList(
15
15
  courseID: string,
16
16
  courseType: CourseType = CourseType.STUDENT,
17
17
  ): Promise<Discussion[]> {
18
- const json = await (await s.fetchWithToken(URLS.LEARN_DISCUSSION_LIST(courseID, courseType))).json();
18
+ const json = await s.fetchJsonWithToken(URLS.LEARN_DISCUSSION_LIST(courseID, courseType));
19
19
  if (json.result !== 'success') {
20
20
  return Promise.reject({
21
21
  reason: FailReason.INVALID_RESPONSE,
@@ -44,7 +44,7 @@ export async function getAnsweredQuestionList(
44
44
  courseID: string,
45
45
  courseType: CourseType = CourseType.STUDENT,
46
46
  ): Promise<Question[]> {
47
- const json = await (await s.fetchWithToken(URLS.LEARN_QUESTION_LIST_ANSWERED(courseID, courseType))).json();
47
+ const json = await s.fetchJsonWithToken(URLS.LEARN_QUESTION_LIST_ANSWERED(courseID, courseType));
48
48
  if (json.result !== 'success') {
49
49
  return Promise.reject({
50
50
  reason: FailReason.INVALID_RESPONSE,
package/src/favorites.ts CHANGED
@@ -11,7 +11,7 @@ import { CONTENT_TYPE_MAP_REVERSE, decodeHTML } from './utils.ts';
11
11
  * Add an item to favorites. (收藏)
12
12
  */
13
13
  export async function addToFavorites(s: Session, type: ContentType, id: string): Promise<void> {
14
- const json = await (await s.fetchWithToken(URLS.LEARN_FAVORITE_ADD(type, id))).json();
14
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FAVORITE_ADD(type, id));
15
15
  if (json.result !== 'success' || !json.msg?.endsWith?.('成功')) {
16
16
  return Promise.reject({
17
17
  reason: FailReason.OPERATION_FAILED,
@@ -24,7 +24,7 @@ export async function addToFavorites(s: Session, type: ContentType, id: string):
24
24
  * Remove an item from favorites. (取消收藏)
25
25
  */
26
26
  export async function removeFromFavorites(s: Session, id: string): Promise<void> {
27
- const json = await (await s.fetchWithToken(URLS.LEARN_FAVORITE_REMOVE(id))).json();
27
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FAVORITE_REMOVE(id));
28
28
  if (json.result !== 'success' || !json.msg?.endsWith?.('成功')) {
29
29
  return Promise.reject({
30
30
  reason: FailReason.OPERATION_FAILED,
@@ -38,12 +38,10 @@ export async function removeFromFavorites(s: Session, id: string): Promise<void>
38
38
  * If `courseID` or `type` is specified, only return favorites of that course or type.
39
39
  */
40
40
  export async function getFavorites(s: Session, courseID?: string, type?: ContentType): Promise<FavoriteItem[]> {
41
- const json = await (
42
- await s.fetchWithToken(URLS.LEARN_FAVORITE_LIST(type), {
43
- method: 'POST',
44
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
45
- })
46
- ).json();
41
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FAVORITE_LIST(type), {
42
+ method: 'POST',
43
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
44
+ });
47
45
  if (json.result !== 'success') {
48
46
  return Promise.reject({
49
47
  reason: FailReason.INVALID_RESPONSE,
@@ -78,12 +76,10 @@ export async function getFavorites(s: Session, courseID?: string, type?: Content
78
76
  * Pin a favorite item. (置顶)
79
77
  */
80
78
  export async function pinFavoriteItem(s: Session, id: string): Promise<void> {
81
- const json = await (
82
- await s.fetchWithToken(URLS.LEARN_FAVORITE_PIN, {
83
- method: 'POST',
84
- body: URLS.LEARN_FAVORITE_PIN_UNPIN_FORM_DATA(id),
85
- })
86
- ).json();
79
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FAVORITE_PIN, {
80
+ method: 'POST',
81
+ body: URLS.LEARN_FAVORITE_PIN_UNPIN_FORM_DATA(id),
82
+ });
87
83
  if (json.result !== 'success') {
88
84
  return Promise.reject({
89
85
  reason: FailReason.OPERATION_FAILED,
@@ -96,12 +92,10 @@ export async function pinFavoriteItem(s: Session, id: string): Promise<void> {
96
92
  * Unpin a favorite item. (取消置顶)
97
93
  */
98
94
  export async function unpinFavoriteItem(s: Session, id: string): Promise<void> {
99
- const json = await (
100
- await s.fetchWithToken(URLS.LEARN_FAVORITE_UNPIN, {
101
- method: 'POST',
102
- body: URLS.LEARN_FAVORITE_PIN_UNPIN_FORM_DATA(id),
103
- })
104
- ).json();
95
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FAVORITE_UNPIN, {
96
+ method: 'POST',
97
+ body: URLS.LEARN_FAVORITE_PIN_UNPIN_FORM_DATA(id),
98
+ });
105
99
  if (json.result !== 'success') {
106
100
  return Promise.reject({
107
101
  reason: FailReason.OPERATION_FAILED,
@@ -115,12 +109,10 @@ export async function unpinFavoriteItem(s: Session, id: string): Promise<void> {
115
109
  * Set an empty string to remove the comment.
116
110
  */
117
111
  export async function setComment(s: Session, type: ContentType, id: string, content: string): Promise<void> {
118
- const json = await (
119
- await s.fetchWithToken(URLS.LEARN_COMMENT_SET, {
120
- method: 'POST',
121
- body: URLS.LEARN_COMMENT_SET_FORM_DATA(type, id, content),
122
- })
123
- ).json();
112
+ const json = await s.fetchJsonWithToken(URLS.LEARN_COMMENT_SET, {
113
+ method: 'POST',
114
+ body: URLS.LEARN_COMMENT_SET_FORM_DATA(type, id, content),
115
+ });
124
116
  if (json.result !== 'success' || !json.msg?.endsWith?.('成功')) {
125
117
  return Promise.reject({
126
118
  reason: FailReason.OPERATION_FAILED,
@@ -134,12 +126,10 @@ export async function setComment(s: Session, type: ContentType, id: string, cont
134
126
  * If `courseID` or `type` is specified, only return favorites of that course or type.
135
127
  */
136
128
  export async function getComments(s: Session, courseID?: string, type?: ContentType): Promise<CommentItem[]> {
137
- const json = await (
138
- await s.fetchWithToken(URLS.LEARN_COMMENT_LIST(type), {
139
- method: 'POST',
140
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
141
- })
142
- ).json();
129
+ const json = await s.fetchJsonWithToken(URLS.LEARN_COMMENT_LIST(type), {
130
+ method: 'POST',
131
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
132
+ });
143
133
  if (json.result !== 'success') {
144
134
  return Promise.reject({
145
135
  reason: FailReason.INVALID_RESPONSE,
package/src/files.ts CHANGED
@@ -6,13 +6,13 @@ import { ContentType, CourseType, FailReason } from './types.ts';
6
6
  import * as URLS from './urls.ts';
7
7
  import { decodeHTML, formatFileSize } from './utils.ts';
8
8
 
9
- /** Get all files (课程文件) of the specified course. */
9
+ /** Get all files (课程文件) of the specified course; `category` is left out if the categories can't be fetched. */
10
10
  export async function getFileList(
11
11
  s: Session,
12
12
  courseID: string,
13
13
  courseType: CourseType = CourseType.STUDENT,
14
14
  ): Promise<File[]> {
15
- const json = await (await s.fetchWithToken(URLS.LEARN_FILE_LIST(courseID, courseType))).json();
15
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FILE_LIST(courseID, courseType));
16
16
  if (json.result !== 'success') {
17
17
  return Promise.reject({
18
18
  reason: FailReason.INVALID_RESPONSE,
@@ -29,7 +29,9 @@ export async function getFileList(
29
29
  result = json.object;
30
30
  }
31
31
 
32
- const categories = new Map((await getFileCategoryList(s, courseID, courseType)).map((c) => [c.id, c]));
32
+ // categories only fill `category`; don't fail the whole list when they can't be fetched
33
+ const categoryList = await getFileCategoryList(s, courseID, courseType).catch(() => []);
34
+ const categories = new Map(categoryList.map((c) => [c.id, c]));
33
35
 
34
36
  return result.map((f) => {
35
37
  const title = decodeHTML(f.bt);
@@ -71,7 +73,7 @@ export async function getFileCategoryList(
71
73
  courseID: string,
72
74
  courseType: CourseType = CourseType.STUDENT,
73
75
  ): Promise<FileCategory[]> {
74
- const json = await (await s.fetchWithToken(URLS.LEARN_FILE_CATEGORY_LIST(courseID, courseType))).json();
76
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FILE_CATEGORY_LIST(courseID, courseType));
75
77
  if (json.result !== 'success') {
76
78
  return Promise.reject({
77
79
  reason: FailReason.INVALID_RESPONSE,
@@ -102,7 +104,7 @@ export async function getFileListByCategory(
102
104
  courseType: CourseType = CourseType.STUDENT,
103
105
  ): Promise<File[]> {
104
106
  if (courseType === CourseType.STUDENT) {
105
- const json = await (await s.fetchWithToken(URLS.LEARN_FILE_LIST_BY_CATEGORY_STUDENT(courseID, categoryId))).json();
107
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FILE_LIST_BY_CATEGORY_STUDENT(courseID, categoryId));
106
108
  if (json.result !== 'success') {
107
109
  return Promise.reject({
108
110
  reason: FailReason.INVALID_RESPONSE,
@@ -147,12 +149,10 @@ export async function getFileListByCategory(
147
149
  } satisfies File;
148
150
  });
149
151
  } else {
150
- const json = await (
151
- await s.fetchWithToken(URLS.LEARN_FILE_LIST_BY_CATEGORY_TEACHER, {
152
- method: 'POST',
153
- body: URLS.LEARN_FILE_LIST_BY_CATEGORY_TEACHER_FORM_DATA(courseID, categoryId),
154
- })
155
- ).json();
152
+ const json = await s.fetchJsonWithToken(URLS.LEARN_FILE_LIST_BY_CATEGORY_TEACHER, {
153
+ method: 'POST',
154
+ body: URLS.LEARN_FILE_LIST_BY_CATEGORY_TEACHER_FORM_DATA(courseID, categoryId),
155
+ });
156
156
  if (json.result !== 'success') {
157
157
  return Promise.reject({
158
158
  reason: FailReason.INVALID_RESPONSE,
@@ -160,7 +160,7 @@ export async function getFileListByCategory(
160
160
  });
161
161
  }
162
162
 
163
- const result = (json.object.aaData ?? []) as any[];
163
+ const result = (json.object?.aaData ?? []) as any[];
164
164
 
165
165
  return result.map((f) => {
166
166
  const title = decodeHTML(f.bt);
package/src/homework.ts CHANGED
@@ -26,12 +26,10 @@ import { GRADE_LEVEL_MAP, decodeHTML, isSelected, trimAndDefine } from './utils.
26
26
  * Get all homeworks (课程作业) of the specified course: 3 status lists and the excellent homework list.
27
27
  * Details (page + description) only for the homework `options.details` selects.
28
28
  */
29
- export function getHomeworkList(s: Session, courseID: string): Promise<Homework[]>;
30
-
31
29
  export function getHomeworkList(
32
30
  s: Session,
33
31
  courseID: string,
34
- courseType: CourseType.STUDENT,
32
+ courseType?: CourseType.STUDENT,
35
33
  options?: HomeworkListOptions,
36
34
  ): Promise<Homework[]>;
37
35
 
@@ -44,12 +42,10 @@ export async function getHomeworkList(
44
42
  options: HomeworkListOptions = {},
45
43
  ): Promise<Homework[] | HomeworkTA[]> {
46
44
  if (courseType === CourseType.TEACHER) {
47
- const json = await (
48
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_LIST_TEACHER, {
49
- method: 'POST',
50
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
51
- })
52
- ).json();
45
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_LIST_TEACHER, {
46
+ method: 'POST',
47
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
48
+ });
53
49
  if (json.result !== 'success') {
54
50
  return Promise.reject({
55
51
  reason: FailReason.INVALID_RESPONSE,
@@ -102,12 +98,10 @@ async function getHomeworkListAtUrl(
102
98
  url: string,
103
99
  status: IHomeworkStatus,
104
100
  ): Promise<IHomework[]> {
105
- const json = await (
106
- await s.fetchWithToken(url, {
107
- method: 'POST',
108
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
109
- })
110
- ).json();
101
+ const json = await s.fetchJsonWithToken(url, {
102
+ method: 'POST',
103
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
104
+ });
111
105
  if (json.result !== 'success') {
112
106
  return Promise.reject({
113
107
  reason: FailReason.INVALID_RESPONSE,
@@ -149,12 +143,10 @@ async function getExcellentHomeworkListByHomework(
149
143
  s: Session,
150
144
  courseID: string,
151
145
  ): Promise<{ [id: string]: ExcellentHomework[] }> {
152
- const json = await (
153
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_LIST_EXCELLENT, {
154
- method: 'POST',
155
- body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
156
- })
157
- ).json();
146
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_LIST_EXCELLENT, {
147
+ method: 'POST',
148
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
149
+ });
158
150
  if (json.result !== 'success') {
159
151
  return Promise.reject({
160
152
  reason: FailReason.INVALID_RESPONSE,
@@ -190,8 +182,7 @@ async function getExcellentHomeworkListByHomework(
190
182
  }
191
183
 
192
184
  async function parseHomeworkAtUrl(s: Session, url: string): Promise<IHomeworkDetail> {
193
- const response = await s.fetchWithToken(url);
194
- const result = $(await response.text());
185
+ const result = $(await s.fetchTextWithToken(url));
195
186
 
196
187
  const fileDivs = result('div.list.fujian.clearfix');
197
188
 
@@ -222,12 +213,10 @@ export async function getHomeworkDetail(
222
213
  }
223
214
 
224
215
  async function getHomeworkDescription(s: Session, baseId: string): Promise<IHomeworkDetail> {
225
- const json = await (
226
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_DETAIL, {
227
- method: 'POST',
228
- body: URLS.LEARN_HOMEWORK_DETAIL_FORM_DATA(baseId),
229
- })
230
- ).json();
216
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_DETAIL, {
217
+ method: 'POST',
218
+ body: URLS.LEARN_HOMEWORK_DETAIL_FORM_DATA(baseId),
219
+ });
231
220
  if (json.result !== 'success') {
232
221
  throw {
233
222
  reason: FailReason.INVALID_RESPONSE,
@@ -241,18 +230,19 @@ async function getHomeworkDescription(s: Session, baseId: string): Promise<IHome
241
230
 
242
231
  function parseHomeworkFile(s: Session, fileDiv: Element): RemoteFile | undefined {
243
232
  const fileNode = $(fileDiv)('.ftitle').children('a')[0] ?? $(fileDiv)('.fl').children('a')[0];
244
- if (fileNode !== undefined) {
245
- const size = trimAndDefine($(fileDiv)('.fl > span[class^="color"]').first().text())!;
246
- const params = new URLSearchParams(fileNode.attribs.href.split('?').slice(-1)[0]);
233
+ const href = fileNode?.attribs.href;
234
+ if (fileNode !== undefined && href) {
235
+ const size = trimAndDefine($(fileDiv)('.fl > span[class^="color"]').first().text()) ?? '';
236
+ const params = new URLSearchParams(href.split('?').slice(-1)[0]);
247
237
  const attachmentId = params.get('fileId')!;
248
238
  // so dirty here...
249
- let downloadUrl = URLS.LEARN_PREFIX + fileNode.attribs.href;
239
+ let downloadUrl = URLS.LEARN_PREFIX + href;
250
240
  if (params.has('downloadUrl')) {
251
241
  downloadUrl = URLS.LEARN_PREFIX + params.get('downloadUrl')!;
252
242
  }
253
243
  return {
254
244
  id: attachmentId,
255
- name: (fileNode.children[0] as { data: string }).data,
245
+ name: (fileNode.children[0] as { data?: string } | undefined)?.data ?? '',
256
246
  downloadUrl,
257
247
  previewUrl: URLS.LEARN_FILE_PREVIEW(ContentType.HOMEWORK, attachmentId, CourseType.STUDENT, s.previewFirstPage),
258
248
  size,
@@ -269,12 +259,12 @@ export async function submitHomework(
269
259
  attachment?: IHomeworkSubmitAttachment,
270
260
  removeAttachment = false,
271
261
  ): Promise<void> {
272
- const json = await (
273
- await s.fetchWithToken(URLS.LEARN_HOMEWORK_SUBMIT(), {
274
- method: 'POST',
275
- body: URLS.LEARN_HOMEWORK_SUBMIT_FORM_DATA(id, content, attachment, removeAttachment),
276
- })
277
- ).json();
262
+ const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_SUBMIT(), {
263
+ method: 'POST',
264
+ body: URLS.LEARN_HOMEWORK_SUBMIT_FORM_DATA(id, content, attachment, removeAttachment),
265
+ // the response only starts after the whole upload, which can take longer than `requestTimeout`
266
+ timeout: attachment ? 0 : undefined,
267
+ });
278
268
  if (json.result !== 'success') {
279
269
  return Promise.reject({
280
270
  reason: FailReason.OPERATION_FAILED,
package/src/index.ts CHANGED
@@ -34,6 +34,8 @@ import type {
34
34
  Notification,
35
35
  Question,
36
36
  Questionnaire,
37
+ QuestionnaireDetail,
38
+ QuestionnaireListOptions,
37
39
  SemesterInfo,
38
40
  UserInfo,
39
41
  ContentType,
@@ -89,7 +91,8 @@ export class Learn2018Helper {
89
91
 
90
92
  /**
91
93
  * Login is necessary if you do not provide a `CredentialProvider`.
92
- * Without credentials it uses the provider, and concurrent calls share one SSO login.
94
+ * Without credentials it uses the provider, and concurrent calls share one SSO login. Logins never overlap: one with
95
+ * credentials waits for a login in progress, and requests that find the session expired meanwhile wait for it.
93
96
  */
94
97
  public async login(
95
98
  username?: string,
@@ -99,7 +102,9 @@ export class Learn2018Helper {
99
102
  fingerGenPrint3?: string,
100
103
  ): Promise<void> {
101
104
  if (!username || !password || !fingerPrint) return this.#session.relogin();
102
- return auth.login(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
105
+ return this.#session.runLogin(() =>
106
+ auth.login(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3),
107
+ );
103
108
  }
104
109
 
105
110
  /**
@@ -124,7 +129,8 @@ export class Learn2018Helper {
124
129
  /**
125
130
  * Get calendar items during the specified period (in yyyymmdd format, both inclusive).
126
131
  * Ranges longer than 28 days are fetched in windows and merged; windows that fail even after a retry are listed in
127
- * `failedRanges`. Rejects only when every window fails (usually with `FailReason.INVALID_RESPONSE`).
132
+ * `failedRanges`. Rejects only when every window fails (usually with `FailReason.INVALID_RESPONSE`), or with a
133
+ * `RangeError` for a date that is not a valid yyyymmdd.
128
134
  */
129
135
  public async getCalendar(startDate: string, endDate: string, options?: CalendarOptions): Promise<CalendarResult> {
130
136
  return calendar.getCalendar(this.#session, startDate, endDate, options);
@@ -220,10 +226,9 @@ export class Learn2018Helper {
220
226
  * Details (page + description, two requests per homework) are only fetched for the homework `options.details`
221
227
  * selects; use `getHomeworkDetail` for the others.
222
228
  */
223
- public async getHomeworkList(courseID: string): Promise<Homework[]>;
224
229
  public async getHomeworkList(
225
230
  courseID: string,
226
- courseType: CourseType.STUDENT,
231
+ courseType?: CourseType.STUDENT,
227
232
  options?: HomeworkListOptions,
228
233
  ): Promise<Homework[]>;
229
234
  public async getHomeworkList(courseID: string, courseType: CourseType.TEACHER): Promise<HomeworkTA[]>;
@@ -265,9 +270,16 @@ export class Learn2018Helper {
265
270
 
266
271
  /**
267
272
  * Get all questionnaires (课程问卷/QNR) of the specified course.
273
+ * Questions (`detail`, one request per questionnaire) for the questionnaires `options.details` selects, by default
274
+ * all; use `getQuestionnaireDetail` for the others.
268
275
  */
269
- public async getQuestionnaireList(courseID: string): Promise<Questionnaire[]> {
270
- return questionnaires.getQuestionnaireList(this.#session, courseID);
276
+ public async getQuestionnaireList(courseID: string, options?: QuestionnaireListOptions): Promise<Questionnaire[]> {
277
+ return questionnaires.getQuestionnaireList(this.#session, courseID, options);
278
+ }
279
+
280
+ /** Get the questions of one questionnaire (one request). */
281
+ public async getQuestionnaireDetail(courseID: string, questionnaireID: string): Promise<QuestionnaireDetail[]> {
282
+ return questionnaires.getQuestionnaireDetail(this.#session, courseID, questionnaireID);
271
283
  }
272
284
 
273
285
  /**