Перейти к содержимому
PD
AI-автоматизация 9 мин чтения

Ваш парсер врёт: контроль качества данных в парсинг-пайплайнах

Парсеры редко падают. Они тихо отдают правдоподобный мусор. Четыре слоя валидации, которые я ставлю в каждый парсинг-пайплайн, чтобы плохие данные уходили в карантин, а не в прод.

PD

Pavel Duglas

AI Automation & MVP Architect

Упавший парсер это хороший парсер. Он честно сообщил, что сломался: есть трейс, есть селектор, который надо поправить, есть понятный вечер работы. Опасный парсер это тот, который отдаёт 200 OK, валидный JSON, правильные имена полей и полностью неверные значения. Три недели никто ничего не замечает. А потом выясняется, что клиент пересчитал свои цены по фантазийным данным о конкурентах, или отдел продаж две недели звонил по номерам, которые на самом деле были почтовыми индексами.

Я сделал много парсеров: проекты на BAS, пайплайны на Python, слои извлечения через LLM. И самый большой рывок по качеству дала скучная мысль: относиться к каждому источнику как к недоверенному API с контрактом и отказываться загружать данные, которые контракт нарушают. Ниже структура, которую я использую по умолчанию.

Как именно врут парсеры

Прежде чем строить проверки, надо понимать, от чего вы защищаетесь. Это реальные сценарии из продакшена, примерно по частоте.

  • Дрейф селекторов. Класс переименовали из price__value в price-value. Экстрактор вернул None, дальше по коду он превратился в 0, и теперь все товары бесплатные.
  • Мягкая блокировка. Сайт отдаёт HTTP 200 с челлендж-страницей, экраном “мы заметили необычную активность” или пустым скелетоном под JS. Размер ответа выглядит нормально. Парсер находит ноль товаров и рапортует об успешном прогоне с нулём строк.
  • Недорендер. Headless-браузер перешёл на страницу, но из 48 карточек ленивой подгрузки успели отрисоваться 8, и извлечение сработало раньше.
  • A/B и гео-варианты. Один и тот же URL, но разный DOM в зависимости от куки-бакета или страны выходного IP. Селекторы работают в 70% случаев, а вы списываете это на “флаки”.
  • Ловушки локали. 1.299,00 распарсилось как 1.299. Валюта сменилась с EUR на USD, потому что прокси переехал в другую страну. Даты перескочили между DD/MM и MM/DD.
  • Обрезанная пагинация. У кнопки “дальше” поменялась разметка, и вы теперь собираете страницу 1 из 40. Просадка строк выглядит как медленная неделя по продажам.
  • Тихий коллапс дедупликации. Источник начал переиспользовать ID, ваш ключ upsert перестал быть уникальным, и 12 000 строк незаметно стали 900.
  • Галлюцинации LLM. Вы отдали модели грязный HTML, попросили JSON и получили красиво структурированный объект, в котором два поля просто придуманы, потому что на странице их не было.

Обратите внимание: почти ничто из этого не бросает исключение. Мониторинг по исключениям слеп ко всем этим случаям. Нужен мониторинг по форме данных.

Слой 1: снимите отпечаток страницы до парсинга

Самая дешёвая проверка идёт первой. До запуска извлечения убедитесь, что вы смотрите на ту страницу, на которую думаете, что смотрите.

Для каждого источника я описываю небольшой контракт страницы:

PAGE_CONTRACT = {
    "must_contain": ["data-product-grid", "В корзину"],
    "must_not_contain": ["необычная активность", "cf-challenge", "Enable JavaScript"],
    "min_bytes": 40_000,
    "max_bytes": 2_500_000,
    "min_item_nodes": 10,
}

Если маркеров из must_contain нет, это не ошибка парсинга, это ошибка получения страницы. Разница операционно принципиальная: fetch-ошибка должна вызывать ретрай с другим прокси или обновление сессии, а parse-ошибка должна вызывать человека, который пойдёт смотреть селекторы. Именно из-за смешивания этих двух вещей команды тратят день на “починку парсера”, когда реальная проблема была в забаненном диапазоне IP.

Всегда сохраняйте сырой HTML упавших страниц. Сжатый HTML стоит копейки. Разбор инцидента с дрейфом разметки без оригинальной страницы стоит дорого. Я держу сырые ответы 14 дней в объектном хранилище с ключом run_id + url_hash, и больше ничего. Эта привычка сэкономила мне больше часов, чем любая хитрая стратегия селекторов.

В BAS логика такая же тривиальная: блок If с проверкой маркерной строки в исходнике страницы до входа в цикл по товарам, плюс счётчик, сколько элементов цикл реально нашёл. Нет маркера - поток уходит в ветку ротации ресурсов, а не продолжает работу как будто всё нормально.

Слой 2: схемы записей, которые отказывают, а не подставляют

Каждая извлечённая запись проходит через типизированную модель. Не ради красоты, а ради права отказать. Правило простое: поле либо парсится чисто, либо запись уходит в карантин. Никаких дефолтов по умолчанию, никаких or 0, никаких try/except: pass.

from decimal import Decimal
from pydantic import BaseModel, field_validator, HttpUrl

class Product(BaseModel):
    source_id: str
    title: str
    price: Decimal
    currency: str
    in_stock: bool
    url: HttpUrl

    @field_validator("title")
    @classmethod
    def sane_title(cls, v: str) -> str:
        v = " ".join(v.split())
        if len(v) < 3 or len(v) > 300:
            raise ValueError("длина title вне допустимого диапазона")
        return v

    @field_validator("price")
    @classmethod
    def sane_price(cls, v: Decimal) -> Decimal:
        if v <= 0 or v > Decimal("1000000"):
            raise ValueError("цена вне допустимого диапазона")
        return v

    @field_validator("currency")
    @classmethod
    def known_currency(cls, v: str) -> str:
        if v not in {"USD", "EUR", "RUB", "VND"}:
            raise ValueError(f"неожиданная валюта {v}")
        return v

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

Карантин, а не падение. Отклонённые записи идут в таблицу rejects с именем поля, текстом ошибки и сырым фрагментом. Дальше правило уровня батча решает, годен ли прогон целиком: я обычно допускаю до 2% отклонённых записей, а всё, что выше, считаю проваленным прогоном.

Слой 3: статистика по батчу, которую все пропускают

Валидация записи ловит некорректные данные. Она не ловит правдоподобные, но неверные. Для этого нужно сравнивать текущий прогон с историей того же источника.

По каждому прогону я пишу небольшую строку статистики: число записей, доля null по каждому полю, медиана и p95 числовых полей, число уникальных значений для полей-перечислений, доля записей, которые видим впервые.

Дальше сами гейты:

def batch_gates(stats, baseline):
    problems = []
    if stats.rows < baseline.median_rows * 0.7:
        problems.append("объём строк упал более чем на 30% к медиане")
    if stats.rows > baseline.median_rows * 2.0:
        problems.append("объём строк удвоился, возможно сломалась дедупликация")
    for field, rate in stats.null_rate.items():
        if rate > baseline.null_rate[field] + 0.15:
            problems.append(f"скачок доли null в поле {field}")
    if abs(stats.price_median - baseline.price_median) / baseline.price_median > 0.25:
        problems.append("медиана цены сдвинулась более чем на 25%")
    if stats.new_record_share > 0.9 and baseline.new_record_share < 0.3:
        problems.append("почти всё новое, скорее всего сменилась схема ID")
    return problems

Берите скользящую медиану по 14 прогонам, а не предыдущий прогон, иначе одна плохая ночь станет новой нормой. Если гейт не пройден, прогон не промоутится. Пайплайн продолжает отдавать последний хороший датасет и пишет в Telegram, какой именно гейт сработал. Не “ошибка парсера”, а “строк на 61% меньше медианы, медиана цены не изменилась”. Второе сообщение уже говорит вам, что дело в пагинации, а не в селекторах, ещё до того как вы открыли ноутбук.

Слой 4: golden set как канарейка

Статистика говорит, что форма данных изменилась. Она не говорит, что значения верные. Для этого нужен golden set: от 15 до 30 URL на источник с вручную проверенными ожидаемыми значениями, обновляется раз в квартал.

Прогоняйте их в начале каждой задачи. Сравнивайте с ожиданиями с допуском на поле: точное совпадение по ID и заголовкам, коридор допуска по ценам, которые законно двигаются, точное совпадение по булеву наличию. Если упало больше одной канарейки, полный обход не запускается вообще. Вы только что сэкономили 40 000 запросов мусора и счёт за прокси.

Канарейки же служат вашим регрессионным набором, когда вы правите селекторы. Поменяли правило, прогнали golden set, увидели, какие поля сломали. Это максимально близкая к юнит-тестам вещь, которая вообще возможна в парсинге.

Где здесь место LLM-извлечению

Модели отлично работают на грязных, низкообъёмных страницах с большим разнообразием разметки и совершенно не умеют сообщать, что они не уверены. Три правила делают их безопасными.

  1. Требуйте доказательство. Просите не только значение, но и точную подстроку исходника, из которой оно взято. Затем программно проверяйте, что эта подстрока действительно есть в тексте страницы. Придуманные поля отваливаются мгновенно, и если держать спаны короткими, это почти не добавляет токенов в ответ.
  2. Не давайте модели формировать финальный тип. Она возвращает строки, а ваш код парсит в Decimal, дату, enum. Схема из слоя 2 остаётся единственным авторитетом.
  3. Эскалируйте только при расхождении. Сначала дешёвая модель. Если схема отклонила запись или проверка доказательства не прошла, повторяем на более сильной. Упало дважды - карантин и человек. На стабильных источниках у меня обычно выходит меньше 5% эскалаций, и стоимость остаётся вменяемой.

И относитесь к тексту страницы как к враждебному вводу, а не к инструкции. Промпт извлечения должен быть построен так, чтобы контент страницы был данными, а не местом, где “игнорируй предыдущие инструкции” меняет поведение.

Внедрение за один рабочий день

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

  1. Таблица runs: run_id, source, started_at, rows, rejects, status, notes. Без учёта прогонов не работает ничего остальное.
  2. Маркеры страницы и сохранение сырого HTML. Час работы, мягкие блокировки видны сразу.
  3. Строгая схема записей, отказы в таблицу rejects.
  4. Логирование статистики по прогону, и только после десяти прогонов истории включайте батч-гейты.
  5. Golden set строится последним, когда вы уже знаете, какие поля реально ломаются.

Цель не в том, чтобы плохих данных не было вообще. Цель в том, чтобы плохие данные никогда не доходили до потребителя молча, а когда они появляются, алерт сразу называл тип поломки. Пайплайн, который умеет сказать “я не доверяю этому прогону”, стоит для клиента гораздо дороже того, который всегда говорит “успех”.

Вопросы и ответы

Какой порог по отклонённым записям считать нормальным?

Я начинаю с 2% на прогон и обязательно смотрю распределение по полям, а не только суммарную цифру. Один процент отказов, размазанный по всем полям, это обычно шум источника. Один процент, целиком сконцентрированный в поле price, это уже сломанный селектор. Порог стоит калибровать по первым десяти прогонам конкретного источника: если у сайта исторически 4% товаров без цены, ставить 2% бессмысленно, вы получите постоянные ложные срабатывания и через неделю перестанете читать алерты.

Не будут ли батч-гейты постоянно ложно срабатывать на сезонности?

Будут, если сравнивать с предыдущим прогоном. Поэтому берётся скользящая медиана по 14 прогонам и разные пороги на разные метрики: объём строк терпит просадку до 30%, а вот скачок доли null или смена набора валют не терпит почти ничего. Для источников с явной недельной сезонностью я сравниваю с тем же днём недели. И главное: сработавший гейт не должен ронять пайплайн, он должен блокировать промоушен датасета и присылать конкретную причину. Тогда ложное срабатывание стоит две минуты, а не рабочий день.

Стоит ли всё это внедрять в маленький парсер на BAS для одного клиента?

Стоит, но не целиком. Минимум для любого проекта - это маркер страницы перед циклом извлечения, счётчик найденных элементов и запись результата прогона куда угодно, хоть в Google Sheets. Это буквально час работы в BAS и ловит мягкие блокировки и недорендер, то есть большинство реальных инцидентов. Строгие схемы, батч-статистику и golden set имеет смысл добавлять, когда данные начинают влиять на деньги клиента: ценообразование, лиды, отчётность. Тогда стоимость одного тихого плохого прогона выше, чем стоимость всей обвязки.

Похожие статьи