Как мы сделали легковесный парсер данных из официального API госзакупок на C++

В работе с открытыми данными часто сталкиваешься с тем, что готовые решения предлагают доступ к информации, которая и так лежит в публичном API, но «в красивой обёртке» и за деньги. Меня интересовал не готовый сервис, а именно инженерная задача: как стабильно забирать большие объёмы данных из внешнего API, хранить их в реляционной БД, отдавать через REST и при этом укладываться в минимальные ресурсы.

В этом кейсе расскажу про архитектуру и ключевые технические решения, которые позволили держать всю систему на VPS с 1 ядром и 1.5 ГБ RAM.

Идея и требования

Задача была такой:

  • Регулярно получать тендеры по 44‑ФЗ и 223‑ФЗ из официального API ЕИС (zakupki.gov.ru).
  • Хранить данные в БД с возможностью поиска и фильтрации по регионам.
  • Отдавать данные через REST API с пагинацией.
  • Поддерживать дополнительные функции (например, рассылку) как отдельные модули, которые можно включать/выключать по необходимости.

При этом система должна быть устойчивой к ошибкам API (429, 504), избегать дублей, не раздувать базу и эффективно использовать память и CPU.

Архитектура

Проект реализован как монолит с чётким разделением компонентов:

  • C++ парсер — забирает тендеры из API каждые 15 минут и сохраняет в PostgreSQL.
  • PostgreSQL — хранит тендеры, подписки и токены подтверждения.
  • C++ API (libmicrohttpd) — отдаёт тендеры через REST API с фильтрацией и пагинацией.
  • Flask API — обрабатывает подписки, вебхуки и подтверждение по email.
  • Рассылка (Python) — отправляет письма с тендерами (модуль временно отключён).
  • Nginx — проксирует запросы к API и отдаёт статику.

Почему C++ для парсера и API

На старте был выбор между Python и C++. У Python — более быстрая разработка, но выше потребление памяти (300+ МБ) и нагрузка на CPU. C++ сложнее в написании, но позволяет добиться минимального потребления ресурсов: парсер стабильно работает в ~20 МБ RAM и практически без нагрузки на CPU в простое. Для слабой конфигурации VPS это оказалось решающим фактором.

Структура данных тендера

Всё начинается с удобной структуры:

struct Tender { std::string purchase_number; std::string object_info; double max_price; std::string purchase_type; std::vector<std::string> customers; std::vector<std::string> owners; std::string published_at; std::string collecting_finished_at; std::string updated_at; std::vector<std::string> okpd2; int region; std::string currency_code; std::string getLink() const { if (purchase_type == "purchaseNotice" || purchase_type == "purchaseNoticeAE" || purchase_type == "purchaseNoticeZK" || purchase_type == "purchaseNoticeZKESMBO" || purchase_type == "purchaseNoticeAESMBO" || purchase_type == "purchaseNoticeEP") { return "https://zakupki.gov.ru/epz/order/notice/notice223/common-info.html?regNumber=" + purchase_number; } else { return "https://zakupki.gov.ru/epz/order/notice/ea20/view/common-info.html?regNumber=" + purchase_number; } } };

Защита от одновременного запуска и повторных попыток

Первые версии парсера падали на 504, создавали дубли при параллельном запуске и не выдерживали 429 Too Many Requests.

PID‑файл и flock

Чтобы не допустить одновременного запуска нескольких экземпляров, используется файловая блокировка:

#include <sys/file.h> int main() { int fd = open("/var/run/tendereye_parser.pid", O_CREAT | O_RDWR, 0666); if (flock(fd, LOCK_EX | LOCK_NB) == -1) { std::cerr << "Парсер уже запущен!" << std::endl; return 1; } // ... основной код ... flock(fd, LOCK_UN); }

Теперь можно запускать задачу через cron хоть каждую минуту: если предыдущий процесс ещё работает, новый просто завершится.

Retry‑механизм для HTTP‑запросов

Для устойчивости к временным сбоям и лимитам API реализован простой retry с задержкой:

std::string ApiClient::httpGet(const std::string& url) { CURL* curl = curl_easy_init(); std::string response; if (!curl) return ""; curl_easy_setopt(curl, CURLOPT_URL, url.c_str()); curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, WriteCallback); curl_easy_setopt(curl, CURLOPT_WRITEDATA, &response); curl_easy_setopt(curl, CURLOPT_TIMEOUT, 30L); curl_easy_setopt(curl, CURLOPT_FOLLOWLOCATION, 1L); curl_easy_setopt(curl, CURLOPT_USERAGENT, "TenderEye/1.0"); CURLcode res = curl_easy_perform(curl); long http_code = 0; curl_easy_getinfo(curl, CURLINFO_RESPONSE_CODE, &http_code); curl_easy_cleanup(curl); if (res != CURLE_OK) { std::cerr << "HTTP error: " << curl_easy_strerror(res) << std::endl; return ""; } if (http_code == 429) { std::this_thread::sleep_for(std::chrono::seconds(15)); return httpGet(url); } if (http_code != 200) { std::cerr << "HTTP code: " << http_code << std::endl; return ""; } return response; }

Это заметно снизило количество сбоев: раньше парсер падал примерно раз в неделю, теперь работает стабильно.

Парсинг JSON и главный цикл

Для работы с JSON используется библиотека nlohmann/json:

std::vector<Tender> ApiClient::parseTenders(const std::string& json_response) { std::vector<Tender> result; try { auto data = json::parse(json_response); if (!data.is_array()) { return result; } for (const auto& j : data) { Tender t; if (j.contains("purchase_number")) t.purchase_number = j["purchase_number"]; if (j.contains("object_info")) t.object_info = j["object_info"]; if (j.contains("max_price") && !j["max_price"].is_null()) t.max_price = j["max_price"]; if (j.contains("purchase_type") && !j["purchase_type"].is_null()) t.purchase_type = j["purchase_type"]; if (j.contains("customers") && j["customers"].is_array()) { t.customers = j["customers"].get<std::vector<std::string>>(); } if (j.contains("region") && !j["region"].is_null()) { t.region = j["region"].get<int>(); } result.push_back(t); } } catch (const std::exception& e) { std::cerr << "JSON parse error: " << e.what() << std::endl; } return result; }

Главный цикл последовательно загружает тендеры по двум законам:

void fetchAndSave(ApiClient& client, Database& db, const std::string& law, const std::string& endpoint) { auto tenders = client.fetchTenders(endpoint, 100, 0); int saved = 0; for (const auto& t : tenders) { if (db.saveTender(t)) { saved++; } } } int main() { Database db("host=localhost port=5432 dbname=tendereye user=tendereye password=..."); ApiClient client; fetchAndSave(client, db, "44-ФЗ", "/fz44/purchases"); fetchAndSave(client, db, "223-ФЗ", "/fz223/purchases"); return 0; }

Почему не грузим всё разом: чтобы не создавать пиковую нагрузку на API и не «положить» его. Забирая по 100 записей каждые 15 минут, удаётся держать базу актуальной без длительных полных выгрузок.

База данных и оптимизация поиска

PostgreSQL 15 развёрнут в Docker‑контейнере. Основная таблица:

CREATE TABLE tenders ( id SERIAL PRIMARY KEY, purchase_number VARCHAR(50) UNIQUE, name TEXT, price DECIMAL(15,2), region VARCHAR(10), customer_name TEXT, publish_date TIMESTAMP, link TEXT, purchase_type VARCHAR(50), created_at TIMESTAMP DEFAULT NOW(), updated_at TIMESTAMP DEFAULT NOW() ); CREATE INDEX idx_publish_date ON tenders(publish_date DESC); CREATE INDEX idx_region ON tenders(region);

Полнотекстовый поиск вместо ILIKE

Изначально использовался ILIKE '%строительство%', но на 500 000 тестовых записей запрос выполнялся 4 секунды. Решение — перейти на полнотекстовый поиск с GIN‑индексом:

CREATE INDEX idx_search_gin ON tenders USING gin(to_tsvector('russian', name || ' ' || purchase_number));

Пример запроса:

SELECT * FROM tenders WHERE to_tsvector('russian', name) @@ to_tsquery('russian', $1);

Поиск ускорился примерно в 20 раз. Да, теперь нельзя искать по произвольной подстроке, но это разумный компромисс для слабой конфигурации.

UPSERT для предотвращения дублей

Чтобы не плодить дубли и обновлять существующие записи, используется ON CONFLICT:

INSERT INTO tenders (...) VALUES (...) ON CONFLICT (purchase_number) DO UPDATE SET name = EXCLUDED.name, price = EXCLUDED.price, updated_at = NOW();

Очистка старых записей

База не должна расти бесконечно. Cron‑скрипт ежедневно удаляет тендеры старше 30 дней:

docker exec -i tendereye_db psql -U tendereye -d tendereye -c " DELETE FROM tenders WHERE publish_date < NOW() - INTERVAL '30 days'; "

Так удаётся удерживать размер базы на уровне 10–12 тысяч записей даже при активном парсинге.

C++ API и пул соединений

Первая версия API открывала новое соединение к БД на каждый HTTP‑запрос. При 10 RPS PostgreSQL быстро упирался в лимит соединений.

Решение — реализовать пул из 5 персистентных соединений:

class DbPool { std::vector<std::unique_ptr<pqxx::connection>> pool; std::mutex mtx; public: DbPool(size_t size, const std::string& conn_str) { for (size_t i = 0; i < size; ++i) { pool.push_back(std::make_unique<pqxx::connection>(conn_str)); } } std::unique_ptr<pqxx::connection> get() { std::lock_guard<std::mutex> lock(mtx); auto conn = std::move(pool.back()); pool.pop_back(); return conn; } void release(std::unique_ptr<pqxx::connection> conn) { std::lock_guard<std::mutex> lock(mtx); pool.push_back(std::move(conn)); } };

Под нагрузкой в 50 одновременных пользователей система стабильно держит 5 активных соединений к БД.

Безопасность и защита

Защита от SQL‑инъекций

Вместо ручной конкатенации и экранирования лучше использовать параметризованные запросы. Это стандарт безопасности для работы с БД и гарантированно защищает от инъекций.

Ограничение частоты запросов (rate limiting) в Nginx

Для защиты от спама на уровне веб‑сервера настроен rate limit:

limit_req_zone $binary_remote_addr zone=subscribe:10m rate=5r/m; location /api/subscribe { limit_req zone=subscribe burst=5 nodelay; proxy_pass http://localhost:5000; }

Защита на стороне Flask

Дополнительно реализована простая защита от брутфорса по IP:

request_log[ip] = [t for t in request_log[ip] if t > now - timedelta(minutes=1)] if len(request_log[ip]) >= 3: return jsonify({"error": "Слишком много попыток"}), 429

Сборка и деплой

Компиляция C++ на слабом VPS (1 ядро) занимает до 15 минут и полностью загружает CPU. Чтобы ускорить деплой, сборка вынесена в GitHub Actions (образ ubuntu:22.04). Готовый бинарник кладётся в artifacts, а на VPS просто скачивается через wget и помещается в /usr/local/bin/. Так удалось сократить время деплоя до ~30 секунд и не держать компилятор на сервере.

Мониторинг и статистика

Для быстрой проверки состояния системы используется простой bash‑скрипт, который выводит:

  • общее количество тендеров;
  • динамику по дням;
  • диапазон цен;
  • число активных подписок.

Это позволяет оперативно понять, работает ли парсер и наполняется ли база.

Выводы

Этот кейс показывает, как можно построить легковесную систему для регулярного получения данных из внешнего API, их хранения и выдачи через REST, не раздувая инфраструктуру. Ключевые моменты:

  • Устойчивость к ошибкам API: retry‑логика и экспоненциальные задержки.
  • Защита от дублей и контроль размера БД: ON CONFLICT и регулярная очистка старых записей.
  • Эффективный поиск: GIN‑индексы и полнотекстовый поиск.
  • Оптимизация соединений: пул соединений вместо открытия нового на каждый запрос.
  • Деплой без компиляции на сервере: сборка в CI/CD и доставка бинарника.

Описанные подходы применимы к любым проектам, где нужно стабильно забирать данные из API, хранить в реляционной БД и отдавать через REST, укладываясь в минимальные ресурсы.

Посмотреть работу сервиса можно здесь:

❓ Ответы на возможные вопросы

А что за баги в API?

Иногда API возвращает тендер, которого нет на сайте ЕИС. На сайте вывешена плашка:

ℹ Внимание: Если при переходе на сайт госзакупок вы видите сообщение «Запрашиваемая страница не существует» — значит тендер был удалён, отозван на редактирование или скрыт заказчиком. Мы показываем только то, что получаем от официального источника.

Были случаи, когда регион в API не совпадал с реальным. Мы не чиним чужие ошибки — мы показываем то, что получили.

2