967 lines
		
	
	
		
			No EOL
		
	
	
		
			45 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
			
		
		
	
	
			967 lines
		
	
	
		
			No EOL
		
	
	
		
			45 KiB
		
	
	
	
		
			Python
		
	
	
	
	
	
| #!/usr/bin/env python3
 | ||
| """
 | ||
| Script de scraping pour l'agenda du libre (https://www.agendadulibre.org/)
 | ||
| Utilise le fichier iCal 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 icalendar
 | ||
| from icalendar import Calendar, Event
 | ||
| import logging
 | ||
| 
 | ||
| # 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('agendadulibre_scraper.log'),
 | ||
|         logging.StreamHandler(sys.stdout)
 | ||
|     ]
 | ||
| )
 | ||
| logger = logging.getLogger(__name__)
 | ||
| 
 | ||
| class AgendaDuLibreScraper:
 | ||
|     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 = "agendadulibre_events.json"
 | ||
|         self.cache_file = "agendadulibre_cache.json"
 | ||
|         self.ical_file = "agendadulibre_events.ics"
 | ||
|         self.ical_url = "https://www.agendadulibre.org/events.ics"
 | ||
|         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 d'événements depuis le fichier JSON local"""
 | ||
|         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.error(f"Erreur lors du chargement du fichier {self.data_file}: {e}")
 | ||
|                 return {"events": {}, "last_update": None}
 | ||
|         return {"events": {}, "last_update": None}
 | ||
|     
 | ||
|     def load_cache_data(self) -> Dict:
 | ||
|         """Charge les données de cache depuis le fichier JSON local"""
 | ||
|         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.error(f"Erreur lors du chargement du fichier cache {self.cache_file}: {e}")
 | ||
|                 return {"processed_events": {}, "last_ical_fetch": None, "ical_content_hash": None}
 | ||
|         return {"processed_events": {}, "last_ical_fetch": None, "ical_content_hash": None}
 | ||
|     
 | ||
|     def save_events_data(self):
 | ||
|         """Sauvegarde les données d'événements dans le fichier JSON local"""
 | ||
|         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 du fichier {self.data_file}: {e}")
 | ||
|     
 | ||
|     def save_cache_data(self):
 | ||
|         """Sauvegarde les données de cache dans le fichier JSON local"""
 | ||
|         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 fichier cache {self.cache_file}: {e}")
 | ||
|     
 | ||
|     def is_ical_cache_valid(self) -> bool:
 | ||
|         """Vérifie si le cache iCal est encore valide (moins d'une heure)"""
 | ||
|         if not os.path.exists(self.ical_file):
 | ||
|             return False
 | ||
|         
 | ||
|         try:
 | ||
|             file_time = os.path.getmtime(self.ical_file)
 | ||
|             cache_age = datetime.now().timestamp() - file_time
 | ||
|             cache_age_hours = cache_age / 3600
 | ||
|             
 | ||
|             logger.debug(f"Cache iCal âgé de {cache_age_hours:.2f} heures")
 | ||
|             return cache_age_hours < self.cache_duration_hours
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"Erreur lors de la vérification du cache iCal: {e}")
 | ||
|             return False
 | ||
|     
 | ||
|     def get_content_hash(self, content: bytes) -> str:
 | ||
|         """Calcule le hash du contenu pour détecter les changements"""
 | ||
|         import hashlib
 | ||
|         return hashlib.md5(content).hexdigest()
 | ||
|     
 | ||
|     def is_ical_content_changed(self, new_content: bytes) -> bool:
 | ||
|         """Vérifie si le contenu iCal a changé depuis la dernière fois"""
 | ||
|         new_hash = self.get_content_hash(new_content)
 | ||
|         old_hash = self.cache_data.get("ical_content_hash")
 | ||
|         return new_hash != old_hash
 | ||
|     
 | ||
|     def save_ical_cache(self, ical_content: bytes):
 | ||
|         """Sauvegarde le contenu iCal en cache local"""
 | ||
|         try:
 | ||
|             with open(self.ical_file, 'wb') as f:
 | ||
|                 f.write(ical_content)
 | ||
|             logger.info(f"Cache iCal sauvegardé dans {self.ical_file}")
 | ||
|             
 | ||
|             # Mettre à jour le cache JSON avec le hash du contenu
 | ||
|             self.cache_data["ical_content_hash"] = self.get_content_hash(ical_content)
 | ||
|             self.cache_data["last_ical_fetch"] = datetime.now().isoformat()
 | ||
|             self.save_cache_data()
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"Erreur lors de la sauvegarde du cache iCal: {e}")
 | ||
|     
 | ||
|     def load_ical_cache(self) -> Optional[bytes]:
 | ||
|         """Charge le contenu iCal depuis le cache local"""
 | ||
|         try:
 | ||
|             with open(self.ical_file, 'rb') as f:
 | ||
|                 content = f.read()
 | ||
|             logger.info(f"Cache iCal chargé depuis {self.ical_file}")
 | ||
|             return content
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"Erreur lors du chargement du cache iCal: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def fetch_ical_data(self, force_refresh: bool = False) -> Optional[Calendar]:
 | ||
|         """Récupère et parse le fichier iCal depuis l'agenda du libre ou depuis le cache"""
 | ||
|         ical_content = None
 | ||
|         
 | ||
|         # Vérifier si le cache est valide (sauf si on force le rechargement)
 | ||
|         if not force_refresh and self.is_ical_cache_valid():
 | ||
|             logger.info("Utilisation du cache iCal local (moins d'une heure)")
 | ||
|             ical_content = self.load_ical_cache()
 | ||
|         else:
 | ||
|             if force_refresh:
 | ||
|                 logger.info(f"Rechargement forcé du fichier iCal depuis {self.ical_url}")
 | ||
|             else:
 | ||
|                 logger.info(f"Cache iCal expiré ou absent, téléchargement depuis {self.ical_url}")
 | ||
|             
 | ||
|             try:
 | ||
|                 response = requests.get(self.ical_url, timeout=30)
 | ||
|                 response.raise_for_status()
 | ||
|                 ical_content = response.content
 | ||
|                 
 | ||
|                 # Vérifier si le contenu a changé
 | ||
|                 if not self.is_ical_content_changed(ical_content):
 | ||
|                     logger.info("Contenu iCal identique au précédent, utilisation du cache existant")
 | ||
|                     ical_content = self.load_ical_cache()
 | ||
|                 else:
 | ||
|                     logger.info("Nouveau contenu iCal détecté, mise à jour du cache")
 | ||
|                     # Sauvegarder en cache
 | ||
|                     self.save_ical_cache(ical_content)
 | ||
|                 
 | ||
|             except requests.RequestException as e:
 | ||
|                 logger.error(f"Erreur lors de la récupération du fichier iCal: {e}")
 | ||
|                 # Essayer de charger depuis le cache même s'il est expiré
 | ||
|                 logger.info("Tentative de chargement depuis le cache expiré...")
 | ||
|                 ical_content = self.load_ical_cache()
 | ||
|         
 | ||
|         if ical_content is None:
 | ||
|             logger.error("Impossible de récupérer le contenu iCal")
 | ||
|             return None
 | ||
|         
 | ||
|         try:
 | ||
|             calendar = Calendar.from_ical(ical_content)
 | ||
|             logger.info(f"Fichier iCal parsé avec succès")
 | ||
|             return calendar
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"Erreur lors du parsing du fichier iCal: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def parse_event(self, event: Event) -> Optional[Dict]:
 | ||
|         """Parse un événement iCal et le convertit au format OEDB"""
 | ||
|         try:
 | ||
|             # Récupérer les propriétés de base
 | ||
|             summary = str(event.get('summary', ''))
 | ||
|             description = str(event.get('description', ''))
 | ||
|             location = str(event.get('location', ''))
 | ||
|             url = str(event.get('url', ''))
 | ||
|             
 | ||
|             # Extraire les coordonnées GEO si disponibles
 | ||
|             geo_coords = self.extract_geo_coordinates(event)
 | ||
|             
 | ||
|             # Extraire les catégories si disponibles
 | ||
|             categories = self.extract_categories(event)
 | ||
|             
 | ||
|             # Extraire les propriétés supplémentaires
 | ||
|             organizer = self.extract_organizer(event)
 | ||
|             alt_description = self.extract_alt_description(event)
 | ||
|             short_description = self.extract_short_description(event)
 | ||
|             sequence = self.extract_sequence(event)
 | ||
|             repeat_rules = self.extract_repeat_rules(event)
 | ||
|             
 | ||
|             # Gestion des dates
 | ||
|             dtstart = event.get('dtstart')
 | ||
|             dtend = event.get('dtend')
 | ||
|             
 | ||
|             if not dtstart:
 | ||
|                 logger.warning(f"Événement sans date de début: {summary}")
 | ||
|                 return None
 | ||
|             
 | ||
|             # Convertir les dates
 | ||
|             start_date = dtstart.dt
 | ||
|             if isinstance(start_date, datetime):
 | ||
|                 start_iso = start_date.isoformat()
 | ||
|             else:
 | ||
|                 # Date seulement (sans heure)
 | ||
|                 start_iso = f"{start_date}T00:00:00"
 | ||
|             
 | ||
|             end_date = None
 | ||
|             if dtend:
 | ||
|                 end_dt = dtend.dt
 | ||
|                 if isinstance(end_dt, datetime):
 | ||
|                     end_iso = end_dt.isoformat()
 | ||
|                 else:
 | ||
|                     end_iso = f"{end_dt}T23:59:59"
 | ||
|             else:
 | ||
|                 # Si pas de date de fin, ajouter 2 heures par défaut
 | ||
|                 if isinstance(start_date, datetime):
 | ||
|                     end_iso = (start_date + timedelta(hours=2)).isoformat()
 | ||
|                 else:
 | ||
|                     end_iso = f"{start_date}T02:00:00"
 | ||
|             
 | ||
|             # Créer l'événement au format OEDB
 | ||
|             oedb_event = {
 | ||
|                 "properties": {
 | ||
|                     "label": summary,
 | ||
|                     "description": description,
 | ||
|                     "type": "scheduled",
 | ||
|                     "what": "culture.floss",  # Type par défaut pour l'agenda du libre
 | ||
|                     "where": location,
 | ||
|                     "start": start_iso,
 | ||
|                     "stop": end_iso,
 | ||
|                     "url": url if url else None,
 | ||
|                     "source:name": "Agenda du Libre",
 | ||
|                     "source:url": "https://www.agendadulibre.org/",
 | ||
|                     "last_modified_by": "agendadulibre_scraper",
 | ||
|                     "tags": categories if categories else [],  # Ajouter les catégories comme tags
 | ||
|                     "organizer": organizer,  # Organisateur de l'événement
 | ||
|                     "alt_description": alt_description,  # Description alternative HTML
 | ||
|                     "short_description": short_description,  # Description courte
 | ||
|                     "sequence": sequence,  # Numéro de séquence
 | ||
|                     "repeat_rules": repeat_rules  # Règles de répétition
 | ||
|                 },
 | ||
|                 "geometry": {
 | ||
|                     "type": "Point",
 | ||
|                     "coordinates": geo_coords if geo_coords else [0, 0]  # Utiliser GEO ou coordonnées par défaut
 | ||
|                 }
 | ||
|             }
 | ||
|             
 | ||
|             # Créer un ID unique basé sur le contenu
 | ||
|             event_id = self.generate_event_id(summary, start_iso, location)
 | ||
|             
 | ||
|             return {
 | ||
|                 "id": event_id,
 | ||
|                 "event": oedb_event,
 | ||
|                 
 | ||
|             }
 | ||
|             
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"Erreur lors du parsing de l'événement: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def extract_geo_coordinates(self, event: Event) -> Optional[List[float]]:
 | ||
|         """Extrait les coordonnées du champ GEO: de l'événement iCal"""
 | ||
|         try:
 | ||
|             geo = event.get('geo')
 | ||
|             if geo:
 | ||
|                 # Le champ GEO peut être sous différentes formes
 | ||
|                 if hasattr(geo, 'lat') and hasattr(geo, 'lon'):
 | ||
|                     # Format avec attributs lat/lon
 | ||
|                     lat = float(geo.lat)
 | ||
|                     lon = float(geo.lon)
 | ||
|                     logger.info(f"📍 Coordonnées GEO trouvées: {lat}, {lon}")
 | ||
|                     return [lon, lat]  # Format GeoJSON (longitude, latitude)
 | ||
|                 else:
 | ||
|                     # Format string "latitude;longitude"
 | ||
|                     geo_str = str(geo)
 | ||
|                     if ';' in geo_str:
 | ||
|                         parts = geo_str.split(';')
 | ||
|                         if len(parts) == 2:
 | ||
|                             lat = float(parts[0].strip())
 | ||
|                             lon = float(parts[1].strip())
 | ||
|                             logger.info(f"📍 Coordonnées GEO trouvées: {lat}, {lon}")
 | ||
|                             return [lon, lat]  # Format GeoJSON (longitude, latitude)
 | ||
|                     else:
 | ||
|                         logger.debug(f"Format GEO non reconnu: {geo_str}")
 | ||
|             else:
 | ||
|                 logger.debug("Aucun champ GEO trouvé")
 | ||
|                 return None
 | ||
|         except (ValueError, AttributeError, TypeError) as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction des coordonnées GEO: {e}")
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"Erreur inattendue lors de l'extraction GEO: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def extract_categories(self, event: Event) -> List[str]:
 | ||
|         """Extrait les catégories du champ CATEGORIES: de l'événement iCal"""
 | ||
|         try:
 | ||
|             categories = []
 | ||
|             
 | ||
|             # Le champ CATEGORIES peut apparaître plusieurs fois
 | ||
|             for category in event.get('categories', []):
 | ||
|                 if category:
 | ||
|                     # Extraire la valeur de l'objet vCategory
 | ||
|                     if hasattr(category, 'cats'):
 | ||
|                         # Si c'est un objet vCategory avec des catégories
 | ||
|                         for cat in category.cats:
 | ||
|                             cat_str = str(cat).strip()
 | ||
|                             if cat_str:
 | ||
|                                 categories.append(cat_str)
 | ||
|                     else:
 | ||
|                         # Sinon, convertir directement en string
 | ||
|                         cat_str = str(category).strip()
 | ||
|                         if cat_str:
 | ||
|                             categories.append(cat_str)
 | ||
|             
 | ||
|             if categories:
 | ||
|                 logger.info(f"🏷️ Catégories trouvées: {', '.join(categories)}")
 | ||
|             else:
 | ||
|                 logger.debug("Aucune catégorie trouvée")
 | ||
|             
 | ||
|             return categories
 | ||
|             
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction des catégories: {e}")
 | ||
|             return []
 | ||
|     
 | ||
|     def extract_organizer(self, event: Event) -> Optional[str]:
 | ||
|         """Extrait l'organisateur du champ ORGANIZER: de l'événement iCal"""
 | ||
|         try:
 | ||
|             organizer = event.get('organizer')
 | ||
|             if organizer:
 | ||
|                 organizer_str = str(organizer).strip()
 | ||
|                 if organizer_str:
 | ||
|                     logger.debug(f"👤 Organisateur trouvé: {organizer_str}")
 | ||
|                     return organizer_str
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction de l'organisateur: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def extract_alt_description(self, event: Event) -> Optional[str]:
 | ||
|         """Extrait la description alternative HTML du champ X-ALT-DESC;FMTTYPE=text/html: de l'événement iCal"""
 | ||
|         try:
 | ||
|             # Chercher le champ X-ALT-DESC avec FMTTYPE=text/html
 | ||
|             for prop in event.property_items():
 | ||
|                 if prop[0] == 'X-ALT-DESC' and hasattr(prop[1], 'params') and prop[1].params.get('FMTTYPE') == 'text/html':
 | ||
|                     alt_desc = str(prop[1]).strip()
 | ||
|                     if alt_desc:
 | ||
|                         logger.debug(f"📄 Description alternative HTML trouvée: {len(alt_desc)} caractères")
 | ||
|                         return alt_desc
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction de la description alternative: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def extract_short_description(self, event: Event) -> Optional[str]:
 | ||
|         """Extrait la description courte du champ SUMMARY: de l'événement iCal"""
 | ||
|         try:
 | ||
|             summary = event.get('summary')
 | ||
|             if summary:
 | ||
|                 summary_str = str(summary).strip()
 | ||
|                 if summary_str:
 | ||
|                     logger.debug(f"📝 Description courte trouvée: {summary_str}")
 | ||
|                     return summary_str
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction de la description courte: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def extract_sequence(self, event: Event) -> Optional[int]:
 | ||
|         """Extrait le numéro de séquence du champ SEQUENCE: de l'événement iCal"""
 | ||
|         try:
 | ||
|             sequence = event.get('sequence')
 | ||
|             if sequence is not None:
 | ||
|                 seq_num = int(sequence)
 | ||
|                 logger.debug(f"🔢 Séquence trouvée: {seq_num}")
 | ||
|                 return seq_num
 | ||
|             return None
 | ||
|         except (ValueError, TypeError) as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction de la séquence: {e}")
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur inattendue lors de l'extraction de la séquence: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def extract_repeat_rules(self, event: Event) -> Optional[str]:
 | ||
|         """Extrait les règles de répétition du champ RRULE: de l'événement iCal"""
 | ||
|         try:
 | ||
|             # Essayer différentes variantes de casse
 | ||
|             rrule = event.get('rrule') or event.get('RRULE') or event.get('Rrule')
 | ||
|             
 | ||
|             if rrule:
 | ||
|                 rrule_str = str(rrule).strip()
 | ||
|                 if rrule_str:
 | ||
|                     logger.info(f"🔄 Règles de répétition trouvées: {rrule_str}")
 | ||
|                     return rrule_str
 | ||
|             
 | ||
|             # Vérifier aussi dans les propriétés avec parcours manuel
 | ||
|             for prop in event.property_items():
 | ||
|                 if prop[0].upper() == 'RRULE':
 | ||
|                     rrule_str = str(prop[1]).strip()
 | ||
|                     if rrule_str:
 | ||
|                         logger.info(f"🔄 Règles de répétition trouvées (parcours): {rrule_str}")
 | ||
|                         return rrule_str
 | ||
|             
 | ||
|             # Note: Pas de log ici car c'est normal qu'il n'y ait pas de RRULE
 | ||
|             # dans tous les événements (seulement les événements récurrents en ont)
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur lors de l'extraction des règles de répétition: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def generate_event_id(self, summary: str, start_date: str, location: str) -> str:
 | ||
|         """Génère un ID unique pour l'événement"""
 | ||
|         import hashlib
 | ||
|         content = f"{summary}_{start_date}_{location}"
 | ||
|         return hashlib.md5(content.encode('utf-8')).hexdigest()
 | ||
|     
 | ||
|     def clean_location_for_geocoding(self, location: str) -> Optional[str]:
 | ||
|         """Nettoie le lieu pour le géocodage en extrayant l'adresse après la première virgule"""
 | ||
|         if not location or location.strip() == "":
 | ||
|             return None
 | ||
|         
 | ||
|         # Diviser par la première virgule
 | ||
|         parts = location.split(',', 1)
 | ||
|         if len(parts) > 1:
 | ||
|             # Prendre la partie après la première virgule
 | ||
|             address_part = parts[1].strip()
 | ||
|             
 | ||
|             # Vérifier si on a un numéro et une adresse
 | ||
|             # Pattern pour détecter un numéro suivi d'une adresse
 | ||
|             address_pattern = r'^\s*\d+.*'
 | ||
|             if re.match(address_pattern, address_part):
 | ||
|                 logger.info(f"📍 Adresse potentielle trouvée: {address_part}")
 | ||
|                 return address_part
 | ||
|         
 | ||
|         # Si pas de virgule ou pas d'adresse valide, essayer le lieu complet
 | ||
|         logger.info(f"📍 Tentative de géocodage avec le lieu complet: {location}")
 | ||
|         return location.strip()
 | ||
|     
 | ||
|     def geocode_with_nominatim(self, location: str) -> Optional[Tuple[float, float]]:
 | ||
|         """Géocode un lieu avec Nominatim"""
 | ||
|         if not location:
 | ||
|             return None
 | ||
|         
 | ||
|         try:
 | ||
|             # URL de l'API Nominatim
 | ||
|             nominatim_url = "https://nominatim.openstreetmap.org/search"
 | ||
|             
 | ||
|             # Paramètres de la requête
 | ||
|             params = {
 | ||
|                 'q': location,
 | ||
|                 'format': 'json',
 | ||
|                 'limit': 1,
 | ||
|                 'countrycodes': 'fr',  # Limiter à la France
 | ||
|                 'addressdetails': 1
 | ||
|             }
 | ||
|             
 | ||
|             headers = {
 | ||
|                 'User-Agent': 'AgendaDuLibreScraper/1.0 (contact@example.com)'
 | ||
|             }
 | ||
|             
 | ||
|             logger.info(f"🌍 Géocodage avec Nominatim: {location}")
 | ||
|             
 | ||
|             # Faire la requête avec un timeout
 | ||
|             response = requests.get(nominatim_url, params=params, headers=headers, timeout=10)
 | ||
|             response.raise_for_status()
 | ||
|             
 | ||
|             # Parser la réponse
 | ||
|             results = response.json()
 | ||
|             
 | ||
|             if results and len(results) > 0:
 | ||
|                 result = results[0]
 | ||
|                 lat = float(result['lat'])
 | ||
|                 lon = float(result['lon'])
 | ||
|                 
 | ||
|                 logger.info(f"✅ Géocodage réussi: {location} -> ({lat}, {lon})")
 | ||
|                 logger.info(f"   Adresse trouvée: {result.get('display_name', 'N/A')}")
 | ||
|                 
 | ||
|                 # Respecter la limite de 1 requête par seconde pour Nominatim
 | ||
|                 time.sleep(1)
 | ||
|                 
 | ||
|                 return (lon, lat)  # Retourner (longitude, latitude) pour GeoJSON
 | ||
|             else:
 | ||
|                 logger.warning(f"⚠️ Aucun résultat de géocodage pour: {location}")
 | ||
|                 return None
 | ||
|                 
 | ||
|         except requests.RequestException as e:
 | ||
|             logger.error(f"❌ Erreur de connexion Nominatim: {e}")
 | ||
|             return None
 | ||
|         except (ValueError, KeyError) as e:
 | ||
|             logger.error(f"❌ Erreur de parsing Nominatim: {e}")
 | ||
|             return None
 | ||
|         except Exception as e:
 | ||
|             logger.error(f"❌ Erreur inattendue lors du géocodage: {e}")
 | ||
|             return None
 | ||
|     
 | ||
|     def improve_event_coordinates(self, event_data: Dict) -> Dict:
 | ||
|         """Améliore les coordonnées de l'événement si nécessaire"""
 | ||
|         coords = event_data["event"]["geometry"]["coordinates"]
 | ||
|         
 | ||
|         # Vérifier si les coordonnées sont par défaut (0, 0)
 | ||
|         if coords == [0, 0]:
 | ||
|             location = event_data["event"]["properties"].get("where", "")
 | ||
|             
 | ||
|             if location:
 | ||
|                 # Nettoyer le lieu pour le géocodage
 | ||
|                 clean_location = self.clean_location_for_geocoding(location)
 | ||
|                 
 | ||
|                 if clean_location:
 | ||
|                     # Tenter le géocodage
 | ||
|                     new_coords = self.geocode_with_nominatim(clean_location)
 | ||
|                     
 | ||
|                     if new_coords:
 | ||
|                         # Mettre à jour les coordonnées
 | ||
|                         event_data["event"]["geometry"]["coordinates"] = list(new_coords)
 | ||
|                         logger.info(f"🎯 Coordonnées mises à jour par géocodage: {coords} -> {new_coords}")
 | ||
|                     else:
 | ||
|                         logger.warning(f"⚠️ Impossible de géocoder: {clean_location}")
 | ||
|                 else:
 | ||
|                     logger.info(f"ℹ️ Lieu non géocodable: {location}")
 | ||
|             else:
 | ||
|                 logger.info("ℹ️ Aucun lieu spécifié pour le géocodage")
 | ||
|         else:
 | ||
|             # Vérifier si les coordonnées viennent du champ GEO
 | ||
|             geo_coords = event_data.get("raw_ical", {}).get("geo")
 | ||
|             if geo_coords:
 | ||
|                 logger.info(f"✅ Coordonnées utilisées depuis le champ GEO: {coords}")
 | ||
|             else:
 | ||
|                 logger.info(f"ℹ️ Coordonnées déjà définies: {coords}")
 | ||
|         
 | ||
|         return event_data
 | ||
|     
 | ||
|     def log_event_details(self, event_data: Dict):
 | ||
|         """Log détaillé de l'événement avant envoi"""
 | ||
|         props = event_data["event"]["properties"]
 | ||
|         geom = event_data["event"]["geometry"]
 | ||
|         
 | ||
|         logger.info("📝 Détails de l'événement à insérer:")
 | ||
|         # INSERT_YOUR_CODE
 | ||
|         # Affiche un dump lisible de l'événement avec json.dumps (indentation)
 | ||
|         try:
 | ||
|             logger.info(json.dumps(event_data, ensure_ascii=False, indent=2))
 | ||
|         except Exception as e:
 | ||
|             logger.warning(f"Erreur lors de l'affichage lisible de l'événement: {e}")
 | ||
|         # logger.info(event_data)
 | ||
|         # 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"   Organisateur: {props.get('organizer', 'N/A')}")
 | ||
|         # logger.info(f"   Description courte: {props.get('short_description', 'N/A')}")
 | ||
|         # logger.info(f"   Séquence: {props.get('sequence', 'N/A')}")
 | ||
|         # logger.info(f"   Règles de répétition: {props.get('repeat_rules', 'N/A')}")
 | ||
|         # logger.info(f"   Description HTML: {'Oui' if props.get('alt_description') else 'N/A'}")
 | ||
|         # logger.info(f"   Modifié par: {props.get('last_modified_by', 'N/A')}")
 | ||
|     
 | ||
|     def send_event_to_api(self, event_data: Dict, skip_geocoding: bool = False) -> Tuple[bool, str]:
 | ||
|         """Envoie un événement à l'API OEDB (ou simule en mode dry-run)"""
 | ||
|         # Améliorer les coordonnées si nécessaire (sauf si déjà traité)
 | ||
|         if not skip_geocoding:
 | ||
|             event_data = self.improve_event_coordinates(event_data)
 | ||
|         else:
 | ||
|             logger.info("ℹ️ Géocodage ignoré - événement déjà traité")
 | ||
|         
 | ||
|         # 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
 | ||
|             skip_geocoding = False
 | ||
|             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"]:
 | ||
|                     skip_geocoding = True
 | ||
|                     logger.info(f"ℹ️ Géocodage ignoré pour {event_label} - déjà traité")
 | ||
|             
 | ||
|             # Envoyer à l'API
 | ||
|             success, message = self.send_event_to_api(event_data, skip_geocoding=skip_geocoding)
 | ||
|             
 | ||
|             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, calendar: Calendar) -> Dict:
 | ||
|         """Traite tous les événements du calendrier"""
 | ||
|         stats = {
 | ||
|             "total_events": 0,
 | ||
|             "new_events": 0,
 | ||
|             "already_saved": 0,
 | ||
|             "api_errors": 0,
 | ||
|             "parse_errors": 0,
 | ||
|             "sent_this_run": 0,
 | ||
|             "skipped_due_to_limit": 0
 | ||
|         }
 | ||
|         
 | ||
|         events_to_process = []
 | ||
|         pending_events = []  # Événements en attente d'envoi
 | ||
|         processed_count = 0
 | ||
|         
 | ||
|         # Parcourir tous les événements
 | ||
|         for component in calendar.walk():
 | ||
|             if component.name == "VEVENT":
 | ||
|                 stats["total_events"] += 1
 | ||
|                 
 | ||
|                 # Parser l'événement
 | ||
|                 parsed_event = self.parse_event(component)
 | ||
|                 if not parsed_event:
 | ||
|                     stats["parse_errors"] += 1
 | ||
|                     continue
 | ||
|                 
 | ||
|                 event_id = parsed_event["id"]
 | ||
|                 event_label = parsed_event["event"]["properties"]["label"]
 | ||
|                 
 | ||
|                 # Vérifier le statut de l'événement
 | ||
|                 event_status = None
 | ||
|                 skip_reason = ""
 | ||
|                 
 | ||
|                 # Vérifier dans les données d'événements
 | ||
|                 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"]:
 | ||
|                         stats["already_saved"] += 1
 | ||
|                         logger.info(f"⏭️ Événement ignoré: {event_label} - déjà traité (status: {event_status})")
 | ||
|                         continue
 | ||
|                 
 | ||
|                 # Vérifier dans le cache des événements traités
 | ||
|                 if event_id in self.cache_data["processed_events"]:
 | ||
|                     cache_status = self.cache_data["processed_events"][event_id].get("status", "unknown")
 | ||
|                     if cache_status in ["saved", "already_exists"]:
 | ||
|                         stats["already_saved"] += 1
 | ||
|                         logger.info(f"⏭️ Événement ignoré: {event_label} - déjà dans le cache (status: {cache_status})")
 | ||
|                         continue
 | ||
|                 
 | ||
|                 # Déterminer la priorité de l'événement
 | ||
|                 priority = 0  # 0 = nouveau, 1 = en attente, 2 = échec précédent
 | ||
|                 
 | ||
|                 if event_status in ["pending", "failed", "api_error"]:
 | ||
|                     priority = 1  # Priorité haute pour les événements en attente
 | ||
|                     logger.info(f"🔄 Événement en attente prioritaire: {event_label} (status: {event_status})")
 | ||
|                 elif event_id in self.cache_data["processed_events"]:
 | ||
|                     cache_status = self.cache_data["processed_events"][event_id].get("status", "unknown")
 | ||
|                     if cache_status in ["pending", "failed", "api_error"]:
 | ||
|                         priority = 1  # Priorité haute pour les événements en attente dans le cache
 | ||
|                         logger.info(f"🔄 Événement en attente du cache: {event_label} (status: {cache_status})")
 | ||
|                 
 | ||
|                 # Ajouter l'événement avec sa priorité
 | ||
|                 event_with_priority = {
 | ||
|                     "event": parsed_event,
 | ||
|                     "priority": priority,
 | ||
|                     "event_id": event_id,
 | ||
|                     "event_label": event_label
 | ||
|                 }
 | ||
|                 
 | ||
|                 if priority > 0:
 | ||
|                     pending_events.append(event_with_priority)
 | ||
|                 else:
 | ||
|                     events_to_process.append(event_with_priority)
 | ||
|         
 | ||
|         # Trier les événements : d'abord les événements en attente, puis les nouveaux
 | ||
|         all_events = pending_events + events_to_process
 | ||
|         all_events.sort(key=lambda x: x["priority"], reverse=True)  # Priorité décroissante
 | ||
|         
 | ||
|         # Appliquer la limite d'événements
 | ||
|         if self.max_events:
 | ||
|             all_events = all_events[:self.max_events]
 | ||
|             if len(pending_events) + len(events_to_process) > self.max_events:
 | ||
|                 stats["skipped_due_to_limit"] = len(pending_events) + len(events_to_process) - self.max_events
 | ||
|         
 | ||
|         # Extraire les événements pour le traitement
 | ||
|         events_to_process = [item["event"] for item in all_events]
 | ||
|         
 | ||
|         # Traiter les événements
 | ||
|         if self.parallel and len(events_to_process) > 10:
 | ||
|             logger.info(f"🚀 Traitement parallèle de {len(events_to_process)} é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_to_process
 | ||
|                 }
 | ||
|                 
 | ||
|                 # 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:
 | ||
|                         stats["new_events"] += 1
 | ||
|                         stats["sent_this_run"] += 1
 | ||
|                         self.events_data["events"][event_id] = {
 | ||
|                             "status": "saved",
 | ||
|                             "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",
 | ||
|                             "event_label": event_label
 | ||
|                         }
 | ||
|                         logger.info(f"✅ {event_label} - {message}")
 | ||
|                     else:
 | ||
|                         if "déjà existant" in message or "already exists" in message.lower():
 | ||
|                             stats["already_saved"] += 1
 | ||
|                             self.events_data["events"][event_id] = {
 | ||
|                                 "status": "already_exists",
 | ||
|                                 "message": message,
 | ||
|                                 "last_attempt": datetime.now().isoformat(),
 | ||
|                                 "event": event_data["event"]
 | ||
|                             }
 | ||
|                             # Ajouter au cache même si déjà existant
 | ||
|                             self.cache_data["processed_events"][event_id] = {
 | ||
|                                 "processed_at": datetime.now().isoformat(),
 | ||
|                                 "status": "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_to_process)} événements par batch de {self.batch_size}")
 | ||
|             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 i in range(0, len(events_to_process), self.batch_size):
 | ||
|                 batch = events_to_process[i:i + self.batch_size]
 | ||
|                 logger.info(f"Traitement du batch {i//self.batch_size + 1}/{(len(events_to_process) + self.batch_size - 1)//self.batch_size}")
 | ||
|                 
 | ||
|                 for event_data in batch:
 | ||
|                     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:
 | ||
|                         stats["new_events"] += 1
 | ||
|                         stats["sent_this_run"] += 1
 | ||
|                         self.events_data["events"][event_id] = {
 | ||
|                             "status": "saved",
 | ||
|                             "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",
 | ||
|                             "event_label": event_label
 | ||
|                         }
 | ||
|                         logger.info(f"✅ {event_label} - {message}")
 | ||
|                     else:
 | ||
|                         if "déjà existant" in message or "already exists" in message.lower():
 | ||
|                             stats["already_saved"] += 1
 | ||
|                             self.events_data["events"][event_id] = {
 | ||
|                                 "status": "already_exists",
 | ||
|                                 "message": message,
 | ||
|                                 "last_attempt": datetime.now().isoformat(),
 | ||
|                                 "event": event_data["event"]
 | ||
|                             }
 | ||
|                             # Ajouter au cache même si déjà existant
 | ||
|                             self.cache_data["processed_events"][event_id] = {
 | ||
|                                 "processed_at": datetime.now().isoformat(),
 | ||
|                                 "status": "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 du libre")
 | ||
|         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(f"Cache iCal: {'ignoré' if force_refresh else f'valide pendant {self.cache_duration_hours}h'}")
 | ||
|         
 | ||
|         # Récupérer le fichier iCal
 | ||
|         calendar = self.fetch_ical_data(force_refresh=force_refresh)
 | ||
|         if not calendar:
 | ||
|             logger.error("❌ Impossible de récupérer le fichier iCal")
 | ||
|             return False
 | ||
|         
 | ||
|         # Traiter les événements
 | ||
|         stats = self.process_events(calendar)
 | ||
|         
 | ||
|         # Sauvegarder les données
 | ||
|         self.save_events_data()
 | ||
|         
 | ||
|         # Afficher les statistiques finales
 | ||
|         logger.info("📊 Statistiques finales:")
 | ||
|         logger.info(f"   Total d'événements trouvés: {stats['total_events']}")
 | ||
|         logger.info(f"   Nouveaux événements envoyés: {stats['new_events']}")
 | ||
|         logger.info(f"   Événements déjà existants: {stats['already_saved']}")
 | ||
|         logger.info(f"   Erreurs d'API: {stats['api_errors']}")
 | ||
|         logger.info(f"   Erreurs de parsing: {stats['parse_errors']}")
 | ||
|         logger.info(f"   Événements envoyés cette fois: {stats['sent_this_run']}")
 | ||
|         if stats['skipped_due_to_limit'] > 0:
 | ||
|             logger.info(f"   Événements ignorés (limite atteinte): {stats['skipped_due_to_limit']}")
 | ||
|         
 | ||
|         logger.info("✅ Scraping terminé avec succès")
 | ||
|         return True
 | ||
| 
 | ||
| def main():
 | ||
|     parser = argparse.ArgumentParser(description="Scraper pour l'agenda du libre")
 | ||
|     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=None,
 | ||
|                        help="Limiter le nombre d'événements à traiter (défaut: aucun)")
 | ||
|     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 du fichier iCal (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 = AgendaDuLibreScraper(
 | ||
|         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é
 | ||
|     success = scraper.run(force_refresh=args.force_refresh)
 | ||
|     sys.exit(0 if success else 1)
 | ||
| 
 | ||
| if __name__ == "__main__":
 | ||
|     main() | 
