Всё, что нужно, чтобы начать работать с бэкендом. Код бэкенда читать не требуется.
https://my-olymp.ru
Постоянный домен. Туннель ngrok больше не используется, старый адрес
hatching-landside-crown.ngrok-free.dev мёртв.
Само мини-приложение отдаётся с того же адреса, так что запросы из
него уходят на свой же origin: preflight не делается, CORS не участвует.
Если открываете копию страницы с другого адреса (GitHub Pages, файл с
диска), origin нужно добавить в CORS_ORIGINS на сервере.
Локально docker compose up --build поднимает ту же раскладку на
http://localhost:8080: статика из frontend/ и прокси в локальный
бэкенд. frontend/api.js поэтому ходит не на зашитый домен, а на свой
origin — на сервере это https://my-olymp.ru, локально — localhost:8080.
Исключение одно: копия на GitHub Pages идёт в https://my-olymp.ru.
Новые запросы делайте через Api из api.js, а не через fetch с
адресом сервера — иначе локальная копия будет ходить в боевой бэкенд.
Живая схема со всеми полями и возможностью подёргать эндпоинты — /docs,
но только на локальном запуске (http://localhost:8080/docs): на
сервере стоит APP_ENV=production, и схема наружу не отдаётся.
Authorization: tma <window.WebApp.initData>
Строка запуска, которую MAX отдаёт мини-приложению. Берётся из
window.WebApp.initData и передаётся декодированной ровно один раз.
Бэкенд проверяет её подпись на каждом запросе: без неё придёт 401.
Идентификатор пользователя сервер берёт только отсюда, поэтому слать
user_id в теле запроса не нужно — он будет проигнорирован.
Заголовок ngrok-skip-browser-warning был нужен только туннелю. Он
удалён из frontend/api.js, добавлять его обратно не нужно.
const API = "https://my-olymp.ru";
async function api(path, options = {}) {
const response = await fetch(`${API}${path}`, {
...options,
headers: {
"Authorization": `tma ${window.WebApp.initData}`,
...options.headers,
},
});
if (response.status === 401) {
const { detail } = await response.json();
// Строка запуска протухла — мини-приложение нужно переоткрыть,
// а не показывать пользователю ошибку.
if (detail?.code === "expired") {
window.WebApp.close();
return;
}
throw new Error("Не авторизован");
}
if (!response.ok) throw new Error(`HTTP ${response.status}`);
return response.json();
}
GET /api/meПрофиль текущего пользователя. Хороший первый запрос: проверяет всю цепочку авторизации целиком.
GET /api/catalog/olympiadsСписок олимпиад.
| Параметр | Значения | По умолчанию |
|---|---|---|
q |
поиск по названию и предмету | — |
subject_id |
фильтр по предмету | — |
grade |
класс участника, 1–11 | — |
level |
1, 2 или 3 | — |
sort |
urgency, name, level |
urgency |
limit |
1–200 | 50 |
offset |
сдвиг для пагинации | 0 |
Ответ: { items, total, limit, offset }.
limit по умолчанию 50, а записей в каталоге больше двухсот.
Потолок подняли до 500, так что весь каталог выгружается одним запросом:
?limit=500. Это то, что нужно для локальных поиска и сортировки и для
офлайн-кэша.
Для подписи «сколько олимпиад по предмету» выкачивать каталог не надо —
есть olympiad_count в справочнике предметов.
grade оставляет олимпиады, подходящие классу: «7-11 классы» попадут в
выдачу для 7–11 и не попадут для 6. Записи, у которых источник классов не
знает, остаются в выдаче — неизвестно не значит «не подходит».
GET /api/catalog/olympiads/{id}Одна олимпиада со всеми этапами. 404, если такой нет.
GET /api/catalog/subjectsСправочник предметов — берите его отсюда, а не из константы SUBJECTS
в app.js:
[{ "id": 8, "name": "Математика", "color": "#8FDCE0",
"short_code": "МАТ", "olympiad_count": 5 }]
Идентификаторы и цвета совпадают с тем списком один в один, так что подменять ничего не нужно. Но если справочник поменяется, сервер и интерфейс разъедутся только при захардкоженной копии.
short_code — для режима дальтоников: «МАТ» рядом с цветным кружком.
olympiad_count — готовое число для подписи «5 олимпиад». С
параметром ?grade=6 считается только то, что подходит шестикласснику.
Считает сервер одним запросом: если считать на клиенте по загруженной
странице каталога, числа выйдут заниженными.
Важная особенность данных: уровень по перечню РСОШ зависит от предмета.
Московская олимпиада по астрономии — I уровня, по биологии — II. Поэтому
каталог отдаёт отдельную запись на каждую пару «олимпиада + предмет»:
«Олимпиада СПбГУ» встретится четырнадцать раз, с разными id, subject
и level.
Для вкладки «Поиск» это ровно то, что нужно — там олимпиады и так показываются внутри предмета. Но поиск по названию без фильтра вернёт несколько одноимённых карточек, и предмет на карточке показывать обязательно, иначе они будут выглядеть дублями.
Добавление в «Мои олимпиады» тоже идёт по предмету: «Московская по математике» и «Московская по физике» — разные записи.
| Поле | Смысл |
|---|---|
subject |
{ id, name, color, short_code } — всё для карточки сразу |
subject_id |
то же, отдельным полем |
level |
лучший уровень: 1, 2 или 3 — по нему идёт сортировка |
levels |
все уровни по этому предмету, иногда их два: [2, 3] → «II–III ур.» |
grades |
классы строкой: "7–11" или null, если источник не знает |
summary |
описание олимпиады |
official_url |
сайт олимпиады |
organizers |
массив коротких названий, не больше трёх: ["МГУ", "МФТИ"] (только в деталях) |
source_url |
ссылка на страницу источника, может быть null |
grades приходит null, когда источник классов не знает, а
organizers — пустым массивом. Показывать заглушку пользователю не
нужно, её уже отфильтровал сервер.
Источник знает точную дату не всегда. Из 97 этапов у 65 известен день, у 32 — только месяц и год. Поэтому каждый этап приходит так:
{
"name": "Финальный этап",
"kind": "final",
"starts_on": "2027-03-01",
"start_precision": "month",
"days_until_start": null,
"plannable": false,
"raw_date_range": "мар 2027",
"status": "upcoming"
}
Если days_until_start равен null — таймер показывать нельзя.
Вместо него выводится raw_date_range как есть: «мар 2027». Поле
starts_on в этом случае содержит первое число месяца — оно нужно
только для сортировки, показывать его пользователю нельзя.
Если plannable равен false — этап нельзя поставить в календарь по
дням: конкретной даты у него нет.
Для этапов с точным днём приходит число дней и plannable: true —
таймер и календарь работают как обычно.
| Поле | Значения |
|---|---|
kind |
registration, qualifying, final, other |
status |
upcoming, active, finished, unknown |
start_precision |
day, month, null |
kind определяется по названию этапа и является предположением.
other означает «определить не удалось» — таких сейчас 10 из 97, для
них лучше показывать название как есть, без ярлыка типа.
Все пути ниже начинаются с /api/me и требуют тех же двух заголовков.
Пользователь определяется по подписи — передавать его id не нужно.
| Действие в интерфейсе | Запрос |
|---|---|
| Кнопка «Буду писать» | POST /api/me/olympiads/{id} |
| Кнопка показывает «✓ Добавлено» | поле saved: true в каталоге |
| Вкладка «Мои олимпиады» | GET /api/me/olympiads |
| Подробный просмотр в «Моих» | GET /api/me/olympiads/{id} |
| «Удалить из моих олимпиад» | DELETE /api/me/olympiads/{id} |
| «Я прошёл(а) в следующий этап» | PUT /api/me/stages/{stage_id}/result с {"result": "passed"} |
| «Я не прошёл(а)» | то же с {"result": "failed"} |
| Отменить ответ | DELETE /api/me/stages/{stage_id}/result |
| Полосы этапов в календаре | GET /api/me/calendar?date_from=…&date_to=… |
| Окно «В этот день я буду писать…» | GET /api/me/calendar/{день} |
| Галочка в этом окне | PUT /api/me/stages/{stage_id}/plan с {"planned_on": "2026-09-23"} |
| Снять галочку | DELETE /api/me/stages/{stage_id}/plan |
| Вкладка «Новости» | GET /api/me/news |
| Настройки | GET /api/me/settings, PATCH /api/me/settings |
PUT/DELETE на result и plan отвечают полным состоянием
олимпиады — тем же, что GET /api/me/olympiads/{id}. Это сделано
специально: ответ «не прошёл» блокирует все следующие этапы и снимает их
с календаря, и интерфейсу нужно сразу это увидеть, без второго запроса.
К полям этапа из каталога добавляются:
| Поле | Смысл |
|---|---|
result |
passed, failed или null |
planned_on |
дата из календаря — «Запланировано на _ число» |
locked |
этап закрыт ответом «не прошёл» на одном из предыдущих |
awaiting_answer |
этап завершён, пора спросить «прошёл ли дальше» |
plan_window_start, plan_window_end |
в какие дни этап можно поставить в календарь |
У этапа в GET /api/me/olympiads/{id} (и в каталоге) даты совпадают с
тем, что для него же отдаёт GET /api/me/calendar:
| Этап | Календарь | То же поле в олимпиаде |
|---|---|---|
| форма сроков | date_precision |
date_precision |
| начало | start_stage |
starts_on |
| конец | end_stage |
ends_on |
| в какие дни можно запланировать | window_start … window_end |
plan_window_start … plan_window_end |
| на какой день запланирован | planned_on |
planned_on |
date_precision |
starts_on |
ends_on |
|---|---|---|
exact |
день | тот же день |
range |
начало | конец |
until |
null |
срок |
unknown |
null или первое число месяца |
null или первое число месяца |
Раньше у exact приходил ends_on: null — теперь в нём тот же день,
что в starts_on, и end_precision: "day". Проверять «однодневный ли
этап» по ends_on == null больше нельзя: смотрите на date_precision.
У until окно планирования начинается за 14 дней до срока, но
starts_on остаётся null — это не начало этапа.
У самой олимпиады есть eliminated: true, если пользователь ответил
«не прошёл», — по механике она серая и уходит вниз списка. Сервер уже
отдаёт такие олимпиады последними.
GET /api/me/olympiads?sort=urgency — варианты urgency, level,
subject. Фильтр по предметам — несколько параметров подряд:
?subject_id=1&subject_id=4. «Сбросить фильтр» — запрос без них.
GET /api/me/calendar?date_from=2026-09-28&date_to=2026-11-01 отдаёт
этапы сохранённых олимпиад, которые задевают этот период. Для отрисовки
удобнее всего olympiads — те же поля, что у парсера расписаний:
{
"date_from": "2026-09-28",
"date_to": "2026-11-01",
"olympiads": [
{
"olympiad_id": 48,
"name": "Олимпиада школьников «Физтех»",
"level": 1,
"subject_id": 5,
"subject": "Физика",
"color": "#F5E6C8",
"stages": [
{
"stage_id": 45,
"name_stage": "Регистрация на первый тур",
"kind": "registration",
"date_precision": "until",
"start_stage": null,
"end_stage": "2026-10-10",
"planned_on": null
}
]
}
],
"entries": ["…те же этапы плоским списком, см. ниже"]
}
date_precision |
Поля | Как рисовать |
|---|---|---|
exact |
start_stage = end_stage |
кружок в этот день |
range |
start_stage … end_stage |
сплошная полоса |
until |
только end_stage |
полоса, набирающая цвет к сроку |
Этапов с unknown в календаре нет: у них не известен ни один день.
subject и color — название и цвет предмета, для легенды и полос.
entries — плоский список тех же этапов с окном планирования
window_start … window_end (у until окно строится на 14 дней назад от
срока). Он остаётся для совместимости.
GET /api/me/calendar/2026-09-23 отвечает:
{
"day": "2026-09-23",
"limit": 3,
"selected_count": 2,
"past": false,
"options": [
{
"stage_id": 41,
"olympiad_name": "НТО по профилю «Искусственный интеллект»",
"selected": true,
"planned_on_other_day": null,
"selectable": true
}
]
}
| Поле варианта | Как показать |
|---|---|
selected: true |
галочка стоит |
planned_on_other_day не null |
серый, со значком замка — уже выбран на другой день |
selectable: false |
галочку поставить нельзя: лимит, замок или прошедший день |
Лимит — три олимпиады в день, он проверяется на сервере. Даже если
интерфейс пропустит четвёртую галочку, запрос вернёт day_limit_reached.
GET /api/me/news отдаёт шесть списков:
| Ключ | Блок |
|---|---|
urgent |
«Срочно» |
soon |
«Скоро» |
later |
«Позже» |
awaiting_answer |
«Ожидает ответа от тебя» — с кнопками |
finished |
«Завершено» |
dates_added |
«Появились даты» |
Ключей wait и done нет: «Ожидает ответа» — это awaiting_answer,
«Завершено» — finished. У каждой новости готовый текст в message,
например «„Отборочный этап“ начинается через 3 дня».
Поля новости — одни для всех списков:
{
"olympiad_id": 405,
"olympiad_name": "Ломоносов",
"subject_id": 2,
"subject": "Химия",
"level": 1,
"stage_id": 882,
"stage_name": "Отборочный этап",
"message": "Подтверди, прошёл ли ты в следующий этап",
"badge": "Ожидает ответа",
"date_range": "1–16 сентября 2026",
"plan_window_end": "2026-09-16",
"created_at": null,
"event_date": "2026-09-16",
"raw_date_range": "1...16 сен",
"result": null
}
| Поле | Смысл |
|---|---|
subject |
название предмета строкой |
badge |
текст бейджа; у urgent, soon, later, finished — null |
date_range |
даты этапа словами: «25 сентября – 15 октября 2026», «до 18 ноября 2026»; null, если известен только месяц |
plan_window_end |
у awaiting_answer — последний день окна планирования, он уже прошёл |
created_at |
у dates_added — когда появились даты |
«Ожидает ответа от тебя» (awaiting_answer) — этапы из «Моих
олимпиад», у которых окно планирования закончилось (plan_window_end
раньше сегодня), а ответа «прошёл / не прошёл» нет. Кнопки шлют
PUT /api/me/stages/{stage_id}/result; после ответа этап уходит из
списка — достаточно перезапросить новости. Сверху — те, что ждут
дольше всех. Регистрации здесь нет (спрашивать «прошёл ли регистрацию»
незачем), этапов после ответа «не прошёл» — тоже: отвечать на них
сервер не даст.
«Появились даты» (dates_added) — у этапа было «Уточняется», а
теперь есть день, или у олимпиады без расписания оно появилось. Считается
только то, что случилось, пока олимпиада уже была в «Моих»: кто добавил
её с готовыми датами, тот их и так видел. Этап уходит из блока, когда
завершится. Одна олимпиада может встретиться несколько раз — по разу на
этап. Этап с новыми датами остаётся и в своей категории, например в
soon. Порядок: сначала новые по created_at, среди одновременных —
ближайшие этапы.
Даты появляются при импорте расписания с olimpiada.ru, поэтому блок пустой, пока очередной импорт не найдёт новых дат.
Отдельно хранить новости не нужно — они вычисляются из дат этапов и ответов пользователя, поэтому всегда совпадают с календарём.
Этапы, у которых известен только месяц, в «Срочно» и «Скоро» не попадают никогда — по той же причине, по которой у них нет таймера.
{ "grade": 10, "notifications_enabled": true, "colorblind_mode": false }
PATCH меняет только переданные поля. {"grade": null} сбрасывает класс.
Класс — от 1 до 11.
Ошибка приходит в поле detail как {"code": …, "message": …}.
Переключаться нужно по code; message написан для пользователя и его
можно показать как есть.
Авторизация:
| Код | Что случилось | Что делать |
|---|---|---|
expired |
строка запуска устарела | переоткрыть мини-приложение |
bad_signature |
подпись не совпала | проверить, что initData не изменён |
not_url_decoded |
строка не декодирована | декодировать ровно один раз |
bot_token_missing |
не настроен сервер | написать бэкендеру |
Пользовательская часть:
| Код | HTTP | Когда |
|---|---|---|
not_found |
404 | нет такой олимпиады или этапа |
not_saved |
404 / 409 | олимпиады нет в «Моих» |
stage_locked |
409 | этап закрыт ответом «не прошёл» |
stage_not_started |
422 | отвечать на ещё не начавшийся этап рано |
not_plannable |
422 | у этапа нет точной даты |
date_outside_stage |
422 | день вне окна этапа |
date_in_past |
422 | день уже прошёл |
already_planned_other_day |
409 | этап уже стоит на другом дне — сначала снять ту отметку |
day_limit_reached |
409 | на день уже три олимпиады |
bad_range |
422 | некорректный период календаря |
Дат у записей из нового справочника. В каталоге сейчас два источника:
200 олимпиад по предметам — с уровнями, классами и описаниями, но без
этапов; и 20 олимпиад из выгрузки парсера — с датами этапов, но без
предмета. Поэтому «Поиск» работает на первых, а «Календарь» и «Новости» —
на вторых. Когда даты появятся в основном файле, второй источник уйдёт, и
всё сойдётся в одном месте. Для интерфейса при этом ничего не изменится:
олимпиада без этапов просто приходит с пустым stages и next_stage: null.
PUSH-уведомления работают: раз в день бот присылает сводку в чат, и
переключатель notifications_enabled её выключает. Отдельного эндпоинта
для этого нет и не нужно — достаточно сохранить настройку.
Кнопка в сводке открывает мини-приложение с меткой запуска reminder
(в приветствии бота — bot). Если захочется сразу показывать календарь
тем, кто пришёл из напоминания, метку можно прочитать из
WebApp.initDataUnsafe.start_param; бэкенд её тоже видит и хранит в
users.start_param.