mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-22 22:58:11 +02:00
feat: Add NewSearchService for orchestrating e-commerce searches, including site-specific parsing and AI-powered item detail scraping.
This commit is contained in:
1 parent
22f8aedaa4
commit
292eb3d12e
1 file changed
+112
-41
+112
-41
@@ -207,21 +207,52 @@ class NewSearchService:
|
|||||||
logger.info(f"Found {len(results)} results for {site_key}")
|
logger.info(f"Found {len(results)} results for {site_key}")
|
||||||
return results
|
return results
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
async def search_site_generator(site_key: str, query: str) -> AsyncGenerator[SearchResult, None]:
|
||||||
|
"""Search a single site and yield results as they are scraped"""
|
||||||
|
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}")
|
||||||
|
|
||||||
|
# Use proxy if required by config
|
||||||
|
use_proxy = config.get("requires_proxy", False)
|
||||||
|
|
||||||
|
html_content, _ = await browserless_service.get_page_content(
|
||||||
|
search_url,
|
||||||
|
use_proxy=use_proxy,
|
||||||
|
wait_selector=config.get("wait_selector")
|
||||||
|
)
|
||||||
|
|
||||||
|
if not html_content:
|
||||||
|
logger.warning(f"No content returned for {site_key}")
|
||||||
|
return
|
||||||
|
|
||||||
|
# Phase 1: Parse results
|
||||||
|
initial_results = NewSearchService._parse_results(html_content, site_key, search_url)
|
||||||
|
|
||||||
|
# Phase 2: Scrape details for each result (Sequential or Limited Parallel)
|
||||||
|
# We yield as soon as one is done
|
||||||
|
for res in initial_results:
|
||||||
|
enriched_res = await NewSearchService.scrape_item(res)
|
||||||
|
yield enriched_res
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def search_all(query: str) -> list[SearchResult]:
|
async def search_all(query: str) -> list[SearchResult]:
|
||||||
"""Search all configured sites in parallel"""
|
"""Legacy method for compatibility"""
|
||||||
|
results = []
|
||||||
|
# This is not optimized for streaming, but keeps old signature
|
||||||
tasks = []
|
tasks = []
|
||||||
for site_key in SITE_CONFIGS.keys():
|
for site_key in SITE_CONFIGS.keys():
|
||||||
tasks.append(NewSearchService.search_site(site_key, query))
|
tasks.append(NewSearchService.search_site(site_key, query))
|
||||||
|
|
||||||
results_list = await asyncio.gather(*tasks)
|
results_list = await asyncio.gather(*tasks)
|
||||||
|
for r in results_list:
|
||||||
# Flatten list
|
results.extend(r)
|
||||||
all_results = []
|
return results
|
||||||
for site_results in results_list:
|
|
||||||
all_results.extend(site_results)
|
|
||||||
|
|
||||||
return all_results
|
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
new_search_service = NewSearchService()
|
new_search_service = NewSearchService()
|
||||||
@@ -298,7 +329,7 @@ async def search_products(
|
|||||||
) -> AsyncGenerator[SearchProgress, None]:
|
) -> AsyncGenerator[SearchProgress, None]:
|
||||||
"""
|
"""
|
||||||
Compatibility wrapper for search_products.
|
Compatibility wrapper for search_products.
|
||||||
Yields SearchProgress events.
|
Yields SearchProgress events incrementally.
|
||||||
"""
|
"""
|
||||||
# 1. Get sites to search
|
# 1. Get sites to search
|
||||||
sites = get_all_sites(db)
|
sites = get_all_sites(db)
|
||||||
@@ -307,56 +338,96 @@ async def search_products(
|
|||||||
|
|
||||||
active_sites = [s for s in sites if s.is_active]
|
active_sites = [s for s in sites if s.is_active]
|
||||||
|
|
||||||
|
# Initial event
|
||||||
yield SearchProgress(
|
yield SearchProgress(
|
||||||
status="searching",
|
status="searching",
|
||||||
total=len(active_sites),
|
total=len(active_sites),
|
||||||
completed=0,
|
completed=0,
|
||||||
message=f"Recherche sur {len(active_sites)} sites...",
|
message=f"Démarrage de la recherche sur {len(active_sites)} sites...",
|
||||||
results=[],
|
results=[],
|
||||||
)
|
)
|
||||||
|
|
||||||
# 2. Map DB sites to Config keys
|
# 2. Map DB sites to Config keys
|
||||||
# We need to match DB sites (domain) to SITE_CONFIGS keys
|
site_keys = []
|
||||||
tasks = []
|
|
||||||
|
|
||||||
for site in active_sites:
|
for site in active_sites:
|
||||||
# Find matching config key
|
|
||||||
site_key = None
|
|
||||||
for key in SITE_CONFIGS.keys():
|
for key in SITE_CONFIGS.keys():
|
||||||
if key in site.domain or site.domain in key:
|
if key in site.domain or site.domain in key:
|
||||||
site_key = key
|
site_keys.append(key)
|
||||||
break
|
break
|
||||||
|
|
||||||
if site_key:
|
# 3. Execute searches and stream results
|
||||||
tasks.append(NewSearchService.search_site(site_key, query))
|
# We create a task for each site generator
|
||||||
|
|
||||||
|
generators = [NewSearchService.search_site_generator(key, query) for key in site_keys]
|
||||||
|
|
||||||
|
# We need to iterate over multiple async generators concurrently
|
||||||
|
# This is a bit complex, so we'll use a queue or similar
|
||||||
|
# Simpler approach: Use aiostream if available, or just interleave manually
|
||||||
|
# For now, let's just run them and yield as we get them.
|
||||||
|
# Since we want to show results ASAP, we can use asyncio.as_completed on the *next* item of each generator?
|
||||||
|
# No, generators are stateful.
|
||||||
|
|
||||||
|
# Simplest robust approach without extra libs:
|
||||||
|
# Create a wrapper task for each generator that puts items into a shared Queue
|
||||||
|
|
||||||
|
queue = asyncio.Queue()
|
||||||
|
active_producers = len(generators)
|
||||||
|
|
||||||
|
async def producer(gen):
|
||||||
|
async for item in gen:
|
||||||
|
await queue.put(item)
|
||||||
|
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
|
||||||
|
# Optional: yield progress update without new result
|
||||||
|
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:
|
else:
|
||||||
logger.warning(f"No config found for site {site.domain}")
|
# 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)
|
||||||
|
|
||||||
# 3. Execute searches
|
# Yield update with new result
|
||||||
# To stream results, we should ideally use as_completed, but search_site returns a list
|
yield SearchProgress(
|
||||||
# Let's just wait for all for now to keep it simple, or implement a generator in NewSearchService
|
status="searching",
|
||||||
|
total=len(active_sites),
|
||||||
# Simple implementation: Wait for all (streaming is harder with current architecture)
|
completed=completed_sites,
|
||||||
results_lists = await asyncio.gather(*tasks)
|
message=f"Trouvé: {item.title[:30]}...",
|
||||||
|
results=results_so_far,
|
||||||
all_results = []
|
)
|
||||||
for res_list in results_lists:
|
|
||||||
for r in res_list:
|
|
||||||
all_results.append(SearchResultItem(
|
|
||||||
url=r.url,
|
|
||||||
title=r.title,
|
|
||||||
price=r.price,
|
|
||||||
currency=r.currency,
|
|
||||||
in_stock=r.in_stock,
|
|
||||||
site_name=r.source,
|
|
||||||
site_domain=r.source, # approximate
|
|
||||||
image_url=r.image_url,
|
|
||||||
))
|
|
||||||
|
|
||||||
|
# Final event
|
||||||
yield SearchProgress(
|
yield SearchProgress(
|
||||||
status="completed",
|
status="completed",
|
||||||
total=len(active_sites),
|
total=len(active_sites),
|
||||||
completed=len(active_sites),
|
completed=len(active_sites),
|
||||||
message=f"Terminé. {len(all_results)} résultats trouvés.",
|
message=f"Terminé. {len(results_so_far)} résultats trouvés.",
|
||||||
results=all_results,
|
results=results_so_far,
|
||||||
)
|
)
|
||||||
Reference in new issue
Block a user