Делаю 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-стороны, могу отдельный пост написать.

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

Комментарии (14)


  1. SHK83
    30.05.2026 16:50

    А чего ради все эти приседания? Через месяц верстку поменяют (а это часто случается) и все, приложуха мертва или работает коряво. Вы прям готовы каждый день проверять не «поломали» ли верстку на сайте? Раньше были сотни парсеров инсты и автопостинг и еще куча плюшек, так они жили неделю и потом разрабы неделю парсер правили под новую верстку. В итоге все это оказалось никому ненужным мусором. Парсинг на долгосроке - вещь не очень надежная.


    1. KivApple
      30.05.2026 16:50

      Вообще, сейчас ИИ хорошо справляется с задачей "вот тебе несколько примеров HTML, напиши скрипт на Python, который выдирает вот такие то данные". Чисто write only решение без архитектуры. Когда вёрстка меняется просто перегенерируешь парсер с нуля на новых образцах HTML.

      Единственное, что надо просить добавить в парсер детектор, что всё спарсилось, чтобы он не тихо терял данные, а полностью ломался от любого изменения вёрстки. И ты не пропустил момент, когда нужен новый парсер.


      1. SHK83
        30.05.2026 16:50

        Ну если чуток поправили верстку, то, возможно AI и справится. Но вот попробуйте сетку натравить на инсту и она налажает как только сможет. А там разрабы стараются структуру менять довольно сильно. Я не о том, что это невозможно. Я про то, что поддерживают такую прилу будет крайне сложно и муторно, будет сжирать уйму времени. Вот и спросил автора: ради чего такие приседания? Денег ради или по фану?


    1. sunnybear
      30.05.2026 16:50

      А это единственный вариант. Можно, конечно, 5 млн. за год доступа (или на порядок больше - когда-то запрашивал прайсинг для юр.лиц). Но оно тупо того не стоит для пет проектов.


    1. alekzernov Автор
      30.05.2026 16:50

      Согласен риск есть но в целом настроен алертинг на почту плюс смотрю логи на тему парсинга. Если что подкручу. Плюс можно в целом ИИ натровить на то что бы она парсер подкручивала. это пет проект а не какой то продукт так как если это делать готовым продуктом то источник данных должен быть максимально без отказным.


    1. alkoro
      30.05.2026 16:50

      Не так часто и изменяется, мониторю эти закупки html-парсингом минимум с 2010 года. Но тогда эта часть называлась “Реестр недобросовестных поставщиков”, потом это переехало на закупки. Вот, поднял старые скрипты из доступных - окт2020,июл2021,окт2021,янв2022,сен2025 - пять правок за пять лет, не так много, но и не мало. Это из интересующих доков по 44-,223-,615-ФЗ. Но, полностью подтверждаю автора - формат запросов и вывода под каждый ФЗ разный. Соглашусь, что это развлечение не из категории “сделал и забыл”. Если уж речь идёт о “..прям готовы каждый день проверять не «поломали» ли верстку на сайте” - то да, именно это со мной и происходит, мониторю, правлю. Никогда не считал, вот, получается, примерно раз в год по этому ресурсу.


  1. sigprof
    30.05.2026 16:50

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

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

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

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

    И это не просто так — если организация имеет филиалы, может оказаться, что закупки филиала отражаются под отдельным organizationCode, но при этом собственного ИНН у филиала нет — ИНН совпадает с ИНН головной организации, ОГРН тоже совпадает, отличается только КПП. Поэтому сводить всё к одному ИНН неверно.


    1. alekzernov Автор
      30.05.2026 16:50

      Спасибо что подсветили. В БД сейчас всё канонизируется к ИНН головной, а нужно по паре (ИНН, КПП) или сразу по orgCode держать отдельные scoring-записи для филиалов. Это в backlog


  1. Karakurtuss
    30.05.2026 16:50

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

    Это не слабое место, это особенности контрактной системы. Точное значение вы не найдете, так как это и есть точное значение - заказчик не может закупить больше чем максимальная цена договора. То что вы назвали

    диапазоном типа “не более 1М ₽” 

    диапазоном не является.

    Смотрите есть контракты с НМЦК в них известен объем ТРУ (товаров, работ, услуг), которые планирует приобрести заказчик.

    А есть контракты, в которых объем ТРУ не известен. В этих контрактах устанавливается НМЦЕ (начальная (максимальная) цена за единицу) и поставщики торгуют именно эту цену. Но Заказчик обязан установить предельную цену закупки. Поэтому он устанавливает максимальную цену договора, которую вы и видите в виде "диапазоном типа “не более 1М ₽”.

    В этом случает пусть Поставщик предложил карандаши по 5 руб. и это самая низкая цена из предложенных. Тогда Заказчик покупает у него карандаши по 5 руб. за штуку на сумму не более 100 000 руб., например.

    Мы сейчас парсим информацию через другой сервис. Сейчас не помню ссылки, спрошу у наших разрабов в понедельник и напишу. Но там может возникнуть нюанс. Пока сервис бесплатный, но разработчик хочет ввести оплату 3000 руб. в месяц. Для компании, которая регулярно участвует в тендерах это не принципиально, но вот для вас.


    1. alekzernov Автор
      30.05.2026 16:50

      Спасибо за разбор! Это семантика контрактной системы которую я недопонимал. Для cost-per-unit контрактов margin score нужно считать иначе — через объём ТРУ и НМЦЕ, а не через discount от единого числа. Подкручу формулу.

      Жду ссылку на альтернативный сервис


  1. sopranox
    30.05.2026 16:50

    Несколько лет делаю парсинг, верстка за это время пару раз менялась. Все отлично.


  1. md33vr
    30.05.2026 16:50

    я уж в конце подумал гос "слоты"


  1. aikendo
    30.05.2026 16:50

    Я написал себе парсер за полчаса, это тз + генерация и выложил это на youtube. Тут что то придумывать сложное не нужно, учитывая что там ест лента RSS. API вы серьезно?


    1. alekzernov Автор
      30.05.2026 16:50

      Про RSS — да, есть, и его можно использовать для ленты тендеров (заголовки + регномера). Но скоринг строится на исторических контрактах, документах, статусах приёмки — это всё уже HTML парсить надо. RSS даст разве что pub-date и название. Буду рад глянуть youtube, скинете линк?