oedb-backend/extractors/ccpl_agenda.py
2025-10-10 15:11:10 +02:00

977 lines
43 KiB
Python
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env python3
"""
Script de scraping pour l'agenda de la CCPL (Communauté de Communes du Pays de Limours)
https://www.cc-paysdelimours.fr/agenda
Utilise le scraping HTML pour récupérer les événements et les envoyer à l'API OEDB
"""
import requests
import json
import os
import sys
import argparse
import re
import time
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Tuple
import logging
from bs4 import BeautifulSoup
import hashlib
# Configuration par défaut
api_oedb = "https://api.openeventdatabase.org"
# Configuration du logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('ccpl_agenda_scraper.log'),
logging.StreamHandler()
]
)
logger = logging.getLogger(__name__)
class CCPLAgendaScraper:
def __init__(self, api_base_url: str = api_oedb, batch_size: int = 1, max_events: int = None, dry_run: bool = True,
parallel: bool = False, max_workers: int = 4):
self.api_base_url = api_base_url
self.batch_size = batch_size
self.max_events = max_events
self.dry_run = dry_run
self.parallel = parallel
self.max_workers = max_workers
self.data_file = "ccpl_agenda_events.json"
self.cache_file = "ccpl_agenda_cache.json"
self.agenda_url = "https://www.cc-paysdelimours.fr/agenda"
self.cache_duration_hours = 1 # Durée de cache en heures
# Charger les données existantes
self.events_data = self.load_events_data()
self.cache_data = self.load_cache_data()
def load_events_data(self) -> Dict:
"""Charge les données des événements depuis le fichier JSON"""
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Erreur lors du chargement des données: {e}")
return {
"events": {},
"last_update": None
}
def save_events_data(self):
"""Sauvegarde les données des événements dans le fichier JSON"""
try:
with open(self.data_file, 'w', encoding='utf-8') as f:
json.dump(self.events_data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Erreur lors de la sauvegarde des données: {e}")
def load_cache_data(self) -> Dict:
"""Charge les données du cache depuis le fichier JSON"""
if os.path.exists(self.cache_file):
try:
with open(self.cache_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
logger.warning(f"Erreur lors du chargement du cache: {e}")
return {
"processed_events": {},
"last_fetch": None,
"content_hash": None
}
def save_cache_data(self):
"""Sauvegarde les données du cache dans le fichier JSON"""
try:
with open(self.cache_file, 'w', encoding='utf-8') as f:
json.dump(self.cache_data, f, ensure_ascii=False, indent=2)
except Exception as e:
logger.error(f"Erreur lors de la sauvegarde du cache: {e}")
def get_content_hash(self, content: str) -> str:
"""Génère un hash du contenu pour détecter les changements"""
import hashlib
return hashlib.md5(content.encode('utf-8')).hexdigest()
def is_content_changed(self, new_hash: str) -> bool:
"""Vérifie si le contenu a changé depuis la dernière récupération"""
cached_hash = self.cache_data.get("content_hash")
return cached_hash != new_hash
def fetch_agenda_data(self, force_refresh: bool = False) -> Optional[str]:
"""Récupère les données de l'agenda CCPL"""
try:
logger.info(f"🌐 Récupération de l'agenda CCPL: {self.agenda_url}")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
response = requests.get(self.agenda_url, headers=headers, timeout=30)
response.raise_for_status()
content = response.text
content_hash = self.get_content_hash(content)
# Vérifier si le contenu a changé ou si on force le rafraîchissement
if self.is_content_changed(content_hash) or force_refresh:
if force_refresh:
logger.info("🔄 Rafraîchissement forcé, mise à jour du cache")
else:
logger.info("🔄 Nouveau contenu détecté, mise à jour du cache")
self.cache_data["content_hash"] = content_hash
self.cache_data["last_fetch"] = datetime.now().isoformat()
self.save_cache_data()
return content
else:
logger.info(" Contenu identique au précédent, utilisation du cache")
return None
except requests.RequestException as e:
logger.error(f"❌ Erreur lors de la récupération de l'agenda: {e}")
return None
except Exception as e:
logger.error(f"❌ Erreur inattendue: {e}")
return None
def parse_agenda_html(self, html_content: str) -> List[Dict]:
"""Parse le HTML de l'agenda pour extraire les événements"""
try:
soup = BeautifulSoup(html_content, 'html.parser')
events = []
# D'après l'analyse HTML, les événements sont dans des liens <a> avec des classes spécifiques
# Chercher les liens d'événements
event_links = soup.find_all('a', class_=re.compile(r'col-lg-3|col-sm-6|mb-3'))
logger.info(f"🔗 {len(event_links)} liens d'événements trouvés")
for i, link in enumerate(event_links):
if self.max_events and len(events) >= self.max_events:
break
try:
event_data = self.extract_event_data_from_link(link, i)
if event_data:
events.append(event_data)
except Exception as e:
logger.warning(f"Erreur lors du parsing de l'événement {i}: {e}")
continue
# Si pas d'événements trouvés avec les liens, essayer une approche alternative
if not events:
logger.info("🔍 Tentative d'extraction alternative...")
# Chercher par pattern de date dans les spans
date_spans = soup.find_all('span', class_='small')
for i, span in enumerate(date_spans):
if self.max_events and len(events) >= self.max_events:
break
# Trouver l'élément parent qui contient l'événement
parent = span.parent
while parent and parent.name != 'a':
parent = parent.parent
if parent and parent.name == 'a':
try:
event_data = self.extract_event_data_from_link(parent, i)
if event_data:
events.append(event_data)
except Exception as e:
logger.warning(f"Erreur lors du parsing alternatif de l'événement {i}: {e}")
continue
logger.info(f"📅 {len(events)} événements extraits au total")
return events
except Exception as e:
logger.error(f"❌ Erreur lors du parsing HTML: {e}")
return []
def extract_event_data_from_link(self, link_element, index: int) -> Optional[Dict]:
"""Extrait les données d'un événement depuis un lien d'événement"""
try:
# Extraire l'URL
url = link_element.get('href', '')
if url.startswith('/'):
url = f"https://www.cc-paysdelimours.fr{url}"
# Extraire le titre
title_elem = link_element.find('p', class_='agenda-title')
title = title_elem.get_text(strip=True) if title_elem else f"Événement {index + 1}"
# Extraire la date
date_text = ""
date_wrapper = link_element.find('div', class_='date-wrapper')
if date_wrapper:
# Extraire le jour
day_elem = date_wrapper.find('span', class_='number')
day = day_elem.get_text(strip=True) if day_elem else ""
# Extraire le mois
month_elem = date_wrapper.find('span', class_='small')
month = month_elem.get_text(strip=True) if month_elem else ""
if day and month:
date_text = f"{day} {month}"
# Extraire l'image si disponible
image_elem = link_element.find('img')
image_url = ""
if image_elem:
src = image_elem.get('src', '')
if src.startswith('/'):
image_url = f"https://www.cc-paysdelimours.fr{src}"
elif src.startswith('http'):
image_url = src
# Extraire le lieu (par défaut)
location = "Pays de Limours, France"
# Récupérer les détails supplémentaires depuis la page de l'événement
details = {}
if url:
details = self.fetch_event_details(url)
# Utiliser les coordonnées de la carte si disponibles
coordinates = self.get_coordinates_for_location(location)
if details.get("coordinates"):
coordinates = details["coordinates"]
logger.info(f"📍 Coordonnées précises utilisées: {coordinates}")
# Utiliser l'adresse détaillée si disponible
if details.get("address"):
location = details["address"]
logger.info(f"📍 Adresse détaillée: {location}")
# Générer un ID unique
event_id = self.generate_event_id(title, date_text, location)
# Construire les propriétés de contact (seulement si non vides)
contact_properties = {}
if details.get("contact_phone") and details["contact_phone"].strip():
contact_properties["contact:phone"] = details["contact_phone"]
if details.get("contact_email") and details["contact_email"].strip():
contact_properties["contact:email"] = details["contact_email"]
if details.get("website") and details["website"].strip():
contact_properties["contact:website"] = details["website"]
# Construire la description enrichie
description = f"Événement organisé par la CCPL - {title}"
if details.get("description"):
description = details["description"]
# Ajouter les informations d'ouverture et de tarifs
additional_info = []
if details.get("opening_hours"):
additional_info.append(f"Ouverture: {details['opening_hours']}")
if details.get("pricing"):
additional_info.append(f"Tarifs: {details['pricing']}")
if additional_info:
description += "\n\n" + "\n".join(additional_info)
# Créer l'événement au format OEDB
properties = {
"label": title,
"description": description,
"type": "scheduled",
"what": "culture.community.ccpl",
"where": location,
"start": self.parse_date(date_text),
"stop": self.parse_date(date_text, end=True),
"source:name": "CCPL Agenda",
"source:url": self.agenda_url,
"last_modified_by": "ccpl_agenda_scraper",
"tags": ["ccpl", "pays-de-limours", "événement-communal"]
}
# Ajouter les propriétés optionnelles seulement si elles ne sont pas nulles
if url and url.strip():
properties["url"] = url
if image_url and image_url.strip():
properties["image"] = image_url
# Ajouter les propriétés de contact
properties.update(contact_properties)
oedb_event = {
"properties": properties,
"geometry": {
"type": "Point",
"coordinates": coordinates
}
}
return {
"id": event_id,
"event": oedb_event,
"raw_html": {
"title": title,
"date": date_text,
"location": location,
"url": url,
"image": image_url
}
}
except Exception as e:
logger.error(f"Erreur lors de l'extraction de l'événement depuis le lien: {e}")
return None
def fetch_event_details(self, event_url: str) -> Dict:
"""Récupère les détails supplémentaires depuis la page de l'événement"""
try:
logger.info(f"🔍 Récupération des détails: {event_url}")
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
response = requests.get(event_url, headers=headers, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
details = {
"description": "",
"contact_phone": "",
"contact_email": "",
"website": "",
"coordinates": None,
"address": "",
"opening_hours": "",
"pricing": ""
}
# Extraire la description principale
description_elem = soup.find('div', class_=re.compile(r'content|description|text', re.I))
if description_elem:
# Nettoyer le texte de la description
description_text = description_elem.get_text(strip=True)
# Enlever les "Offres liées" et autres sections non pertinentes
lines = description_text.split('\n')
cleaned_lines = []
skip_section = False
for line in lines:
line = line.strip()
if not line:
continue
if 'Offres liées' in line or 'TOUT L\'AGENDA' in line:
skip_section = True
break
if 'Partager sur' in line:
break
cleaned_lines.append(line)
details["description"] = ' '.join(cleaned_lines)
# Extraire les informations de contact depuis toute la page
page_text = soup.get_text()
# Téléphone (format français)
phone_match = re.search(r'(\d{2}\s?\d{2}\s?\d{2}\s?\d{2}\s?\d{2})', page_text)
if phone_match:
details["contact_phone"] = phone_match.group(1).replace(' ', '')
# Email
email_match = re.search(r'([a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,})', page_text)
if email_match:
email = email_match.group(1).strip()
# Nettoyer l'email (enlever les caractères parasites à la fin, notamment le T majuscule)
email = re.sub(r'[^a-zA-Z0-9._%+-@]+$', '', email)
# Enlever spécifiquement le T majuscule à la fin
if email.endswith('T'):
email = email[:-1]
details["contact_email"] = email
# Site web (éviter les liens de partage social)
website_links = soup.find_all('a', href=True)
for link in website_links:
href = link['href']
if (href.startswith('http') and
'facebook.com' not in href and
'twitter.com' not in href and
'linkedin.com' not in href and
'viadeo.com' not in href and
'x.com' not in href and
'instagram.com' not in href and
'tiktok.com' not in href and
'youtube.com' not in href and
'vimeo.com' not in href and
'soundcloud.com' not in href and
'spotify.com' not in href and
'deezer.com' not in href and
'apple.com' not in href and
'google.com' not in href and
'microsoft.com' not in href and
'amazon.com' not in href and
'sharer' not in href):
details["website"] = href
break
# Extraire l'adresse
address_elem = soup.find(text=re.compile(r'Place|Rue|Avenue|Boulevard', re.I))
if address_elem:
# Trouver l'élément parent qui contient l'adresse complète
parent = address_elem.parent
while parent and len(parent.get_text(strip=True)) < 20:
parent = parent.parent
if parent:
details["address"] = parent.get_text(strip=True)
# Extraire les coordonnées depuis la carte Leaflet
# Chercher les scripts qui contiennent les coordonnées de la carte
scripts = soup.find_all('script')
for script in scripts:
if script.string:
# Chercher les coordonnées dans les scripts Leaflet avec différents patterns
patterns = [
r'lat["\']?\s*:\s*([0-9.-]+).*?lng["\']?\s*:\s*([0-9.-]+)',
r'latitude["\']?\s*:\s*([0-9.-]+).*?longitude["\']?\s*:\s*([0-9.-]+)',
r'center["\']?\s*:\s*\[([0-9.-]+),\s*([0-9.-]+)\]',
r'lat["\']?\s*:\s*([0-9.-]+).*?lon["\']?\s*:\s*([0-9.-]+)',
r'([0-9]{1,2}\.[0-9]+),\s*([0-9]{1,2}\.[0-9]+)'
]
for pattern in patterns:
coord_match = re.search(pattern, script.string)
if coord_match:
try:
lat = float(coord_match.group(1))
lng = float(coord_match.group(2))
# Vérifier que les coordonnées sont dans une plage valide pour la France
if 41 <= lat <= 52 and -6 <= lng <= 10:
details["coordinates"] = [lng, lat] # Format GeoJSON [longitude, latitude]
logger.info(f"📍 Coordonnées trouvées: {lat}, {lng}")
break
except ValueError:
continue
if details["coordinates"]:
break
# Extraire les horaires d'ouverture
opening_elem = soup.find(text=re.compile(r'Du.*au.*tous les jours|Ouverture|Horaires', re.I))
if opening_elem:
parent = opening_elem.parent
if parent:
details["opening_hours"] = parent.get_text(strip=True)
# Extraire les tarifs
pricing_elem = soup.find(text=re.compile(r'Gratuit|Tarifs|Prix', re.I))
if pricing_elem:
parent = pricing_elem.parent
if parent:
details["pricing"] = parent.get_text(strip=True)
logger.info(f"📋 Détails extraits: {len(details['description'])} caractères, tel: {details['contact_phone']}, email: {details['contact_email']}")
return details
except Exception as e:
logger.warning(f"Erreur lors de la récupération des détails de {event_url}: {e}")
return {
"description": "",
"contact_phone": "",
"contact_email": "",
"website": "",
"coordinates": None,
"address": "",
"opening_hours": "",
"pricing": ""
}
def extract_event_data(self, element, index: int) -> Optional[Dict]:
"""Extrait les données d'un événement depuis un élément HTML"""
try:
# Obtenir tout le texte de l'élément
full_text = element.get_text(strip=True)
# Extraire la date
date_text = ""
date_match = re.search(r'\b(\d{1,2})\s+(jan|fév|mar|avr|mai|jun|jul|aoû|sep|oct|nov|déc)\b', full_text, re.I)
if date_match:
date_text = f"{date_match.group(1)} {date_match.group(2)}"
# Extraire le titre (première ligne significative après la date)
lines = [line.strip() for line in full_text.split('\n') if line.strip()]
title = f"Événement {index + 1}"
# Chercher le titre dans les lignes
for line in lines:
if line and not re.match(r'^\d{1,2}\s+(jan|fév|mar|avr|mai|jun|jul|aoû|sep|oct|nov|déc)', line, re.I):
title = line[:100] # Limiter la longueur
break
# Extraire le lieu
location = "Pays de Limours, France" # Lieu par défaut
communes = ['Angervilliers', 'Fontenay-lès-Briis', 'Forges-les-Bains', 'Gometz-la-Ville',
'Les Molières', 'Limours', 'Saint-Maurice-Montcouronne', 'Vaugrigneuse']
for commune in communes:
if commune.lower() in full_text.lower():
location = f"{commune}, Pays de Limours, France"
break
# Extraire la description (texte complet sans la date)
description = full_text
if date_text:
description = description.replace(date_text, '').strip()
# Nettoyer la description
description = re.sub(r'\s+', ' ', description).strip()
if len(description) > 200:
description = description[:200] + "..."
# Extraire l'URL si disponible
url = ""
link_elem = element.find('a', href=True)
if link_elem:
href = link_elem['href']
if href.startswith('/'):
url = f"https://www.cc-paysdelimours.fr{href}"
elif href.startswith('http'):
url = href
# Générer un ID unique
event_id = self.generate_event_id(title, date_text, location)
# Créer l'événement au format OEDB
oedb_event = {
"properties": {
"label": title,
"description": description,
"type": "scheduled",
"what": "culture.community", # Type pour événements communautaires
"where": location,
"start": self.parse_date(date_text),
"stop": self.parse_date(date_text, end=True),
"url": url if url else None,
"source:name": "CCPL Agenda",
"source:url": self.agenda_url,
"last_modified_by": "ccpl_agenda_scraper",
"tags": ["ccpl", "pays-de-limours", "événement-communal"]
},
"geometry": {
"type": "Point",
"coordinates": self.get_coordinates_for_location(location)
}
}
return {
"id": event_id,
"event": oedb_event,
"raw_html": {
"title": title,
"date": date_text,
"location": location,
"description": description,
"url": url
}
}
except Exception as e:
logger.error(f"Erreur lors de l'extraction de l'événement: {e}")
return None
def parse_date(self, date_text: str, end: bool = False) -> str:
"""Parse une date française et la convertit en format ISO"""
try:
if not date_text:
# Date par défaut si pas de date trouvée
now = datetime.now()
if end:
return (now + timedelta(hours=2)).isoformat()
return now.isoformat()
# Mapping des mois français
months = {
'jan': '01', 'fév': '02', 'mar': '03', 'avr': '04', 'mai': '05', 'jun': '06',
'jul': '07', 'aoû': '08', 'sep': '09', 'oct': '10', 'nov': '11', 'déc': '12'
}
# Extraire jour et mois
match = re.search(r'(\d{1,2})\s+(\w{3})', date_text.lower())
if match:
day = match.group(1).zfill(2)
month_abbr = match.group(2)
month = months.get(month_abbr, '01')
# Utiliser l'année courante
year = datetime.now().year
# Créer la date
date_obj = datetime.strptime(f"{year}-{month}-{day}", "%Y-%m-%d")
if end:
# Date de fin: ajouter 2 heures
date_obj += timedelta(hours=2)
return date_obj.isoformat()
# Fallback: date actuelle
now = datetime.now()
if end:
return (now + timedelta(hours=2)).isoformat()
return now.isoformat()
except Exception as e:
logger.warning(f"Erreur lors du parsing de la date '{date_text}': {e}")
now = datetime.now()
if end:
return (now + timedelta(hours=2)).isoformat()
return now.isoformat()
def get_coordinates_for_location(self, location: str) -> List[float]:
"""Obtient les coordonnées pour un lieu du Pays de Limours"""
# Coordonnées approximatives pour les communes du Pays de Limours
coordinates = {
"Angervilliers": [2.0644, 48.5917],
"Fontenay-lès-Briis": [2.0644, 48.5917],
"Forges-les-Bains": [2.0644, 48.5917],
"Gometz-la-Ville": [2.0644, 48.5917],
"Les Molières": [2.0644, 48.5917],
"Limours": [2.0644, 48.5917],
"Saint-Maurice-Montcouronne": [2.0644, 48.5917],
"Vaugrigneuse": [2.0644, 48.5917]
}
for commune, coords in coordinates.items():
if commune.lower() in location.lower():
return coords
# Coordonnées par défaut pour Limours (centre du Pays de Limours)
return [2.0644, 48.5917]
def generate_event_id(self, title: str, date: str, location: str) -> str:
"""Génère un ID unique pour l'événement"""
import hashlib
content = f"{title}_{date}_{location}"
return hashlib.md5(content.encode('utf-8')).hexdigest()
def log_event_details(self, event_data: Dict):
"""Affiche les détails de l'événement dans les logs"""
props = event_data["event"]["properties"]
geom = event_data["event"]["geometry"]
logger.info("📝 Détails de l'événement à insérer:")
logger.info(json.dumps(event_data, ensure_ascii=False, indent=2))
# logger.info(f" ID: {event_data['id']}")
# logger.info(f" Titre: {props.get('label', 'N/A')}")
# logger.info(f" Description: {props.get('description', 'N/A')[:100]}{'...' if len(props.get('description', '')) > 100 else ''}")
# logger.info(f" Type: {props.get('type', 'N/A')}")
# logger.info(f" Catégorie: {props.get('what', 'N/A')}")
# logger.info(f" Lieu: {props.get('where', 'N/A')}")
# logger.info(f" Début: {props.get('start', 'N/A')}")
# logger.info(f" Fin: {props.get('stop', 'N/A')}")
# logger.info(f" URL: {props.get('url', 'N/A')}")
# logger.info(f" Source: {props.get('source:name', 'N/A')}")
# logger.info(f" Coordonnées: {geom.get('coordinates', 'N/A')}")
# logger.info(f" Tags: {', '.join(props.get('tags', [])) if props.get('tags') else 'N/A'}")
# logger.info(f" Modifié par: {props.get('last_modified_by', 'N/A')}")
# Afficher les nouvelles propriétés de contact (seulement si présentes)
if props.get('contact:phone'):
logger.info(f" 📞 Téléphone: {props.get('contact:phone')}")
if props.get('contact:email'):
logger.info(f" 📧 Email: {props.get('contact:email')}")
if props.get('contact:website'):
logger.info(f" 🌐 Site web: {props.get('contact:website')}")
if props.get('image'):
logger.info(f" 🖼️ Image: {props.get('image')}")
if props.get('url'):
logger.info(f" 🔗 URL: {props.get('url')}")
def send_event_to_api(self, event_data: Dict) -> Tuple[bool, str]:
"""Envoie un événement à l'API OEDB (ou simule en mode dry-run)"""
# Log détaillé de l'événement
self.log_event_details(event_data)
if self.dry_run:
logger.info(f"[DRY-RUN] Simulation d'envoi de l'événement: {event_data['event']['properties']['label']}")
return True, "Simulé (dry-run)"
try:
url = f"{self.api_base_url}/event"
headers = {"Content-Type": "application/json"}
# Formater l'événement au format GeoJSON attendu par l'API
geojson_event = {
"type": "Feature",
"geometry": event_data["event"]["geometry"],
"properties": event_data["event"]["properties"]
}
logger.info(f"🌐 Envoi à l'API: {url}")
response = requests.post(url, json=geojson_event, headers=headers, timeout=30)
if response.status_code == 201:
logger.info("✅ Événement créé avec succès dans l'API")
return True, "Créé avec succès"
elif response.status_code == 409:
logger.warning("⚠️ Événement déjà existant dans l'API")
return False, "Événement déjà existant"
else:
logger.error(f"❌ Erreur API: {response.status_code} - {response.text}")
return False, f"Erreur API: {response.status_code} - {response.text}"
except requests.RequestException as e:
logger.error(f"❌ Erreur de connexion: {e}")
return False, f"Erreur de connexion: {e}"
except Exception as e:
logger.error(f"❌ Erreur inattendue: {e}")
return False, f"Erreur inattendue: {e}"
def process_single_event(self, event_data: Dict) -> Tuple[str, bool, str]:
"""Traite un événement individuellement (thread-safe)"""
event_id = event_data["id"]
event_label = event_data["event"]["properties"]["label"]
try:
# Vérifier si l'événement a déjà été traité avec succès
if event_id in self.events_data["events"]:
event_status = self.events_data["events"][event_id].get("status", "unknown")
if event_status in ["saved", "already_exists"]:
logger.info(f" Événement déjà traité: {event_label}")
return event_id, True, "Déjà traité"
# Envoyer à l'API
success, message = self.send_event_to_api(event_data)
return event_id, success, message
except Exception as e:
logger.error(f"❌ Erreur lors du traitement de {event_label}: {e}")
return event_id, False, f"Erreur: {e}"
def process_events(self, events: List[Dict]) -> Dict:
"""Traite tous les événements"""
stats = {
"total_events": len(events),
"new_events": 0,
"already_saved": 0,
"api_errors": 0,
"parse_errors": 0,
"sent_this_run": 0,
"skipped_due_to_limit": 0
}
if not events:
logger.info(" Aucun événement à traiter")
return stats
# Appliquer la limite d'événements
if self.max_events:
events = events[:self.max_events]
if len(events) < stats["total_events"]:
stats["skipped_due_to_limit"] = stats["total_events"] - len(events)
# Traiter les événements
if self.parallel and len(events) > 10:
logger.info(f"🚀 Traitement parallèle de {len(events)} événements avec {self.max_workers} workers")
if self.max_events:
logger.info(f"Limite d'événements: {self.max_events}")
if self.dry_run:
logger.info("Mode DRY-RUN activé - aucun événement ne sera envoyé à l'API")
# Traitement parallèle
with ThreadPoolExecutor(max_workers=self.max_workers) as executor:
# Soumettre tous les événements
future_to_event = {
executor.submit(self.process_single_event, event_data): event_data
for event_data in events
}
# Traiter les résultats au fur et à mesure
for future in as_completed(future_to_event):
event_data = future_to_event[future]
event_id, success, message = future.result()
event_label = event_data["event"]["properties"]["label"]
# Mettre à jour les statistiques et les données locales
if success:
if "déjà traité" in message.lower():
stats["already_saved"] += 1
else:
stats["new_events"] += 1
stats["sent_this_run"] += 1
self.events_data["events"][event_id] = {
"status": "saved" if "déjà traité" not in message.lower() else "already_exists",
"message": message,
"last_attempt": datetime.now().isoformat(),
"event": event_data["event"]
}
# Ajouter au cache des événements traités
self.cache_data["processed_events"][event_id] = {
"processed_at": datetime.now().isoformat(),
"status": "saved" if "déjà traité" not in message.lower() else "already_exists",
"event_label": event_label
}
logger.info(f"{event_label} - {message}")
else:
stats["api_errors"] += 1
self.events_data["events"][event_id] = {
"status": "api_error",
"message": message,
"last_attempt": datetime.now().isoformat(),
"event": event_data["event"]
}
logger.error(f"{event_label} - {message}")
# Sauvegarder les données après chaque événement
self.save_events_data()
self.save_cache_data()
else:
# Traitement séquentiel (mode original)
logger.info(f"Traitement séquentiel de {len(events)} événements")
if self.max_events:
logger.info(f"Limite d'événements: {self.max_events}")
if self.dry_run:
logger.info("Mode DRY-RUN activé - aucun événement ne sera envoyé à l'API")
for event_data in events:
event_id, success, message = self.process_single_event(event_data)
event_label = event_data["event"]["properties"]["label"]
# Mettre à jour les statistiques et les données locales
if success:
if "déjà traité" in message.lower():
stats["already_saved"] += 1
else:
stats["new_events"] += 1
stats["sent_this_run"] += 1
self.events_data["events"][event_id] = {
"status": "saved" if "déjà traité" not in message.lower() else "already_exists",
"message": message,
"last_attempt": datetime.now().isoformat(),
"event": event_data["event"]
}
# Ajouter au cache des événements traités
self.cache_data["processed_events"][event_id] = {
"processed_at": datetime.now().isoformat(),
"status": "saved" if "déjà traité" not in message.lower() else "already_exists",
"event_label": event_label
}
logger.info(f"{event_label} - {message}")
else:
stats["api_errors"] += 1
self.events_data["events"][event_id] = {
"status": "api_error",
"message": message,
"last_attempt": datetime.now().isoformat(),
"event": event_data["event"]
}
logger.error(f"{event_label} - {message}")
# Sauvegarder les données après chaque événement
self.save_events_data()
self.save_cache_data()
# Mettre à jour la date de dernière mise à jour
self.events_data["last_update"] = datetime.now().isoformat()
# Sauvegarder le cache
self.save_cache_data()
return stats
def run(self, force_refresh: bool = False):
"""Exécute le scraping complet"""
logger.info("🚀 Démarrage du scraping de l'agenda CCPL")
logger.info(f"Configuration: batch_size={self.batch_size}, api_url={self.api_base_url}")
logger.info(f"Mode dry-run: {'OUI' if self.dry_run else 'NON'}")
if self.max_events:
logger.info(f"Limite d'événements: {self.max_events}")
logger.info("=" * 60)
try:
# Récupérer les données de l'agenda
html_content = self.fetch_agenda_data(force_refresh)
if html_content is None and not force_refresh:
logger.info(" Utilisation du cache (pas de nouveau contenu)")
return
# Parser les événements
events = self.parse_agenda_html(html_content) if html_content else []
if not events:
logger.warning("⚠️ Aucun événement trouvé dans l'agenda")
return
logger.info(f"Traitement de {len(events)} événements")
# Traiter les événements
stats = self.process_events(events)
# Afficher les statistiques finales
logger.info("📊 Statistiques finales:")
for key, value in stats.items():
logger.info(f" {key}: {value}")
logger.info("✅ Scraping terminé avec succès")
except Exception as e:
logger.error(f"❌ Erreur lors du scraping: {e}")
raise
def main():
parser = argparse.ArgumentParser(description="Scraper pour l'agenda CCPL")
parser.add_argument("--api-url", default=api_oedb,
help=f"URL de base de l'API OEDB (défaut: {api_oedb})")
parser.add_argument("--batch-size", type=int, default=1,
help="Nombre d'événements à envoyer par batch (défaut: 1)")
parser.add_argument("--max-events", type=int, default=1,
help="Limiter le nombre d'événements à traiter (défaut: 1)")
parser.add_argument("--dry-run", action="store_true", default=True,
help="Mode dry-run par défaut (simulation sans envoi à l'API)")
parser.add_argument("--no-dry-run", action="store_true",
help="Désactiver le mode dry-run (envoi réel à l'API)")
parser.add_argument("--verbose", "-v", action="store_true",
help="Mode verbeux")
parser.add_argument("--force-refresh", "-f", action="store_true",
help="Forcer le rechargement de l'agenda (ignorer le cache)")
parser.add_argument("--cache-duration", type=int, default=1,
help="Durée de validité du cache en heures (défaut: 1)")
parser.add_argument("--parallel", action="store_true",
help="Activer le traitement parallèle pour plus de 10 événements")
parser.add_argument("--max-workers", type=int, default=4,
help="Nombre maximum de workers pour le traitement parallèle (défaut: 4)")
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
# Déterminer le mode dry-run
dry_run = args.dry_run and not args.no_dry_run
# Créer et exécuter le scraper
scraper = CCPLAgendaScraper(
api_base_url=args.api_url,
batch_size=args.batch_size,
max_events=args.max_events,
dry_run=dry_run,
parallel=args.parallel,
max_workers=args.max_workers
)
# Modifier la durée de cache si spécifiée
scraper.cache_duration_hours = args.cache_duration
# Exécuter avec ou sans rechargement forcé
scraper.run(force_refresh=args.force_refresh)
if __name__ == "__main__":
main()