chuvsu-js 4.0.0 → 4.1.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/dist/shared.d.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  export { Schedule } from "./tt/schedule.js";
2
- export { parseGroupsString } from "./tt/parse/index.js";
3
- export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, getHolidayTransfers, getLessonNumber, getSemesterStart, getSemesterWeeks, getTimeSlots, getWeekNumber, getWeekdayName, isHoliday, isSessionPeriod, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
2
+ export { parseGroupsString, parseWebinars } from "./tt/parse/index.js";
3
+ export { attachWebinarsToLessons, getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, getHolidayTransfers, getLessonNumber, getSemesterStart, getSemesterWeeks, getTimeSlots, getWeekNumber, getWeekdayName, isHoliday, isSessionPeriod, matchWebinarToLesson, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
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
7
  export type { BlobAdapter, BlobPutOptions, CacheAdapter, CacheEntry, } from "./common/cache.js";
8
- export type { Audience, AudienceInfo, CacheConfig, Faculty, FullScheduleDay, FullScheduleSlot, Group, Lesson, LessonTime, LessonTimeSlot, ScheduleEntry, SemesterWeek, SubstituteForInfo, Substitution, TeacherInfo, TransferInfo, TtClientOptions, } from "./tt/types.js";
8
+ export type { Audience, AudienceInfo, CacheConfig, Faculty, FullScheduleDay, FullScheduleSlot, Group, Lesson, LessonTime, LessonTimeSlot, ScheduleEntry, SemesterWeek, SubstituteForInfo, Substitution, TeacherInfo, TransferInfo, TtClientOptions, Webinar, } from "./tt/types.js";
9
9
  export type { LkCacheConfig, LkClientOptions, PersonalData } from "./lk/types.js";
package/dist/shared.js CHANGED
@@ -2,6 +2,6 @@
2
2
  // Both ./index.ts and ./browser.ts re-export everything from here and only
3
3
  // add their platform-specific extras on top.
4
4
  export { Schedule } from "./tt/schedule.js";
5
- export { parseGroupsString } from "./tt/parse/index.js";
6
- export { getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, getHolidayTransfers, getLessonNumber, getSemesterStart, getSemesterWeeks, getTimeSlots, getWeekNumber, getWeekdayName, isHoliday, isSessionPeriod, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
5
+ export { parseGroupsString, parseWebinars } from "./tt/parse/index.js";
6
+ export { attachWebinarsToLessons, getAdjacentSemester, getCompensatingWorkDays, getCurrentPeriod, getEffectiveHolidays, getHolidayTransfers, getLessonNumber, getSemesterStart, getSemesterWeeks, getTimeSlots, getWeekNumber, getWeekdayName, isHoliday, isSessionPeriod, matchWebinarToLesson, RUSSIAN_HOLIDAYS, } from "./tt/utils/index.js";
7
7
  export { AuthError, ParseError, } from "./common/types.js";
@@ -1,7 +1,7 @@
1
1
  import type { CacheEntry } from "../common/cache.js";
2
2
  import { Period } from "../common/types.js";
3
3
  import { Schedule } from "./schedule.js";
4
- import type { Audience, AudienceInfo, Faculty, Group, TeacherInfo, TtClientOptions, CacheConfig } from "./types.js";
4
+ import type { Audience, AudienceInfo, Faculty, Group, TeacherInfo, TtClientOptions, CacheConfig, Webinar } from "./types.js";
5
5
  export declare class TtClient {
6
6
  private http;
7
7
  private educationType;
@@ -36,6 +36,16 @@ export declare class TtClient {
36
36
  groupId: number;
37
37
  period: Period;
38
38
  }): Promise<Schedule>;
39
+ getWebinars(opts?: {
40
+ date?: Date;
41
+ facultyId?: number;
42
+ }): Promise<Webinar[]>;
43
+ getWebinarJoinUrl(opts: {
44
+ webinarId: string | number;
45
+ idType?: number;
46
+ email: string;
47
+ password: string;
48
+ }): Promise<string>;
39
49
  getFaculties(): Promise<Faculty[]>;
40
50
  getGroupsForFaculty(opts: {
41
51
  facultyId: number;
package/dist/tt/client.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { HttpClient } from "../common/http.js";
2
2
  import { HybridCache } from "../common/cache.js";
3
3
  import { AuthError } from "../common/types.js";
4
- import { parseAudienceButtons, parseAudienceFullSchedule, parseAudienceInfo, parseAudienceName, parseGroupButtons, parseFacultyButtons, parseTeacherButtons, parseFullSchedule, parseTeacherFullSchedule, parseTeacherInfo, } from "./parse/index.js";
4
+ import { parseAudienceButtons, parseAudienceFullSchedule, parseAudienceInfo, parseAudienceName, parseGroupButtons, parseFacultyButtons, parseTeacherButtons, parseFullSchedule, parseTeacherFullSchedule, parseTeacherInfo, parseWebinars, } from "./parse/index.js";
5
5
  import { Schedule } from "./schedule.js";
6
6
  const BASE = "https://tt.chuvsu.ru";
7
7
  const AUTH_URL = `${BASE}/auth`;
@@ -23,8 +23,20 @@ function makeUniformCacheConfig(ttl) {
23
23
  teacherPhotos: ttl,
24
24
  audienceInfo: ttl,
25
25
  audienceImages: ttl,
26
+ webinars: ttl,
26
27
  };
27
28
  }
29
+ function formatDate(date) {
30
+ const year = date.getFullYear();
31
+ const month = String(date.getMonth() + 1).padStart(2, "0");
32
+ const day = String(date.getDate()).padStart(2, "0");
33
+ return `${year}-${month}-${day}`;
34
+ }
35
+ function getAcademicYearKey(date = new Date()) {
36
+ const year = date.getFullYear();
37
+ const start = date.getMonth() >= 8 ? year : year - 1;
38
+ return `${start}-${start + 1}`;
39
+ }
28
40
  export class TtClient {
29
41
  http = new HttpClient();
30
42
  educationType;
@@ -113,7 +125,7 @@ export class TtClient {
113
125
  }
114
126
  // --- Schedule ---
115
127
  async fetchSchedule(groupId, period) {
116
- const cacheKey = `${groupId}:${period}`;
128
+ const cacheKey = `${groupId}:${period}:${getAcademicYearKey()}`;
117
129
  const cached = await this.cache?.get("schedule", cacheKey);
118
130
  if (cached)
119
131
  return cached;
@@ -147,6 +159,43 @@ export class TtClient {
147
159
  schedules.set(opts.period, days);
148
160
  return new Schedule(opts.groupId, schedules, opts.period, this.educationType);
149
161
  }
162
+ async getWebinars(opts) {
163
+ const date = opts?.date ?? new Date();
164
+ const facultyId = opts?.facultyId ?? 0;
165
+ const cacheKey = `${formatDate(date)}:${facultyId}:${this.pertt}`;
166
+ const cached = await this.cache?.get("webinars", cacheKey);
167
+ if (cached)
168
+ return cached;
169
+ const { body } = await this.authPost(`${BASE}/webinar`, {
170
+ seldate: formatDate(date),
171
+ selfac: String(facultyId),
172
+ pertt: this.pertt,
173
+ });
174
+ const data = parseWebinars(body);
175
+ await this.cache?.set("webinars", cacheKey, data);
176
+ return data;
177
+ }
178
+ async getWebinarJoinUrl(opts) {
179
+ const { body } = await this.authPost(`${BASE}/webinar/getjoin`, {
180
+ idw: String(opts.webinarId),
181
+ idwt: String(opts.idType ?? 1),
182
+ name: opts.email,
183
+ pass: opts.password,
184
+ auto: "1",
185
+ });
186
+ let parsed;
187
+ try {
188
+ parsed = JSON.parse(body);
189
+ }
190
+ catch {
191
+ throw new AuthError("TT webinar join failed: invalid response");
192
+ }
193
+ const data = parsed;
194
+ if (data.mes !== "SUCCESS" || !data.url?.startsWith("http")) {
195
+ throw new AuthError(data.mes ?? "TT webinar join failed");
196
+ }
197
+ return data.url;
198
+ }
150
199
  // --- Search / Discovery ---
151
200
  async getFaculties() {
152
201
  const cached = await this.cache?.get("faculties", "all");
@@ -3,6 +3,7 @@ import { parseSemesterScheduleWith } from "./full-schedule.js";
3
3
  import { parseGroupsString } from "./groups.js";
4
4
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
5
5
  import { LESSON_TYPE_GLOBAL_RE, LESSON_TYPE_RE, SUBGROUP_ANNOTATION_RE, SUBGROUP_RE, WEEKS_GLOBAL_RE, WEEKS_RE, } from "./patterns.js";
6
+ const DISTANCE_RE = /дистанционно|ДОТ/i;
6
7
  export function parseAudienceInfo(html) {
7
8
  const doc = parseHtml(html);
8
9
  // Name: <span class="htext"><nobr>Аудитория <span style="color: blue;">NAME</span></nobr></span>
@@ -141,6 +142,7 @@ function parseAudienceSemesterEntry(el) {
141
142
  groups: parseGroupsString(groupsLine),
142
143
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
143
144
  weekParity,
145
+ isDistance: DISTANCE_RE.test(cleanText),
144
146
  substitutions: substitutions.length > 0 ? substitutions : undefined,
145
147
  possibleChanges,
146
148
  };
@@ -2,6 +2,7 @@ import { parseHtml, parseTeacher, parseTime, parseWeekParity, parseWeeks, text,
2
2
  import { getLessonNumber } from "../utils/index.js";
3
3
  import { parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
4
4
  import { FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
5
+ const DISTANCE_RE = /дистанционно|ДОТ/i;
5
6
  export function parseFullSchedule(html, educationType) {
6
7
  const doc = parseHtml(html);
7
8
  const edType = educationType ?? 1 /* EducationType.HigherEducation */;
@@ -110,6 +111,7 @@ function parseSemesterEntry(el) {
110
111
  groups: [],
111
112
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
112
113
  weekParity,
114
+ isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
113
115
  substitutions: substitutions.length > 0 ? substitutions : undefined,
114
116
  possibleChanges,
115
117
  };
@@ -162,6 +164,7 @@ function parseSessionEntry(td) {
162
164
  const plainText = text(td);
163
165
  if (!plainText)
164
166
  return null;
167
+ const possibleChanges = (td.getAttribute("class") ?? "").includes("want") || undefined;
165
168
  const subjectEl = td.querySelector('span[style*="color: blue"]');
166
169
  const subject = subjectEl ? text(subjectEl) : "";
167
170
  if (!subject)
@@ -172,6 +175,14 @@ function parseSessionEntry(td) {
172
175
  // Type: parenthesized text after </span>, case-insensitive
173
176
  const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
174
177
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
178
+ const subgroupMatch = plainText.match(SUBGROUP_RE);
179
+ const parts = fullHtml
180
+ .split(/<br\s*\/?>/i)
181
+ .map((part) => part.replace(/<[^>]*>/g, "").trim())
182
+ .filter((part) => part.length > 0);
183
+ const teacherPart = parts.find((part) => !part.includes(subject) &&
184
+ !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
185
+ !SUBGROUP_RE.test(part)) ?? "";
175
186
  // Time: after <br>, format HH:MM - HH:MM
176
187
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
177
188
  if (!timeMatch)
@@ -182,8 +193,11 @@ function parseSessionEntry(td) {
182
193
  subject,
183
194
  type,
184
195
  weeks: { from: 0, to: 0 },
185
- teacher: { name: "" },
196
+ teacher: parseTeacher(teacherPart),
186
197
  groups: [],
198
+ subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
199
+ isDistance: DISTANCE_RE.test(plainText) || DISTANCE_RE.test(room),
200
+ possibleChanges,
187
201
  },
188
202
  timeStart: parseTime(timeMatch[1]),
189
203
  timeEnd: parseTime(timeMatch[2]),
@@ -3,3 +3,4 @@ export { parseAudienceButtons, parseAudienceName, parseFacultyButtons, parseGrou
3
3
  export { parseFullSchedule } from "./full-schedule.js";
4
4
  export { parseAudienceFullSchedule, parseAudienceInfo, } from "./audience.js";
5
5
  export { parseTeacherFullSchedule, parseTeacherInfo, } from "./teacher.js";
6
+ export { parseWebinars } from "./webinars.js";
@@ -3,3 +3,4 @@ export { parseAudienceButtons, parseAudienceName, parseFacultyButtons, parseGrou
3
3
  export { parseFullSchedule } from "./full-schedule.js";
4
4
  export { parseAudienceFullSchedule, parseAudienceInfo, } from "./audience.js";
5
5
  export { parseTeacherFullSchedule, parseTeacherInfo, } from "./teacher.js";
6
+ export { parseWebinars } from "./webinars.js";
@@ -1,6 +1,8 @@
1
1
  import { parseTeacher, text } from "../../common/parse.js";
2
2
  import { parseGroupsString } from "./groups.js";
3
3
  import { LESSON_TYPE_PATTERN, LESSON_TYPE_RE, LESSON_TYPE_RE_I, SUBGROUP_RE, } from "./patterns.js";
4
+ const GROUP_CODE_RE = /[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+/u;
5
+ const DISTANCE_RE = /дистанционно|ДОТ/i;
4
6
  export function parseDate(dd, mm, yyyy) {
5
7
  return new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
6
8
  }
@@ -29,8 +31,7 @@ export function parseTransferDiv(div) {
29
31
  const cleaned = part.trim();
30
32
  if (!cleaned)
31
33
  continue;
32
- const groups = parseGroupsString(cleaned);
33
- if (groups.length > 0) {
34
+ if (GROUP_CODE_RE.test(cleaned)) {
34
35
  groupsPart = cleaned;
35
36
  continue;
36
37
  }
@@ -53,6 +54,7 @@ export function parseTransferDiv(div) {
53
54
  teacher: parseTeacher(teacherPart),
54
55
  groups: parseGroupsString(groupsPart),
55
56
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
57
+ isDistance: DISTANCE_RE.test(divText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
56
58
  transfer,
57
59
  },
58
60
  };
@@ -72,7 +74,7 @@ export function parseSubstitutionDiv(div) {
72
74
  const teacherMatch = divHtml.match(/Преподаватель:\s*<span[^>]*>([^<]+)<\/span>/);
73
75
  if (teacherMatch)
74
76
  teacher = parseTeacher(teacherMatch[1].trim());
75
- return { date, room, teacher };
77
+ return { date, room, teacher, isDistance: DISTANCE_RE.test(room ?? divText) };
76
78
  }
77
79
  export function parseSubstituteForDiv(div) {
78
80
  const divText = text(div);
@@ -111,6 +113,7 @@ export function parseSubstituteForDiv(div) {
111
113
  teacher: { name: "" },
112
114
  groups: parseGroupsString(groupsMatch?.[1]),
113
115
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
116
+ isDistance: DISTANCE_RE.test(divText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
114
117
  substituteFor: { date, originalTeacher },
115
118
  },
116
119
  };
@@ -1,5 +1,5 @@
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";
1
+ export declare const LESSON_TYPE_PATTERN = "\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447\u043E|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441|\u043A\u043F";
2
+ export declare const FLEXIBLE_LESSON_TYPE_PATTERN = "(?:\u043B\u043A|\u043F\u0440|\u043B\u0431|\u0437\u0430\u0447\u043E|\u0437\u0430\u0447|\u044D\u043A\u0437|\u043A\u043E\u043D\u0441|\u043A\u043F)\\.?|\u042D\u043A\u0437";
3
3
  export declare const LESSON_TYPE_RE: RegExp;
4
4
  export declare const LESSON_TYPE_RE_I: RegExp;
5
5
  export declare const FLEXIBLE_LESSON_TYPE_RE_I: RegExp;
@@ -1,5 +1,5 @@
1
- export const LESSON_TYPE_PATTERN = "лк|пр|лб|зач|экз|конс";
2
- export const FLEXIBLE_LESSON_TYPE_PATTERN = `${LESSON_TYPE_PATTERN}\\.?|Экз`;
1
+ export const LESSON_TYPE_PATTERN = "лк|пр|лб|зачо|зач|экз|конс|кп";
2
+ export const FLEXIBLE_LESSON_TYPE_PATTERN = `(?:${LESSON_TYPE_PATTERN})\\.?|Экз`;
3
3
  export const LESSON_TYPE_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`);
4
4
  export const LESSON_TYPE_RE_I = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "i");
5
5
  export const FLEXIBLE_LESSON_TYPE_RE_I = new RegExp(`\\((${FLEXIBLE_LESSON_TYPE_PATTERN})\\)`, "i");
@@ -4,6 +4,7 @@ import { parseSemesterScheduleWith } from "./full-schedule.js";
4
4
  import { parseGroupsString } from "./groups.js";
5
5
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
6
6
  import { FLEXIBLE_LESSON_TYPE_PATTERN, FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
7
+ const DISTANCE_RE = /дистанционно|ДОТ/i;
7
8
  export function parseTeacherFullSchedule(html, educationType) {
8
9
  const doc = parseHtml(html);
9
10
  const edType = educationType ?? 1 /* EducationType.HigherEducation */;
@@ -68,6 +69,7 @@ function parseTeacherSemesterEntry(el) {
68
69
  groups: parseGroupsString(groupsMatch?.[1]),
69
70
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
70
71
  weekParity,
72
+ isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
71
73
  substitutions: substitutions.length > 0 ? substitutions : undefined,
72
74
  possibleChanges,
73
75
  };
@@ -116,6 +118,7 @@ function parseTeacherSessionEntry(td) {
116
118
  const plainText = text(td);
117
119
  if (!plainText)
118
120
  return null;
121
+ const possibleChanges = (td.getAttribute("class") ?? "").includes("want") || undefined;
119
122
  const subjectEl = td.querySelector('span[style*="color: blue"]');
120
123
  const subject = subjectEl ? text(subjectEl) : "";
121
124
  if (!subject)
@@ -124,6 +127,7 @@ function parseTeacherSessionEntry(td) {
124
127
  const room = roomMatch ? roomMatch[1].trim() : "";
125
128
  const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
126
129
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
130
+ const subgroupMatch = plainText.match(SUBGROUP_RE);
127
131
  // Groups: text between </span> type and <br>time
128
132
  const groupsMatch = fullHtml.match(new RegExp(`\\((?:${FLEXIBLE_LESSON_TYPE_PATTERN})\\)\\s*([^<]+?)\\s*<br`, "i"));
129
133
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
@@ -137,6 +141,9 @@ function parseTeacherSessionEntry(td) {
137
141
  weeks: { from: 0, to: 0 },
138
142
  teacher: { name: "" },
139
143
  groups: parseGroupsString(groupsMatch?.[1]),
144
+ subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
145
+ isDistance: DISTANCE_RE.test(plainText) || DISTANCE_RE.test(room),
146
+ possibleChanges,
140
147
  },
141
148
  timeStart: parseTime(timeMatch[1]),
142
149
  timeEnd: parseTime(timeMatch[2]),
@@ -0,0 +1,2 @@
1
+ import type { Webinar } from "../types.js";
2
+ export declare function parseWebinars(html: string): Webinar[];
@@ -0,0 +1,98 @@
1
+ import { parseHtml, parseTeacher, parseTime, text, } from "../../common/parse.js";
2
+ import { parseGroupsString } from "./groups.js";
3
+ import { FLEXIBLE_LESSON_TYPE_RE_I, SUBGROUP_RE } from "./patterns.js";
4
+ const GROUP_CODE_RE = /[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+(?:\s*ин)?/u;
5
+ function parseDateValue(value) {
6
+ const match = value?.match(/^(\d{4})-(\d{2})-(\d{2})$/);
7
+ if (!match)
8
+ return undefined;
9
+ return new Date(parseInt(match[1]), parseInt(match[2]) - 1, parseInt(match[3]));
10
+ }
11
+ function parseTimeRange(raw) {
12
+ const match = raw.match(/(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
13
+ if (!match)
14
+ return null;
15
+ return { start: parseTime(match[1]), end: parseTime(match[2]) };
16
+ }
17
+ function splitLesson(raw) {
18
+ const typeMatches = [...raw.matchAll(new RegExp(FLEXIBLE_LESSON_TYPE_RE_I, "gi"))];
19
+ const typeMatch = typeMatches.at(-1);
20
+ if (!typeMatch || typeMatch.index == null) {
21
+ return {
22
+ subject: raw,
23
+ type: "",
24
+ teacherRaw: "",
25
+ groupsRaw: "",
26
+ };
27
+ }
28
+ const subject = raw.slice(0, typeMatch.index).trim();
29
+ const type = typeMatch[1].replace(/\.$/, "").toLowerCase();
30
+ const rest = raw.slice(typeMatch.index + typeMatch[0].length).trim();
31
+ const groupMatch = rest.match(GROUP_CODE_RE);
32
+ const teacherRaw = groupMatch?.index == null ? rest : rest.slice(0, groupMatch.index).trim();
33
+ const groupsRaw = groupMatch?.index == null ? "" : rest.slice(groupMatch.index).trim();
34
+ const subgroupMatch = raw.match(SUBGROUP_RE);
35
+ return {
36
+ subject,
37
+ type,
38
+ teacherRaw,
39
+ groupsRaw,
40
+ subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
41
+ };
42
+ }
43
+ function parseWebinarRows(doc, tableSelector, scheduled, fallbackDate) {
44
+ const webinars = [];
45
+ for (const slotRow of doc.querySelectorAll(`${tableSelector} > tbody > tr`)) {
46
+ const slotCells = [...slotRow.children].filter((child) => child.tagName.toLowerCase() === "td");
47
+ const timeCell = slotCells[0];
48
+ const dataCell = slotCells[1];
49
+ if (!timeCell || !dataCell)
50
+ continue;
51
+ const timeText = text(timeCell);
52
+ const timeRange = parseTimeRange(timeText);
53
+ if (!timeRange)
54
+ continue;
55
+ const id = timeCell.querySelector("div")?.getAttribute("id") ?? "";
56
+ const idMatch = id.match(/trd(\d{4}-\d{2}-\d{2})t(\d+)/);
57
+ const date = parseDateValue(idMatch?.[1]) ?? fallbackDate;
58
+ const slotNumber = idMatch ? parseInt(idMatch[2]) : undefined;
59
+ for (const row of dataCell.querySelectorAll("table tr")) {
60
+ const cells = [...row.children].filter((child) => child.tagName.toLowerCase() === "td");
61
+ const lessonCell = cells[0];
62
+ const titleCell = cells[1];
63
+ if (!lessonCell || !titleCell)
64
+ continue;
65
+ const button = row.querySelector("button[onclick*=\"jointo\"]");
66
+ const onclick = button?.getAttribute("onclick") ?? "";
67
+ const joinMatch = onclick.match(/jointo(?:sub)?\('([^']+)',\s*(\d+)\)/);
68
+ const raw = text(lessonCell);
69
+ const parsed = splitLesson(raw);
70
+ webinars.push({
71
+ id: joinMatch?.[1] ?? "",
72
+ idType: joinMatch ? parseInt(joinMatch[2]) : 1,
73
+ scheduled,
74
+ date,
75
+ slotNumber,
76
+ timeStart: timeRange.start,
77
+ timeEnd: timeRange.end,
78
+ subject: parsed.subject,
79
+ type: parsed.type,
80
+ teacher: parseTeacher(parsed.teacherRaw),
81
+ groups: parseGroupsString(parsed.groupsRaw),
82
+ subgroup: parsed.subgroup,
83
+ title: text(titleCell),
84
+ raw,
85
+ });
86
+ }
87
+ }
88
+ return webinars;
89
+ }
90
+ export function parseWebinars(html) {
91
+ const doc = parseHtml(html);
92
+ const selectedDate = doc.querySelector('select[name="seldate"] option[selected]')?.getAttribute("value");
93
+ const fallbackDate = parseDateValue(selectedDate);
94
+ return [
95
+ ...parseWebinarRows(doc, "#webstt", true, fallbackDate),
96
+ ...parseWebinarRows(doc, "#websttext", false, fallbackDate),
97
+ ];
98
+ }
@@ -23,6 +23,7 @@ export declare class Schedule {
23
23
  getDays(period: Period): FullScheduleDay[];
24
24
  private getSlotsForWeekday;
25
25
  private getDateForWeekday;
26
+ private isInCurrentAcademicYear;
26
27
  forDay(weekday: number, opts?: {
27
28
  subgroup?: number;
28
29
  week?: number;
@@ -60,6 +60,15 @@ export class Schedule {
60
60
  date.setHours(0, 0, 0, 0);
61
61
  return date;
62
62
  }
63
+ isInCurrentAcademicYear(date) {
64
+ const now = new Date();
65
+ const startYear = now.getMonth() >= 8
66
+ ? now.getFullYear()
67
+ : now.getFullYear() - 1;
68
+ const start = new Date(startYear, 8, 1);
69
+ const end = new Date(startYear + 1, 7, 31, 23, 59, 59, 999);
70
+ return date >= start && date <= end;
71
+ }
63
72
  // --- Public query methods ---
64
73
  forDay(weekday, opts) {
65
74
  const period = this.period;
@@ -70,7 +79,10 @@ export class Schedule {
70
79
  const dayName = getWeekdayName(weekday);
71
80
  for (const d of days) {
72
81
  if (d.weekday.toLowerCase() === dayName.toLowerCase() && d.date) {
73
- lessons.push(...slotsToLessons(d.slots, d.date, { isTeacherSchedule: this.isTeacherSchedule }));
82
+ const slots = filterSlots(d.slots, { subgroup: opts?.subgroup });
83
+ lessons.push(...slotsToLessons(slots, d.date, {
84
+ isTeacherSchedule: this.isTeacherSchedule,
85
+ }));
74
86
  }
75
87
  }
76
88
  return lessons.sort(sortLessons);
@@ -80,6 +92,8 @@ export class Schedule {
80
92
  return slotsToLessons(slots, date, { isTeacherSchedule: this.isTeacherSchedule });
81
93
  }
82
94
  forDate(date, opts) {
95
+ if (!this.isInCurrentAcademicYear(date))
96
+ return [];
83
97
  if (isHoliday(date, this.holidays, this.holidayTransfers))
84
98
  return [];
85
99
  const period = getCurrentPeriod({ date });
@@ -88,7 +102,10 @@ export class Schedule {
88
102
  for (const [, d] of this.scheduleMap) {
89
103
  for (const day of d) {
90
104
  if (day.date && isSameDay(day.date, date)) {
91
- lessons.push(...slotsToLessons(day.slots, date, { isTeacherSchedule: this.isTeacherSchedule }));
105
+ const slots = filterSlots(day.slots, { subgroup: opts?.subgroup });
106
+ lessons.push(...slotsToLessons(slots, date, {
107
+ isTeacherSchedule: this.isTeacherSchedule,
108
+ }));
92
109
  }
93
110
  }
94
111
  }
@@ -42,6 +42,8 @@ export interface Substitution {
42
42
  date: Date;
43
43
  /** New room, if changed. */
44
44
  room?: string;
45
+ /** Whether the substitution moves the lesson online. */
46
+ isDistance?: boolean;
45
47
  /** New teacher, if changed. */
46
48
  teacher?: Teacher;
47
49
  }
@@ -78,6 +80,8 @@ export interface ScheduleEntry {
78
80
  groups: string[];
79
81
  subgroup?: number;
80
82
  weekParity?: "even" | "odd";
83
+ /** True when the lesson is explicitly marked as дистанционно / ДОТ. */
84
+ isDistance?: boolean;
81
85
  /** Date-specific substitutions (замена на). */
82
86
  substitutions?: Substitution[];
83
87
  /** If this entry is a transferred lesson (перенос). */
@@ -121,6 +125,10 @@ export interface Lesson {
121
125
  weeks: WeekRange;
122
126
  subgroup?: number;
123
127
  weekParity?: "even" | "odd";
128
+ /** True when the lesson is explicitly marked as дистанционно / ДОТ. */
129
+ isDistance?: boolean;
130
+ /** Matched webinar metadata, if attached by `attachWebinarsToLessons`. */
131
+ webinar?: Webinar;
124
132
  /** If a substitution was applied, the original room. */
125
133
  originalRoom?: string;
126
134
  /** If a substitution was applied, the original teacher. */
@@ -145,6 +153,27 @@ export interface SemesterWeek {
145
153
  start: Date;
146
154
  end: Date;
147
155
  }
156
+ export interface Webinar {
157
+ /** Internal tt.chuvsu.ru webinar id used by `/webinar/getjoin`. */
158
+ id: string;
159
+ /** Webinar table type argument (`idwt`) used by `/webinar/getjoin`. */
160
+ idType: number;
161
+ /** True for "Вебинары по расписанию"; false for external webinars. */
162
+ scheduled: boolean;
163
+ date?: Date;
164
+ slotNumber?: number;
165
+ timeStart: Time;
166
+ timeEnd: Time;
167
+ subject: string;
168
+ type: string;
169
+ teacher: Teacher;
170
+ groups: string[];
171
+ subgroup?: number;
172
+ /** Free-form title/topic from the second table column. */
173
+ title: string;
174
+ /** Raw first-column text as rendered by tt.chuvsu.ru. */
175
+ raw: string;
176
+ }
148
177
  export interface CacheConfig {
149
178
  schedule?: number;
150
179
  faculties?: number;
@@ -156,6 +185,7 @@ export interface CacheConfig {
156
185
  teacherPhotos?: number;
157
186
  audienceInfo?: number;
158
187
  audienceImages?: number;
188
+ webinars?: number;
159
189
  }
160
190
  export interface TtClientOptions {
161
191
  educationType?: EducationType;
@@ -92,18 +92,18 @@ export function getEffectiveHolidays(year, holidays = RUSSIAN_HOLIDAYS, transfer
92
92
  const nonJanuaryOnWeekend = holidays
93
93
  .filter((h) => !isJanuaryHoliday(h))
94
94
  .map((h) => new Date(year, h.month - 1, h.day))
95
- .filter((d) => d.getDay() === 0 || d.getDay() === 6)
95
+ .filter((d) => d.getDay() === 0 || (!sixDayWeek && d.getDay() === 6))
96
96
  .sort((a, b) => a.getTime() - b.getTime());
97
97
  for (const holiday of nonJanuaryOnWeekend) {
98
98
  let candidate = new Date(holiday);
99
- if (candidate.getDay() === 6) {
99
+ if (!sixDayWeek && candidate.getDay() === 6) {
100
100
  candidate.setDate(candidate.getDate() + 2);
101
101
  }
102
102
  else {
103
103
  candidate.setDate(candidate.getDate() + 1);
104
104
  }
105
105
  while (candidate.getDay() === 0 ||
106
- candidate.getDay() === 6 ||
106
+ (!sixDayWeek && candidate.getDay() === 6) ||
107
107
  isOriginalHoliday(candidate) ||
108
108
  effectiveDays.some((ed) => isSameDay(ed, candidate))) {
109
109
  candidate.setDate(candidate.getDate() + 1);
@@ -141,19 +141,19 @@ export function getHolidayTransfers(year, holidays = RUSSIAN_HOLIDAYS, transfers
141
141
  const nonJanuaryOnWeekend = holidays
142
142
  .filter((h) => !isJanuaryHoliday(h))
143
143
  .map((h) => new Date(year, h.month - 1, h.day))
144
- .filter((d) => d.getDay() === 0 || d.getDay() === 6)
144
+ .filter((d) => d.getDay() === 0 || (!sixDayWeek && d.getDay() === 6))
145
145
  .sort((a, b) => a.getTime() - b.getTime());
146
146
  const isOriginalHoliday = (d) => originalDates.some((od) => isSameDay(od, d));
147
147
  for (const holiday of nonJanuaryOnWeekend) {
148
148
  let candidate = new Date(holiday);
149
- if (candidate.getDay() === 6) {
149
+ if (!sixDayWeek && candidate.getDay() === 6) {
150
150
  candidate.setDate(candidate.getDate() + 2);
151
151
  }
152
152
  else {
153
153
  candidate.setDate(candidate.getDate() + 1);
154
154
  }
155
155
  while (candidate.getDay() === 0 ||
156
- candidate.getDay() === 6 ||
156
+ (!sixDayWeek && candidate.getDay() === 6) ||
157
157
  isOriginalHoliday(candidate) ||
158
158
  effectiveDays.some((ed) => isSameDay(ed, candidate))) {
159
159
  candidate.setDate(candidate.getDate() + 1);
@@ -2,6 +2,6 @@ export { getMonday, getWeekdayName, isSameDay } from "./date.js";
2
2
  export { getAdjacentSemester, getCurrentPeriod, isSessionPeriod, } from "./period.js";
3
3
  export { getSemesterStart, getSemesterWeeks, getWeekNumber, } from "./semester.js";
4
4
  export { getLessonNumber, getTimeSlots } from "./time-slots.js";
5
- export { collectTransfers, filterSlots, slotsToLessons, sortLessons, suppressTransferredLessons, } from "./lessons.js";
5
+ export { attachWebinarsToLessons, collectTransfers, filterSlots, matchWebinarToLesson, slotsToLessons, sortLessons, suppressTransferredLessons, } from "./lessons.js";
6
6
  export { getCompensatingWorkDays, getEffectiveHolidays, getHolidayTransfers, isHoliday, RUSSIAN_HOLIDAYS, } from "./holidays.js";
7
7
  export type { Holiday, HolidayTransfer } from "./holidays.js";
@@ -2,5 +2,5 @@ export { getMonday, getWeekdayName, isSameDay } from "./date.js";
2
2
  export { getAdjacentSemester, getCurrentPeriod, isSessionPeriod, } from "./period.js";
3
3
  export { getSemesterStart, getSemesterWeeks, getWeekNumber, } from "./semester.js";
4
4
  export { getLessonNumber, getTimeSlots } from "./time-slots.js";
5
- export { collectTransfers, filterSlots, slotsToLessons, sortLessons, suppressTransferredLessons, } from "./lessons.js";
5
+ export { attachWebinarsToLessons, collectTransfers, filterSlots, matchWebinarToLesson, slotsToLessons, sortLessons, suppressTransferredLessons, } from "./lessons.js";
6
6
  export { getCompensatingWorkDays, getEffectiveHolidays, getHolidayTransfers, isHoliday, RUSSIAN_HOLIDAYS, } from "./holidays.js";
@@ -1,4 +1,4 @@
1
- import type { FullScheduleDay, FullScheduleSlot, Lesson, ScheduleEntry } from "../types.js";
1
+ import type { FullScheduleDay, FullScheduleSlot, Lesson, ScheduleEntry, Webinar } from "../types.js";
2
2
  export declare function sortLessons(a: Lesson, b: Lesson): number;
3
3
  export declare function filterSlots(slots: FullScheduleSlot[], opts?: {
4
4
  subgroup?: number;
@@ -8,6 +8,8 @@ export declare function filterSlots(slots: FullScheduleSlot[], opts?: {
8
8
  export declare function slotsToLessons(slots: FullScheduleSlot[], date: Date, opts?: {
9
9
  isTeacherSchedule?: boolean;
10
10
  }): Lesson[];
11
+ export declare function matchWebinarToLesson(lesson: Lesson, webinars: Webinar[]): Webinar | undefined;
12
+ export declare function attachWebinarsToLessons(lessons: Lesson[], webinars: Webinar[]): Lesson[];
11
13
  /** All transfer entries from the given schedule days. */
12
14
  export declare function collectTransfers(days: FullScheduleDay[]): ScheduleEntry[];
13
15
  /** Remove lessons whose source date/slot match a transfer. */
@@ -51,12 +51,16 @@ function makeLessonTime(date, time) {
51
51
  d.setHours(time.hours, time.minutes, 0, 0);
52
52
  return { date: d, hours: time.hours, minutes: time.minutes };
53
53
  }
54
+ function isDistanceRoom(room) {
55
+ return /дистанционно|ДОТ/i.test(room ?? "");
56
+ }
54
57
  export function slotsToLessons(slots, date, opts) {
55
58
  const lessons = [];
56
59
  for (const slot of slots) {
57
60
  for (const entry of slot.entries) {
58
61
  let room = entry.room;
59
62
  let teacher = entry.teacher;
63
+ let isDistance = entry.isDistance || isDistanceRoom(room);
60
64
  let originalRoom;
61
65
  let originalTeacher;
62
66
  // Apply date-specific substitutions
@@ -66,6 +70,7 @@ export function slotsToLessons(slots, date, opts) {
66
70
  if (sub.room) {
67
71
  originalRoom = room;
68
72
  room = sub.room;
73
+ isDistance = sub.isDistance || isDistanceRoom(room);
69
74
  }
70
75
  if (sub.teacher) {
71
76
  // On teacher schedules, a teacher substitution means another teacher
@@ -89,6 +94,7 @@ export function slotsToLessons(slots, date, opts) {
89
94
  weeks: entry.weeks,
90
95
  subgroup: entry.subgroup,
91
96
  weekParity: entry.weekParity,
97
+ isDistance,
92
98
  originalRoom,
93
99
  originalTeacher,
94
100
  transfer: entry.transfer,
@@ -99,6 +105,50 @@ export function slotsToLessons(slots, date, opts) {
99
105
  }
100
106
  return lessons;
101
107
  }
108
+ function normalizeText(value) {
109
+ return value
110
+ .toLowerCase()
111
+ .replace(/ё/g, "е")
112
+ .replace(/\s+/g, " ")
113
+ .trim();
114
+ }
115
+ function sameTime(lesson, webinar) {
116
+ return (lesson.start.hours === webinar.timeStart.hours &&
117
+ lesson.start.minutes === webinar.timeStart.minutes &&
118
+ lesson.end.hours === webinar.timeEnd.hours &&
119
+ lesson.end.minutes === webinar.timeEnd.minutes);
120
+ }
121
+ export function matchWebinarToLesson(lesson, webinars) {
122
+ return webinars.find((webinar) => {
123
+ if (!webinar.scheduled)
124
+ return false;
125
+ if (webinar.date && !isSameDay(webinar.date, lesson.start.date))
126
+ return false;
127
+ if (webinar.slotNumber != null && webinar.slotNumber !== lesson.number) {
128
+ return false;
129
+ }
130
+ if (!sameTime(lesson, webinar))
131
+ return false;
132
+ if (normalizeText(webinar.subject) !== normalizeText(lesson.subject)) {
133
+ return false;
134
+ }
135
+ if (webinar.type && lesson.type && webinar.type !== lesson.type) {
136
+ return false;
137
+ }
138
+ if (webinar.subgroup != null &&
139
+ lesson.subgroup != null &&
140
+ webinar.subgroup !== lesson.subgroup) {
141
+ return false;
142
+ }
143
+ return true;
144
+ });
145
+ }
146
+ export function attachWebinarsToLessons(lessons, webinars) {
147
+ return lessons.map((lesson) => {
148
+ const webinar = matchWebinarToLesson(lesson, webinars);
149
+ return webinar ? { ...lesson, webinar } : lesson;
150
+ });
151
+ }
102
152
  /** All transfer entries from the given schedule days. */
103
153
  export function collectTransfers(days) {
104
154
  const transfers = [];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "4.0.0",
3
+ "version": "4.1.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",