diff --git a/extractors/osm_cal.py b/extractors/osm_cal.py index 663d3b2..5263bc0 100755 --- a/extractors/osm_cal.py +++ b/extractors/osm_cal.py @@ -42,6 +42,8 @@ import xml.etree.ElementTree as ET import re import html from datetime import datetime, timedelta +from bs4 import BeautifulSoup +import unicodedata # Add the parent directory to the path so we can import from oedb sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))) @@ -53,6 +55,178 @@ from oedb.utils.logging import logger RSS_URL = "https://osmcal.org/events.rss" # Base URL for OSM Calendar events OSMCAL_EVENT_BASE_URL = "https://osmcal.org/event/" +# Main OSM Calendar page +OSMCAL_MAIN_URL = "https://osmcal.org" +# Cache file for processed events +CACHE_FILE = os.path.join(os.path.dirname(__file__), 'osm_cal_cache.json') + +def fix_encoding(text): + """ + Corrige les problèmes d'encodage UTF-8 courants. + + Args: + text (str): Texte potentiellement mal encodé + + Returns: + str: Texte avec l'encodage corrigé + """ + if not text: + return text + + try: + # Essayer de détecter et corriger l'encodage double UTF-8 + # (UTF-8 interprété comme Latin-1 puis réencodé en UTF-8) + if 'Ã' in text: + # Encoder en latin-1 puis décoder en UTF-8 + corrected = text.encode('latin-1').decode('utf-8') + logger.info(f"Encodage corrigé : '{text}' -> '{corrected}'") + return corrected + except (UnicodeEncodeError, UnicodeDecodeError): + # Si la correction échoue, essayer d'autres méthodes + try: + # Normaliser les caractères Unicode + normalized = unicodedata.normalize('NFKD', text) + return normalized + except: + pass + + # Si aucune correction ne fonctionne, retourner le texte original + return text + +def load_event_cache(): + """ + Charge le cache des événements traités depuis le fichier JSON. + + Returns: + dict: Dictionnaire des événements avec leur statut de traitement + """ + if os.path.exists(CACHE_FILE): + try: + with open(CACHE_FILE, 'r', encoding='utf-8') as f: + cache = json.load(f) + logger.info(f"Cache chargé : {len(cache)} événements en cache") + return cache + except Exception as e: + logger.error(f"Erreur lors du chargement du cache : {e}") + return {} + else: + logger.info("Aucun cache trouvé, création d'un nouveau cache") + return {} + +def save_event_cache(cache): + """ + Sauvegarde le cache des événements dans le fichier JSON. + + Args: + cache (dict): Dictionnaire des événements avec leur statut + """ + try: + with open(CACHE_FILE, 'w', encoding='utf-8') as f: + json.dump(cache, f, indent=2, ensure_ascii=False) + logger.info(f"Cache sauvegardé : {len(cache)} événements") + except Exception as e: + logger.error(f"Erreur lors de la sauvegarde du cache : {e}") + +def scrape_osmcal_event_links(): + """ + Scrape la page principale d'osmcal.org pour extraire tous les liens d'événements. + + Returns: + list: Liste des URLs d'événements trouvés + """ + logger.info(f"Scraping de la page principale : {OSMCAL_MAIN_URL}") + + try: + 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(OSMCAL_MAIN_URL, headers=headers) + response.raise_for_status() + + soup = BeautifulSoup(response.content, 'html.parser') + + # Debugging : sauvegarder le HTML pour inspection + debug_file = os.path.join(os.path.dirname(__file__), 'osmcal_debug.html') + with open(debug_file, 'w', encoding='utf-8') as f: + f.write(response.text) + logger.info(f"HTML de débogage sauvegardé dans : {debug_file}") + + event_links = [] + + # Essayer différents sélecteurs basés sur la structure HTML fournie + selectors_to_try = [ + 'a.event-list-entry-box', # Sélecteur principal basé sur l'exemple HTML + 'li.event-list-entry a', # Sélecteur alternatif basé sur la structure + '.event-list-entry a', # Variation sans spécifier le tag li + 'a[href*="/event/"]', # Tous les liens contenant "/event/" + '.event-list-entry-box' # Au cas où ce serait juste la classe + ] + + for selector in selectors_to_try: + logger.info(f"Essai du sélecteur : {selector}") + elements = soup.select(selector) + logger.info(f"Trouvé {len(elements)} éléments avec le sélecteur {selector}") + + if elements: + for element in elements: + href = None + + # Si l'élément est déjà un lien + if element.name == 'a' and element.get('href'): + href = element.get('href') + # Si l'élément contient un lien + elif element.name != 'a': + link_element = element.find('a') + if link_element and link_element.get('href'): + href = link_element.get('href') + + if href: + # Construire l'URL complète si c'est un lien relatif + if href.startswith('/'): + # Enlever les paramètres de requête de l'URL de base + base_url = OSMCAL_MAIN_URL.split('?')[0] + if base_url.endswith('/'): + base_url = base_url[:-1] + full_url = base_url + href + else: + full_url = href + + # Vérifier que c'est bien un lien vers un événement + if '/event/' in href and full_url not in event_links: + event_links.append(full_url) + logger.info(f"Lien d'événement trouvé : {full_url}") + + # Si on a trouvé des liens avec ce sélecteur, on s'arrête + if event_links: + break + + # Si aucun lien trouvé, essayer de lister tous les liens pour débugger + if not event_links: + logger.warning("Aucun lien d'événement trouvé. Listing de tous les liens pour débogage :") + all_links = soup.find_all('a', href=True) + logger.info(f"Total de liens trouvés sur la page : {len(all_links)}") + + # Afficher les 10 premiers liens pour débogage + for i, link in enumerate(all_links[:10]): + logger.info(f"Lien {i+1}: {link.get('href')} (classes: {link.get('class', [])})") + + # Chercher spécifiquement les liens contenant "event" + event_related_links = [link for link in all_links if 'event' in link.get('href', '').lower()] + logger.info(f"Liens contenant 'event' : {len(event_related_links)}") + for link in event_related_links[:5]: + logger.info(f"Lien event: {link.get('href')}") + + logger.success(f"Trouvé {len(event_links)} liens d'événements uniques sur la page principale") + return event_links + + except requests.exceptions.RequestException as e: + logger.error(f"Erreur lors du scraping de osmcal.org : {e}") + return [] + except Exception as e: + logger.error(f"Erreur inattendue lors du scraping : {e}") + import traceback + logger.error(f"Traceback: {traceback.format_exc()}") + return [] def fetch_osm_calendar_data(): """ @@ -233,7 +407,8 @@ def fetch_ical_data(event_url): logger.warning(f"Failed to fetch iCal data: {response.status_code}") return ("Unknown Location", [0, 0]) - # Parse the iCal content + # Parse the iCal content avec l'encodage correct + response.encoding = response.apparent_encoding or 'utf-8' ical_content = response.text # Extract GEO information @@ -254,6 +429,8 @@ def fetch_ical_data(event_url): location_name = location_match.group(1).strip() # Unescape backslash-escaped characters (e.g., \, becomes ,) location_name = re.sub(r'\\(.)', r'\1', location_name) + # Corriger l'encodage + location_name = fix_encoding(location_name) logger.info(f"Extracted location from iCal: {location_name}") else: logger.warning(f"No LOCATION information found in iCal data for event: {event_id}") @@ -288,7 +465,7 @@ def extract_location(description): # The second paragraph often contains the location location_candidate = location_matches[1].strip() if location_candidate and "," in location_candidate and not location_candidate.startswith('<'): - location_name = location_candidate + location_name = fix_encoding(location_candidate) # For now, we don't have exact coordinates, so we'll use a placeholder # In a real implementation, you might want to geocode the location @@ -322,6 +499,10 @@ def create_event(item): clean_description = html.unescape(clean_description) clean_description = re.sub(r'\s+', ' ', clean_description).strip() + # Corriger l'encodage du titre et de la description + title = fix_encoding(title) + clean_description = fix_encoding(clean_description) + # Parse dates from the description start_date, end_date = parse_event_dates(description) @@ -491,36 +672,167 @@ def main(max_events=1, offset=0): logger.info("Environment variables loaded successfully from .env file") - # Fetch events from the OSM Calendar RSS feed - items = fetch_osm_calendar_data() + # Charger le cache des événements traités + event_cache = load_event_cache() - if not items: - logger.warning("No events found, exiting") + # Scraper la page principale pour obtenir tous les liens d'événements + event_links = scrape_osmcal_event_links() + + if not event_links: + logger.warning("Aucun lien d'événement trouvé sur la page principale") return - # Apply offset and limit - if offset >= len(items): - logger.warning(f"Offset {offset} is greater than or equal to the number of events {len(items)}, no events to process") - return - - # Slice the items list according to offset and max_events - items_to_process = items[offset:offset + max_events] - logger.info(f"Processing {len(items_to_process)} events (offset={offset}, max_events={max_events})") + # Identifier les nouveaux événements (non présents dans le cache ou non traités avec succès) + new_events = [] + success_events = [] - # Process each item + for link in event_links: + # Vérifier si l'événement existe dans le cache et a le statut 'success' + if link in event_cache and event_cache[link].get('status') == 'success': + success_events.append(link) + logger.info(f"Événement déjà traité avec succès, ignoré : {link}") + else: + new_events.append(link) + # Initialiser l'événement dans le cache s'il n'existe pas + if link not in event_cache: + event_cache[link] = { + 'discovered_at': datetime.now().isoformat(), + 'status': 'pending', + 'attempts': 0 + } + else: + # Log du statut actuel pour les événements déjà en cache + current_status = event_cache[link].get('status', 'unknown') + attempts = event_cache[link].get('attempts', 0) + logger.info(f"Événement à retraiter (statut: {current_status}, tentatives: {attempts}) : {link}") + + logger.info(f"Liens d'événements trouvés : {len(event_links)}") + logger.info(f"Événements déjà traités avec succès : {len(success_events)}") + logger.info(f"Nouveaux événements à traiter : {len(new_events)}") + + if len(new_events) == 0: + logger.success("Aucun nouvel événement à traiter. Tous les événements ont déjà été insérés avec succès.") + return + + # Appliquer l'offset et la limite aux nouveaux événements + if offset >= len(new_events): + logger.warning(f"Offset {offset} est supérieur ou égal au nombre de nouveaux événements {len(new_events)}") + return + + events_to_process = new_events[offset:offset + max_events] + logger.info(f"Traitement de {len(events_to_process)} nouveaux événements") + + # Fetch events from the OSM Calendar RSS feed pour obtenir les détails + rss_items = fetch_osm_calendar_data() + + if not rss_items: + logger.warning("Aucun événement trouvé dans le flux RSS, mais continuons avec les liens scrapés") + + # Créer un mapping des liens RSS vers les items pour un accès rapide + rss_link_to_item = {} + for item in rss_items: + link_element = item.find('link') + if link_element is not None: + rss_link_to_item[link_element.text] = item + + # Process each new event success_count = 0 - for item in items_to_process: - # Create an event from the item - event = create_event(item) + for event_link in events_to_process: + try: + event_cache[event_link]['attempts'] += 1 + event_cache[event_link]['last_attempt'] = datetime.now().isoformat() - if not event: - continue + # Chercher l'item correspondant dans le flux RSS + rss_item = rss_link_to_item.get(event_link) - # Submit the event to the API - if submit_event(event): - success_count += 1 + if rss_item is not None: + # Créer l'événement depuis l'item RSS + event = create_event(rss_item) + else: + # Si pas trouvé dans le flux RSS, essayer de créer un événement minimal depuis le lien + logger.warning(f"Événement {event_link} non trouvé dans le flux RSS, tentative de création depuis le lien") + event = create_event_from_link(event_link) - logger.success(f"Successfully added {success_count} out of {len(items_to_process)} events to the OpenEventDatabase") + if event: + # Tenter de soumettre l'événement à l'API + if submit_event(event): + success_count += 1 + event_cache[event_link]['status'] = 'success' + event_cache[event_link]['inserted_at'] = datetime.now().isoformat() + logger.success(f"Événement inséré avec succès : {event_link}") + else: + event_cache[event_link]['status'] = 'failed' + logger.warning(f"Échec de l'insertion de l'événement : {event_link}") + else: + event_cache[event_link]['status'] = 'failed' + logger.error(f"Impossible de créer l'événement depuis : {event_link}") + + except Exception as e: + logger.error(f"Erreur lors du traitement de l'événement {event_link} : {e}") + event_cache[event_link]['status'] = 'error' + event_cache[event_link]['error'] = str(e) + + # Sauvegarder le cache mis à jour + save_event_cache(event_cache) + + logger.success(f"Traitement terminé : {success_count} événements insérés avec succès sur {len(events_to_process)} traités") + +def create_event_from_link(event_link): + """ + Créer un événement minimal depuis un lien osmcal.org quand il n'est pas disponible dans le flux RSS. + + Args: + event_link (str): URL de l'événement osmcal.org + + Returns: + dict: Un objet GeoJSON Feature représentant l'événement, ou None en cas d'échec + """ + try: + logger.info(f"Tentative de création d'événement depuis le lien : {event_link}") + + # Si c'est un lien vers un événement OSM Calendar, essayer d'obtenir les données iCal + if event_link.startswith(OSMCAL_EVENT_BASE_URL): + location_name, coordinates = fetch_ical_data(event_link) + + # Extraire l'ID de l'événement pour créer un GUID + event_id_match = re.search(r'event/(\d+)', event_link) + if event_id_match: + event_id = event_id_match.group(1) + external_id = f"osmcal_{event_id}" + else: + external_id = event_link + + # Créer un événement avec les informations minimales disponibles + now = datetime.now() + event = { + "type": "Feature", + "geometry": { + "type": "Point", + "coordinates": coordinates + }, + "properties": { + "type": "scheduled", + "what": "community.osm.event", + "what:series": "OpenStreetMap Calendar", + "where": location_name, + "label": f"Événement OSM Calendar {event_id if 'event_id' in locals() else 'inconnu'}", + "description": f"Événement trouvé sur osmcal.org : {event_link}", + "start": now.isoformat(), + "stop": (now + timedelta(days=1)).isoformat(), + "url": event_link, + "external_id": external_id, + "source": "OSM Calendar (scraped)" + } + } + + return event + else: + logger.warning(f"Lien non reconnu comme un événement OSM Calendar : {event_link}") + return None + + except Exception as e: + logger.error(f"Erreur lors de la création d'événement depuis le lien {event_link} : {e}") + return None if __name__ == "__main__": import argparse diff --git a/extractors/osmcal_debug.html b/extractors/osmcal_debug.html new file mode 100644 index 0000000..3e94caa --- /dev/null +++ b/extractors/osmcal_debug.html @@ -0,0 +1,1909 @@ + + + +
+ +UN Mappers Mappy Hour
+ +Workshop „OpenStreetMap“ beim Science Day 2025
+Kiel, Schleswig-Holstein, Germany
+Düsseldorfer OpenStreetMap-Treffen (online)
+Dusseldorf, North Rhine-Westphalia, Germany
+Encuentro de la Comunidad de OpenStreetMap de Argentina
+Luján, Buenos Aires, Argentina
+Atelier du groupe local de Metz - Septembre 2025
+Metz, Grand Est, France
+OpenStreetMap Community Meet-Up - Istanbul
+Kadıköy, Turkey
+Intro to OpenStreetMap.org Operations - Getting involved
+ +23rd OSM Delhi MapWalk
+Gurgaon, Haryana, India
+Rencontre Saint-Étienne et sud Loire
+Saint-Étienne, Auvergne-Rhône-Alpes, France
+OSM Suomi kartoittajatapaaminen
+Helsinki, Uusimaa, Finland
+Stuttgarter OpenStreetMap-Treffen
+Stuttgart, Baden-Württemberg, Germany
+Mappy Hour OSM España
+Madrid, Community of Madrid, Spain
+OSM-Treffen in Bochum
+Bochum, North Rhine-Westphalia, Germany
+[Online] 🇧🇷 Oficina de mapeamento de áreas no OpenStreetMap com editor JOSM
+Rio de Janeiro, Rio de Janeiro, Brazil
+State of the Map 2025
+Quezon City, Philippines
+OSMF Engineering Working Group meeting
+ +17. Mapathon & Mapping Party Rapperswil 2025
+Rapperswil, Rapperswil-Jona, St. Gallen, Switzerland
+Maptime Amsterdam: Autumn mapping party
+Amsterdam, North Holland, Netherlands
+OSM India Online (Remote) Mapping Party
+New Delhi, Delhi, India
+Vortrag - Wie nutze ich OpenStreetMap - BWS Lehen-Vorstadt, Salzburg
+Salzburg, Salzburg, Austria
+East Midlands pub meet-up
+Derby, England, United Kingdom
+Missing Maps London: (Online) Mapathon [eng]
+ +Mappy Hour OSM España
+Madrid, Community of Madrid, Spain
+[Online] 🇧🇷 Oficina de validação com editor JOSM
+Rio de Janeiro, Rio de Janeiro, Brazil
+Mapathon Bliesgau, Saarpfalz-Kreis
+Homburg, Saarland, Germany
+OpenStreetMap Midwest Meetup
+Ohio, United States
+208. OSM-Stammtisch Berlin-Brandenburg
+Berlin, Germany
+Wikigita geologica Su e giù per l'antico Mare Padano
+Castell'Arquato, Emilia-Romagna, Italy
+OSM Hackweekend Berlin-Brandenburg 10/2025
+Berlin, Germany
+OSMmapperCPH
+Copenhagen, Capital Region of Denmark, Denmark
+24th OSM Delhi MapWalk (Outer West zone)
+Delhi, India
+Missing Maps : Mapathon en ligne - CartONG [fr]
+ +Atelier découverte et initiation
+Grenoble, Auvergne-Rhône-Alpes, France
+OpenStreetMap x Wikidata Taipei #81
+Chengnei, Taipei, Taiwan
+Hamburger Mappertreffen
+Hamburg, Germany
+Münchner OSM-Treffen
+Munich, Bavaria, Germany
+Stammtisch Karlsruhe
+Karlsruhe, Baden-Württemberg, Germany
+Open Transport Community Conference
+Vienna, Austria
+Open Transport Community Conference (ÖBB)
+ +OSM Mumbai MapWalk #4
+Mumbai, Maharashtra, India
+25th OSM Delhi MapWalk (Meerut)
+Meerut, Uttar Pradesh, India
+Missing Maps London: (Online) Mid-Month Mapathon [eng]
+ +Réunion du groupe local de Lyon
+Lyon, Auvergne-Rhône-Alpes, France
+193. OSM-Stammtisch Bonn
+Bonn, North Rhine-Westphalia, Germany
+OSM Edinburgh pub meetup
+City of Edinburgh, Scotland, United Kingdom
+Lüneburger Mappertreffen
+Lüneburg, Lower Saxony, Germany
+Réunion OpenStreetMap
+Vandœuvre-lès-Nancy, Grand Est, France
+OSM-Stammtisch Hannover
+Hanover, Lower Saxony, Germany
+76. Wiener OSM-Stammtisch
+Vienna, Austria
+OpenStreetMap al Linux Day di Bologna
+Bologna, Emilia-Romagna, Italy
+OSM-Stammtisch Hannover
+Hanover, Lower Saxony, Germany
+Missing Maps : Mapathon en ligne - CartONG [fr]
+ +State of the Map Nigeria 2025 Conference Enugu
+Enugu State, Nigeria
+Kieler Mapper*innentreffen
+Kiel, Schleswig-Holstein, Germany
+OSM Radinfra-Mapathon #4
+Vogtei, Thuringia, Germany
+Düsseldorfer OpenStreetMap-Treffen (online)
+Dusseldorf, North Rhine-Westphalia, Germany
+OSM Treffen Salzburg
+Salzburg, Salzburg, Austria
+Missing Maps London: (Online) Mapathon [eng]
+ +East Midlands pub meet-up
+Derby, England, United Kingdom
+itWikiCon
+Catania, Sicily, Italy
+Karlsruhe Hack Weekend November 2025
+Karlsruhe, Baden-Württemberg, Germany
+Maker Faire Salzburg
+Salzburg, Salzburg, Austria
+Missing Maps : Mapathon en ligne - CartONG [fr]
+ +OpenStreetMap x Wikidata Taipei #82
+Chengnei, Taipei, Taiwan
+Hamburger Mappertreffen
+Hamburg, Germany
+Münchner OSM-Treffen
+Munich, Bavaria, Germany
+OpenStreetMap Midwest Meetup
+Ohio, United States
+State of the Map Europe
+Dundee, Scotland, United Kingdom
+Missing Maps London: (Online) Mid-Month Mapathon [eng]
+ +Réunion du groupe local de Lyon
+Lyon, Auvergne-Rhône-Alpes, France
+194. OSM-Stammtisch Bonn
+Bonn, North Rhine-Westphalia, Germany
+OSM Edinburgh pub meetup
+City of Edinburgh, Scotland, United Kingdom
+Lüneburger Mappertreffen
+Lüneburg, Lower Saxony, Germany
+Missing Maps : Mapathon en ligne - CartONG [fr]
+ +OSM-Stammtisch Hannover
+Hanover, Lower Saxony, Germany
+OSM-Verkehrswende #70
+Berlin, Germany
+Réunion OpenStreetMap
+Vandœuvre-lès-Nancy, Grand Est, France
+Düsseldorfer OpenStreetMap-Treffen (online)
+Dusseldorf, North Rhine-Westphalia, Germany
+State of the Map Africa 2025
+Dar es-Salaam, Tanzania
+OSM Treffen Salzburg
+Salzburg, Salzburg, Austria
+Missing Maps London: (Online) Mapathon [eng]
+ +PSL XXL
+Paris, France
+Missing Maps : Mapathon en ligne - CartONG [fr]
+ +OpenStreetMap x Wikidata Taipei #83
+Chengnei, Taipei, Taiwan
+Hamburger Mappertreffen
+Hamburg, Germany
+OpenStreetMap Midwest Meetup
+Ohio, United States
+Münchner OSM-Treffen
+Munich, Bavaria, Germany
+Missing Maps London: (Online) Mid-Month Mapathon [eng]
+ +Réunion du groupe local de Lyon
+Lyon, Auvergne-Rhône-Alpes, France
+195. OSM-Stammtisch Bonn
+Bonn, North Rhine-Westphalia, Germany
+Lüneburger Mappertreffen
+Lüneburg, Lower Saxony, Germany
+19. Österreichischer OSM-Stammtisch (online)
+Stainach-Pürgg, Styria, Austria
+Düsseldorfer OpenStreetMap-Treffen (online)
+Dusseldorf, North Rhine-Westphalia, Germany
+Missing Maps : Mapathon en ligne - CartONG [fr]
+ +OSM-Stammtisch Hannover
+Hanover, Lower Saxony, Germany
+