Парсил zakupki.gov.ru без API — расскажу что узнал

от автора

Делаю pet-проект — приложение, чтобы свайпать тендеры в телефоне и видеть AI-скоринг заказчика. Идея простая: свайпнул, посмотрел «ваш шанс — высокий/средний/низкий», дальше принимаешь решение, лезть в этот тендер или нет.

Чтобы скоринг был не из воздуха, нужно собрать всю историю заказчика — какие контракты у него были, как он платил, какой типичный дисконт от стартовой цены. Источник один — ЕИС zakupki.gov.ru. И вот тут я наступил на грабли которыми и хочу поделиться.

Если кто тоже думает парсить госзакупки — пост сэкономит вам пару недель.

Что у Минфина есть из официального

Перед тем как идти парсить HTML я честно попробовал три «официальных» способа. Все три отвалились.

Первый — SOAP-API на int44.zakupki.gov.ru. Документация есть, схемы XSD есть, метод getCustomerDocs есть. Чтобы подключиться нужна квалифицированная ЭЦП юр.лица. Я физлицо. не подходит.

Второй — FTP-сервер с XML-выгрузками. Был. Закрыли с 1 января 2025, в объяснении что-то про оптимизацию инфраструктуры. Видел старые статьи на Хабре где люди этим пользовались — теперь не пользуются.

Третий — портал opendata.gov.ru. Звучит как обещание счастья, но это просто красивый каталог. Заходишь, видишь «Реестр контрактов 44-ФЗ», кликаешь — попадаешь на страницу с описанием датасета. Ссылок на скачать ничего нет. Кнопка «API» ведёт обратно на сам же ЕИС, замкнутый круг.

Я много времени убил. Делал curl на каждый passport, грепал по zip/xml/csv. Ничего не работало. Если кто-то реально через них что-то скачал — буду благодарен в комментариях, может я не догадался до чего то.

Остаётся то с чем у любого нормального человека всё работает — публичные HTML-страницы в браузере.

Какие страницы вообще нужны

Пять штук:

  1. /epz/order/extendedsearch/results.html?fz44=on&regions=... — лента тендеров по 44-ФЗ

  2. То же самое с fz223=on — лента 223-ФЗ

  3. /epz/order/notice/{type}/view/common-info.html?regNumber=X — открытие конкретного тендера 44-ФЗ

  4. /epz/contract/contractCard/common-info.html?reestrNumber=Y — общая инфа по контракту

  5. /epz/contract/contractCard/document-info.html?reestrNumber=Y — там лежат акты приёмки

Все рендерятся серверно, SPA нет, JS не требуется. Обычный парсинг через SwiftSoup (Swift-порт jsoup) или любой аналог в вашем стеке.

Чем парсю

Я пишу на Swift, бэкенд на Vapor 4 — потому что знаю Swift и Vapor нормальный фреймворк без бойлерплейта Spring и без какашек Express. PostgreSQL для тендеров и контрактов, Redis для кэша HTML-страниц и mapping orgId→ИНН.

Флоу грубо такой: юзер открывает ленту → бэк дёргает feed-страницу ЕИС → парсит HTML в список карточек → достаёт ИНН заказчиков → джойнит с табличкой customer_scores → возвращает клиенту. Cold-cache около 100мс, hot-cache — 5-15мс. С учётом сетки до ЕИС.

Дальше — где были проблемы.

44-ФЗ и 223-ФЗ совершенно разные

Сначала я думал что страницы для 44-ФЗ и 223-ФЗ — просто разные параметры в одном URL.

У них:

  • Разная вёрстка, классы и айдишники не совпадают

  • Идентификатор заказчика по-разному: 44-ФЗ даёт ?organizationId=12345, 223-ФЗ — ?agencyId=618991

  • Поиск контрактов работает по разному URL вообще: 44-ФЗ через /contract/search/results.html?customer={inn}, 223-ФЗ — /organization/view223/info.html?agencyId={X} и там JSON совершенно другой формы

В итоге два полностью раздельных парсера за общим интерфейсом. И если что-то сломается в вёрстке — два места править. Но другого пути нет.

Идентификатор заказчика — три формата

Это была самая болезненная часть.

ЕИС использует три разных идентификатора для одного и того же юр.лица:

  • Настоящий ИНН — 10 цифр у юр.лиц, 12 у ИП. Например 7708410783

  • organizationId — внутренний 5-8-значный ID, например 2225253

  • organizationCode — 11-значный код, например 01795000003

Я этого не знал когда начинал. Делал парсер, всё нормально работало, потом в какой-то момент стал замечать что у части тендеров на ленте AI-скоринг «нет данных». Лезу в БД — там скоринг есть! Под другим ключом.

Оказалось что feed-парсер вытаскивает organizationCode (то что в URL карточки), а в табличку customer_scores я писал по реальному ИНН (то что приходит с детальной страницы). При сопоставлении промах.

Когда конкретно ЕИС добавил organizationCode — я не отследил, может быть постепенный rollout по регионам. Просто заметил что в фид-листинге сейчас могут прилетать оба формата и оба нужно обрабатывать.

В итоге сделал bridge через Redis. Идея простая: как только хоть один раз резолвим связку “orgCode = real_inn” (например после открытия деталки тендера), записываем mapping под всеми возможными ключами:

private func writeAllAliases(_ ids: CustomerIds) async throws {    let json = try JSONEncoder().encode(ids).asString()    // forward    try await redis.setex("customer_ids:" + ids.inn, 30*24*3600, json)    // reverse под orgId    if let orgId = ids.organizationId {        try await redis.setex("customer_ids_byorg:v2:\(orgId)", 30*24*3600, json)    }    // reverse под orgCode    if let orgCode = ids.organizationCode {        try await redis.setex("customer_ids_byorg:v2:\(orgCode)", 30*24*3600, json)    }}

И потом при поиске scoring канонизируем любой входной идентификатор через эту таблицу:

for inn in inns {    if inn.count == 10 || inn.count == 12 {        // уже реальный ИНН, оставляем как есть        canonicalByOriginal[inn] = inn    } else {        // orgId или orgCode — резолвим из Redis        let cached: CustomerIds? = try? await readJson("customer_ids_byorg:v2:\(inn)")        canonicalByOriginal[inn] = cached?.inn ?? inn    }}

До этого фикса в БД находилось примерно 5% запрошенных скорингов. После — около 85%. Остальные 15% — холодные заказчики, которых никто ещё не открывал, для них mapping не создавался, обогащаются ленивым резолвом.

Кстати с префикса v2: сразу советую начинать. Я сначала писал без префикса, потом понадобилось менять формат и старые ключи болтаются с устаревшими данными. Версионирование решает.

НМЦК не там где я думал

Долго разбирался с парсером для страницы /contract/contractCard/common-info.html?reestrNumber=X. URL называется “info.html”, в голове логично — там должна быть инфа о контракте. Достаю поля по их подписям, парсю — НМЦК не нахожу. Дата электронного акта приёмки — тоже не нахожу.

Психанул, открыл DevTools, начал тыкать другие страницы. Выяснил:

НМЦК (начальная максимальная цена) живёт не на странице контракта, а на странице извещения: /epz/order/notice/ea44/view/common-info.html?regNumber={pn}. Там есть label «Начальная (максимальная) цена контракта». Покрытие правда грустное — около 4-5% от тендеров 44-ФЗ, остальные публикуют НМЦК диапазоном типа “не более 1М ₽” что для скоринга бесполезно.

Дата эл.акта приёмки — на отдельной странице document-info.html?reestrNumber={Y}. Там в HTML строки вида «акт № (N) от ДД.ММ.ГГГГ». Берём максимальную дату среди актов с этапным номером — это и есть дата фактического закрытия контракта. Покрытие хорошее, около 100%.

А вот тот common-info.html который я долго разбирал — там просто общая инфа, цена факта, дата заключения, срок исполнения. НМЦК и elact_at туда не положили. Моя ошибка в том что я полез писать парсер не проверив сначала что данные вообще на странице есть.

Strict mode vs толерантный парсер

Первая версия парсера НМЦК у меня была «умная»:

func parseNoticeNmck(html: String) -> Decimal? {    // canonical label    if let v = extractAfterLabel(html, "Начальная (максимальная) цена контракта") {        return parseDecimal(v)    }    // fallback на синоним    if let v = extractAfterLabel(html, "Максимальное значение цены договора") {        return parseDecimal(v)    }    return nil}

Звучит логично — если основной label не нашли, попробуем синоним, авось то же самое. На одном тестовом контракте этот fallback мне дал discount 99.6%. Заказчик в БД вдруг стал «гениально дисконтить», скоринг покрасился зелёным.

Открываю руками, смотрю — а там фактическая цена 1.2М, а «Максимальное значение цены договора» вернуло 35М. Потому что это был рамочный контракт на много лет, и максимум — это лимит всей суммы за все годы вперёд, а не НМЦК конкретной закупки.

Урок банальный — для финансовых полей strict mode лучше чем толерантность. Если canonical label не нашли — return nil. nil лучше чем neправильное число которое поедет в формулу и сломает результат.

Заодно поставил anti-outlier guard в формулу margin:

let valid = contracts.filter { c in    guard let nmck = c.maxPrice, nmck > 0,          let price = c.price, price > 0 else { return false }    let discount = 1.0 - Double(price) / Double(nmck)    return discount < 0.80  // больше 80% дисконта — почти наверняка outlier}

Грубо но эффективно. Реальные дисконты редко больше 30-40%, всё что выше 80% — почти всегда либо ошибка парсинга, либо рамочник который проскочил strict-mode.

Rate limit и пустые ответы Varnish

ЕИС не публикует rate limits, мерил эмпирически. У меня вышло:

  • 8 req/s — комфортно, 429 не ловлю

  • 15 req/s — иногда 429 на всплесках

  • 30 req/s — стабильно ловлю 429 в 30% случаев

Остановился на 3 параллельных запроса для тяжёлой задачи (обогащение контрактов). Пробовал 5 — на customer’е с 316 контрактами получил 30% rate-limit. На 3 уже почти 0%.

Ещё прикол — у ЕИС перед бэком стоит Varnish и иногда отдаёт 0-байтные ответы. Видел такое раз из 4-5 на document-info при cold cache. Лечится retry с exponential backoff:

for attempt in 0..<3 {    do {        let response = try await client.get(url, timeout: 15)        if response.body.isEmpty { throw EisError.emptyResponse }        return response.body.string    } catch {        try await Task.sleep(seconds: pow(2.0, Double(attempt)))    }}throw EisError.gaveUp

302 редиректы тоже фолловлю руками а не через стандартный механизм клиента — нужен был контроль над количеством хопов для логирования. По-моему overkill, но работает.

Кэш — по разному для разного

С TTL я сначала пытался обойтись одним глобальным значением. Быстро понял что глупость. Лента тендеров — обновляется каждые 5 минут, новые тендеры публикуются постоянно. А mapping ИНН → orgId — да он по сути никогда не меняется, юр.лицо своё name свой ИНН не теряет. Зачем им один TTL.

Сейчас у меня примерно так:

  • лента — 5 минут

  • карточка тендера — сутки

  • акты приёмки — неделя

  • НМЦК извещения — 90 дней

  • mapping ИНН — 30 дней

И отдельно для негативных кэшей (404, пустые ответы) — короткий TTL, час-сутки, чтобы не дёргать ЕИС в пустую. Без negative cache юзер скроллит ленту → каждый swipe долбит сетку → сразу же ловишь rate limit.

Что в итоге

После долгой разработки:

В БД 128 тысяч контрактов, около 340 уникальных заказчиков с заполненным скорингом по 4 метрикам (надёжность, активность, маржа, стабильность). Покрытие около 85% от запрашиваемых скорингов, остальное — холодные заказчики которые обогащаются по первому запросу за 3-15 секунд.

Latency feed-запроса на hot cache 15мс, на cold 100мс. Глубокое обогащение customer’а с большим количеством контрактов — 30-90 секунд в фоне, это нормально, юзер этого не видит, scoring появится через SSE когда recalculate отработает.

Где сейчас слабо

Покрытие НМЦК для 44-ФЗ — всего 4-5%. Большая часть аукционов публикует НМЦК как диапазон что для расчёта margin score бесполезно. Думаю где брать точные значения, пока не нашёл.

Mapping для холодных заказчиков делает один HTTP к ЕИС, около 500мс cold. Если в ленте 50 неизвестных orgId — это 25 секунд latency на резолв всех. Решаю через async warmup — запускаем резолв в фоне после возврата ответа, к следующему feed-запросу всё в кэше.

Вёрстка ЕИС иногда меняется без предупреждения. Каждые несколько месяцев что-то ломается — добавляется класс, переименовывается ID. Нужен monitoring парсера в CI на нескольких известных тендерах. Пока делаю руками когда вижу баги от тестеров.

Парсер 223-ФЗ слабее чем 44-ФЗ. У 44 я месяц копал, у 223 пока половина методов работает в режиме best-effort. Этот разрыв закрываю.

P.S.

Парсер использую в pet-приложении «ГосЛоты» — мобильный клиент для свайпа госзакупок. Сейчас open beta в RuStore, бесплатно. Если кому интересно как продукт устроен с UX-стороны, могу отдельный пост написать.

И если кто-то знает легальный путь к данным ЕИС без ЭЦП юр.лица — пишите в комменты. Может я реально что-то пропустил из официальных каналов.

ссылка на оригинал статьи https://habr.com/ru/articles/1041566/