feat: Add improved search service with persistent browser connection for e-commerce scraping.

This commit is contained in:
Michael committed 2025-12-01 01:00:25 +01:00
1 parent b5c5961618
commit 39893e8c1e
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
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__)
+198 -210
View File
@@ -51,236 +51,224 @@ export default function Search() {
// Charger les sites au démarrage
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 {
const response = await axios.get(`${API_URL}/search-sites`);
const activeSites = response.data.filter(site => site.is_active);
setSites(activeSites);
// 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 data = JSON.parse(event.data);
setProgress(data);
setResults(data.results || []);
const handleSearch = async (e) => {
e.preventDefault();
if (!query.trim() || !selectedSiteId) {
toast.error('Veuillez entrer une recherche et sélectionner un site');
return;
}
if (data.status === 'completed' || data.status === 'error') {
setIsSearching(false);
eventSource.close();
// 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 {
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);
}
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");
console.error('Error parsing SSE data:', error);
}
});
eventSource.onerror = () => {
setIsSearching(false);
eventSource.close();
toast.error('Erreur de connexion au serveur');
};
};
const progressPercent = progress
? Math.round((progress.completed / Math.max(progress.total, 1)) * 100)
: 0;
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 selectedSite = sites.find(s => s.id.toString() === selectedSiteId);
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 un site et ajoutez-les au monitoring
</p>
</div>
const selectedSite = sites.find(s => s.id.toString() === selectedSiteId);
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 un site et ajoutez-les au monitoring
</p>
</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>
<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 className="space-y-2">
<div className="flex justify-between text-sm">
<span>{progress.message || 'Recherche en cours...'}</span>
<span>{progressPercent}%</span>
</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>
<Progress value={progressPercent} />
</div>
</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>{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>
{/* 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 un autre site
</p>
</CardContent>
</Card>
)}
</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 un autre site
</p>
</CardContent>
</Card>
)}
</div>
);
}