Блог

Парсер цен на Python: от прототипа к надёжному мониторингу

Автор: Опубликовано Обновлено

Парсер цен на Python полезно начинать с локального прототипа, а не с запроса к действующему магазину или маркетплейсу. Минимальный проверяемый пример включает синтетический HTML, явную схему результата, валидацию и тесты для пропущенного поля, изменённой разметки, неверной цены, дубля и времени проверки. Даже когда такой пример проходит все тесты, он ещё не является регулярным мониторингом: отдельно нужны согласованные источники, правила доступа, сопоставление товаров, расписание, журнал ошибок, хранение и ответственный за регулярный процесс. Ниже разобран полностью офлайн-прототип на CPython 3.12.3 без сторонних пакетов и без сетевых обращений.

Что именно проверяет пример

Прототип читает один локальный файл fixture.html. Все товары, продавцы, цены и ссылки в нём вымышлены, а адреса используют домен example.com. Код не содержит HTTP-клиента и не обращается к внешним страницам.

Для запуска нужны пять файлов:

  • fixture.html — шесть синтетических карточек;
  • parser.py — извлечение и валидация;
  • expected-output.json — ожидаемый результат;
  • test_parser.py — семь тестов;
  • verify.py — детерминированный отчёт о тестах и совпадении результата.

Скачать файлы офлайн-примера (ZIP): fixture.html, parser.py, expected-output.json, test_parser.py и verify.py.

Для разбора HTML используется только стандартный модуль html.parser. Его документация поясняет: экземпляр HTMLParser получает HTML и вызывает обработчики тегов и текста, которые переопределяет подкласс. Там же указано ограничение — этот парсер не проверяет соответствие открывающих и закрывающих тегов. Поэтому пример рассчитан на контролируемую фикстуру, а не на произвольную страницу.

Синтетическая HTML-карточка

<article
  class="product-card"
  data-sku="SYN-PY-001"
  data-source-url="https://shop.example.com/products/syn-py-001">
  <h2 data-field="name">Чайник «Локальный», 1.7 л</h2>
  <span data-field="price">2490.00</span>
  <span data-field="seller">Учебный продавец А</span>
  <span data-field="availability">in_stock</span>
</article>

Атрибуты data-field — учебный контракт фикстуры. Они не являются универсальными селекторами магазинов или маркетплейсов. В реальной задаче схема страницы и доступные поля проверяются по конкретному источнику.

Извлечение полей с помощью HTMLParser

Ниже показан ключевой фрагмент parser.py из скачиваемого примера; полный файл есть в архиве. Тело класса ProductFixtureParser воспроизведено без изменений; последующие функции нормализации и часть для запуска из командной строки в этот фрагмент не входят. Обработчик начинает запись на элементе article.product-card, собирает только явно помеченные поля и завершает запись при закрытии карточки.

from html.parser import HTMLParser
from typing import Any

class ProductFixtureParser(HTMLParser):
    """Collect deliberately simple product-card elements from a local fixture."""

    def __init__(self) -> None:
        super().__init__(convert_charrefs=True)
        self.cards: list[dict[str, Any]] = []
        self._card: dict[str, Any] | None = None
        self._active_field: tuple[str, str, list[str]] | None = None

    def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
        attributes = {key: value or "" for key, value in attrs}
        classes = set(attributes.get("class", "").split())

        if tag == "article" and "product-card" in classes:
            self._card = {
                "sku": attributes.get("data-sku", "").strip(),
                "source_url": attributes.get("data-source-url", "").strip(),
                "fields": {},
            }
            self._active_field = None
            return

        if self._card is not None and attributes.get("data-field"):
            self._active_field = (tag, attributes["data-field"].strip(), [])

    def handle_data(self, data: str) -> None:
        if self._active_field is not None:
            self._active_field[2].append(data)

    def handle_endtag(self, tag: str) -> None:
        if self._active_field is not None and tag == self._active_field[0]:
            _, field_name, chunks = self._active_field
            assert self._card is not None
            self._card["fields"][field_name] = " ".join("".join(chunks).split())
            self._active_field = None

        if tag == "article" and self._card is not None:
            self.cards.append(self._card)
            self._card = None
            self._active_field = None

Само извлечение ещё не делает запись пригодной. После него проверяются обязательные поля name, price, seller и availability, SKU, абсолютная HTTPS-ссылка и дубли принятого SKU.

Почему цена разбирается как Decimal

В учебной схеме цена — не свободный текст, а неотрицательное число с максимум двумя знаками после разделителя. Строки с пробелом очищаются, запятая переводится в точку, затем значение создаётся через Decimal. Ниже функция normalize_price воспроизведена из скачиваемого примера без изменений вместе с необходимыми импортами. Официальная документация Python подтверждает, что Decimal можно создавать из строк.

import re
from decimal import Decimal, InvalidOperation

PRICE_PATTERN = re.compile(r"^\d+(?:[.,]\d{1,2})?$")

def normalize_price(value: str) -> str:
    compact = value.replace(" ", "")
    if not PRICE_PATTERN.fullmatch(compact):
        raise ValueError("price must be a decimal number with at most two fraction digits")
    try:
        price = Decimal(compact.replace(",", "."))
    except InvalidOperation as error:
        raise ValueError("price is not a valid decimal") from error
    if price < 0:
        raise ValueError("price must not be negative")
    return format(price.quantize(Decimal("0.01")), "f")

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

Схема результата

Поле Назначение Проверка прототипа
sku Учебный идентификатор товара Не пустой; повтор принятого SKU становится ошибкой
name Название из фикстуры Обязательная непустая строка
price_rub Нормализованное значение цены в учебной схеме Неотрицательный Decimal, два знака после точки
seller Вымышленный продавец Обязательная непустая строка
availability Учебный статус наличия Обязательная строка; перечень значений в этом примере не претендует на универсальность
source_url Ссылка-основание Абсолютный HTTPS URL; никакой запрос по нему не выполняется
checked_at Время наблюдения Передаётся в команду явно и обязано содержать UTC-смещение

Для принятых записей прототип выдаёт JSON. Пример одной строки:

{
  "availability": "in_stock",
  "checked_at": "2026-07-29T09:00:00+00:00",
  "name": "Чайник «Локальный», 1.7 л",
  "price_rub": "2490.00",
  "seller": "Учебный продавец А",
  "sku": "SYN-PY-001",
  "source_url": "https://shop.example.com/products/syn-py-001"
}

Значение in_stock в учебной записи означает «в наличии». Дата, продавец, товар, цена и URL в этом фрагменте вымышлены. Фиксированное время нужно для воспроизводимости теста, а не для обещания частоты обновления.

Шесть случаев в фикстуре и ожидаемый результат

Случай Что находится в HTML Результат Смысл проверки
Корректная запись Все обязательные поля Принята Запись соответствует объявленной схеме прототипа
Пропущенное поле Нет продавца MISSING_FIELD Строка не смешивается с готовыми данными
Изменённая разметка data-role="price" вместо data-field="price" MISSING_FIELD для цены Изменение селектора становится видимым сбоем
Неверный тип цены Цена записана словами INVALID_PRICE Невалидный текст не превращается в число
Дубль Повтор уже принятого SKU DUPLICATE_SKU Две записи не принимаются под одним ключом
Время Явный ISO 8601 с UTC-смещением Сохраняется в принятой записи Наблюдение получает проверяемый момент времени

Как воспроизвести контрольный запуск

В архиве пять файлов, и среди них нет requirements.txt, потому что сторонних зависимостей нет. Зафиксированная среда — CPython 3.12.3. Команда обработки локальной фикстуры:

python3 parser.py \
  --input fixture.html \
  --checked-at 2026-07-29T09:00:00+00:00 \
  --output actual-output.json

Тесты запускаются стандартным модулем unittest:

python3 -m unittest -v test_parser.py
python3 verify.py

Контрольный запуск на CPython 3.12.3 выполнил семь тестов без ошибок. Первая команда создаёт локальный actual-output.json, который совпадает с expected-output.json; их SHA-256 одинаков: b394effd643ea6918a635492cfce77ad82f99b4952bf4a01507adf9b69505275. Команда verify.py создаёт локальный test-results.json с SHA-256 0936ee749ae55ce76f78cb8d4200bafa2540caab9f0ba089a1730c21ea2845d7. Эти два сгенерированных файла не входят в архив.

SHA-256 архива scanhub-python-price-parser-offline-example.zip: d4b8fdf5894800814c254d7b158e1c1558c7914a1569b4b49f8ce57766d50e9b. SHA-256 пяти файлов внутри: fixture.html97beab290e8cfd1ab2ebf249807ab6a8cfc26cdd4234a08cdc820cb3c0f2d211; parser.pybcc9c9691b3530dd3baef152ff879825172d276a63b047a4ae6d1f92b6df25e1; expected-output.jsonb394effd643ea6918a635492cfce77ad82f99b4952bf4a01507adf9b69505275; test_parser.pyf92e1ab1466b27ff68c446741f6f70ee6580977b83809fe809ee6605128542f8; verify.pyfeddf566925719d1db1f475c3f2e160de4845c7db51c5ebdb47391bd9af07d91.

Эти хеши подтверждают только точную проверенную версию файлов. Они не доказывают пригодность к другому HTML, источнику или расписанию.

Почему прототип ещё не стал регулярным мониторингом

Контур Локальный прототип Регулярный процесс
Источник Один контролируемый файл Согласованные источники после проверки допустимости и выборки
Схема Семь учебных полей Версионируемая проектная схема с обязательными и условными полями
Проверки Поля, цена, URL, дубль и время Проектные типы, диапазоны, сопоставление, свежесть и проверяемость результата
Изменения Одна испорченная метка в фикстуре Обнаружение изменения источника, регрессионные тесты и согласованное восстановление
Сопоставление Только дубль SKU Точные товары, варианты, аналоги и неоднозначности по согласованным правилам
Расписание Ручная команда с фиксированным временем Согласованная частота, наблюдение за выполнением и владелец исключений
Доставка Локальный JSON Один согласованный файл XLSX/CSV в базовом контуре; интеграционные каналы и BI — отдельный объём

Поэтому нельзя взять селектор, поставить его в расписание и объявить систему готовой. До регулярного запуска нужно подготовить данные для мониторинга цен: каталог, источники, условия наблюдения, типы совпадений, исключения и приёмку.

Чек-лист перед переходом к рабочему контуру

  1. Подтвердить источник, допустимый способ доступа и доступные поля на небольшой выборке.
  2. Версионировать схему и разделить обязательные и условные значения.
  3. Согласовать точное совпадение, вариант, аналог и неоднозначность.
  4. Сохранять ссылку, регион, продавца и время проверки там, где они поддерживаются.
  5. Отделять пропуски, неверные типы, дубли и ошибки источника от готовых данных.
  6. Иметь контролируемые фикстуры и регрессионные тесты для изменений разметки.
  7. Назвать владельцев источника, исключений, схемы и коммерческого решения.
  8. Согласовать частоту, хранение и формат доставки без универсального SLA.

ScanHub рассматривает такой контур как задачу, где источники, товары, регионы, поля, частота, сопоставление и результат сначала проверяются на выборке. Базовый результат передаётся одним согласованным файлом XLSX или CSV. Интеграции через API/SFTP, целевое хранилище, BI и дополнительные работы требуют отдельного согласования. Подробнее границы описывает услуга мониторинга цен конкурентов.

Для предварительной проверки передайте список источников, обязательных полей, регионов, желаемую частоту и несколько контрольных примеров. Следующий шаг — Проверить задачу на выборке. Такая проверка не означает поддержку любого маркетплейса и не превращает учебный код в производственное решение.

Источники и границы материала

Применить к вашей задаче

Нужны регулярные данные, а не разовая выгрузка?

Покажите список товаров и источников. Проверим доступность данных и предложим структуру первого теста.

Получить оценку