From e9b0db6772384044f0397fc0539c68aded658242 Mon Sep 17 00:00:00 2001 From: wasrusgen Date: Mon, 11 May 2026 22:59:14 +0300 Subject: [PATCH] =?UTF-8?q?wb:=20API=20v9=20=E2=86=92=20v18=20(WB=20=D1=81?= =?UTF-8?q?=D0=BC=D0=B5=D0=BD=D0=B8=D0=BB=20endpoint=20=D0=B8=20=D1=81?= =?UTF-8?q?=D1=82=D1=80=D1=83=D0=BA=D1=82=D1=83=D1=80=D1=83)=20+=20brand+c?= =?UTF-8?q?ategory=20fallback=20query?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit DISCOVERED in real test: - WB API v9 (/exactmatch/ru/common/v9/search) теперь возвращает только метаданные (name, query, shardKey, filters, search_result={}) — products пусто - WB API v18 (/exactmatch/ru/common/v18/search) — рабочий Структура: {metadata, products, total} — products НА ВЕРХНЕМ уровне (не data.products) - Подтверждено: query='Haier холодильник' → 100 products via v18 CHANGES: 1. _SEARCH_URL → v18 endpoint 2. Парсинг products: сначала data.products (legacy fallback), потом products top-level 3. _build_item: цены теперь читаются из sizes[].price.{product, total, basic} (v18 формат), с fallback на priceU/salePriceU (v9 legacy) 4. _generate_query_variants: добавлен brand+category fallback ('Bosch холодильник' если не нашли по модели) TEST: Haier холодильник → 100 results (first: 'Холодильник двухкамерный C2F619CFU1') --- backend-py/app/parsers/wb.py | 81 ++++++++++++++++++++++++------------ 1 file changed, 54 insertions(+), 27 deletions(-) diff --git a/backend-py/app/parsers/wb.py b/backend-py/app/parsers/wb.py index ca0c768..b50f6a9 100644 --- a/backend-py/app/parsers/wb.py +++ b/backend-py/app/parsers/wb.py @@ -16,13 +16,14 @@ from .. import proxy_pool log = logging.getLogger("zov.parser.wb") -_SEARCH_URL = "https://search.wb.ru/exactmatch/ru/common/v9/search" +# WB обновил API: v9 → v18 (2026). Новый формат: products на верхнем уровне. +_SEARCH_URL = "https://search.wb.ru/exactmatch/ru/common/v18/search" _DEFAULT_PARAMS = { "TestGroup": "no_test", "TestID": "no_test", "appType": "1", "curr": "rub", - "dest": "-1257786", # Москва, можно поменять + "dest": "-1257786", # Москва (можно подменить, нам это не критично) "resultset": "catalog", "sort": "popular", "spp": "30", @@ -54,26 +55,40 @@ def search_wb(query: str, limit: int = 3, timeout: float = 12.0, def _generate_query_variants(query: str) -> list[str]: - """Из 'Bosch Serie 4 KGN39NW00R холодильник' делаем варианты: - 1. Bosch Serie 4 KGN39NW00R холодильник - 2. Bosch KGN39NW00R - 3. KGN39NW00R - 4. Bosch holodilnik + """Из 'Bosch Serie 4 KGN39NW00R холодильник' делаем варианты от specific к general: + 1. Bosch Serie 4 KGN39NW00R холодильник (исходный) + 2. Bosch KGN39NW00R (brand + model) + 3. KGN39NW00R (только индекс) + 4. Bosch холодильник (brand + category — последний шанс) """ import re variants = [query] parts = query.split() - # Находим модель-индекс (с цифрами и буквами) + # Находим модель-индекс (буквы + цифры, длина ≥4) model_idx = None for p in parts: if re.search(r"\d", p) and re.search(r"[a-zA-Z]", p) and len(p) >= 4: model_idx = p break brand = parts[0] if parts else "" + + # Категории-ключевые слова на русском + cat_words = { + "холодильник", "холодильника", "варочная", "духовой", "духовка", "плита", + "посудомоечная", "вытяжка", "микроволновая", "свч", "кофемашина", "стиральная", + "морозильник", "морозильная", + } + cat_in_query = next((w for w in parts if w.lower() in cat_words), None) + if brand and model_idx: variants.append(f"{brand} {model_idx}") variants.append(model_idx) - return list(dict.fromkeys(variants)) # дедуп с сохранением порядка + + # Brand + category — широкий fallback + if brand and cat_in_query and f"{brand} {cat_in_query}" not in variants: + variants.append(f"{brand} {cat_in_query}") + + return list(dict.fromkeys(variants)) def _search_wb_one(query: str, limit: int, timeout: float, max_retries: int) -> list[dict[str, Any]]: @@ -109,7 +124,8 @@ def _search_wb_one(query: str, limit: int, timeout: float, max_retries: int) -> log.warning("WB JSON parse failed: %s", e) return [] - products = (data.get("data") or {}).get("products") or [] + # WB v18: products на верхнем уровне; v9 (legacy fallback): data.products + products = data.get("products") or (data.get("data") or {}).get("products") or [] if not products: log.info("WB no products for query=%r", query) return [] @@ -121,25 +137,36 @@ def _search_wb_one(query: str, limit: int, timeout: float, max_retries: int) -> def _build_item(p: dict[str, Any]) -> dict[str, Any]: - sale_u = p.get("salePriceU") or 0 - price_u = p.get("priceU") or 0 - # WB цена в копейках (или /100). Старое поле было в копейках, иногда в условных единицах. - # Делим на 100 — стандартный паттерн. - price_min = (sale_u // 100) if sale_u else (price_u // 100 if price_u else None) - price_max = (price_u // 100) if price_u and price_u != sale_u else None + """Парсит product из WB API. + v9: salePriceU / priceU (в копейках, делим на 100) + v18: sizes[0].price.{basic, product, total} (в копейках) + """ + price_min = None + price_max = None - # Если у товара есть варианты sizes — берём минимальную цену оттуда + # v18: цены в sizes[].price.{product, total, basic} sizes = p.get("sizes") or [] - if sizes: - size_prices = [] - for s in sizes: - sp = (s.get("price") or {}).get("product") or 0 - if sp: - size_prices.append(sp // 100) - if size_prices: - price_min = min(size_prices) - if len(size_prices) > 1: - price_max = max(size_prices) + size_prices = [] + for s in sizes: + pr = s.get("price") or {} + # Приоритет: product (с учётом скидки) > total > basic + for fld in ("product", "total", "basic"): + v = pr.get(fld) or 0 + if v: + size_prices.append(v // 100) + break + if size_prices: + price_min = min(size_prices) + if len(size_prices) > 1 and max(size_prices) != price_min: + price_max = max(size_prices) + + # v9 fallback — только если ничего не нашли в sizes + if price_min is None: + sale_u = p.get("salePriceU") or 0 + price_u = p.get("priceU") or 0 + price_min = (sale_u // 100) if sale_u else (price_u // 100 if price_u else None) + if price_u and price_u != sale_u: + price_max = price_u // 100 pid = p.get("id") image_url = _build_image_url(pid) if pid else None