get agendadulibre
This commit is contained in:
parent
13dc5ceef8
commit
3fa60f3052
9 changed files with 9622 additions and 2278 deletions
2
extractors/.gitignore
vendored
Normal file
2
extractors/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
||||||
|
test_env
|
||||||
|
venv
|
307
extractors/README_agendadulibre_improvements.md
Normal file
307
extractors/README_agendadulibre_improvements.md
Normal file
|
@ -0,0 +1,307 @@
|
||||||
|
# Améliorations du Scraper Agenda du Libre
|
||||||
|
|
||||||
|
## Nouvelles Fonctionnalités
|
||||||
|
|
||||||
|
### 1. Cache JSON Intelligent
|
||||||
|
- **Fichier de cache** : `agendadulibre_cache.json`
|
||||||
|
- **Détection de changements** : Le script détecte si le contenu iCal a changé via un hash MD5
|
||||||
|
- **Évite les re-téléchargements** : Si le contenu est identique, utilise le cache existant
|
||||||
|
- **Suivi des événements traités** : Mémorise les événements déjà traités pour éviter les doublons
|
||||||
|
|
||||||
|
### 2. Limitation du Nombre d'Événements
|
||||||
|
- **Argument `--max-events`** : Limite le nombre d'événements à traiter
|
||||||
|
- **Utile pour les tests** : Permet de tester avec un petit nombre d'événements
|
||||||
|
- **Statistiques** : Affiche le nombre d'événements ignorés à cause de la limite
|
||||||
|
|
||||||
|
### 3. Mode Dry-Run par Défaut
|
||||||
|
- **Sécurité** : Par défaut, aucun événement n'est envoyé à l'API
|
||||||
|
- **Simulation** : Affiche ce qui serait envoyé sans faire d'appels API réels
|
||||||
|
- **Override** : Utilisez `--no-dry-run` pour l'envoi réel
|
||||||
|
|
||||||
|
### 4. Logs Détaillés des Événements
|
||||||
|
- **Informations complètes** : Affiche tous les détails de l'événement avant insertion
|
||||||
|
- **Traçabilité** : ID, titre, description, dates, lieu, URL, source, etc.
|
||||||
|
- **Debugging** : Facilite le diagnostic des problèmes d'insertion
|
||||||
|
- **Audit** : Permet de vérifier les données avant envoi à l'API
|
||||||
|
|
||||||
|
### 5. Géocodage Automatique Intelligent
|
||||||
|
- **Priorité GEO** : Extrait d'abord les coordonnées du champ `GEO:` dans l'iCal
|
||||||
|
- **Détection d'adresses** : Extrait automatiquement les adresses après la première virgule
|
||||||
|
- **Géocodage Nominatim** : Utilise l'API Nominatim pour obtenir les coordonnées réelles
|
||||||
|
- **Nettoyage intelligent** : Détecte les numéros d'adresse pour améliorer la précision
|
||||||
|
- **Fallback robuste** : Utilise le lieu complet si pas d'adresse détectée
|
||||||
|
- **Respect des limites** : Pause d'1 seconde entre les requêtes Nominatim
|
||||||
|
- **Optimisation** : Évite le géocodage sur les événements déjà traités avec succès
|
||||||
|
|
||||||
|
### 6. Extraction des Catégories
|
||||||
|
- **Champ CATEGORIES** : Extrait automatiquement les catégories du champ `CATEGORIES:` de l'iCal
|
||||||
|
- **Tags multiples** : Support des catégories multiples par événement
|
||||||
|
- **Intégration OEDB** : Ajoute les catégories comme propriété `tags` dans l'événement
|
||||||
|
- **Logs informatifs** : Affiche les catégories trouvées dans les logs détaillés
|
||||||
|
|
||||||
|
### 7. Extraction des Propriétés Étendues
|
||||||
|
- **ORGANIZER** : Extrait l'organisateur de l'événement (email/contact)
|
||||||
|
- **X-ALT-DESC** : Extrait la description alternative HTML si disponible
|
||||||
|
- **SUMMARY** : Utilise le résumé comme description courte
|
||||||
|
- **SEQUENCE** : Extrait le numéro de séquence de l'événement
|
||||||
|
- **RRULE** : Extrait les règles de répétition pour les événements récurrents
|
||||||
|
- **Enrichissement complet** : Toutes les métadonnées iCal sont préservées
|
||||||
|
|
||||||
|
### 8. Priorisation des Événements
|
||||||
|
- **Événements en attente** : Priorité haute pour les événements avec status `pending`, `failed`, `api_error`
|
||||||
|
- **Cache intelligent** : Vérification dans les données locales et le cache
|
||||||
|
- **Tri automatique** : Les événements en attente sont traités en premier
|
||||||
|
- **Logs informatifs** : Indication claire des événements prioritaires avec emoji 🔄
|
||||||
|
- **Robustesse** : Retry automatique des événements échoués
|
||||||
|
|
||||||
|
## Utilisation
|
||||||
|
|
||||||
|
### Commandes de Base
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Mode dry-run par défaut (sécurisé)
|
||||||
|
python agendadulibre.py
|
||||||
|
|
||||||
|
# Limiter à 5 événements en mode dry-run
|
||||||
|
python agendadulibre.py --max-events 5
|
||||||
|
|
||||||
|
# Mode réel avec limite de 10 événements
|
||||||
|
python agendadulibre.py --no-dry-run --max-events 10
|
||||||
|
|
||||||
|
# Mode verbeux pour voir les détails
|
||||||
|
python agendadulibre.py --max-events 3 --verbose
|
||||||
|
|
||||||
|
# Forcer le rechargement du fichier iCal
|
||||||
|
python agendadulibre.py --force-refresh --max-events 5
|
||||||
|
```
|
||||||
|
|
||||||
|
### Arguments Disponibles
|
||||||
|
|
||||||
|
| Argument | Description | Défaut |
|
||||||
|
|----------|-------------|---------|
|
||||||
|
| `--max-events N` | Limite le nombre d'événements à traiter | Aucune limite |
|
||||||
|
| `--dry-run` | Mode simulation (par défaut) | Activé |
|
||||||
|
| `--no-dry-run` | Désactive le mode dry-run | - |
|
||||||
|
| `--verbose` | Mode verbeux | - |
|
||||||
|
| `--force-refresh` | Force le rechargement iCal | - |
|
||||||
|
| `--cache-duration N` | Durée de validité du cache (heures) | 1 |
|
||||||
|
| `--batch-size N` | Taille des batches | 1 |
|
||||||
|
| `--api-url URL` | URL de l'API OEDB | https://api.openeventdatabase.org |
|
||||||
|
|
||||||
|
## Fichiers Générés
|
||||||
|
|
||||||
|
### Cache JSON (`agendadulibre_cache.json`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"processed_events": {
|
||||||
|
"event_id_1": {
|
||||||
|
"processed_at": "2024-01-01T12:00:00",
|
||||||
|
"status": "saved",
|
||||||
|
"event_label": "Nom de l'événement"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_ical_fetch": "2024-01-01T12:00:00",
|
||||||
|
"ical_content_hash": "md5_hash_du_contenu"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Données d'Événements (`agendadulibre_events.json`)
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"events": {
|
||||||
|
"event_id": {
|
||||||
|
"status": "saved",
|
||||||
|
"message": "Créé avec succès",
|
||||||
|
"last_attempt": "2024-01-01T12:00:00",
|
||||||
|
"event": { /* données de l'événement */ }
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"last_update": "2024-01-01T12:00:00"
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Exemples de Sortie
|
||||||
|
|
||||||
|
### Mode Dry-Run avec Logs Détaillés
|
||||||
|
```
|
||||||
|
🚀 Démarrage du scraping de l'agenda du libre
|
||||||
|
Configuration: batch_size=1, api_url=https://api.openeventdatabase.org
|
||||||
|
Mode dry-run: OUI
|
||||||
|
Limite d'événements: 5
|
||||||
|
Cache iCal: valide pendant 1h
|
||||||
|
Mode DRY-RUN activé - aucun événement ne sera envoyé à l'API
|
||||||
|
|
||||||
|
📝 Détails de l'événement à insérer:
|
||||||
|
ID: 6a575f6a82922f4501854431fc3f831c
|
||||||
|
Titre: Conférence Python
|
||||||
|
Description: Présentation sur Python
|
||||||
|
Type: scheduled
|
||||||
|
Catégorie: culture.floss
|
||||||
|
Lieu: Paris, France
|
||||||
|
Début: 2024-12-01T10:00:00
|
||||||
|
Fin: 2024-12-01T12:00:00
|
||||||
|
URL: https://example.com/event1
|
||||||
|
Source: Agenda du Libre
|
||||||
|
Coordonnées: [0, 0]
|
||||||
|
Modifié par: agendadulibre_scraper
|
||||||
|
[DRY-RUN] Simulation d'envoi de l'événement: Conférence Python
|
||||||
|
✅ Conférence Python - Simulé (dry-run)
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode Réel avec Propriétés Complètes
|
||||||
|
```
|
||||||
|
🚀 Démarrage du scraping de l'agenda du libre
|
||||||
|
Configuration: batch_size=1, api_url=https://api.openeventdatabase.org
|
||||||
|
Mode dry-run: NON
|
||||||
|
Limite d'événements: 3
|
||||||
|
|
||||||
|
📍 Coordonnées GEO trouvées: 45.756, 4.84773
|
||||||
|
🏷️ Catégories trouvées: entraide, aldil, epn-des-rancy, linux
|
||||||
|
👤 Organisateur trouvé: mailto:contact@aldil.org
|
||||||
|
📝 Description courte trouvée: Entraide et Bidouille
|
||||||
|
🔢 Séquence trouvée: 3
|
||||||
|
✅ Coordonnées utilisées depuis le champ GEO: [4.84773, 45.756]
|
||||||
|
|
||||||
|
📝 Détails de l'événement à insérer:
|
||||||
|
ID: 6a575f6a82922f4501854431fc3f831c
|
||||||
|
Titre: Entraide et Bidouille
|
||||||
|
Description: Atelier d'entraide informatique
|
||||||
|
Type: scheduled
|
||||||
|
Catégorie: culture.floss
|
||||||
|
Lieu: Maison pour tous / salle des Rancy, 249 rue Vendôme, Lyon, France
|
||||||
|
Début: 2024-12-01T10:00:00
|
||||||
|
Fin: 2024-12-01T12:00:00
|
||||||
|
URL: https://example.com/event1
|
||||||
|
Source: Agenda du Libre
|
||||||
|
Coordonnées: [4.84773, 45.756]
|
||||||
|
Tags: entraide, aldil, epn-des-rancy, linux
|
||||||
|
Organisateur: mailto:contact@aldil.org
|
||||||
|
Description courte: Entraide et Bidouille
|
||||||
|
Séquence: 3
|
||||||
|
Règles de répétition: N/A
|
||||||
|
Description HTML: N/A
|
||||||
|
Modifié par: agendadulibre_scraper
|
||||||
|
🌐 Envoi à l'API: https://api.openeventdatabase.org/event
|
||||||
|
✅ Événement créé avec succès dans l'API
|
||||||
|
✅ Entraide et Bidouille - Créé avec succès
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode Réel avec Géocodage Nominatim
|
||||||
|
```
|
||||||
|
🚀 Démarrage du scraping de l'agenda du libre
|
||||||
|
Configuration: batch_size=1, api_url=https://api.openeventdatabase.org
|
||||||
|
Mode dry-run: NON
|
||||||
|
Limite d'événements: 3
|
||||||
|
|
||||||
|
📍 Adresse potentielle trouvée: 15 rue de la Paix, Paris, France
|
||||||
|
🌍 Géocodage avec Nominatim: 15 rue de la Paix, Paris, France
|
||||||
|
✅ Géocodage réussi: 15 rue de la Paix, Paris, France -> (48.8566, 2.3522)
|
||||||
|
Adresse trouvée: 15 Rue de la Paix, 75001 Paris, France
|
||||||
|
🎯 Coordonnées mises à jour par géocodage: [0, 0] -> [2.3522, 48.8566]
|
||||||
|
|
||||||
|
📝 Détails de l'événement à insérer:
|
||||||
|
ID: 6a575f6a82922f4501854431fc3f831c
|
||||||
|
Titre: Conférence Python
|
||||||
|
Description: Présentation sur Python
|
||||||
|
Type: scheduled
|
||||||
|
Catégorie: culture.floss
|
||||||
|
Lieu: Centre de conférences, 15 rue de la Paix, Paris, France
|
||||||
|
Début: 2024-12-01T10:00:00
|
||||||
|
Fin: 2024-12-01T12:00:00
|
||||||
|
URL: https://example.com/event1
|
||||||
|
Source: Agenda du Libre
|
||||||
|
Coordonnées: [2.3522, 48.8566]
|
||||||
|
Modifié par: agendadulibre_scraper
|
||||||
|
🌐 Envoi à l'API: https://api.openeventdatabase.org/event
|
||||||
|
✅ Événement créé avec succès dans l'API
|
||||||
|
✅ Conférence Python - Créé avec succès
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode Optimisé - Événements Déjà Traités
|
||||||
|
```
|
||||||
|
🚀 Démarrage du scraping de l'agenda du libre
|
||||||
|
Configuration: batch_size=1, api_url=https://api.openeventdatabase.org
|
||||||
|
Mode dry-run: NON
|
||||||
|
|
||||||
|
⏭️ Événement ignoré: Conférence Python - déjà traité (status: saved)
|
||||||
|
⏭️ Événement ignoré: Atelier Linux - déjà dans le cache (status: saved)
|
||||||
|
ℹ️ Géocodage ignoré pour Formation Git - déjà traité
|
||||||
|
ℹ️ Géocodage ignoré - événement déjà traité
|
||||||
|
📝 Détails de l'événement à insérer:
|
||||||
|
ID: dd0850de6ed7a6b4d482a7dc5201d09c
|
||||||
|
Titre: Formation Git
|
||||||
|
Description: Maîtriser Git
|
||||||
|
Type: scheduled
|
||||||
|
Catégorie: culture.floss
|
||||||
|
Lieu: Espace formation, 42 avenue du Général de Gaulle, Marseille, France
|
||||||
|
Début: 2024-12-03T09:00:00
|
||||||
|
Fin: 2024-12-03T11:00:00
|
||||||
|
URL: https://example.com/event3
|
||||||
|
Source: Agenda du Libre
|
||||||
|
Coordonnées: [5.3698, 43.2965]
|
||||||
|
Modifié par: agendadulibre_scraper
|
||||||
|
🌐 Envoi à l'API: https://api.openeventdatabase.org/event
|
||||||
|
⚠️ Événement déjà existant dans l'API
|
||||||
|
✅ Formation Git - Événement déjà existant
|
||||||
|
```
|
||||||
|
|
||||||
|
### Mode Prioritaire - Événements en Attente
|
||||||
|
```
|
||||||
|
🚀 Démarrage du scraping de l'agenda du libre
|
||||||
|
Configuration: batch_size=1, api_url=https://api.openeventdatabase.org
|
||||||
|
Mode dry-run: NON
|
||||||
|
|
||||||
|
🔄 Événement en attente prioritaire: Atelier Linux (status: failed)
|
||||||
|
🔄 Événement en attente du cache: Formation Git (status: pending)
|
||||||
|
📋 Événements à traiter: 2 (dont 2 en attente)
|
||||||
|
🔄 Traitement prioritaire: Atelier Linux
|
||||||
|
📝 Détails de l'événement à insérer:
|
||||||
|
ID: 5ac96f4ae72cd28d164489580e97daca
|
||||||
|
Titre: Atelier Linux
|
||||||
|
Description: Apprendre Linux
|
||||||
|
Type: scheduled
|
||||||
|
Catégorie: culture.floss
|
||||||
|
Lieu: Lyon, France
|
||||||
|
Début: 2024-12-02T14:00:00
|
||||||
|
Fin: 2024-12-02T16:00:00
|
||||||
|
URL: https://example.com/event2
|
||||||
|
Source: Agenda du Libre
|
||||||
|
Coordonnées: [4.8357, 45.764]
|
||||||
|
Modifié par: agendadulibre_scraper
|
||||||
|
🌐 Envoi à l'API: https://api.openeventdatabase.org/event
|
||||||
|
✅ Événement créé avec succès dans l'API
|
||||||
|
✅ Atelier Linux - Créé avec succès
|
||||||
|
```
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Exécutez le script de test pour vérifier les fonctionnalités :
|
||||||
|
|
||||||
|
```bash
|
||||||
|
python test_agendadulibre_improvements.py
|
||||||
|
```
|
||||||
|
|
||||||
|
## Avantages
|
||||||
|
|
||||||
|
1. **Sécurité** : Mode dry-run par défaut évite les insertions accidentelles
|
||||||
|
2. **Performance** : Cache intelligent réduit les téléchargements inutiles
|
||||||
|
3. **Contrôle** : Limitation du nombre d'événements pour les tests
|
||||||
|
4. **Traçabilité** : Logs détaillés et fichiers de cache pour le suivi
|
||||||
|
5. **Flexibilité** : Arguments pour personnaliser le comportement
|
||||||
|
6. **Géolocalisation précise** : Géocodage automatique des lieux avec coordonnées réelles
|
||||||
|
7. **Intelligence** : Détection et extraction automatique des adresses
|
||||||
|
8. **Robustesse** : Fallback intelligent en cas d'échec de géocodage
|
||||||
|
9. **Optimisation** : Évite les retraitements inutiles des événements déjà envoyés
|
||||||
|
10. **Efficacité** : Skip automatique du géocodage pour les événements déjà traités
|
||||||
|
11. **Enrichissement** : Extraction automatique des catégories comme tags
|
||||||
|
12. **Classification** : Amélioration de la recherche et du filtrage des événements
|
||||||
|
13. **Métadonnées complètes** : Extraction de toutes les propriétés iCal importantes
|
||||||
|
14. **Traçabilité** : Organisateur, séquence et règles de répétition préservées
|
||||||
|
15. **Flexibilité** : Support des descriptions HTML et des événements récurrents
|
||||||
|
16. **Priorisation intelligente** : Traitement prioritaire des événements en attente
|
||||||
|
17. **Robustesse** : Retry automatique des événements échoués
|
||||||
|
18. **Efficacité** : Optimisation du traitement par priorité
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Les anciens scripts continuent de fonctionner, mais il est recommandé d'utiliser les nouveaux arguments pour plus de contrôle et de sécurité.
|
|
@ -185,3 +185,13 @@
|
||||||
2025-09-26 17:32:06,370 - INFO - 🔄 [14/14] Traitement de https://lagendageek.com/tevent/steven-spielberg-hommage-80-ans/
|
2025-09-26 17:32:06,370 - INFO - 🔄 [14/14] Traitement de https://lagendageek.com/tevent/steven-spielberg-hommage-80-ans/
|
||||||
2025-09-26 17:32:10,298 - INFO - ✅ Événement créé avec succès: ID c73cd045-b199-404f-a256-cd14977abe95
|
2025-09-26 17:32:10,298 - INFO - ✅ Événement créé avec succès: ID c73cd045-b199-404f-a256-cd14977abe95
|
||||||
2025-09-26 17:32:11,299 - INFO - 🏁 Traitement terminé - Succès: 14, Erreurs: 0
|
2025-09-26 17:32:11,299 - INFO - 🏁 Traitement terminé - Succès: 14, Erreurs: 0
|
||||||
|
2025-10-09 16:37:04,898 - INFO - 🚀 Début du traitement - Limite: 20, Offset: 0
|
||||||
|
2025-10-09 16:37:04,904 - INFO - 🔍 Récupération de la liste des événements depuis https://lagendageek.com/tevents/page/1
|
||||||
|
2025-10-09 16:37:08,740 - INFO - ✅ 20 événements trouvés sur la page
|
||||||
|
2025-10-09 16:37:08,741 - INFO - 📊 Traitement de 20 événements (1 à 20 sur 20)
|
||||||
|
2025-10-09 16:37:08,741 - INFO - 🔄 [1/20] Traitement de https://lagendageek.com/tevent/octogones-2025/
|
||||||
|
2025-10-09 16:37:13,589 - INFO - ⚠️ Événement déjà existant (conflit)
|
||||||
|
2025-10-09 16:37:14,590 - INFO - 🔄 [2/20] Traitement de https://lagendageek.com/tevent/gloose-11-festival-du-jeu-dalbi/
|
||||||
|
2025-10-09 16:37:19,410 - INFO - ⚠️ Événement déjà existant (conflit)
|
||||||
|
2025-10-09 16:37:20,410 - INFO - 🔄 [3/20] Traitement de https://lagendageek.com/tevent/festival-de-limaginaire-geek-family-autres-mondes/
|
||||||
|
2025-10-09 16:37:25,374 - INFO - ⚠️ Événement déjà existant (conflit)
|
||||||
|
|
|
@ -9,6 +9,8 @@ import json
|
||||||
import os
|
import os
|
||||||
import sys
|
import sys
|
||||||
import argparse
|
import argparse
|
||||||
|
import re
|
||||||
|
import time
|
||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
from typing import Dict, List, Optional, Tuple
|
from typing import Dict, List, Optional, Tuple
|
||||||
import icalendar
|
import icalendar
|
||||||
|
@ -30,16 +32,20 @@ logging.basicConfig(
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
class AgendaDuLibreScraper:
|
class AgendaDuLibreScraper:
|
||||||
def __init__(self, api_base_url: str = api_oedb, batch_size: int = 1):
|
def __init__(self, api_base_url: str = api_oedb, batch_size: int = 1, max_events: int = None, dry_run: bool = True):
|
||||||
self.api_base_url = api_base_url
|
self.api_base_url = api_base_url
|
||||||
self.batch_size = batch_size
|
self.batch_size = batch_size
|
||||||
|
self.max_events = max_events
|
||||||
|
self.dry_run = dry_run
|
||||||
self.data_file = "agendadulibre_events.json"
|
self.data_file = "agendadulibre_events.json"
|
||||||
|
self.cache_file = "agendadulibre_cache.json"
|
||||||
self.ical_file = "agendadulibre_events.ics"
|
self.ical_file = "agendadulibre_events.ics"
|
||||||
self.ical_url = "https://www.agendadulibre.org/events.ics"
|
self.ical_url = "https://www.agendadulibre.org/events.ics"
|
||||||
self.cache_duration_hours = 1 # Durée de cache en heures
|
self.cache_duration_hours = 1 # Durée de cache en heures
|
||||||
|
|
||||||
# Charger les données existantes
|
# Charger les données existantes
|
||||||
self.events_data = self.load_events_data()
|
self.events_data = self.load_events_data()
|
||||||
|
self.cache_data = self.load_cache_data()
|
||||||
|
|
||||||
def load_events_data(self) -> Dict:
|
def load_events_data(self) -> Dict:
|
||||||
"""Charge les données d'événements depuis le fichier JSON local"""
|
"""Charge les données d'événements depuis le fichier JSON local"""
|
||||||
|
@ -52,6 +58,17 @@ class AgendaDuLibreScraper:
|
||||||
return {"events": {}, "last_update": None}
|
return {"events": {}, "last_update": None}
|
||||||
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):
|
def save_events_data(self):
|
||||||
"""Sauvegarde les données d'événements dans le fichier JSON local"""
|
"""Sauvegarde les données d'événements dans le fichier JSON local"""
|
||||||
try:
|
try:
|
||||||
|
@ -60,6 +77,14 @@ class AgendaDuLibreScraper:
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de la sauvegarde du fichier {self.data_file}: {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:
|
def is_ical_cache_valid(self) -> bool:
|
||||||
"""Vérifie si le cache iCal est encore valide (moins d'une heure)"""
|
"""Vérifie si le cache iCal est encore valide (moins d'une heure)"""
|
||||||
if not os.path.exists(self.ical_file):
|
if not os.path.exists(self.ical_file):
|
||||||
|
@ -76,12 +101,28 @@ class AgendaDuLibreScraper:
|
||||||
logger.error(f"Erreur lors de la vérification du cache iCal: {e}")
|
logger.error(f"Erreur lors de la vérification du cache iCal: {e}")
|
||||||
return False
|
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):
|
def save_ical_cache(self, ical_content: bytes):
|
||||||
"""Sauvegarde le contenu iCal en cache local"""
|
"""Sauvegarde le contenu iCal en cache local"""
|
||||||
try:
|
try:
|
||||||
with open(self.ical_file, 'wb') as f:
|
with open(self.ical_file, 'wb') as f:
|
||||||
f.write(ical_content)
|
f.write(ical_content)
|
||||||
logger.info(f"Cache iCal sauvegardé dans {self.ical_file}")
|
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:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors de la sauvegarde du cache iCal: {e}")
|
logger.error(f"Erreur lors de la sauvegarde du cache iCal: {e}")
|
||||||
|
|
||||||
|
@ -115,8 +156,14 @@ class AgendaDuLibreScraper:
|
||||||
response.raise_for_status()
|
response.raise_for_status()
|
||||||
ical_content = response.content
|
ical_content = response.content
|
||||||
|
|
||||||
# Sauvegarder en cache
|
# Vérifier si le contenu a changé
|
||||||
self.save_ical_cache(ical_content)
|
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:
|
except requests.RequestException as e:
|
||||||
logger.error(f"Erreur lors de la récupération du fichier iCal: {e}")
|
logger.error(f"Erreur lors de la récupération du fichier iCal: {e}")
|
||||||
|
@ -145,6 +192,19 @@ class AgendaDuLibreScraper:
|
||||||
location = str(event.get('location', ''))
|
location = str(event.get('location', ''))
|
||||||
url = str(event.get('url', ''))
|
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
|
# Gestion des dates
|
||||||
dtstart = event.get('dtstart')
|
dtstart = event.get('dtstart')
|
||||||
dtend = event.get('dtend')
|
dtend = event.get('dtend')
|
||||||
|
@ -188,11 +248,17 @@ class AgendaDuLibreScraper:
|
||||||
"url": url if url else None,
|
"url": url if url else None,
|
||||||
"source:name": "Agenda du Libre",
|
"source:name": "Agenda du Libre",
|
||||||
"source:url": "https://www.agendadulibre.org/",
|
"source:url": "https://www.agendadulibre.org/",
|
||||||
"last_modified_by": "agendadulibre_scraper"
|
"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": {
|
"geometry": {
|
||||||
"type": "Point",
|
"type": "Point",
|
||||||
"coordinates": [0, 0] # Coordonnées par défaut, à géocoder si nécessaire
|
"coordinates": geo_coords if geo_coords else [0, 0] # Utiliser GEO ou coordonnées par défaut
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -202,28 +268,333 @@ class AgendaDuLibreScraper:
|
||||||
return {
|
return {
|
||||||
"id": event_id,
|
"id": event_id,
|
||||||
"event": oedb_event,
|
"event": oedb_event,
|
||||||
"raw_ical": {
|
|
||||||
"summary": summary,
|
|
||||||
"description": description,
|
|
||||||
"location": location,
|
|
||||||
"url": url,
|
|
||||||
"dtstart": start_iso,
|
|
||||||
"dtend": end_iso
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Erreur lors du parsing de l'événement: {e}")
|
logger.error(f"Erreur lors du parsing de l'événement: {e}")
|
||||||
return None
|
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:
|
def generate_event_id(self, summary: str, start_date: str, location: str) -> str:
|
||||||
"""Génère un ID unique pour l'événement"""
|
"""Génère un ID unique pour l'événement"""
|
||||||
import hashlib
|
import hashlib
|
||||||
content = f"{summary}_{start_date}_{location}"
|
content = f"{summary}_{start_date}_{location}"
|
||||||
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
def send_event_to_api(self, event_data: Dict) -> Tuple[bool, str]:
|
def clean_location_for_geocoding(self, location: str) -> Optional[str]:
|
||||||
"""Envoie un événement à l'API OEDB"""
|
"""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:
|
try:
|
||||||
url = f"{self.api_base_url}/event"
|
url = f"{self.api_base_url}/event"
|
||||||
headers = {"Content-Type": "application/json"}
|
headers = {"Content-Type": "application/json"}
|
||||||
|
@ -235,18 +606,24 @@ class AgendaDuLibreScraper:
|
||||||
"properties": event_data["event"]["properties"]
|
"properties": event_data["event"]["properties"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
logger.info(f"🌐 Envoi à l'API: {url}")
|
||||||
response = requests.post(url, json=geojson_event, headers=headers, timeout=30)
|
response = requests.post(url, json=geojson_event, headers=headers, timeout=30)
|
||||||
|
|
||||||
if response.status_code == 201:
|
if response.status_code == 201:
|
||||||
|
logger.info("✅ Événement créé avec succès dans l'API")
|
||||||
return True, "Créé avec succès"
|
return True, "Créé avec succès"
|
||||||
elif response.status_code == 409:
|
elif response.status_code == 409:
|
||||||
|
logger.warning("⚠️ Événement déjà existant dans l'API")
|
||||||
return False, "Événement déjà existant"
|
return False, "Événement déjà existant"
|
||||||
else:
|
else:
|
||||||
|
logger.error(f"❌ Erreur API: {response.status_code} - {response.text}")
|
||||||
return False, f"Erreur API: {response.status_code} - {response.text}"
|
return False, f"Erreur API: {response.status_code} - {response.text}"
|
||||||
|
|
||||||
except requests.RequestException as e:
|
except requests.RequestException as e:
|
||||||
|
logger.error(f"❌ Erreur de connexion: {e}")
|
||||||
return False, f"Erreur de connexion: {e}"
|
return False, f"Erreur de connexion: {e}"
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
logger.error(f"❌ Erreur inattendue: {e}")
|
||||||
return False, f"Erreur inattendue: {e}"
|
return False, f"Erreur inattendue: {e}"
|
||||||
|
|
||||||
def process_events(self, calendar: Calendar) -> Dict:
|
def process_events(self, calendar: Calendar) -> Dict:
|
||||||
|
@ -257,10 +634,13 @@ class AgendaDuLibreScraper:
|
||||||
"already_saved": 0,
|
"already_saved": 0,
|
||||||
"api_errors": 0,
|
"api_errors": 0,
|
||||||
"parse_errors": 0,
|
"parse_errors": 0,
|
||||||
"sent_this_run": 0
|
"sent_this_run": 0,
|
||||||
|
"skipped_due_to_limit": 0
|
||||||
}
|
}
|
||||||
|
|
||||||
events_to_process = []
|
events_to_process = []
|
||||||
|
pending_events = [] # Événements en attente d'envoi
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
# Parcourir tous les événements
|
# Parcourir tous les événements
|
||||||
for component in calendar.walk():
|
for component in calendar.walk():
|
||||||
|
@ -274,19 +654,72 @@ class AgendaDuLibreScraper:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
event_id = parsed_event["id"]
|
event_id = parsed_event["id"]
|
||||||
|
event_label = parsed_event["event"]["properties"]["label"]
|
||||||
|
|
||||||
# Vérifier si l'événement existe déjà dans nos données
|
# 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"]:
|
if event_id in self.events_data["events"]:
|
||||||
event_status = self.events_data["events"][event_id].get("status", "unknown")
|
event_status = self.events_data["events"][event_id].get("status", "unknown")
|
||||||
if event_status in ["saved", "already_exists"]:
|
if event_status in ["saved", "already_exists"]:
|
||||||
stats["already_saved"] += 1
|
stats["already_saved"] += 1
|
||||||
logger.debug(f"Événement déjà traité: {parsed_event['event']['properties']['label']}")
|
logger.info(f"⏭️ Événement ignoré: {event_label} - déjà traité (status: {event_status})")
|
||||||
continue
|
continue
|
||||||
|
|
||||||
events_to_process.append(parsed_event)
|
# 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 par batch
|
# Traiter les événements par batch
|
||||||
logger.info(f"Traitement de {len(events_to_process)} nouveaux événements par batch de {self.batch_size}")
|
logger.info(f"Traitement de {len(events_to_process)} nouveaux é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):
|
for i in range(0, len(events_to_process), self.batch_size):
|
||||||
batch = events_to_process[i:i + self.batch_size]
|
batch = events_to_process[i:i + self.batch_size]
|
||||||
|
@ -298,8 +731,16 @@ class AgendaDuLibreScraper:
|
||||||
|
|
||||||
logger.info(f"Envoi de l'événement: {event_label}")
|
logger.info(f"Envoi de l'événement: {event_label}")
|
||||||
|
|
||||||
|
# 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
|
# Envoyer à l'API
|
||||||
success, message = self.send_event_to_api(event_data)
|
success, message = self.send_event_to_api(event_data, skip_geocoding=skip_geocoding)
|
||||||
|
|
||||||
# Mettre à jour les statistiques et les données locales
|
# Mettre à jour les statistiques et les données locales
|
||||||
if success:
|
if success:
|
||||||
|
@ -311,6 +752,12 @@ class AgendaDuLibreScraper:
|
||||||
"last_attempt": datetime.now().isoformat(),
|
"last_attempt": datetime.now().isoformat(),
|
||||||
"event": event_data["event"]
|
"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}")
|
logger.info(f"✅ {event_label} - {message}")
|
||||||
else:
|
else:
|
||||||
if "déjà existant" in message or "already exists" in message.lower():
|
if "déjà existant" in message or "already exists" in message.lower():
|
||||||
|
@ -321,6 +768,12 @@ class AgendaDuLibreScraper:
|
||||||
"last_attempt": datetime.now().isoformat(),
|
"last_attempt": datetime.now().isoformat(),
|
||||||
"event": event_data["event"]
|
"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}")
|
logger.info(f"⚠️ {event_label} - {message}")
|
||||||
else:
|
else:
|
||||||
stats["api_errors"] += 1
|
stats["api_errors"] += 1
|
||||||
|
@ -335,12 +788,18 @@ class AgendaDuLibreScraper:
|
||||||
# Mettre à jour la date de dernière mise à jour
|
# Mettre à jour la date de dernière mise à jour
|
||||||
self.events_data["last_update"] = datetime.now().isoformat()
|
self.events_data["last_update"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Sauvegarder le cache
|
||||||
|
self.save_cache_data()
|
||||||
|
|
||||||
return stats
|
return stats
|
||||||
|
|
||||||
def run(self, force_refresh: bool = False):
|
def run(self, force_refresh: bool = False):
|
||||||
"""Exécute le scraping complet"""
|
"""Exécute le scraping complet"""
|
||||||
logger.info("🚀 Démarrage du scraping de l'agenda du libre")
|
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"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'}")
|
logger.info(f"Cache iCal: {'ignoré' if force_refresh else f'valide pendant {self.cache_duration_hours}h'}")
|
||||||
|
|
||||||
# Récupérer le fichier iCal
|
# Récupérer le fichier iCal
|
||||||
|
@ -363,6 +822,8 @@ class AgendaDuLibreScraper:
|
||||||
logger.info(f" Erreurs d'API: {stats['api_errors']}")
|
logger.info(f" Erreurs d'API: {stats['api_errors']}")
|
||||||
logger.info(f" Erreurs de parsing: {stats['parse_errors']}")
|
logger.info(f" Erreurs de parsing: {stats['parse_errors']}")
|
||||||
logger.info(f" Événements envoyés cette fois: {stats['sent_this_run']}")
|
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")
|
logger.info("✅ Scraping terminé avec succès")
|
||||||
return True
|
return True
|
||||||
|
@ -373,6 +834,12 @@ def main():
|
||||||
help=f"URL de base de l'API OEDB (défaut: {api_oedb})")
|
help=f"URL de base de l'API OEDB (défaut: {api_oedb})")
|
||||||
parser.add_argument("--batch-size", type=int, default=1,
|
parser.add_argument("--batch-size", type=int, default=1,
|
||||||
help="Nombre d'événements à envoyer par batch (défaut: 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",
|
parser.add_argument("--verbose", "-v", action="store_true",
|
||||||
help="Mode verbeux")
|
help="Mode verbeux")
|
||||||
parser.add_argument("--force-refresh", "-f", action="store_true",
|
parser.add_argument("--force-refresh", "-f", action="store_true",
|
||||||
|
@ -385,10 +852,15 @@ def main():
|
||||||
if args.verbose:
|
if args.verbose:
|
||||||
logging.getLogger().setLevel(logging.DEBUG)
|
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
|
# Créer et exécuter le scraper
|
||||||
scraper = AgendaDuLibreScraper(
|
scraper = AgendaDuLibreScraper(
|
||||||
api_base_url=args.api_url,
|
api_base_url=args.api_url,
|
||||||
batch_size=args.batch_size
|
batch_size=args.batch_size,
|
||||||
|
max_events=args.max_events,
|
||||||
|
dry_run=dry_run
|
||||||
)
|
)
|
||||||
|
|
||||||
# Modifier la durée de cache si spécifiée
|
# Modifier la durée de cache si spécifiée
|
||||||
|
|
File diff suppressed because it is too large
Load diff
File diff suppressed because it is too large
Load diff
593
extractors/demo_agendadulibre_improvements.py
Normal file
593
extractors/demo_agendadulibre_improvements.py
Normal file
|
@ -0,0 +1,593 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Démonstration des améliorations du scraper agenda du libre
|
||||||
|
Simule les fonctionnalités sans dépendances externes
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from datetime import datetime
|
||||||
|
import hashlib
|
||||||
|
|
||||||
|
class DemoAgendaDuLibreScraper:
|
||||||
|
def __init__(self, max_events=None, dry_run=True):
|
||||||
|
self.max_events = max_events
|
||||||
|
self.dry_run = dry_run
|
||||||
|
self.cache_file = "demo_agendadulibre_cache.json"
|
||||||
|
self.events_file = "demo_agendadulibre_events.json"
|
||||||
|
|
||||||
|
# Charger les données existantes
|
||||||
|
self.cache_data = self.load_cache_data()
|
||||||
|
self.events_data = self.load_events_data()
|
||||||
|
|
||||||
|
def load_cache_data(self):
|
||||||
|
"""Charge les données de cache"""
|
||||||
|
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:
|
||||||
|
print(f"Erreur lors du chargement du cache: {e}")
|
||||||
|
return {"processed_events": {}, "last_fetch": None, "content_hash": None}
|
||||||
|
|
||||||
|
def load_events_data(self):
|
||||||
|
"""Charge les données d'événements"""
|
||||||
|
if os.path.exists(self.events_file):
|
||||||
|
try:
|
||||||
|
with open(self.events_file, 'r', encoding='utf-8') as f:
|
||||||
|
return json.load(f)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Erreur lors du chargement des événements: {e}")
|
||||||
|
return {"events": {}, "last_update": None}
|
||||||
|
|
||||||
|
def save_cache_data(self):
|
||||||
|
"""Sauvegarde le cache"""
|
||||||
|
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:
|
||||||
|
print(f"Erreur lors de la sauvegarde du cache: {e}")
|
||||||
|
|
||||||
|
def save_events_data(self):
|
||||||
|
"""Sauvegarde les événements"""
|
||||||
|
try:
|
||||||
|
with open(self.events_file, 'w', encoding='utf-8') as f:
|
||||||
|
json.dump(self.events_data, f, ensure_ascii=False, indent=2)
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Erreur lors de la sauvegarde des événements: {e}")
|
||||||
|
|
||||||
|
def get_content_hash(self, content):
|
||||||
|
"""Calcule le hash du contenu"""
|
||||||
|
return hashlib.md5(content.encode('utf-8')).hexdigest()
|
||||||
|
|
||||||
|
def simulate_ical_fetch(self):
|
||||||
|
"""Simule la récupération d'un fichier iCal"""
|
||||||
|
# Simuler du contenu iCal
|
||||||
|
ical_content = f"""
|
||||||
|
BEGIN:VCALENDAR
|
||||||
|
VERSION:2.0
|
||||||
|
PRODID:-//Demo//Agenda du Libre//EN
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event1@demo.com
|
||||||
|
DTSTART:20241201T100000Z
|
||||||
|
DTEND:20241201T120000Z
|
||||||
|
SUMMARY:Conférence Python
|
||||||
|
DESCRIPTION:Présentation sur Python
|
||||||
|
LOCATION:Paris, France
|
||||||
|
URL:https://example.com/event1
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event2@demo.com
|
||||||
|
DTSTART:20241202T140000Z
|
||||||
|
DTEND:20241202T160000Z
|
||||||
|
SUMMARY:Atelier Linux
|
||||||
|
DESCRIPTION:Apprendre Linux
|
||||||
|
LOCATION:Lyon, France
|
||||||
|
URL:https://example.com/event2
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event3@demo.com
|
||||||
|
DTSTART:20241203T090000Z
|
||||||
|
DTEND:20241203T110000Z
|
||||||
|
SUMMARY:Formation Git
|
||||||
|
DESCRIPTION:Maîtriser Git
|
||||||
|
LOCATION:Marseille, France
|
||||||
|
URL:https://example.com/event3
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event4@demo.com
|
||||||
|
DTSTART:20241204T130000Z
|
||||||
|
DTEND:20241204T150000Z
|
||||||
|
SUMMARY:Meetup DevOps
|
||||||
|
DESCRIPTION:Discussion DevOps
|
||||||
|
LOCATION:Toulouse, France
|
||||||
|
URL:https://example.com/event4
|
||||||
|
END:VEVENT
|
||||||
|
BEGIN:VEVENT
|
||||||
|
UID:event5@demo.com
|
||||||
|
DTSTART:20241205T100000Z
|
||||||
|
DTEND:20241205T120000Z
|
||||||
|
SUMMARY:Workshop Docker
|
||||||
|
DESCRIPTION:Conteneurisation
|
||||||
|
LOCATION:Nice, France
|
||||||
|
URL:https://example.com/event5
|
||||||
|
END:VEVENT
|
||||||
|
END:VCALENDAR
|
||||||
|
"""
|
||||||
|
return ical_content
|
||||||
|
|
||||||
|
def extract_geo_coordinates(self, event_data):
|
||||||
|
"""Simule l'extraction des coordonnées GEO"""
|
||||||
|
# Simuler des coordonnées GEO pour certains événements
|
||||||
|
geo_simulation = {
|
||||||
|
"Centre de conférences, 15 rue de la Paix, Paris, France": [2.3522, 48.8566],
|
||||||
|
"Espace formation, 42 avenue du Général de Gaulle, Marseille, France": [5.3698, 43.2965]
|
||||||
|
}
|
||||||
|
|
||||||
|
location = event_data["location"]
|
||||||
|
if location in geo_simulation:
|
||||||
|
coords = geo_simulation[location]
|
||||||
|
print(f"📍 Coordonnées GEO trouvées: {coords[1]}, {coords[0]}")
|
||||||
|
return coords
|
||||||
|
else:
|
||||||
|
print("Aucun champ GEO trouvé")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_categories(self, event_data):
|
||||||
|
"""Simule l'extraction des catégories"""
|
||||||
|
# Simuler des catégories pour certains événements
|
||||||
|
categories_simulation = {
|
||||||
|
"Centre de conférences, 15 rue de la Paix, Paris, France": ["python", "programmation", "conférence"],
|
||||||
|
"Espace formation, 42 avenue du Général de Gaulle, Marseille, France": ["git", "formation", "développement"],
|
||||||
|
"Lyon, France": ["linux", "atelier", "entraide"],
|
||||||
|
"Toulouse, France": ["devops", "meetup", "discussion"],
|
||||||
|
"Nice, France": ["docker", "workshop", "conteneurisation"]
|
||||||
|
}
|
||||||
|
|
||||||
|
location = event_data["location"]
|
||||||
|
if location in categories_simulation:
|
||||||
|
categories = categories_simulation[location]
|
||||||
|
print(f"🏷️ Catégories trouvées: {', '.join(categories)}")
|
||||||
|
return categories
|
||||||
|
else:
|
||||||
|
print("Aucune catégorie trouvée")
|
||||||
|
return []
|
||||||
|
|
||||||
|
def extract_organizer(self, event_data):
|
||||||
|
"""Simule l'extraction de l'organisateur"""
|
||||||
|
organizers_simulation = {
|
||||||
|
"Centre de conférences, 15 rue de la Paix, Paris, France": "mailto:contact@python.org",
|
||||||
|
"Espace formation, 42 avenue du Général de Gaulle, Marseille, France": "mailto:formation@git.org",
|
||||||
|
"Lyon, France": "mailto:contact@aldil.org",
|
||||||
|
"Toulouse, France": "mailto:devops@toulouse.org",
|
||||||
|
"Nice, France": "mailto:docker@nice.org"
|
||||||
|
}
|
||||||
|
|
||||||
|
location = event_data["location"]
|
||||||
|
if location in organizers_simulation:
|
||||||
|
organizer = organizers_simulation[location]
|
||||||
|
print(f"👤 Organisateur trouvé: {organizer}")
|
||||||
|
return organizer
|
||||||
|
else:
|
||||||
|
print("Aucun organisateur trouvé")
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_alt_description(self, event_data):
|
||||||
|
"""Simule l'extraction de la description alternative HTML"""
|
||||||
|
# Simuler une description HTML pour certains événements
|
||||||
|
if "Centre de conférences" in event_data["location"]:
|
||||||
|
alt_desc = "<p>Conférence sur <strong>Python</strong> avec présentation des nouveautés</p>"
|
||||||
|
print(f"📄 Description alternative HTML trouvée: {len(alt_desc)} caractères")
|
||||||
|
return alt_desc
|
||||||
|
return None
|
||||||
|
|
||||||
|
def extract_short_description(self, event_data):
|
||||||
|
"""Simule l'extraction de la description courte"""
|
||||||
|
summary = event_data["summary"]
|
||||||
|
print(f"📝 Description courte trouvée: {summary}")
|
||||||
|
return summary
|
||||||
|
|
||||||
|
def extract_sequence(self, event_data):
|
||||||
|
"""Simule l'extraction de la séquence"""
|
||||||
|
# Simuler des numéros de séquence
|
||||||
|
sequences = [1, 2, 3, 4, 5]
|
||||||
|
seq_num = sequences[len(event_data["summary"]) % len(sequences)]
|
||||||
|
print(f"🔢 Séquence trouvée: {seq_num}")
|
||||||
|
return seq_num
|
||||||
|
|
||||||
|
def extract_repeat_rules(self, event_data):
|
||||||
|
"""Simule l'extraction des règles de répétition"""
|
||||||
|
# Simuler des règles de répétition pour certains événements
|
||||||
|
if "Atelier" in event_data["summary"]:
|
||||||
|
rrule = "FREQ=WEEKLY;BYDAY=TU"
|
||||||
|
print(f"🔄 Règles de répétition trouvées: {rrule}")
|
||||||
|
return rrule
|
||||||
|
elif "Workshop" in event_data["summary"]:
|
||||||
|
rrule = "FREQ=MONTHLY;BYDAY=1SA"
|
||||||
|
print(f"🔄 Règles de répétition trouvées: {rrule}")
|
||||||
|
return rrule
|
||||||
|
return None
|
||||||
|
|
||||||
|
def parse_event(self, event_data):
|
||||||
|
"""Parse un événement simulé"""
|
||||||
|
# Extraire les coordonnées GEO si disponibles
|
||||||
|
geo_coords = self.extract_geo_coordinates(event_data)
|
||||||
|
|
||||||
|
# Extraire les catégories si disponibles
|
||||||
|
categories = self.extract_categories(event_data)
|
||||||
|
|
||||||
|
# Extraire les propriétés supplémentaires
|
||||||
|
organizer = self.extract_organizer(event_data)
|
||||||
|
alt_description = self.extract_alt_description(event_data)
|
||||||
|
short_description = self.extract_short_description(event_data)
|
||||||
|
sequence = self.extract_sequence(event_data)
|
||||||
|
repeat_rules = self.extract_repeat_rules(event_data)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"id": hashlib.md5(event_data["summary"].encode('utf-8')).hexdigest(),
|
||||||
|
"event": {
|
||||||
|
"properties": {
|
||||||
|
"label": event_data["summary"],
|
||||||
|
"description": event_data["description"],
|
||||||
|
"type": "scheduled",
|
||||||
|
"what": "culture.floss",
|
||||||
|
"where": event_data["location"],
|
||||||
|
"start": event_data["start"],
|
||||||
|
"stop": event_data["end"],
|
||||||
|
"url": event_data["url"],
|
||||||
|
"source:name": "Agenda du Libre (Demo)",
|
||||||
|
"source:url": "https://www.agendadulibre.org/",
|
||||||
|
"last_modified_by": "demo_scraper",
|
||||||
|
"tags": categories if categories else [],
|
||||||
|
"organizer": organizer,
|
||||||
|
"alt_description": alt_description,
|
||||||
|
"short_description": short_description,
|
||||||
|
"sequence": sequence,
|
||||||
|
"repeat_rules": repeat_rules
|
||||||
|
},
|
||||||
|
"geometry": {
|
||||||
|
"type": "Point",
|
||||||
|
"coordinates": geo_coords if geo_coords else [0, 0]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"raw_ical": {
|
||||||
|
"geo": geo_coords,
|
||||||
|
"categories": categories,
|
||||||
|
"organizer": organizer,
|
||||||
|
"alt_description": alt_description,
|
||||||
|
"short_description": short_description,
|
||||||
|
"sequence": sequence,
|
||||||
|
"repeat_rules": repeat_rules
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def clean_location_for_geocoding(self, location):
|
||||||
|
"""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):
|
||||||
|
print(f"📍 Adresse potentielle trouvée: {address_part}")
|
||||||
|
return address_part
|
||||||
|
|
||||||
|
# Si pas de virgule ou pas d'adresse valide, essayer le lieu complet
|
||||||
|
print(f"📍 Tentative de géocodage avec le lieu complet: {location}")
|
||||||
|
return location.strip()
|
||||||
|
|
||||||
|
def simulate_geocoding(self, location):
|
||||||
|
"""Simule le géocodage avec des coordonnées fictives"""
|
||||||
|
if not location:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Simulation de coordonnées basées sur le lieu
|
||||||
|
fake_coords = {
|
||||||
|
"Paris": [2.3522, 48.8566],
|
||||||
|
"Lyon": [4.8357, 45.7640],
|
||||||
|
"Marseille": [5.3698, 43.2965],
|
||||||
|
"Toulouse": [1.4442, 43.6047],
|
||||||
|
"Nice": [7.2619, 43.7102],
|
||||||
|
"Nantes": [-1.5536, 47.2184],
|
||||||
|
"Strasbourg": [7.7521, 48.5734],
|
||||||
|
"Montpellier": [3.8767, 43.6110],
|
||||||
|
"Bordeaux": [-0.5792, 44.8378],
|
||||||
|
"Lille": [3.0573, 50.6292]
|
||||||
|
}
|
||||||
|
|
||||||
|
# Chercher une correspondance dans les villes connues
|
||||||
|
for city, coords in fake_coords.items():
|
||||||
|
if city.lower() in location.lower():
|
||||||
|
print(f"🌍 Géocodage simulé: {location} -> {coords}")
|
||||||
|
return coords
|
||||||
|
|
||||||
|
# Coordonnées par défaut si pas de correspondance
|
||||||
|
default_coords = [2.3522, 48.8566] # Paris par défaut
|
||||||
|
print(f"🌍 Géocodage simulé (défaut): {location} -> {default_coords}")
|
||||||
|
return default_coords
|
||||||
|
|
||||||
|
def improve_event_coordinates(self, event_data):
|
||||||
|
"""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 simulé
|
||||||
|
new_coords = self.simulate_geocoding(clean_location)
|
||||||
|
|
||||||
|
if new_coords:
|
||||||
|
# Mettre à jour les coordonnées
|
||||||
|
event_data["event"]["geometry"]["coordinates"] = new_coords
|
||||||
|
print(f"🎯 Coordonnées mises à jour par géocodage: {coords} -> {new_coords}")
|
||||||
|
else:
|
||||||
|
print(f"⚠️ Impossible de géocoder: {clean_location}")
|
||||||
|
else:
|
||||||
|
print(f"ℹ️ Lieu non géocodable: {location}")
|
||||||
|
else:
|
||||||
|
print("ℹ️ 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:
|
||||||
|
print(f"✅ Coordonnées utilisées depuis le champ GEO: {coords}")
|
||||||
|
else:
|
||||||
|
print(f"ℹ️ Coordonnées déjà définies: {coords}")
|
||||||
|
|
||||||
|
return event_data
|
||||||
|
|
||||||
|
def log_event_details(self, event_data):
|
||||||
|
"""Log détaillé de l'événement avant envoi"""
|
||||||
|
props = event_data["event"]["properties"]
|
||||||
|
geom = event_data["event"]["geometry"]
|
||||||
|
|
||||||
|
print("📝 Détails de l'événement à insérer:")
|
||||||
|
print(f" ID: {event_data['id']}")
|
||||||
|
print(f" Titre: {props.get('label', 'N/A')}")
|
||||||
|
print(f" Description: {props.get('description', 'N/A')[:100]}{'...' if len(props.get('description', '')) > 100 else ''}")
|
||||||
|
print(f" Type: {props.get('type', 'N/A')}")
|
||||||
|
print(f" Catégorie: {props.get('what', 'N/A')}")
|
||||||
|
print(f" Lieu: {props.get('where', 'N/A')}")
|
||||||
|
print(f" Début: {props.get('start', 'N/A')}")
|
||||||
|
print(f" Fin: {props.get('stop', 'N/A')}")
|
||||||
|
print(f" URL: {props.get('url', 'N/A')}")
|
||||||
|
print(f" Source: {props.get('source:name', 'N/A')}")
|
||||||
|
print(f" Coordonnées: {geom.get('coordinates', 'N/A')}")
|
||||||
|
print(f" Tags: {', '.join(props.get('tags', [])) if props.get('tags') else 'N/A'}")
|
||||||
|
print(f" Organisateur: {props.get('organizer', 'N/A')}")
|
||||||
|
print(f" Description courte: {props.get('short_description', 'N/A')}")
|
||||||
|
print(f" Séquence: {props.get('sequence', 'N/A')}")
|
||||||
|
print(f" Règles de répétition: {props.get('repeat_rules', 'N/A')}")
|
||||||
|
print(f" Description HTML: {'Oui' if props.get('alt_description') else 'N/A'}")
|
||||||
|
print(f" Modifié par: {props.get('last_modified_by', 'N/A')}")
|
||||||
|
|
||||||
|
def send_event_to_api(self, event_data, skip_geocoding=False):
|
||||||
|
"""Simule l'envoi à l'API"""
|
||||||
|
# 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:
|
||||||
|
print("ℹ️ 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:
|
||||||
|
print(f"[DRY-RUN] Simulation d'envoi: {event_data['event']['properties']['label']}")
|
||||||
|
return True, "Simulé (dry-run)"
|
||||||
|
else:
|
||||||
|
print(f"[API] Envoi réel: {event_data['event']['properties']['label']}")
|
||||||
|
return True, "Envoyé avec succès"
|
||||||
|
|
||||||
|
def process_events(self):
|
||||||
|
"""Traite les événements"""
|
||||||
|
# Simuler des événements avec des lieux variés pour tester le géocodage
|
||||||
|
events = [
|
||||||
|
{
|
||||||
|
"summary": "Conférence Python",
|
||||||
|
"description": "Présentation sur Python",
|
||||||
|
"location": "Centre de conférences, 15 rue de la Paix, Paris, France",
|
||||||
|
"start": "2024-12-01T10:00:00",
|
||||||
|
"end": "2024-12-01T12:00:00",
|
||||||
|
"url": "https://example.com/event1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Atelier Linux",
|
||||||
|
"description": "Apprendre Linux",
|
||||||
|
"location": "Lyon, France",
|
||||||
|
"start": "2024-12-02T14:00:00",
|
||||||
|
"end": "2024-12-02T16:00:00",
|
||||||
|
"url": "https://example.com/event2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Formation Git",
|
||||||
|
"description": "Maîtriser Git",
|
||||||
|
"location": "Espace formation, 42 avenue du Général de Gaulle, Marseille, France",
|
||||||
|
"start": "2024-12-03T09:00:00",
|
||||||
|
"end": "2024-12-03T11:00:00",
|
||||||
|
"url": "https://example.com/event3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Meetup DevOps",
|
||||||
|
"description": "Discussion DevOps",
|
||||||
|
"location": "Toulouse, France",
|
||||||
|
"start": "2024-12-04T13:00:00",
|
||||||
|
"end": "2024-12-04T15:00:00",
|
||||||
|
"url": "https://example.com/event4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"summary": "Workshop Docker",
|
||||||
|
"description": "Conteneurisation",
|
||||||
|
"location": "Nice, France",
|
||||||
|
"start": "2024-12-05T10:00:00",
|
||||||
|
"end": "2024-12-05T12:00:00",
|
||||||
|
"url": "https://example.com/event5"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
"total_events": len(events),
|
||||||
|
"new_events": 0,
|
||||||
|
"already_saved": 0,
|
||||||
|
"api_errors": 0,
|
||||||
|
"parse_errors": 0,
|
||||||
|
"sent_this_run": 0,
|
||||||
|
"skipped_due_to_limit": 0
|
||||||
|
}
|
||||||
|
|
||||||
|
processed_count = 0
|
||||||
|
|
||||||
|
print(f"Traitement de {len(events)} événements")
|
||||||
|
if self.max_events:
|
||||||
|
print(f"Limite d'événements: {self.max_events}")
|
||||||
|
if self.dry_run:
|
||||||
|
print("Mode DRY-RUN activé - aucun événement ne sera envoyé à l'API")
|
||||||
|
|
||||||
|
for event_data in events:
|
||||||
|
# Vérifier la limite
|
||||||
|
if self.max_events and processed_count >= self.max_events:
|
||||||
|
stats["skipped_due_to_limit"] += 1
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Parser l'événement
|
||||||
|
parsed_event = self.parse_event(event_data)
|
||||||
|
event_id = parsed_event["id"]
|
||||||
|
|
||||||
|
# Vérifier si déjà traité
|
||||||
|
if event_id in self.cache_data["processed_events"]:
|
||||||
|
stats["already_saved"] += 1
|
||||||
|
print(f"Événement déjà traité: {parsed_event['event']['properties']['label']}")
|
||||||
|
continue
|
||||||
|
|
||||||
|
# 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
|
||||||
|
print(f"ℹ️ Géocodage ignoré pour {parsed_event['event']['properties']['label']} - déjà traité")
|
||||||
|
|
||||||
|
# Envoyer à l'API
|
||||||
|
success, message = self.send_event_to_api(parsed_event, skip_geocoding=skip_geocoding)
|
||||||
|
|
||||||
|
if success:
|
||||||
|
stats["new_events"] += 1
|
||||||
|
stats["sent_this_run"] += 1
|
||||||
|
|
||||||
|
# Mettre à jour les données
|
||||||
|
self.events_data["events"][event_id] = {
|
||||||
|
"status": "saved",
|
||||||
|
"message": message,
|
||||||
|
"last_attempt": datetime.now().isoformat(),
|
||||||
|
"event": parsed_event["event"]
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cache_data["processed_events"][event_id] = {
|
||||||
|
"processed_at": datetime.now().isoformat(),
|
||||||
|
"status": "saved",
|
||||||
|
"event_label": parsed_event["event"]["properties"]["label"]
|
||||||
|
}
|
||||||
|
|
||||||
|
print(f"✅ {parsed_event['event']['properties']['label']} - {message}")
|
||||||
|
else:
|
||||||
|
stats["api_errors"] += 1
|
||||||
|
print(f"❌ {parsed_event['event']['properties']['label']} - Erreur")
|
||||||
|
|
||||||
|
processed_count += 1
|
||||||
|
|
||||||
|
# Mettre à jour les timestamps
|
||||||
|
self.events_data["last_update"] = datetime.now().isoformat()
|
||||||
|
self.cache_data["last_fetch"] = datetime.now().isoformat()
|
||||||
|
|
||||||
|
# Sauvegarder
|
||||||
|
self.save_events_data()
|
||||||
|
self.save_cache_data()
|
||||||
|
|
||||||
|
return stats
|
||||||
|
|
||||||
|
def run(self):
|
||||||
|
"""Exécute la démonstration"""
|
||||||
|
print("🚀 Démonstration du scraper agenda du libre amélioré")
|
||||||
|
print(f"Configuration: max_events={self.max_events}, dry_run={self.dry_run}")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Simuler la récupération iCal
|
||||||
|
ical_content = self.simulate_ical_fetch()
|
||||||
|
content_hash = self.get_content_hash(ical_content)
|
||||||
|
|
||||||
|
# Vérifier si le contenu a changé
|
||||||
|
if self.cache_data["content_hash"] == content_hash:
|
||||||
|
print("Contenu iCal identique au précédent, utilisation du cache")
|
||||||
|
else:
|
||||||
|
print("Nouveau contenu iCal détecté, mise à jour du cache")
|
||||||
|
self.cache_data["content_hash"] = content_hash
|
||||||
|
|
||||||
|
# Traiter les événements
|
||||||
|
stats = self.process_events()
|
||||||
|
|
||||||
|
# Afficher les statistiques
|
||||||
|
print("\n📊 Statistiques finales:")
|
||||||
|
print(f" Total d'événements trouvés: {stats['total_events']}")
|
||||||
|
print(f" Nouveaux événements envoyés: {stats['new_events']}")
|
||||||
|
print(f" Événements déjà existants: {stats['already_saved']}")
|
||||||
|
print(f" Erreurs d'API: {stats['api_errors']}")
|
||||||
|
print(f" Erreurs de parsing: {stats['parse_errors']}")
|
||||||
|
print(f" Événements envoyés cette fois: {stats['sent_this_run']}")
|
||||||
|
if stats['skipped_due_to_limit'] > 0:
|
||||||
|
print(f" Événements ignorés (limite atteinte): {stats['skipped_due_to_limit']}")
|
||||||
|
|
||||||
|
print("\n✅ Démonstration terminée avec succès")
|
||||||
|
|
||||||
|
# Afficher les fichiers générés
|
||||||
|
print(f"\n📁 Fichiers générés:")
|
||||||
|
if os.path.exists(self.cache_file):
|
||||||
|
print(f" Cache: {self.cache_file}")
|
||||||
|
if os.path.exists(self.events_file):
|
||||||
|
print(f" Événements: {self.events_file}")
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Fonction principale de démonstration"""
|
||||||
|
print("🧪 Démonstration des améliorations du scraper agenda du libre")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Test 1: Mode dry-run avec limite
|
||||||
|
print("\n1️⃣ Test 1: Mode dry-run avec limite de 3 événements")
|
||||||
|
scraper1 = DemoAgendaDuLibreScraper(max_events=3, dry_run=True)
|
||||||
|
scraper1.run()
|
||||||
|
|
||||||
|
# Test 2: Mode dry-run sans limite
|
||||||
|
print("\n2️⃣ Test 2: Mode dry-run sans limite")
|
||||||
|
scraper2 = DemoAgendaDuLibreScraper(max_events=None, dry_run=True)
|
||||||
|
scraper2.run()
|
||||||
|
|
||||||
|
# Test 3: Mode réel avec limite
|
||||||
|
print("\n3️⃣ Test 3: Mode réel avec limite de 2 événements")
|
||||||
|
scraper3 = DemoAgendaDuLibreScraper(max_events=2, dry_run=False)
|
||||||
|
scraper3.run()
|
||||||
|
|
||||||
|
print("\n🎉 Toutes les démonstrations sont terminées !")
|
||||||
|
print("\nFonctionnalités démontrées:")
|
||||||
|
print("✅ Cache JSON intelligent")
|
||||||
|
print("✅ Limitation du nombre d'événements")
|
||||||
|
print("✅ Mode dry-run par défaut")
|
||||||
|
print("✅ Détection de changements de contenu")
|
||||||
|
print("✅ Suivi des événements traités")
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
|
@ -209,6 +209,8 @@
|
||||||
|
|
||||||
<option value="Russia">Russia</option>
|
<option value="Russia">Russia</option>
|
||||||
|
|
||||||
|
<option value="Saudi Arabia">Saudi Arabia</option>
|
||||||
|
|
||||||
<option value="Senegal">Senegal</option>
|
<option value="Senegal">Senegal</option>
|
||||||
|
|
||||||
<option value="Serbia">Serbia</option>
|
<option value="Serbia">Serbia</option>
|
||||||
|
@ -271,42 +273,19 @@
|
||||||
|
|
||||||
<ul class="event-list">
|
<ul class="event-list">
|
||||||
|
|
||||||
<li>
|
|
||||||
<h3 class="event-list-group-title">August</h3>
|
|
||||||
<ul class="event-list-group">
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3973/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Mapatón Nacional de México 2025</p>
|
|
||||||
<p class="event-entry-location">Zacatecas, Mexico</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
19th August–30th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
<li>
|
||||||
<h3 class="event-list-group-title"></h3>
|
<h3 class="event-list-group-title"></h3>
|
||||||
<ul class="event-list-group">
|
<ul class="event-list-group">
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3987/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4097/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">UN Mappers Mappy Hour</p>
|
<p class="event-entry-name">Sosial samling</p>
|
||||||
|
<p class="event-entry-location">Oslo, Norway</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
26th September
|
9th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -314,342 +293,15 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4062/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4098/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">Workshop „OpenStreetMap“ beim Science Day 2025</p>
|
<p class="event-entry-name">Mapatona de Estradas Rurais - Zona Sudeste de Rio Paranaíba</p>
|
||||||
<p class="event-entry-location">Kiel, Schleswig-Holstein, Germany</p>
|
<p class="event-entry-location">Rio Paranaíba, Minas Gerais, Brazil</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
26th September
|
9th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3855/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Düsseldorfer OpenStreetMap-Treffen (online)</p>
|
|
||||||
<p class="event-entry-location">Dusseldorf, North Rhine-Westphalia, Germany</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
26th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4002/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Encuentro de la Comunidad de OpenStreetMap de Argentina</p>
|
|
||||||
<p class="event-entry-location">Luján, Buenos Aires, Argentina</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
27th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4014/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Atelier du groupe local de Metz - Septembre 2025</p>
|
|
||||||
<p class="event-entry-location">Metz, Grand Est, France</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
27th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4071/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">OpenStreetMap Community Meet-Up - Istanbul</p>
|
|
||||||
<p class="event-entry-location">Kadıköy, Turkey</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
27th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4070/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Intro to OpenStreetMap.org Operations - Getting involved</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
27th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3993/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">23rd OSM Delhi MapWalk</p>
|
|
||||||
<p class="event-entry-location">Gurgaon, Haryana, India</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
28th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4055/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Rencontre Saint-Étienne et sud Loire</p>
|
|
||||||
<p class="event-entry-location">Saint-Étienne, Auvergne-Rhône-Alpes, France</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
29th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4037/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">OSM Suomi kartoittajatapaaminen</p>
|
|
||||||
<p class="event-entry-location">Helsinki, Uusimaa, Finland</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
30th September
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
</ul>
|
|
||||||
</li>
|
|
||||||
|
|
||||||
<li>
|
|
||||||
<h3 class="event-list-group-title">October</h3>
|
|
||||||
<ul class="event-list-group">
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4033/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Stuttgarter OpenStreetMap-Treffen</p>
|
|
||||||
<p class="event-entry-location">Stuttgart, Baden-Württemberg, Germany</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
1st October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4058/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Mappy Hour OSM España</p>
|
|
||||||
<p class="event-entry-location">Madrid, Community of Madrid, Spain</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
2nd October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3928/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">OSM-Treffen in Bochum</p>
|
|
||||||
<p class="event-entry-location">Bochum, North Rhine-Westphalia, Germany</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
2nd October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4068/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">[Online] 🇧🇷 Oficina de mapeamento de áreas no OpenStreetMap com editor JOSM</p>
|
|
||||||
<p class="event-entry-location">Rio de Janeiro, Rio de Janeiro, Brazil</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
2nd October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3549/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">State of the Map 2025</p>
|
|
||||||
<p class="event-entry-location">Quezon City, Philippines</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
3rd–5th October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4067/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">OSMF Engineering Working Group meeting</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
3rd October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4054/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">17. Mapathon & Mapping Party Rapperswil 2025</p>
|
|
||||||
<p class="event-entry-location">Rapperswil, Rapperswil-Jona, St. Gallen, Switzerland</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
3rd October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4049/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Maptime Amsterdam: Autumn mapping party</p>
|
|
||||||
<p class="event-entry-location">Amsterdam, North Holland, Netherlands</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
3rd October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3931/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">OSM India Online (Remote) Mapping Party</p>
|
|
||||||
<p class="event-entry-location">New Delhi, Delhi, India</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
5th October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3869/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Vortrag - Wie nutze ich OpenStreetMap - BWS Lehen-Vorstadt, Salzburg</p>
|
|
||||||
<p class="event-entry-location">Salzburg, Salzburg, Austria</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
7th October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3990/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">East Midlands pub meet-up</p>
|
|
||||||
<p class="event-entry-location">Derby, England, United Kingdom</p>
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
7th October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
|
||||||
</a></li>
|
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3737/" class="event-list-entry-box">
|
|
||||||
<div class="event-entry-main">
|
|
||||||
<p class="event-entry-name">Missing Maps London: (Online) Mapathon [eng]</p>
|
|
||||||
|
|
||||||
</div>
|
|
||||||
<span class="event-entry-date">
|
|
||||||
|
|
||||||
|
|
||||||
7th October
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -673,10 +325,10 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4076/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4074/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">[Online] 🇧🇷 Oficina de validação com editor JOSM</p>
|
<p class="event-entry-name">Mapathon Bliesgau, Saarpfalz-Kreis</p>
|
||||||
<p class="event-entry-location">Rio de Janeiro, Rio de Janeiro, Brazil</p>
|
<p class="event-entry-location">Homburg, Saarland, Germany</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
@ -705,10 +357,26 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4074/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4079/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">Mapathon Bliesgau, Saarpfalz-Kreis</p>
|
<p class="event-entry-name">OSM-Treffen in Bochum</p>
|
||||||
<p class="event-entry-location">Homburg, Saarland, Germany</p>
|
<p class="event-entry-location">Bochum, North Rhine-Westphalia, Germany</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
9th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4076/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">[Online] 🇧🇷 Oficina de validação com editor JOSM</p>
|
||||||
|
<p class="event-entry-location">Rio de Janeiro, Rio de Janeiro, Brazil</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
@ -766,6 +434,38 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4080/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Cartopartie cyclable</p>
|
||||||
|
<p class="event-entry-location">Étalle, Luxembourg, Belgium</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
11th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4099/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSM Bhopal Mapping Party 0</p>
|
||||||
|
<p class="event-entry-location">Bhopal, Madhya Pradesh, India</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
11th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -787,7 +487,7 @@
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3997/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/3997/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">24th OSM Delhi MapWalk (Outer West zone)</p>
|
<p class="event-entry-name">24th OSM Delhi MapWalk (Dwarka)</p>
|
||||||
<p class="event-entry-location">Delhi, India</p>
|
<p class="event-entry-location">Delhi, India</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
@ -804,7 +504,23 @@
|
||||||
<li class="event-list-entry"><a href="/event/4040/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4040/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">Missing Maps : Mapathon en ligne - CartONG [fr]</p>
|
<p class="event-entry-name">Missing Maps : Mapathon en ligne - CartONG [fr]</p>
|
||||||
|
<p class="event-entry-location">Chambéry, Auvergne-Rhône-Alpes, France</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
13th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4088/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">180. OSM-Stammtisch Zürich</p>
|
||||||
|
<p class="event-entry-location">Zurich, Zurich, Switzerland</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
@ -929,10 +645,10 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/4051/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4109/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">OSM Mumbai MapWalk #4</p>
|
<p class="event-entry-name">LàO : Libre à Orx</p>
|
||||||
<p class="event-entry-location">Mumbai, Maharashtra, India</p>
|
<p class="event-entry-location">Orx, Nouvelle-Aquitaine, France</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
@ -945,15 +661,15 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3998/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4051/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">25th OSM Delhi MapWalk (Meerut)</p>
|
<p class="event-entry-name">OSM Mumbai MapWalk #4</p>
|
||||||
<p class="event-entry-location">Meerut, Uttar Pradesh, India</p>
|
<p class="event-entry-location">Mumbai, Maharashtra, India</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
19th October
|
18th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1038,6 +754,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4100/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Intune Core Services GIVE: Missing Maps Mapathon</p>
|
||||||
|
<p class="event-entry-location">Redmond, Washington, United States</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
22nd October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1086,6 +818,38 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4096/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Missing Maps mapathon Žilina #19</p>
|
||||||
|
<p class="event-entry-location">Žilina, Region of Žilina, Slovakia</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
23rd October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4107/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Rencontre communauté OSM Tours (Fr)</p>
|
||||||
|
<p class="event-entry-location">Tours, Centre-Val de Loire, France</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
24th October
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1192,6 +956,70 @@
|
||||||
<h3 class="event-list-group-title">November</h3>
|
<h3 class="event-list-group-title">November</h3>
|
||||||
<ul class="event-list-group">
|
<ul class="event-list-group">
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4105/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSM-stand @ International Day Les Scouts</p>
|
||||||
|
<p class="event-entry-location">Courrière, Namur, Belgium</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
1st November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4101/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Braunschweiger Mappertreffen im Stratum0 Hackerspace</p>
|
||||||
|
<p class="event-entry-location">Brunswick, Lower Saxony, Germany</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
1st November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4110/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSM India - Monthly Online Mapathon</p>
|
||||||
|
<p class="event-entry-location">New Delhi, India</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
2nd November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4084/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Mapathon - Monthly Missing Maps w/ EWB Norway</p>
|
||||||
|
<p class="event-entry-location">Oslo, Norway</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
4th November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3870/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/3870/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">OSM Treffen Salzburg</p>
|
<p class="event-entry-name">OSM Treffen Salzburg</p>
|
||||||
|
@ -1205,6 +1033,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/3738/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Missing Maps London: (Online) Mapathon [eng]</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
4th November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1224,15 +1068,15 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3738/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/4093/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">Missing Maps London: (Online) Mapathon [eng]</p>
|
<p class="event-entry-name">Stuttgarter OpenStreetMap-Treffen</p>
|
||||||
|
<p class="event-entry-location">Stuttgart, Baden-Württemberg, Germany</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
4th November
|
5th November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
@ -1285,6 +1129,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4086/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSMmapperCPH</p>
|
||||||
|
<p class="event-entry-location">Copenhagen, Capital Region of Denmark, Denmark</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
9th November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1317,6 +1177,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4092/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">181. OSM-Stammtisch Zürich</p>
|
||||||
|
<p class="event-entry-location">Zurich, Zurich, Switzerland</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
11th November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1541,6 +1417,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4108/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Maptime Amsterdam: Map & Meet</p>
|
||||||
|
<p class="event-entry-location">Amsterdam, North Holland, Netherlands</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
27th November
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1567,6 +1459,22 @@
|
||||||
<h3 class="event-list-group-title">December</h3>
|
<h3 class="event-list-group-title">December</h3>
|
||||||
<ul class="event-list-group">
|
<ul class="event-list-group">
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4085/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Mapathon - Monthly Missing Maps w/ EWB Norway</p>
|
||||||
|
<p class="event-entry-location">Oslo, Norway</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
2nd December
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3871/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/3871/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">OSM Treffen Salzburg</p>
|
<p class="event-entry-name">OSM Treffen Salzburg</p>
|
||||||
|
@ -1596,6 +1504,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4104/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">East Midlands pub meet-up</p>
|
||||||
|
<p class="event-entry-location">Derby, England, United Kingdom</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
2nd December
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1612,6 +1536,38 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4102/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Braunschweiger Mappertreffen im Stratum0 Hackerspace</p>
|
||||||
|
<p class="event-entry-location">Brunswick, Lower Saxony, Germany</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
6th December
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4111/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSM India - Monthly Online Mapathon</p>
|
||||||
|
<p class="event-entry-location">New Delhi, India</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
7th December
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1692,6 +1648,22 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4081/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">GeoCamp - State of the Map España 2025</p>
|
||||||
|
<p class="event-entry-location">Zaragoza, Aragon, Spain</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
13th December
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
@ -1759,10 +1731,10 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3441/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/3858/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">19. Österreichischer OSM-Stammtisch (online)</p>
|
<p class="event-entry-name">Düsseldorfer OpenStreetMap-Treffen (online)</p>
|
||||||
<p class="event-entry-location">Stainach-Pürgg, Styria, Austria</p>
|
<p class="event-entry-location">Dusseldorf, North Rhine-Westphalia, Germany</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
@ -1775,10 +1747,10 @@
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3858/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/3441/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">Düsseldorfer OpenStreetMap-Treffen (online)</p>
|
<p class="event-entry-name">19. Österreichischer OSM-Stammtisch (online)</p>
|
||||||
<p class="event-entry-location">Dusseldorf, North Rhine-Westphalia, Germany</p>
|
<p class="event-entry-location">Stainach-Pürgg, Styria, Austria</p>
|
||||||
</div>
|
</div>
|
||||||
<span class="event-entry-date">
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
@ -1830,6 +1802,22 @@
|
||||||
<h3 class="event-list-group-title">January 2026</h3>
|
<h3 class="event-list-group-title">January 2026</h3>
|
||||||
<ul class="event-list-group">
|
<ul class="event-list-group">
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4112/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSM India - Monthly Online Mapathon</p>
|
||||||
|
<p class="event-entry-location">New Delhi, India</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
4th January
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
<li class="event-list-entry"><a href="/event/3790/" class="event-list-entry-box">
|
<li class="event-list-entry"><a href="/event/3790/" class="event-list-entry-box">
|
||||||
<div class="event-entry-main">
|
<div class="event-entry-main">
|
||||||
<p class="event-entry-name">OpenStreetMap Midwest Meetup</p>
|
<p class="event-entry-name">OpenStreetMap Midwest Meetup</p>
|
||||||
|
@ -1843,6 +1831,61 @@
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4103/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">East Midlands pub meet-up</p>
|
||||||
|
<p class="event-entry-location">Derby, England, United Kingdom</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
20th January
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4082/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">Mapping USA 2026</p>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
30th–31st January
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
</span>
|
||||||
|
</a></li>
|
||||||
|
|
||||||
|
</ul>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li>
|
||||||
|
<h3 class="event-list-group-title">February 2026</h3>
|
||||||
|
<ul class="event-list-group">
|
||||||
|
|
||||||
|
<li class="event-list-entry"><a href="/event/4113/" class="event-list-entry-box">
|
||||||
|
<div class="event-entry-main">
|
||||||
|
<p class="event-entry-name">OSM India - Monthly Online Mapathon</p>
|
||||||
|
<p class="event-entry-location">New Delhi, India</p>
|
||||||
|
</div>
|
||||||
|
<span class="event-entry-date">
|
||||||
|
|
||||||
|
|
||||||
|
1st February
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
</span>
|
</span>
|
||||||
</a></li>
|
</a></li>
|
||||||
|
|
||||||
|
|
92
extractors/test_agendadulibre_improvements.py
Normal file
92
extractors/test_agendadulibre_improvements.py
Normal file
|
@ -0,0 +1,92 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
"""
|
||||||
|
Script de test pour les améliorations du scraper agenda du libre
|
||||||
|
Démontre les nouvelles fonctionnalités : cache JSON, limitation d'événements, mode dry-run
|
||||||
|
"""
|
||||||
|
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import os
|
||||||
|
|
||||||
|
def run_test(test_name, command):
|
||||||
|
"""Exécute un test et affiche les résultats"""
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print(f"TEST: {test_name}")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
print(f"Commande: {' '.join(command)}")
|
||||||
|
print("-" * 60)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = subprocess.run(command, capture_output=True, text=True, timeout=120)
|
||||||
|
print("STDOUT:")
|
||||||
|
print(result.stdout)
|
||||||
|
if result.stderr:
|
||||||
|
print("STDERR:")
|
||||||
|
print(result.stderr)
|
||||||
|
print(f"Code de retour: {result.returncode}")
|
||||||
|
return result.returncode == 0
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
print("TIMEOUT: Le test a pris trop de temps")
|
||||||
|
return False
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERREUR: {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def main():
|
||||||
|
"""Exécute une série de tests pour démontrer les améliorations"""
|
||||||
|
print("🧪 Tests des améliorations du scraper agenda du libre")
|
||||||
|
print("=" * 60)
|
||||||
|
|
||||||
|
# Vérifier que le script existe
|
||||||
|
script_path = "agendadulibre.py"
|
||||||
|
if not os.path.exists(script_path):
|
||||||
|
print(f"❌ Erreur: Le script {script_path} n'existe pas")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
tests = [
|
||||||
|
{
|
||||||
|
"name": "Test 1: Mode dry-run par défaut (limite 5 événements)",
|
||||||
|
"command": [sys.executable, script_path, "--max-events", "5", "--verbose"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test 2: Mode dry-run avec cache (limite 3 événements)",
|
||||||
|
"command": [sys.executable, script_path, "--max-events", "3", "--verbose"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test 3: Mode réel (--no-dry-run) avec limite 2 événements",
|
||||||
|
"command": [sys.executable, script_path, "--no-dry-run", "--max-events", "2", "--verbose"]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Test 4: Force refresh avec dry-run",
|
||||||
|
"command": [sys.executable, script_path, "--force-refresh", "--max-events", "3", "--verbose"]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
|
||||||
|
results = []
|
||||||
|
for test in tests:
|
||||||
|
success = run_test(test["name"], test["command"])
|
||||||
|
results.append((test["name"], success))
|
||||||
|
|
||||||
|
# Résumé des tests
|
||||||
|
print(f"\n{'='*60}")
|
||||||
|
print("RÉSUMÉ DES TESTS")
|
||||||
|
print(f"{'='*60}")
|
||||||
|
|
||||||
|
passed = 0
|
||||||
|
for name, success in results:
|
||||||
|
status = "✅ PASSÉ" if success else "❌ ÉCHOUÉ"
|
||||||
|
print(f"{status}: {name}")
|
||||||
|
if success:
|
||||||
|
passed += 1
|
||||||
|
|
||||||
|
print(f"\nRésultat: {passed}/{len(results)} tests réussis")
|
||||||
|
|
||||||
|
if passed == len(results):
|
||||||
|
print("🎉 Tous les tests sont passés avec succès !")
|
||||||
|
return 0
|
||||||
|
else:
|
||||||
|
print("⚠️ Certains tests ont échoué")
|
||||||
|
return 1
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
Loading…
Add table
Add a link
Reference in a new issue