feat: Add Cataloguemate.fr scraper to replace Tiendeo and introduce new Catalogues frontend page.

This commit is contained in:
Michael committed 2025-11-30 11:17:28 +01:00
1 parent 03d79e45fb
commit 99650a992b
2 files changed
+164 -70

No files matched your search

+55 -38
View File
@@ -102,58 +102,65 @@ async def scrape_catalog_list(enseigne: Enseigne) -> list[dict[str, Any]]:
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.
Detects total pages from pagination links on first page. Detects total pages from pagination links (must check page 2, as page 1 doesn't show them).
""" """
pages = [] pages = []
page_num = 1 page_num = 1
max_pages = 100 # Default safety limit max_pages = 100 # Default safety limit
# First page: detect total number of pages from pagination # IMPORTANT: Pagination links only appear on page 2+, not on page 1
first_page_html, _ = await browserless_service.get_page_content(catalog_url) # So we fetch page 2 first to detect the max pages
page_2_url = f"{catalog_url}?page=2"
page_2_html, _ = await browserless_service.get_page_content(page_2_url)
if not first_page_html: if page_2_html:
logger.error(f"Failed to fetch first page for {catalog_url}") soup = BeautifulSoup(page_2_html, 'html.parser')
return []
soup = BeautifulSoup(first_page_html, 'html.parser') # Try to detect max pages from pagination links on page 2
pagination_links = soup.find_all('a', href=True)
page_numbers = []
# Try to detect max pages from pagination links for link in pagination_links:
# Look for links with ?page=X or just numeric text href = link.get('href', '')
pagination_links = soup.find_all('a', href=True) text = link.get_text(strip=True)
page_numbers = []
for link in pagination_links: # Check if it's a page number link
href = link.get('href', '') if 'page=' in href:
text = link.get_text(strip=True) try:
page_match = re.search(r'page=(\d+)', href)
if page_match:
page_numbers.append(int(page_match.group(1)))
except:
pass
elif text.isdigit():
# Direct numeric link (e.g., "11")
page_numbers.append(int(text))
# Check if it's a page number link if page_numbers:
if 'page=' in href: detected_max = max(page_numbers)
try: # Sanity check: cataloguemate sometimes shows wrong page counts (e.g., 188)
page_match = re.search(r'page=(\d+)', href) # Real catalogs rarely exceed 30-40 pages. Cap at 50 to be safe.
if page_match: if detected_max > 50:
page_numbers.append(int(page_match.group(1))) logger.warning(f"Detected abnormally high page count ({detected_max}), capping at 50")
except: max_pages = 50
pass else:
elif text.isdigit(): max_pages = detected_max
# Direct numeric link (e.g., "11") logger.info(f"Detected {max_pages} total pages from pagination (checked page 2)")
page_numbers.append(int(text)) else:
logger.warning(f"Could not detect page count from page 2, using default limit of {max_pages}")
if page_numbers:
max_pages = max(page_numbers)
logger.info(f"Detected {max_pages} total pages from pagination")
else: else:
logger.warning(f"Could not detect page count, using default limit of {max_pages}") logger.warning(f"Could not fetch page 2 to detect pagination, using default limit")
# Now scrape all pages # 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 first page HTML for page 1 # Reuse page 2 HTML if we're on page 2
if page_num == 1: if page_num == 2 and page_2_html:
html_content = first_page_html html_content = page_2_html
else: else:
html_content, _ = await browserless_service.get_page_content(current_url) html_content, _ = await browserless_service.get_page_content(current_url)
@@ -252,12 +259,14 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
log.catalogues_trouves = len(catalogs_list) log.catalogues_trouves = len(catalogs_list)
for cat_info in catalogs_list: for cat_info in catalogs_list:
# Check if catalog exists # Generate content hash BEFORE checking existence (need pages for first image)
# First, check if catalog exists by URL
existing = db.query(Catalogue).filter( existing = db.query(Catalogue).filter(
Catalogue.catalogue_url == cat_info['url'] Catalogue.catalogue_url == cat_info['url']
).first() ).first()
if existing: if existing:
logger.info(f"Catalogue already exists by URL: {cat_info['title']} (ID: {existing.id})")
continue continue
# 2. Scrape pages for new catalog # 2. Scrape pages for new catalog
@@ -266,11 +275,19 @@ async def scrape_enseigne(enseigne: Enseigne, db: Session) -> ScrapingLog:
if not pages: if not pages:
continue continue
# Create catalog entry # Generate content hash based on title and first image (stable identifier)
# Generate content hash (URL + Title + Date) hash_input = f"{cat_info['title']}{pages[0]['image_url']}".encode('utf-8')
hash_input = f"{cat_info['url']}{cat_info['title']}{datetime.now().date()}".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
existing_by_hash = db.query(Catalogue).filter(
Catalogue.content_hash == content_hash
).first()
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})")
continue
new_cat = Catalogue( new_cat = Catalogue(
enseigne_id=enseigne.id, enseigne_id=enseigne.id,
titre=cat_info['title'], titre=cat_info['title'],
+109 -32
View File
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { toast } from 'sonner'; import { toast } from 'sonner';
import { Loader2, Calendar, BookOpen, Search, ChevronRight, Eye, Trash2 } from 'lucide-react'; import { Loader2, Calendar, BookOpen, Search, ChevronRight, Eye, Trash2, ChevronLeft, X } from 'lucide-react';
import axios from 'axios'; import axios from 'axios';
import { useAuth } from '../hooks/use-auth'; import { useAuth } from '../hooks/use-auth';
@@ -15,6 +15,7 @@ export default function Catalogues() {
const [selectedCatalogue, setSelectedCatalogue] = useState(null); const [selectedCatalogue, setSelectedCatalogue] = useState(null);
const [cataloguePages, setCataloguePages] = useState([]); const [cataloguePages, setCataloguePages] = useState([]);
const [searchQuery, setSearchQuery] = useState(''); const [searchQuery, setSearchQuery] = useState('');
const [currentPageIndex, setCurrentPageIndex] = useState(0);
const { user, getAuthHeaders } = useAuth(); const { user, getAuthHeaders } = useAuth();
useEffect(() => { useEffect(() => {
@@ -86,12 +87,14 @@ export default function Catalogues() {
const openCatalogue = (catalogue) => { const openCatalogue = (catalogue) => {
setSelectedCatalogue(catalogue); setSelectedCatalogue(catalogue);
setCurrentPageIndex(0); // Reset to first page
loadCataloguePages(catalogue.id); loadCataloguePages(catalogue.id);
}; };
const closeCatalogue = () => { const closeCatalogue = () => {
setSelectedCatalogue(null); setSelectedCatalogue(null);
setCataloguePages([]); setCataloguePages([]);
setCurrentPageIndex(0);
}; };
const formatDate = (dateString) => { const formatDate = (dateString) => {
@@ -126,6 +129,24 @@ export default function Catalogues() {
} }
}; };
// Keyboard navigation support
useEffect(() => {
const handleKeyDown = (e) => {
if (!selectedCatalogue) return;
if (e.key === 'ArrowLeft') {
setCurrentPageIndex(prev => Math.max(0, prev - 1));
} else if (e.key === 'ArrowRight') {
setCurrentPageIndex(prev => Math.min(cataloguePages.length - 1, prev + 1));
} else if (e.key === 'Escape') {
closeCatalogue();
}
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [selectedCatalogue, cataloguePages.length]);
return ( return (
<div className="p-6 max-w-7xl mx-auto"> <div className="p-6 max-w-7xl mx-auto">
{/* Header */} {/* Header */}
@@ -311,53 +332,109 @@ export default function Catalogues() {
</> </>
)} )}
{/* Catalogue Viewer Modal */} {/* Catalogue Viewer Modal - Page by Page Navigation */}
{selectedCatalogue && ( {selectedCatalogue && (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"> <div
<div className="bg-background rounded-lg max-w-4xl w-full max-h-[90vh] flex flex-col"> className="fixed inset-0 z-50 bg-black/95 flex items-center justify-center"
onClick={closeCatalogue}
>
<div
className="relative w-full h-full flex flex-col"
onClick={(e) => e.stopPropagation()}
>
{/* Header */} {/* Header */}
<div className="p-4 border-b flex justify-between items-start"> <div className="bg-black/50 backdrop-blur-sm p-4 flex justify-between items-center">
<div> <div className="flex items-center gap-4">
<h2 className="text-xl font-bold mb-1">{selectedCatalogue.titre}</h2> <h2 className="text-white text-xl font-bold">{selectedCatalogue.titre}</h2>
<p className="text-sm text-muted-foreground"> <span className="text-white/70 text-sm">
{selectedCatalogue.enseigne.nom} • {cataloguePages.length} pages {selectedCatalogue.enseigne.nom}
</p> </span>
</div>
<div className="flex items-center gap-4">
{cataloguePages.length > 0 && (
<span className="text-white text-sm font-medium">
Page {currentPageIndex + 1} / {cataloguePages.length}
</span>
)}
<button
onClick={closeCatalogue}
className="p-2 text-white hover:bg-white/20 rounded-lg transition-colors"
title="Fermer (ESC)"
>
<X className="h-6 w-6" />
</button>
</div> </div>
<button
onClick={closeCatalogue}
className="px-4 py-2 rounded-lg hover:bg-secondary"
>
Fermer
</button>
</div> </div>
{/* Pages Viewer */} {/* Main Image Viewer */}
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 flex items-center justify-center p-8 relative">
{cataloguePages.length === 0 ? ( {cataloguePages.length === 0 ? (
<div className="flex justify-center py-20"> <Loader2 className="h-12 w-12 animate-spin text-white" />
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
) : ( ) : (
<div className="grid grid-cols-2 md:grid-cols-3 gap-4"> <>
{cataloguePages.map(page => ( {/* Current Page Image */}
<div <div className="max-w-4xl max-h-full flex items-center justify-center">
<img
src={cataloguePages[currentPageIndex]?.image_url}
alt={`Page ${currentPageIndex + 1}`}
className="max-w-full max-h-full object-contain rounded-lg shadow-2xl"
/>
</div>
{/* Navigation Buttons */}
{cataloguePages.length > 1 && (
<>
{/* Previous Button */}
<button
onClick={() => setCurrentPageIndex(prev => Math.max(0, prev - 1))}
disabled={currentPageIndex === 0}
className="absolute left-4 top-1/2 -translate-y-1/2 p-4 bg-black/50 hover:bg-black/70 text-white rounded-full disabled:opacity-30 disabled:cursor-not-allowed transition-all"
title="Page précédente (←)"
>
<ChevronLeft className="h-8 w-8" />
</button>
{/* Next Button */}
<button
onClick={() => setCurrentPageIndex(prev => Math.min(cataloguePages.length - 1, prev + 1))}
disabled={currentPageIndex === cataloguePages.length - 1}
className="absolute right-4 top-1/2 -translate-y-1/2 p-4 bg-black/50 hover:bg-black/70 text-white rounded-full disabled:opacity-30 disabled:cursor-not-allowed transition-all"
title="Page suivante (→)"
>
<ChevronRight className="h-8 w-8" />
</button>
</>
)}
</>
)}
</div>
{/* Thumbnail Strip */}
{cataloguePages.length > 1 && (
<div className="bg-black/50 backdrop-blur-sm p-4">
<div className="flex gap-2 overflow-x-auto pb-2">
{cataloguePages.map((page, index) => (
<button
key={page.id} key={page.id}
className="relative aspect-[3/4] rounded-lg overflow-hidden border cursor-pointer hover:shadow-lg transition-shadow" onClick={() => setCurrentPageIndex(index)}
onClick={() => window.open(page.image_url, '_blank')} className={`relative flex-shrink-0 w-20 h-28 rounded-lg overflow-hidden border-2 transition-all ${index === currentPageIndex
? 'border-primary scale-110'
: 'border-white/20 hover:border-white/50'
}`}
> >
<img <img
src={page.image_url} src={page.image_url}
alt={`Page ${page.numero_page}`} alt={`Page ${index + 1}`}
className="w-full h-full object-cover" className="w-full h-full object-cover"
/> />
<div className="absolute top-2 left-2 px-2 py-1 rounded bg-black/70 text-white text-xs font-medium"> <div className="absolute bottom-0 left-0 right-0 bg-black/70 text-white text-xs text-center py-0.5">
Page {page.numero_page} {index + 1}
</div> </div>
</div> </button>
))} ))}
</div> </div>
)} </div>
</div> )}
</div> </div>
</div> </div>
)} )}