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
@@ -1,10 +1,10 @@
1
- import { parseHtml, parseTeacher, parseWeekParity, parseWeeks, text, } from "../../common/parse.js";
2
- import { parseSemesterScheduleWith } from "./full-schedule.js";
1
+ import { parseHtml, parseTeacher, parseTime, parseWeekParity, parseWeeks, text, } from "../../common/parse.js";
2
+ import { parseSemesterScheduleWith, parseSessionScheduleWith, } from "./full-schedule.js";
3
3
  import { parseGroupsString } from "./groups.js";
4
4
  import { parseSubstituteForDiv, parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
5
- import { LESSON_TYPE_GLOBAL_RE, LESSON_TYPE_RE, SUBGROUP_ANNOTATION_RE, SUBGROUP_RE, WEEKS_GLOBAL_RE, WEEKS_RE, } from "./patterns.js";
6
- const DISTANCE_RE = /дистанционно|ДОТ/i;
7
- export function parseAudienceInfo(html) {
5
+ import { LESSON_TYPE_GLOBAL_RE, LESSON_TYPE_RE, FLEXIBLE_LESSON_TYPE_RE_I, SUBGROUP_ANNOTATION_RE, SUBGROUP_RE, WEEKS_GLOBAL_RE, WEEKS_RE, } from "./patterns.js";
6
+ import { containsGroupCode, hasDistanceMarker, linesAfterSubject, stripDistanceMarker, } from "./entry-parts.js";
7
+ export function parseRoomInfo(html) {
8
8
  const doc = parseHtml(html);
9
9
  // Name: <span class="htext"><nobr>Аудитория <span style="color: blue;">NAME</span></nobr></span>
10
10
  const nameEl = doc.querySelector('.htext span[style*="color: blue"]');
@@ -67,7 +67,7 @@ export function parseAudienceInfo(html) {
67
67
  floorplanRect,
68
68
  };
69
69
  }
70
- function parseAudienceSemesterEntry(el) {
70
+ function parseRoomSemesterEntry(el) {
71
71
  const td = el.querySelector("td") ?? el;
72
72
  const fullHtml = td.innerHTML ?? "";
73
73
  const plainText = text(td);
@@ -111,43 +111,67 @@ function parseAudienceSemesterEntry(el) {
111
111
  const weeksMatch = cleanText.match(WEEKS_RE);
112
112
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
113
113
  const weekParity = parseWeekParity(cleanHtml);
114
- // Audience entries layout:
115
- // <span blue>SUBJ</span> (TYPE) (WEEKS) <br>TEACHER<br>GROUPS
116
- // Teacher is the first line after </span>...<br>, groups is the next line.
117
- const afterSubject = cleanHtml.split(/<\/span>/i).slice(1).join("</span>");
118
- const parts = afterSubject
119
- .split(/<br\s*\/?>/i)
120
- .map((p) => p.replace(/<[^>]*>/g, "").trim())
121
- .filter((p) => p.length > 0);
122
- // parts[0] = " (лк) (1 - 16 нед.) " — trailing metadata; drop tokens that
123
- // look like (type)/(weeks)/(N подгруппа). First real text line = teacher.
124
- const textLines = [];
125
- for (const p of parts) {
126
- const cleaned = p
127
- .replace(LESSON_TYPE_GLOBAL_RE, "")
128
- .replace(WEEKS_GLOBAL_RE, "")
129
- .replace(SUBGROUP_ANNOTATION_RE, "")
130
- .trim();
131
- if (cleaned)
132
- textLines.push(cleaned);
133
- }
134
- const teacherLine = textLines[0] ?? "";
135
- const groupsLine = textLines.slice(1).join(" ").trim();
114
+ // Audience entries: subject line, then teacher line, then group line(s).
115
+ const parts = linesAfterSubject(cleanHtml, subject);
116
+ const teacherLine = stripDistanceMarker(parts.find((line) => !containsGroupCode(stripDistanceMarker(line))) ?? "");
117
+ const groupsLine = parts
118
+ .filter((line) => containsGroupCode(stripDistanceMarker(line)))
119
+ .map(stripDistanceMarker)
120
+ .map((line) => line
121
+ .replace(LESSON_TYPE_GLOBAL_RE, "")
122
+ .replace(WEEKS_GLOBAL_RE, "")
123
+ .replace(SUBGROUP_ANNOTATION_RE, "")
124
+ .trim())
125
+ .filter(Boolean)
126
+ .join(" ");
127
+ const groups = parseGroupsString(groupsLine);
136
128
  return {
137
- room: "",
138
129
  subject,
139
130
  type: typeMatch?.[1] ?? "",
140
131
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
141
- teacher: parseTeacher(teacherLine),
142
- groups: parseGroupsString(groupsLine),
132
+ teacher: teacherLine ? parseTeacher(teacherLine) : undefined,
133
+ groups: groups.length > 0 ? groups : undefined,
143
134
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
144
135
  weekParity,
145
- isDistance: DISTANCE_RE.test(cleanText),
136
+ isDistance: hasDistanceMarker(cleanText),
146
137
  substitutions: substitutions.length > 0 ? substitutions : undefined,
147
138
  possibleChanges,
148
139
  };
149
140
  }
150
- export function parseAudienceFullSchedule(html) {
141
+ export function parseRoomSchedule(html) {
151
142
  const doc = parseHtml(html);
152
- return parseSemesterScheduleWith(doc, parseAudienceSemesterEntry);
143
+ if (doc.querySelector('td[id^="trd2"]')) {
144
+ return parseSessionScheduleWith(doc, parseRoomSessionEntry);
145
+ }
146
+ return parseSemesterScheduleWith(doc, parseRoomSemesterEntry);
147
+ }
148
+ function parseRoomSessionEntry(td) {
149
+ const fullHtml = td.innerHTML ?? "";
150
+ const plainText = text(td);
151
+ const subjectEl = td.querySelector('span[style*="color: blue"]');
152
+ const subject = subjectEl ? text(subjectEl) : "";
153
+ if (!plainText || !subject)
154
+ return null;
155
+ const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
156
+ if (!timeMatch)
157
+ return null;
158
+ const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
159
+ const parts = linesAfterSubject(fullHtml, subject);
160
+ const teacherLine = parts.find((part) => !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
161
+ !containsGroupCode(stripDistanceMarker(part)));
162
+ const groupsLine = parts.find((part) => containsGroupCode(stripDistanceMarker(part)));
163
+ const groups = parseGroupsString(stripDistanceMarker(groupsLine ?? ""));
164
+ const subgroupMatch = plainText.match(SUBGROUP_RE);
165
+ return {
166
+ lesson: {
167
+ subject,
168
+ type: typeMatch?.[1].replace(/\.$/, "").toLowerCase() ?? "",
169
+ teacher: teacherLine ? parseTeacher(stripDistanceMarker(teacherLine)) : undefined,
170
+ groups: groups.length > 0 ? groups : undefined,
171
+ subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
172
+ isDistance: hasDistanceMarker(plainText),
173
+ possibleChanges: (td.getAttribute("class") ?? "").includes("want") || undefined,
174
+ },
175
+ time: { start: parseTime(timeMatch[1]), end: parseTime(timeMatch[2]) },
176
+ };
153
177
  }
@@ -0,0 +1,8 @@
1
+ export declare function entryHtmlLines(html: string): string[];
2
+ export declare function entryTextLines(html: string): string[];
3
+ export declare function stripHtml(html: string): string;
4
+ export declare function hasDistanceMarker(value: string): boolean;
5
+ export declare function linesAfterSubject(html: string, subject: string): string[];
6
+ export declare function parseEntryRoom(html: string, subject: string): string;
7
+ export declare function stripDistanceMarker(value: string): string;
8
+ export declare function containsGroupCode(value: string): boolean;
@@ -0,0 +1,52 @@
1
+ const BR_RE = /<br\s*\/?>/i;
2
+ const TAG_RE = /<[^>]*>/g;
3
+ const DISTANCE_RE = /(?:^|[^\p{L}\p{N}])(?:дистанционно|ДОТ)(?=$|[^\p{L}\p{N}])/iu;
4
+ const GROUP_TOKEN_RE = /^[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+$/u;
5
+ export function entryHtmlLines(html) {
6
+ return html.split(BR_RE).map((line) => line.trim());
7
+ }
8
+ export function entryTextLines(html) {
9
+ return entryHtmlLines(html)
10
+ .map(stripHtml)
11
+ .filter((line) => line.length > 0);
12
+ }
13
+ export function stripHtml(html) {
14
+ return html.replace(TAG_RE, " ").replace(/\s+/g, " ").trim();
15
+ }
16
+ export function hasDistanceMarker(value) {
17
+ return DISTANCE_RE.test(value);
18
+ }
19
+ export function linesAfterSubject(html, subject) {
20
+ const lines = entryTextLines(html);
21
+ const index = lines.findIndex((line) => line.includes(subject));
22
+ return index < 0 ? [] : lines.slice(index + 1);
23
+ }
24
+ export function parseEntryRoom(html, subject) {
25
+ const lines = entryHtmlLines(html);
26
+ const line = lines.find((part) => stripHtml(part).includes(subject));
27
+ if (!line)
28
+ return "";
29
+ const lineText = stripHtml(line);
30
+ const subjectIndex = lineText.indexOf(subject);
31
+ if (subjectIndex < 0)
32
+ return "";
33
+ const beforeSubject = lineText
34
+ .slice(0, subjectIndex)
35
+ .replace(/^\*+\s*/, "")
36
+ .trim();
37
+ if (hasDistanceMarker(stripHtml(html))) {
38
+ return "Дистанционно (ДОТ)";
39
+ }
40
+ if (!beforeSubject)
41
+ return "";
42
+ return beforeSubject;
43
+ }
44
+ export function stripDistanceMarker(value) {
45
+ return value
46
+ .replace(/\(\s*ДОТ\s*\)/giu, " ")
47
+ .replace(/\s+/g, " ")
48
+ .trim();
49
+ }
50
+ export function containsGroupCode(value) {
51
+ return value.split(/\s+/).some((token) => GROUP_TOKEN_RE.test(token));
52
+ }
@@ -1,4 +1,9 @@
1
- import { EducationType } from "../../common/types.js";
2
- import type { FullScheduleDay, ScheduleEntry } from "../types.js";
3
- export declare function parseFullSchedule(html: string, educationType?: EducationType): FullScheduleDay[];
4
- export declare function parseSemesterScheduleWith(doc: Document, entryParser: (el: Element) => ScheduleEntry | null): FullScheduleDay[];
1
+ import type { ParsedScheduleDay, ParsedScheduleBlock, ParsedLesson } from "../types.js";
2
+ export declare function parseGroupSchedule(html: string): ParsedScheduleDay[];
3
+ export declare function parseSemesterScheduleWith(doc: Document, entryParser: (el: Element) => ParsedLesson | null): ParsedScheduleDay[];
4
+ interface ParsedSessionEntry {
5
+ lesson: ParsedLesson;
6
+ time: NonNullable<ParsedScheduleBlock["time"]>;
7
+ }
8
+ export declare function parseSessionScheduleWith(doc: Document, entryParser: (element: Element) => ParsedSessionEntry | null): ParsedScheduleDay[];
9
+ export {};
@@ -1,14 +1,12 @@
1
1
  import { parseHtml, parseTeacher, parseTime, parseWeekParity, parseWeeks, text, } from "../../common/parse.js";
2
- import { getLessonNumber } from "../utils/index.js";
3
2
  import { parseSubstitutionDiv, parseTransferDiv, } from "./overlays.js";
4
3
  import { FLEXIBLE_LESSON_TYPE_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
5
- const DISTANCE_RE = /дистанционно|ДОТ/i;
6
- export function parseFullSchedule(html, educationType) {
4
+ import { hasDistanceMarker, containsGroupCode, linesAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
5
+ export function parseGroupSchedule(html) {
7
6
  const doc = parseHtml(html);
8
- const edType = educationType ?? 1 /* EducationType.HigherEducation */;
9
7
  // Session layout has date-based cells with ids like "trd20251224"
10
8
  if (doc.querySelector('td[id^="trd2"]')) {
11
- return parseSessionSchedule(doc, edType);
9
+ return parseSessionSchedule(doc);
12
10
  }
13
11
  return parseSemesterSchedule(doc);
14
12
  }
@@ -23,13 +21,20 @@ export function parseSemesterScheduleWith(doc, entryParser) {
23
21
  if (style.includes("lightgray") && cls.includes("trfd")) {
24
22
  const dayName = text(row.querySelector("td"));
25
23
  if (dayName) {
26
- currentDay = { weekday: dayName, slots: [] };
24
+ currentDay = { weekday: dayName, blocks: [] };
27
25
  days.push(currentDay);
28
26
  }
29
27
  continue;
30
28
  }
31
29
  if (!currentDay)
32
30
  continue;
31
+ const selfStudyMarker = row.querySelector('span[style*="color: blue"]');
32
+ if (selfStudyMarker &&
33
+ /^День самостоятельной работы$/i.test(text(selfStudyMarker)) &&
34
+ selfStudyMarker.closest("tr") === row) {
35
+ currentDay.isSelfStudyDay = true;
36
+ continue;
37
+ }
33
38
  const timeCell = row.querySelector("td.trf");
34
39
  const dataCell = row.querySelector("td.trdata:not(.trf)");
35
40
  if (!timeCell || !dataCell)
@@ -42,17 +47,18 @@ export function parseSemesterScheduleWith(doc, entryParser) {
42
47
  const timeMatch = timeText.match(/\((\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})\)/);
43
48
  if (!numberMatch)
44
49
  continue;
45
- const entries = [];
50
+ const lessons = [];
46
51
  for (const entryRow of dataCell.querySelectorAll("table tr")) {
47
52
  const entry = entryParser(entryRow);
48
53
  if (entry)
49
- entries.push(entry);
54
+ lessons.push(entry);
50
55
  }
51
- currentDay.slots.push({
52
- number: parseInt(numberMatch[1]),
53
- timeStart: parseTime(timeMatch?.[1] ?? "00:00"),
54
- timeEnd: parseTime(timeMatch?.[2] ?? "00:00"),
55
- entries,
56
+ currentDay.blocks.push({
57
+ slotNumber: parseInt(numberMatch[1]),
58
+ time: timeMatch
59
+ ? { start: parseTime(timeMatch[1]), end: parseTime(timeMatch[2]) }
60
+ : undefined,
61
+ lessons,
56
62
  });
57
63
  }
58
64
  return days;
@@ -98,26 +104,27 @@ function parseSemesterEntry(el) {
98
104
  return null;
99
105
  const typeMatch = cleanText.match(LESSON_TYPE_RE);
100
106
  const weeksMatch = cleanText.match(WEEKS_RE);
101
- const roomMatch = cleanHtml.match(/(?:<sup>[^<]*<\/sup>)?([А-Яа-яA-Za-z]-\d+)/);
102
- const teacherMatch = cleanHtml.match(/<br\s*\/?>\s*([^<]+?)(?:<br|<\/td|<div|<i|$)/);
107
+ const room = parseEntryRoom(cleanHtml, subject);
108
+ const teacherLine = linesAfterSubject(cleanHtml, subject).find((line) => {
109
+ const candidate = stripDistanceMarker(line);
110
+ return candidate.length > 0 && !SUBGROUP_RE.test(candidate);
111
+ }) ?? "";
103
112
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
104
113
  const weekParity = parseWeekParity(cleanHtml);
105
114
  return {
106
- room: roomMatch?.[1] ?? "",
115
+ room: room || undefined,
107
116
  subject,
108
117
  type: typeMatch?.[1] ?? "",
109
118
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
110
- teacher: parseTeacher(teacherMatch?.[1] ?? ""),
111
- groups: [],
119
+ teacher: teacherLine ? parseTeacher(teacherLine) : undefined,
112
120
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
113
121
  weekParity,
114
- isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
122
+ isDistance: hasDistanceMarker(cleanText) || hasDistanceMarker(room ?? ""),
115
123
  substitutions: substitutions.length > 0 ? substitutions : undefined,
116
124
  possibleChanges,
117
125
  };
118
126
  }
119
- // --- Session schedule parsing (date-based, specific dates) ---
120
- function parseSessionSchedule(doc, educationType) {
127
+ export function parseSessionScheduleWith(doc, entryParser) {
121
128
  const days = [];
122
129
  for (const dateCell of doc.querySelectorAll('td[id^="trd2"]')) {
123
130
  // Parse date from cell id: trd20251224 -> 2025-12-24
@@ -125,10 +132,7 @@ function parseSessionSchedule(doc, educationType) {
125
132
  const dateMatch = id.match(/trd(\d{4})(\d{2})(\d{2})/);
126
133
  if (!dateMatch)
127
134
  continue;
128
- const year = parseInt(dateMatch[1]);
129
- const month = parseInt(dateMatch[2]) - 1;
130
- const dayNum = parseInt(dateMatch[3]);
131
- const date = new Date(year, month, dayNum);
135
+ const date = `${dateMatch[1]}-${dateMatch[2]}-${dateMatch[3]}`;
132
136
  // Extract weekday from after <br>
133
137
  const cellHtml = dateCell.innerHTML ?? "";
134
138
  const brMatch = cellHtml.match(/<br\s*\/?>\s*(.+)/i);
@@ -139,25 +143,26 @@ function parseSessionSchedule(doc, educationType) {
139
143
  if (!dataCell?.matches("td.trdata:not(.trfd)")) {
140
144
  continue;
141
145
  }
142
- const slots = [];
146
+ const blocks = [];
143
147
  for (const entryRow of dataCell.querySelectorAll("table tr")) {
144
148
  const td = entryRow.querySelector("td") ?? entryRow;
145
- const entry = parseSessionEntry(td);
149
+ const entry = entryParser(td);
146
150
  if (!entry)
147
151
  continue;
148
- slots.push({
149
- number: getLessonNumber(entry.timeStart, educationType),
150
- timeStart: entry.timeStart,
151
- timeEnd: entry.timeEnd,
152
- entries: [entry.entry],
152
+ blocks.push({
153
+ time: entry.time,
154
+ lessons: [entry.lesson],
153
155
  });
154
156
  }
155
- if (slots.length > 0) {
156
- days.push({ weekday, date, slots });
157
+ if (blocks.length > 0) {
158
+ days.push({ weekday, date, blocks });
157
159
  }
158
160
  }
159
161
  return days;
160
162
  }
163
+ function parseSessionSchedule(doc) {
164
+ return parseSessionScheduleWith(doc, parseSessionEntry);
165
+ }
161
166
  function parseSessionEntry(td) {
162
167
  const fullHtml = td.innerHTML ?? "";
163
168
  const plainText = text(td);
@@ -168,37 +173,30 @@ function parseSessionEntry(td) {
168
173
  const subject = subjectEl ? text(subjectEl) : "";
169
174
  if (!subject)
170
175
  return null;
171
- // Room: text before the first <span
172
- const roomMatch = fullHtml.match(/^([^<]*?)\s*<span/);
173
- const room = roomMatch ? roomMatch[1].trim() : "";
176
+ const room = parseEntryRoom(fullHtml, subject);
174
177
  // Type: parenthesized text after </span>, case-insensitive
175
178
  const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
176
179
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
177
180
  const subgroupMatch = plainText.match(SUBGROUP_RE);
178
- const parts = fullHtml
179
- .split(/<br\s*\/?>/i)
180
- .map((part) => part.replace(/<[^>]*>/g, "").trim())
181
- .filter((part) => part.length > 0);
182
- const teacherPart = parts.find((part) => !part.includes(subject) &&
181
+ const parts = linesAfterSubject(fullHtml, subject);
182
+ const teacherPart = parts.find((part) => stripDistanceMarker(part).length > 0 &&
183
183
  !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
184
- !SUBGROUP_RE.test(part)) ?? "";
184
+ !SUBGROUP_RE.test(part) &&
185
+ !containsGroupCode(stripDistanceMarker(part))) ?? "";
185
186
  // Time: after <br>, format HH:MM - HH:MM
186
187
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
187
188
  if (!timeMatch)
188
189
  return null;
189
190
  return {
190
- entry: {
191
- room,
191
+ lesson: {
192
+ room: room || undefined,
192
193
  subject,
193
194
  type,
194
- weeks: { from: 0, to: 0 },
195
- teacher: parseTeacher(teacherPart),
196
- groups: [],
195
+ teacher: teacherPart ? parseTeacher(teacherPart) : undefined,
197
196
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
198
- isDistance: DISTANCE_RE.test(plainText) || DISTANCE_RE.test(room),
197
+ isDistance: hasDistanceMarker(plainText) || hasDistanceMarker(room ?? ""),
199
198
  possibleChanges,
200
199
  },
201
- timeStart: parseTime(timeMatch[1]),
202
- timeEnd: parseTime(timeMatch[2]),
200
+ time: { start: parseTime(timeMatch[1]), end: parseTime(timeMatch[2]) },
203
201
  };
204
202
  }
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * Preserves in-name annotations (e.g. "КТ-42-25 (АихС)") and strips service
5
5
  * markers like "(N подгруппа)" — the subgroup number is carried separately on
6
- * {@link import("../types.js").ScheduleEntry.subgroup}.
6
+ * {@link import("../types.js").ParsedLesson.subgroup}.
7
7
  *
8
8
  * Examples:
9
9
  * "КТ-42-25" -> ["КТ-42-25"]
@@ -4,7 +4,7 @@ import { SUBGROUP_ANNOTATION_RE_I } from "./patterns.js";
4
4
  *
5
5
  * Preserves in-name annotations (e.g. "КТ-42-25 (АихС)") and strips service
6
6
  * markers like "(N подгруппа)" — the subgroup number is carried separately on
7
- * {@link import("../types.js").ScheduleEntry.subgroup}.
7
+ * {@link import("../types.js").ParsedLesson.subgroup}.
8
8
  *
9
9
  * Examples:
10
10
  * "КТ-42-25" -> ["КТ-42-25"]
@@ -1,6 +1,6 @@
1
1
  export { parseGroupsString } from "./groups.js";
2
- export { parseAcademicYearFromPage, parseAudienceButtons, parseAudienceName, parseFacultyButtons, parseGroupButtons, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
3
- export { parseFullSchedule } from "./full-schedule.js";
4
- export { parseAudienceFullSchedule, parseAudienceInfo, } from "./audience.js";
5
- export { parseTeacherFullSchedule, parseTeacherInfo, } from "./teacher.js";
2
+ export { parseAcademicYearFromPage, parseRoomButtons, parseRoomName, parseFacultyButtons, parseGroupButtons, parseGroupName, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
3
+ export { parseGroupSchedule } from "./full-schedule.js";
4
+ export { parseRoomSchedule, parseRoomInfo, } from "./audience.js";
5
+ export { parseTeacherSchedule, parseTeacherInfo, } from "./teacher.js";
6
6
  export { parseWebinars } from "./webinars.js";
@@ -1,6 +1,6 @@
1
1
  export { parseGroupsString } from "./groups.js";
2
- export { parseAcademicYearFromPage, parseAudienceButtons, parseAudienceName, parseFacultyButtons, parseGroupButtons, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
3
- export { parseFullSchedule } from "./full-schedule.js";
4
- export { parseAudienceFullSchedule, parseAudienceInfo, } from "./audience.js";
5
- export { parseTeacherFullSchedule, parseTeacherInfo, } from "./teacher.js";
2
+ export { parseAcademicYearFromPage, parseRoomButtons, parseRoomName, parseFacultyButtons, parseGroupButtons, parseGroupName, parsePeriodFromPage, parseTeacherButtons, } from "./lists.js";
3
+ export { parseGroupSchedule } from "./full-schedule.js";
4
+ export { parseRoomSchedule, parseRoomInfo, } from "./audience.js";
5
+ export { parseTeacherSchedule, parseTeacherInfo, } from "./teacher.js";
6
6
  export { parseWebinars } from "./webinars.js";
@@ -1,12 +1,13 @@
1
- import { Period } from "../../common/types.js";
2
- import type { Audience, Faculty, Group } from "../types.js";
3
- export declare function parsePeriodFromPage(html: string): Period | null;
1
+ import { AcademicPeriod } from "../../common/types.js";
2
+ import type { Faculty, Group, Room } from "../types.js";
3
+ export declare function parsePeriodFromPage(html: string): AcademicPeriod | null;
4
4
  /** Parse the start year from labels like "2026/2027 учебный год". */
5
5
  export declare function parseAcademicYearFromPage(html: string): number | null;
6
6
  export declare function parseGroupButtons(html: string): Group[];
7
+ export declare function parseGroupName(html: string): string | null;
7
8
  export declare function parseFacultyButtons(html: string): Faculty[];
8
- export declare function parseAudienceButtons(html: string): Audience[];
9
- export declare function parseAudienceName(html: string): string | null;
9
+ export declare function parseRoomButtons(html: string): Room[];
10
+ export declare function parseRoomName(html: string): string | null;
10
11
  export declare function parseTeacherButtons(html: string): {
11
12
  id: number;
12
13
  name: string;
@@ -49,6 +49,11 @@ export function parseGroupButtons(html) {
49
49
  }
50
50
  return groups;
51
51
  }
52
+ export function parseGroupName(html) {
53
+ const doc = parseHtml(html);
54
+ const name = text(doc.querySelector('span.htext span[style*="color: blue"]'));
55
+ return name || null;
56
+ }
52
57
  export function parseFacultyButtons(html) {
53
58
  const doc = parseHtml(html);
54
59
  const faculties = [];
@@ -61,7 +66,7 @@ export function parseFacultyButtons(html) {
61
66
  }
62
67
  return faculties;
63
68
  }
64
- export function parseAudienceButtons(html) {
69
+ export function parseRoomButtons(html) {
65
70
  const results = [];
66
71
  const seen = new Set();
67
72
  const re = /<button[^>]*\bname="aud(\d+)"[^>]*\bvalue="([^"]*)"/g;
@@ -74,7 +79,7 @@ export function parseAudienceButtons(html) {
74
79
  }
75
80
  return results;
76
81
  }
77
- export function parseAudienceName(html) {
82
+ export function parseRoomName(html) {
78
83
  const m = html.match(/id="path"[\s\S]*?findaud[^>]*>[^<]*<\/a>([\s\S]*?)<\/div>/);
79
84
  if (!m)
80
85
  return null;
@@ -1,10 +1,11 @@
1
- import type { ScheduleEntry, Substitution, TransferInfo } from "../types.js";
2
- export declare function parseDate(dd: string, mm: string, yyyy: string): Date;
1
+ import type { LocalDate } from "../../common/types.js";
2
+ import type { ParsedLesson, Substitution, TransferInfo } from "../types.js";
3
+ export declare function parseDate(dd: string, mm: string, yyyy: string): LocalDate;
3
4
  export declare function parseTransferDiv(div: Element): {
4
5
  transfer: TransferInfo;
5
- entry: ScheduleEntry;
6
+ entry: ParsedLesson;
6
7
  } | null;
7
8
  export declare function parseSubstitutionDiv(div: Element): Substitution | null;
8
9
  export declare function parseSubstituteForDiv(div: Element): {
9
- entry: ScheduleEntry;
10
+ entry: ParsedLesson;
10
11
  } | null;
@@ -1,11 +1,11 @@
1
1
  import { parseTeacher, text } from "../../common/parse.js";
2
2
  import { parseGroupsString } from "./groups.js";
3
+ import { hasDistanceMarker } from "./entry-parts.js";
3
4
  import { LESSON_TYPE_PATTERN, LESSON_TYPE_RE, LESSON_TYPE_RE_I, SUBGROUP_RE, } from "./patterns.js";
4
5
  const GROUP_CODE_RE = /[A-ZА-ЯЁ]{1,}(?:-[A-ZА-ЯЁa-zа-яё0-9]+)+/u;
5
- const DISTANCE_RE = /дистанционно|ДОТ/i;
6
6
  const BLUE_SPAN_RE = /<span\b(?=[^>]*(?:style=["'][^"']*color:\s*blue|class=["'][^"']*\bblue\b))[^>]*>([^<]+)<\/span>/i;
7
7
  export function parseDate(dd, mm, yyyy) {
8
- return new Date(parseInt(yyyy), parseInt(mm) - 1, parseInt(dd));
8
+ return `${yyyy}-${mm}-${dd}`;
9
9
  }
10
10
  export function parseTransferDiv(div) {
11
11
  const divText = text(div);
@@ -53,17 +53,17 @@ export function parseTransferDiv(div) {
53
53
  }
54
54
  const transfer = { targetDate, fromDate, fromSlot, subject };
55
55
  const subgroupMatch = divText.match(SUBGROUP_RE);
56
+ const groups = parseGroupsString(groupsPart);
56
57
  return {
57
58
  transfer,
58
59
  entry: {
59
- room: roomMatch?.[1] ?? "",
60
+ room: roomMatch?.[1] || undefined,
60
61
  subject,
61
62
  type: typeMatch?.[1] ?? "",
62
- weeks: { from: 0, to: 0 },
63
- teacher: parseTeacher(teacherPart),
64
- groups: parseGroupsString(groupsPart),
63
+ teacher: teacherPart ? parseTeacher(teacherPart) : undefined,
64
+ groups: groups.length > 0 ? groups : undefined,
65
65
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
66
- isDistance: DISTANCE_RE.test(divText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
66
+ isDistance: hasDistanceMarker(divText) || hasDistanceMarker(roomMatch?.[1] ?? ""),
67
67
  transfer,
68
68
  },
69
69
  };
@@ -83,7 +83,7 @@ export function parseSubstitutionDiv(div) {
83
83
  const teacherMatch = divHtml.match(/Преподаватель:\s*<span[^>]*>([^<]+)<\/span>/);
84
84
  if (teacherMatch)
85
85
  teacher = parseTeacher(teacherMatch[1].trim());
86
- return { date, room, teacher, isDistance: DISTANCE_RE.test(room ?? divText) };
86
+ return { date, room, teacher, isDistance: hasDistanceMarker(room ?? divText) };
87
87
  }
88
88
  export function parseSubstituteForDiv(div) {
89
89
  const divText = text(div);
@@ -96,7 +96,7 @@ export function parseSubstituteForDiv(div) {
96
96
  const origTeacherMatch = divHtml.match(/замена\s*вместо:\s*<\/b><\/span>\s*<span[^>]*>([^<]+)<\/span>/);
97
97
  const originalTeacher = origTeacherMatch
98
98
  ? parseTeacher(origTeacherMatch[1].trim())
99
- : { name: "" };
99
+ : undefined;
100
100
  // Subject: second blue span
101
101
  const subjectEl = div.querySelectorAll('span[style*="color: blue"]');
102
102
  let subject = "";
@@ -113,16 +113,15 @@ export function parseSubstituteForDiv(div) {
113
113
  const typeMatch = divText.match(LESSON_TYPE_RE);
114
114
  const groupsMatch = divHtml.match(new RegExp(`\\((?:${LESSON_TYPE_PATTERN})\\)\\s*(?:<br\\s*\\/?>)\\s*([^<]+?)(?:\\s*<i|$)`));
115
115
  const subgroupMatch = divText.match(SUBGROUP_RE);
116
+ const groups = parseGroupsString(groupsMatch?.[1]);
116
117
  return {
117
118
  entry: {
118
- room: roomMatch?.[1] ?? "",
119
+ room: roomMatch?.[1] || undefined,
119
120
  subject,
120
121
  type: typeMatch?.[1] ?? "",
121
- weeks: { from: 0, to: 0 },
122
- teacher: { name: "" },
123
- groups: parseGroupsString(groupsMatch?.[1]),
122
+ groups: groups.length > 0 ? groups : undefined,
124
123
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
125
- isDistance: DISTANCE_RE.test(divText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
124
+ isDistance: hasDistanceMarker(divText) || hasDistanceMarker(roomMatch?.[1] ?? ""),
126
125
  substituteFor: { date, originalTeacher },
127
126
  },
128
127
  };
@@ -1,5 +1,5 @@
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";
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|\u0438\u0437|\u0433\u0437|\u043A\u0440\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|\u0438\u0437|\u0433\u0437|\u043A\u0440\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,9 +1,9 @@
1
- export const LESSON_TYPE_PATTERN = "лк|пр|лб|зачо|зач|экз|конс|кп";
1
+ export const LESSON_TYPE_PATTERN = "лк|пр|лб|зачо|зач|экз|конс|кп|из|гз|крп";
2
2
  export const FLEXIBLE_LESSON_TYPE_PATTERN = `(?:${LESSON_TYPE_PATTERN})\\.?|Экз`;
3
- export const LESSON_TYPE_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`);
3
+ export const LESSON_TYPE_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "i");
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");
6
- export const LESSON_TYPE_GLOBAL_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "g");
6
+ export const LESSON_TYPE_GLOBAL_RE = new RegExp(`\\((${LESSON_TYPE_PATTERN})\\)`, "gi");
7
7
  export const WEEKS_RE = /\(([^)]*нед\.?[^)]*)\)/;
8
8
  export const WEEKS_GLOBAL_RE = /\([^)]*нед\.?[^)]*\)/g;
9
9
  export const SUBGROUP_RE = /(\d+)\s*подгруппа/;
@@ -1,4 +1,3 @@
1
- import { EducationType } from "../../common/types.js";
2
- import type { FullScheduleDay, TeacherInfo } from "../types.js";
3
- export declare function parseTeacherFullSchedule(html: string, educationType?: EducationType): FullScheduleDay[];
1
+ import type { ParsedScheduleDay, TeacherInfo } from "../types.js";
2
+ export declare function parseTeacherSchedule(html: string): ParsedScheduleDay[];
4
3
  export declare function parseTeacherInfo(html: string): TeacherInfo | null;