@robertying/thu-learn-lib 5.1.0 → 6.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.
- package/README.md +81 -248
- package/lib/auth.d.ts +14 -3
- package/lib/auth.d.ts.map +1 -1
- package/lib/auth.js +56 -58
- package/lib/auth.js.map +1 -1
- package/lib/calendar.d.ts +7 -2
- package/lib/calendar.d.ts.map +1 -1
- package/lib/calendar.js +99 -14
- package/lib/calendar.js.map +1 -1
- package/lib/contents.d.ts.map +1 -1
- package/lib/contents.js +4 -9
- package/lib/contents.js.map +1 -1
- package/lib/courses.d.ts.map +1 -1
- package/lib/courses.js +11 -25
- package/lib/courses.js.map +1 -1
- package/lib/discussions.d.ts.map +1 -1
- package/lib/discussions.js +6 -17
- package/lib/discussions.js.map +1 -1
- package/lib/favorites.d.ts +1 -0
- package/lib/favorites.d.ts.map +1 -1
- package/lib/favorites.js +13 -48
- package/lib/favorites.js.map +1 -1
- package/lib/files.d.ts +3 -3
- package/lib/files.d.ts.map +1 -1
- package/lib/files.js +109 -133
- package/lib/files.js.map +1 -1
- package/lib/homework.d.ts +34 -3
- package/lib/homework.d.ts.map +1 -1
- package/lib/homework.js +135 -79
- package/lib/homework.js.map +1 -1
- package/lib/index.d.ts +39 -19
- package/lib/index.d.ts.map +1 -1
- package/lib/index.js +44 -17
- package/lib/index.js.map +1 -1
- package/lib/notices.d.ts +3 -3
- package/lib/notices.d.ts.map +1 -1
- package/lib/notices.js +4 -20
- package/lib/notices.js.map +1 -1
- package/lib/questionnaires.d.ts.map +1 -1
- package/lib/questionnaires.js +8 -14
- package/lib/questionnaires.js.map +1 -1
- package/lib/request.d.ts +27 -13
- package/lib/request.d.ts.map +1 -1
- package/lib/request.js +84 -51
- package/lib/request.js.map +1 -1
- package/lib/timetable.d.ts +26 -0
- package/lib/timetable.d.ts.map +1 -0
- package/lib/timetable.js +110 -0
- package/lib/timetable.js.map +1 -0
- package/lib/types.d.ts +108 -75
- package/lib/types.d.ts.map +1 -1
- package/lib/types.js +3 -0
- package/lib/types.js.map +1 -1
- package/lib/urls.d.ts +10 -20
- package/lib/urls.d.ts.map +1 -1
- package/lib/urls.js +11 -56
- package/lib/urls.js.map +1 -1
- package/lib/user.d.ts +7 -2
- package/lib/user.d.ts.map +1 -1
- package/lib/user.js +19 -5
- package/lib/user.js.map +1 -1
- package/lib/utils.d.ts +42 -4
- package/lib/utils.d.ts.map +1 -1
- package/lib/utils.js +76 -31
- package/lib/utils.js.map +1 -1
- package/package.json +8 -7
- package/src/auth.ts +62 -73
- package/src/calendar.ts +110 -16
- package/src/contents.ts +7 -9
- package/src/courses.ts +12 -26
- package/src/discussions.ts +8 -19
- package/src/favorites.ts +22 -48
- package/src/files.ts +121 -139
- package/src/homework.ts +184 -102
- package/src/index.ts +67 -48
- package/src/notices.ts +19 -26
- package/src/questionnaires.ts +18 -15
- package/src/request.ts +86 -62
- package/src/timetable.ts +121 -0
- package/src/types.ts +111 -86
- package/src/urls.ts +13 -64
- package/src/user.ts +21 -6
- package/src/utils.ts +94 -32
package/src/homework.ts
CHANGED
|
@@ -1,26 +1,45 @@
|
|
|
1
1
|
// Homework (课程作业), excellent homework and homework submission.
|
|
2
2
|
|
|
3
|
-
import type { Element } from 'cheerio';
|
|
4
3
|
import { Base64 } from 'js-base64';
|
|
5
4
|
|
|
6
5
|
import type { Session } from './request.ts';
|
|
7
|
-
import {
|
|
6
|
+
import { addCSRFTokenToUrl } from './request.ts';
|
|
8
7
|
import type {
|
|
9
|
-
ApiError,
|
|
10
8
|
ExcellentHomework,
|
|
9
|
+
ExcellentHomeworkBase,
|
|
11
10
|
Homework,
|
|
11
|
+
HomeworkBase,
|
|
12
|
+
HomeworkDetail,
|
|
12
13
|
HomeworkListOptions,
|
|
14
|
+
HomeworkStatus,
|
|
15
|
+
HomeworkSubmitAttachment,
|
|
16
|
+
HomeworkSubmitRequest,
|
|
13
17
|
HomeworkTA,
|
|
14
|
-
IExcellentHomework,
|
|
15
|
-
IHomework,
|
|
16
|
-
IHomeworkDetail,
|
|
17
|
-
IHomeworkStatus,
|
|
18
|
-
IHomeworkSubmitAttachment,
|
|
19
18
|
RemoteFile,
|
|
20
19
|
} from './types.ts';
|
|
21
20
|
import { ContentType, CourseType, FailReason } from './types.ts';
|
|
22
21
|
import * as URLS from './urls.ts';
|
|
23
|
-
import {
|
|
22
|
+
import {
|
|
23
|
+
$,
|
|
24
|
+
GRADE_LEVEL_MAP,
|
|
25
|
+
YES,
|
|
26
|
+
apiError,
|
|
27
|
+
decodeHTML,
|
|
28
|
+
expectSuccess,
|
|
29
|
+
isSelected,
|
|
30
|
+
looksLikeLoginPage,
|
|
31
|
+
toRequiredServerTime,
|
|
32
|
+
toServerTime,
|
|
33
|
+
trimAndDefine,
|
|
34
|
+
} from './utils.ts';
|
|
35
|
+
import type { Element } from './utils.ts';
|
|
36
|
+
|
|
37
|
+
/** the student homework lists, by submission status */
|
|
38
|
+
export const HOMEWORK_LIST_SOURCES: readonly { url: string; status: HomeworkStatus }[] = [
|
|
39
|
+
{ url: URLS.LEARN_HOMEWORK_LIST_NEW, status: { submitted: false, graded: false } },
|
|
40
|
+
{ url: URLS.LEARN_HOMEWORK_LIST_SUBMITTED, status: { submitted: true, graded: false } },
|
|
41
|
+
{ url: URLS.LEARN_HOMEWORK_LIST_GRADED, status: { submitted: true, graded: true } },
|
|
42
|
+
];
|
|
24
43
|
|
|
25
44
|
/**
|
|
26
45
|
* Get all homeworks (课程作业) of the specified course: 3 status lists and the excellent homework list.
|
|
@@ -41,73 +60,70 @@ export async function getHomeworkList(
|
|
|
41
60
|
courseType: CourseType = CourseType.STUDENT,
|
|
42
61
|
options: HomeworkListOptions = {},
|
|
43
62
|
): Promise<Homework[] | HomeworkTA[]> {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
});
|
|
49
|
-
if (json.result !== 'success') {
|
|
50
|
-
return Promise.reject({
|
|
51
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
52
|
-
extra: json,
|
|
53
|
-
});
|
|
54
|
-
}
|
|
63
|
+
return courseType === CourseType.TEACHER
|
|
64
|
+
? getTeacherHomeworkList(s, courseID)
|
|
65
|
+
: getStudentHomeworkList(s, courseID, options);
|
|
66
|
+
}
|
|
55
67
|
|
|
56
|
-
|
|
68
|
+
async function getTeacherHomeworkList(s: Session, courseID: string): Promise<HomeworkTA[]> {
|
|
69
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_LIST_TEACHER, {
|
|
70
|
+
method: 'POST',
|
|
71
|
+
body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
|
|
72
|
+
});
|
|
73
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
57
74
|
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
75
|
+
const result = (json.object?.aaData ?? []) as any[];
|
|
76
|
+
|
|
77
|
+
return result.map(
|
|
78
|
+
(d) =>
|
|
79
|
+
({
|
|
80
|
+
id: d.zyid,
|
|
81
|
+
index: d.wz,
|
|
82
|
+
title: decodeHTML(d.bt),
|
|
83
|
+
description: decodeHTML(Base64.decode(d.nr ?? '')),
|
|
84
|
+
publisherId: d.fbr,
|
|
85
|
+
publishTime: toRequiredServerTime(d.fbsj),
|
|
86
|
+
startTime: toRequiredServerTime(d.kssj),
|
|
87
|
+
deadline: toRequiredServerTime(d.jzsj),
|
|
88
|
+
lateSubmissionDeadline: toServerTime(d.bjjzsj),
|
|
89
|
+
url: URLS.LEARN_HOMEWORK_DETAIL_TEACHER(courseID, d.zyid),
|
|
90
|
+
completionType: d.zywcfs,
|
|
91
|
+
submissionType: d.zytjfs,
|
|
92
|
+
gradedCount: d.ypys,
|
|
93
|
+
submittedCount: d.yjs,
|
|
94
|
+
unsubmittedCount: d.wjs,
|
|
95
|
+
}) satisfies HomeworkTA,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
async function getStudentHomeworkList(s: Session, courseID: string, options: HomeworkListOptions): Promise<Homework[]> {
|
|
100
|
+
const [lists, excellentHomeworkListByHomework] = await Promise.all([
|
|
101
|
+
Promise.all(HOMEWORK_LIST_SOURCES.map((source) => getHomeworkListAtUrl(s, courseID, source.url, source.status))),
|
|
102
|
+
// a failed excellent homework list doesn't fail the homework list: its items get `excellentHomeworkList: undefined`
|
|
103
|
+
getExcellentHomeworkListByHomework(s, courseID).catch(() => undefined),
|
|
104
|
+
]);
|
|
105
|
+
return Promise.all(
|
|
106
|
+
lists.flat().map(async (h) => {
|
|
107
|
+
const homework: Homework = {
|
|
108
|
+
...h,
|
|
109
|
+
excellentHomeworkList: excellentHomeworkListByHomework && (excellentHomeworkListByHomework[h.baseId] ?? []),
|
|
110
|
+
};
|
|
111
|
+
return isSelected(options.details, homework) ? { ...homework, ...(await getHomeworkDetail(s, h)) } : homework;
|
|
112
|
+
}),
|
|
113
|
+
);
|
|
93
114
|
}
|
|
94
115
|
|
|
95
116
|
async function getHomeworkListAtUrl(
|
|
96
117
|
s: Session,
|
|
97
118
|
courseID: string,
|
|
98
119
|
url: string,
|
|
99
|
-
status:
|
|
100
|
-
): Promise<
|
|
120
|
+
status: HomeworkStatus,
|
|
121
|
+
): Promise<HomeworkBase[]> {
|
|
101
122
|
const json = await s.fetchJsonWithToken(url, {
|
|
102
123
|
method: 'POST',
|
|
103
124
|
body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
|
|
104
125
|
});
|
|
105
|
-
|
|
106
|
-
return Promise.reject({
|
|
107
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
108
|
-
extra: json,
|
|
109
|
-
});
|
|
110
|
-
}
|
|
126
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
111
127
|
|
|
112
128
|
const result = (json.object?.aaData ?? []) as any[];
|
|
113
129
|
|
|
@@ -119,23 +135,22 @@ async function getHomeworkListAtUrl(
|
|
|
119
135
|
baseId: h.zyid,
|
|
120
136
|
title: decodeHTML(h.bt),
|
|
121
137
|
url: URLS.LEARN_HOMEWORK_PAGE(h.wlkcid, h.xszyid),
|
|
122
|
-
deadline: h.jzsj,
|
|
123
|
-
lateSubmissionDeadline: h.bjjzsj
|
|
138
|
+
deadline: toRequiredServerTime(h.jzsj),
|
|
139
|
+
lateSubmissionDeadline: toServerTime(h.bjjzsj),
|
|
124
140
|
isLateSubmission: h.sfbj === YES,
|
|
125
141
|
completionType: h.zywcfs,
|
|
126
142
|
submissionType: h.zytjfs,
|
|
127
143
|
submitUrl: URLS.LEARN_HOMEWORK_SUBMIT_PAGE(h.wlkcid, h.xszyid),
|
|
128
|
-
submitTime: h.scsj
|
|
144
|
+
submitTime: toServerTime(h.scsj),
|
|
129
145
|
grade: h.cj === null ? undefined : h.cj,
|
|
130
146
|
gradeLevel: GRADE_LEVEL_MAP.get(h.cj),
|
|
131
147
|
graderName: trimAndDefine(h.jsm),
|
|
132
148
|
gradeContent: trimAndDefine(h.pynr),
|
|
133
|
-
gradeTime: h.pysj
|
|
149
|
+
gradeTime: toServerTime(h.pysj),
|
|
134
150
|
isFavorite: h.sfsc === YES,
|
|
135
|
-
favoriteTime: h.scsj === null || h.sfsc !== YES ? undefined : h.scsj,
|
|
136
151
|
comment: h.bznr ?? undefined,
|
|
137
152
|
...status,
|
|
138
|
-
}) satisfies
|
|
153
|
+
}) satisfies HomeworkBase,
|
|
139
154
|
);
|
|
140
155
|
}
|
|
141
156
|
|
|
@@ -147,12 +162,7 @@ async function getExcellentHomeworkListByHomework(
|
|
|
147
162
|
method: 'POST',
|
|
148
163
|
body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
|
|
149
164
|
});
|
|
150
|
-
|
|
151
|
-
return Promise.reject({
|
|
152
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
153
|
-
extra: json,
|
|
154
|
-
});
|
|
155
|
-
}
|
|
165
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
156
166
|
|
|
157
167
|
const result = (json.object?.aaData ?? []) as any[];
|
|
158
168
|
|
|
@@ -170,7 +180,7 @@ async function getExcellentHomeworkListByHomework(
|
|
|
170
180
|
name: h.cy?.split(' ')?.[1],
|
|
171
181
|
anonymous: h.sfzm === YES,
|
|
172
182
|
},
|
|
173
|
-
}) satisfies
|
|
183
|
+
}) satisfies ExcellentHomeworkBase,
|
|
174
184
|
)
|
|
175
185
|
.reduce<{ [id: string]: ExcellentHomework[] }>((acc, cur) => {
|
|
176
186
|
if (!acc[cur.baseId]) {
|
|
@@ -181,7 +191,7 @@ async function getExcellentHomeworkListByHomework(
|
|
|
181
191
|
}, {});
|
|
182
192
|
}
|
|
183
193
|
|
|
184
|
-
async function parseHomeworkAtUrl(s: Session, url: string): Promise<
|
|
194
|
+
async function parseHomeworkAtUrl(s: Session, url: string): Promise<HomeworkDetail> {
|
|
185
195
|
const result = $(await s.fetchTextWithToken(url));
|
|
186
196
|
|
|
187
197
|
const fileDivs = result('div.list.fujian.clearfix');
|
|
@@ -203,43 +213,38 @@ async function parseHomeworkAtUrl(s: Session, url: string): Promise<IHomeworkDet
|
|
|
203
213
|
*/
|
|
204
214
|
export async function getHomeworkDetail(
|
|
205
215
|
s: Session,
|
|
206
|
-
homework: Pick<
|
|
207
|
-
): Promise<
|
|
216
|
+
homework: Pick<HomeworkBase | ExcellentHomeworkBase, 'url' | 'baseId'>,
|
|
217
|
+
): Promise<HomeworkDetail> {
|
|
208
218
|
const [page, description] = await Promise.all([
|
|
209
219
|
parseHomeworkAtUrl(s, homework.url),
|
|
210
220
|
getHomeworkDescription(s, homework.baseId),
|
|
211
221
|
]);
|
|
212
|
-
|
|
222
|
+
// the description JSON wins, but an empty one falls back to the page
|
|
223
|
+
return { ...page, description: description.description ?? page.description };
|
|
213
224
|
}
|
|
214
225
|
|
|
215
|
-
async function getHomeworkDescription(s: Session, baseId: string): Promise<
|
|
226
|
+
async function getHomeworkDescription(s: Session, baseId: string): Promise<HomeworkDetail> {
|
|
216
227
|
const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_DETAIL, {
|
|
217
228
|
method: 'POST',
|
|
218
229
|
body: URLS.LEARN_HOMEWORK_DETAIL_FORM_DATA(baseId),
|
|
219
230
|
});
|
|
220
|
-
|
|
221
|
-
throw {
|
|
222
|
-
reason: FailReason.INVALID_RESPONSE,
|
|
223
|
-
extra: json,
|
|
224
|
-
} as ApiError;
|
|
225
|
-
}
|
|
231
|
+
expectSuccess(json, FailReason.INVALID_RESPONSE);
|
|
226
232
|
return {
|
|
227
233
|
description: trimAndDefine(json.msg),
|
|
228
234
|
};
|
|
229
235
|
}
|
|
230
236
|
|
|
231
|
-
|
|
237
|
+
/** the attachment in one `div.list.fujian` block; `undefined` if the block is missing or has no link */
|
|
238
|
+
function parseHomeworkFile(s: Session, fileDiv: Element | undefined): RemoteFile | undefined {
|
|
239
|
+
if (!fileDiv) return undefined;
|
|
232
240
|
const fileNode = $(fileDiv)('.ftitle').children('a')[0] ?? $(fileDiv)('.fl').children('a')[0];
|
|
233
241
|
const href = fileNode?.attribs.href;
|
|
234
242
|
if (fileNode !== undefined && href) {
|
|
235
243
|
const size = trimAndDefine($(fileDiv)('.fl > span[class^="color"]').first().text()) ?? '';
|
|
236
244
|
const params = new URLSearchParams(href.split('?').slice(-1)[0]);
|
|
237
245
|
const attachmentId = params.get('fileId')!;
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
if (params.has('downloadUrl')) {
|
|
241
|
-
downloadUrl = URLS.LEARN_PREFIX + params.get('downloadUrl')!;
|
|
242
|
-
}
|
|
246
|
+
// a link to the file viewer carries the download path in its `downloadUrl` parameter
|
|
247
|
+
const downloadUrl = URLS.LEARN_PREFIX + (params.get('downloadUrl') ?? href);
|
|
243
248
|
return {
|
|
244
249
|
id: attachmentId,
|
|
245
250
|
name: (fileNode.children[0] as { data?: string } | undefined)?.data ?? '',
|
|
@@ -256,19 +261,96 @@ export async function submitHomework(
|
|
|
256
261
|
s: Session,
|
|
257
262
|
id: string,
|
|
258
263
|
content = '',
|
|
259
|
-
attachment?:
|
|
264
|
+
attachment?: HomeworkSubmitAttachment,
|
|
260
265
|
removeAttachment = false,
|
|
261
266
|
): Promise<void> {
|
|
262
|
-
const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_SUBMIT
|
|
267
|
+
const json = await s.fetchJsonWithToken(URLS.LEARN_HOMEWORK_SUBMIT, {
|
|
263
268
|
method: 'POST',
|
|
264
|
-
body:
|
|
269
|
+
body: homeworkSubmitForm(id, content, attachment, removeAttachment),
|
|
265
270
|
// the response only starts after the whole upload, which can take longer than `requestTimeout`
|
|
266
271
|
timeout: attachment ? 0 : undefined,
|
|
267
272
|
});
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
+
checkHomeworkSubmitResponse(json);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* The request `submitHomework` would send, for sending it with another HTTP client (e.g. one that reports upload
|
|
278
|
+
* progress). Checks the session first (`ensureSession`: one request, and a login only if it has expired), as the
|
|
279
|
+
* request is sent without the helper's re-login and retry. Read the answer with `checkHomeworkSubmitResponse`.
|
|
280
|
+
*/
|
|
281
|
+
export async function getHomeworkSubmitRequest(
|
|
282
|
+
s: Session,
|
|
283
|
+
id: string,
|
|
284
|
+
content = '',
|
|
285
|
+
attachment?: HomeworkSubmitAttachment,
|
|
286
|
+
removeAttachment = false,
|
|
287
|
+
): Promise<HomeworkSubmitRequest> {
|
|
288
|
+
await s.ensureSession(URLS.LEARN_CURRENT_SEMESTER);
|
|
289
|
+
return {
|
|
290
|
+
url: addCSRFTokenToUrl(URLS.LEARN_HOMEWORK_SUBMIT, s.csrfToken),
|
|
291
|
+
body: homeworkSubmitForm(id, content, attachment, removeAttachment),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Check the server's answer to a homework submission: its parsed JSON, or the body as text. Rejects with
|
|
297
|
+
* `NOT_LOGGED_IN` for the login page of an expired session (which the server sends with status 200),
|
|
298
|
+
* `INVALID_RESPONSE` for any other text that isn't JSON, and `OPERATION_FAILED` (with the answer in `extra`) unless
|
|
299
|
+
* `result` is `'success'`.
|
|
300
|
+
*/
|
|
301
|
+
export function checkHomeworkSubmitResponse(body: unknown): void {
|
|
302
|
+
let json = body;
|
|
303
|
+
if (typeof body === 'string') {
|
|
304
|
+
try {
|
|
305
|
+
json = JSON.parse(body);
|
|
306
|
+
} catch {
|
|
307
|
+
throw apiError(
|
|
308
|
+
looksLikeLoginPage(body) ? FailReason.NOT_LOGGED_IN : FailReason.INVALID_RESPONSE,
|
|
309
|
+
body.slice(0, 200),
|
|
310
|
+
);
|
|
311
|
+
}
|
|
273
312
|
}
|
|
313
|
+
expectSuccess(json, FailReason.OPERATION_FAILED);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* The form the website's submit page sends (`fileUploadProgress` in `/res/js/common/myApp.js`): `fileupload` is the
|
|
318
|
+
* picked file, or the text "undefined" when none is picked (it appends `files[0]` as is); `zynr` is the whole current
|
|
319
|
+
* answer as HTML from its editor, sent every time (also when only the attachment changes); `isDeleted` is "1" when the
|
|
320
|
+
* existing attachment is deleted, and then the server ignores `zynr` (the submitted answer stays).
|
|
321
|
+
*/
|
|
322
|
+
export const homeworkSubmitForm = (
|
|
323
|
+
id: string,
|
|
324
|
+
content = '',
|
|
325
|
+
attachment?: HomeworkSubmitAttachment,
|
|
326
|
+
removeAttachment = false,
|
|
327
|
+
) => {
|
|
328
|
+
const form = new FormData();
|
|
329
|
+
form.append('xszyid', id);
|
|
330
|
+
form.append('zynr', content ?? '');
|
|
331
|
+
if (!attachment) form.append('fileupload', 'undefined');
|
|
332
|
+
else if (attachment.content instanceof Blob) form.append('fileupload', attachment.content, attachment.filename);
|
|
333
|
+
else {
|
|
334
|
+
// React Native's FormData reads a file from an object with its uri, name and type
|
|
335
|
+
const { uri, type } = attachment.content;
|
|
336
|
+
form.append('fileupload', { uri, name: attachment.filename, type: type || 'application/octet-stream' } as any);
|
|
337
|
+
}
|
|
338
|
+
if (removeAttachment) form.append('isDeleted', '1');
|
|
339
|
+
else form.append('isDeleted', '0');
|
|
340
|
+
return form;
|
|
341
|
+
};
|
|
342
|
+
|
|
343
|
+
const HTML_ESCAPES: Record<string, string> = { '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' };
|
|
344
|
+
|
|
345
|
+
/**
|
|
346
|
+
* Plain text as the website's answer editor sends it: one `<p>` per line, with ` ` for an empty line, and `''`
|
|
347
|
+
* for text that is only whitespace. The server replaces the submitted answer with what is sent, so send its HTML
|
|
348
|
+
* as it is when the answer wasn't edited, to keep its formatting.
|
|
349
|
+
*/
|
|
350
|
+
export function homeworkContentHtml(text: string): string {
|
|
351
|
+
if (!text.trim()) return '';
|
|
352
|
+
return text
|
|
353
|
+
.split(/\r?\n/)
|
|
354
|
+
.map((line) => `<p>${line.trim() ? line.replace(/[&<>"']/g, (c) => HTML_ESCAPES[c]) : ' '}</p>`)
|
|
355
|
+
.join('');
|
|
274
356
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,40 +11,45 @@ import * as questionnaires from './questionnaires.ts';
|
|
|
11
11
|
import { Session } from './request.ts';
|
|
12
12
|
import type {
|
|
13
13
|
AllContentsOptions,
|
|
14
|
+
Credential,
|
|
14
15
|
AllContentsResult,
|
|
15
16
|
CalendarOptions,
|
|
16
17
|
CalendarResult,
|
|
17
|
-
CourseListOptions,
|
|
18
|
-
ExcellentHomework,
|
|
19
|
-
HomeworkListOptions,
|
|
20
|
-
IHomeworkDetail,
|
|
21
|
-
NotificationListOptions,
|
|
22
|
-
RemoteFile,
|
|
23
18
|
CommentItem,
|
|
19
|
+
ContentType,
|
|
20
|
+
CourseFile,
|
|
24
21
|
CourseInfo,
|
|
22
|
+
CourseListOptions,
|
|
23
|
+
CourseNotification,
|
|
25
24
|
Discussion,
|
|
25
|
+
ExcellentHomework,
|
|
26
26
|
FavoriteItem,
|
|
27
|
-
File,
|
|
28
27
|
FileCategory,
|
|
29
28
|
HelperConfig,
|
|
30
29
|
Homework,
|
|
30
|
+
HomeworkDetail,
|
|
31
|
+
HomeworkListOptions,
|
|
32
|
+
HomeworkSubmitAttachment,
|
|
33
|
+
HomeworkSubmitRequest,
|
|
31
34
|
HomeworkTA,
|
|
32
|
-
IHomeworkSubmitAttachment,
|
|
33
35
|
Language,
|
|
34
|
-
|
|
36
|
+
NotificationListOptions,
|
|
35
37
|
Question,
|
|
36
38
|
Questionnaire,
|
|
37
39
|
QuestionnaireDetail,
|
|
38
40
|
QuestionnaireListOptions,
|
|
41
|
+
RemoteFile,
|
|
39
42
|
SemesterInfo,
|
|
40
43
|
UserInfo,
|
|
41
|
-
ContentType,
|
|
42
44
|
} from './types.ts';
|
|
43
|
-
import { CourseType } from './types.ts';
|
|
45
|
+
import { CourseType, FailReason } from './types.ts';
|
|
46
|
+
import { apiError } from './utils.ts';
|
|
44
47
|
import * as URLS from './urls.ts';
|
|
45
48
|
import * as user from './user.ts';
|
|
46
49
|
|
|
47
|
-
export { addCSRFTokenToUrl } from './request.ts';
|
|
50
|
+
export { addCSRFTokenToUrl, isConnectionError } from './request.ts';
|
|
51
|
+
export { checkHomeworkSubmitResponse, homeworkContentHtml } from './homework.ts';
|
|
52
|
+
export { CLASS_PERIOD_TIMES, parseCourseMeeting, parseWeeks, semesterWeek } from './timetable.ts';
|
|
48
53
|
export * from './types.ts';
|
|
49
54
|
|
|
50
55
|
/** the main helper class */
|
|
@@ -53,7 +58,7 @@ export class Learn2018Helper {
|
|
|
53
58
|
|
|
54
59
|
/** you can provide a CookieJar and / or CredentialProvider in the configuration */
|
|
55
60
|
constructor(config?: HelperConfig) {
|
|
56
|
-
this.#session = new Session(config, () => auth.
|
|
61
|
+
this.#session = new Session(config, () => auth.loginWithProvider(this.#session));
|
|
57
62
|
}
|
|
58
63
|
|
|
59
64
|
/** whether preview URLs open the first page only (`HelperConfig.generatePreviewUrlForFirstPage`) */
|
|
@@ -76,35 +81,25 @@ export class Learn2018Helper {
|
|
|
76
81
|
}
|
|
77
82
|
|
|
78
83
|
/**
|
|
79
|
-
*
|
|
80
|
-
*
|
|
84
|
+
* Sign in to id.tsinghua.edu.cn and return the Web Learning roaming ticket, without logging in to Web Learning.
|
|
85
|
+
* Rejects with `NO_CREDENTIAL` unless `credential` has a username, a password and a fingerprint. If using
|
|
86
|
+
* alternative cookie management systems, be sure to clear id.tsinghua.edu.cn cookies before calling this function.
|
|
81
87
|
*/
|
|
82
|
-
public async getRoamingTicket(
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
fingerPrint: string,
|
|
86
|
-
fingerGenPrint: string = '',
|
|
87
|
-
fingerGenPrint3: string = '',
|
|
88
|
-
): Promise<string> {
|
|
89
|
-
return auth.getRoamingTicket(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
|
|
88
|
+
public async getRoamingTicket(credential: Credential): Promise<string> {
|
|
89
|
+
if (!auth.isComplete(credential)) throw apiError(FailReason.NO_CREDENTIAL);
|
|
90
|
+
return auth.getRoamingTicket(this.#session, credential);
|
|
90
91
|
}
|
|
91
92
|
|
|
92
93
|
/**
|
|
93
94
|
* Login is necessary if you do not provide a `CredentialProvider`.
|
|
94
|
-
* Without
|
|
95
|
-
*
|
|
95
|
+
* Without a credential it uses the provider, and concurrent calls share one SSO login; a credential without a
|
|
96
|
+
* username, a password and a fingerprint rejects with `NO_CREDENTIAL`. Logins never overlap: one with a credential
|
|
97
|
+
* waits for a login in progress, and requests that find the session expired meanwhile wait for it.
|
|
96
98
|
*/
|
|
97
|
-
public async login(
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
fingerGenPrint?: string,
|
|
102
|
-
fingerGenPrint3?: string,
|
|
103
|
-
): Promise<void> {
|
|
104
|
-
if (!username || !password || !fingerPrint) return this.#session.relogin();
|
|
105
|
-
return this.#session.runLogin(() =>
|
|
106
|
-
auth.login(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3),
|
|
107
|
-
);
|
|
99
|
+
public async login(credential?: Credential): Promise<void> {
|
|
100
|
+
if (credential === undefined) return this.#session.relogin();
|
|
101
|
+
if (!auth.isComplete(credential)) throw apiError(FailReason.NO_CREDENTIAL);
|
|
102
|
+
return this.#session.runLogin(() => auth.login(this.#session, credential));
|
|
108
103
|
}
|
|
109
104
|
|
|
110
105
|
/**
|
|
@@ -113,7 +108,7 @@ export class Learn2018Helper {
|
|
|
113
108
|
* Use it when resuming an app instead of `login()`.
|
|
114
109
|
*/
|
|
115
110
|
public async ensureSession(): Promise<void> {
|
|
116
|
-
return this.#session.ensureSession(URLS.LEARN_CURRENT_SEMESTER
|
|
111
|
+
return this.#session.ensureSession(URLS.LEARN_CURRENT_SEMESTER);
|
|
117
112
|
}
|
|
118
113
|
|
|
119
114
|
/** logout (to make everyone happy) */
|
|
@@ -128,9 +123,11 @@ export class Learn2018Helper {
|
|
|
128
123
|
|
|
129
124
|
/**
|
|
130
125
|
* Get calendar items during the specified period (in yyyymmdd format, both inclusive).
|
|
131
|
-
* Ranges longer than 28 days are fetched in windows and merged; windows that fail
|
|
132
|
-
*
|
|
133
|
-
* `
|
|
126
|
+
* Ranges longer than 28 days are fetched in windows and merged; windows that still fail are listed in `failedRanges`
|
|
127
|
+
* (a timed-out window is not retried). Rejects when every window fails (usually with `FailReason.INVALID_RESPONSE`),
|
|
128
|
+
* with `FailReason.ERROR_REGISTRAR_AUTH` when signing in to the registrar fails, or with a `RangeError` for a date
|
|
129
|
+
* that is not a valid yyyymmdd. With a `CredentialProvider`, a sign-in that id.tsinghua.edu.cn turns down (its session
|
|
130
|
+
* from the last login expired) logs in again and is tried once more.
|
|
134
131
|
*/
|
|
135
132
|
public async getCalendar(startDate: string, endDate: string, options?: CalendarOptions): Promise<CalendarResult> {
|
|
136
133
|
return calendar.getCalendar(this.#session, startDate, endDate, options);
|
|
@@ -146,7 +143,8 @@ export class Learn2018Helper {
|
|
|
146
143
|
|
|
147
144
|
/**
|
|
148
145
|
* Get all courses in the specified semester.
|
|
149
|
-
* `timeAndLocation` is only fetched (one request per course) with `options.timeAndLocation
|
|
146
|
+
* `timeAndLocation` is only fetched (one request per course) with `options.timeAndLocation`, and left out for a
|
|
147
|
+
* course whose request failed.
|
|
150
148
|
*/
|
|
151
149
|
public async getCourseList(
|
|
152
150
|
semesterID: string,
|
|
@@ -184,20 +182,20 @@ export class Learn2018Helper {
|
|
|
184
182
|
courseID: string,
|
|
185
183
|
courseType: CourseType = CourseType.STUDENT,
|
|
186
184
|
options?: NotificationListOptions,
|
|
187
|
-
): Promise<
|
|
185
|
+
): Promise<CourseNotification[]> {
|
|
188
186
|
return notices.getNotificationList(this.#session, courseID, courseType, options);
|
|
189
187
|
}
|
|
190
188
|
|
|
191
189
|
/** Get the attachment of a notice (one request), `undefined` if it has none. */
|
|
192
190
|
public async getNotificationAttachment(
|
|
193
|
-
notification: Pick<
|
|
191
|
+
notification: Pick<CourseNotification, 'url' | 'attachmentName'>,
|
|
194
192
|
courseType: CourseType = CourseType.STUDENT,
|
|
195
193
|
): Promise<RemoteFile | undefined> {
|
|
196
194
|
return notices.getNotificationAttachment(this.#session, notification, courseType);
|
|
197
195
|
}
|
|
198
196
|
|
|
199
197
|
/** Get all files (课程文件) of the specified course. */
|
|
200
|
-
public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<
|
|
198
|
+
public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<CourseFile[]> {
|
|
201
199
|
return files.getFileList(this.#session, courseID, courseType);
|
|
202
200
|
}
|
|
203
201
|
|
|
@@ -217,7 +215,7 @@ export class Learn2018Helper {
|
|
|
217
215
|
courseID: string,
|
|
218
216
|
categoryId: string,
|
|
219
217
|
courseType: CourseType = CourseType.STUDENT,
|
|
220
|
-
): Promise<
|
|
218
|
+
): Promise<CourseFile[]> {
|
|
221
219
|
return files.getFileListByCategory(this.#session, courseID, categoryId, courseType);
|
|
222
220
|
}
|
|
223
221
|
|
|
@@ -243,12 +241,12 @@ export class Learn2018Helper {
|
|
|
243
241
|
}
|
|
244
242
|
|
|
245
243
|
/** Get the detail (page content, attachments and description) of one homework. */
|
|
246
|
-
public async getHomeworkDetail(item: Pick<Homework, 'url' | 'baseId'>): Promise<
|
|
244
|
+
public async getHomeworkDetail(item: Pick<Homework, 'url' | 'baseId'>): Promise<HomeworkDetail> {
|
|
247
245
|
return homework.getHomeworkDetail(this.#session, item);
|
|
248
246
|
}
|
|
249
247
|
|
|
250
248
|
/** Get the detail (page content, attachments and description) of one excellent homework. */
|
|
251
|
-
public async getExcellentHomeworkDetail(item: Pick<ExcellentHomework, 'url' | 'baseId'>): Promise<
|
|
249
|
+
public async getExcellentHomeworkDetail(item: Pick<ExcellentHomework, 'url' | 'baseId'>): Promise<HomeworkDetail> {
|
|
252
250
|
return homework.getHomeworkDetail(this.#session, item);
|
|
253
251
|
}
|
|
254
252
|
|
|
@@ -338,15 +336,36 @@ export class Learn2018Helper {
|
|
|
338
336
|
return courses.sortCourses(this.#session, courseIDs);
|
|
339
337
|
}
|
|
340
338
|
|
|
339
|
+
/**
|
|
340
|
+
* Submit homework `id` (the `studentHomeworkId`), the way the website's submit page does. `content` is the whole
|
|
341
|
+
* answer as HTML (the website sends its editor's HTML) and replaces the submitted one, so pass the current answer
|
|
342
|
+
* when only changing the attachment (`homeworkContentHtml` turns plain text into the editor's HTML). With no
|
|
343
|
+
* `attachment` the existing one is kept, unless `removeAttachment`: that only deletes the attachment, and `content` is
|
|
344
|
+
* ignored. In React Native, pass the attachment's `content` as `{ uri, type }`.
|
|
345
|
+
*/
|
|
341
346
|
public async submitHomework(
|
|
342
347
|
id: string,
|
|
343
348
|
content = '',
|
|
344
|
-
attachment?:
|
|
349
|
+
attachment?: HomeworkSubmitAttachment,
|
|
345
350
|
removeAttachment = false,
|
|
346
351
|
): Promise<void> {
|
|
347
352
|
return homework.submitHomework(this.#session, id, content, attachment, removeAttachment);
|
|
348
353
|
}
|
|
349
354
|
|
|
355
|
+
/**
|
|
356
|
+
* The request `submitHomework` would send, for sending with another HTTP client (e.g. to show upload progress, which
|
|
357
|
+
* `fetch` can't): POST `body` to `url`, then pass the answer (parsed JSON or text) to `checkHomeworkSubmitResponse`.
|
|
358
|
+
* It checks the session first (`ensureSession`), since that request doesn't log in again and retry.
|
|
359
|
+
*/
|
|
360
|
+
public async getHomeworkSubmitRequest(
|
|
361
|
+
id: string,
|
|
362
|
+
content = '',
|
|
363
|
+
attachment?: HomeworkSubmitAttachment,
|
|
364
|
+
removeAttachment = false,
|
|
365
|
+
): Promise<HomeworkSubmitRequest> {
|
|
366
|
+
return homework.getHomeworkSubmitRequest(this.#session, id, content, attachment, removeAttachment);
|
|
367
|
+
}
|
|
368
|
+
|
|
350
369
|
public async setLanguage(lang: Language): Promise<void> {
|
|
351
370
|
return user.setLanguage(this.#session, lang);
|
|
352
371
|
}
|