Files
Priceflow/app/services/bonial_scraper.py
T

410 lines
14 KiB
Python

"""
Bonial.fr Scraper Service
Scrapes promotional catalogs from Bonial.fr for configured enseignes.
Uses Playwright for browser automation.
"""
import asyncio
import hashlib
import logging
import re
import os
from datetime import datetime
from typing import Any
from playwright.async_api import async_playwright, Page, Browser
from sqlalchemy.orm import Session
from app.models import Enseigne, Catalogue, CataloguePage, ScrapingLog
logger = logging.getLogger(__name__)
# Bonial CSS selectors (based on browser inspection - updated for new structure)
BONIAL_SELECTORS = {
"catalog_link": "a:has-text('Ouvrir le catalogue')", # Playwright syntax for "has text"
"catalog_title": "h2", # Will be searched within the parent container
"catalog_image": "img", # Will be searched within the parent container
"cookie_accept": "button:has-text('Accepter')",
}
DATE_PATTERN = r"(\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)"
def parse_bonial_dates(date_str: str) -> tuple[datetime, datetime]:
"""
Parse Bonial date format to datetime objects.
Args:
date_str: Date string like "mar. 25/11 - lun. 08/12/2025"
Returns:
Tuple of (date_debut, date_fin)
Raises:
ValueError: If date format is invalid
"""
matches = re.findall(DATE_PATTERN, date_str)
if not matches:
logger.warning(f"No dates found in '{date_str}', using default duration")
raise ValueError(f"Invalid date format: {date_str}")
today = datetime.now()
current_year = today.year
parsed_dates = []
for date_match in matches:
date_match = date_match.replace("-", "/")
parts = date_match.split("/")
day = int(parts[0])
month = int(parts[1])
year = int(parts[2]) if len(parts) > 2 else current_year
if year < 100:
year += 2000
if len(parts) == 2:
if month < today.month - 3:
year += 1
try:
parsed_dates.append(datetime(year, month, day))
except ValueError:
continue
if not parsed_dates:
raise ValueError("No valid dates parsed")
if len(parsed_dates) >= 2:
return min(parsed_dates), max(parsed_dates)
elif len(parsed_dates) == 1:
date = parsed_dates[0]
if date > today:
return today, date
else:
return date, date
return parsed_dates[0], parsed_dates[0]
def compute_catalog_hash(enseigne_id: int, titre: str, date_debut: datetime) -> str:
"""Compute SHA256 hash for duplicate detection."""
content = f"{enseigne_id}|{titre}|{date_debut.isoformat()}"
return hashlib.sha256(content.encode()).hexdigest()
async def accept_cookies(page: Page) -> None:
"""Accept cookie consent banner if present."""
try:
await page.click(BONIAL_SELECTORS["cookie_accept"], timeout=3000)
logger.debug("Accepted cookies")
await asyncio.sleep(1)
except Exception:
logger.debug("No cookie banner found or already accepted")
async def scrape_catalog_list(page: Page, enseigne: Enseigne) -> list[dict[str, Any]]:
"""Scrape the list of catalogs for an enseigne from Bonial."""
url = f"https://www.bonial.fr/Enseignes/{enseigne.slug_bonial}"
logger.info(f"Scraping catalog list for {enseigne.nom} from {url}")
await page.goto(url, wait_until="networkidle")
await accept_cookies(page)
# Wait a bit for dynamic content
await asyncio.sleep(3)
# Find all "Ouvrir le catalogue" buttons/links
try:
catalog_links = await page.locator(BONIAL_SELECTORS["catalog_link"]).all()
logger.info(f"Found {len(catalog_links)} catalog links for {enseigne.nom}")
except Exception as e:
logger.error(f"No catalog links found for {enseigne.nom}: {e}")
return []
if not catalog_links:
logger.warning(f"No catalogs found for {enseigne.nom}")
return []
catalogues = []
for idx, link in enumerate(catalog_links):
try:
# Get the catalog URL
catalogue_url = await link.get_attribute("href")
if not catalogue_url:
logger.warning(f"Link {idx} has no href")
continue
if not catalogue_url.startswith("http"):
catalogue_url = f"https://www.bonial.fr{catalogue_url}"
# Find the parent container that has the title and image
# Go up to find a section/div that contains both the link and the title/image
parent = link
for _ in range(5): # Try going up max 5 levels
parent = await parent.evaluateHandle("el => el.parentElement")
parent = parent.as_element()
if not parent:
break
# Try to find title in this parent
try:
title_el = await parent.query_selector("h2, h3, [class*='title']")
if title_el:
break
except:
continue
if not parent:
logger.warning(f"Could not find parent container for link {idx}")
continue
# Extract title from parent
titre = "Catalogue"
try:
title_el = await parent.query_selector("h2, h3, [class*='title']")
if title_el:
titre = await title_el.inner_text()
except:
pass
# Extract dates from parent text
dates_text = await parent.inner_text() if parent else ""
# Extract image from parent
image_url = None
try:
image_el = await parent.query_selector("img")
if image_el:
image_url = await image_el.get_attribute("src")
if image_url and image_url.startswith("//"):
image_url = "https:" + image_url
except:
pass
# Filter generic titles
if titre:
lower_title = titre.lower()
ignored_titles = [
"restez informé", "catalogues bazar", "toutes les offres",
"voir les offres", "téléchargez l'application", "newsletter"
]
if any(ignored in lower_title for ignored in ignored_titles):
logger.info(f"Skipping generic/banner card: {titre}")
continue
has_url = bool(catalogue_url)
has_image = bool(image_url)
date_debut, date_fin = datetime.now(), datetime.now()
try:
date_debut, date_fin = parse_bonial_dates(dates_text)
except ValueError:
if "semaine" in dates_text.lower() or "valable" in dates_text.lower():
from datetime import timedelta
date_fin = datetime.now() + timedelta(days=7)
if has_url and has_image:
catalogues.append({
"titre": titre.strip(),
"date_debut": date_debut,
"date_fin": date_fin,
"image_couverture_url": image_url,
"catalogue_url": catalogue_url,
})
logger.debug(f"Extracted catalog: {titre}")
else:
if not has_url:
logger.warning(f"Skipping {idx} (No URL): {titre}")
elif not has_image:
logger.warning(f"Skipping {idx} (No Image): {titre}")
except Exception as e:
logger.error(f"Error extracting catalog {idx} for {enseigne.nom}: {e}")
continue
logger.info(f"Successfully extracted {len(catalogues)} catalogs for {enseigne.nom}")
return catalogues
async def scrape_catalog_pages(page: Page, catalogue_url: str) -> list[dict[str, Any]]:
"""Scrape all pages of a catalog from the Bonial viewer."""
logger.info(f"Scraping catalog pages from {catalogue_url}")
await page.goto(catalogue_url, wait_until="networkidle")
await asyncio.sleep(2)
try:
await page.click("button:has-text('Continuer'), button:has-text('Fermer')", timeout=2000)
await asyncio.sleep(1)
except Exception:
pass
images = await page.query_selector_all("img[src*='content-media.bonial.biz']")
if not images:
logger.warning(f"No images found in catalog viewer: {catalogue_url}")
return []
seen_urls = set()
pages = []
for idx, img in enumerate(images):
try:
src = await img.get_attribute("src")
if src and src not in seen_urls:
width = await img.evaluate("el => el.naturalWidth")
height = await img.evaluate("el => el.naturalHeight")
if width < 200 or height < 200:
continue
seen_urls.add(src)
pages.append({
"numero_page": len(pages) + 1,
"image_url": src,
"largeur": width,
"hauteur": height,
})
except Exception as e:
logger.debug(f"Error processing image {idx}: {e}")
continue
logger.info(f"Found {len(pages)} pages in catalog")
return pages
async def scrape_enseigne(enseigne: Enseigne, db: Session, browser: Browser | None = None) -> ScrapingLog:
"""Scrape all catalogs for a specific enseigne."""
start_time = datetime.now()
log = ScrapingLog(
enseigne_id=enseigne.id,
statut="error",
catalogues_trouves=0,
catalogues_nouveaux=0,
catalogues_mis_a_jour=0,
)
own_browser = browser is None
try:
if own_browser:
browserless_url = os.environ.get("BROWSERLESS_URL", "ws://browserless:3000")
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(browserless_url)
page = await browser.new_page()
await page.set_extra_http_headers({
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
})
catalogues_data = await scrape_catalog_list(page, enseigne)
log.catalogues_trouves = len(catalogues_data)
if not catalogues_data:
log.statut = "success"
log.message_erreur = "No catalogs found (may be normal)"
await page.close()
return log
for cat_data in catalogues_data:
try:
content_hash = compute_catalog_hash(
enseigne.id,
cat_data["titre"],
cat_data["date_debut"]
)
existing = db.query(Catalogue).filter_by(content_hash=content_hash).first()
if existing:
logger.debug(f"Catalog already exists: {cat_data['titre']}")
continue
await asyncio.sleep(3)
pages_data = await scrape_catalog_pages(page, cat_data["catalogue_url"])
catalogue = Catalogue(
enseigne_id=enseigne.id,
titre=cat_data["titre"],
date_debut=cat_data["date_debut"],
date_fin=cat_data["date_fin"],
image_couverture_url=cat_data["image_couverture_url"],
catalogue_url=cat_data["catalogue_url"],
statut="actif",
nombre_pages=len(pages_data),
content_hash=content_hash,
)
db.add(catalogue)
db.flush()
for page_data in pages_data:
catalogue_page = CataloguePage(
catalogue_id=catalogue.id,
numero_page=page_data["numero_page"],
image_url=page_data["image_url"],
largeur=page_data["largeur"],
hauteur=page_data["hauteur"],
)
db.add(catalogue_page)
log.catalogues_nouveaux += 1
logger.info(f"Added new catalog: {cat_data['titre']}")
except Exception as e:
logger.error(f"Error processing catalog: {e}")
continue
db.commit()
log.statut = "success"
await page.close()
except Exception as e:
logger.error(f"Error scraping {enseigne.nom}: {e}")
log.statut = "error"
log.message_erreur = str(e)
db.rollback()
finally:
if own_browser and browser:
await browser.close()
log.duree_secondes = (datetime.now() - start_time).total_seconds()
db.add(log)
db.commit()
return log
async def scrape_all_enseignes(db: Session) -> list[ScrapingLog]:
"""Scrape all active enseignes. Uses Browserless service via WebSocket."""
enseignes = db.query(Enseigne).filter_by(is_active=True).all()
logger.info(f"Starting scraping for {len(enseignes)} active enseignes")
logs = []
browserless_url = os.environ.get("BROWSERLESS_URL", "ws://browserless:3000")
logger.info(f"Connecting to Browserless at {browserless_url}")
async with async_playwright() as p:
browser = await p.chromium.connect_over_cdp(browserless_url)
for enseigne in enseignes:
try:
await asyncio.sleep(2)
log = await scrape_enseigne(enseigne, db, browser)
logs.append(log)
except Exception as e:
logger.error(f"Failed to scrape {enseigne.nom}: {e}")
await browser.close()
logger.info(f"Scraping complete: {len(logs)} enseignes processed")
return logs