Files
psu-schedule/server/parser.js
2026-10-16 22:08:45 +03:00

531 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 });
}
}