mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 16:44:48 +00:00
wb: API v9 → v18 (WB сменил endpoint и структуру) + brand+category fallback query
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')
This commit is contained in:
parent
555c5568ff
commit
e9b0db6772
@ -16,13 +16,14 @@ from .. import proxy_pool
|
|||||||
|
|
||||||
log = logging.getLogger("zov.parser.wb")
|
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 = {
|
_DEFAULT_PARAMS = {
|
||||||
"TestGroup": "no_test",
|
"TestGroup": "no_test",
|
||||||
"TestID": "no_test",
|
"TestID": "no_test",
|
||||||
"appType": "1",
|
"appType": "1",
|
||||||
"curr": "rub",
|
"curr": "rub",
|
||||||
"dest": "-1257786", # Москва, можно поменять
|
"dest": "-1257786", # Москва (можно подменить, нам это не критично)
|
||||||
"resultset": "catalog",
|
"resultset": "catalog",
|
||||||
"sort": "popular",
|
"sort": "popular",
|
||||||
"spp": "30",
|
"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]:
|
def _generate_query_variants(query: str) -> list[str]:
|
||||||
"""Из 'Bosch Serie 4 KGN39NW00R холодильник' делаем варианты:
|
"""Из 'Bosch Serie 4 KGN39NW00R холодильник' делаем варианты от specific к general:
|
||||||
1. Bosch Serie 4 KGN39NW00R холодильник
|
1. Bosch Serie 4 KGN39NW00R холодильник (исходный)
|
||||||
2. Bosch KGN39NW00R
|
2. Bosch KGN39NW00R (brand + model)
|
||||||
3. KGN39NW00R
|
3. KGN39NW00R (только индекс)
|
||||||
4. Bosch holodilnik
|
4. Bosch холодильник (brand + category — последний шанс)
|
||||||
"""
|
"""
|
||||||
import re
|
import re
|
||||||
variants = [query]
|
variants = [query]
|
||||||
parts = query.split()
|
parts = query.split()
|
||||||
# Находим модель-индекс (с цифрами и буквами)
|
# Находим модель-индекс (буквы + цифры, длина ≥4)
|
||||||
model_idx = None
|
model_idx = None
|
||||||
for p in parts:
|
for p in parts:
|
||||||
if re.search(r"\d", p) and re.search(r"[a-zA-Z]", p) and len(p) >= 4:
|
if re.search(r"\d", p) and re.search(r"[a-zA-Z]", p) and len(p) >= 4:
|
||||||
model_idx = p
|
model_idx = p
|
||||||
break
|
break
|
||||||
brand = parts[0] if parts else ""
|
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:
|
if brand and model_idx:
|
||||||
variants.append(f"{brand} {model_idx}")
|
variants.append(f"{brand} {model_idx}")
|
||||||
variants.append(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]]:
|
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)
|
log.warning("WB JSON parse failed: %s", e)
|
||||||
return []
|
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:
|
if not products:
|
||||||
log.info("WB no products for query=%r", query)
|
log.info("WB no products for query=%r", query)
|
||||||
return []
|
return []
|
||||||
@ -121,26 +137,37 @@ def _search_wb_one(query: str, limit: int, timeout: float, max_retries: int) ->
|
|||||||
|
|
||||||
|
|
||||||
def _build_item(p: dict[str, Any]) -> dict[str, Any]:
|
def _build_item(p: dict[str, Any]) -> dict[str, Any]:
|
||||||
sale_u = p.get("salePriceU") or 0
|
"""Парсит product из WB API.
|
||||||
price_u = p.get("priceU") or 0
|
v9: salePriceU / priceU (в копейках, делим на 100)
|
||||||
# WB цена в копейках (или /100). Старое поле было в копейках, иногда в условных единицах.
|
v18: sizes[0].price.{basic, product, total} (в копейках)
|
||||||
# Делим на 100 — стандартный паттерн.
|
"""
|
||||||
price_min = (sale_u // 100) if sale_u else (price_u // 100 if price_u else None)
|
price_min = None
|
||||||
price_max = (price_u // 100) if price_u and price_u != sale_u else None
|
price_max = None
|
||||||
|
|
||||||
# Если у товара есть варианты sizes — берём минимальную цену оттуда
|
# v18: цены в sizes[].price.{product, total, basic}
|
||||||
sizes = p.get("sizes") or []
|
sizes = p.get("sizes") or []
|
||||||
if sizes:
|
|
||||||
size_prices = []
|
size_prices = []
|
||||||
for s in sizes:
|
for s in sizes:
|
||||||
sp = (s.get("price") or {}).get("product") or 0
|
pr = s.get("price") or {}
|
||||||
if sp:
|
# Приоритет: product (с учётом скидки) > total > basic
|
||||||
size_prices.append(sp // 100)
|
for fld in ("product", "total", "basic"):
|
||||||
|
v = pr.get(fld) or 0
|
||||||
|
if v:
|
||||||
|
size_prices.append(v // 100)
|
||||||
|
break
|
||||||
if size_prices:
|
if size_prices:
|
||||||
price_min = min(size_prices)
|
price_min = min(size_prices)
|
||||||
if len(size_prices) > 1:
|
if len(size_prices) > 1 and max(size_prices) != price_min:
|
||||||
price_max = max(size_prices)
|
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")
|
pid = p.get("id")
|
||||||
image_url = _build_image_url(pid) if pid else None
|
image_url = _build_image_url(pid) if pid else None
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user