feat: Add direct e-commerce search service using Playwright/Browserless with site-specific configurations.

This commit is contained in:
Michael committed 2025-11-23 16:06:16 +01:00
1 parent 9a6ebf6f06
commit e551927327
4 files changed
+243 -160

No files matched your search

+57 -42
View File
@@ -415,6 +415,7 @@ async def search(
sites: list[dict], sites: list[dict],
max_results: int = 20, max_results: int = 20,
timeout: float = 30.0, timeout: float = 30.0,
browser=None, # Instance de navigateur partagée optionnelle
) -> list[SearchResult]: ) -> list[SearchResult]:
""" """
Recherche sur les sites e-commerce via Browserless. Recherche sur les sites e-commerce via Browserless.
@@ -424,6 +425,7 @@ async def search(
sites: Liste de dicts avec {domain, search_url, product_link_selector, name} sites: Liste de dicts avec {domain, search_url, product_link_selector, name}
max_results: Nombre maximum de résultats max_results: Nombre maximum de résultats
timeout: Timeout par site timeout: Timeout par site
browser: Instance Playwright browser partagée (optionnel)
Returns: Returns:
Liste de SearchResult Liste de SearchResult
@@ -435,56 +437,69 @@ async def search(
all_results = [] all_results = []
results_per_site = max(5, max_results // len(sites)) results_per_site = max(5, max_results // len(sites))
logger.info(f"Démarrage recherche parallèle sur {len(sites)} sites pour '{query}'") logger.info(f"Démarrage recherche parallèle v2 (Shared Browser) sur {len(sites)} sites pour '{query}'")
# Si un navigateur est fourni, on l'utilise directement
if browser:
return await _execute_search_with_browser(query, sites, results_per_site, timeout, browser)
# Sinon on crée notre propre instance (comportement autonome)
try: try:
async with async_playwright() as p: async with async_playwright() as p:
# Se connecter à Browserless UNE SEULE FOIS pour tous les sites logger.info(f"Connexion autonome à Browserless: {BROWSERLESS_URL}")
logger.info(f"Connexion à Browserless: {BROWSERLESS_URL}") local_browser = await p.chromium.connect_over_cdp(BROWSERLESS_URL)
browser = await p.chromium.connect_over_cdp(BROWSERLESS_URL)
try: try:
# Créer des tâches pour chaque site return await _execute_search_with_browser(query, sites, results_per_site, timeout, local_browser)
tasks = []
for site in sites:
task = _search_site_browserless(
query=query,
site=site,
max_results=results_per_site,
timeout=timeout,
browser=browser, # Passer l'instance du navigateur
)
tasks.append(task)
# Exécuter en parallèle avec asyncio.gather
# return_exceptions=True permet de continuer même si un site plante
results_list = await asyncio.gather(*tasks, return_exceptions=True)
# Agréger les résultats
for i, result in enumerate(results_list):
site_domain = sites[i].get("domain", "inconnu")
if isinstance(result, Exception):
logger.error(f"Erreur fatale recherche {site_domain}: {result}")
continue
if result:
all_results.extend(result)
logger.info(f"Site {site_domain}: {len(result)} résultats")
else:
logger.info(f"Site {site_domain}: 0 résultat")
finally: finally:
# Fermer le navigateur à la fin await local_browser.close()
if browser: logger.info("Navigateur autonome fermé")
await browser.close() except Exception as e:
logger.info("Navigateur Browserless fermé") logger.error(f"Erreur globale recherche autonome: {e}")
return []
async def _execute_search_with_browser(
query: str,
sites: list[dict],
results_per_site: int,
timeout: float,
browser,
) -> list[SearchResult]:
"""Exécute la recherche avec un navigateur donné"""
all_results = []
try:
# Créer des tâches pour chaque site
tasks = []
for site in sites:
task = _search_site_browserless(
query=query,
site=site,
max_results=results_per_site,
timeout=timeout,
browser=browser,
)
tasks.append(task)
# Exécuter en parallèle avec asyncio.gather
results_list = await asyncio.gather(*tasks, return_exceptions=True)
# Agréger les résultats
for i, result in enumerate(results_list):
site_domain = sites[i].get("domain", "inconnu")
if isinstance(result, Exception):
logger.error(f"Erreur fatale recherche {site_domain}: {result}")
continue
if result:
all_results.extend(result)
logger.info(f"Site {site_domain}: {len(result)} résultats")
else:
logger.info(f"Site {site_domain}: 0 résultat")
except Exception as e: except Exception as e:
logger.error(f"Erreur globale recherche Browserless: {e}") logger.error(f"Erreur exécution recherche: {e}")
logger.info(f"Recherche Browserless terminée: {len(all_results)} résultats totaux") return all_results
return all_results[:max_results]
async def _search_site_browserless( # noqa: PLR0912, PLR0915 async def _search_site_browserless( # noqa: PLR0912, PLR0915
+38 -17
View File
@@ -88,6 +88,7 @@ class ScraperService:
scroll_pixels: int = 350, scroll_pixels: int = 350,
text_length: int = 0, text_length: int = 0,
timeout: int = 90000, timeout: int = 90000,
browser=None, # Instance de navigateur partagée optionnelle
) -> tuple[str | None, str, bool]: ) -> tuple[str | None, str, bool]:
""" """
Scrapes the given URL using Browserless and Playwright. Scrapes the given URL using Browserless and Playwright.
@@ -101,6 +102,7 @@ class ScraperService:
scroll_pixels: Number of pixels to scroll (must be positive) scroll_pixels: Number of pixels to scroll (must be positive)
text_length: Number of characters to extract (0 = disabled) text_length: Number of characters to extract (0 = disabled)
timeout: Page load timeout in milliseconds timeout: Page load timeout in milliseconds
browser: Optional shared Playwright browser instance
""" """
# Simplifier l'URL avant le scraping # Simplifier l'URL avant le scraping
original_url = url original_url = url
@@ -130,6 +132,7 @@ class ScraperService:
scroll_pixels=scroll_pixels, scroll_pixels=scroll_pixels,
text_length=text_length, text_length=text_length,
timeout=timeout, timeout=timeout,
browser=browser,
) )
if result[0] is not None: # Screenshot réussi if result[0] is not None: # Screenshot réussi
@@ -150,25 +153,36 @@ class ScraperService:
scroll_pixels: int = 350, scroll_pixels: int = 350,
text_length: int = 0, text_length: int = 0,
timeout: int = 90000, timeout: int = 90000,
browser=None,
) -> tuple[str | None, str, bool]: ) -> tuple[str | None, str, bool]:
"""Exécute le scraping réel (appelé par scrape_item avec retries).""" """Exécute le scraping réel (appelé par scrape_item avec retries)."""
async with async_playwright() as p:
browser = None # Si un navigateur est fourni, on l'utilise directement sans async_playwright context manager
# sinon on crée tout de zéro
playwright_manager = None
local_browser = None
try:
if browser:
# Utiliser le navigateur partagé
current_browser = browser
else:
# Créer un nouveau navigateur
playwright_manager = async_playwright()
p = await playwright_manager.start()
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}")
current_browser = await p.chromium.connect_over_cdp(
BROWSERLESS_URL,
timeout=60000,
)
local_browser = current_browser
context = None context = None
page = None page = None
is_available = True # Par défaut, le produit est disponible is_available = True # Par défaut, le produit est disponible
try: try:
logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}") context = await current_browser.new_context(
# Utiliser l'endpoint WebSocket de Browserless
# Format: ws://browserless:3000?token=xxx ou ws://browserless:3000
browser = await p.chromium.connect_over_cdp(
BROWSERLESS_URL,
timeout=60000, # 60s pour la connexion
)
context = await browser.new_context(
viewport={"width": 1920, "height": 1080}, viewport={"width": 1920, "height": 1080},
user_agent=( user_agent=(
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
@@ -427,8 +441,15 @@ class ScraperService:
except Exception as e: except Exception as e:
logger.debug(f"Error closing context: {e}") logger.debug(f"Error closing context: {e}")
try: # On ne ferme le navigateur que s'il est local (non partagé)
if browser and browser.is_connected(): if local_browser:
await browser.close() try:
except Exception as e: if local_browser.is_connected():
logger.debug(f"Error closing browser: {e}") await local_browser.close()
except Exception as e:
logger.debug(f"Error closing browser: {e}")
finally:
# Si on a créé un manager playwright local, on l'arrête
if playwright_manager:
await playwright_manager.stop()
+128 -97
View File
@@ -89,125 +89,154 @@ async def search_products(
results=[], results=[],
) )
browser = None
playwright = None
try: try:
search_results = await direct_search_service.search( # Initialiser Playwright et Browserless pour TOUTE la session de recherche
query=query, logger.info(f"Initialisation session Browserless globale pour '{query}'")
sites=sites_data, playwright = await async_playwright().start()
max_results=max_results, browser = await playwright.chromium.connect_over_cdp(
os.getenv("BROWSERLESS_URL", "ws://browserless:3000"),
timeout=60000
) )
except Exception as e:
logger.error(f"Erreur critique lors de la recherche directe: {e}")
yield SearchProgress(
status="error",
total=0,
completed=0,
message=f"Erreur de recherche: {str(e)}",
results=[],
)
return
if not search_results: try:
yield SearchProgress( search_results = await direct_search_service.search(
status="completed", # Changed from error to completed to avoid scary red alerts for just 0 results query=query,
total=0, sites=sites_data,
completed=0, max_results=max_results,
message="Aucun résultat trouvé sur les sites configurés", browser=browser, # Utiliser le navigateur partagé
results=[], )
) except Exception as e:
return logger.error(f"Erreur critique lors de la recherche directe: {e}")
yield SearchProgress(
status="error",
total=0,
completed=0,
message=f"Erreur de recherche: {str(e)}",
results=[],
)
return
total = len(search_results) if not search_results:
logger.info(f"Recherche directe: {total} URLs trouvées pour '{query}'") yield SearchProgress(
status="completed",
total=0,
completed=0,
message="Aucun résultat trouvé sur les sites configurés",
results=[],
)
return
# Phase 2: Scraping des URLs total = len(search_results)
results: list[SearchResultItem] = [] logger.info(f"Recherche directe: {total} URLs trouvées pour '{query}'")
completed = 0
# Créer un sémaphore pour limiter la concurrence # Phase 2: Scraping des URLs
semaphore = asyncio.Semaphore(parallel_limit) results: list[SearchResultItem] = []
completed = 0
async def process_url(search_result: direct_search_service.SearchResult) -> SearchResultItem | None: # Créer un sémaphore pour limiter la concurrence
async with semaphore: semaphore = asyncio.Semaphore(parallel_limit)
url = search_result.url
domain = search_result.source
site = site_map.get(domain)
if not site: async def process_url(search_result: direct_search_service.SearchResult) -> SearchResultItem | None:
# Trouver le site par correspondance partielle async with semaphore:
for d, s in site_map.items(): url = search_result.url
if d in domain or domain in d: domain = search_result.source
site = s site = site_map.get(domain)
break
site_name = site.name if site else domain if not site:
requires_js = site.requires_js if site else False # Trouver le site par correspondance partielle
for d, s in site_map.items():
if d in domain or domain in d:
site = s
break
# Essayer le scraping léger d'abord (sauf si le site requiert JS) site_name = site.name if site else domain
if not requires_js: requires_js = site.requires_js if site else False
light_result = await light_scraper_service.scrape_url(url)
if light_result.success and light_result.price is not None: # 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 search_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:
# Passer le navigateur partagé
result = await _scrape_with_browserless(url, site_name, domain, search_result.title, browser)
return result
except Exception as e:
logger.error(f"Erreur scraping {url}: {e}")
return SearchResultItem( return SearchResultItem(
url=url, url=url,
title=light_result.title or search_result.title, title=search_result.title,
price=light_result.price, price=None,
currency=light_result.currency,
in_stock=light_result.in_stock,
image_url=light_result.image_url,
site_name=site_name, site_name=site_name,
site_domain=domain, site_domain=domain,
confidence=0.7, # Confiance moyenne pour le scraping léger error=str(e),
) )
# Fallback: Browserless + IA # Traiter les URLs en parallèle avec mises à jour progressives
tasks = [asyncio.create_task(process_url(r)) for r in search_results]
for coro in asyncio.as_completed(tasks):
try: try:
result = await _scrape_with_browserless(url, site_name, domain, search_result.title) result = await coro
return result completed += 1
if result:
# Ajouter tous les résultats, même sans prix (le frontend affichera "Prix non disponible")
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: except Exception as e:
logger.error(f"Erreur scraping {url}: {e}") completed += 1
return SearchResultItem( logger.error(f"Erreur lors du traitement: {e}")
url=url,
title=search_result.title,
price=None,
site_name=site_name,
site_domain=domain,
error=str(e),
)
# Traiter les URLs en parallèle avec mises à jour progressives # Trier les résultats par prix
tasks = [asyncio.create_task(process_url(r)) for r in search_results] results.sort(key=lambda x: x.price if x.price else float("inf"))
for coro in asyncio.as_completed(tasks): yield SearchProgress(
try: status="completed",
result = await coro total=total,
completed += 1 completed=completed,
results=results,
message=f"{len(results)} produits trouvés avec prix",
)
if result: finally:
# Ajouter tous les résultats, même sans prix (le frontend affichera "Prix non disponible") # Nettoyage global des ressources
results.append(result) if browser:
try:
await browser.close()
logger.info("Navigateur global fermé")
except Exception as e:
logger.error(f"Erreur fermeture navigateur: {e}")
yield SearchProgress( if playwright:
status="scraping", try:
total=total, await playwright.stop()
completed=completed, except Exception as e:
current_site=result.site_name if result else None, logger.error(f"Erreur arrêt Playwright: {e}")
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( async def _scrape_with_browserless(
@@ -215,10 +244,11 @@ async def _scrape_with_browserless(
site_name: str, site_name: str,
domain: str, domain: str,
fallback_title: str, fallback_title: str,
browser=None, # Navigateur partagé
) -> SearchResultItem: ) -> SearchResultItem:
"""Scrape une URL avec Browserless + extraction IA""" """Scrape une URL avec Browserless + extraction IA"""
try: try:
# Scraper avec Browserless # Scraper avec Browserless (utiliser le navigateur partagé)
screenshot_path, page_text, is_available = await ScraperService.scrape_item( screenshot_path, page_text, is_available = await ScraperService.scrape_item(
url=url, url=url,
item_id=None, item_id=None,
@@ -226,6 +256,7 @@ async def _scrape_with_browserless(
scroll_pixels=350, scroll_pixels=350,
text_length=3000, text_length=3000,
timeout=30000, timeout=30000,
browser=browser,
) )
if not screenshot_path: if not screenshot_path:
+20 -4
View File
@@ -71,11 +71,22 @@ async def test_search_flow():
# Mock SettingsService # Mock SettingsService
search_service.SettingsService.get_setting_value.side_effect = lambda db, key, default: default search_service.SettingsService.get_setting_value.side_effect = lambda db, key, default: default
# Mock async_playwright in search_service
mock_browser = AsyncMock()
mock_playwright_obj = AsyncMock()
mock_playwright_obj.chromium.connect_over_cdp.return_value = mock_browser
mock_playwright_manager = MagicMock()
mock_playwright_manager.start = AsyncMock(return_value=mock_playwright_obj)
search_service.async_playwright = MagicMock(return_value=mock_playwright_manager)
# Mock direct_search_service.search # Mock direct_search_service.search
mock_results = [ mock_results = [
MockSearchResult("http://site1.com/p1", "Product 1", "site1.com"), MockSearchResult("http://site1.com/p1", "Product 1", "site1.com"),
MockSearchResult("http://site2.com/p2", "Product 2", "site2.com"), MockSearchResult("http://site2.com/p2", "Product 2", "site2.com"),
] ]
# Accept any arguments including browser
direct_search_mock.search = AsyncMock(return_value=mock_results) direct_search_mock.search = AsyncMock(return_value=mock_results)
direct_search_mock.SearchResult = MockSearchResult direct_search_mock.SearchResult = MockSearchResult
@@ -100,10 +111,15 @@ async def test_search_flow():
# Run the search # Run the search
print("Running search_products...") print("Running search_products...")
async for progress in search_service.search_products("test query", db): try:
print(f"Event: {progress.status} - {progress.message}") async for progress in search_service.search_products("test query", db):
if progress.results: print(f"Event: {progress.status} - {progress.message}")
print(f" Results: {len(progress.results)}") if progress.results:
print(f" Results: {len(progress.results)}")
except Exception as e:
print(f"Caught exception during search: {e}")
import traceback
traceback.print_exc()
print("--- Verification Complete ---") print("--- Verification Complete ---")