В логистике проблема часто не в том, что нет данных.
Проблема в том, что данные разбросаны по разным местам.
Одни заявки лежат во внутренней системе, другие — в закрытых кабинетах грузоотправителей, третьи — на тендерных площадках, четвёртые приходят через Excel-выгрузки, пятые доступны только через веб-интерфейс. Где-то есть нормальный HTTP-обмен, где-то данные спрятаны за фронтендом, где-то приходится читать DOM-таблицу, а где-то сначала кажется, что всё просто, пока не выясняется, что цена приходит в копейках, маршрут состоит из трёх точек, а тип кузова записан как “тент 20т, верхняя загрузка”.
Для менеджера всё это выглядит не как единый рынок грузов, а как набор вкладок в браузере.
Открыть один кабинет. Потом второй. Потом третий. Проверить направление. Сравнить цену. Посмотреть дату. Понять, где реф, где тент, где просто “20 тонн”. Не забыть про аукцион, у которого скоро истекает время. Потом всё равно перенести результат в таблицу или открыть внутреннюю панель.
В какой-то момент стало понятно: нам нужен не ещё один парсер, а единая витрина.
Так появился внутренний агрегатор заявок — условный “Авиасейлз для логистики”.

Немного контекста
Этот проект я делал не как часть большой инженерной команды, где роли заранее разделены: один человек отвечает за архитектуру, второй пишет backend, третий делает frontend, четвёртый настраивает инфраструктуру, пятый считает метрики.
В моём случае почти весь контур был на мне.
По старой доброй традиции рф бизнеса, я одновременно был архитектором, программистом, тестировщиком, дата-аналитиком, DevOps-инженером и внутренним продактом, который должен понимать, зачем эта система вообще нужна бизнесу.
Поэтому задача быстро вышла за рамки “написать парсер”.
Нужно было разобраться, как реально работают логисты, где они теряют время, какие данные влияют на прибыль, какие источники дают пользу, где появляются ошибки, как считать воронку и как понять, что автоматизация действительно помогает, а не просто красиво перекладывает данные из одного места в другое.
Утром можно было чинить нормализацию цены, днём разбираться с историей в PostgreSQL, вечером пересобирать frontend-бандл, потом смотреть логи деплоя, а после этого думать уже не как разработчик, а как маркетолог или аналитик: сколько заявок попало в систему, сколько дошло до рекомендаций, сколько увидел менеджер, где потерялась потенциальная сделка и есть ли в этом экономический смысл.
Это сильно повлияло на архитектуру.
Я не мог позволить себе систему, которую можно поддерживать только отдельной командой data engineering. Поэтому решения приходилось делать прагматичными.
Где-то я оставил Google Sheets как промежуточный слой контроля, потому что менеджерам и руководителям удобно быстро проверить данные глазами. Где-то добавил PostgreSQL, потому что без истории невозможно нормально анализировать изменения. Где-то поставил lock на агрегатор, потому что параллельные запуски могут испортить общий срез. Где-то написал тесты на нормализацию, потому что одна ошибка в цене, городе или типе кузова может превратить полезную рекомендацию в опасную.
В итоге это стало не просто задачей про сбор данных.
Это был полный рабочий контур:
источник→ сбор→ нормализация→ контроль качества→ база→ поиск→ рекомендации→ аналитика→ деплой→ обратная связь от бизнеса
С этим контекстом проще объяснить, почему система получилась именно такой: не идеально академической, зато рабочей и поддерживаемой одним человеком.
Что это вообще такое
Это внутренняя система, которая собирает заявки и аукционы из разных логистических источников, приводит их к единой схеме и показывает менеджерам в одной поисковой панели.
На выходе менеджер видит не 16 разных кабинетов, а одну таблицу:
-
маршрут;
-
дата погрузки и выгрузки;
-
источник;
-
цена;
-
рубли за километр;
-
тип транспорта;
-
время до окончания;
-
ссылка на исходную карточку;
-
дополнительные поля из источника.
Задача была не в том, чтобы “достать HTML”. Это как раз самая простая и наименее ценная часть.
Главная задача — сделать данные пригодными для принятия решений.
Почему нельзя просто “спарсить таблицу”
На словах задача звучит просто:
Забери таблицу с сайта и положи в Google Sheets.
В реальности почти каждый источник ломает эту простую схему.
Один источник отдаёт цену в рублях. Другой — в копейках. Третий — строкой с пробелами, запятой и символом валюты.
Для кода это три разных формата. Для менеджера это одна и та же бизнес-сущность: ставка.
Если ошибиться здесь, система будет выглядеть рабочей, но рекомендации станут опасными. Менеджер увидит неправильную цену и может принять неправильное решение.
С маршрутами такая же история.
Для человека это одно направление:
Москва → Краснодар
А для системы это могут быть совершенно разные строки:
Московская область, склад такой-то → Краснодарский край, станица такая-то
или:
Москва → промежуточная точка → Краснодар
или вообще одна длинная строка, где перемешаны адрес, название склада, регион, комментарий и контактная информация.
С типом кузова тоже не всё красиво.
“Реф”, “рефрижератор”, “изотерм”, “температурный режим”, “тент 20т” — для менеджера это рабочие термины, а для системы это разные текстовые значения. Но от них зависит, подходит ли заявка, какая ставка нормальная и можно ли вообще предлагать этот груз перевозчику.
Отдельная боль — дедлайны.
В одном кабинете время до окончания торгов приходит как дата. В другом — как количество секунд. В третьем — как текст в интерфейсе. Пользователь при этом не должен видеть технические остатки вроде “8432 сек.”. Ему нужно нормальное поле: “осталось 2 часа 20 минут”.
Так задача постепенно перестала быть “парсингом таблиц” и стала задачей нормализации данных.
Общая архитектура
Упрощённо пайплайн выглядит так:
закрытый источник→ сборщик конкретного источника→ flatten сырого ответа→ нормализация в единую схему→ промежуточная таблица источника→ общий агрегатор→ общий агрегированный срез→ PostgreSQL→ внутренняя панель поиска→ рекомендации для менеджеров→ AI-помощник поверх витрины
Я специально разделил сбор и агрегацию.
Сборщик конкретного источника отвечает только за то, чтобы стабильно достать данные из конкретного кабинета и положить их в промежуточный слой. Он знает особенности этого источника: где лежит маршрут, где цена, как устроена пагинация, как выглядит дедлайн, какие поля могут пропасть.
Агрегатор не должен знать, как устроен каждый кабинет. Его задача — взять уже подготовленные данные, привести их к общей схеме, убрать мусор, нормализовать маршруты, типы кузова, цены и записать итоговый срез.
Это оказалось важным архитектурным решением.
Если смешать сбор, нормализацию и бизнес-логику в одном месте, система быстро превращается в набор исключений вида “если источник такой-то, поле брать отсюда, но только если вот это не пустое”.
Сначала это работает. Потом никто уже не понимает, почему одна строка попала в рекомендации, а другая нет.
Какие бывают источники
Все источники условно разделились на несколько типов.
HTTP/API-источники
Самый удобный вариант — когда после авторизации можно получать данные через HTTP-обмен без полноценного браузера.
Такой режим легче запускать на сервере, проще логировать, проще ретраить и дешевле по ресурсам. Если источник стабильно работает через HTTP-клиент, браузерный сбор лучше не использовать без необходимости.
DOM-кабинеты
Иногда данные фактически живут только в веб-интерфейсе. Таблица отрисовывается фронтендом, нормального внешнего API нет или он слишком сильно завязан на состояние страницы.
В таких случаях приходится использовать браузерный режим и читать DOM-таблицу. Это тяжелее, медленнее и капризнее, но иногда это единственный рабочий путь.
XLSX-выгрузки
Есть источники, где проще забрать Excel-файл, чем пытаться повторить всю логику фронтенда.
С точки зрения инженерной эстетики это не самый красивый вариант. С точки зрения практики — иногда самый стабильный. Файл скачался, разобрался, нормализовался, попал в общий пайплайн.
Гибридные источники
Часто всё начинается с браузера: нужно понять, как устроен кабинет, какие данные где появляются, какие запросы делает фронт.
Потом, если получается, регулярный сбор переводится в более лёгкий HTTP-режим. Это снижает нагрузку, уменьшает количество нестабильности и делает запуск на сервере проще.
Нормализация — самая важная часть
Самая большая ценность системы оказалась не в сборщиках, а в нормализации.
Нужно было привести к единому виду:
-
цену;
-
рубли за километр;
-
маршрут;
-
город погрузки;
-
город выгрузки;
-
тип транспорта;
-
дату погрузки;
-
дату выгрузки;
-
время до окончания;
-
ссылку на карточку;
-
дополнительные поля источника.
Цена
Цена — один из самых опасных типов данных.
В одном источнике она может быть в рублях:
82389.00
В другом — в копейках:
8238900
В третьем — строкой:
82 389,00 ₽
Если ошибиться в 100 раз, это не просто техническая ошибка. Менеджер увидит неправильную ставку. Рекомендация станет вредной.
Поэтому цена должна проходить отдельный слой нормализации и проверок.
Маршрут
Маршрут — это не просто строка.
Для поиска важны не только исходные адреса, но и нормализованные города. Пользователь ищет “Москва — Краснодар”, а источник может дать адрес склада в области или населённый пункт рядом с крупным городом.
Поэтому пришлось отдельно работать с извлечением первой и последней точки маршрута, хабами, пригородами и неоднозначными названиями.
Здесь нельзя слишком сильно упрощать. Если всё вокруг Москвы автоматически считать Москвой, можно получить ложные совпадения. Если ничего не объединять, менеджер потеряет нормальные варианты, потому что один источник написал “Москва”, другой — “Московская область”, а третий — адрес склада.
Тип кузова
Тип транспорта тоже пришлось приводить к бизнес-понятным категориям.
Внутри источников может быть много вариантов написания. В панели менеджеру нужны простые фильтры: реф, тент, прочее или не указан.
Это звучит просто, но в реальности такие правила нужно держать аккуратно. Если слишком агрессивно нормализовать текст, можно начать относить неподходящие заявки к нужному типу кузова. Если нормализовать слишком слабо — фильтр будет пропускать полезные варианты.
Время до окончания
Для аукционов важно понимать срочность.
Если у заявки осталось 20 минут, она должна восприниматься иначе, чем заявка с дедлайном завтра. Поэтому поле “время до окончания” пришлось привести к человекочитаемому виду, независимо от того, как оно пришло из источника: секундой, датой или текстом.
Почему я оставил Google Sheets
Можно было бы сразу делать всё только через PostgreSQL.
И, если смотреть чисто инженерно, это было бы аккуратнее: нормальная база, типы данных, индексы, история, запросы, миграции.
Но в логистике Google Sheets — это не просто временное хранилище. Это привычный рабочий интерфейс. Менеджеры и руководители умеют быстро смотреть таблицу глазами, фильтровать, сравнивать, замечать странности.
Поэтому я оставил промежуточные таблицы.
Схема получилась компромиссной:
источники→ промежуточные таблицы→ общий агрегатор→ PostgreSQL→ панель поиска
Sheets остаётся понятным слоем контроля для людей. PostgreSQL становится нормальной базой для поиска, истории, аналитики и рекомендаций.
Это не идеальная академическая архитектура. Зато она рабочая.
И ещё важный момент: когда ты строишь систему в бизнесе, доверие пользователей иногда важнее чистоты архитектуры. Если менеджер может открыть промежуточную таблицу и глазами проверить, что источник действительно собрался, он быстрее начинает доверять панели.
Как устроена защита от поломок
Самый неприятный класс багов в таких системах — не когда сборщик падает.
Хуже, когда он “успешно” возвращает пустой результат.
Например, кабинет временно отдал пустую страницу. Или сессия разлогинилась. Или таблица не успела прогрузиться. Или источник вернул ошибку, которую код неправильно посчитал нормальным ответом.
Если после этого затереть рабочий лист пустыми данными, менеджеры увидят, что заявок нет. Хотя на самом деле сломался сбор.
Поэтому пустой сбор не должен автоматически считаться успешным.
В системе появились несколько защитных принципов:
-
пустая выгрузка не затирает рабочие данные по умолчанию;
-
агрегатор запускается с lock, чтобы не было параллельных прогонов;
-
итоговый общий лист публикуется через staging/swap, а не прямым затиранием;
-
данные дублируются в PostgreSQL;
-
по успешному прогону проверяются счётчики;
-
история хранится отдельно от текущего среза.
Это не делает систему неубиваемой, но сильно снижает шанс тихо испортить данные.
Зачем понадобилась история в PostgreSQL
Сначала кажется, что достаточно хранить только последний срез.
Менеджеру же нужна актуальная таблица: что есть сейчас, какие ставки сейчас, какие аукционы ещё не закончились.
Но потом появляются вопросы:
-
сколько заявок было утром;
-
что исчезло к обеду;
-
какие источники чаще дают совпадения;
-
какие направления появляются регулярно;
-
не сломался ли конкретный сборщик;
-
почему вчера менеджер видел заявку, а сегодня её уже нет;
-
как меняется воронка рекомендаций;
-
какие источники реально полезны, а какие просто создают шум.
Для этого нужен не только текущий срез, но и append-only история.
Текущая витрина нужна для поиска. История — для аналитики, контроля качества, расследования ошибок и будущей воронки.
Это отдельный важный переход: система перестаёт быть “таблицей со свежими данными” и становится слоем данных.
Что видит менеджер
Для пользователя всё должно выглядеть проще, чем внутренняя архитектура.
Он открывает одну внутреннюю панель и ищет:
Москва → Краснодар
или:
реф на завтра
или:
грузы от 80 руб/км
или:
все заявки с истекающим временем
Вместо того чтобы открывать 10–15 кабинетов, менеджер работает с одной таблицей.
В панели есть фильтры по маршруту, дате, типу транспорта, цене, рублям за километр и источнику. В строках видно, откуда пришла заявка, сколько осталось времени, какая ставка и есть ли ссылка на исходную карточку.
Главная цель интерфейса — не заменить менеджера, а сократить ручную сверку.
Панель не принимает решение за человека. Она собирает рынок в одно место и помогает быстрее увидеть подходящие варианты.
Как это связано с рекомендациями
Когда появилась единая витрина внешних предложений, её стало можно использовать не только для ручного поиска.
Следующий шаг — рекомендации.
Внутри компании есть свои заявки, направления и потребности. Снаружи есть предложения из разных источников. Если привести всё к единой схеме, можно сопоставлять одно с другим:
наша заявка↔ внешнее предложение↔ маршрут↔ тип транспорта↔ дата↔ ставка↔ срочность
Так появляется не просто поиск, а рабочая панель рекомендаций: какие внешние предложения могут быть интересны под наши текущие заявки.
Здесь уже важна не только техническая точность, но и бизнес-воронка.
Сколько внешних предложений попало в систему?
Сколько из них совпало с нашими направлениями?
Сколько рекомендаций увидел менеджер?
Сколько вариантов реально дошло до звонка или сделки?
Где мы теряем потенциальную прибыль?
Это уже не задача “собрать таблицу”. Это задача построить слой, на котором можно считать эффективность.
Где здесь появился AI-помощник
Позже поверх этой же витрины появился AI-помощник.
Важно: он не является источником истины.
Он не должен придумывать ставки, количество заявок или выводы из воздуха. Его задача — помогать работать с уже подготовленными данными.
Например, пользователь может спросить обычным языком:
Найди реф Москва — Краснодар на завтра
или:
Что по ренте за неделю?
или:
Покажи заявки с истекающим временем
AI-помощник может распознать намерение, применить фильтры, подсказать маршрут или запустить следующий сценарий. Но фактические цифры должны приходить из базы, агрегированной витрины и backend-логики.
Иначе это быстро превращается в красивый, но опасный чат.
Поэтому часть сценариев закрывается обычными правилами и quick-actions, без LLM. Если запрос простой и повторяемый, модель там не нужна. Это быстрее, стабильнее и проще тестировать.
AI здесь — не магия поверх хаоса. Он полезен только потому, что под ним уже есть нормальный слой данных.
Какие были сложности
Грязные адреса
Адреса редко приходят в идеальном виде.
Где-то указан город. Где-то полный адрес склада. Где-то область, населённый пункт и комментарий в одной строке. Где-то маршрут состоит из нескольких точек.
Для человека это нормально. Для фильтра — источник ошибок.
Особенно неприятны омонимы: одинаковые названия населённых пунктов в разных регионах. Если нормализовать слишком грубо, можно начать относить заявку не к тому хабу.
Разные типы кузова
Тип кузова влияет на применимость заявки.
“Реф”, “изотерм” и “температурный режим” могут быть близкими по смыслу, но не всегда взаимозаменяемыми. “Тент 20т” может содержать и тип, и грузоподъёмность, и дополнительные условия.
Здесь нельзя просто сделать поиск по подстроке и считать задачу решённой.
Лимиты и нестабильность источников
Закрытые кабинеты не всегда ведут себя как стабильные API.
Сегодня таблица загрузилась быстро. Завтра дольше. Послезавтра вернулась пустая страница. Потом источник поменял верстку или формат поля.
Поэтому важны ретраи, артефакты диагностики, ограничение параллельных запусков и защита от частичных выгрузок.
Расхождения между UI и backend-логикой
Отдельный класс проблем — когда панель поиска и backend используют разные правила нормализации.
Например, UI по маршруту что-то находит, а рекомендации — нет. Или фильтр по типу транспорта в одном месте работает шире, чем в другом.
После таких багов становится понятно, что нормализация должна быть общей, а не размазанной по разным модулям.
Доверие пользователей
Если менеджер один раз увидит явно неправильную ставку или пропавшие заявки, доверие к системе падает.
Поэтому важны не только фичи, но и объяснимость: откуда пришла строка, когда она собрана, какой источник, какие поля были нормализованы, что лежит в дополнительных данных.
Что получилось технически
В итоге система стала не набором парсеров, а отдельным data pipeline.
Сейчас в ней есть:
-
отдельные сборщики по источникам;
-
промежуточные таблицы для контроля;
-
единая нормализованная схема;
-
общий агрегатор;
-
защита от пустых и частичных выгрузок;
-
PostgreSQL-слой для текущего среза и истории;
-
внутренняя веб-панель поиска;
-
панель рекомендаций;
-
метрики воронки;
-
AI-помощник, который работает поверх готовой витрины.
То есть ценность постепенно сместилась.
В начале кажется, что главное — подключить очередной источник.
Потом становится понятно, что главный актив — не конкретный сборщик, а единая схема данных, нормализация, история и возможность строить поверх этого новые сценарии.
Что бы я сделал иначе
Во-первых, раньше бы зафиксировал контракт единой схемы.
Когда источников два-три, кажется, что можно быстро поправить маппинг руками. Когда их становится больше 16, любое неаккуратное поле начинает разъезжаться по всей системе.
Во-вторых, раньше бы сделал больше debug-полей для каждой строки: откуда пришла цена, как был нормализован маршрут, какой тип кузова распознан, почему строка попала или не попала в рекомендации.
В-третьих, раньше бы отделил отображение для пользователя от технических полей. Пользователю нужен “реф” или “тент”, а не все варианты текста, которые пришли из источника. Но технический raw-контекст всё равно нужно хранить, иначе потом невозможно расследовать ошибки.
В-четвёртых, раньше бы начал хранить историю. Пока есть только текущая таблица, многие вопросы остаются без ответа: что исчезло, когда сломалось, какие источники реально дают пользу.
В-пятых, раньше бы начал считать воронку. Не только “сколько строк собрали”, а сколько из них превратилось в полезные рекомендации, сколько дошло до менеджера и где теряется потенциальная сделка.
Что получилось
Получился рабочий слой данных для логистики.
Он решает конкретную боль: менеджеру больше не нужно вручную проверять множество кабинетов, сравнивать ставки и держать в голове, где какие заявки появились.
Система собирает данные из разных источников, приводит их к единой схеме, сохраняет текущий срез и историю, показывает результат в панели поиска и даёт основу для рекомендаций.
Это не отменяет работу менеджера. Но убирает часть рутины, снижает количество переключений между вкладками и делает рынок заявок более управляемым.
Вывод
Главная ценность оказалась не в парсинге.
Парсинг — это только вход в задачу.
Настоящая работа начинается дальше: привести данные к единому виду, защититься от пустых выгрузок, сохранить историю, сделать понятный интерфейс и дать пользователю инструмент, которому можно доверять.
В логистике много данных, но сами по себе они не дают преимущества.
Преимущество появляется тогда, когда 16 разрозненных источников превращаются в одну витрину, по которой можно искать, сравнивать, строить рекомендации и принимать решения быстрее.
Нужен парсер? Пиши в личку.
ссылка на оригинал статьи https://habr.com/ru/articles/1035316/