- на сайте логотип лежит внутри ссылки и как строчный элемент добавлял 7px: шапка была 70px против 63px в админке. Теперь логотип блочный, а ссылка — inline-flex: обе шапки 63px (и min-height layout сходится точно); - выход и «Админка» при наведении только меняют цвет (тёмно-красный и синий соответственно), без подъёма, тени и transform — как остальные кнопки проекта.
Расписание
Публичное расписание учебных групп. Источник данных — Яндекс.Таблица (xlsx на Яндекс.Диске): файл скачивается по публичной ссылке, разбирается без сторонних библиотек и отдаётся как JSON API и как сайт, который можно «установить» на главный экран телефона (PWA).
Умеет:
- расписание по группам, преподавателям и аудиториям с разделением числитель/знаменатель и бейджем текущей недели;
- занятость всех аудиторий по дням и поиск свободных на конкретную пару;
- совпадающие недели показываются одним списком, различающиеся — двумя колонками, пустая — «Окно»;
- примечания из таблицы (красный текст) — отдельной строкой в карточке;
- PWA: манифест, service worker, иконки — «на главный экран».
Архитектура
Яндекс.Диск (xlsx)
│ скачивание по публичной ссылке
▼
api — FastAPI, порт 8000
• качает и разбирает xlsx стандартной библиотекой (zipfile + xml.etree)
• кэш в памяти: TTL + проверка даты изменения файла + stale-while-error
• один воркер, поэтому кэш общий
▲ SCHEDULE_API=http://api:8000 (внутри docker-сети)
│
frontend — FastAPI, порт из .env (по умолчанию 8080)
• отдаёт index.html / app.js / style.css / иконки
• /api/* — тонкий прокси к api (браузер не знает адрес бэкенда)
▲
nginx — порты 80/443, https://<домен сайта> → 127.0.0.1:<порт фронта>
Роли:
- docker изолирует оба сервиса, перезапускает их при падении
(
restart: unless-stopped) и следит за здоровьем (healthcheck на/healthz); frontend стартует, только когда api стал healthy. - Оба порта публикуются только на 127.0.0.1 (жёстко задано в
docker-compose.yml): из интернета контейнеры напрямую недоступны. - nginx — единственная дверь из интернета: принимает https и проксирует на
фронт. Его конфиг генерирует
deploy/nginx-site.sh(это делает установщик).
Установка на сервер
Docker (deploy.sh)
Скачайте zip с проектом, распакуйте и заполните .env:
unzip schedule.zip && cd schedule
cp .env.example .env && nano .env # SCHEDULE_LINK обязателен; ADMIN_* — по желанию
sudo ./deploy.sh # собрать образы и запустить
curl -s http://127.0.0.1:8080/api/status # {"has_data":true} — работает
deploy.sh проверяет, что Docker и .env на месте, собирает образы и поднимает
контейнеры. Обновление — тем же скриптом: git pull && sudo ./deploy.sh.
Почему Docker: сервисы изолированы, автоматически перезапускаются, не зависят
от Python в системе и обновляются одной командой.
Без Docker (systemd)
sudo apt install -y python3 python3-venv python3-pip # Python 3.11+
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
cp .env.example .env && nano .env # SCHEDULE_LINK (+ ADMIN_*, если нужна админка)
Две службы systemd (/etc/systemd/system/), затем
sudo systemctl daemon-reload && sudo systemctl enable --now schedule-api schedule-frontend:
# schedule-api.service
[Unit]
Description=Schedule API (FastAPI)
After=network-online.target
Wants=network-online.target
[Service]
User=www-data
WorkingDirectory=/путь/к/проекту/src/backend
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8000
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
# schedule-frontend.service
[Unit]
Description=Schedule frontend (FastAPI)
After=network-online.target schedule-api.service
Wants=network-online.target schedule-api.service
[Service]
User=www-data
WorkingDirectory=/путь/к/проекту/src/frontend
Environment=SCHEDULE_API=http://127.0.0.1:8000
ExecStart=/путь/к/проекту/.venv/bin/python -m uvicorn main:app --host 127.0.0.1 --port 8080
Restart=always
RestartSec=3
[Install]
WantedBy=multi-user.target
Если службы работают не от владельца каталога проекта, выдайте пользователю
службы права на запись в data/ (там админка хранит ссылку, интервал и историю
входов) или задайте свой путь в SETTINGS_FILE.
nginx + сертификат (общий шаг для обоих способов; .env уже должен быть):
sudo apt install -y nginx certbot
# временный http-конфиг домена с location /.well-known/acme-challenge/ → /var/www/html
sudo certbot certonly --webroot -w /var/www/html -d sched.example.ru
sudo deploy/nginx-site.sh --domain sched.example.ru --cert sched.example.ru --port 8080
curl -sI https://sched.example.ru/ | head -3 # HTTP/2 200 — готово
deploy/nginx-site.sh генерирует финальный конфиг (http → https, прокси на
FRONTEND_PORT из .env, таймауты под холодный кэш), включает его, проверяет
nginx -t и перечитывает nginx. Посмотреть конфиг, ничего не меняя:
deploy/nginx-site.sh --print --domain sched.example.ru.
Обслуживание
-
Обновление: Docker —
cd <каталог проекта> && sudo ./deploy.sh; система — заменить файлы проекта иsudo systemctl restart schedule-api schedule-frontend. -
Диагностика: Docker —
sudo docker compose psиsudo docker logs api --tail 50; система —systemctl status schedule-apiиjournalctl -u schedule-api -u schedule-frontend. Состояние кэша —/api/status(смотреть полеerror). -
Ротация логов контейнеров — настроить сразу: без лимита логи растут вечно и однажды забивают диск.
/etc/docker/daemon.json:{ "log-driver": "json-file", "log-opts": { "max-size": "10m", "max-file": "3" } }затем
sudo systemctl restart docker. -
Диск: следить
df -h /; раз в месяцsudo docker system prune -af && sudo journalctl --vacuum-size=200M.
Локальная разработка
Нужны Python 3.11+ и bash (Linux, macOS или WSL; на Windows без WSL — Docker).
python3 --version # должен быть 3.11 или новее
cp .env.example .env # вписать SCHEDULE_LINK
make run # создаст .venv, поставит зависимости, поднимет оба сервиса
Открыть http://127.0.0.1:8080 (API — http://127.0.0.1:8000). Остановить —
Ctrl+C; отвязать от терминала — nohup make run &.
Данные скачиваются сразу на старте (несколько секунд) и дальше обновляются по интервалу.
Полезные команды (make help — весь список):
| Команда | Что делает |
|---|---|
make venv |
создать .venv и установить зависимости |
make run (или make dev) |
запустить API и сайт |
make run-api / make run-front |
запустить только один из сервисов |
make test |
прогнать тесты |
make nginx-print / make nginx-install |
показать / установить конфиг nginx |
Без make — то же самое руками:
python3 -m venv .venv
.venv/bin/pip install -r requirements-dev.txt
.venv/bin/python -m uvicorn main:app --reload --port 8000 # в каталоге src/backend
.venv/bin/python -m uvicorn main:app --reload --port 8080 # в каталоге src/frontend
Тесты
make test # 121 тест, сеть не нужна
Зависимости
Системные требования
| Что | Зачем |
|---|---|
| Python 3.11+ | локальная разработка и режим «в систему» |
| Docker + docker compose v2 | режим Docker (опционально) |
Python-пакеты — закреплены в requirements.txt (рабочие) и
requirements-dev.txt (тесты):
| Пакет | Версия | Назначение |
|---|---|---|
| fastapi | 0.141.1 | веб-фреймворк обоих сервисов |
| uvicorn | 0.52.4 | ASGI-сервер, которым запускаются сервисы |
| python-dotenv | 1.2.3 | чтение .env |
| requests | 2.34.2 | скачивание файла с Я.Диска и прокси к API |
| pytest | 9.1.1 | тесты — только dev |
| httpx2 | 2.12.0 | HTTP-клиент (наследник httpx) для TestClient — только dev |
Больше ничего не нужно: xlsx разбирается стандартной библиотекой, базы данных
нет, кэш — в памяти процесса. httpx2 в рабочем коде не используется и нужен
только тестам; модуль импортируется как httpx2 — httpx перешёл под опеку
Pydantic и был переименован в августе 2026, import httpx больше не работает.
Если установка не идёт
- Проверьте
python3 --version: нужен 3.11 или новее. Если подpython3скрывается старая версия, создайте окружение нужной явно:python3.12 -m venv .venv. - Debian/Ubuntu: если
make venvпадает на создании окружения, доставьте пакет venv:sudo apt install python3-venv(для нестандартной версии — например,python3.12-venv).
Если pip не находит пакеты: зеркала
Если pip настроен на корпоративное/национальное зеркало, оно может отставать от PyPI — свежих пакетов там ещё нет. Диагностика и обход:
pip index versions httpx2 # есть ли пакет на текущем индексе
pip install -r requirements-dev.txt -i <зеркало> # разово через другое зеркало
pip config set global.index-url <зеркало> # закрепить зеркало навсегда
Зеркала, проверенные 07.10.2026 (пакеты httpx2/httpcore2 на месте;
URL подставлять как есть в -i / index-url):
| Зеркало | URL |
|---|---|
| PyPI, официальный | https://pypi.org/simple |
| TUNA (Tsinghua) | https://pypi.tuna.tsinghua.edu.cn/simple |
| Huawei Cloud | https://mirrors.huaweicloud.com/repository/pypi/simple |
| BFSU | https://mirrors.bfsu.edu.cn/pypi/web/simple |
| SJTU | https://mirror.sjtu.edu.cn/pypi/web/simple |
| USTC | https://mirrors.ustc.edu.cn/pypi/simple |
| SUSTech | https://mirrors.sustech.edu.cn/pypi/simple |
Примечания: Alibaba Cloud (https://mirrors.aliyun.com/pypi/simple) обычно
тоже свежий, но из части сетей недоступен; Яндекс-зеркало (mirror.yandex.ru)
PyPI не раздаёт вовсе. Зеркала отстают по-разному, поэтому для свежих пакетов
надёжнее разово ставить с официального индекса.
Переменные окружения
| Переменная | По умолчанию | Где | Описание |
|---|---|---|---|
SCHEDULE_LINK |
— | api | публичная ссылка Я.Диска, обязательна |
SCHEDULE_INTERVAL_MINUTES |
15 |
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 | таймаут прокси к бэкенду, сек |
ADMIN_PATH |
— | api+frontend | секретный путь админки; пусто = админка выключена |
ADMIN_LOGIN / ADMIN_PASSWORD |
— | api | логин и пароль администратора |
ADMIN_MAX_ATTEMPTS |
3 |
api | сколько неудачных попыток входа допускается |
ADMIN_LOCKOUT_MINUTES |
15 |
api | на сколько минут блокировать вход после них |
SETTINGS_FILE |
data/settings.json |
api | где хранить ссылку, интервал и историю входов |
API_PORT |
8000 |
docker | внешний порт api (публикуется на 127.0.0.1) |
FRONTEND_PORT |
8080 |
docker | внешний порт frontend (публикуется на 127.0.0.1) |
.env ищется вверх от каталога модуля: и backend, и frontend находят общий
.env в корне репозитория; load_dotenv не перезаписывает уже выставленные
переменные окружения.
Админка
Необязательная страница для управления источником данных. Отдельного входа на
сайте нет: панель открывается по секретному пути ADMIN_PATH, а пока
ADMIN_LOGIN/ADMIN_PASSWORD пусты, админка выключена и наружу ничего не
торчит (маршруты /api/admin/* не регистрируются).
Включить: заполнить в .env четыре переменные и перезапустить службу.
ADMIN_PATH=/panel-8f3a91x7 # придумайте длинный и неочевидный
ADMIN_LOGIN=admin
ADMIN_PASSWORD=<длинный пароль>
Что умеет:
- посмотреть состояние: сколько групп, когда обновлялось, дата файла на
Диске, изменился ли файл при последнем чтении (
sha256), есть ли ошибки; - вставить новую ссылку на таблицу: она проверяется скачиванием и разбором до сохранения — нерабочая не применится, данные на сайте не пострадают;
- задать интервал обновления в минутах (
1…1440); - обновить сейчас: скачивает таблицу принудительно, не дожидаясь интервала (по интервалу файл перечитывается, только если на Диске изменилась дата);
- видеть входы: последняя попытка (откуда, когда, успешна ли) и последний успешный вход — и в панели, и в логе службы.
Защита входа: после ADMIN_MAX_ATTEMPTS неудач (по умолчанию 3) вход с этого
адреса блокируется на ADMIN_LOCKOUT_MINUTES минут (по умолчанию 15). Сессия —
подписанная cookie на 12 часов.
Как защищён путь админки
- путь задаётся только в
.envи больше нигде не хранится: ни в HTML, ни в JSON, ни в JS — неавторизованный пользователь его нигде не увидит; - кнопка «Админка» на главной показывается только вошедшему (сервер проверяет
cookie и добавляет в страницу лишь флаг, без пути) и ведёт на служебный
/api/admin/goto, который отвечает редиректом (302) на секретный путь — поэтому путь не попадает в разметку и его не вычитает краулер; - без валидной сессии
/api/admin/*отвечает401, на неизвестных путях сайт отдаёт обычный 404 — перебором путь не находится; - страница панели отдаётся с заголовками
X-Robots-Tag: noindex, nofollow, noarchive,Referrer-Policy: no-referrer(путь не утечёт во внешние ссылки через Referer),X-Frame-Options: DENYиCache-Control: no-store; - в
robots.txtпути нет (там толькоDisallow: /api/) — иначе мы бы сами его опубликовали; внешние ссылки в панели открываются сrel="noopener noreferrer"; - при желании путь можно убрать и из логов nginx: добавить в конфиг сайта
location = <ADMIN_PATH> { access_log off; }.
Остаётся только история браузера у самого админа — это неизбежно (браузер должен знать, куда идти), поэтому панель лучше открывать с личного устройства, а путь держать в менеджере паролей.
Настройки (ссылка, интервал, история входов) хранятся в SETTINGS_FILE
(по умолчанию data/settings.json в корне проекта; в docker — в томе
schedule_data). Если файл недоступен для записи, изменения живут в памяти до
перезапуска, а панель предупредит об этом.
Структура кода
src/
backend/
main.py FastAPI: маршруты, create_app(cache), /healthz
admin.py админка: cookie-сессия, блокировка входа, ссылка, интервал, force
schedule/
xlsx.py чтение xlsx: общие строки, объединённые ячейки,
скрытые столбцы, красный текст, серийные даты
source.py скачивание файла по публичной ссылке Я.Диска
lessons.py разбор ячеек занятий (общий конвейер ролей)
parsing.py лист → расписание, недели, индексы преподавателей/аудиторий
cache.py ScheduleCache: блокировка, интервал, stale-while-error, force
settings.py хранилище настроек админки (ссылка, интервал, история входов)
errors.py ScheduleError
frontend/
main.py FastAPI: статика + прокси /api/* (таймаут 150 c)
admin.html страница админки (вне static: прямой доступ закрыт)
static/
index.html, app.js, style.css
manifest.json PWA: имя, иконки, standalone-режим
sw.js минимальный service worker (без кэша) для установки приложения
img/ логотипы и иконки приложения
tools/
make_icons.py генерация иконок приложения из логотипа (нужен rsvg-convert)
deploy/
nginx-site.sh генерация и включение конфига nginx (ручная настройка)
tests/
conftest.py общие фикстуры
xlsx_builder.py генерация xlsx в памяти (без openpyxl)
fixtures.py общая тестовая таблица
test_lessons.py все форматы ячеек
test_xlsx.py чтение книги
test_parsing.py разбор листов, недели, индексы
test_api.py API и поведение кэша (интервал/stale, 404/400/502)
test_admin.py админка: вход, блокировка, история, ссылка/интервал/force
test_settings.py хранилище настроек админки
test_deploy.py проверка, что образы копируют все нужные файлы
Данные админки (data/settings.json) — рабочие, в git не попадают.
Как устроена таблица-источник
Лист «Бакалавры и магистры» — это сетка:
столбцы 0–1 даты «числителя»: начало и конец недели (серийные номера Excel)
столбцы 2–3 даты «знаменателя»
столбец 4 день недели (указан один раз на блок дня)
столбец 5 номер пары и время (ячейка объединена на две строки пары)
столбцы 6+ группы — по одному столбцу на группу (скрытые столбцы пропускаются)
Каждый день занимает блок строк, и строки идут парами: «числитель» / «знаменатель». У каждой пары строк — своя неделя семестра, а даты в ячейках — это соседние недели по порядку. Поэтому недели семестра собираются со всего листа (повторы отбрасываются по паре «чётность + дата»), а текущая чётность («Сейчас» на бейдже) определяется по сегодняшней дате.
Как разбирается ячейка занятия
Ячейка свободного формата, и её содержимое проходит единый конвейер
(schedule/lessons.py) без особых веток на число строк:
- Красные фрагменты (в xlsx помечаются как
⟪...⟫ещё при чтении строк) выносятся в полеnoteи из остального текста убираются, даже если внутри них спрятано другое занятие; обрамляющие скобки у таких пометок снимаются. - Маркер курса по выбору «к.в. [- N]» может быть отдельной строкой или префиксом предмета. Если в ячейке несколько курсов, а маркер один, его получают все курсы этой ячейки.
- Каждая строка относится к одной из ролей:
- аудитория —
ауд. N,каб. N, URL,онлайн,зал…(включая корпус: «юридический корпус, 3 этаж ауд. 22»); - преподаватель — фамилия с инициалами (
Смирнов А.В.), полное ФИО, организация (ПравоТех,НПО Криста); преподавателей может быть несколько (через,или/); имя допускается в одной строке с предметом и (или) аудиторией; - предмет — всё, что стоит до преподавателя/аудитории (название может переноситься на несколько строк);
- заметка — время («начало в 14:05»), даты («с 8.09.26»), «ссылка в эл. университете» и прочие примечания.
- аудитория —
- Результат приводится к единому виду: время
ЧЧ:ММ-ЧЧ:ММ, аудиторияауд. N.
В одной ячейке может быть несколько занятий: они разделяются пустой строкой, новым маркером «к.в.» или началом нового предмета сразу после строки аудитории/преподавателя. Если пустой строкой (или красной заметкой, снятой с целой строки) от предмета оторвался только преподаватель и/или аудитория, этот кусок достраивает предыдущее занятие, а не создаёт занятие без предмета — иначе в расписании аудитории оставались бы одни группы.
Итог по паре: {time, ч: {raw, lessons[]}, з: {raw, lessons[]}}, где
lesson = {marker, subject, teacher, room, note}.
Как отображается
- Если занятия числителя и знаменателя совпадают, пара показывается одним списком без пометки недели (одинаково для групп, преподавателей и аудиторий); иначе — две колонки «числитель»/«знаменатель», пустая заменяется на «Окно».
- Подпись недели появляется только там, где недели действительно различаются; бейдж «Сейчас» подсвечивает текущую неделю.
- Длинные ссылки в карточке переносятся по служебным символам и каждые
24 символа (
<wbr>), поэтому карточка не вылезает за экран даже в старых браузерах, где нетoverflow-wrap. - На вкладке «Аудитории» есть фильтры: тип (в карточке — формулировка из таблицы, в фильтре близкие варианты объединены: «комп.класс» включает «комп.класс Linux» и «компьютерный класс», «кабинет английского» — «английский»; есть пункт «без типа»), вместимость («не меньше N мест») и оснащение чекбоксами с выбором условия «все выбранные (И)» или «любое из выбранных (ИЛИ)». Выбранное запоминается в браузере, под списком видно «Показано N из M» и пояснения: сколько аудиторий скрыто из-за отсутствия данных о вместимости и какие доступны только по отдельным дням.
- Там же переключатель «Занятость за день» / «Свободные на паре»: во втором режиме выбирается пара и показываются только свободные аудитории — вместе с фильтрами получается «свободная аудитория на 30 человек с проектором в 3-ю пару».
- Приложение можно «установить» на главный экран (манифест + service
worker + иконки приложения); на iOS иконку берёт
apple-touch-icon.
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 |
состояние кэша (last_updated, table_modified, error, …) |
| GET | /healthz |
живость процесса (не обращается к таблице) |
В ответах rooms-day и rooms-free по каждой аудитории, кроме room и info
(описание как в таблице), идут разобранные атрибуты: type (тип фразой из
таблицы: «комп.класс», «кабинет английского», «лекционная»…) и type_group
(тот же тип, но близкие формулировки сведены к одной — «комп.класс»,
«комп.класс Linux» и «компьютерный класс» дают группу «комп.класс»),
capacity
(мест; для компьютерных классов — рабочие места; null, если в таблице нет
данных), equipment (список: «ПК», «Проектор», «Интерактивная панель»,
«Телевизор», «Маркер», «Меловая доска») и available_days (дни, если в
описании указано «только четверг, пятница»). Аудитории с ограничением по дням
не попадают в /rooms-free для остальных дней.
Фронт проксирует только то, что использует, под префиксом /api/...:
groups, schedule/{group}, teachers, teacher/{name}, rooms,
room/{label}, rooms-day, rooms-free, week, status.
Остальное (today, schedule/{group}/{day}) доступно только на бэкенде.