Files
Priceflow/app/services/searxng_service.py
T
Claude 1116c3a648 feat: Add multi-site product search system with SearXNG integration
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
2025-11-22 11:03:48 +00:00

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