up osmcal scrapper
This commit is contained in:
parent
7d57086047
commit
205d77e2f6
2 changed files with 2245 additions and 24 deletions
|
@ -42,6 +42,8 @@ import xml.etree.ElementTree as ET
|
||||||
import re
|
import re
|
||||||
import html
|
import html
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
from bs4 import BeautifulSoup
|
||||||
|
import unicodedata
|
||||||
|
|
||||||
# Add the parent directory to the path so we can import from oedb
|
# 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__), '..')))
|
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"
|
RSS_URL = "https://osmcal.org/events.rss"
|
||||||
# Base URL for OSM Calendar events
|
# Base URL for OSM Calendar events
|
||||||
OSMCAL_EVENT_BASE_URL = "https://osmcal.org/event/"
|
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():
|
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}")
|
logger.warning(f"Failed to fetch iCal data: {response.status_code}")
|
||||||
return ("Unknown Location", [0, 0])
|
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
|
ical_content = response.text
|
||||||
|
|
||||||
# Extract GEO information
|
# Extract GEO information
|
||||||
|
@ -254,6 +429,8 @@ def fetch_ical_data(event_url):
|
||||||
location_name = location_match.group(1).strip()
|
location_name = location_match.group(1).strip()
|
||||||
# Unescape backslash-escaped characters (e.g., \, becomes ,)
|
# Unescape backslash-escaped characters (e.g., \, becomes ,)
|
||||||
location_name = re.sub(r'\\(.)', r'\1', location_name)
|
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}")
|
logger.info(f"Extracted location from iCal: {location_name}")
|
||||||
else:
|
else:
|
||||||
logger.warning(f"No LOCATION information found in iCal data for event: {event_id}")
|
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
|
# The second paragraph often contains the location
|
||||||
location_candidate = location_matches[1].strip()
|
location_candidate = location_matches[1].strip()
|
||||||
if location_candidate and "," in location_candidate and not location_candidate.startswith('<'):
|
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
|
# 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
|
# 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 = html.unescape(clean_description)
|
||||||
clean_description = re.sub(r'\s+', ' ', clean_description).strip()
|
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
|
# Parse dates from the description
|
||||||
start_date, end_date = parse_event_dates(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")
|
logger.info("Environment variables loaded successfully from .env file")
|
||||||
|
|
||||||
# Fetch events from the OSM Calendar RSS feed
|
# Charger le cache des événements traités
|
||||||
items = fetch_osm_calendar_data()
|
event_cache = load_event_cache()
|
||||||
|
|
||||||
if not items:
|
# Scraper la page principale pour obtenir tous les liens d'événements
|
||||||
logger.warning("No events found, exiting")
|
event_links = scrape_osmcal_event_links()
|
||||||
|
|
||||||
|
if not event_links:
|
||||||
|
logger.warning("Aucun lien d'événement trouvé sur la page principale")
|
||||||
return
|
return
|
||||||
|
|
||||||
# Apply offset and limit
|
# Identifier les nouveaux événements (non présents dans le cache ou non traités avec succès)
|
||||||
if offset >= len(items):
|
new_events = []
|
||||||
logger.warning(f"Offset {offset} is greater than or equal to the number of events {len(items)}, no events to process")
|
success_events = []
|
||||||
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})")
|
|
||||||
|
|
||||||
# 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
|
success_count = 0
|
||||||
for item in items_to_process:
|
for event_link in events_to_process:
|
||||||
# Create an event from the item
|
try:
|
||||||
event = create_event(item)
|
event_cache[event_link]['attempts'] += 1
|
||||||
|
event_cache[event_link]['last_attempt'] = datetime.now().isoformat()
|
||||||
|
|
||||||
if not event:
|
# Chercher l'item correspondant dans le flux RSS
|
||||||
continue
|
rss_item = rss_link_to_item.get(event_link)
|
||||||
|
|
||||||
# Submit the event to the API
|
if rss_item is not None:
|
||||||
if submit_event(event):
|
# Créer l'événement depuis l'item RSS
|
||||||
success_count += 1
|
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__":
|
if __name__ == "__main__":
|
||||||
import argparse
|
import argparse
|
||||||
|
|
1909
extractors/osmcal_debug.html
Normal file
1909
extractors/osmcal_debug.html
Normal file
File diff suppressed because it is too large
Load diff
Loading…
Add table
Add a link
Reference in a new issue