mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-14 18:56:43 +02:00
Complete refactoring of the search module for robustness and reliability: **New Architecture:** - Modular parser system with specialized parsers for each major site - Factory pattern for automatic parser selection - Generic fallback parser for sites without specific implementation **Specialized Parsers:** - ✅ Amazon.fr - Robust parser with multiple fallback selectors - Handles sponsored products, Prime eligibility, ratings, reviews - Based on proven amazon_scraper_v2.py implementation - ✅ Cdiscount.com - Optimized for Cdiscount's HTML structure - ✅ Fnac.com - Handles Fnac's picture elements and article structure - ✅ Darty.com - Specialized for electronics retailer - ✅ Boulanger.com - Optimized for Boulanger's product cards **Key Features:** - Multiple fallback selectors for each element (title, price, image) - Proper price parsing for French format (1 234,56 €) - Rating and review count extraction - Stock status detection - Image URL extraction with multiple attribute fallbacks - Query filtering to ensure relevant results **Benefits:** - Much more robust than previous generic parsing - Easier to maintain (one parser per site) - Easily extensible (add new parser for new site) - Better error handling and logging - Higher success rate for product extraction **Updated Services:** - improved_search_service.py now uses ParserFactory - Legacy _parse_results marked as deprecated - Conversion layer between ProductResult and SearchResult This implements the Amazon France model across all major sites for consistent, reliable product search results.
706 lines
25 KiB
Python
706 lines
25 KiB
Python
"""
|
|
Improved Search Service - Using Persistent Browser Connection
|
|
Based on ScraperService pattern for better session management and reliability
|
|
|
|
UPDATED: Now uses modular, site-specific parsers for robust product extraction
|
|
"""
|
|
|
|
import asyncio
|
|
import logging
|
|
from typing import AsyncGenerator
|
|
from urllib.parse import quote_plus
|
|
|
|
from playwright.async_api import Browser, BrowserContext, Page, async_playwright
|
|
from playwright.async_api import TimeoutError as PlaywrightTimeoutError
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.core.search_config import SITE_CONFIGS, BROWSERLESS_URL
|
|
from app.models import SearchSite
|
|
from app.schemas import SearchProgress, SearchResultItem
|
|
from app.services.parsers import ParserFactory, ProductResult
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Common popup/cookie selectors
|
|
COMMON_POPUP_SELECTORS = [
|
|
"#sp-cc-accept", # Cookie banner
|
|
"#onetrust-accept-btn-handler", # OneTrust
|
|
".cookie-consent-accept",
|
|
"[data-action='accept-cookies']",
|
|
"button[id*='accept']",
|
|
"button[class*='accept']",
|
|
]
|
|
|
|
|
|
class SearchResult:
|
|
"""Search result data class"""
|
|
def __init__(
|
|
self,
|
|
url: str,
|
|
title: str,
|
|
snippet: str,
|
|
source: str,
|
|
price: float | None = None,
|
|
currency: str = "EUR",
|
|
in_stock: bool | None = None,
|
|
image_url: str | None = None,
|
|
):
|
|
self.url = url
|
|
self.title = title
|
|
self.snippet = snippet
|
|
self.source = source
|
|
self.price = price
|
|
self.currency = currency
|
|
self.in_stock = in_stock
|
|
self.image_url = image_url
|
|
|
|
def to_dict(self):
|
|
return {
|
|
"url": self.url,
|
|
"title": self.title,
|
|
"snippet": self.snippet,
|
|
"source": self.source,
|
|
"price": self.price,
|
|
"currency": self.currency,
|
|
"in_stock": self.in_stock,
|
|
"image_url": self.image_url,
|
|
}
|
|
|
|
|
|
class ImprovedSearchService:
|
|
"""Persistent browser service for e-commerce search scraping"""
|
|
|
|
_playwright = None
|
|
_browser: Browser | None = None
|
|
_lock = asyncio.Lock()
|
|
|
|
@classmethod
|
|
async def initialize(cls):
|
|
"""Initialize shared browser (Thread-Safe)"""
|
|
async with cls._lock:
|
|
await cls._initialize()
|
|
|
|
@classmethod
|
|
async def _initialize(cls):
|
|
"""Internal initialization"""
|
|
if cls._browser is None:
|
|
logger.info("Initializing ImprovedSearchService shared browser...")
|
|
cls._playwright = await async_playwright().start()
|
|
cls._browser = await cls._connect_browser(cls._playwright)
|
|
logger.info("ImprovedSearchService initialized.")
|
|
|
|
@classmethod
|
|
async def shutdown(cls):
|
|
"""Shutdown shared browser"""
|
|
async with cls._lock:
|
|
if cls._browser:
|
|
logger.info("Shutting down ImprovedSearchService...")
|
|
await cls._browser.close()
|
|
cls._browser = None
|
|
if cls._playwright:
|
|
await cls._playwright.stop()
|
|
cls._playwright = None
|
|
logger.info("ImprovedSearchService shutdown complete.")
|
|
|
|
@classmethod
|
|
async def _ensure_browser_connected(cls) -> bool:
|
|
"""Ensure browser is connected, reconnect if needed"""
|
|
async with cls._lock:
|
|
try:
|
|
if cls._browser is None:
|
|
logger.warning("Browser not initialized, initializing...")
|
|
await cls._initialize()
|
|
return cls._browser is not None
|
|
|
|
# Test connection
|
|
try:
|
|
test_context = await cls._browser.new_context()
|
|
await test_context.close()
|
|
return True
|
|
except Exception as e:
|
|
logger.error(f"Browser connection test failed: {e}")
|
|
logger.info("Attempting to reconnect...")
|
|
cls._browser = None
|
|
if cls._playwright:
|
|
try:
|
|
await cls._playwright.stop()
|
|
except Exception:
|
|
pass
|
|
cls._playwright = None
|
|
await cls._initialize()
|
|
return cls._browser is not None
|
|
except Exception as e:
|
|
logger.error(f"Failed to ensure browser connection: {e}")
|
|
return False
|
|
|
|
@staticmethod
|
|
async def _connect_browser(p) -> Browser:
|
|
"""Connect to Browserless"""
|
|
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}")
|
|
return await p.chromium.connect_over_cdp(BROWSERLESS_URL)
|
|
|
|
@staticmethod
|
|
async def _create_context(browser: Browser) -> BrowserContext:
|
|
"""Create browser context with stealth settings"""
|
|
context = await browser.new_context(
|
|
viewport={"width": 1920, "height": 1080},
|
|
user_agent=(
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|
"Chrome/131.0.0.0 Safari/537.36"
|
|
),
|
|
locale="fr-FR",
|
|
timezone_id="Europe/Paris",
|
|
)
|
|
|
|
# Stealth mode
|
|
await context.add_init_script("""
|
|
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
|
|
window.chrome = { runtime: {} };
|
|
""")
|
|
|
|
await context.route("**/*", lambda route: route.continue_())
|
|
return context
|
|
|
|
@staticmethod
|
|
async def _handle_popups(page: Page):
|
|
"""Close common popups/cookies"""
|
|
logger.debug("Handling popups...")
|
|
for selector in COMMON_POPUP_SELECTORS:
|
|
try:
|
|
if await page.locator(selector).count() > 0:
|
|
logger.debug(f"Found popup: {selector}")
|
|
await page.locator(selector).first.click(timeout=2000)
|
|
await page.wait_for_timeout(500)
|
|
except Exception:
|
|
pass
|
|
|
|
try:
|
|
await page.keyboard.press("Escape")
|
|
except Exception:
|
|
pass
|
|
|
|
@classmethod
|
|
async def search_site(cls, site_key: str, query: str) -> list[SearchResult]:
|
|
"""
|
|
Search a single site using persistent browser
|
|
|
|
Args:
|
|
site_key: Site configuration key
|
|
query: Search query
|
|
|
|
Returns:
|
|
List of SearchResult objects
|
|
"""
|
|
config = SITE_CONFIGS.get(site_key)
|
|
if not config:
|
|
logger.error(f"Unknown site: {site_key}")
|
|
return []
|
|
|
|
search_url = config["search_url"].format(query=quote_plus(query))
|
|
logger.info(f"🔍 Searching {config['name']} at {search_url}")
|
|
|
|
# Ensure browser is connected
|
|
if not await cls._ensure_browser_connected():
|
|
logger.error("Failed to establish browser connection")
|
|
return []
|
|
|
|
results = []
|
|
|
|
try:
|
|
context = await cls._create_context(cls._browser)
|
|
page = await context.new_page()
|
|
|
|
try:
|
|
# Navigate to search page
|
|
logger.debug(f"Navigating to {search_url}")
|
|
await page.goto(search_url, wait_until="domcontentloaded", timeout=30000)
|
|
logger.debug("Page loaded (domcontentloaded)")
|
|
|
|
# Wait for network idle
|
|
try:
|
|
await page.wait_for_load_state("networkidle", timeout=10000)
|
|
logger.debug("Network idle reached")
|
|
except PlaywrightTimeoutError:
|
|
logger.debug("Network idle timed out (non-critical)")
|
|
|
|
# Handle popups
|
|
await cls._handle_popups(page)
|
|
|
|
# Wait for content to load
|
|
wait_selector = config.get("wait_selector")
|
|
if wait_selector:
|
|
try:
|
|
await page.wait_for_selector(wait_selector, timeout=5000)
|
|
logger.debug(f"Wait selector found: {wait_selector}")
|
|
except PlaywrightTimeoutError:
|
|
logger.warning(f"Wait selector not found: {wait_selector}")
|
|
|
|
# Small delay for JS rendering
|
|
await page.wait_for_timeout(2000)
|
|
|
|
# Get HTML content
|
|
html_content = await page.content()
|
|
logger.info(f"✅ Page content extracted ({len(html_content)} bytes)")
|
|
|
|
if len(html_content) < 5000:
|
|
logger.warning(f"⚠️ Page too small - possibly blocked")
|
|
return []
|
|
|
|
# Parse results using specialized parser
|
|
parser = ParserFactory.get_parser(site_key)
|
|
parsed_products = parser.parse_search_results(html_content, query, search_url)
|
|
|
|
# Convert ProductResult to SearchResult
|
|
results = [cls._convert_to_search_result(p) for p in parsed_products]
|
|
|
|
# Scrape details for each result (in parallel)
|
|
if results:
|
|
logger.info(f"📦 Found {len(results)} initial results, enriching with details...")
|
|
semaphore = asyncio.Semaphore(2) # Limit concurrency
|
|
|
|
async def scrape_with_limit(res):
|
|
async with semaphore:
|
|
return await cls._scrape_item_details(res, context)
|
|
|
|
tasks = [scrape_with_limit(r) for r in results]
|
|
enriched_results = await asyncio.gather(*tasks)
|
|
results = [r for r in enriched_results if r] # Filter None
|
|
|
|
finally:
|
|
await context.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Error during search: {e}", exc_info=True)
|
|
return []
|
|
|
|
logger.info(f"✅ Successfully found {len(results)} products from {config['name']}")
|
|
return results
|
|
|
|
@staticmethod
|
|
def _convert_to_search_result(product: ProductResult) -> SearchResult:
|
|
"""Convert ProductResult to SearchResult"""
|
|
return SearchResult(
|
|
url=product.url,
|
|
title=product.title,
|
|
snippet=product.snippet,
|
|
source=product.source,
|
|
price=product.price,
|
|
currency=product.currency,
|
|
in_stock=product.in_stock,
|
|
image_url=product.image_url,
|
|
)
|
|
|
|
@staticmethod
|
|
def _parse_results(html: str, site_key: str, base_url: str, query: str) -> list[SearchResult]:
|
|
"""
|
|
DEPRECATED: Legacy parsing method - now using specialized parsers
|
|
This method is kept for backward compatibility but should not be used
|
|
"""
|
|
config = SITE_CONFIGS[site_key]
|
|
soup = BeautifulSoup(html, "html.parser")
|
|
results = []
|
|
|
|
# Prepare query words for filtering
|
|
query_words = [w.lower() for w in query.split() if len(w) > 2]
|
|
|
|
# Select product links
|
|
links = soup.select(config["product_selector"])
|
|
|
|
# Deduplicate links
|
|
seen_urls = set()
|
|
|
|
for link in links:
|
|
href = link.get("href")
|
|
if not href:
|
|
continue
|
|
|
|
full_url = urljoin(base_url, href)
|
|
|
|
# Basic cleanup
|
|
if full_url in seen_urls:
|
|
continue
|
|
seen_urls.add(full_url)
|
|
|
|
# Extract title
|
|
title = link.get_text(strip=True)
|
|
|
|
# If no text, check title attribute or nested image alt
|
|
if not title:
|
|
if link.get("title"):
|
|
title = link.get("title")
|
|
else:
|
|
img = link.find("img")
|
|
if img and img.get("alt"):
|
|
title = img.get("alt")
|
|
|
|
if not title or len(title) < 3:
|
|
continue
|
|
|
|
# RELAXED FILTERING: Check if at least ONE query word is in the title
|
|
# (instead of ALL words, which was too strict)
|
|
title_lower = title.lower()
|
|
if query_words:
|
|
at_least_one_word_found = False
|
|
for word in query_words:
|
|
if word in title_lower:
|
|
at_least_one_word_found = True
|
|
break
|
|
|
|
# Only keep results with at least one matching word
|
|
if not at_least_one_word_found:
|
|
continue
|
|
|
|
# Extract Image URL - PRIORITIZE CONFIGURED SELECTOR
|
|
image_url = None
|
|
|
|
# PRIORITY 1: Use product_image_selector if configured (site-specific)
|
|
if "product_image_selector" in config:
|
|
# Try in link first
|
|
img_el = link.select_one(config["product_image_selector"])
|
|
|
|
# If not found in link, search in parent container
|
|
if not img_el:
|
|
container = link.find_parent("article") or link.find_parent("div", class_=lambda x: x and "product" in x)
|
|
if container:
|
|
img_el = container.select_one(config["product_image_selector"])
|
|
|
|
if img_el:
|
|
# Try multiple attributes in order of priority
|
|
image_url= (
|
|
img_el.get("src") or
|
|
img_el.get("data-src") or
|
|
img_el.get("data-lazy-src") or
|
|
img_el.get("data-original")
|
|
)
|
|
|
|
# Handle srcset (use first URL)
|
|
if not image_url and img_el.get("srcset"):
|
|
srcset = img_el.get("srcset")
|
|
# srcset format: "url1 size1, url2 size2"
|
|
image_url = srcset.split(",")[0].split()[0]
|
|
|
|
if image_url:
|
|
logger.debug(f" 🖼️ Image found via product_image_selector: {image_url[:50]}...")
|
|
|
|
# PRIORITY 2: Fallback - Look for any img directly in the link
|
|
if not image_url:
|
|
img = link.find("img")
|
|
if img:
|
|
image_url = (
|
|
img.get("src") or
|
|
img.get("data-src") or
|
|
img.get("data-lazy-src") or
|
|
img.get("data-original")
|
|
)
|
|
|
|
# Handle srcset
|
|
if not image_url and img.get("srcset"):
|
|
srcset = img.get("srcset")
|
|
image_url = srcset.split(",")[0].split()[0]
|
|
|
|
if image_url:
|
|
logger.debug(f" 🖼️ Image found via link.find('img'): {image_url[:50]}...")
|
|
|
|
# PRIORITY 3: Look for picture > source elements
|
|
if not image_url:
|
|
picture = link.find("picture")
|
|
if picture:
|
|
source = picture.find("source")
|
|
if source and source.get("srcset"):
|
|
srcset = source.get("srcset")
|
|
image_url = srcset.split(",")[0].split()[0]
|
|
|
|
if not image_url:
|
|
img_in_picture = picture.find("img")
|
|
if img_in_picture:
|
|
image_url = img_in_picture.get("src") or img_in_picture.get("data-src")
|
|
|
|
if image_url:
|
|
logger.debug(f" 🖼️ Image found via <picture>: {image_url[:50]}...")
|
|
|
|
# Clean up and validate image URL
|
|
if image_url:
|
|
# Remove data URIs, 1x1 pixels, placeholders
|
|
if (image_url.startswith("data:") or
|
|
"1x1" in image_url or
|
|
"placeholder" in image_url.lower() or
|
|
image_url.strip() == ""):
|
|
logger.debug(f" ⏭️ Skipping invalid image: {image_url[:50]}")
|
|
image_url = None
|
|
elif not image_url.startswith("http"):
|
|
image_url = urljoin(base_url, image_url)
|
|
logger.debug(f" 🔗 Made image URL absolute: {image_url[:80]}...")
|
|
|
|
if not image_url:
|
|
logger.warning(f" ⚠️ No image found for: {title[:50]}")
|
|
|
|
# Create result
|
|
results.append(SearchResult(
|
|
url=full_url,
|
|
title=title,
|
|
snippet=f"Product from {config['name']}",
|
|
source=config["name"],
|
|
image_url=image_url
|
|
))
|
|
|
|
logger.debug(f"Parsed {len(results)} results from HTML")
|
|
return results
|
|
|
|
@classmethod
|
|
async def _scrape_item_details(cls, result: SearchResult, context: BrowserContext) -> SearchResult | None:
|
|
"""Scrape price and details for a single item using same context"""
|
|
try:
|
|
page = await context.new_page()
|
|
try:
|
|
logger.debug(f"Scraping details for: {result.title[:50]}...")
|
|
|
|
# Navigate to product page
|
|
await page.goto(result.url, wait_until="domcontentloaded", timeout=20000)
|
|
|
|
# Wait for network idle
|
|
try:
|
|
await page.wait_for_load_state("networkidle", timeout=5000)
|
|
except PlaywrightTimeoutError:
|
|
pass
|
|
|
|
# Handle popups
|
|
await cls._handle_popups(page)
|
|
|
|
# Wait for content
|
|
await page.wait_for_timeout(1500)
|
|
|
|
# Extract price using multiple selectors
|
|
price = await cls._extract_price(page)
|
|
result.price = price
|
|
|
|
# Extract stock status
|
|
in_stock = await cls._extract_stock_status(page)
|
|
result.in_stock = in_stock
|
|
|
|
# Keep original image URL from search page - don't replace with screenshot
|
|
# (Screenshots would need to be served by FastAPI, and original images are already good)
|
|
|
|
logger.debug(f" ✓ {result.title[:40]}... - {price}€ - Stock: {in_stock}")
|
|
return result
|
|
|
|
finally:
|
|
await page.close()
|
|
|
|
except Exception as e:
|
|
logger.error(f"Error scraping item details {result.url}: {e}")
|
|
return result # Return original result without price
|
|
|
|
@staticmethod
|
|
async def _extract_price(page: Page) -> float | None:
|
|
"""Extract price from product page using multiple selectors"""
|
|
price_selectors = [
|
|
'.price',
|
|
'[data-testid="price"]',
|
|
'.prix-actuel',
|
|
'.price-current',
|
|
'[itemprop="price"]',
|
|
'.product-price',
|
|
'.a-price .a-offscreen',
|
|
'.a-price-whole',
|
|
'span[class*="price"]',
|
|
]
|
|
|
|
for selector in price_selectors:
|
|
try:
|
|
elements = await page.query_selector_all(selector)
|
|
for elem in elements:
|
|
price_text = await elem.inner_text()
|
|
if price_text:
|
|
# Parse price
|
|
import re
|
|
cleaned = price_text.strip().replace('€', '').replace('EUR', '').strip()
|
|
cleaned = cleaned.replace(' ', '').replace('\xa0', '')
|
|
cleaned = cleaned.replace(',', '.')
|
|
|
|
match = re.search(r'(\d+\.?\d*)', cleaned)
|
|
if match:
|
|
try:
|
|
price_val = float(match.group(1))
|
|
# Validate price
|
|
if 0.01 < price_val < 100000:
|
|
logger.debug(f"Found price: {price_val}€ from {selector}")
|
|
return price_val
|
|
except ValueError:
|
|
continue
|
|
except Exception:
|
|
continue
|
|
|
|
logger.debug("No price found")
|
|
return None
|
|
|
|
@staticmethod
|
|
async def _extract_stock_status(page: Page) -> bool | None:
|
|
"""Extract stock status from product page"""
|
|
# Check for out of stock indicators
|
|
out_of_stock_texts = [
|
|
"rupture de stock",
|
|
"indisponible",
|
|
"out of stock",
|
|
"unavailable",
|
|
"épuisé",
|
|
"non disponible"
|
|
]
|
|
|
|
try:
|
|
page_text = await page.inner_text("body")
|
|
page_text_lower = page_text.lower()
|
|
|
|
for text in out_of_stock_texts:
|
|
if text in page_text_lower:
|
|
logger.debug(f"Out of stock detected: '{text}'")
|
|
return False
|
|
|
|
# If we find "add to cart" or similar, assume in stock
|
|
add_to_cart_texts = ["ajouter au panier", "add to cart", "acheter", "buy now"]
|
|
for text in add_to_cart_texts:
|
|
if text in page_text_lower:
|
|
logger.debug(f"In stock detected: '{text}'")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.debug(f"Stock check error: {e}")
|
|
|
|
return None # Unknown
|
|
|
|
|
|
@classmethod
|
|
async def search_site_generator(cls, site_key: str, query: str) -> AsyncGenerator[SearchResult, None]:
|
|
"""Search a single site and yield results as they are scraped"""
|
|
results = await cls.search_site(site_key, query)
|
|
for result in results:
|
|
yield result
|
|
|
|
@classmethod
|
|
async def search_all(cls, query: str) -> list[SearchResult]:
|
|
"""Search all configured sites"""
|
|
tasks = []
|
|
for site_key in SITE_CONFIGS.keys():
|
|
tasks.append(cls.search_site(site_key, query))
|
|
|
|
results_list = await asyncio.gather(*tasks)
|
|
all_results = []
|
|
for r in results_list:
|
|
all_results.extend(r)
|
|
return all_results
|
|
|
|
|
|
# ==========================================
|
|
# COMPATIBILITY LAYER FOR API ROUTERS
|
|
# ==========================================
|
|
|
|
async def search_products(
|
|
query: str,
|
|
db: Session,
|
|
site_ids: list[int] | None = None,
|
|
max_results: int | None = None,
|
|
) -> AsyncGenerator[SearchProgress, None]:
|
|
"""
|
|
Compatibility wrapper for search_products using improved service.
|
|
Yields SearchProgress events incrementally.
|
|
"""
|
|
# 1. Get sites to search
|
|
sites = db.query(SearchSite).order_by(SearchSite.priority).all()
|
|
if site_ids:
|
|
sites = [s for s in sites if s.id in site_ids]
|
|
|
|
active_sites = [s for s in sites if s.is_active]
|
|
|
|
# Initial event
|
|
yield SearchProgress(
|
|
status="searching",
|
|
total=len(active_sites),
|
|
completed=0,
|
|
message=f"Démarrage de la recherche sur {len(active_sites)} sites...",
|
|
results=[],
|
|
)
|
|
|
|
# 2. Map DB sites to Config keys
|
|
site_keys = []
|
|
for site in active_sites:
|
|
for key in SITE_CONFIGS.keys():
|
|
if key in site.domain or site.domain in key:
|
|
site_keys.append(key)
|
|
break
|
|
|
|
# 3. Execute searches and stream results
|
|
generators = [ImprovedSearchService.search_site_generator(key, query) for key in site_keys]
|
|
|
|
queue = asyncio.Queue()
|
|
active_producers = len(generators)
|
|
|
|
# Limit concurrent sites
|
|
site_semaphore = asyncio.Semaphore(2)
|
|
|
|
async def producer(gen):
|
|
async with site_semaphore:
|
|
try:
|
|
async for item in gen:
|
|
await queue.put(item)
|
|
except Exception as e:
|
|
logger.error(f"Error in search producer: {e}")
|
|
finally:
|
|
await queue.put(None) # Sentinel
|
|
|
|
# Start producers
|
|
for gen in generators:
|
|
asyncio.create_task(producer(gen))
|
|
|
|
# Consumer loop
|
|
results_so_far = []
|
|
completed_sites = 0
|
|
|
|
while active_producers > 0:
|
|
item = await queue.get()
|
|
|
|
if item is None:
|
|
active_producers -= 1
|
|
completed_sites += 1
|
|
yield SearchProgress(
|
|
status="searching",
|
|
total=len(active_sites),
|
|
completed=completed_sites,
|
|
message=f"Recherche en cours... ({completed_sites}/{len(active_sites)} sites terminés)",
|
|
results=results_so_far,
|
|
)
|
|
else:
|
|
# Convert to SearchResultItem
|
|
api_item = SearchResultItem(
|
|
url=item.url,
|
|
title=item.title,
|
|
price=item.price,
|
|
currency=item.currency,
|
|
in_stock=item.in_stock,
|
|
site_name=item.source,
|
|
site_domain=item.source,
|
|
image_url=item.image_url,
|
|
)
|
|
results_so_far.append(api_item)
|
|
|
|
# Yield update with new result
|
|
yield SearchProgress(
|
|
status="searching",
|
|
total=len(active_sites),
|
|
completed=completed_sites,
|
|
message=f"Trouvé: {item.title[:30]}...",
|
|
results=results_so_far,
|
|
)
|
|
|
|
# Final event
|
|
yield SearchProgress(
|
|
status="completed",
|
|
total=len(active_sites),
|
|
completed=len(active_sites),
|
|
message=f"Terminé. {len(results_so_far)} résultats trouvés.",
|
|
results=results_so_far,
|
|
)
|
|
|
|
|
|
# Global instance
|
|
improved_search_service = ImprovedSearchService()
|