mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-24 15:48:11 +02:00
feat: add Tiendeo scraper service with Crawl4AI and update project dependencies and Dockerfile.
This commit is contained in:
1 parent
34ac9b343a
commit
79612690b3
4 files changed
+391
-132
No files matched your search
+30
@@ -19,6 +19,33 @@ WORKDIR /app
|
|||||||
# Enable unbuffered logging
|
# Enable unbuffered logging
|
||||||
ENV PYTHONUNBUFFERED=1
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
# Install system dependencies for crawl4ai and playwright
|
||||||
|
RUN apt-get update && apt-get install -y \
|
||||||
|
wget \
|
||||||
|
gnupg \
|
||||||
|
# Playwright dependencies for Chromium
|
||||||
|
libnss3 \
|
||||||
|
libnspr4 \
|
||||||
|
libatk1.0-0 \
|
||||||
|
libatk-bridge2.0-0 \
|
||||||
|
libcups2 \
|
||||||
|
libdrm2 \
|
||||||
|
libdbus-1-3 \
|
||||||
|
libxkbcommon0 \
|
||||||
|
libxcomposite1 \
|
||||||
|
libxdamage1 \
|
||||||
|
libxfixes3 \
|
||||||
|
libxrandr2 \
|
||||||
|
libgbm1 \
|
||||||
|
libasound2 \
|
||||||
|
libpango-1.0-0 \
|
||||||
|
libcairo2 \
|
||||||
|
libatspi2.0-0 \
|
||||||
|
fonts-liberation \
|
||||||
|
libappindicator3-1 \
|
||||||
|
xdg-utils \
|
||||||
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
# Install uv
|
# Install uv
|
||||||
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
|
||||||
|
|
||||||
@@ -29,6 +56,9 @@ COPY README.md .
|
|||||||
# Use uv for faster installation (fixed syntax)
|
# Use uv for faster installation (fixed syntax)
|
||||||
RUN uv pip install --system --no-cache .
|
RUN uv pip install --system --no-cache .
|
||||||
|
|
||||||
|
# Install Playwright browsers for crawl4ai
|
||||||
|
RUN playwright install chromium --with-deps
|
||||||
|
|
||||||
# Copy backend code
|
# Copy backend code
|
||||||
COPY app/ ./app
|
COPY app/ ./app
|
||||||
COPY alembic.ini ./
|
COPY alembic.ini ./
|
||||||
|
|||||||
+196
-132
@@ -1,19 +1,19 @@
|
|||||||
"""
|
"""
|
||||||
Tiendeo.fr Scraper Service
|
Tiendeo.fr Scraper Service - Powered by Crawl4AI
|
||||||
|
|
||||||
Scrapes promotional catalogs from Tiendeo.fr for configured enseignes.
|
Scrapes promotional catalogs from Tiendeo.fr for configured enseignes.
|
||||||
Much simpler structure than Bonial - catalog cards are clearly defined with direct links.
|
Uses Crawl4AI for intelligent content extraction instead of fragile CSS selectors.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
import hashlib
|
import hashlib
|
||||||
import logging
|
import logging
|
||||||
import re
|
import re
|
||||||
import os
|
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
from playwright.async_api import async_playwright, Page, Browser
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from app.models import Enseigne, Catalogue, CataloguePage, ScrapingLog
|
from app.models import Enseigne, Catalogue, CataloguePage, ScrapingLog
|
||||||
@@ -27,6 +27,30 @@ TIENDEO_BASE_URL = "https://www.tiendeo.fr"
|
|||||||
DATE_PATTERN = r"(\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)"
|
DATE_PATTERN = r"(\d{1,2}[/-]\d{1,2}(?:[/-]\d{2,4})?)"
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# PYDANTIC SCHEMAS FOR EXTRACTION
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
class CatalogueCard(BaseModel):
|
||||||
|
"""Schema for catalog card extraction"""
|
||||||
|
title: str = Field(description="Catalogue title")
|
||||||
|
url: str = Field(description="Full URL to the catalogue viewer")
|
||||||
|
image_url: str | None = Field(default=None, description="Cover image URL")
|
||||||
|
date_text: str | None = Field(default=None, description="Date validity text")
|
||||||
|
|
||||||
|
|
||||||
|
class CataloguePageSchema(BaseModel):
|
||||||
|
"""Schema for catalog page image extraction"""
|
||||||
|
page_number: int = Field(description="Page number in sequence")
|
||||||
|
image_url: str = Field(description="Full resolution image URL")
|
||||||
|
width: int | None = Field(default=None, description="Image width in pixels")
|
||||||
|
height: int | None = Field(default=None, description="Image height in pixels")
|
||||||
|
|
||||||
|
|
||||||
|
# ============================================================================
|
||||||
|
# DATE PARSING
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
def parse_tiendeo_dates(date_str: str) -> tuple[datetime, datetime]:
|
def parse_tiendeo_dates(date_str: str) -> tuple[datetime, datetime]:
|
||||||
"""
|
"""
|
||||||
Parse Tiendeo date format to datetime objects.
|
Parse Tiendeo date format to datetime objects.
|
||||||
@@ -86,18 +110,34 @@ def compute_catalog_hash(enseigne_id: int, titre: str, date_debut: datetime) ->
|
|||||||
return hashlib.sha256(content.encode()).hexdigest()
|
return hashlib.sha256(content.encode()).hexdigest()
|
||||||
|
|
||||||
|
|
||||||
async def scrape_catalog_list(page: Page, enseigne: Enseigne) -> list[dict[str, Any]]:
|
# ============================================================================
|
||||||
"""Scrape the list of catalogs for an enseigne from Tiendeo."""
|
# SCRAPING FUNCTIONS
|
||||||
|
# ============================================================================
|
||||||
|
|
||||||
|
async def scrape_catalog_list(crawler: AsyncWebCrawler, enseigne: Enseigne) -> list[dict[str, Any]]:
|
||||||
|
"""Scrape the list of catalogs for an enseigne from Tiendeo using Crawl4AI."""
|
||||||
# Tiendeo uses store URLs like: /Magasins/{city}/{enseigne-slug}
|
# Tiendeo uses store URLs like: /Magasins/{city}/{enseigne-slug}
|
||||||
# We'll use Nancy as default city for now
|
|
||||||
url = f"{TIENDEO_BASE_URL}/Magasins/nancy/{enseigne.slug_bonial.lower()}"
|
url = f"{TIENDEO_BASE_URL}/Magasins/nancy/{enseigne.slug_bonial.lower()}"
|
||||||
logger.info(f"Scraping catalog list for {enseigne.nom} from {url}")
|
logger.info(f"Scraping catalog list for {enseigne.nom} from {url}")
|
||||||
|
|
||||||
await page.goto(url, wait_until="networkidle")
|
# Configure crawler for this page
|
||||||
await asyncio.sleep(2) # Wait for dynamic content
|
config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for_images=True,
|
||||||
|
process_iframes=False,
|
||||||
|
remove_overlay_elements=True,
|
||||||
|
wait_until="networkidle",
|
||||||
|
)
|
||||||
|
|
||||||
# Extract catalog data using JavaScript
|
result = await crawler.arun(url=url, config=config)
|
||||||
catalogs_data = await page.evaluate("""
|
|
||||||
|
if not result.success:
|
||||||
|
logger.error(f"Failed to crawl {url}: {result.error_message}")
|
||||||
|
return []
|
||||||
|
|
||||||
|
# Extract catalog cards using JavaScript
|
||||||
|
catalog_data = await crawler.crawler_strategy.execute_js(
|
||||||
|
"""
|
||||||
() => {
|
() => {
|
||||||
const results = [];
|
const results = [];
|
||||||
|
|
||||||
@@ -141,20 +181,20 @@ async def scrape_catalog_list(page: Page, enseigne: Enseigne) -> list[dict[str,
|
|||||||
url: href,
|
url: href,
|
||||||
image: imgSrc,
|
image: imgSrc,
|
||||||
containerText: containerText.substring(0, 300), // For date parsing
|
containerText: containerText.substring(0, 300), // For date parsing
|
||||||
catalogId: catalogId
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
}
|
}
|
||||||
""")
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
logger.info(f"Found {len(catalogs_data)} catalog links for {enseigne.nom}")
|
logger.info(f"Found {len(catalog_data)} catalog links for {enseigne.nom}")
|
||||||
|
|
||||||
catalogues = []
|
catalogues = []
|
||||||
|
|
||||||
for idx, cat_data in enumerate(catalogs_data):
|
for idx, cat_data in enumerate(catalog_data):
|
||||||
try:
|
try:
|
||||||
titre = cat_data.get('title', 'Catalogue')
|
titre = cat_data.get('title', 'Catalogue')
|
||||||
image_url = cat_data.get('image')
|
image_url = cat_data.get('image')
|
||||||
@@ -165,7 +205,7 @@ async def scrape_catalog_list(page: Page, enseigne: Enseigne) -> list[dict[str,
|
|||||||
logger.warning(f"Skipping catalog {idx}: missing URL")
|
logger.warning(f"Skipping catalog {idx}: missing URL")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Clean title - remove extra info like distances, "Gifi", etc.
|
# Clean title - remove extra info
|
||||||
titre = titre.replace('Gifi', '').replace('Action', '').replace('Centrakor', '')
|
titre = titre.replace('Gifi', '').replace('Action', '').replace('Centrakor', '')
|
||||||
titre = re.sub(r'\d+\.\d+\s*km', '', titre) # Remove "3.2 km"
|
titre = re.sub(r'\d+\.\d+\s*km', '', titre) # Remove "3.2 km"
|
||||||
titre = re.sub(r'Nancy', '', titre, flags=re.IGNORECASE)
|
titre = re.sub(r'Nancy', '', titre, flags=re.IGNORECASE)
|
||||||
@@ -206,53 +246,92 @@ async def scrape_catalog_list(page: Page, enseigne: Enseigne) -> list[dict[str,
|
|||||||
return catalogues
|
return catalogues
|
||||||
|
|
||||||
|
|
||||||
async def scrape_catalog_pages(page: Page, catalogue_url: str) -> list[dict[str, Any]]:
|
async def scrape_catalog_pages(crawler: AsyncWebCrawler, catalogue_url: str) -> list[dict[str, Any]]:
|
||||||
"""Scrape all pages of a catalog from the Tiendeo viewer."""
|
"""Scrape all pages of a catalog from the Tiendeo viewer using Crawl4AI."""
|
||||||
logger.info(f"Scraping catalog pages from {catalogue_url}")
|
logger.info(f"Scraping catalog pages from {catalogue_url}")
|
||||||
|
|
||||||
await page.goto(catalogue_url, wait_until="networkidle")
|
# Configure crawler to wait for images
|
||||||
await asyncio.sleep(3) # Wait for viewer to load
|
config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for_images=True,
|
||||||
|
process_iframes=True,
|
||||||
|
remove_overlay_elements=True,
|
||||||
|
wait_until="networkidle",
|
||||||
|
delay_before_return_html=3.0, # Wait for viewer to fully load
|
||||||
|
)
|
||||||
|
|
||||||
# Find all catalog page images
|
result = await crawler.arun(url=catalogue_url, config=config)
|
||||||
images = await page.query_selector_all("img[src*='tiendeo'], img[src*='cdn']")
|
|
||||||
|
|
||||||
if not images:
|
if not result.success:
|
||||||
logger.warning(f"No images found in catalog viewer: {catalogue_url}")
|
logger.error(f"Failed to crawl {catalogue_url}: {result.error_message}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
seen_urls = set()
|
# Extract catalog page images using JavaScript with intelligent filtering
|
||||||
pages = []
|
pages_data = await crawler.crawler_strategy.execute_js(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const results = [];
|
||||||
|
const seenUrls = new Set();
|
||||||
|
|
||||||
for idx, img in enumerate(images):
|
// Find all images - prioritize those in viewer containers
|
||||||
try:
|
const allImages = document.querySelectorAll('img');
|
||||||
src = await img.get_attribute("src")
|
|
||||||
|
|
||||||
if src and src not in seen_urls:
|
allImages.forEach((img) => {
|
||||||
width = await img.evaluate("el => el.naturalWidth")
|
// Get image source (try multiple attributes)
|
||||||
height = await img.evaluate("el => el.naturalHeight")
|
let src = img.src || img.getAttribute('data-src');
|
||||||
|
|
||||||
# Skip small images (thumbnails)
|
// Try srcset as fallback
|
||||||
if width < 200 or height < 200:
|
if (!src && img.srcset) {
|
||||||
continue
|
const srcsetParts = img.srcset.split(',')[0].trim().split(' ');
|
||||||
|
src = srcsetParts[0];
|
||||||
|
}
|
||||||
|
|
||||||
seen_urls.add(src)
|
if (!src || seenUrls.has(src)) return;
|
||||||
pages.append({
|
|
||||||
"numero_page": len(pages) + 1,
|
|
||||||
"image_url": src,
|
|
||||||
"largeur": width,
|
|
||||||
"hauteur": height,
|
|
||||||
})
|
|
||||||
|
|
||||||
except Exception as e:
|
// Skip obvious non-catalog images
|
||||||
logger.debug(f"Error processing image {idx}: {e}")
|
if (src.includes('logo') || src.includes('icon') || src.includes('avatar')) {
|
||||||
continue
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
logger.info(f"Found {len(pages)} pages in catalog")
|
// Get dimensions
|
||||||
return pages
|
const width = img.naturalWidth || img.width;
|
||||||
|
const height = img.naturalHeight || img.height;
|
||||||
|
|
||||||
|
// Filter by dimensions - catalog pages are typically portrait and high-res
|
||||||
|
// Minimum 400px width, aspect ratio close to 3:4 or similar
|
||||||
|
if (width < 400 || height < 400) return;
|
||||||
|
|
||||||
|
const aspectRatio = width / height;
|
||||||
|
|
||||||
|
// Portrait images (0.5 to 0.9 ratio) - catalog pages are usually portrait
|
||||||
|
if (aspectRatio > 0.5 && aspectRatio < 0.9) {
|
||||||
|
seenUrls.add(src);
|
||||||
|
results.push({
|
||||||
|
image_url: src,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Sort by area (largest first) to prioritize full resolution images
|
||||||
|
results.sort((a, b) => (b.width * b.height) - (a.width * a.height));
|
||||||
|
|
||||||
|
// Assign page numbers
|
||||||
|
return results.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
numero_page: index + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"Found {len(pages_data)} pages in catalog")
|
||||||
|
return pages_data
|
||||||
|
|
||||||
|
|
||||||
async def scrape_enseigne(enseigne: Enseigne, db: Session, browser: Browser | None = None) -> ScrapingLog:
|
async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
|
||||||
"""Scrape all catalogs for a specific enseigne from Tiendeo."""
|
"""Scrape all catalogs for a specific enseigne from Tiendeo using Crawl4AI."""
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
log = ScrapingLog(
|
log = ScrapingLog(
|
||||||
enseigne_id=enseigne.id,
|
enseigne_id=enseigne.id,
|
||||||
@@ -262,80 +341,77 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session, browser: Browser | No
|
|||||||
catalogues_mis_a_jour=0,
|
catalogues_mis_a_jour=0,
|
||||||
)
|
)
|
||||||
|
|
||||||
own_browser = browser is None
|
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if own_browser:
|
# Configure browser for Crawl4AI
|
||||||
browserless_url = os.environ.get("BROWSERLESS_URL", "ws://browserless:3000")
|
browser_config = BrowserConfig(
|
||||||
async with async_playwright() as p:
|
headless=True,
|
||||||
browser = await p.chromium.connect_over_cdp(browserless_url)
|
verbose=False,
|
||||||
|
extra_args=["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
)
|
||||||
|
|
||||||
page = await browser.new_page()
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||||
|
catalogues_data = await scrape_catalog_list(crawler, enseigne)
|
||||||
|
log.catalogues_trouves = len(catalogues_data)
|
||||||
|
|
||||||
await page.set_extra_http_headers({
|
if not catalogues_data:
|
||||||
"User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36"
|
log.statut = "success"
|
||||||
})
|
log.message_erreur = "No catalogs found (may be normal)"
|
||||||
|
return log
|
||||||
|
|
||||||
catalogues_data = await scrape_catalog_list(page, enseigne)
|
for cat_data in catalogues_data:
|
||||||
log.catalogues_trouves = len(catalogues_data)
|
try:
|
||||||
|
content_hash = compute_catalog_hash(
|
||||||
|
enseigne.id,
|
||||||
|
cat_data["titre"],
|
||||||
|
cat_data["date_debut"]
|
||||||
|
)
|
||||||
|
|
||||||
if not catalogues_data:
|
existing = db.query(Catalogue).filter_by(content_hash=content_hash).first()
|
||||||
log.statut = "success"
|
|
||||||
log.message_erreur = "No catalogs found (may be normal)"
|
|
||||||
await page.close()
|
|
||||||
return log
|
|
||||||
|
|
||||||
for cat_data in catalogues_data:
|
if existing:
|
||||||
try:
|
logger.debug(f"Catalog already exists: {cat_data['titre']}")
|
||||||
content_hash = compute_catalog_hash(
|
continue
|
||||||
enseigne.id,
|
|
||||||
cat_data["titre"],
|
|
||||||
cat_data["date_debut"]
|
|
||||||
)
|
|
||||||
|
|
||||||
existing = db.query(Catalogue).filter_by(content_hash=content_hash).first()
|
await asyncio.sleep(2) # Respectful delay
|
||||||
|
pages_data = await scrape_catalog_pages(crawler, cat_data["catalogue_url"])
|
||||||
|
|
||||||
if existing:
|
if not pages_data:
|
||||||
logger.debug(f"Catalog already exists: {cat_data['titre']}")
|
logger.warning(f"No pages found for catalog: {cat_data['titre']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
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.get("width"),
|
||||||
|
hauteur=page_data.get("height"),
|
||||||
|
)
|
||||||
|
db.add(catalogue_page)
|
||||||
|
|
||||||
|
log.catalogues_nouveaux += 1
|
||||||
|
logger.info(f"Added new catalog: {cat_data['titre']} with {len(pages_data)} pages")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error processing catalog: {e}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
await asyncio.sleep(2) # Respectful delay
|
db.commit()
|
||||||
pages_data = await scrape_catalog_pages(page, cat_data["catalogue_url"])
|
log.statut = "success"
|
||||||
|
|
||||||
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:
|
except Exception as e:
|
||||||
logger.error(f"Error scraping {enseigne.nom}: {e}")
|
logger.error(f"Error scraping {enseigne.nom}: {e}")
|
||||||
@@ -343,10 +419,6 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session, browser: Browser | No
|
|||||||
log.message_erreur = str(e)
|
log.message_erreur = str(e)
|
||||||
db.rollback()
|
db.rollback()
|
||||||
|
|
||||||
finally:
|
|
||||||
if own_browser and browser:
|
|
||||||
await browser.close()
|
|
||||||
|
|
||||||
log.duree_secondes = (datetime.now() - start_time).total_seconds()
|
log.duree_secondes = (datetime.now() - start_time).total_seconds()
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -361,21 +433,13 @@ async def scrape_all_enseignes(db: Session) -> list[ScrapingLog]:
|
|||||||
|
|
||||||
logs = []
|
logs = []
|
||||||
|
|
||||||
browserless_url = os.environ.get("BROWSERLESS_URL", "ws://browserless:3000")
|
for enseigne in enseignes:
|
||||||
logger.info(f"Connecting to Browserless at {browserless_url}")
|
try:
|
||||||
|
await asyncio.sleep(2)
|
||||||
async with async_playwright() as p:
|
log = await scrape_enseigne(enseigne, db)
|
||||||
browser = await p.chromium.connect_over_cdp(browserless_url)
|
logs.append(log)
|
||||||
|
except Exception as e:
|
||||||
for enseigne in enseignes:
|
logger.error(f"Failed to scrape {enseigne.nom}: {e}")
|
||||||
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"Tiendeo scraping complete: {len(logs)} enseignes processed")
|
logger.info(f"Tiendeo scraping complete: {len(logs)} enseignes processed")
|
||||||
return logs
|
return logs
|
||||||
@@ -26,6 +26,7 @@ dependencies = [
|
|||||||
"beautifulsoup4",
|
"beautifulsoup4",
|
||||||
"lxml",
|
"lxml",
|
||||||
"pyjwt",
|
"pyjwt",
|
||||||
|
"crawl4ai",
|
||||||
]
|
]
|
||||||
|
|
||||||
[build-system]
|
[build-system]
|
||||||
|
|||||||
@@ -0,0 +1,164 @@
|
|||||||
|
"""
|
||||||
|
Script de vérification pour tester Crawl4AI dans le scraper Tiendeo.
|
||||||
|
|
||||||
|
Usage:
|
||||||
|
python verify_crawl4ai_scraper.py
|
||||||
|
|
||||||
|
Ce script teste:
|
||||||
|
1. Import de Crawl4AI
|
||||||
|
2. Extraction d'une page catalogue Tiendeo
|
||||||
|
3. Comptage des pages trouvées
|
||||||
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
from crawl4ai import AsyncWebCrawler, BrowserConfig, CrawlerRunConfig, CacheMode
|
||||||
|
|
||||||
|
# Configure logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_crawl4ai_import():
|
||||||
|
"""Test 1: Vérifier que Crawl4AI est bien installé"""
|
||||||
|
try:
|
||||||
|
logger.info("✓ Crawl4AI importé avec succès")
|
||||||
|
logger.info(f" Version: {AsyncWebCrawler.__module__}")
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"✗ Erreur import Crawl4AI: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_catalog_page_extraction():
|
||||||
|
"""Test 2: Extraire les pages d'un catalogue Tiendeo"""
|
||||||
|
# URL de test - catalogue Gifi Nancy (à adapter si nécessaire)
|
||||||
|
test_url = "https://www.tiendeo.fr/Catalogues/nancy/gifi"
|
||||||
|
|
||||||
|
logger.info(f"\nTest extraction depuis: {test_url}")
|
||||||
|
|
||||||
|
try:
|
||||||
|
browser_config = BrowserConfig(
|
||||||
|
headless=True,
|
||||||
|
verbose=False,
|
||||||
|
extra_args=["--disable-gpu", "--no-sandbox", "--disable-dev-shm-usage"],
|
||||||
|
)
|
||||||
|
|
||||||
|
config = CrawlerRunConfig(
|
||||||
|
cache_mode=CacheMode.BYPASS,
|
||||||
|
wait_for_images=True,
|
||||||
|
process_iframes=True,
|
||||||
|
remove_overlay_elements=True,
|
||||||
|
wait_until="networkidle",
|
||||||
|
delay_before_return_html=3.0,
|
||||||
|
)
|
||||||
|
|
||||||
|
async with AsyncWebCrawler(config=browser_config) as crawler:
|
||||||
|
result = await crawler.arun(url=test_url, config=config)
|
||||||
|
|
||||||
|
if not result.success:
|
||||||
|
logger.error(f"✗ Échec du crawling: {result.error_message}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
logger.info(f"✓ Page chargée avec succès")
|
||||||
|
logger.info(f" HTML length: {len(result.html)} chars")
|
||||||
|
|
||||||
|
# Tester l'extraction JavaScript
|
||||||
|
pages_data = await crawler.crawler_strategy.execute_js(
|
||||||
|
"""
|
||||||
|
() => {
|
||||||
|
const results = [];
|
||||||
|
const seenUrls = new Set();
|
||||||
|
|
||||||
|
const allImages = document.querySelectorAll('img');
|
||||||
|
|
||||||
|
allImages.forEach((img) => {
|
||||||
|
let src = img.src || img.getAttribute('data-src');
|
||||||
|
|
||||||
|
if (!src && img.srcset) {
|
||||||
|
const srcsetParts = img.srcset.split(',')[0].trim().split(' ');
|
||||||
|
src = srcsetParts[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!src || seenUrls.has(src)) return;
|
||||||
|
|
||||||
|
if (src.includes('logo') || src.includes('icon') || src.includes('avatar')) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const width = img.naturalWidth || img.width;
|
||||||
|
const height = img.naturalHeight || img.height;
|
||||||
|
|
||||||
|
if (width < 400 || height < 400) return;
|
||||||
|
|
||||||
|
const aspectRatio = width / height;
|
||||||
|
|
||||||
|
if (aspectRatio > 0.5 && aspectRatio < 0.9) {
|
||||||
|
seenUrls.add(src);
|
||||||
|
results.push({
|
||||||
|
image_url: src,
|
||||||
|
width: width,
|
||||||
|
height: height,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
results.sort((a, b) => (b.width * b.height) - (a.width * a.height));
|
||||||
|
|
||||||
|
return results.map((item, index) => ({
|
||||||
|
...item,
|
||||||
|
numero_page: index + 1,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
|
||||||
|
logger.info(f"✓ Extraction JavaScript réussie")
|
||||||
|
logger.info(f" Pages trouvées: {len(pages_data)}")
|
||||||
|
|
||||||
|
if len(pages_data) > 0:
|
||||||
|
logger.info(f"\n Exemples de pages:")
|
||||||
|
for page in pages_data[:3]:
|
||||||
|
logger.info(f" - Page {page['numero_page']}: {page['width']}x{page['height']}px")
|
||||||
|
logger.info(f" URL: {page['image_url'][:80]}...")
|
||||||
|
return True
|
||||||
|
else:
|
||||||
|
logger.warning("⚠ Aucune page trouvée (structure du site a peut-être changé)")
|
||||||
|
return False
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"✗ Erreur lors de l'extraction: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
async def main():
|
||||||
|
"""Exécuter tous les tests"""
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info("VÉRIFICATION CRAWL4AI - SCRAPER TIENDEO")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
|
||||||
|
# Test 1: Import
|
||||||
|
test1 = await test_crawl4ai_import()
|
||||||
|
|
||||||
|
# Test 2: Extraction
|
||||||
|
test2 = await test_catalog_page_extraction()
|
||||||
|
|
||||||
|
# Résumé
|
||||||
|
logger.info("\n" + "=" * 70)
|
||||||
|
logger.info("RÉSUMÉ")
|
||||||
|
logger.info("=" * 70)
|
||||||
|
logger.info(f"Import Crawl4AI: {'✓ OK' if test1 else '✗ ÉCHEC'}")
|
||||||
|
logger.info(f"Extraction pages: {'✓ OK' if test2 else '✗ ÉCHEC'}")
|
||||||
|
|
||||||
|
if test1 and test2:
|
||||||
|
logger.info("\n✓ TOUS LES TESTS SONT PASSÉS!")
|
||||||
|
logger.info("Le scraper Crawl4AI est prêt à être utilisé.")
|
||||||
|
else:
|
||||||
|
logger.info("\n✗ CERTAINS TESTS ONT ÉCHOUÉ")
|
||||||
|
logger.info("Vérifiez les erreurs ci-dessus.")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(main())
|
||||||
Reference in new issue
Block a user