chuvsu-js 4.1.4 → 4.2.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.
@@ -28,7 +28,7 @@ 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*/);
@@ -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
+ import { linesAfterSubject, stripDistanceMarker } from "./entry-parts.js";
6
7
  const DISTANCE_RE = /дистанционно|ДОТ/i;
7
8
  export function parseAudienceInfo(html) {
8
9
  const doc = parseHtml(html);
@@ -111,28 +112,19 @@ function parseAudienceSemesterEntry(el) {
111
112
  const weeksMatch = cleanText.match(WEEKS_RE);
112
113
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
113
114
  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();
115
+ // Audience entries: subject line, then teacher line, then group line(s).
116
+ const parts = linesAfterSubject(cleanHtml, subject);
117
+ const teacherLine = stripDistanceMarker(parts[0] ?? "");
118
+ const groupsLine = parts
119
+ .slice(1)
120
+ .map(stripDistanceMarker)
121
+ .map((line) => line
122
+ .replace(LESSON_TYPE_GLOBAL_RE, "")
123
+ .replace(WEEKS_GLOBAL_RE, "")
124
+ .replace(SUBGROUP_ANNOTATION_RE, "")
125
+ .trim())
126
+ .filter(Boolean)
127
+ .join(" ");
136
128
  return {
137
129
  room: "",
138
130
  subject,
@@ -0,0 +1,7 @@
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 linesAfterSubject(html: string, subject: string): string[];
5
+ export declare function parseEntryRoom(html: string, subject: string): string;
6
+ export declare function stripDistanceMarker(value: string): string;
7
+ export declare function containsGroupCode(value: string): boolean;
@@ -0,0 +1,49 @@
1
+ const BR_RE = /<br\s*\/?>/i;
2
+ const TAG_RE = /<[^>]*>/g;
3
+ const DISTANCE_RE = /дистанционно|ДОТ/i;
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 linesAfterSubject(html, subject) {
17
+ const lines = entryTextLines(html);
18
+ const index = lines.findIndex((line) => line.includes(subject));
19
+ return index < 0 ? [] : lines.slice(index + 1);
20
+ }
21
+ export function parseEntryRoom(html, subject) {
22
+ const lines = entryHtmlLines(html);
23
+ const line = lines.find((part) => stripHtml(part).includes(subject));
24
+ if (!line)
25
+ return "";
26
+ const lineText = stripHtml(line);
27
+ const subjectIndex = lineText.indexOf(subject);
28
+ if (subjectIndex < 0)
29
+ return "";
30
+ const beforeSubject = lineText
31
+ .slice(0, subjectIndex)
32
+ .replace(/^\*+\s*/, "")
33
+ .trim();
34
+ if (DISTANCE_RE.test(stripHtml(html))) {
35
+ return "Дистанционно (ДОТ)";
36
+ }
37
+ if (!beforeSubject)
38
+ return "";
39
+ return beforeSubject;
40
+ }
41
+ export function stripDistanceMarker(value) {
42
+ return value
43
+ .replace(/\(\s*ДОТ\s*\)/giu, " ")
44
+ .replace(/\s+/g, " ")
45
+ .trim();
46
+ }
47
+ export function containsGroupCode(value) {
48
+ return value.split(/\s+/).some((token) => GROUP_TOKEN_RE.test(token));
49
+ }
@@ -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
+ import { containsGroupCode, linesAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
5
6
  const DISTANCE_RE = /дистанционно|ДОТ/i;
6
7
  export function parseFullSchedule(html, educationType) {
7
8
  const doc = parseHtml(html);
@@ -30,6 +31,13 @@ export function parseSemesterScheduleWith(doc, entryParser) {
30
31
  }
31
32
  if (!currentDay)
32
33
  continue;
34
+ const selfStudyMarker = row.querySelector('span[style*="color: blue"]');
35
+ if (selfStudyMarker &&
36
+ /^День самостоятельной работы$/i.test(text(selfStudyMarker)) &&
37
+ selfStudyMarker.closest("tr") === row) {
38
+ currentDay.isSelfStudyDay = true;
39
+ continue;
40
+ }
33
41
  const timeCell = row.querySelector("td.trf");
34
42
  const dataCell = row.querySelector("td.trdata:not(.trf)");
35
43
  if (!timeCell || !dataCell)
@@ -98,20 +106,23 @@ function parseSemesterEntry(el) {
98
106
  return null;
99
107
  const typeMatch = cleanText.match(LESSON_TYPE_RE);
100
108
  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|$)/);
109
+ const room = parseEntryRoom(cleanHtml, subject);
110
+ const teacherLine = linesAfterSubject(cleanHtml, subject).find((line) => {
111
+ const candidate = stripDistanceMarker(line);
112
+ return candidate.length > 0 && !SUBGROUP_RE.test(candidate);
113
+ }) ?? "";
103
114
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
104
115
  const weekParity = parseWeekParity(cleanHtml);
105
116
  return {
106
- room: roomMatch?.[1] ?? "",
117
+ room,
107
118
  subject,
108
119
  type: typeMatch?.[1] ?? "",
109
120
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
110
- teacher: parseTeacher(teacherMatch?.[1] ?? ""),
121
+ teacher: parseTeacher(teacherLine),
111
122
  groups: [],
112
123
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
113
124
  weekParity,
114
- isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
125
+ isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(room),
115
126
  substitutions: substitutions.length > 0 ? substitutions : undefined,
116
127
  possibleChanges,
117
128
  };
@@ -168,20 +179,16 @@ function parseSessionEntry(td) {
168
179
  const subject = subjectEl ? text(subjectEl) : "";
169
180
  if (!subject)
170
181
  return null;
171
- // Room: text before the first <span
172
- const roomMatch = fullHtml.match(/^([^<]*?)\s*<span/);
173
- const room = roomMatch ? roomMatch[1].trim() : "";
182
+ const room = parseEntryRoom(fullHtml, subject);
174
183
  // Type: parenthesized text after </span>, case-insensitive
175
184
  const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
176
185
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
177
186
  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) &&
187
+ const parts = linesAfterSubject(fullHtml, subject);
188
+ const teacherPart = parts.find((part) => stripDistanceMarker(part).length > 0 &&
183
189
  !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
184
- !SUBGROUP_RE.test(part)) ?? "";
190
+ !SUBGROUP_RE.test(part) &&
191
+ !containsGroupCode(stripDistanceMarker(part))) ?? "";
185
192
  // Time: after <br>, format HH:MM - HH:MM
186
193
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
187
194
  if (!timeMatch)
@@ -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*подгруппа/;
@@ -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_RE_I, LESSON_TYPE_RE, SUBGROUP_RE, WEEKS_RE, } from "./patterns.js";
7
+ import { containsGroupCode, linesAfterSubject, parseEntryRoom, stripDistanceMarker, } from "./entry-parts.js";
7
8
  const DISTANCE_RE = /дистанционно|ДОТ/i;
8
9
  export function parseTeacherFullSchedule(html, educationType) {
9
10
  const doc = parseHtml(html);
@@ -56,20 +57,20 @@ function parseTeacherSemesterEntry(el) {
56
57
  return null;
57
58
  const typeMatch = cleanText.match(LESSON_TYPE_RE);
58
59
  const weeksMatch = cleanText.match(WEEKS_RE);
59
- const roomMatch = cleanHtml.match(/(?:<sup>[^<]*<\/sup>)?([А-Яа-яA-Za-z]-\d+)/);
60
- const groupsMatch = cleanHtml.match(/<br\s*\/?>\s*([^<]+?)(?:<br|<\/td|<div|<i|$)/);
60
+ const room = parseEntryRoom(cleanHtml, subject);
61
+ const groupsLine = linesAfterSubject(cleanHtml, subject).find((line) => containsGroupCode(stripDistanceMarker(line))) ?? "";
61
62
  const subgroupMatch = cleanText.match(SUBGROUP_RE);
62
63
  const weekParity = parseWeekParity(cleanHtml);
63
64
  return {
64
- room: roomMatch?.[1] ?? "",
65
+ room,
65
66
  subject,
66
67
  type: typeMatch?.[1] ?? "",
67
68
  weeks: parseWeeks(weeksMatch?.[1] ?? ""),
68
69
  teacher: { name: "" },
69
- groups: parseGroupsString(groupsMatch?.[1]),
70
+ groups: parseGroupsString(stripDistanceMarker(groupsLine)),
70
71
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
71
72
  weekParity,
72
- isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(roomMatch?.[1] ?? ""),
73
+ isDistance: DISTANCE_RE.test(cleanText) || DISTANCE_RE.test(room),
73
74
  substitutions: substitutions.length > 0 ? substitutions : undefined,
74
75
  possibleChanges,
75
76
  };
@@ -121,20 +122,17 @@ function parseTeacherSessionEntry(td) {
121
122
  const subject = subjectEl ? text(subjectEl) : "";
122
123
  if (!subject)
123
124
  return null;
124
- const roomMatch = fullHtml.match(/^([^<]*?)\s*<span/);
125
- const room = roomMatch ? roomMatch[1].trim() : "";
125
+ const room = parseEntryRoom(fullHtml, subject);
126
126
  const typeMatch = plainText.match(FLEXIBLE_LESSON_TYPE_RE_I);
127
127
  const type = typeMatch ? typeMatch[1].replace(/\.$/, "").toLowerCase() : "";
128
128
  const subgroupMatch = plainText.match(SUBGROUP_RE);
129
129
  const timeMatch = fullHtml.match(/<br\s*\/?>\s*(\d{2}:\d{2})\s*-\s*(\d{2}:\d{2})/);
130
130
  if (!timeMatch)
131
131
  return null;
132
- const parts = fullHtml
133
- .split(/<br\s*\/?>/i)
134
- .map((part) => part.replace(/<[^>]*>/g, "").trim())
135
- .filter((part) => part.length > 0);
136
- const groupsPart = parts.find((part) => !part.includes(subject) &&
137
- !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part)) ?? "";
132
+ const parts = linesAfterSubject(fullHtml, subject);
133
+ const groupsPart = parts.find((part) => stripDistanceMarker(part).length > 0 &&
134
+ !/^\d{2}:\d{2}\s*-\s*\d{2}:\d{2}$/.test(part) &&
135
+ containsGroupCode(stripDistanceMarker(part))) ?? "";
138
136
  return {
139
137
  entry: {
140
138
  room,
@@ -142,7 +140,7 @@ function parseTeacherSessionEntry(td) {
142
140
  type,
143
141
  weeks: { from: 0, to: 0 },
144
142
  teacher: { name: "" },
145
- groups: parseGroupsString(groupsPart),
143
+ groups: parseGroupsString(stripDistanceMarker(groupsPart)),
146
144
  subgroup: subgroupMatch ? parseInt(subgroupMatch[1]) : undefined,
147
145
  isDistance: DISTANCE_RE.test(plainText) || DISTANCE_RE.test(room),
148
146
  possibleChanges,
@@ -135,7 +135,7 @@ export class Schedule {
135
135
  year: this.getSemesterYear(semesterPeriod),
136
136
  date,
137
137
  });
138
- if (week >= 0 && week <= 17) {
138
+ if (week >= 1 && week <= 17) {
139
139
  const weekday = date.getDay();
140
140
  const slots = this.getSlotsForWeekday(weekday, semesterDays, {
141
141
  subgroup: opts?.subgroup,
@@ -100,6 +100,8 @@ export interface FullScheduleSlot {
100
100
  export interface FullScheduleDay {
101
101
  weekday: string;
102
102
  date?: Date;
103
+ /** True when portal marks this weekday as a self-study day. */
104
+ isSelfStudyDay?: boolean;
103
105
  slots: FullScheduleSlot[];
104
106
  }
105
107
  export interface LessonTimeSlot {
@@ -14,7 +14,7 @@ export declare function getSemesterStart(opts: {
14
14
  }): Date;
15
15
  /**
16
16
  * All weeks in a semester with their start/end dates.
17
- * Week 0 starts from the semester start date.
17
+ * Week 1 is the calendar week containing the semester start date.
18
18
  */
19
19
  export declare function getSemesterWeeks(opts: {
20
20
  period: Period;
@@ -36,16 +36,16 @@ export function getSemesterStart(opts) {
36
36
  }
37
37
  /**
38
38
  * All weeks in a semester with their start/end dates.
39
- * Week 0 starts from the semester start date.
39
+ * Week 1 is the calendar week containing the semester start date.
40
40
  */
41
41
  export function getSemesterWeeks(opts) {
42
42
  const weekCount = opts.weekCount ?? 17;
43
43
  const semesterStart = getSemesterStart(opts);
44
44
  const startMonday = getMonday(semesterStart);
45
45
  const weeks = [];
46
- for (let i = 0; i <= weekCount; i++) {
46
+ for (let i = 1; i <= weekCount; i++) {
47
47
  const start = new Date(startMonday);
48
- start.setDate(startMonday.getDate() + i * 7);
48
+ start.setDate(startMonday.getDate() + (i - 1) * 7);
49
49
  const end = new Date(start);
50
50
  end.setDate(start.getDate() + 6);
51
51
  end.setHours(23, 59, 59, 999);
@@ -60,5 +60,5 @@ export function getWeekNumber(opts) {
60
60
  const startMonday = getMonday(semesterStart);
61
61
  const targetMonday = getMonday(date);
62
62
  const diff = targetMonday.getTime() - startMonday.getTime();
63
- return Math.floor(diff / (7 * 24 * 60 * 60 * 1000));
63
+ return Math.floor(diff / (7 * 24 * 60 * 60 * 1000)) + 1;
64
64
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chuvsu-js",
3
- "version": "4.1.4",
3
+ "version": "4.2.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",
@@ -43,6 +43,9 @@
43
43
  "scripts": {
44
44
  "clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\"",
45
45
  "build": "pnpm clean && tsc",
46
- "test": "pnpm build && node --test"
46
+ "test": "pnpm build && node --test",
47
+ "test:live:schedules": "pnpm build && node --env-file=.env utils/testSchedules.mjs",
48
+ "fixtures:audit": "pnpm build && node utils/auditScheduleExpectations.mjs",
49
+ "fixtures:collect": "pnpm build && node --env-file=.env utils/collectScheduleFixtures.mjs"
47
50
  }
48
51
  }