Merge pull request #202 from R0m1k3/antigravity

feat: Add improved search service with persistent browser connection …
This commit is contained in:
LogiFlow authored and GitHub committed 2025-12-01 01:00:46 +01:00
commit 7eac22aebf
2 files changed
+214 -211

No files matched your search

+16 -1
View File
@@ -1,6 +1,21 @@
""" """
Improved Search Service - Using Persistent Browser Connection Improved Search Service - Using Persistent Browser Connection
from app.services.parsers import ParserFactory, ProductResult Based on ScraperService pattern for better session management and reliability
"""
import asyncio
import json
import logging
import re
from typing import AsyncGenerator, Optional
from urllib.parse import quote_plus, urljoin, urlparse
from bs4 import BeautifulSoup
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
from app.core.config import settings
from app.core.search_config import SITE_CONFIGS, BROWSERLESS_URL
from app.services.ai_price_extractor import AIPriceExtractor
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
+198 -210
View File
@@ -51,236 +51,224 @@ export default function Search() {
// Charger les sites au démarrage // Charger les sites au démarrage
useEffect(() => { useEffect(() => {
loadSites(); } catch (error) {
}, []); console.error('Error loading sites:', error);
toast.error('Erreur lors du chargement des sites');
}
};
const loadSites = async () => { const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim() || !selectedSiteId) {
toast.error('Veuillez entrer une recherche et sélectionner un site');
return;
}
// Clear previous storage when starting new search
sessionStorage.removeItem(STORAGE_KEY);
// Fermer l'EventSource précédent si existant
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
setIsSearching(true);
setResults([]);
setProgress({ status: 'searching', total: 1, completed: 0 });
// Construire l'URL avec les paramètres
const params = new URLSearchParams({
q: query.trim(),
sites: selectedSiteId,
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 { try {
const response = await axios.get(`${API_URL}/search-sites`); const data = JSON.parse(event.data);
const activeSites = response.data.filter(site => site.is_active); setProgress(data);
setSites(activeSites); setResults(data.results || []);
// Sélectionner le premier site actif par défaut si aucun site n'est sélectionné
if (activeSites.length > 0 && !selectedSiteId) {
setSelectedSiteId(activeSites[0].id.toString());
}
} catch (error) {
console.error('Error loading sites:', error);
toast.error('Erreur lors du chargement des sites');
}
};
const handleSearch = async (e) => { if (data.status === 'completed' || data.status === 'error') {
e.preventDefault(); setIsSearching(false);
if (!query.trim() || !selectedSiteId) { eventSource.close();
toast.error('Veuillez entrer une recherche et sélectionner un site');
return;
}
// Clear previous storage when starting new search if (data.status === 'completed') {
sessionStorage.removeItem(STORAGE_KEY); toast.success(`${data.results?.length || 0} produits trouvés`);
} else if (data.message) {
// Fermer l'EventSource précédent si existant toast.error(data.message);
if (eventSourceRef.current) {
eventSourceRef.current.close();
}
setIsSearching(true);
setResults([]);
setProgress({ status: 'searching', total: 1, completed: 0 });
// Construire l'URL avec les paramètres
const params = new URLSearchParams({
q: query.trim(),
sites: selectedSiteId,
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) { } catch (error) {
console.error('Error adding item:', error); console.error('Error parsing SSE data:', error);
toast.error("Erreur lors de l'ajout au monitoring");
} }
});
eventSource.onerror = () => {
setIsSearching(false);
eventSource.close();
toast.error('Erreur de connexion au serveur');
}; };
};
const progressPercent = progress const handleAddToMonitoring = async (result) => {
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100) try {
: 0; 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 selectedSite = sites.find(s => s.id.toString() === selectedSiteId); const progressPercent = progress
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
: 0;
return ( const selectedSite = sites.find(s => s.id.toString() === selectedSiteId);
<div className="space-y-6 animate-in fade-in duration-500">
{/* Header */} return (
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between"> <div className="space-y-6 animate-in fade-in duration-500">
<div> {/* Header */}
<h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2> <div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<p className="text-muted-foreground"> <div>
Recherchez des produits sur un site et ajoutez-les au monitoring <h2 className="text-2xl font-bold tracking-tight">Recherche de produits</h2>
</p> <p className="text-muted-foreground">
</div> Recherchez des produits sur un site et ajoutez-les au monitoring
</p>
</div> </div>
</div>
{/* Search Form */} {/* Search Form */}
<Card>
<CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4">
{/* Site Selection */}
<div className="space-y-2">
<p className="text-sm font-medium">Site de recherche :</p>
{sites.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucun site configuré. Contactez l'administrateur pour activer des sites.
</p>
) : (
<Select
value={selectedSiteId || ''}
onValueChange={setSelectedSiteId}
disabled={isSearching}
>
<SelectTrigger className="w-full md:w-[300px]">
<SelectValue placeholder="Sélectionner un site" />
</SelectTrigger>
<SelectContent>
{sites.map(site => (
<SelectItem key={site.id} value={site.id.toString()}>
<div className="flex items-center gap-2">
<span>{site.name}</span>
{site.category && (
<span className="text-xs text-muted-foreground">({site.category})</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div>
{/* 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() || !selectedSiteId}>
{isSearching ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Recherche...
</>
) : (
<>
<SearchIcon className="h-4 w-4 mr-2" />
Rechercher
</>
)}
</Button>
</div>
{/* Selected site info */}
{selectedSite && (
<p className="text-sm text-muted-foreground">
Recherche sur <span className="font-medium">{selectedSite.name}</span> ({selectedSite.domain})
</p>
)}
</form>
</CardContent>
</Card>
{/* Progress Bar */}
{progress && isSearching && (
<Card> <Card>
<CardContent className="pt-6"> <CardContent className="pt-6">
<form onSubmit={handleSearch} className="space-y-4"> <div className="space-y-2">
{/* Site Selection */} <div className="flex justify-between text-sm">
<div className="space-y-2"> <span>{progress.message || 'Recherche en cours...'}</span>
<p className="text-sm font-medium">Site de recherche :</p> <span>{progressPercent}%</span>
{sites.length === 0 ? (
<p className="text-sm text-muted-foreground">
Aucun site configuré. Contactez l'administrateur pour activer des sites.
</p>
) : (
<Select
value={selectedSiteId || ''}
onValueChange={setSelectedSiteId}
disabled={isSearching}
>
<SelectTrigger className="w-full md:w-[300px]">
<SelectValue placeholder="Sélectionner un site" />
</SelectTrigger>
<SelectContent>
{sites.map(site => (
<SelectItem key={site.id} value={site.id.toString()}>
<div className="flex items-center gap-2">
<span>{site.name}</span>
{site.category && (
<span className="text-xs text-muted-foreground">({site.category})</span>
)}
</div>
</SelectItem>
))}
</SelectContent>
</Select>
)}
</div> </div>
<Progress value={progressPercent} />
{/* Search Input */} </div>
<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() || !selectedSiteId}>
{isSearching ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Recherche...
</>
) : (
<>
<SearchIcon className="h-4 w-4 mr-2" />
Rechercher
</>
)}
</Button>
</div>
{/* Selected site info */}
{selectedSite && (
<p className="text-sm text-muted-foreground">
Recherche sur <span className="font-medium">{selectedSite.name}</span> ({selectedSite.domain})
</p>
)}
</form>
</CardContent> </CardContent>
</Card> </Card>
)}
{/* Progress Bar */} {/* Results */}
{progress && isSearching && ( {results.length > 0 && (
<Card> <div className="space-y-4">
<CardContent className="pt-6"> <div className="flex items-center justify-between">
<div className="space-y-2"> <h3 className="text-lg font-semibold">
<div className="flex justify-between text-sm"> {results.length} produit{results.length > 1 ? 's' : ''} trouvé{results.length > 1 ? 's' : ''}
<span>{progress.message || 'Recherche en cours...'}</span> </h3>
<span>{progressPercent}%</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> </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 */} {/* Empty State */}
{!isSearching && results.length === 0 && progress?.status === 'completed' && ( {!isSearching && results.length === 0 && progress?.status === 'completed' && (
<Card> <Card>
<CardContent className="py-12 text-center"> <CardContent className="py-12 text-center">
<SearchIcon className="h-12 w-12 mx-auto text-muted-foreground mb-4" /> <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> <h3 className="text-lg font-medium mb-2">Aucun produit trouvé</h3>
<p className="text-muted-foreground"> <p className="text-muted-foreground">
Essayez avec d'autres termes de recherche ou sélectionnez un autre site Essayez avec d'autres termes de recherche ou sélectionnez un autre site
</p> </p>
</CardContent> </CardContent>
</Card> </Card>
)} )}
</div> </div>
); );
} }