@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.
- package/README.md +41 -7
- package/lib/auth.d.ts +1 -1
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js +17 -5
- package/lib/auth.js.map +1 -1
- package/lib/calendar.d.ts +6 -4
- package/lib/calendar.d.ts.map +1 -1
- package/lib/calendar.js +21 -8
- package/lib/calendar.js.map +1 -1
- package/lib/contents.d.ts.map +1 -1
- package/lib/contents.js +10 -1
- package/lib/contents.js.map +1 -1
- package/lib/courses.d.ts.map +1 -1
- package/lib/courses.js +6 -6
- package/lib/courses.js.map +1 -1
- package/lib/discussions.js +2 -2
- package/lib/discussions.js.map +1 -1
- package/lib/favorites.d.ts.map +1 -1
- package/lib/favorites.js +12 -12
- package/lib/favorites.js.map +1 -1
- package/lib/files.d.ts +1 -1
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +10 -8
- package/lib/files.js.map +1 -1
- package/lib/homework.d.ts +1 -2
- package/lib/homework.d.ts.map +1 -1
- package/lib/homework.js +19 -17
- package/lib/homework.js.map +1 -1
- package/lib/index.d.ts +11 -6
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +13 -5
- package/lib/index.js.map +1 -1
- package/lib/notices.d.ts +1 -1
- package/lib/notices.d.ts.map +1 -1
- package/lib/notices.js +11 -15
- package/lib/notices.js.map +1 -1
- package/lib/questionnaires.d.ts +6 -3
- package/lib/questionnaires.d.ts.map +1 -1
- package/lib/questionnaires.js +20 -12
- package/lib/questionnaires.js.map +1 -1
- package/lib/request.d.ts +44 -14
- package/lib/request.d.ts.map +1 -1
- package/lib/request.js +120 -43
- package/lib/request.js.map +1 -1
- package/lib/types.d.ts +11 -3
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js.map +1 -1
- package/lib/user.js +1 -1
- package/lib/user.js.map +1 -1
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +9 -3
- package/lib/utils.js.map +1 -1
- package/package.json +2 -2
- package/src/auth.ts +18 -5
- package/src/calendar.ts +28 -10
- package/src/contents.ts +10 -1
- package/src/courses.ts +11 -13
- package/src/discussions.ts +2 -2
- package/src/favorites.ts +22 -32
- package/src/files.ts +12 -12
- package/src/homework.ts +30 -40
- package/src/index.ts +19 -7
- package/src/notices.ts +12 -18
- package/src/questionnaires.ts +51 -37
- package/src/request.ts +141 -42
- package/src/types.ts +14 -3
- package/src/user.ts +1 -1
- 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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
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
|
|
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
|
|
64
|
-
* merged in date order. Windows that still fail are listed in `failedRanges`, so callers can
|
|
65
|
-
* retry only those
|
|
66
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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,
|
package/src/discussions.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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
|
-
|
|
101
|
-
|
|
102
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
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
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
-
|
|
227
|
-
|
|
228
|
-
|
|
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
|
-
|
|
245
|
-
|
|
246
|
-
const
|
|
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 +
|
|
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
|
|
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
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
)
|
|
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
|
|
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
|
|
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
|
/**
|