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:
wasrusgen 2026-05-11 11:42:37 +03:00
parent 717c6ea138
commit 64edb76035
5 changed files with 420 additions and 32 deletions

View File

@ -51,12 +51,38 @@ def _get_token() -> str:
SYSTEM_PROMPT_PICKER = ( SYSTEM_PROMPT_PICKER = (
"Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n" "Ты — эксперт-консультант по подбору кухонной техники для фабрики мебели «ЗОВ».\n"
"Помогаешь менеджерам салонов согласовать с клиентом комплект техники.\n\n" "Помогаешь менеджерам салонов согласовать с клиентом комплект техники.\n\n"
"Принципы подбора:\n" "═══ ВХОДНЫЕ ДАННЫЕ ═══\n"
"1. Уважай ценовой коридор. У каждой категории `price_ranges.{cat}.from..to` — попадай в него (±5%).\n" "В `checklist` получаешь:\n"
"2. Уважай предпочтения по брендам: сначала preferred (★), потом acceptable (✓).\n" " • `categories[]` — какие категории подбираем (fridge, hob, oven, dw, hood, microwave, coffee, washer)\n"
"3. Учитывай инфраструктуру: газ исключает индукцию; нет вентиляции = только рециркуляция (угольный фильтр).\n" " • `per_cat[cat].answers{}` — иерархические ответы wizard'а по каждой категории\n"
"4. Учитывай приоритеты выбора (`priorities`): «цена/качество» → балансные модели; «отзывы» → проверенные хиты; «дизайн» → подбирай эстетику; «технологичность» → топовые фичи.\n" " (install, chamber, size, features[], heat_source, subtype[], burners и т.д.)\n"
"5. Если клиент явно отметил features в `per_cat.{cat}.features` — обязательно ставь модели с этими фичами.\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" "6. ВАЖНО: каждую тех. фичу в highlights ОБЯЗАТЕЛЬНО объясняй простым языком в скобках.\n\n"
"Примеры пояснений:\n" "Примеры пояснений:\n"
" «NoFrost (не нужно размораживать вручную)»\n" " «NoFrost (не нужно размораживать вручную)»\n"
@ -66,26 +92,40 @@ SYSTEM_PROMPT_PICKER = (
" «Термощуп (готовит до точной температуры)»\n" " «Термощуп (готовит до точной температуры)»\n"
" «AquaStop (защита от протечек)»\n" " «AquaStop (защита от протечек)»\n"
" «Инвертор (тише и экономия ~30% электричества)»\n\n" " «Инвертор (тише и экономия ~30% электричества)»\n\n"
"Формат ответа — валидный JSON без markdown:\n" "═══ ФОРМАТ ОТВЕТА ═══\n"
"Возвращай **35 моделей по КАЖДОЙ категории** (не одну!) — для клиента это выбор.\n"
"Валидный JSON без markdown, без ```:\n"
"{\n" "{\n"
' "summary": "1-2 предложения общего вывода",\n' ' "summary": "1-2 предложения общего вывода",\n'
' "items": [{\n' ' "by_category": {\n'
' "category": "fridge",\n' ' "fridge": {\n'
' "models": [\n'
' {\n'
' "brand": "Bosch",\n' ' "brand": "Bosch",\n'
' "model": "Serie 4 60см",\n' ' "model": "Serie 4 KGN39NW00R",\n'
' "price_rub": 79990,\n' ' "price_min_rub": 79990,\n'
' "price_max_rub": 92000,\n'
' "search_query": "Bosch Serie 4 KGN39NW00R холодильник",\n'
' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n' ' "highlights": ["NoFrost (не нужно размораживать)", "Инвертор (тише и экономия ~30%)"],\n'
' "caveats": "Глубина 660мм — на 60мм больше стандартной ниши",\n' ' "pros": ["тихий 38дБ", "класс A++", "стеклянные полки"],\n'
' "match_score": 0.92,\n' ' "cons": ["глубина 660мм — на 60мм больше ниши"],\n'
' "tier_signal": "middle"\n' ' "tier": "middle",\n'
" }],\n" ' "match_score": 0.92\n'
' "total_price_rub": 350000,\n' " }\n"
" ]\n"
" }\n"
" },\n"
' "total_price_estimate_rub": { "min": 320000, "max": 480000 },\n'
' "budget_status": "в_рамках|превышение|значительно_ниже",\n' ' "budget_status": "в_рамках|превышение|значительно_ниже",\n'
' "client_temperature": "premium|middle|budget|mixed",\n' ' "client_temperature": "premium|middle|budget|mixed",\n'
' "warnings": [],\n' ' "warnings": [],\n'
' "next_steps": []\n' ' "next_steps": []\n'
"}\n\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 — диапазон цен по разным магазинам (если не уверен — поставь один и тот же)"
) )

View File

@ -12,6 +12,7 @@ from fastapi.responses import JSONResponse
from .config import get_config from .config import get_config
from .auth import verify_init_data from .auth import verify_init_data
from . import sheets, ai, telegram as tg 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") logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(name)s: %(message)s")
log = logging.getLogger("zov.backend") log = logging.getLogger("zov.backend")
@ -140,6 +141,18 @@ async def api_seed_admin():
return _handle_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 # Handlers
# ================================================================= # =================================================================
@ -276,6 +289,14 @@ def _handle_podbor(body: dict[str, Any]) -> dict[str, Any]:
) )
ai_result = ai.call_ai(user_prompt) 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 # Update lead row with AI response
sheets.update_cell_by_key("Leads", "id", lead_id, "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)) 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, "ai_tokens_used", ai_result.get("tokens", 0))
sheets.update_cell_by_key("Leads", "id", lead_id, "sent_to_tg", True) 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) tg.send_message(tg_id, summary_text)
sheets.log_event("podbor_completed", tg_id, {"id": lead_id, "tokens": ai_result.get("tokens", 0)}) 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]: def _handle_test_ai() -> dict[str, Any]:
@ -334,7 +368,19 @@ def _handle_seed_admin() -> dict[str, Any]:
# Helpers # 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"): if ai_result.get("error"):
return f"Не удалось получить подбор от AI.\n{ai_result.get('text', '')}" return f"Не удалось получить подбор от AI.\n{ai_result.get('text', '')}"
j = ai_result.get("json") j = ai_result.get("json")
@ -349,6 +395,31 @@ def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str) ->
lines.append(j["summary"]) lines.append(j["summary"])
lines.append("") 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 []): for item in (j.get("items") or []):
lines.append(f"<b>{item.get('brand', '')} {item.get('model', '')}</b>") lines.append(f"<b>{item.get('brand', '')} {item.get('model', '')}</b>")
if item.get("price_rub"): if item.get("price_rub"):
@ -359,10 +430,22 @@ def _format_podbor_for_telegram(ai_result: dict[str, Any], client_name: str) ->
lines.append(f"⚠️ {item['caveats']}") lines.append(f"⚠️ {item['caveats']}")
lines.append("") 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', '')}") lines.append(f"<b>ИТОГО: {_format_price(j['total_price_rub'])} ₽</b> · {j.get('budget_status', '')}")
if j.get("warnings"): if j.get("warnings"):
lines.append("\n⚠️ " + "; ".join(j["warnings"])) lines.append("\n⚠️ " + "; ".join(j["warnings"]))
if lead_id:
lines.append(f"\n<i>ID: {lead_id[:8]}</i>")
return "\n".join(lines) return "\n".join(lines)

View 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"]

View 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

View File

@ -5,3 +5,5 @@ httpx>=0.27.0
gspread>=6.0.0 gspread>=6.0.0
google-auth>=2.30.0 google-auth>=2.30.0
python-dotenv>=1.0.0 python-dotenv>=1.0.0
beautifulsoup4>=4.12.0
lxml>=5.2.0