feat: Hardcode French e-commerce websites configuration

- Remove ability to add/edit/delete search sites from settings UI
- Configure 16 French e-commerce sites with search URLs:
  - Discount: Gifi, Stokomani, B&M, Centrakor, L'Incroyable, Action, La Foir'Fouille
  - Grandes Surfaces: E.Leclerc, Auchan, Carrefour
  - E-commerce: Amazon France, Cdiscount
  - Électronique: Darty, Boulanger, Fnac
- Add automatic database seeding on application startup
- Add /api/search-sites/seed and /api/search-sites/reset endpoints
- Add cookie consent banner handling for sites that require it
- Simplify Settings page to only show site list with toggle for active state
This commit is contained in:
Claude committed 2025-11-23 07:40:12 +00:00
1 parent c6a9ef681e
commit e1a9bd2657
5 files changed
+449 -468

No files matched your search

+17 -1
View File
@@ -11,10 +11,11 @@ from slowapi import _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from sqlalchemy import text
from app.database import engine
from app.database import SessionLocal, engine
from app.limiter import limiter
from app.routers import items, jobs, notifications, openrouter, search, search_sites, settings
from app.services.scheduler_service import scheduled_refresh, scheduler
from app.services import search_service
# Configure logging
logging.basicConfig(
@@ -80,6 +81,21 @@ async def lifespan(app: FastAPI):
except Exception as e:
logger.error(f"Migration error: {e}")
# Seed default sites
logger.info("Seeding default search sites...")
try:
db = SessionLocal()
try:
created = search_service.seed_default_sites(db)
if created > 0:
logger.info(f"{created} site(s) de recherche créé(s)")
else:
logger.info("Sites de recherche déjà initialisés")
finally:
db.close()
except Exception as e:
logger.error(f"Error seeding sites: {e}")
logger.info("Starting smart scheduler (Heartbeat: 1 minute)")
scheduler.add_job(scheduled_refresh, IntervalTrigger(minutes=1), id="refresh_job", replace_existing=True)
scheduler.start()
+44 -142
View File
@@ -1,17 +1,16 @@
"""
API Router pour la gestion des sites de recherche
Les sites sont configurés en dur dans direct_search_service.py
"""
import logging
import os
from fastapi import APIRouter, BackgroundTasks, Depends, HTTPException
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.orm import Session
from app.database import SessionLocal, get_db
from app.schemas import SearchSiteCreate, SearchSiteResponse, SearchSiteUpdate
from app.database import get_db
from app.schemas import SearchSiteResponse, SearchSiteUpdate
from app.services import search_service
from app.services.search_url_discovery import discover_search_url
logger = logging.getLogger(__name__)
@@ -34,157 +33,60 @@ async def get_site(site_id: int, db: Session = Depends(get_db)):
return site
async def _discover_and_update_site(site_id: int, domain: str, db_url: str):
"""Découvre l'URL de recherche en background et met à jour le site"""
try:
logger.info(f"Discovering search URL for site {site_id} ({domain})")
result = await discover_search_url(domain)
if result.get("discovered"):
# Mettre à jour le site avec l'URL découverte
db = SessionLocal()
try:
update_data = {}
if result.get("search_url"):
update_data["search_url"] = result["search_url"]
if result.get("product_link_selector"):
update_data["product_link_selector"] = result["product_link_selector"]
if update_data:
search_service.update_site(db, site_id, update_data)
logger.info(f"Updated site {site_id} with discovered URL: {result.get('search_url')}")
finally:
db.close()
else:
logger.warning(f"Could not discover search URL for {domain}")
except Exception as e:
logger.error(f"Error in background discovery for {domain}: {e}")
@router.post("", response_model=SearchSiteResponse, status_code=201)
async def create_site(
site: SearchSiteCreate,
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
"""
Crée un nouveau site de recherche.
Si search_url n'est pas fourni, tente de le découvrir automatiquement.
"""
try:
new_site = search_service.create_site(db, site.model_dump())
# Si pas de search_url, lancer la découverte en background
if not site.search_url:
db_url = os.getenv("DATABASE_URL", "")
background_tasks.add_task(
_discover_and_update_site,
new_site.id,
new_site.domain,
db_url,
)
logger.info(f"Started background discovery for site {new_site.id}")
return new_site
except Exception as e:
raise HTTPException(status_code=400, detail=str(e)) from 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)
)
"""
Met à jour un site de recherche.
Permet uniquement de modifier is_active et priority.
"""
# Limiter les champs modifiables
allowed_fields = {"is_active", "priority"}
update_data = site.model_dump(exclude_unset=True)
filtered_data = {k: v for k, v in update_data.items() if k in allowed_fields}
if not filtered_data:
raise HTTPException(
status_code=400,
detail="Seuls les champs 'is_active' et 'priority' peuvent être modifiés"
)
updated = search_service.update_site(db, site_id, filtered_data)
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")
@router.post("/{site_id}/discover", response_model=dict)
async def discover_site_search_url(
site_id: int,
db: Session = Depends(get_db),
):
@router.post("/seed", response_model=dict)
async def seed_default_sites(db: Session = Depends(get_db)):
"""
Force la découverte de l'URL de recherche pour un site existant.
Utile si la découverte automatique n'a pas fonctionné.
Initialise les sites par défaut s'ils n'existent pas encore.
Utile après une première installation.
"""
site = search_service.get_site_by_id(db, site_id)
if not site:
raise HTTPException(status_code=404, detail="Site not found")
result = await discover_search_url(site.domain)
if result.get("discovered"):
# Mettre à jour le site
update_data = {}
if result.get("search_url"):
update_data["search_url"] = result["search_url"]
if result.get("product_link_selector"):
update_data["product_link_selector"] = result["product_link_selector"]
if update_data:
search_service.update_site(db, site_id, update_data)
return {
"success": True,
"message": f"URL de recherche découverte: {result.get('search_url')}",
"search_url": result.get("search_url"),
"product_link_selector": result.get("product_link_selector"),
}
else:
return {
"success": False,
"message": "Impossible de découvrir l'URL de recherche automatiquement",
}
@router.post("/discover-all", response_model=dict)
async def discover_all_missing_search_urls(
background_tasks: BackgroundTasks,
db: Session = Depends(get_db),
):
"""
Lance la découverte d'URL de recherche pour tous les sites qui n'en ont pas.
La découverte se fait en arrière-plan.
"""
sites = search_service.get_all_sites(db)
sites_to_discover = [s for s in sites if not s.search_url]
if not sites_to_discover:
return {
"success": True,
"message": "Tous les sites ont déjà une URL de recherche configurée",
"sites_queued": 0,
}
db_url = os.getenv("DATABASE_URL", "")
for site in sites_to_discover:
background_tasks.add_task(
_discover_and_update_site,
site.id,
site.domain,
db_url,
)
created = search_service.seed_default_sites(db)
total = len(search_service.get_all_sites(db))
return {
"success": True,
"message": f"Découverte lancée pour {len(sites_to_discover)} sites",
"sites_queued": len(sites_to_discover),
"sites": [{"id": s.id, "domain": s.domain} for s in sites_to_discover],
"message": f"{created} nouveau(x) site(s) créé(s)",
"sites_created": created,
"total_sites": total,
}
@router.post("/reset", response_model=dict)
async def reset_sites_to_defaults(db: Session = Depends(get_db)):
"""
Réinitialise tous les sites avec les valeurs par défaut.
ATTENTION: Supprime tous les sites existants et recrée à partir de la config.
"""
created = search_service.reset_sites_to_defaults(db)
return {
"success": True,
"message": f"Sites réinitialisés. {created} site(s) créé(s)",
"sites_created": created,
}
+234 -65
View File
@@ -39,99 +39,265 @@ class SearchResult:
# Configuration des sites avec leurs sélecteurs par défaut
# Liste des sites français configurés en dur
DEFAULT_SITE_CONFIGS = {
"amazon.fr": {
"search_url": "https://www.amazon.fr/s?k={query}",
"product_selector": "div[data-component-type='s-search-result'] h2 a",
"wait_selector": "div[data-component-type='s-search-result']",
},
"amazon.com": {
"search_url": "https://www.amazon.com/s?k={query}",
"product_selector": "div[data-component-type='s-search-result'] h2 a",
"wait_selector": "div[data-component-type='s-search-result']",
},
"action.com": {
"search_url": "https://www.action.com/fr-fr/search/?q={query}",
"product_selector": "a.product-card__link, .product-card a, a[href*='/p/']",
"wait_selector": ".product-card, .search-results",
},
"fnac.com": {
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
"wait_selector": ".Article-item",
},
"cdiscount.com": {
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
"product_selector": "a.prdtBImg, .prdtBILDetails a",
"wait_selector": ".prdtBILDetails",
},
"darty.com": {
"search_url": "https://www.darty.com/nav/recherche?text={query}",
"product_selector": ".product-card a, a[href*='/product']",
"wait_selector": ".product-card",
},
"boulanger.com": {
"search_url": "https://www.boulanger.com/resultats?tr={query}",
"product_selector": ".product-list__item a, a[href*='/ref/']",
"wait_selector": ".product-list",
},
# Magasins discount français
"stokomani.fr": {
"search_url": "https://www.stokomani.fr/recherche?q={query}",
"product_selector": (
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
".products article a, a[href*='/products/'], a[href*='/produit/']"
),
"wait_selector": ".products, .product-miniature, #js-product-list, .collection-products",
},
# === MAGASINS DISCOUNT ===
"gifi.fr": {
"name": "Gifi",
"search_url": "https://www.gifi.fr/catalogsearch/result/?q={query}",
"product_selector": (
".product-item a.product-item-link, .product-item-info a, "
".product-item a, .products-grid a, a[href$='.html']"
),
"wait_selector": ".products-grid, .product-items, .search-results, .products",
"category": "Discount",
"requires_js": True,
"priority": 1,
},
"stokomani.fr": {
"name": "Stokomani",
"search_url": "https://www.stokomani.fr/recherche?q={query}",
"product_selector": (
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
".products article a, a[href*='/products/'], a[href*='/produit/']"
),
"wait_selector": ".products, .product-miniature, #js-product-list, .collection-products",
"category": "Discount",
"requires_js": True,
"priority": 2,
},
"bmstores.fr": {
"name": "B&M",
"search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}",
"product_selector": (
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
".products article a, a[href*='/produits/']"
),
"wait_selector": ".products, .product-miniature, #js-product-list, #search_results",
"category": "Discount",
"requires_js": True,
"priority": 3,
},
"centrakor.com": {
"search_url": "https://www.centrakor.com/recherche?search={query}",
"product_selector": "a.product-card, .product-item a",
"wait_selector": ".product-card, .product-list",
"name": "Centrakor",
"search_url": "https://www.centrakor.com/catalogsearch/result/?q={query}",
"product_selector": (
".product-item a.product-item-link, .product-item-info a, "
".products-grid a, a[href$='.html']"
),
"wait_selector": ".products-grid, .product-items, .search-results",
"category": "Déco & Maison",
"requires_js": True,
"priority": 4,
},
"lincroyable.fr": {
"name": "L'Incroyable",
"search_url": "https://www.lincroyable.fr/recherche?q={query}",
"product_selector": (
".product-miniature a, .js-product-miniature a, a.product-thumbnail, "
".products article a, a[href*='/produit']"
),
"wait_selector": ".products, .product-miniature, #js-product-list",
"category": "Discount",
"requires_js": True,
"priority": 5,
},
"action.com": {
"name": "Action",
"search_url": "https://www.action.com/fr-fr/search/?q={query}",
"product_selector": "a.product-card__link, .product-card a, a[href*='/p/']",
"wait_selector": ".product-card, .search-results",
"category": "Discount",
"requires_js": True,
"priority": 6,
},
"lafoirfouille.fr": {
"search_url": "https://www.lafoirfouille.fr/recherche?q={query}",
"product_selector": "a.product-item-link, .product-card a",
"wait_selector": ".product-item, .products",
"name": "La Foir'Fouille",
"search_url": "https://www.lafoirfouille.fr/catalogsearch/result/?q={query}",
"product_selector": (
".product-item a.product-item-link, .product-item-info a, "
".products-grid a, a[href$='.html']"
),
"wait_selector": ".products-grid, .product-items, .search-results",
"category": "Discount",
"requires_js": True,
"priority": 7,
},
"tedi.fr": {
"search_url": "https://www.tedi.fr/search?q={query}",
"product_selector": "a.product-card, .product a",
"wait_selector": ".product-card, .search-results",
},
"leclerc.fr": {
# === GRANDES SURFACES ===
"e.leclerc": {
"name": "E.Leclerc",
"search_url": "https://www.e.leclerc/recherche?q={query}",
"product_selector": "a.product-card, .product-tile a",
"wait_selector": ".product-card, .products-grid",
},
"carrefour.fr": {
"search_url": "https://www.carrefour.fr/s?q={query}",
"product_selector": "a.product-card, .product-item a",
"wait_selector": ".product-card, .product-list",
"product_selector": (
"a[data-testid='product-card-link'], .product-card a, "
".product-tile a, a[href*='/fp/']"
),
"wait_selector": ".product-card, .products-grid, [data-testid='product-card']",
"category": "Grande Surface",
"requires_js": True,
"priority": 8,
},
"auchan.fr": {
"name": "Auchan",
"search_url": "https://www.auchan.fr/recherche?text={query}",
"product_selector": "a.product-card, .product-tile a",
"wait_selector": ".product-card, .products",
"product_selector": (
".product-card a, .product-tile a, "
"a[href*='/p/'], .product-item a"
),
"wait_selector": ".product-card, .products, .product-list",
"category": "Grande Surface",
"requires_js": True,
"priority": 9,
},
"carrefour.fr": {
"name": "Carrefour",
"search_url": "https://www.carrefour.fr/s?q={query}",
"product_selector": (
"a[data-testid='product-card-link'], .product-card a, "
".product-item a, a[href*='/p/']"
),
"wait_selector": ".product-card, .product-list, [data-testid='product-card']",
"category": "Grande Surface",
"requires_js": True,
"priority": 10,
},
# === E-COMMERCE GÉNÉRALISTE ===
"amazon.fr": {
"name": "Amazon France",
"search_url": "https://www.amazon.fr/s?k={query}",
"product_selector": "div[data-component-type='s-search-result'] h2 a",
"wait_selector": "div[data-component-type='s-search-result']",
"category": "E-commerce",
"requires_js": True,
"priority": 11,
},
"cdiscount.com": {
"name": "Cdiscount",
"search_url": "https://www.cdiscount.com/search/10/{query}.html",
"product_selector": (
"a.prdtBImg, .prdtBILDetails a, .product-item a, "
"a[href*='/f-']"
),
"wait_selector": ".prdtBILDetails, .product-list",
"category": "E-commerce",
"requires_js": True,
"priority": 12,
},
# === ÉLECTRONIQUE / HIGH-TECH ===
"darty.com": {
"name": "Darty",
"search_url": "https://www.darty.com/nav/recherche?text={query}",
"product_selector": (
".product-card a, a[href*='/product'], "
".product-list-item a"
),
"wait_selector": ".product-card, .product-list",
"category": "Électronique",
"requires_js": True,
"priority": 13,
},
"boulanger.com": {
"name": "Boulanger",
"search_url": "https://www.boulanger.com/resultats?tr={query}",
"product_selector": (
".product-list__item a, a[href*='/ref/'], "
".product-card a"
),
"wait_selector": ".product-list, .product-card",
"category": "Électronique",
"requires_js": True,
"priority": 14,
},
# === AUTRES (conservés pour compatibilité) ===
"amazon.com": {
"name": "Amazon US",
"search_url": "https://www.amazon.com/s?k={query}",
"product_selector": "div[data-component-type='s-search-result'] h2 a",
"wait_selector": "div[data-component-type='s-search-result']",
"category": "E-commerce",
"requires_js": True,
"priority": 99,
},
"fnac.com": {
"name": "Fnac",
"search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}",
"product_selector": "a.Article-title, .Article-item a.js-minifa-title",
"wait_selector": ".Article-item",
"category": "Culture & Tech",
"requires_js": True,
"priority": 15,
},
}
# Sélecteurs communs pour les bannières de cookies
COOKIE_ACCEPT_SELECTORS = [
# Boutons d'acceptation génériques
"button[id*='accept']",
"button[id*='cookie']",
"button[class*='accept']",
"button[class*='cookie']",
"button[data-testid*='accept']",
"button[data-testid*='cookie']",
# Texte français
"button:has-text('Accepter')",
"button:has-text('J\\'accepte')",
"button:has-text('Tout accepter')",
"button:has-text('Accepter tout')",
"button:has-text('Accepter les cookies')",
"button:has-text('OK')",
"button:has-text('Continuer')",
# Texte anglais (certains sites utilisent l'anglais)
"button:has-text('Accept')",
"button:has-text('Accept all')",
"button:has-text('Accept cookies')",
"button:has-text('I accept')",
# Liens
"a:has-text('Accepter')",
"a:has-text('J\\'accepte')",
"a:has-text('Tout accepter')",
# Classes communes de CMP (Consent Management Platforms)
".didomi-continue-without-agreeing",
"#didomi-notice-agree-button",
".onetrust-accept-btn-handler",
"#onetrust-accept-btn-handler",
".tarteaucitronAllow",
"#tarteaucitronPersonalize",
".cc-btn.cc-allow",
".cky-btn-accept",
"#CybsAccept",
".qc-cmp2-summary-buttons button:first-child",
"[data-accept-cookies]",
"[data-gdpr-accept]",
]
async def _accept_cookies(page, domain: str) -> bool:
"""
Tente d'accepter les cookies sur une page.
Retourne True si un bouton a été cliqué, False sinon.
"""
try:
# Attendre un court moment pour que la bannière apparaisse
await asyncio.sleep(1)
for selector in COOKIE_ACCEPT_SELECTORS:
try:
button = page.locator(selector).first
if await button.is_visible(timeout=500):
await button.click(timeout=2000)
logger.info(f"{domain}: Cookies acceptés avec sélecteur {selector}")
# Attendre que la bannière disparaisse
await asyncio.sleep(0.5)
return True
except Exception:
continue
logger.debug(f"{domain}: Aucune bannière de cookies trouvée")
return False
except Exception as e:
logger.debug(f"{domain}: Erreur lors de l'acceptation des cookies: {e}")
return False
async def search(
query: str,
@@ -220,6 +386,9 @@ async def _search_site_browserless( # noqa: PLR0912, PLR0915
# Naviguer vers la page de recherche
await page.goto(final_url, wait_until="domcontentloaded", timeout=int(timeout * 1000))
# Accepter les cookies si nécessaire
await _accept_cookies(page, domain)
# Attendre que les résultats se chargent
if wait_selector:
try:
+93
View File
@@ -330,3 +330,96 @@ def delete_site(db: Session, site_id: int) -> bool:
db.delete(site)
db.commit()
return True
def seed_default_sites(db: Session) -> int:
"""
Initialise la base de données avec les sites configurés en dur.
Ne crée que les sites qui n'existent pas encore (basé sur le domaine).
Returns:
Nombre de sites créés
"""
from app.services.direct_search_service import DEFAULT_SITE_CONFIGS
created_count = 0
# Récupérer les domaines existants
existing_domains = {site.domain.lower().replace("www.", "")
for site in db.query(SearchSite).all()}
for domain, config in DEFAULT_SITE_CONFIGS.items():
# Normaliser le domaine pour comparaison
clean_domain = domain.lower().replace("www.", "")
# Ne pas créer si déjà existant
if clean_domain in existing_domains:
logger.debug(f"Site {domain} déjà existant, ignoré")
continue
# Créer le site
site_data = {
"name": config.get("name", domain),
"domain": domain,
"search_url": config.get("search_url"),
"product_link_selector": config.get("product_selector"),
"category": config.get("category"),
"requires_js": config.get("requires_js", True),
"priority": config.get("priority", 99),
"is_active": True,
}
try:
site = SearchSite(**site_data)
db.add(site)
db.commit()
created_count += 1
logger.info(f"Site créé: {config.get('name', domain)} ({domain})")
except Exception as e:
db.rollback()
logger.error(f"Erreur création site {domain}: {e}")
return created_count
def reset_sites_to_defaults(db: Session) -> int:
"""
Réinitialise tous les sites avec les valeurs par défaut.
Supprime tous les sites existants et recrée à partir de la config.
Returns:
Nombre de sites créés
"""
from app.services.direct_search_service import DEFAULT_SITE_CONFIGS
# Supprimer tous les sites existants
db.query(SearchSite).delete()
db.commit()
logger.info("Tous les sites supprimés")
# Recréer avec les valeurs par défaut
created_count = 0
for domain, config in DEFAULT_SITE_CONFIGS.items():
site_data = {
"name": config.get("name", domain),
"domain": domain,
"search_url": config.get("search_url"),
"product_link_selector": config.get("product_selector"),
"category": config.get("category"),
"requires_js": config.get("requires_js", True),
"priority": config.get("priority", 99),
"is_active": True,
}
try:
site = SearchSite(**site_data)
db.add(site)
db.commit()
created_count += 1
logger.info(f"Site créé: {config.get('name', domain)} ({domain})")
except Exception as e:
db.rollback()
logger.error(f"Erreur création site {domain}: {e}")
return created_count
+61 -260
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, Globe, Plus } 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, RotateCcw } 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';
@@ -9,7 +9,6 @@ import { Switch } from '@/components/ui/switch';
import { Button } from '@/components/ui/button';
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
import { Slider } from '@/components/ui/slider';
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog';
import { cn } from '@/lib/utils';
const API_URL = '/api';
@@ -57,10 +56,7 @@ export default function Settings() {
// Search Sites state
const [searchSites, setSearchSites] = useState([]);
const [newSite, setNewSite] = useState({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false, price_selector: '' });
const [editingSiteId, setEditingSiteId] = useState(null);
const [siteModalOpen, setSiteModalOpen] = useState(false);
const [editSiteData, setEditSiteData] = useState(null);
const [resettingSites, setResettingSites] = useState(false);
// OpenRouter state
const [openrouterModels, setOpenrouterModels] = useState([]);
@@ -244,72 +240,6 @@ 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, price_selector: '' });
setEditingSiteId(null);
fetchAll();
} catch (error) {
toast.error('Erreur lors de l\'opération sur le site');
}
};
const editSite = (site) => {
setEditSiteData({
id: site.id,
name: site.name,
domain: site.domain,
category: site.category || '',
is_active: site.is_active,
priority: site.priority,
requires_js: site.requires_js,
price_selector: site.price_selector || ''
});
setSiteModalOpen(true);
};
const handleEditSiteSubmit = async (e) => {
e.preventDefault();
if (!editSiteData) return;
try {
await axios.put(`${API_URL}/search-sites/${editSiteData.id}`, editSiteData);
toast.success('Site mis à jour');
setSiteModalOpen(false);
setEditSiteData(null);
fetchAll();
} catch (error) {
toast.error('Erreur lors de la mise à jour');
}
};
const cancelSiteEdit = () => {
setNewSite({ name: '', domain: '', category: '', is_active: true, priority: 0, requires_js: false, price_selector: '' });
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 });
@@ -319,6 +249,22 @@ export default function Settings() {
}
};
const resetSitesToDefaults = async () => {
if (!confirm('Êtes-vous sûr de vouloir réinitialiser tous les sites ? Cette action va remettre les valeurs par défaut.')) {
return;
}
setResettingSites(true);
try {
const response = await axios.post(`${API_URL}/search-sites/reset`);
toast.success(response.data.message);
fetchAll();
} catch (error) {
toast.error('Erreur lors de la réinitialisation');
} finally {
setResettingSites(false);
}
};
return (
<div className="space-y-6 animate-in fade-in duration-500 max-w-5xl mx-auto pb-10">
<div>
@@ -622,118 +568,55 @@ export default function Settings() {
{/* 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 className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2"><Globe className="h-5 w-5" />Sites de recherche</CardTitle>
<CardDescription>Sites e-commerce français configurés pour la recherche de produits.</CardDescription>
</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="space-y-2">
<Label>Sélecteur CSS (prix)</Label>
<Input value={newSite.price_selector} onChange={(e) => setNewSite({ ...newSite, price_selector: e.target.value })} placeholder="ex: .price, #product-price, [data-price]" />
<p className="text-xs text-muted-foreground">Sélecteur CSS pour extraire le prix sur ce site (optionnel)</p>
</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 variant="outline" size="sm" onClick={resetSitesToDefaults} disabled={resettingSites}>
<RotateCcw className={cn("h-4 w-4 mr-2", resettingSites && "animate-spin")} />
Réinitialiser
</Button>
</form>
</div>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{searchSites.filter(s => s.is_active).length} site(s) actif(s) sur {searchSites.length}</span>
</div>
<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">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={() => editSite(site)} title="Modifier">
<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)} title="Supprimer">
<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>
)}
{site.price_selector && (
<span className="inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold bg-purple-500/10 text-purple-700 dark:text-purple-400" title={site.price_selector}>
CSS: {site.price_selector.length > 15 ? site.price_selector.substring(0, 15) + '...' : site.price_selector}
</span>
)}
</div>
<div className="pt-2">
<Switch checked={site.is_active} onCheckedChange={() => toggleSiteActive(site)} />
{searchSites.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">Aucun site configuré. Cliquez sur "Réinitialiser" pour charger les sites par défaut.</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{searchSites.map(site => (
<div key={site.id} className={cn(
"relative overflow-hidden rounded-lg border bg-card text-card-foreground shadow-sm transition-all",
!site.is_active && "opacity-50"
)}>
<div className="p-3 space-y-2">
<div className="flex items-center justify-between">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-sm truncate">{site.name}</h3>
<p className="text-xs text-muted-foreground truncate">{site.domain}</p>
</div>
<Switch
checked={site.is_active}
onCheckedChange={() => toggleSiteActive(site)}
className="ml-2"
/>
</div>
<div className="flex flex-wrap gap-1">
{site.category && (
<span className="inline-flex items-center rounded-full border px-2 py-0.5 text-xs bg-secondary text-secondary-foreground">
{site.category}
</span>
)}
</div>
</div>
))}
</div>
)}
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
@@ -855,88 +738,6 @@ export default function Settings() {
</CardContent>
</Card>
{/* Modal d'édition de site */}
<Dialog open={siteModalOpen} onOpenChange={setSiteModalOpen}>
<DialogContent className="sm:max-w-[500px]">
<DialogHeader>
<DialogTitle>Modifier le site</DialogTitle>
</DialogHeader>
{editSiteData && (
<form onSubmit={handleEditSiteSubmit} className="space-y-4">
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Nom du site</Label>
<Input
required
value={editSiteData.name}
onChange={(e) => setEditSiteData({ ...editSiteData, name: e.target.value })}
/>
</div>
<div className="space-y-2">
<Label>Domaine</Label>
<Input
required
value={editSiteData.domain}
onChange={(e) => setEditSiteData({ ...editSiteData, domain: e.target.value })}
/>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Catégorie</Label>
<Input
value={editSiteData.category}
onChange={(e) => setEditSiteData({ ...editSiteData, category: e.target.value })}
placeholder="ex: Électronique"
/>
</div>
<div className="space-y-2">
<Label>Priorité</Label>
<Input
type="number"
min="0"
value={editSiteData.priority}
onChange={(e) => setEditSiteData({ ...editSiteData, priority: parseInt(e.target.value) || 0 })}
/>
</div>
</div>
<div className="space-y-2">
<Label>Sélecteur CSS (prix)</Label>
<Input
value={editSiteData.price_selector}
onChange={(e) => setEditSiteData({ ...editSiteData, price_selector: e.target.value })}
placeholder="ex: .price, #product-price"
/>
<p className="text-xs text-muted-foreground">Sélecteur CSS pour extraire le prix</p>
</div>
<div className="flex gap-4">
<div className="flex items-center space-x-2">
<Switch
checked={editSiteData.is_active}
onCheckedChange={(checked) => setEditSiteData({ ...editSiteData, is_active: checked })}
/>
<Label>Actif</Label>
</div>
<div className="flex items-center space-x-2">
<Switch
checked={editSiteData.requires_js}
onCheckedChange={(checked) => setEditSiteData({ ...editSiteData, requires_js: checked })}
/>
<Label>Nécessite JavaScript</Label>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => setSiteModalOpen(false)}>
Annuler
</Button>
<Button type="submit">
Enregistrer
</Button>
</DialogFooter>
</form>
)}
</DialogContent>
</Dialog>
</div>
);
}