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
This commit is contained in:
Claude committed 2025-11-22 11:03:48 +00:00
1 parent 8795c6fea1
commit 1116c3a648
26 files changed
+1831 -10

No files matched your search

@@ -0,0 +1,52 @@
"""Add search_sites table for product search
Revision ID: b2c3d4e5f6g7
Revises: a1b2c3d4e5f6
Create Date: 2025-11-22 10:00:00.000000
"""
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision: str = "b2c3d4e5f6g7"
down_revision: str | None = "a1b2c3d4e5f6"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
def upgrade() -> None:
"""Create search_sites table for configurable product search sites."""
op.create_table(
"search_sites",
sa.Column("id", sa.Integer(), primary_key=True, index=True),
sa.Column("name", sa.String(), nullable=False),
sa.Column("domain", sa.String(), nullable=False, unique=True, index=True),
sa.Column("logo_url", sa.String(), nullable=True),
sa.Column("category", sa.String(), nullable=True),
sa.Column("is_active", sa.Boolean(), default=True),
sa.Column("priority", sa.Integer(), default=0),
sa.Column("requires_js", sa.Boolean(), default=False),
sa.Column("created_at", sa.DateTime(), nullable=True),
sa.Column("updated_at", sa.DateTime(), nullable=True),
)
# Insert default popular sites
op.execute("""
INSERT INTO search_sites (name, domain, category, is_active, priority, requires_js, created_at, updated_at)
VALUES
('Amazon France', 'amazon.fr', 'Généraliste', true, 1, true, NOW(), NOW()),
('Fnac', 'fnac.com', 'Tech / Culture', true, 2, true, NOW(), NOW()),
('Cdiscount', 'cdiscount.com', 'Généraliste', true, 3, true, NOW(), NOW()),
('Darty', 'darty.com', 'Électroménager', true, 4, true, NOW(), NOW()),
('Boulanger', 'boulanger.com', 'Électronique', true, 5, true, NOW(), NOW())
""")
def downgrade() -> None:
"""Drop search_sites table."""
op.drop_table("search_sites")
+5 -1
View File
@@ -11,7 +11,7 @@ from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from app.limiter import limiter
from app.routers import items, jobs, notifications, openrouter, settings
from app.routers import items, jobs, notifications, openrouter, search, search_sites, settings
from app.services.scheduler_service import scheduled_refresh, scheduler
# Configure logging
@@ -66,6 +66,10 @@ app.mount("/screenshots", StaticFiles(directory="screenshots"), name="screenshot
for router in [notifications.router, items.router, settings.router, jobs.router, openrouter.router]:
app.include_router(router, prefix="/api")
# Search routers (already have /api prefix)
app.include_router(search.router)
app.include_router(search_sites.router)
@app.get("/api/")
def read_root():
+16
View File
@@ -77,3 +77,19 @@ class Settings(Base):
key = Column(String, primary_key=True, index=True)
value = Column(Text)
class SearchSite(Base):
"""Sites configurés pour la recherche de produits"""
__tablename__ = "search_sites"
id: int = Column(Integer, primary_key=True, index=True) # type: ignore
name: str = Column(String, nullable=False) # type: ignore # "Amazon France"
domain: str = Column(String, unique=True, nullable=False, index=True) # type: ignore # "amazon.fr"
logo_url: str | None = Column(String, nullable=True) # type: ignore
category: str | None = Column(String, nullable=True) # type: ignore # "Tech", "Déco", "Généraliste"
is_active: bool = Column(Boolean, default=True) # type: ignore
priority: int = Column(Integer, default=0) # type: ignore # Ordre d'affichage
requires_js: bool = Column(Boolean, default=False) # type: ignore # Force Browserless si True
created_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC)) # type: ignore
updated_at: datetime = Column(DateTime, default=lambda: datetime.now(UTC), onupdate=lambda: datetime.now(UTC)) # type: ignore
+89
View File
@@ -0,0 +1,89 @@
"""
API Router pour la recherche de produits
Utilise Server-Sent Events (SSE) pour le streaming des résultats
"""
import json
import logging
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy.orm import Session
from app.database import get_db
from app.services import search_service
logger = logging.getLogger(__name__)
router = APIRouter(prefix="/api/search", tags=["search"])
@router.get("")
async def search_products(
q: str = Query(..., min_length=1, description="Terme de recherche"),
sites: str | None = Query(None, description="IDs des sites, séparés par virgule"),
max_results: int = Query(20, ge=1, le=50, description="Nombre max de résultats"),
db: Session = Depends(get_db),
):
"""
Recherche des produits sur les sites configurés.
Retourne un flux SSE avec les résultats au fur et à mesure.
Format des événements SSE:
- event: progress
- data: {"status": "...", "total": N, "completed": N, "results": [...]}
"""
# Parser les IDs de sites
site_ids = None
if sites:
try:
site_ids = [int(s.strip()) for s in sites.split(",") if s.strip()]
except ValueError:
site_ids = None
async def generate():
try:
async for progress in search_service.search_products(
query=q,
db=db,
site_ids=site_ids,
max_results=max_results,
):
# Format SSE
data = progress.model_dump_json()
yield f"event: progress\ndata: {data}\n\n"
except Exception as e:
logger.error(f"Erreur recherche: {e}")
error_data = json.dumps({
"status": "error",
"total": 0,
"completed": 0,
"message": str(e),
"results": [],
})
yield f"event: progress\ndata: {error_data}\n\n"
return StreamingResponse(
generate(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
@router.get("/health")
async def search_health():
"""Vérifie que le service de recherche est opérationnel"""
from app.services import searxng_service
searxng_ok = await searxng_service.health_check()
return {
"status": "ok" if searxng_ok else "degraded",
"searxng": "ok" if searxng_ok else "unavailable",
}
+62
View File
@@ -0,0 +1,62 @@
"""
API Router pour la gestion des sites de recherche
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import get_db
from app.schemas import SearchSiteCreate, SearchSiteResponse, SearchSiteUpdate
from app.services import search_service
router = APIRouter(prefix="/api/search-sites", tags=["search-sites"])
@router.get("", response_model=list[SearchSiteResponse])
async def get_all_sites(db: Session = Depends(get_db)):
"""Récupère tous les sites de recherche"""
sites = search_service.get_all_sites(db)
return sites
@router.get("/{site_id}", response_model=SearchSiteResponse)
async def get_site(site_id: int, db: Session = Depends(get_db)):
"""Récupère un site par son ID"""
site = search_service.get_site_by_id(db, site_id)
if not site:
raise HTTPException(status_code=404, detail="Site not found")
return site
@router.post("", response_model=SearchSiteResponse, status_code=201)
async def create_site(site: SearchSiteCreate, db: Session = Depends(get_db)):
"""Crée un nouveau site de recherche"""
try:
new_site = search_service.create_site(db, site.model_dump())
return new_site
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.put("/{site_id}", response_model=SearchSiteResponse)
async def update_site(
site_id: int,
site: SearchSiteUpdate,
db: Session = Depends(get_db),
):
"""Met à jour un site de recherche"""
updated = search_service.update_site(
db, site_id, site.model_dump(exclude_unset=True)
)
if not updated:
raise HTTPException(status_code=404, detail="Site not found")
return updated
@router.delete("/{site_id}", status_code=204)
async def delete_site(site_id: int, db: Session = Depends(get_db)):
"""Supprime un site de recherche"""
deleted = search_service.delete_site(db, site_id)
if not deleted:
raise HTTPException(status_code=404, detail="Site not found")
return None
+68
View File
@@ -50,3 +50,71 @@ class ItemResponse(ItemCreate):
class SettingsUpdate(BaseModel):
key: str
value: str
# === Search Sites Schemas ===
class SearchSiteCreate(BaseModel):
name: str
domain: str
logo_url: str | None = None
category: str | None = None
is_active: bool = True
priority: int = 0
requires_js: bool = False
class SearchSiteUpdate(BaseModel):
name: str | None = None
domain: str | None = None
logo_url: str | None = None
category: str | None = None
is_active: bool | None = None
priority: int | None = None
requires_js: bool | None = None
class SearchSiteResponse(BaseModel):
id: int
name: str
domain: str
logo_url: str | None = None
category: str | None = None
is_active: bool
priority: int
requires_js: bool
created_at: datetime | None = None
updated_at: datetime | None = None
model_config = ConfigDict(from_attributes=True)
# === Search Results Schemas ===
class SearchQuery(BaseModel):
query: str
site_ids: list[int] | None = None # If None, use all active sites
max_results: int = 20
class SearchResultItem(BaseModel):
"""Un produit trouvé lors de la recherche"""
url: str
title: str
price: float | None = None
currency: str = "EUR"
in_stock: bool | None = None
image_url: str | None = None
site_name: str
site_domain: str
confidence: float | None = None
error: str | None = None
class SearchProgress(BaseModel):
"""Progression de la recherche pour SSE"""
status: str # "searching", "scraping", "completed", "error"
total: int
completed: int
current_site: str | None = None
results: list[SearchResultItem] = []
message: str | None = None
+276
View File
@@ -0,0 +1,276 @@
"""
Light Scraper Service - Scraping HTTP rapide sans navigateur
Utilisé comme première tentative avant le fallback Browserless
"""
import logging
import re
from dataclasses import dataclass
import httpx
logger = logging.getLogger(__name__)
# Headers pour simuler un navigateur
DEFAULT_HEADERS = {
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
"Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8",
"Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7",
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Cache-Control": "max-age=0",
}
# Patterns regex pour extraire les prix
PRICE_PATTERNS = [
# Format européen : 1 234,56 € ou 1234,56€
r'(\d{1,3}(?:\s?\d{3})*[,\.]\d{2})\s*(?:€|EUR|euros?)',
# Format : €1,234.56 ou € 1234.56
r'(?:€|EUR)\s*(\d{1,3}(?:[,\s]?\d{3})*[,\.]\d{2})',
# Prix simples : 12,99 € ou 12.99€
r'(\d+[,\.]\d{2})\s*(?:€|EUR)',
# data-price ou price attributes
r'data-price["\']?\s*[:=]\s*["\']?(\d+[,\.]?\d*)',
r'price["\']?\s*[:=]\s*["\']?(\d+[,\.]?\d*)',
]
# Patterns pour le stock
STOCK_POSITIVE_PATTERNS = [
r'en\s*stock',
r'disponible',
r'in\s*stock',
r'available',
r'ajouter\s*au\s*panier',
r'add\s*to\s*cart',
r'acheter',
r'livraison',
]
STOCK_NEGATIVE_PATTERNS = [
r'rupture',
r'indisponible',
r'out\s*of\s*stock',
r'épuisé',
r'sold\s*out',
r'plus\s*disponible',
r'non\s*disponible',
]
@dataclass
class LightScraperResult:
"""Résultat du scraping léger"""
success: bool
url: str
title: str | None = None
price: float | None = None
currency: str = "EUR"
in_stock: bool | None = None
image_url: str | None = None
error: str | None = None
requires_js: bool = False # Indique si le site nécessite JS
async def scrape_url(url: str, timeout: float = 10.0) -> LightScraperResult:
"""
Tente un scraping léger via HTTP simple.
Args:
url: URL du produit à scraper
timeout: Timeout en secondes
Returns:
LightScraperResult avec les données extraites ou requires_js=True
"""
try:
async with httpx.AsyncClient(
timeout=timeout,
follow_redirects=True,
headers=DEFAULT_HEADERS,
) as client:
response = await client.get(url)
response.raise_for_status()
html = response.text
# Vérifier si la page nécessite JavaScript
if _requires_javascript(html):
logger.debug(f"Page {url} nécessite JavaScript")
return LightScraperResult(
success=False,
url=url,
requires_js=True,
error="Page requires JavaScript",
)
# Extraire les données
title = _extract_title(html)
price = _extract_price(html)
in_stock = _extract_stock_status(html)
image_url = _extract_image(html, url)
# Si on n'a pas pu extraire le prix, fallback sur Browserless
if price is None:
logger.debug(f"Impossible d'extraire le prix de {url}")
return LightScraperResult(
success=False,
url=url,
title=title,
requires_js=True,
error="Could not extract price",
)
logger.info(f"Scraping léger réussi pour {url}: {price}€")
return LightScraperResult(
success=True,
url=url,
title=title,
price=price,
in_stock=in_stock,
image_url=image_url,
)
except httpx.TimeoutException:
logger.warning(f"Timeout HTTP pour {url}")
return LightScraperResult(
success=False,
url=url,
error="HTTP timeout",
requires_js=True,
)
except httpx.HTTPStatusError as e:
logger.warning(f"Erreur HTTP {e.response.status_code} pour {url}")
return LightScraperResult(
success=False,
url=url,
error=f"HTTP {e.response.status_code}",
requires_js=True,
)
except Exception as e:
logger.error(f"Erreur scraping léger {url}: {e}")
return LightScraperResult(
success=False,
url=url,
error=str(e),
requires_js=True,
)
def _requires_javascript(html: str) -> bool:
"""Détecte si la page nécessite JavaScript pour afficher le contenu"""
indicators = [
"Please enable JavaScript",
"JavaScript is required",
"You need to enable JavaScript",
"__NEXT_DATA__", # Next.js SSR mais peut nécessiter JS
"window.__INITIAL_STATE__",
"React.createElement",
'<div id="root"></div>', # React SPA vide
'<div id="app"></div>', # Vue SPA vide
"Loading...",
]
html_lower = html.lower()
# Vérifier si le body est presque vide
body_match = re.search(r"<body[^>]*>(.*?)</body>", html, re.DOTALL | re.IGNORECASE)
if body_match:
body_content = body_match.group(1)
# Retirer les scripts et styles
body_clean = re.sub(r"<script[^>]*>.*?</script>", "", body_content, flags=re.DOTALL | re.IGNORECASE)
body_clean = re.sub(r"<style[^>]*>.*?</style>", "", body_clean, flags=re.DOTALL | re.IGNORECASE)
body_clean = re.sub(r"<[^>]+>", "", body_clean)
body_clean = body_clean.strip()
if len(body_clean) < 500: # Page très peu de contenu texte
return True
for indicator in indicators:
if indicator.lower() in html_lower:
return True
return False
def _extract_title(html: str) -> str | None:
"""Extrait le titre de la page"""
# Essayer og:title d'abord
og_match = re.search(r'<meta[^>]+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE)
if og_match:
return og_match.group(1).strip()
# Fallback sur <title>
title_match = re.search(r"<title[^>]*>([^<]+)</title>", html, re.IGNORECASE)
if title_match:
title = title_match.group(1).strip()
# Nettoyer les suffixes de site courants
title = re.sub(r"\s*[-|]\s*(Amazon|Fnac|Cdiscount|Darty).*$", "", title, flags=re.IGNORECASE)
return title
return None
def _extract_price(html: str) -> float | None:
"""Extrait le prix de la page"""
prices = []
for pattern in PRICE_PATTERNS:
matches = re.findall(pattern, html, re.IGNORECASE)
for match in matches:
try:
# Normaliser le format
price_str = match.replace(" ", "").replace(",", ".")
price = float(price_str)
if 0.01 < price < 100000: # Prix raisonnable
prices.append(price)
except ValueError:
continue
if not prices:
return None
# Retourner le prix le plus fréquent ou le premier
from collections import Counter
price_counts = Counter(prices)
most_common = price_counts.most_common(1)
if most_common:
return most_common[0][0]
return prices[0]
def _extract_stock_status(html: str) -> bool | None:
"""Extrait le statut de stock"""
html_lower = html.lower()
# Chercher les indicateurs négatifs d'abord
for pattern in STOCK_NEGATIVE_PATTERNS:
if re.search(pattern, html_lower):
return False
# Chercher les indicateurs positifs
for pattern in STOCK_POSITIVE_PATTERNS:
if re.search(pattern, html_lower):
return True
return None # Incertain
def _extract_image(html: str, base_url: str) -> str | None:
"""Extrait l'image principale du produit"""
from urllib.parse import urljoin
# Essayer og:image d'abord
og_match = re.search(r'<meta[^>]+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE)
if og_match:
return urljoin(base_url, og_match.group(1))
# Essayer twitter:image
twitter_match = re.search(r'<meta[^>]+name=["\']twitter:image["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE)
if twitter_match:
return urljoin(base_url, twitter_match.group(1))
return None
+311
View File
@@ -0,0 +1,311 @@
"""
Search Service - Orchestrateur de recherche de produits
Combine SearXNG + Light Scraper + Browserless fallback
"""
import asyncio
import logging
from collections.abc import AsyncGenerator
from typing import Any
from sqlalchemy.orm import Session
from app.models import SearchSite
from app.schemas import SearchProgress, SearchResultItem
from app.services import ai_service, light_scraper_service, searxng_service
from app.services.scraper_service import ScraperService
from app.services.settings_service import get_setting_value
logger = logging.getLogger(__name__)
# Configuration par défaut
DEFAULT_MAX_RESULTS = 20
DEFAULT_PARALLEL_LIMIT = 5
DEFAULT_TIMEOUT = 30
async def search_products(
query: str,
db: Session,
site_ids: list[int] | None = None,
max_results: int | None = None,
) -> AsyncGenerator[SearchProgress, None]:
"""
Recherche des produits sur les sites configurés.
Flux:
1. SearXNG recherche les URLs
2. Light scraper tente extraction HTTP rapide
3. Browserless fallback pour les sites JS
Args:
query: Terme de recherche
db: Session de base de données
site_ids: IDs des sites à utiliser (None = tous les actifs)
max_results: Nombre max de résultats
Yields:
SearchProgress avec les résultats au fur et à mesure
"""
# Récupérer la configuration
if max_results is None:
max_results = int(get_setting_value(db, "search_max_results", str(DEFAULT_MAX_RESULTS)))
parallel_limit = int(get_setting_value(db, "search_parallel_limit", str(DEFAULT_PARALLEL_LIMIT)))
# Récupérer les sites
sites = _get_sites(db, site_ids)
if not sites:
yield SearchProgress(
status="error",
total=0,
completed=0,
message="Aucun site de recherche configuré",
results=[],
)
return
domains = [site.domain for site in sites]
site_map = {site.domain: site for site in sites}
# Phase 1: Recherche SearXNG
yield SearchProgress(
status="searching",
total=0,
completed=0,
message=f"Recherche sur {len(domains)} sites...",
results=[],
)
searxng_results = await searxng_service.search(
query=query,
domains=domains,
max_results=max_results,
)
if not searxng_results:
yield SearchProgress(
status="error",
total=0,
completed=0,
message="Aucun résultat trouvé",
results=[],
)
return
total = len(searxng_results)
logger.info(f"SearXNG: {total} URLs trouvées pour '{query}'")
# Phase 2: Scraping des URLs
results: list[SearchResultItem] = []
completed = 0
# Créer un sémaphore pour limiter la concurrence
semaphore = asyncio.Semaphore(parallel_limit)
async def process_url(searxng_result: searxng_service.SearXNGResult) -> SearchResultItem | None:
async with semaphore:
url = searxng_result.url
domain = searxng_result.source
site = site_map.get(domain)
if not site:
# Trouver le site par correspondance partielle
for d, s in site_map.items():
if d in domain or domain in d:
site = s
break
site_name = site.name if site else domain
requires_js = site.requires_js if site else False
# Essayer le scraping léger d'abord (sauf si le site requiert JS)
if not requires_js:
light_result = await light_scraper_service.scrape_url(url)
if light_result.success and light_result.price is not None:
return SearchResultItem(
url=url,
title=light_result.title or searxng_result.title,
price=light_result.price,
currency=light_result.currency,
in_stock=light_result.in_stock,
image_url=light_result.image_url,
site_name=site_name,
site_domain=domain,
confidence=0.7, # Confiance moyenne pour le scraping léger
)
# Fallback: Browserless + IA
try:
result = await _scrape_with_browserless(url, site_name, domain, searxng_result.title)
return result
except Exception as e:
logger.error(f"Erreur scraping {url}: {e}")
return SearchResultItem(
url=url,
title=searxng_result.title,
price=None,
site_name=site_name,
site_domain=domain,
error=str(e),
)
# Traiter les URLs en parallèle avec mises à jour progressives
tasks = [asyncio.create_task(process_url(r)) for r in searxng_results]
for coro in asyncio.as_completed(tasks):
try:
result = await coro
completed += 1
if result:
# N'ajouter que les résultats avec un prix
if result.price is not None:
results.append(result)
yield SearchProgress(
status="scraping",
total=total,
completed=completed,
current_site=result.site_name if result else None,
results=results.copy(),
message=f"Extraction {completed}/{total}...",
)
except Exception as e:
completed += 1
logger.error(f"Erreur lors du traitement: {e}")
# Trier les résultats par prix
results.sort(key=lambda x: x.price if x.price else float("inf"))
yield SearchProgress(
status="completed",
total=total,
completed=completed,
results=results,
message=f"{len(results)} produits trouvés avec prix",
)
async def _scrape_with_browserless(
url: str,
site_name: str,
domain: str,
fallback_title: str,
) -> SearchResultItem:
"""Scrape une URL avec Browserless + extraction IA"""
try:
# Scraper avec Browserless
screenshot_path, page_text = await ScraperService.scrape_item(
url=url,
item_id=None,
smart_scroll=True,
scroll_pixels=350,
text_length=3000,
timeout=30000,
)
if not screenshot_path:
return SearchResultItem(
url=url,
title=fallback_title,
price=None,
site_name=site_name,
site_domain=domain,
error="Screenshot failed",
)
# Extraction IA
ai_result = await ai_service.analyze_image(
screenshot_path=screenshot_path,
webpage_text=page_text,
)
if ai_result and ai_result.get("price"):
return SearchResultItem(
url=url,
title=fallback_title,
price=ai_result.get("price"),
currency=ai_result.get("currency", "EUR"),
in_stock=ai_result.get("in_stock"),
image_url=None, # Pas d'image depuis le screenshot
site_name=site_name,
site_domain=domain,
confidence=ai_result.get("price_confidence", 0.5),
)
return SearchResultItem(
url=url,
title=fallback_title,
price=None,
site_name=site_name,
site_domain=domain,
error="AI extraction failed",
)
except Exception as e:
logger.error(f"Browserless scraping error for {url}: {e}")
return SearchResultItem(
url=url,
title=fallback_title,
price=None,
site_name=site_name,
site_domain=domain,
error=str(e),
)
def _get_sites(db: Session, site_ids: list[int] | None = None) -> list[SearchSite]:
"""Récupère les sites de recherche actifs"""
query = db.query(SearchSite).filter(SearchSite.is_active == True)
if site_ids:
query = query.filter(SearchSite.id.in_(site_ids))
return query.order_by(SearchSite.priority).all()
def get_all_sites(db: Session) -> list[SearchSite]:
"""Récupère tous les sites de recherche"""
return db.query(SearchSite).order_by(SearchSite.priority).all()
def get_site_by_id(db: Session, site_id: int) -> SearchSite | None:
"""Récupère un site par son ID"""
return db.query(SearchSite).filter(SearchSite.id == site_id).first()
def create_site(db: Session, site_data: dict[str, Any]) -> SearchSite:
"""Crée un nouveau site de recherche"""
site = SearchSite(**site_data)
db.add(site)
db.commit()
db.refresh(site)
return site
def update_site(db: Session, site_id: int, site_data: dict[str, Any]) -> SearchSite | None:
"""Met à jour un site de recherche"""
site = get_site_by_id(db, site_id)
if not site:
return None
for key, value in site_data.items():
if value is not None:
setattr(site, key, value)
db.commit()
db.refresh(site)
return site
def delete_site(db: Session, site_id: int) -> bool:
"""Supprime un site de recherche"""
site = get_site_by_id(db, site_id)
if not site:
return False
db.delete(site)
db.commit()
return True
+140
View File
@@ -0,0 +1,140 @@
"""
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
+17
View File
@@ -13,6 +13,7 @@ services:
environment:
- DATABASE_URL=postgresql://priceflow:priceflow@db:5432/priceflow
- BROWSERLESS_URL=ws://browserless:3000
- SEARXNG_URL=http://searxng:8080
- LOG_LEVEL=INFO
- CORS_ORIGINS=*
depends_on:
@@ -20,6 +21,8 @@ services:
condition: service_healthy
browserless:
condition: service_started
searxng:
condition: service_started
volumes:
- screenshots_data:/app/screenshots
networks:
@@ -47,6 +50,20 @@ services:
networks:
- nginx_default
# SearXNG - Moteur de recherche auto-hébergé
searxng:
image: searxng/searxng:latest
container_name: priceflow-searxng
restart: unless-stopped
ports:
- "8888:8080"
environment:
- SEARXNG_BASE_URL=http://searxng:8080
volumes:
- ./searxng:/etc/searxng:rw
networks:
- nginx_default
# Browserless - Navigateur headless pour le scraping
browserless:
image: ghcr.io/browserless/chromium:latest
+2 -1
View File
@@ -7,7 +7,8 @@
<!-- - favicon.ico (32x32 ou 64x64) -->
<!-- - apple-touch-icon.png (180x180) -->
<!-- - logo.png (logo principal de l'application) -->
<link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<link rel="alternate icon" type="image/x-icon" href="/favicon.ico" />
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>PriceFlow</title>
+2
View File
@@ -9,8 +9,10 @@
"preview": "vite preview"
},
"dependencies": {
"@radix-ui/react-checkbox": "^1.1.4",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-progress": "^1.1.4",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
+11
View File
@@ -0,0 +1,11 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
<defs>
<linearGradient id="bgGrad" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#D4AF37"/>
<stop offset="100%" style="stop-color:#22C55E"/>
</linearGradient>
</defs>
<rect x="1" y="1" width="30" height="30" rx="6" fill="url(#bgGrad)"/>
<path d="M 6 20 Q 10 16, 14 18 Q 18 20, 22 12 L 26 8" fill="none" stroke="#FFF" stroke-width="2.5" stroke-linecap="round"/>
<path d="M 23 10 L 26 8 L 26 12" fill="none" stroke="#22C55E" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 614 B

+71
View File
@@ -0,0 +1,71 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<defs>
<linearGradient id="bgGradient" x1="0%" y1="0%" x2="100%" y2="100%">
<stop offset="0%" style="stop-color:#D4AF37;stop-opacity:1" />
<stop offset="50%" style="stop-color:#C5A028;stop-opacity:1" />
<stop offset="100%" style="stop-color:#22C55E;stop-opacity:1" />
</linearGradient>
<linearGradient id="lineGradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" style="stop-color:#FFFFFF;stop-opacity:0.9" />
<stop offset="100%" style="stop-color:#22C55E;stop-opacity:1" />
</linearGradient>
<filter id="glow" x="-50%" y="-50%" width="200%" height="200%">
<feGaussianBlur stdDeviation="8" result="coloredBlur"/>
<feMerge>
<feMergeNode in="coloredBlur"/>
<feMergeNode in="SourceGraphic"/>
</feMerge>
</filter>
<filter id="shadow" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="4" stdDeviation="8" flood-color="#000000" flood-opacity="0.3"/>
</filter>
</defs>
<!-- Background rounded square -->
<rect x="20" y="20" width="472" height="472" rx="80" ry="80" fill="url(#bgGradient)" filter="url(#shadow)"/>
<!-- Price chart line -->
<g filter="url(#glow)">
<path
d="M 80 320
Q 120 280, 160 300
Q 200 320, 240 260
Q 280 200, 320 220
Q 360 240, 400 160
L 400 160"
fill="none"
stroke="url(#lineGradient)"
stroke-width="24"
stroke-linecap="round"
stroke-linejoin="round"
/>
<!-- Arrow pointing down at the end -->
<path
d="M 380 180 L 420 140 L 420 175"
fill="none"
stroke="#22C55E"
stroke-width="20"
stroke-linecap="round"
stroke-linejoin="round"
/>
</g>
<!-- Decorative dots on the line -->
<circle cx="80" cy="320" r="12" fill="#FFFFFF" opacity="0.9"/>
<circle cx="160" cy="300" r="10" fill="#FFFFFF" opacity="0.8"/>
<circle cx="240" cy="260" r="10" fill="#FFFFFF" opacity="0.8"/>
<circle cx="320" cy="220" r="10" fill="#FFFFFF" opacity="0.8"/>
<circle cx="400" cy="160" r="14" fill="#22C55E"/>
<!-- Euro symbol -->
<text x="256" y="430"
font-family="Arial, sans-serif"
font-size="80"
font-weight="bold"
fill="#FFFFFF"
fill-opacity="0.3"
text-anchor="middle">
PRICEFLOW
</text>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

+2
View File
@@ -3,6 +3,7 @@ import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'
import { Toaster } from 'sonner'
import Layout from '@/components/layout/Layout'
import Dashboard from '@/pages/Dashboard'
import Search from '@/pages/Search'
import Settings from '@/pages/Settings'
import { useTheme } from '@/hooks/use-theme'
@@ -15,6 +16,7 @@ export default function App() {
<Layout theme={theme} toggleTheme={toggleTheme}>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/search" element={<Search />} />
<Route path="/settings" element={<Settings />} />
</Routes>
</Layout>
+6 -5
View File
@@ -1,7 +1,7 @@
import React, { useState } from 'react';
import { Link, useLocation } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { LayoutDashboard, Settings, Moon, Sun, Menu } from 'lucide-react';
import { LayoutDashboard, Search, Settings, Moon, Sun, Menu } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
import { cn } from '@/lib/utils';
@@ -13,6 +13,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
const navItems = [
{ icon: LayoutDashboard, label: t('nav.dashboard'), path: '/' },
{ icon: Search, label: t('nav.search') || 'Recherche', path: '/search' },
{ icon: Settings, label: t('nav.settings'), path: '/settings' },
];
@@ -23,7 +24,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 rounded-lg" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
PriceFlow
</span>
@@ -80,9 +81,9 @@ const Layout = ({ children, theme, toggleTheme }) => {
<SheetContent side="left" className="w-64 p-0">
<div className="flex h-full flex-col">
<div className="flex h-14 items-center border-b px-6">
<img src="/logo.png" alt="PriceFlow Logo" className="h-8 w-8 mr-2" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-8 w-8 mr-2 rounded-lg" />
<span className="text-xl font-bold bg-gradient-to-r from-primary to-primary/60 bg-clip-text text-transparent">
Pricecious
PriceFlow
</span>
</div>
<nav className="flex-1 space-y-1 p-4">
@@ -125,7 +126,7 @@ const Layout = ({ children, theme, toggleTheme }) => {
</SheetContent>
</Sheet>
<Link to="/" className="flex items-center gap-2 font-semibold">
<img src="/logo.png" alt="PriceFlow Logo" className="h-6 w-6" />
<img src="/logo.svg" alt="PriceFlow Logo" className="h-6 w-6 rounded" />
PriceFlow
</Link>
<div className="ml-auto flex items-center gap-2">
@@ -0,0 +1,112 @@
import React from 'react';
import { ExternalLink, Plus, Package, PackageX } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
export function SearchResultCard({ result, onAddToMonitoring }) {
const formatPrice = (price) => {
if (price === null || price === undefined) return 'N/A';
return new Intl.NumberFormat('fr-FR', {
style: 'currency',
currency: result.currency || 'EUR',
}).format(price);
};
const truncateTitle = (title, maxLength = 60) => {
if (!title) return 'Produit sans nom';
if (title.length <= maxLength) return title;
return title.substring(0, maxLength) + '...';
};
return (
<Card className="overflow-hidden hover:shadow-lg transition-shadow">
<CardContent className="p-0">
{/* Image */}
<div className="aspect-square bg-muted flex items-center justify-center">
{result.image_url ? (
<img
src={result.image_url}
alt={result.title}
className="w-full h-full object-contain"
onError={(e) => {
e.target.style.display = 'none';
e.target.nextSibling.style.display = 'flex';
}}
/>
) : null}
<div
className={`w-full h-full items-center justify-center text-muted-foreground ${result.image_url ? 'hidden' : 'flex'}`}
>
<Package className="h-16 w-16" />
</div>
</div>
{/* Content */}
<div className="p-4 space-y-3">
{/* Title */}
<h3 className="font-medium text-sm leading-tight line-clamp-2" title={result.title}>
{truncateTitle(result.title)}
</h3>
{/* Price & Stock */}
<div className="flex items-center justify-between">
<span className="text-xl font-bold text-primary">
{formatPrice(result.price)}
</span>
{result.in_stock !== null && (
<Badge
variant={result.in_stock ? 'default' : 'destructive'}
className="text-xs"
>
{result.in_stock ? (
<>
<Package className="h-3 w-3 mr-1" />
En stock
</>
) : (
<>
<PackageX className="h-3 w-3 mr-1" />
Rupture
</>
)}
</Badge>
)}
</div>
{/* Site */}
<div className="flex items-center text-xs text-muted-foreground">
<span className="truncate">{result.site_name}</span>
{result.confidence && (
<span className="ml-auto text-xs opacity-50">
{Math.round(result.confidence * 100)}%
</span>
)}
</div>
{/* Actions */}
<div className="flex gap-2 pt-2">
<Button
variant="default"
size="sm"
className="flex-1"
onClick={onAddToMonitoring}
>
<Plus className="h-4 w-4 mr-1" />
Suivre
</Button>
<Button
variant="outline"
size="sm"
asChild
>
<a href={result.url} target="_blank" rel="noopener noreferrer">
<ExternalLink className="h-4 w-4" />
</a>
</Button>
</div>
</div>
</CardContent>
</Card>
);
}
+36
View File
@@ -0,0 +1,36 @@
import * as React from "react"
import { cva } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80",
outline: "text-foreground",
success:
"border-transparent bg-green-500 text-white shadow hover:bg-green-500/80",
warning:
"border-transparent bg-yellow-500 text-white shadow hover:bg-yellow-500/80",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({ className, variant, ...props }) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }
+25
View File
@@ -0,0 +1,25 @@
import * as React from "react"
import * as CheckboxPrimitive from "@radix-ui/react-checkbox"
import { Check } from "lucide-react"
import { cn } from "@/lib/utils"
const Checkbox = React.forwardRef(({ className, ...props }, ref) => (
<CheckboxPrimitive.Root
ref={ref}
className={cn(
"peer h-4 w-4 shrink-0 rounded-sm border border-primary shadow focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground",
className
)}
{...props}
>
<CheckboxPrimitive.Indicator
className={cn("flex items-center justify-center text-current")}
>
<Check className="h-4 w-4" />
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
))
Checkbox.displayName = CheckboxPrimitive.Root.displayName
export { Checkbox }
+23
View File
@@ -0,0 +1,23 @@
import * as React from "react"
import * as ProgressPrimitive from "@radix-ui/react-progress"
import { cn } from "@/lib/utils"
const Progress = React.forwardRef(({ className, value, ...props }, ref) => (
<ProgressPrimitive.Root
ref={ref}
className={cn(
"relative h-2 w-full overflow-hidden rounded-full bg-primary/20",
className
)}
{...props}
>
<ProgressPrimitive.Indicator
className="h-full w-full flex-1 bg-primary transition-all"
style={{ transform: `translateX(-${100 - (value || 0)}%)` }}
/>
</ProgressPrimitive.Root>
))
Progress.displayName = ProgressPrimitive.Root.displayName
export { Progress }
+1
View File
@@ -5,6 +5,7 @@
},
"nav": {
"dashboard": "Tableau de bord",
"search": "Recherche",
"settings": "Paramètres",
"lightMode": "Mode clair",
"darkMode": "Mode sombre"
+262
View File
@@ -0,0 +1,262 @@
import React, { useState, useEffect, useRef } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { useTranslation } from 'react-i18next';
import { Search as SearchIcon, Loader2, Plus, ExternalLink, Settings } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { Progress } from '@/components/ui/progress';
import { Checkbox } from '@/components/ui/checkbox';
import { SearchResultCard } from '@/components/search/SearchResultCard';
import { Link } from 'react-router-dom';
const API_URL = '/api';
export default function Search() {
const { t } = useTranslation();
const [query, setQuery] = useState('');
const [sites, setSites] = useState([]);
const [selectedSites, setSelectedSites] = useState([]);
const [isSearching, setIsSearching] = useState(false);
const [progress, setProgress] = useState(null);
const [results, setResults] = useState([]);
const eventSourceRef = useRef(null);
// Charger les sites au démarrage
useEffect(() => {
loadSites();
}, []);
const loadSites = async () => {
try {
const response = await axios.get(`${API_URL}/search-sites`);
setSites(response.data);
// Sélectionner tous les sites actifs par défaut
setSelectedSites(
response.data
.filter(site => site.is_active)
.map(site => site.id)
);
} catch (error) {
console.error('Error loading sites:', error);
toast.error('Erreur lors du chargement des sites');
}
};
const toggleSite = (siteId) => {
setSelectedSites(prev =>
prev.includes(siteId)
? prev.filter(id => id !== siteId)
: [...prev, siteId]
);
};
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim() || selectedSites.length === 0) {
toast.error('Veuillez entrer une recherche et sélectionner au moins un site');
return;
}
// Fermer l'EventSource précédent si existant
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
setIsSearching(true);
setResults([]);
setProgress({ status: 'searching', total: 0, completed: 0 });
// Construire l'URL avec les paramètres
const params = new URLSearchParams({
q: query.trim(),
sites: selectedSites.join(','),
max_results: 20,
});
// Créer l'EventSource pour SSE
const eventSource = new EventSource(`${API_URL}/search?${params}`);
eventSourceRef.current = eventSource;
eventSource.addEventListener('progress', (event) => {
try {
const data = JSON.parse(event.data);
setProgress(data);
setResults(data.results || []);
if (data.status === 'completed' || data.status === 'error') {
setIsSearching(false);
eventSource.close();
if (data.status === 'completed') {
toast.success(`${data.results?.length || 0} produits trouvés`);
} else if (data.message) {
toast.error(data.message);
}
}
} catch (error) {
console.error('Error parsing SSE data:', error);
}
});
eventSource.onerror = () => {
setIsSearching(false);
eventSource.close();
toast.error('Erreur de connexion au serveur');
};
};
const handleAddToMonitoring = async (result) => {
try {
await axios.post(`${API_URL}/items`, {
url: result.url,
name: result.title,
target_price: null,
check_interval_minutes: 60,
});
toast.success(`"${result.title}" ajouté au monitoring`);
} catch (error) {
console.error('Error adding item:', error);
toast.error("Erreur lors de l'ajout au monitoring");
}
};
const progressPercent = progress
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
: 0;
return (
<div className="space-y-6 animate-in fade-in duration-500">
{/* Header */}
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
<p className="text-muted-foreground">
Recherchez des produits sur plusieurs sites et ajoutez-les au monitoring
</p>
</div>
<Link to="/settings">
<Button variant="outline" size="sm">
<Settings className="h-4 w-4 mr-2" />
Gérer les sites
</Button>
</Link>
</div>
{/* Search Form */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
{/* Search Input */}
<div className="flex gap-2">
<div className="relative flex-1">
<SearchIcon className="absolute left-3 top-3 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder="Rechercher un produit..."
value={query}
onChange={(e) => setQuery(e.target.value)}
className="pl-9"
disabled={isSearching}
/>
</div>
<Button type="submit" disabled={isSearching || !query.trim()}>
{isSearching ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Recherche...
</>
) : (
<>
<SearchIcon className="h-4 w-4 mr-2" />
Rechercher
</>
)}
</Button>
</div>
{/* Site Selection */}
<div className="space-y-2">
<p className="text-sm font-medium">Sites de recherche :</p>
<div className="flex flex-wrap gap-3">
{sites.filter(s => s.is_active).map(site => (
<label
key={site.id}
className={`flex items-center gap-2 px-3 py-2 rounded-lg border cursor-pointer transition-colors ${
selectedSites.includes(site.id)
? 'bg-primary/10 border-primary'
: 'bg-background border-border hover:bg-muted'
}`}
>
<Checkbox
checked={selectedSites.includes(site.id)}
onCheckedChange={() => toggleSite(site.id)}
/>
<span className="text-sm">{site.name}</span>
</label>
))}
{sites.filter(s => s.is_active).length === 0 && (
<p className="text-sm text-muted-foreground">
Aucun site configuré.{' '}
<Link to="/settings" className="text-primary underline">
Ajouter des sites
</Link>
</p>
)}
</div>
</div>
</form>
</CardContent>
</Card>
{/* Progress Bar */}
{progress && isSearching && (
<Card>
<CardContent className="pt-6">
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span>{progress.message || 'Recherche en cours...'}</span>
<span>{progress.completed}/{progress.total}</span>
</div>
<Progress value={progressPercent} />
</div>
</CardContent>
</Card>
)}
{/* Results */}
{results.length > 0 && (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold">
{results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
</h3>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{results.map((result, index) => (
<SearchResultCard
key={`${result.url}-${index}`}
result={result}
onAddToMonitoring={() => handleAddToMonitoring(result)}
/>
))}
</div>
</div>
)}
{/* Empty State */}
{!isSearching && results.length === 0 && progress?.status === 'completed' && (
<Card>
<CardContent className="py-12 text-center">
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
<p className="text-muted-foreground">
Essayez avec d'autres termes de recherche ou sélectionnez plus de sites
</p>
</CardContent>
</Card>
)}
</div>
);
}
+178 -3
View File
@@ -1,7 +1,7 @@
import React, { useState, useEffect } from 'react';
import axios from 'axios';
import { toast } from 'sonner';
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles } from 'lucide-react';
import { Sun, Clock, Cpu, Settings as SettingsIcon, Bell, Trash2, ChevronDown, ChevronUp, Edit2, CheckCircle2, AlertCircle, TrendingDown, DollarSign, Package, RefreshCw, Filter, Eye, Code, Brain, Sparkles, Globe, Plus } from 'lucide-react';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { Label } from '@/components/ui/label';
import { Input } from '@/components/ui/input';
@@ -54,6 +54,11 @@ export default function Settings() {
const [editingProfileId, setEditingProfileId] = useState(null);
const [showAdvancedAI, setShowAdvancedAI] = useState(false);
// Search Sites state
const [searchSites, setSearchSites] = useState([]);
const [newSite, setNewSite] = useState({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
const [editingSiteId, setEditingSiteId] = useState(null);
// OpenRouter state
const [openrouterModels, setOpenrouterModels] = useState([]);
const [openrouterLoading, setOpenrouterLoading] = useState(false);
@@ -108,14 +113,16 @@ export default function Settings() {
const fetchAll = async () => {
try {
const [profilesRes, settingsRes, jobRes] = await Promise.all([
const [profilesRes, settingsRes, jobRes, sitesRes] = await Promise.all([
axios.get(`${API_URL}/notification-profiles`),
axios.get(`${API_URL}/settings`),
axios.get(`${API_URL}/jobs/config`)
axios.get(`${API_URL}/jobs/config`),
axios.get(`${API_URL}/search-sites`)
]);
setProfiles(profilesRes.data);
setJobConfig(jobRes.data);
setSearchSites(sitesRes.data);
const settingsMap = {};
settingsRes.data.forEach(s => settingsMap[s.key] = s.value);
@@ -233,6 +240,66 @@ export default function Settings() {
}
};
// Search Sites functions
const handleSiteSubmit = async (e) => {
e.preventDefault();
try {
if (editingSiteId) {
await axios.put(`${API_URL}/search-sites/${editingSiteId}`, newSite);
toast.success('Site mis à jour');
} else {
await axios.post(`${API_URL}/search-sites`, newSite);
toast.success('Site ajouté');
}
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
setEditingSiteId(null);
fetchAll();
} catch (error) {
toast.error('Erreur lors de l\'opération sur le site');
}
};
const editSite = (site) => {
setNewSite({
name: site.name,
domain: site.domain,
category: site.category || '',
is_active: site.is_active,
priority: site.priority,
requires_js: site.requires_js
});
setEditingSiteId(site.id);
};
const cancelSiteEdit = () => {
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false });
setEditingSiteId(null);
};
const deleteSite = async (id) => {
if (confirm('Êtes-vous sûr de vouloir supprimer ce site ?')) {
try {
await axios.delete(`${API_URL}/search-sites/${id}`);
toast.success('Site supprimé');
if (editingSiteId === id) {
cancelSiteEdit();
}
fetchAll();
} catch (error) {
toast.error('Erreur lors de la suppression');
}
}
};
const toggleSiteActive = async (site) => {
try {
await axios.put(`${API_URL}/search-sites/${site.id}`, { is_active: !site.is_active });
fetchAll();
} catch (error) {
toast.error('Erreur lors de la mise à jour');
}
};
return (
<div className="space-y-6 animate-in fade-in duration-500 max-w-5xl mx-auto pb-10">
<div>
@@ -533,6 +600,114 @@ export default function Settings() {
</CardContent>
</Card>
{/* Search Sites Configuration */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
<CardDescription>Configurez les sites sur lesquels rechercher des produits.</CardDescription>
</CardHeader>
<CardContent className="space-y-8">
<form onSubmit={handleSiteSubmit} className="space-y-4 border-b pb-8">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">{editingSiteId ? 'Modifier le site' : 'Ajouter un site'}</h4>
{editingSiteId && (
<Button type="button" variant="ghost" size="sm" onClick={cancelSiteEdit}>Annuler</Button>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nom du site</Label>
<Input required value={newSite.name} onChange={(e) => setNewSite({ ...newSite, name: e.target.value })} placeholder="ex: Amazon France" />
</div>
<div className="space-y-2">
<Label>Domaine</Label>
<Input required value={newSite.domain} onChange={(e) => setNewSite({ ...newSite, domain: e.target.value })} placeholder="ex: amazon.fr" />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Catégorie</Label>
<Input value={newSite.category} onChange={(e) => setNewSite({ ...newSite, category: e.target.value })} placeholder="ex: Électronique, Déco, Généraliste" />
</div>
<div className="space-y-2">
<Label>Priorité</Label>
<Input type="number" min="0" value={newSite.priority} onChange={(e) => setNewSite({ ...newSite, priority: parseInt(e.target.value) || 0 })} />
</div>
</div>
<div className="flex flex-wrap gap-4">
<div className="flex items-center space-x-2">
<Switch checked={newSite.is_active} onCheckedChange={(checked) => setNewSite({ ...newSite, is_active: checked })} />
<Label>Actif</Label>
</div>
<div className="flex items-center space-x-2">
<Switch checked={newSite.requires_js} onCheckedChange={(checked) => setNewSite({ ...newSite, requires_js: checked })} />
<Label>Nécessite JavaScript</Label>
</div>
</div>
<Button type="submit">
<Plus className="h-4 w-4 mr-2" />
{editingSiteId ? 'Mettre à jour' : 'Ajouter le site'}
</Button>
</form>
<div className="space-y-4">
<h4 className="text-sm font-medium">Sites configurés ({searchSites.length})</h4>
{searchSites.length === 0 ? (
<p className="text-sm text-muted-foreground">Aucun site configuré.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{searchSites.map(site => (
<div key={site.id} className={cn(
"relative group overflow-hidden rounded-xl border bg-card text-card-foreground shadow transition-all hover:shadow-md",
editingSiteId === site.id && "ring-2 ring-primary",
!site.is_active && "opacity-60"
)}>
<div className="p-4 space-y-3">
<div className="flex items-start justify-between">
<div>
<h3 className="font-semibold leading-none tracking-tight flex items-center gap-2">
{site.name}
{site.is_active ? (
<span className="px-2 py-0.5 bg-green-500/10 text-green-700 dark:text-green-400 rounded text-xs">Actif</span>
) : (
<span className="px-2 py-0.5 bg-red-500/10 text-red-700 dark:text-red-400 rounded text-xs">Inactif</span>
)}
</h3>
<p className="text-sm text-muted-foreground mt-1">{site.domain}</p>
</div>
<div className="flex items-center gap-1 opacity-0 group-hover:opacity-100 transition-opacity">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => editSite(site)}>
<Edit2 className="h-4 w-4" />
</Button>
<Button variant="ghost" size="icon" className="h-8 w-8 text-destructive hover:text-destructive" onClick={() => deleteSite(site.id)}>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
<div className="flex flex-wrap gap-2">
{site.category && (
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-secondary text-secondary-foreground">
{site.category}
</span>
)}
{site.requires_js && (
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-blue-500/10 text-blue-700 dark:text-blue-400">
JavaScript
</span>
)}
</div>
<div className="pt-2">
<Switch checked={site.is_active} onCheckedChange={() => toggleSiteActive(site)} />
</div>
</div>
</div>
))}
</div>
)}
</div>
</CardContent>
</Card>
{/* Notification Profiles */}
<Card>
<CardHeader>
+1
View File
@@ -11,6 +11,7 @@ dependencies = [
"psycopg2-binary",
"playwright",
"requests",
"httpx",
"pydantic",
"apprise",
"Pillow",
+13
View File
@@ -0,0 +1,13 @@
# SearXNG rate limiter configuration
# Disabled for internal use
[botdetection.ip_limit]
# Disable rate limiting for internal Docker network
link_token = false
[botdetection.ip_lists]
pass_ip = [
"192.168.0.0/16",
"172.16.0.0/12",
"10.0.0.0/8",
]
+50
View File
@@ -0,0 +1,50 @@
# SearXNG settings for PriceFlow
use_default_settings: true
server:
secret_key: "priceflow-searxng-secret-key-change-in-production"
limiter: false
image_proxy: false
port: 8080
bind_address: "0.0.0.0"
search:
safe_search: 0
autocomplete: ""
default_lang: "fr"
formats:
- html
- json
engines:
- name: google
engine: google
shortcut: g
disabled: false
- name: bing
engine: bing
shortcut: b
disabled: false
- name: duckduckgo
engine: duckduckgo
shortcut: ddg
disabled: false
- name: qwant
engine: qwant
shortcut: qw
disabled: false
outgoing:
request_timeout: 10.0
max_request_timeout: 15.0
useragent_suffix: ""
pool_connections: 100
pool_maxsize: 20
general:
debug: false
instance_name: "PriceFlow Search"