Парсер
Как каталог carincasa.ru превращается в JSON, почему это устроено именно так и что делать, если понадобится пересобрать данные.
Две фазы
Парсер намеренно разделён на два независимых шага.
fetch.py → raw/ (ходит в сеть, запускается один раз)
parse.py → data/ (работает только с локальными файлами)
images.py → media/ (докачивает изображения по манифесту)
Причина в том, что парсер не заработал с первого раза и не заработает у любого, кто возьмётся его менять: вёрстка у столов, стульев, зеркал и комодов отличается. Разделение означает, что каждая итерация правки разбора стоит ноль запросов к чужому сайту и ноль ожидания. Сырой HTML остаётся на диске, даже если сайт завтра переделают.
fetch.py
Скачивает три вещи:
- Список товаров через открытый WordPress REST API —
/wp-json/wp/v2/products. Отдаёт 114 записей сslugи ссылкой; исключённые (см. ниже) сразу отбрасываются, в работу идут 113. - HTML каждой карточки — 113 страниц, около 49 МБ.
- Листинги категорий — потому что принадлежность товара к категории в 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-options-prices— тот же приём для опций. 218 применений. Цена опции тоже зависит от размера.- Литерал
skipозначает «эту позицию не трогать». Числом не парсится и роняет наивный разбор:"49500,55000,skip". - Пустая позиция эквивалентна нулю, потому что в JavaScript
Number("") === 0. Встречается как"0,15000,".
Плюс 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():
- Кириллические
хиХприводятся к латинскойx,d -к⌀. - Из строки вырезается высота (
h - 600/700); если её там нет, берётся из заголовка блока (Выберите Размер, мм (высота - 750)). - Если остался
⌀— форма круглая, первое число диаметр, второе, если есть, диаметр в разложенном виде. Числа в скобках дают раздвижной размер. - Иначе ищется
ширина x глубина, где число в скобках — ширина в разложенном виде. - Если ничего не подошло — форма
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)))"
Если сайт-источник изменится
Каталог заморожен, обновлять его проект не предполагает. Но если понадобится, сломается скорее всего вот что:
- Имена классов. Разбор держится на
product__about-configurator-*иproduct__materials-*. При редизайне темы поменяются они. - Механизмы цен. Если добавится четвёртый способ, признак
от N ₽против+N ₽перестанет быть достаточным. - Photon. Если Jetpack отключат, изменится схема адресов изображений.
Диагностика в таком случае начинается с data/_report.json: там видно,
что именно перестало распознаваться.