MAX-olymp

API для фронтенда

Всё, что нужно, чтобы начать работать с бэкендом. Код бэкенда читать не требуется.

Адрес

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.