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