Files
psu-schedule/server/index.js
Zakhar Timoshenko e38c388462 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.
2026-10-19 22:55:28 +03:00

265 lines
10 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 express from "express";
import compression from "compression";
import { fileURLToPath } from "node:url";
import multer from "multer";
import { parseWord, PARSER_VERSION } from "./parser.js";
import { scheduleFor } from "./schedules.js";
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,
search,
dayOf,
freeRooms,
examsOf,
findGroups,
readyIndex,
sessionEntriesOf,
directoryCalendar,
todayIso,
} from "./directory.js";
import {
getCatalog,
getCalendar,
documentFor,
request,
sourceStatus,
} from "./source.js";
const app = express();
app.disable("x-powered-by");
// Behind Caddy or a tunnel on this host, the client's address comes from X-Forwarded-For;
// only proxies on private networks are believed, so a client cannot forge its own.
app.set("trust proxy", "loopback, linklocal, uniquelocal");
// gzip or brotli: a semester of lessons is about 200 KB of JSON and shrinks tenfold.
app.use(compression());
app.use("/api", (_req, res, next) => {
// Clients may keep a response but must ask again each time; Express's ETag turns an
// unchanged answer into an empty 304, so a phone re-downloads a timetable only when it changed.
res.set("Cache-Control", "no-cache");
next();
});
// Raised whenever an endpoint is added or changed, so a deployment is easy to verify:
// 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;
// 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) => {
const sources = await sourceStatus().catch((e) => ({ error: e.message }));
res.json({
ok: true,
apiVersion: API_VERSION,
parserVersion: PARSER_VERSION,
...sources,
directory: indexStatus(),
});
});
const isDate = (s) => /^\d{4}-\d{2}-\d{2}$/.test(s || "");
const badRequest = (res, error) => res.status(400).json({ error, code: "bad_request" });
// University-wide lookups over every catalog document.
app.get("/api/directory/search", async (req, res, next) => {
try {
res.json(search(await getIndex(), String(req.query.q || "").slice(0, 60)));
} catch (e) {
next(e);
}
});
app.get("/api/directory/day", async (req, res, next) => {
try {
const date = req.query.date || todayIso();
const teacher = String(req.query.teacher || "").slice(0, 80),
room = String(req.query.room || "").slice(0, 20);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
if (!teacher && !room) return badRequest(res, "Укажите преподавателя или аудиторию.");
res.json(dayOf(await getIndex(), await directoryCalendar(), { teacher, room }, date));
} catch (e) {
next(e);
}
});
app.get("/api/directory/free", async (req, res, next) => {
try {
const date = req.query.date || todayIso(),
pair = Number(req.query.pair);
if (!isDate(date)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const calendar = await directoryCalendar();
const result = freeRooms(await getIndex(), calendar, date, pair);
if (!result) return badRequest(res, "Нет такой пары в расписании звонков.");
res.json(result);
} catch (e) {
next(e);
}
});
app.get("/api/groups", async (req, res, next) => {
try {
const text = (name) => String(req.query[name] || "").slice(0, 120);
res.json(
findGroups(await getIndex(), {
q: text("q"),
faculty: text("faculty"),
course: text("course"),
form: text("form"),
}),
);
} catch (e) {
next(e);
}
});
// A group's lessons by date with weeks applied: for native and other clients.
app.get("/api/timetable", async (req, res, next) => {
try {
const doc = String(req.query.doc || ""),
group = String(req.query.group || "").slice(0, 40),
from = req.query.from || todayIso();
if (!doc || !group) return badRequest(res, "Укажите документ (doc) и группу (group).");
if (!isDate(from)) return badRequest(res, "Дата должна быть в формате ГГГГ-ММ-ДД.");
const file = await documentFor(doc);
const calendar = await directoryCalendar();
const range = req.query.range === "semester" ? "semester" : undefined;
const today = todayIso();
// The group's exams and credits from other documents ("Расписание зачетов"); a server
// still building its first index answers without them rather than wait.
const idx = readyIndex();
// The answer follows only from these: the document's version, the parser and the server
// that read it, the week table, the request and the date. An unchanged one comes back
// as an empty 304 before the document is opened or a single day is computed.
const etag = 'W/"tt-' + createHash("sha1")
.update([API_VERSION, PARSER_VERSION, file.id, file.modified, calendar?.updatedAt, idx?.builtAt, group, from, req.query.days, range, today].join("|"))
.digest("base64url")
.slice(0, 22) + '"';
res.set("ETag", etag);
if (req.fresh) return res.status(304).end();
const schedule = await scheduleFor(file);
res.json(
timetable({
file,
schedule,
calendar,
group,
from,
days: req.query.days,
range,
today,
session: sessionEntriesOf(idx, group, file.faculty || schedule.source.faculty, file.id),
}),
);
} catch (e) {
next(e);
}
});
app.get("/api/exams", async (req, res, next) => {
try {
const group = String(req.query.group || "").slice(0, 40);
if (!group) return badRequest(res, "Укажите группу.");
res.json({
group,
exams: examsOf(await getIndex(), group, String(req.query.faculty || "") || null),
});
} catch (e) {
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"));
} catch (e) {
next(e);
}
});
app.get("/api/calendar", async (req, res, next) => {
try {
res.json(await getCalendar(req.query.refresh === "1"));
} catch (e) {
next(e);
}
});
app.get("/api/schedule/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.json(await scheduleFor(file, { refresh: req.query.refresh === "1" }));
} catch (e) {
next(e);
}
});
app.get("/api/original/:id", async (req, res, next) => {
try {
const file = await documentFor(req.params.id);
res.set("Content-Type", "application/octet-stream");
res.set(
"Content-Disposition",
`attachment; filename*=UTF-8''${encodeURIComponent(file.name)}`,
);
res.send(await request(file.path));
} catch (e) {
next(e);
}
});
// Parsing runs a LibreOffice conversion for old .doc files: two at a time, a few more wait,
// and each address may import ten files in ten minutes.
const importLimit = rateLimit({
max: 10,
windowMs: 10 * 60 * 1000,
message: "Слишком много файлов подряд. Попробуйте через несколько минут.",
});
const parsing = semaphore({
limit: 2,
queue: 4,
message: "Сервер занят разбором других файлов. Попробуйте через минуту.",
});
const upload = multer({
storage: multer.memoryStorage(),
limits: { fileSize: 10 * 1024 * 1024, files: 1, fields: 0 },
});
app.post("/api/import", importLimit, upload.single("file"), async (req, res, next) => {
try {
if (!req.file)
return badRequest(res, "Выберите файл Word.");
// Multer decodes multipart names as latin1 by default.
const name = Buffer.from(req.file.originalname, "latin1").toString("utf8");
if (!/\.docx?$/i.test(name))
return badRequest(res, "Поддерживаются файлы .docx и .doc.");
res.json(await parsing(() => parseWord(req.file.buffer, { name, uploaded: true })));
} catch (e) {
// A file that cannot be read is the client's problem; a busy server keeps its 429.
next(e.status || e.code === "LIMIT_FILE_SIZE" ? e : apiError(422, "unreadable_file", e.message));
}
});
const dist = fileURLToPath(new URL("../dist/", import.meta.url));
app.use(express.static(dist, { maxAge: 0 }));
app.get("/{*path}", (req, res, next) =>
req.path.startsWith("/api/")
? res.status(404).json({ error: "Адрес не найден.", code: "unknown_endpoint" })
: res.sendFile(dist + "index.html", (err) => err && next(err)),
);
app.use((error, req, res, _next) => {
const { status, code, message } = describe(error);
// Faults of the server keep their stack; expected errors need a line.
if (status >= 500 && code === "internal") console.error(req.method, req.originalUrl, error);
else console.error(`${req.method} ${req.originalUrl} → ${status} ${code}: ${error.message}`);
res.status(status).json({ error: message, code });
});
const port = Number(process.env.PORT || 3001);
app.listen(port, process.env.HOST || "127.0.0.1", () => {
console.log(`API и PWA: http://127.0.0.1:${port}`);
// Read the week table and parse the whole catalog in the background,
// so the first visit and the first search do not wait for the university.
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
getIndex().catch((e) => console.error(`Индекс: ${e.message}`));
});