Тендерные площадки давно перестали быть просто электронными досками объявлений. На них ежедневно появляются закупки, меняются сроки подачи заявок, публикуются разъяснения, протоколы, извещения об отмене и десятки сопутствующих документов.
Для поставщика, аналитика или отдела продаж это означает большой поток данных, который неудобно обрабатывать вручную. Парсер на Python помогает превратить разрозненную информацию в понятную базу: с фильтрами, историей изменений, уведомлениями и отчетами.
При этом парсинг тендеров - не задача из серии "скачать HTML и найти несколько тегов". Современные площадки используют JavaScript, авторизацию, капчи, ограничения по частоте запросов и нестандартные форматы документов.
Поэтому хороший проект начинается не с написания цикла на Python, а с постановки задачи, анализа источников и выбора корректной архитектуры.
Ниже разберем, как спроектировать такой сервис: от поиска подходящих площадок и изучения структуры страниц до хранения данных, работы с документами, планировщика, контроля качества и соблюдения правил.
Примеры будут ориентированы на интернет-сервисы, внутренние системы компаний и небольшие команды, которым нужен рабочий прототип без лишней сложности.
Что должен делать парсер тендерных площадок
Сначала стоит определить, какую именно проблему решает будущая система. "Собирать все тендеры" - слишком расплывчатая формулировка. У разных пользователей отличаются регионы, отрасли, типы закупок, допустимые суммы, сроки и формат результата.
Одному нужен ежедневный список закупок по ключевым словам, другому - мониторинг действий конкретного заказчика, третьему - история цен и победителей.
Минимальный парсер обычно выполняет четыре действия: находит карточки закупок, извлекает основные поля, сохраняет их в базу и сообщает о новых или изменившихся позициях.
Расширенная версия дополнительно скачивает документы, распознает текст, отслеживает этапы процедуры, строит статистику и объединяет данные нескольких источников.
- идентификатор закупки и ссылка на карточку;
- название и краткое описание предмета закупки;
- заказчик, регион и контактная информация;
- начальная цена и валюта;
- дата публикации, окончания подачи заявок и проведения процедуры;
- статус: прием заявок, завершена, отменена, заключен контракт;
- способ закупки и применяемая процедура;
- ссылки на документы и сведения о лотах;
- дата последней проверки и история изменений.
Полезно заранее разделить данные на обязательные и дополнительные. Обязательными должны быть поля, без которых запись теряет смысл: уникальный номер, название, источник и хотя бы одна дата.
Цена, регион или контакт могут отсутствовать в конкретной карточке, поэтому система не должна падать из-за пустого значения.
Еще один важный вопрос - частота обновления. Для закупок с горизонтом подачи заявок в несколько недель достаточно проверки раз в час или раз в несколько часов. Если пользователь отслеживает срочные закупки, разумнее запускать сбор каждые 10–30 минут, но только при условии, что площадка допускает такую нагрузку.
Частый опрос без необходимости быстро превращается в источник ошибок и блокировок.
В техническом задании стоит зафиксировать не только список полей, но и ожидаемый результат. Например: "новая закупка появляется в базе не позднее чем через 20 минут после публикации; повторные записи не создаются; документы доступны для скачивания; при изменении срока пользователь получает уведомление".
Такие критерии позволяют оценить качество проекта не по впечатлению, а по измеримым показателям.
Анализ площадки и подготовка к сбору данных
Перед кодированием нужно вручную изучить каждую площадку. Откройте поиск, карточку закупки, раздел документов, страницу результатов и несколько страниц пагинации.
Посмотрите, какие элементы доступны без входа, меняется ли адрес страницы при применении фильтров, есть ли встроенный API, как обозначаются даты и где находится идентификатор процедуры.
Разница между источниками может быть огромной. На одной площадке список закупок полностью присутствует в HTML, а фильтр формирует обычный GET-запрос.
На другой браузер после загрузки страницы выполняет несколько фоновых запросов, а в исходном HTML есть только контейнеры для будущего содержимого. В третьем случае карточка частично открыта для всех, но документы доступны только авторизованному пользователю.
Для каждой площадки составьте технический паспорт:
| Параметр | Что проверить | Зачем это нужно |
|---|---|---|
| Способ загрузки | HTML, JSON, JavaScript | Помогает выбрать инструмент сбора |
| Пагинация | Номера страниц, курсор, бесконечная прокрутка | Определяет алгоритм обхода |
| Фильтры | GET, POST, параметры формы | Позволяет не загружать лишние записи |
| Документы | Открытый доступ, авторизация, отдельные запросы | Влияет на модуль файлов |
| Ограничения | Лимиты, капча, блокировка IP | Определяет допустимую частоту запросов |
В браузере разработчика полезно открыть вкладку Network и обновить страницу. В фильтре запросов можно искать слова вроде api, search, items, tenders, procurement или document. Если список загружается через JSON, это часто лучший вариант: ответ проще разобрать, чем сложную разметку. Однако нельзя автоматически считать любой внутренний запрос публичным API.
Нужно проверить правила использования и убедиться, что обращение не требует обхода ограничений.
Сохраните несколько тестовых ответов площадки локально. Это поможет разрабатывать парсер без постоянных обращений к сайту и даст стабильные фикстуры для тестов.
Желательно взять варианты с обычной карточкой, несколькими лотами, пропущенной ценой, измененным статусом и пустым списком документов.
Отдельно проверьте кодировку, часовой пояс и формат чисел. Цена может выглядеть как "1 250 000,50", дата - как "18.09.2026 14:30", а часовой пояс вообще не указываться.
Если сохранить такие значения без нормализации, в отчетах появятся неверные сортировки, а уведомление может прийти на час позже или раньше.
Выбор инструментов Python
Для статичных страниц базовый набор обычно состоит из requests и BeautifulSoup. requests отвечает за HTTP-запросы, заголовки, тайм-ауты и обработку ответов, а BeautifulSoup помогает находить элементы в HTML.
Это простой и быстрый вариант, который подходит для первых прототипов и страниц, где данные действительно отдаются сервером.
Когда площадка активно использует JavaScript, применяют Playwright или Selenium. Браузерный автоматизатор открывает страницу почти так же, как обычный пользователь, ждет выполнения скриптов и позволяет извлечь уже отрисованный DOM.
Playwright обычно удобен для современных проектов: у него хорошие ожидания элементов, поддержка нескольких браузеров и удобная работа с контекстами.
Если сервер возвращает JSON, браузер может вообще не понадобиться. Для асинхронного сбора большого количества карточек подойдет aiohttp, но асинхронность не означает "можно отправлять сколько угодно запросов".
Ограничитель скорости, очередь задач и повторные попытки все равно обязательны.
- requests - синхронные HTTP-запросы для простых сценариев;
- httpx - современный клиент с синхронным и асинхронным режимами;
- BeautifulSoup - разбор HTML и поиск элементов;
- lxml - быстрый парсер HTML и XPath;
- Playwright - работа с JavaScript и браузерным DOM;
- SQLAlchemy - модели и доступ к базе данных;
- pydantic - проверка и нормализация схем данных;
- APScheduler или системный планировщик - регулярные запуски;
- logging - журналирование ошибок и событий.
Для хранения небольшого прототипа достаточно SQLite. Она не требует отдельного сервера и хорошо подходит для одного процесса, локальной разработки и демонстрации. Если сбор выполняется постоянно, пользователей несколько, а объем истории растет, лучше выбрать PostgreSQL.
Для очередей и распределенных задач пригодятся Redis и Celery, но добавлять их на старте стоит только при реальной необходимости.
Окружение проекта удобно зафиксировать в файле зависимостей. Также понадобятся переменные окружения для адресов баз, учетных данных и настроек запуска. Пароли нельзя зашивать в исходный код: файл с секретами может случайно попасть в репозиторий, архив или логи.
Практичная архитектура часто состоит из отдельных слоев: клиент источника, извлекатель данных, нормализатор, хранилище, модуль документов, планировщик и уведомления. Такое разделение позволяет заменить HTML-селекторы, не переписывая базу и отчеты. Если все находится в одной функции на несколько сотен строк, первый редизайн площадки превратит поддержку в мучение.
Получение HTML и JSON без лишних проблем
Начнем с простого HTTP-клиента. В реальном проекте нужно указывать тайм-аут, проверять код ответа и корректно обрабатывать сетевые ошибки. Тайм-аут защищает программу от зависания, если сервер перестал отвечать.
Повторять запросы следует только для временных проблем - например, ответа 429 или кратковременной ошибки сервера, а не для любой подряд ошибки.
import requests
session = requests.Session()
session.headers.update({
"User-Agent": "TenderMonitor/1.0 contact@example.local",
"Accept": "text/html,application/xhtml+xml"
})
def fetch_page(url):
response = session.get(url, timeout=20)
response.raise_for_status()
return response.text
Здесь User-Agent должен честно описывать приложение, а не маскировать его под случайный браузер. Если площадка предоставляет контакт для технических вопросов, его можно указать в описании сервиса.
Для крупных проектов полезно согласовать режим доступа с владельцем источника и получить официальный API или выгрузку.
При работе с JSON важно проверять структуру ответа. Нельзя предполагать, что поле items всегда существует и всегда является списком. Сервер может вернуть сообщение об ошибке, пустую страницу или другой формат после обновления.
def read_json(response):
data = response.json()
if not isinstance(data, dict):
raise ValueError("Неожиданный формат ответа")
items = data.get("items", [])
if not isinstance(items, list):
raise ValueError("Поле items должно быть списком")
return items
Для запросов с параметрами используйте словарь params, а не собирайте URL конкатенацией строк. Так меньше риск ошибиться с кодировкой русских слов, датами и специальными символами.
Значения фильтров лучше нормализовать до отправки: убрать лишние пробелы, привести основные слова к единому регистру, проверить диапазон дат.
Если площадка требует сессионные cookies, это не означает, что нужно пытаться обходить защиту.
Авторизация должна выполняться разрешенным способом: через официальную учетную запись, токен, корпоративный доступ или ручное подтверждение сессии.
Капчи нельзя "ломать" или передавать сторонним сомнительным сервисам. Для коммерческого решения безопаснее запросить интеграцию у владельца площадки.
Повторные запросы должны иметь экспоненциальную задержку. Например, после временного сбоя подождать 2 секунды, затем 4 и 8, ограничив общее число попыток.
Между обычными запросами также нужен небольшой интервал. Практический показатель - не максимальная скорость, а стабильность: лучше собрать 10 тысяч записей за час без блокировок, чем отправить их за две минуты и потерять доступ.
Извлечение полей из HTML
HTML-разметка редко бывает идеальной. На странице могут встречаться пустые блоки, скрытые элементы, дублирующие подписи и разные варианты карточек.
Поэтому извлекатель должен не просто искать один CSS-класс, а иметь понятные правила: где искать значение, как очищать текст и что делать, если поле не найдено.
from bs4 import BeautifulSoup
def clean_text(value):
return " ".join(value.split()) if value else None
def parse_card(html):
soup = BeautifulSoup(html, "html.parser")
number_node = soup.select_one("[data-tender-id]")
title_node = soup.select_one("h1,.tender-title")
price_node = soup.select_one(".price, [data-price]")
status_node = soup.select_one(".status")
return {
"external_id": (
number_node.get("data-tender-id")
if number_node else None
),
"title": clean_text(title_node.get_text(" ", strip=True)
if title_node else None),
"price_raw": clean_text(price_node.get_text(" ", strip=True)
if price_node else None),
"status_raw": clean_text(status_node.get_text(" ", strip=True)
if status_node else None)
}
Селекторы вида.tender-title удобны, но хрупки: класс может измениться после редизайна. Если в разметке есть стабильные data-атрибуты, идентификаторы или семантические признаки, лучше опираться на них.
Иногда надежнее найти подпись "Начальная цена" и взять соседний элемент, чем использовать длинный путь из множества вложенных div.
Сырые значения полезно сохранять вместе с нормализованными. Например, поле price_raw хранит исходную строку, а price - число Decimal. Это помогает разбираться с ошибками и подтверждать, откуда взялось значение в отчете.
Для дат используйте отдельную функцию, которая учитывает несколько форматов:
from datetime import datetime
from decimal import Decimal
DATE_FORMATS = (
"%d.%m.%Y %H:%M",
"%d.%m.%Y",
"%Y-%m-%dT%H:%M:%S"
)
def parse_date(value):
if not value:
return None
value = " ".join(value.split())
for fmt in DATE_FORMATS:
try:
return datetime.strptime(value, fmt)
except ValueError:
continue
return None
def parse_price(value):
if not value:
return None
normalized = value.replace("\xa0", " ")
normalized = normalized.replace(" ", "").replace(",", ".")
digits = "".join(ch for ch in normalized if ch.isdigit() or ch == ".")
return Decimal(digits) if digits else None
В боевом коде обработку денег стоит сделать строже: отдельно учитывать скобки, знак, валюту и несколько разделителей. Для финансовых значений не используйте float, потому что двоичная арифметика может дать неожиданные копейки.
Decimal подходит для денежных расчетов гораздо лучше.
После извлечения проверяйте обязательные поля. Если у карточки отсутствует идентификатор, запись нельзя молча сохранять как новую: это приведет к дублям. Лучше отправить событие в журнал ошибок и сохранить исходный HTML для анализа.
Полезный прием - писать тесты на локальных HTML-файлах. В тесте проверяется, что для конкретной фикстуры правильно извлекаются номер, цена, дата и статус. Когда площадка меняет верстку, тест сразу показывает, какой участок перестал работать.
Работа с JavaScript и браузером
Если данные появляются только после выполнения скриптов, можно использовать Playwright. Основная ошибка новичков - ждать фиксированное количество секунд.
Такой подход то работает слишком медленно, то ломается на медленном соединении. Лучше ждать конкретный элемент, сетевой ответ или изменение состояния страницы.
from playwright.sync_api import sync_playwright
def load_rendered_page(url):
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page()
page.goto(url, wait_until="domcontentloaded", timeout=30000)
page.locator(".tender-card").first.wait_for(timeout=15000)
html = page.content()
browser.close()
return html
Браузерный режим заметно тяжелее обычного HTTP-клиента. Один экземпляр Chromium может использовать сотни мегабайт памяти, а параллельный запуск десятков страниц быстро перегрузит сервер. Поэтому браузер стоит применять только там, где без него нельзя обойтись.
Если в Network обнаружен разрешенный JSON-запрос, обычно рациональнее работать с ним напрямую.
Для списка карточек нужно продумать ожидание окончания загрузки. Иногда первые записи появляются быстро, а остальные подгружаются после прокрутки. Тогда алгоритм должен либо нажимать кнопку "Показать еще", либо обрабатывать страницы через параметры пагинации. Автоматическая прокрутка без ограничения может зациклиться на бесконечной ленте.
Авторизация - отдельный слой. Внутри браузерного контекста можно использовать сохраненную сессию, но файл состояния должен храниться защищенно.
Не помещайте cookies в репозиторий и не выводите их в журнал. Если срок действия сессии закончился, сервис должен сообщить об этом оператору, а не бесконечно повторять неудачные попытки входа.
Важна и стабильность селекторов. Не привязывайтесь к координатам мыши, случайным классам и тексту кнопки, который может измениться из-за языка интерфейса. Предпочтительны role-селекторы, data-атрибуты и устойчивые признаки структуры.
Если приложение использует iframe, нужно явно работать с нужным фреймом, иначе автоматизатор не увидит элементы.
Браузерная автоматизация допустима только в рамках правил площадки. Она не должна использоваться для обхода капчи, ограничения доступа, платной подписки или технических барьеров.
Если данные закрыты, корректный путь - договориться о доступе или применить предоставленный интерфейс.
Проектирование базы данных и устранение дублей
Главное правило хранения - одна закупка не должна превращаться в десятки одинаковых строк при каждом запуске.
Для этого нужен уникальный ключ. В идеальном варианте им является внешний идентификатор площадки вместе с кодом источника. Один и тот же номер на разных сайтах может обозначать разные процедуры, поэтому одного external_id недостаточно.
from sqlalchemy import (
Column, Integer, String, DateTime, Numeric, Text,
UniqueConstraint
)
from sqlalchemy.orm import declarative_base
Base = declarative_base()
class Tender(Base):
tablename = "tenders"
id = Column(Integer, primary_key=True)
source = Column(String(50), nullable=False)
external_id = Column(String(120), nullable=False)
title = Column(Text, nullable=False)
price = Column(Numeric(18, 2))
status = Column(String(80))
published_at = Column(DateTime)
deadline_at = Column(DateTime)
url = Column(Text, nullable=False)
updated_at = Column(DateTime, nullable=False)
table_args = (
UniqueConstraint("source", "external_id"),
)
Для обновления применяют операцию upsert: если уникальная пара уже существует, запись обновляется, а если нет - добавляется. В историю при этом можно писать только реальные изменения.
Например, изменение срока подачи заявок - важное событие, а изменение времени последней проверки таковым не является.
Минимальная схема может включать таблицы tenders, lots, documents, changes и notifications. Лоты нужны, если одна процедура содержит несколько позиций с отдельными ценами.
Документы лучше хранить отдельно, чтобы карточка не разрасталась большим набором URL. В changes можно записывать имя поля, старое значение, новое значение и время обнаружения.
| Таблица | Назначение | Пример полей |
|---|---|---|
| tenders | Основные сведения о закупке | источник, номер, название, цена, статус |
| lots | Состав процедуры | номер лота, описание, стоимость |
| documents | Файлы и ссылки | имя, URL, размер, хэш |
| changes | История обновлений | поле, старое и новое значение, дата |
| notifications | Отправленные уведомления | тип, получатель, статус доставки |
Документы и большие файлы не обязательно хранить прямо в базе. Практичнее использовать объектное хранилище или файловую систему, а в базе оставить путь, размер, MIME-тип и контрольную сумму. Хэш помогает понять, изменился ли файл, даже если его имя осталось прежним.
Индексы следует создавать по полям, по которым выполняется поиск: источнику, статусу, датам, региону, заказчику и внешнему идентификатору. Но индексировать каждую колонку подряд не стоит: это увеличивает размер базы и замедляет запись.
Для отчетов по диапазонам дат особенно полезны составные индексы, подобранные по реальным запросам.
Храните время в UTC, а показывайте пользователю в его часовом поясе. Это избавляет от путаницы при переходе на летнее или зимнее время и при работе команды из разных регионов. В интерфейсе желательно явно указывать часовой пояс рядом с дедлайном.
Скачивание и обработка документов
Тендерная карточка редко содержит всю нужную информацию. Существенные условия могут находиться в техническом задании, проекте договора, смете или протоколе.
Поэтому модуль документов должен уметь обнаруживать ссылки, проверять доступность файлов и безопасно сохранять их.
Сначала нужно определить тип файла по заголовку Content-Type и, по возможности, по сигнатуре содержимого. Расширение из URL может быть неправильным. Не следует без проверки открывать скачанный файл как исполняемый объект.
Файлы нужно сохранять в отдельный каталог, ограничивать размер ответа и защищаться от путей вроде../../secret.txt.
from pathlib import Path
import hashlib
import requests
def download_file(url, folder, max_size=30 * 1024 * 1024):
response = requests.get(url, timeout=40, stream=True)
response.raise_for_status()
content_length = response.headers.get("Content-Length")
if content_length and int(content_length) > max_size:
raise ValueError("Файл слишком большой")
digest = hashlib.sha256()
chunks = []
total = 0
for chunk in response.iter_content(chunk_size=65536):
total += len(chunk)
if total > max_size:
raise ValueError("Превышен лимит размера")
digest.update(chunk)
chunks.append(chunk)
data = b"".join(chunks)
path = Path(folder) / digest.hexdigest()
path.write_bytes(data)
return path, digest.hexdigest()
В производственной версии не обязательно собирать весь файл в оперативной памяти. Лучше записывать поток во временный файл, считать хэш и затем переносить его в постоянное хранилище. Это особенно важно, если документы могут весить десятки или сотни мегабайт.
Для PDF с текстовым слоем подходят библиотеки вроде pypdf или PyMuPDF. Таблицы Excel можно анализировать через openpyxl, а документы Word - через python-docx. Сканированные страницы потребуют OCR, например отдельного сервиса распознавания.
OCR дает ошибки в номерах, датах и ценах, поэтому важные поля желательно подтверждать вручную или сохранять ссылку на исходную страницу.
Документы можно индексировать для полнотекстового поиска. В небольшом проекте подойдет PostgreSQL с полнотекстовым индексом, в более крупном - специализированный поисковый движок.
Перед индексацией нужно очистить повторяющиеся пробелы, определить язык и сохранить связь текста с конкретным документом и страницей.
Следите за изменениями файлов. Если площадка публикует новый документ по тому же URL, размер и хэш изменятся. Парсер должен создать новую версию, а не молча перезаписать старую.
Для юридически значимых процессов особенно важна история: какой файл был доступен на момент принятия решения и когда он был заменен.
Нельзя забывать о конфиденциальности. В документах могут содержаться персональные данные, коммерческие условия и подписи. Доступ к хранилищу ограничивают ролями, резервные копии шифруют, а ненужные файлы удаляют по установленному сроку хранения.
Фильтрация, нормализация и поиск релевантных закупок
Пользователь редко хочет видеть весь поток. Ему нужны процедуры по определенным словам, регионам, категориям и диапазонам цен.
Фильтры лучше применять как можно раньше: сначала на стороне источника, если такая возможность есть, затем в коде и только потом в интерфейсе. Это снижает объем данных и расходы на хранение.
Поиск по ключевым словам не сводится к простому оператору in. Одно и то же понятие может встречаться в разных формах: "ремонт кровли", "ремонт кровельного покрытия", "кровельные работы".
Для русского языка полезны словари синонимов, нормализация регистра, удаление лишних знаков и морфологический анализ.
- приведение текста к нижнему регистру;
- замена неразрывных пробелов обычными;
- удаление повторяющихся пробелов и служебных символов;
- проверка словоформ и распространенных сокращений;
- исключение стоп-слов, если они не влияют на смысл;
- учет отрицательных условий, например "не поставка";
- разделение обязательных и дополнительных терминов.
Полезно хранить не только итоговый признак релевантности, но и причину совпадения. Например: "совпало ключевое слово в названии" или "совпадение найдено в техническом задании на странице 4". Это делает систему понятной пользователю и помогает корректировать правила.
Для оценки качества применяют две метрики: полноту и точность. Полнота показывает, какая доля подходящих закупок найдена, а точность - какая доля найденных действительно подходит.
Если из 100 показанных карточек только 40 релевантны, фильтр дает много шума. Если из 100 подходящих найдено 50, он слишком узкий.
Практический способ настройки - взять выборку из 200–300 закупок и вручную разметить ее. После этого сравнить результат правил с эталоном, определить ложные срабатывания и пропуски.
Даже простая таблица с колонками "подходит", "не подходит", "причина" быстро показывает, какие слова создают проблемы.
Суммы и даты нужно сравнивать в нормализованном виде. Цена может быть указана отдельно для каждого лота, с налогом или без него, а иногда вместо суммы отображается "не определена".
Не стоит автоматически превращать любую найденную цифру в стоимость: номер дома, количество товара и срок тоже могут быть числами.
Для сложной классификации можно использовать машинное обучение или языковые модели, но начинать лучше с прозрачных правил. Пользователю важно понимать, почему закупка попала в выдачу. Модель можно добавить позже как дополнительный рейтинг, не заменяя базовые проверки.
Планировщик, очереди и уведомления
Парсер, который запускается вручную, полезен только на этапе эксперимента. Для постоянного мониторинга нужен планировщик. На одном сервере подойдет cron или APScheduler, а для распределенной системы - очередь задач с отдельными воркерами.
Главное - сделать запуск идемпотентным: повторное выполнение не должно портить данные и создавать дубли.
Разные операции стоит запускать с разной частотой. Список новых процедур можно проверять часто, карточки активных закупок - реже, завершенные - еще реже, а архивные записи вообще не трогать без специальной команды. Такой режим уменьшает нагрузку и экономит ресурсы.
| Задача | Пример интервала | Комментарий |
|---|---|---|
| Новые закупки | 15–60 минут | Зависит от срочности и правил источника |
| Активные карточки | 1–6 часов | Отслеживаются сроки и изменения |
| Документы | 1–12 часов | Проверяются новые версии |
| Завершенные процедуры | 1 раз в сутки | Для протоколов и итогов |
Очередь должна хранить не только адрес задачи, но и число попыток, время следующего запуска, приоритет и причину последней ошибки. После нескольких неудачных попыток задача попадает в карантин, а оператор получает уведомление.
Бесконечный цикл повторов скрывает проблему и создает нагрузку на источник.
Уведомления отправляют при появлении подходящей закупки, изменении дедлайна, публикации нового документа, отмене процедуры или обнаружении ошибки доступа. Не отправляйте сообщение при каждом обновлении карточки: пользователи быстро начнут игнорировать канал.
Нужны группировка, дедупликация и настройка порогов.
Подойдут электронная почта, корпоративный чат, внутренний интерфейс или push-уведомления. В сообщении должны быть название, цена, крайний срок, заказчик, причина срабатывания и понятная кнопка или ссылка на карточку.
Если уведомление невозможно доставить, его статус фиксируется в базе, а не теряется в логах.
При росте объема можно добавить приоритеты. Закупки с дедлайном менее чем через три дня, высокой суммой или точным совпадением ключевого слова обрабатываются первыми. Но приоритет не должен отменять ограничения скорости для конкретного источника.
Ошибки, логирование и контроль качества
Основная причина провала парсеров - не отсутствие красивого интерфейса, а отсутствие наблюдаемости.
Если сервис перестал собирать данные, разработчик должен быстро понять, что произошло: изменилась верстка, истекла сессия, база недоступна, сервер вернул капчу или возникла ошибка нормализации.
Логи должны содержать время, источник, тип операции, URL без секретных параметров, код ответа, длительность и текст ошибки.
Личные данные, cookies, токены и содержимое закрытых документов в журнал не записываются. Уровни DEBUG, INFO, WARNING и ERROR помогают отделять обычную работу от проблем.
import logging
logger = logging.getLogger("tender_parser")
def process_url(url):
try:
logger.info("Начало обработки: %s", url)
html = fetch_page(url)
record = parse_card(html)
if not record["external_id"]:
logger.warning("Не найден идентификатор: %s", url)
return record
except requests.RequestException:
logger.exception("Сетевая ошибка: %s", url)
raise
Полезные метрики включают количество запросов, долю ответов 2xx, число 429 и 5xx, длительность обработки, количество новых карточек, процент пустых обязательных полей и объем скачанных файлов.
Если вчера находилось 500 закупок, а сегодня внезапно ноль, это должно быть видно на графике или хотя бы в ежедневном отчете.
Нужны автоматические проверки здравого смысла. Цена не должна быть отрицательной, дата окончания обычно не должна быть раньше публикации, идентификатор не может быть пустым, а количество записей за запуск не должно отклоняться от среднего в десятки раз без предупреждения.
Такие проверки не доказывают идеальность данных, но хорошо ловят поломки.
Тестирование стоит разделить на уровни. Модульные тесты проверяют очистку текста, дат и цен. Интеграционные - взаимодействие с локальным тестовым сервером или сохраненными ответами. Сквозные - полный путь от загрузки карточки до записи в базу и отправки уведомления.
Для изменяющихся площадок полезен снимок разметки. Если ключевой селектор перестал находиться, задача завершается с понятной ошибкой, а не записывает в базу сотни пустых строк.
В некоторых проектах применяют контрольные признаки: наличие заголовка, идентификатора, контейнера списка и нескольких ожидаемых атрибутов.
План восстановления тоже входит в качество. База должна регулярно резервироваться, файлы - копироваться отдельно, а конфигурация - храниться в безопасном месте.
Перед обновлением парсера стоит проверить его на тестовой выборке и иметь возможность быстро откатить предыдущую версию.
Законность, безопасность и этика парсинга
Техническая возможность получить данные не равна разрешению их собирать. Перед запуском нужно изучить пользовательское соглашение, правила доступа, условия API, ограничения robots.txt и применимое законодательство.
Особенно внимательно следует относиться к персональным данным, коммерческой информации, авторизации и автоматизированным действиям в закрытом кабинете.
Открытые данные тоже нельзя использовать бездумно. Уважайте лимиты, не создавайте заметную нагрузку, кэшируйте неизменяющиеся ответы и запускайте сбор в согласованное время, если это предусмотрено правилами.
Парсер не должен подменять обычного пользователя в сценариях, которые площадка явно запрещает.
- не обходить капчи и технические ограничения;
- не подбирать пароли и не использовать чужие сессии;
- не собирать больше персональных данных, чем требуется задаче;
- не хранить токены в исходном коде и открытых логах;
- не скачивать бесконечно одни и те же документы;
- предусмотреть удаление данных по сроку хранения;
- предоставить доступ к системе только нужным сотрудникам.
Безопасность начинается с изоляции. Запускайте парсер от отдельного пользователя, ограничивайте права каталога, используйте контейнер или виртуальное окружение, обновляйте зависимости и проверяйте файлы антивирусом.
Документы, полученные из внешнего источника, нельзя автоматически исполнять или открывать на сервере в небезопасном режиме.
Для внутренних систем полезно разделить роли: оператор видит результаты и может перезапустить задачу, администратор меняет источники и настройки, а аудитор просматривает историю действий. Все изменения конфигурации записываются в журнал.
Это особенно важно, если сервис влияет на коммерческие решения.
Если площадка предлагает выгрузки или API, выбирайте их вместо имитации браузера. Официальный интерфейс обычно стабильнее, лучше документирован и снижает юридические риски. Иногда платный доступ к API обходится дешевле, чем постоянная поддержка самописного обходного решения.
Наконец, заранее определите политику удаления. Старые карточки можно архивировать, документы удалять через установленный срок, а историю изменений хранить дольше только при наличии деловой причины.
Чем больше данных накапливается, тем выше стоимость хранения и последствия возможной утечки.
Практическая архитектура проекта
Небольшой, но поддерживаемый проект можно организовать в виде нескольких модулей.
В каталоге sources находятся адаптеры конкретных площадок, в parsers - разбор HTML и JSON, в models - схемы данных, в storage - работа с базой, в documents - скачивание и извлечение текста, в notifications - сообщения, а в scheduler - запуск задач.
tender_monitor/
sources/
first_platform.py
second_platform.py
parsers/
html_parser.py
json_parser.py
storage/
models.py
repository.py
documents/
downloader.py
text_extractors.py
notifications/
email.py
chat.py
scheduler/
jobs.py
tests/
fixtures/
test_parsers.py
config.py
main.py
Каждый адаптер источника должен возвращать единый внутренний формат. Тогда отчеты и фильтры не зависят от того, откуда пришла запись.
Например, у разных площадок могут быть "номер закупки", "реестровый номер" и "идентификатор процедуры", но внутри проекта это одно поле external_id с признаком source.
Для надежности используйте схемы валидации. Если площадка неожиданно отдала строку вместо числа или дату в новом виде, ошибка должна возникнуть на границе системы. Не позволяйте некорректным значениям спокойно проходить до отчета, где их будет сложнее обнаружить.
Бизнес-логику нельзя смешивать с селекторами. Функция извлечения знает, как найти цену в HTML, а функция нормализации знает, как превратить строку в Decimal. Репозиторий знает, как сохранить объект, но не должен сам разбирать HTML.
Такое разделение кажется избыточным в начале, зато заметно экономит время при расширении.
Конфигурация должна включать список источников, тайм-ауты, лимиты, расписание, подключение к базе и параметры уведомлений. Переменные, которые меняются между тестовой и рабочей средой, передаются через окружение.
Секреты хранятся в менеджере секретов или защищенном хранилище.
Развернуть сервис можно на виртуальном сервере или в контейнерах. Для одного источника достаточно одного приложения и базы. При росте нагрузки отдельными процессами выносят планировщик, воркеры, браузерные задачи и обработку документов.
Горизонтальное масштабирование возможно только после того, как введены ограничения на каждый источник и защита от повторной обработки.
Версионирование схемы базы обязательно. Миграции позволяют добавить поле или индекс без ручного редактирования production-базы. Перед миграцией выполняется резервная копия, а после - проверка количества записей и работоспособности основных запросов.
Пошаговый план разработки
Начинать лучше с одного источника и нескольких ключевых полей. На первом этапе достаточно собрать список, открыть карточку, сохранить идентификатор, название, цену, даты и ссылку.
Цель - проверить гипотезу и понять реальные ограничения площадки, а не сразу строить универсальную платформу.
- описать сценарии пользователей и обязательные поля;
- изучить страницы, фильтры, документы и правила доступа;
- сохранить тестовые HTML- и JSON-ответы;
- создать минимальный HTTP-клиент с тайм-аутами;
- написать извлечение и нормализацию основных полей;
- добавить схему базы и уникальные ограничения;
- реализовать повторный запуск без дублей;
- проверить документы и версии файлов;
- подключить расписание и уведомления;
- добавить логи, метрики, тесты и резервное копирование.
На втором этапе расширяют фильтры, добавляют лоты, заказчиков, регионы и полнотекстовый поиск. Здесь же появляется история изменений. Пользователь должен видеть не только текущее состояние, но и то, что дедлайн перенесли, цену уточнили или документ заменили.
Третий этап - эксплуатация. Сервис запускается на ограниченной группе пользователей, собирается обратная связь, анализируются ложные срабатывания и пропуски.
Полезно измерять время от публикации до появления записи, процент успешных загрузок и долю карточек с незаполненными обязательными полями.
Не стоит сразу добавлять десятки площадок. Каждый новый источник отдельные правила, форматы, ошибки, документы и политика доступа. Лучше сделать один надежный адаптер и общий контракт, чем пять нестабильных скриптов, которые никто не умеет чинить.
Оценка ресурсов зависит от режима. Для нескольких тысяч карточек в сутки может хватить небольшого сервера с 1–2 ядрами и несколькими гигабайтами памяти, если данные берутся через HTTP и JSON. Браузерный сбор и OCR требуют больше памяти и процессорного времени.
Точные параметры лучше определять нагрузочным тестом, а не универсальными цифрами из статьи.
При расчете стоимости учитывайте не только сервер. В бюджет входят разработка, поддержка после изменений верстки, прокси или официальные API, хранение документов, резервные копии, мониторинг и время аналитика.
На практике именно сопровождение часто занимает больше времени, чем первоначальная реализация.
Типичные ошибки начинающих разработчиков
Первая ошибка - написать один огромный скрипт без разделения на этапы. В нем одновременно открывается браузер, ищется элемент, преобразуется дата, записывается база и отправляется письмо.
Такой код трудно тестировать: непонятно, на каком шаге возник сбой и что можно заменить без побочных эффектов.
Вторая ошибка - использовать жесткие задержки и бесконечные повторы. sleep(10) не гарантирует загрузку данных, а повторять запрос до успеха опасно для источника. Нужны ожидания условий, лимит попыток, экспоненциальная пауза и карантин неудачных задач.
Третья проблема - отсутствие уникального ключа. Если после каждого запуска база растет на одинаковые карточки, аналитика становится бесполезной.
Уникальность должна быть обеспечена не только кодом, но и ограничением на уровне базы, потому что два параллельных процесса могут одновременно принять одну и ту же запись.
Четвертая ошибка - хранить даты строками, деньги float и статусы в произвольном виде. Такой подход ломает сортировку, сравнение и отчеты. Нормализация должна выполняться сразу после извлечения, пока рядом находится контекст исходного значения.
Пятая ошибка - игнорировать изменения площадки. Любая верстка может измениться после обновления дизайна. Поэтому нужны тестовые фикстуры, проверки обязательных полей, метрики и уведомление о резком падении числа найденных записей.
Наконец, многие недооценивают документы. Ссылка на PDF - еще не сохраненный результат: файл может исчезнуть, измениться или потребовать авторизацию. Для важных процессов нужно хранить версию, хэш, дату скачивания и статус обработки.
Хороший парсер не самый быстрый сборщик страниц, а устойчивый сервис, который предсказуемо работает в разрешенных рамках, объясняет свои результаты и позволяет восстановить историю.
Скорость важна, но стабильность, качество данных и безопасность обычно важнее нескольких сэкономленных минут.
Создание парсера тендерных площадок на Python лучше воспринимать как разработку небольшого информационного продукта. В нем есть сбор данных, нормализация, база, поиск, документы, расписание, уведомления, мониторинг и правила доступа.
Если продумать эти части заранее, первый прототип можно сделать достаточно быстро, а затем без болезненной переделки превратить его в рабочий инструмент для отдела закупок, продаж или аналитики.
Начните с одной площадки, ограниченного набора полей и четких критериев качества. Проверьте, что система не создает дубли, правильно обрабатывает даты и цены, фиксирует изменения и сообщает об ошибках.
После этого добавляйте документы, классификацию и новые источники. Такой постепенный подход дает больше пользы, чем попытка сразу построить универсальный комбайн.
Частые вопросы
Можно ли обойтись без Selenium и Playwright? Да, если источник отдает нужные данные в HTML или разрешенном JSON-интерфейсе. Браузер нужен только для страниц, где без выполнения JavaScript невозможно получить содержимое.
Как часто запускать сбор? Это зависит от срочности закупок и правил площадки. Для большинства задач достаточно интервала от 30 минут до нескольких часов. Частоту следует подтверждать нагрузочным тестом и ограничениями источника.
Что делать, если площадка заблокировала запросы? Остановить сбор, изучить причину, проверить правила и перейти на официальный API или согласованный способ доступа. Не следует пытаться обходить блокировку техническими уловками.