mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-24 07:38:13 +02:00
fix: Improve Amazon scraping to avoid 503 blocking errors
- Add User-Agent rotation pool with modern Chrome/Firefox/Edge versions
- Implement Amazon-specific blocking detection ("Toutes nos excuses", etc.)
- Add exponential backoff with jitter for Amazon retries (up to 4 retries)
- Include Sec-Fetch-* headers and proper Referer to appear more legitimate
- Simulate human behavior (mouse movements, random scrolling)
- Add random delays between requests to avoid rate limiting
- Properly differentiate Amazon 503 (blocking) from true product unavailability
- Apply same protections to both search and product scraping services
This commit is contained in:
2 files changed
+397
-126
No files matched your search
@@ -6,6 +6,7 @@ Utilise Playwright/Browserless pour gérer JavaScript et contourner la détectio
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
import time
|
import time
|
||||||
from urllib.parse import quote_plus, urljoin, urlparse
|
from urllib.parse import quote_plus, urljoin, urlparse
|
||||||
@@ -20,10 +21,92 @@ BROWSERLESS_URL = os.getenv("BROWSERLESS_URL", "ws://browserless:3000")
|
|||||||
# Constants
|
# Constants
|
||||||
MIN_TITLE_LENGTH = 3
|
MIN_TITLE_LENGTH = 3
|
||||||
MIN_LINK_TEXT_LENGTH = 5
|
MIN_LINK_TEXT_LENGTH = 5
|
||||||
USER_AGENT = (
|
|
||||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
# Pool of realistic User-Agents for rotation (Chrome on Windows/Mac)
|
||||||
"(KHTML, like Gecko) Chrome/123.0.0.0 Safari/537.36"
|
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]
|
||||||
|
|
||||||
|
|
||||||
# Chemin absolu pour les dumps de débogage (doit correspondre à celui dans debug.py)
|
# Chemin absolu pour les dumps de débogage (doit correspondre à celui dans debug.py)
|
||||||
@@ -562,142 +645,229 @@ async def _search_site_browserless( # noqa: PLR0912, PLR0915
|
|||||||
|
|
||||||
logger.info(f"Recherche Browserless sur {domain}: {final_url}")
|
logger.info(f"Recherche Browserless sur {domain}: {final_url}")
|
||||||
|
|
||||||
context = None
|
# Check if this is Amazon - requires special handling
|
||||||
page = None
|
is_amazon = "amazon" in domain.lower()
|
||||||
|
max_retries = AMAZON_MAX_RETRIES if is_amazon else 1
|
||||||
|
|
||||||
try:
|
for attempt in range(max_retries):
|
||||||
# Créer un nouveau contexte isolé pour ce site
|
if attempt > 0:
|
||||||
context = await browser.new_context(
|
# Exponential backoff with jitter for Amazon retries
|
||||||
viewport={"width": 1920, "height": 1080},
|
base_delay = AMAZON_BASE_DELAY * (2 ** attempt)
|
||||||
user_agent=USER_AGENT,
|
jitter = random.uniform(0.5, 1.5)
|
||||||
locale="fr-FR",
|
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)
|
||||||
|
|
||||||
# Bloquer les ressources inutiles
|
context = None
|
||||||
await context.route("**/*.{png,jpg,jpeg,gif,webp,svg,ico}", lambda route: route.abort())
|
page = None
|
||||||
await context.route("**/analytics*", lambda route: route.abort())
|
|
||||||
await context.route("**/tracking*", lambda route: route.abort())
|
|
||||||
|
|
||||||
page = await context.new_page()
|
try:
|
||||||
|
# Get random User-Agent for each attempt (important for Amazon)
|
||||||
|
current_user_agent = _get_random_user_agent()
|
||||||
|
|
||||||
# Naviguer vers la page de recherche
|
# Create stealth headers - more comprehensive for Amazon
|
||||||
await page.goto(final_url, wait_until="domcontentloaded", timeout=int(timeout * 1000))
|
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",
|
||||||
|
}
|
||||||
|
|
||||||
# Accepter les cookies si nécessaire
|
# Add referer for Amazon to look more natural
|
||||||
await _accept_cookies(page, domain)
|
if is_amazon:
|
||||||
|
extra_headers["Referer"] = "https://www.google.fr/"
|
||||||
|
|
||||||
# Attendre que les résultats se chargent
|
# Créer un nouveau contexte isolé pour ce site
|
||||||
if wait_selector:
|
context = await browser.new_context(
|
||||||
try:
|
viewport={"width": 1920, "height": 1080},
|
||||||
await page.wait_for_selector(wait_selector, timeout=10000)
|
user_agent=current_user_agent,
|
||||||
except Exception:
|
locale="fr-FR",
|
||||||
logger.debug(f"Selector {wait_selector} non trouvé sur {domain}, on continue...")
|
timezone_id="Europe/Paris",
|
||||||
|
extra_http_headers=extra_headers,
|
||||||
|
)
|
||||||
|
|
||||||
# Attendre un peu pour le JS (réduit pour parallélisation)
|
# For Amazon, don't block images - it may trigger detection
|
||||||
await asyncio.sleep(1.5)
|
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())
|
||||||
|
|
||||||
# Extraire le HTML
|
page = await context.new_page()
|
||||||
html_content = await page.content()
|
|
||||||
|
|
||||||
# Check for CAPTCHA
|
# Add random delay before navigation for Amazon
|
||||||
if "Enter the characters you see below" in html_content or "Saisissez les caractères" in html_content:
|
if is_amazon:
|
||||||
logger.warning(f"CAPTCHA detected on {domain}!")
|
await _random_delay(1.0, 3.0)
|
||||||
_dump_debug_html(html_content, domain, query)
|
|
||||||
return []
|
|
||||||
|
|
||||||
# Parser le HTML avec BeautifulSoup
|
# Naviguer vers la page de recherche
|
||||||
soup = BeautifulSoup(html_content, "lxml")
|
await page.goto(final_url, wait_until="domcontentloaded", timeout=int(timeout * 1000))
|
||||||
results = []
|
|
||||||
seen_urls = set()
|
|
||||||
|
|
||||||
# Trouver les liens produits
|
# For Amazon, simulate human behavior before interacting
|
||||||
links = []
|
if is_amazon:
|
||||||
if product_selector:
|
await _simulate_human_behavior(page)
|
||||||
# Essayer plusieurs sélecteurs séparés par virgule
|
|
||||||
selectors = [s.strip() for s in product_selector.split(",")]
|
# Accepter les cookies si nécessaire
|
||||||
for sel in selectors:
|
await _accept_cookies(page, domain)
|
||||||
|
|
||||||
|
# Attendre que les résultats se chargent
|
||||||
|
if wait_selector:
|
||||||
try:
|
try:
|
||||||
found = soup.select(sel)
|
await page.wait_for_selector(wait_selector, timeout=10000)
|
||||||
links.extend(found)
|
|
||||||
except Exception:
|
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}'")
|
||||||
|
_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}!")
|
||||||
|
_dump_debug_html(html_content, domain, query)
|
||||||
|
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()
|
||||||
|
|
||||||
|
# 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
|
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é
|
# Construire l'URL complète
|
||||||
if not links:
|
if href.startswith("/"):
|
||||||
logger.info(f"{domain}: Sélecteur configuré n'a rien trouvé, utilisation détection générique")
|
href = f"https://{domain}{href}"
|
||||||
links = _find_product_links(soup, domain)
|
elif not href.startswith("http"):
|
||||||
logger.info(f"{domain}: {len(links)} liens trouvés avec détection générique")
|
href = urljoin(final_url, href)
|
||||||
|
|
||||||
for link in links:
|
# Nettoyer l'URL
|
||||||
href = link.get("href", "")
|
href = _clean_product_url(href, domain)
|
||||||
if not href:
|
|
||||||
continue
|
|
||||||
|
|
||||||
# Construire l'URL complète
|
# Éviter les doublons
|
||||||
if href.startswith("/"):
|
if href in seen_urls:
|
||||||
href = f"https://{domain}{href}"
|
continue
|
||||||
elif not href.startswith("http"):
|
seen_urls.add(href)
|
||||||
href = urljoin(final_url, href)
|
|
||||||
|
|
||||||
# Nettoyer l'URL
|
# Vérifier que c'est bien un lien vers ce domaine
|
||||||
href = _clean_product_url(href, domain)
|
url_domain = _extract_domain(href)
|
||||||
|
if domain not in url_domain and url_domain not in domain:
|
||||||
|
# logger.debug(f"Ignored external link: {href}")
|
||||||
|
continue
|
||||||
|
|
||||||
# Éviter les doublons
|
# Filtrer les liens non-produits
|
||||||
if href in seen_urls:
|
if _is_non_product_url(href):
|
||||||
continue
|
# logger.debug(f"Ignored non-product link: {href}")
|
||||||
seen_urls.add(href)
|
continue
|
||||||
|
|
||||||
# Vérifier que c'est bien un lien vers ce domaine
|
# Extraire le titre
|
||||||
url_domain = _extract_domain(href)
|
title = _extract_title(link)
|
||||||
if domain not in url_domain and url_domain not in domain:
|
if not title or len(title) < MIN_TITLE_LENGTH:
|
||||||
# logger.debug(f"Ignored external link: {href}")
|
# logger.debug(f"Ignored link with no title: {href}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Filtrer les liens non-produits
|
results.append(SearchResult(
|
||||||
if _is_non_product_url(href):
|
url=href,
|
||||||
# logger.debug(f"Ignored non-product link: {href}")
|
title=title,
|
||||||
continue
|
snippet="",
|
||||||
|
source=domain,
|
||||||
|
# On ne peut pas extraire le prix facilement ici sans scraper chaque page
|
||||||
|
# Le scraping détaillé se fera dans la phase 2
|
||||||
|
))
|
||||||
|
|
||||||
# Extraire le titre
|
if len(results) >= max_results:
|
||||||
title = _extract_title(link)
|
break
|
||||||
if not title or len(title) < MIN_TITLE_LENGTH:
|
|
||||||
# logger.debug(f"Ignored link with no title: {href}")
|
|
||||||
continue
|
|
||||||
|
|
||||||
results.append(SearchResult(
|
logger.info(f"{domain}: {len(results)} résultats extraits")
|
||||||
url=href,
|
|
||||||
title=title,
|
|
||||||
snippet="",
|
|
||||||
source=domain,
|
|
||||||
# On ne peut pas extraire le prix facilement ici sans scraper chaque page
|
|
||||||
# Le scraping détaillé se fera dans la phase 2
|
|
||||||
))
|
|
||||||
|
|
||||||
if len(results) >= max_results:
|
# If we got results, success! Return them
|
||||||
break
|
if len(results) > 0:
|
||||||
|
return results
|
||||||
|
|
||||||
logger.info(f"{domain}: {len(results)} résultats extraits")
|
# No results found - for Amazon this might be temporary blocking
|
||||||
|
|
||||||
if len(results) == 0:
|
|
||||||
logger.warning(f"{domain}: 0 results found. Dumping HTML for debugging.")
|
logger.warning(f"{domain}: 0 results found. Dumping HTML for debugging.")
|
||||||
_dump_debug_html(html_content, domain, query)
|
_dump_debug_html(html_content, domain, query)
|
||||||
|
|
||||||
return results
|
# 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
|
||||||
|
|
||||||
except Exception as e:
|
return results
|
||||||
logger.error(f"Erreur Browserless sur {domain}: {e}")
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.debug(f"Erreur fermeture contexte {domain}: {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:
|
def _get_default_search_url(domain: str) -> str | None:
|
||||||
|
|||||||
+118
-17
@@ -1,6 +1,7 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
import os
|
import os
|
||||||
|
import random
|
||||||
import re
|
import re
|
||||||
from urllib.parse import urlparse, urlunparse
|
from urllib.parse import urlparse, urlunparse
|
||||||
|
|
||||||
@@ -15,6 +16,50 @@ BROWSERLESS_URL = os.getenv("BROWSERLESS_URL", "ws://browserless:3000")
|
|||||||
MAX_RETRIES = 2
|
MAX_RETRIES = 2
|
||||||
RETRY_DELAY = 3 # secondes
|
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",
|
||||||
|
]
|
||||||
|
|
||||||
|
# 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",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
def simplify_url(url: str) -> str:
|
||||||
"""
|
"""
|
||||||
@@ -117,12 +162,23 @@ class ScraperService:
|
|||||||
logger.warning(f"Invalid timeout value: {timeout}, using default 90000")
|
logger.warning(f"Invalid timeout value: {timeout}, using default 90000")
|
||||||
timeout = 90000
|
timeout = 90000
|
||||||
|
|
||||||
|
# Amazon needs more retries with longer delays
|
||||||
|
is_amazon = _is_amazon_url(url)
|
||||||
|
max_retries = AMAZON_MAX_RETRIES if is_amazon else MAX_RETRIES
|
||||||
|
base_delay = AMAZON_BASE_DELAY if is_amazon else RETRY_DELAY
|
||||||
|
|
||||||
# Retries avec backoff
|
# Retries avec backoff
|
||||||
last_error = None
|
last_error = None
|
||||||
for attempt in range(MAX_RETRIES + 1):
|
for attempt in range(max_retries + 1):
|
||||||
if attempt > 0:
|
if attempt > 0:
|
||||||
logger.info(f"Tentative {attempt + 1}/{MAX_RETRIES + 1} pour {url}")
|
# Exponential backoff with jitter for Amazon
|
||||||
await asyncio.sleep(RETRY_DELAY * attempt)
|
if is_amazon:
|
||||||
|
jitter = random.uniform(0.5, 1.5)
|
||||||
|
delay = min(base_delay * (2 ** attempt) * jitter, 30.0)
|
||||||
|
else:
|
||||||
|
delay = base_delay * attempt
|
||||||
|
logger.info(f"Tentative {attempt + 1}/{max_retries + 1} pour {url} (délai: {delay:.1f}s)")
|
||||||
|
await asyncio.sleep(delay)
|
||||||
|
|
||||||
result = await ScraperService._do_scrape(
|
result = await ScraperService._do_scrape(
|
||||||
url=url,
|
url=url,
|
||||||
@@ -133,6 +189,7 @@ class ScraperService:
|
|||||||
text_length=text_length,
|
text_length=text_length,
|
||||||
timeout=timeout,
|
timeout=timeout,
|
||||||
browser=browser,
|
browser=browser,
|
||||||
|
is_amazon=is_amazon,
|
||||||
)
|
)
|
||||||
|
|
||||||
if result[0] is not None: # Screenshot réussi
|
if result[0] is not None: # Screenshot réussi
|
||||||
@@ -154,6 +211,7 @@ class ScraperService:
|
|||||||
text_length: int = 0,
|
text_length: int = 0,
|
||||||
timeout: int = 90000,
|
timeout: int = 90000,
|
||||||
browser=None,
|
browser=None,
|
||||||
|
is_amazon: bool = False,
|
||||||
) -> tuple[str | None, str, bool]:
|
) -> tuple[str | None, str, bool]:
|
||||||
"""Exécute le scraping réel (appelé par scrape_item avec retries)."""
|
"""Exécute le scraping réel (appelé par scrape_item avec retries)."""
|
||||||
|
|
||||||
@@ -181,21 +239,36 @@ class ScraperService:
|
|||||||
page = None
|
page = None
|
||||||
is_available = True # Par défaut, le produit est disponible
|
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/"
|
||||||
|
|
||||||
try:
|
try:
|
||||||
context = await current_browser.new_context(
|
context = await current_browser.new_context(
|
||||||
viewport={"width": 1920, "height": 1080},
|
viewport={"width": 1920, "height": 1080},
|
||||||
user_agent=(
|
user_agent=current_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"
|
|
||||||
),
|
|
||||||
locale="fr-FR",
|
locale="fr-FR",
|
||||||
timezone_id="Europe/Paris",
|
timezone_id="Europe/Paris",
|
||||||
# Options supplémentaires pour éviter la détection
|
extra_http_headers=extra_headers,
|
||||||
extra_http_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/webp,*/*;q=0.8",
|
|
||||||
}
|
|
||||||
)
|
)
|
||||||
|
|
||||||
# Stealth mode / Ad blocking attempts
|
# Stealth mode / Ad blocking attempts
|
||||||
@@ -203,8 +276,13 @@ class ScraperService:
|
|||||||
|
|
||||||
page = await context.new_page()
|
page = await context.new_page()
|
||||||
|
|
||||||
|
# Add random delay before navigation for Amazon
|
||||||
|
if is_amazon:
|
||||||
|
await asyncio.sleep(random.uniform(1.0, 3.0))
|
||||||
|
|
||||||
logger.info(f"Navigating to {url} (Timeout: {timeout}ms)")
|
logger.info(f"Navigating to {url} (Timeout: {timeout}ms)")
|
||||||
response = None
|
response = None
|
||||||
|
amazon_blocked = False
|
||||||
try:
|
try:
|
||||||
# First wait for domcontentloaded - this is the minimum we need
|
# First wait for domcontentloaded - this is the minimum we need
|
||||||
response = await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
response = await page.goto(url, wait_until="domcontentloaded", timeout=timeout)
|
||||||
@@ -214,8 +292,15 @@ class ScraperService:
|
|||||||
status = response.status
|
status = response.status
|
||||||
logger.info(f"HTTP Status: {status}")
|
logger.info(f"HTTP Status: {status}")
|
||||||
|
|
||||||
# Detect unavailable products by HTTP status
|
# For Amazon, 503 is usually a block, not product unavailability
|
||||||
if status in [404, 410, 451, 503]:
|
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}")
|
logger.warning(f"Product unavailable - HTTP {status}")
|
||||||
is_available = False
|
is_available = False
|
||||||
elif status >= 400:
|
elif status >= 400:
|
||||||
@@ -239,8 +324,24 @@ class ScraperService:
|
|||||||
# Try to take screenshot anyway if page partially loaded
|
# Try to take screenshot anyway if page partially loaded
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Wait a bit for dynamic content if needed
|
# Wait a bit for dynamic content if needed - longer for Amazon
|
||||||
await page.wait_for_timeout(2000)
|
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
|
# Try to close common popups and cookie banners
|
||||||
logger.info("Attempting to close popups and cookie banners...")
|
logger.info("Attempting to close popups and cookie banners...")
|
||||||
|
|||||||
Reference in new issue
Block a user