@robertying/thu-learn-lib 0.0.0-stage → 5.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +324 -2
  3. package/lib/auth.d.ts +11 -0
  4. package/lib/auth.d.ts.map +1 -0
  5. package/lib/auth.js +99 -0
  6. package/lib/auth.js.map +1 -0
  7. package/lib/calendar.d.ts +16 -0
  8. package/lib/calendar.d.ts.map +1 -0
  9. package/lib/calendar.js +81 -0
  10. package/lib/calendar.js.map +1 -0
  11. package/lib/contents.d.ts +9 -0
  12. package/lib/contents.d.ts.map +1 -0
  13. package/lib/contents.js +51 -0
  14. package/lib/contents.js.map +1 -0
  15. package/lib/courses.d.ts +11 -0
  16. package/lib/courses.d.ts.map +1 -0
  17. package/lib/courses.js +94 -0
  18. package/lib/courses.js.map +1 -0
  19. package/lib/discussions.d.ts +11 -0
  20. package/lib/discussions.d.ts.map +1 -0
  21. package/lib/discussions.js +56 -0
  22. package/lib/discussions.js.map +1 -0
  23. package/lib/favorites.d.ts +35 -0
  24. package/lib/favorites.d.ts.map +1 -0
  25. package/lib/favorites.js +150 -0
  26. package/lib/favorites.js.map +1 -0
  27. package/lib/files.d.ts +13 -0
  28. package/lib/files.d.ts.map +1 -0
  29. package/lib/files.js +167 -0
  30. package/lib/files.js.map +1 -0
  31. package/lib/homework.d.ts +17 -0
  32. package/lib/homework.d.ts.map +1 -0
  33. package/lib/homework.js +194 -0
  34. package/lib/homework.js.map +1 -0
  35. package/lib/index.d.ts +134 -0
  36. package/lib/index.d.ts.map +1 -0
  37. package/lib/index.js +219 -0
  38. package/lib/index.js.map +1 -0
  39. package/lib/notices.d.ts +14 -0
  40. package/lib/notices.d.ts.map +1 -0
  41. package/lib/notices.js +92 -0
  42. package/lib/notices.js.map +1 -0
  43. package/lib/questionnaires.d.ts +7 -0
  44. package/lib/questionnaires.d.ts.map +1 -0
  45. package/lib/questionnaires.js +62 -0
  46. package/lib/questionnaires.js.map +1 -0
  47. package/lib/request.d.ts +57 -0
  48. package/lib/request.d.ts.map +1 -0
  49. package/lib/request.js +197 -0
  50. package/lib/request.js.map +1 -0
  51. package/lib/types.d.ts +433 -0
  52. package/lib/types.d.ts.map +1 -0
  53. package/lib/types.js +98 -0
  54. package/lib/types.js.map +1 -0
  55. package/lib/urls.d.ts +78 -0
  56. package/lib/urls.d.ts.map +1 -0
  57. package/lib/urls.js +160 -0
  58. package/lib/urls.js.map +1 -0
  59. package/lib/user.d.ts +7 -0
  60. package/lib/user.d.ts.map +1 -0
  61. package/lib/user.js +22 -0
  62. package/lib/user.js.map +1 -0
  63. package/lib/utils.d.ts +16 -0
  64. package/lib/utils.d.ts.map +1 -0
  65. package/lib/utils.js +117 -0
  66. package/lib/utils.js.map +1 -0
  67. package/package.json +67 -4
  68. package/src/auth.ts +121 -0
  69. package/src/calendar.ts +102 -0
  70. package/src/contents.ts +63 -0
  71. package/src/courses.ts +111 -0
  72. package/src/discussions.ts +80 -0
  73. package/src/favorites.ts +167 -0
  74. package/src/files.ts +197 -0
  75. package/src/homework.ts +284 -0
  76. package/src/index.ts +345 -0
  77. package/src/notices.ts +115 -0
  78. package/src/questionnaires.ts +76 -0
  79. package/src/request.ts +214 -0
  80. package/src/types.ts +494 -0
  81. package/src/urls.ts +257 -0
  82. package/src/user.ts +28 -0
  83. package/src/utils.ts +125 -0
@@ -0,0 +1,102 @@
1
+ // Course calendar from the registrar (academic.tsinghua.edu.cn).
2
+
3
+ import type { Session } from './request.ts';
4
+ import type { CalendarEvent, CalendarOptions, CalendarRange, CalendarResult } from './types.ts';
5
+ import { FailReason } from './types.ts';
6
+ import * as URLS from './urls.ts';
7
+ import { JSONP_EXTRACTOR_NAME, extractJSONPResult } from './utils.ts';
8
+
9
+ /** The registrar often fails for ranges longer than about a month, so longer ranges are fetched in windows of this many days. */
10
+ export const CALENDAR_WINDOW_DAYS = 28;
11
+
12
+ const DAY_MS = 24 * 60 * 60 * 1000;
13
+
14
+ const parseDate = (date: string): number => {
15
+ const match = /^(\d{4})(\d{2})(\d{2})$/.exec(date);
16
+ const time = match ? Date.UTC(Number(match[1]), Number(match[2]) - 1, Number(match[3])) : NaN;
17
+ if (Number.isNaN(time) || formatDate(time) !== date) {
18
+ throw { reason: FailReason.INVALID_RESPONSE, extra: `invalid date ${date}, expected yyyymmdd` };
19
+ }
20
+ return time;
21
+ };
22
+
23
+ const formatDate = (time: number): string => new Date(time).toISOString().slice(0, 10).replaceAll('-', '');
24
+
25
+ /** Split an inclusive yyyymmdd range into consecutive inclusive windows of at most `days` days. */
26
+ export function splitCalendarRange(startDate: string, endDate: string, days = CALENDAR_WINDOW_DAYS): CalendarRange[] {
27
+ const start = parseDate(startDate);
28
+ const end = parseDate(endDate);
29
+ const ranges: CalendarRange[] = [];
30
+ for (let from = start; from <= end; from += days * DAY_MS) {
31
+ const to = Math.min(from + (days - 1) * DAY_MS, end);
32
+ ranges.push({ startDate: formatDate(from), endDate: formatDate(to) });
33
+ }
34
+ return ranges;
35
+ }
36
+
37
+ async function fetchWindow(s: Session, range: CalendarRange, graduate: boolean): Promise<CalendarEvent[]> {
38
+ const response = await s.fetchWithToken(
39
+ URLS.REGISTRAR_CALENDAR(range.startDate, range.endDate, graduate, JSONP_EXTRACTOR_NAME),
40
+ );
41
+
42
+ if (!response.ok) {
43
+ return Promise.reject({
44
+ reason: FailReason.INVALID_RESPONSE,
45
+ });
46
+ }
47
+
48
+ const result = extractJSONPResult(await response.text()) as any[];
49
+
50
+ return result.map<CalendarEvent>((i) => ({
51
+ location: i.dd,
52
+ status: i.fl,
53
+ startTime: i.kssj,
54
+ endTime: i.jssj,
55
+ date: i.nq,
56
+ courseName: i.nr,
57
+ }));
58
+ }
59
+
60
+ /**
61
+ * Get calendar items during the specified period (in yyyymmdd format, both inclusive).
62
+ *
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.
67
+ */
68
+ export async function getCalendar(
69
+ s: Session,
70
+ startDate: string,
71
+ endDate: string,
72
+ options: CalendarOptions = {},
73
+ ): Promise<CalendarResult> {
74
+ const graduate = options.graduate ?? false;
75
+ const ranges = splitCalendarRange(startDate, endDate);
76
+ if (ranges.length === 0) return { events: [], failedRanges: [] };
77
+
78
+ const ticketResponse = await s.fetchWithToken(URLS.REGISTRAR_TICKET(), {
79
+ method: 'POST',
80
+ body: URLS.REGISTRAR_TICKET_FORM_DATA(),
81
+ });
82
+
83
+ let ticket = await ticketResponse.text();
84
+ ticket = ticket.substring(1, ticket.length - 1);
85
+
86
+ await s.fetch(URLS.REGISTRAR_AUTH(ticket));
87
+
88
+ const events: CalendarEvent[] = [];
89
+ const failedRanges: CalendarRange[] = [];
90
+ let lastError: unknown;
91
+ for (const range of ranges) {
92
+ try {
93
+ events.push(...(await fetchWindow(s, range, graduate).catch(() => fetchWindow(s, range, graduate))));
94
+ } catch (e) {
95
+ lastError = e;
96
+ failedRanges.push(range);
97
+ }
98
+ }
99
+
100
+ if (failedRanges.length === ranges.length) throw lastError;
101
+ return { events, failedRanges };
102
+ }
@@ -0,0 +1,63 @@
1
+ // One kind of content for many courses at once.
2
+
3
+ import type { Session } from './request.ts';
4
+ import type { AllContentsOptions, AllContentsResult, ContentTypeMap, CourseContent } from './types.ts';
5
+ import { ContentType, CourseType, FailReason } from './types.ts';
6
+ import { getAnsweredQuestionList, getDiscussionList } from './discussions.ts';
7
+ import { getFileList } from './files.ts';
8
+ import { getHomeworkList } from './homework.ts';
9
+ import { getNotificationList } from './notices.ts';
10
+ import { getQuestionnaireList } from './questionnaires.ts';
11
+
12
+ /**
13
+ * Get certain type of content of all specified courses.
14
+ * It wraps around the `getXXX` list functions; one course failing doesn't fail the others: its error is in `errors`.
15
+ */
16
+ export async function getAllContents<T extends ContentType>(
17
+ s: Session,
18
+ courseIDs: string[],
19
+ type: T,
20
+ options: AllContentsOptions<T> = {},
21
+ ): Promise<AllContentsResult<T>> {
22
+ const courseType = options.courseType ?? CourseType.STUDENT;
23
+ const fetchContentForCourse = (id: string): Promise<ContentTypeMap[T][]> => {
24
+ switch (type) {
25
+ case ContentType.NOTIFICATION:
26
+ return getNotificationList(s, id, courseType, {
27
+ attachments: (options as AllContentsOptions<ContentType.NOTIFICATION>).details,
28
+ }) as Promise<ContentTypeMap[T][]>;
29
+ case ContentType.FILE:
30
+ return getFileList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
31
+ case ContentType.HOMEWORK:
32
+ return getHomeworkList(s, id, CourseType.STUDENT, {
33
+ details: (options as AllContentsOptions<ContentType.HOMEWORK>).details,
34
+ }) as Promise<ContentTypeMap[T][]>;
35
+ case ContentType.DISCUSSION:
36
+ return getDiscussionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
37
+ case ContentType.QUESTION:
38
+ return getAnsweredQuestionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
39
+ case ContentType.QUESTIONNAIRE:
40
+ return getQuestionnaireList(s, id) as Promise<ContentTypeMap[T][]>;
41
+ default:
42
+ return Promise.reject({
43
+ reason: FailReason.NOT_IMPLEMENTED,
44
+ extra: 'Unknown content type',
45
+ });
46
+ }
47
+ };
48
+
49
+ const contents: CourseContent<T> = {};
50
+ const errors: AllContentsResult<T>['errors'] = {};
51
+
52
+ await Promise.all(
53
+ courseIDs.map(async (id) => {
54
+ try {
55
+ contents[id] = await fetchContentForCourse(id);
56
+ } catch (err) {
57
+ errors[id] = err;
58
+ }
59
+ }),
60
+ );
61
+
62
+ return { contents, errors };
63
+ }
package/src/courses.ts ADDED
@@ -0,0 +1,111 @@
1
+ // Semesters and courses.
2
+
3
+ import type { Session } from './request.ts';
4
+ import type { ApiError, CourseInfo, CourseListOptions, SemesterInfo } from './types.ts';
5
+ import { CourseType, FailReason } from './types.ts';
6
+ import * as URLS from './urls.ts';
7
+ import { decodeHTML, parseSemesterType } from './utils.ts';
8
+
9
+ export async function getSemesterIdList(s: Session): Promise<string[]> {
10
+ const json = await (await s.fetchWithToken(URLS.LEARN_SEMESTER_LIST())).json();
11
+ if (!Array.isArray(json)) {
12
+ return Promise.reject({
13
+ reason: FailReason.INVALID_RESPONSE,
14
+ extra: json,
15
+ });
16
+ }
17
+ const semesters = json as string[];
18
+ // sometimes web learning returns null, so confusing...
19
+ return semesters.filter((semester) => semester != null);
20
+ }
21
+
22
+ export async function getCurrentSemester(s: Session): Promise<SemesterInfo> {
23
+ const json = await (await s.fetchWithToken(URLS.LEARN_CURRENT_SEMESTER())).json();
24
+ if (json.message !== 'success') {
25
+ return Promise.reject({
26
+ reason: FailReason.INVALID_RESPONSE,
27
+ extra: json,
28
+ });
29
+ }
30
+ const result = json.result;
31
+ return {
32
+ id: result.id,
33
+ startDate: result.kssj,
34
+ endDate: result.jssj,
35
+ startYear: Number(result.xnxq.slice(0, 4)),
36
+ endYear: Number(result.xnxq.slice(5, 9)),
37
+ type: parseSemesterType(Number(result.xnxq.slice(10, 11))),
38
+ };
39
+ }
40
+
41
+ /** get all courses in the specified semester; time and location only with `options.timeAndLocation` */
42
+ export async function getCourseList(
43
+ s: Session,
44
+ semesterID: string,
45
+ courseType: CourseType = CourseType.STUDENT,
46
+ lang = s.lang,
47
+ options: CourseListOptions = {},
48
+ ): Promise<CourseInfo[]> {
49
+ const json = await (await s.fetchWithToken(URLS.LEARN_COURSE_LIST(semesterID, courseType, lang))).json();
50
+ if (json.message !== 'success' || !Array.isArray(json.resultList)) {
51
+ return Promise.reject({
52
+ reason: FailReason.INVALID_RESPONSE,
53
+ extra: json,
54
+ });
55
+ }
56
+ const result = (json.resultList ?? []) as any[];
57
+
58
+ return Promise.all(
59
+ result.map(async (c) => {
60
+ let timeAndLocation: string[] | undefined;
61
+ if (options.timeAndLocation) {
62
+ try {
63
+ timeAndLocation = await getCourseTimeAndLocation(s, c.wlkcid);
64
+ } catch {
65
+ timeAndLocation = [];
66
+ }
67
+ }
68
+ return {
69
+ id: c.wlkcid,
70
+ name: decodeHTML(c.zywkcm),
71
+ chineseName: decodeHTML(c.kcm),
72
+ englishName: decodeHTML(c.ywkcm),
73
+ timeAndLocation,
74
+ url: URLS.LEARN_COURSE_PAGE(c.wlkcid, courseType),
75
+ teacherName: c.jsm ?? '', // teacher can not fetch this
76
+ teacherNumber: c.jsh,
77
+ courseNumber: c.kch,
78
+ courseIndex: Number(c.kxh), // c.kxh could be string (teacher mode) or number (student mode)
79
+ courseType,
80
+ } satisfies CourseInfo;
81
+ }),
82
+ );
83
+ }
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 (await s.fetchWithToken(URLS.LEARN_COURSE_TIME_LOCATION(courseID))).json();
89
+ if (!Array.isArray(json)) {
90
+ throw { reason: FailReason.INVALID_RESPONSE, extra: json } as ApiError;
91
+ }
92
+ return json as string[];
93
+ }
94
+
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();
105
+ if (json.result !== 'success') {
106
+ return Promise.reject({
107
+ reason: FailReason.OPERATION_FAILED,
108
+ extra: json,
109
+ });
110
+ }
111
+ }
@@ -0,0 +1,80 @@
1
+ // Discussions (课程讨论) and answered questions (课程答疑).
2
+
3
+ import { Base64 } from 'js-base64';
4
+
5
+ import type { Session } from './request.ts';
6
+ import { YES } from './request.ts';
7
+ import type { Discussion, IDiscussionBase, Question } from './types.ts';
8
+ import { CourseType, FailReason } from './types.ts';
9
+ import * as URLS from './urls.ts';
10
+ import { decodeHTML } from './utils.ts';
11
+
12
+ /** Get all discussions (课程讨论) of the specified course. */
13
+ export async function getDiscussionList(
14
+ s: Session,
15
+ courseID: string,
16
+ courseType: CourseType = CourseType.STUDENT,
17
+ ): Promise<Discussion[]> {
18
+ const json = await (await s.fetchWithToken(URLS.LEARN_DISCUSSION_LIST(courseID, courseType))).json();
19
+ if (json.result !== 'success') {
20
+ return Promise.reject({
21
+ reason: FailReason.INVALID_RESPONSE,
22
+ extra: json,
23
+ });
24
+ }
25
+
26
+ const result = (json.object?.resultsList ?? []) as any[];
27
+
28
+ return result.map(
29
+ (d) =>
30
+ ({
31
+ ...parseDiscussionBase(d),
32
+ boardId: d.bqid,
33
+ url: URLS.LEARN_DISCUSSION_DETAIL(d.wlkcid, d.bqid, d.id, courseType),
34
+ }) satisfies Discussion,
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Get all notifications (课程答疑) of the specified course.
40
+ * The student version supports only answered questions, while the teacher version supports all questions.
41
+ */
42
+ export async function getAnsweredQuestionList(
43
+ s: Session,
44
+ courseID: string,
45
+ courseType: CourseType = CourseType.STUDENT,
46
+ ): Promise<Question[]> {
47
+ const json = await (await s.fetchWithToken(URLS.LEARN_QUESTION_LIST_ANSWERED(courseID, courseType))).json();
48
+ if (json.result !== 'success') {
49
+ return Promise.reject({
50
+ reason: FailReason.INVALID_RESPONSE,
51
+ extra: json,
52
+ });
53
+ }
54
+
55
+ const result = (json.object?.resultsList ?? []) as any[];
56
+
57
+ return result.map(
58
+ (q) =>
59
+ ({
60
+ ...parseDiscussionBase(q),
61
+ question: Base64.decode(q.wtnr),
62
+ url: URLS.LEARN_QUESTION_DETAIL(q.wlkcid, q.id, courseType),
63
+ }) satisfies Question,
64
+ );
65
+ }
66
+
67
+ function parseDiscussionBase(d: any): IDiscussionBase {
68
+ return {
69
+ id: d.id,
70
+ title: decodeHTML(d.bt),
71
+ publisherName: d.fbrxm,
72
+ publishTime: d.fbsj,
73
+ lastReplyTime: d.zhhfsj,
74
+ lastReplierName: d.zhhfrxm,
75
+ visitCount: d.djs ?? 0, // teacher cannot fetch this
76
+ replyCount: d.hfcs,
77
+ isFavorite: d.sfsc === YES,
78
+ comment: d.bznr ?? undefined,
79
+ };
80
+ }
@@ -0,0 +1,167 @@
1
+ // Favorites (收藏) and comments (备注).
2
+
3
+ import type { Session } from './request.ts';
4
+ import { YES } from './request.ts';
5
+ import type { CommentItem, FavoriteItem } from './types.ts';
6
+ import { ContentType, FailReason } from './types.ts';
7
+ import * as URLS from './urls.ts';
8
+ import { CONTENT_TYPE_MAP_REVERSE, decodeHTML } from './utils.ts';
9
+
10
+ /**
11
+ * Add an item to favorites. (收藏)
12
+ */
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();
15
+ if (json.result !== 'success' || !json.msg?.endsWith?.('成功')) {
16
+ return Promise.reject({
17
+ reason: FailReason.OPERATION_FAILED,
18
+ extra: json,
19
+ });
20
+ }
21
+ }
22
+
23
+ /**
24
+ * Remove an item from favorites. (取消收藏)
25
+ */
26
+ export async function removeFromFavorites(s: Session, id: string): Promise<void> {
27
+ const json = await (await s.fetchWithToken(URLS.LEARN_FAVORITE_REMOVE(id))).json();
28
+ if (json.result !== 'success' || !json.msg?.endsWith?.('成功')) {
29
+ return Promise.reject({
30
+ reason: FailReason.OPERATION_FAILED,
31
+ extra: json,
32
+ });
33
+ }
34
+ }
35
+
36
+ /**
37
+ * Get favorites. (我的收藏)
38
+ * If `courseID` or `type` is specified, only return favorites of that course or type.
39
+ */
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();
47
+ if (json.result !== 'success') {
48
+ return Promise.reject({
49
+ reason: FailReason.INVALID_RESPONSE,
50
+ extra: json,
51
+ });
52
+ }
53
+ const result = (json.object?.aaData ?? []) as any[];
54
+ return result
55
+ .map((e): FavoriteItem | undefined => {
56
+ const itemType = CONTENT_TYPE_MAP_REVERSE.get(e.ywlx);
57
+ if (!itemType) return undefined; // ignore unknown type
58
+ return {
59
+ id: e.ywid,
60
+ type: itemType,
61
+ title: decodeHTML(e.ywbt),
62
+ time: itemType === ContentType.DISCUSSION || itemType === ContentType.QUESTION ? e.tlsj : e.ywsj,
63
+ state: e.ywzt,
64
+ extra: e.ywbz ?? undefined,
65
+ semesterId: e.xnxq,
66
+ courseId: e.wlkcid,
67
+ pinned: e.sfzd === YES,
68
+ pinnedTime: e.zdsj === null ? undefined : e.zdsj, // Note: this field is originally unix timestamp instead of string
69
+ comment: e.bznr ?? undefined,
70
+ addedTime: e.scsj,
71
+ itemId: e.id,
72
+ } satisfies FavoriteItem;
73
+ })
74
+ .filter((x) => !!x);
75
+ }
76
+
77
+ /**
78
+ * Pin a favorite item. (置顶)
79
+ */
80
+ 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();
87
+ if (json.result !== 'success') {
88
+ return Promise.reject({
89
+ reason: FailReason.OPERATION_FAILED,
90
+ extra: json,
91
+ });
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Unpin a favorite item. (取消置顶)
97
+ */
98
+ 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();
105
+ if (json.result !== 'success') {
106
+ return Promise.reject({
107
+ reason: FailReason.OPERATION_FAILED,
108
+ extra: json,
109
+ });
110
+ }
111
+ }
112
+
113
+ /**
114
+ * Set a comment. (备注)
115
+ * Set an empty string to remove the comment.
116
+ */
117
+ 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();
124
+ if (json.result !== 'success' || !json.msg?.endsWith?.('成功')) {
125
+ return Promise.reject({
126
+ reason: FailReason.OPERATION_FAILED,
127
+ extra: json,
128
+ });
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Get comments. (我的备注)
134
+ * If `courseID` or `type` is specified, only return favorites of that course or type.
135
+ */
136
+ 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();
143
+ if (json.result !== 'success') {
144
+ return Promise.reject({
145
+ reason: FailReason.INVALID_RESPONSE,
146
+ extra: json,
147
+ });
148
+ }
149
+ const result = (json.object?.aaData ?? []) as any[];
150
+ return result
151
+ .map((e): CommentItem | undefined => {
152
+ const itemType = CONTENT_TYPE_MAP_REVERSE.get(e.ywlx);
153
+ if (!itemType) return undefined; // ignore unknown type
154
+ return {
155
+ id: e.ywid,
156
+ type: itemType,
157
+ content: e.bt,
158
+ contentHTML: decodeHTML(e.bznrstring),
159
+ title: decodeHTML(e.ywbt),
160
+ semesterId: e.xnxq,
161
+ courseId: e.wlkcid,
162
+ commentTime: e.cjsj,
163
+ itemId: e.id,
164
+ } satisfies CommentItem;
165
+ })
166
+ .filter((x) => !!x);
167
+ }