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.
- package/README.md +165 -205
- package/dist/browser.d.ts +1 -1
- package/dist/browser.js +1 -1
- package/dist/common/cache.js +30 -9
- package/dist/common/http.d.ts +6 -0
- package/dist/common/http.js +8 -1
- package/dist/common/parse.d.ts +1 -1
- package/dist/common/parse.js +3 -3
- package/dist/common/types.d.ts +8 -2
- package/dist/common/types.js +12 -0
- package/dist/core.d.ts +10 -0
- package/dist/core.js +5 -0
- package/dist/index.d.ts +5 -3
- package/dist/index.js +4 -4
- package/dist/lk/client.d.ts +8 -6
- package/dist/lk/client.js +51 -23
- package/dist/lk/types.d.ts +7 -7
- package/dist/node.d.ts +1 -0
- package/dist/node.js +1 -0
- package/dist/parsers.d.ts +3 -0
- package/dist/parsers.js +2 -0
- package/dist/tt/client.d.ts +55 -63
- package/dist/tt/client.js +477 -226
- package/dist/tt/domain/directory.d.ts +14 -0
- package/dist/tt/domain/directory.js +116 -0
- package/dist/tt/domain/ids.d.ts +7 -0
- package/dist/tt/domain/ids.js +20 -0
- package/dist/tt/domain/index.d.ts +7 -0
- package/dist/tt/domain/index.js +5 -0
- package/dist/tt/domain/normalize.d.ts +7 -0
- package/dist/tt/domain/normalize.js +65 -0
- package/dist/tt/domain/repository.d.ts +51 -0
- package/dist/tt/domain/repository.js +805 -0
- package/dist/tt/domain/schedule.d.ts +37 -0
- package/dist/tt/domain/schedule.js +292 -0
- package/dist/tt/domain/types.d.ts +175 -0
- package/dist/tt/domain/types.js +1 -0
- package/dist/tt/observations.d.ts +11 -0
- package/dist/tt/observations.js +131 -0
- package/dist/tt/parse/audience.d.ts +3 -3
- package/dist/tt/parse/audience.js +58 -34
- package/dist/tt/parse/entry-parts.d.ts +8 -0
- package/dist/tt/parse/entry-parts.js +52 -0
- package/dist/tt/parse/full-schedule.d.ts +9 -4
- package/dist/tt/parse/full-schedule.js +49 -51
- package/dist/tt/parse/groups.d.ts +1 -1
- package/dist/tt/parse/groups.js +1 -1
- package/dist/tt/parse/index.d.ts +4 -4
- package/dist/tt/parse/index.js +4 -4
- package/dist/tt/parse/lists.d.ts +6 -5
- package/dist/tt/parse/lists.js +7 -2
- package/dist/tt/parse/overlays.d.ts +5 -4
- package/dist/tt/parse/overlays.js +13 -14
- package/dist/tt/parse/patterns.d.ts +2 -2
- package/dist/tt/parse/patterns.js +3 -3
- package/dist/tt/parse/teacher.d.ts +2 -3
- package/dist/tt/parse/teacher.js +21 -65
- package/dist/tt/parse/webinars.js +3 -4
- package/dist/tt/types.d.ts +56 -69
- package/dist/tt/utils/date.d.ts +4 -0
- package/dist/tt/utils/date.js +30 -1
- package/dist/tt/utils/index.d.ts +2 -3
- package/dist/tt/utils/index.js +2 -3
- package/dist/tt/utils/period.d.ts +4 -4
- package/dist/tt/utils/period.js +9 -8
- package/dist/tt/utils/semester.d.ts +4 -4
- package/dist/tt/utils/semester.js +25 -5
- package/dist/tt/utils/time-slots.d.ts +3 -5
- package/dist/tt/utils/time-slots.js +19 -34
- package/dist/tt/webinars.d.ts +7 -0
- package/dist/tt/webinars.js +38 -0
- package/docs/fixture-review.md +56 -0
- package/docs/testing.md +23 -0
- package/docs/v5-architecture.md +144 -0
- package/docs/v5-migration.md +451 -0
- package/package.json +19 -7
- package/dist/shared.d.ts +0 -9
- package/dist/shared.js +0 -7
- package/dist/tt/schedule.d.ts +0 -55
- package/dist/tt/schedule.js +0 -233
- package/dist/tt/utils/lessons.d.ts +0 -16
- package/dist/tt/utils/lessons.js +0 -173
package/README.md
CHANGED
|
@@ -1,14 +1,15 @@
|
|
|
1
1
|
# chuvsu-js
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Типизированная библиотека для сервисов ЧГУ им. И. Н. Ульянова:
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
5
|
+
- `tt.chuvsu.ru` — расписания групп, преподавателей, аудиторий и вебинары;
|
|
6
|
+
- `lk.chuvsu.ru` — личный кабинет студента.
|
|
7
7
|
|
|
8
|
-
|
|
9
|
-
|
|
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
|
-
|
|
30
|
-
`--group=КТ-41-24` (аргументы передаются после `--`). Значения авторизации
|
|
31
|
-
скрипт не выводит.
|
|
32
|
-
|
|
33
|
-
### Расписание (TtClient)
|
|
22
|
+
## Быстрый старт
|
|
34
23
|
|
|
35
24
|
```ts
|
|
36
|
-
import {
|
|
37
|
-
|
|
38
|
-
const tt = new TtClient();
|
|
25
|
+
import { TimetableClient } from "chuvsu-js";
|
|
39
26
|
|
|
40
|
-
|
|
41
|
-
await
|
|
27
|
+
const client = new TimetableClient({ cache: 15 * 60_000 });
|
|
28
|
+
await client.loginAsGuest();
|
|
42
29
|
|
|
43
|
-
|
|
44
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
43
|
+
У каждой конкретной пары есть `id`. У повторяющейся пары также есть
|
|
44
|
+
`seriesId`:
|
|
72
45
|
|
|
73
46
|
```ts
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
const
|
|
77
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
54
|
+
ID не строится из даты, аудитории или времени. Перенос и смена аудитории сами
|
|
55
|
+
по себе не создают новую сущность. Чтобы ID сохранялись между запусками,
|
|
56
|
+
подключите постоянное хранилище репозитория.
|
|
89
57
|
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
#### Конструктор
|
|
58
|
+
## Расписания разных владельцев
|
|
93
59
|
|
|
94
60
|
```ts
|
|
95
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
+
import { AcademicPeriod } from "chuvsu-js";
|
|
79
|
+
|
|
80
|
+
await client.getGroupSchedule(groupId, {
|
|
81
|
+
periods: [AcademicPeriod.FallSemester],
|
|
82
|
+
});
|
|
119
83
|
```
|
|
120
84
|
|
|
121
|
-
|
|
85
|
+
## Дополнение данных из разных страниц
|
|
122
86
|
|
|
123
|
-
|
|
87
|
+
Страница группы часто содержит только фамилию и инициалы преподавателя.
|
|
88
|
+
Страница преподавателя сообщает список групп, а страница аудитории — другие
|
|
89
|
+
связи той же пары. Все это считается неполными наблюдениями:
|
|
124
90
|
|
|
125
91
|
```ts
|
|
126
|
-
|
|
127
|
-
const
|
|
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
|
-
|
|
95
|
+
await client.getTeacherSchedule(teacherId);
|
|
96
|
+
await client.getRoomSchedule(roomId);
|
|
134
97
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
- L1: in-memory кеш внутри процесса
|
|
162
|
-
- L2: внешний адаптер (`cacheAdapter`)
|
|
111
|
+
`unknown` означает, что страница ничего не сообщила. `complete` с пустым
|
|
112
|
+
`values` означает, что отсутствие известно явно. Поэтому пустая строка
|
|
113
|
+
аудитории в канонической модели не используется.
|
|
163
114
|
|
|
164
|
-
|
|
115
|
+
## Номер пары и время
|
|
165
116
|
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
Объект расписания группы. Все методы синхронные — данные уже загружены.
|
|
169
|
-
|
|
170
|
-
#### Свойства
|
|
117
|
+
Портал иногда показывает противоречивые номер пары и время. В v5 это независимые
|
|
118
|
+
утверждения:
|
|
171
119
|
|
|
172
120
|
```ts
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
181
|
-
|
|
182
|
-
schedule.today({ subgroup?: number });
|
|
129
|
+
Даты без времени представлены строкой `LocalDate` формата `YYYY-MM-DD`, например
|
|
130
|
+
`lesson.scheduledDate === "2026-09-03"`. Это исключает сдвиги даты из-за UTC.
|
|
183
131
|
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
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
|
-
|
|
205
|
-
|
|
153
|
+
const teacher = await client.resolveTeacher("Иванов И. И.", {
|
|
154
|
+
strategy: "search",
|
|
155
|
+
});
|
|
206
156
|
```
|
|
207
157
|
|
|
208
|
-
|
|
158
|
+
Или заранее загрузить нужные справочники:
|
|
209
159
|
|
|
210
160
|
```ts
|
|
211
|
-
|
|
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
|
-
|
|
175
|
+
const client = new TimetableClient({
|
|
176
|
+
cache: 15 * 60_000,
|
|
177
|
+
cacheAdapter,
|
|
178
|
+
repositoryAdapter,
|
|
179
|
+
});
|
|
180
|
+
```
|
|
219
181
|
|
|
220
|
-
|
|
221
|
-
|
|
182
|
+
`TimetableRepositoryAdapter` можно реализовать поверх БД, KV-хранилища или
|
|
183
|
+
файлового сервиса. Запись использует ревизии compare-and-set, чтобы параллельные
|
|
184
|
+
процессы не перезаписывали установленные ID.
|
|
222
185
|
|
|
223
|
-
|
|
224
|
-
|
|
186
|
+
```ts
|
|
187
|
+
const snapshot = await client.exportRepository();
|
|
225
188
|
```
|
|
226
189
|
|
|
227
|
-
|
|
190
|
+
Для одного процесса есть память:
|
|
228
191
|
|
|
229
192
|
```ts
|
|
230
193
|
import {
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
getWeekNumber,
|
|
234
|
-
Period,
|
|
194
|
+
MemoryTimetableRepositoryAdapter,
|
|
195
|
+
TimetableClient,
|
|
235
196
|
} from "chuvsu-js";
|
|
236
197
|
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
getWeekNumber({ period: Period.SpringSemester });
|
|
198
|
+
const repositoryAdapter = new MemoryTimetableRepositoryAdapter();
|
|
199
|
+
const client = new TimetableClient({ repositoryAdapter });
|
|
240
200
|
```
|
|
241
201
|
|
|
242
|
-
|
|
202
|
+
## Использование в браузере
|
|
243
203
|
|
|
244
|
-
|
|
204
|
+
`chuvsu-js/browser` не содержит `undici`, сертификатов ЧГУ, `Buffer` и HTML-
|
|
205
|
+
парсера. Браузер может получить снимок с сервера и выполнять запросы локально:
|
|
245
206
|
|
|
246
207
|
```ts
|
|
247
|
-
|
|
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
|
-
|
|
254
|
-
|
|
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
|
-
|
|
257
|
-
|
|
258
|
-
## Типы
|
|
217
|
+
const lessons = schedule.on(new Date(2026, 8, 7));
|
|
218
|
+
```
|
|
259
219
|
|
|
260
|
-
|
|
220
|
+
Сетевые клиенты остаются Node-only: авторизация, cookie, сертификаты и CORS
|
|
221
|
+
портала нельзя надежно перенести в чистый браузерный пакет.
|
|
261
222
|
|
|
262
223
|
```ts
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
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
|
-
|
|
230
|
+
## Поиск и метаданные
|
|
272
231
|
|
|
273
232
|
```ts
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
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
|
-
|
|
247
|
+
Фото преподавателя загружается по уже известному прямому URL без лишнего
|
|
248
|
+
запроса страницы преподавателя.
|
|
249
|
+
|
|
250
|
+
## Личный кабинет
|
|
281
251
|
|
|
282
252
|
```ts
|
|
283
|
-
|
|
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
|
-
|
|
300
|
-
|
|
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
|
-
|
|
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 "./
|
|
1
|
+
export * from "./core.js";
|
package/dist/browser.js
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export * from "./
|
|
1
|
+
export * from "./core.js";
|
package/dist/common/cache.js
CHANGED
|
@@ -2,7 +2,14 @@ export class Cache {
|
|
|
2
2
|
ttls;
|
|
3
3
|
store = new Map();
|
|
4
4
|
constructor(ttls) {
|
|
5
|
-
|
|
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
|
-
|
|
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
|
|
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);
|
package/dist/common/http.d.ts
CHANGED
|
@@ -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
|
}
|
package/dist/common/http.js
CHANGED
|
@@ -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
|
|
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();
|
package/dist/common/parse.d.ts
CHANGED
|
@@ -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;
|
package/dist/common/parse.js
CHANGED
|
@@ -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
|
|
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(/^([кд]\.[а-яё.-]
|
|
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)
|