Парсер цен на 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.html — 97beab290e8cfd1ab2ebf249807ab6a8cfc26cdd4234a08cdc820cb3c0f2d211; parser.py — bcc9c9691b3530dd3baef152ff879825172d276a63b047a4ae6d1f92b6df25e1; expected-output.json — b394effd643ea6918a635492cfce77ad82f99b4952bf4a01507adf9b69505275; test_parser.py — f92e1ab1466b27ff68c446741f6f70ee6580977b83809fe809ee6605128542f8; verify.py — feddf566925719d1db1f475c3f2e160de4845c7db51c5ebdb47391bd9af07d91.
Эти хеши подтверждают только точную проверенную версию файлов. Они не доказывают пригодность к другому HTML, источнику или расписанию.
Почему прототип ещё не стал регулярным мониторингом
| Контур | Локальный прототип | Регулярный процесс |
|---|---|---|
| Источник | Один контролируемый файл | Согласованные источники после проверки допустимости и выборки |
| Схема | Семь учебных полей | Версионируемая проектная схема с обязательными и условными полями |
| Проверки | Поля, цена, URL, дубль и время | Проектные типы, диапазоны, сопоставление, свежесть и проверяемость результата |
| Изменения | Одна испорченная метка в фикстуре | Обнаружение изменения источника, регрессионные тесты и согласованное восстановление |
| Сопоставление | Только дубль SKU | Точные товары, варианты, аналоги и неоднозначности по согласованным правилам |
| Расписание | Ручная команда с фиксированным временем | Согласованная частота, наблюдение за выполнением и владелец исключений |
| Доставка | Локальный JSON | Один согласованный файл XLSX/CSV в базовом контуре; интеграционные каналы и BI — отдельный объём |
Поэтому нельзя взять селектор, поставить его в расписание и объявить систему готовой. До регулярного запуска нужно подготовить данные для мониторинга цен: каталог, источники, условия наблюдения, типы совпадений, исключения и приёмку.
Чек-лист перед переходом к рабочему контуру
- Подтвердить источник, допустимый способ доступа и доступные поля на небольшой выборке.
- Версионировать схему и разделить обязательные и условные значения.
- Согласовать точное совпадение, вариант, аналог и неоднозначность.
- Сохранять ссылку, регион, продавца и время проверки там, где они поддерживаются.
- Отделять пропуски, неверные типы, дубли и ошибки источника от готовых данных.
- Иметь контролируемые фикстуры и регрессионные тесты для изменений разметки.
- Назвать владельцев источника, исключений, схемы и коммерческого решения.
- Согласовать частоту, хранение и формат доставки без универсального SLA.
ScanHub рассматривает такой контур как задачу, где источники, товары, регионы, поля, частота, сопоставление и результат сначала проверяются на выборке. Базовый результат передаётся одним согласованным файлом XLSX или CSV. Интеграции через API/SFTP, целевое хранилище, BI и дополнительные работы требуют отдельного согласования. Подробнее границы описывает услуга мониторинга цен конкурентов.
Для предварительной проверки передайте список источников, обязательных полей, регионов, желаемую частоту и несколько контрольных примеров. Следующий шаг — Проверить задачу на выборке. Такая проверка не означает поддержку любого маркетплейса и не превращает учебный код в производственное решение.
Источники и границы материала
- Python 3.12: html.parser, Python Software Foundation, обновлено 7 марта 2026 года; проверено 29 июля 2026 года.
- Python 3.12: decimal, Python Software Foundation, обновлено 7 марта 2026 года; проверено 29 июля 2026 года.
- Python 3.12: unittest, Python Software Foundation, обновлено 7 марта 2026 года; проверено 29 июля 2026 года.
- Границы мониторинга цен ScanHub; проверено 29 июля 2026 года.
Применить к вашей задаче
Нужны регулярные данные, а не разовая выгрузка?
Покажите список товаров и источников. Проверим доступность данных и предложим структуру первого теста.