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

Страницы:  1

Ответить
 

Professor Seleznov


Делаю 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-страницы в браузере.
Какие страницы вообще нужны
Пять штук:
  • /epz/order/extendedsearch/results.html?fz44=on&regions=... — лента тендеров по 44-ФЗ
  • То же самое с fz223=on — лента 223-ФЗ
  • /epz/order/notice/{type}/view/common-info.html?regNumber=X — открытие конкретного тендера 44-ФЗ
  • /epz/contract/contractCard/common-info.html?reestrNumber=Y — общая инфа по контракту
  • /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-стороны, могу отдельный пост написать.
И если кто-то знает легальный путь к данным ЕИС без ЭЦП юр.лица — пишите в комменты. Может я реально что-то пропустил из официальных каналов.-Источник
 
Loading...
Error