Add course schedules for ISF and ITZS programs for 2026 and 2024 sessions

- Created new snapshot files for ISF credits and daily schedules for the 26-АРХ and 26-ПГС programs.
- Added summer 2026 and winter 2024 exam schedules for the 23-ИТзс program, detailing lectures, labs, and consultations.
This commit is contained in:
2026-10-19 22:55:28 +03:00
parent 15851b26cd
commit e38c388462
24 changed files with 73775 additions and 70942 deletions

View File

@@ -6,9 +6,9 @@ import { mkdtemp, readFile, writeFile, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { createHash } from "node:crypto";
import { parseWeekRanges, parseTextDates, DATE_LIST } from "../shared/weeks.js";
import { parseWeekRanges, parseTextDates, DATE_LIST, weekday } from "../shared/weeks.js";
const run = promisify(execFile);
export const PARSER_VERSION = 11;
export const PARSER_VERSION = 12;
const days = [
"понедельник",
"вторник",
@@ -46,10 +46,15 @@ function timesIn(value) {
? { start: times[0], end: times.at(-1), original: value }
: null;
}
// One part of a week mark, "6-12" or "2,4", with its optional "н."; a mark is several parts
// joined by ";" or "," ("1-4н.;6-12н.", "2-5 н., 10-17 н.", "1-9;15,16н.") ending in "н".
const WEEK_PART = String.raw`\d{1,2}(?:\s*[-–,]\s*\d{1,2})*(?:\s*н(?:ед)?\.?)?`;
const WEEK_MARK = new RegExp(
String.raw`^(?:(?:с|со)\s*)?${WEEK_PART}(?:\s*[;,]\s*${WEEK_PART})*(?<=н|нед|н\.|нед\.)(?:\s*\.)?\s*`,
"i",
);
function conditionIn(value) {
const m = value.match(
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)(?:\s*\.)?\s*/i,
);
const m = value.match(WEEK_MARK);
if (m) return m[0].trim();
// Some university cells omit the week suffix (for example 1-8,10-17).
const range = value.match(
@@ -131,11 +136,7 @@ export function details(raw) {
}
// Split only at an explicit new week condition. Keep unfamiliar wording intact.
function alternatives(raw) {
return raw
.split(
/\n(?=(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*н(?:ед)?\.?)/i,
)
.filter(Boolean);
return raw.split(new RegExp(String.raw`\n(?=${WEEK_MARK.source.slice(1)})`, "i")).filter(Boolean);
}
export function readDocx(buffer) {
let zip;
@@ -226,6 +227,43 @@ export function readDocx(buffer) {
* columns is "Д.н." (the weekday with its date), "Время" and "Дисциплина, преподаватель,
* аудитория", whose columns split in two where the subgroups have separate laboratory classes.
*/
/**
* A date that opens a cell: "13.10 Химия", "3.12 Физика", "24.11. Графика", "12.09.26г. Алгебра",
* "09.09.Материалы". A one-digit day only below 7, so "9.10" stays ambiguous rather than a date.
*/
export function leadingDate(text, yearStart) {
const m = text.match(/^(\d{1,2})\.(\d{2})(?:\.(\d{4}|\d{2})(?!\d))?\.?(?:\s*г\.?)?(?=\s|[А-ЯЁа-яё])/u);
if (!m) return null;
const day = Number(m[1]), month = Number(m[2]);
if (day < 1 || day > 31 || month < 1 || month > 12 || (m[1].length === 1 && day >= 7)) return null;
const year = m[3] ? (m[3].length === 2 ? `20${m[3]}` : m[3]) : yearStart ? (month >= 8 ? Number(yearStart) : Number(yearStart) + 1) : null;
return {
text: m[0].trim(),
length: m[0].length,
date: year ? `${year}-${m[2]}-${m[1].padStart(2, "0")}` : null,
};
}
/**
* Times of their own that open a cell, for a curator's hour or a late pair: "16.50 Кураторский час",
* "9.00 Информационный час", "18.35. Налоговое право", "11:10-11:30 – информационный час",
* "17.00 и 18.35 Русский язык". With `loose`, also "20.10", which reads as a date as well.
*/
export function leadingTime(text, loose = false) {
const hm = (h, m) => `${h.padStart(2, "0")}:${m}`;
const range = text.match(/^([0-2]?\d)[.:]([0-5]\d)\s*[-–]\s*([0-2]?\d)[.:]([0-5]\d)(?=\s)/);
if (range && Number(range[1]) <= 23 && Number(range[3]) <= 23)
return { starts: [hm(range[1], range[2])], end: hm(range[3], range[4]), length: range[0].length };
const T = String.raw`[0-2]?\d[.:][0-5]\d`;
const list = text.match(new RegExp(String.raw`^${T}(?:\s*(?:и|\+|,)\s*${T})*\.?(?=\s|[А-ЯЁа-яё])`, "u"));
if (!list) return null;
const times = [...list[0].matchAll(/([0-2]?\d)([.:])([0-5]\d)/g)];
// One unmistakable time ("18.35+20.10") vouches for the others in the list.
const clear = ([, , sep, m]) => sep === ":" || Number(m) > 12 || m === "00";
if (!loose && !times.some(clear)) return null;
// Lessons run from 7 to 22: "3.12" next to a clear time is still not a time.
if (times.some((t) => Number(t[1]) > 23 || (!clear(t) && (Number(t[1]) < 7 || Number(t[1]) > 21)))) return null;
return { starts: times.map(([, h, , m]) => hm(h, m)), end: null, length: list[0].length };
}
function weekBlocks(rows) {
for (let i = 0; i < Math.min(rows.length, 4); i++) {
const cells = rows[i];
@@ -411,43 +449,25 @@ export function parseDocx(buffer, source = {}) {
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
for (const alternative of alternatives(raw)) {
// "Единый день информирования 10.09;15.10;19.11;17.12": the dates are not part of the subject.
const data = details(alternative.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
if (credits && data.type === "Занятие") data.type = "Зачёт";
const dateCandidate = alternative.match(
/^(\d{2})[.](\d{2})(?:[.](\d{4}))?\s/,
);
const singleDate =
dateCandidate &&
Number(dateCandidate[1]) >= 1 &&
Number(dateCandidate[1]) <= 31 &&
Number(dateCandidate[2]) >= 1 &&
Number(dateCandidate[2]) <= 12
? dateCandidate
: null;
const explicitTime = alternative.match(
/^([0-2]?\d)([.:])([0-5]\d)\s+/,
);
const ownTime =
explicitTime &&
Number(explicitTime[1]) <= 23 &&
(explicitTime[2] === ":" || Number(explicitTime[3]) > 12)
? `${explicitTime[1].padStart(2, "0")}:${explicitTime[3]}`
: null;
const oneDate =
singleDate && (singleDate[3] || academicYear)
? `${singleDate[3] || academicYear}-${singleDate[2]}-${singleDate[1]}`
: null;
if (singleDate) {
data.condition = singleDate[0].trim();
data.subject = details(
alternative.slice(singleDate[0].length),
).subject;
const read = (text) => {
const data = details(text.replace(DATE_LIST, "").replace(/[ \t]{2,}/g, " ").trim());
if (credits && data.type === "Занятие") data.type = "Зачёт";
return data;
};
let data = read(alternative);
// "20.10" is 20 October or the eighth pair at 20:10. A date on another weekday than
// the row, or followed by a week mark, is the time.
let lead = leadingTime(alternative) || leadingDate(alternative, yearStart);
if (lead?.date) {
const after = alternative.slice(lead.length).trim();
const time = leadingTime(alternative, true);
if (time && (weekday(lead.date) !== day || WEEK_MARK.test(after))) lead = time;
}
if (ownTime) {
data.subject = details(
alternative.slice(explicitTime[0].length),
).subject;
data.condition = "";
const oneDate = lead?.date || null;
if (lead) {
// The rest of the cell is an ordinary entry: "17.00 1 нед. Математика (пр.) …".
data = read(alternative.slice(lead.length).replace(/^[\s–—:-]+/, ""));
if (oneDate) data.condition = lead.text;
}
const subgroups = sessionColumns
? []
@@ -463,17 +483,18 @@ export function parseDocx(buffer, source = {}) {
group: r.group,
label: `Подгруппа ${cell.col - r.col + 1}`,
}));
const id = createHash("sha256")
.update([source.name, i, cell.col, alternative].join("|"))
.digest("hex")
.slice(0, 16);
// "17.00 и 18.35 Русский язык": one lesson at each time.
for (const [n, start] of (lead?.starts || [null]).entries())
lessons.push({
id,
id: createHash("sha256")
.update([source.name, i, cell.col, alternative, n || ""].join("|"))
.digest("hex")
.slice(0, 16),
day,
date: date || oneDate,
pair: pair || null,
...time,
...(ownTime ? { start: ownTime, end: null } : {}),
...(start && { start, end: lead.end }),
...data,
...overrides,
groups: targetGroups,