@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/utils.ts
CHANGED
|
@@ -1,21 +1,61 @@
|
|
|
1
|
+
import * as cheerio from 'cheerio';
|
|
1
2
|
import { decodeHTML as _decodeHTML } from 'entities';
|
|
2
3
|
|
|
3
|
-
import type { ApiError, DetailSelector } from './types.ts';
|
|
4
|
+
import type { ApiError, DetailSelector, Fetch } from './types.ts';
|
|
4
5
|
import { ContentType, FailReason, HomeworkGradeLevel, QuestionnaireType, SemesterType } from './types.ts';
|
|
5
6
|
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
}
|
|
14
|
-
|
|
15
|
-
|
|
7
|
+
const CHEERIO_CONFIG: cheerio.CheerioOptions = { xml: true };
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The error every API rejects with: a plain `{ reason, extra }` object (not an `Error`), so callers can compare
|
|
11
|
+
* `reason` with `FailReason`. `extra` is left out when not given.
|
|
12
|
+
*/
|
|
13
|
+
export const apiError = (reason: FailReason, extra?: unknown): ApiError =>
|
|
14
|
+
extra === undefined ? { reason } : { reason, extra };
|
|
15
|
+
|
|
16
|
+
/** throw `apiError(reason, json)` unless the server's JSON answer says `result: 'success'` */
|
|
17
|
+
export function expectSuccess(json: any, reason: FailReason): void {
|
|
18
|
+
if (json?.result !== 'success') throw apiError(reason, json);
|
|
16
19
|
}
|
|
17
20
|
|
|
18
|
-
|
|
21
|
+
/** a DOM element as cheerio's selections hold it (domhandler's `Element`, taken from cheerio's types so it always matches) */
|
|
22
|
+
export type Element = ReturnType<ReturnType<cheerio.CheerioAPI['root']>['children']>[number];
|
|
23
|
+
|
|
24
|
+
/** load an HTML page or fragment for querying */
|
|
25
|
+
export const $ = (html: Parameters<typeof cheerio.load>[0]): cheerio.CheerioAPI => cheerio.load(html, CHEERIO_CONFIG);
|
|
26
|
+
|
|
27
|
+
/** what the server writes for "yes" in its flags (and 否 for "no") */
|
|
28
|
+
export const YES = '是';
|
|
29
|
+
|
|
30
|
+
/** the URL of a `fetch` input */
|
|
31
|
+
export const urlOf = (input: Parameters<Fetch>[0]): string =>
|
|
32
|
+
typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
|
33
|
+
|
|
34
|
+
/** pages longer than this are taken as real pages, never as the short login / redirect page of an expired session */
|
|
35
|
+
const MAX_LOGIN_PAGE_LENGTH = 4096;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Whether a 200 page is the short login or redirect page of an expired session rather than the page asked for: short,
|
|
39
|
+
* and either Web Learning's "login timeout" page (what the server sends for an HTML page of an expired session, in
|
|
40
|
+
* either website language: title 登录超时, image `log_fail.png`, text 您未登录或登录失效) or a page pointing at the
|
|
41
|
+
* login timeout page or the SSO login.
|
|
42
|
+
*/
|
|
43
|
+
export const looksLikeLoginPage = (text: string) =>
|
|
44
|
+
text.length < MAX_LOGIN_PAGE_LENGTH &&
|
|
45
|
+
/登录超时|您未登录或登录失效|log_fail\.png|login_timeout|id\.tsinghua\.edu\.cn\/do\/off\/ui\/auth\/login|j_spring_security_thauth_roaming_entry/.test(
|
|
46
|
+
text,
|
|
47
|
+
);
|
|
48
|
+
|
|
49
|
+
const SEMESTER_TYPES: Record<number, SemesterType> = {
|
|
50
|
+
1: SemesterType.FALL,
|
|
51
|
+
2: SemesterType.SPRING,
|
|
52
|
+
3: SemesterType.SUMMER,
|
|
53
|
+
};
|
|
54
|
+
|
|
55
|
+
/** the semester type from the last digit of a semester id (`2024-2025-1`) */
|
|
56
|
+
export const parseSemesterType = (n: number): SemesterType => SEMESTER_TYPES[n] ?? SemesterType.UNKNOWN;
|
|
57
|
+
|
|
58
|
+
const CONTENT_TYPE_MK_MAP: Record<ContentType, string> = {
|
|
19
59
|
[ContentType.NOTIFICATION]: 'kcgg',
|
|
20
60
|
[ContentType.FILE]: 'kcwj',
|
|
21
61
|
[ContentType.HOMEWORK]: 'kczy',
|
|
@@ -25,7 +65,7 @@ const CONTENT_TYPE_MK_MAP = {
|
|
|
25
65
|
};
|
|
26
66
|
|
|
27
67
|
export function getMkFromType(type: ContentType): string {
|
|
28
|
-
return 'mk_' +
|
|
68
|
+
return 'mk_' + CONTENT_TYPE_MK_MAP[type];
|
|
29
69
|
}
|
|
30
70
|
|
|
31
71
|
export function decodeHTML(html: string): string {
|
|
@@ -40,6 +80,34 @@ export function decodeHTML(html: string): string {
|
|
|
40
80
|
: text;
|
|
41
81
|
}
|
|
42
82
|
|
|
83
|
+
const BEIJING_OFFSET_MS = 8 * 60 * 60 * 1000;
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* A server time as an ISO 8601 string in Beijing time with its offset, e.g. `2026-11-07T18:28:00+08:00`. The server
|
|
87
|
+
* sends either a Unix timestamp (a number or a string of digits; milliseconds, or seconds for a 10-digit value such as
|
|
88
|
+
* a favorite's pin time) or a Beijing wall-clock time without a zone (`2026-11-07 18:28`, `2026-11-07 18:28:00`).
|
|
89
|
+
* `undefined` for a missing or empty value, or a timestamp that is 0, negative or out of range; a string in any other
|
|
90
|
+
* shape is returned as it is, so nothing the server sent is lost.
|
|
91
|
+
*/
|
|
92
|
+
export function toServerTime(value: unknown): string | undefined {
|
|
93
|
+
if (value === null || value === undefined || value === '') return undefined;
|
|
94
|
+
if (typeof value === 'number' || (typeof value === 'string' && /^\d{10,}$/.test(value))) {
|
|
95
|
+
const number = Number(value);
|
|
96
|
+
// 0 (or less) is the server's "no time", e.g. a homework without a late submission deadline
|
|
97
|
+
if (!Number.isFinite(number) || number <= 0) return undefined;
|
|
98
|
+
// seconds until the year 5138, milliseconds after 1973
|
|
99
|
+
const date = new Date((number < 1e11 ? number * 1000 : number) + BEIJING_OFFSET_MS);
|
|
100
|
+
if (Number.isNaN(date.getTime())) return undefined;
|
|
101
|
+
return date.toISOString().replace(/(\.\d{3})?Z$/, '+08:00');
|
|
102
|
+
}
|
|
103
|
+
if (typeof value !== 'string') return undefined;
|
|
104
|
+
const wallClock = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2})(:\d{2})?(?:\.\d+)?$/.exec(value.trim());
|
|
105
|
+
return wallClock ? `${wallClock[1]}T${wallClock[2]}${wallClock[3] ?? ':00'}+08:00` : value;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** `toServerTime` for a time every item has; '' when the server left it out */
|
|
109
|
+
export const toRequiredServerTime = (value: unknown): string => toServerTime(value) ?? '';
|
|
110
|
+
|
|
43
111
|
export function trimAndDefine(text: string | undefined | null): string | undefined {
|
|
44
112
|
if (text === undefined || text === null) {
|
|
45
113
|
return undefined;
|
|
@@ -64,7 +132,7 @@ export const GRADE_LEVEL_MAP = new Map([
|
|
|
64
132
|
[-66, HomeworkGradeLevel.D_PLUS],
|
|
65
133
|
[-64, HomeworkGradeLevel.D],
|
|
66
134
|
[-65, HomeworkGradeLevel.EXEMPTED_COURSE],
|
|
67
|
-
[-63, HomeworkGradeLevel.
|
|
135
|
+
[-63, HomeworkGradeLevel.P],
|
|
68
136
|
[-62, HomeworkGradeLevel.EX],
|
|
69
137
|
[-61, HomeworkGradeLevel.EXEMPTION],
|
|
70
138
|
[-60, HomeworkGradeLevel.PASS],
|
|
@@ -78,19 +146,20 @@ export const GRADE_LEVEL_MAP = new Map([
|
|
|
78
146
|
|
|
79
147
|
export const JSONP_EXTRACTOR_NAME = 'thu_learn_lib_jsonp_extractor';
|
|
80
148
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
149
|
+
/**
|
|
150
|
+
* The argument of a `thu_learn_lib_jsonp_extractor(...)` JSONP body, parsed as JSON. The body is never run as code,
|
|
151
|
+
* so whatever the registrar (or anything between) sends can't execute in the app.
|
|
152
|
+
*/
|
|
153
|
+
export function extractJSONPResult(jsonp: string): unknown {
|
|
154
|
+
const prefix = `${JSONP_EXTRACTOR_NAME}(`;
|
|
155
|
+
const body = jsonp.trim().replace(/;$/, '').trimEnd();
|
|
156
|
+
if (!body.startsWith(prefix) || !body.endsWith(')')) {
|
|
157
|
+
throw apiError(FailReason.INVALID_RESPONSE, jsonp.slice(0, 200));
|
|
87
158
|
}
|
|
88
|
-
// evaluate the result
|
|
89
159
|
try {
|
|
90
|
-
|
|
91
|
-
return Function(`"use strict";const ${JSONP_EXTRACTOR_NAME}=(s)=>s;return ${jsonp};`)();
|
|
160
|
+
return JSON.parse(body.slice(prefix.length, -1));
|
|
92
161
|
} catch (err) {
|
|
93
|
-
throw
|
|
162
|
+
throw apiError(FailReason.INVALID_RESPONSE, { error: String(err), text: jsonp.slice(0, 200) });
|
|
94
163
|
}
|
|
95
164
|
}
|
|
96
165
|
|
|
@@ -111,14 +180,7 @@ export const CONTENT_TYPE_MAP = new Map([
|
|
|
111
180
|
[ContentType.QUESTIONNAIRE, 'KCWJ'],
|
|
112
181
|
// omitted: 课表(KCKB)
|
|
113
182
|
]);
|
|
114
|
-
export const CONTENT_TYPE_MAP_REVERSE = new Map([
|
|
115
|
-
[CONTENT_TYPE_MAP.get(ContentType.NOTIFICATION)!, ContentType.NOTIFICATION],
|
|
116
|
-
[CONTENT_TYPE_MAP.get(ContentType.FILE)!, ContentType.FILE],
|
|
117
|
-
[CONTENT_TYPE_MAP.get(ContentType.HOMEWORK)!, ContentType.HOMEWORK],
|
|
118
|
-
[CONTENT_TYPE_MAP.get(ContentType.DISCUSSION)!, ContentType.DISCUSSION],
|
|
119
|
-
[CONTENT_TYPE_MAP.get(ContentType.QUESTION)!, ContentType.QUESTION],
|
|
120
|
-
[CONTENT_TYPE_MAP.get(ContentType.QUESTIONNAIRE)!, ContentType.QUESTIONNAIRE],
|
|
121
|
-
]);
|
|
183
|
+
export const CONTENT_TYPE_MAP_REVERSE = new Map([...CONTENT_TYPE_MAP].map(([type, code]) => [code, type]));
|
|
122
184
|
|
|
123
185
|
export const QNR_TYPE_MAP = new Map([
|
|
124
186
|
['投票', QuestionnaireType.VOTE],
|