Блог

Парсер цен на 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 Результат Смысл проверки
Корректная запись Все обязательные поля Принята Запись соответствует объявленной схеме пр