feat: Implement Telegram alerts for catalog updates and failures
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -6,3 +6,4 @@ dist/
|
|||||||
xcuserdata/
|
xcuserdata/
|
||||||
DerivedData/
|
DerivedData/
|
||||||
*.xcuserstate
|
*.xcuserstate
|
||||||
|
.env
|
||||||
|
|||||||
29
README.md
29
README.md
@@ -49,7 +49,34 @@ Vite работает на http://127.0.0.1:5173 и проксирует `/api`
|
|||||||
|
|
||||||
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
|
**Обновления у студента.** При запуске и при возвращении в приложение, если прошло больше часа, клиент сверяет сохранённое расписание с каталогом. Тот же файл с новой датой тихо перечитывается. Так же один раз перечитывается расписание, сохранённое более старым парсером, чем на сервере: `/api/catalog` сообщает `parserVersion`, и PWA сравнивает его с версией сохранённой копии. Поэтому исправления разбора доходят до студентов без перепубликации документа. У сервера без `parserVersion` перечитывания нет. Если для группы появился более новый документ (следующий семестр или год), показывается предложение его открыть; отказ запоминается. Если документ исчез из каталога, приложение предлагает найти группу заново.
|
||||||
|
|
||||||
Каталог и папка года перечитываются раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
|
**Проверка каталога по ETag.** Сервер сам проверяет каталог университета раз в 5 минут, а не только когда приходит запрос. У каждой папки Nextcloud есть ETag, и он меняется, когда меняется что‑то внутри. Сервер хранит листинги прошлого обхода (`.data/catalog-tree.json`) и заново запрашивает только папки с новым ETag. Проверка неизменного каталога — 2 запроса вместо ~100: корень и папка года. Перезалитый документ стоит ещё пары запросов по пути к нему. Раз в 6 часов на всякий случай идёт полный обход. Индекс пересобирается, только если изменился какой‑то документ, поэтому ETag расписаний и кэши клиентов между проверками не сбрасываются. В `/api/health` поле `catalog.lastCrawl` показывает последний обход: число запросов, полный ли он, сколько папок взято из кэша. Поле `watch` показывает время и ошибку последней проверки.
|
||||||
|
|
||||||
|
**Оповещения в Telegram.** Если заданы `TELEGRAM_BOT_TOKEN` и `TELEGRAM_CHAT_ID`, сервер пишет владельцу после проверок каталога:
|
||||||
|
- появился новый документ — с его группами и подозрительными ячейками;
|
||||||
|
- документ не читается;
|
||||||
|
- документ перезалит, и в нём появились подозрительные ячейки, которых раньше не было;
|
||||||
|
- каталог перешёл на новый учебный год, или папка нового года задерживается;
|
||||||
|
- университет не отвечает дольше часа, а потом снова отвечает.
|
||||||
|
|
||||||
|
Первая проверка только сообщает, что оповещения включены, и называет число документов и подозрительных ячеек. Если сообщение не ушло, оно повторится при следующей проверке. С `PSU_PUBLIC_URL` в сообщениях есть ссылка на `/api/diagnostics` документа.
|
||||||
|
|
||||||
|
Настройка:
|
||||||
|
1. Создайте бота у @BotFather и получите токен.
|
||||||
|
2. Напишите боту любое сообщение.
|
||||||
|
3. Возьмите `chat.id` из ответа `https://api.telegram.org/bot<токен>/getUpdates`.
|
||||||
|
4. Положите значения в `.env` рядом с `docker-compose.yml` (файл в `.gitignore`):
|
||||||
|
|
||||||
|
```sh
|
||||||
|
TELEGRAM_BOT_TOKEN=123456:ABC...
|
||||||
|
TELEGRAM_CHAT_ID=123456789
|
||||||
|
PSU_PUBLIC_URL=https://psu.rumblur.by
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Перезапустите контейнер: `docker compose up -d`.
|
||||||
|
|
||||||
|
Состояние оповещений — `telegram` в `/api/health`: включены ли, сколько отправлено и последняя ошибка. Токен в журналы и в `/api/health` не попадает.
|
||||||
|
|
||||||
|
Каталог перечитывается раз в 5 минут (по ETag), папка года — раз в 15 минут, таблица недель и звонки — раз в 6 часов, распознанный документ — при изменении даты файла или версии парсера. Устаревшая копия отвечает сразу, а перечитывание идёт в фоне. Ждать приходится только при первом запуске, в начале нового учебного года и при «Обновить». «Обновить» (`?refresh=1`) принудительно перечитывает всё, но обращается к университету не чаще раза в 2 минуты (таблицу недель — раз в 10 минут), иначе отвечает только что прочитанной копией. После сбоя источника фоновые попытки тоже ждут 2 минуты. Если источник недоступен, используются сохранённые копии с явной пометкой. Фоновая синхронизация при закрытом приложении не реализована.
|
||||||
|
|
||||||
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ.
|
Ответы API сжимаются (gzip или brotli; семестр группы — около 200 КБ JSON, в gzip — около 5 КБ) и отдаются с `Cache-Control: no-cache` и `ETag`. Клиент хранит ответ, но каждый раз спрашивает заново. Если ничего не изменилось, приходит пустой `304`. Android-клиент делает это сам через дисковый кэш OkHttp. Импорт Word ограничен: два разбора одновременно, ещё четыре ждут, остальным — `429`; с одного адреса — до 10 файлов за 10 минут. За прокси (Caddy, туннель) адрес клиента берётся из `X-Forwarded-For`, если прокси находится в локальной или частной сети. Для `/api/timetable` ETag считается до любой работы — из версии документа, парсера и сервера, таблицы недель, параметров запроса и даты, — поэтому неизменённое расписание отвечает `304` примерно за миллисекунду, не открывая документ.
|
||||||
|
|
||||||
|
|||||||
@@ -21,6 +21,12 @@ services:
|
|||||||
HOST: 0.0.0.0
|
HOST: 0.0.0.0
|
||||||
PORT: 3001
|
PORT: 3001
|
||||||
TZ: Europe/Minsk
|
TZ: Europe/Minsk
|
||||||
|
# Оповещения владельцу в Telegram (см. README): токен бота от @BotFather
|
||||||
|
# и id чата. Значения удобно держать в файле .env рядом с этим файлом.
|
||||||
|
TELEGRAM_BOT_TOKEN: ${TELEGRAM_BOT_TOKEN:-}
|
||||||
|
TELEGRAM_CHAT_ID: ${TELEGRAM_CHAT_ID:-}
|
||||||
|
# Публичный адрес для ссылок в сообщениях, например https://psu.rumblur.by
|
||||||
|
PSU_PUBLIC_URL: ${PSU_PUBLIC_URL:-}
|
||||||
# Путь к LibreOffice для импорта старых .doc уже есть в образе.
|
# Путь к LibreOffice для импорта старых .doc уже есть в образе.
|
||||||
volumes:
|
volumes:
|
||||||
# Кэш каталога, таблицы недель и распознанных документов.
|
# Кэш каталога, таблицы недель и распознанных документов.
|
||||||
|
|||||||
175
server/alerts.js
Normal file
175
server/alerts.js
Normal file
@@ -0,0 +1,175 @@
|
|||||||
|
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
||||||
|
import { DATA } from "./paths.js";
|
||||||
|
import { diagnose } from "./diagnostics.js";
|
||||||
|
|
||||||
|
// Messages to the server's owner in Telegram, so a new document format, a typo the parser cannot
|
||||||
|
// read or a university that stopped answering is known the same day, not when a student notices.
|
||||||
|
// Off unless TELEGRAM_BOT_TOKEN and TELEGRAM_CHAT_ID are set.
|
||||||
|
|
||||||
|
const TOKEN = process.env.TELEGRAM_BOT_TOKEN || "";
|
||||||
|
const CHAT = process.env.TELEGRAM_CHAT_ID || "";
|
||||||
|
// Tests point this at a local stand-in.
|
||||||
|
const API = process.env.TELEGRAM_API || "https://api.telegram.org";
|
||||||
|
// With the public address, messages link to /api/diagnostics of the document.
|
||||||
|
const PUBLIC = (process.env.PSU_PUBLIC_URL || "").replace(/\/$/, "");
|
||||||
|
// The university is often down for a few minutes; only a longer outage is worth a message.
|
||||||
|
const DOWN_AFTER = Number(process.env.PSU_ALERT_SOURCE_DOWN_MS ?? 60 * 60 * 1000);
|
||||||
|
const STATE = "alerts.json";
|
||||||
|
const MAX_TEXT = 3900;
|
||||||
|
const MAX_DOCS = 8;
|
||||||
|
const MAX_PROBLEMS = 4;
|
||||||
|
const DAYS = ["", "пн", "вт", "ср", "чт", "пт", "сб", "вс"];
|
||||||
|
|
||||||
|
/** For /api/health: whether messages go out and how the last one went. */
|
||||||
|
export const telegram = { enabled: Boolean(TOKEN && CHAT), sent: 0, lastSentAt: null, lastError: null };
|
||||||
|
|
||||||
|
/** What the last message knew: every document with its date and its suspicious cells. */
|
||||||
|
let state;
|
||||||
|
let sourceNotified = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
if (state !== undefined) return state;
|
||||||
|
try {
|
||||||
|
state = JSON.parse(await readFile(DATA + STATE, "utf8"));
|
||||||
|
} catch {
|
||||||
|
state = null;
|
||||||
|
}
|
||||||
|
return state;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(next) {
|
||||||
|
state = next;
|
||||||
|
await mkdir(DATA, { recursive: true });
|
||||||
|
await writeFile(DATA + STATE, JSON.stringify(next));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Sends a message; false when it did not go out (the error is kept for /api/health). */
|
||||||
|
export async function send(text) {
|
||||||
|
if (!telegram.enabled) return false;
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API}/bot${TOKEN}/sendMessage`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
chat_id: CHAT,
|
||||||
|
text: text.length > MAX_TEXT ? text.slice(0, MAX_TEXT) + "\n…" : text,
|
||||||
|
disable_web_page_preview: true,
|
||||||
|
}),
|
||||||
|
signal: AbortSignal.timeout(15000),
|
||||||
|
});
|
||||||
|
if (!response.ok) throw new Error(`Telegram ответил ${response.status}: ${(await response.text()).slice(0, 200)}`);
|
||||||
|
telegram.sent++;
|
||||||
|
telegram.lastSentAt = new Date().toISOString();
|
||||||
|
telegram.lastError = null;
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
// Never the token in a log or in /api/health.
|
||||||
|
telegram.lastError = String(error.message).replaceAll(TOKEN, "…");
|
||||||
|
console.error(`Telegram: ${telegram.lastError}`);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signature = (p) => `${p.code}|${(p.groups || []).join(",")}|${p.day ?? ""}|${p.start ?? ""}|${p.raw ?? ""}`;
|
||||||
|
const link = (id) => (PUBLIC ? `\n${PUBLIC}/api/diagnostics?doc=${id}` : "");
|
||||||
|
const problemLine = (p) =>
|
||||||
|
`• ${[(p.groups || []).join(", "), p.day ? `${DAYS[p.day]} ${p.start || ""}`.trim() : ""].filter(Boolean).join(", ")}: ${p.message}` +
|
||||||
|
(p.raw ? `\n «${p.raw.replace(/\s+/g, " ").slice(0, 90)}»` : "");
|
||||||
|
|
||||||
|
/** The catalog's documents as the index read them. */
|
||||||
|
function snapshot(idx, catalog) {
|
||||||
|
const docs = {};
|
||||||
|
for (const { id, file, schedule } of idx.docs)
|
||||||
|
docs[id] = {
|
||||||
|
name: file.name,
|
||||||
|
faculty: file.faculty || schedule.source.faculty || "",
|
||||||
|
modified: file.modified,
|
||||||
|
groups: schedule.groups,
|
||||||
|
problems: diagnose(schedule),
|
||||||
|
};
|
||||||
|
for (const f of idx.failed) {
|
||||||
|
const file = catalog.files.find((c) => c.id === f.id);
|
||||||
|
docs[f.id] = { name: f.name, faculty: file?.faculty || "", modified: file?.modified || null, groups: [], problems: [], failed: f.error };
|
||||||
|
}
|
||||||
|
return docs;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* After every check of the catalog: a message about documents that appeared, documents that do
|
||||||
|
* not read, republished documents with suspicious cells they did not have, and a new academic
|
||||||
|
* year. The first run only says that messages are on. A message that does not go out is tried
|
||||||
|
* again on the next check.
|
||||||
|
*/
|
||||||
|
export async function checkCatalog(idx, catalog) {
|
||||||
|
if (!telegram.enabled || !idx) return;
|
||||||
|
const was = await load();
|
||||||
|
const docs = snapshot(idx, catalog);
|
||||||
|
const stored = (d) => ({ ...d, problems: d.problems.map(signature) });
|
||||||
|
const next = {
|
||||||
|
docs: Object.fromEntries(Object.entries(docs).map(([id, d]) => [id, stored(d)])),
|
||||||
|
year: catalog.year,
|
||||||
|
yearWarning: was?.yearWarning || null,
|
||||||
|
};
|
||||||
|
if (!was) {
|
||||||
|
const problems = Object.values(docs).reduce((n, d) => n + d.problems.length, 0);
|
||||||
|
const failed = Object.values(docs).filter((d) => d.failed).length;
|
||||||
|
const sent = await send(
|
||||||
|
`Оповещения расписания ПГУ включены.\nКаталог ${catalog.year}: ${idx.docs.length} документов` +
|
||||||
|
(failed ? `, не читаются: ${failed}` : "") +
|
||||||
|
`, подозрительных ячеек: ${problems}.` +
|
||||||
|
(PUBLIC ? `\n${PUBLIC}/api/diagnostics` : ""),
|
||||||
|
);
|
||||||
|
if (sent) await save(next);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = [];
|
||||||
|
for (const [id, now] of Object.entries(docs)) {
|
||||||
|
const before = was.docs?.[id];
|
||||||
|
if (before && before.modified === now.modified && (before.failed || null) === (now.failed || null)) continue;
|
||||||
|
const title = `${now.name}${now.faculty ? ` (${now.faculty})` : ""}`;
|
||||||
|
if (now.failed) {
|
||||||
|
sections.push(`Не читается: ${title}\n${now.failed}`);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const known = new Set(before?.problems || []);
|
||||||
|
const added = now.problems.filter((p) => !known.has(signature(p)));
|
||||||
|
const lines = added.slice(0, MAX_PROBLEMS).map(problemLine);
|
||||||
|
if (added.length > MAX_PROBLEMS) lines.push(`• и ещё ${added.length - MAX_PROBLEMS}`);
|
||||||
|
if (!before)
|
||||||
|
sections.push(
|
||||||
|
`Новый документ: ${title}\nГруппы: ${now.groups.join(", ") || "не найдены"}` +
|
||||||
|
(added.length ? `\nПодозрительные ячейки:\n${lines.join("\n")}` : "") +
|
||||||
|
(added.length ? link(id) : ""),
|
||||||
|
);
|
||||||
|
else if (added.length) sections.push(`Перезалит с новыми подозрительными ячейками: ${title}\n${lines.join("\n")}${link(id)}`);
|
||||||
|
}
|
||||||
|
if (was.year && catalog.year && was.year !== catalog.year) sections.unshift(`Каталог перешёл на учебный год ${catalog.year}.`);
|
||||||
|
// The new year's folder is late: the catalog still shows last year's documents.
|
||||||
|
if (catalog.currentYear === false && catalog.expectedYear && was.yearWarning !== catalog.expectedYear) {
|
||||||
|
sections.unshift(`Папка учебного года ${catalog.expectedYear} ещё не опубликована: используется ${catalog.year}.`);
|
||||||
|
next.yearWarning = catalog.expectedYear;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!sections.length) return save(next);
|
||||||
|
const shown = sections.slice(0, MAX_DOCS);
|
||||||
|
if (sections.length > MAX_DOCS) shown.push(`И ещё изменений: ${sections.length - MAX_DOCS}.`);
|
||||||
|
if (await send(`Расписание ПГУ\n\n${shown.join("\n\n")}`)) await save(next);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The university's catalog failing for longer than an hour, and its return. Kept in memory: after
|
||||||
|
* a restart a long outage is reported again, which is better than missing it.
|
||||||
|
*/
|
||||||
|
export async function checkSource(failure) {
|
||||||
|
if (!telegram.enabled) return;
|
||||||
|
if (failure) {
|
||||||
|
const since = Date.parse(failure.since);
|
||||||
|
if (!sourceNotified && Number.isFinite(since) && Date.now() - since >= DOWN_AFTER) {
|
||||||
|
const at = new Date(since).toLocaleString("ru-RU", { timeZone: "Europe/Minsk", dateStyle: "short", timeStyle: "short" });
|
||||||
|
sourceNotified = await send(
|
||||||
|
`Каталог университета не отвечает с ${at}.\n${failure.error}\nПриложения показывают сохранённые копии расписаний.`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
} else if (sourceNotified && (await send("Каталог университета снова отвечает."))) sourceNotified = false;
|
||||||
|
}
|
||||||
@@ -154,13 +154,13 @@ async function build(catalog) {
|
|||||||
try {
|
try {
|
||||||
docs.push({ id: file.id, file, schedule: await scheduleFor(file) });
|
docs.push({ id: file.id, file, schedule: await scheduleFor(file) });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed.push({ name: file.name, error: error.message });
|
failed.push({ id: file.id, name: file.name, error: error.message });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
index = {
|
index = {
|
||||||
catalogAt: catalog.updatedAt,
|
catalogVersion: versionOf(catalog),
|
||||||
year: catalog.year,
|
year: catalog.year,
|
||||||
builtAt: new Date().toISOString(),
|
builtAt: new Date().toISOString(),
|
||||||
total: catalog.files.length,
|
total: catalog.files.length,
|
||||||
@@ -171,16 +171,36 @@ async function build(catalog) {
|
|||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The current index; rebuilt in the background when the catalog changes. */
|
// A catalog's documents and their dates; older cached catalogs have only the time of the crawl.
|
||||||
export async function getIndex() {
|
const versionOf = (catalog) => catalog.version || catalog.updatedAt;
|
||||||
const catalog = await getCatalog();
|
|
||||||
if ((!index || index.catalogAt !== catalog.updatedAt) && !building)
|
function rebuild(catalog) {
|
||||||
|
if (!building)
|
||||||
building = build(catalog).finally(() => {
|
building = build(catalog).finally(() => {
|
||||||
building = null;
|
building = null;
|
||||||
});
|
});
|
||||||
|
return building;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The current index; rebuilt in the background when a document of the catalog changes. A check
|
||||||
|
* that finds the catalog as it was keeps the index, so its ETags, and the clients' caches, hold.
|
||||||
|
*/
|
||||||
|
export async function getIndex() {
|
||||||
|
const catalog = await getCatalog();
|
||||||
|
if (!index || index.catalogVersion !== versionOf(catalog)) rebuild(catalog);
|
||||||
if (!index) await building;
|
if (!index) await building;
|
||||||
return index;
|
return index;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The index of the catalog as it is now, waiting for the rebuild a change needs (the watcher). */
|
||||||
|
export async function syncIndex(catalog) {
|
||||||
|
if (!index || index.catalogVersion !== versionOf(catalog)) {
|
||||||
|
if (building) await building;
|
||||||
|
if (!index || index.catalogVersion !== versionOf(catalog)) await rebuild(catalog);
|
||||||
|
}
|
||||||
|
return index;
|
||||||
|
}
|
||||||
export const indexStatus = () =>
|
export const indexStatus = () =>
|
||||||
index
|
index
|
||||||
? {
|
? {
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ import { createHash } from "node:crypto";
|
|||||||
import { timetable } from "./timetable.js";
|
import { timetable } from "./timetable.js";
|
||||||
import { catalogDiagnostics } from "./diagnostics.js";
|
import { catalogDiagnostics } from "./diagnostics.js";
|
||||||
import { securityHeaders, requestTimeout } from "./protect.js";
|
import { securityHeaders, requestTimeout } from "./protect.js";
|
||||||
|
import { startWatch, watchOnce, watchStatus } from "./watch.js";
|
||||||
|
import { telegram } from "./alerts.js";
|
||||||
import {
|
import {
|
||||||
getIndex,
|
getIndex,
|
||||||
indexStatus,
|
indexStatus,
|
||||||
@@ -67,8 +69,9 @@ app.use("/api", (_req, res, next) => {
|
|||||||
// 7 — /api/diagnostics, cells the parser could not read with confidence;
|
// 7 — /api/diagnostics, cells the parser could not read with confidence;
|
||||||
// 8 — /api/ready, and `ready` in /api/health;
|
// 8 — /api/ready, and `ready` in /api/health;
|
||||||
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers;
|
// 9 — a request limit on /api (429 too_many_requests), a 30 s time limit (504 timeout), security headers;
|
||||||
// 10 — parserVersion in /api/catalog.
|
// 10 — parserVersion in /api/catalog;
|
||||||
const API_VERSION = 10;
|
// 11 — the catalog is checked by ETag every 5 minutes; `watch`, `telegram` and `catalog.lastCrawl` in /api/health.
|
||||||
|
const API_VERSION = 11;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* What the server still waits for before it answers from memory: the catalog, the week table
|
* What the server still waits for before it answers from memory: the catalog, the week table
|
||||||
@@ -89,6 +92,9 @@ app.get("/api/health", async (req, res) => {
|
|||||||
parserVersion: PARSER_VERSION,
|
parserVersion: PARSER_VERSION,
|
||||||
...sources,
|
...sources,
|
||||||
directory: indexStatus(),
|
directory: indexStatus(),
|
||||||
|
// The server's own checks of the catalog and its messages to the owner.
|
||||||
|
watch: watchStatus,
|
||||||
|
telegram: { enabled: telegram.enabled, sent: telegram.sent, lastSentAt: telegram.lastSentAt, lastError: telegram.lastError },
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
// Readiness for Docker and a proxy: 503 until the catalog, the week table and the index are
|
// Readiness for Docker and a proxy: 503 until the catalog, the week table and the index are
|
||||||
@@ -309,7 +315,13 @@ export function start(port = Number(process.env.PORT || 3001), host = process.en
|
|||||||
console.log(`API и PWA: http://127.0.0.1:${port}`);
|
console.log(`API и PWA: http://127.0.0.1:${port}`);
|
||||||
// So the first visit and the first search do not wait for the university.
|
// So the first visit and the first search do not wait for the university.
|
||||||
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
|
getCalendar().catch((e) => console.error(`Таблица недель: ${e.message}`));
|
||||||
getIndex().catch((e) => console.error(`Индекс: ${e.message}`));
|
getIndex()
|
||||||
|
.catch((e) => console.error(`Индекс: ${e.message}`))
|
||||||
|
// Then the catalog is watched every few minutes, with messages in Telegram if set up.
|
||||||
|
.finally(() => {
|
||||||
|
watchOnce();
|
||||||
|
startWatch();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
// A client must send its headers within 15 s and the whole request (a Word upload of up to
|
// A client must send its headers within 15 s and the whole request (a Word upload of up to
|
||||||
// 10 MB on a slow phone) within two minutes, so slow connections cannot pile up.
|
// 10 MB on a slow phone) within two minutes, so slow connections cannot pile up.
|
||||||
|
|||||||
117
server/source.js
117
server/source.js
@@ -16,21 +16,25 @@ const SHARE = "/public.php/dav/files/no5zKbMrns6j5jQ/";
|
|||||||
const YEAR_FOLDER = /^(\d{4})-(\d{4})$/;
|
const YEAR_FOLDER = /^(\d{4})-(\d{4})$/;
|
||||||
const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false });
|
const parser = new XMLParser({ removeNSPrefix: true, ignoreAttributes: false });
|
||||||
const arr = (value) => (value ? (Array.isArray(value) ? value : [value]) : []);
|
const arr = (value) => (value ? (Array.isArray(value) ? value : [value]) : []);
|
||||||
|
// A check of an unchanged catalog costs two listings (see crawl), so it can run every 5 minutes.
|
||||||
const DISCOVERY_TTL = 15 * 60 * 1000,
|
const DISCOVERY_TTL = 15 * 60 * 1000,
|
||||||
CATALOG_TTL = 15 * 60 * 1000,
|
CATALOG_TTL = 5 * 60 * 1000,
|
||||||
CALENDAR_TTL = 6 * 60 * 60 * 1000;
|
CALENDAR_TTL = 6 * 60 * 60 * 1000;
|
||||||
|
// Even with unchanged ETags the whole catalog is listed again this often, in case the university's
|
||||||
|
// server ever fails to pass a change up to the parent folders.
|
||||||
|
const FULL_CRAWL = 6 * 60 * 60 * 1000;
|
||||||
// A forced refresh (?refresh=1, the PWA's "Обновить") reaches the university at most this often;
|
// A forced refresh (?refresh=1, the PWA's "Обновить") reaches the university at most this often;
|
||||||
// sooner, the copy just read is the answer. Anyone can send the request, and one catalog crawl
|
// sooner, the copy just read is the answer. Anyone can send the request, and a full catalog crawl
|
||||||
// is up to 180 listings of the university's Nextcloud.
|
// is up to 180 listings of the university's Nextcloud.
|
||||||
export const MIN_REFRESH = 2 * 60 * 1000;
|
export const MIN_REFRESH = process.env.PSU_MIN_REFRESH_MS ? Number(process.env.PSU_MIN_REFRESH_MS) : 2 * 60 * 1000;
|
||||||
const CALENDAR_MIN_REFRESH = 10 * 60 * 1000;
|
const CALENDAR_MIN_REFRESH = 10 * 60 * 1000;
|
||||||
/** Milliseconds since an ISO time; Infinity for a missing or broken one. */
|
/** Milliseconds since an ISO time; Infinity for a missing or broken one. */
|
||||||
const ageOf = (iso) => {
|
const ageOf = (iso) => {
|
||||||
const age = Date.now() - new Date(iso).getTime();
|
const age = Date.now() - new Date(iso).getTime();
|
||||||
return Number.isFinite(age) ? age : Infinity;
|
return Number.isFinite(age) ? age : Infinity;
|
||||||
};
|
};
|
||||||
let catalogue, pending, calendar, calendarPending, sources, sourcesPending;
|
let catalogue, pending, calendar, calendarPending, sources, sourcesPending, tree;
|
||||||
const status = { catalogError: null, calendarError: null };
|
const status = { catalogError: null, calendarError: null, catalogErrorSince: null, lastCrawl: null };
|
||||||
// When the university last failed to answer: background refreshes back off for MIN_REFRESH,
|
// When the university last failed to answer: background refreshes back off for MIN_REFRESH,
|
||||||
// so a down source is not crawled again on every request.
|
// so a down source is not crawled again on every request.
|
||||||
const failedAt = { catalog: 0, calendar: 0 };
|
const failedAt = { catalog: 0, calendar: 0 };
|
||||||
@@ -88,6 +92,8 @@ export function decodeListing(xml, root = SHARE) {
|
|||||||
folder: props.resourcetype?.collection !== undefined,
|
folder: props.resourcetype?.collection !== undefined,
|
||||||
modified: props.getlastmodified || null,
|
modified: props.getlastmodified || null,
|
||||||
size: Number(props.getcontentlength || 0),
|
size: Number(props.getcontentlength || 0),
|
||||||
|
// Nextcloud changes a folder's ETag whenever anything below it changes.
|
||||||
|
etag: props.getetag ? String(props.getetag) : null,
|
||||||
};
|
};
|
||||||
})
|
})
|
||||||
.filter((e) => typeof e.path === "string" && e.path.startsWith(root));
|
.filter((e) => typeof e.path === "string" && e.path.startsWith(root));
|
||||||
@@ -175,10 +181,12 @@ async function discover(refresh = false) {
|
|||||||
const yearEntries = decodeListing(
|
const yearEntries = decodeListing(
|
||||||
(await request(yearRoot, "PROPFIND")).toString(),
|
(await request(yearRoot, "PROPFIND")).toString(),
|
||||||
).filter((e) => e.path.toLowerCase() !== yearRoot.toLowerCase());
|
).filter((e) => e.path.toLowerCase() !== yearRoot.toLowerCase());
|
||||||
|
const folder = root.find((e) => nameOf(e.path) === year.name);
|
||||||
return (sources = {
|
return (sources = {
|
||||||
checkedAt: Date.now(),
|
checkedAt: Date.now(),
|
||||||
year,
|
year,
|
||||||
yearRoot: root.find((e) => nameOf(e.path) === year.name)?.path || yearRoot,
|
yearRoot: folder?.path || yearRoot,
|
||||||
|
yearEntries,
|
||||||
references: pickReferences(root, yearEntries),
|
references: pickReferences(root, yearEntries),
|
||||||
});
|
});
|
||||||
})().finally(() => {
|
})().finally(() => {
|
||||||
@@ -187,43 +195,104 @@ async function discover(refresh = false) {
|
|||||||
return sourcesPending;
|
return sourcesPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const TREE = "catalog-tree.json";
|
||||||
|
async function readTree() {
|
||||||
|
if (tree !== undefined) return;
|
||||||
|
try {
|
||||||
|
tree = JSON.parse(await readFile(DATA + TREE, "utf8"));
|
||||||
|
} catch {
|
||||||
|
tree = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A short fingerprint of the catalog's documents: it changes only when a file does. */
|
||||||
|
export const catalogVersion = (files, year) =>
|
||||||
|
createHash("sha1")
|
||||||
|
.update([year, ...files.map((f) => `${f.id}:${f.modified}`).sort()].join("|"))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 16);
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Lists the year folder and everything below it. Folder listings of the last crawl are kept with
|
||||||
|
* each folder's ETag: a folder whose ETag is unchanged is taken from there, subfolders included,
|
||||||
|
* without a request. A check of an unchanged catalog therefore costs only the two listings
|
||||||
|
* discover makes; a changed document costs the folders on the way to it.
|
||||||
|
*/
|
||||||
async function crawl() {
|
async function crawl() {
|
||||||
|
const started = Date.now();
|
||||||
const source = await discover(true);
|
const source = await discover(true);
|
||||||
let queue = [source.yearRoot],
|
await readTree();
|
||||||
files = [],
|
const full = !tree || Date.now() - (tree.fullAt || 0) >= FULL_CRAWL;
|
||||||
count = 0;
|
const known = full ? {} : tree.folders || {};
|
||||||
|
const folders = {};
|
||||||
|
const files = [];
|
||||||
|
let queue = [],
|
||||||
|
requested = 0,
|
||||||
|
reused = 0;
|
||||||
|
const visit = (entries) => {
|
||||||
|
for (const entry of entries) {
|
||||||
|
if (entry.folder) queue.push(entry);
|
||||||
|
else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path)) files.push(fileMetadata(entry));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// discover has just listed the year folder itself.
|
||||||
|
folders[source.yearRoot] = { entries: source.yearEntries };
|
||||||
|
visit(source.yearEntries);
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
if (count + queue.length > 180)
|
const fetch = [];
|
||||||
|
for (const entry of queue.splice(0)) {
|
||||||
|
const cached = known[entry.path];
|
||||||
|
if (cached && entry.etag && cached.etag === entry.etag) {
|
||||||
|
folders[entry.path] = cached;
|
||||||
|
reused++;
|
||||||
|
visit(cached.entries);
|
||||||
|
} else fetch.push(entry);
|
||||||
|
}
|
||||||
|
for (let i = 0; i < fetch.length; i += 5) {
|
||||||
|
if (requested + fetch.length - i > 180)
|
||||||
throw apiError(502, "source_invalid", "Каталог университета слишком большой.");
|
throw apiError(502, "source_invalid", "Каталог университета слишком большой.");
|
||||||
const batch = queue.splice(0, 5);
|
const batch = fetch.slice(i, i + 5);
|
||||||
count += batch.length;
|
requested += batch.length;
|
||||||
const results = await Promise.all(
|
const listed = await Promise.all(
|
||||||
batch.map(async (path) =>
|
batch.map(async ({ path }) =>
|
||||||
decodeListing((await request(path, "PROPFIND")).toString()).filter(
|
decodeListing((await request(path, "PROPFIND")).toString()).filter(
|
||||||
(e) => e.path.toLowerCase() !== path.toLowerCase(),
|
(e) => e.path.toLowerCase() !== path.toLowerCase(),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
for (const entries of results)
|
batch.forEach((entry, k) => {
|
||||||
for (const entry of entries) {
|
folders[entry.path] = { etag: entry.etag, entries: listed[k] };
|
||||||
if (entry.folder) queue.push(entry.path);
|
visit(listed[k]);
|
||||||
else if (/\.(docx?|xlsx?|pdf)$/i.test(entry.path))
|
});
|
||||||
files.push(fileMetadata(entry));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const docs = files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name));
|
||||||
const result = {
|
const result = {
|
||||||
// Week tables and bell schedules are reference documents, not timetables.
|
// Week tables and bell schedules are reference documents, not timetables.
|
||||||
files: files.filter((f) => /\.docx?$/i.test(f.name) && !isReference(f.name)),
|
files: docs,
|
||||||
otherFiles: files.filter((f) => !/\.docx?$/i.test(f.name)).length,
|
otherFiles: files.filter((f) => !/\.docx?$/i.test(f.name)).length,
|
||||||
|
version: catalogVersion(docs, source.year.label),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
stale: false,
|
stale: false,
|
||||||
year: source.year.label,
|
year: source.year.label,
|
||||||
currentYear: source.year.current,
|
currentYear: source.year.current,
|
||||||
expectedYear: source.year.expected.replace("-", "–"),
|
expectedYear: source.year.expected.replace("-", "–"),
|
||||||
};
|
};
|
||||||
|
tree = { fullAt: full ? Date.now() : tree.fullAt, folders };
|
||||||
await mkdir(DATA, { recursive: true });
|
await mkdir(DATA, { recursive: true });
|
||||||
|
await writeFile(DATA + TREE, JSON.stringify(tree));
|
||||||
await writeFile(DATA + "catalog.json", JSON.stringify(result));
|
await writeFile(DATA + "catalog.json", JSON.stringify(result));
|
||||||
status.catalogError = null;
|
status.catalogError = null;
|
||||||
|
status.catalogErrorSince = null;
|
||||||
|
status.lastCrawl = {
|
||||||
|
at: result.updatedAt,
|
||||||
|
full,
|
||||||
|
// Two listings come from discover: the share's root and the year folder.
|
||||||
|
requests: requested + 2,
|
||||||
|
folders: Object.keys(folders).length,
|
||||||
|
reused,
|
||||||
|
ms: Date.now() - started,
|
||||||
|
};
|
||||||
return (catalogue = result);
|
return (catalogue = result);
|
||||||
}
|
}
|
||||||
async function readCatalogCache() {
|
async function readCatalogCache() {
|
||||||
@@ -265,6 +334,7 @@ export async function getCatalog(refresh = false) {
|
|||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
failedAt.catalog = Date.now();
|
failedAt.catalog = Date.now();
|
||||||
status.catalogError = error.message;
|
status.catalogError = error.message;
|
||||||
|
status.catalogErrorSince ||= new Date().toISOString();
|
||||||
if (catalogue)
|
if (catalogue)
|
||||||
return {
|
return {
|
||||||
...catalogue,
|
...catalogue,
|
||||||
@@ -368,6 +438,10 @@ export async function getCalendar(refresh = false) {
|
|||||||
return calendarPending;
|
return calendarPending;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Since when the university's catalog has been failing, with the error; null while it answers. */
|
||||||
|
export const catalogFailure = () =>
|
||||||
|
status.catalogError ? { error: status.catalogError, since: status.catalogErrorSince } : null;
|
||||||
|
|
||||||
/** What the server currently uses, for /api/health. Never touches the network. */
|
/** What the server currently uses, for /api/health. Never touches the network. */
|
||||||
export async function sourceStatus(today = todayIso()) {
|
export async function sourceStatus(today = todayIso()) {
|
||||||
await Promise.all([readCatalogCache(), readCalendarCache()]);
|
await Promise.all([readCatalogCache(), readCalendarCache()]);
|
||||||
@@ -399,6 +473,9 @@ export async function sourceStatus(today = todayIso()) {
|
|||||||
updatedAt: catalogue.updatedAt,
|
updatedAt: catalogue.updatedAt,
|
||||||
ageMinutes: age(catalogue.updatedAt),
|
ageMinutes: age(catalogue.updatedAt),
|
||||||
stale: Boolean(catalogue.stale || status.catalogError),
|
stale: Boolean(catalogue.stale || status.catalogError),
|
||||||
|
// The last check: how many listings it cost and how many folders came from the cache.
|
||||||
|
lastCrawl: status.lastCrawl,
|
||||||
|
failingSince: status.catalogErrorSince,
|
||||||
}
|
}
|
||||||
: null,
|
: null,
|
||||||
calendar: calendar
|
calendar: calendar
|
||||||
|
|||||||
43
server/watch.js
Normal file
43
server/watch.js
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { getCatalog, catalogFailure } from "./source.js";
|
||||||
|
import { syncIndex } from "./directory.js";
|
||||||
|
import { checkCatalog, checkSource } from "./alerts.js";
|
||||||
|
|
||||||
|
// The server checks the university's catalog by itself, not only when a request asks: a
|
||||||
|
// republished document is parsed within minutes, the clients' next refresh gets it, and the
|
||||||
|
// owner hears about problems (see alerts.js). An unchanged catalog costs two listings.
|
||||||
|
|
||||||
|
const INTERVAL = Number(process.env.PSU_WATCH_INTERVAL_MS) || 5 * 60 * 1000;
|
||||||
|
|
||||||
|
/** For /api/health. */
|
||||||
|
export const watchStatus = { intervalMinutes: INTERVAL / 60000, lastAt: null, lastError: null };
|
||||||
|
|
||||||
|
let running = null;
|
||||||
|
/** One check: the catalog (refreshed), the index of a changed catalog, the messages. */
|
||||||
|
export function watchOnce() {
|
||||||
|
// A slow university must not stack checks on top of each other.
|
||||||
|
running ||= check().finally(() => {
|
||||||
|
running = null;
|
||||||
|
});
|
||||||
|
return running;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function check() {
|
||||||
|
try {
|
||||||
|
const catalog = await getCatalog(true);
|
||||||
|
// A failed crawl answers with the saved catalog: nothing new to read or report then.
|
||||||
|
if (!catalog.stale) await checkCatalog(await syncIndex(catalog), catalog);
|
||||||
|
watchStatus.lastError = catalog.stale ? catalog.error || null : null;
|
||||||
|
} catch (error) {
|
||||||
|
watchStatus.lastError = error.message;
|
||||||
|
}
|
||||||
|
await checkSource(catalogFailure());
|
||||||
|
watchStatus.lastAt = new Date().toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
let timer = null;
|
||||||
|
/** Checks every few minutes (PSU_WATCH_INTERVAL_MS) for as long as the process runs. */
|
||||||
|
export function startWatch() {
|
||||||
|
if (timer) return;
|
||||||
|
timer = setInterval(watchOnce, INTERVAL);
|
||||||
|
timer.unref();
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { createServer } from "node:http";
|
import { createServer } from "node:http";
|
||||||
|
import { createHash } from "node:crypto";
|
||||||
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
|
import { readFileSync, mkdtempSync, rmSync } from "node:fs";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
@@ -22,6 +23,12 @@ const files = {
|
|||||||
[`${FOLDER}Медленный.docx`]: "slow",
|
[`${FOLDER}Медленный.docx`]: "slow",
|
||||||
};
|
};
|
||||||
const folders = [SHARE, `${SHARE}${YEAR}/`, `${SHARE}${YEAR}/Финансово-экономический факультет/`, FOLDER];
|
const folders = [SHARE, `${SHARE}${YEAR}/`, `${SHARE}${YEAR}/Финансово-экономический факультет/`, FOLDER];
|
||||||
|
// Dates of files changed during a test; the others keep MODIFIED.
|
||||||
|
const dates = {};
|
||||||
|
const dateOf = (path) => dates[path] || MODIFIED;
|
||||||
|
// As in Nextcloud, a folder's ETag changes whenever anything below it does.
|
||||||
|
const etagOf = (folder) =>
|
||||||
|
`"${createHash("sha1").update(Object.keys(files).filter((f) => f.startsWith(folder)).sort().map((f) => f + dateOf(f)).join("|")).digest("hex").slice(0, 13)}"`;
|
||||||
|
|
||||||
const encode = (path) => path.split("/").map(encodeURIComponent).join("/");
|
const encode = (path) => path.split("/").map(encodeURIComponent).join("/");
|
||||||
const listing = (folder) => {
|
const listing = (folder) => {
|
||||||
@@ -32,19 +39,23 @@ const listing = (folder) => {
|
|||||||
const size = Buffer.isBuffer(files[path]) ? files[path].length : 1000;
|
const size = Buffer.isBuffer(files[path]) ? files[path].length : 1000;
|
||||||
return `<d:response><d:href>${encode(path)}</d:href><d:propstat><d:prop>${
|
return `<d:response><d:href>${encode(path)}</d:href><d:propstat><d:prop>${
|
||||||
folder ? "<d:resourcetype><d:collection/></d:resourcetype>" : `<d:resourcetype/><d:getcontentlength>${size}</d:getcontentlength>`
|
folder ? "<d:resourcetype><d:collection/></d:resourcetype>" : `<d:resourcetype/><d:getcontentlength>${size}</d:getcontentlength>`
|
||||||
}<d:getlastmodified>${MODIFIED}</d:getlastmodified></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`;
|
}<d:getlastmodified>${dateOf(path)}</d:getlastmodified><d:getetag>${folder ? etagOf(path) : `"${dateOf(path)}"`}</d:getetag></d:prop><d:status>HTTP/1.1 200 OK</d:status></d:propstat></d:response>`;
|
||||||
};
|
};
|
||||||
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">${[folder, ...children].map(entry).join("")}</d:multistatus>`;
|
return `<?xml version="1.0"?><d:multistatus xmlns:d="DAV:">${[folder, ...children].map(entry).join("")}</d:multistatus>`;
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Starts the stand-in and the API server; `env` adds settings such as lower limits.
|
* Starts the stand-in and the API server; `env` adds settings such as lower limits.
|
||||||
* Returns the server's base URL, request statistics of the stand-in and `stop()`.
|
* Returns the server's base URL, request statistics of the stand-in, `catalog` to change the
|
||||||
|
* stand-in's documents during a test, and `stop()`.
|
||||||
*/
|
*/
|
||||||
export async function startApi(env = {}) {
|
export async function startApi(env = {}) {
|
||||||
const stats = { requests: 0, connections: 0 };
|
const stats = { requests: 0, connections: 0, listings: 0 };
|
||||||
|
let down = false;
|
||||||
const source = createServer((req, res) => {
|
const source = createServer((req, res) => {
|
||||||
stats.requests++;
|
stats.requests++;
|
||||||
|
if (req.method === "PROPFIND") stats.listings++;
|
||||||
|
if (down) return res.writeHead(503).end();
|
||||||
const path = decodeURIComponent(req.url);
|
const path = decodeURIComponent(req.url);
|
||||||
if (req.method === "PROPFIND" && folders.includes(path)) {
|
if (req.method === "PROPFIND" && folders.includes(path)) {
|
||||||
res.writeHead(207, { "Content-Type": "application/xml" });
|
res.writeHead(207, { "Content-Type": "application/xml" });
|
||||||
@@ -67,9 +78,22 @@ export async function startApi(env = {}) {
|
|||||||
const { app } = await import("../../server/index.js");
|
const { app } = await import("../../server/index.js");
|
||||||
const server = app.listen(0, "127.0.0.1");
|
const server = app.listen(0, "127.0.0.1");
|
||||||
await new Promise((resolve) => server.on("listening", resolve));
|
await new Promise((resolve) => server.on("listening", resolve));
|
||||||
|
let tick = 0;
|
||||||
return {
|
return {
|
||||||
base: `http://127.0.0.1:${server.address().port}`,
|
base: `http://127.0.0.1:${server.address().port}`,
|
||||||
stats,
|
stats,
|
||||||
|
catalog: {
|
||||||
|
/** Publishes a document in the faculty folder (or republishes it) with a new date. */
|
||||||
|
put(name, body) {
|
||||||
|
const path = FOLDER + name;
|
||||||
|
files[path] = body;
|
||||||
|
dates[path] = new Date(Date.parse(MODIFIED) + ++tick * 60_000).toUTCString();
|
||||||
|
},
|
||||||
|
/** The university stops (true) or starts again (false) answering. */
|
||||||
|
down(on) {
|
||||||
|
down = on;
|
||||||
|
},
|
||||||
|
},
|
||||||
stop() {
|
stop() {
|
||||||
server.close();
|
server.close();
|
||||||
server.closeAllConnections();
|
server.closeAllConnections();
|
||||||
|
|||||||
113
test/watch.test.js
Normal file
113
test/watch.test.js
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import { test, before, after } from "node:test";
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { createServer } from "node:http";
|
||||||
|
import { startApi, fixture } from "./support/nextcloud.js";
|
||||||
|
|
||||||
|
// The server's own checks of the catalog: listings only of folders whose ETag changed, the index
|
||||||
|
// rebuilt only for a changed document, and messages to the owner in Telegram (a stand-in here).
|
||||||
|
let api, telegram, watch;
|
||||||
|
const messages = [];
|
||||||
|
const health = async () => (await fetch(api.base + "/api/health")).json();
|
||||||
|
const lastMessage = () => messages.at(-1)?.text || "";
|
||||||
|
|
||||||
|
before(async () => {
|
||||||
|
telegram = createServer((req, res) => {
|
||||||
|
let body = "";
|
||||||
|
req.on("data", (c) => (body += c));
|
||||||
|
req.on("end", () => {
|
||||||
|
messages.push({ path: req.url, ...JSON.parse(body) });
|
||||||
|
res.setHeader("Content-Type", "application/json");
|
||||||
|
res.end(JSON.stringify({ ok: true }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
await new Promise((resolve) => telegram.listen(0, "127.0.0.1", resolve));
|
||||||
|
api = await startApi({
|
||||||
|
TELEGRAM_BOT_TOKEN: "test-token",
|
||||||
|
TELEGRAM_CHAT_ID: "42",
|
||||||
|
TELEGRAM_API: `http://127.0.0.1:${telegram.address().port}`,
|
||||||
|
PSU_PUBLIC_URL: "https://psu.example",
|
||||||
|
PSU_ALERT_SOURCE_DOWN_MS: "0",
|
||||||
|
// Every check reaches the stand-in, however soon after the last one.
|
||||||
|
PSU_MIN_REFRESH_MS: "0",
|
||||||
|
});
|
||||||
|
watch = await import("../server/watch.js");
|
||||||
|
});
|
||||||
|
after(() => {
|
||||||
|
api?.stop();
|
||||||
|
telegram?.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Наблюдение: первая проверка обходит весь каталог и включает оповещения", async () => {
|
||||||
|
await watch.watchOnce();
|
||||||
|
const { catalog, directory, telegram: tg, watch: status } = await health();
|
||||||
|
assert.equal(catalog.lastCrawl.full, true);
|
||||||
|
assert.equal(directory.documents, 2);
|
||||||
|
assert.equal(tg.enabled, true);
|
||||||
|
assert.equal(status.lastError, null);
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
assert.equal(messages[0].path, "/bottest-token/sendMessage");
|
||||||
|
assert.equal(messages[0].chat_id, "42");
|
||||||
|
assert.match(lastMessage(), /Оповещения расписания ПГУ включены/);
|
||||||
|
assert.match(lastMessage(), /2 документов, не читаются: 2/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Наблюдение: неизменный каталог — два листинга, индекс и ETag расписаний те же", async () => {
|
||||||
|
const before = await health();
|
||||||
|
const listings = api.stats.listings;
|
||||||
|
await watch.watchOnce();
|
||||||
|
const after = await health();
|
||||||
|
assert.equal(api.stats.listings - listings, 2);
|
||||||
|
assert.equal(after.catalog.lastCrawl.requests, 2);
|
||||||
|
assert.equal(after.catalog.lastCrawl.full, false);
|
||||||
|
assert.ok(after.catalog.lastCrawl.reused >= 1);
|
||||||
|
assert.equal(after.directory.builtAt, before.directory.builtAt);
|
||||||
|
assert.equal(messages.length, 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Наблюдение: новый документ — листинг только изменившихся папок и сообщение", async () => {
|
||||||
|
const listings = api.stats.listings;
|
||||||
|
api.catalog.put("26-АРХ.docx", fixture("isf-credits-26-arh.docx"));
|
||||||
|
await watch.watchOnce();
|
||||||
|
const { catalog, directory } = await health();
|
||||||
|
// The share's root, the year folder, the faculty folder and the day-time folder.
|
||||||
|
assert.equal(api.stats.listings - listings, 4);
|
||||||
|
assert.equal(catalog.lastCrawl.requests, 4);
|
||||||
|
assert.equal(directory.documents, 3);
|
||||||
|
assert.equal(messages.length, 2);
|
||||||
|
assert.match(lastMessage(), /Новый документ: 26-АРХ\.docx/);
|
||||||
|
assert.match(lastMessage(), /Группы: 26-АРХ/);
|
||||||
|
const groups = await (await fetch(api.base + "/api/groups?" + new URLSearchParams({ q: "26-АРХ" }))).json();
|
||||||
|
assert.ok(groups.results.some((g) => g.group === "26-АРХ"));
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Наблюдение: перезалитый документ с новыми подозрительными ячейками", async () => {
|
||||||
|
// 23-БА's "2,6-12-16н" lands in a document that had no such cell.
|
||||||
|
api.catalog.put("24-ИТ.docx", fixture("fef-23-ba.docx"));
|
||||||
|
await watch.watchOnce();
|
||||||
|
assert.equal(messages.length, 3);
|
||||||
|
assert.match(lastMessage(), /Перезалит с новыми подозрительными ячейками: 24-ИТ\.docx/);
|
||||||
|
assert.match(lastMessage(), /2,6-12-16н/);
|
||||||
|
assert.match(lastMessage(), /https:\/\/psu\.example\/api\/diagnostics\?doc=/);
|
||||||
|
// Republished again as it is: nothing new to say.
|
||||||
|
api.catalog.put("24-ИТ.docx", fixture("fef-23-ba.docx"));
|
||||||
|
await watch.watchOnce();
|
||||||
|
assert.equal(messages.length, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("Наблюдение: университет не отвечает и снова отвечает", async () => {
|
||||||
|
api.catalog.down(true);
|
||||||
|
await watch.watchOnce();
|
||||||
|
const { watch: status, catalog } = await health();
|
||||||
|
assert.ok(status.lastError);
|
||||||
|
assert.ok(catalog.failingSince);
|
||||||
|
assert.equal(messages.length, 4);
|
||||||
|
assert.match(lastMessage(), /Каталог университета не отвечает/);
|
||||||
|
// Still down: one message per outage.
|
||||||
|
await watch.watchOnce();
|
||||||
|
assert.equal(messages.length, 4);
|
||||||
|
api.catalog.down(false);
|
||||||
|
await watch.watchOnce();
|
||||||
|
assert.equal(messages.length, 5);
|
||||||
|
assert.match(lastMessage(), /снова отвечает/);
|
||||||
|
assert.equal((await health()).watch.lastError, null);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user