Парсер
Как каталог 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и ссылкой. - HTML каждой карточки — 114 страниц, около 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 роняет сборку — это осознанно: пусть лучше упадёт
на этапе разбора, чем всплывёт на демонстрации.
images.py
Изображения на сайте отдаются через Jetpack Photon (i0.wp.com), который
умеет ресайзить на своей стороне. Поэтому вместо скачивания оригиналов
запрашивается нужная ширина, а локально из неё генерируются три размера
WebP: 400, 1200 и 2000 пикселей. Образцы отделок только 400 — они мелкие.
1543 исходника дают 4225 файлов и 189 МБ. Повторный запуск пропускает готовое.
Модель ценообразования
Это самая неочевидная часть, и разбиралась она не по разметке, а по исходнику:
в теме сайта лежит минифицированный 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 ценников каталога сверены с подписями, которые сервер отрендерил
рядом на той же кнопке — расхождений ноль. Итоговые цены совпали
с независимо написанным движком у 113 товаров из 114; единственное
расхождение — lazy-susan, где парсер ставит «цена по запросу» вместо нуля.
Контрольный пример: 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 из 114 | Не парсится вовсе |
Пробелы внутри чисел: 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: там видно,
что именно перестало распознаваться.