@robertying/thu-learn-lib 5.1.0 → 6.0.1

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