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:
116
server/diagnostics.js
Normal file
116
server/diagnostics.js
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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) };
|
||||
|
||||
|
||||
@@ -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"));
|
||||
|
||||
125
server/parser.js
125
server/parser.js
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user