diff --git a/app/ai_schema.py b/app/ai_schema.py index 7378917..1e7ad8d 100644 --- a/app/ai_schema.py +++ b/app/ai_schema.py @@ -73,14 +73,28 @@ class AIExtractionResponse(BaseModel): @field_validator("price", mode="before") @classmethod def normalize_price(cls, v): - """Normalize price to float or None.""" + """Normalize price to float or None, handling French format.""" if v is None or v in ("null", ""): return None if isinstance(v, str): - # Remove currency symbols and commas - cleaned = re.sub(r"[^\d.]", "", v) + # Handle French format: "1 234,56" -> "1234.56" + # First, remove spaces (thousand separators) + cleaned = v.replace(" ", "").replace("\u00a0", "") + # Replace comma with dot (French decimal separator) + # But only if there's no dot already (to handle "1.234,56" format) + if "," in cleaned and "." in cleaned: + # Format "1.234,56" - remove dots, replace comma with dot + cleaned = cleaned.replace(".", "").replace(",", ".") + elif "," in cleaned: + # Format "12,99" - just replace comma with dot + cleaned = cleaned.replace(",", ".") + # Remove currency symbols and other non-numeric chars (except dot) + cleaned = re.sub(r"[^\d.]", "", cleaned) if cleaned: - return float(cleaned) + try: + return float(cleaned) + except ValueError: + return None return None return float(v) @@ -116,23 +130,27 @@ class AIExtractionMetadata(BaseModel): # Prompt template for schema-first extraction -EXTRACTION_PROMPT_TEMPLATE = """Extract product price and stock status from the image. +EXTRACTION_PROMPT_TEMPLATE = """Extract product price and stock status from this French e-commerce page. -**PRICE:** -- Find the main current price -- Ignore crossed-out prices -- Extract number only (no symbols) +**PRICE (IMPORTANT - French format):** +- French prices use COMMA for decimals: "12,99 €" means 12.99 +- Thousands separator is SPACE or DOT: "1 234,56 €" or "1.234,56 €" means 1234.56 +- Currency symbol is usually € at the end +- Look for: price tags, "Prix:", "€", numbers near "Ajouter au panier" +- Extract as DECIMAL NUMBER (convert comma to dot): 12,99 -> 12.99 +- Ignore crossed-out/barré prices (old prices) +- If multiple prices, take the current/main price (not the original) - If unclear: set null and confidence < 0.5 **STOCK:** -- TRUE if: "Add to Cart", "Buy Now", "In Stock", "Available" -- FALSE if: "Out of Stock", "Sold Out", "Unavailable", "Notify Me" +- TRUE if: "Ajouter au panier", "Acheter", "En stock", "Disponible", "Add to Cart" +- FALSE if: "Rupture", "Indisponible", "Épuisé", "Out of Stock", "Notify Me" - NULL if unclear or not shown **CONFIDENCE (0.0 to 1.0):** -- 0.9-1.0: Very certain -- 0.5-0.8: Moderately certain -- Below 0.5: Unsure +- 0.9-1.0: Price clearly visible +- 0.5-0.8: Price found but partially obscured or uncertain +- Below 0.5: Cannot find price reliably Respond ONLY with valid JSON: {{ diff --git a/app/services/scraper_service.py b/app/services/scraper_service.py index 1328bc8..059185b 100644 --- a/app/services/scraper_service.py +++ b/app/services/scraper_service.py @@ -209,16 +209,64 @@ class ScraperService: # Wait a bit for dynamic content if needed await page.wait_for_timeout(2000) - # Try to close common popups (inclut Amazon) - logger.info("Attempting to close popups...") + # Try to close common popups and cookie banners + logger.info("Attempting to close popups and cookie banners...") popup_selectors = [ - # Amazon spécifique - boutons cookies + # === 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']", - # Popups généraux + # 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']", @@ -229,9 +277,7 @@ class ScraperService: "[class*='modal'] button", "button:has-text('No, thanks')", "button:has-text('No thanks')", - "button:has-text('Accepter')", "button:has-text('Accept')", - "button:has-text('Continuer')", "a:has-text('No, thanks')", "div[role='dialog'] button[aria-label='Close']", ]