Продукт создан специально для национального мессенджера MAX: чат-бот и мини-приложение.
Сейчас массовых решений для отслеживания олимпиад нет: есть только календари, куда данные приходится вносить вручную. По результатам нашего опроса, более 75% учащихся пропускали этапы олимпиад из-за сложности отслеживания сроков, и примерно столько же хотели бы иметь для этого удобное мини-приложение.
Целевая аудитория продукта — учащиеся 5–11 классов, участвующие или желающие участвовать в олимпиадах.
Продукт автоматически отслеживает изменения в данных об олимпиадах (название, уровень, даты и др.), которые берутся с портала Olimpiada.ru, составляет индивидуальный календарь, помогающий отслеживать сроки олимпиад, а также присылает напоминания о ближайших этапах. Пользователю достаточно указать класс и отметить олимпиады, в которых он участвует.
Так олимпиадники не пропустят этапы из-за забытых сроков: вся информация и напоминания приходят в привычный мессенджер.
Открыть мини-приложение в MAX –> выбрать класс обучения (его можно позже изменить в настройках при необходимости) –> просмотреть каталог олимпиад (олимпиады отсортированы по предметам) с фильтрами (по наличию информации о датах, по уровню), воспользоваться поиском –> выбрать для участия интересующие олимпиады нажатием кнопки “Буду участвовать”. После этого выбранные олимпиады автоматически переносятся в “Мои олимпиады” и “Календарь” –> в персонализированном календаре ознакомление со сроками, сравнивание сроков, выбор точных дат для написания –> получение напоминаний в push-уведомлениях о приближающемся окончании этапа или об участии в заранее выбранную дату –> успешная и своевременная регистрация в выбранных олимпиадах, участие и отслеживание своего статуса.
В решении три части: бэкенд на FastAPI, мини-приложение на JavaScript без сборки и база Postgres. Бэкенд в одном процессе обслуживает API мини-приложения, вебхук бота и ежедневную рассылку. Всё запускается в Docker и работает по HTTPS на одном домене:
пользователь в MAX
│ HTTPS
▼
https://my-olymp.ru — docker-compose.prod.yml
nginx :443 TLS (Let's Encrypt), :80 → редирект на HTTPS
├── / статика мини-приложения
└── /api/*, /webhook, /health
│
▼
backend FastAPI, наружу не открыт ──► db Postgres 16
│
└──► platform-api2.max.ru: сообщения бота
certbot продлевает сертификат
Локально docker-compose.yml поднимает те же контейнеры, но без TLS: всё открывается на http://localhost:8080.
В мини-приложении четыре вкладки и окно настроек:
| Экран | Что там | API |
|---|---|---|
| Поиск | предметы с числом олимпиад для класса пользователя, список олимпиад по предмету, карточка олимпиады с этапами и кнопкой «Буду писать» | GET /api/catalog/subjects, GET /api/catalog/olympiads, GET /api/catalog/olympiads/{id} |
| Новости | события по сохранённым олимпиадам: «Появились даты», «Срочно», «Скоро», «Позже», «Ожидает ответа от тебя» | GET /api/me/news |
| Мои | сохранённые олимпиады и ответы «прошёл / не прошёл» по этапам | GET /api/me/olympiads, GET, POST и DELETE /api/me/olympiads/{id}, PUT /api/me/stages/{id}/result |
| Календарь | этапы на сетке месяца и план — какой этап в какой день писать | GET /api/me/calendar, PUT и DELETE /api/me/stages/{id}/plan |
| Настройки | класс, уведомления, режим для дальтоников | GET и PATCH /api/me/settings |
Правила механики проверяет сервер, в personal/rules.py:
Если действие нарушает правило, сервер отвечает 409 или 422 с кодом ошибки, и фронтенд выбирает реакцию по коду. Все эндпоинты, поля и коды описаны в docs/api-for-frontend.md; там есть и те, что интерфейс пока не вызывает: события одного дня календаря и отмена ответа по этапу.
Бот делает три вещи: на запуск присылает приветствие с кнопкой открытия приложения, на любое сообщение отвечает той же кнопкой и раз в день присылает сводку по сохранённым олимпиадам (см. «Утренняя сводка» ниже).
.
├── docker-compose.yml запуск всех локальных компонентов одной командой
├── docker-compose.prod.yml запуск на сервере my-olymp.ru
├── .dockerignore контекст образа фронтенда: только статика и nginx
├── .env.example шаблон переменных окружения, без секретов
├── certs/ сертификаты Минцифры (сами файлы в git не хранятся)
├── deploy/
│ ├── nginx/ local.conf — локально, my-olymp.ru.conf — TLS на 443,
│ │ proxy.conf — общие настройки прокси
│ ├── Caddyfile TLS, статика и прокси для сервера без Docker (Windows)
│ └── diagnose.sh / .ps1 снимок состояния сервера, ничего не меняет
├── docs/
│ ├── api-for-frontend.md эндпоинты, поля и коды ошибок
│ ├── deploy-my-olymp.md сервер с нуля: домен, сертификат, вебхук
│ ├── deploy-without-docker.md то же на Windows без Docker
│ ├── launch-in-max.md как привязать приложение к боту в MAX
│ └── frontend-todo.md что осталось поправить во фронтенде
├── tools/ парсер расписания olimpiada.ru и его тесты
├── frontend/ мини-приложение, отдаётся с того же домена
│ ├── index.html разметка и подключение MAX Bridge
│ ├── app.js экраны, вкладки, календарь
│ ├── api.js клиент бэкенда: Api и ApiError
│ ├── style.css
│ └── Dockerfile nginx со статикой и прокси в бэкенд
└── backend/
├── Dockerfile два образа: runtime и dev
├── .dockerignore контекст образа бэкенда
├── requirements.txt зависимости времени выполнения
├── requirements-dev.txt + pytest
├── app/
│ ├── main.py сборка приложения, запуск и остановка
│ ├── config.py настройки из переменных окружения
│ ├── clock.py «сегодня» в часовом поясе приложения
│ ├── security/
│ │ ├── launch_data.py проверка подписи данных запуска
│ │ ├── deps.py аутентификация запросов мини-приложения
│ │ └── testing.py генератор подписанных данных для отладки
│ ├── max_api/
│ │ ├── client.py HTTP-клиент Bot API MAX
│ │ └── rate_limiter.py очередь под лимит 2 сообщения в секунду
│ ├── bot/
│ │ ├── router.py приём событий на /webhook
│ │ ├── handlers.py обработка событий
│ │ ├── keyboards.py кнопка открытия мини-приложения из чата
│ │ └── subscription.py проверка подписки на события при старте
│ ├── catalog/
│ │ ├── models.py олимпиады, этапы, предметы, категории
│ │ ├── dates.py разбор дат этапов с точностью
│ │ ├── subject_importer.py импорт каталога по предметам
│ │ ├── schedule_importer.py импорт расписания с olimpiada.ru
│ │ ├── importer.py импорт выгрузки postupi.online
│ │ └── presentation.py статусы этапов и таймеры для интерфейса
│ ├── personal/
│ │ ├── models.py мои олимпиады, ответы, планы, настройки
│ │ ├── rules.py правила механики: лимит, блокировки, окна
│ │ ├── queries.py выборки по пользователю, общие для API и бота
│ │ ├── news.py лента «Новости»
│ │ ├── reminders.py текст утренней сводки
│ │ └── reminder_service.py ежедневная рассылка сводки
│ ├── api/
│ │ ├── router.py профиль пользователя
│ │ ├── catalog.py предметы, список и карточки олимпиад
│ │ └── personal.py мои олимпиады, календарь, новости, настройки
│ └── db/ модели пользователя и подключение к базе
├── alembic.ini
├── migrations/ версии схемы базы данных
├── data/ выгрузки источников, из них наполняется каталог
├── scripts/ связь с MAX, вебхук, импорт, рассылка, сквозная проверка;
│ seed.py наполняет пустой каталог при старте
└── tests/ 335 тестов
| Контур | Кто обращается | Как подтверждает себя |
|---|---|---|
POST /webhook |
платформа MAX | заголовок X-Max-Bot-Api-Secret |
/api/* |
мини-приложение | подпись данных запуска в Authorization |
Секрет вебхука бэкенд передаёт MAX сам, при подписке на события (scripts.setup_webhook). Проверка подписи для мини-приложения описана ниже.
MAX принимает вебхук только по HTTPS на порту 443 и с сертификатом доверенного центра. Если разнести бота и API по разным сервисам, понадобятся два публичных адреса и два сертификата. Поэтому сервис один, а bot/ и api/ разделены только в коде: при необходимости их можно развести, не переписывая логику.
Мини-приложение отдаётся с того же домена, что и API. Запросы идут на свой origin, поэтому в проде нет ни preflight, ни CORS. HTTPS обслуживает контейнер nginx с конфигом из deploy/nginx/, сертификат Let’s Encrypt выпускает и продлевает контейнер certbot. Бэкенд наружу не открыт, до него доходят только запросы через nginx.
MAX передаёт мини-приложению подписанную строку запуска, а фронтенд отправляет её на сервер в заголовке Authorization: tma <initData>. Подпись считается от токена бота, поэтому проверить её может только сервер.
Алгоритм взят из документации MAX, реализация — в launch_data.py:
hash откладывается отдельно и в подписываемые данные не входит;\n;secret_key = HMAC-SHA256(key="WebAppData", msg=BOT_TOKEN);hash = hex(HMAC-SHA256(key=secret_key, msg=launch_params));hash в постоянном времени.Ещё проверяется auth_date: строка старше LAUNCH_DATA_TTL отклоняется, чтобы перехваченная строка не работала вечно. initDataUnsafe сервер не использует, его можно подделать. Идентификатор пользователя берётся только из проверенной строки и никогда из тела запроса.
Уровень по перечню РСОШ зависит от предмета: Московская олимпиада школьников по астрономии — I уровня, по истории — II. В текущем каталоге 34 олимпиады проходят сразу по нескольким предметам, и у 5 из них уровень разный.
Поэтому одна запись в каталоге — это пара «олимпиада + предмет». Описание, сайт, классы и организаторы у всех предметов одной олимпиады одинаковые и просто дублируются. «Ломоносов» встречается в каталоге 13 раз, олимпиада СПбГУ — 12, и в «Мои олимпиады» они тоже добавляются по предмету.
Схему ведёт Alembic. Бэкенд применяет миграции сам при старте, до запуска HTTP-сервера, так что одна команда поднимает и пустую базу, и существующую. Потом scripts.seed заполняет каталог из backend/data/, если он пустой, а непустой не трогает.
После изменения моделей:
docker compose run --rm backend alembic revision --autogenerate -m "что меняем"
Файл появится в backend/migrations/versions/. Его нужно просмотреть и закоммитить, применится он при следующем старте.
Приложение считает «до этапа N дней», рисует календарь по дням и присылает сводку о событиях дня. Для этого нужен точный день, а он известен не всегда. Расписание с olimpiada.ru даёт дни, а в выгрузке postupi.online у 32 этапов из 97 есть только месяц и год («мар 2027»). Если считать по такой дате дни, получится правдоподобное, но выдуманное число.
Поэтому у каждой даты хранится её точность, и API отдаёт её явно:
| Поле | Смысл |
|---|---|
start_precision |
day — известен день, month — только месяц |
days_until_start |
число дней или null, если точного дня нет |
plannable |
false — этап нельзя поставить в календарь |
raw_date_range |
исходная строка источника, например «мар 2027» |
Если days_until_start равен null, фронтенд не показывает таймер и выводит raw_date_range как есть. Как этап рисуется в календаре (полоса, кружок или дедлайн), описано в разделе «Форма сроков этапа».
Тип этапа (kind: регистрация, отборочный, финал) определяется по названию, в источнике его нет. Если определить не получилось, ставится other; в расписании таких 16 этапов из 147.
Ленту сервер не хранит, а считает при каждом запросе из дат этапов и ответов пользователя (personal/news.py). Так она не может разойтись с календарём, когда даты в источнике меняются.
| Категория | Что в ней |
|---|---|
| Срочно | этап начинается или заканчивается сегодня |
| Скоро | начинается или заканчивается в ближайшие 7 дней |
| Позже | всё остальное впереди |
| Ожидает ответа от тебя | этап закончился, а ответа «прошёл / не прошёл» нет |
| Появились даты | у этапа появились даты, пока олимпиада была в «Моих» |
Каждый этап попадает ровно в одну из первых четырёх категорий (ещё есть «Завершённые», их интерфейс пока не показывает). «Появились даты» идёт поверх: этап с новыми датами остаётся и в своей категории. В «Срочно» и «Скоро» попадают только этапы с точным днём.
Раз в день, в REMINDER_TIME, бот присылает одно сообщение по всем олимпиадам, за которыми следит пользователь. Одно, а не по сообщению на событие: в сезон у школьника с пятью олимпиадами бывает по нескольку событий в день, и десяток уведомлений он просто выключит.
В сводку попадают только события с точным днём, у одного этапа — не больше одной строки:
| Блок | Что в нём |
|---|---|
| Сегодня | запланированный на сегодня этап, начало этапа, последний день |
| Завтра | то же про завтра |
| Через 3 дня | последний день этапа, чтобы успеть доделать работу |
| Как прошло? | этап закончился вчера, а ответа «прошёл / не прошёл» нет |
Если сказать нечего, сообщения не будет: пустые сводки приучают не открывать сообщения бота.
Сводку получают те, кто запустил бота (иначе MAX не даст ему писать), не выключил уведомления в настройках и сохранил хотя бы одну олимпиаду.
Время этапов в источнике не указано, только даты. Поэтому напоминания «за час до начала» нет, его заменяет сводка накануне и утром в день события.
Чтобы не написать человеку дважды, перед отправкой в reminder_log вставляется строка «пользователь + день», и первичный ключ не даёт вставить её второй раз. Бэкенд можно перезапускать: если он поднялся в 9:40 после сбоя, то допишет тем, кому не успел, а остальных не тронет. Если MAX недоступен, проход прерывается и повторяется через 10 минут. Ошибка по одному человеку (например, он удалил диалог) на остальных не влияет. После 21:00 рассылка не начинается.
Проверить на себе, не дожидаясь утра:
python -m scripts.send_reminders --users # найти свой id
python -m scripts.send_reminders --dry-run # что уйдёт сегодня, без отправки
python -m scripts.send_reminders --dry-run --date 2026-10-14 # как будто сегодня 14 октября
python -m scripts.send_reminders --user <id> --date 2026-10-14 --force
Если сводка кому-то не приходит, --user <id> покажет причину: от человека не было событий бота, уведомления выключены или нет сохранённых олимпиад. С --dry-run текст сводки выводится и в этом случае. Что бот уже отправил, записано в reminder_log.text.
О том, что человек запустил бота, сервер узнаёт только из событий MAX, а они приходят только при подписке на вебхук. При старте бэкенд проверяет подписку и пишет в лог «Подписка на события MAX: …», если всё в порядке, или «MAX не присылает события на …», если нужно запустить python -m scripts.setup_webhook. Событие bot_started приходит один раз, поэтому тех, кто запустил бота до подписки, сервер отметит по первому их сообщению боту.
docker compose version. Более старый Compose не понимает необязательный .env в docker-compose.yml и откажется запускать. Больше ничего ставить не нужно: Python, Postgres и nginx приезжают в образах.certs/, см. certs/README.md. Без них запросы к API MAX падают с ошибкой TLS.docker compose up --build
Поднимаются три сервиса:
| Сервис | Что это | Адрес |
|---|---|---|
db |
Postgres 16 | 127.0.0.1:5432, только для клиента БД |
backend |
API и бот; при старте применяет миграции и наполняет пустой каталог | http://localhost:8000 |
web |
nginx: мини-приложение и прокси /api, /webhook, /health в бэкенд |
http://localhost:8080 |
Каталог олимпиад наполняется сам при первом старте из выгрузок в backend/data/ — после up приложение сразу с данными, импортировать руками ничего не нужно.
.env для запуска не обязателен: без него поднимается всё, только бот не знает своего токена. Для работы с MAX скопируйте шаблон и заполните BOT_TOKEN:
cp .env.example .env
В PowerShell:
Copy-Item .env.example .env
Подробнее о запуске в разделе “Пошаговый сценарий проверки”
| Файл | Назначение |
|---|---|
backend/Dockerfile |
образ бэкенда: runtime — рабочий, dev — с pytest для тестов |
frontend/Dockerfile |
образ фронтенда: nginx со статикой мини-приложения |
docker-compose.yml |
все локальные компоненты одной командой |
docker-compose.prod.yml |
сервер: те же образы плюс TLS и certbot |
.dockerignore, backend/.dockerignore |
в контекст сборки не попадают .env, certs/, .git и прочее лишнее |
.env.example |
шаблон переменных, токены и пароли пустые |
Сборка обоих образов с нуля занимает меньше минуты (замер — 16–30 секунд без учёта загрузки базовых образов): зависимости ставятся готовыми колёсами, ничего не компилируется, слой с зависимостями кэшируется отдельно от кода.
docker compose down
Данные Postgres лежат в именованном томе и переживают остановку — следующий docker compose up поднимет то же состояние.
Удалить данные вместе с контейнерами:
docker compose down -v
Перезапустить один сервис, не трогая базу:
docker compose restart backend
Пересобрать после изменения кода или зависимостей:
docker compose up --build
Локальный docker-compose.yml TLS не делает и слушает обычный HTTP. Для сервера есть отдельный файл — он собирает те же образы, но nginx получает сертификат Let’s Encrypt и слушает 80 и 443, а порты бэкенда и базы с хоста убраны:
docker compose -f docker-compose.prod.yml up -d --build
Первый выпуск сертификата, перерегистрация вебхука и привязка адреса к боту — docs/deploy-my-olymp.md.
Полный список с комментариями — в .env.example. Обязательные для рабочего запуска отмечены звёздочкой.
| Переменная | По умолчанию | Назначение |
|---|---|---|
BOT_TOKEN * |
— | токен чат-бота, даёт полный доступ к управлению |
BOT_USERNAME |
— | ник бота для диплинка https://max.ru/<botName> |
MAX_API_BASE_URL |
https://platform-api2.max.ru |
домен API |
MAX_API_TIMEOUT |
15 |
таймаут запросов к MAX, секунды |
PUBLIC_BASE_URL |
https://my-olymp.ru |
публичный HTTPS-адрес бэкенда |
WEBHOOK_PATH |
/webhook |
путь endpoint’а вебхука |
WEBHOOK_SECRET * |
— | значение заголовка X-Max-Bot-Api-Secret |
LAUNCH_DATA_TTL |
86400 |
предельный возраст auth_date, секунды |
CORS_ORIGINS |
https://my-olymp.ru |
origin фронтенда через запятую |
DATABASE_URL |
задаётся в compose | строка подключения к Postgres |
POSTGRES_PASSWORD |
локально app |
пароль базы; на сервере обязателен |
EXTRA_CA_CERTS_DIR |
certs |
каталог с сертификатами Минцифры, путь от рабочего |
APP_ENV |
development |
в production скрывается /docs |
APP_TIMEZONE |
Europe/Moscow |
в каком поясе считается «сегодня» |
REMINDERS_ENABLED |
true |
рассылать ли утреннюю сводку |
REMINDER_TIME |
09:00 |
во сколько, в поясе APP_TIMEZONE |
LOG_LEVEL |
INFO |
уровень логирования |
Секретов в репозитории нет: .env и certs/* перечислены в .gitignore. Если токен хоть раз попал в историю коммитов, считайте его скомпрометированным и перевыпустите.
| Порт | Сервис | Переменная | Примечание |
|---|---|---|---|
8080 |
web | WEB_PORT |
локально: мини-приложение и API на одном адресе |
8000 |
backend | BACKEND_PORT |
локально: бэкенд в обход nginx; в проде на хост не публикуется |
5432 |
Postgres | POSTGRES_PORT |
локально, только 127.0.0.1: для клиента БД |
80 |
nginx | — | подтверждение домена и редирект на HTTPS (прод) |
443 |
nginx | — | HTTPS: мини-приложение и API (прод) |
Наружу MAX ходит по 443 — его обеспечивает nginx, а не приложение. Локально тот же nginx слушает обычный HTTP на 8080.
Версии зафиксированы точно в requirements.txt (время выполнения) и requirements-dev.txt (тесты).
FastAPI и uvicorn, pydantic и pydantic-settings, httpx и certifi, SQLAlchemy с asyncpg. Образ бэкенда собирается из python:3.12-slim, фронтенда — из nginx:1.30-alpine; база — готовый postgres:16-alpine, certbot на сервере — certbot/certbot:v5.8.0.
Их нельзя воспроизвести внутри Docker — для проверки решения нужны условия ниже.
| Сервис | Назначение | Что нужно для проверки |
|---|---|---|
| Платформа MAX | доставка событий бота, отправка сообщений | действующий BOT_TOKEN |
| Сертификаты Минцифры | TLS-соединение с platform-api2.max.ru |
файлы в certs/, скачиваются с Госуслуг |
Домен my-olymp.ru |
публичный адрес приложения и вебхука на 443 | A-запись на сервер и сертификат Let’s Encrypt |
| CDN MAX | библиотека MAX Bridge для фронтенда | доступ в интернет из браузера |
Публичный адрес нужен потому, что MAX доставляет события только на HTTPS-адрес с сертификатом доверенного центра; самоподписанные не принимаются.
Раньше эту роль играл туннель ngrok, и его адрес приходилось помнить и перерегистрировать. Теперь адрес постоянный, и туннель нужен только для отладки: тогда его адрес подставляется в PUBLIC_BASE_URL, а подписка оформляется заново.
Как поднять сервер с нуля — docs/deploy-my-olymp.md.
Хранятся только те данные, что приходят из проверенных данных запуска и событий бота:
users — идентификатор в MAX, имя, ник, язык, ссылка на фото, метка запуска из диплинка, время первого и последнего визита;bot_dialogs — может ли бот писать пользователю (запустил он бота или остановил).Каталог олимпиад:
olympiads — название, ссылка на источник, вузы-партнёры, отметка о том, когда запись последний раз подтверждалась в источнике;stages — этапы с датами, точностью дат и предполагаемым типом;subjects, categories — справочники предметов и категорий.Данные пользователя:
saved_olympiads — что добавлено кнопкой «Буду писать»;stage_progress — ответы «прошёл / не прошёл» по этапам;stage_plans — какой этап на какой день запланирован в календаре;user_settings — класс, уведомления, режим для дальтоников.Хранятся на сервере, а не в браузере: напоминания отправляет бот, и он должен знать, за чем следит пользователь, даже когда мини-приложение закрыто. Вкладка «Новости» отдельно не хранится — она вычисляется из дат этапов и ответов пользователя. Единственное, что для неё запоминается, — stages.dates_added_at: момент, когда импорт расписания увидел у этапа даты, которых раньше не было. Из него строится блок «Появились даты»: этапы обновляются на месте, и без отметки прошлое состояние не узнать.
Идентификатор пользователя записывается только после проверки подписи. Таблицы создаются автоматически при старте приложения, каталог при первом старте наполняется из backend/data/.
Персональные данные из внешних систем не обрабатываются.
| Источник | Что даёт | Чего нет |
|---|---|---|
data/subjects.sample.json |
166 записей: предметы, уровни, классы, описания, организаторы | этапов и дат |
data/olympiads_schedule.json |
147 этапов с датами у 61 олимпиады | всего остального |
data/olympiads.sample.json |
97 этапов у 20 олимпиад, другой источник | предмета и уровня |
Первый файл наполняет каталог, второй доставляет в него даты. До появления второго «Поиск» работал на каталоге, а «Календарь» и «Новости» — на третьем файле, чьи записи предмету не принадлежат и потому в интерфейсе не видны. Теперь даты приходят прямо в каталожные олимпиады, и обе вкладки работают на тех же записях, что и поиск.
Расписание снимается с olimpiada.ru отдельным парсером (tools/olympiad_schedule_parser.py) и кладётся в базу командой python -m scripts.import_schedule. Связать его с каталогом напрямую нечем: парсер знает олимпиады по activity_id olimpiada.ru, а база — по id из каталога. Соответствие берётся из data/subjects.olimpiada.json — это тот же каталог, размеченный идентификаторами olimpiada.ru. Импортируется он не как каталог, а только как таблица соответствия.
Года в расписании на сайте нет — парсер угадывает его от даты запуска, и у диапазона год считается один раз, по концу: иначе «20 авг…22 сен», разобранный в сентябре, получал начало на год позже конца. Разбор дат проверяют тесты рядом с парсером (cd tools && python -m pytest), а файлы, снятые до исправления, импорт выправляет сам.
Расписание есть не у всех: на olimpiada.ru его нет у 85 олимпиад из 146. Записи разных источников различаются полем source и друг другу не мешают.
Парсер размечает сроки четырьмя способами, и каждый рисуется в календаре по-своему. В базе для этого нет отдельного поля: всё выражено тем, какие из дат заполнены, а обратно значение вычисляет presentation.date_precision и отдаёт в API полем date_precision.
| Значение | starts_on |
ends_on |
Как показывать |
|---|---|---|---|
range |
начало | конец | непрерывная полоса цвета предмета |
exact |
день | — | кружок в цвет олимпиады |
until |
— | дедлайн | полоса, набирающая непрозрачность к последнему дню |
unknown |
— | — | в календаре не показывать |
Таблица — о том, как даты лежат в базе. Наружу их отдаёт presentation.stage_bounds в полях парсера: у exact конец равен началу, поэтому карточка этапа и календарь показывают одни и те же даты.
Вычисление, а не хранение, выбрано намеренно: значение не может разойтись с датами, по которым посчитано. Дата с точностью до месяца попадает в unknown — конкретного дня у неё нет, а календарь размечает дни; сама точность при этом остаётся в start_precision и raw_date_range.
Все выгрузки лежат в backend/data/ и едут в образе — проверка решения не требует ручного подкладывания файлов. При первом старте бэкенд сам импортирует их в пустой каталог (scripts.seed). Обновить данные в уже наполненной базе — те же импорты по отдельности:
docker compose run --rm backend python -m scripts.import_subjects
docker compose run --rm backend python -m scripts.import_schedule
docker compose run --rm backend python -m scripts.import_catalog
import_schedule не пропускайте: без него у олимпиад каталога нет дат, и «Календарь» с «Новостями» пусты.
Импорт идемпотентен: повторный запуск обновляет записи на месте, а не дублирует их. Обновление на месте принципиально — прогресс пользователя по этапам ссылается на строки таблицы stages, и пересоздание обнуляло бы его при каждом обновлении данных.
Данные собраны парсером с postupi.online и являются срезом на момент выгрузки. Дата последней проверки записи отдаётся в поле source_checked_at.
1. Проверка интерфейса локально без MAX
BOT_TOKEN=local-test-token-not-for-max Это не действующий токен MAX. Он используется только для подписи и проверки локальных тестовых данных.
docker compose up -d --build
docker compose run --rm backend python -m scripts.make_launch_data --curl
Выполните js:
sessionStorage.setItem( “localInitData”, “СТРОКА_ИЗ_КОМАНДЫ_ОТ_auth_date=_ДО_КОНЦА_hash” ); location.reload();
Скопируйте строку без префикса Authorization: tma , сохранив URL-кодирование.
Интерфейс работает от имени тестового пользователя. Доступны локальные функции приложения; настоящие сообщения бота и интеграция с MAX проверяются в действующем мини-приложении.
2. Живость сервиса
curl http://localhost:8080/health
Тот же ответ и напрямую, в обход nginx: http://localhost:8000/health. Мини-приложение открывается на http://localhost:8080/ — вне MAX у него нет строки запуска, поэтому данные в нём не загрузятся; API с подписью проверяется на шаге 6.
Ожидается:
{"status":"ok","env":"development","bot_configured":true,"webhook_secret_set":true}
3. Тесты
docker compose --profile test run --rm tests
Проверяются валидные данные запуска, подделанный идентификатор, чужой токен, просроченный auth_date, дубликат параметра, а также соответствие разбора официальному примеру из документации и соблюдение лимита отправки.
4. Каталог олимпиад
Наполняется сам при первом старте — в логе бэкенда:
docker compose logs backend | grep data/
Ожидается по выгрузкам: subjects.sample.json — subjects_created: 14, olympiads_created: 166; olympiads_schedule.json — 150 этапов у 62 олимпиад; olympiads.sample.json — olympiads_created: 20, stages_created: 97. Дальше список доступен по GET /api/catalog/olympiads, детали — по GET /api/catalog/olympiads/{id}; оба требуют того же заголовка Authorization: tma <initData>. Без расписания GET /api/me/calendar отдаёт пустой entries при любых сохранённых олимпиадах.
5. Пользовательский сценарий целиком
docker compose run --rm backend python -m scripts.smoke_test
Скрипт проходит механику от лица тестового пользователя: «Буду писать», планы в календаре, лимит трёх олимпиад в день, ответ «не прошёл» с блокировкой следующих этапов, новости, настройки и удаление. Ожидается провалено 0. Данные тестового пользователя удаляются после прогона.
6. Вебхук
В PUBLIC_BASE_URL уже стоит https://my-olymp.ru. Если проверяете на своей машине, подставьте туда адрес туннеля — MAX не доставит события на localhost. Туннель направляйте на порт 8080: там на одном адресе и вебхук, и мини-приложение. Затем:
docker compose run --rm backend python -m scripts.setup_webhook
Напишите боту в MAX — в логах docker compose logs -f backend появится событие, а бот ответит в чат.
| Действие | Ответ |
|---|---|
GET /api/me с корректной строкой запуска |
200 и профиль пользователя |
GET /api/me без заголовка Authorization |
401, «Нет заголовка Authorization с данными запуска» |
GET /api/me с изменённым user.id |
401, {"code": "bad_signature"} |
GET /api/me со строкой старше LAUNCH_DATA_TTL |
401, {"code": "expired"} — можно переоткрыть приложение |
POST /webhook с неверным секретом |
403, событие не обрабатывается |
POST /webhook с корректным секретом |
200 сразу, обработка — после ответа |
| Пользователь запустил бота | бот присылает приветствие с кнопкой открытия приложения |
| Отправка нескольких сообщений одному адресату | очередь выдерживает паузу ≥ 0,5 с между отправками |
Проверить сценарии с ошибками:
# просроченные данные запуска
docker compose run --rm backend python -m scripts.make_launch_data --age 999999 --curl
# запрос без авторизации
curl -i http://localhost:8000/api/me
LAUNCH_DATA_TTL (по умолчанию сутки). После истечения фронтенд должен переоткрыть мини-приложение.source_checked_at.localhost: либо сервер на my-olymp.ru, либо туннель.CORS_ORIGINS — список, а не *. В бою фронтенд и API на одном origin, и CORS не участвует; звёздочка понадобилась бы только для копии страницы на стороннем адресе.То, на чём мы уже спотыкались:
platform-api2.max.ru, старый platform-api.max.ru для них не работает;Authorization, без Bearer и не в query;GET /chats удалён, список чатов теперь получают через POST /subscriptions;A-Z a-z 0-9 _ -, с пробелами или кириллицей параметр молча вырезается;