README: запуск (prod/dev), архитектура, устройство парсера, API, деплой

This commit is contained in:
2026-09-09 12:15:05 +03:00
parent 9c9875579c
commit 5ac3db50d3

196
README.md Normal file
View File

@@ -0,0 +1,196 @@
# Расписание
Публичное расписание учебных групп факультета: источник данных — Яндекс.Таблица
университета (файл xlsx), который скачивается, разбирается, превращается в JSON
API и отображается на фронте. Деплой: `schedule.our-place.ru`.
## Архитектура
```
Яндекс.Диск (xlsx)
│ скачивание по публичной ссылке
▼
api (FastAPI), порт 8000, доступ только с localhost
• скачивает и разбирает xlsx без сторонних библиотек
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
• один воркер, поэтому кэш общий
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
│ SCHEDULE_API=http://127.0.0.1:8000 (локальная разработка)
frontend (FastAPI: статика + прокси), прод :8090, dev :8080
• отдаёт index.html / app.js / style.css / картинки
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
▲
nginx (https://schedule.our-place.ru) → 127.0.0.1:8090
```
## Как устроено расписание в таблице
Лист «Бакалавры и магистры» — это сетка:
```
столбцы 0–1 даты «числителя»: начало и конец недели (серийные номера Excel)
столбцы 2–3 даты «знаменателя»
столбец 4 день недели (указан один раз на блок дня)
столбец 5 номер пары и время (ячейка объединена на две строки пары)
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
```
Каждый день занимает блок строк, и строки идут парами: «числитель» /
«знаменатель». У каждой пары строк — своя неделя семестра, а даты в ячейках —
это соседние недели по порядку. Поэтому недели семестра собираются со всего
листа (повторы отбрасываются по паре «чётность + дата»), а текущая чётность
(«Сейчас» на бейдже) определяется по сегодняшней дате.
## Как разбирается ячейка занятия
Ячейка свободного формата, и её содержимое проходит единый конвейер
(`schedule/lessons.py`) без особых веток на число строк:
1. Красные фрагменты (в xlsx помечаются как `⟪...⟫` ещё при чтении строк)
выносятся в поле `note` и из остального текста убираются, даже если внутри
них спрятано другое занятие; обрамляющие скобки у таких пометок снимаются.
2. Маркер курса по выбору «к.в. [- N]» может быть отдельной строкой или
префиксом предмета. Если в ячейке несколько курсов, а маркер один, его
получают все курсы этой ячейки.
3. Каждая строка относится к одной из ролей:
- **аудитория** — `ауд. N`, `каб. N`, URL, `онлайн`, `зал…` (включая
корпус: «юридический корпус, 3 этаж ауд. 22»);
- **преподаватель** — фамилия с инициалами (`Смирнов А.В.`), полное ФИО,
организация (`ПравоТех`, `НПО Криста`); преподавателей может быть
несколько (через `,` или `/`); имя допускается в одной строке
с предметом и (или) аудиторией;
- **предмет** — всё, что стоит до преподавателя/аудитории (название может
переноситься на несколько строк);
- **заметка** — время («начало в 14:05»), даты («с 8.09.26»),
«ссылка в эл. университете» и прочие примечания.
4. Результат приводится к единому виду: время `ЧЧ:ММ-ЧЧ:ММ`, аудитория `ауд. N`.
В одной ячейке может быть несколько занятий: они разделяются пустой строкой,
новым маркером «к.в.» или началом нового предмета сразу после строки
аудитории/преподавателя.
Итог по паре: `{time, ч: {raw, lessons[]}, з: {raw, lessons[]}}`, где
`lesson = {marker, subject, teacher, room, note}`.
## Структура кода
```
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
schedule/
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
скрытые столбцы, красный текст, серийные даты
source.py скачивание файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, TTL, modified, stale-while-error
errors.py ScheduleError
frontend/
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
static/
index.html, app.js, style.css
img/ логотипы (owl.svg, owl-white.svg), favicon.png
tests/
conftest.py общие фикстуры
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
fixtures.py общая тестовая таблица
test_lessons.py все форматы ячеек
test_xlsx.py чтение книги
test_parsing.py разбор листов, недели, индексы
test_api.py API и поведение кэша (TTL/modified/stale, 404/400/502)
```
## API
Полный список методов отдаёт сам бэкенд: `GET /`.
| Метод | Путь | Описание |
|---|---|---|
| GET | `/groups` | список групп (сначала БО, потом МО) |
| GET | `/schedule/{group}` | расписание группы с разбором на занятия |
| GET | `/schedule/{group}/{day}` | расписание группы на конкретный день |
| GET | `/today?group={group}` | расписание группы на сегодня |
| GET | `/teachers` | список преподавателей |
| GET | `/teacher/{name}` | расписание преподавателя (+ `full_name`) |
| GET | `/rooms` | список аудиторий (занятые ∪ лист «аудитории») |
| GET | `/room/{label}` | занятость аудитории |
| GET | `/rooms-day?day={день}` | занятость всех аудиторий за день |
| GET | `/rooms-free?day={день}&period={N}` | свободные аудитории |
| GET | `/week` | недели семестра и текущая чётность |
| GET | `/status` | состояние кэша (`cache_ttl_seconds`, `last_updated`, `table_modified`, `error`) |
| GET | `/healthz` | живость процесса (не обращается к таблице) |
Фронт проксирует только то, что использует, под префиксом `/api/...`:
`groups`, `schedule/{group}`, `teachers`, `teacher/{name}`, `rooms`,
`room/{label}`, `rooms-day`, `rooms-free`, `week`, `status`.
Остальное (`today`, `schedule/{group}/{day}`) доступно только на бэкенде.
## Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
| `SCHEDULE_LINK` | — | api | публичная ссылка Я.Диска, **обязательна** |
| `SCHEDULE_CACHE_TTL` | `900` | api | время жизни кэша, сек |
| `SCHEDULE_RETRY_AFTER` | `60` | api | пауза между попытками при сбое источника |
| `SCHEDULE_API` | `http://127.0.0.1:8000` | frontend | адрес бэкенда (в docker: `http://api:8000`) |
| `SCHEDULE_PROXY_TIMEOUT` | `150` | frontend | таймаут прокси к бэкенду, сек |
| `API_PORT` | `8000` | docker | внешний порт api |
| `FRONTEND_PORT` | `8080` | docker | внешний порт frontend (на проде: `127.0.0.1:8090`) |
`.env` ищется вверх от каталога модуля (backend — до корня репозитория);
`load_dotenv` не перезаписывает уже выставленные переменные окружения.
## Запуск
### Продакшен (Docker)
```bash
cp .env.example .env # вписать SCHEDULE_LINK (и порты при необходимости)
./deploy.sh # = docker compose build && up -d
```
У обоих сервисов есть healthcheck на `/healthz`; frontend стартует после того,
как api стал healthy. Обновление: `git pull && docker compose up -d --build`.
### Разработка
```bash
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
make dev # api :8000 и фронт :8080, оба с --reload
```
### Тесты
```bash
make test # 104 теста, сеть не нужна
```
## Развёртывание на сервере
Код хранится в Gitea (`git.our-place.ru`, web через nginx, git-ssh на порту
2222) и живёт в `/opt/schedule`:
```bash
# первый запуск
cd /opt/schedule
# .env: SCHEDULE_LINK=... , API_PORT=127.0.0.1:8000, FRONTEND_PORT=127.0.0.1:8090
sudo docker compose up -d --build
# обновление
cd /opt/schedule && git pull && sudo docker compose up -d --build
```
nginx-сайт `schedule.our-place.ru` проксирует 443 → 127.0.0.1:8090 (фронт),
сертификат Let's Encrypt. Порты api и frontend слушаются только на loopback.
## Замечания
- Принудительное обновление таблицы (пропуск кэша, `force`) из API намеренно
убрано; планируется вернуть вместе с авторизацией и ролью администратора.
- Кэш живёт в памяти единственного воркера: при горизонтальном масштабировании
api его придётся выносить в общее хранилище.
- Строки внутри дня в таблице строго чередуются «числитель / знаменатель».
Если строку посреди блока вставят или удалят, бэкенд запишет предупреждение
«нечётное число строк, недели могли сдвинуться».