Парсер

Как каталог carincasa.ru превращается в JSON, почему это устроено именно так и что делать, если понадобится пересобрать данные.

Две фазы

Парсер намеренно разделён на два независимых шага.

fetch.py   →  raw/    (ходит в сеть, запускается один раз)
parse.py   →  data/   (работает только с локальными файлами)
images.py  →  media/  (докачивает изображения по манифесту)

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

fetch.py

Скачивает три вещи:

  1. Список товаров через открытый WordPress REST API — /wp-json/wp/v2/products. Отдаёт 114 записей с slug и ссылкой; исключённые (см. ниже) сразу отбрасываются, в работу идут 113.
  2. HTML каждой карточки — 113 страниц, около 49 МБ.
  3. Листинги категорий — потому что принадлежность товара к категории в REST API отсутствует и восстанавливается только по ним.

Результат категорий сохраняется в raw/categories.json. Товар попадает в первую подходящую категорию, а список в CATEGORY_PAGES упорядочен от частного к общему: coffee_tables раньше, чем tables, иначе журнальные столики утонули бы в общей категории столов.

Повторный запуск пропускает уже скачанное, так что прерванную загрузку можно просто продолжить. Между запросами выдерживается пауза 0,3 секунды.

parse.py

Разбирает raw/*.html и пишет:

data/catalog.json           краткий список для эндпоинта /v1/products
data/products/<slug>.json   полные карточки
data/_downloads.json        манифест изображений для images.py
data/_report.json           что не распозналось

Каждая карточка перед записью валидируется схемой Pydantic из scraper/models.py. Товар без цены, без базовой группы или с price_to меньше price_from роняет сборку — это осознанно: пусть лучше упадёт на этапе разбора, чем всплывёт на демонстрации.

Исключённые товары

scraper/exclusions.py — единственное место, где записано, каких товаров в каталоге нет. Список читают обе фазы парсера (fetch.py не качает, parse.py не разбирает) и загрузчик API, чтобы карточка не всплыла из старых данных, если на диске не прибрались.

Сейчас исключён lazy-susan — он был единственным товаром категории accessories, поэтому вместе с ним из /v1/categories пропала и категория. Чтобы вернуть товар, достаточно убрать slug из списка и пересобрать каталог.

images.py

Изображения на сайте отдаются через Jetpack Photon (i0.wp.com), который умеет ресайзить на своей стороне. Поэтому вместо скачивания оригиналов запрашивается нужная ширина, а локально из неё генерируются три размера WebP: 400, 1200 и 2000 пикселей. Образцы отделок только 400 — они мелкие.

1534 исходника дают 4198 файлов и 194 МБ. Повторный запуск пропускает готовое; --force перекачивает вопреки этому, а --force elio kate — только перечисленные товары.

Прозрачность. Часть исходников — вырезанные объекты с альфа-каналом, и Photon отдаёт их как PNG с палитрой: прозрачность лежит в chunk tRNS, а не в режиме картинки. Судить о ней по режиму нельзя — в P буквы A нет, и convert("RGB") впечатывает в фон цвет прозрачной записи палитры. У carincasa это тёмно-зелёный #48704B, из-за которого первое фото у всех 113 товаров приехало с зелёным фоном. Конвертер проверяет ещё и info["transparency"], а уменьшает такие картинки с премультиплицированной альфой (RGBa), иначе цвет невидимых пикселей затекает в полупрозрачные края и даёт кайму.

Модель ценообразования

Это самая неочевидная часть, и разбиралась она не по разметке, а по исходнику: в теме сайта лежит минифицированный main.js, где за конфигуратор отвечает класс Ib с методами setSizeSelect и calculatePrice. Логика ниже — их порт, а не догадка.

Три механизма в источнике

Механизм Товаров Где лежит надбавка за материал
A 32 data-materials-prices на кнопке размера, кнопки материалов нулевые
B 73 data-price на кнопке материала
C 9 кнопка материала несёт абсолютную цену, блока размеров нет

Механизм A — столы вроде ELIO. Надбавка за керамику зависит от размера столешницы, поэтому она и хранится на размере:

<button data-price="194000" data-materials-prices="0,40000,84000">⌀ 1000</button>
<button data-price="373000" data-materials-prices="0,40000,106000">⌀ 1600</button>

Механизм C — стулья. Блока размеров нет вовсе, а цена целиком лежит на кнопке ткани:

<button data-price="75300"><span>рогожка, велюр</span><span>от 75 300 ₽</span></button>

Как отличить базу от надбавки

По подписи на кнопке: от N ₽ — абсолютная цена, +N ₽ — надбавка. Проверено на всём каталоге: 491 базовая кнопка и 282 дельтовые, ровно один базовый блок на товар, исключений нет.

Различать по названию блока нельзя: у столов базовый блок называется «Выберите Размер», у стульев — «Ткань», и оба размечены как --materials или --size без системы.

Механизмы перезаписи

При выборе размера JS переписывает data-price у кнопок материалов и опций по позиции в массиве:

// setSizeSelect, порт в _apply_override()
if (btn.hasAttribute("data-materials-prices")) {
  for (const [i, v] of btn.getAttribute("data-materials-prices").split(",").entries()) {
    if (v.toLowerCase().trim() === "skip") continue;
    materialsBtns[i]?.setAttribute("data-price", v);
  }
}

Отсюда три ловушки:

Плюс data-final-price на блоке итога перебивает весь расчёт целиком — три товара с фиксированной ценой.

Единая формула

при выборе базового варианта:
    material[i].price ← materials_prices[i]   (кроме "skip")
    option[i].price   ← options_prices[i]     (кроме "skip")

итог = база.price + материал.price + фасад.price + Σ(отмеченные опции)
       либо data-final-price, если он задан

Парсер разворачивает это в плоские таблицы price_matrix и option_prices на каждом базовом варианте, поэтому клиент считает цену одной строкой и не знает ни про позиционные массивы, ни про skip, ни про три механизма.

Проверка

Все 869 ценников каталога сверены с подписями, которые сервер отрендерил рядом на той же кнопке — расхождений ноль. Итоговые цены совпали с независимо написанным движком на всём каталоге.

Контрольный пример: ELIO, диаметр 1600, керамика в дереве, обе опции — 373000 + 106000 + 35000 + 15000 = 529000 ₽.

Разбор габаритов

Размеры на сайте — свободный текст в 25 форматах. Реальные примеры:

⌀ 1400
⌀ 1000, h - 350/450
⌀ 1000 (1000 x 1800)
1650 x 450, h - 600/700
1050 х 800                 ← кириллическая «х»
1400 (2000) X 900          ← раздвижной
d - 600, H - 450/570
Длина полок - 900 мм, Глубина - 300 мм

Порядок разбора в parse_dimensions():

  1. Кириллические х и Х приводятся к латинской x, d - к ⌀.
  2. Из строки вырезается высота (h - 600/700); если её там нет, берётся из заголовка блока (Выберите Размер, мм (высота - 750)).
  3. Если остался ⌀ — форма круглая, первое число диаметр, второе, если есть, диаметр в разложенном виде. Числа в скобках дают раздвижной размер.
  4. Иначе ищется ширина x глубина, где число в скобках — ширина в разложенном виде.
  5. Если ничего не подошло — форма other, числа складываются как есть.

Запасной источник для стульев. У стульев конфигуратор размеров не содержит, но габариты напечатаны строкой в описании: Размер, Д, Ш, В, мм: 540 / 540 / 790. Парсер использует её, когда у варианта своих размеров нет. Это добавило 33 варианта.

Итог: 485 вариантов из 495. Оставшиеся 10 — четыре товара без конфигуратора и два барных стула, у которых строки габаритов на сайте нет. Всё, что не распозналось, попадает в data/_report.json, а не исчезает молча.

Дефекты исходных данных

Найдены при разборе, оставлены как есть, но помечены.

Дефект Масштаб Что сделано
harry: размер 2400 мм стоит 35 900 ₽ при соседних 325 000 и 392 000 1 товар Помечен в price_warnings
Предварительная стоимость 180 000 ₽ захардкожена в шаблоне 110 из 113 Не парсится вовсе
Пробелы внутри чисел: 530 / 5 3 0 / 820 5 товаров Вычищаются перед разбором
U+3164 HANGUL FILLER в названиях серий отделок 9 из 19 серий Вычищается, дубликаты сливаются
Таксономия price-segment расходится с расчётом 45 товаров Не используется

Про заглушку в 180 000 ₽ стоит сказать отдельно: это статическое значение в шаблоне, которое JS перетирает при инициализации. Минимальная реальная конфигурация у ELIO — 194 000 ₽. Парсить её нельзя ни при каких условиях.

Про хангыль: у CMS названия серий набиты невидимыми символами-заполнителями, и без чистки Бизнес - Серия ㅤ ㅤ и Бизнес - Серия ㅤㅤㅤ остаются разными строками, которые в интерфейсе выглядят одинаково. После чистки 19 серий схлопнулись в 17.

Пересборка каталога

Полный цикл с нуля:

cd /srv/carincasa
./venv/bin/python scraper/fetch.py     # ~2 минуты, ходит в сеть
./venv/bin/python scraper/parse.py     # ~5 секунд
./venv/bin/python scraper/images.py    # ~10 минут при пустом media/
systemctl restart carincasa

Если правится только разбор, первая команда не нужна — raw/ уже на месте:

./venv/bin/python scraper/parse.py && systemctl restart carincasa

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

curl -s https://api.desperatemeasure.tech/v1/products \
  | python3 -c "import json,sys; print(sum(p['price_from'] or 0 for p in json.load(sys.stdin)))"

Если сайт-источник изменится

Каталог заморожен, обновлять его проект не предполагает. Но если понадобится, сломается скорее всего вот что:

Диагностика в таком случае начинается с data/_report.json: там видно, что именно перестало распознаваться.