diff --git a/app/core/search_config.py b/app/core/search_config.py new file mode 100644 index 0000000..9d9082b --- /dev/null +++ b/app/core/search_config.py @@ -0,0 +1,206 @@ +""" +Search Configuration +Centralizes all configuration for the search system: +- Site definitions (selectors, URLs) +- Proxy configurations +- User Agents +""" + +import os +import random + +# === BROWSERLESS CONFIGURATION === +BROWSERLESS_URL = os.getenv("BROWSERLESS_URL", "ws://browserless:3000") +DEBUG_DUMPS_DIR = "/app/debug_dumps" + +# === PROXY CONFIGURATION === +# Format from user: ip:port:username:password +AMAZON_PROXY_LIST_RAW = [ + "142.111.48.253:7030:jasuwwjr:elbsx170nmnl", + "31.59.20.176:6754:jasuwwjr:elbsx170nmnl", + "23.95.150.145:6114:jasuwwjr:elbsx170nmnl", + "198.23.239.134:6540:jasuwwjr:elbsx170nmnl", + "107.172.163.27:6543:jasuwwjr:elbsx170nmnl", + "198.105.121.200:6462:jasuwwjr:elbsx170nmnl", + "64.137.96.74:6641:jasuwwjr:elbsx170nmnl", + "84.247.60.125:6095:jasuwwjr:elbsx170nmnl", + "216.10.27.159:6837:jasuwwjr:elbsx170nmnl", + "142.111.67.146:5611:jasuwwjr:elbsx170nmnl", +] + +def get_amazon_proxies() -> list[dict]: + """Convert raw proxy list to Playwright format""" + proxies = [] + for proxy in AMAZON_PROXY_LIST_RAW: + parts = proxy.split(":") + if len(parts) == 4: + proxies.append({ + "server": f"http://{parts[0]}:{parts[1]}", + "username": parts[2], + "password": parts[3] + }) + return proxies + +# === USER AGENTS === +USER_AGENT_POOL = [ + # Chrome Windows + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", + # Chrome Mac + "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + # Firefox Windows + "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", + # Edge Windows + "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", +] + +def get_random_user_agent() -> str: + return random.choice(USER_AGENT_POOL) + +# === SITE CONFIGURATIONS === +SITE_CONFIGS = { + # === MAGASINS DISCOUNT === + "gifi.fr": { + "name": "Gifi", + "search_url": "https://www.gifi.fr/resultat-recherche?q={query}", + "product_selector": "a.link, .product-item a.link, .product-tile a.link", + "wait_selector": ".product-item, .product-tile, .products-grid", + "category": "Discount", + "requires_proxy": False, + }, + "stokomani.fr": { + "name": "Stokomani", + "search_url": "https://www.stokomani.fr/recherche?q={query}", + "product_selector": ".product-miniature a, .js-product-miniature a, a.product-thumbnail", + "wait_selector": ".products, .product-miniature", + "category": "Discount", + "requires_proxy": False, + }, + "bmstores.fr": { + "name": "B&M", + "search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}", + "product_selector": "a.product-thumbnail[href*='/produits/']:not([href*='/c/'])", + "wait_selector": ".products, .product-miniature", + "category": "Discount", + "requires_proxy": False, + }, + "centrakor.com": { + "name": "Centrakor", + "search_url": "https://www.centrakor.com/catalogsearch/result/?q={query}", + "product_selector": ".product-item a.product-item-link, .product-item-info a", + "wait_selector": ".products-grid, .product-items", + "category": "Déco & Maison", + "requires_proxy": False, + }, + "lincroyable.fr": { + "name": "L'Incroyable", + "search_url": "https://www.lincroyable.fr/recherche-query={query}/?", + "product_selector": ".product-miniature a, .js-product-miniature a", + "wait_selector": ".products, .product-miniature", + "category": "Discount", + "requires_proxy": False, + }, + "action.com": { + "name": "Action", + "search_url": "https://www.action.com/fr-fr/search/?q={query}", + "product_selector": "a.product-card__link, .product-card a", + "wait_selector": ".product-card, .search-results", + "category": "Discount", + "requires_proxy": False, + }, + "lafoirfouille.fr": { + "name": "La Foir'Fouille", + "search_url": "https://www.lafoirfouille.fr/catalogsearch/result/?q={query}", + "product_selector": ".product-item a.product-item-link, .product-item-info a", + "wait_selector": ".products-grid, .product-items", + "category": "Discount", + "requires_proxy": False, + }, + # === GRANDES SURFACES === + "e.leclerc": { + "name": "E.Leclerc", + "search_url": "https://www.e.leclerc/recherche?q={query}", + "product_selector": "a[href*='/fp/'][href*='-']:not([href*='promo'])", + "wait_selector": "[data-testid='product-grid'], .search-results-list", + "category": "Grande Surface", + "requires_proxy": False, + }, + "auchan.fr": { + "name": "Auchan", + "search_url": "https://www.auchan.fr/recherche?text={query}", + "product_selector": "a[href*='/p/'][href$='.html']", + "wait_selector": ".search-results, .product-grid", + "category": "Grande Surface", + "requires_proxy": False, + }, + "carrefour.fr": { + "name": "Carrefour", + "search_url": "https://www.carrefour.fr/s?q={query}", + "product_selector": "a[href*='/p/'][href$='.html']", + "wait_selector": ".search-results, .product-grid", + "category": "Grande Surface", + "requires_proxy": False, + }, + # === E-COMMERCE GÉNÉRALISTE === + "amazon.fr": { + "name": "Amazon France", + "search_url": "https://www.amazon.fr/s?k={query}", + "product_selector": "div[data-asin]:not([data-asin='']) h2 a", + "wait_selector": "[data-component-type='s-search-result']", + "category": "E-commerce", + "requires_proxy": True, + }, + "cdiscount.com": { + "name": "Cdiscount", + "search_url": "https://www.cdiscount.com/search/10/{query}.html", + "product_selector": "a.prdtBImg[href*='/f-'][href*='.html']", + "wait_selector": ".prdtBILDetails, .product-list", + "category": "E-commerce", + "requires_proxy": False, + }, + # === ÉLECTRONIQUE / HIGH-TECH === + "darty.com": { + "name": "Darty", + "search_url": "https://www.darty.com/nav/recherche?text={query}", + "product_selector": "a[href*='/nav/achat/'][href*='.html']", + "wait_selector": ".product-card, .product-list", + "category": "Électronique", + "requires_proxy": False, + }, + "boulanger.com": { + "name": "Boulanger", + "search_url": "https://www.boulanger.com/resultats?tr={query}", + "product_selector": "a[href*='/ref/'][href*='_']", + "wait_selector": ".product-list, .product-card", + "category": "Électronique", + "requires_proxy": False, + }, + "fnac.com": { + "name": "Fnac", + "search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}", + "product_selector": "a.Article-title[href*='/a']", + "wait_selector": ".Article-item, .Article-list", + "category": "Culture & Tech", + "requires_proxy": False, + }, +} + +# === COOKIE BANNERS === +COOKIE_ACCEPT_SELECTORS = [ + "#sp-cc-accept", # Amazon + "#onetrust-accept-btn-handler", # Many sites + ".onetrust-accept-btn-handler", + "#popin_tc_privacy_button_2", # Auchan/Cdiscount + ".popin_tc_privacy_button", + "#didomi-notice-agree-button", # Many French sites + ".didomi-continue-without-agreeing", + "[data-testid='accept-cookies-button']", # Carrefour + ".bcom-consent-accept-all", # Boulanger + "#cookieBanner-accept", + ".action-primary.action-accept", # Magento + "#btn-cookie-allow", + "button:has-text('Tout accepter')", + "button:has-text('Accepter tout')", + "button:has-text('Accepter')", + "button:has-text('J\\'accepte')", +] diff --git a/app/services/browserless_service.py b/app/services/browserless_service.py new file mode 100644 index 0000000..d1dcfc1 --- /dev/null +++ b/app/services/browserless_service.py @@ -0,0 +1,180 @@ +""" +Browserless Service +Manages connections to Browserless.io with advanced stealth capabilities. +Handles: +- Connection lifecycle +- Stealth injection +- Proxy rotation +- Human behavior simulation +""" + +import asyncio +import logging +import random +import time +from typing import Optional, Any + +from playwright.async_api import async_playwright, Browser, BrowserContext, Page +from app.core.search_config import ( + BROWSERLESS_URL, + get_amazon_proxies, + get_random_user_agent, + COOKIE_ACCEPT_SELECTORS +) + +logger = logging.getLogger(__name__) + +# Stealth JS to inject +STEALTH_JS = """ +// Advanced Stealth Mode +Object.defineProperty(navigator, 'webdriver', { get: () => false }); +delete Object.getPrototypeOf(navigator).webdriver; +window.chrome = { runtime: {} }; +Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] }); +Object.defineProperty(navigator, 'languages', { get: () => ['fr-FR', 'fr', 'en-US', 'en'] }); +""" + +class BrowserlessService: + def __init__(self): + self._playwright = None + self._browser: Optional[Browser] = None + self._proxies = get_amazon_proxies() + + async def start(self): + """Initialize the browser connection""" + if not self._playwright: + self._playwright = await async_playwright().start() + + if not self._browser or not self._browser.is_connected(): + try: + logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}") + # Use the stealth endpoint if possible, otherwise standard + # Note: /chromium/stealth might need specific token or config, + # falling back to standard connect_over_cdp which is safer for generic browserless + self._browser = await self._playwright.chromium.connect_over_cdp( + BROWSERLESS_URL, + timeout=60000 + ) + except Exception as e: + logger.error(f"Failed to connect to Browserless: {e}") + raise + + async def stop(self): + """Close resources""" + if self._browser: + await self._browser.close() + if self._playwright: + await self._playwright.stop() + + async def get_context(self, use_proxy: bool = False) -> BrowserContext: + """Create a new context with stealth settings""" + await self.start() + + user_agent = get_random_user_agent() + + options = { + "viewport": {"width": 1920, "height": 1080}, + "user_agent": user_agent, + "locale": "fr-FR", + "timezone_id": "Europe/Paris", + "java_script_enabled": True, + "bypass_csp": True, + "extra_http_headers": { + "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", + "Sec-Ch-Ua-Platform": '"Windows"', + "Upgrade-Insecure-Requests": "1", + } + } + + if use_proxy and self._proxies: + proxy = random.choice(self._proxies) + options["proxy"] = proxy + logger.info(f"Using proxy: {proxy['server'].split('//')[1]}") + + context = await self._browser.new_context(**options) + + # Inject Stealth JS + await context.add_init_script(STEALTH_JS) + + # Block aggressive tracking but keep images for visual verification if needed + # (Optimized for speed vs detection) + await context.route("**/*", lambda route: route.continue_()) + + return context + + async def simulate_human_behavior(self, page: Page): + """Simulate random mouse movements and scrolling""" + try: + # Mouse movements + for _ in range(random.randint(2, 5)): + await page.mouse.move( + random.randint(100, 1800), + random.randint(100, 900) + ) + await asyncio.sleep(random.uniform(0.1, 0.3)) + + # Scrolling + await page.evaluate("window.scrollBy(0, 300)") + await asyncio.sleep(random.uniform(0.5, 1.0)) + await page.evaluate("window.scrollBy(0, -100)") + except Exception: + pass + + async def handle_cookie_banners(self, page: Page): + """Attempt to close cookie banners""" + try: + for selector in COOKIE_ACCEPT_SELECTORS: + if await page.locator(selector).first.is_visible(timeout=200): + await page.locator(selector).first.click() + logger.info(f"Closed cookie banner with {selector}") + await asyncio.sleep(0.5) + return + except Exception: + pass + + async def get_page_content(self, url: str, use_proxy: bool = False, wait_selector: str = None) -> tuple[str, str]: + """ + Fetch page content with full stealth lifecycle. + Returns (html_content, screenshot_path) + """ + context = await self.get_context(use_proxy=use_proxy) + page = await context.new_page() + + try: + logger.info(f"Navigating to {url}") + # Random delay before start + await asyncio.sleep(random.uniform(0.5, 1.5)) + + response = await page.goto(url, wait_until="domcontentloaded", timeout=60000) + + if response.status == 503: + logger.warning(f"503 Detected on {url}") + # Retry logic could be handled here or by caller, + # for now we return what we have, caller decides + + await self.handle_cookie_banners(page) + await self.simulate_human_behavior(page) + + if wait_selector: + try: + await page.wait_for_selector(wait_selector, timeout=10000) + except Exception: + logger.warning(f"Wait selector {wait_selector} timed out") + + # Wait for network idle to ensure dynamic content loads + try: + await page.wait_for_load_state("networkidle", timeout=5000) + except Exception: + pass + + content = await page.content() + return content, "" # Screenshot logic can be added if needed + + except Exception as e: + logger.error(f"Error fetching {url}: {e}") + return "", "" + finally: + await context.close() + +# Global instance +browserless_service = BrowserlessService() diff --git a/app/services/direct_search_service.py b/app/services/direct_search_service.py deleted file mode 100644 index df3df26..0000000 --- a/app/services/direct_search_service.py +++ /dev/null @@ -1,1675 +0,0 @@ -""" -Direct Search Service - Recherche sur les sites e-commerce via Browserless -Utilise Playwright/Browserless pour gérer JavaScript et contourner la détection de bots. -""" - -import asyncio -import logging -import os -import random -import re -import time -from urllib.parse import quote_plus, urljoin, urlparse - -from bs4 import BeautifulSoup -from playwright.async_api import async_playwright - -logger = logging.getLogger(__name__) - -BROWSERLESS_URL = os.getenv("BROWSERLESS_URL", "ws://browserless:3000") - -# Proxy configuration for Amazon - 10 rotating proxies -# Format from user: ip:port:username:password -AMAZON_PROXY_LIST_RAW = [ - "142.111.48.253:7030:jasuwwjr:elbsx170nmnl", - "31.59.20.176:6754:jasuwwjr:elbsx170nmnl", - "23.95.150.145:6114:jasuwwjr:elbsx170nmnl", - "198.23.239.134:6540:jasuwwjr:elbsx170nmnl", - "107.172.163.27:6543:jasuwwjr:elbsx170nmnl", - "198.105.121.200:6462:jasuwwjr:elbsx170nmnl", - "64.137.96.74:6641:jasuwwjr:elbsx170nmnl", - "84.247.60.125:6095:jasuwwjr:elbsx170nmnl", - "216.10.27.159:6837:jasuwwjr:elbsx170nmnl", - "142.111.67.146:5611:jasuwwjr:elbsx170nmnl", -] - -# Convert to Playwright proxy format: {"server": "http://ip:port", "username": "user", "password": "pass"} -AMAZON_PROXY_LIST = [] -for proxy in AMAZON_PROXY_LIST_RAW: - parts = proxy.split(":") - if len(parts) == 4: - AMAZON_PROXY_LIST.append({ - "server": f"http://{parts[0]}:{parts[1]}", - "username": parts[2], - "password": parts[3] - }) - -# Log proxy initialization at startup -logger.info(f"Amazon proxies initialized: {len(AMAZON_PROXY_LIST)} proxies available") - -def _get_amazon_proxy() -> dict | None: - """Get a random proxy for Amazon requests from the rotating pool""" - if AMAZON_PROXY_LIST: - proxy = random.choice(AMAZON_PROXY_LIST) - # Log only the IP part for security (hide credentials) - ip_port = proxy["server"].replace("http://", "") - logger.info(f"Using proxy: {ip_port}") - return proxy - logger.warning("No Amazon proxies available!") - return None - -# Constants -MIN_TITLE_LENGTH = 3 -MIN_LINK_TEXT_LENGTH = 5 - -# Pool of realistic User-Agents for rotation (Chrome on Windows/Mac) -USER_AGENT_POOL = [ - # Chrome Windows - Latest versions - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/128.0.0.0 Safari/537.36", - # Chrome Mac - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - # Firefox Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:132.0) Gecko/20100101 Firefox/132.0", - # Firefox Mac - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.15; rv:133.0) Gecko/20100101 Firefox/133.0", - # Edge Windows - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", -] - -# Amazon-specific blocking indicators -AMAZON_BLOCK_INDICATORS = [ - "Toutes nos excuses", - "Sorry, something went wrong", - "To discuss automated access to Amazon data", - "api-services-support@amazon.com", - "Enter the characters you see below", - "Saisissez les caractères", - "Type the characters you see", - "Sorry! Something went wrong", - "Service Unavailable", - "robot check", - "automated access", - "ref=cs_503", -] - -# Amazon retry configuration -AMAZON_MAX_RETRIES = 4 -AMAZON_BASE_DELAY = 3.0 # seconds -AMAZON_MAX_DELAY = 30.0 # seconds - - -def _get_random_user_agent() -> str: - """Get a random User-Agent from the pool""" - return random.choice(USER_AGENT_POOL) - - -def _is_amazon_blocked(html_content: str) -> bool: - """Check if Amazon has blocked the request""" - html_lower = html_content.lower() - for indicator in AMAZON_BLOCK_INDICATORS: - if indicator.lower() in html_lower: - return True - return False - - -async def _random_delay(min_seconds: float = 1.0, max_seconds: float = 3.0): - """Add a random delay to simulate human behavior""" - delay = random.uniform(min_seconds, max_seconds) - await asyncio.sleep(delay) - - -async def _simulate_human_behavior(page): - """Simulate human-like behavior on the page""" - try: - # Random mouse movements - for _ in range(random.randint(2, 4)): - x = random.randint(100, 1800) - y = random.randint(100, 900) - await page.mouse.move(x, y) - await asyncio.sleep(random.uniform(0.1, 0.3)) - - # Small random scroll - scroll_amount = random.randint(100, 400) - await page.evaluate(f"window.scrollBy(0, {scroll_amount})") - await asyncio.sleep(random.uniform(0.3, 0.7)) - - # Scroll back up a bit - await page.evaluate(f"window.scrollBy(0, -{random.randint(50, 150)})") - await asyncio.sleep(random.uniform(0.2, 0.5)) - except Exception as e: - logger.debug(f"Human behavior simulation error (non-critical): {e}") - - -# Legacy constant for backward compatibility -USER_AGENT = USER_AGENT_POOL[0] - - -# Stealth JavaScript to inject before page load to hide automation -STEALTH_JS = """ -// ============================================ -// COMPREHENSIVE STEALTH MODE FOR AMAZON -// ============================================ - -// 1. Override navigator.webdriver in multiple ways -Object.defineProperty(navigator, 'webdriver', { - get: () => false, - configurable: true -}); - -// Also delete from navigator prototype -delete Object.getPrototypeOf(navigator).webdriver; - -// Override the getAttribute method to hide webdriver attribute -const originalGetAttribute = Element.prototype.getAttribute; -Element.prototype.getAttribute = function(name) { - if (name === 'webdriver') return null; - return originalGetAttribute.call(this, name); -}; - -// 2. Override navigator.plugins with realistic plugins -Object.defineProperty(navigator, 'plugins', { - get: () => { - const plugins = [ - { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1 }, - { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', length: 1 }, - { name: 'Native Client', filename: 'internal-nacl-plugin', description: '', length: 2 }, - { name: 'Chromium PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1 }, - { name: 'Chromium PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', length: 1 } - ]; - plugins.item = (i) => plugins[i] || null; - plugins.namedItem = (name) => plugins.find(p => p.name === name) || null; - plugins.refresh = () => {}; - plugins.length = plugins.length; - return plugins; - }, - configurable: true -}); - -// 3. Override navigator.mimeTypes -Object.defineProperty(navigator, 'mimeTypes', { - get: () => { - const mimeTypes = [ - { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }, - { type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format' } - ]; - mimeTypes.item = (i) => mimeTypes[i] || null; - mimeTypes.namedItem = (name) => mimeTypes.find(m => m.type === name) || null; - mimeTypes.length = mimeTypes.length; - return mimeTypes; - }, - configurable: true -}); - -// 4. Override navigator.languages -Object.defineProperty(navigator, 'languages', { - get: () => ['fr-FR', 'fr', 'en-US', 'en'], - configurable: true -}); - -// 5. Override permissions API -if (window.navigator.permissions) { - const originalQuery = window.navigator.permissions.query; - window.navigator.permissions.query = (parameters) => { - if (parameters.name === 'notifications') { - return Promise.resolve({ state: Notification.permission, onchange: null }); - } - return originalQuery.call(window.navigator.permissions, parameters); - }; -} - -// 6. Hide automation-related properties (Chromium DevTools Protocol) -const propsToDelete = [ - 'cdc_adoQpoasnfa76pfcZLmcfl_Array', - 'cdc_adoQpoasnfa76pfcZLmcfl_Promise', - 'cdc_adoQpoasnfa76pfcZLmcfl_Symbol', - '__webdriver_evaluate', - '__selenium_evaluate', - '__webdriver_script_function', - '__webdriver_script_func', - '__webdriver_script_fn', - '__fxdriver_evaluate', - '__driver_unwrapped', - '__webdriver_unwrapped', - '__driver_evaluate', - '__selenium_unwrapped', - '__fxdriver_unwrapped', - '_Selenium_IDE_Recorder', - '_selenium', - 'calledSelenium', - '$chrome_asyncScriptInfo', - '$cdc_asdjflasutopfhvcZLmcfl_', - '$wdc_' -]; -propsToDelete.forEach(prop => { - try { delete window[prop]; } catch(e) {} -}); - -// 7. Override chrome object with realistic properties -window.chrome = { - app: { - isInstalled: false, - InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, - RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } - }, - runtime: { - OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' }, - OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' }, - PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' }, - PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' }, - PlatformOs: { ANDROID: 'android', CROS: 'cros', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' }, - RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available' }, - connect: function() { return { onDisconnect: { addListener: function() {} }, onMessage: { addListener: function() {} }, postMessage: function() {} }; }, - sendMessage: function() {} - }, - csi: function() { return {}; }, - loadTimes: function() { return { requestTime: Date.now() / 1000, startLoadTime: Date.now() / 1000, firstPaintAfterLoadTime: 0, firstPaintTime: Date.now() / 1000, navigationType: 'navigate' }; } -}; - -// 8. WebGL fingerprinting protection -const getParameterProxyHandler = { - apply: function(target, ctx, args) { - if (args[0] === 37445) return 'Intel Inc.'; - if (args[0] === 37446) return 'Intel Iris OpenGL Engine'; - if (args[0] === 7937) return 'WebKit'; - if (args[0] === 7936) return 'WebKit WebGL'; - return Reflect.apply(target, ctx, args); - } -}; -try { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); - if (gl) { - const getParameter = gl.getParameter.bind(gl); - gl.getParameter = new Proxy(getParameter, getParameterProxyHandler); - } - const gl2 = canvas.getContext('webgl2'); - if (gl2) { - const getParameter2 = gl2.getParameter.bind(gl2); - gl2.getParameter = new Proxy(getParameter2, getParameterProxyHandler); - } -} catch(e) {} - -// 9. Canvas fingerprinting protection -const originalToDataURL = HTMLCanvasElement.prototype.toDataURL; -HTMLCanvasElement.prototype.toDataURL = function(type) { - if (type === 'image/png' && this.width === 220 && this.height === 30) { - // Likely fingerprinting attempt, add noise - const context = this.getContext('2d'); - if (context) { - const imageData = context.getImageData(0, 0, this.width, this.height); - for (let i = 0; i < imageData.data.length; i += 4) { - imageData.data[i] = imageData.data[i] ^ (Math.random() * 2); - } - context.putImageData(imageData, 0, 0); - } - } - return originalToDataURL.apply(this, arguments); -}; - -// 10. AudioContext fingerprinting protection -if (window.AudioContext || window.webkitAudioContext) { - const AudioContext = window.AudioContext || window.webkitAudioContext; - const originalCreateOscillator = AudioContext.prototype.createOscillator; - AudioContext.prototype.createOscillator = function() { - const oscillator = originalCreateOscillator.apply(this, arguments); - oscillator.frequency.value = oscillator.frequency.value + (Math.random() * 0.0001); - return oscillator; - }; -} - -// 11. Override connection properties -Object.defineProperty(navigator, 'connection', { - get: () => ({ - effectiveType: '4g', - rtt: 50 + Math.floor(Math.random() * 50), - downlink: 10 + Math.random() * 5, - saveData: false - }), - configurable: true -}); - -// 12. Override hardware properties -Object.defineProperty(navigator, 'hardwareConcurrency', { - get: () => 8, - configurable: true -}); - -Object.defineProperty(navigator, 'deviceMemory', { - get: () => 8, - configurable: true -}); - -// 13. Override screen properties -Object.defineProperty(screen, 'colorDepth', { - get: () => 24, - configurable: true -}); - -Object.defineProperty(screen, 'pixelDepth', { - get: () => 24, - configurable: true -}); - -// 14. Spoof Notification API -if (window.Notification) { - Object.defineProperty(Notification, 'permission', { - get: () => 'default', - configurable: true - }); -} - -// 15. Override iframe contentWindow checks -const originalContentWindow = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow'); -Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { - get: function() { - const win = originalContentWindow.get.call(this); - if (win) { - try { - Object.defineProperty(win.navigator, 'webdriver', { - get: () => false, - configurable: true - }); - } catch(e) {} - } - return win; - } -}); - -// 16. Mock Battery API -if (navigator.getBattery) { - navigator.getBattery = () => Promise.resolve({ - charging: true, - chargingTime: 0, - dischargingTime: Infinity, - level: 1, - addEventListener: () => {}, - removeEventListener: () => {} - }); -} - -// 17. Hide Playwright/Puppeteer specific objects -delete window.__playwright; -delete window.__pw_manual; -delete window.__PW_inspect; - -console.log('Advanced stealth mode activated'); -""" - - -# Chemin absolu pour les dumps de débogage (doit correspondre à celui dans debug.py) -DEBUG_DUMPS_DIR = "/app/debug_dumps" - - -def _dump_debug_html(html: str, domain: str, query: str): - """Sauvegarde le HTML pour débogage""" - try: - if not os.path.exists(DEBUG_DUMPS_DIR): - os.makedirs(DEBUG_DUMPS_DIR, exist_ok=True) - logger.info(f"Created debug dumps directory: {DEBUG_DUMPS_DIR}") - - # Utiliser un timestamp unique basé sur le temps actuel - timestamp = int(time.time() * 1000) - # Nettoyer la query pour le nom de fichier - safe_query = "".join(c if c.isalnum() else "_" for c in query)[:50] - filename = f"{DEBUG_DUMPS_DIR}/{domain}_{safe_query}_{timestamp}.html" - - with open(filename, "w", encoding="utf-8") as f: - f.write(html) - logger.info(f"HTML dump saved to {filename}") - return filename - except Exception as e: - logger.error(f"Failed to dump HTML: {e}") - return None - - -class SearchResult: - """Résultat de recherche""" - - def __init__( - self, - url: str, - title: str, - snippet: str, - source: str, - price: float | None = None, - currency: str = "EUR", - in_stock: bool | 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 - - def __repr__(self): - return f"SearchResult(url={self.url}, title={self.title[:30]}...)" - - -# Configuration des sites avec leurs sélecteurs par défaut -# Liste des sites français configurés en dur -DEFAULT_SITE_CONFIGS = { - # === MAGASINS DISCOUNT === - "gifi.fr": { - "name": "Gifi", - "search_url": "https://www.gifi.fr/resultat-recherche?q={query}", - "product_selector": ( - "h3[data-gtm] a.link, .product-tile a[aria-label='Afficher les détails du produit']" - ), - "wait_selector": ".products-grid, .product-items, .search-results, .products", - "category": "Discount", - "requires_js": True, - "priority": 1, - }, - "stokomani.fr": { - "name": "Stokomani", - "search_url": "https://www.stokomani.fr/recherche?q={query}", - "product_selector": ( - ".product-miniature a, .js-product-miniature a, a.product-thumbnail, " - ".products article a, a[href*='/products/'], a[href*='/produit/']" - ), - "wait_selector": ".products, .product-miniature, #js-product-list, .collection-products", - "category": "Discount", - "requires_js": True, - "priority": 2, - }, - "bmstores.fr": { - "name": "B&M", - "search_url": "https://bmstores.fr/module/ambjolisearch/jolisearch?s={query}", - "product_selector": ( - # Cibler uniquement les liens produits, pas les catégories - "a.product-thumbnail[href*='/produits/']:not([href*='/c/']):not([href*='/categorie']), " - ".product-miniature a[href*='/produits/']:not([href*='/c/']), " - ".js-product-miniature a[href*='/produits/']" - ), - "wait_selector": ".products, .product-miniature, #js-product-list, #search_results", - "category": "Discount", - "requires_js": True, - "priority": 3, - }, - "centrakor.com": { - "name": "Centrakor", - "search_url": "https://www.centrakor.com/catalogsearch/result/?q={query}", - "product_selector": ( - ".product-item a.product-item-link, .product-item-info a, " - ".products-grid a, a[href$='.html']" - ), - "wait_selector": ".products-grid, .product-items, .search-results", - "category": "Déco & Maison", - "requires_js": True, - "priority": 4, - }, - "lincroyable.fr": { - "name": "L'Incroyable", - "search_url": "https://www.lincroyable.fr/recherche-query={query}/?", - "product_selector": ( - ".product-miniature a, .js-product-miniature a, a.product-thumbnail, " - ".products article a, a[href*='/produit']" - ), - "wait_selector": ".products, .product-miniature, #js-product-list", - "category": "Discount", - "requires_js": True, - "priority": 5, - }, - "action.com": { - "name": "Action", - "search_url": "https://www.action.com/fr-fr/search/?q={query}", - "product_selector": "a.product-card__link, .product-card a, a[href*='/p/']", - "wait_selector": ".product-card, .search-results", - "category": "Discount", - "requires_js": True, - "priority": 6, - }, - "lafoirfouille.fr": { - "name": "La Foir'Fouille", - "search_url": "https://www.lafoirfouille.fr/catalogsearch/result/?q={query}", - "product_selector": ( - ".product-item a.product-item-link, .product-item-info a, " - ".products-grid a, a[href$='.html']" - ), - "wait_selector": ".products-grid, .product-items, .search-results", - "category": "Discount", - "requires_js": True, - "priority": 7, - }, - # === GRANDES SURFACES === - "e.leclerc": { - "name": "E.Leclerc", - # URL correcte: https://www.e.leclerc/recherche?q=lutin - "search_url": "https://www.e.leclerc/recherche?q={query}", - "product_selector": ( - # Sélecteurs spécifiques aux produits E.Leclerc (liens /fp/) - "a[href*='/fp/'][href*='-']:not([href*='promo']):not([href*='promotion']), " - ".product-card a[href*='/fp/'], " - "[data-testid='product-card'] a[href*='/fp/']" - ), - "wait_selector": "[data-testid='product-grid'], .search-results-list, .products-grid", - "category": "Grande Surface", - "requires_js": True, - "priority": 8, - }, - "auchan.fr": { - "name": "Auchan", - # URL correcte: https://www.auchan.fr/recherche?text=lutin - "search_url": "https://www.auchan.fr/recherche?text={query}", - "product_selector": ( - # Sélecteurs spécifiques aux produits Auchan (liens /p/ avec ID produit) - "a[href*='/p/'][href$='.html'], " - "a[href*='/p/'][href*='-p-'], " - ".product-thumbnail a[href*='/p/'], " - "[data-testid='product-card'] a[href*='/p/']" - ), - "wait_selector": ".search-results, .product-grid, [data-testid='search-results']", - "category": "Grande Surface", - "requires_js": True, - "priority": 9, - }, - "carrefour.fr": { - "name": "Carrefour", - # URL correcte: https://www.carrefour.fr/s?q=lutin - "search_url": "https://www.carrefour.fr/s?q={query}", - "product_selector": ( - # Sélecteurs spécifiques aux produits Carrefour (liens /p/) - "a[href*='/p/'][href$='.html'], " - "a[href*='/p/'][href*='-p-'], " - ".product-card a[href*='/p/'], " - "[data-testid='product-card'] a[href*='/p/']" - ), - "wait_selector": ".search-results, .product-grid, [data-testid='search-results']", - "category": "Grande Surface", - "requires_js": True, - "priority": 10, - }, - # === E-COMMERCE GÉNÉRALISTE === - "amazon.fr": { - "name": "Amazon France", - "search_url": "https://www.amazon.fr/s?k={query}", - "product_selector": ( - # Sélecteurs robustes pour Amazon - "div[data-asin]:not([data-asin='']) h2 a, " - ".s-result-item[data-asin] h2 a, " - "[data-component-type='s-search-result'] h2 a, " - # Fallback sur les liens d'images si le titre est manqué - ".s-result-item[data-asin] .s-image-link, " - "a.a-link-normal.s-no-outline[href*='/dp/']" - ), - "wait_selector": "[data-component-type='s-search-result'], .s-result-item", - "category": "E-commerce", - "requires_js": True, - "priority": 11, - }, - "cdiscount.com": { - "name": "Cdiscount", - "search_url": "https://www.cdiscount.com/search/10/{query}.html", - "product_selector": ( - # Sélecteurs spécifiques Cdiscount - produits uniquement - "a.prdtBImg[href*='/f-'][href*='.html'], " - ".prdtBILDetails a[href*='/f-'][href*='.html'], " - "a[href*='/f-'][href*='.html']:not([href*='/l-']):not([href*='/rayon'])" - ), - "wait_selector": ".prdtBILDetails, .product-list, #lpBloc", - "category": "E-commerce", - "requires_js": True, - "priority": 12, - }, - # === ÉLECTRONIQUE / HIGH-TECH === - "darty.com": { - "name": "Darty", - "search_url": "https://www.darty.com/nav/recherche?text={query}", - "product_selector": ( - # Cibler les fiches produits, exclure navigation et catégories - "a[href*='/nav/achat/'][href*='.html']:not([href*='/rayon']):not([href*='/promo']), " - ".product-card a[href*='/nav/achat/'], " - "a.product-link[href*='/nav/achat/']" - ), - "wait_selector": ".product-card, .product-list, .product-item", - "category": "Électronique", - "requires_js": True, - "priority": 13, - }, - "boulanger.com": { - "name": "Boulanger", - "search_url": "https://www.boulanger.com/resultats?tr={query}", - "product_selector": ( - # Cibler uniquement les pages produits avec /ref/ - "a[href*='/ref/'][href*='_']:not([href*='/c/']):not([href*='/rayon']), " - ".product-list__item a[href*='/ref/'], " - ".product-card__link[href*='/ref/']" - ), - "wait_selector": ".product-list, .product-card, .products-list", - "category": "Électronique", - "requires_js": True, - "priority": 14, - }, - # === AUTRES (conservés pour compatibilité) === - "amazon.com": { - "name": "Amazon US", - "search_url": "https://www.amazon.com/s?k={query}", - "product_selector": ( - # Sélecteurs robustes pour Amazon - "div[data-asin]:not([data-asin='']) h2 a, " - ".s-result-item[data-asin] h2 a, " - "[data-component-type='s-search-result'] h2 a, " - # Fallback sur les liens d'images si le titre est manqué - ".s-result-item[data-asin] .s-image-link, " - "a.a-link-normal.s-no-outline[href*='/dp/']" - ), - "wait_selector": "[data-component-type='s-search-result'], .s-result-item", - "category": "E-commerce", - "requires_js": True, - "priority": 99, - }, - "fnac.com": { - "name": "Fnac", - "search_url": "https://www.fnac.com/SearchResult/ResultList.aspx?Search={query}", - "product_selector": ( - # Sélecteurs Fnac pour les produits - "a.Article-title[href*='/a'], " - ".Article-item a.js-minifa-title[href*='/a'], " - "a[href*='/a'][href*='.html']:not([href*='/rayon']):not([href*='/univers'])" - ), - "wait_selector": ".Article-item, .Article-list, #SearchResultList", - "category": "Culture & Tech", - "requires_js": True, - "priority": 15, - }, -} - - -# Sélecteurs communs pour les bannières de cookies -# Ordonnés du plus spécifique au plus générique pour une meilleure détection -COOKIE_ACCEPT_SELECTORS = [ - # === SITES SPÉCIFIQUES FRANÇAIS === - # Amazon France - "#sp-cc-accept", - "input[name='accept'][type='submit']", - "[data-action='sp-cc']", - # E.Leclerc - "#onetrust-accept-btn-handler", - ".onetrust-accept-btn-handler", - # Auchan - "#popin_tc_privacy_button_2", - ".popin_tc_privacy_button", - "#didomi-notice-agree-button", - # Carrefour - "#onetrust-accept-btn-handler", - "[data-testid='accept-cookies-button']", - # Cdiscount - "#footer_tc_privacy_button_2", - ".privacy_prompt_accept", - # Darty / Fnac - "#onetrust-accept-btn-handler", - # Boulanger - ".bcom-consent-accept-all", - "#cookieBanner-accept", - - # === CMP (Consent Management Platforms) === - "#didomi-notice-agree-button", - ".didomi-continue-without-agreeing", - "#onetrust-accept-btn-handler", - ".onetrust-accept-btn-handler", - "#tarteaucitronPersonalize2", - ".tarteaucitronAllow", - "#tarteaucitronAllDenied2", - ".cc-btn.cc-allow", - ".cky-btn-accept", - "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", - "#CybotCookiebotDialogBodyButtonAccept", - ".qc-cmp2-summary-buttons button:first-child", - "[data-accept-cookies]", - "[data-gdpr-accept]", - "#axeptio_btn_acceptAll", - ".axeptio_acceptAll", - - # === TEXTE FRANÇAIS (Playwright :has-text) === - "button:has-text('Tout accepter')", - "button:has-text('Accepter tout')", - "button:has-text('Accepter et fermer')", - "button:has-text('Accepter les cookies')", - "button:has-text('Accepter')", - "button:has-text('J\\'accepte')", - "button:has-text('OK')", - "button:has-text('Continuer')", - "button:has-text('Continuer sans accepter')", - "a:has-text('Tout accepter')", - "a:has-text('Accepter')", - "a:has-text('J\\'accepte')", - "span:has-text('Tout accepter')", - - # === TEXTE ANGLAIS === - "button:has-text('Accept all')", - "button:has-text('Accept cookies')", - "button:has-text('Accept')", - "button:has-text('I accept')", - "button:has-text('Allow all')", - - # === GÉNÉRIQUES === - "button[id*='accept']", - "button[id*='cookie']", - "button[class*='accept']", - "button[class*='cookie']", - "button[data-testid*='accept']", - "button[data-testid*='cookie']", -] - - -async def _accept_cookies(page, domain: str) -> bool: - """ - Tente d'accepter les cookies sur une page. - Essaie plusieurs fois et gère les iframes. - Retourne True si un bouton a été cliqué, False sinon. - """ - try: - # Attendre un court moment pour que la bannière apparaisse - await asyncio.sleep(1.5) - - # Essayer d'abord dans la page principale - for selector in COOKIE_ACCEPT_SELECTORS: - try: - button = page.locator(selector).first - if await button.is_visible(timeout=300): - await button.click(timeout=2000) - logger.info(f"{domain}: Cookies acceptés avec sélecteur {selector}") - await asyncio.sleep(0.5) - return True - except Exception: - continue - - # Essayer dans les iframes (certains CMP utilisent des iframes) - frames = page.frames - for frame in frames: - if frame == page.main_frame: - continue - for selector in COOKIE_ACCEPT_SELECTORS[:20]: # Limiter aux premiers sélecteurs - try: - button = frame.locator(selector).first - if await button.is_visible(timeout=200): - await button.click(timeout=2000) - logger.info(f"{domain}: Cookies acceptés dans iframe avec {selector}") - await asyncio.sleep(0.5) - return True - except Exception: - continue - - # Deuxième tentative après un peu plus d'attente (pour les bannières lentes) - await asyncio.sleep(1) - for selector in COOKIE_ACCEPT_SELECTORS[:30]: - try: - button = page.locator(selector).first - if await button.is_visible(timeout=200): - await button.click(timeout=2000) - logger.info(f"{domain}: Cookies acceptés (2e essai) avec {selector}") - await asyncio.sleep(0.5) - return True - except Exception: - continue - - logger.debug(f"{domain}: Aucune bannière de cookies trouvée") - return False - - except Exception as e: - logger.debug(f"{domain}: Erreur lors de l'acceptation des cookies: {e}") - return False - - -async def search( - query: str, - sites: list[dict], - max_results: int = 20, - timeout: float = 30.0, - browser=None, # Instance de navigateur partagée optionnelle -) -> list[SearchResult]: - """ - Recherche sur les sites e-commerce via Browserless. - - Args: - query: Terme de recherche - sites: Liste de dicts avec {domain, search_url, product_link_selector, name} - max_results: Nombre maximum de résultats - timeout: Timeout par site - browser: Instance Playwright browser partagée (optionnel) - - Returns: - Liste de SearchResult - """ - if not sites: - logger.warning("Aucun site configuré pour la recherche") - return [] - - all_results = [] - results_per_site = max(5, max_results // len(sites)) - - 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: - async with async_playwright() as p: - logger.info(f"Connexion autonome à Browserless: {BROWSERLESS_URL}") - local_browser = await p.chromium.connect_over_cdp(BROWSERLESS_URL) - try: - return await _execute_search_with_browser(query, sites, results_per_site, timeout, local_browser) - finally: - await local_browser.close() - logger.info("Navigateur autonome fermé") - except Exception as e: - 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: - logger.error(f"Erreur exécution recherche: {e}") - - return all_results - - -async def _search_site_browserless( # noqa: PLR0912, PLR0915 - query: str, - site: dict, - max_results: int, - timeout: float, - browser, # Instance de navigateur partagée -) -> list[SearchResult]: - """Recherche sur un site via Browserless (Playwright)""" - raw_domain = site.get("domain", "").lower() - # Nettoyer le domaine (enlever protocole, www, slash final) - domain = _clean_domain(raw_domain) - - # Check if debug is enabled for this site - debug_enabled = site.get("debug_enabled", False) - - search_url = site.get("search_url") or _get_default_search_url(domain) - product_selector = site.get("product_link_selector") or _get_default_product_selector(domain) - wait_selector = _get_default_wait_selector(domain) - - if not search_url: - logger.warning(f"Pas d'URL de recherche configurée pour {domain}") - return [] - - # Construire l'URL de recherche - encoded_query = quote_plus(query) - final_url = search_url.replace("{query}", encoded_query) - - logger.info(f"Recherche Browserless sur {domain}: {final_url}") - - # Check if this is Amazon - requires special handling - is_amazon = "amazon" in domain.lower() - max_retries = AMAZON_MAX_RETRIES if is_amazon else 1 - - for attempt in range(max_retries): - if attempt > 0: - # Exponential backoff with jitter for Amazon retries - base_delay = AMAZON_BASE_DELAY * (2 ** attempt) - jitter = random.uniform(0.5, 1.5) - delay = min(base_delay * jitter, AMAZON_MAX_DELAY) - logger.info(f"Amazon retry {attempt + 1}/{max_retries} for '{query}' after {delay:.1f}s delay") - await asyncio.sleep(delay) - - context = None - page = None - - try: - # Get random User-Agent for each attempt (important for Amazon) - current_user_agent = _get_random_user_agent() - - # Create stealth headers - more comprehensive for Amazon - extra_headers = { - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "max-age=0", - "Sec-Ch-Ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"Windows"', - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - } - - # Add referer for Amazon to look more natural - if is_amazon: - extra_headers["Referer"] = "https://www.google.fr/" - - # Prepare context options - context_options = { - "viewport": {"width": 1920, "height": 1080}, - "user_agent": current_user_agent, - "locale": "fr-FR", - "timezone_id": "Europe/Paris", - "extra_http_headers": extra_headers, - "java_script_enabled": True, - "bypass_csp": True, - } - - # Add proxy for Amazon if configured - if is_amazon: - proxy_config = _get_amazon_proxy() - if proxy_config: - # proxy_config is already a dict with {"server": "...", "username": "...", "password": "..."} - context_options["proxy"] = proxy_config - - # Créer un nouveau contexte isolé pour ce site - context = await browser.new_context(**context_options) - - # Inject stealth script before any page loads (especially for Amazon) - if is_amazon: - await context.add_init_script(STEALTH_JS) - logger.debug("Stealth JS injected for Amazon") - - # For Amazon, don't block images - it may trigger detection - if not is_amazon: - await context.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico}", lambda route: route.abort()) - await context.route("**/analytics*", lambda route: route.abort()) - await context.route("**/tracking*", lambda route: route.abort()) - - page = await context.new_page() - - # Add random delay before navigation for Amazon - if is_amazon: - await _random_delay(1.0, 3.0) - - # Naviguer vers la page de recherche - await page.goto(final_url, wait_until="domcontentloaded", timeout=int(timeout * 1000)) - - # For Amazon, simulate human behavior before interacting - if is_amazon: - await _simulate_human_behavior(page) - - # Accepter les cookies si nécessaire - await _accept_cookies(page, domain) - - # Attendre que les résultats se chargent - if wait_selector: - try: - await page.wait_for_selector(wait_selector, timeout=10000) - except Exception: - logger.debug(f"Selector {wait_selector} non trouvé sur {domain}, on continue...") - - # Attendre un peu pour le JS - longer wait for Amazon - wait_time = random.uniform(2.0, 4.0) if is_amazon else 1.5 - await asyncio.sleep(wait_time) - - # Extraire le HTML - html_content = await page.content() - - # Check for Amazon blocking (503, "Toutes nos excuses", etc.) - if is_amazon and _is_amazon_blocked(html_content): - logger.warning(f"Amazon blocked request (attempt {attempt + 1}/{max_retries}) for '{query}'") - # Always dump for Amazon blocking (critical error) - if debug_enabled or is_amazon: - _dump_debug_html(html_content, domain, f"{query}_blocked_attempt{attempt + 1}") - # Close resources before retry - try: - if page: - await page.close() - if context: - await context.close() - except Exception: - pass - continue # Retry with new User-Agent and delay - - # Check for CAPTCHA - if "Enter the characters you see below" in html_content or "Saisissez les caractères" in html_content: - logger.warning(f"CAPTCHA detected on {domain}!") - # Always dump for CAPTCHA (critical error) - if debug_enabled or is_amazon: - _dump_debug_html(html_content, domain, f"{query}_captcha") - if is_amazon and attempt < max_retries - 1: - try: - if page: - await page.close() - if context: - await context.close() - except Exception: - pass - continue # Retry - return [] - - # Parser le HTML avec BeautifulSoup - soup = BeautifulSoup(html_content, "lxml") - results = [] - seen_urls = set() - - # ===== AMAZON: Utiliser extraction dédiée sans les filtres génériques ===== - if is_amazon: - logger.info(f"{domain}: Utilisation extraction Amazon dédiée") - links = _find_amazon_product_links(soup) - - for link in links: - href = link.get("href", "") - if not href: - continue - - # Construire l'URL complète pour Amazon - if href.startswith("/"): - href = f"https://www.amazon.fr{href}" - elif not href.startswith("http"): - href = urljoin(final_url, href) - - # Nettoyer l'URL Amazon (enlever les paramètres de tracking) - if "?" in href: - href = href.split("?")[0] - - # Éviter les doublons - if href in seen_urls: - continue - seen_urls.add(href) - - # Extraire le titre - title = _extract_title(link) - if not title or len(title) < MIN_TITLE_LENGTH: - # Pour Amazon, essayer aussi alt de l'image - img = link.find("img") - if img and img.get("alt"): - title = img.get("alt").strip() - - if not title or len(title) < MIN_TITLE_LENGTH: - continue - - results.append(SearchResult( - url=href, - title=title, - snippet="", - source=domain, - )) - - if len(results) >= max_results: - break - - logger.info(f"{domain}: {len(results)} résultats Amazon extraits") - - # ===== AUTRES SITES: Utiliser la logique générique avec filtres ===== - else: - # Trouver les liens produits - links = [] - if product_selector: - # Essayer plusieurs sélecteurs séparés par virgule - selectors = [s.strip() for s in product_selector.split(",")] - for sel in selectors: - try: - found = soup.select(sel) - links.extend(found) - except Exception: - continue - logger.info(f"{domain}: {len(links)} liens trouvés avec sélecteur configuré") - - # Fallback: détection générique si le sélecteur n'a rien trouvé - if not links: - logger.info(f"{domain}: Sélecteur configuré n'a rien trouvé, utilisation détection générique") - links = _find_product_links(soup, domain) - logger.info(f"{domain}: {len(links)} liens trouvés avec détection générique") - - for link in links: - href = link.get("href", "") - if not href: - continue - - # Construire l'URL complète - if href.startswith("/"): - href = f"https://{domain}{href}" - elif not href.startswith("http"): - href = urljoin(final_url, href) - - # Nettoyer l'URL - href = _clean_product_url(href, domain) - - # Éviter les doublons - if href in seen_urls: - continue - seen_urls.add(href) - - # Vérifier que c'est bien un lien vers ce domaine - url_domain = _extract_domain(href) - if domain not in url_domain and url_domain not in domain: - continue - - # Filtrer les liens non-produits (pas pour Amazon qui a sa propre logique) - if _is_non_product_url(href): - continue - - # Extraire le titre - title = _extract_title(link) - if not title or len(title) < MIN_TITLE_LENGTH: - continue - - results.append(SearchResult( - url=href, - title=title, - snippet="", - source=domain, - )) - - if len(results) >= max_results: - break - - logger.info(f"{domain}: {len(results)} résultats extraits") - - # If we got results, success! Return them - if len(results) > 0: - return results - - # No results found - for Amazon this might be temporary blocking - if debug_enabled: - logger.warning(f"{domain}: 0 results found. Dumping HTML for debugging.") - _dump_debug_html(html_content, domain, f"{query}_no_results") - else: - logger.warning(f"{domain}: 0 results found (debug disabled, no HTML dump)") - - # For Amazon, if no results and we have retries left, try again - if is_amazon and attempt < max_retries - 1: - logger.info(f"Amazon returned 0 results, will retry...") - continue - - return results - - except Exception as e: - logger.error(f"Erreur Browserless sur {domain}: {e}") - if is_amazon and attempt < max_retries - 1: - logger.info(f"Will retry after error...") - continue - return [] - - finally: - # Fermer le contexte et la page, mais PAS le navigateur - try: - if page: - await page.close() - if context: - await context.close() - except Exception as e: - logger.debug(f"Erreur fermeture contexte {domain}: {e}") - - # If we exhausted all retries for Amazon - logger.error(f"All {max_retries} attempts failed for Amazon search '{query}'") - return [] - - -def _get_default_search_url(domain: str) -> str | None: - """Retourne l'URL de recherche par défaut pour un domaine""" - # Nettoyer le domaine pour la comparaison - clean = domain.lower().strip() - if clean.startswith("www."): - clean = clean[4:] - - for key, config in DEFAULT_SITE_CONFIGS.items(): - key_clean = key.lower().strip() - if key_clean.startswith("www."): - key_clean = key_clean[4:] - - # Comparaison exacte ou partielle - if clean == key_clean or key_clean in clean or clean in key_clean: - return config.get("search_url") - return None - - -def _get_default_product_selector(domain: str) -> str | None: - """Retourne le sélecteur de produits par défaut pour un domaine""" - # Nettoyer le domaine pour la comparaison - clean = domain.lower().strip() - if clean.startswith("www."): - clean = clean[4:] - - for key, config in DEFAULT_SITE_CONFIGS.items(): - key_clean = key.lower().strip() - if key_clean.startswith("www."): - key_clean = key_clean[4:] - - if clean == key_clean or key_clean in clean or clean in key_clean: - return config.get("product_selector") - return None - - -def _get_default_wait_selector(domain: str) -> str | None: - """Retourne le sélecteur d'attente par défaut pour un domaine""" - # Nettoyer le domaine pour la comparaison - clean = domain.lower().strip() - if clean.startswith("www."): - clean = clean[4:] - - for key, config in DEFAULT_SITE_CONFIGS.items(): - key_clean = key.lower().strip() - if key_clean.startswith("www."): - key_clean = key_clean[4:] - - if clean == key_clean or key_clean in clean or clean in key_clean: - return config.get("wait_selector") - return None - - -def _find_amazon_product_links(soup: BeautifulSoup) -> list: - """ - Extraction dédiée pour Amazon - simple et directe, sans tous les filtres génériques. - Amazon utilise des structures spécifiques qui ne nécessitent pas le filtrage complexe - utilisé pour les autres sites e-commerce. - """ - links = [] - - # Amazon utilise des data-attributes spécifiques pour les résultats de recherche - # Méthode 1: Liens avec data-component-type="s-product-image" - for item in soup.select('[data-component-type="s-product-image"] a'): - href = item.get("href", "") - if href and "/dp/" in href: - links.append(item) - - # Méthode 2: Liens dans les cartes de produits Amazon - if not links: - for item in soup.select('.s-result-item a.a-link-normal[href*="/dp/"]'): - links.append(item) - - # Méthode 3: Tous les liens avec /dp/ dans le main content - if not links: - main_content = soup.select_one('#search, .s-main-slot, #s-results-list-atf') - if main_content: - for a in main_content.find_all('a', href=True): - href = a.get('href', '') - if '/dp/' in href and href not in [l.get('href', '') for l in links]: - links.append(a) - - # Méthode 4: Fallback - tous les liens /dp/ sur la page - if not links: - for a in soup.find_all('a', href=True): - href = a.get('href', '') - if '/dp/' in href or '/gp/product/' in href: - # Ignorer les liens de navigation Amazon - if '/ref=nb_sb' not in href and '/ref=cs_' not in href: - links.append(a) - - # Dédupliquer par ASIN (l'identifiant produit Amazon) - seen_asins = set() - unique_links = [] - for link in links: - href = link.get('href', '') - # Extraire l'ASIN du lien - if '/dp/' in href: - parts = href.split('/dp/') - if len(parts) > 1: - asin = parts[1].split('/')[0].split('?')[0] - if asin and asin not in seen_asins and len(asin) == 10: - seen_asins.add(asin) - unique_links.append(link) - elif '/gp/product/' in href: - parts = href.split('/gp/product/') - if len(parts) > 1: - asin = parts[1].split('/')[0].split('?')[0] - if asin and asin not in seen_asins and len(asin) == 10: - seen_asins.add(asin) - unique_links.append(link) - - logger.info(f"Amazon: {len(unique_links)} produits uniques trouvés (ASINs: {list(seen_asins)[:5]}...)") - return unique_links - - -def _find_product_links(soup: BeautifulSoup, domain: str) -> list: - """Trouve les liens produits de manière générique""" - links = [] - - # Patterns très génériques pour les liens produits - ordre du plus spécifique au plus générique - patterns = [ - # Patterns spécifiques e-commerce - "a[href*='/product']", - "a[href*='/products/']", - "a[href*='/produit']", - "a[href*='/produits/']", - "a[href*='/p/']", - "a[href*='/dp/']", - "a[href*='/item']", - "a[href*='/article']", - "a[href*='/fiche']", - "a[href*='-p-']", - "a[href*='_p_']", - "a[href*='/detail']", - "a[href$='.html']", - # Classes communes - "a.product", - "a.product-link", - "a.product-card", - "a.product-item", - "a.product-title", - "a.product-name", - ".product a", - ".product-card a", - ".product-tile a", - ".product-item a", - ".product-miniature a", - ".product-container a", - # Patterns PrestaShop (très commun en France) - ".product-miniature a.thumbnail", - ".product-miniature a.product-thumbnail", - "a.product-thumbnail", - ".js-product-miniature a", - # Patterns Magento - ".product-item-link", - "a.product-item-link", - ".products-grid a", - ".products-list a", - # Patterns WooCommerce - ".woocommerce-loop-product__link", - "a.woocommerce-LoopProduct-link", - # Autres patterns génériques - "[data-product] a", - "[data-item] a", - ".search-result a", - ".results a", - ".listing a", - ".catalog a", - # Images de produits avec liens - "a:has(img[alt])", - ".thumbnail a", - ".thumb a", - ] - - # Textes de liens à exclure (navigation, actions, etc.) - bad_texts = [ - "voir plus", "voir tout", "tout voir", "afficher plus", - "suivant", "précédent", "next", "previous", "back", - "accueil", "home", "menu", "filtrer", "trier", - "conditions", "mentions", "contact", "aide", - "connexion", "inscription", "panier", "compte", - "retour", "livraison", "cgv", "cgu", - "top ventes", "nouveautés", "promotions", "soldes", - "nos magasins", "découvrir", "en savoir plus", - "valider", "ok", "fermer", "accepter", - "choisir", "modifier", "supprimer", "ajouter", - "comparer", "wishlist", "favoris", - "imprimer", "partager", "envoyer", - "plan du site", "sitemap", - ] - - for pattern in patterns: - try: - found = soup.select(pattern) - if found: - links.extend(found) - except Exception: - continue - - # Si toujours rien, essayer de trouver des liens dans les conteneurs de liste - if not links: - containers = soup.select( - ".search-results, .products, .product-list, .results, " - ".listing, .catalog, [class*='product'], [class*='result'], " - "main, #content, .content" - ) - for container in containers: - # Trouver tous les liens dans ces conteneurs - container_links = container.find_all("a", href=True) - for link in container_links: - href = link.get("href", "") - # Filtrer les liens de navigation/footer - if href and not _is_navigation_link(href): - # Vérifier si le lien a une image ou du texte substantiel - if link.find("img") or len(link.get_text(strip=True)) > MIN_LINK_TEXT_LENGTH: - links.append(link) - - # Dédupliquer et filtrer par texte - seen = set() - unique_links = [] - for link in links: - href = link.get("href", "") - if not href or href in seen: - continue - - # Vérification du texte du lien - link_text = link.get_text(strip=True).lower() - - # Si le texte correspond exactement à un texte banni - if any(bad_text == link_text for bad_text in bad_texts): - continue - - # Si le texte contient des indicateurs de navigation forts - if "voir tout" in link_text or "tous les produits" in link_text or "voir la gamme" in link_text: - continue - - seen.add(href) - unique_links.append(link) - - return unique_links - - -def _is_navigation_link(href: str) -> bool: - """Vérifie si un lien est un lien de navigation (pas un produit)""" - nav_patterns = [ - # Catégories et navigation - "/category", "/categories", "/categorie", "/c/", - "/rayon", "/rayons", "/univers", "/famille", "/groupe", - "/brand", "/brands", "/marque", "/marques", "/fabricant", - "/boutique", "/boutiques", "/shop", "/store", - "/collection", "/collections", "/theme", "/themes", - "/liste", "/list", "/listing", - "/tag", "/tags", "/sujet", "/topic", - "/catalog", "/catalogue", - "/view-all", "/tous-les-produits", "/tout-voir", - "/page/", "/pages/", - "/cart", "/panier", "/basket", - "/checkout", "/commande", - "/account", "/compte", "/mon-compte", - "/login", "/connexion", "/register", "/inscription", - "/search", "/recherche", - "/contact", "/help", "/aide", - "/about", "/a-propos", - "/terms", "/privacy", "/cgv", "/mentions", - "/faq", "/shipping", "/livraison", - "/returns", "/retours", - "javascript:", "mailto:", "tel:", - "#", "?page=", "&page=", - # URLs non-produit spécifiques aux sites français - "/application", "/app/", "/apps/", - "/courses", "/course/", - "/ep-", "/endpoint", - # Promotions et offres (patterns communs) - "/promo/", "/promotions/", "/promotion/", - "/soldes/", "/solde/", - "/bon-plan", "/bons-plans", - "/offres/", "/offre/", "/offre-", - "/deals/", "/deal/", - "/ventes-flash", "/vente-flash", - # Services et pages informatives - "/services/", "/service/", - "/magasin", "/magasins", "/stores/", "/store/", - "/blog", "/actualites", "/news", - "/recettes", "/recipes", - "/conseils", "/tips", - "/guide", "/guides", - # Patterns e-commerce spécifiques - "/l-", "/lp-", "/landing", # Landing pages (Cdiscount, etc.) - "/selection", "/selections", - "/top-", "/best-", "/nouveautes", - # Amazon spécifique - "/ref=cs_", "/logo", "/nav_", "/gp/help", - "/gp/css", "/gp/redirect", "/hz/", - "/ref=nb_", "/ref=sr_", "amazon.fr/ref=", "amazon.com/ref=", - "/ap/signin", "/gp/yourstore", "/gp/cart", - "/503", "/error", "/robot", - # Liens génériques à exclure - "/home", "/accueil", "/index", - "/newsletter", "/subscribe", - "/wishlist", "/liste-envies", - "/compare", "/comparaison", - ] - href_lower = href.lower() - return any(pattern in href_lower for pattern in nav_patterns) - - - -def _extract_title(element) -> str: - """Extrait le titre d'un élément""" - # Essayer différentes méthodes - title = element.get("title", "") - if title and len(title) > MIN_TITLE_LENGTH: - return title.strip() - - # Texte de l'élément - text = element.get_text(strip=True) - if text and len(text) > MIN_TITLE_LENGTH: - return text[:200] - - # Attribut aria-label - label = element.get("aria-label", "") - if label: - return label.strip() - - # Image alt text - img = element.find("img") - if img: - alt = img.get("alt", "") - if alt: - return alt.strip() - - return "" - - -def _clean_product_url(url: str, domain: str) -> str: - """Nettoie une URL produit""" - # Pour Amazon, simplifier l'URL - if "amazon." in url and "/dp/" in url: - match = re.search(r"(/dp/[A-Z0-9]{10})", url) - if match: - domain_match = re.search(r"(https?://[^/]+)", url) - if domain_match: - return domain_match.group(1) + match.group(1) - - return url.split("?")[0] if "?" in url else url - - -def _extract_domain(url: str) -> str: - """Extrait le domaine d'une URL""" - try: - parsed = urlparse(url) - domain = parsed.netloc.lower() - if domain.startswith("www."): - domain = domain[4:] - return domain - except Exception: - return "" - - -def _clean_domain(domain: str) -> str: - """Nettoie un domaine (enlève protocole, www, slash final)""" - if not domain: - return "" - - domain = domain.strip().lower() - - # Enlever le protocole - if domain.startswith("https://"): - domain = domain[8:] - elif domain.startswith("http://"): - domain = domain[7:] - - # Enlever www. - if domain.startswith("www."): - domain = domain[4:] - - # Enlever le slash final - domain = domain.rstrip("/") - - return domain - - -def _is_non_product_url(url: str) -> bool: - """Vérifie si l'URL n'est pas une page produit""" - url_lower = url.lower() - - # Pour Amazon, on vérifie que l'URL contient /dp/ (page produit) - if "amazon" in url_lower: - # Si c'est Amazon mais pas un lien /dp/, c'est pas un produit - if "/dp/" not in url_lower and "/gp/product/" not in url_lower: - return True - # Exclure les liens d'erreur/redirection Amazon - if "/ref=cs_" in url_lower or "/ref=nb_sb" in url_lower: - return True - if "amazon.fr/ref=" in url_lower or "amazon.com/ref=" in url_lower: - if "/dp/" not in url_lower: - return True - - non_product_patterns = [ - # Navigation et catégories - "/search", "/category", "/categories", "/categorie", "/c/", - "/rayon", "/rayons", "/univers", "/famille", "/groupe", - "/brand", "/brands", "/marque", "/marques", "/fabricant", - "/boutique", "/boutiques", "/shop", "/store", - "/collection", "/collections", "/theme", "/themes", - "/liste", "/list", "/listing", - "/tag", "/tags", "/sujet", "/topic", - "/catalog", "/catalogue", - "/view-all", "/tous-les-produits", "/tout-voir", - # Pages utilisateur - "/help", "/contact", "/about", "/account", "/cart", "/checkout", - "/login", "/register", "/wishlist", "/compare", "/reviews", - "/mon-compte", "/connexion", "/inscription", "/panier", - # Pages légales - "/terms", "/privacy", "/faq", "/shipping", "/returns", - "/cgv", "/cgu", "/mentions-legales", "/donnees-personnelles", - # Erreurs - "/503", "/error", "/robot", "/captcha", - # Applications et services - "/application", "/courses", "/app/", "/mobile", - # Landing pages et promotions (patterns spécifiques e-commerce français) - "/l-", "/lp-", "/landing", # Cdiscount landing pages - "/promo/", "/soldes/", "/bon-plan", "/offre-", "/deals/", - "/selection", "/top-", "/best-", "/nouveautes", - "/guide", "/conseil", "/blog", "/actualites", - ] - return any(pattern in url_lower for pattern in non_product_patterns) - - - -async def health_check() -> bool: - """Vérifie la connectivité avec Browserless""" - try: - async with async_playwright() as p: - browser = await p.chromium.connect_over_cdp(BROWSERLESS_URL) - await browser.close() - return True - except Exception as e: - logger.warning(f"Browserless health check failed: {e}") - return False diff --git a/app/services/light_scraper_service.py b/app/services/light_scraper_service.py deleted file mode 100644 index 3ba3849..0000000 --- a/app/services/light_scraper_service.py +++ /dev/null @@ -1,276 +0,0 @@ -""" -Light Scraper Service - Scraping HTTP rapide sans navigateur -Utilisé comme première tentative avant le fallback Browserless -""" - -import logging -import re -from dataclasses import dataclass - -import httpx - -logger = logging.getLogger(__name__) - -# Headers pour simuler un navigateur -DEFAULT_HEADERS = { - "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8", - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", - "Accept-Encoding": "gzip, deflate, br", - "Connection": "keep-alive", - "Upgrade-Insecure-Requests": "1", - "Cache-Control": "max-age=0", -} - -# Patterns regex pour extraire les prix -PRICE_PATTERNS = [ - # Format européen : 1 234,56 € ou 1234,56€ - r'(\d{1,3}(?:\s?\d{3})*[,\.]\d{2})\s*(?:€|EUR|euros?)', - # Format : €1,234.56 ou € 1234.56 - r'(?:€|EUR)\s*(\d{1,3}(?:[,\s]?\d{3})*[,\.]\d{2})', - # Prix simples : 12,99 € ou 12.99€ - r'(\d+[,\.]\d{2})\s*(?:€|EUR)', - # data-price ou price attributes - r'data-price["\']?\s*[:=]\s*["\']?(\d+[,\.]?\d*)', - r'price["\']?\s*[:=]\s*["\']?(\d+[,\.]?\d*)', -] - -# Patterns pour le stock -STOCK_POSITIVE_PATTERNS = [ - r'en\s*stock', - r'disponible', - r'in\s*stock', - r'available', - r'ajouter\s*au\s*panier', - r'add\s*to\s*cart', - r'acheter', - r'livraison', -] - -STOCK_NEGATIVE_PATTERNS = [ - r'rupture', - r'indisponible', - r'out\s*of\s*stock', - r'épuisé', - r'sold\s*out', - r'plus\s*disponible', - r'non\s*disponible', -] - - -@dataclass -class LightScraperResult: - """Résultat du scraping léger""" - - success: bool - url: str - title: str | None = None - price: float | None = None - currency: str = "EUR" - in_stock: bool | None = None - image_url: str | None = None - error: str | None = None - requires_js: bool = False # Indique si le site nécessite JS - - -async def scrape_url(url: str, timeout: float = 10.0) -> LightScraperResult: - """ - Tente un scraping léger via HTTP simple. - - Args: - url: URL du produit à scraper - timeout: Timeout en secondes - - Returns: - LightScraperResult avec les données extraites ou requires_js=True - """ - try: - async with httpx.AsyncClient( - timeout=timeout, - follow_redirects=True, - headers=DEFAULT_HEADERS, - ) as client: - response = await client.get(url) - response.raise_for_status() - - html = response.text - - # Vérifier si la page nécessite JavaScript - if _requires_javascript(html): - logger.debug(f"Page {url} nécessite JavaScript") - return LightScraperResult( - success=False, - url=url, - requires_js=True, - error="Page requires JavaScript", - ) - - # Extraire les données - title = _extract_title(html) - price = _extract_price(html) - in_stock = _extract_stock_status(html) - image_url = _extract_image(html, url) - - # Si on n'a pas pu extraire le prix, fallback sur Browserless - if price is None: - logger.debug(f"Impossible d'extraire le prix de {url}") - return LightScraperResult( - success=False, - url=url, - title=title, - requires_js=True, - error="Could not extract price", - ) - - logger.info(f"Scraping léger réussi pour {url}: {price}€") - return LightScraperResult( - success=True, - url=url, - title=title, - price=price, - in_stock=in_stock, - image_url=image_url, - ) - - except httpx.TimeoutException: - logger.warning(f"Timeout HTTP pour {url}") - return LightScraperResult( - success=False, - url=url, - error="HTTP timeout", - requires_js=True, - ) - except httpx.HTTPStatusError as e: - logger.warning(f"Erreur HTTP {e.response.status_code} pour {url}") - return LightScraperResult( - success=False, - url=url, - error=f"HTTP {e.response.status_code}", - requires_js=True, - ) - except Exception as e: - logger.error(f"Erreur scraping léger {url}: {e}") - return LightScraperResult( - success=False, - url=url, - error=str(e), - requires_js=True, - ) - - -def _requires_javascript(html: str) -> bool: - """Détecte si la page nécessite JavaScript pour afficher le contenu""" - indicators = [ - "Please enable JavaScript", - "JavaScript is required", - "You need to enable JavaScript", - "__NEXT_DATA__", # Next.js SSR mais peut nécessiter JS - "window.__INITIAL_STATE__", - "React.createElement", - '
', # React SPA vide - '
', # Vue SPA vide - "Loading...", - ] - - html_lower = html.lower() - - # Vérifier si le body est presque vide - body_match = re.search(r"]*>(.*?)", html, re.DOTALL | re.IGNORECASE) - if body_match: - body_content = body_match.group(1) - # Retirer les scripts et styles - body_clean = re.sub(r"]*>.*?", "", body_content, flags=re.DOTALL | re.IGNORECASE) - body_clean = re.sub(r"]*>.*?", "", body_clean, flags=re.DOTALL | re.IGNORECASE) - body_clean = re.sub(r"<[^>]+>", "", body_clean) - body_clean = body_clean.strip() - - if len(body_clean) < 500: # Page très peu de contenu texte - return True - - for indicator in indicators: - if indicator.lower() in html_lower: - return True - - return False - - -def _extract_title(html: str) -> str | None: - """Extrait le titre de la page""" - # Essayer og:title d'abord - og_match = re.search(r']+property=["\']og:title["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE) - if og_match: - return og_match.group(1).strip() - - # Fallback sur - title_match = re.search(r"<title[^>]*>([^<]+)", html, re.IGNORECASE) - if title_match: - title = title_match.group(1).strip() - # Nettoyer les suffixes de site courants - title = re.sub(r"\s*[-|]\s*(Amazon|Fnac|Cdiscount|Darty).*$", "", title, flags=re.IGNORECASE) - return title - - return None - - -def _extract_price(html: str) -> float | None: - """Extrait le prix de la page""" - prices = [] - - for pattern in PRICE_PATTERNS: - matches = re.findall(pattern, html, re.IGNORECASE) - for match in matches: - try: - # Normaliser le format - price_str = match.replace(" ", "").replace(",", ".") - price = float(price_str) - if 0.01 < price < 100000: # Prix raisonnable - prices.append(price) - except ValueError: - continue - - if not prices: - return None - - # Retourner le prix le plus fréquent ou le premier - from collections import Counter - - price_counts = Counter(prices) - most_common = price_counts.most_common(1) - if most_common: - return most_common[0][0] - - return prices[0] - - -def _extract_stock_status(html: str) -> bool | None: - """Extrait le statut de stock""" - html_lower = html.lower() - - # Chercher les indicateurs négatifs d'abord - for pattern in STOCK_NEGATIVE_PATTERNS: - if re.search(pattern, html_lower): - return False - - # Chercher les indicateurs positifs - for pattern in STOCK_POSITIVE_PATTERNS: - if re.search(pattern, html_lower): - return True - - return None # Incertain - - -def _extract_image(html: str, base_url: str) -> str | None: - """Extrait l'image principale du produit""" - from urllib.parse import urljoin - - # Essayer og:image d'abord - og_match = re.search(r']+property=["\']og:image["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE) - if og_match: - return urljoin(base_url, og_match.group(1)) - - # Essayer twitter:image - twitter_match = re.search(r']+name=["\']twitter:image["\'][^>]+content=["\']([^"\']+)["\']', html, re.IGNORECASE) - if twitter_match: - return urljoin(base_url, twitter_match.group(1)) - - return None diff --git a/app/services/scraper_service.py b/app/services/scraper_service.py deleted file mode 100644 index 400bae8..0000000 --- a/app/services/scraper_service.py +++ /dev/null @@ -1,948 +0,0 @@ -import asyncio -import logging -import os -import random -import re -from urllib.parse import urlparse, urlunparse - -from playwright.async_api import TimeoutError as PlaywrightTimeoutError -from playwright.async_api import async_playwright - -logger = logging.getLogger(__name__) - -BROWSERLESS_URL = os.getenv("BROWSERLESS_URL", "ws://browserless:3000") - -# Proxy configuration for Amazon - 10 rotating proxies (same as direct_search_service) -# Format from user: ip:port:username:password -AMAZON_PROXY_LIST_RAW = [ - "142.111.48.253:7030:jasuwwjr:elbsx170nmnl", - "31.59.20.176:6754:jasuwwjr:elbsx170nmnl", - "23.95.150.145:6114:jasuwwjr:elbsx170nmnl", - "198.23.239.134:6540:jasuwwjr:elbsx170nmnl", - "107.172.163.27:6543:jasuwwjr:elbsx170nmnl", - "198.105.121.200:6462:jasuwwjr:elbsx170nmnl", - "64.137.96.74:6641:jasuwwjr:elbsx170nmnl", - "84.247.60.125:6095:jasuwwjr:elbsx170nmnl", - "216.10.27.159:6837:jasuwwjr:elbsx170nmnl", - "142.111.67.146:5611:jasuwwjr:elbsx170nmnl", -] - -# Convert to Playwright proxy format: {"server": "http://ip:port", "username": "user", "password": "pass"} -AMAZON_PROXY_LIST = [] -for proxy in AMAZON_PROXY_LIST_RAW: - parts = proxy.split(":") - if len(parts) == 4: - AMAZON_PROXY_LIST.append({ - "server": f"http://{parts[0]}:{parts[1]}", - "username": parts[2], - "password": parts[3] - }) - -# Log proxy initialization at startup -logger.info(f"Amazon proxies initialized: {len(AMAZON_PROXY_LIST)} proxies available") - -def _get_amazon_proxy() -> dict | None: - """Get a random proxy for Amazon requests from the rotating pool""" - if AMAZON_PROXY_LIST: - proxy = random.choice(AMAZON_PROXY_LIST) - # Log only the IP part for security (hide credentials) - ip_port = proxy["server"].replace("http://", "") - logger.info(f"Using proxy: {ip_port}") - return proxy - logger.warning("No Amazon proxies available!") - return None - -# Nombre de tentatives pour le scraping -MAX_RETRIES = 2 -RETRY_DELAY = 3 # secondes - -# Amazon-specific configuration -AMAZON_MAX_RETRIES = 4 -AMAZON_BASE_DELAY = 3.0 - -# Pool of realistic User-Agents for rotation -USER_AGENT_POOL = [ - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/130.0.0.0 Safari/537.36", - "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:133.0) Gecko/20100101 Firefox/133.0", - "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36 Edg/131.0.0.0", - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", -] - -# Amazon blocking indicators -AMAZON_BLOCK_INDICATORS = [ - "Toutes nos excuses", - "Sorry, something went wrong", - "api-services-support@amazon.com", - "Enter the characters you see below", - "Saisissez les caractères", - "Service Unavailable", - "robot check", - "automated access", -] - -# Stealth JavaScript to inject before page load to hide automation -STEALTH_JS = """ -// ============================================ -// COMPREHENSIVE STEALTH MODE FOR AMAZON -// ============================================ - -// 1. Override navigator.webdriver in multiple ways -Object.defineProperty(navigator, 'webdriver', { - get: () => false, - configurable: true -}); - -// Also delete from navigator prototype -delete Object.getPrototypeOf(navigator).webdriver; - -// Override the getAttribute method to hide webdriver attribute -const originalGetAttribute = Element.prototype.getAttribute; -Element.prototype.getAttribute = function(name) { - if (name === 'webdriver') return null; - return originalGetAttribute.call(this, name); -}; - -// 2. Override navigator.plugins with realistic plugins -Object.defineProperty(navigator, 'plugins', { - get: () => { - const plugins = [ - { name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1 }, - { name: 'Chrome PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', length: 1 }, - { name: 'Native Client', filename: 'internal-nacl-plugin', description: '', length: 2 }, - { name: 'Chromium PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1 }, - { name: 'Chromium PDF Viewer', filename: 'mhjfbmdgcfjbbpaeojofohoefgiehjai', description: '', length: 1 } - ]; - plugins.item = (i) => plugins[i] || null; - plugins.namedItem = (name) => plugins.find(p => p.name === name) || null; - plugins.refresh = () => {}; - plugins.length = plugins.length; - return plugins; - }, - configurable: true -}); - -// 3. Override navigator.mimeTypes -Object.defineProperty(navigator, 'mimeTypes', { - get: () => { - const mimeTypes = [ - { type: 'application/pdf', suffixes: 'pdf', description: 'Portable Document Format' }, - { type: 'text/pdf', suffixes: 'pdf', description: 'Portable Document Format' } - ]; - mimeTypes.item = (i) => mimeTypes[i] || null; - mimeTypes.namedItem = (name) => mimeTypes.find(m => m.type === name) || null; - mimeTypes.length = mimeTypes.length; - return mimeTypes; - }, - configurable: true -}); - -// 4. Override navigator.languages -Object.defineProperty(navigator, 'languages', { - get: () => ['fr-FR', 'fr', 'en-US', 'en'], - configurable: true -}); - -// 5. Override permissions API -if (window.navigator.permissions) { - const originalQuery = window.navigator.permissions.query; - window.navigator.permissions.query = (parameters) => { - if (parameters.name === 'notifications') { - return Promise.resolve({ state: Notification.permission, onchange: null }); - } - return originalQuery.call(window.navigator.permissions, parameters); - }; -} - -// 6. Hide automation-related properties (Chromium DevTools Protocol) -const propsToDelete = [ - 'cdc_adoQpoasnfa76pfcZLmcfl_Array', - 'cdc_adoQpoasnfa76pfcZLmcfl_Promise', - 'cdc_adoQpoasnfa76pfcZLmcfl_Symbol', - '__webdriver_evaluate', - '__selenium_evaluate', - '__webdriver_script_function', - '__webdriver_script_func', - '__webdriver_script_fn', - '__fxdriver_evaluate', - '__driver_unwrapped', - '__webdriver_unwrapped', - '__driver_evaluate', - '__selenium_unwrapped', - '__fxdriver_unwrapped', - '_Selenium_IDE_Recorder', - '_selenium', - 'calledSelenium', - '$chrome_asyncScriptInfo', - '$cdc_asdjflasutopfhvcZLmcfl_', - '$wdc_' -]; -propsToDelete.forEach(prop => { - try { delete window[prop]; } catch(e) {} -}); - -// 7. Override chrome object with realistic properties -window.chrome = { - app: { - isInstalled: false, - InstallState: { DISABLED: 'disabled', INSTALLED: 'installed', NOT_INSTALLED: 'not_installed' }, - RunningState: { CANNOT_RUN: 'cannot_run', READY_TO_RUN: 'ready_to_run', RUNNING: 'running' } - }, - runtime: { - OnInstalledReason: { CHROME_UPDATE: 'chrome_update', INSTALL: 'install', SHARED_MODULE_UPDATE: 'shared_module_update', UPDATE: 'update' }, - OnRestartRequiredReason: { APP_UPDATE: 'app_update', OS_UPDATE: 'os_update', PERIODIC: 'periodic' }, - PlatformArch: { ARM: 'arm', ARM64: 'arm64', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' }, - PlatformNaclArch: { ARM: 'arm', MIPS: 'mips', MIPS64: 'mips64', X86_32: 'x86-32', X86_64: 'x86-64' }, - PlatformOs: { ANDROID: 'android', CROS: 'cros', LINUX: 'linux', MAC: 'mac', OPENBSD: 'openbsd', WIN: 'win' }, - RequestUpdateCheckStatus: { NO_UPDATE: 'no_update', THROTTLED: 'throttled', UPDATE_AVAILABLE: 'update_available' }, - connect: function() { return { onDisconnect: { addListener: function() {} }, onMessage: { addListener: function() {} }, postMessage: function() {} }; }, - sendMessage: function() {} - }, - csi: function() { return {}; }, - loadTimes: function() { return { requestTime: Date.now() / 1000, startLoadTime: Date.now() / 1000, firstPaintAfterLoadTime: 0, firstPaintTime: Date.now() / 1000, navigationType: 'navigate' }; } -}; - -// 8. WebGL fingerprinting protection -const getParameterProxyHandler = { - apply: function(target, ctx, args) { - if (args[0] === 37445) return 'Intel Inc.'; - if (args[0] === 37446) return 'Intel Iris OpenGL Engine'; - if (args[0] === 7937) return 'WebKit'; - if (args[0] === 7936) return 'WebKit WebGL'; - return Reflect.apply(target, ctx, args); - } -}; -try { - const canvas = document.createElement('canvas'); - const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); - if (gl) { - const getParameter = gl.getParameter.bind(gl); - gl.getParameter = new Proxy(getParameter, getParameterProxyHandler); - } - const gl2 = canvas.getContext('webgl2'); - if (gl2) { - const getParameter2 = gl2.getParameter.bind(gl2); - gl2.getParameter = new Proxy(getParameter2, getParameterProxyHandler); - } -} catch(e) {} - -// 9. Canvas fingerprinting protection -const originalToDataURL = HTMLCanvasElement.prototype.toDataURL; -HTMLCanvasElement.prototype.toDataURL = function(type) { - if (type === 'image/png' && this.width === 220 && this.height === 30) { - const context = this.getContext('2d'); - if (context) { - const imageData = context.getImageData(0, 0, this.width, this.height); - for (let i = 0; i < imageData.data.length; i += 4) { - imageData.data[i] = imageData.data[i] ^ (Math.random() * 2); - } - context.putImageData(imageData, 0, 0); - } - } - return originalToDataURL.apply(this, arguments); -}; - -// 10. AudioContext fingerprinting protection -if (window.AudioContext || window.webkitAudioContext) { - const AudioContext = window.AudioContext || window.webkitAudioContext; - const originalCreateOscillator = AudioContext.prototype.createOscillator; - AudioContext.prototype.createOscillator = function() { - const oscillator = originalCreateOscillator.apply(this, arguments); - oscillator.frequency.value = oscillator.frequency.value + (Math.random() * 0.0001); - return oscillator; - }; -} - -// 11. Override connection properties -Object.defineProperty(navigator, 'connection', { - get: () => ({ - effectiveType: '4g', - rtt: 50 + Math.floor(Math.random() * 50), - downlink: 10 + Math.random() * 5, - saveData: false - }), - configurable: true -}); - -// 12. Override hardware properties -Object.defineProperty(navigator, 'hardwareConcurrency', { - get: () => 8, - configurable: true -}); - -Object.defineProperty(navigator, 'deviceMemory', { - get: () => 8, - configurable: true -}); - -// 13. Override screen properties -Object.defineProperty(screen, 'colorDepth', { - get: () => 24, - configurable: true -}); - -Object.defineProperty(screen, 'pixelDepth', { - get: () => 24, - configurable: true -}); - -// 14. Spoof Notification API -if (window.Notification) { - Object.defineProperty(Notification, 'permission', { - get: () => 'default', - configurable: true - }); -} - -// 15. Override iframe contentWindow checks -const originalContentWindow = Object.getOwnPropertyDescriptor(HTMLIFrameElement.prototype, 'contentWindow'); -Object.defineProperty(HTMLIFrameElement.prototype, 'contentWindow', { - get: function() { - const win = originalContentWindow.get.call(this); - if (win) { - try { - Object.defineProperty(win.navigator, 'webdriver', { - get: () => false, - configurable: true - }); - } catch(e) {} - } - return win; - } -}); - -// 16. Mock Battery API -if (navigator.getBattery) { - navigator.getBattery = () => Promise.resolve({ - charging: true, - chargingTime: 0, - dischargingTime: Infinity, - level: 1, - addEventListener: () => {}, - removeEventListener: () => {} - }); -} - -// 17. Hide Playwright/Puppeteer specific objects -delete window.__playwright; -delete window.__pw_manual; -delete window.__PW_inspect; - -console.log('Advanced stealth mode activated'); -""" - - -def _get_random_user_agent() -> str: - """Get a random User-Agent from the pool""" - return random.choice(USER_AGENT_POOL) - - -def _is_amazon_url(url: str) -> bool: - """Check if URL is an Amazon URL""" - return "amazon" in url.lower() - - -def _is_amazon_blocked(html_content: str) -> bool: - """Check if Amazon has blocked the request""" - html_lower = html_content.lower() - for indicator in AMAZON_BLOCK_INDICATORS: - if indicator.lower() in html_lower: - return True - return False - - -def simplify_url(url: str) -> str: - """ - Simplifie les URLs en supprimant les paramètres de tracking. - Particulièrement utile pour Amazon qui a des URLs très longues. - - Exemples: - - Amazon: https://www.amazon.fr/dp/B0CFYHHPPV?ref=... -> https://www.amazon.fr/dp/B0CFYHHPPV - - Autres: garde l'URL originale si pas de simplification possible - """ - try: - parsed = urlparse(url) - hostname = parsed.hostname or "" - - # Simplification pour Amazon - if "amazon" in hostname: - # Extraire l'ASIN (identifiant produit Amazon) - # Format: /dp/ASIN ou /gp/product/ASIN - asin_match = re.search(r"/(?:dp|gp/product)/([A-Z0-9]{10})", url) - if asin_match: - asin = asin_match.group(1) - # Reconstruire une URL simple - simplified = f"{parsed.scheme}://{parsed.netloc}/dp/{asin}" - logger.info(f"URL Amazon simplifiée: {url[:80]}... -> {simplified}") - return simplified - - # Pour les autres sites, supprimer les paramètres de tracking courants - # mais garder les paramètres essentiels - tracking_params = [ - "utm_source", "utm_medium", "utm_campaign", "utm_content", "utm_term", - "ref", "ref_", "tag", "linkCode", "linkId", "camp", "creative", - "crid", "dib", "dib_tag", "qid", "sprefix", "sr", "keywords", - "fbclid", "gclid", "msclkid" - ] - - if parsed.query: - # Garder seulement les paramètres non-tracking - from urllib.parse import parse_qs, urlencode - params = parse_qs(parsed.query) - filtered_params = { - k: v[0] for k, v in params.items() - if k.lower() not in tracking_params - } - new_query = urlencode(filtered_params) if filtered_params else "" - simplified = urlunparse(( - parsed.scheme, - parsed.netloc, - parsed.path, - parsed.params, - new_query, - "" # fragment - )) - if simplified != url: - logger.info(f"URL simplifiée: {url[:60]}... -> {simplified[:60]}...") - return simplified - - return url - - except Exception as e: - logger.warning(f"Erreur lors de la simplification de l'URL: {e}") - return url - - -class ScraperService: - @classmethod - async def scrape_item( - cls, - url: str, - selector: str | None = None, - item_id: int | None = None, - smart_scroll: bool = False, - scroll_pixels: int = 350, - text_length: int = 0, - timeout: int = 90000, - browser=None, # Support external browser instance - ) -> tuple[str | None, str, bool]: - """ - Scrape a product page with retries and rotation. - Manages the browser lifecycle to reuse connections across retries. - If 'browser' is provided, it uses that instance and DOES NOT close it. - """ - is_amazon = _is_amazon_url(url) - max_retries = AMAZON_MAX_RETRIES if is_amazon else MAX_RETRIES - - playwright_manager = None - local_browser = None - - # Determine if we are managing the browser or using an external one - using_external_browser = browser is not None - current_browser = browser - - try: - # Initial browser creation if not provided - if not current_browser: - try: - playwright_manager = async_playwright() - p = await playwright_manager.start() - logger.info(f"Connecting to Browserless at {BROWSERLESS_URL}") - local_browser = await p.chromium.connect_over_cdp( - BROWSERLESS_URL, - timeout=60000, - ) - current_browser = local_browser - except Exception as e: - logger.error(f"Failed to initialize browser: {e}") - return None, "", False - - for attempt in range(max_retries + 1): - try: - # Check if browser is still connected - if not current_browser or not current_browser.is_connected(): - if using_external_browser: - logger.warning("External browser disconnected! Cannot proceed with this browser.") - # We cannot reconnect an external browser here as we don't own the playwright instance - # We could try to fall back to a local browser, but for now let's just fail - return None, "", False - - logger.warning("Browser disconnected, reconnecting...") - try: - if current_browser: - try: - await current_browser.close() - except: - pass - - # Reconnect local browser - current_browser = await p.chromium.connect_over_cdp( - BROWSERLESS_URL, - timeout=60000, - ) - local_browser = current_browser - except Exception as re_e: - logger.error(f"Failed to reconnect browser: {re_e}") - await asyncio.sleep(5) - continue - - logger.info(f"Scraping attempt {attempt + 1}/{max_retries + 1} for {url}") - - screenshot, text, is_available = await cls._do_scrape( - url, - selector, - item_id, - smart_scroll, - scroll_pixels, - text_length, - timeout, - browser=current_browser, - is_amazon=is_amazon, - attempt=attempt, - max_retries=max_retries, - ) - - if screenshot or (text and len(text) > 0): - return screenshot, text, is_available - - logger.warning(f"Tentative {attempt + 1} échouée pour {url}") - - except Exception as e: - logger.error(f"Error in scrape_item attempt {attempt + 1}: {e}") - # If it's a critical browser error - if "Target page, context or browser has been closed" in str(e) or "Connection closed" in str(e): - if using_external_browser: - logger.warning("Critical error with external browser.") - # Stop retrying with this browser - return None, "", False - - logger.warning("Critical browser error detected, forcing reconnection.") - try: - if current_browser: - await current_browser.close() - except: - pass - current_browser = None - - if attempt < max_retries: - delay = AMAZON_BASE_DELAY * (attempt + 1) + random.uniform(1, 3) if is_amazon else RETRY_DELAY - logger.info(f"Tentative {attempt + 2}/{max_retries + 1} pour {url} (délai: {delay:.1f}s)") - await asyncio.sleep(delay) - - logger.error(f"Toutes les tentatives ont échoué pour {url}") - return None, "", False - - finally: - # Only close resources we created - if local_browser: - try: - if local_browser.is_connected(): - await local_browser.close() - except Exception as e: - logger.debug(f"Error closing local browser in scrape_item: {e}") - - if playwright_manager: - try: - await playwright_manager.stop() - except Exception as e: - logger.debug(f"Error stopping playwright manager in scrape_item: {e}") - - @classmethod - async def _do_scrape( - cls, - url: str, - selector: str | None = None, - item_id: int | None = None, - smart_scroll: bool = False, - scroll_pixels: int = 350, - text_length: int = 0, - timeout: int = 90000, - browser=None, - is_amazon: bool = False, - attempt: int = 0, - max_retries: int = 1, - ) -> tuple[str | None, str, bool]: - """Exécute le scraping réel (appelé par scrape_item avec retries).""" - - # 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 - page = None - is_available = True # Par défaut, le produit est disponible - - # Use random User-Agent for anti-detection (especially for Amazon) - current_user_agent = _get_random_user_agent() - - # Enhanced headers for stealth - extra_headers = { - "Accept-Language": "fr-FR,fr;q=0.9,en-US;q=0.8,en;q=0.7", - "Accept": "text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8", - "Accept-Encoding": "gzip, deflate, br", - "Cache-Control": "max-age=0", - "Sec-Ch-Ua": '"Google Chrome";v="131", "Chromium";v="131", "Not_A Brand";v="24"', - "Sec-Ch-Ua-Mobile": "?0", - "Sec-Ch-Ua-Platform": '"Windows"', - "Sec-Fetch-Dest": "document", - "Sec-Fetch-Mode": "navigate", - "Sec-Fetch-Site": "none", - "Sec-Fetch-User": "?1", - "Upgrade-Insecure-Requests": "1", - } - - # Add referer for Amazon - if is_amazon: - extra_headers["Referer"] = "https://www.google.fr/" - - # Gestion intelligente du Proxy : - # - Tentatives 0 à MAX-1 : Utiliser un Proxy (si dispo) - # - Dernière tentative (max_retries) : Essayer SANS Proxy (Direct IP) en dernier recours - # (car l'utilisateur signale que l'accès direct marche parfois mieux pour les pages produits) - use_proxy = is_amazon and (attempt < max_retries) - - if not use_proxy and is_amazon and attempt > 0: - logger.info(f"Tentative {attempt + 1}/{max_retries + 1} : Passage en mode DIRECT (Sans Proxy) pour tenter de contourner le blocage proxy") - - # Prepare context options - context_options = { - "viewport": {"width": 1920, "height": 1080}, - "user_agent": current_user_agent, - "locale": "fr-FR", - "timezone_id": "Europe/Paris", - "extra_http_headers": extra_headers, - "java_script_enabled": True, - "bypass_csp": True, - } - - # Add proxy for Amazon if configured AND enabled for this attempt - if use_proxy: - proxy_config = _get_amazon_proxy() - if proxy_config: - # proxy_config is already a dict with {"server": "...", "username": "...", "password": "..."} - context_options["proxy"] = proxy_config - - # Création du contexte - context = await current_browser.new_context(**context_options) - - # Inject stealth script before any page loads (especially for Amazon) - if is_amazon: - await context.add_init_script(STEALTH_JS) - logger.debug("Stealth JS injected for Amazon product page") - - # Stealth mode / Ad blocking attempts - don't block images for Amazon - if is_amazon: - # For Amazon, only block tracking - keep images to avoid detection - await context.route("**/analytics*", lambda route: route.abort()) - await context.route("**/tracking*", lambda route: route.abort()) - else: - await context.route("**/*", lambda route: route.continue_()) - - page = await context.new_page() - - # Add random delay before navigation for Amazon - if is_amazon: - await asyncio.sleep(random.uniform(1.0, 3.0)) - - # Warmup: Visit home page first - try: - logger.info("Amazon Warmup: Visiting home page...") - await page.goto("https://www.amazon.fr/", wait_until="domcontentloaded", timeout=20000) - await asyncio.sleep(random.uniform(2.0, 4.0)) - except Exception as e: - logger.warning(f"Amazon warmup failed: {e}") - - logger.info(f"Navigating to {url} (Timeout: {timeout}ms)") - response = None - amazon_blocked = False - try: - # First wait for domcontentloaded - this is the minimum we need - response = await page.goto(url, wait_until="domcontentloaded", timeout=timeout) - - # Check HTTP status code - if response: - status = response.status - logger.info(f"HTTP Status: {status}") - - # For Amazon, 503 is usually a block, not product unavailability - if is_amazon and status == 503: - logger.warning(f"Amazon returned 503 - likely blocking") - amazon_blocked = True - # Detect unavailable products by HTTP status (non-Amazon) - elif status in [404, 410, 451]: - logger.warning(f"Product unavailable - HTTP {status}") - is_available = False - elif status == 503 and not is_amazon: - logger.warning(f"Product unavailable - HTTP {status}") - is_available = False - elif status >= 400: - logger.warning(f"HTTP error {status}, marking as potentially unavailable") - is_available = False - - logger.info(f"Page loaded (domcontentloaded): {url}") - - # Then try to wait for networkidle, but don't fail if it times out - # This helps with heavy pages that never fully settle - try: - await page.wait_for_load_state("networkidle", timeout=5000) - logger.info("Network idle reached") - except PlaywrightTimeoutError: - logger.info("Network idle timed out (non-critical), proceeding...") - - except Exception as e: - logger.error(f"Error navigating to {url}: {e}") - # If navigation fails completely, mark as unavailable - is_available = False - # Try to take screenshot anyway if page partially loaded - pass - - # Wait a bit for dynamic content if needed - longer for Amazon - wait_time = random.uniform(2.0, 4.0) if is_amazon else 2.0 - await page.wait_for_timeout(int(wait_time * 1000)) - - # Check for Amazon blocking in page content - if is_amazon: - try: - page_html = await page.content() - if _is_amazon_blocked(page_html): - logger.warning(f"Amazon blocking detected in page content for {url}") - amazon_blocked = True - except Exception as e: - logger.debug(f"Could not check Amazon blocking: {e}") - - # If Amazon blocked us, return None to trigger retry - if amazon_blocked: - logger.warning(f"Amazon blocked - will retry with different User-Agent") - return None, "", True # Return None screenshot to trigger retry - - # Try to close common popups and cookie banners - logger.info("Attempting to close popups and cookie banners...") - popup_selectors = [ - # === SITES SPÉCIFIQUES FRANÇAIS === - # Amazon France - "#sp-cc-accept", - "#sp-cc-rejectall-link", - "input[data-action-type='DISMISS']", - "#a-popover-content-1 button", - "[data-action='a-popover-close']", - # E.Leclerc, Darty, Fnac - "#onetrust-accept-btn-handler", - ".onetrust-accept-btn-handler", - # Auchan - "#popin_tc_privacy_button_2", - ".popin_tc_privacy_button", - "#didomi-notice-agree-button", - # Carrefour - "[data-testid='accept-cookies-button']", - # Cdiscount - "#footer_tc_privacy_button_2", - ".privacy_prompt_accept", - # Boulanger - ".bcom-consent-accept-all", - "#cookieBanner-accept", - # Gifi, Centrakor, La Foir'Fouille (Magento) - ".action-primary.action-accept", - "#btn-cookie-allow", - # PrestaShop (Stokomani, B&M, etc.) - ".btn-primary[data-dismiss='modal']", - "#gdpr_consent_agree", - - # === CMP (Consent Management Platforms) === - ".didomi-continue-without-agreeing", - "#tarteaucitronPersonalize2", - ".tarteaucitronAllow", - "#tarteaucitronAllDenied2", - ".cc-btn.cc-allow", - ".cky-btn-accept", - "#CybotCookiebotDialogBodyLevelButtonLevelOptinAllowAll", - "#CybotCookiebotDialogBodyButtonAccept", - ".qc-cmp2-summary-buttons button:first-child", - "#axeptio_btn_acceptAll", - ".axeptio_acceptAll", - - # === TEXTE FRANÇAIS === - "button:has-text('Tout accepter')", - "button:has-text('Accepter tout')", - "button:has-text('Accepter et fermer')", - "button:has-text('Accepter les cookies')", - "button:has-text('Accepter')", - "button:has-text('J\\'accepte')", - "button:has-text('OK')", - "button:has-text('Continuer')", - "a:has-text('Tout accepter')", - "a:has-text('Accepter')", - - # === POPUPS GÉNÉRAUX === - "button[aria-label='Close']", - "button[aria-label='close']", - "button[aria-label='Fermer']", - ".close-button", - ".modal-close", - "svg[data-name='Close']", - "[class*='popup'] button", - "[class*='modal'] button", - "button:has-text('No, thanks')", - "button:has-text('No thanks')", - "button:has-text('Accept')", - "a:has-text('No, thanks')", - "div[role='dialog'] button[aria-label='Close']", - ] - - for popup_selector in popup_selectors: - try: - if await page.locator(popup_selector).count() > 0: - logger.info(f"Found popup close button: {popup_selector}") - # Try to click it. If it fails, catch and continue - await page.locator(popup_selector).first.click(timeout=2000) - await page.wait_for_timeout(1000) # Wait for animation - except Exception as e: - logger.debug(f"Could not close popup with selector {popup_selector}: {e}") - - # Also try pressing Escape - try: - await page.keyboard.press("Escape") - except Exception as e: - logger.debug(f"Could not press Escape key: {e}") - - if selector: - try: - logger.info(f"Waiting for selector: {selector}") - await page.wait_for_selector(selector, timeout=5000) - # Scroll to element - element = page.locator(selector).first - await element.scroll_into_view_if_needed() - logger.info(f"Scrolled to selector: {selector}") - except Exception as e: - logger.warning(f"Selector {selector} not found or timed out: {e}") - else: - # Auto-detect price if no selector - logger.info("No selector provided. Attempting to find price element...") - try: - # Look for common price patterns - price_locator = page.locator("text=/$[0-9,]+(\\.[0-9]{2})?/") - if await price_locator.count() > 0: - # Pick the first one that looks visible and reasonable size - # This is heuristic - await price_locator.first.scroll_into_view_if_needed() - logger.info("Scrolled to potential price element") - except Exception as e: - logger.warning(f"Auto-price detection failed: {e}") - - # Smart Scroll - if smart_scroll: - logger.info(f"Performing smart scroll ({scroll_pixels}px)...") - try: - await page.evaluate(f"window.scrollBy(0, {scroll_pixels})") - await page.wait_for_timeout(1000) - except Exception as e: - logger.warning(f"Smart scroll failed: {e}") - - # IMPORTANT: Prendre le screenshot AVANT l'extraction de texte - # pour éviter de perdre le screenshot si le navigateur se ferme - screenshot_dir = "screenshots" - os.makedirs(screenshot_dir, exist_ok=True) - if item_id: - filename = f"{screenshot_dir}/item_{item_id}.png" - else: - url_part = url.split("//")[-1].replace("/", "_")[:50] # Limiter la longueur - timestamp = int(asyncio.get_event_loop().time()) - filename = f"{screenshot_dir}/{url_part}_{timestamp}.png" - - try: - await page.screenshot(path=filename, full_page=False) - logger.info(f"Screenshot saved to {filename}") - except Exception as e: - logger.error(f"Screenshot failed: {e}") - return None, "", is_available - - # Text Extraction (après le screenshot pour ne pas le perdre) - page_text = "" - if text_length > 0: - try: - logger.info(f"Extracting text (limit: {text_length} chars)...") - # Get text from body avec un timeout court - raw_text = await page.inner_text("body", timeout=10000) - # Simple truncation - page_text = raw_text[:text_length] - logger.info(f"Extracted {len(page_text)} characters") - - # Check for "product not found" messages in page text - unavailable_patterns = [ - "produit introuvable", - "produit indisponible", - "page introuvable", - "404", - "product not found", - "item not found", - "page not found", - "n'existe plus", - "plus disponible", - "no longer available", - "article introuvable", - "cette page n'existe pas", - "this page doesn't exist", - ] - - page_text_lower = page_text.lower() - for pattern in unavailable_patterns: - if pattern in page_text_lower: - logger.warning(f"Product unavailable - found pattern: '{pattern}'") - is_available = False - break - - except Exception as e: - logger.warning(f"Text extraction failed (screenshot saved): {e}") - # On continue car le screenshot a été pris - - return filename, page_text, is_available - - except Exception as e: - logger.error(f"Error scraping {url}: {e}") - return None, "", False # Mark as unavailable on error - - finally: - # Fermeture propre des ressources dans l'ordre inverse - try: - if page and not page.is_closed(): - await page.close() - except Exception as e: - logger.debug(f"Error closing page: {e}") - - try: - if context: - await context.close() - except Exception as e: - logger.debug(f"Error closing context: {e}") - - # On ne ferme le navigateur que s'il est local (non partagé) - if local_browser: - try: - if local_browser.is_connected(): - await local_browser.close() - except Exception as e: - logger.debug(f"Error closing browser: {e}") - - - # Si on a créé un manager playwright local, on l'arrête - if playwright_manager: - try: - await playwright_manager.__aexit__(None, None, None) - except Exception as e: - logger.debug(f"Error stopping playwright manager: {e}") diff --git a/app/services/search_service.py b/app/services/search_service.py index c2a5934..61c16cb 100644 --- a/app/services/search_service.py +++ b/app/services/search_service.py @@ -1,483 +1,144 @@ """ -Search Service - Orchestrateur de recherche de produits -Combine recherche directe sur sites + Light Scraper + Browserless fallback +New Search Service +Orchestrates searches across multiple e-commerce sites using BrowserlessService. """ import asyncio import logging -import os -from collections.abc import AsyncGenerator -from typing import Any +import re +from urllib.parse import quote_plus, urljoin -from playwright.async_api import async_playwright -from sqlalchemy.orm import Session +from bs4 import BeautifulSoup -from app.models import SearchSite -from app.schemas import SearchProgress, SearchResultItem -from app.services import direct_search_service, light_scraper_service -from app.services.ai_service import AIService -from app.services.scraper_service import ScraperService -from app.services.settings_service import SettingsService +from app.core.search_config import SITE_CONFIGS +from app.services.browserless_service import browserless_service logger = logging.getLogger(__name__) -# Configuration par défaut -DEFAULT_MAX_RESULTS = 20 -DEFAULT_PARALLEL_LIMIT = 5 -DEFAULT_TIMEOUT = 30 +class SearchResult: + def __init__( + self, + url: str, + title: str, + snippet: str, + source: str, + price: float | None = None, + currency: str = "EUR", + in_stock: bool | 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 - -async def search_products( - query: str, - db: Session, - site_ids: list[int] | None = None, - max_results: int | None = None, -) -> AsyncGenerator[SearchProgress, None]: - """ - Recherche des produits sur les sites configurés. - - Flux: - 1. Recherche directe sur les pages de recherche de chaque site - 2. Light scraper tente extraction HTTP rapide - 3. Browserless fallback pour les sites JS - - Args: - query: Terme de recherche - db: Session de base de données - site_ids: IDs des sites à utiliser (None = tous les actifs) - max_results: Nombre max de résultats - - Yields: - SearchProgress avec les résultats au fur et à mesure - """ - # Récupérer la configuration - if max_results is None: - max_results = int(SettingsService.get_setting_value(db, "search_max_results", str(DEFAULT_MAX_RESULTS))) - - parallel_limit = int(SettingsService.get_setting_value(db, "search_parallel_limit", str(DEFAULT_PARALLEL_LIMIT))) - - # Récupérer les sites - sites = _get_sites(db, site_ids) - if not sites: - yield SearchProgress( - status="error", - total=0, - completed=0, - message="Aucun site de recherche configuré", - results=[], - ) - return - - # Préparer les données des sites pour la recherche directe - sites_data = [ - { - "domain": site.domain, - "name": site.name, - "search_url": site.search_url, - "product_link_selector": site.product_link_selector, - "requires_js": site.requires_js, - "debug_enabled": site.debug_enabled, + 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, } - for site in sites - ] - site_map = {site.domain: site for site in sites} - # Phase 1: Recherche directe sur les sites - yield SearchProgress( - status="searching", - total=0, - completed=0, - message=f"Recherche sur {len(sites)} sites...", - results=[], - ) +class NewSearchService: + @staticmethod + async def search_site(site_key: str, query: str) -> list[SearchResult]: + """Search a single site""" + config = SITE_CONFIGS.get(site_key) + if not config: + logger.error(f"Unknown site: {site_key}") + return [] - browser = None - playwright = None - - try: - # Initialiser Playwright et Browserless pour TOUTE la session de recherche - logger.info(f"Initialisation session Browserless globale pour '{query}'") - playwright = await async_playwright().start() - browser = await playwright.chromium.connect_over_cdp( - os.getenv("BROWSERLESS_URL", "ws://browserless:3000"), - timeout=60000 - ) + search_url = config["search_url"].format(query=quote_plus(query)) + logger.info(f"Searching {config['name']} at {search_url}") - try: - search_results = await direct_search_service.search( - query=query, - sites=sites_data, - max_results=max_results, - browser=browser, # Utiliser le navigateur partagé - ) - 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: - yield SearchProgress( - status="completed", - total=0, - completed=0, - message="Aucun résultat trouvé sur les sites configurés", - results=[], - ) - return - - total = len(search_results) - logger.info(f"Recherche directe: {total} URLs trouvées pour '{query}'") - - # Phase 2: Scraping des URLs - results: list[SearchResultItem] = [] - completed = 0 - - # Créer un sémaphore pour limiter la concurrence globale - semaphore = asyncio.Semaphore(parallel_limit) - # Sémaphore spécifique pour Amazon (1 seul thread à la fois pour éviter le blocage) - amazon_semaphore = asyncio.Semaphore(1) - - async def process_url(search_result: direct_search_service.SearchResult) -> SearchResultItem | None: - async with semaphore: - url = search_result.url - domain = search_result.source - site = site_map.get(domain) - - if not site: - # Trouver le site par correspondance partielle - for d, s in site_map.items(): - if d in domain or domain in d: - site = s - break - - site_name = site.name if site else domain - requires_js = site.requires_js if site else False - - # 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é - if "amazon" in domain.lower(): - async with amazon_semaphore: - # Petit délai supplémentaire entre les requêtes Amazon - await asyncio.sleep(2.0) - result = await _scrape_with_browserless(url, site_name, domain, search_result.title, browser) - else: - 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( - 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 - tasks = [asyncio.create_task(process_url(r)) for r in search_results] - - for coro in asyncio.as_completed(tasks): - try: - result = await coro - 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: - 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", - ) + # Use proxy if required by config + use_proxy = config.get("requires_proxy", False) - finally: - # Nettoyage global des ressources - if browser: - try: - if browser.is_connected(): - await browser.close() - logger.info("Navigateur global fermé") - except Exception as e: - logger.error(f"Erreur fermeture navigateur: {e}") + 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 [] + + return NewSearchService._parse_results(html_content, site_key, search_url) + + @staticmethod + def _parse_results(html: str, site_key: str, base_url: str) -> list[SearchResult]: + """Parse HTML content to extract search results""" + config = SITE_CONFIGS[site_key] + soup = BeautifulSoup(html, "html.parser") + results = [] + + # Select product links + links = soup.select(config["product_selector"]) - # Petit délai pour laisser le temps aux connexions de se fermer proprement - await asyncio.sleep(0.5) - - if playwright: - try: - await playwright.stop() - except Exception as e: - # Ignorer les erreurs courantes lors de l'arrêt si déjà fermé - if "Event loop is closed" not in str(e): - logger.error(f"Erreur arrêt Playwright: {e}") + # Deduplicate links + seen_urls = set() + + for link in links: + if len(results) >= 5: # Limit to 5 results per site + break + href = link.get("href") + if not href: + continue -async def _scrape_with_browserless( - url: str, - site_name: str, - domain: str, - fallback_title: str, - browser=None, # Navigateur partagé -) -> SearchResultItem: - """Scrape une URL avec Browserless + extraction IA""" - try: - # Scraper avec Browserless (utiliser le navigateur partagé) - screenshot_path, page_text, is_available = await ScraperService.scrape_item( - url=url, - item_id=None, - smart_scroll=True, - scroll_pixels=350, - text_length=3000, - timeout=30000, - browser=browser, - ) + full_url = urljoin(base_url, href) + + # Basic cleanup + if full_url in seen_urls: + continue + seen_urls.add(full_url) - if not screenshot_path: - return SearchResultItem( - url=url, - title=fallback_title, - price=None, - site_name=site_name, - site_domain=domain, - error="Screenshot failed", - ) + # Extract title + title = link.get_text(strip=True) + if not title: + # Try finding title in children or attributes + img = link.find("img") + if img and img.get("alt"): + title = img.get("alt") + elif link.get("title"): + title = link.get("title") + + if not title or len(title) < 3: + continue - # Extraction IA - ai_result = await AIService.analyze_image( - image_path=screenshot_path, - page_text=page_text, - ) + # Create result + results.append(SearchResult( + url=full_url, + title=title, + snippet=f"Product from {config['name']}", + source=config["name"] + )) - # Construire l'URL du screenshot pour le frontend - screenshot_url = None - if screenshot_path: - screenshot_filename = os.path.basename(screenshot_path) - screenshot_url = f"/screenshots/{screenshot_filename}" + logger.info(f"Found {len(results)} results for {site_key}") + return results - if ai_result: - extraction, metadata = ai_result - if extraction and extraction.price is not None: - return SearchResultItem( - url=url, - title=fallback_title, - price=extraction.price, - currency=extraction.currency or "EUR", - in_stock=extraction.in_stock, - image_url=screenshot_url, - site_name=site_name, - site_domain=domain, - confidence=extraction.price_confidence or 0.5, - ) + @staticmethod + async def search_all(query: str) -> list[SearchResult]: + """Search all configured sites in parallel""" + tasks = [] + for site_key in SITE_CONFIGS.keys(): + tasks.append(NewSearchService.search_site(site_key, query)) + + results_list = await asyncio.gather(*tasks) + + # Flatten list + all_results = [] + for site_results in results_list: + all_results.extend(site_results) + + return all_results - return SearchResultItem( - url=url, - title=fallback_title, - price=None, - image_url=screenshot_url, - site_name=site_name, - site_domain=domain, - error="AI extraction failed", - ) - - except Exception as e: - logger.error(f"Browserless scraping error for {url}: {e}") - return SearchResultItem( - url=url, - title=fallback_title, - price=None, - site_name=site_name, - site_domain=domain, - error=str(e), - ) - - -def _get_sites(db: Session, site_ids: list[int] | None = None) -> list[SearchSite]: - """Récupère les sites de recherche actifs""" - query = db.query(SearchSite).filter(SearchSite.is_active == True) - - if site_ids: - query = query.filter(SearchSite.id.in_(site_ids)) - - return query.order_by(SearchSite.priority).all() - - -def get_all_sites(db: Session) -> list[SearchSite]: - """Récupère tous les sites de recherche""" - return db.query(SearchSite).order_by(SearchSite.priority).all() - - -def get_site_by_id(db: Session, site_id: int) -> SearchSite | None: - """Récupère un site par son ID""" - return db.query(SearchSite).filter(SearchSite.id == site_id).first() - - -def create_site(db: Session, site_data: dict[str, Any]) -> SearchSite: - """Crée un nouveau site de recherche""" - site = SearchSite(**site_data) - db.add(site) - db.commit() - db.refresh(site) - return site - - -def update_site(db: Session, site_id: int, site_data: dict[str, Any]) -> SearchSite | None: - """Met à jour un site de recherche""" - site = get_site_by_id(db, site_id) - if not site: - return None - - for key, value in site_data.items(): - if value is not None: - setattr(site, key, value) - - db.commit() - db.refresh(site) - return site - - -def delete_site(db: Session, site_id: int) -> bool: - """Supprime un site de recherche""" - site = get_site_by_id(db, site_id) - if not site: - return False - - db.delete(site) - db.commit() - return True - - -def seed_default_sites(db: Session) -> int: - """ - Initialise la base de données avec les sites configurés en dur. - Ne crée que les sites qui n'existent pas encore (basé sur le domaine). - - Returns: - Nombre de sites créés - """ - from app.services.direct_search_service import DEFAULT_SITE_CONFIGS - - created_count = 0 - - # Récupérer les domaines existants - existing_domains = {site.domain.lower().replace("www.", "") - for site in db.query(SearchSite).all()} - - for domain, config in DEFAULT_SITE_CONFIGS.items(): - # Normaliser le domaine pour comparaison - clean_domain = domain.lower().replace("www.", "") - - # Ne pas créer si déjà existant - if clean_domain in existing_domains: - logger.debug(f"Site {domain} déjà existant, ignoré") - continue - - # Créer le site - site_data = { - "name": config.get("name", domain), - "domain": domain, - "search_url": config.get("search_url"), - "product_link_selector": config.get("product_selector"), - "category": config.get("category"), - "requires_js": config.get("requires_js", True), - "priority": config.get("priority", 99), - "is_active": True, - } - - try: - site = SearchSite(**site_data) - db.add(site) - db.commit() - created_count += 1 - logger.info(f"Site créé: {config.get('name', domain)} ({domain})") - except Exception as e: - db.rollback() - logger.error(f"Erreur création site {domain}: {e}") - - return created_count - - -def reset_sites_to_defaults(db: Session) -> int: - """ - Réinitialise tous les sites avec les valeurs par défaut. - Supprime tous les sites existants et recrée à partir de la config. - - Returns: - Nombre de sites créés - """ - from app.services.direct_search_service import DEFAULT_SITE_CONFIGS - - # Supprimer tous les sites existants - db.query(SearchSite).delete() - db.commit() - logger.info("Tous les sites supprimés") - - # Recréer avec les valeurs par défaut - created_count = 0 - - for domain, config in DEFAULT_SITE_CONFIGS.items(): - site_data = { - "name": config.get("name", domain), - "domain": domain, - "search_url": config.get("search_url"), - "product_link_selector": config.get("product_selector"), - "category": config.get("category"), - "requires_js": config.get("requires_js", True), - "priority": config.get("priority", 99), - "is_active": True, - } - - try: - site = SearchSite(**site_data) - db.add(site) - db.commit() - created_count += 1 - logger.info(f"Site créé: {config.get('name', domain)} ({domain})") - except Exception as e: - db.rollback() - logger.error(f"Erreur création site {domain}: {e}") - - return created_count +# Global instance +new_search_service = NewSearchService() diff --git a/verify_new_search.py b/verify_new_search.py new file mode 100644 index 0000000..1fa16c0 --- /dev/null +++ b/verify_new_search.py @@ -0,0 +1,53 @@ +import asyncio +import logging +import sys +import os + +# Add project root to path +sys.path.append(os.path.abspath(os.path.join(os.path.dirname(__file__), "."))) + +from app.services.search_service import new_search_service +from app.services.browserless_service import browserless_service + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", + handlers=[logging.StreamHandler()] +) + +async def verify_search(): + query = "iphone 15" + print(f"--- Starting Verification Search for '{query}' ---") + + # 1. Test Browserless Connection + print("\n[1] Testing Browserless Connection...") + try: + await browserless_service.start() + print("✅ Browserless connected successfully") + except Exception as e: + print(f"❌ Browserless connection failed: {e}") + return + + # 2. Test Specific Sites + sites_to_test = ["gifi.fr", "amazon.fr", "e.leclerc"] + + for site in sites_to_test: + print(f"\n[2] Testing Search on {site}...") + try: + results = await new_search_service.search_site(site, query) + if results: + print(f"✅ Found {len(results)} results for {site}") + for r in results[:2]: + print(f" - {r.title} ({r.url})") + else: + print(f"⚠️ No results found for {site}") + except Exception as e: + print(f"❌ Error searching {site}: {e}") + + # 3. Cleanup + await browserless_service.stop() + print("\n--- Verification Complete ---") + +if __name__ == "__main__": + asyncio.run(verify_search())