@robertying/thu-learn-lib 5.0.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.
Files changed (84) hide show
  1. package/README.md +94 -227
  2. package/lib/auth.d.ts +15 -4
  3. package/lib/auth.d.ts.map +1 -1
  4. package/lib/auth.js +65 -55
  5. package/lib/auth.js.map +1 -1
  6. package/lib/calendar.d.ts +12 -5
  7. package/lib/calendar.d.ts.map +1 -1
  8. package/lib/calendar.js +118 -20
  9. package/lib/calendar.js.map +1 -1
  10. package/lib/contents.d.ts.map +1 -1
  11. package/lib/contents.js +10 -6
  12. package/lib/contents.js.map +1 -1
  13. package/lib/courses.d.ts.map +1 -1
  14. package/lib/courses.js +15 -29
  15. package/lib/courses.js.map +1 -1
  16. package/lib/discussions.d.ts.map +1 -1
  17. package/lib/discussions.js +8 -19
  18. package/lib/discussions.js.map +1 -1
  19. package/lib/favorites.d.ts +1 -0
  20. package/lib/favorites.d.ts.map +1 -1
  21. package/lib/favorites.js +25 -60
  22. package/lib/favorites.js.map +1 -1
  23. package/lib/files.d.ts +4 -4
  24. package/lib/files.d.ts.map +1 -1
  25. package/lib/files.js +112 -134
  26. package/lib/files.js.map +1 -1
  27. package/lib/homework.d.ts +35 -5
  28. package/lib/homework.d.ts.map +1 -1
  29. package/lib/homework.js +150 -92
  30. package/lib/homework.js.map +1 -1
  31. package/lib/index.d.ts +45 -20
  32. package/lib/index.d.ts.map +1 -1
  33. package/lib/index.js +52 -17
  34. package/lib/index.js.map +1 -1
  35. package/lib/notices.d.ts +4 -4
  36. package/lib/notices.d.ts.map +1 -1
  37. package/lib/notices.js +13 -33
  38. package/lib/notices.js.map +1 -1
  39. package/lib/questionnaires.d.ts +6 -3
  40. package/lib/questionnaires.d.ts.map +1 -1
  41. package/lib/questionnaires.js +26 -24
  42. package/lib/questionnaires.js.map +1 -1
  43. package/lib/request.d.ts +64 -20
  44. package/lib/request.d.ts.map +1 -1
  45. package/lib/request.js +174 -64
  46. package/lib/request.js.map +1 -1
  47. package/lib/timetable.d.ts +26 -0
  48. package/lib/timetable.d.ts.map +1 -0
  49. package/lib/timetable.js +110 -0
  50. package/lib/timetable.js.map +1 -0
  51. package/lib/types.d.ts +117 -76
  52. package/lib/types.d.ts.map +1 -1
  53. package/lib/types.js +3 -0
  54. package/lib/types.js.map +1 -1
  55. package/lib/urls.d.ts +10 -20
  56. package/lib/urls.d.ts.map +1 -1
  57. package/lib/urls.js +11 -56
  58. package/lib/urls.js.map +1 -1
  59. package/lib/user.d.ts +7 -2
  60. package/lib/user.d.ts.map +1 -1
  61. package/lib/user.js +20 -6
  62. package/lib/user.js.map +1 -1
  63. package/lib/utils.d.ts +42 -4
  64. package/lib/utils.d.ts.map +1 -1
  65. package/lib/utils.js +80 -29
  66. package/lib/utils.js.map +1 -1
  67. package/package.json +9 -8
  68. package/src/auth.ts +72 -70
  69. package/src/calendar.ts +136 -24
  70. package/src/contents.ts +13 -6
  71. package/src/courses.ts +21 -37
  72. package/src/discussions.ts +10 -21
  73. package/src/favorites.ts +44 -80
  74. package/src/files.ts +124 -142
  75. package/src/homework.ts +207 -135
  76. package/src/index.ts +79 -48
  77. package/src/notices.ts +29 -42
  78. package/src/questionnaires.ts +62 -45
  79. package/src/request.ts +192 -69
  80. package/src/timetable.ts +121 -0
  81. package/src/types.ts +123 -87
  82. package/src/urls.ts +13 -64
  83. package/src/user.ts +22 -7
  84. package/src/utils.ts +97 -29
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 { 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
- export function parseSemesterType(n: number): SemesterType {
7
- if (n === 1) {
8
- return SemesterType.FALL;
9
- } else if (n === 2) {
10
- return SemesterType.SPRING;
11
- } else if (n === 3) {
12
- return SemesterType.SUMMER;
13
- } else {
14
- return SemesterType.UNKNOWN;
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
- const CONTENT_TYPE_MK_MAP = {
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_' + (CONTENT_TYPE_MK_MAP[type] ?? 'UNKNOWN');
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.PASS],
135
+ [-63, HomeworkGradeLevel.P],
68
136
  [-62, HomeworkGradeLevel.EX],
69
137
  [-61, HomeworkGradeLevel.EXEMPTION],
70
138
  [-60, HomeworkGradeLevel.PASS],
@@ -78,14 +146,21 @@ export const GRADE_LEVEL_MAP = new Map([
78
146
 
79
147
  export const JSONP_EXTRACTOR_NAME = 'thu_learn_lib_jsonp_extractor';
80
148
 
81
- export function extractJSONPResult(jsonp: string): any {
82
- // check jsonp format
83
- if (!jsonp.startsWith(JSONP_EXTRACTOR_NAME)) {
84
- throw FailReason.INVALID_RESPONSE;
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));
158
+ }
159
+ try {
160
+ return JSON.parse(body.slice(prefix.length, -1));
161
+ } catch (err) {
162
+ throw apiError(FailReason.INVALID_RESPONSE, { error: String(err), text: jsonp.slice(0, 200) });
85
163
  }
86
- // evaluate the result
87
- // oxlint-disable-next-line typescript/no-implied-eval -- JSONP from the registrar is a JS expression, not JSON
88
- return Function(`"use strict";const ${JSONP_EXTRACTOR_NAME}=(s)=>s;return ${jsonp};`)();
89
164
  }
90
165
 
91
166
  export function formatFileSize(size: number): string {
@@ -105,14 +180,7 @@ export const CONTENT_TYPE_MAP = new Map([
105
180
  [ContentType.QUESTIONNAIRE, 'KCWJ'],
106
181
  // omitted: 课表(KCKB)
107
182
  ]);
108
- export const CONTENT_TYPE_MAP_REVERSE = new Map([
109
- [CONTENT_TYPE_MAP.get(ContentType.NOTIFICATION)!, ContentType.NOTIFICATION],
110
- [CONTENT_TYPE_MAP.get(ContentType.FILE)!, ContentType.FILE],
111
- [CONTENT_TYPE_MAP.get(ContentType.HOMEWORK)!, ContentType.HOMEWORK],
112
- [CONTENT_TYPE_MAP.get(ContentType.DISCUSSION)!, ContentType.DISCUSSION],
113
- [CONTENT_TYPE_MAP.get(ContentType.QUESTION)!, ContentType.QUESTION],
114
- [CONTENT_TYPE_MAP.get(ContentType.QUESTIONNAIRE)!, ContentType.QUESTIONNAIRE],
115
- ]);
183
+ export const CONTENT_TYPE_MAP_REVERSE = new Map([...CONTENT_TYPE_MAP].map(([type, code]) => [code, type]));
116
184
 
117
185
  export const QNR_TYPE_MAP = new Map([
118
186
  ['投票', QuestionnaireType.VOTE],