diff --git a/backend-py/app/ai.py b/backend-py/app/ai.py index 0196e06..ed7e34a 100644 --- a/backend-py/app/ai.py +++ b/backend-py/app/ai.py @@ -51,12 +51,38 @@ def _get_token() -> str: SYSTEM_PROMPT_PICKER = ( "Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n" "Помогаешь менеджерам салонов согласовать с клиентом комплект техники.\n\n" - "Принципы подбора:\n" - "1. Уважай ценовой коридор. У каждой категории `price_ranges.{cat}.from..to` — попадай в него (±5%).\n" - "2. Уважай предпочтения по брендам: сначала preferred (★), потом acceptable (✓).\n" - "3. Учитывай инфраструктуру: газ исключает индукцию; нет вентиляции = только рециркуляция (угольный фильтр).\n" - "4. Учитывай приоритеты выбора (`priorities`): «цена/качество» → балансные модели; «отзывы» → проверенные хиты; «дизайн» → подбирай эстетику; «технологичность» → топовые фичи.\n" - "5. Если клиент явно отметил features в `per_cat.{cat}.features` — обязательно ставь модели с этими фичами.\n" + "═══ ВХОДНЫЕ ДАННЫЕ ═══\n" + "В `checklist` получаешь:\n" + " • `categories[]` — какие категории подбираем (fridge, hob, oven, dw, hood, microwave, coffee, washer)\n" + " • `per_cat[cat].answers{}` — иерархические ответы wizard'а по каждой категории\n" + " (install, chamber, size, features[], heat_source, subtype[], burners и т.д.)\n" + " • `per_cat[cat].notes` — заметки менеджера по категории\n" + " • `brand_strategy` — 'ai' (AI решит) | 'single' (одна марка) | 'different' (разные марки)\n" + " • `single_brand` — если brand_strategy='single', выбранная марка (или 'ai_pick')\n" + " • `brands{}` — если brand_strategy='different', по категориям: { fridge: { Bosch: 'preferred'|'acceptable'|'avoid' } }\n" + " • `budget_preset` — 'luxe' (от 1.5М₽) | 'premium' (700к-1.5М) | 'middle' (350-700к) | 'budget' (до 350к) | 'exact'\n" + " • `price_ranges{}` — если budget_preset='exact', точные коридоры от-до по категориям\n" + " • `pick_strategies[]` — стратегии (multi): 'reviews', 'balance', 'premium_brand', 'cheap', 'tech', 'style'\n" + " • `infra` — { stove: 'induction'|'gas'|'el_220'|'any', vent: 'yes'|'no'|'unknown' }\n\n" + "═══ ПРИНЦИПЫ ПОДБОРА ═══\n" + "1. **Бренд-стратегия**:\n" + " - 'single' → ВСЯ техника от одной марки (или близких из её линейки), укажи модель из этой марки\n" + " - 'different' → preferred (★) приоритет, acceptable (✓) запасной вариант, avoid (✗) ИСКЛЮЧИ\n" + " - 'ai' → подбирай оптимальный микс под бюджет/стратегию\n" + "2. **Бюджет**:\n" + " - 'exact' → попадай в price_ranges[cat].from..to (±5%)\n" + " - 'luxe' / 'premium' / 'middle' / 'budget' → сам распредели бюджет по категориям:\n" + " холодильник ~25%, варочная ~12%, духовка ~15%, ПММ ~10%, вытяжка ~8%, СВЧ ~5%, кофемашина ~15%, стиралка ~10%\n" + "3. **Стратегии подбора** (pick_strategies, multi — учитывай ВСЕ):\n" + " - 'reviews' → топ по отзывам пользователей\n" + " - 'balance' → оптимальное цена/качество\n" + " - 'premium_brand' → только премиум-имена (Miele, Gaggenau, Sub-Zero, V-ZUG, Asko)\n" + " - 'cheap' → надёжный минимум по цене\n" + " - 'tech' → топ функционал (Wi-Fi, инвертор, пар, авто-программы)\n" + " - 'style' → согласованный дизайн всей техники\n" + "4. **Инфраструктура**:\n" + " - газ исключает индукцию; нет вентиляции → только рециркуляция (угольный фильтр)\n" + "5. **Особенности (features)**: если клиент явно отметил — обязательно ставь модели с этими фичами\n" "6. ВАЖНО: каждую тех. фичу в highlights ОБЯЗАТЕЛЬНО объясняй простым языком в скобках.\n\n" "Примеры пояснений:\n" " «NoFrost (не нужно размораживать вручную)»\n" @@ -66,26 +92,40 @@ SYSTEM_PROMPT_PICKER = ( " «Термощуп (готовит до точной температуры)»\n" " «AquaStop (защита от протечек)»\n" " «Инвертор (тише и экономия ~30% электричества)»\n\n" - "Формат ответа — валидный JSON без markdown:\n" + "═══ ФОРМАТ ОТВЕТА ═══\n" + "Возвращай **3–5 моделей по КАЖДОЙ категории** (не одну!) — для клиента это выбор.\n" + "Валидный JSON без markdown, без ```:\n" "{\n" ' "summary": "1-2 предложения общего вывода",\n' - ' "items": [{\n' - ' "category": "fridge",\n' - ' "brand": "Bosch",\n' - ' "model": "Serie 4 60см",\n' - ' "price_rub": 79990,\n' - ' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n' - ' "caveats": "Глубина 660мм — на 60мм больше стандартной ниши",\n' - ' "match_score": 0.92,\n' - ' "tier_signal": "middle"\n' - " }],\n" - ' "total_price_rub": 350000,\n' + ' "by_category": {\n' + ' "fridge": {\n' + ' "models": [\n' + ' {\n' + ' "brand": "Bosch",\n' + ' "model": "Serie 4 KGN39NW00R",\n' + ' "price_min_rub": 79990,\n' + ' "price_max_rub": 92000,\n' + ' "search_query": "Bosch Serie 4 KGN39NW00R холодильник",\n' + ' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n' + ' "pros": ["тихий 38дБ", "класс A++", "стеклянные полки"],\n' + ' "cons": ["глубина 660мм — на 60мм больше ниши"],\n' + ' "tier": "middle",\n' + ' "match_score": 0.92\n' + " }\n" + " ]\n" + " }\n" + " },\n" + ' "total_price_estimate_rub": { "min": 320000, "max": 480000 },\n' ' "budget_status": "в_рамках|превышение|значительно_ниже",\n' ' "client_temperature": "premium|middle|budget|mixed",\n' ' "warnings": [],\n' ' "next_steps": []\n' "}\n\n" - "Не выдумывай несуществующие артикулы — указывай линейку (Bosch Serie 4 60см)." + "ВАЖНО:\n" + "- Не выдумывай артикулы — указывай реальные линейки/индексы (Bosch Serie 4 KGN39NW00R, не «Bosch X-200»)\n" + "- `search_query` — точная строка для поиска модели на маркетплейсе (бренд + индекс + категория)\n" + "- Если клиент выбрал brand_strategy='single' с конкретной маркой — ВСЕ models в каждой категории должны быть из этой марки\n" + "- price_min_rub / price_max_rub — диапазон цен по разным магазинам (если не уверен — поставь один и тот же)" ) diff --git a/backend-py/app/main.py b/backend-py/app/main.py index b80d16c..921e91c 100644 --- a/backend-py/app/main.py +++ b/backend-py/app/main.py @@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse from .config import get_config from .auth import verify_init_data from . import sheets, ai, telegram as tg +from .parsers import dns as parser_dns logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s") log = logging.getLogger("zov.backend") @@ -140,6 +141,18 @@ async def api_seed_admin(): return _handle_seed_admin() +@app.get("/api/parse_dns") +async def api_parse_dns(q: str = "", limit: int = 1): + """Тестовый эндпоинт парсера DNS. Пример: /api/parse_dns?q=Bosch+KGN39&limit=3""" + if not q: + return {"error": "missing_query", "hint": "use ?q="} + try: + results = parser_dns.search_dns(q, limit=min(max(1, limit), 5)) + return {"ok": True, "query": q, "count": len(results), "results": results} + except Exception as e: + return {"ok": False, "error": str(e), "query": q} + + # ================================================================= # Handlers # ================================================================= @@ -276,6 +289,14 @@ def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]: ) ai_result = ai.call_ai(user_prompt) + # Обогащение моделей DNS-парсингом + enrich_dns = body.get("enrich", True) + if enrich_dns: + try: + _enrich_ai_with_dns(ai_result) + except Exception as e: + log.warning("DNS enrich failed: %s", e) + # Update lead row with AI response sheets.update_cell_by_key("Leads", "id", lead_id, "ai_response", json.dumps(ai_result.get("json") or ai_result.get("text", ""), ensure_ascii=False)) @@ -283,11 +304,24 @@ def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]: sheets.update_cell_by_key("Leads", "id", lead_id, "ai_tokens_used", ai_result.get("tokens", 0)) sheets.update_cell_by_key("Leads", "id", lead_id, "sent_to_tg", True) - summary_text = _format_podbor_for_telegram(ai_result, client_name) + summary_text = _format_podbor_for_telegram(ai_result, client_name, lead_id) tg.send_message(tg_id, summary_text) sheets.log_event("podbor_completed", tg_id, {"id": lead_id, "tokens": ai_result.get("tokens", 0)}) - return {"ok": True, "id": lead_id, "summary": summary_text} + return {"ok": True, "id": lead_id, "summary": summary_text, "ai": ai_result.get("json")} + + +def _enrich_ai_with_dns(ai_result: dict[str, Any]) -> None: + """Берёт ai_result['json']['by_category'][cat]['models'] и обогащает каждую DNS-данными.""" + j = ai_result.get("json") + if not j or not isinstance(j, dict): + return + by_cat = j.get("by_category") or {} + for cat_key, cat_data in by_cat.items(): + if not isinstance(cat_data, dict): + continue + models = cat_data.get("models") or [] + cat_data["models"] = parser_dns.enrich_models(models, delay_sec=0.4) def _handle_test_ai() -> dict[str, Any]: @@ -334,7 +368,19 @@ def _handle_seed_admin() -> dict[str, Any]: # Helpers # ================================================================= -def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str) -> str: +_CAT_LABELS = { + "fridge": "❄️ Холодильник", + "hob": "🔥 Варочная панель", + "oven": "🔥 Духовой шкаф", + "dw": "💧 Посудомоечная", + "hood": "💨 Вытяжка", + "microwave": "📻 СВЧ", + "coffee": "☕ Кофемашина", + "washer": "🧺 Стиральная машина", +} + + +def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str, lead_id: str = "") -> str: if ai_result.get("error"): return f"❌ Не удалось получить подбор от AI.\n{ai_result.get('text', '')}" j = ai_result.get("json") @@ -349,20 +395,57 @@ def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str) -> lines.append(j["summary"]) lines.append("") - for item in (j.get("items") or []): - lines.append(f"{item.get('brand', '')} {item.get('model', '')}") - if item.get("price_rub"): - lines.append(f"💰 {_format_price(item['price_rub'])} ₽") - if item.get("highlights"): - lines.append("✓ " + ", ".join(item["highlights"])) - if item.get("caveats"): - lines.append(f"⚠️ {item['caveats']}") - lines.append("") + # Новая структура: by_category + by_cat = j.get("by_category") or {} + if by_cat: + for cat_key, cat_data in by_cat.items(): + cat_label = _CAT_LABELS.get(cat_key, cat_key.upper()) + lines.append(f"━━━ {cat_label} ━━━") + models = (cat_data or {}).get("models") or [] + for i, m in enumerate(models, 1): + lines.append(f"{i}. {m.get('brand', '')} {m.get('model', '')}") + pmin = m.get("price_min_rub") + pmax = m.get("price_max_rub") + if pmin and pmax and pmin != pmax: + lines.append(f"💰 {_format_price(pmin)} — {_format_price(pmax)} ₽") + elif pmin: + lines.append(f"💰 {_format_price(pmin)} ₽") + if m.get("highlights"): + lines.append("✓ " + ", ".join(m["highlights"])) + if m.get("pros"): + lines.append("⊕ " + "; ".join(m["pros"][:3])) + if m.get("cons"): + lines.append("⊖ " + "; ".join(m["cons"][:2])) + lines.append("") + lines.append("") + else: + # Fallback: старая структура items[] + for item in (j.get("items") or []): + lines.append(f"{item.get('brand', '')} {item.get('model', '')}") + if item.get("price_rub"): + lines.append(f"💰 {_format_price(item['price_rub'])} ₽") + if item.get("highlights"): + lines.append("✓ " + ", ".join(item["highlights"])) + if item.get("caveats"): + lines.append(f"⚠️ {item['caveats']}") + lines.append("") - if j.get("total_price_rub"): + # Итого + tpe = j.get("total_price_estimate_rub") or {} + if isinstance(tpe, dict) and (tpe.get("min") or tpe.get("max")): + tmin = tpe.get("min", 0) + tmax = tpe.get("max", 0) + if tmin and tmax and tmin != tmax: + lines.append(f"ИТОГО: {_format_price(tmin)} — {_format_price(tmax)} ₽ · {j.get('budget_status', '')}") + else: + lines.append(f"ИТОГО: {_format_price(tmin or tmax)} ₽ · {j.get('budget_status', '')}") + elif j.get("total_price_rub"): lines.append(f"ИТОГО: {_format_price(j['total_price_rub'])} ₽ · {j.get('budget_status', '')}") + if j.get("warnings"): lines.append("\n⚠️ " + "; ".join(j["warnings"])) + if lead_id: + lines.append(f"\nID: {lead_id[:8]}") return "\n".join(lines) diff --git a/backend-py/app/parsers/__init__.py b/backend-py/app/parsers/__init__.py new file mode 100644 index 0000000..30c3b74 --- /dev/null +++ b/backend-py/app/parsers/__init__.py @@ -0,0 +1,27 @@ +"""Парсеры маркетплейсов для обогащения карточек моделей. + +Подход MVP: парсим публичные HTML-страницы напрямую с VPS (без прокси). +При обнаружении anti-bot блокировок — переходим на резидентные прокси (Proxy6). + +Источники: +- dns.py — DNS Shop (dns-shop.ru) — самый простой anti-bot, основной источник характеристик +- yamarket.py — Я.Маркет (market.yandex.ru) — для сравнения цен между магазинами +- wildberries.py — Wildberries (wildberries.ru) — для отзывов и рейтингов + +Унифицированный формат результата: +{ + "title": str, # Название как на странице + "url": str, # Ссылка на товар + "image_url": str | None, # URL основного фото + "price_min_rub": int | None, # Минимальная найденная цена + "price_max_rub": int | None, # Максимальная (если есть данные по нескольким магазинам) + "rating": float | None, # 0.0 - 5.0 + "reviews_count": int | None, # Кол-во отзывов + "stores_count": int | None, # На скольких сайтах найдено (Я.Маркет) + "specs": dict[str, str], # Ключевые характеристики + "source": str, # "dns" / "yamarket" / "wildberries" +} +""" +from .dns import search_dns + +__all__ = ["search_dns"] diff --git a/backend-py/app/parsers/dns.py b/backend-py/app/parsers/dns.py new file mode 100644 index 0000000..81b59bf --- /dev/null +++ b/backend-py/app/parsers/dns.py @@ -0,0 +1,236 @@ +"""Парсер DNS Shop (dns-shop.ru) — MVP без anti-bot защиты. + +DNS отдаёт классический HTML с серверным рендерингом + AJAX-цены через +GraphQL. Для нашего MVP достаточно поисковой страницы — там есть title, +URL, картинка и цена в data-атрибутах карточки товара. + +Если DNS изменит вёрстку — селекторы ниже придётся обновить. +""" +from __future__ import annotations +import logging +import re +import time +from typing import Any +from urllib.parse import quote_plus + +import httpx +from bs4 import BeautifulSoup + +log = logging.getLogger("zov.parser.dns") + +_BASE_URL = "https://www.dns-shop.ru" +_SEARCH_URL = "https://www.dns-shop.ru/search/" + +# Реалистичный User-Agent (свежий Chrome on Windows) +_HEADERS = { + "User-Agent": ( + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/130.0.0.0 Safari/537.36" + ), + "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8", + "Accept-Language": "ru-RU,ru;q=0.9,en;q=0.8", + "Accept-Encoding": "gzip, deflate, br", + "Connection": "keep-alive", + "Upgrade-Insecure-Requests": "1", + "Sec-Fetch-Dest": "document", + "Sec-Fetch-Mode": "navigate", + "Sec-Fetch-Site": "none", + "Sec-Fetch-User": "?1", +} + +_PRICE_RE = re.compile(r"(\d[\d\s]*)\s*₽") + + +def search_dns(query: str, limit: int = 1, timeout: float = 12.0) -> list[dict[str, Any]]: + """Поиск товара на DNS по строке запроса. + + Возвращает список результатов (топ-N). Каждый элемент — унифицированный + формат (см. parsers/__init__.py). Пустой список при ошибке. + """ + url = f"{_SEARCH_URL}?q={quote_plus(query)}" + log.info("DNS search: %s", url) + + try: + with httpx.Client(headers=_HEADERS, timeout=timeout, follow_redirects=True) as client: + resp = client.get(url) + except httpx.HTTPError as e: + log.warning("DNS request failed: %s", e) + return [] + + if resp.status_code != 200: + log.warning("DNS returned %s for query=%r", resp.status_code, query) + return [] + + if "challenge" in resp.text.lower() or "captcha" in resp.text.lower(): + log.warning("DNS anti-bot challenge detected for query=%r", query) + return [] + + return _parse_search_html(resp.text, limit=limit) + + +def _parse_search_html(html: str, limit: int) -> list[dict[str, Any]]: + soup = BeautifulSoup(html, "html.parser") + results: list[dict[str, Any]] = [] + + # DNS использует разные шаблоны карточек. Пробуем несколько селекторов. + candidates = ( + soup.select("div.catalog-product") + or soup.select("[data-product-card]") + or soup.select("div.product-buy") + ) + + for card in candidates: + if len(results) >= limit: + break + item = _extract_card(card) + if item: + results.append(item) + + if not results: + # Резерв: попытаемся достать товар из JSON-LD + for script in soup.find_all("script", type="application/ld+json"): + data = _try_json(script.string or "") + if not data: + continue + items = data if isinstance(data, list) else [data] + for d in items: + if isinstance(d, dict) and d.get("@type") == "Product": + results.append({ + "title": d.get("name") or "", + "url": d.get("url") or "", + "image_url": (d.get("image") or [None])[0] if isinstance(d.get("image"), list) else d.get("image"), + "price_min_rub": _try_int((d.get("offers") or {}).get("price")), + "price_max_rub": None, + "rating": _try_float((d.get("aggregateRating") or {}).get("ratingValue")), + "reviews_count": _try_int((d.get("aggregateRating") or {}).get("reviewCount")), + "stores_count": None, + "specs": {}, + "source": "dns", + }) + if len(results) >= limit: + break + if len(results) >= limit: + break + + return results + + +def _extract_card(card) -> dict[str, Any] | None: + """Извлекает данные карточки товара из произвольного блока.""" + # Заголовок и ссылка + link_el = ( + card.select_one("a.catalog-product__name") + or card.select_one("a.product-buy__title") + or card.select_one("a[href*='/product/']") + ) + if not link_el: + return None + title = link_el.get_text(strip=True) or link_el.get("title") or "" + href = link_el.get("href") or "" + url = href if href.startswith("http") else f"{_BASE_URL}{href}" + + # Цена + price = None + price_el = ( + card.select_one(".product-buy__price") + or card.select_one("[data-price]") + or card.select_one(".product-min-price__current") + ) + if price_el: + # data-price атрибут — самый надёжный + dp = price_el.get("data-price") or price_el.get("data-product-price") + if dp: + price = _try_int(dp) + if not price: + m = _PRICE_RE.search(price_el.get_text(" ", strip=True)) + if m: + price = _try_int(m.group(1).replace(" ", "")) + + # Изображение + img_url = None + img_el = card.select_one("img.catalog-product__image, img.loaded-product__image, img[data-src], img[src]") + if img_el: + img_url = img_el.get("data-src") or img_el.get("src") or img_el.get("data-original") + if img_url and img_url.startswith("//"): + img_url = "https:" + img_url + + # Рейтинг и кол-во отзывов + rating = None + rating_el = card.select_one(".catalog-product__rating, [data-rating]") + if rating_el: + rating = _try_float(rating_el.get("data-rating") or rating_el.get_text(strip=True)) + + reviews = None + reviews_el = card.select_one(".catalog-product__reviews, [data-reviews]") + if reviews_el: + m = re.search(r"\d+", reviews_el.get_text(" ", strip=True)) + if m: + reviews = int(m.group(0)) + + if not title: + return None + + return { + "title": title, + "url": url, + "image_url": img_url, + "price_min_rub": price, + "price_max_rub": price, # DNS показывает одну цену + "rating": rating, + "reviews_count": reviews, + "stores_count": 1, + "specs": {}, + "source": "dns", + } + + +def _try_int(v: Any) -> int | None: + if v is None: + return None + try: + s = str(v).strip().replace(" ", "").replace(" ", "").replace(",", ".") + # Цена может быть строкой "79990" или "79990.00" + return int(float(s)) + except (ValueError, TypeError): + return None + + +def _try_float(v: Any) -> float | None: + if v is None: + return None + try: + return float(str(v).strip().replace(",", ".")) + except (ValueError, TypeError): + return None + + +def _try_json(s: str) -> Any: + import json + try: + return json.loads(s) + except (ValueError, TypeError): + return None + + +def enrich_models(models: list[dict[str, Any]], delay_sec: float = 0.5) -> list[dict[str, Any]]: + """Обогащает список моделей данными с DNS. + + На входе: список моделей от AI с полем `search_query` (или brand+model). + На выходе: те же модели + ключи `dns: {...}` с парсингом. + """ + enriched: list[dict[str, Any]] = [] + for i, m in enumerate(models): + q = m.get("search_query") or f"{m.get('brand', '')} {m.get('model', '')}".strip() + if not q: + enriched.append({**m, "dns": None}) + continue + try: + results = search_dns(q, limit=1) + except Exception as e: + log.warning("DNS enrich failed for %r: %s", q, e) + results = [] + enriched.append({**m, "dns": results[0] if results else None}) + if i < len(models) - 1 and delay_sec > 0: + time.sleep(delay_sec) # вежливая задержка между запросами + return enriched diff --git a/backend-py/requirements.txt b/backend-py/requirements.txt index 44ee7de..75e408b 100644 --- a/backend-py/requirements.txt +++ b/backend-py/requirements.txt @@ -5,3 +5,5 @@ httpx>=0.27.0 gspread>=6.0.0 google-auth>=2.30.0 python-dotenv>=1.0.0 +beautifulsoup4>=4.12.0 +lxml>=5.2.0