chuvsu-js 4.2.0 → 5.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 (80) hide show
  1. package/README.md +165 -193
  2. package/dist/browser.d.ts +1 -1
  3. package/dist/browser.js +1 -1
  4. package/dist/common/cache.js +30 -9
  5. package/dist/common/http.d.ts +6 -0
  6. package/dist/common/http.js +8 -1
  7. package/dist/common/parse.d.ts +1 -1
  8. package/dist/common/parse.js +2 -2
  9. package/dist/common/types.d.ts +8 -2
  10. package/dist/common/types.js +12 -0
  11. package/dist/core.d.ts +10 -0
  12. package/dist/core.js +5 -0
  13. package/dist/index.d.ts +5 -3
  14. package/dist/index.js +4 -4
  15. package/dist/lk/client.d.ts +8 -6
  16. package/dist/lk/client.js +51 -23
  17. package/dist/lk/types.d.ts +7 -7
  18. package/dist/node.d.ts +1 -0
  19. package/dist/node.js +1 -0
  20. package/dist/parsers.d.ts +3 -0
  21. package/dist/parsers.js +2 -0
  22. package/dist/tt/client.d.ts +55 -63
  23. package/dist/tt/client.js +477 -226
  24. package/dist/tt/domain/directory.d.ts +14 -0
  25. package/dist/tt/domain/directory.js +116 -0
  26. package/dist/tt/domain/ids.d.ts +7 -0
  27. package/dist/tt/domain/ids.js +20 -0
  28. package/dist/tt/domain/index.d.ts +7 -0
  29. package/dist/tt/domain/index.js +5 -0
  30. package/dist/tt/domain/normalize.d.ts +7 -0
  31. package/dist/tt/domain/normalize.js +65 -0
  32. package/dist/tt/domain/repository.d.ts +51 -0
  33. package/dist/tt/domain/repository.js +805 -0
  34. package/dist/tt/domain/schedule.d.ts +37 -0
  35. package/dist/tt/domain/schedule.js +292 -0
  36. package/dist/tt/domain/types.d.ts +175 -0
  37. package/dist/tt/domain/types.js +1 -0
  38. package/dist/tt/observations.d.ts +11 -0
  39. package/dist/tt/observations.js +131 -0
  40. package/dist/tt/parse/audience.d.ts +3 -3
  41. package/dist/tt/parse/audience.js +47 -15
  42. package/dist/tt/parse/entry-parts.d.ts +1 -0
  43. package/dist/tt/parse/entry-parts.js +5 -2
  44. package/dist/tt/parse/full-schedule.d.ts +9 -4
  45. package/dist/tt/parse/full-schedule.js +32 -41
  46. package/dist/tt/parse/groups.d.ts +1 -1
  47. package/dist/tt/parse/groups.js +1 -1
  48. package/dist/tt/parse/index.d.ts +4 -4
  49. package/dist/tt/parse/index.js +4 -4
  50. package/dist/tt/parse/lists.d.ts +6 -5
  51. package/dist/tt/parse/lists.js +7 -2
  52. package/dist/tt/parse/overlays.d.ts +5 -4
  53. package/dist/tt/parse/overlays.js +25 -18
  54. package/dist/tt/parse/teacher.d.ts +2 -3
  55. package/dist/tt/parse/teacher.js +14 -56
  56. package/dist/tt/parse/webinars.js +3 -4
  57. package/dist/tt/types.d.ts +54 -69
  58. package/dist/tt/utils/date.d.ts +4 -0
  59. package/dist/tt/utils/date.js +30 -1
  60. package/dist/tt/utils/index.d.ts +2 -3
  61. package/dist/tt/utils/index.js +2 -3
  62. package/dist/tt/utils/period.d.ts +4 -4
  63. package/dist/tt/utils/period.js +9 -8
  64. package/dist/tt/utils/semester.d.ts +4 -4
  65. package/dist/tt/utils/semester.js +25 -5
  66. package/dist/tt/utils/time-slots.d.ts +3 -5
  67. package/dist/tt/utils/time-slots.js +19 -34
  68. package/dist/tt/webinars.d.ts +7 -0
  69. package/dist/tt/webinars.js +38 -0
  70. package/docs/fixture-review.md +56 -0
  71. package/docs/testing.md +23 -0
  72. package/docs/v5-architecture.md +144 -0
  73. package/docs/v5-migration.md +451 -0
  74. package/package.json +17 -7
  75. package/dist/shared.d.ts +0 -9
  76. package/dist/shared.js +0 -7
  77. package/dist/tt/schedule.d.ts +0 -55
  78. package/dist/tt/schedule.js +0 -233
  79. package/dist/tt/utils/lessons.d.ts +0 -16
  80. package/dist/tt/utils/lessons.js +0 -173
package/README.md CHANGED
@@ -1,14 +1,15 @@
1
1
  # chuvsu-js
2
2
 
3
- Node.js библиотека для работы с порталами ЧувГУ:
3
+ Типизированная библиотека для сервисов ЧГУ им. И. Н. Ульянова:
4
4
 
5
- - **tt.chuvsu.ru** — расписание занятий (факультеты, группы, преподаватели)
6
- - **lk.chuvsu.ru** — личный кабинет студента (персональные данные)
5
+ - `tt.chuvsu.ru` — расписания групп, преподавателей, аудиторий и вебинары;
6
+ - `lk.chuvsu.ru` — личный кабинет студента.
7
7
 
8
- > [!WARNING]
9
- > Пока не доработана, код и архитектура говно (написано Claude) и надо бы его 10 раз переписать.
10
- > Не надейтесь на правильный вывод расписания, возможны расхождения но я их фикшу оперативно,
11
- > как появляется больше информации.
8
+ В версии 5 расписание хранится в едином каноническом репозитории. Если одна
9
+ пара встречается в расписаниях группы, преподавателя и аудитории, она сохраняет
10
+ один ID, а сведения из разных страниц дополняют друг друга.
11
+
12
+ Переход с версии 4: [`docs/v5-migration.md`](docs/v5-migration.md).
12
13
 
13
14
  ## Установка
14
15
 
@@ -16,286 +17,257 @@ Node.js библиотека для работы с порталами ЧувГ
16
17
  npm install chuvsu-js
17
18
  ```
18
19
 
19
- ## Быстрый старт
20
+ Рекомендуются Node.js 20+ и ESM.
20
21
 
21
- ### Расписание (TtClient)
22
+ ## Быстрый старт
22
23
 
23
24
  ```ts
24
- import { TtClient } from "chuvsu-js";
25
+ import { TimetableClient } from "chuvsu-js";
25
26
 
26
- const tt = new TtClient();
27
+ const client = new TimetableClient({ cache: 15 * 60_000 });
28
+ await client.loginAsGuest();
27
29
 
28
- // Войти гостем (без учётной записи)
29
- await tt.loginAsGuest();
30
+ const [group] = await client.searchGroups("КТ-41-24");
31
+ const schedule = await client.getGroupSchedule(group.id);
30
32
 
31
- // Найти группу по названию
32
- const groups = await tt.searchGroup({ name: "КТ-41-24" });
33
- console.log(groups); // [{ id: 8919, name: "КТ-41-24", specialty: "...", profile: "..." }]
34
-
35
- // Получить расписание группы
36
- const schedule = await tt.getSchedule({ groupId: groups[0].id });
37
-
38
- // Расписание на сегодня
39
- const today = schedule.today();
40
- for (const lesson of today) {
33
+ for (const lesson of schedule.today({ subgroup: 1 })) {
41
34
  console.log(
42
- `${lesson.start.hours}:${lesson.start.minutes} — ${lesson.subject} (${lesson.type})`,
35
+ lesson.id,
36
+ lesson.subject,
37
+ lesson.teachers.values,
38
+ lesson.rooms.values,
43
39
  );
44
40
  }
45
-
46
- // С фильтром по подгруппе
47
- schedule.today({ subgroup: 1 });
48
-
49
- // На завтра
50
- schedule.tomorrow();
51
-
52
- // На текущую неделю
53
- schedule.thisWeek();
54
-
55
- // Текущая пара
56
- schedule.currentLesson();
57
41
  ```
58
42
 
59
- ### Личный кабинет (LkClient)
43
+ У каждой конкретной пары есть `id`. У повторяющейся пары также есть
44
+ `seriesId`:
60
45
 
61
46
  ```ts
62
- import { LkClient } from "chuvsu-js";
63
-
64
- const lk = new LkClient({ cache: 60_000 });
65
- await lk.login({ email: "student@mail.ru", password: "password" });
66
-
67
- const data = await lk.getPersonalData();
68
- console.log(`${data.lastName} ${data.firstName}, группа ${data.group}`);
69
-
70
- // Получить ID группы для использования с TtClient
71
- const groupId = await lk.getGroupId();
47
+ const series = schedule.series();
48
+ const monday = schedule.weekday(1, { week: 4 });
49
+ const day = schedule.on(new Date(2026, 8, 7));
50
+ const week = schedule.week(4);
51
+ const current = schedule.current();
72
52
  ```
73
53
 
74
- ## API
75
-
76
- ### TtClient
77
-
78
- Клиент для работы с расписанием (`tt.chuvsu.ru`).
54
+ ID не строится из даты, аудитории или времени. Перенос и смена аудитории сами
55
+ по себе не создают новую сущность. Чтобы ID сохранялись между запусками,
56
+ подключите постоянное хранилище репозитория.
79
57
 
80
- #### Конструктор
58
+ ## Расписания разных владельцев
81
59
 
82
60
  ```ts
83
- new TtClient(options?: TtClientOptions)
61
+ await client.getGroupSchedule(groupId);
62
+ await client.getTeacherSchedule(teacherId);
63
+ await client.getRoomSchedule(roomId);
84
64
  ```
85
65
 
86
- | Опция | Тип | По умолчанию | Описание |
87
- | --------------- | ----------------------- | ----------------- | -------------------------------------------------------------- |
88
- | `educationType` | `EducationType` | `HigherEducation` | Тип образования: высшее (1) или СПО (2) |
89
- | `cache` | `number \| CacheConfig` | — | TTL кеша в мс. Число задаёт единый TTL, объект — по категориям |
90
- | `cacheAdapter` | `CacheAdapter` | — | Внешний L2-кеш для JSON-данных (например Redis/БД) |
91
- | `blobAdapter` | `BlobAdapter` | — | Хранилище бинарных данных, например S3/R2/MinIO |
92
-
93
- #### Авторизация
66
+ Общий метод:
94
67
 
95
68
  ```ts
96
- // С учётной записью
97
- await tt.login({ email: "...", password: "..." });
98
-
99
- // Гостевой вход
100
- await tt.loginAsGuest();
69
+ await client.getSchedule({
70
+ type: "teacher",
71
+ teacher: { id: teacherId, name: "Иванов Иван Иванович" },
72
+ });
101
73
  ```
102
74
 
103
- #### Получение расписания
75
+ Можно загрузить только нужные периоды:
104
76
 
105
77
  ```ts
106
- const schedule = await tt.getSchedule({ groupId, period? });
78
+ import { AcademicPeriod } from "chuvsu-js";
79
+
80
+ await client.getGroupSchedule(groupId, {
81
+ periods: [AcademicPeriod.FallSemester],
82
+ });
107
83
  ```
108
84
 
109
- Возвращает объект `Schedule`, который позволяет получать расписание локально, без дополнительных запросов к серверу.
85
+ ## Дополнение данных из разных страниц
110
86
 
111
- #### Поиск
87
+ Страница группы часто содержит только фамилию и инициалы преподавателя.
88
+ Страница преподавателя сообщает список групп, а страница аудитории — другие
89
+ связи той же пары. Все это считается неполными наблюдениями:
112
90
 
113
91
  ```ts
114
- // Список факультетов
115
- const faculties = await tt.getFaculties();
92
+ const groupSchedule = await client.getGroupSchedule(groupId);
93
+ const before = groupSchedule.on(date);
116
94
 
117
- // Группы факультета
118
- const groups = await tt.getGroupsForFaculty({ facultyId });
95
+ await client.getTeacherSchedule(teacherId);
96
+ await client.getRoomSchedule(roomId);
119
97
 
120
- // Поиск группы по названию
121
- const groups = await tt.searchGroup({ name: "ЗИ" });
122
-
123
- // Поиск преподавателя
124
- const teachers = await tt.searchTeacher({ name: "Иванов" });
98
+ const after = groupSchedule.on(date);
99
+ // ID прежний, но в связях могли появиться дополнительные группы и ID сущностей.
125
100
  ```
126
101
 
127
- #### Период
102
+ `Schedule` — живое представление репозитория: следующий запрос через уже
103
+ созданный объект видит новые данные.
128
104
 
129
- ```ts
130
- // Текущий учебный период
131
- const period = tt.getCurrentPeriod();
132
- ```
133
-
134
- #### Кеш
105
+ Связи имеют вид `RelationSet<T>`:
135
106
 
136
107
  ```ts
137
- // Очистить весь кеш или по категории
138
- tt.clearCache();
139
- tt.clearCache("schedule");
140
-
141
- // Экспорт/импорт (для сохранения между запусками)
142
- const data = tt.exportCache();
143
- tt.importCache(data);
108
+ lesson.rooms // { values: RoomRef[], completeness: "unknown" | "partial" | "complete" }
144
109
  ```
145
110
 
146
- Категории кеша: `schedule`, `faculties`, `groups`, `audiences`, `audienceNames`, `teachers`, `teacherInfo`, `teacherPhotos`, `audienceInfo`, `audienceImages`.
111
+ `unknown` означает, что страница ничего не сообщила. `complete` с пустым
112
+ `values` означает, что отсутствие известно явно. Поэтому пустая строка
113
+ аудитории в канонической модели не используется.
147
114
 
148
- Если передать `cacheAdapter`, библиотека использует двухуровневый кеш:
149
- - L1: in-memory кеш внутри процесса
150
- - L2: внешний адаптер (`cacheAdapter`)
115
+ ## Номер пары и время
151
116
 
152
- Если передать `blobAdapter`, фото преподавателей, аудиторий, корпусов и планов этажей будут храниться во внешнем blob/object storage, а во внешнем JSON-кеше сохранятся только метаданные с ключом blob-объекта.
153
-
154
- ### Schedule
155
-
156
- Объект расписания группы. Все методы синхронные — данные уже загружены.
157
-
158
- #### Свойства
117
+ Портал иногда показывает противоречивые номер пары и время. В v5 это независимые
118
+ утверждения:
159
119
 
160
120
  ```ts
161
- schedule.groupId; // ID группы
162
- schedule.period; // Учебный период
163
- schedule.days; // Сырые данные (FullScheduleDay[])
121
+ lesson.slotNumber // 6
122
+ lesson.time // { start: { hours: 16, minutes: 40 }, end: ... }
164
123
  ```
165
124
 
166
- #### Расписание по дате
125
+ Номер не вычисляется из времени, а время — из номера. При объединении одна
126
+ совпавшая величина может подтвердить пару, если остальные признаки надежны;
127
+ разные дни или несовместимое чередование объединяться не будут.
167
128
 
168
- ```ts
169
- // На сегодня
170
- schedule.today({ subgroup?: number });
129
+ Даты без времени представлены строкой `LocalDate` формата `YYYY-MM-DD`, например
130
+ `lesson.scheduledDate === "2026-09-03"`. Это исключает сдвиги даты из-за UTC.
171
131
 
172
- // На завтра
173
- schedule.tomorrow({ subgroup?: number });
132
+ Экзамены, консультации и другие строки сессии являются сразу конкретными
133
+ `LessonOccurrence`: дата берется со страницы, `seriesId` и `recurrence` у них
134
+ нет. Значения-заглушки наподобие `weeks: { from: 0, to: 0 }` не используются.
174
135
 
175
- // На конкретную дату
176
- schedule.forDate(date: Date, { subgroup?: number });
177
- ```
136
+ ## Справочник сущностей без каскадных запросов
178
137
 
179
- #### Расписание по неделе
138
+ Загрузка расписания никогда автоматически не запускает поиск преподавателей,
139
+ групп или аудиторий. Обычные методы поиска наполняют общий справочник:
180
140
 
181
141
  ```ts
182
- // На текущую неделю
183
- schedule.thisWeek({ subgroup?: number });
142
+ await client.getTeachers();
143
+ await client.getRooms();
144
+ await client.getFacultyGroups(facultyId);
184
145
 
185
- // На конкретную неделю
186
- schedule.forWeek(week?: number, { subgroup?: number });
146
+ const teacher = await client.resolveTeacher("Иванов И. И.");
187
147
  ```
188
148
 
189
- #### Расписание по дню недели
149
+ По умолчанию разрешение использует только уже известные данные. Один явный
150
+ поиск можно разрешить отдельно:
190
151
 
191
152
  ```ts
192
- // По дню недели (0 = воскресенье, 1 = понедельник, ...)
193
- schedule.forDay(weekday: number, { subgroup?: number, week?: number });
153
+ const teacher = await client.resolveTeacher("Иванов И. И.", {
154
+ strategy: "search",
155
+ });
194
156
  ```
195
157
 
196
- #### Текущая пара
158
+ Или заранее загрузить нужные справочники:
197
159
 
198
160
  ```ts
199
- const lesson = schedule.currentLesson({ subgroup?: number });
161
+ await client.preloadDirectory({
162
+ teachers: true,
163
+ rooms: true,
164
+ facultyIds: [19],
165
+ });
200
166
  ```
201
167
 
202
- #### Утилиты семестра
168
+ ID присваивается сокращенному имени только при единственном совпадении.
169
+
170
+ ## Постоянный репозиторий
171
+
172
+ TTL-кеш ответов и хранилище канонических ID независимы:
203
173
 
204
174
  ```ts
205
- // Номер текущей недели
206
- schedule.getWeekNumber(date?: Date);
175
+ const client = new TimetableClient({
176
+ cache: 15 * 60_000,
177
+ cacheAdapter,
178
+ repositoryAdapter,
179
+ });
180
+ ```
207
181
 
208
- // Все недели семестра
209
- schedule.getSemesterWeeks(weekCount?: number);
182
+ `TimetableRepositoryAdapter` можно реализовать поверх БД, KV-хранилища или
183
+ файлового сервиса. Запись использует ревизии compare-and-set, чтобы параллельные
184
+ процессы не перезаписывали установленные ID.
210
185
 
211
- // Начало семестра
212
- schedule.getSemesterStart();
186
+ ```ts
187
+ const snapshot = await client.exportRepository();
213
188
  ```
214
189
 
215
- Утилиты также доступны как standalone функции:
190
+ Для одного процесса есть память:
216
191
 
217
192
  ```ts
218
193
  import {
219
- getSemesterStart,
220
- getSemesterWeeks,
221
- getWeekNumber,
222
- Period,
194
+ MemoryTimetableRepositoryAdapter,
195
+ TimetableClient,
223
196
  } from "chuvsu-js";
224
197
 
225
- getSemesterStart({ period: Period.FallSemester, year: 2025 });
226
- getSemesterWeeks({ period: Period.SpringSemester });
227
- getWeekNumber({ period: Period.SpringSemester });
198
+ const repositoryAdapter = new MemoryTimetableRepositoryAdapter();
199
+ const client = new TimetableClient({ repositoryAdapter });
228
200
  ```
229
201
 
230
- ### LkClient
202
+ ## Использование в браузере
231
203
 
232
- Клиент для личного кабинета (`lk.chuvsu.ru`).
204
+ `chuvsu-js/browser` не содержит `undici`, сертификатов ЧГУ, `Buffer` и HTML-
205
+ парсера. Браузер может получить снимок с сервера и выполнять запросы локально:
233
206
 
234
207
  ```ts
235
- const lk = new LkClient({ cache?: number | LkCacheConfig });
236
- await lk.login({ email, password });
237
- const data = await lk.getPersonalData();
238
- const groupId = await lk.getGroupId();
239
- ```
240
-
241
- `cache` работает так же, как в `TtClient`: число задаёт единый TTL в мс, объект — TTL по категориям (`personalData`, `photo`, `groupId`).
242
- Также поддерживаются `cacheAdapter` и `blobAdapter`.
208
+ import { Schedule, TimetableRepository } from "chuvsu-js/browser";
243
209
 
244
- **PersonalData** содержит: `lastName`, `firstName`, `patronymic`, `sex`, `birthday`, `recordBookNumber`, `faculty`, `specialty`, `profile`, `group`, `course`, `email`, `phone`.
210
+ const repository = new TimetableRepository({ snapshot });
211
+ const schedule = new Schedule(
212
+ repository,
213
+ { type: "group", group: { id: 8919, name: "КТ-41-24" } },
214
+ 2026,
215
+ );
245
216
 
246
- ## Типы
217
+ const lessons = schedule.on(new Date(2026, 8, 7));
218
+ ```
247
219
 
248
- ### Period
220
+ Сетевые клиенты остаются Node-only: авторизация, cookie, сертификаты и CORS
221
+ портала нельзя надежно перенести в чистый браузерный пакет.
249
222
 
250
223
  ```ts
251
- enum Period {
252
- FallSemester = 1, // Осенний семестр
253
- WinterSession = 2, // Зимняя сессия
254
- SpringSemester = 3, // Весенний семестр
255
- SummerSession = 4, // Летняя сессия
256
- }
224
+ import { TimetableClient } from "chuvsu-js"; // Node + ядро
225
+ import { TimetableClient } from "chuvsu-js/node"; // явно Node
226
+ import { TimetableRepository } from "chuvsu-js/browser";
227
+ import { parseGroupSchedule } from "chuvsu-js/parsers";
257
228
  ```
258
229
 
259
- ### EducationType
230
+ ## Поиск и метаданные
260
231
 
261
232
  ```ts
262
- enum EducationType {
263
- HigherEducation = 1, // Высшее образование
264
- VocationalEducation = 2, // СПО
265
- }
233
+ const faculties = await client.getFaculties();
234
+ const groups = await client.getFacultyGroups(facultyId);
235
+ const groupsByName = await client.searchGroups("КТ-41");
236
+ const teachers = await client.searchTeachers("Иванов");
237
+ const rooms = await client.searchRooms("Г-40");
238
+
239
+ const teacherInfo = await client.getTeacherInfo(teacherId);
240
+ const teacherPhoto = await client.getTeacherPhoto(teacherId);
241
+ const roomInfo = await client.getRoomInfo(roomId);
242
+ const roomPhoto = await client.getRoomImage(roomId);
243
+ const buildingPhoto = await client.getRoomBuildingImage(roomId);
244
+ const floorPlan = await client.getRoomFloorPlan(roomId);
266
245
  ```
267
246
 
268
- ### Lesson
247
+ Фото преподавателя загружается по уже известному прямому URL без лишнего
248
+ запроса страницы преподавателя.
249
+
250
+ ## Личный кабинет
269
251
 
270
252
  ```ts
271
- interface Lesson {
272
- number: number; // Номер пары
273
- start: LessonTime; // Начало { date, hours, minutes }
274
- end: LessonTime; // Конец { date, hours, minutes }
275
- subject: string; // Предмет
276
- type: string; // Тип (лекция, практика, лаб. работа)
277
- room: string; // Аудитория
278
- teacher: Teacher; // Преподаватель { name, position?, degree? }
279
- weeks: WeekRange; // Диапазон недель { from, to }
280
- subgroup?: number; // Подгруппа
281
- weekParity?: "even" | "odd"; // Чётность недели
282
- }
283
- ```
253
+ import { StudentPortalClient } from "chuvsu-js";
284
254
 
285
- ## Обработка ошибок
255
+ const portal = new StudentPortalClient({ cache: 60_000 });
256
+ await portal.login({ email: "student@example.com", password: "password" });
286
257
 
287
- ```ts
288
- import { AuthError, ParseError } from "chuvsu-js";
289
-
290
- try {
291
- await tt.login({ email: "...", password: "wrong" });
292
- } catch (e) {
293
- if (e instanceof AuthError) {
294
- console.error("Неверные данные для входа");
295
- }
296
- }
258
+ const profile = await portal.getProfile();
259
+ const photo = await portal.getProfilePhoto();
260
+ const groupId = await portal.getTimetableGroupId();
297
261
  ```
298
262
 
299
- ## Лицензия
263
+ ## Разработка
264
+
265
+ ```bash
266
+ pnpm build
267
+ pnpm test
268
+ pnpm test:coverage
269
+ pnpm fixtures:test
270
+ ```
300
271
 
301
- MIT
272
+ Подробности архитектуры: [`docs/v5-architecture.md`](docs/v5-architecture.md).
273
+ Границы и честная интерпретация тестов: [`docs/testing.md`](docs/testing.md).
package/dist/browser.d.ts CHANGED
@@ -1 +1 @@
1
- export * from "./shared.js";
1
+ export * from "./core.js";
package/dist/browser.js CHANGED
@@ -1 +1 @@
1
- export * from "./shared.js";
1
+ export * from "./core.js";
@@ -2,7 +2,14 @@ export class Cache {
2
2
  ttls;
3
3
  store = new Map();
4
4
  constructor(ttls) {
5
- this.ttls = ttls;
5
+ for (const [category, ttl] of Object.entries(ttls)) {
6
+ if (ttl != null &&
7
+ ttl !== Infinity &&
8
+ (!Number.isFinite(ttl) || ttl < 0)) {
9
+ throw new RangeError(`Invalid cache TTL for ${category}`);
10
+ }
11
+ }
12
+ this.ttls = { ...ttls };
6
13
  }
7
14
  get(category, key) {
8
15
  const ttl = this.ttls[category];
@@ -15,13 +22,13 @@ export class Cache {
15
22
  this.store.delete(`${category}:${key}`);
16
23
  return null;
17
24
  }
18
- return entry.data;
25
+ return structuredClone(entry.data);
19
26
  }
20
27
  set(category, key, data) {
21
28
  if (this.ttls[category] == null)
22
29
  return;
23
30
  this.store.set(`${category}:${key}`, {
24
- data,
31
+ data: structuredClone(data),
25
32
  timestamp: Date.now(),
26
33
  });
27
34
  }
@@ -38,11 +45,25 @@ export class Cache {
38
45
  }
39
46
  }
40
47
  export() {
41
- return Object.fromEntries(this.store);
48
+ return structuredClone(Object.fromEntries(this.store));
42
49
  }
43
50
  import(data) {
44
51
  for (const [key, entry] of Object.entries(data)) {
45
- this.store.set(key, entry);
52
+ const separator = key.indexOf(":");
53
+ const category = separator < 1 || separator === key.length - 1
54
+ ? ""
55
+ : key.slice(0, separator);
56
+ if (this.ttls[category] == null) {
57
+ throw new TypeError(`Invalid or disabled cache category in ${key}`);
58
+ }
59
+ if (entry == null ||
60
+ typeof entry !== "object" ||
61
+ !Number.isFinite(entry.timestamp) ||
62
+ entry.timestamp < 0 ||
63
+ !("data" in entry)) {
64
+ throw new TypeError(`Invalid cache entry: ${key}`);
65
+ }
66
+ this.store.set(key, structuredClone(entry));
46
67
  }
47
68
  }
48
69
  }
@@ -51,8 +72,8 @@ export class HybridCache {
51
72
  ttls;
52
73
  adapter;
53
74
  constructor(ttls, adapter) {
54
- this.ttls = ttls;
55
- this.memory = new Cache(ttls);
75
+ this.ttls = { ...ttls };
76
+ this.memory = new Cache(this.ttls);
56
77
  this.adapter = adapter;
57
78
  }
58
79
  ttl(category) {
@@ -75,7 +96,7 @@ export class HybridCache {
75
96
  if (external === null || external === undefined)
76
97
  return null;
77
98
  this.memory.set(category, key, external);
78
- return external;
99
+ return this.memory.get(category, key);
79
100
  }
80
101
  async set(category, key, data) {
81
102
  this.memory.set(category, key, data);
@@ -85,7 +106,7 @@ export class HybridCache {
85
106
  const ttl = this.ttls[category];
86
107
  if (ttl == null || !this.adapter)
87
108
  return;
88
- await this.adapter.set(category, key, data, ttl);
109
+ await this.adapter.set(category, key, structuredClone(data), ttl);
89
110
  }
90
111
  async clear(category) {
91
112
  this.memory.clear(category);
@@ -3,11 +3,17 @@ export interface HttpResponse {
3
3
  body: string;
4
4
  location?: string;
5
5
  }
6
+ export interface HttpBufferResponse {
7
+ status: number;
8
+ body: Buffer;
9
+ contentType?: string;
10
+ }
6
11
  export declare class HttpClient {
7
12
  private cookies;
8
13
  private cookieHeader;
9
14
  private saveCookies;
10
15
  get(url: string, followRedirects?: boolean): Promise<HttpResponse>;
11
16
  getBuffer(url: string): Promise<Buffer>;
17
+ getBufferResponse(url: string): Promise<HttpBufferResponse>;
12
18
  post(url: string, data: Record<string, string>, followRedirects?: boolean): Promise<HttpResponse>;
13
19
  }
@@ -33,13 +33,20 @@ export class HttpClient {
33
33
  };
34
34
  }
35
35
  async getBuffer(url) {
36
+ return (await this.getBufferResponse(url)).body;
37
+ }
38
+ async getBufferResponse(url) {
36
39
  const res = await fetch(url, {
37
40
  method: "GET",
38
41
  headers: { Cookie: this.cookieHeader() },
39
42
  dispatcher: agent,
40
43
  });
41
44
  this.saveCookies(res.headers);
42
- return Buffer.from(await res.arrayBuffer());
45
+ return {
46
+ status: res.status,
47
+ body: Buffer.from(await res.arrayBuffer()),
48
+ contentType: res.headers.get("content-type") ?? undefined,
49
+ };
43
50
  }
44
51
  async post(url, data, followRedirects = true) {
45
52
  const body = new URLSearchParams(data).toString();
@@ -4,7 +4,7 @@ export declare function text(el: Element | null): string;
4
4
  /** Parse "HH:MM" into {hours, minutes} */
5
5
  export declare function parseTime(s: string): Time;
6
6
  /** Parse "2 нед." -> {from:2,to:2}, "6 - 8 нед." -> {from:6,to:8} */
7
- export declare function parseWeeks(s: string): WeekRange;
7
+ export declare function parseWeeks(s: string): WeekRange | undefined;
8
8
  /** Parse <sup>*</sup> / <sup>**</sup> markers: * = odd week, ** = even week */
9
9
  export declare function parseWeekParity(html: string): "even" | "odd" | undefined;
10
10
  export declare function parseTeacher(s: string): Teacher;
@@ -18,7 +18,7 @@ export function parseWeeks(s) {
18
18
  const single = s.match(/(\d+)/);
19
19
  if (single)
20
20
  return { from: parseInt(single[1]), to: parseInt(single[1]) };
21
- return { from: 0, to: 0 };
21
+ return undefined;
22
22
  }
23
23
  /** Parse <sup>*</sup> / <sup>**</sup> markers: * = odd week, ** = even week */
24
24
  export function parseWeekParity(html) {
@@ -33,7 +33,7 @@ export function parseTeacher(s) {
33
33
  return { name: "" };
34
34
  const posMatch = trimmed.match(/^(доц\.|проф\.|ст\.преп\.|ст\. преп\.|преп\.|асс\.|зав\.каф\.)\s*/);
35
35
  const afterPos = posMatch ? trimmed.slice(posMatch[0].length) : trimmed;
36
- const degMatch = afterPos.match(/^([кд]\.[а-яё.-]+н\.)\s*/);
36
+ const degMatch = afterPos.match(/^((?:[кд]\.[а-яё.-]+н\.|к\.иск\.))\s*/);
37
37
  const name = degMatch ? afterPos.slice(degMatch[0].length).trim() : afterPos.trim();
38
38
  const result = { name };
39
39
  if (posMatch)