mirror of
https://github.com/wasrusgen/zov-tech.git
synced 2026-06-03 15:44:47 +00:00
backend: new state-shape AI prompt + DNS parser MVP
AI PROMPT (ai.py):
- Документирует новую форму checklist (per_cat.answers, brand_strategy, single_brand, brands, budget_preset, pick_strategies)
- Просит вернуть 3-5 моделей по КАЖДОЙ категории (не одну)
- Новый формат ответа: by_category[cat].models[] с brand/model/price_min/price_max/search_query/pros/cons/tier
- Подробные правила для бренд-стратегий (single → вся техника одной марки; different → preferred/acceptable/avoid)
- Бюджет-пресеты с авто-распределением по категориям (fridge ~25%, hob ~12% и т.д.)
DNS PARSER (parsers/dns.py):
- search_dns(query, limit) — HTTP + BeautifulSoup
- Реалистичный User-Agent, фолбэк на JSON-LD если HTML-селекторы не сработали
- enrich_models(models) — обогащает список моделей от AI, добавляя dns: {title, price, image, url, rating, reviews}
- Вежливая задержка 0.4с между запросами
MAIN.PY:
- /api/parse_dns?q=... — тестовый эндпоинт для проверки парсера
- _handle_podbor теперь после AI вызывает _enrich_ai_with_dns для каждой модели
- _format_podbor_for_telegram переписан под новый формат by_category — выводит 3-5 моделей в каждой категории с pros/cons
- Fallback на старый формат items[] для совместимости
REQUIREMENTS:
- + beautifulsoup4 >= 4.12
- + lxml >= 5.2
DEPLOY: после пуша на VPS нужно пересобрать backend контейнер (docker compose up --build -d backend)
This commit is contained in:
parent
717c6ea138
commit
64edb76035
@ -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 — диапазон цен по разным магазинам (если не уверен — поставь один и тот же)"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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=<search>"}
|
||||
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"<b>{item.get('brand', '')} {item.get('model', '')}</b>")
|
||||
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"━━━ <b>{cat_label}</b> ━━━")
|
||||
models = (cat_data or {}).get("models") or []
|
||||
for i, m in enumerate(models, 1):
|
||||
lines.append(f"<b>{i}. {m.get('brand', '')} {m.get('model', '')}</b>")
|
||||
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"<b>{item.get('brand', '')} {item.get('model', '')}</b>")
|
||||
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"<b>ИТОГО: {_format_price(tmin)} — {_format_price(tmax)} ₽</b> · {j.get('budget_status', '')}")
|
||||
else:
|
||||
lines.append(f"<b>ИТОГО: {_format_price(tmin or tmax)} ₽</b> · {j.get('budget_status', '')}")
|
||||
elif j.get("total_price_rub"):
|
||||
lines.append(f"<b>ИТОГО: {_format_price(j['total_price_rub'])} ₽</b> · {j.get('budget_status', '')}")
|
||||
|
||||
if j.get("warnings"):
|
||||
lines.append("\n⚠️ " + "; ".join(j["warnings"]))
|
||||
if lead_id:
|
||||
lines.append(f"\n<i>ID: {lead_id[:8]}</i>")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
|
||||
27
backend-py/app/parsers/__init__.py
Normal file
27
backend-py/app/parsers/__init__.py
Normal file
@ -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"]
|
||||
236
backend-py/app/parsers/dns.py
Normal file
236
backend-py/app/parsers/dns.py
Normal file
@ -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
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user