initial commit

This commit is contained in:
2026-10-16 22:08:45 +03:00
commit 19ee41411b
97 changed files with 15141 additions and 0 deletions

530
server/parser.js Normal file
View File

@@ -0,0 +1,530 @@
import AdmZip from "adm-zip";
import { load } from "cheerio";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
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 } from "../shared/weeks.js";
const run = promisify(execFile);
export const PARSER_VERSION = 7;
const days = [
"понедельник",
"вторник",
"среда",
"четверг",
"пятница",
"суббота",
"воскресенье",
];
const clean = (value) =>
value
.replace(/\u00a0/g, " ")
.replace(/[\t ]+/g, " ")
.replace(/\s*\n\s*/g, "\n")
.trim();
const groupsIn = (value) =>
[
...value.matchAll(
/\b\d{2}\s*[-–]?\s*[А-ЯЁа-яё]{2,8}(?:\s*\([А-ЯЁа-яё]+\))?(?:\s*[-–]\s*\d)?/gu,
),
].map((m) =>
m[0]
.replace(/\s+/g, "")
.replace(/–/g, "-")
.replace(/^(\d{2})([А-ЯЁа-яё])/, "$1-$2"),
);
function timesIn(value) {
const times = [...value.matchAll(/\b([0-2]?\d)[.:]([0-5]\d)\b/g)].map(
(m) => `${m[1].padStart(2, "0")}:${m[2]}`,
);
return times.length >= 2
? { start: times[0], end: times.at(-1), original: value }
: null;
}
function conditionIn(value) {
const m = value.match(
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})*\s*(?:н(?:ед)?\.?)(?:\s*\.)?\s*/i,
);
if (m) return m[0].trim();
// Some university cells omit the week suffix (for example 1-8,10-17).
const range = value.match(
/^(?:(?:с|со)\s*)?\d{1,2}(?:\s*[-–,]\s*\d{1,2})+\s*/i,
);
return range?.[0].trim() || "";
}
export function details(raw) {
const condition = conditionIn(raw);
let rest = raw.slice(condition.length).trim();
// "Экзамен. Финансы …" or "Зачёт: …": the label is the kind of session event, not the subject.
const leading = rest.match(
/^(экзамен|зач[её]т|консультация)(?![а-яё])[\s.:–—-]*/i,
);
if (leading) rest = rest.slice(leading[0].length);
const kind = rest.match(
/\((л\.?\s*з\.?|п\.?\s*з\.?|л\.?|лаб\.?|пр\.?|лек\.?|сем\.?|экз\.?|зач\.?|конс\.?)\)/i,
);
// Whole words only: "консультирования" in a subject name is not a consultation.
const sessionWord = (leading?.[1] ||
rest.match(/(?<![а-яё])(экзамен|зач[её]т|консультация)(?![а-яё])/i)?.[1] ||
"").toLowerCase();
const person = rest.match(/[А-ЯЁ][а-яё-]+\s+[А-ЯЁ]\.\s*[А-ЯЁ]\./u);
const room =
rest.match(/(?:ауд\.?\s*)([\d]+\s*[А-Яа-я]?(?:\s*\([^)]*\))?)/i) ||
rest.match(/\b(\d{2,3}\s*[А-Яа-я]?)\s*$/u);
let subject = rest
.slice(0, kind?.index ?? person?.index ?? room?.index ?? rest.length)
.trim()
.replace(/[–—,\-]+$/, "")
.trim();
if (!subject) subject = rest;
const token =
kind?.[1].replace(/[.\s]/g, "").toLowerCase() ||
(sessionWord.startsWith("экз")
? "экз"
: sessionWord.startsWith("зач")
? "зач"
: sessionWord.startsWith("конс")
? "конс"
: undefined);
return {
subject,
teacher: person?.[0] || "",
room: room?.[1].trim().replace(/\s+/g, "") || "",
type:
token === "лз" || token === "лаб"
? "Лабораторная"
: token === "пз" || token === "пр"
? "Практика"
: token === "л" || token === "лек"
? "Лекция"
: token === "сем"
? "Семинар"
: token === "экз"
? "Экзамен"
: token === "зач"
? "Зачёт"
: token === "конс"
? "Консультация"
: "Занятие",
condition,
};
}
// 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);
}
export function readDocx(buffer) {
let zip;
try {
zip = new AdmZip(buffer);
} catch {
throw new Error("Файл повреждён или не является документом Word.");
}
const entry = zip.getEntry("word/document.xml");
if (!entry)
throw new Error(
"В файле нет документа Word. Выберите .docx с таблицей расписания.",
);
if (entry.header.size > 20 * 1024 * 1024 || zip.getEntries().length > 2000)
throw new Error("Документ слишком большой для импорта.");
const xml = entry.getData().toString("utf8");
const $ = load(xml.replace(/(<\/?)(?:w:)/g, "$1"), { xmlMode: true });
const text = (node) =>
clean(
$(node)
.find("p")
.map((_, p) =>
$(p)
.find("t, br, tab")
.map((_, t) =>
t.name === "br" ? "\n" : t.name === "tab" ? " " : $(t).text(),
)
.get()
.join(""),
)
.get()
.join("\n"),
);
const tables = $("body > tbl")
.map((_, tbl) => {
const rows = $(tbl)
.children("tr")
.map((_, tr) => {
let col = Number(
$(tr).children("trPr").find("gridBefore").attr("w:val") || 0,
);
return [
$(tr)
.children("tc")
.map((_, tc) => {
const span = Number(
$(tc).children("tcPr").find("gridSpan").attr("w:val") || 1,
);
const vm = $(tc).children("tcPr").find("vMerge");
const cell = {
text: text(tc),
col,
span,
merge: vm.length ? vm.attr("w:val") || "continue" : null,
color:
$(tc).children("tcPr").find("shd").attr("w:fill") || null,
};
col += span;
return cell;
})
.get(),
];
})
.get();
return [{ rows }];
})
.get();
return {
tables,
text: text($("body")[0]),
notes: $("body > p")
.map((_, p) =>
clean(
$(p)
.find("t")
.map((_, t) => $(t).text())
.get()
.join(""),
),
)
.get()
.filter((s) => s && !/декан|начальник|_{3}|^утверждаю/i.test(s)),
};
}
export function parseDocx(buffer, source = {}) {
const document = readDocx(buffer);
const lessons = [],
groups = new Set(),
warnings = new Set();
// The catalog folder ("2026–2027") wins: some files keep last year's header text.
const academicYear =
String(source.year || "").match(/^(20\d{2})/)?.[1] ||
document.text.match(/(20\d{2})\s*[/–-]\s*20\d{2}/)?.[1];
// Text dates like "15 октября" need the autumn year; fall back to the file date.
const modified = source.modified ? new Date(source.modified) : null;
const yearStart =
academicYear ||
(modified && Number.isFinite(modified.getTime())
? modified.getUTCFullYear() - (modified.getUTCMonth() < 7 ? 1 : 0)
: null);
let seenRows = 0,
skippedRows = 0;
for (const { rows } of document.tables) {
let ranges = [],
header = -1;
for (let i = 0; i < rows.length; i++) {
const found = rows[i].flatMap((cell) =>
groupsIn(cell.text).map((group) => ({
group,
col: cell.col,
end: cell.col + cell.span,
})),
);
if (
found.length &&
rows[i].some((cell) => /^(дни|день|часы|пара|время)/i.test(cell.text))
) {
ranges = found;
header = i;
break;
}
}
if (header < 0) continue;
ranges.forEach((r) => groups.add(r.group));
let day = null,
date = null,
time = null,
pair = null,
timeCol = null;
// Session schedules use separate room, subject and teacher columns.
const session = rows[header].some((cell) => /пара/i.test(cell.text));
let sessionColumns = null;
if (session) {
const columns = rows.slice(header, header + 3).flat();
const room = columns.find((c) => /^ауд/i.test(c.text));
const subject = columns.find((c) => /название|дисциплин/i.test(c.text));
const teacher = columns.find((c) => /фамил|преподавател/i.test(c.text));
sessionColumns =
room && subject
? { room: room.col, subject: subject.col, teacher: teacher?.col }
: null;
}
for (let i = header + 1; i < rows.length; i++) {
const row = rows[i];
const dayCell = row.find((c) =>
days.some((d) => c.text.toLowerCase().startsWith(d)),
);
if (dayCell) {
day =
days.findIndex((d) => dayCell.text.toLowerCase().startsWith(d)) + 1;
const m = dayCell.text.match(/(\d{1,2})[.](\d{1,2})[.](\d{4})/);
date = m
? `${m[3]}-${m[2].padStart(2, "0")}-${m[1].padStart(2, "0")}`
: null;
time = null;
pair = null;
}
if (!day) continue;
const timeCell = row.find(
(c) => c.col < ranges[0].col && timesIn(c.text),
);
if (timeCell) {
time = timesIn(timeCell.text);
timeCol = timeCell.col;
pair = session ? row.find((c) => c.col === 1)?.text : null;
}
if (!time) continue;
seenRows++;
const continuedTime =
row.find((c) => c.col === timeCol)?.merge === "continue";
const pairedTime =
timeCell?.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === timeCol)?.merge === "continue";
const variant = continuedTime ? "lower" : pairedTime ? "upper" : "all";
const emit = (cell, targetGroups, raw, overrides = {}) => {
if (!raw.trim()) return;
const spansBoth =
cell.merge === "restart" &&
rows[i + 1]?.find((c) => c.col === cell.col)?.merge === "continue";
for (const alternative of alternatives(raw)) {
const data = details(alternative);
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;
}
if (ownTime) {
data.subject = details(
alternative.slice(explicitTime[0].length),
).subject;
data.condition = "";
}
const subgroups = sessionColumns
? []
: ranges
.filter(
(r) =>
targetGroups.includes(r.group) &&
cell.col >= r.col &&
cell.col + cell.span <= r.end &&
cell.span < r.end - r.col,
)
.map((r) => ({
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);
lessons.push({
id,
day,
date: date || oneDate,
pair: pair || null,
...time,
...(ownTime ? { start: ownTime, end: null } : {}),
...data,
...overrides,
groups: targetGroups,
subgroups,
variant: spansBoth ? "all" : variant,
weekRanges:
date || oneDate ? null : parseWeekRanges(data.condition),
dates:
date || oneDate || data.condition
? []
: parseTextDates(alternative, yearStart),
sourceColor:
cell.color &&
!["auto", "FFFFFF"].includes(cell.color.toUpperCase())
? cell.color
: null,
raw: alternative,
});
}
};
if (sessionColumns) {
const cell = row.find((c) => c.col === sessionColumns.subject);
if (!cell || cell.merge === "continue" || !cell.text) continue;
const room = row.find((c) => c.col === sessionColumns.room)?.text || "";
const teacher =
row.find((c) => c.col === sessionColumns.teacher)?.text || "";
emit(
cell,
ranges.map((r) => r.group),
[cell.text, teacher, room].filter(Boolean).join("\n"),
{ room, teacher },
);
} else {
let count = 0;
for (const cell of row) {
if (
cell.merge === "continue" ||
!cell.text ||
cell.col < ranges[0].col
)
continue;
const targetGroups = ranges
.filter((r) => cell.col < r.end && cell.col + cell.span > r.col)
.map((r) => r.group);
if (!targetGroups.length) continue;
emit(cell, targetGroups, cell.text);
count++;
}
if (!count) skippedRows++;
}
}
}
if (!lessons.length)
throw new Error(
"Таблица расписания не распознана. Нужны названия дней, время и заголовки групп. Исходный файл не изменён.",
);
if (lessons.some((l) => l.variant !== "all"))
warnings.add(
"Белые строки относятся к нечётным неделям, зелёные — к чётным, по таблице недель университета. Цвет строк сохранён как в Word.",
);
if (
lessons.some(
(l) =>
l.condition ||
/нед|\d\s*н[.\s]|сентября|октября|ноября|декабря/i.test(l.raw),
)
)
warnings.add(
"Номера недель и даты рассчитаны по таблице недель университета. Исходная пометка сохранена в карточке; при сомнениях сверяйте её с Word.",
);
if (lessons.some((l) => l.subgroups.length))
warnings.add(
"Номера подгрупп обозначают столбцы внутри группы в исходном Word.",
);
return {
id: createHash("sha256").update(buffer).update(source.fileId || source.name || "").digest("hex").slice(0, 20),
groups: [...groups],
lessons: lessons.sort(
(a, b) => a.day - b.day || a.start.localeCompare(b.start),
),
notes: document.notes,
warnings: [...warnings],
source: {
faculty:
source.faculty ||
[
[/финансово-экономическ/iu, "Финансово-экономический факультет"],
[
/факультета информационных технологий/iu,
"Факультет информационных технологий",
],
[
/факультета компьютерных наук и электроники/iu,
"Факультет компьютерных наук и электроники",
],
[/гуманитарного факультета/iu, "Гуманитарный факультет"],
[/инженерно-строительн/iu, "Инженерно-строительный факультет"],
[/механико-технологическ/iu, "Механико-технологический факультет"],
[/юридического факультета/iu, "Юридический факультет"],
].find(([pattern]) => pattern.test(document.text))?.[1],
course:
source.course ||
(document.text.match(/студентов\s+(\d)\s+курса/iu)?.[1]
? `${document.text.match(/студентов\s+(\d)\s+курса/iu)[1]} курс`
: undefined),
...source,
importedAt: new Date().toISOString(),
},
schemaVersion: PARSER_VERSION,
// Start year of the academic year printed in Word ("2026/2027" → "2026").
academicYear: academicYear || (yearStart ? String(yearStart) : null),
stats: {
lessons: lessons.length,
groups: groups.size,
rows: seenRows,
emptyRows: skippedRows,
},
format: "docx",
period: /экзаменационн/i.test(document.text)
? "Экзаменационная сессия"
: /установочн/i.test(document.text)
? "Установочная сессия"
: /осенний семестр/i.test(document.text)
? "Осенний семестр"
: /весенний семестр/i.test(document.text)
? "Весенний семестр"
: "Расписание из Word",
};
}
export async function parseWord(buffer, source = {}) {
if (buffer.subarray(0, 2).toString() === "PK")
return parseDocx(buffer, source);
if (buffer.subarray(0, 8).toString("hex") !== "d0cf11e0a1b11ae1")
throw new Error("Выберите настоящий файл Word (.docx или .doc).");
const dir = await mkdtemp(join(tmpdir(), "psu-word-"));
try {
const input = join(dir, "schedule.doc"),
output = join(dir, "schedule.docx");
await writeFile(input, buffer);
await run(
process.env.LIBREOFFICE_BIN || "soffice",
[
"-env:UserInstallation=file://" + join(dir, "profile"),
"--headless",
"--convert-to",
"docx",
"--outdir",
dir,
input,
],
{ timeout: 30000 },
);
const parsed = parseDocx(await readFile(output), source);
parsed.format = "doc";
parsed.warnings.unshift(
"Старый .doc преобразован в .docx. Проверьте таблицу и условия по оригиналу.",
);
return parsed;
} catch (error) {
if (error.code === "ENOENT")
throw new Error(
"Для старых .doc на сервере нужен LibreOffice. Можно сохранить документ как .docx в Word и импортировать его.",
);
throw error;
} finally {
await rm(dir, { recursive: true, force: true });
}
}