mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-11 17:29:14 +02:00
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:
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
@@ -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():
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -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 }
|
||||
@@ -5,6 +5,7 @@
|
||||
},
|
||||
"nav": {
|
||||
"dashboard": "Tableau de bord",
|
||||
"search": "Recherche",
|
||||
"settings": "Paramètres",
|
||||
"lightMode": "Mode clair",
|
||||
"darkMode": "Mode sombre"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -11,6 +11,7 @@ dependencies = [
|
||||
"psycopg2-binary",
|
||||
"playwright",
|
||||
"requests",
|
||||
"httpx",
|
||||
"pydantic",
|
||||
"apprise",
|
||||
"Pillow",
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
@@ -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"
|
||||
Reference in new issue
Block a user