Сервис забирает каталог текстовых файлов через внешнее API, складывает его локально и считает статистику по цифрам в содержимом.
Внешнее API отдаёт файлы маленькими порциями, не сообщает их общее количество
и ограничивает частоту запросов, поэтому основная работа здесь — не столько
подсчёт цифр, сколько аккуратный клиент: соблюдение лимитов, повторы,
корректная обработка 429/403 и внятная индикация прогресса.
Страница «Скачивание»
- кнопка «Скачать данные» запускает фоновый процесс, который идёт до тех пор, пока ручка имён не вернёт пустой список;
- время старта по Новосибирску, «получено N названий файлов, скачано M из N»,
накопительные счётчики, число запросов к внешнему API и количество ответов
429; - прогресс приходит через Server-Sent Events — без опроса; при недоступности SSE автоматически включается резервный опрос;
- при бане (
403) видно причину простоя и обратный отсчёт до разблокировки; - процесс можно остановить кнопкой.
Страница «Скачанные файлы»
- список с именем и временем скачивания, сортировка в обе стороны, пагинация;
- выбор файлов: точечно, все на странице, вообще все (включая те, что не показаны на текущей странице);
- кнопка «Произвести расчёты» показывает, сколько раз встретилась каждая цифра — суммарно и по каждому файлу отдельно.
docker compose up --buildИнтерфейс и API — на http://localhost:8000, Swagger — на http://localhost:8000/docs.
Нужны Python 3.11+ и Node.js 20+.
python -m venv backend/.venvbackend/.venv/Scripts/python -m pip install -e "backend[dev]"Бэкенд (из каталога backend):
cd backend && .venv/Scripts/python -m uvicorn app.main:create_app --factory --reload --port 8000Фронтенд в режиме разработки (из каталога frontend):
cd frontend && npm install && npm run devVite поднимется на http://localhost:5173 и проксирует /api на бэкенд.
Чтобы собрать всё в одно приложение на порту 8000:
cd frontend && npm run build && cp -r dist ../backend/staticПараметры читаются из переменных окружения с префиксом APP_, полный список —
в .env.example. Значения по умолчанию рассчитаны на стенд
тестового задания, так что сервис работает без настройки.
| Переменная | По умолчанию | Назначение |
|---|---|---|
APP_CATALOG_BASE_URL |
http://91.199.149.128:18001 |
адрес внешнего API |
APP_CANDIDATE_ID |
пусто | значение X-Candidate-Id; пусто — опознание по IP |
APP_RATE_LIMIT_PER_SECOND |
1.25 |
скорость запросов к внешнему API |
APP_RATE_LIMIT_BURST |
1 |
допустимый мгновенный всплеск |
APP_DOWNLOAD_CONCURRENCY |
2 |
сколько батчей качать параллельно |
APP_DISPLAY_TIMEZONE |
Asia/Novosibirsk |
пояс для отображения времени |
Смена APP_CANDIDATE_ID — самый простой способ начать скачивание каталога
с нуля: прогресс на стенде привязан к этому идентификатору.
backend/app
├── api/ HTTP-ручки: скачивание, файлы, расчёты
├── clients/ клиент внешнего API и ограничитель частоты
├── services/ оркестратор скачивания, хранилище, репозитории, статистика
├── config.py настройки
├── models.py таблицы: скачанные файлы и история запусков
└── main.py сборка приложения, раздача фронтенда
frontend/src
├── api/ типы и обёртка над HTTP
├── hooks/ подписка на прогресс (SSE), модель выбора файлов
├── pages/ страница скачивания и страница файлов
└── components/ переиспользуемые элементы интерфейса
Метаданные файлов лежат в SQLite, содержимое — на диске в data/files.
Расчёты читают файлы с диска в момент нажатия кнопки: задание требует именно
разбора содержимого, а не отдачи заранее посчитанных чисел.
| Метод | Путь | Назначение |
|---|---|---|
POST |
/api/download/start |
запустить скачивание |
POST |
/api/download/cancel |
остановить скачивание |
GET |
/api/download/status |
текущее состояние |
GET |
/api/download/stream |
поток обновлений прогресса (SSE) |
GET |
/api/files |
список файлов с пагинацией и сортировкой |
GET |
/api/files/{id}/content |
содержимое файла |
POST |
/api/statistics |
расчёты по выбранным файлам |
GET |
/api/health |
проверка живости |
Ограничение частоты. Стенд не документирует свои лимиты, поэтому они были
измерены: пять запросов проходят мгновенно, шестой отдаёт 429 с
Retry-After: 3. Счётчик общий для всех трёх ручек и ведётся по IP-адресу.
Отсюда следует неочевидное требование к клиенту. Token bucket за окно T
пропускает не больше burst + rate * T запросов, значит параметры обязаны
удовлетворять burst + 3 * rate <= 5. Первая версия с burst = 4 и
rate = 1.4 выглядела безобидно, но давала 4 + 4.2 = 8.2 запроса за первые
три секунды — и стабильно ловила 429 на старте. Значения по умолчанию
(burst = 1, rate = 1.25) дают 4.75 и на полном проходе каталога не
приводят ни к одному 429. Инвариант закреплён тестом, чтобы его не «ускорили»
обратно.
Общая пауза вместо повтора в одиночку. Получив 429, клиент не просто
повторяет свой запрос, а останавливает все конкурентные задачи до истечения
Retry-After. Иначе остальные воркеры продолжили бы долбить сервер, который
только что попросил притормозить, и заработали бы блокировку на полчаса.
При 403 ожидание выносится наверх — процесс показывает причину простоя и
время до разблокировки, а не замирает молча.
Экономия запросов. Отметка о скачивании отправляется одной пачкой на всю порцию имён: на девять файлов уходит пять запросов (1 имена + 3 скачивания + 1 отметка) вместо семи, если отмечать каждый батч отдельно. При лимите около полутора запросов в секунду это заметная разница на полном проходе каталога.
Возобновляемость. Файлы, которые уже лежат на диске, повторно не скачиваются — только отмечаются на стенде. Это чинит ситуацию, когда прошлый запуск успел сохранить файлы, но упал до отметки: иначе лимит тратился бы на повторную закачку тех же данных.
Выбор «вообще все». Наивная реализация потребовала бы выкачать все
страницы списка, чтобы собрать идентификаторы, и отправить их тысячами в теле
запроса. Вместо этого фронтенд передаёт флаг select_all и список исключений,
так что снятие галочки с отдельной строки продолжает работать.
Один процесс скачивания. Одновременный запуск запрещён: внешнее API считает запросы по IP, поэтому два процесса лишь мешали бы друг другу. По той же причине в Docker поднимается ровно один воркер uvicorn.
Время. Внутри всё хранится в UTC, перевод в новосибирский пояс происходит
на границе — при отдаче наружу. Фронтенд намеренно не приводит время к поясу
браузера, иначе «время по НСК» превратилось бы в местное время пользователя.
За базой часовых поясов тянется зависимость tzdata: в Windows и в slim-образах
Linux системной базы нет.
cd backend && .venv/Scripts/python -m pytest -q111 тестов, внешнее API подменяется поддельным стендом, часы и сон — управляемыми, поэтому весь набор проходит примерно за три секунды.
Покрыты, в частности:
- арифметика ограничителя частоты и инвариант
burst + 3 * rate <= 5; - поведение при
429,403, сетевых сбоях,5xxи битом ZIP; - полный цикл скачивания, отметка пачкой, пропуск уже скачанных файлов, отмена и восстановление прерванного запуска;
- пагинация, сортировка, три режима выбора и совпадение общей статистики с суммой постраничной.
Сервис прогонялся против рабочего стенда до полного исчерпания каталога:
| Показатель | Значение |
|---|---|
| Скачано файлов | 1234 (каталог исчерпан, ручка имён вернула пустой список) |
| Запросов к внешнему API | 761 — около 1.6 файла на запрос |
Ответов 429 |
0 |
Блокировок 403 |
0 |
| Длительность | ~10 минут (упирается в лимит частоты, а не в наш код) |
| Расчёт по всем 1234 файлам | ~0.24 с |
Результат расчётов сверялся с независимым пересчётом прямо по файлам на диске: 617 000 символов, все — цифры, суммы по файлам сходятся с общими.
Линтеры и типы:
cd backend && .venv/Scripts/python -m ruff check app tests && .venv/Scripts/python -m mypy appcd frontend && npm run lint && npm run typecheckСхема базы создаётся через create_all: таблиц две и они стабильны, поэтому
Alembic был бы избыточен — при переходе на Postgres миграции понадобятся.
Состояние процесса скачивания живёт в памяти одного процесса. Для этой задачи этого достаточно (параллельные запуски всё равно запрещены лимитом внешнего API); горизонтальное масштабирование потребовало бы вынести состояние в Redis или очередь задач.