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