@robertying/thu-learn-lib 0.0.0-stage → 4.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 (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 +52 -0
  14. package/lib/contents.js.map +1 -0
  15. package/lib/courses.d.ts +9 -0
  16. package/lib/courses.d.ts.map +1 -0
  17. package/lib/courses.js +82 -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 +9 -0
  32. package/lib/homework.d.ts.map +1 -0
  33. package/lib/homework.js +193 -0
  34. package/lib/homework.js.map +1 -0
  35. package/lib/index.d.ts +105 -0
  36. package/lib/index.d.ts.map +1 -0
  37. package/lib/index.js +181 -0
  38. package/lib/index.js.map +1 -0
  39. package/lib/notices.d.ts +6 -0
  40. package/lib/notices.d.ts.map +1 -0
  41. package/lib/notices.js +84 -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 +31 -0
  48. package/lib/request.d.ts.map +1 -0
  49. package/lib/request.js +90 -0
  50. package/lib/request.js.map +1 -0
  51. package/lib/types.d.ts +392 -0
  52. package/lib/types.d.ts.map +1 -0
  53. package/lib/types.js +97 -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 +13 -0
  64. package/lib/utils.d.ts.map +1 -0
  65. package/lib/utils.js +115 -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 +67 -0
  71. package/src/courses.ts +97 -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 +279 -0
  76. package/src/index.ts +282 -0
  77. package/src/notices.ts +110 -0
  78. package/src/questionnaires.ts +76 -0
  79. package/src/request.ts +101 -0
  80. package/src/types.ts +445 -0
  81. package/src/urls.ts +257 -0
  82. package/src/user.ts +28 -0
  83. package/src/utils.ts +120 -0
package/src/auth.ts ADDED
@@ -0,0 +1,121 @@
1
+ // Login to Web Learning through id.tsinghua.edu.cn (SSO) and logout.
2
+
3
+ import { sm2 } from 'sm-crypto';
4
+
5
+ import type { Session } from './request.ts';
6
+ import { $ } from './request.ts';
7
+ import type { ApiError } from './types.ts';
8
+ import { FailReason, Language } from './types.ts';
9
+ import * as URLS from './urls.ts';
10
+
11
+ /**
12
+ * If using alternative cookie management systems,
13
+ * be sure to clear id.tsinghua.edu.cn cookies before calling this function
14
+ */
15
+ export async function getRoamingTicket(
16
+ s: Session,
17
+ username: string,
18
+ password: string,
19
+ fingerPrint: string,
20
+ fingerGenPrint: string = '',
21
+ fingerGenPrint3: string = '',
22
+ ) {
23
+ // Make sure we always start with the form page
24
+ // More code is needed to handle the case where the user is already logged in (i.e. cookies are set)
25
+ // It won't be a problem if it is always the same user,
26
+ // but if the current cookies are from a different user than the one trying to log in,
27
+ // it will cause problems.
28
+ try {
29
+ await s.cookieJar.setCookie('JSESSIONID=; path=/; HttpOnly', URLS.ID_PREFIX);
30
+ } catch (err) {
31
+ throw {
32
+ reason: FailReason.ERROR_SETTING_COOKIES,
33
+ extra: err,
34
+ } as ApiError;
35
+ }
36
+
37
+ try {
38
+ const loginForm = await s.rawFetch(URLS.ID_LOGIN());
39
+ const body = $(await loginForm.text());
40
+ const sm2publicKey = body('#sm2publicKey').text().trim();
41
+
42
+ const formData = new FormData();
43
+ formData.append('i_user', username);
44
+ formData.append('i_pass', '04' + sm2.doEncrypt(password, sm2publicKey));
45
+ formData.append('singleLogin', 'on');
46
+ formData.append('fingerPrint', fingerPrint);
47
+ formData.append('fingerGenPrint', fingerGenPrint ?? '');
48
+ formData.append('fingerGenPrint3', fingerGenPrint3 ?? '');
49
+ formData.append('i_captcha', '');
50
+ const checkResponse = await s.rawFetch(URLS.ID_LOGIN_CHECK(), {
51
+ method: 'POST',
52
+ body: formData,
53
+ });
54
+
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;
59
+ } catch (err) {
60
+ throw {
61
+ reason: FailReason.ERROR_FETCH_FROM_ID,
62
+ extra: err,
63
+ } as ApiError;
64
+ }
65
+ }
66
+
67
+ /** login is necessary if you do not provide a `CredentialProvider` */
68
+ export async function login(
69
+ s: Session,
70
+ username?: string,
71
+ password?: string,
72
+ fingerPrint?: string,
73
+ fingerGenPrint?: string,
74
+ fingerGenPrint3?: string,
75
+ ): Promise<void> {
76
+ if (!username || !password || !fingerPrint) {
77
+ if (!s.provider)
78
+ return Promise.reject({
79
+ reason: FailReason.NO_CREDENTIAL,
80
+ });
81
+ const credential = await s.provider();
82
+ username = credential.username;
83
+ password = credential.password;
84
+ fingerPrint = credential.fingerPrint;
85
+ fingerGenPrint = credential.fingerGenPrint;
86
+ fingerGenPrint3 = credential.fingerGenPrint3;
87
+ if (!username || !password || !fingerPrint) {
88
+ return Promise.reject({
89
+ reason: FailReason.NO_CREDENTIAL,
90
+ });
91
+ }
92
+ }
93
+
94
+ // check response from id.tsinghua.edu.cn
95
+ const ticket = await getRoamingTicket(s, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
96
+
97
+ const loginResponse = await s.rawFetch(URLS.LEARN_AUTH_ROAM(ticket));
98
+ if (!loginResponse.ok) {
99
+ return Promise.reject({
100
+ reason: FailReason.ERROR_ROAMING,
101
+ });
102
+ }
103
+ const courseListPageSource: string = await (await s.rawFetch(URLS.LEARN_STUDENT_COURSE_LIST_PAGE())).text();
104
+ const tokenRegex = /^.*&_csrf=(\S*)".*$/gm;
105
+ const tokenMatches = [...courseListPageSource.matchAll(tokenRegex)];
106
+ if (tokenMatches.length === 0) {
107
+ return Promise.reject({
108
+ reason: FailReason.INVALID_RESPONSE,
109
+ extra: 'cannot fetch CSRF token from source',
110
+ });
111
+ }
112
+ s.csrfToken = tokenMatches[0][1];
113
+ const langRegex = /<script src="\/f\/wlxt\/common\/languagejs\?lang=(zh|en)"><\/script>/g;
114
+ const langMatches = [...courseListPageSource.matchAll(langRegex)];
115
+ if (langMatches.length !== 0) s.lang = langMatches[0][1] === 'en' ? Language.EN : Language.ZH;
116
+ }
117
+
118
+ /** logout (to make everyone happy) */
119
+ export async function logout(s: Session): Promise<void> {
120
+ await s.rawFetch(URLS.LEARN_LOGOUT(), { method: 'POST' });
121
+ }
@@ -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,67 @@
1
+ // One kind of content for many courses at once.
2
+
3
+ import type { Session } from './request.ts';
4
+ import type { 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 actually wraps around other `getXXX` functions. You can ignore the failure caused by certain courses.
15
+ */
16
+ export async function getAllContents<T extends ContentType>(
17
+ s: Session,
18
+ courseIDs: string[],
19
+ type: T,
20
+ courseType: CourseType = CourseType.STUDENT,
21
+ allowFailure = false,
22
+ ) {
23
+ const fetchContentForCourse = (id: string) => {
24
+ switch (type) {
25
+ case ContentType.NOTIFICATION:
26
+ return getNotificationList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
27
+ case ContentType.FILE:
28
+ return getFileList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
29
+ case ContentType.HOMEWORK:
30
+ return getHomeworkList(s, id) as Promise<ContentTypeMap[T][]>;
31
+ case ContentType.DISCUSSION:
32
+ return getDiscussionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
33
+ case ContentType.QUESTION:
34
+ return getAnsweredQuestionList(s, id, courseType) as Promise<ContentTypeMap[T][]>;
35
+ case ContentType.QUESTIONNAIRE:
36
+ return getQuestionnaireList(s, id) as Promise<ContentTypeMap[T][]>;
37
+ default:
38
+ return Promise.reject({
39
+ reason: FailReason.NOT_IMPLEMENTED,
40
+ extra: 'Unknown content type',
41
+ });
42
+ }
43
+ };
44
+
45
+ const contents: CourseContent<T> = {};
46
+
47
+ const results = await Promise.allSettled(
48
+ courseIDs.map(async (id) => {
49
+ contents[id] = await fetchContentForCourse(id);
50
+ }),
51
+ );
52
+
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;
67
+ }
package/src/courses.ts ADDED
@@ -0,0 +1,97 @@
1
+ // Semesters and courses.
2
+
3
+ import type { Session } from './request.ts';
4
+ import type { CourseInfo, 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 */
42
+ export async function getCourseList(
43
+ s: Session,
44
+ semesterID: string,
45
+ courseType: CourseType = CourseType.STUDENT,
46
+ lang = s.lang,
47
+ ): Promise<CourseInfo[]> {
48
+ const json = await (await s.fetchWithToken(URLS.LEARN_COURSE_LIST(semesterID, courseType, lang))).json();
49
+ if (json.message !== 'success' || !Array.isArray(json.resultList)) {
50
+ return Promise.reject({
51
+ reason: FailReason.INVALID_RESPONSE,
52
+ extra: json,
53
+ });
54
+ }
55
+ const result = (json.resultList ?? []) as any[];
56
+
57
+ return Promise.all(
58
+ 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) {}
64
+ return {
65
+ id: c.wlkcid,
66
+ name: decodeHTML(c.zywkcm),
67
+ chineseName: decodeHTML(c.kcm),
68
+ englishName: decodeHTML(c.ywkcm),
69
+ timeAndLocation,
70
+ url: URLS.LEARN_COURSE_PAGE(c.wlkcid, courseType),
71
+ teacherName: c.jsm ?? '', // teacher can not fetch this
72
+ teacherNumber: c.jsh,
73
+ courseNumber: c.kch,
74
+ courseIndex: Number(c.kxh), // c.kxh could be string (teacher mode) or number (student mode)
75
+ courseType,
76
+ } satisfies CourseInfo;
77
+ }),
78
+ );
79
+ }
80
+
81
+ 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();
91
+ if (json.result !== 'success') {
92
+ return Promise.reject({
93
+ reason: FailReason.OPERATION_FAILED,
94
+ extra: json,
95
+ });
96
+ }
97
+ }
@@ -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
+ }