@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/index.ts ADDED
@@ -0,0 +1,282 @@
1
+ import * as auth from './auth.ts';
2
+ import * as calendar from './calendar.ts';
3
+ import * as contents from './contents.ts';
4
+ import * as courses from './courses.ts';
5
+ import * as discussions from './discussions.ts';
6
+ import * as favorites from './favorites.ts';
7
+ import * as files from './files.ts';
8
+ import * as homework from './homework.ts';
9
+ import * as notices from './notices.ts';
10
+ import * as questionnaires from './questionnaires.ts';
11
+ import { Session } from './request.ts';
12
+ import type {
13
+ CalendarOptions,
14
+ CalendarResult,
15
+ CommentItem,
16
+ CourseInfo,
17
+ Discussion,
18
+ FavoriteItem,
19
+ File,
20
+ FileCategory,
21
+ HelperConfig,
22
+ Homework,
23
+ HomeworkTA,
24
+ IHomeworkSubmitAttachment,
25
+ Language,
26
+ Notification,
27
+ Question,
28
+ Questionnaire,
29
+ SemesterInfo,
30
+ UserInfo,
31
+ ContentType,
32
+ } from './types.ts';
33
+ import { CourseType } from './types.ts';
34
+ import * as user from './user.ts';
35
+
36
+ export { addCSRFTokenToUrl } from './request.ts';
37
+ export * from './types.ts';
38
+
39
+ /** the main helper class */
40
+ export class Learn2018Helper {
41
+ readonly #session: Session;
42
+
43
+ /** you can provide a CookieJar and / or CredentialProvider in the configuration */
44
+ constructor(config?: HelperConfig) {
45
+ this.#session = new Session(config, () => this.login());
46
+ }
47
+
48
+ /** whether preview URLs open the first page only (`HelperConfig.generatePreviewUrlForFirstPage`) */
49
+ public get previewFirstPage(): boolean {
50
+ return this.#session.previewFirstPage;
51
+ }
52
+
53
+ public set previewFirstPage(value: boolean) {
54
+ this.#session.previewFirstPage = value;
55
+ }
56
+
57
+ /** fetch CSRF token from helper (invalid after login / re-login), might be '' if not logged in */
58
+ public getCSRFToken(): string {
59
+ return this.#session.csrfToken;
60
+ }
61
+
62
+ /** manually set CSRF token (useful when you want to reuse previous token) */
63
+ public setCSRFToken(csrfToken: string): void {
64
+ this.#session.csrfToken = csrfToken;
65
+ }
66
+
67
+ /**
68
+ * If using alternative cookie management systems,
69
+ * be sure to clear id.tsinghua.edu.cn cookies before calling this function
70
+ */
71
+ public async getRoamingTicket(
72
+ username: string,
73
+ password: string,
74
+ fingerPrint: string,
75
+ fingerGenPrint: string = '',
76
+ fingerGenPrint3: string = '',
77
+ ): Promise<string> {
78
+ return auth.getRoamingTicket(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
79
+ }
80
+
81
+ /** login is necessary if you do not provide a `CredentialProvider` */
82
+ public async login(
83
+ username?: string,
84
+ password?: string,
85
+ fingerPrint?: string,
86
+ fingerGenPrint?: string,
87
+ fingerGenPrint3?: string,
88
+ ): Promise<void> {
89
+ return auth.login(this.#session, username, password, fingerPrint, fingerGenPrint, fingerGenPrint3);
90
+ }
91
+
92
+ /** logout (to make everyone happy) */
93
+ public async logout(): Promise<void> {
94
+ return auth.logout(this.#session);
95
+ }
96
+
97
+ /** get user's name and department */
98
+ public async getUserInfo(courseType = CourseType.STUDENT): Promise<UserInfo> {
99
+ return user.getUserInfo(this.#session, courseType);
100
+ }
101
+
102
+ /**
103
+ * Get calendar items during the specified period (in yyyymmdd format, both inclusive).
104
+ * Ranges longer than 28 days are fetched in windows and merged; windows that fail even after a retry are listed in
105
+ * `failedRanges`. Rejects only when every window fails (usually with `FailReason.INVALID_RESPONSE`).
106
+ */
107
+ public async getCalendar(startDate: string, endDate: string, options?: CalendarOptions): Promise<CalendarResult> {
108
+ return calendar.getCalendar(this.#session, startDate, endDate, options);
109
+ }
110
+
111
+ public async getSemesterIdList(): Promise<string[]> {
112
+ return courses.getSemesterIdList(this.#session);
113
+ }
114
+
115
+ public async getCurrentSemester(): Promise<SemesterInfo> {
116
+ return courses.getCurrentSemester(this.#session);
117
+ }
118
+
119
+ /** get all courses in the specified semester */
120
+ public async getCourseList(
121
+ semesterID: string,
122
+ courseType: CourseType = CourseType.STUDENT,
123
+ lang: Language = this.#session.lang,
124
+ ): Promise<CourseInfo[]> {
125
+ return courses.getCourseList(this.#session, semesterID, courseType, lang);
126
+ }
127
+
128
+ /**
129
+ * Get certain type of content of all specified courses.
130
+ * It actually wraps around other `getXXX` functions. You can ignore the failure caused by certain courses.
131
+ */
132
+ public async getAllContents<T extends ContentType>(
133
+ courseIDs: string[],
134
+ type: T,
135
+ courseType: CourseType = CourseType.STUDENT,
136
+ allowFailure = false,
137
+ ) {
138
+ return contents.getAllContents(this.#session, courseIDs, type, courseType, allowFailure);
139
+ }
140
+
141
+ /** Get all notifications (课程公告) of the specified course. */
142
+ public async getNotificationList(
143
+ courseID: string,
144
+ courseType: CourseType = CourseType.STUDENT,
145
+ ): Promise<Notification[]> {
146
+ return notices.getNotificationList(this.#session, courseID, courseType);
147
+ }
148
+
149
+ /** Get all files (课程文件) of the specified course. */
150
+ public async getFileList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<File[]> {
151
+ return files.getFileList(this.#session, courseID, courseType);
152
+ }
153
+
154
+ /** Get file categories of the specified course. */
155
+ public async getFileCategoryList(
156
+ courseID: string,
157
+ courseType: CourseType = CourseType.STUDENT,
158
+ ): Promise<FileCategory[]> {
159
+ return files.getFileCategoryList(this.#session, courseID, courseType);
160
+ }
161
+
162
+ /**
163
+ * Get all files of the specified category of the specified course.
164
+ * Note: this cannot get correct `visitCount` and `downloadCount` for student
165
+ */
166
+ public async getFileListByCategory(
167
+ courseID: string,
168
+ categoryId: string,
169
+ courseType: CourseType = CourseType.STUDENT,
170
+ ): Promise<File[]> {
171
+ return files.getFileListByCategory(this.#session, courseID, categoryId, courseType);
172
+ }
173
+
174
+ /** Get all homeworks (课程作业) of the specified course. */
175
+ public async getHomeworkList(courseID: string): Promise<Homework[]>;
176
+ public async getHomeworkList(courseID: string, courseType: CourseType.STUDENT): Promise<Homework[]>;
177
+ public async getHomeworkList(courseID: string, courseType: CourseType.TEACHER): Promise<HomeworkTA[]>;
178
+ public async getHomeworkList(
179
+ courseID: string,
180
+ courseType: CourseType = CourseType.STUDENT,
181
+ ): Promise<Homework[] | HomeworkTA[]> {
182
+ return courseType === CourseType.TEACHER
183
+ ? homework.getHomeworkList(this.#session, courseID, CourseType.TEACHER)
184
+ : homework.getHomeworkList(this.#session, courseID, CourseType.STUDENT);
185
+ }
186
+
187
+ /** Get all discussions (课程讨论) of the specified course. */
188
+ public async getDiscussionList(courseID: string, courseType: CourseType = CourseType.STUDENT): Promise<Discussion[]> {
189
+ return discussions.getDiscussionList(this.#session, courseID, courseType);
190
+ }
191
+
192
+ /**
193
+ * Get all notifications (课程答疑) of the specified course.
194
+ * The student version supports only answered questions, while the teacher version supports all questions.
195
+ */
196
+ public async getAnsweredQuestionList(
197
+ courseID: string,
198
+ courseType: CourseType = CourseType.STUDENT,
199
+ ): Promise<Question[]> {
200
+ return discussions.getAnsweredQuestionList(this.#session, courseID, courseType);
201
+ }
202
+
203
+ /**
204
+ * Get all questionnaires (课程问卷/QNR) of the specified course.
205
+ */
206
+ public async getQuestionnaireList(courseID: string): Promise<Questionnaire[]> {
207
+ return questionnaires.getQuestionnaireList(this.#session, courseID);
208
+ }
209
+
210
+ /**
211
+ * Add an item to favorites. (收藏)
212
+ */
213
+ public async addToFavorites(type: ContentType, id: string): Promise<void> {
214
+ return favorites.addToFavorites(this.#session, type, id);
215
+ }
216
+
217
+ /**
218
+ * Remove an item from favorites. (取消收藏)
219
+ */
220
+ public async removeFromFavorites(id: string): Promise<void> {
221
+ return favorites.removeFromFavorites(this.#session, id);
222
+ }
223
+
224
+ /**
225
+ * Get favorites. (我的收藏)
226
+ * If `courseID` or `type` is specified, only return favorites of that course or type.
227
+ */
228
+ public async getFavorites(courseID?: string, type?: ContentType): Promise<FavoriteItem[]> {
229
+ return favorites.getFavorites(this.#session, courseID, type);
230
+ }
231
+
232
+ /**
233
+ * Pin a favorite item. (置顶)
234
+ */
235
+ public async pinFavoriteItem(id: string): Promise<void> {
236
+ return favorites.pinFavoriteItem(this.#session, id);
237
+ }
238
+
239
+ /**
240
+ * Unpin a favorite item. (取消置顶)
241
+ */
242
+ public async unpinFavoriteItem(id: string): Promise<void> {
243
+ return favorites.unpinFavoriteItem(this.#session, id);
244
+ }
245
+
246
+ /**
247
+ * Set a comment. (备注)
248
+ * Set an empty string to remove the comment.
249
+ */
250
+ public async setComment(type: ContentType, id: string, content: string): Promise<void> {
251
+ return favorites.setComment(this.#session, type, id, content);
252
+ }
253
+
254
+ /**
255
+ * Get comments. (我的备注)
256
+ * If `courseID` or `type` is specified, only return favorites of that course or type.
257
+ */
258
+ public async getComments(courseID?: string, type?: ContentType): Promise<CommentItem[]> {
259
+ return favorites.getComments(this.#session, courseID, type);
260
+ }
261
+
262
+ public async sortCourses(courseIDs: string[]): Promise<void> {
263
+ return courses.sortCourses(this.#session, courseIDs);
264
+ }
265
+
266
+ public async submitHomework(
267
+ id: string,
268
+ content = '',
269
+ attachment?: IHomeworkSubmitAttachment,
270
+ removeAttachment = false,
271
+ ): Promise<void> {
272
+ return homework.submitHomework(this.#session, id, content, attachment, removeAttachment);
273
+ }
274
+
275
+ public async setLanguage(lang: Language): Promise<void> {
276
+ return user.setLanguage(this.#session, lang);
277
+ }
278
+
279
+ public getCurrentLanguage(): Language {
280
+ return this.#session.lang;
281
+ }
282
+ }
package/src/notices.ts ADDED
@@ -0,0 +1,110 @@
1
+ // Course notifications (课程公告).
2
+
3
+ import { Base64 } from 'js-base64';
4
+
5
+ import type { Session } from './request.ts';
6
+ import { $, YES } from './request.ts';
7
+ import type { INotification, INotificationDetail, Notification } from './types.ts';
8
+ import { ContentType, CourseType, FailReason } from './types.ts';
9
+ import * as URLS from './urls.ts';
10
+ import { decodeHTML, trimAndDefine } from './utils.ts';
11
+
12
+ /** Get all notifications (课程公告) of the specified course. */
13
+ export async function getNotificationList(
14
+ s: Session,
15
+ courseID: string,
16
+ courseType: CourseType = CourseType.STUDENT,
17
+ ): Promise<Notification[]> {
18
+ return Promise.all([
19
+ getNotificationListKind(s, courseID, courseType, false),
20
+ getNotificationListKind(s, courseID, courseType, true),
21
+ ]).then((r) => r.flat());
22
+ }
23
+
24
+ async function getNotificationListKind(
25
+ s: Session,
26
+ courseID: string,
27
+ courseType: CourseType,
28
+ expired: boolean,
29
+ ): Promise<Notification[]> {
30
+ const json = await (
31
+ await s.fetchWithToken(URLS.LEARN_NOTIFICATION_LIST(courseType, expired), {
32
+ method: 'POST',
33
+ body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID),
34
+ })
35
+ ).json();
36
+ if (json.result !== 'success') {
37
+ return Promise.reject({
38
+ reason: FailReason.INVALID_RESPONSE,
39
+ extra: json,
40
+ });
41
+ }
42
+
43
+ const result = (json.object?.aaData ?? json.object?.resultsList ?? []) as any[];
44
+
45
+ return Promise.all(
46
+ result.map(async (n) => {
47
+ const notification: INotification = {
48
+ id: n.ggid,
49
+ content: Base64.decode(n.ggnr ?? ''),
50
+ title: decodeHTML(n.bt),
51
+ url: URLS.LEARN_NOTIFICATION_DETAIL(courseID, n.ggid, courseType),
52
+ publisher: n.fbrxm,
53
+ hasRead: n.sfyd === YES,
54
+ markedImportant: Number(n.sfqd) === 1, // n.sfqd could be string '1' (teacher mode) or number 1 (student mode)
55
+ publishTime: n.fbsj && typeof n.fbsj === 'string' ? n.fbsj : n.fbsjStr,
56
+ expireTime: n.jzsj ?? undefined,
57
+ isFavorite: n.sfsc === YES,
58
+ comment: n.bznr ?? undefined,
59
+ };
60
+ let detail: INotificationDetail = {};
61
+ const attachmentName = courseType === CourseType.STUDENT ? n.fjmc : n.fjbt;
62
+ if (attachmentName) {
63
+ detail = await parseNotificationDetail(s, courseID, notification.id, courseType, attachmentName);
64
+ }
65
+ return { ...notification, ...detail } satisfies Notification;
66
+ }),
67
+ );
68
+ }
69
+
70
+ async function parseNotificationDetail(
71
+ s: Session,
72
+ courseID: string,
73
+ id: string,
74
+ courseType: CourseType,
75
+ attachmentName: string,
76
+ ): Promise<INotificationDetail> {
77
+ /// from JSON (backup, currently not used)
78
+ // const postParams = new FormData();
79
+ // postParams.append('id', id);
80
+ // postParams.append('wlkcid', courseID);
81
+ // const metadata = await (await s.fetchWithToken(URL.LEARN_NOTIFICATION_EDIT(courseType), {
82
+ // 'method': 'POST',
83
+ // 'body': postParams,
84
+ // })).json();
85
+ // const attachmentId = metadata.ggfjid as string;
86
+ /// parsed from HTML
87
+ const response = await s.fetchWithToken(URLS.LEARN_NOTIFICATION_DETAIL(courseID, id, courseType));
88
+ const result = $(await response.text());
89
+ let path = '';
90
+ if (courseType === CourseType.STUDENT) {
91
+ path = result('.ml-10').attr('href')!;
92
+ } else {
93
+ path = result('#wjid').attr('href')!;
94
+ }
95
+ const size = trimAndDefine(result('div#attachment > div.fl > span[class^="color"]').first().text())!;
96
+ const params = new URLSearchParams(path.split('?').slice(-1)[0]);
97
+ const attachmentId = params.get('wjid')!;
98
+ if (!path.startsWith(URLS.LEARN_PREFIX)) {
99
+ path = URLS.LEARN_PREFIX + path;
100
+ }
101
+ return {
102
+ attachment: {
103
+ name: attachmentName,
104
+ id: attachmentId,
105
+ downloadUrl: path,
106
+ previewUrl: URLS.LEARN_FILE_PREVIEW(ContentType.NOTIFICATION, attachmentId, courseType, s.previewFirstPage),
107
+ size,
108
+ },
109
+ };
110
+ }
@@ -0,0 +1,76 @@
1
+ // Questionnaires (课程问卷).
2
+
3
+ import type { Session } from './request.ts';
4
+ import { YES } from './request.ts';
5
+ import type { Questionnaire, QuestionnaireDetail } from './types.ts';
6
+ import { FailReason, QuestionnaireType } from './types.ts';
7
+ import * as URLS from './urls.ts';
8
+ import { QNR_TYPE_MAP, decodeHTML } from './utils.ts';
9
+
10
+ /**
11
+ * Get all questionnaires (课程问卷/QNR) of the specified course.
12
+ */
13
+ export async function getQuestionnaireList(s: Session, courseID: string): Promise<Questionnaire[]> {
14
+ return Promise.all([
15
+ getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ONGOING),
16
+ getQuestionnaireListAtUrl(s, courseID, URLS.LEARN_QNR_LIST_ENDED),
17
+ ]).then((r) => r.flat());
18
+ }
19
+
20
+ async function getQuestionnaireListAtUrl(s: Session, courseID: string, url: string): Promise<Questionnaire[]> {
21
+ const json = await (
22
+ await s.fetchWithToken(url, { method: 'POST', body: URLS.LEARN_PAGE_LIST_FORM_DATA(courseID) })
23
+ ).json();
24
+ if (json.result !== 'success') {
25
+ return Promise.reject({
26
+ reason: FailReason.INVALID_RESPONSE,
27
+ extra: json,
28
+ });
29
+ }
30
+ const result = (json.object?.aaData ?? []) as any[];
31
+ return Promise.all(
32
+ result.map(async (e) => {
33
+ const type = QNR_TYPE_MAP.get(e.wjlx) ?? QuestionnaireType.SURVEY;
34
+ return {
35
+ id: e.wjid,
36
+ type,
37
+ title: decodeHTML(e.wjbt),
38
+ startTime: e.kssj,
39
+ endTime: e.jssj,
40
+ uploadTime: e.scsj,
41
+ uploaderId: e.scr,
42
+ uploaderName: e.scrxm,
43
+ submitTime: e.tjsj ? e.tjsj : undefined,
44
+ isFavorite: e.sfsc === YES,
45
+ comment: e.bznr ?? undefined,
46
+ url: URLS.LEARN_QNR_SUBMIT_PAGE(e.wlkcid, e.wjid, type),
47
+ detail: await getQuestionnaireDetail(s, courseID, e.wjid),
48
+ } satisfies Questionnaire;
49
+ }),
50
+ );
51
+ }
52
+
53
+ async function getQuestionnaireDetail(s: Session, courseID: string, qnrID: string): Promise<QuestionnaireDetail[]> {
54
+ const json = await (
55
+ await s.fetchWithToken(URLS.LEARN_QNR_DETAIL, {
56
+ method: 'POST',
57
+ body: URLS.LEARN_QNR_DETAIL_FORM(courseID, qnrID),
58
+ })
59
+ ).json();
60
+ return (json as any[]).map(
61
+ (e) =>
62
+ ({
63
+ id: e.wtid,
64
+ index: Number(e.wtbh),
65
+ type: e.type,
66
+ required: e.require === YES,
67
+ title: decodeHTML(e.wtbt),
68
+ score: e.wtfz ? Number(e.wtfz) : undefined, // unsure about original type
69
+ options: (e.list as any[])?.map((o) => ({
70
+ id: o.xxid,
71
+ index: Number(o.xxbh),
72
+ title: decodeHTML(o.xxbt),
73
+ })),
74
+ }) satisfies QuestionnaireDetail,
75
+ );
76
+ }
package/src/request.ts ADDED
@@ -0,0 +1,101 @@
1
+ import * as cheerio from 'cheerio';
2
+ import type { Element } from 'cheerio';
3
+ import fetchCookie from 'fetch-cookie';
4
+ import { fetch } from 'node-fetch-native';
5
+ import { CookieJar } from 'tough-cookie';
6
+
7
+ import type { CredentialProvider, Fetch, HelperConfig } from './types.ts';
8
+ import { FailReason, Language } from './types.ts';
9
+
10
+ const CHEERIO_CONFIG: cheerio.CheerioOptions = { xml: true };
11
+
12
+ export const $ = (html: string | Element | Element[]): cheerio.CheerioAPI => {
13
+ return cheerio.load(html, CHEERIO_CONFIG);
14
+ };
15
+
16
+ export const noLogin = (res: Response) => res.url.includes('login_timeout') || res.status === 403;
17
+
18
+ export const YES = '是';
19
+
20
+ /** add CSRF token to any request URL as parameters */
21
+ export const addCSRFTokenToUrl = (url: string, token: string): string => {
22
+ const newUrl = new URL(url);
23
+ newUrl.searchParams.set('_csrf', token);
24
+ return newUrl.toString();
25
+ };
26
+
27
+ function makeFetch(jar?: CookieJar): typeof fetch {
28
+ return fetchCookie(fetch, jar);
29
+ }
30
+
31
+ function withReAuth(rawFetch: Fetch, login: () => Promise<void>): Fetch {
32
+ return async function wrappedFetch(...args) {
33
+ const retryAfterLogin = async () => {
34
+ await login();
35
+ return await rawFetch(...args).then((res: Response) => {
36
+ if (noLogin(res)) {
37
+ return Promise.reject({
38
+ reason: FailReason.NOT_LOGGED_IN,
39
+ });
40
+ } else if (res.status !== 200) {
41
+ return Promise.reject({
42
+ reason: FailReason.UNEXPECTED_STATUS,
43
+ extra: {
44
+ code: res.status,
45
+ text: res.statusText,
46
+ },
47
+ });
48
+ } else {
49
+ return res;
50
+ }
51
+ });
52
+ };
53
+ return await rawFetch(...args).then((res: Response) => (noLogin(res) ? retryAfterLogin() : res));
54
+ };
55
+ }
56
+
57
+ /**
58
+ * State shared by every request a `Learn2018Helper` makes: cookies, CSRF token, language and the fetch wrappers.
59
+ * Internal; the helper keeps it in a private field.
60
+ */
61
+ export class Session {
62
+ readonly provider?: CredentialProvider;
63
+ readonly cookieJar: CookieJar;
64
+ /** plain fetch with cookies, no login check */
65
+ readonly rawFetch: Fetch;
66
+ /** rejects with `NOT_LOGGED_IN`, or logs in again and retries once when a `CredentialProvider` is set */
67
+ readonly fetch: Fetch;
68
+ csrfToken = '';
69
+ lang = Language.ZH;
70
+ previewFirstPage: boolean;
71
+
72
+ /** `login` is called with no arguments when a request needs a (new) session */
73
+ constructor(
74
+ config: HelperConfig | undefined,
75
+ readonly login: () => Promise<void>,
76
+ ) {
77
+ this.previewFirstPage = config?.generatePreviewUrlForFirstPage ?? true;
78
+ this.provider = config?.provider;
79
+ this.cookieJar = config?.cookieJar ?? new CookieJar();
80
+ this.rawFetch = config?.fetch ?? makeFetch(this.cookieJar);
81
+ this.fetch = this.provider
82
+ ? withReAuth(this.rawFetch, login)
83
+ : async (...args) => {
84
+ const result = await this.rawFetch(...args);
85
+ if (noLogin(result))
86
+ return Promise.reject({
87
+ reason: FailReason.NOT_LOGGED_IN,
88
+ });
89
+ return result;
90
+ };
91
+ }
92
+
93
+ /** like `fetch`, but logs in first if there is no CSRF token yet and adds the token to the URL */
94
+ readonly fetchWithToken: Fetch = async (...args) => {
95
+ if (this.csrfToken === '') {
96
+ await this.login();
97
+ }
98
+ const [url, ...remaining] = args;
99
+ return this.fetch(addCSRFTokenToUrl(url as string, this.csrfToken), ...remaining);
100
+ };
101
+ }