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

116
server/diagnostics.js Normal file
View File

@@ -0,0 +1,116 @@
import { parseWeekRanges, weekday, DATE_LIST } from "../shared/weeks.js";
// The parser never guesses: a cell it cannot read becomes a lesson "every week" or keeps its
// whole text as the subject. These checks find such cells after parsing, so a new format or a
// typo in Word shows up as soon as the document is published instead of when a student notices.
const MONTHS = /(?<![а-яё])(?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)[а-яё]*/iu;
// The longest semester has about twenty weeks; "27н." is a typo, not a week.
const LAST_WEEK = 24;
const message = {
weeks_unreadable: "Пометка недель не распознана: занятие показывается каждую неделю.",
weeks_out_of_range: "Номер недели больше, чем недель в семестре.",
weeks_like_date: "Пометка недель похожа на дату с опечаткой.",
dates_unread: "В ячейке есть даты, но ни одна не распознана: занятие показывается каждую неделю.",
date_weekday: "Дата приходится на другой день недели, чем строка таблицы.",
date_out_of_year: "Дата вне учебного года документа.",
weeks_partly: "Пометка недель прочитана не целиком: часть недель попала в название предмета.",
date_in_subject: "Дата в начале ячейки не распознана: занятие показывается каждую неделю.",
time_in_subject: "Время в начале ячейки не распознано: занятие стоит во время пары, а время попало в название.",
subject_missing: "Название предмета не выделено.",
time_invalid: "Время пары не распознано или выглядит неправдоподобно.",
group_empty: "В заголовке есть группа, но ни одного её занятия.",
};
/** Problems found in one parsed document: `{code, message, groups, day, start, raw, …}`. */
export function diagnose(schedule) {
const found = [];
const add = (code, lesson, extra = {}) =>
found.push({
code,
message: message[code],
...(lesson && {
groups: lesson.groups,
day: lesson.day,
start: lesson.start || null,
raw: lesson.raw,
}),
...extra,
});
const year = Number(schedule.academicYear) || null;
for (const lesson of schedule.lessons) {
const raw = lesson.raw || "";
const dated = Boolean(lesson.date) || lesson.dates?.length > 0;
if (lesson.condition && !dated) {
const ranges = lesson.weekRanges ?? parseWeekRanges(lesson.condition);
// "19,11, 03.12, 17.12": a date typed with a comma reads as weeks 19 and 11.
if (!/н/i.test(lesson.condition) && DATE_LIST.test(raw.slice(lesson.condition.length - 1)))
add("weeks_like_date", lesson, { condition: lesson.condition });
else if (!ranges) add("weeks_unreadable", lesson, { condition: lesson.condition });
else if (ranges.some(([a, b]) => Math.max(a, b ?? a) > LAST_WEEK))
add("weeks_out_of_range", lesson, { condition: lesson.condition });
DATE_LIST.lastIndex = 0;
}
if (!dated && !lesson.condition && (MONTHS.test(raw) || /\b\d{1,2}\.\d{2}\.\d{4}\b/.test(raw)))
add("dates_unread", lesson);
for (const date of lesson.date ? [lesson.date] : lesson.dates || []) {
if (weekday(date) !== lesson.day) add("date_weekday", lesson, { date });
if (year && (date < `${year}-08-01` || date > `${year + 1}-08-31`))
add("date_out_of_year", lesson, { date });
}
const subject = (lesson.subject || "").trim();
const lead = subject.match(/^(\d{1,2})([.:])(\d{2})(\.?)(?:\s*[-–]\s*\d{1,2}[.:]\d{2})?/);
if (/^[;,]?\s*\d{1,2}(?:\s*[-–,;]\s*\d{1,2})*\s*н/i.test(subject)) add("weeks_partly", lesson, { subject });
else if (lead) {
const [whole, a, sep, b, dot] = lead;
// "3.12" or "24.11." is a date, "9.00", "17.00" or "11:10-11:30" a time.
const date = sep === "." && whole === `${a}.${b}${dot}` && Number(b) >= 1 && Number(b) <= 12 && (dot || Number(a) < 7 || Number(a) > 23);
add(date ? "date_in_subject" : "time_in_subject", lesson, { subject });
} else if (!subject || /^[\d.,;:–-]/.test(subject) || (subject === raw.trim() && raw.length > 120))
add("subject_missing", lesson, { subject });
const start = lesson.start || "";
if (!/^\d{2}:\d{2}$/.test(start) || start < "07:00" || start > "21:00" || (lesson.end && lesson.end <= start))
add("time_invalid", lesson, { end: lesson.end || null });
}
for (const group of schedule.groups)
if (!schedule.lessons.some((l) => l.groups.includes(group))) add("group_empty", null, { groups: [group] });
return found;
}
const reports = new WeakMap();
/**
* Every catalog document's problems, newest index only: computed once per index build.
* Filters: `group` (as printed, "23-АРХ"), `doc` (catalog id) and `code`.
*/
export function catalogDiagnostics(idx, { group = "", doc = "", code = "" } = {}) {
if (!reports.has(idx))
reports.set(
idx,
idx.docs.map(({ id, file, schedule }) => ({
doc: { id, name: file.name, faculty: file.faculty || schedule.source.faculty || null, url: file.url || null },
problems: diagnose(schedule),
})),
);
const key = group.replace(/\s+/g, "").toLowerCase();
const items = reports
.get(idx)
.filter((r) => !doc || r.doc.id === doc)
.map((r) => ({
...r,
problems: r.problems.filter(
(p) => (!code || p.code === code) && (!key || p.groups?.some((g) => g.toLowerCase() === key)),
),
}))
.filter((r) => r.problems.length);
const counts = {};
for (const r of items) for (const p of r.problems) counts[p.code] = (counts[p.code] || 0) + 1;
return {
builtAt: idx.builtAt,
documents: idx.docs.length,
problems: items.reduce((n, r) => n + r.problems.length, 0),
counts,
failed: doc || group || code ? [] : idx.failed,
items,
};
}

View File

@@ -1,6 +1,7 @@
// University-wide directory: every catalog document parsed once and searched
// by teacher, room and date. Answers come from the Word files only, so every
// response carries its coverage and the caller must present it as such.
import { catalogDiagnostics } from "./diagnostics.js";
import { getCatalog, getCalendar } from "./source.js";
import { scheduleFor } from "./schedules.js";
import {
@@ -188,6 +189,8 @@ export const indexStatus = () =>
failed: index.failed.length,
builtAt: index.builtAt,
rebuilding: Boolean(building),
// Cells that look misread, listed by /api/diagnostics.
problems: catalogDiagnostics(index).problems,
}
: { documents: 0, rebuilding: Boolean(building) };

View File

@@ -8,6 +8,7 @@ import { rateLimit, semaphore } from "./limits.js";
import { apiError, describe } from "./errors.js";
import { createHash } from "node:crypto";
import { timetable } from "./timetable.js";
import { catalogDiagnostics } from "./diagnostics.js";
import {
getIndex,
indexStatus,
@@ -45,8 +46,9 @@ app.use("/api", (_req, res, next) => {
// 2 — /api/timetable for the Android client; 3 — its range=semester;
// 4 — compression, ETag revalidation, throttled refresh and import limits;
// 5 — error codes (`code` beside `error`, see server/errors.js), timetable 304 before any work;
// 6 — the timetable carries the group's exams and credits from other catalog documents.
const API_VERSION = 6;
// 6 — the timetable carries the group's exams and credits from other catalog documents;
// 7 — /api/diagnostics, cells the parser could not read with confidence.
const API_VERSION = 7;
// Liveness plus what the server currently reads from the university, without network calls.
app.get("/api/health", async (_req, res) => {
@@ -163,6 +165,16 @@ app.get("/api/exams", async (req, res, next) => {
next(e);
}
});
// Cells of catalog documents that look misread: a new layout or a typo in Word, with the
// original text, so they can be checked against the document and the parser taught.
app.get("/api/diagnostics", async (req, res, next) => {
try {
const text = (name) => String(req.query[name] || "").slice(0, 60);
res.json(catalogDiagnostics(await getIndex(), { group: text("group"), doc: text("doc"), code: text("code") }));
} catch (e) {
next(e);
}
});
app.get("/api/catalog", async (req, res, next) => {
try {
res.json(await getCatalog(req.query.refresh === "1"));

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,