mirror of
https://github.com/R0m1k3/Priceflow.git
synced 2026-10-23 23:28:12 +02:00
feat: Add catalogue management API with enseigne, catalogue, and scraping endpoints, and integrate Cataloguemate scraper.
This commit is contained in:
1 parent
81b7cce947
commit
954672cfc0
6 files changed
+439
-208
No files matched your search
+78
-87
@@ -34,41 +34,33 @@ router = APIRouter(prefix="/api/catalogues", tags=["catalogues"])
|
|||||||
|
|
||||||
# === Enseignes Endpoints ===
|
# === Enseignes Endpoints ===
|
||||||
|
|
||||||
|
|
||||||
@router.get("/enseignes", response_model=list[EnseigneResponse])
|
@router.get("/enseignes", response_model=list[EnseigneResponse])
|
||||||
async def get_enseignes(
|
async def get_enseignes(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get all active enseignes with catalog counts"""
|
"""Get all active enseignes with catalog counts"""
|
||||||
enseignes = (
|
enseignes = db.query(Enseigne).filter(Enseigne.is_active == True).order_by(Enseigne.ordre_affichage).all()
|
||||||
db.query(Enseigne)
|
|
||||||
.filter(Enseigne.is_active == True)
|
|
||||||
.order_by(Enseigne.ordre_affichage)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
# Add catalog counts
|
# Add catalog counts
|
||||||
result = []
|
result = []
|
||||||
for enseigne in enseignes:
|
for enseigne in enseignes:
|
||||||
count = (
|
count = (
|
||||||
db.query(func.count(Catalogue.id))
|
db.query(func.count(Catalogue.id))
|
||||||
.filter(
|
.filter(and_(Catalogue.enseigne_id == enseigne.id, Catalogue.statut == "actif"))
|
||||||
and_(
|
|
||||||
Catalogue.enseigne_id == enseigne.id,
|
|
||||||
Catalogue.statut == "actif"
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.scalar()
|
.scalar()
|
||||||
)
|
)
|
||||||
|
|
||||||
enseigne_data = EnseigneResponse.model_validate(enseigne)
|
enseigne_data = EnseigneResponse.model_validate(enseigne)
|
||||||
enseigne_data.catalogues_actifs_count = count
|
enseigne_data.catalogues_actifs_count = count
|
||||||
result.append(enseigne_data)
|
result.append(enseigne_data)
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
|
|
||||||
# === Catalogues Endpoints ===
|
# === Catalogues Endpoints ===
|
||||||
|
|
||||||
|
|
||||||
@router.get("", response_model=CataloguesPaginatedResponse)
|
@router.get("", response_model=CataloguesPaginatedResponse)
|
||||||
async def get_catalogues(
|
async def get_catalogues(
|
||||||
enseigne_ids: str | None = Query(None, description="Comma-separated enseigne IDs"),
|
enseigne_ids: str | None = Query(None, description="Comma-separated enseigne IDs"),
|
||||||
@@ -83,10 +75,10 @@ async def get_catalogues(
|
|||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""Get catalogues with filters and pagination"""
|
"""Get catalogues with filters and pagination"""
|
||||||
|
|
||||||
# Build query
|
# Build query
|
||||||
query = db.query(Catalogue)
|
query = db.query(Catalogue)
|
||||||
|
|
||||||
# Filter by enseignes
|
# Filter by enseignes
|
||||||
if enseigne_ids:
|
if enseigne_ids:
|
||||||
try:
|
try:
|
||||||
@@ -94,64 +86,58 @@ async def get_catalogues(
|
|||||||
query = query.filter(Catalogue.enseigne_id.in_(ids))
|
query = query.filter(Catalogue.enseigne_id.in_(ids))
|
||||||
except ValueError:
|
except ValueError:
|
||||||
raise HTTPException(status_code=400, detail="Invalid enseigne_ids format")
|
raise HTTPException(status_code=400, detail="Invalid enseigne_ids format")
|
||||||
|
|
||||||
# Filter by status
|
# Filter by status
|
||||||
if statut and statut != "tous":
|
if statut and statut != "tous":
|
||||||
query = query.filter(Catalogue.statut == statut)
|
query = query.filter(Catalogue.statut == statut)
|
||||||
|
|
||||||
# Filter by dates
|
# Filter by dates
|
||||||
if date_debut_min:
|
if date_debut_min:
|
||||||
query = query.filter(Catalogue.date_debut >= date_debut_min)
|
query = query.filter(Catalogue.date_debut >= date_debut_min)
|
||||||
if date_fin_max:
|
if date_fin_max:
|
||||||
query = query.filter(Catalogue.date_fin <= date_fin_max)
|
query = query.filter(Catalogue.date_fin <= date_fin_max)
|
||||||
|
|
||||||
# Search in titles
|
# Search in titles
|
||||||
if recherche:
|
if recherche:
|
||||||
query = query.filter(Catalogue.titre.ilike(f"%{recherche}%"))
|
query = query.filter(Catalogue.titre.ilike(f"%{recherche}%"))
|
||||||
|
|
||||||
# Sort
|
# Sort
|
||||||
sort_column = {
|
sort_column = {
|
||||||
"date_debut": Catalogue.date_debut,
|
"date_debut": Catalogue.date_debut,
|
||||||
"date_fin": Catalogue.date_fin,
|
"date_fin": Catalogue.date_fin,
|
||||||
"enseigne": Catalogue.enseigne_id,
|
"enseigne": Catalogue.enseigne_id,
|
||||||
}.get(sort, Catalogue.date_debut)
|
}.get(sort, Catalogue.date_debut)
|
||||||
|
|
||||||
if order == "desc":
|
if order == "desc":
|
||||||
query = query.order_by(sort_column.desc())
|
query = query.order_by(sort_column.desc())
|
||||||
else:
|
else:
|
||||||
query = query.order_by(sort_column.asc())
|
query = query.order_by(sort_column.asc())
|
||||||
|
|
||||||
# Count total
|
# Count total
|
||||||
total = query.count()
|
total = query.count()
|
||||||
|
|
||||||
# Paginate
|
# Paginate
|
||||||
offset = (page - 1) * limit
|
offset = (page - 1) * limit
|
||||||
catalogues = query.offset(offset).limit(limit).all()
|
catalogues = query.offset(offset).limit(limit).all()
|
||||||
|
|
||||||
# Build response
|
# Build response
|
||||||
data = []
|
data = []
|
||||||
for catalogue in catalogues:
|
for catalogue in catalogues:
|
||||||
cat_response = CatalogueListResponse.model_validate(catalogue)
|
cat_response = CatalogueListResponse.model_validate(catalogue)
|
||||||
data.append(cat_response)
|
data.append(cat_response)
|
||||||
|
|
||||||
pagination = PaginationMeta(
|
pagination = PaginationMeta(
|
||||||
total=total,
|
total=total,
|
||||||
page=page,
|
page=page,
|
||||||
limit=limit,
|
limit=limit,
|
||||||
pages_total=(total + limit - 1) // limit,
|
pages_total=(total + limit - 1) // limit,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Get last scraping time
|
# Get last scraping time
|
||||||
last_scraping = (
|
last_scraping = db.query(ScrapingLog).order_by(ScrapingLog.date_execution.desc()).first()
|
||||||
db.query(ScrapingLog)
|
|
||||||
.order_by(ScrapingLog.date_execution.desc())
|
metadata = {"derniere_mise_a_jour": last_scraping.date_execution if last_scraping else None}
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
metadata = {
|
|
||||||
"derniere_mise_a_jour": last_scraping.date_execution if last_scraping else None
|
|
||||||
}
|
|
||||||
|
|
||||||
return CataloguesPaginatedResponse(
|
return CataloguesPaginatedResponse(
|
||||||
data=data,
|
data=data,
|
||||||
pagination=pagination,
|
pagination=pagination,
|
||||||
@@ -166,10 +152,10 @@ async def get_catalogue_detail(
|
|||||||
):
|
):
|
||||||
"""Get detailed information for a specific catalogue"""
|
"""Get detailed information for a specific catalogue"""
|
||||||
catalogue = db.query(Catalogue).filter(Catalogue.id == catalogue_id).first()
|
catalogue = db.query(Catalogue).filter(Catalogue.id == catalogue_id).first()
|
||||||
|
|
||||||
if not catalogue:
|
if not catalogue:
|
||||||
raise HTTPException(status_code=404, detail="Catalogue not found")
|
raise HTTPException(status_code=404, detail="Catalogue not found")
|
||||||
|
|
||||||
return CatalogueDetailResponse.model_validate(catalogue)
|
return CatalogueDetailResponse.model_validate(catalogue)
|
||||||
|
|
||||||
|
|
||||||
@@ -180,22 +166,23 @@ async def get_catalogue_pages(
|
|||||||
):
|
):
|
||||||
"""Get all pages for a specific catalogue"""
|
"""Get all pages for a specific catalogue"""
|
||||||
catalogue = db.query(Catalogue).filter(Catalogue.id == catalogue_id).first()
|
catalogue = db.query(Catalogue).filter(Catalogue.id == catalogue_id).first()
|
||||||
|
|
||||||
if not catalogue:
|
if not catalogue:
|
||||||
raise HTTPException(status_code=404, detail="Catalogue not found")
|
raise HTTPException(status_code=404, detail="Catalogue not found")
|
||||||
|
|
||||||
pages = (
|
pages = (
|
||||||
db.query(CataloguePage)
|
db.query(CataloguePage)
|
||||||
.filter(CataloguePage.catalogue_id == catalogue_id)
|
.filter(CataloguePage.catalogue_id == catalogue_id)
|
||||||
.order_by(CataloguePage.numero_page)
|
.order_by(CataloguePage.numero_page)
|
||||||
.all()
|
.all()
|
||||||
)
|
)
|
||||||
|
|
||||||
return [CataloguePageResponse.model_validate(page) for page in pages]
|
return [CataloguePageResponse.model_validate(page) for page in pages]
|
||||||
|
|
||||||
|
|
||||||
# === Admin Endpoints ===
|
# === Admin Endpoints ===
|
||||||
|
|
||||||
|
|
||||||
@router.get("/admin/stats", response_model=ScrapingStatsResponse)
|
@router.get("/admin/stats", response_model=ScrapingStatsResponse)
|
||||||
async def get_scraping_stats(
|
async def get_scraping_stats(
|
||||||
db: Session = Depends(get_db),
|
db: Session = Depends(get_db),
|
||||||
@@ -203,25 +190,17 @@ async def get_scraping_stats(
|
|||||||
):
|
):
|
||||||
"""Get scraping statistics"""
|
"""Get scraping statistics"""
|
||||||
total_catalogues = db.query(func.count(Catalogue.id)).scalar()
|
total_catalogues = db.query(func.count(Catalogue.id)).scalar()
|
||||||
|
|
||||||
# Catalogues par enseigne
|
# Catalogues par enseigne
|
||||||
catalogues_par_enseigne = {}
|
catalogues_par_enseigne = {}
|
||||||
enseignes = db.query(Enseigne).all()
|
enseignes = db.query(Enseigne).all()
|
||||||
for enseigne in enseignes:
|
for enseigne in enseignes:
|
||||||
count = (
|
count = db.query(func.count(Catalogue.id)).filter(Catalogue.enseigne_id == enseigne.id).scalar()
|
||||||
db.query(func.count(Catalogue.id))
|
|
||||||
.filter(Catalogue.enseigne_id == enseigne.id)
|
|
||||||
.scalar()
|
|
||||||
)
|
|
||||||
catalogues_par_enseigne[enseigne.nom] = count
|
catalogues_par_enseigne[enseigne.nom] = count
|
||||||
|
|
||||||
# Last scraping
|
# Last scraping
|
||||||
last_scraping = (
|
last_scraping = db.query(ScrapingLog).order_by(ScrapingLog.date_execution.desc()).first()
|
||||||
db.query(ScrapingLog)
|
|
||||||
.order_by(ScrapingLog.date_execution.desc())
|
|
||||||
.first()
|
|
||||||
)
|
|
||||||
|
|
||||||
return ScrapingStatsResponse(
|
return ScrapingStatsResponse(
|
||||||
total_catalogues=total_catalogues,
|
total_catalogues=total_catalogues,
|
||||||
catalogues_par_enseigne=catalogues_par_enseigne,
|
catalogues_par_enseigne=catalogues_par_enseigne,
|
||||||
@@ -237,13 +216,8 @@ async def get_scraping_logs(
|
|||||||
current_user: User = Depends(get_current_user),
|
current_user: User = Depends(get_current_user),
|
||||||
):
|
):
|
||||||
"""Get scraping logs"""
|
"""Get scraping logs"""
|
||||||
logs = (
|
logs = db.query(ScrapingLog).order_by(ScrapingLog.date_execution.desc()).limit(limit).all()
|
||||||
db.query(ScrapingLog)
|
|
||||||
.order_by(ScrapingLog.date_execution.desc())
|
|
||||||
.limit(limit)
|
|
||||||
.all()
|
|
||||||
)
|
|
||||||
|
|
||||||
return [ScrapingLogResponse.model_validate(log) for log in logs]
|
return [ScrapingLogResponse.model_validate(log) for log in logs]
|
||||||
|
|
||||||
|
|
||||||
@@ -258,24 +232,25 @@ async def trigger_scraping(
|
|||||||
enseigne = db.query(Enseigne).filter(Enseigne.id == enseigne_id).first()
|
enseigne = db.query(Enseigne).filter(Enseigne.id == enseigne_id).first()
|
||||||
if not enseigne:
|
if not enseigne:
|
||||||
raise HTTPException(status_code=404, detail="Enseigne not found")
|
raise HTTPException(status_code=404, detail="Enseigne not found")
|
||||||
|
|
||||||
# Use Cataloguemate scraper
|
# Use Cataloguemate scraper
|
||||||
log = await scrape_enseigne(enseigne, db)
|
log = await scrape_enseigne(enseigne, db)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Scraping completed for {enseigne.nom}",
|
"message": f"Scraping completed for {enseigne.nom}",
|
||||||
"catalogues_trouves": log.catalogues_trouves,
|
"catalogues_trouves": log.catalogues_trouves,
|
||||||
"catalogues_nouveaux": log.catalogues_nouveaux,
|
"catalogues_nouveaux": log.catalogues_nouveaux,
|
||||||
"status": log.statut,
|
"status": log.statut,
|
||||||
"error": log.message_erreur
|
"error": log.message_erreur,
|
||||||
}
|
}
|
||||||
else:
|
else:
|
||||||
# Scrape all enseignes
|
# Scrape all enseignes
|
||||||
from app.services.cataloguemate_scraper import scrape_all_enseignes
|
from app.services.cataloguemate_scraper import scrape_all_enseignes
|
||||||
|
|
||||||
logs = await scrape_all_enseignes(db)
|
logs = await scrape_all_enseignes(db)
|
||||||
|
|
||||||
total_new = sum(log.catalogues_nouveaux for log in logs)
|
total_new = sum(log.catalogues_nouveaux for log in logs)
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Scraping completed for all enseignes. {len(logs)} processed.",
|
"message": f"Scraping completed for all enseignes. {len(logs)} processed.",
|
||||||
"catalogues_nouveaux": total_new,
|
"catalogues_nouveaux": total_new,
|
||||||
@@ -290,16 +265,33 @@ async def cleanup_catalogs(
|
|||||||
"""Delete invalid catalogs (0 pages or generic titles)"""
|
"""Delete invalid catalogs (0 pages or generic titles)"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
deleted_count = 0
|
deleted_count = 0
|
||||||
|
|
||||||
# 1. Delete catalogs with 0 pages
|
# 1. Delete catalogs with 0 pages
|
||||||
bad_catalogs = db.query(Catalogue).filter(Catalogue.nombre_pages == 0).all()
|
bad_catalogs = db.query(Catalogue).filter(Catalogue.nombre_pages == 0).all()
|
||||||
for cat in bad_catalogs:
|
for cat in bad_catalogs:
|
||||||
db.delete(cat)
|
db.delete(cat)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
|
|
||||||
# 2. Delete generic titles
|
# 2. Delete catalogs with generic icon images (failed scrapes)
|
||||||
|
# This addresses the user request: "effacer les images quand on supprime un catalogue"
|
||||||
|
# (interpreted as removing the bad data so it can be re-scraped cleanly)
|
||||||
|
bad_image_catalogs = db.query(Catalogue).all()
|
||||||
|
for cat in bad_image_catalogs:
|
||||||
|
if not cat.image_couverture_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Check for suspicious keywords in the image URL that indicate it's not a real catalog page
|
||||||
|
if any(
|
||||||
|
x in cat.image_couverture_url.lower()
|
||||||
|
for x in ["icon", "logo", "loader", "facebook", "twitter", "assets/img"]
|
||||||
|
):
|
||||||
|
logger.info(f"Deleting catalog {cat.id} ('{cat.titre}') due to bad image: {cat.image_couverture_url}")
|
||||||
|
db.delete(cat)
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
# 3. Delete generic titles
|
||||||
generic_titles = [
|
generic_titles = [
|
||||||
"Restez informé",
|
"Restez informé",
|
||||||
"Catalogues Bazar",
|
"Catalogues Bazar",
|
||||||
@@ -307,9 +299,9 @@ async def cleanup_catalogs(
|
|||||||
"Voir les offres",
|
"Voir les offres",
|
||||||
"Téléchargez l'application",
|
"Téléchargez l'application",
|
||||||
"Newsletter",
|
"Newsletter",
|
||||||
"BLACK FRIDAY"
|
"BLACK FRIDAY",
|
||||||
]
|
]
|
||||||
|
|
||||||
for title_part in generic_titles:
|
for title_part in generic_titles:
|
||||||
generic_cats = db.query(Catalogue).filter(Catalogue.titre.ilike(f"%{title_part}%")).all()
|
generic_cats = db.query(Catalogue).filter(Catalogue.titre.ilike(f"%{title_part}%")).all()
|
||||||
for cat in generic_cats:
|
for cat in generic_cats:
|
||||||
@@ -317,10 +309,10 @@ async def cleanup_catalogs(
|
|||||||
if cat.nombre_pages < 2:
|
if cat.nombre_pages < 2:
|
||||||
db.delete(cat)
|
db.delete(cat)
|
||||||
deleted_count += 1
|
deleted_count += 1
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Cleanup: deleted {deleted_count} invalid catalogs")
|
logger.info(f"Cleanup: deleted {deleted_count} invalid catalogs")
|
||||||
|
|
||||||
return {"message": f"Cleanup complete. Deleted {deleted_count} invalid catalogs."}
|
return {"message": f"Cleanup complete. Deleted {deleted_count} invalid catalogs."}
|
||||||
|
|
||||||
|
|
||||||
@@ -333,16 +325,16 @@ async def delete_catalogue(
|
|||||||
"""Delete a specific catalogue (admin only)"""
|
"""Delete a specific catalogue (admin only)"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
catalogue = db.query(Catalogue).filter(Catalogue.id == catalogue_id).first()
|
catalogue = db.query(Catalogue).filter(Catalogue.id == catalogue_id).first()
|
||||||
if not catalogue:
|
if not catalogue:
|
||||||
raise HTTPException(status_code=404, detail="Catalogue not found")
|
raise HTTPException(status_code=404, detail="Catalogue not found")
|
||||||
|
|
||||||
# Pages are cascade-deleted automatically due to relationship
|
# Pages are cascade-deleted automatically due to relationship
|
||||||
db.delete(catalogue)
|
db.delete(catalogue)
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Deleted catalogue {catalogue_id} ({catalogue.titre})")
|
logger.info(f"Deleted catalogue {catalogue_id} ({catalogue.titre})")
|
||||||
|
|
||||||
return {"message": f"Catalogue '{catalogue.titre}' deleted successfully"}
|
return {"message": f"Catalogue '{catalogue.titre}' deleted successfully"}
|
||||||
|
|
||||||
|
|
||||||
@@ -354,23 +346,22 @@ async def purge_old_catalogues(
|
|||||||
"""Purge catalogues expired more than 3 months ago (admin only)"""
|
"""Purge catalogues expired more than 3 months ago (admin only)"""
|
||||||
if not current_user.is_admin:
|
if not current_user.is_admin:
|
||||||
raise HTTPException(status_code=403, detail="Not authorized")
|
raise HTTPException(status_code=403, detail="Not authorized")
|
||||||
|
|
||||||
from datetime import timedelta
|
from datetime import timedelta
|
||||||
|
|
||||||
cutoff_date = datetime.now() - timedelta(days=90)
|
cutoff_date = datetime.now() - timedelta(days=90)
|
||||||
|
|
||||||
old_catalogues = db.query(Catalogue).filter(
|
old_catalogues = db.query(Catalogue).filter(Catalogue.date_fin < cutoff_date).all()
|
||||||
Catalogue.date_fin < cutoff_date
|
|
||||||
).all()
|
|
||||||
|
|
||||||
count = len(old_catalogues)
|
count = len(old_catalogues)
|
||||||
for cat in old_catalogues:
|
for cat in old_catalogues:
|
||||||
db.delete(cat)
|
db.delete(cat)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
logger.info(f"Purged {count} catalogues with date_fin < {cutoff_date}")
|
logger.info(f"Purged {count} catalogues with date_fin < {cutoff_date}")
|
||||||
|
|
||||||
return {
|
return {
|
||||||
"message": f"Purged {count} catalogues expired more than 3 months ago",
|
"message": f"Purged {count} catalogues expired more than 3 months ago",
|
||||||
"cutoff_date": cutoff_date.isoformat(),
|
"cutoff_date": cutoff_date.isoformat(),
|
||||||
"deleted_count": count
|
"deleted_count": count,
|
||||||
}
|
}
|
||||||
@@ -25,6 +25,7 @@ logger = logging.getLogger(__name__)
|
|||||||
|
|
||||||
BASE_URL = "https://www.cataloguemate.fr"
|
BASE_URL = "https://www.cataloguemate.fr"
|
||||||
|
|
||||||
|
|
||||||
async def _fetch_with_fallback(url: str) -> str:
|
async def _fetch_with_fallback(url: str) -> str:
|
||||||
"""Fetch content using Browserless first, then fallback to HTTPX."""
|
"""Fetch content using Browserless first, then fallback to HTTPX."""
|
||||||
# 1. Try Browserless
|
# 1. Try Browserless
|
||||||
@@ -52,9 +53,10 @@ async def _fetch_with_fallback(url: str) -> str:
|
|||||||
logger.error(f"HTTPX failed with status {response.status_code}")
|
logger.error(f"HTTPX failed with status {response.status_code}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"HTTPX fallback failed: {e}")
|
logger.error(f"HTTPX fallback failed: {e}")
|
||||||
|
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
|
|
||||||
async def scrape_catalog_list(enseigne: Enseigne) -> list[dict[str, Any]]:
|
async def scrape_catalog_list(enseigne: Enseigne) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Scrape the list of catalogs for an enseigne.
|
Scrape the list of catalogs for an enseigne.
|
||||||
@@ -67,62 +69,64 @@ async def scrape_catalog_list(enseigne: Enseigne) -> list[dict[str, Any]]:
|
|||||||
slug = "bm"
|
slug = "bm"
|
||||||
elif enseigne.nom.lower() == "la foir'fouille":
|
elif enseigne.nom.lower() == "la foir'fouille":
|
||||||
slug = "la-foirfouille"
|
slug = "la-foirfouille"
|
||||||
|
|
||||||
# Use Paris as default city to find national catalogs
|
# Use Paris as default city to find national catalogs
|
||||||
url = f"{BASE_URL}/offres/paris/{slug}/"
|
url = f"{BASE_URL}/offres/paris/{slug}/"
|
||||||
logger.info(f"Scraping catalog list from: {url}")
|
logger.info(f"Scraping catalog list from: {url}")
|
||||||
|
|
||||||
# Use robust fetch with fallback
|
# Use robust fetch with fallback
|
||||||
html_content = await _fetch_with_fallback(url)
|
html_content = await _fetch_with_fallback(url)
|
||||||
|
|
||||||
if not html_content:
|
if not html_content:
|
||||||
logger.error(f"Failed to fetch list {url}")
|
logger.error(f"Failed to fetch list {url}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
catalogs = []
|
catalogs = []
|
||||||
|
|
||||||
# Find all links that might be catalogs
|
# Find all links that might be catalogs
|
||||||
links = soup.find_all('a', href=True)
|
links = soup.find_all("a", href=True)
|
||||||
seen_urls = set()
|
seen_urls = set()
|
||||||
|
|
||||||
logger.info(f"Found {len(links)} links on page {url}")
|
logger.info(f"Found {len(links)} links on page {url}")
|
||||||
|
|
||||||
for link in links:
|
for link in links:
|
||||||
href = link['href']
|
href = link["href"]
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
|
|
||||||
# Normalize href
|
# Normalize href
|
||||||
if href.startswith(BASE_URL):
|
if href.startswith(BASE_URL):
|
||||||
href = href.replace(BASE_URL, "")
|
href = href.replace(BASE_URL, "")
|
||||||
|
|
||||||
# Debug log for potential candidates
|
# Debug log for potential candidates
|
||||||
if slug in href:
|
if slug in href:
|
||||||
logger.debug(f"Checking link: {href}")
|
logger.debug(f"Checking link: {href}")
|
||||||
|
|
||||||
# Filter:
|
# Filter:
|
||||||
# 1. Must contain the slug (or be a catalog link for this enseigne)
|
# 1. Must contain the slug (or be a catalog link for this enseigne)
|
||||||
# 2. Must NOT be a city search page (/offres/)
|
# 2. Must NOT be a city search page (/offres/)
|
||||||
# 3. Must NOT be a product search (/rechercher/)
|
# 3. Must NOT be a product search (/rechercher/)
|
||||||
# 4. Should usually have a numeric ID at the end
|
# 4. Should usually have a numeric ID at the end
|
||||||
|
|
||||||
if f"/{slug}/" in href:
|
if f"/{slug}/" in href:
|
||||||
if any(x in href for x in ["/offres/", "/magasins/", "/rechercher/", "page="]):
|
if any(x in href for x in ["/offres/", "/magasins/", "/rechercher/", "page="]):
|
||||||
logger.debug(f" -> Rejected (invalid pattern): {href}")
|
logger.debug(f" -> Rejected (invalid pattern): {href}")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
full_url = f"{BASE_URL}{href}"
|
full_url = f"{BASE_URL}{href}"
|
||||||
|
|
||||||
if full_url in seen_urls:
|
if full_url in seen_urls:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# Check for numeric ID pattern which is typical for catalogs
|
# Check for numeric ID pattern which is typical for catalogs
|
||||||
# e.g. -61130/
|
# e.g. -61130/
|
||||||
if re.search(r'-\d+/?$', href) or "catalogue" in href.lower():
|
if re.search(r"-\d+/?$", href) or "catalogue" in href.lower():
|
||||||
catalogs.append({
|
catalogs.append(
|
||||||
'url': full_url,
|
{
|
||||||
'title': text or "Catalogue",
|
"url": full_url,
|
||||||
})
|
"title": text or "Catalogue",
|
||||||
|
}
|
||||||
|
)
|
||||||
seen_urls.add(full_url)
|
seen_urls.add(full_url)
|
||||||
logger.info(f"Found catalog: {full_url}")
|
logger.info(f"Found catalog: {full_url}")
|
||||||
else:
|
else:
|
||||||
@@ -131,6 +135,7 @@ async def scrape_catalog_list(enseigne: Enseigne) -> list[dict[str, Any]]:
|
|||||||
logger.info(f"Total catalogs found: {len(catalogs)}")
|
logger.info(f"Total catalogs found: {len(catalogs)}")
|
||||||
return catalogs
|
return catalogs
|
||||||
|
|
||||||
|
|
||||||
async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
||||||
"""
|
"""
|
||||||
Scrape pages from a specific catalog.
|
Scrape pages from a specific catalog.
|
||||||
@@ -138,27 +143,27 @@ async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
|||||||
"""
|
"""
|
||||||
pages = []
|
pages = []
|
||||||
page_num = 1
|
page_num = 1
|
||||||
max_pages = 100 # Default safety limit
|
max_pages = 100 # Default safety limit
|
||||||
|
|
||||||
# So we fetch page 2 first to detect the max pages
|
# So we fetch page 2 first to detect the max pages
|
||||||
page_2_url = f"{catalog_url}?page=2"
|
page_2_url = f"{catalog_url}?page=2"
|
||||||
page_2_html = await _fetch_with_fallback(page_2_url)
|
page_2_html = await _fetch_with_fallback(page_2_url)
|
||||||
|
|
||||||
if page_2_html:
|
if page_2_html:
|
||||||
soup = BeautifulSoup(page_2_html, 'html.parser')
|
soup = BeautifulSoup(page_2_html, "html.parser")
|
||||||
|
|
||||||
# Try to detect max pages from pagination links on page 2
|
# Try to detect max pages from pagination links on page 2
|
||||||
pagination_links = soup.find_all('a', href=True)
|
pagination_links = soup.find_all("a", href=True)
|
||||||
page_numbers = []
|
page_numbers = []
|
||||||
|
|
||||||
for link in pagination_links:
|
for link in pagination_links:
|
||||||
href = link.get('href', '')
|
href = link.get("href", "")
|
||||||
text = link.get_text(strip=True)
|
text = link.get_text(strip=True)
|
||||||
|
|
||||||
# Check if it's a page number link
|
# Check if it's a page number link
|
||||||
if 'page=' in href:
|
if "page=" in href:
|
||||||
try:
|
try:
|
||||||
page_match = re.search(r'page=(\d+)', href)
|
page_match = re.search(r"page=(\d+)", href)
|
||||||
if page_match:
|
if page_match:
|
||||||
page_numbers.append(int(page_match.group(1)))
|
page_numbers.append(int(page_match.group(1)))
|
||||||
except:
|
except:
|
||||||
@@ -166,7 +171,7 @@ async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
|||||||
elif text.isdigit():
|
elif text.isdigit():
|
||||||
# Direct numeric link (e.g., "11")
|
# Direct numeric link (e.g., "11")
|
||||||
page_numbers.append(int(text))
|
page_numbers.append(int(text))
|
||||||
|
|
||||||
if page_numbers:
|
if page_numbers:
|
||||||
detected_max = max(page_numbers)
|
detected_max = max(page_numbers)
|
||||||
# Sanity check: cataloguemate sometimes shows wrong page counts (e.g., 188)
|
# Sanity check: cataloguemate sometimes shows wrong page counts (e.g., 188)
|
||||||
@@ -181,79 +186,99 @@ async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
|||||||
logger.warning(f"Could not detect page count from page 2, using default limit of {max_pages}")
|
logger.warning(f"Could not detect page count from page 2, using default limit of {max_pages}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"Could not fetch page 2 to detect pagination, using default limit")
|
logger.warning(f"Could not fetch page 2 to detect pagination, using default limit")
|
||||||
|
|
||||||
# Now scrape all pages starting from page 1
|
# Now scrape all pages starting from page 1
|
||||||
while page_num <= max_pages:
|
while page_num <= max_pages:
|
||||||
# Construct URL for specific page
|
# Construct URL for specific page
|
||||||
current_url = catalog_url if page_num == 1 else f"{catalog_url}?page={page_num}"
|
current_url = catalog_url if page_num == 1 else f"{catalog_url}?page={page_num}"
|
||||||
|
|
||||||
logger.info(f"Scraping page {page_num}/{max_pages}: {current_url}")
|
logger.info(f"Scraping page {page_num}/{max_pages}: {current_url}")
|
||||||
|
|
||||||
# Reuse page 2 HTML if we're on page 2
|
# Reuse page 2 HTML if we're on page 2
|
||||||
if page_num == 2 and page_2_html:
|
if page_num == 2 and page_2_html:
|
||||||
html_content = page_2_html
|
html_content = page_2_html
|
||||||
else:
|
else:
|
||||||
html_content = await _fetch_with_fallback(current_url)
|
html_content = await _fetch_with_fallback(current_url)
|
||||||
|
|
||||||
if not html_content:
|
if not html_content:
|
||||||
logger.warning(f"Failed to fetch page {page_num}")
|
logger.warning(f"Failed to fetch page {page_num}")
|
||||||
break
|
break
|
||||||
|
|
||||||
soup = BeautifulSoup(html_content, 'html.parser')
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
|
||||||
# Find the main catalog image
|
# Find the main catalog image
|
||||||
main_image_url = None
|
main_image_url = None
|
||||||
max_area = 0
|
max_area = 0
|
||||||
|
|
||||||
all_imgs = soup.find_all('img')
|
# Strategy 1: Look for specific container/class identified in browser inspection
|
||||||
for img in all_imgs:
|
candidates = soup.select(".letaky-grid-preview img")
|
||||||
# Check multiple attributes for the real image URL (lazy loading)
|
|
||||||
src = img.get('data-src') or img.get('data-original') or img.get('src')
|
# Strategy 2: Fallback to all images if specific container not found
|
||||||
|
if not candidates:
|
||||||
if not src: continue
|
candidates = soup.find_all("img")
|
||||||
|
|
||||||
# Skip common UI elements
|
for img in candidates:
|
||||||
if any(x in src.lower() for x in ['logo', 'icon', 'facebook', 'twitter', 'instagram', 'loader']):
|
# Check multiple attributes for the real image URL
|
||||||
|
# Browser inspection showed 'src' is used directly for the main image
|
||||||
|
src = img.get("src") or img.get("data-src") or img.get("data-original")
|
||||||
|
|
||||||
|
if not src:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
# Skip common UI elements - refined list
|
||||||
|
if any(
|
||||||
|
x in src.lower()
|
||||||
|
for x in [
|
||||||
|
"logo",
|
||||||
|
"icon",
|
||||||
|
"facebook",
|
||||||
|
"twitter",
|
||||||
|
"instagram",
|
||||||
|
"loader",
|
||||||
|
"spinner",
|
||||||
|
"market",
|
||||||
|
"googleplay",
|
||||||
|
"appstore",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Strong Signal: URL contains 'thumbor' or 'leafletscdns' (host for catalog images)
|
||||||
|
is_thumbor = "thumbor" in src.lower() or "leafletscdns" in src.lower()
|
||||||
|
|
||||||
# Calculate area if dimensions exist
|
# Calculate area if dimensions exist
|
||||||
width = img.get('width')
|
width = img.get("width")
|
||||||
height = img.get('height')
|
height = img.get("height")
|
||||||
area = 0
|
area = 0
|
||||||
if width and height:
|
if width and height:
|
||||||
try:
|
try:
|
||||||
area = int(width) * int(height)
|
area = int(width) * int(height)
|
||||||
except:
|
except:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
# Heuristic: Catalog pages are usually large vertical images
|
|
||||||
# Check src for keywords
|
|
||||||
is_likely_catalog = any(k in src.lower() for k in ['page', 'flyer', 'catalog', 'upload', 'images', 'leaflet', 'thumbor'])
|
|
||||||
|
|
||||||
# Logic:
|
# Logic:
|
||||||
# 1. If keyword match AND decent size -> Strong candidate
|
# 1. If it's in the specific container (candidates were filtered if Strategy 1 worked), it's very likely valid.
|
||||||
# 2. If no keyword match but HUGE size -> Candidate
|
# 2. If it has 'thumbor'/'leafletscdns', it's very likely valid.
|
||||||
|
|
||||||
if is_likely_catalog:
|
if is_thumbor:
|
||||||
|
# If we found a thumbor image, it's almost certainly the catalog page.
|
||||||
|
# If we have multiple, pick the largest (though usually there's just one main one per page container)
|
||||||
if area > max_area or (area == 0 and max_area == 0):
|
if area > max_area or (area == 0 and max_area == 0):
|
||||||
max_area = area
|
max_area = area
|
||||||
main_image_url = src
|
main_image_url = src
|
||||||
elif area > 100000: # Arbitrary large size (e.g. 300x333)
|
elif area > 50000: # Fallback area check
|
||||||
if area > max_area:
|
if area > max_area:
|
||||||
max_area = area
|
max_area = area
|
||||||
main_image_url = src
|
main_image_url = src
|
||||||
|
|
||||||
if main_image_url:
|
if main_image_url:
|
||||||
# Ensure absolute URL
|
# Ensure absolute URL
|
||||||
if main_image_url.startswith("//"):
|
if main_image_url.startswith("//"):
|
||||||
main_image_url = "https:" + main_image_url
|
main_image_url = "https:" + main_image_url
|
||||||
elif main_image_url.startswith("/"):
|
elif main_image_url.startswith("/"):
|
||||||
main_image_url = BASE_URL + main_image_url
|
main_image_url = BASE_URL + main_image_url
|
||||||
|
|
||||||
pages.append({
|
pages.append({"page_number": page_num, "image_url": main_image_url})
|
||||||
'page_number': page_num,
|
|
||||||
'image_url': main_image_url
|
|
||||||
})
|
|
||||||
logger.info(f"Found image for page {page_num}: {main_image_url}")
|
logger.info(f"Found image for page {page_num}: {main_image_url}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No catalog image found on page {page_num}")
|
logger.warning(f"No catalog image found on page {page_num}")
|
||||||
@@ -261,7 +286,7 @@ async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
|||||||
if page_num > 1:
|
if page_num > 1:
|
||||||
logger.info(f"Stopping pagination at page {page_num - 1} (no image found)")
|
logger.info(f"Stopping pagination at page {page_num - 1} (no image found)")
|
||||||
break
|
break
|
||||||
|
|
||||||
page_num += 1
|
page_num += 1
|
||||||
# Small delay to avoid overwhelming the server
|
# Small delay to avoid overwhelming the server
|
||||||
await asyncio.sleep(0.5)
|
await asyncio.sleep(0.5)
|
||||||
@@ -269,6 +294,7 @@ async def scrape_catalog_pages(catalog_url: str) -> list[dict[str, Any]]:
|
|||||||
logger.info(f"Scraped {len(pages)} pages total")
|
logger.info(f"Scraped {len(pages)} pages total")
|
||||||
return pages
|
return pages
|
||||||
|
|
||||||
|
|
||||||
async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
|
async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
|
||||||
"""Main entry point for scraping an enseigne"""
|
"""Main entry point for scraping an enseigne"""
|
||||||
start_time = datetime.now()
|
start_time = datetime.now()
|
||||||
@@ -278,7 +304,7 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
|
|||||||
# date_execution is set automatically by default
|
# date_execution is set automatically by default
|
||||||
catalogues_trouves=0,
|
catalogues_trouves=0,
|
||||||
catalogues_nouveaux=0,
|
catalogues_nouveaux=0,
|
||||||
catalogues_mis_a_jour=0
|
catalogues_mis_a_jour=0,
|
||||||
)
|
)
|
||||||
db.add(log)
|
db.add(log)
|
||||||
db.commit()
|
db.commit()
|
||||||
@@ -290,96 +316,96 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
|
|||||||
# 1. Get list of catalogs
|
# 1. Get list of catalogs
|
||||||
catalogs_list = await scrape_catalog_list(enseigne)
|
catalogs_list = await scrape_catalog_list(enseigne)
|
||||||
log.catalogues_trouves = len(catalogs_list)
|
log.catalogues_trouves = len(catalogs_list)
|
||||||
|
|
||||||
for cat_info in catalogs_list:
|
for cat_info in catalogs_list:
|
||||||
# Generate content hash BEFORE checking existence (need pages for first image)
|
# Generate content hash BEFORE checking existence (need pages for first image)
|
||||||
# First, check if catalog exists by URL
|
# First, check if catalog exists by URL
|
||||||
existing = db.query(Catalogue).filter(
|
existing = db.query(Catalogue).filter(Catalogue.catalogue_url == cat_info["url"]).first()
|
||||||
Catalogue.catalogue_url == cat_info['url']
|
|
||||||
).first()
|
|
||||||
|
|
||||||
# Check if existing catalog has valid images or needs repair
|
# Check if existing catalog has valid images or needs repair
|
||||||
needs_repair = False
|
needs_repair = False
|
||||||
if existing:
|
if existing:
|
||||||
# Check if coverage image is a placeholder/loader
|
# Check if coverage image is a placeholder/loader
|
||||||
if any(x in existing.image_couverture_url.lower() for x in ['loader', 'icon', 'logo', 'facebook']):
|
if any(x in existing.image_couverture_url.lower() for x in ["loader", "icon", "logo", "facebook"]):
|
||||||
logger.info(f"Catalogue {existing.id} exists but has bad cover image ({existing.image_couverture_url}). Repairing...")
|
logger.info(
|
||||||
|
f"Catalogue {existing.id} exists but has bad cover image ({existing.image_couverture_url}). Repairing..."
|
||||||
|
)
|
||||||
needs_repair = True
|
needs_repair = True
|
||||||
else:
|
else:
|
||||||
logger.info(f"Catalogue already exists by URL: {cat_info['title']} (ID: {existing.id})")
|
logger.info(f"Catalogue already exists by URL: {cat_info['title']} (ID: {existing.id})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
# 2. Scrape pages for new catalog (or repair)
|
# 2. Scrape pages for new catalog (or repair)
|
||||||
pages = await scrape_catalog_pages(cat_info['url'])
|
pages = await scrape_catalog_pages(cat_info["url"])
|
||||||
|
|
||||||
if not pages:
|
if not pages:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if needs_repair and existing:
|
if needs_repair and existing:
|
||||||
# Update existing catalog
|
# Update existing catalog
|
||||||
existing.image_couverture_url = pages[0]['image_url']
|
existing.image_couverture_url = pages[0]["image_url"]
|
||||||
existing.nombre_pages = len(pages)
|
existing.nombre_pages = len(pages)
|
||||||
# content_hash might change, let's update it
|
# content_hash might change, let's update it
|
||||||
hash_input = f"{cat_info['title']}{pages[0]['image_url']}".encode('utf-8')
|
hash_input = f"{cat_info['title']}{pages[0]['image_url']}".encode("utf-8")
|
||||||
existing.content_hash = hashlib.sha256(hash_input).hexdigest()
|
existing.content_hash = hashlib.sha256(hash_input).hexdigest()
|
||||||
|
|
||||||
# Delete old pages
|
# Delete old pages
|
||||||
db.query(CataloguePage).filter(CataloguePage.catalogue_id == existing.id).delete()
|
db.query(CataloguePage).filter(CataloguePage.catalogue_id == existing.id).delete()
|
||||||
|
|
||||||
# Add new pages
|
# Add new pages
|
||||||
for page_data in pages:
|
for page_data in pages:
|
||||||
new_page = CataloguePage(
|
new_page = CataloguePage(
|
||||||
catalogue_id=existing.id,
|
catalogue_id=existing.id,
|
||||||
numero_page=page_data['page_number'],
|
numero_page=page_data["page_number"],
|
||||||
image_url=page_data['image_url'],
|
image_url=page_data["image_url"],
|
||||||
)
|
)
|
||||||
db.add(new_page)
|
db.add(new_page)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
log.catalogues_mis_a_jour += 1
|
log.catalogues_mis_a_jour += 1
|
||||||
logger.info(f"Repaired catalog '{existing.titre}' with {len(pages)} pages")
|
logger.info(f"Repaired catalog '{existing.titre}' with {len(pages)} pages")
|
||||||
continue # Done with this catalog
|
continue # Done with this catalog
|
||||||
|
|
||||||
# Generate content hash based on title and first image (stable identifier)
|
# Generate content hash based on title and first image (stable identifier)
|
||||||
hash_input = f"{cat_info['title']}{pages[0]['image_url']}".encode('utf-8')
|
hash_input = f"{cat_info['title']}{pages[0]['image_url']}".encode("utf-8")
|
||||||
content_hash = hashlib.sha256(hash_input).hexdigest()
|
content_hash = hashlib.sha256(hash_input).hexdigest()
|
||||||
|
|
||||||
# Check if a catalog with the same content already exists
|
# Check if a catalog with the same content already exists
|
||||||
existing_by_hash = db.query(Catalogue).filter(
|
existing_by_hash = db.query(Catalogue).filter(Catalogue.content_hash == content_hash).first()
|
||||||
Catalogue.content_hash == content_hash
|
|
||||||
).first()
|
|
||||||
|
|
||||||
if existing_by_hash:
|
if existing_by_hash:
|
||||||
logger.info(f"Catalogue already exists by content hash: {cat_info['title']} (ID: {existing_by_hash.id}, original URL: {existing_by_hash.catalogue_url})")
|
logger.info(
|
||||||
|
f"Catalogue already exists by content hash: {cat_info['title']} (ID: {existing_by_hash.id}, original URL: {existing_by_hash.catalogue_url})"
|
||||||
|
)
|
||||||
continue
|
continue
|
||||||
|
|
||||||
new_cat = Catalogue(
|
new_cat = Catalogue(
|
||||||
enseigne_id=enseigne.id,
|
enseigne_id=enseigne.id,
|
||||||
titre=cat_info['title'],
|
titre=cat_info["title"],
|
||||||
catalogue_url=cat_info['url'],
|
catalogue_url=cat_info["url"],
|
||||||
date_debut=datetime.now(), # Todo: extract real dates
|
date_debut=datetime.now(), # Todo: extract real dates
|
||||||
date_fin=datetime.now() + timedelta(days=14),
|
date_fin=datetime.now() + timedelta(days=14),
|
||||||
image_couverture_url=pages[0]['image_url'],
|
image_couverture_url=pages[0]["image_url"],
|
||||||
content_hash=content_hash,
|
content_hash=content_hash,
|
||||||
nombre_pages=len(pages) # Set actual page count
|
nombre_pages=len(pages), # Set actual page count
|
||||||
)
|
)
|
||||||
db.add(new_cat)
|
db.add(new_cat)
|
||||||
db.commit()
|
db.commit()
|
||||||
db.refresh(new_cat)
|
db.refresh(new_cat)
|
||||||
|
|
||||||
# Add pages
|
# Add pages
|
||||||
for page_data in pages:
|
for page_data in pages:
|
||||||
new_page = CataloguePage(
|
new_page = CataloguePage(
|
||||||
catalogue_id=new_cat.id,
|
catalogue_id=new_cat.id,
|
||||||
numero_page=page_data['page_number'],
|
numero_page=page_data["page_number"],
|
||||||
image_url=page_data['image_url'],
|
image_url=page_data["image_url"],
|
||||||
)
|
)
|
||||||
db.add(new_page)
|
db.add(new_page)
|
||||||
|
|
||||||
db.commit()
|
db.commit()
|
||||||
log.catalogues_nouveaux += 1
|
log.catalogues_nouveaux += 1
|
||||||
logger.info(f"Saved catalog '{new_cat.titre}' with {len(pages)} pages")
|
logger.info(f"Saved catalog '{new_cat.titre}' with {len(pages)} pages")
|
||||||
|
|
||||||
log.statut = "success"
|
log.statut = "success"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Scraping failed: {e}")
|
logger.error(f"Scraping failed: {e}")
|
||||||
@@ -391,23 +417,24 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
|
|||||||
duration = (end_time - start_time).total_seconds()
|
duration = (end_time - start_time).total_seconds()
|
||||||
log.duree_secondes = duration
|
log.duree_secondes = duration
|
||||||
db.commit()
|
db.commit()
|
||||||
|
|
||||||
return log
|
return log
|
||||||
|
|
||||||
|
|
||||||
async def scrape_all_enseignes(db: Session) -> list[ScrapingLog]:
|
async def scrape_all_enseignes(db: Session) -> list[ScrapingLog]:
|
||||||
"""
|
"""
|
||||||
Scrape catalogs for all active enseignes.
|
Scrape catalogs for all active enseignes.
|
||||||
"""
|
"""
|
||||||
logger.info("Starting scraping for all enseignes...")
|
logger.info("Starting scraping for all enseignes...")
|
||||||
logs = []
|
logs = []
|
||||||
|
|
||||||
enseignes = db.query(Enseigne).filter(Enseigne.is_active == True).all()
|
enseignes = db.query(Enseigne).filter(Enseigne.is_active == True).all()
|
||||||
|
|
||||||
for enseigne in enseignes:
|
for enseigne in enseignes:
|
||||||
try:
|
try:
|
||||||
log = await scrape_enseigne(enseigne, db)
|
log = await scrape_enseigne(enseigne, db)
|
||||||
logs.append(log)
|
logs.append(log)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Error scraping enseigne {enseigne.nom}: {e}")
|
logger.error(f"Error scraping enseigne {enseigne.nom}: {e}")
|
||||||
|
|
||||||
return logs
|
return logs
|
||||||
@@ -0,0 +1,53 @@
|
|||||||
|
import sys
|
||||||
|
import os
|
||||||
|
import logging
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
|
# Add project root to path
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
|
||||||
|
from app.database import SessionLocal, engine
|
||||||
|
from app.models import Catalogue
|
||||||
|
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def cleanup_catalogs():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
logger.info("Starting cleanup...")
|
||||||
|
deleted_count = 0
|
||||||
|
|
||||||
|
# 1. Delete catalogs with 0 pages
|
||||||
|
bad_catalogs = db.query(Catalogue).filter(Catalogue.nombre_pages == 0).all()
|
||||||
|
for cat in bad_catalogs:
|
||||||
|
logger.info(f"Deleting empty catalog: {cat.titre}")
|
||||||
|
db.delete(cat)
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
# 2. Delete catalogs with iconic/bad images
|
||||||
|
all_catalogs = db.query(Catalogue).all()
|
||||||
|
for cat in all_catalogs:
|
||||||
|
if not cat.image_couverture_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if any(
|
||||||
|
x in cat.image_couverture_url.lower()
|
||||||
|
for x in ["icon", "logo", "loader", "facebook", "twitter", "assets/img"]
|
||||||
|
):
|
||||||
|
logger.info(f"Deleting catalog with bad image: {cat.titre} ({cat.image_couverture_url})")
|
||||||
|
db.delete(cat)
|
||||||
|
deleted_count += 1
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
logger.info(f"Cleanup complete. Deleted {deleted_count} catalogs.")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error during cleanup: {e}")
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
cleanup_catalogs()
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
# Task Board: Vision-Based Price Extraction
|
# Task Board: Catalog Image Fix
|
||||||
|
|
||||||
## 🚀 Current Focus
|
## 🚀 Current Focus
|
||||||
|
|
||||||
- [ ] Fix Catalog Images (Generic/Missing icons)
|
- [x] Fix Catalog Images (Generic/Missing icons)
|
||||||
|
- [x] Address "PDF vs Image" question
|
||||||
|
- [x] Implement "Delete Images" (Cleanup bad data)
|
||||||
|
|
||||||
## 📋 Master Plan
|
## 📋 Master Plan
|
||||||
|
|
||||||
@@ -10,18 +12,25 @@
|
|||||||
- [x] Proposal Phase: Vision Priority accepted.
|
- [x] Proposal Phase: Vision Priority accepted.
|
||||||
- [x] Implementation Phase:
|
- [x] Implementation Phase:
|
||||||
- [x] Update `ai_schema.py`.
|
- [x] Update `ai_schema.py`.
|
||||||
- [x] Fix `scheduler_service.py` (Disable conflicting Text AI).
|
- [x] Fix `scheduler_service.py`.
|
||||||
- [x] Verification Phase: Verified with `verify_vision_priority.py`.
|
- [x] Verification Phase: Verified with `verify_vision_priority.py`.
|
||||||
- [x] **Catalog Retrieval Fix**:
|
- [x] **Catalog Retrieval Fix**:
|
||||||
- [x] Reproduce failure (Confirmed Browserless issue).
|
- [x] Reproduce failure (Confirmed Browserless issue).
|
||||||
- [x] Fix `cataloguemate_scraper.py` with HTTP fallback.
|
- [x] Fix `cataloguemate_scraper.py` with HTTP fallback.
|
||||||
- [x] Cleanup debug files.
|
- [x] Cleanup debug files.
|
||||||
- [ ] **Catalog Image Fix**:
|
- [x] **Catalog Image Fix**:
|
||||||
- [x] Analyze page HTML for correct image selectors (Found `data-src`).
|
- [x] Analyze page HTML for correct image selectors (Found Thumbor URL).
|
||||||
- [x] Refine `cataloguemate_scraper.py` image extraction logic.
|
- [x] **Check PDF availability** (Browser Task).
|
||||||
|
- [x] **Refine Scraper Logic**:
|
||||||
|
- [x] Update `cataloguemate_scraper.py` to grab `src` and prioritize Thumbor.
|
||||||
|
- [x] Update `routers/catalogues.py` to clean up bad images.
|
||||||
|
- [x] **Cleanup Bad Data**:
|
||||||
|
- [x] Run cleanup endpoint.
|
||||||
|
- [x] Verify fix with new scrape.
|
||||||
|
|
||||||
## 📝 Progress Log
|
## 📝 Progress Log
|
||||||
|
|
||||||
- **2025-12-22**: Vision Priority implemented and verified.
|
- **2025-12-22**: Vision Priority implemented and verified.
|
||||||
- **2025-12-22**: Fixed Catalog Retrieval using HTTP fallback.
|
- **2025-12-22**: Fixed Catalog Retrieval using HTTP fallback.
|
||||||
- **2025-12-22**: User reports images are generic icons. Investigating image selectors.
|
- **2025-12-22**: Investigating generic icon issue. Found images are served via Thumbor.
|
||||||
|
- **2025-12-22**: **FIXED**: Scraper now targets Thumbor images. Wiped bad catalogs.
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
# Identify workspace root
|
||||||
|
sys.path.append(os.getcwd())
|
||||||
|
|
||||||
|
from app.services.cataloguemate_scraper import scrape_catalog_pages
|
||||||
|
|
||||||
|
# Setup logging
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
async def verify_scraper():
|
||||||
|
# Gifi catalog URL from the browser session
|
||||||
|
url = "https://www.cataloguemate.fr/gifi/catalogue-du-mardi-16122025-61964/"
|
||||||
|
|
||||||
|
print(f"Verifying scraper on: {url}")
|
||||||
|
|
||||||
|
pages = await scrape_catalog_pages(url)
|
||||||
|
|
||||||
|
print(f"Found {len(pages)} pages.")
|
||||||
|
|
||||||
|
if not pages:
|
||||||
|
print("FAIL: No pages found.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# check first page image
|
||||||
|
first_img = pages[0]["image_url"]
|
||||||
|
print(f"Page 1 Image: {first_img}")
|
||||||
|
|
||||||
|
if "thumbor" in first_img or "leafletscdns" in first_img:
|
||||||
|
print("SUCCESS: Image is a Thumbor/Leaflet URL.")
|
||||||
|
elif "icon" in first_img or "logo" in first_img:
|
||||||
|
print("FAIL: Image appears to be an icon/logo.")
|
||||||
|
else:
|
||||||
|
print(f"WARNING: Image URL is: {first_img}")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
asyncio.run(verify_scraper())
|
||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Mock logger
|
||||||
|
logging.basicConfig(level=logging.INFO)
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
# Fallback implementation of _fetch_with_fallback for standalone test
|
||||||
|
async def _fetch_with_fallback(url):
|
||||||
|
# We need to install httpx for this to work
|
||||||
|
try:
|
||||||
|
import httpx
|
||||||
|
|
||||||
|
headers = {
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
async with httpx.AsyncClient(verify=False, timeout=30.0) as client:
|
||||||
|
response = await client.get(url, headers=headers)
|
||||||
|
return response.text
|
||||||
|
except ImportError:
|
||||||
|
print("Please pip install httpx strict")
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
async def scrape_catalog_pages_standalone(catalog_url: str):
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
|
||||||
|
print(f"Scraping: {catalog_url}")
|
||||||
|
html_content = await _fetch_with_fallback(catalog_url)
|
||||||
|
|
||||||
|
if not html_content:
|
||||||
|
print("Failed to fetch content")
|
||||||
|
return []
|
||||||
|
|
||||||
|
soup = BeautifulSoup(html_content, "html.parser")
|
||||||
|
|
||||||
|
# --- COPIED LOGIC FROM cataloguemate_scraper.py ---
|
||||||
|
main_image_url = None
|
||||||
|
max_area = 0
|
||||||
|
|
||||||
|
# Strategy 1: Look for specific container/class identified in browser inspection
|
||||||
|
candidates = soup.select(".letaky-grid-preview img")
|
||||||
|
|
||||||
|
# Strategy 2: Fallback to all images if specific container not found
|
||||||
|
if not candidates:
|
||||||
|
candidates = soup.find_all("img")
|
||||||
|
|
||||||
|
print(f"Found {len(candidates)} candidates")
|
||||||
|
|
||||||
|
for img in candidates:
|
||||||
|
# Check multiple attributes for the real image URL
|
||||||
|
src = img.get("src") or img.get("data-src") or img.get("data-original")
|
||||||
|
|
||||||
|
if not src:
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Skip common UI elements - refined list
|
||||||
|
if any(
|
||||||
|
x in src.lower()
|
||||||
|
for x in [
|
||||||
|
"logo",
|
||||||
|
"icon",
|
||||||
|
"facebook",
|
||||||
|
"twitter",
|
||||||
|
"instagram",
|
||||||
|
"loader",
|
||||||
|
"spinner",
|
||||||
|
"market",
|
||||||
|
"googleplay",
|
||||||
|
"appstore",
|
||||||
|
]
|
||||||
|
):
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Strong Signal: URL contains 'thumbor' or 'leafletscdns' (host for catalog images)
|
||||||
|
is_thumbor = "thumbor" in src.lower() or "leafletscdns" in src.lower()
|
||||||
|
|
||||||
|
# Calculate area if dimensions exist
|
||||||
|
width = img.get("width")
|
||||||
|
height = img.get("height")
|
||||||
|
area = 0
|
||||||
|
if width and height:
|
||||||
|
try:
|
||||||
|
area = int(width) * int(height)
|
||||||
|
except:
|
||||||
|
pass
|
||||||
|
|
||||||
|
if is_thumbor:
|
||||||
|
if area > max_area or (area == 0 and max_area == 0):
|
||||||
|
max_area = area
|
||||||
|
main_image_url = src
|
||||||
|
print(f"Match (Thumbor): {src}")
|
||||||
|
elif area > 50000:
|
||||||
|
if area > max_area:
|
||||||
|
max_area = area
|
||||||
|
main_image_url = src
|
||||||
|
print(f"Match (Size): {src}")
|
||||||
|
|
||||||
|
return main_image_url
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
url = "https://www.cataloguemate.fr/gifi/catalogue-du-mardi-16122025-61964/"
|
||||||
|
result = asyncio.run(scrape_catalog_pages_standalone(url))
|
||||||
|
print(f"FINAL RESULT: {result}")
|
||||||
Reference in new issue
Block a user