Files
Priceflow/app/services/bonial_scraper.py
T

403 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 using full-page scroll."""
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)
# Scroll the page multiple times to trigger lazy loading
logger.info("Scrolling page to load all dynamic content...")
for i in range(5): # Scroll 5 times
await page.evaluate("window.scrollTo(0, document.body.scrollHeight)")
await asyncio.sleep(2) # Wait for content to load
# Scroll back to top
await page.evaluate("window.scrollTo(0, 0)")
await asyncio.sleep(1)
# Extract all catalog data using JavaScript to find patterns
catalogs_data = await page.evaluate("""
() => {
const results = [];
// Find all h2 elements which typically contain catalog titles
const h2Elements = document.querySelectorAll('h2');
h2Elements.forEach((h2, index) => {
const title = h2.textContent.trim();
// Skip generic titles
const genericTitles = ['restez informé', 'catalogues bazar', 'une enseigne', 'optimisez'];
if (genericTitles.some(generic => title.toLowerCase().includes(generic))) {
return;
}
// Find parent container
let container = h2.parentElement;
for (let i = 0; i < 10 && container; i++) {
// Look for image in this container
const img = container.querySelector('img');
// Look for link in this container
const link = container.querySelector('a[href*="/contentViewer/"]');
if (img && link) {
// Found a complete catalog card!
const imgSrc = img.src || img.getAttribute('data-src') || img.getAttribute('srcset')?.split(' ')[0];
const href = link.href;
// Try to extract dates from container text
const containerText = container.textContent;
const dateMatch = containerText.match(/(\d{1,2}[\/\-]\d{1,2}(?:[\/\-]\d{2,4})?)/g);
results.push({
title: title,
image: imgSrc,
url: href,
dates: dateMatch || [],
containerText: containerText.substring(0, 500) // First 500 chars for debugging
});
break; // Found it, stop going up
}
container = container.parentElement;
}
});
return results;
}
""")
logger.info(f"Found {len(catalogs_data)} potential catalog cards for {enseigne.nom}")
catalogues = []
for idx, cat_data in enumerate(catalogs_data):
try:
titre = cat_data.get('title', 'Catalogue')
image_url = cat_data.get('image')
catalogue_url = cat_data.get('url')
dates_list = cat_data.get('dates', [])
container_text = cat_data.get('containerText', '')
if not catalogue_url or not image_url:
logger.warning(f"Skipping catalog {idx}: missing URL or image")
continue
# Ensure absolute URLs
if image_url and image_url.startswith('//'):
image_url = 'https:' + image_url
# Parse dates
date_debut, date_fin = datetime.now(), datetime.now()
try:
if dates_list:
dates_str = ' - '.join(dates_list)
date_debut, date_fin = parse_bonial_dates(dates_str)
except ValueError:
# Default to 1 week validity
from datetime import timedelta
date_fin = datetime.now() + timedelta(days=7)
catalogues.append({
"titre": titre.strip(),
"date_debut": date_debut,
"date_fin": date_fin,
"image_couverture_url": image_url,
"catalogue_url": catalogue_url,
})
logger.info(f"Extracted catalog: {titre}")
except Exception as e:
logger.error(f"Error processing 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