Ваш парсер врёт: контроль качества данных в парсинг-пайплайнах
Парсеры редко падают. Они тихо отдают правдоподобный мусор. Четыре слоя валидации, которые я ставлю в каждый парсинг-пайплайн, чтобы плохие данные уходили в карантин, а не в прод.
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-извлечению
Модели отлично работают на грязных, низкообъёмных страницах с большим разнообразием разметки и совершенно не умеют сообщать, что они не уверены. Три правила делают их безопасными.
- Требуйте доказательство. Просите не только значение, но и точную подстроку исходника, из которой оно взято. Затем программно проверяйте, что эта подстрока действительно есть в тексте страницы. Придуманные поля отваливаются мгновенно, и если держать спаны короткими, это почти не добавляет токенов в ответ.
- Не давайте модели формировать финальный тип. Она возвращает строки, а ваш код парсит в Decimal, дату, enum. Схема из слоя 2 остаётся единственным авторитетом.
- Эскалируйте только при расхождении. Сначала дешёвая модель. Если схема отклонила запись или проверка доказательства не прошла, повторяем на более сильной. Упало дважды - карантин и человек. На стабильных источниках у меня обычно выходит меньше 5% эскалаций, и стоимость остаётся вменяемой.
И относитесь к тексту страницы как к враждебному вводу, а не к инструкции. Промпт извлечения должен быть построен так, чтобы контент страницы был данными, а не местом, где “игнорируй предыдущие инструкции” меняет поведение.
Внедрение за один рабочий день
Если у вас уже есть парсер и нет никаких проверок, вот порядок, который даёт максимум безопасности на час работы.
- Таблица
runs:run_id, source, started_at, rows, rejects, status, notes. Без учёта прогонов не работает ничего остальное. - Маркеры страницы и сохранение сырого HTML. Час работы, мягкие блокировки видны сразу.
- Строгая схема записей, отказы в таблицу
rejects. - Логирование статистики по прогону, и только после десяти прогонов истории включайте батч-гейты.
- Golden set строится последним, когда вы уже знаете, какие поля реально ломаются.
Цель не в том, чтобы плохих данных не было вообще. Цель в том, чтобы плохие данные никогда не доходили до потребителя молча, а когда они появляются, алерт сразу называл тип поломки. Пайплайн, который умеет сказать “я не доверяю этому прогону”, стоит для клиента гораздо дороже того, который всегда говорит “успех”.
Вопросы и ответы
Какой порог по отклонённым записям считать нормальным?
Я начинаю с 2% на прогон и обязательно смотрю распределение по полям, а не только суммарную цифру. Один процент отказов, размазанный по всем полям, это обычно шум источника. Один процент, целиком сконцентрированный в поле price, это уже сломанный селектор. Порог стоит калибровать по первым десяти прогонам конкретного источника: если у сайта исторически 4% товаров без цены, ставить 2% бессмысленно, вы получите постоянные ложные срабатывания и через неделю перестанете читать алерты.
Не будут ли батч-гейты постоянно ложно срабатывать на сезонности?
Будут, если сравнивать с предыдущим прогоном. Поэтому берётся скользящая медиана по 14 прогонам и разные пороги на разные метрики: объём строк терпит просадку до 30%, а вот скачок доли null или смена набора валют не терпит почти ничего. Для источников с явной недельной сезонностью я сравниваю с тем же днём недели. И главное: сработавший гейт не должен ронять пайплайн, он должен блокировать промоушен датасета и присылать конкретную причину. Тогда ложное срабатывание стоит две минуты, а не рабочий день.
Стоит ли всё это внедрять в маленький парсер на BAS для одного клиента?
Стоит, но не целиком. Минимум для любого проекта - это маркер страницы перед циклом извлечения, счётчик найденных элементов и запись результата прогона куда угодно, хоть в Google Sheets. Это буквально час работы в BAS и ловит мягкие блокировки и недорендер, то есть большинство реальных инцидентов. Строгие схемы, батч-статистику и golden set имеет смысл добавлять, когда данные начинают влиять на деньги клиента: ценообразование, лиды, отчётность. Тогда стоимость одного тихого плохого прогона выше, чем стоимость всей обвязки.
Похожие статьи
Сделаю под ключ
Соберу ИИ-агента под реальную задачу
С инструментами, памятью и логами, чтобы он работал в проде, а не только в демо.
от 1 500 $ · 1-2 недели