chuvsu-js 3.0.2 → 4.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 CHANGED
@@ -61,7 +61,7 @@ schedule.currentLesson();
61
61
  ```ts
62
62
  import { LkClient } from "chuvsu-js";
63
63
 
64
- const lk = new LkClient();
64
+ const lk = new LkClient({ cache: 60_000 });
65
65
  await lk.login({ email: "student@mail.ru", password: "password" });
66
66
 
67
67
  const data = await lk.getPersonalData();
@@ -87,6 +87,8 @@ new TtClient(options?: TtClientOptions)
87
87
  | --------------- | ----------------------- | ----------------- | -------------------------------------------------------------- |
88
88
  | `educationType` | `EducationType` | `HigherEducation` | Тип образования: высшее (1) или СПО (2) |
89
89
  | `cache` | `number \| CacheConfig` | — | TTL кеша в мс. Число задаёт единый TTL, объект — по категориям |
90
+ | `cacheAdapter` | `CacheAdapter` | — | Внешний L2-кеш для JSON-данных (например Redis/БД) |
91
+ | `blobAdapter` | `BlobAdapter` | — | Хранилище бинарных данных, например S3/R2/MinIO |
90
92
 
91
93
  #### Авторизация
92
94
 
@@ -141,7 +143,13 @@ const data = tt.exportCache();
141
143
  tt.importCache(data);
142
144
  ```
143
145
 
144
- Категории кеша: `schedule`, `faculties`, `groups`.
146
+ Категории кеша: `schedule`, `faculties`, `groups`, `audiences`, `audienceNames`, `teachers`, `teacherInfo`, `teacherPhotos`, `audienceInfo`, `audienceImages`.
147
+
148
+ Если передать `cacheAdapter`, библиотека использует двухуровневый кеш:
149
+ - L1: in-memory кеш внутри процесса
150
+ - L2: внешний адаптер (`cacheAdapter`)
151
+
152
+ Если передать `blobAdapter`, фото преподавателей, аудиторий, корпусов и планов этажей будут храниться во внешнем blob/object storage, а во внешнем JSON-кеше сохранятся только метаданные с ключом blob-объекта.
145
153
 
146
154
  ### Schedule
147
155
 
@@ -224,11 +232,15 @@ getWeekNumber({ period: Period.SpringSemester });
224
232
  Клиент для личного кабинета (`lk.chuvsu.ru`).
225
233
 
226
234
  ```ts
235
+ const lk = new LkClient({ cache?: number | LkCacheConfig });
227
236
  await lk.login({ email, password });
228
237
  const data = await lk.getPersonalData();
229
238
  const groupId = await lk.getGroupId();
230
239
  ```
231
240
 
241
+ `cache` работает так же, как в `TtClient`: число задаёт единый TTL в мс, объект — TTL по категориям (`personalData`, `photo`, `groupId`).
242
+ Также поддерживаются `cacheAdapter` и `blobAdapter`.
243
+
232
244
  **PersonalData** содержит: `lastName`, `firstName`, `patronymic`, `sex`, `birthday`, `recordBookNumber`, `faculty`, `specialty`, `profile`, `group`, `course`, `email`, `phone`.
233
245
 
234
246
  ## Типы
@@ -2,6 +2,21 @@ export interface CacheEntry {
2
2
  data: unknown;
3
3
  timestamp: number;
4
4
  }
5
+ export interface CacheAdapter {
6
+ get(category: string, key: string): Promise<unknown | null | undefined>;
7
+ set(category: string, key: string, data: unknown, ttl?: number): Promise<void>;
8
+ clear?(category?: string): Promise<void>;
9
+ delete?(category: string, key: string): Promise<void>;
10
+ }
11
+ export interface BlobPutOptions {
12
+ contentType?: string;
13
+ ttl?: number;
14
+ }
15
+ export interface BlobAdapter {
16
+ get(key: string): Promise<Buffer | null>;
17
+ put(key: string, data: Buffer, opts?: BlobPutOptions): Promise<void>;
18
+ delete?(key: string): Promise<void>;
19
+ }
5
20
  export declare class Cache {
6
21
  private ttls;
7
22
  private store;
@@ -12,3 +27,18 @@ export declare class Cache {
12
27
  export(): Record<string, CacheEntry>;
13
28
  import(data: Record<string, CacheEntry>): void;
14
29
  }
30
+ export declare class HybridCache {
31
+ private memory;
32
+ private ttls;
33
+ private adapter?;
34
+ constructor(ttls: Record<string, number | undefined>, adapter?: CacheAdapter);
35
+ ttl(category: string): number | undefined;
36
+ getLocal(category: string, key: string): unknown | null;
37
+ setLocal(category: string, key: string, data: unknown): void;
38
+ get(category: string, key: string): Promise<unknown | null>;
39
+ set(category: string, key: string, data: unknown): Promise<void>;
40
+ setExternal(category: string, key: string, data: unknown): Promise<void>;
41
+ clear(category?: string): Promise<void>;
42
+ export(): Record<string, CacheEntry>;
43
+ import(data: Record<string, CacheEntry>): void;
44
+ }
@@ -46,3 +46,55 @@ export class Cache {
46
46
  }
47
47
  }
48
48
  }
49
+ export class HybridCache {
50
+ memory;
51
+ ttls;
52
+ adapter;
53
+ constructor(ttls, adapter) {
54
+ this.ttls = ttls;
55
+ this.memory = new Cache(ttls);
56
+ this.adapter = adapter;
57
+ }
58
+ ttl(category) {
59
+ return this.ttls[category];
60
+ }
61
+ getLocal(category, key) {
62
+ return this.memory.get(category, key);
63
+ }
64
+ setLocal(category, key, data) {
65
+ this.memory.set(category, key, data);
66
+ }
67
+ async get(category, key) {
68
+ const local = this.memory.get(category, key);
69
+ if (local !== null)
70
+ return local;
71
+ const ttl = this.ttls[category];
72
+ if (ttl == null || !this.adapter)
73
+ return null;
74
+ const external = await this.adapter.get(category, key);
75
+ if (external === null || external === undefined)
76
+ return null;
77
+ this.memory.set(category, key, external);
78
+ return external;
79
+ }
80
+ async set(category, key, data) {
81
+ this.memory.set(category, key, data);
82
+ await this.setExternal(category, key, data);
83
+ }
84
+ async setExternal(category, key, data) {
85
+ const ttl = this.ttls[category];
86
+ if (ttl == null || !this.adapter)
87
+ return;
88
+ await this.adapter.set(category, key, data, ttl);
89
+ }
90
+ async clear(category) {
91
+ this.memory.clear(category);
92
+ await this.adapter?.clear?.(category);
93
+ }
94
+ export() {
95
+ return this.memory.export();
96
+ }
97
+ import(data) {
98
+ this.memory.import(data);
99
+ }
100
+ }
@@ -1,7 +1,10 @@
1
- import type { PersonalData } from "./types.js";
1
+ import type { LkClientOptions, PersonalData } from "./types.js";
2
2
  export declare class LkClient {
3
3
  private http;
4
4
  private credentials;
5
+ private cache;
6
+ private blobAdapter;
7
+ constructor(opts?: LkClientOptions);
5
8
  login(opts: {
6
9
  email: string;
7
10
  password: string;
package/dist/lk/client.js CHANGED
@@ -1,12 +1,34 @@
1
1
  import { HttpClient } from "../common/http.js";
2
+ import { HybridCache } from "../common/cache.js";
2
3
  import { AuthError } from "../common/types.js";
3
4
  import { extractScriptValues } from "./parse.js";
4
5
  const BASE = "https://lk.chuvsu.ru";
5
6
  const LOGIN_URL = `${BASE}/info/login.php`;
6
7
  const STUDENT_BASE = `${BASE}/student`;
8
+ function makeUniformCacheConfig(ttl) {
9
+ return {
10
+ personalData: ttl,
11
+ photo: ttl,
12
+ groupId: ttl,
13
+ };
14
+ }
7
15
  export class LkClient {
8
16
  http = new HttpClient();
9
17
  credentials = null;
18
+ cache;
19
+ blobAdapter = undefined;
20
+ constructor(opts) {
21
+ this.blobAdapter = opts?.blobAdapter;
22
+ if (opts?.cache == null) {
23
+ this.cache = null;
24
+ }
25
+ else if (typeof opts.cache === "number") {
26
+ this.cache = new HybridCache(makeUniformCacheConfig(opts.cache), opts.cacheAdapter);
27
+ }
28
+ else {
29
+ this.cache = new HybridCache(opts.cache, opts.cacheAdapter);
30
+ }
31
+ }
10
32
  async login(opts) {
11
33
  const res = await this.http.post(LOGIN_URL, { email: opts.email, password: opts.password, role: "1", enter: "" }, false);
12
34
  if (!(res.status === 302 && res.location?.includes("student"))) {
@@ -26,9 +48,12 @@ export class LkClient {
26
48
  return res;
27
49
  }
28
50
  async getPersonalData() {
51
+ const cached = await this.cache?.get("personalData", "self");
52
+ if (cached)
53
+ return cached;
29
54
  const { body } = await this.authGet(`${STUDENT_BASE}/personal_data.php`);
30
55
  const vals = extractScriptValues(body, "form_personal_data");
31
- return {
56
+ const data = {
32
57
  lastName: vals.fam ?? "",
33
58
  firstName: vals.nam ?? "",
34
59
  patronymic: vals.oth ?? "",
@@ -43,13 +68,49 @@ export class LkClient {
43
68
  email: vals.email ?? "",
44
69
  phone: vals.phone ?? "",
45
70
  };
71
+ await this.cache?.set("personalData", "self", data);
72
+ return data;
46
73
  }
47
74
  async getPhoto() {
48
- return this.http.getBuffer(`${STUDENT_BASE}/face.php`);
75
+ const cached = this.cache?.getLocal("photo", "self") ??
76
+ await this.cache?.get("photo", "self");
77
+ if (cached !== null && cached !== undefined) {
78
+ const entry = cached;
79
+ if (typeof entry === "string")
80
+ return Buffer.from(entry, "base64");
81
+ if (entry.data !== undefined) {
82
+ return entry.data ? Buffer.from(entry.data, "base64") : Buffer.alloc(0);
83
+ }
84
+ if (entry.blobKey && this.blobAdapter) {
85
+ const photo = await this.blobAdapter.get(entry.blobKey);
86
+ if (photo) {
87
+ this.cache?.setLocal("photo", "self", { data: photo.toString("base64") });
88
+ return photo;
89
+ }
90
+ }
91
+ }
92
+ const photo = await this.http.getBuffer(`${STUDENT_BASE}/face.php`);
93
+ if (this.blobAdapter) {
94
+ const blobKey = "lk/photo/self";
95
+ this.cache?.setLocal("photo", "self", { data: photo.toString("base64") });
96
+ await this.blobAdapter.put(blobKey, photo, {
97
+ ttl: this.cache?.ttl("photo"),
98
+ });
99
+ await this.cache?.setExternal("photo", "self", { blobKey });
100
+ }
101
+ else {
102
+ await this.cache?.set("photo", "self", photo.toString("base64"));
103
+ }
104
+ return photo;
49
105
  }
50
106
  async getGroupId() {
107
+ const cached = await this.cache?.get("groupId", "self");
108
+ if (cached !== null && cached !== undefined)
109
+ return cached;
51
110
  const { body } = await this.authGet(`${STUDENT_BASE}/tt.php`);
52
111
  const match = body.match(/tt\.chuvsu\.ru\/index\/grouptt\/gr\/(\d+)/);
53
- return match ? parseInt(match[1]) : null;
112
+ const groupId = match ? parseInt(match[1]) : null;
113
+ await this.cache?.set("groupId", "self", groupId);
114
+ return groupId;
54
115
  }
55
116
  }
@@ -1,3 +1,4 @@
1
+ import type { BlobAdapter, CacheAdapter } from "../common/cache.js";
1
2
  export interface PersonalData {
2
3
  lastName: string;
3
4
  firstName: string;
@@ -13,3 +14,13 @@ export interface PersonalData {
13
14
  email: string;
14
15
  phone: string;
15
16
  }
17
+ export interface LkCacheConfig {
18
+ personalData?: number;
19
+ photo?: number;
20
+ groupId?: number;
21
+ }
22
+ export interface LkClientOptions {
23
+ cache?: number | LkCacheConfig;
24
+ cacheAdapter?: CacheAdapter;
25
+ blobAdapter?: BlobAdapter;
26
+ }
package/dist/shared.d.ts CHANGED
@@ -4,6 +4,6 @@ export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffe
4
4
  export type { Holiday, HolidayTransfer } from "./tt/utils/index.js";
5
5
  export { AuthError, EducationType, ParseError, Period, } from "./common/types.js";
6
6
  export type { Teacher, Time, WeekRange } from "./common/types.js";
7
- export type { CacheEntry } from "./common/cache.js";
7
+ export type { BlobAdapter, BlobPutOptions, CacheAdapter, CacheEntry, } from "./common/cache.js";
8
8
  export type { Audience, AudienceInfo, CacheConfig, Faculty, FullScheduleDay, FullScheduleSlot, Group, Lesson, LessonTime, LessonTimeSlot, ScheduleEntry, SemesterWeek, SubstituteForInfo, Substitution, TeacherInfo, TransferInfo, TtClientOptions, } from "./tt/types.js";
9
- export type { PersonalData } from "./lk/types.js";
9
+ export type { LkCacheConfig, LkClientOptions, PersonalData } from "./lk/types.js";
@@ -6,10 +6,11 @@ export declare class TtClient {
6
6
  private http;
7
7
  private educationType;
8
8
  private cache;
9
+ private blobAdapter;
9
10
  private loginMode;
10
11
  constructor(opts?: TtClientOptions);
11
12
  private get pertt();
12
- clearCache(category?: keyof CacheConfig): void;
13
+ clearCache(category?: keyof CacheConfig): Promise<void>;
13
14
  exportCache(): Record<string, CacheEntry>;
14
15
  importCache(data: Record<string, CacheEntry>): void;
15
16
  login(opts: {
package/dist/tt/client.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { HttpClient } from "../common/http.js";
2
- import { Cache } from "../common/cache.js";
2
+ import { HybridCache } from "../common/cache.js";
3
3
  import { AuthError } from "../common/types.js";
4
4
  import { parseAudienceButtons, parseAudienceFullSchedule, parseAudienceInfo, parseAudienceName, parseGroupButtons, parseFacultyButtons, parseTeacherButtons, parseFullSchedule, parseTeacherFullSchedule, parseTeacherInfo, } from "./parse/index.js";
5
5
  import { Schedule } from "./schedule.js";
@@ -11,33 +11,45 @@ const ALL_PERIODS = [
11
11
  3 /* Period.SpringSemester */,
12
12
  4 /* Period.SummerSession */,
13
13
  ];
14
+ function makeUniformCacheConfig(ttl) {
15
+ return {
16
+ schedule: ttl,
17
+ faculties: ttl,
18
+ groups: ttl,
19
+ audiences: ttl,
20
+ audienceNames: ttl,
21
+ teachers: ttl,
22
+ teacherInfo: ttl,
23
+ teacherPhotos: ttl,
24
+ audienceInfo: ttl,
25
+ audienceImages: ttl,
26
+ };
27
+ }
14
28
  export class TtClient {
15
29
  http = new HttpClient();
16
30
  educationType;
17
31
  cache;
32
+ blobAdapter = undefined;
18
33
  loginMode = null;
19
34
  constructor(opts) {
20
35
  this.educationType = opts?.educationType ?? 1 /* EducationType.HigherEducation */;
36
+ this.blobAdapter = opts?.blobAdapter;
21
37
  if (opts?.cache == null) {
22
38
  this.cache = null;
23
39
  }
24
40
  else if (typeof opts.cache === "number") {
25
- this.cache = new Cache({
26
- schedule: opts.cache,
27
- faculties: opts.cache,
28
- groups: opts.cache,
29
- });
41
+ this.cache = new HybridCache(makeUniformCacheConfig(opts.cache), opts.cacheAdapter);
30
42
  }
31
43
  else {
32
- this.cache = new Cache(opts.cache);
44
+ this.cache = new HybridCache(opts.cache, opts.cacheAdapter);
33
45
  }
34
46
  }
35
47
  get pertt() {
36
48
  return String(this.educationType);
37
49
  }
38
50
  // --- Cache ---
39
- clearCache(category) {
40
- this.cache?.clear(category);
51
+ async clearCache(category) {
52
+ await this.cache?.clear(category);
41
53
  }
42
54
  exportCache() {
43
55
  return this.cache?.export() ?? {};
@@ -102,13 +114,13 @@ export class TtClient {
102
114
  // --- Schedule ---
103
115
  async fetchSchedule(groupId, period) {
104
116
  const cacheKey = `${groupId}:${period}`;
105
- const cached = this.cache?.get("schedule", cacheKey);
117
+ const cached = await this.cache?.get("schedule", cacheKey);
106
118
  if (cached)
107
119
  return cached;
108
120
  const url = `${BASE}/index/grouptt/gr/${groupId}`;
109
121
  const { body } = await this.authPost(url, { htype: String(period) });
110
122
  const days = parseFullSchedule(body, this.educationType);
111
- this.cache?.set("schedule", cacheKey, days);
123
+ await this.cache?.set("schedule", cacheKey, days);
112
124
  return days;
113
125
  }
114
126
  /**
@@ -137,17 +149,17 @@ export class TtClient {
137
149
  }
138
150
  // --- Search / Discovery ---
139
151
  async getFaculties() {
140
- const cached = this.cache?.get("faculties", "all");
152
+ const cached = await this.cache?.get("faculties", "all");
141
153
  if (cached)
142
154
  return cached;
143
155
  const { body } = await this.authGet(`${BASE}/`);
144
156
  const data = parseFacultyButtons(body);
145
- this.cache?.set("faculties", "all", data);
157
+ await this.cache?.set("faculties", "all", data);
146
158
  return data;
147
159
  }
148
160
  async getGroupsForFaculty(opts) {
149
161
  const cacheKey = String(opts.facultyId);
150
- const cached = this.cache?.get("groups", cacheKey);
162
+ const cached = await this.cache?.get("groups", cacheKey);
151
163
  if (cached)
152
164
  return cached;
153
165
  const { body } = await this.authPost(`${BASE}/`, {
@@ -155,30 +167,42 @@ export class TtClient {
155
167
  pertt: this.pertt,
156
168
  });
157
169
  const data = parseGroupButtons(body);
158
- this.cache?.set("groups", cacheKey, data);
170
+ await this.cache?.set("groups", cacheKey, data);
159
171
  return data;
160
172
  }
161
173
  async searchGroup(opts) {
174
+ const cacheKey = `search:${opts.name}:${this.pertt}`;
175
+ const cached = await this.cache?.get("groups", cacheKey);
176
+ if (cached)
177
+ return cached;
162
178
  const { body } = await this.authPost(`${BASE}/`, {
163
179
  grname: opts.name,
164
180
  findgr: "найти",
165
181
  hfac: "0",
166
182
  pertt: this.pertt,
167
183
  });
168
- return parseGroupButtons(body);
184
+ const data = parseGroupButtons(body);
185
+ await this.cache?.set("groups", cacheKey, data);
186
+ return data;
169
187
  }
170
188
  /**
171
189
  * Search audiences by name (substring match). The server requires at
172
190
  * least 3 characters in the query.
173
191
  */
174
192
  async searchAudience(opts) {
193
+ const cacheKey = `search:${opts.name}:${this.pertt}`;
194
+ const cached = await this.cache?.get("audiences", cacheKey);
195
+ if (cached)
196
+ return cached;
175
197
  const { body } = await this.authPost(`${BASE}/`, {
176
198
  audname: opts.name,
177
199
  findaud: "найти",
178
200
  hfac: "0",
179
201
  pertt: this.pertt,
180
202
  });
181
- return parseAudienceButtons(body);
203
+ const data = parseAudienceButtons(body);
204
+ await this.cache?.set("audiences", cacheKey, data);
205
+ return data;
182
206
  }
183
207
  /**
184
208
  * Get every audience known to the system in a single request.
@@ -189,13 +213,19 @@ export class TtClient {
189
213
  * the full list of (id, name) pairs.
190
214
  */
191
215
  async getAudiences() {
216
+ const cacheKey = `all:${this.pertt}`;
217
+ const cached = await this.cache?.get("audiences", cacheKey);
218
+ if (cached)
219
+ return cached;
192
220
  const { body } = await this.authPost(`${BASE}/`, {
193
221
  audname: "%%%",
194
222
  findaud: "найти",
195
223
  hfac: "0",
196
224
  pertt: this.pertt,
197
225
  });
198
- return parseAudienceButtons(body);
226
+ const data = parseAudienceButtons(body);
227
+ await this.cache?.set("audiences", cacheKey, data);
228
+ return data;
199
229
  }
200
230
  /**
201
231
  * Resolve an audience id from its exact name by searching and
@@ -208,37 +238,50 @@ export class TtClient {
208
238
  }
209
239
  /** Fetch the audience's display name from its schedule page. */
210
240
  async getAudienceName(audienceId) {
241
+ const cacheKey = String(audienceId);
242
+ const cached = await this.cache?.get("audienceNames", cacheKey);
243
+ if (cached !== null && cached !== undefined)
244
+ return cached;
245
+ const cachedInfo = await this.cache?.get("audienceInfo", cacheKey);
246
+ if (cachedInfo) {
247
+ return cachedInfo.name ?? null;
248
+ }
211
249
  const { body } = await this.authGet(`${BASE}/index/audtt/aud/${audienceId}`);
212
- return parseAudienceName(body);
250
+ const name = parseAudienceName(body);
251
+ const info = parseAudienceInfo(body);
252
+ await this.cache?.set("audienceNames", cacheKey, name);
253
+ if (info)
254
+ await this.cache?.set("audienceInfo", cacheKey, info);
255
+ return name;
213
256
  }
214
257
  /**
215
258
  * Fetch detailed info about an audience (building, floor, usage,
216
259
  * image URLs for the audience photo, building photo and floor plan).
217
260
  */
218
261
  async getAudienceInfo(audienceId) {
219
- const cached = this.cache?.get("audienceInfo", String(audienceId));
262
+ const cached = await this.cache?.get("audienceInfo", String(audienceId));
220
263
  if (cached)
221
264
  return cached;
222
265
  const { body } = await this.authGet(`${BASE}/index/audtt/aud/${audienceId}`);
223
266
  const info = parseAudienceInfo(body);
224
267
  if (info)
225
- this.cache?.set("audienceInfo", String(audienceId), info);
268
+ await this.cache?.set("audienceInfo", String(audienceId), info);
226
269
  return info;
227
270
  }
228
271
  async fetchAudienceSchedule(audienceId, period) {
229
272
  const cacheKey = `audience:${audienceId}:${period}`;
230
- const cached = this.cache?.get("schedule", cacheKey);
273
+ const cached = await this.cache?.get("schedule", cacheKey);
231
274
  if (cached)
232
275
  return cached;
233
276
  const url = `${BASE}/index/audtt/aud/${audienceId}`;
234
277
  const { body } = await this.authPost(url, { htype: String(period) });
235
278
  const days = parseAudienceFullSchedule(body);
236
- this.cache?.set("schedule", cacheKey, days);
279
+ await this.cache?.set("schedule", cacheKey, days);
237
280
  // Cache audience info from the same page to avoid an extra request.
238
- if (!this.cache?.get("audienceInfo", String(audienceId))) {
281
+ if (!(await this.cache?.get("audienceInfo", String(audienceId)))) {
239
282
  const info = parseAudienceInfo(body);
240
283
  if (info)
241
- this.cache?.set("audienceInfo", String(audienceId), info);
284
+ await this.cache?.set("audienceInfo", String(audienceId), info);
242
285
  }
243
286
  return days;
244
287
  }
@@ -260,24 +303,48 @@ export class TtClient {
260
303
  return new Schedule(opts.audienceId, schedules, opts.period, this.educationType);
261
304
  }
262
305
  async getCachedAudienceImage(cacheKey, fetchUrl) {
263
- const cached = this.cache?.get("audienceImages", cacheKey);
306
+ const cached = this.cache?.getLocal("audienceImages", cacheKey) ??
307
+ await this.cache?.get("audienceImages", cacheKey);
264
308
  if (cached !== null && cached !== undefined) {
265
309
  const entry = cached;
266
- return entry.data ? Buffer.from(entry.data, "base64") : null;
310
+ if (entry.data !== undefined) {
311
+ return entry.data ? Buffer.from(entry.data, "base64") : null;
312
+ }
313
+ if (entry.blobKey && this.blobAdapter) {
314
+ const buf = await this.blobAdapter.get(entry.blobKey);
315
+ if (buf) {
316
+ this.cache?.setLocal("audienceImages", cacheKey, {
317
+ data: buf.toString("base64"),
318
+ });
319
+ return buf;
320
+ }
321
+ }
267
322
  }
268
323
  const url = await fetchUrl();
269
324
  if (!url) {
270
- this.cache?.set("audienceImages", cacheKey, { data: null });
325
+ await this.cache?.set("audienceImages", cacheKey, { data: null });
271
326
  return null;
272
327
  }
273
328
  const buf = await this.authGetBuffer(`${BASE}${url}`);
274
329
  if (buf.length === 0) {
275
- this.cache?.set("audienceImages", cacheKey, { data: null });
330
+ await this.cache?.set("audienceImages", cacheKey, { data: null });
276
331
  return null;
277
332
  }
278
- this.cache?.set("audienceImages", cacheKey, {
279
- data: buf.toString("base64"),
280
- });
333
+ if (this.blobAdapter) {
334
+ const blobKey = `tt/audience-images/${cacheKey}`;
335
+ this.cache?.setLocal("audienceImages", cacheKey, {
336
+ data: buf.toString("base64"),
337
+ });
338
+ await this.blobAdapter.put(blobKey, buf, {
339
+ ttl: this.cache?.ttl("audienceImages"),
340
+ });
341
+ await this.cache?.setExternal("audienceImages", cacheKey, { blobKey });
342
+ }
343
+ else {
344
+ await this.cache?.set("audienceImages", cacheKey, {
345
+ data: buf.toString("base64"),
346
+ });
347
+ }
281
348
  return buf;
282
349
  }
283
350
  /** Get the audience photo (audimage). Returns null if missing. */
@@ -293,38 +360,44 @@ export class TtClient {
293
360
  return this.getCachedAudienceImage(`floor:${audienceId}`, async () => (await this.getAudienceInfo(audienceId))?.floorplanUrl);
294
361
  }
295
362
  async searchTeacher(opts) {
363
+ const cacheKey = `search:${opts.name}:${this.pertt}`;
364
+ const cached = await this.cache?.get("teachers", cacheKey);
365
+ if (cached)
366
+ return cached;
296
367
  const { body } = await this.authPost(`${BASE}/`, {
297
368
  techname: opts.name,
298
369
  findtech: "найти",
299
370
  hfac: "0",
300
371
  pertt: this.pertt,
301
372
  });
302
- return parseTeacherButtons(body);
373
+ const data = parseTeacherButtons(body);
374
+ await this.cache?.set("teachers", cacheKey, data);
375
+ return data;
303
376
  }
304
377
  // --- Teacher schedule ---
305
378
  async getTeachers() {
306
- const cached = this.cache?.get("teachers", "all");
379
+ const cached = await this.cache?.get("teachers", "all");
307
380
  if (cached)
308
381
  return cached;
309
382
  const { body } = await this.authGet(`${BASE}/index/tech`);
310
383
  const data = parseTeacherButtons(body);
311
- this.cache?.set("teachers", "all", data);
384
+ await this.cache?.set("teachers", "all", data);
312
385
  return data;
313
386
  }
314
387
  async fetchTeacherSchedule(teacherId, period) {
315
388
  const cacheKey = `teacher:${teacherId}:${period}`;
316
- const cached = this.cache?.get("schedule", cacheKey);
389
+ const cached = await this.cache?.get("schedule", cacheKey);
317
390
  if (cached)
318
391
  return cached;
319
392
  const url = `${BASE}/index/techtt/tech/${teacherId}`;
320
393
  const { body } = await this.authPost(url, { htype: String(period) });
321
394
  const days = parseTeacherFullSchedule(body, this.educationType);
322
- this.cache?.set("schedule", cacheKey, days);
395
+ await this.cache?.set("schedule", cacheKey, days);
323
396
  // Cache teacher info from the same page to avoid extra requests
324
- if (!this.cache?.get("teacherInfo", String(teacherId))) {
397
+ if (!(await this.cache?.get("teacherInfo", String(teacherId)))) {
325
398
  const info = parseTeacherInfo(body);
326
399
  if (info)
327
- this.cache?.set("teacherInfo", String(teacherId), info);
400
+ await this.cache?.set("teacherInfo", String(teacherId), info);
328
401
  }
329
402
  return days;
330
403
  }
@@ -346,14 +419,14 @@ export class TtClient {
346
419
  return new Schedule(opts.teacherId, schedules, opts.period, this.educationType, undefined, undefined, true);
347
420
  }
348
421
  async getTeacherInfo(teacherId) {
349
- const cached = this.cache?.get("teacherInfo", String(teacherId));
422
+ const cached = await this.cache?.get("teacherInfo", String(teacherId));
350
423
  if (cached)
351
424
  return cached;
352
425
  const url = `${BASE}/index/techtt/tech/${teacherId}`;
353
426
  const { body } = await this.authGet(url);
354
427
  const info = parseTeacherInfo(body);
355
428
  if (info)
356
- this.cache?.set("teacherInfo", String(teacherId), info);
429
+ await this.cache?.set("teacherInfo", String(teacherId), info);
357
430
  return info;
358
431
  }
359
432
  /**
@@ -363,21 +436,45 @@ export class TtClient {
363
436
  */
364
437
  async getTeacherPhoto(teacherId) {
365
438
  const photoCacheKey = String(teacherId);
366
- const cachedPhoto = this.cache?.get("teacherPhotos", photoCacheKey);
439
+ const cachedPhoto = this.cache?.getLocal("teacherPhotos", photoCacheKey) ??
440
+ await this.cache?.get("teacherPhotos", photoCacheKey);
367
441
  if (cachedPhoto !== null && cachedPhoto !== undefined) {
368
442
  const entry = cachedPhoto;
369
- return entry.data ? Buffer.from(entry.data, "base64") : null;
443
+ if (entry.data !== undefined) {
444
+ return entry.data ? Buffer.from(entry.data, "base64") : null;
445
+ }
446
+ if (entry.blobKey && this.blobAdapter) {
447
+ const buf = await this.blobAdapter.get(entry.blobKey);
448
+ if (buf) {
449
+ this.cache?.setLocal("teacherPhotos", photoCacheKey, {
450
+ data: buf.toString("base64"),
451
+ });
452
+ return buf;
453
+ }
454
+ }
370
455
  }
371
456
  // Get teacher info (may already be cached from schedule fetch)
372
457
  const info = await this.getTeacherInfo(teacherId);
373
458
  if (!info?.photoUrl) {
374
- this.cache?.set("teacherPhotos", photoCacheKey, { data: null });
459
+ await this.cache?.set("teacherPhotos", photoCacheKey, { data: null });
375
460
  return null;
376
461
  }
377
462
  const photoBuffer = await this.authGetBuffer(`${BASE}${info.photoUrl}`);
378
- this.cache?.set("teacherPhotos", photoCacheKey, {
379
- data: photoBuffer.toString("base64"),
380
- });
463
+ if (this.blobAdapter) {
464
+ const blobKey = `tt/teacher-photos/${teacherId}`;
465
+ this.cache?.setLocal("teacherPhotos", photoCacheKey, {
466
+ data: photoBuffer.toString("base64"),
467
+ });
468
+ await this.blobAdapter.put(blobKey, photoBuffer, {
469
+ ttl: this.cache?.ttl("teacherPhotos"),
470
+ });
471
+ await this.cache?.setExternal("teacherPhotos", photoCacheKey, { blobKey });
472
+ }
473
+ else {
474
+ await this.cache?.set("teacherPhotos", photoCacheKey, {
475
+ data: photoBuffer.toString("base64"),
476
+ });
477
+ }
381
478
  return photoBuffer;
382
479
  }
383
480
  /**
@@ -387,20 +484,44 @@ export class TtClient {
387
484
  */
388
485
  async getTeacherPhotoLazy(teacherId) {
389
486
  const photoCacheKey = String(teacherId);
390
- const cachedPhoto = this.cache?.get("teacherPhotos", photoCacheKey);
487
+ const cachedPhoto = this.cache?.getLocal("teacherPhotos", photoCacheKey) ??
488
+ await this.cache?.get("teacherPhotos", photoCacheKey);
391
489
  if (cachedPhoto !== null && cachedPhoto !== undefined) {
392
490
  const entry = cachedPhoto;
393
- return entry.data ? Buffer.from(entry.data, "base64") : null;
491
+ if (entry.data !== undefined) {
492
+ return entry.data ? Buffer.from(entry.data, "base64") : null;
493
+ }
494
+ if (entry.blobKey && this.blobAdapter) {
495
+ const buf = await this.blobAdapter.get(entry.blobKey);
496
+ if (buf) {
497
+ this.cache?.setLocal("teacherPhotos", photoCacheKey, {
498
+ data: buf.toString("base64"),
499
+ });
500
+ return buf;
501
+ }
502
+ }
394
503
  }
395
504
  const url = `${BASE}/index/photo/tech/${teacherId}/id/${teacherId}`;
396
505
  const photoBuffer = await this.authGetBuffer(url);
397
506
  if (photoBuffer.length === 0) {
398
- this.cache?.set("teacherPhotos", photoCacheKey, { data: null });
507
+ await this.cache?.set("teacherPhotos", photoCacheKey, { data: null });
399
508
  return null;
400
509
  }
401
- this.cache?.set("teacherPhotos", photoCacheKey, {
402
- data: photoBuffer.toString("base64"),
403
- });
510
+ if (this.blobAdapter) {
511
+ const blobKey = `tt/teacher-photos/${teacherId}`;
512
+ this.cache?.setLocal("teacherPhotos", photoCacheKey, {
513
+ data: photoBuffer.toString("base64"),
514
+ });
515
+ await this.blobAdapter.put(blobKey, photoBuffer, {
516
+ ttl: this.cache?.ttl("teacherPhotos"),
517
+ });
518
+ await this.cache?.setExternal("teacherPhotos", photoCacheKey, { blobKey });
519
+ }
520
+ else {
521
+ await this.cache?.set("teacherPhotos", photoCacheKey, {
522
+ data: photoBuffer.toString("base64"),
523
+ });
524
+ }
404
525
  return photoBuffer;
405
526
  }
406
527
  }
@@ -2,6 +2,7 @@ import { parseHtml, parseTeacher, parseWeekParity, parseWeeks, text, } from "../
2
2
  import { parseSemesterScheduleWith } from "./full-schedule.js";
3
3
  import { parseGroupsString } from "./groups.js";
4
4
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
5
+ import { LESSON_TYPE_GLOBAL_RE, LESSON_TYPE_RE, SUBGROUP_ANNOTATION_RE, SUBGROUP_RE, WEEKS_GLOBAL_RE, WEEKS_RE, } from "./patterns.js";
5
6
  export function parseAudienceInfo(html) {
6
7
  const doc = parseHtml(html);
7
8
  // Name: <span class="htext"><nobr>Аудитория <span style="color: blue;">NAME</span></nobr></span>
@@ -105,9 +106,9 @@ function parseAudienceSemesterEntry(el) {
105
106
  const subject = subjectEl ? text(subjectEl) : "";
106
107
  if (!subject)
107
108
  return null;
108
- const typeMatch = cleanText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс)\)/);
109
- const weeksMatch = cleanText.match(/\(([^)]*нед\.?[^)]*)\)/);
110
- const subgroupMatch = cleanText.match(/(\d+)\s*подгруппа/);
109
+ const typeMatch = cleanText.match(LESSON_TYPE_RE);
110
+ const weeksMatch = cleanText.match(WEEKS_RE);
111
+ const subgroupMatch = cleanText.match(SUBGROUP_RE);
111
112
  const weekParity = parseWeekParity(cleanHtml);
112
113
  // Audience entries layout:
113
114
  // <span blue>SUBJ</span> (TYPE) (WEEKS) <br>TEACHER<br>GROUPS
@@ -122,9 +123,9 @@ function parseAudienceSemesterEntry(el) {
122
123
  const textLines = [];
123
124
  for (const p of parts) {
124
125
  const cleaned = p
125
- .replace(/\((лк|пр|лб|зач|экз|зчО|кр|конс)\)/g, "")
126
- .replace(/\([^)]*нед\.?[^)]*\)/g, "")
127
- .replace(/\(\d+\s*подгруппа\)/g, "")
126
+ .replace(LESSON_TYPE_GLOBAL_RE, "")
127
+ .replace(WEEKS_GLOBAL_RE, "")
128
+ .replace(SUBGROUP_ANNOTATION_RE, "")
128
129
  .trim();
129
130
  if (cleaned)
130
131
  textLines.push(cleaned);
@@ -1,6 +1,7 @@
1
1
  import { parseHtml, parseTeacher, parseTime, parseWeekParity, parseWeeks, text, } from "../../common/parse.js";
2
2
  import { getLessonNumber } from "../utils/index.js";
3
3
  import { parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
4
+ import { FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
4
5
  export function parseFullSchedule(html, educationType) {
5
6
  const doc = parseHtml(html);
6
7
  const edType = educationType ?? 1 /* EducationType.HigherEducation */;
@@ -94,11 +95,11 @@ function parseSemesterEntry(el) {
94
95
  const subject = subjectEl ? text(subjectEl) : "";
95
96
  if (!subject)
96
97
  return null;
97
- const typeMatch = cleanText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс)\)/);
98
- const weeksMatch = cleanText.match(/\(([^)]*нед\.?[^)]*)\)/);
98
+ const typeMatch = cleanText.match(LESSON_TYPE_RE);
99
+ const weeksMatch = cleanText.match(WEEKS_RE);
99
100
  const roomMatch = cleanHtml.match(/(?:<sup>[^<]*<\/sup>)?([А-Яа-яA-Za-z]-\d+)/);
100
101
  const teacherMatch = cleanHtml.match(/<br\s*\/?>\s*([^<]+?)(?:<br|<\/td|<div|<i|$)/);
101
- const subgroupMatch = cleanText.match(/(\d+)\s*подгруппа/);
102
+ const subgroupMatch = cleanText.match(SUBGROUP_RE);
102
103
  const weekParity = parseWeekParity(cleanHtml);
103
104
  return {
104
105
  room: roomMatch?.[1] ?? "",
@@ -169,7 +170,7 @@ function parseSessionEntry(td) {
169
170
  const roomMatch = fullHtml.match(/^([^<]*?)\s*<span/);
170
171
  const room = roomMatch ? roomMatch[1].trim() : "";
171
172
  // Type: parenthesized text after </span>, case-insensitive
172
- const typeMatch = plainText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс\.?|Экз)\)/i);
173
+ const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
173
174
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
174
175
  // Time: after <br>, format HH:MM - HH:MM
175
176
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
@@ -1,3 +1,4 @@
1
+ import { SUBGROUP_ANNOTATION_RE_I } from "./patterns.js";
1
2
  /**
2
3
  * Split a raw "groups" string from the schedule HTML into individual group names.
3
4
  *
@@ -16,14 +17,15 @@ export function parseGroupsString(raw) {
16
17
  if (!raw)
17
18
  return [];
18
19
  const cleaned = raw
19
- .replace(/\s*\(\s*\d+\s*подгруппа\s*\)\s*/gi, " ")
20
+ .replace(SUBGROUP_ANNOTATION_RE_I, " ")
20
21
  .trim();
21
22
  if (!cleaned)
22
23
  return [];
23
24
  const out = [];
24
- // A new group starts from its code; trailing qualifiers like "ин" belong to
25
- // the current group until the next code begins.
26
- const startsGroup = (token) => /^[A-ZА-ЯЁ]{1,}-\d{1,2}-\d{2,4}[A-ZА-ЯЁa-zа-яё]*$/u.test(token);
25
+ // A new group starts from a code-like token. Optional qualifiers like "ин"
26
+ // may be attached either directly ("М-30-25ин") or as a separate tail
27
+ // ("УП-51-23 ин"), so they stay with the current group until the next code.
28
+ const startsGroup = (token) => /^[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+$/u.test(token);
27
29
  let current = "";
28
30
  for (const token of cleaned.split(/\s+/)) {
29
31
  if (startsGroup(token)) {
@@ -1,5 +1,6 @@
1
1
  import { parseTeacher, text } from "../../common/parse.js";
2
2
  import { parseGroupsString } from "./groups.js";
3
+ import { LESSON_TYPE_PATTERN, LESSON_TYPE_RE, LESSON_TYPE_RE_I, SUBGROUP_RE, } from "./patterns.js";
3
4
  export function parseDate(dd, mm, yyyy) {
4
5
  return new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
5
6
  }
@@ -17,19 +18,31 @@ export function parseTransferDiv(div) {
17
18
  if (!subject)
18
19
  return null;
19
20
  const roomMatch = divHtml.match(/([А-Яа-яA-Za-z]-\d+)/);
20
- const typeMatch = divText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс)\)/);
21
- // Teacher: last text line that isn't a subgroup marker
22
- const parts = divHtml.split(/<br\s*\/?>/);
21
+ const typeMatch = divText.match(LESSON_TYPE_RE);
22
+ const parts = divHtml
23
+ .split(/<br\s*\/?>/i)
24
+ .map((part) => part.replace(/<[^>]*>/g, "").trim())
25
+ .filter((part) => part.length > 0);
23
26
  let teacherPart = "";
24
- for (let i = parts.length - 1; i >= 0; i--) {
25
- const clean = parts[i].replace(/<[^>]*>/g, "").trim();
26
- if (clean && !/подгруппа/.test(clean)) {
27
- teacherPart = clean;
28
- break;
27
+ let groupsPart = "";
28
+ for (const part of parts.slice(1)) {
29
+ const cleaned = part.trim();
30
+ if (!cleaned)
31
+ continue;
32
+ const groups = parseGroupsString(cleaned);
33
+ if (groups.length > 0) {
34
+ groupsPart = cleaned;
35
+ continue;
36
+ }
37
+ const isLessonMeta = cleaned.includes(subject) ||
38
+ (roomMatch?.[1] != null && cleaned.includes(roomMatch[1])) ||
39
+ LESSON_TYPE_RE_I.test(cleaned);
40
+ if (!isLessonMeta && !teacherPart) {
41
+ teacherPart = cleaned;
29
42
  }
30
43
  }
31
44
  const transfer = { targetDate, fromDate, fromSlot, subject };
32
- const subgroupMatch = divText.match(/(\d+)\s*подгруппа/);
45
+ const subgroupMatch = divText.match(SUBGROUP_RE);
33
46
  return {
34
47
  transfer,
35
48
  entry: {
@@ -38,7 +51,7 @@ export function parseTransferDiv(div) {
38
51
  type: typeMatch?.[1] ?? "",
39
52
  weeks: { from: 0, to: 0 },
40
53
  teacher: parseTeacher(teacherPart),
41
- groups: [],
54
+ groups: parseGroupsString(groupsPart),
42
55
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
43
56
  transfer,
44
57
  },
@@ -86,9 +99,9 @@ export function parseSubstituteForDiv(div) {
86
99
  if (!subject)
87
100
  return null;
88
101
  const roomMatch = divHtml.match(/(?:<br\s*\/?>)\s*([А-Яа-яA-Za-z]-\d+)/);
89
- const typeMatch = divText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс)\)/);
90
- const groupsMatch = divHtml.match(/\((?:лк|пр|лб|зач|экз|зчО|кр|конс)\)\s*(?:<br\s*\/?>)\s*([^<]+?)(?:\s*<i|$)/);
91
- const subgroupMatch = divText.match(/(\d+)\s*подгруппа/);
102
+ const typeMatch = divText.match(LESSON_TYPE_RE);
103
+ const groupsMatch = divHtml.match(new RegExp(`\\((?:${LESSON_TYPE_PATTERN})\\)\\s*(?:<br\\s*\\/?>)\\s*([^<]+?)(?:\\s*<i|$)`));
104
+ const subgroupMatch = divText.match(SUBGROUP_RE);
92
105
  return {
93
106
  entry: {
94
107
  room: roomMatch?.[1] ?? "",
@@ -0,0 +1,11 @@
1
+ export declare const LESSON_TYPE_PATTERN = "\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441";
2
+ export declare const FLEXIBLE_LESSON_TYPE_PATTERN = "\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441\\.?|\u042D\u043A\u0437";
3
+ export declare const LESSON_TYPE_RE: RegExp;
4
+ export declare const LESSON_TYPE_RE_I: RegExp;
5
+ export declare const FLEXIBLE_LESSON_TYPE_RE_I: RegExp;
6
+ export declare const LESSON_TYPE_GLOBAL_RE: RegExp;
7
+ export declare const WEEKS_RE: RegExp;
8
+ export declare const WEEKS_GLOBAL_RE: RegExp;
9
+ export declare const SUBGROUP_RE: RegExp;
10
+ export declare const SUBGROUP_ANNOTATION_RE: RegExp;
11
+ export declare const SUBGROUP_ANNOTATION_RE_I: RegExp;
@@ -0,0 +1,11 @@
1
+ export const LESSON_TYPE_PATTERN = "лк|пр|лб|зач|экз|конс";
2
+ export const FLEXIBLE_LESSON_TYPE_PATTERN = `${LESSON_TYPE_PATTERN}\\.?|Экз`;
3
+ export const LESSON_TYPE_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`);
4
+ export const LESSON_TYPE_RE_I = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "i");
5
+ export const FLEXIBLE_LESSON_TYPE_RE_I = new RegExp(`\\((${FLEXIBLE_LESSON_TYPE_PATTERN})\\)`, "i");
6
+ export const LESSON_TYPE_GLOBAL_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "g");
7
+ export const WEEKS_RE = /\(([^)]*нед\.?[^)]*)\)/;
8
+ export const WEEKS_GLOBAL_RE = /\([^)]*нед\.?[^)]*\)/g;
9
+ export const SUBGROUP_RE = /(\d+)\s*подгруппа/;
10
+ export const SUBGROUP_ANNOTATION_RE = /\(\d+\s*подгруппа\)/g;
11
+ export const SUBGROUP_ANNOTATION_RE_I = /\s*\(\s*\d+\s*подгруппа\s*\)\s*/gi;
@@ -3,6 +3,7 @@ import { getLessonNumber } from "../utils/index.js";
3
3
  import { parseSemesterScheduleWith } from "./full-schedule.js";
4
4
  import { parseGroupsString } from "./groups.js";
5
5
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
6
+ import { FLEXIBLE_LESSON_TYPE_PATTERN, FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
6
7
  export function parseTeacherFullSchedule(html, educationType) {
7
8
  const doc = parseHtml(html);
8
9
  const edType = educationType ?? 1 /* EducationType.HigherEducation */;
@@ -52,11 +53,11 @@ function parseTeacherSemesterEntry(el) {
52
53
  const subject = subjectEl ? text(subjectEl) : "";
53
54
  if (!subject)
54
55
  return null;
55
- const typeMatch = cleanText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс)\)/);
56
- const weeksMatch = cleanText.match(/\(([^)]*нед\.?[^)]*)\)/);
56
+ const typeMatch = cleanText.match(LESSON_TYPE_RE);
57
+ const weeksMatch = cleanText.match(WEEKS_RE);
57
58
  const roomMatch = cleanHtml.match(/(?:<sup>[^<]*<\/sup>)?([А-Яа-яA-Za-z]-\d+)/);
58
59
  const groupsMatch = cleanHtml.match(/<br\s*\/?>\s*([^<]+?)(?:<br|<\/td|<div|<i|$)/);
59
- const subgroupMatch = cleanText.match(/(\d+)\s*подгруппа/);
60
+ const subgroupMatch = cleanText.match(SUBGROUP_RE);
60
61
  const weekParity = parseWeekParity(cleanHtml);
61
62
  return {
62
63
  room: roomMatch?.[1] ?? "",
@@ -121,10 +122,10 @@ function parseTeacherSessionEntry(td) {
121
122
  return null;
122
123
  const roomMatch = fullHtml.match(/^([^<]*?)\s*<span/);
123
124
  const room = roomMatch ? roomMatch[1].trim() : "";
124
- const typeMatch = plainText.match(/\((лк|пр|лб|зач|экз|зчО|кр|конс\.?|Экз)\)/i);
125
+ const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
125
126
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
126
127
  // Groups: text between </span> type and <br>time
127
- const groupsMatch = fullHtml.match(/\((?:лк|пр|лб|зач|экз|зчО|кр|конс\.?|Экз)\)\s*([^<]+?)\s*<br/i);
128
+ const groupsMatch = fullHtml.match(new RegExp(`\\((?:${FLEXIBLE_LESSON_TYPE_PATTERN})\\)\\s*([^<]+?)\\s*<br`, "i"));
128
129
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
129
130
  if (!timeMatch)
130
131
  return null;
@@ -1,3 +1,4 @@
1
+ import type { BlobAdapter, CacheAdapter } from "../common/cache.js";
1
2
  import type { Time, WeekRange, Teacher, EducationType } from "../common/types.js";
2
3
  export interface Faculty {
3
4
  id: number;
@@ -148,6 +149,8 @@ export interface CacheConfig {
148
149
  schedule?: number;
149
150
  faculties?: number;
150
151
  groups?: number;
152
+ audiences?: number;
153
+ audienceNames?: number;
151
154
  teachers?: number;
152
155
  teacherInfo?: number;
153
156
  teacherPhotos?: number;
@@ -157,4 +160,6 @@ export interface CacheConfig {
157
160
  export interface TtClientOptions {
158
161
  educationType?: EducationType;
159
162
  cache?: number | CacheConfig;
163
+ cacheAdapter?: CacheAdapter;
164
+ blobAdapter?: BlobAdapter;
160
165
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "3.0.2",
3
+ "version": "4.0.0",
4
4
  "description": "Node.js library for ChuvSU student portal (lk.chuvsu.ru) and schedule (tt.chuvsu.ru)",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -42,6 +42,7 @@
42
42
  },
43
43
  "scripts": {
44
44
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
45
- "build": "pnpm clean && tsc"
45
+ "build": "pnpm clean && tsc",
46
+ "test": "pnpm build && node --test"
46
47
  }
47
48
  }