mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-20 13:48:16 +02:00
This commit adds a comprehensive product search feature that allows users to search for products across multiple configured e-commerce sites. Backend changes: - Add SearXNG service to docker-compose for meta-search capability - Add SearchSite model and Alembic migration with default sites - Create searxng_service.py for search queries across sites - Create light_scraper_service.py for fast HTTP-based extraction - Create search_service.py orchestrator combining SearXNG + HTTP + Browserless - Add API endpoints for search (/api/search) with SSE streaming - Add CRUD endpoints for search sites (/api/search-sites) Frontend changes: - Add new /search page with real-time results display - Create SearchResultCard component for product display - Add Progress, Checkbox, and Badge UI components - Update Settings page with search sites management section - Add navigation link to search page - Create new modern SVG logo with price-flow gradient design - Update favicon to SVG format The search system uses a hybrid approach: 1. SearXNG finds product URLs across configured sites 2. Light HTTP scraper attempts fast extraction 3. Falls back to Browserless for JavaScript-heavy sites 4. Results stream progressively via SSE
141 lines
4.0 KiB
Python
141 lines
4.0 KiB
Python
"""
|
|
SearXNG Service - Client pour le moteur de recherche auto-hébergé
|
|
"""
|
|
|
|
import logging
|
|
import os
|
|
from urllib.parse import quote_plus
|
|
|
|
import httpx
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
SEARXNG_URL = os.getenv("SEARXNG_URL", "http://searxng:8080")
|
|
|
|
|
|
class SearXNGResult:
|
|
"""Résultat de recherche SearXNG"""
|
|
|
|
def __init__(self, url: str, title: str, snippet: str, source: str):
|
|
self.url = url
|
|
self.title = title
|
|
self.snippet = snippet
|
|
self.source = source
|
|
|
|
def __repr__(self):
|
|
return f"SearXNGResult(url={self.url}, title={self.title[:30]}...)"
|
|
|
|
|
|
async def search(
|
|
query: str,
|
|
domains: list[str],
|
|
max_results: int = 20,
|
|
timeout: float = 15.0,
|
|
) -> list[SearXNGResult]:
|
|
"""
|
|
Effectue une recherche SearXNG ciblée sur les domaines spécifiés.
|
|
|
|
Args:
|
|
query: Terme de recherche (ex: "lutin de noel")
|
|
domains: Liste des domaines à cibler (ex: ["amazon.fr", "fnac.com"])
|
|
max_results: Nombre maximum de résultats à retourner
|
|
timeout: Timeout en secondes
|
|
|
|
Returns:
|
|
Liste de SearXNGResult avec url, title, snippet, source
|
|
"""
|
|
if not domains:
|
|
logger.warning("Aucun domaine spécifié pour la recherche")
|
|
return []
|
|
|
|
# Construire la requête avec site: pour chaque domaine
|
|
site_query = " OR ".join([f"site:{domain}" for domain in domains])
|
|
full_query = f"{query} ({site_query})"
|
|
|
|
logger.info(f"Recherche SearXNG: {full_query}")
|
|
|
|
try:
|
|
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
response = await client.get(
|
|
f"{SEARXNG_URL}/search",
|
|
params={
|
|
"q": full_query,
|
|
"format": "json",
|
|
"language": "fr",
|
|
"safesearch": 0,
|
|
"pageno": 1,
|
|
},
|
|
headers={
|
|
"Accept": "application/json",
|
|
},
|
|
)
|
|
response.raise_for_status()
|
|
data = response.json()
|
|
|
|
results = []
|
|
seen_urls = set()
|
|
|
|
for item in data.get("results", []):
|
|
url = item.get("url", "")
|
|
|
|
# Éviter les doublons
|
|
if url in seen_urls:
|
|
continue
|
|
seen_urls.add(url)
|
|
|
|
# Vérifier que l'URL appartient à un des domaines ciblés
|
|
url_domain = _extract_domain(url)
|
|
if not any(domain in url_domain for domain in domains):
|
|
continue
|
|
|
|
results.append(
|
|
SearXNGResult(
|
|
url=url,
|
|
title=item.get("title", ""),
|
|
snippet=item.get("content", ""),
|
|
source=url_domain,
|
|
)
|
|
)
|
|
|
|
if len(results) >= max_results:
|
|
break
|
|
|
|
logger.info(f"SearXNG: {len(results)} résultats trouvés")
|
|
return results
|
|
|
|
except httpx.TimeoutException:
|
|
logger.error(f"Timeout SearXNG après {timeout}s")
|
|
return []
|
|
except httpx.HTTPStatusError as e:
|
|
logger.error(f"Erreur HTTP SearXNG: {e.response.status_code}")
|
|
return []
|
|
except Exception as e:
|
|
logger.error(f"Erreur SearXNG: {e}")
|
|
return []
|
|
|
|
|
|
def _extract_domain(url: str) -> str:
|
|
"""Extrait le domaine d'une URL"""
|
|
try:
|
|
from urllib.parse import urlparse
|
|
|
|
parsed = urlparse(url)
|
|
domain = parsed.netloc.lower()
|
|
# Retirer www. si présent
|
|
if domain.startswith("www."):
|
|
domain = domain[4:]
|
|
return domain
|
|
except Exception:
|
|
return ""
|
|
|
|
|
|
async def health_check() -> bool:
|
|
"""Vérifie que SearXNG est accessible"""
|
|
try:
|
|
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
response = await client.get(f"{SEARXNG_URL}/healthz")
|
|
return response.status_code == 200
|
|
except Exception as e:
|
|
logger.warning(f"SearXNG health check failed: {e}")
|
|
return False
|