Skip to content

Repository files navigation

Сервис скачивания и анализа файлов

Сервис забирает каталог текстовых файлов через внешнее API, складывает его локально и считает статистику по цифрам в содержимом.

Внешнее API отдаёт файлы маленькими порциями, не сообщает их общее количество и ограничивает частоту запросов, поэтому основная работа здесь — не столько подсчёт цифр, сколько аккуратный клиент: соблюдение лимитов, повторы, корректная обработка 429/403 и внятная индикация прогресса.

Возможности

Страница «Скачивание»

  • кнопка «Скачать данные» запускает фоновый процесс, который идёт до тех пор, пока ручка имён не вернёт пустой список;
  • время старта по Новосибирску, «получено N названий файлов, скачано M из N», накопительные счётчики, число запросов к внешнему API и количество ответов 429;
  • прогресс приходит через Server-Sent Events — без опроса; при недоступности SSE автоматически включается резервный опрос;
  • при бане (403) видно причину простоя и обратный отсчёт до разблокировки;
  • процесс можно остановить кнопкой.

Страница «Скачанные файлы»

  • список с именем и временем скачивания, сортировка в обе стороны, пагинация;
  • выбор файлов: точечно, все на странице, вообще все (включая те, что не показаны на текущей странице);
  • кнопка «Произвести расчёты» показывает, сколько раз встретилась каждая цифра — суммарно и по каждому файлу отдельно.

Быстрый старт

Docker

docker compose up --build

Интерфейс и API — на http://localhost:8000, Swagger — на http://localhost:8000/docs.

Локальный запуск

Нужны Python 3.11+ и Node.js 20+.

python -m venv backend/.venv
backend/.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 dev

Vite поднимется на 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. Расчёты читают файлы с диска в момент нажатия кнопки: задание требует именно разбора содержимого, а не отдачи заранее посчитанных чисел.

Собственное API

Метод Путь Назначение
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 -q

111 тестов, внешнее 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 app
cd frontend && npm run lint && npm run typecheck

Замечания

Схема базы создаётся через create_all: таблиц две и они стабильны, поэтому Alembic был бы избыточен — при переходе на Postgres миграции понадобятся.

Состояние процесса скачивания живёт в памяти одного процесса. Для этой задачи этого достаточно (параллельные запуски всё равно запрещены лимитом внешнего API); горизонтальное масштабирование потребовало бы вынести состояние в Redis или очередь задач.

About

Service that downloads a blindly paginated, rate-limited file catalogue and reports digit statistics — token-bucket client with 429/403 handling, SSE progress with polling fallback, FastAPI + React, Docker, CI

Topics

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages