@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/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
@@ -1,7 +1,7 @@
1
1
  // One kind of content for many courses at once.
2
2
 
3
3
  import type { Session } from './request.ts';
4
- import type { ContentTypeMap, CourseContent } from './types.ts';
4
+ import type { AllContentsOptions, AllContentsResult, ContentTypeMap, CourseContent } from './types.ts';
5
5
  import { ContentType, CourseType, FailReason } from './types.ts';
6
6
  import { getAnsweredQuestionList, getDiscussionList } from './discussions.ts';
7
7
  import { getFileList } from './files.ts';
@@ -11,29 +11,42 @@ import { getQuestionnaireList } from './questionnaires.ts';
11
11
 
12
12
  /**
13
13
  * Get certain type of content of all specified courses.
14
- * It actually wraps around other `getXXX` functions. You can ignore the failure caused by certain courses.
14
+ * It wraps around the `getXXX` list functions; one course failing doesn't fail the others: its error is in `errors`.
15
15
  */
16
16
  export async function getAllContents<T extends ContentType>(
17
17
  s: Session,
18
18
  courseIDs: string[],
19
19
  type: T,
20
- courseType: CourseType = CourseType.STUDENT,
21
- allowFailure = false,
22
- ) {
23
- const fetchContentForCourse = (id: string) => {
20
+ options: AllContentsOptions<T> = {},
21
+ ): Promise<AllContentsResult<T>> {
22
+ const courseType = options.courseType ?? CourseType.STUDENT;
23
+ const fetchContentForCourse = (id: string): Promise<ContentTypeMap[T][]> => {
24
24
  switch (type) {
25
25
  case ContentType.NOTIFICATION:
26
- return getNotificationList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
26
+ return getNotificationList(s, id, courseType, {
27
+ attachments: (options as AllContentsOptions<ContentType.NOTIFICATION>).details,
28
+ }) as Promise<ContentTypeMap[T][]>;
27
29
  case ContentType.FILE:
28
30
  return getFileList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
29
31
  case ContentType.HOMEWORK:
30
- return getHomeworkList(s, id) as Promise<ContentTypeMap[T][]>;
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
+ }
39
+ return getHomeworkList(s, id, CourseType.STUDENT, {
40
+ details: (options as AllContentsOptions<ContentType.HOMEWORK>).details,
41
+ }) as Promise<ContentTypeMap[T][]>;
31
42
  case ContentType.DISCUSSION:
32
43
  return getDiscussionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
33
44
  case ContentType.QUESTION:
34
45
  return getAnsweredQuestionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
35
46
  case ContentType.QUESTIONNAIRE:
36
- 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][]>;
37
50
  default:
38
51
  return Promise.reject({
39
52
  reason: FailReason.NOT_IMPLEMENTED,
@@ -43,25 +56,17 @@ export async function getAllContents<T extends ContentType>(
43
56
  };
44
57
 
45
58
  const contents: CourseContent<T> = {};
59
+ const errors: AllContentsResult<T>['errors'] = {};
46
60
 
47
- const results = await Promise.allSettled(
61
+ await Promise.all(
48
62
  courseIDs.map(async (id) => {
49
- contents[id] = await fetchContentForCourse(id);
63
+ try {
64
+ contents[id] = await fetchContentForCourse(id);
65
+ } catch (err) {
66
+ errors[id] = err;
67
+ }
50
68
  }),
51
69
  );
52
70
 
53
- if (!allowFailure) {
54
- for (const r of results) {
55
- if (r.status === 'rejected') {
56
- return Promise.reject({
57
- reason: FailReason.INVALID_RESPONSE,
58
- extra: {
59
- reason: r.reason,
60
- },
61
- });
62
- }
63
- }
64
- }
65
-
66
- return contents;
71
+ return { contents, errors };
67
72
  }
package/src/courses.ts CHANGED
@@ -1,13 +1,13 @@
1
1
  // Semesters and courses.
2
2
 
3
3
  import type { Session } from './request.ts';
4
- import type { CourseInfo, SemesterInfo } from './types.ts';
4
+ import type { ApiError, CourseInfo, CourseListOptions, SemesterInfo } from './types.ts';
5
5
  import { CourseType, FailReason } from './types.ts';
6
6
  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,
@@ -38,14 +38,15 @@ export async function getCurrentSemester(s: Session): Promise<SemesterInfo> {
38
38
  };
39
39
  }
40
40
 
41
- /** get all courses in the specified semester */
41
+ /** get all courses in the specified semester; time and location only with `options.timeAndLocation` */
42
42
  export async function getCourseList(
43
43
  s: Session,
44
44
  semesterID: string,
45
45
  courseType: CourseType = CourseType.STUDENT,
46
46
  lang = s.lang,
47
+ options: CourseListOptions = {},
47
48
  ): Promise<CourseInfo[]> {
48
- 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));
49
50
  if (json.message !== 'success' || !Array.isArray(json.resultList)) {
50
51
  return Promise.reject({
51
52
  reason: FailReason.INVALID_RESPONSE,
@@ -56,11 +57,14 @@ export async function getCourseList(
56
57
 
57
58
  return Promise.all(
58
59
  result.map(async (c) => {
59
- let timeAndLocation: string[] = [];
60
- try {
61
- // see https://github.com/Harry-Chen/Learn-Helper/issues/145
62
- timeAndLocation = await (await s.fetchWithToken(URLS.LEARN_COURSE_TIME_LOCATION(c.wlkcid))).json();
63
- } catch (e) {}
60
+ let timeAndLocation: string[] | undefined;
61
+ if (options.timeAndLocation) {
62
+ try {
63
+ timeAndLocation = await getCourseTimeAndLocation(s, c.wlkcid);
64
+ } catch {
65
+ timeAndLocation = [];
66
+ }
67
+ }
64
68
  return {
65
69
  id: c.wlkcid,
66
70
  name: decodeHTML(c.zywkcm),
@@ -78,16 +82,24 @@ export async function getCourseList(
78
82
  );
79
83
  }
80
84
 
85
+ /** get the time and location of one course (1 request) */
86
+ export async function getCourseTimeAndLocation(s: Session, courseID: string): Promise<string[]> {
87
+ // see https://github.com/Harry-Chen/Learn-Helper/issues/145
88
+ const json = await s.fetchJsonWithToken(URLS.LEARN_COURSE_TIME_LOCATION(courseID));
89
+ if (!Array.isArray(json)) {
90
+ throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
91
+ }
92
+ return json as string[];
93
+ }
94
+
81
95
  export async function sortCourses(s: Session, courseIDs: string[]): Promise<void> {
82
- const json = await (
83
- await s.fetchWithToken(URLS.LEARN_SORT_COURSES, {
84
- method: 'POST',
85
- body: JSON.stringify(courseIDs.map((id, index) => ({ wlkcid: id, xh: index + 1 }))),
86
- headers: {
87
- 'Content-Type': 'application/json',
88
- },
89
- })
90
- ).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
+ });
91
103
  if (json.result !== 'success') {
92
104
  return Promise.reject({
93
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);