Compare commits

...
Sign in to create a new pull request.

69 commits

Author SHA1 Message Date
Tykayn
2f9b91f2c7 more style for interface; dark top 2025-10-15 00:20:52 +02:00
Tykayn
e7a2d93d18 ajout infos dans event preset 2025-10-14 23:54:59 +02:00
Tykayn
6e3965e515 scrapper expos paris 2025-10-14 23:02:32 +02:00
Tykayn
8678036a2c clean files 2025-10-14 23:02:21 +02:00
Tykayn
a6331c8ced up setting plein air 2025-10-14 18:34:49 +02:00
Tykayn
4f6a388129 up 2025-10-14 17:37:12 +02:00
Tykayn
cc92e2b5d7 ajout mode plein air v1 2025-10-14 17:29:37 +02:00
Tykayn
11151bc91d crontab script 2025-10-13 10:59:31 +02:00
Tykayn
7dd38624a4 style agenda 2025-10-13 10:49:13 +02:00
Tykayn
737781e9aa changer pages embed, ajout définitions culture g 2025-10-12 18:13:11 +02:00
Tykayn
2c95bea01b add embed, research pages 2025-10-12 17:19:50 +02:00
Tykayn
2238380e80 up demo by what 2025-10-10 17:56:50 +02:00
Tykayn
26bfe4ae36 add journées mondiales et vacances 2025-10-10 17:45:23 +02:00
Tykayn
d22dbde2e7 add search filter, toggle options, count unlocated events 2025-10-10 16:59:13 +02:00
Tykayn
ee48a3c665 add extractor viparis 2025-10-10 15:11:10 +02:00
Tykayn
fd2d51b662 load 3000 events 2025-10-10 13:56:03 +02:00
Tykayn
65d990af12 remove datasources folder from git 2025-10-10 10:39:16 +02:00
Tykayn
24bd65565c add ccpl scraping start 2025-10-09 23:35:12 +02:00
Tykayn
e16d77d056 ignore logs 2025-10-09 23:01:50 +02:00
Tykayn
3fa60f3052 get agendadulibre 2025-10-09 22:57:06 +02:00
Tykayn
13dc5ceef8 upcoming events style 2025-10-07 15:20:49 +02:00
Tykayn
c189ce1650 styling doc page 2025-10-07 14:42:39 +02:00
Tykayn
23c598034c routes doc, future, express mode thematique 2025-10-07 14:12:50 +02:00
Tykayn
eeb219cffa scrapping mobilizon 2025-10-07 14:10:08 +02:00
Tykayn
3b70a4772d caddy config frontend 2025-10-07 14:09:53 +02:00
Tykayn
5d6154e631 update budget scsss build 2025-10-05 00:53:28 +02:00
Tykayn
7accbe0b13 afficher évènement sélectionné dans le panel de gauche 2025-10-05 00:49:12 +02:00
Tykayn
800d0682c4 ajout types IRVE, compteur véhicules, lampadaires devraient être allumés ou éteints 2025-10-05 00:32:30 +02:00
Tykayn
080cb4df48 up 2025-10-05 00:24:43 +02:00
Tykayn
464e0e5499 guide de contrib 2025-10-05 00:21:11 +02:00
Tykayn
e7f7e9e19e style agenda 2025-10-04 23:36:37 +02:00
Tykayn
ba6ec93860 add nav 2025-10-04 19:33:22 +02:00
Tykayn
74738772b4 scrapping agendadulibre 2025-10-04 19:26:00 +02:00
Tykayn
6deed13d0b up display; ajout scrap agendadulibre; qa évènements sans localisation 2025-10-04 19:18:10 +02:00
Tykayn
73f18e1d31 up build 2025-10-04 16:46:58 +02:00
Tykayn
5ba8771454 catégories traffic 2025-10-04 16:31:31 +02:00
Tykayn
677ee382d6 ajout évènements bison futé 2025-10-04 16:21:26 +02:00
Tykayn
9fb9986a2c style marqueurs events, page nouvelles catégories 2025-10-04 16:14:42 +02:00
Tykayn
20a8445a5f ajout page calendrier 2025-10-04 12:58:44 +02:00
Tykayn
8aa4e107ac liste des évènements non localisés 2025-10-04 12:46:25 +02:00
Tykayn
22314d7b9e ajout catégories randonnée dans le frontend 2025-10-04 12:32:01 +02:00
Tykayn
bdb3728494 up edit 2025-10-03 14:00:35 +02:00
Tykayn
f991aee8ed edit form 2025-10-03 13:40:08 +02:00
Tykayn
83ef7bab6c add frontend ng 2025-10-03 11:56:55 +02:00
Tykayn
80d52ff819 up edit menu 2025-10-02 23:19:15 +02:00
Tykayn
070e8435d8 add frontend angular 2025-10-02 22:53:50 +02:00
Tykayn
f34a5f0a83 add mammouth icon 2025-10-02 19:01:39 +02:00
Tykayn
b8d7cf1b20 ajout catégories wildlife et tests 2025-10-02 16:50:46 +02:00
Tykayn
30aa570a43 up add any 2025-09-27 15:57:59 +02:00
Tykayn
0a7a527e68 fix différents types d'events 2025-09-27 01:34:26 +02:00
Tykayn
3ec22cbe3b up traffic 2025-09-27 01:31:36 +02:00
Tykayn
11cd3236c5 QA page 2025-09-27 01:10:47 +02:00
Tykayn
dea71fc6b3 up emoji on demo map 2025-09-27 00:39:18 +02:00
Tykayn
65956ff6be up emojis 2025-09-27 00:18:03 +02:00
Tykayn
205d77e2f6 up osmcal scrapper 2025-09-26 18:19:54 +02:00
Tykayn
7d57086047 up map by what and when 2025-09-26 17:47:59 +02:00
Tykayn
0f4d3c0ace up display one event 2025-09-26 17:40:12 +02:00
Tykayn
98c40b2447 up pages 2025-09-26 17:38:30 +02:00
tykayn
2bb77d2300 add agenda geek scrapper 2025-09-26 17:16:29 +02:00
Tykayn
9aa8da5872 split templates 2025-09-26 15:08:33 +02:00
Tykayn
6548460478 add event details page, refacto templates 2025-09-26 14:18:14 +02:00
Tykayn
eb8c42d0c0 add live page 2025-09-26 11:57:54 +02:00
Tykayn
114bcca24e styling popup title 2025-09-23 12:53:52 +02:00
Tykayn
c194862e00 up fetch api route 2025-09-23 12:19:03 +02:00
Tykayn
a2834c1f25 up home 2025-09-23 12:18:10 +02:00
Tykayn
c3dc3bdbb5 add toast for fetch fail 2025-09-23 12:01:30 +02:00
Tykayn
2e3c2cd25d up demo with less stuff 2025-09-23 11:51:54 +02:00
Tykayn
91e34032b2 change vectory tiles 2025-09-23 11:26:44 +02:00
Tykayn
1a3df2ed75 add panoramax token 2025-09-22 11:44:25 +02:00
191 changed files with 102493 additions and 2049 deletions

2
.gitignore vendored
View file

@ -9,3 +9,5 @@ datasources
extractors/**/*.zip
extractors/**/*.7z
extractors/**/*.json
*.json
*.log

3
.gitmodules vendored
View file

@ -1,3 +1,6 @@
[submodule "datasources"]
path = datasources
url = https://github.com/openeventdatabase/datasources.git
[submodule "OEDb_Scrappers"]
path = OEDb_Scrappers
url = https://codeberg.org/K12230LF/OEDb_Scrappers.git

1
.idea/vcs.xml generated
View file

@ -2,5 +2,6 @@
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
<mapping directory="$PROJECT_DIR$/OEDb_Scrappers" vcs="Git" />
</component>
</project>

3
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,3 @@
{
"makefile.configureOnOpen": false
}

103
CHANGES.md Normal file
View file

@ -0,0 +1,103 @@
# Changes Implemented
## 1. Delete Button for Events
The delete button was already implemented in the event edit page:
- The button exists in `/oedb/resources/demo/templates/edit.html` (line 77)
- The JavaScript functionality to send a DELETE request is implemented in `/oedb/resources/demo/static/edit.js` (lines 167-209)
- When clicked, the button sends a DELETE request to `/event/{id}` and handles the response
## 2. Force Atlas Graph in Live Page
Modified the force atlas graph in the live page to use event types from the last 1000 events:
- Updated the API URL in `/oedb/resources/live.py` from:
```javascript
const API_URL = 'https://api.openeventdatabase.org/event?when=last7days&limit=2000';
```
to:
```javascript
const API_URL = 'https://api.openeventdatabase.org/event?limit=1000';
```
- The existing implementation already groups events by "what" field in the `buildFamilyGraph` function (lines 321-348)
## 3. Database Dump Endpoints
Created new endpoints for database dumps:
1. Created a new file `/oedb/resources/db_dump.py` with two resource classes:
- `DbDumpListResource`: Lists existing database dumps
- `DbDumpCreateResource`: Creates new dumps in SQL and GeoJSON formats
2. Implemented features:
- Created a directory to store database dumps
- Used `pg_dump` to create SQL dumps
- Queried the database and converted to GeoJSON for GeoJSON dumps
- Included timestamps in the filenames (e.g., `oedb_dump_20250926_145800.sql`)
- Added proper error handling and logging
3. Updated `/backend.py` to:
- Import the new resources
- Register the new endpoints:
- `/db/dumps`: Lists all available database dumps
- `/db/dumps/create`: Creates new database dumps
## Usage
### Listing Database Dumps
Send a GET request to `/db/dumps` to get a list of all available database dumps:
```
GET /db/dumps
```
Response:
```json
{
"dumps": [
{
"filename": "oedb_dump_20250926_145800.sql",
"path": "/db/dumps/oedb_dump_20250926_145800.sql",
"size": 1234567,
"created": "2025-09-26T14:58:00",
"type": "sql"
},
{
"filename": "oedb_dump_20250926_145800.geojson",
"path": "/db/dumps/oedb_dump_20250926_145800.geojson",
"size": 7654321,
"created": "2025-09-26T14:58:00",
"type": "geojson"
}
]
}
```
### Creating Database Dumps
Send a POST request to `/db/dumps/create` to create new database dumps:
```
POST /db/dumps/create
```
Response:
```json
{
"message": "Database dumps created successfully",
"dumps": [
{
"filename": "oedb_dump_20250926_145800.sql",
"path": "/db/dumps/oedb_dump_20250926_145800.sql",
"type": "sql",
"size": 1234567
},
{
"filename": "oedb_dump_20250926_145800.geojson",
"path": "/db/dumps/oedb_dump_20250926_145800.geojson",
"type": "geojson",
"size": 7654321
}
]
}
```

62
LANCER_SERVEUR.md Normal file
View file

@ -0,0 +1,62 @@
# Lancement du serveur OEDB avec WebSockets
Ce document explique comment lancer le serveur OEDB avec le support des WebSockets intégré via uWSGI.
## Prérequis
Assurez-vous d'avoir installé les dépendances nécessaires :
```bash
pip install uwsgi
```
## Lancement du serveur
Pour lancer le serveur avec le support WebSocket, vous avez plusieurs options :
### Utiliser la commande make
```bash
make websocket
```
Ou pour lancer en arrière-plan (mode démon) :
```bash
make websocket-daemon
```
### Lancer manuellement avec uWSGI
```bash
uwsgi --ini uwsgi.ini
```
Ces commandes démarreront le serveur sur le port 8080 avec le support WebSocket activé sur la route `/ws`.
## Configuration en production
En production, vous pouvez utiliser uWSGI avec Nginx. Voici un exemple de configuration Nginx :
```nginx
server {
listen 80;
server_name votre-domaine.com;
location / {
include uwsgi_params;
uwsgi_pass 127.0.0.1:8080;
}
location /ws {
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
```
Cette configuration permet d'utiliser Nginx comme proxy inverse pour les requêtes HTTP normales et les connexions WebSocket.

View file

@ -7,4 +7,13 @@ LOGFILE ?= uwsgi.log
start:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt && uwsgi --http :$(PORT) --wsgi-file backend.py --callable app
. venv/bin/activate && pip install -r requirements.txt && uwsgi --http :$(PORT) --wsgi-file backend.py --callable app
websocket:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt && pip install websockets && uwsgi --ini uwsgi.ini
# Version en arrière-plan (démon)
websocket-daemon:
python3 -m venv venv
. venv/bin/activate && pip install -r requirements.txt && pip install websockets && uwsgi --ini uwsgi.ini --daemonize $(LOGFILE)

1
OEDb_Scrappers Submodule

@ -0,0 +1 @@
Subproject commit df0a6e21133d33e0af7e427f39c57c238afc67ac

View file

@ -12,7 +12,7 @@ import falcon
# Import utility modules
from oedb.utils.logging import logger
from oedb.utils.db import check_db_connection
from oedb.utils.db import check_db_connection, load_env_from_file
# Import middleware
from oedb.middleware.headers import HeaderMiddleware
@ -24,8 +24,12 @@ from oedb.resources.event import event
from oedb.resources.stats import StatsResource
from oedb.resources.search import EventSearch
from oedb.resources.root import root
from oedb.resources.demo import demo
from oedb.resources.demo import demo, demo_stats
from oedb.resources.live import live
from oedb.resources.rss import rss_latest, rss_by_family
from oedb.resources.event_form import event_form
from oedb.resources.db_dump import db_dump_list, db_dump_create
from oedb.resources.quality_assurance import quality_assurance
def create_app():
"""
@ -34,6 +38,9 @@ def create_app():
Returns:
falcon.App: The configured Falcon application.
"""
# Load environment variables from .env (if present)
load_env_from_file()
# Create the Falcon application with middleware
logger.info("Initializing Falcon application")
app = falcon.App(middleware=[
@ -47,6 +54,21 @@ def create_app():
static_dir = os.path.abspath(os.path.join(os.path.dirname(__file__), 'oedb', 'resources', 'demo', 'static'))
app.add_static_route('/static/', static_dir)
# Check environment variables
required_env = [
'DB_NAME', 'DB_HOST', 'DB_USER', 'POSTGRES_PASSWORD',
'CLIENT_ID', 'CLIENT_SECRET', 'CLIENT_REDIRECT', 'CLIENT_AUTHORIZATIONS'
]
optional_env = [
'PANORAMAX_UPLOAD_URL', 'PANORAMAX_TOKEN'
]
missing_required = [k for k in required_env if not os.getenv(k)]
missing_optional = [k for k in optional_env if not os.getenv(k)]
if missing_required:
logger.warning(f"Missing required environment variables: {', '.join(missing_required)}")
if missing_optional:
logger.info(f"Optional environment variables not set: {', '.join(missing_optional)}")
# Check database connection before continuing
if not check_db_connection():
logger.error("Cannot start server - PostgreSQL database is not responding")
@ -63,13 +85,23 @@ def create_app():
app.add_route('/event/{id}', event) # Handle single event requests
app.add_route('/event', event) # Handle event collection requests
app.add_route('/stats', stats) # Handle stats requests
app.add_route('/quality_assurance', quality_assurance) # Handle quality assurance requests
app.add_route('/demo', demo) # Handle demo page requests
app.add_route('/demo/add', event_form) # Handle event submission form
app.add_route('/demo/by-what', demo, suffix='by_what') # Handle events by type page
app.add_route('/demo/map-by-what', demo, suffix='map_by_what') # Handle map by event type page
app.add_route('/demo/map-by-what/{event_type}', demo, suffix='map_by_what_type') # Handle map by specific event type
app.add_route('/demo/edit/{id}', demo, suffix='edit') # Handle event editing page
app.add_route('/demo/by_id/{id}', demo, suffix='by_id') # Handle view single event by id
app.add_route('/demo/traffic', demo, suffix='traffic') # Handle traffic jam reporting page
app.add_route('/demo/view-events', demo, suffix='view_events') # Handle view saved events page
app.add_route('/demo/stats', demo_stats) # Handle stats by what page
app.add_route('/demo/property-stats', demo, suffix='property_stats') # Handle property statistics page
app.add_route('/demo/live', live) # Live page
app.add_route('/rss', rss_latest) # RSS latest 200
app.add_route('/rss/by/{family}', rss_by_family) # RSS by family
app.add_route('/db/dumps', db_dump_list) # List database dumps
app.add_route('/db/dumps/create', db_dump_create) # Create database dumps
logger.success("Application initialized successfully")
return app

View file

@ -38,7 +38,7 @@ CURRENT_DATE = datetime(2025, 9, 15, 23, 0)
EVENT_TYPES = ["scheduled", "forecast", "unscheduled"]
# Event categories (what)
EVENT_CATEGORIES = ["traffic", "nature", "weather", "sport", "conference", "party"]
EVENT_CATEGORIES = ["traffic", "nature", "weather", "sport", "conference", "party", "community", "wildlife"]
# Sample locations (Paris, Lyon, Marseille, Toulouse, Bordeaux, Lille)
SAMPLE_LOCATIONS = [

@ -1 +0,0 @@
Subproject commit 4f1410e69cbee6c1c4f8864240379678ad6162cd

160
doc/api_endpoints.md Normal file
View file

@ -0,0 +1,160 @@
# API Endpoints Documentation
This document provides a comprehensive list of all API endpoints available in the OpenEventDatabase (OEDB) backend.
## Main API Endpoints
### Root Endpoint
- **URL**: `/`
- **Method**: GET
- **Description**: Provides general information about the API, including version and available endpoints.
- **Response**: JSON object with API information.
### Event Endpoints
#### Get Events
- **URL**: `/event`
- **Method**: GET
- **Description**: Retrieves a collection of events based on query parameters.
- **Query Parameters**:
- `what`: Filter events by type (e.g., `sport.match.football`)
- `when`: Filter events by time period (e.g., `last7days`, `today`, `tomorrow`)
- `limit`: Maximum number of events to return (default: 100)
- `bbox`: Bounding box for geographic filtering (format: `min_lon,min_lat,max_lon,max_lat`)
- **Response**: GeoJSON FeatureCollection of events.
#### Create Event
- **URL**: `/event`
- **Method**: POST
- **Description**: Creates a new event.
- **Request Body**: GeoJSON Feature representing the event.
- **Response**: JSON object with the created event ID.
#### Get Event by ID
- **URL**: `/event/{id}`
- **Method**: GET
- **Description**: Retrieves a specific event by its ID.
- **Response**: GeoJSON Feature of the requested event.
#### Update Event
- **URL**: `/event/{id}`
- **Method**: PUT
- **Description**: Updates an existing event.
- **Request Body**: GeoJSON Feature with updated event data.
- **Response**: JSON object with the updated event ID.
#### Delete Event
- **URL**: `/event/{id}`
- **Method**: DELETE
- **Description**: Deletes an event by its ID.
- **Response**: JSON object confirming deletion.
#### Search Events
- **URL**: `/event/search`
- **Method**: GET
- **Description**: Advanced search for events with more complex filtering options.
- **Query Parameters**: Various search parameters (see `/doc/api_search.md` for details).
- **Response**: GeoJSON FeatureCollection of matching events.
### Statistics Endpoint
- **URL**: `/stats`
- **Method**: GET
- **Description**: Provides statistics about the database, including event counts and recent updates.
- **Response**: JSON object with statistics.
### RSS Feeds
#### Latest Events RSS
- **URL**: `/rss`
- **Method**: GET
- **Description**: Provides an RSS feed of the latest 200 events.
- **Response**: XML RSS feed.
#### Events by Family RSS
- **URL**: `/rss/by/{family}`
- **Method**: GET
- **Description**: Provides an RSS feed of events filtered by family (e.g., sport, culture).
- **Response**: XML RSS feed.
### Database Dumps
#### List Database Dumps
- **URL**: `/db/dumps`
- **Method**: GET
- **Description**: Lists all available database dumps.
- **Response**: JSON object with list of dumps.
#### Create Database Dumps
- **URL**: `/db/dumps/create`
- **Method**: POST
- **Description**: Creates new database dumps in SQL and GeoJSON formats.
- **Response**: JSON object with information about the created dumps.
## Demo/UI Endpoints
These endpoints provide web interfaces for interacting with the API:
### Main Demo Page
- **URL**: `/demo`
- **Method**: GET
- **Description**: Main demo page with a map interface to explore events.
### Event Submission Form
- **URL**: `/demo/add`
- **Method**: GET
- **Description**: Form for submitting new events.
### Events by Type
- **URL**: `/demo/by-what`
- **Method**: GET
- **Description**: Page showing events grouped by their type.
### Map by Event Type
- **URL**: `/demo/map-by-what`
- **Method**: GET
- **Description**: Map interface showing events colored by their type.
### Event Editing
- **URL**: `/demo/edit/{id}`
- **Method**: GET
- **Description**: Interface for editing an existing event.
### View Event by ID
- **URL**: `/demo/by_id/{id}`
- **Method**: GET
- **Description**: Page showing details of a specific event.
### Traffic Jam Reporting
- **URL**: `/demo/traffic`
- **Method**: GET
- **Description**: Interface for reporting traffic jams.
### View Saved Events
- **URL**: `/demo/view-events`
- **Method**: GET
- **Description**: Page showing events saved by the user.
### Statistics by Event Type
- **URL**: `/demo/stats`
- **Method**: GET
- **Description**: Page showing statistics grouped by event type.
### Live Events
- **URL**: `/demo/live`
- **Method**: GET
- **Description**: Real-time view of events with auto-refresh.
## Static Files
- **URL**: `/static/`
- **Method**: GET
- **Description**: Serves static files (CSS, JavaScript, images) for the demo interfaces.
## Additional Documentation
For more detailed information about specific aspects of the API, refer to these documents:
- [API Query Parameters](/doc/api_query_params.md)
- [API Search](/doc/api_search.md)
- [API Load Testing](/doc/api_load_test.md)
- [Anti-Spam Measures](/doc/anti_spam.md)
- [Demo Endpoint](/doc/demo_endpoint.md)

64
doc/index.html.php Normal file
View file

@ -0,0 +1,64 @@
<!---->
Alimentation et boissons
Animaux domestiques
Apprentissage
Artisanat
Arts
Arts du spectacle et arts visuels
Automobile, bateaux et aéronautique
Causes
Clubs de lecture
Communauté
Comédie
Entreprises
Famille et éducation
Films et médias
Fête
Jeux
LGBTQ
Langue et Culture
Mode et beauté
Musique
Photographie
Plein air et aventure
Politique et organisations
Rencontre
Réseautage
Santé
Science et technologie
Spiritualité, religion et croyances
Sports
Théâtre
sport
birthday
party
weather
traffic
nature
conference
music
culture.festival
culture.dragshow
culture.geek
community.osm.event
community.osm.event
culture.geek
culture.art
civic.sanitation.garbage
emergency.medical
emergency.fire
music.festival
music.concert
power.production.unavail
sale
traffic.accident
traffic.roadwork
traffic.obstacle
tourism.exhibition
time.daylight.summer
transport.rail.delay
weather.storm
weather.heat
weather.flood

3
extractors/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
test_env
venv
*.log

114
extractors/Makefile Normal file
View file

@ -0,0 +1,114 @@
# Makefile pour le scraper agenda du libre
.PHONY: help install test demo monitor clean run setup-cron
# Configuration par défaut
API_URL ?= https://api.openeventdatabase.org
BATCH_SIZE ?= 1
help: ## Afficher l'aide
@echo "🔧 Scraper Agenda du Libre - Commandes disponibles"
@echo "=================================================="
@echo ""
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | sort | awk 'BEGIN {FS = ":.*?## "}; {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}'
@echo ""
@echo "💡 Variables d'environnement:"
@echo " API_URL URL de l'API OEDB (défaut: https://api.openeventdatabase.org)"
@echo " BATCH_SIZE Taille des batches (défaut: 1)"
@echo ""
@echo "📝 Exemples:"
@echo " make run BATCH_SIZE=5"
@echo " make run API_URL=http://api.example.com:8080"
install: ## Installer les dépendances
@echo "📦 Installation des dépendances..."
sudo apt update
sudo apt install -y python3-requests python3-icalendar
@echo "✅ Dépendances installées"
test: ## Exécuter les tests
@echo "🧪 Exécution des tests..."
python3 test_agendadulibre.py
python3 test_api_connection.py --api-url $(API_URL)
demo: ## Exécuter la démonstration
@echo "🎭 Exécution de la démonstration..."
python3 demo_agendadulibre.py
monitor: ## Afficher les statistiques
@echo "📊 Affichage des statistiques..."
python3 monitor_agendadulibre.py
run: ## Exécuter le scraper
@echo "🚀 Exécution du scraper..."
python3 agendadulibre.py --api-url $(API_URL) --batch-size $(BATCH_SIZE)
run-verbose: ## Exécuter le scraper en mode verbeux
@echo "🚀 Exécution du scraper en mode verbeux..."
python3 agendadulibre.py --api-url $(API_URL) --batch-size $(BATCH_SIZE) --verbose
run-force: ## Exécuter le scraper en forçant le rechargement
@echo "🚀 Exécution du scraper avec rechargement forcé..."
python3 agendadulibre.py --api-url $(API_URL) --batch-size $(BATCH_SIZE) --force-refresh
run-cache-test: ## Tester le système de cache
@echo "🔄 Test du système de cache..."
python3 agendadulibre.py --api-url $(API_URL) --batch-size 1 --verbose
@echo " Premier appel terminé, deuxième appel (depuis cache)..."
python3 agendadulibre.py --api-url $(API_URL) --batch-size 1 --verbose
setup-cron: ## Configurer la planification cron
@echo "⏰ Configuration de la planification cron..."
./setup_cron.sh
clean: ## Nettoyer les fichiers temporaires
@echo "🧹 Nettoyage des fichiers temporaires..."
rm -f agendadulibre_events.json
rm -f agendadulibre_events.ics
rm -f agendadulibre_scraper.log
rm -f cron_agendadulibre.log
@echo "✅ Nettoyage terminé"
status: ## Afficher le statut du système
@echo "📊 Statut du système:"
@echo "===================="
@echo "📁 Fichier de données:"
@if [ -f agendadulibre_events.json ]; then \
SIZE=$$(stat -c%s agendadulibre_events.json 2>/dev/null || echo "0"); \
echo " ✅ Présent ($$SIZE octets)"; \
else \
echo " ❌ Absent"; \
fi
@echo "📁 Fichier cache iCal:"
@if [ -f agendadulibre_events.ics ]; then \
SIZE=$$(stat -c%s agendadulibre_events.ics 2>/dev/null || echo "0"); \
DATE=$$(stat -c%y agendadulibre_events.ics 2>/dev/null || echo "inconnue"); \
echo " ✅ Présent ($$SIZE octets)"; \
echo " 📅 Modifié: $$DATE"; \
else \
echo " ❌ Absent"; \
fi
@echo ""
@echo "📋 Tâches cron:"
@if crontab -l 2>/dev/null | grep -q agendadulibre; then \
echo " ✅ Tâches configurées:"; \
crontab -l | grep agendadulibre | sed 's/^/ /'; \
else \
echo " ❌ Aucune tâche configurée"; \
fi
@echo ""
@echo "🔗 Connexion API:"
@python3 -c "import requests; print(' ✅' if requests.get('$(API_URL)', timeout=5).status_code == 200 else ' ❌')" 2>/dev/null || echo " ❌ API non accessible"
logs: ## Afficher les logs récents
@echo "📋 Logs récents:"
@echo "==============="
@if [ -f agendadulibre_scraper.log ]; then \
tail -20 agendadulibre_scraper.log; \
else \
echo "Aucun fichier de log trouvé"; \
fi
check-deps: ## Vérifier les dépendances
@echo "🔍 Vérification des dépendances..."
@python3 -c "import requests, icalendar; print('✅ Toutes les dépendances sont disponibles')" || echo "❌ Dépendances manquantes - exécutez 'make install'"

View file

@ -0,0 +1,161 @@
# Scraper Agenda du Libre
Script de scraping pour récupérer les événements de l'agenda du libre (https://www.agendadulibre.org/) et les envoyer à l'API OEDB.
## Fonctionnalités
- 📥 Récupération automatique du fichier iCal depuis l'agenda du libre
- 🔄 Traitement par batch configurable
- 💾 Sauvegarde locale de l'état des événements (JSON)
- 🚫 Évite les doublons (ne renvoie pas les événements déjà traités)
- 📊 Statistiques détaillées et logging
- 🧪 Mode démo et tests inclus
## Installation
1. Installer les dépendances Python :
```bash
pip install -r requirements_agendadulibre.txt
```
2. Rendre les scripts exécutables :
```bash
chmod +x agendadulibre.py
chmod +x test_agendadulibre.py
chmod +x demo_agendadulibre.py
```
## Utilisation
### Scraping complet
```bash
# Utilisation basique (1 événement par batch)
python agendadulibre.py
# Avec options personnalisées
python agendadulibre.py --api-url https://api.openeventdatabase.org --batch-size 5 --verbose
```
### Options disponibles
- `--api-url` : URL de base de l'API OEDB (défaut: https://api.openeventdatabase.org)
- `--batch-size` : Nombre d'événements à traiter par batch (défaut: 1)
- `--verbose` : Mode verbeux pour plus de détails
- `--force-refresh` : Forcer le rechargement du fichier iCal (ignorer le cache)
- `--cache-duration` : Durée de validité du cache en heures (défaut: 1)
### Démonstration
```bash
# Mode démo (ne fait pas d'appels API réels)
python demo_agendadulibre.py
```
### Tests
```bash
# Exécuter les tests
python test_agendadulibre.py
```
## Fichiers générés
- `agendadulibre_events.json` : Base de données locale des événements traités
- `agendadulibre_events.ics` : Cache local du fichier iCal (valide 1h)
- `agendadulibre_scraper.log` : Logs détaillés du scraper
## Système de cache iCal
Le script utilise un système de cache intelligent pour éviter de télécharger le fichier iCal à chaque exécution :
- **Cache valide** : Le fichier iCal est mis en cache localement pendant 1 heure par défaut
- **Rechargement automatique** : Si le cache est expiré, le fichier est automatiquement rechargé
- **Fallback** : En cas d'erreur de téléchargement, le script utilise le cache même s'il est expiré
- **Force refresh** : Option `--force-refresh` pour ignorer le cache et forcer le rechargement
### Avantages du cache
- ⚡ **Performance** : Évite les téléchargements inutiles
- 🔄 **Fiabilité** : Fonctionne même si l'API iCal est temporairement indisponible
- 📊 **Efficacité** : Réduit la charge sur le serveur de l'agenda du libre
## Format des événements
Les événements sont convertis au format OEDB avec les propriétés suivantes :
```json
{
"properties": {
"label": "Titre de l'événement",
"description": "Description de l'événement",
"what": "culture.geek",
"where": "Lieu de l'événement",
"start": "2024-01-01T10:00:00",
"stop": "2024-01-01T12:00:00",
"url": "https://www.agendadulibre.org/event/123",
"source": "agendadulibre.org",
"last_modified_by": "agendadulibre_scraper"
},
"geometry": {
"type": "Point",
"coordinates": [0, 0]
}
}
```
## Gestion des doublons
Le script utilise un système de suivi local pour éviter les doublons :
- Chaque événement reçoit un ID unique basé sur son contenu
- Les événements déjà traités avec succès ne sont pas renvoyés
- Les événements en erreur peuvent être retentés
- Les événements déjà existants (réponse 409) sont marqués comme traités
## Statuts des événements
- `saved` : Événement envoyé avec succès à l'API
- `already_exists` : Événement déjà existant dans l'API (réponse 409)
- `error` : Erreur lors de l'envoi à l'API
## Exemple de sortie
```
2024-01-01 10:00:00 - INFO - 🚀 Démarrage du scraping de l'agenda du libre
2024-01-01 10:00:01 - INFO - Récupération du fichier iCal depuis https://www.agendadulibre.org/events.ics
2024-01-01 10:00:02 - INFO - Fichier iCal récupéré avec succès
2024-01-01 10:00:03 - INFO - Traitement de 15 nouveaux événements par batch de 1
2024-01-01 10:00:04 - INFO - Envoi de l'événement: Conférence Python
2024-01-01 10:00:05 - INFO - ✅ Conférence Python - Créé avec succès
...
2024-01-01 10:00:30 - INFO - 📊 Statistiques finales:
2024-01-01 10:00:30 - INFO - Total d'événements trouvés: 25
2024-01-01 10:00:30 - INFO - Nouveaux événements envoyés: 12
2024-01-01 10:00:30 - INFO - Événements déjà existants: 8
2024-01-01 10:00:30 - INFO - Erreurs d'API: 2
2024-01-01 10:00:30 - INFO - Erreurs de parsing: 3
2024-01-01 10:00:30 - INFO - Événements envoyés cette fois: 12
2024-01-01 10:00:30 - INFO - ✅ Scraping terminé avec succès
```
## Planification
Pour automatiser le scraping, vous pouvez utiliser cron :
```bash
# Exécuter toutes les heures
0 * * * * cd /path/to/extractors && python agendadulibre.py --batch-size 5
# Exécuter tous les jours à 6h
0 6 * * * cd /path/to/extractors && python agendadulibre.py --batch-size 10
```
## Dépannage
### Erreur de connexion à l'API
- Vérifiez que l'API OEDB est démarrée
- Vérifiez l'URL de l'API avec `--api-url`
### Erreur de parsing iCal
- Vérifiez la connectivité internet
- Vérifiez que l'URL iCal est accessible
### Événements non géocodés
- Les événements sont créés avec des coordonnées par défaut [0, 0]
- Un processus de géocodage séparé peut être ajouté si nécessaire

View file

@ -0,0 +1,324 @@
# 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
### 9. Traitement Parallèle
- **Activation automatique** : Se déclenche pour plus de 10 événements avec `--parallel`
- **ThreadPoolExecutor** : Utilise `concurrent.futures` pour la parallélisation
- **Workers configurables** : Nombre de workers ajustable avec `--max-workers`
- **Thread-safe** : Méthode `process_single_event()` sécurisée pour les threads
- **Performance** : Amélioration significative pour les gros volumes d'événements
## 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
# Traitement parallèle pour gros volumes
python agendadulibre.py --max-events 50 --parallel --max-workers 8 --no-dry-run
# Traitement parallèle en mode dry-run
python agendadulibre.py --max-events 100 --parallel --max-workers 4
```
### 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 |
| `--parallel` | Activer le traitement parallèle pour plus de 10 événements | False |
| `--max-workers N` | Nombre maximum de workers pour le traitement parallèle | 4 |
## 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é
19. **Parallélisation** : Traitement simultané pour les gros volumes d'événements
20. **Performance** : Amélioration significative avec `--parallel` et `--max-workers`
## 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é.

View file

@ -0,0 +1,231 @@
# Scraper CCPL Agenda
Script de scraping pour l'agenda de la CCPL (Communauté de Communes du Pays de Limours) - https://www.cc-paysdelimours.fr/agenda
## Fonctionnalités
### 🚀 Scraping HTML Intelligent
- **Parsing HTML** : Extraction des événements depuis la structure HTML de l'agenda CCPL
- **Détection automatique** : Identification des liens d'événements avec classes spécifiques
- **Extraction complète** : Titre, date, URL, image, lieu
- **Détails enrichis** : Récupération des informations depuis les pages individuelles des événements
- **Fallback robuste** : Méthodes alternatives si la structure change
### 💾 Cache JSON Intelligent
- **Détection de changements** : Hash MD5 du contenu HTML pour éviter les re-traitements
- **Cache persistant** : Sauvegarde des événements traités dans `ccpl_agenda_events.json`
- **Cache de contenu** : Sauvegarde du hash dans `ccpl_agenda_cache.json`
- **Optimisation** : Évite les re-téléchargements inutiles
### ⚙️ Paramètres Configurables
- **Limite d'événements** : `--max-events N` (défaut: 1)
- **Mode dry-run** : Simulation par défaut, `--no-dry-run` pour l'envoi réel
- **Traitement parallèle** : `--parallel` pour plus de 10 événements
- **Workers** : `--max-workers N` pour le traitement parallèle
- **Cache** : `--cache-duration N` heures de validité
### 🔄 Traitement Parallèle
- **Activation automatique** : Se déclenche pour plus de 10 événements avec `--parallel`
- **ThreadPoolExecutor** : Utilise `concurrent.futures` pour la parallélisation
- **Workers configurables** : Nombre de workers ajustable avec `--max-workers`
- **Thread-safe** : Méthode `process_single_event()` sécurisée pour les threads
## Utilisation
### Commandes de Base
```bash
# Mode dry-run par défaut (sécurisé)
python ccpl_agenda.py
# Limiter à 1 événement en mode dry-run
python ccpl_agenda.py --max-events 1
# Mode réel avec limite de 5 événements
python ccpl_agenda.py --no-dry-run --max-events 5
# Mode verbeux pour voir les détails
python ccpl_agenda.py --max-events 3 --verbose
# Forcer le rechargement de l'agenda
python ccpl_agenda.py --force-refresh --max-events 3
# Traitement parallèle pour gros volumes
python ccpl_agenda.py --max-events 20 --parallel --max-workers 4 --no-dry-run
# Traitement parallèle en mode dry-run
python ccpl_agenda.py --max-events 50 --parallel --max-workers 8
```
### Arguments Disponibles
| Argument | Description | Défaut |
|----------|-------------|---------|
| `--max-events N` | Limite le nombre d'événements à traiter | 1 |
| `--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 de l'agenda | - |
| `--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 |
| `--parallel` | Activer le traitement parallèle pour plus de 10 événements | False |
| `--max-workers N` | Nombre maximum de workers pour le traitement parallèle | 4 |
## Fichiers Générés
### Cache JSON (`ccpl_agenda_cache.json`)
```json
{
"processed_events": {
"event_id": {
"processed_at": "2024-01-01T12:00:00",
"status": "saved",
"event_label": "Titre de l'événement"
}
},
"last_fetch": "2024-01-01T12:00:00",
"content_hash": "abc123..."
}
```
### Événements JSON (`ccpl_agenda_events.json`)
```json
{
"events": {
"event_id": {
"status": "saved",
"message": "Créé avec succès",
"last_attempt": "2024-01-01T12:00:00",
"event": {
"properties": {
"label": "Titre de l'événement",
"description": "Description...",
"type": "scheduled",
"what": "culture.community",
"where": "Pays de Limours, France",
"start": "2024-01-01T10:00:00",
"stop": "2024-01-01T12:00:00",
"url": "https://www.cc-paysdelimours.fr/agenda/event",
"source:name": "CCPL Agenda",
"source:url": "https://www.cc-paysdelimours.fr/agenda",
"last_modified_by": "ccpl_agenda_scraper",
"tags": ["ccpl", "pays-de-limours", "événement-communal"],
"image": "https://www.cc-paysdelimours.fr/image.jpg"
},
"geometry": {
"type": "Point",
"coordinates": [2.0644, 48.5917]
}
}
}
},
"last_update": "2024-01-01T12:00:00"
}
```
## Structure des Événements
### Propriétés Extraites
- **Titre** : Extrait depuis `<p class="agenda-title">`
- **Date** : Extrait depuis `<span class="number">` et `<span class="small">`
- **URL** : Lien vers la page détaillée de l'événement
- **Image** : Image de l'événement si disponible
- **Lieu** : Adresse détaillée extraite depuis la page de l'événement
- **Coordonnées** : Coordonnées depuis la carte Leaflet ou par défaut du Pays de Limours
- **Contact** : Téléphone, email et site web extraits depuis la page de l'événement
- **Description** : Description complète de l'événement
- **Horaires** : Informations d'ouverture et de tarifs
### Format OEDB
Les événements sont formatés selon le standard GeoJSON attendu par l'API OEDB :
- **Type** : `scheduled` (événement programmé)
- **Catégorie** : `culture.community` (événement communautaire)
- **Tags** : `["ccpl", "pays-de-limours", "événement-communal"]`
- **Source** : `CCPL Agenda` avec URL de référence
- **Contact** : `contact:phone`, `contact:email`, `contact:website` si disponibles
## Exemples de Sortie
### Mode Dry-Run
```
🚀 Démarrage du scraping de l'agenda CCPL
Configuration: batch_size=1, api_url=https://api.openeventdatabase.org
Mode dry-run: OUI
Limite d'événements: 3
============================================================
🌐 Récupération de l'agenda CCPL: https://www.cc-paysdelimours.fr/agenda
🔄 Nouveau contenu détecté, mise à jour du cache
🔗 30 liens d'événements trouvés
📅 3 événements extraits au total
Traitement de 3 événements
Mode DRY-RUN activé - aucun événement ne sera envoyé à l'API
📝 Détails de l'événement à insérer:
ID: a650b1026dbfe0ae8a8832906591af4d
Titre: Kylen... entre le rêve et la création
Description: Événement organisé par la CCPL - Kylen... entre le rêve et la création
Type: scheduled
Catégorie: culture.community
Lieu: Pays de Limours, France
Début: 2025-09-30T00:00:00
Fin: 2025-09-30T02:00:00
URL: https://www.cc-paysdelimours.fr/agenda/kylen...-entre-le-reve-et-la-creation
Source: CCPL Agenda
Coordonnées: [2.0644, 48.5917]
Tags: ccpl, pays-de-limours, événement-communal
Modifié par: ccpl_agenda_scraper
📞 Téléphone: 0164911908
📧 Email: bibliotheque@mairie-limours.fr
🌐 Site web: https://x.com/CCPAYSDELIMOURS
🖼️ Image: https://www.cc-paysdelimours.fr/isens_thumb.php?image=...
[DRY-RUN] Simulation d'envoi de l'événement: Kylen... entre le rêve et la création
✅ Kylen... entre le rêve et la création - Simulé (dry-run)
📊 Statistiques finales:
total_events: 3
new_events: 3
already_saved: 0
api_errors: 0
parse_errors: 0
sent_this_run: 3
skipped_due_to_limit: 0
✅ Scraping terminé avec succès
```
### Mode Parallèle
```
🚀 Traitement parallèle de 20 événements avec 4 workers
Limite d'événements: 20
Mode DRY-RUN activé - aucun événement ne sera envoyé à l'API
✅ Événement 1 - Simulé (dry-run)
✅ Événement 2 - Simulé (dry-run)
...
📊 Statistiques finales:
total_events: 20
new_events: 20
sent_this_run: 20
```
## Avantages
1. **Sécurité** : Mode dry-run par défaut
2. **Performance** : Cache intelligent et traitement parallèle
3. **Robustesse** : Gestion d'erreurs et fallbacks
4. **Flexibilité** : Paramètres configurables
5. **Traçabilité** : Logs détaillés et sauvegarde des états
6. **Efficacité** : Évite les re-traitements inutiles
7. **Parallélisation** : Traitement simultané pour les gros volumes
8. **Extraction complète** : Toutes les métadonnées disponibles
## Dépendances
```bash
pip install -r requirements_ccpl.txt
```
- `requests>=2.25.0` : Requêtes HTTP
- `beautifulsoup4>=4.9.0` : Parsing HTML
- `lxml>=4.6.0` : Parser XML/HTML rapide
## Migration
Le script est compatible avec la même structure que le scraper agenda du libre, permettant une utilisation cohérente dans l'écosystème OEDB.

View file

@ -0,0 +1,39 @@
# Extractors d'évènements (vacances, journées mondiales)
Scripts CLI ajoutant des évènements dans OEDB, avec cache JSON, paramètres et rapport.
## Commun
- Cache: dossier `extractors_cache/` (créé automatiquement)
- Paramètres: `--dry-run` pour simuler sans écrire dans OEDB
- API OEDB: `--base-url` (par défaut `https://api.openeventdatabase.org`)
## Vacances scolaires FR
```bash
python3 extractors/fr_holidays_extractor.py \
--start 2025-01-01 --end 2025-12-31 \
--academie A \
--cache extractors_cache/fr_holidays_cache.json \
--cache-ttl $((24*3600)) \
--base-url https://api.openeventdatabase.org \
--dry-run
```
Sortie: JSON avec `success`, `failed`, `networkErrors`.
## Journées mondiales / internationales
```bash
python3 extractors/world_days_extractor.py \
--year 2025 \
--cache extractors_cache/world_days_cache.json \
--cache-ttl $((24*3600)) \
--base-url https://api.openeventdatabase.org \
--dry-run
```
Remarques:
- Les sources sont branchées de façon minimaliste (exemples). Brancher des sources plus riches selon besoin.
- Conversion en format OEDB: évènements non localisés (Point [0,0]) par défaut, `online=yes` pour journées mondiales.

415
extractors/agenda_geek.py Normal file
View file

@ -0,0 +1,415 @@
#!/usr/bin/env python3
"""
Scraper pour l'agenda geek - Import des événements dans OEDB
Usage:
python3 agenda_geek.py --limit 10 --offset 0
Options:
--limit: Nombre d'événements à traiter (défaut: 5)
--offset: Nombre d'événements à ignorer (défaut: 0)
--api-url: URL de l'API OEDB (défaut: https://api.openeventdatabase.org)
--dry-run: Mode test sans envoi vers l'API
--verbose: Mode verbeux
"""
import requests
import argparse
import re
import logging
from bs4 import BeautifulSoup
from icalendar import Calendar
from datetime import datetime, timezone
from urllib.parse import urljoin, urlparse
from typing import Optional, Dict, List, Tuple
import time
import json
# Configuration du logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
logging.FileHandler('agenda_geek_scraper.log')
]
)
logger = logging.getLogger(__name__)
class AgendaGeekScraper:
def __init__(self, api_url: str = "https://api.openeventdatabase.org", dry_run: bool = False, page: int = 1):
self.api_url = api_url.rstrip('/')
self.dry_run = dry_run
self.page = page
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'OEDB-AgendaGeek-Scraper/1.0 (+https://github.com/cquest/oedb)'
})
def get_events_list(self) -> List[str]:
"""Récupère la liste des liens d'événements depuis la page principale"""
url = f"https://lagendageek.com/tevents/page/{self.page}"
logger.info(f"🔍 Récupération de la liste des événements depuis {url}")
try:
response = self.session.get(url, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
event_links = []
# Rechercher les liens des titres d'événements
title_links = soup.find_all('a', class_='tribe-events-calendar-list__event-title-link')
for link in title_links:
href = link.get('href')
if href:
full_url = urljoin(url, href)
event_links.append(full_url)
logger.debug(f"📅 Événement trouvé: {link.get_text(strip=True)} - {full_url}")
logger.info(f"{len(event_links)} événements trouvés sur la page")
return event_links
except requests.RequestException as e:
logger.error(f"❌ Erreur lors de la récupération de la liste: {e}")
return []
def get_ical_link(self, event_url: str) -> Optional[str]:
"""Extrait le lien iCal depuis une page d'événement"""
logger.debug(f"🔗 Recherche du lien iCal pour {event_url}")
try:
response = self.session.get(event_url, timeout=30)
response.raise_for_status()
# Le lien iCal est généralement construit en ajoutant ?ical=1 à l'URL
ical_url = f"{event_url.rstrip('/')}/?ical=1"
# Vérifier que le lien iCal existe
ical_response = self.session.head(ical_url, timeout=10)
if ical_response.status_code == 200:
logger.debug(f"✅ Lien iCal trouvé: {ical_url}")
return ical_url
else:
logger.warning(f"⚠️ Lien iCal non accessible: {ical_url} (status: {ical_response.status_code})")
return None
except requests.RequestException as e:
logger.error(f"❌ Erreur lors de la récupération du lien iCal: {e}")
return None
def parse_ical(self, ical_url: str) -> Optional[Dict]:
"""Parse un fichier iCal et extrait les données de l'événement"""
# Convertir webcal:// en https://
if ical_url.startswith('webcal://'):
ical_url = ical_url.replace('webcal://', 'https://')
logger.debug(f"📖 Parse du fichier iCal: {ical_url}")
try:
response = self.session.get(ical_url, timeout=30)
response.raise_for_status()
# Parser le contenu iCal
cal = Calendar.from_ical(response.content)
for component in cal.walk():
if component.name == "VEVENT":
event_data = {
'summary': str(component.get('SUMMARY', '')),
'description': str(component.get('DESCRIPTION', '')),
'location': str(component.get('LOCATION', '')),
'dtstart': component.get('DTSTART'),
'dtend': component.get('DTEND'),
'geo': component.get('GEO'),
'url': str(component.get('URL', '')),
'uid': str(component.get('UID', ''))
}
logger.debug(f"📅 Événement parsé: {event_data['summary']}")
return event_data
logger.warning("⚠️ Aucun événement VEVENT trouvé dans le fichier iCal")
return None
except Exception as e:
logger.error(f"❌ Erreur lors du parsing iCal: {e}")
return None
def geocode_address(self, address: str) -> Optional[Tuple[float, float]]:
"""Géocode une adresse en utilisant Nominatim"""
if not address or address.strip() == '':
return None
logger.debug(f"🌍 Géocodage de l'adresse: {address}")
try:
# Utiliser Nominatim pour le géocodage
geocode_url = "https://nominatim.openstreetmap.org/search"
params = {
'q': address,
'format': 'json',
'limit': 1,
'countrycodes': 'fr', # Limiter à la France
'addressdetails': 1
}
response = self.session.get(geocode_url, params=params, timeout=10)
response.raise_for_status()
results = response.json()
if results:
result = results[0]
lat = float(result['lat'])
lon = float(result['lon'])
logger.debug(f"✅ Géocodage réussi: {lat}, {lon}")
return (lat, lon)
else:
logger.warning(f"⚠️ Aucun résultat de géocodage pour: {address}")
return None
except Exception as e:
logger.error(f"❌ Erreur lors du géocodage: {e}")
return None
def extract_coordinates(self, event_data: Dict) -> Optional[Tuple[float, float]]:
"""Extrait les coordonnées depuis les données de l'événement"""
# D'abord essayer la propriété GEO
if event_data.get('geo'):
try:
geo = event_data['geo']
logger.debug(f"🔍 Type GEO trouvé: {type(geo)} - Valeur: {geo}")
# Cas 1: GEO avec paramètres latitude/longitude
if hasattr(geo, 'params') and 'latitude' in geo.params and 'longitude' in geo.params:
lat = float(geo.params['latitude'])
lon = float(geo.params['longitude'])
logger.debug(f"📍 Coordonnées GEO (params) trouvées: {lat}, {lon}")
return (lat, lon)
# Cas 2: GEO avec méthode to_ical
elif hasattr(geo, 'to_ical'):
# Format GEO standard: "latitude;longitude"
geo_bytes = geo.to_ical()
# Gérer le cas où c'est déjà une string ou des bytes
if isinstance(geo_bytes, bytes):
geo_str = geo_bytes.decode('utf-8')
else:
geo_str = str(geo_bytes)
logger.debug(f"🔍 GEO string extrait: '{geo_str}'")
parts = geo_str.split(';')
if len(parts) == 2:
lat = float(parts[0])
lon = float(parts[1])
logger.debug(f"📍 Coordonnées GEO parsées: {lat}, {lon}")
return (lat, lon)
# Cas 3: GEO est directement une string
elif isinstance(geo, str):
logger.debug(f"🔍 GEO est une string directe: '{geo}'")
parts = geo.split(';')
if len(parts) == 2:
lat = float(parts[0])
lon = float(parts[1])
logger.debug(f"📍 Coordonnées GEO (string) parsées: {lat}, {lon}")
return (lat, lon)
# Cas 4: Autres formats possibles
else:
logger.debug(f"🔍 Format GEO non reconnu, tentative de conversion en string: {str(geo)}")
geo_str = str(geo)
if ';' in geo_str:
parts = geo_str.split(';')
if len(parts) == 2:
lat = float(parts[0])
lon = float(parts[1])
logger.debug(f"📍 Coordonnées GEO (fallback) parsées: {lat}, {lon}")
return (lat, lon)
except (ValueError, AttributeError) as e:
logger.warning(f"⚠️ Erreur parsing GEO: {e}")
# Si pas de GEO, essayer de géocoder la location
location = event_data.get('location', '').strip()
if location:
return self.geocode_address(location)
return None
def format_for_oedb(self, event_data: Dict, coordinates: Tuple[float, float], source_url: str) -> Dict:
"""Formate les données de l'événement pour l'API OEDB"""
lat, lon = coordinates
# Convertir les dates
dtstart = event_data.get('dtstart')
dtend = event_data.get('dtend')
start_iso = None
end_iso = None
if dtstart:
if hasattr(dtstart, 'dt'):
dt = dtstart.dt
if not isinstance(dt, datetime):
# Si c'est juste une date, créer un datetime
dt = datetime.combine(dt, datetime.min.time())
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
start_iso = dt.isoformat()
if dtend:
if hasattr(dtend, 'dt'):
dt = dtend.dt
if not isinstance(dt, datetime):
dt = datetime.combine(dt, datetime.min.time())
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
end_iso = dt.isoformat()
# Si pas de date de fin, définir à +2h de la date de début
if start_iso and not end_iso:
start_dt = datetime.fromisoformat(start_iso.replace('Z', '+00:00'))
end_dt = start_dt.replace(hour=start_dt.hour + 2)
end_iso = end_dt.isoformat()
# Construire l'objet pour l'API OEDB
oedb_event = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [lon, lat]
},
"properties": {
"label": event_data.get('summary', 'Événement Agenda Geek'),
"type": "scheduled",
"what": "culture.geek",
"start": start_iso,
"stop": end_iso,
"where": event_data.get('location', ''),
"description": event_data.get('description', ''),
"source:name": "L'Agenda Geek",
"source:url": source_url,
"source:uid": event_data.get('uid', ''),
"url": event_data.get('url', source_url)
}
}
return oedb_event
def send_to_oedb(self, event: Dict) -> bool:
"""Envoie un événement vers l'API OEDB"""
if self.dry_run:
logger.info(f"🏃‍♂️ DRY RUN - Événement qui serait envoyé:")
logger.info(json.dumps(event, indent=2, ensure_ascii=False))
return True
try:
response = self.session.post(
f"{self.api_url}/event",
json=event,
timeout=30
)
if response.status_code == 201:
result = response.json()
event_id = result.get('id', 'unknown')
logger.info(f"✅ Événement créé avec succès: ID {event_id}")
return True
elif response.status_code == 409:
logger.info("⚠️ Événement déjà existant (conflit)")
return True # Considérer comme un succès
else:
logger.error(f"❌ Erreur API ({response.status_code}): {response.text}")
return False
except requests.RequestException as e:
logger.error(f"❌ Erreur lors de l'envoi vers l'API: {e}")
return False
def process_events(self, limit: int = 5, offset: int = 0) -> None:
"""Traite les événements avec pagination"""
logger.info(f"🚀 Début du traitement - Limite: {limit}, Offset: {offset}")
# Récupérer la liste des événements
event_links = self.get_events_list()
if not event_links:
logger.error("❌ Aucun événement trouvé")
return
# Appliquer l'offset et la limite
total_events = len(event_links)
start_idx = min(offset, total_events)
end_idx = min(offset + limit, total_events)
events_to_process = event_links[start_idx:end_idx]
logger.info(f"📊 Traitement de {len(events_to_process)} événements ({start_idx+1} à {end_idx} sur {total_events})")
success_count = 0
error_count = 0
for i, event_url in enumerate(events_to_process, 1):
logger.info(f"🔄 [{i}/{len(events_to_process)}] Traitement de {event_url}")
try:
# Obtenir le lien iCal
ical_url = self.get_ical_link(event_url)
if not ical_url:
logger.warning(f"⚠️ Pas de lien iCal trouvé pour {event_url}")
error_count += 1
continue
# Parser le fichier iCal
event_data = self.parse_ical(ical_url)
if not event_data:
logger.warning(f"⚠️ Impossible de parser l'iCal pour {event_url}")
error_count += 1
continue
# Extraire les coordonnées
coordinates = self.extract_coordinates(event_data)
if not coordinates:
logger.warning(f"⚠️ Pas de coordonnées trouvées pour {event_data.get('summary', 'événement sans titre')}")
error_count += 1
continue
# Formater pour OEDB
oedb_event = self.format_for_oedb(event_data, coordinates, event_url)
# Envoyer vers l'API
if self.send_to_oedb(oedb_event):
success_count += 1
else:
error_count += 1
# Pause entre les requêtes pour éviter la surcharge
time.sleep(1)
except Exception as e:
logger.error(f"❌ Erreur lors du traitement de {event_url}: {e}")
error_count += 1
logger.info(f"🏁 Traitement terminé - Succès: {success_count}, Erreurs: {error_count}")
def main():
parser = argparse.ArgumentParser(description='Scraper Agenda Geek vers OEDB')
parser.add_argument('--limit', type=int, default=20, help='Nombre d\'événements à traiter')
parser.add_argument('--page', type=int, default=1, help='Numéro de page du site')
parser.add_argument('--offset', type=int, default=0, help='Nombre d\'événements à ignorer')
parser.add_argument('--api-url', default='https://api.openeventdatabase.org', help='URL de l\'API OEDB')
parser.add_argument('--dry-run', action='store_true', help='Mode test sans envoi vers l\'API')
parser.add_argument('--verbose', action='store_true', help='Mode verbeux')
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
scraper = AgendaGeekScraper(api_url=args.api_url, dry_run=args.dry_run, page=args.page)
scraper.process_events(limit=args.limit, offset=args.offset)
if __name__ == "__main__":
main()

967
extractors/agendadulibre.py Normal file
View file

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

File diff suppressed because it is too large Load diff

977
extractors/ccpl_agenda.py Normal file
View file

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

View file

@ -0,0 +1,83 @@
#!/usr/bin/env python3
"""
Script de debug pour analyser la structure HTML de l'agenda CCPL
"""
import requests
from bs4 import BeautifulSoup
import re
def debug_html_structure():
"""Analyse la structure HTML de l'agenda CCPL"""
url = "https://www.cc-paysdelimours.fr/agenda"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
try:
print(f"🌐 Récupération de: {url}")
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
print(f"📄 Taille du HTML: {len(response.text)} caractères")
# Chercher tous les éléments qui contiennent des dates
date_pattern = re.compile(r'\b\d{1,2}\s+(jan|fév|mar|avr|mai|jun|jul|aoû|sep|oct|nov|déc)\b', re.I)
date_elements = soup.find_all(string=date_pattern)
print(f"📅 Éléments avec dates trouvés: {len(date_elements)}")
# Afficher les premiers éléments avec dates
for i, elem in enumerate(date_elements[:5]):
print(f" {i+1}. {elem.strip()}")
print(f" Parent: {elem.parent.name if elem.parent else 'None'}")
print(f" Classes: {elem.parent.get('class', []) if elem.parent else 'None'}")
print()
# Chercher des patterns spécifiques
print("🔍 Recherche de patterns spécifiques:")
# Chercher des éléments avec des classes communes
common_classes = ['event', 'agenda', 'manifestation', 'item', 'card', 'content']
for class_name in common_classes:
elements = soup.find_all(class_=re.compile(class_name, re.I))
print(f" Classe '{class_name}': {len(elements)} éléments")
# Chercher des éléments avec du texte contenant des dates
all_elements = soup.find_all(['div', 'article', 'li', 'p', 'span'])
elements_with_dates = []
for elem in all_elements:
text = elem.get_text()
if date_pattern.search(text) and len(text) > 10:
elements_with_dates.append((elem, text[:100]))
print(f"📋 Éléments avec dates et texte significatif: {len(elements_with_dates)}")
# Afficher les premiers éléments
for i, (elem, text) in enumerate(elements_with_dates[:3]):
print(f" {i+1}. Tag: {elem.name}, Classes: {elem.get('class', [])}")
print(f" Texte: {text}...")
print()
# Chercher des liens
links = soup.find_all('a', href=True)
print(f"🔗 Liens trouvés: {len(links)}")
# Afficher quelques liens
for i, link in enumerate(links[:5]):
print(f" {i+1}. {link.get('href')} - {link.get_text()[:50]}...")
# Sauvegarder le HTML pour inspection
with open('ccpl_debug.html', 'w', encoding='utf-8') as f:
f.write(response.text)
print("💾 HTML sauvegardé dans ccpl_debug.html")
except Exception as e:
print(f"❌ Erreur: {e}")
if __name__ == "__main__":
debug_html_structure()

View file

@ -0,0 +1,79 @@
#!/usr/bin/env python3
"""
Script de debug pour analyser la structure HTML de la page Viparis
"""
import requests
from bs4 import BeautifulSoup
import re
def analyze_viparis_structure():
"""Analyse la structure HTML de la page Viparis"""
url = "https://www.viparis.com/actualites-evenements/evenements"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
try:
print(f"🔍 Analyse de la structure HTML de: {url}")
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
# Chercher les éléments contenant des événements
print("\n📋 Recherche d'éléments d'événements...")
# Chercher des patterns communs pour les événements
event_patterns = [
'event', 'evenement', 'agenda', 'salon', 'exposition', 'congres'
]
for pattern in event_patterns:
elements = soup.find_all(string=re.compile(pattern, re.I))
if elements:
print(f"✅ Trouvé '{pattern}': {len(elements)} éléments")
for i, elem in enumerate(elements[:3]): # Afficher les 3 premiers
print(f" {i+1}: {elem.strip()[:100]}...")
# Chercher des divs avec des classes qui pourraient contenir des événements
print("\n🔍 Recherche de divs avec classes d'événements...")
div_classes = soup.find_all('div', class_=re.compile(r'event|evenement|agenda|salon|expo', re.I))
print(f"Divs avec classes d'événements: {len(div_classes)}")
# Chercher des liens qui pourraient être des événements
print("\n🔗 Recherche de liens d'événements...")
event_links = soup.find_all('a', href=re.compile(r'event|evenement|salon|expo', re.I))
print(f"Liens d'événements: {len(event_links)}")
# Chercher des images d'événements
print("\n🖼️ Recherche d'images d'événements...")
event_images = soup.find_all('img', src=re.compile(r'event|evenement|salon|expo', re.I))
print(f"Images d'événements: {len(event_images)}")
# Chercher des éléments avec des dates
print("\n📅 Recherche d'éléments avec des dates...")
date_elements = soup.find_all(string=re.compile(r'\d{1,2}/\d{1,2}/\d{4}|\d{1,2}\s+\w+\s+\d{4}', re.I))
print(f"Éléments avec dates: {len(date_elements)}")
for i, elem in enumerate(date_elements[:5]):
print(f" {i+1}: {elem.strip()}")
# Chercher des éléments avec des titres d'événements
print("\n📝 Recherche de titres d'événements...")
title_elements = soup.find_all(['h1', 'h2', 'h3', 'h4', 'h5', 'h6'], string=re.compile(r'BattleKart|Virtual Room|PRODURABLE|RÉÉDUCA|SALON', re.I))
print(f"Titres d'événements: {len(title_elements)}")
for i, elem in enumerate(title_elements[:5]):
print(f" {i+1}: {elem.get_text().strip()}")
# Sauvegarder le HTML pour analyse
with open('viparis_debug.html', 'w', encoding='utf-8') as f:
f.write(response.text)
print(f"\n💾 HTML sauvegardé dans viparis_debug.html")
except Exception as e:
print(f"❌ Erreur: {e}")
if __name__ == "__main__":
analyze_viparis_structure()

View file

@ -0,0 +1,133 @@
#!/usr/bin/env python3
"""
Script de démonstration pour le scraper de l'agenda du libre
Mode dry-run pour tester sans envoyer de données à l'API
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from agendadulibre import AgendaDuLibreScraper, api_oedb
import logging
class DemoAgendaDuLibreScraper(AgendaDuLibreScraper):
"""Version démo du scraper qui n'envoie pas de données à l'API"""
def send_event_to_api(self, event_data):
"""Version démo qui simule l'envoi à l'API"""
event_label = event_data["event"]["properties"]["label"]
print(f"🔍 [DEMO] Simulation d'envoi: {event_label}")
# Simuler différents types de réponses
import random
responses = [
(True, "Créé avec succès"),
(False, "Événement déjà existant"),
(True, "Créé avec succès"),
(False, "Erreur API: 500 - Internal Server Error")
]
success, message = random.choice(responses)
if success:
print(f"✅ [DEMO] {event_label} - {message}")
else:
print(f"❌ [DEMO] {event_label} - {message}")
return success, message
def main():
"""Exécute la démonstration"""
print("🎭 Démonstration du scraper agenda du libre (mode dry-run)")
print("=" * 60)
# Configuration du logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
# Créer le scraper en mode démo
scraper = DemoAgendaDuLibreScraper(
api_base_url=api_oedb, # Utiliser l'URL par défaut
batch_size=2 # Traiter 2 événements par batch pour la démo
)
print(f"📋 Configuration:")
print(f" - URL iCal: {scraper.ical_url}")
print(f" - Taille des batches: {scraper.batch_size}")
print(f" - Fichier de données: {scraper.data_file}")
print(f" - Fichier cache iCal: {scraper.ical_file}")
print(f" - Durée de cache: {scraper.cache_duration_hours}h")
print()
# Récupérer le fichier iCal
print("📥 Récupération du fichier iCal...")
calendar = scraper.fetch_ical_data()
if not calendar:
print("❌ Impossible de récupérer le fichier iCal")
return False
# Compter les événements
event_count = 0
for component in calendar.walk():
if component.name == "VEVENT":
event_count += 1
print(f"📅 {event_count} événements trouvés dans le fichier iCal")
# Test du cache
print("\n🔄 Test du cache iCal...")
print(" Premier appel (téléchargement)...")
calendar2 = scraper.fetch_ical_data()
print(" Deuxième appel (depuis le cache)...")
calendar3 = scraper.fetch_ical_data()
print(" ✅ Cache fonctionne correctement")
print()
# Traiter seulement les 5 premiers événements pour la démo
print("🔄 Traitement des 5 premiers événements (démo)...")
print("-" * 40)
processed = 0
for component in calendar.walk():
if component.name == "VEVENT" and processed < 5:
parsed_event = scraper.parse_event(component)
if parsed_event:
event_label = parsed_event["event"]["properties"]["label"]
start_date = parsed_event["event"]["properties"]["start"]
location = parsed_event["event"]["properties"]["where"]
print(f"📝 Événement {processed + 1}:")
print(f" Titre: {event_label}")
print(f" Date: {start_date}")
print(f" Lieu: {location}")
print()
# Simuler l'envoi
success, message = scraper.send_event_to_api(parsed_event)
# Mettre à jour les données locales (simulation)
event_id = parsed_event["id"]
scraper.events_data["events"][event_id] = {
"status": "saved" if success else "error",
"message": message,
"last_attempt": "2024-01-01T00:00:00",
"event": parsed_event["event"]
}
processed += 1
print("-" * 40)
print(f"✅ Démonstration terminée - {processed} événements traités")
print()
print("💡 Pour exécuter le vrai scraper:")
print(" python agendadulibre.py --batch-size 5 --api-url http://localhost:5000")
print()
print("🧪 Pour exécuter les tests:")
print(" python test_agendadulibre.py")
return True
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)

View file

@ -0,0 +1,601 @@
#!/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, parallel=False, max_workers=4):
self.max_events = max_events
self.dry_run = dry_run
self.parallel = parallel
self.max_workers = max_workers
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()
# Test 4: Mode parallèle
print("\n4⃣ Test 4: Mode parallèle avec 15 événements")
scraper4 = DemoAgendaDuLibreScraper(max_events=15, dry_run=True, parallel=True, max_workers=3)
scraper4.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")
print("✅ Traitement parallèle")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,41 @@
#!/usr/bin/env python3
"""
Script de démonstration pour le scraper CCPL Agenda
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from ccpl_agenda import CCPLAgendaScraper
def main():
print("🧪 Démonstration du scraper CCPL Agenda")
print("=" * 50)
# Test 1: Mode dry-run avec limite de 1 événement
print("\n1⃣ Test 1: Mode dry-run avec limite de 1 événement")
scraper1 = CCPLAgendaScraper(max_events=1, dry_run=True)
scraper1.run()
print("\n2⃣ Test 2: Mode dry-run avec limite de 3 événements")
scraper2 = CCPLAgendaScraper(max_events=3, dry_run=True)
scraper2.run()
print("\n3⃣ Test 3: Mode parallèle avec 5 événements")
scraper3 = CCPLAgendaScraper(max_events=5, dry_run=True, parallel=True, max_workers=2)
scraper3.run()
print("\n🎉 Toutes les démonstrations sont terminées !")
print("\nFonctionnalités démontrées:")
print("✅ Scraping HTML de l'agenda CCPL")
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")
print("✅ Traitement parallèle")
print("✅ Extraction des métadonnées (titre, date, URL, image)")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,136 @@
#!/usr/bin/env python3
"""
Script pour extraire les coordonnées géographiques du fichier ICS de l'agenda du libre.
Le fichier ICS contient des coordonnées dans les propriétés X-ALT-DESC;FMTTYPE=text/html
sous forme d'attributs data-latitude et data-longitude.
"""
import re
import json
from typing import List, Dict, Tuple
def extract_coordinates_from_ics(ics_file_path: str) -> List[Dict]:
"""
Extrait les coordonnées géographiques du fichier ICS.
Args:
ics_file_path: Chemin vers le fichier ICS
Returns:
Liste de dictionnaires contenant les coordonnées et informations associées
"""
coordinates = []
with open(ics_file_path, 'r', encoding='utf-8') as file:
content = file.read()
# Recherche des patterns data-latitude et data-longitude
# Pattern pour capturer les coordonnées dans le HTML
pattern = r'data-latitude="([^"]+)"[^>]*data-longitude="([^"]+)"'
matches = re.findall(pattern, content)
for i, (lat, lon) in enumerate(matches):
try:
lat_float = float(lat)
lon_float = float(lon)
# Vérifier que les coordonnées sont valides
if -90 <= lat_float <= 90 and -180 <= lon_float <= 180:
coordinates.append({
'id': i + 1,
'latitude': lat_float,
'longitude': lon_float,
'source': 'agendadulibre_ics'
})
except ValueError:
# Ignorer les coordonnées invalides
continue
return coordinates
def find_events_with_coordinates(ics_file_path: str) -> List[Dict]:
"""
Trouve les événements qui ont des coordonnées dans le fichier ICS.
Args:
ics_file_path: Chemin vers le fichier ICS
Returns:
Liste des événements avec leurs coordonnées
"""
events = []
with open(ics_file_path, 'r', encoding='utf-8') as file:
content = file.read()
# Diviser le contenu en événements individuels
event_blocks = content.split('BEGIN:VEVENT')
for i, block in enumerate(event_blocks[1:], 1): # Ignorer le premier bloc (en-tête)
# Rechercher les coordonnées dans ce bloc d'événement
lat_match = re.search(r'data-latitude="([^"]+)"', block)
lon_match = re.search(r'data-longitude="([^"]+)"', block)
if lat_match and lon_match:
try:
lat = float(lat_match.group(1))
lon = float(lon_match.group(1))
# Vérifier que les coordonnées sont valides
if -90 <= lat <= 90 and -180 <= lon <= 180:
# Extraire d'autres informations de l'événement
summary_match = re.search(r'SUMMARY:(.+)', block)
location_match = re.search(r'LOCATION:(.+)', block)
description_match = re.search(r'DESCRIPTION:(.+)', block)
event = {
'event_id': i,
'latitude': lat,
'longitude': lon,
'summary': summary_match.group(1).strip() if summary_match else '',
'location': location_match.group(1).strip() if location_match else '',
'description': description_match.group(1).strip() if description_match else '',
'source': 'agendadulibre_ics'
}
events.append(event)
except ValueError:
continue
return events
def main():
"""Fonction principale pour extraire et afficher les coordonnées."""
ics_file = 'agendadulibre_events.ics'
print("🔍 Extraction des coordonnées du fichier ICS...")
# Extraire toutes les coordonnées
all_coordinates = extract_coordinates_from_ics(ics_file)
print(f"📍 {len(all_coordinates)} coordonnées trouvées")
# Extraire les événements avec coordonnées
events_with_coords = find_events_with_coordinates(ics_file)
print(f"🎯 {len(events_with_coords)} événements avec coordonnées trouvés")
# Afficher quelques exemples
print("\n📋 Exemples d'événements avec coordonnées :")
for event in events_with_coords[:5]:
print(f"{event['summary']}")
print(f" 📍 {event['latitude']}, {event['longitude']}")
print(f" 📍 Lieu: {event['location']}")
print()
# Sauvegarder les résultats
with open('extracted_coordinates.json', 'w', encoding='utf-8') as f:
json.dump(events_with_coords, f, indent=2, ensure_ascii=False)
print(f"💾 Résultats sauvegardés dans 'extracted_coordinates.json'")
# Statistiques
unique_coords = set((event['latitude'], event['longitude']) for event in events_with_coords)
print(f"📊 {len(unique_coords)} coordonnées uniques")
if __name__ == "__main__":
main()

View file

@ -0,0 +1,266 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Extracteur des vacances scolaires en France (par zones A/B/C et Corse) + jours fériés.
Sources:
- Vacances scolaires (ICS): https://www.data.gouv.fr/api/1/datasets/r/e5f40fbc-7a84-4c4a-94e4-55ac4299b222
- Jours fériés: https://calendrier.api.gouv.fr/jours-feries.json
Fonctionnalités:
- Cache JSON pour limiter les requêtes
- Paramètres CLI (période, zone optionnelle, dry-run, base_url OEDB, ttl cache)
- Conversion vers format Feature OEDB (un évènement par zone et par période de vacances)
- Pas de coordonnées GPS (point [0,0])
- Rapport succès/échecs à l'issue de l'envoi
"""
import argparse
import datetime as dt
import sys
from typing import Any, Dict, List, Tuple
from utils_extractor_common import (
CacheConfig,
load_cache,
save_cache,
oedb_feature,
post_oedb_features,
http_get_json,
)
DEFAULT_CACHE = "extractors_cache/fr_holidays_cache.json"
OEDB_DEFAULT = "https://api.openeventdatabase.org"
ICS_URL = "https://www.data.gouv.fr/api/1/datasets/r/e5f40fbc-7a84-4c4a-94e4-55ac4299b222"
def build_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Extracteur vacances scolaires FR -> OEDB")
p.add_argument("--start", help="Date de début YYYY-MM-DD", default=None)
p.add_argument("--end", help="Date de fin YYYY-MM-DD", default=None)
p.add_argument("--academie", help="Filtrer par académie (optionnel)", default=None)
p.add_argument("--base-url", help="Base URL OEDB", default=OEDB_DEFAULT)
p.add_argument("--cache", help="Fichier de cache JSON", default=DEFAULT_CACHE)
p.add_argument("--cache-ttl", help="Durée de vie du cache (sec)", type=int, default=24*3600)
p.add_argument("--limit", help="Limiter le nombre d'événements à traiter", type=int, default=None)
p.add_argument("--dry-run", help="N'envoie pas à l'API OEDB", action="store_true")
return p.parse_args()
def date_in_range(d: str, start: str, end: str) -> bool:
if not start and not end:
return True
dd = dt.date.fromisoformat(d)
if start:
if dd < dt.date.fromisoformat(start):
return False
if end:
if dd > dt.date.fromisoformat(end):
return False
return True
def _zones_from_text(summary: str, location: str) -> List[str]:
s = f"{summary} {location}".lower()
zones: List[str] = []
if "corse" in s:
zones.append("Corse")
# Chercher motifs Zones A/B/C, B/C, A/B, Zone A, etc.
# Simpliste mais robuste pour notre source
if "zones a/b/c" in s or "zones a / b / c" in s:
zones.extend(["A", "B", "C"])
else:
if "zones a/b" in s or "zones a / b" in s:
zones.extend(["A", "B"])
if "zones b/c" in s or "zones b / c" in s:
zones.extend(["B", "C"])
if "zones a/c" in s or "zones a / c" in s:
zones.extend(["A", "C"])
if "zone a" in s:
zones.append("A")
if "zone b" in s:
zones.append("B")
if "zone c" in s:
zones.append("C")
# Dédupliquer en conservant l'ordre
seen = set()
out: List[str] = []
for z in zones:
if z not in seen:
seen.add(z)
out.append(z)
return out or ["A", "B", "C"] # fallback si non indiqué
def _parse_ics_events(ics_text: str) -> List[Dict[str, Any]]:
events: List[Dict[str, Any]] = []
current: Dict[str, str] = {}
in_event = False
for raw in ics_text.splitlines():
line = raw.strip()
if line == "BEGIN:VEVENT":
in_event = True
current = {}
continue
if line == "END:VEVENT":
if current:
events.append(current)
in_event = False
current = {}
continue
if not in_event:
continue
if line.startswith("DTSTART"):
# DTSTART;VALUE=DATE:YYYYMMDD
val = line.split(":", 1)[-1]
current["DTSTART"] = val
elif line.startswith("DTEND"):
val = line.split(":", 1)[-1]
current["DTEND"] = val
elif line.startswith("SUMMARY:"):
current["SUMMARY"] = line[len("SUMMARY:"):].strip()
elif line.startswith("LOCATION:"):
current["LOCATION"] = line[len("LOCATION:"):].strip()
return events
def _yymmdd_to_iso(d: str) -> str:
# d: YYYYMMDD
return f"{d[0:4]}-{d[4:6]}-{d[6:8]}"
def fetch_sources(cache_cfg: CacheConfig) -> Dict[str, Any]:
cache = load_cache(cache_cfg)
if cache:
return cache
out: Dict[str, Any] = {}
# Jours fériés France métropolitaine (année courante)
year = dt.date.today().year
holidays_url = f"https://calendrier.api.gouv.fr/jours-feries/metropole/{year}.json"
out["jours_feries"] = http_get_json(holidays_url)
# Vacances scolaires via ICS data.gouv
import requests
r = requests.get(ICS_URL, timeout=30)
r.raise_for_status()
ics_text = r.text
vevents = _parse_ics_events(ics_text)
vacances: List[Dict[str, Any]] = []
for ev in vevents:
dtstart = ev.get("DTSTART")
dtend = ev.get("DTEND")
summary = ev.get("SUMMARY", "")
location = ev.get("LOCATION", "")
if not (dtstart and dtend and summary):
continue
start_iso = _yymmdd_to_iso(dtstart)
end_excl_iso = _yymmdd_to_iso(dtend)
# DTEND valeur-date dans ICS est exclusive -> stop inclusif = end_excl - 1 jour
end_excl = dt.date.fromisoformat(end_excl_iso)
stop_incl = (end_excl - dt.timedelta(days=1)).isoformat()
zones = _zones_from_text(summary, location)
vacances.append({
"label": summary,
"start": start_iso,
"stop": stop_incl,
"zones": zones,
})
out["vacances_scolaires_ics"] = vacances
save_cache(cache_cfg, out)
return out
def convert_to_oedb(data: Dict[str, Any], start: str | None, end: str | None, academie: str | None, limit: int | None = None) -> List[Dict[str, Any]]:
features: List[Dict[str, Any]] = []
# Jours fériés
jf: Dict[str, str] = data.get("jours_feries", {}) or {}
for date_iso, label in jf.items():
if not date_in_range(date_iso, start, end):
continue
# Améliorer le nom avec la date
try:
date_obj = dt.date.fromisoformat(date_iso)
day_name = date_obj.strftime("%A %d %B %Y")
full_label = f"{label} ({day_name})"
except:
full_label = label
feature = oedb_feature(
label=full_label,
what="time.daylight.holiday",
start=f"{date_iso}T00:00:00Z",
stop=f"{date_iso}T23:59:59Z",
description="Jour férié national",
where="France",
)
# Ajouter la propriété type requise par l'API OEDB
feature["properties"]["type"] = "scheduled"
features.append(feature)
# Appliquer la limite si définie
if limit and len(features) >= limit:
return features[:limit]
# Vacances scolaires via ICS un évènement par zone listée
vs_ics: List[Dict[str, Any]] = data.get("vacances_scolaires_ics", []) or []
for item in vs_ics:
s = item.get("start")
e = item.get("stop")
label = item.get("label") or "Vacances scolaires"
zones: List[str] = item.get("zones") or []
if not (s and e and zones):
continue
if not (date_in_range(s, start, end) or date_in_range(e, start, end)):
continue
for z in zones:
if academie and z != academie:
continue
# Améliorer le nom avec la période et la zone
try:
start_date = dt.date.fromisoformat(s)
end_date = dt.date.fromisoformat(e)
period_duration = (end_date - start_date).days + 1
full_label = f"{label} - Zone {z} ({period_duration} jours)"
except:
full_label = f"{label} - Zone {z}"
feature = oedb_feature(
label=full_label,
what="time.holidays",
start=f"{s}T00:00:00Z",
stop=f"{e}T23:59:59Z",
description=f"Vacances scolaires zone {z}",
where=f"Zone {z}",
)
# Ajouter la propriété type requise par l'API OEDB
feature["properties"]["type"] = "event"
features.append(feature)
return features
def main() -> int:
args = build_args()
cache_cfg = CacheConfig(path=args.cache, ttl_seconds=args.cache_ttl)
src = fetch_sources(cache_cfg)
feats = convert_to_oedb(src, args.start, args.end, args.academie, args.limit)
# Utiliser un cache pour éviter de renvoyer les événements déjà traités
sent_cache_path = "extractors_cache/fr_holidays_sent.json"
ok, failed, neterr = post_oedb_features(args.base_url, feats, dry_run=args.dry_run, sent_cache_path=sent_cache_path)
print(json_report(ok, failed, neterr))
return 0
def json_report(ok: int, failed: int, neterr: int) -> str:
import json
return json.dumps({"success": ok, "failed": failed, "networkErrors": neterr}, indent=2)
if __name__ == "__main__":
sys.exit(main())

582
extractors/mobilizon.py Normal file
View file

@ -0,0 +1,582 @@
#!/usr/bin/env python3
"""
Import d'événements depuis l'API GraphQL de Mobilizon vers OEDB
Usage:
python3 mobilizon.py --limit 25 --page-size 10 --instance-url https://mobilizon.fr \
--api-url https://api.openeventdatabase.org --dry-run --verbose
Notes:
- S'inspire de extractors/agenda_geek.py pour la structure générale (CLI, dry-run,
session HTTP, envoi vers /event) et évite de scraper les pages web en
utilisant l'API GraphQL officielle.
- Ajoute un paramètre --limit pour borner le nombre d'événements à insérer.
"""
import argparse
import json
import logging
import time
import os
import math
import os
from dataclasses import dataclass
from datetime import datetime, timezone
from typing import Dict, Iterable, List, Optional, Tuple
import requests
# Configuration logging (alignée avec agenda_geek.py)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.StreamHandler(),
]
)
logger = logging.getLogger(__name__)
@dataclass
class MobilizonEvent:
uuid: Optional[str]
url: Optional[str]
title: Optional[str]
description: Optional[str]
begins_on: Optional[str]
ends_on: Optional[str]
status: Optional[str]
latitude: Optional[float]
longitude: Optional[float]
address_text: Optional[str]
tags: Optional[List[str]]
organizer_name: Optional[str]
organizer_url: Optional[str]
category: Optional[str]
website: Optional[str]
class MobilizonClient:
def __init__(self, instance_url: str = "https://mobilizon.fr") -> None:
self.base = instance_url.rstrip('/')
# L'endpoint GraphQL public d'une instance Mobilizon est typiquement /api
self.endpoint = f"{self.base}/api"
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'OEDB-Mobilizon-Importer/1.0 (+https://github.com/cquest/oedb)',
'Content-Type': 'application/json'
})
def fetch_events_page(self, page: int, page_size: int) -> Tuple[List[MobilizonEvent], int]:
"""Récupère une page d'événements publics via GraphQL.
Retourne (events, total) total est le total connu côté API (si exposé), sinon 0.
"""
# Plusieurs schémas existent selon versions; on tente un query générique.
# Le champ events retourne elements[] + total dans de nombreuses versions.
query = """
query Events($page: Int!, $limit: Int!) {
events(page: $page, limit: $limit) {
total
elements {
uuid
url
title
description
beginsOn
endsOn
status
physicalAddress {
description
locality
geom
street
postalCode
region
country
}
onlineAddress
tags { title slug }
organizerActor { name url }
category
}
}
}
"""
variables = {"page": page, "limit": page_size}
try:
logger.info(f"Fetching events page {page} with size {page_size}")
logger.info(f"Query: {query}")
logger.info(f"Variables: {variables}")
logger.info(f"Endpoint: {self.endpoint}")
resp = self.session.post(self.endpoint, json={"query": query, "variables": variables}, timeout=30)
resp.raise_for_status()
data = resp.json()
except requests.RequestException as e:
logger.error(f"Erreur HTTP GraphQL: {e}")
return ([], 0)
except ValueError:
logger.error("Réponse GraphQL non JSON")
return ([], 0)
if 'errors' in data:
logger.error(f"Erreurs GraphQL: {data['errors']}")
return ([], 0)
events_raw = (((data.get('data') or {}).get('events')) or {}).get('elements') or []
total = (((data.get('data') or {}).get('events')) or {}).get('total') or 0
parsed: List[MobilizonEvent] = []
for ev in events_raw:
# Adresse/coords
addr = ev.get('physicalAddress') or {}
address_text = None
if addr:
parts = [
addr.get('description'),
addr.get('street'),
addr.get('postalCode'),
addr.get('locality'),
addr.get('region'),
addr.get('country'),
]
address_text = ", ".join([p for p in parts if p]) or None
lat = None
lon = None
geom = addr.get('geom') if isinstance(addr, dict) else None
# geom peut être un scalaire JSON (string) ou déjà un objet (selon versions)
if geom:
parsed_ok = False
# 1) Essayer JSON
if isinstance(geom, (dict, list)):
try:
g = geom
if isinstance(g, dict) and isinstance(g.get('coordinates'), (list, tuple)):
coords = g.get('coordinates')
if isinstance(coords, list) and len(coords) >= 2:
lon = float(coords[0])
lat = float(coords[1])
parsed_ok = True
except Exception:
pass
else:
# string -> tenter json, sinon WKT POINT(lon lat)
try:
g = json.loads(geom)
if isinstance(g, dict) and isinstance(g.get('coordinates'), (list, tuple)):
coords = g.get('coordinates')
if isinstance(coords, list) and len(coords) >= 2:
lon = float(coords[0])
lat = float(coords[1])
parsed_ok = True
except Exception:
# WKT
import re
m = re.search(r"POINT\s*\(\s*([+-]?[0-9]*\.?[0-9]+)\s+([+-]?[0-9]*\.?[0-9]+)\s*\)", str(geom))
if m:
try:
lon = float(m.group(1))
lat = float(m.group(2))
parsed_ok = True
except Exception:
pass
# tags
tags_field = ev.get('tags')
tags_list: Optional[List[str]] = None
if isinstance(tags_field, list):
tags_list = []
for t in tags_field:
if isinstance(t, dict):
val = t.get('title') or t.get('slug') or t.get('name')
if val:
tags_list.append(val)
elif isinstance(t, str):
tags_list.append(t)
if not tags_list:
tags_list = None
# organizer
organizer = ev.get('organizerActor') or {}
organizer_name = organizer.get('name') if isinstance(organizer, dict) else None
organizer_url = organizer.get('url') if isinstance(organizer, dict) else None
# category & website
category = ev.get('category')
website = ev.get('onlineAddress') or ev.get('url')
parsed.append(MobilizonEvent(
uuid=ev.get('uuid') or ev.get('id'),
url=ev.get('url') or ev.get('onlineAddress'),
title=ev.get('title'),
description=ev.get('description'),
begins_on=ev.get('beginsOn'),
ends_on=ev.get('endsOn'),
status=ev.get('status'),
latitude=lat,
longitude=lon,
address_text=address_text,
tags=tags_list,
organizer_name=organizer_name,
organizer_url=organizer_url,
category=category,
website=website,
))
return (parsed, total)
class MobilizonImporter:
def __init__(self, api_url: str, instance_url: str, dry_run: bool = False, geocode_missing: bool = False, cache_file: Optional[str] = None) -> None:
self.api_url = api_url.rstrip('/')
self.client = MobilizonClient(instance_url)
self.dry_run = dry_run
self.geocode_missing = geocode_missing
self.cache_file = cache_file
self.cache = {"fetched": {}, "sent": {}, "events": {}} # uid -> ts, uid -> event dict
if self.cache_file:
self._load_cache()
self.session = requests.Session()
self.session.headers.update({
'User-Agent': 'OEDB-Mobilizon-Importer/1.0 (+https://github.com/cquest/oedb)'
})
def _load_cache(self) -> None:
try:
if self.cache_file and os.path.exists(self.cache_file):
with open(self.cache_file, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
self.cache["fetched"] = data.get("fetched", {})
self.cache["sent"] = data.get("sent", {})
self.cache["events"] = data.get("events", {})
logger.info(f"Cache chargé: fetched={len(self.cache['fetched'])}, sent={len(self.cache['sent'])}, events={len(self.cache['events'])}")
except Exception as e:
logger.warning(f"Chargement du cache échoué: {e}")
def _save_cache(self) -> None:
if not self.cache_file:
return
try:
tmp = self.cache_file + ".tmp"
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(self.cache, f, ensure_ascii=False, indent=2)
os.replace(tmp, self.cache_file)
except Exception as e:
logger.warning(f"Écriture du cache échouée: {e}")
def geocode_address(self, address: str) -> Optional[Tuple[float, float]]:
if not address or address.strip() == '':
return None
try:
geocode_url = "https://nominatim.openstreetmap.org/search"
params = {
'q': address,
'format': 'json',
'limit': 1,
'addressdetails': 0,
}
# Utiliser une session distincte pour respecter headers/politiques
s = requests.Session()
s.headers.update({'User-Agent': 'OEDB-Mobilizon-Importer/1.0 (+https://github.com/cquest/oedb)'})
r = s.get(geocode_url, params=params, timeout=15)
r.raise_for_status()
results = r.json()
if isinstance(results, list) and results:
lat = float(results[0]['lat'])
lon = float(results[0]['lon'])
return (lat, lon)
except Exception as e:
logger.warning(f"Géocodage échoué pour '{address}': {e}")
return None
@staticmethod
def _iso_or_none(dt_str: Optional[str]) -> Optional[str]:
if not dt_str:
return None
try:
# Mobilizon renvoie souvent des ISO 8601 déjà valides.
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
except Exception:
return None
@staticmethod
def _parse_dt(dt_str: Optional[str]) -> Optional[datetime]:
if not dt_str:
return None
try:
dt = datetime.fromisoformat(dt_str.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt
except Exception:
return None
@staticmethod
def _oedb_feature(ev: MobilizonEvent) -> Optional[Dict]:
# Nécessite des coords; si absentes on ignore (évite un géocodage agressif).
if ev.latitude is None or ev.longitude is None:
return None
start_iso = MobilizonImporter._iso_or_none(ev.begins_on)
end_iso = MobilizonImporter._iso_or_none(ev.ends_on)
properties = {
"label": ev.title or "Événement Mobilizon",
"type": "scheduled",
"what": "culture.meetup",
"start": start_iso,
"stop": end_iso,
"where": ev.address_text or "",
"description": ev.description or "",
"source:name": "Mobilizon",
"source:url": ev.url or "",
"source:uid": ev.uuid or "",
"url": ev.url or "",
}
if ev.tags:
properties["tags"] = ev.tags
if ev.organizer_name:
properties["organizer:name"] = ev.organizer_name
if ev.organizer_url:
properties["organizer:url"] = ev.organizer_url
if ev.category:
properties["category"] = ev.category
if ev.website:
properties["website"] = ev.website
feature = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": [ev.longitude, ev.latitude],
},
"properties": properties,
}
logger.info(json.dumps(feature, indent=2, ensure_ascii=False))
return feature
def send_to_oedb(self, feature: Dict) -> bool:
# Toujours logguer le JSON envoyé (ou qui serait envoyé)
if self.dry_run:
logger.info("DRY RUN - Événement qui serait envoyé:")
else:
logger.info("Envoi de l'événement vers OEDB:")
logger.info(json.dumps(feature, indent=2, ensure_ascii=False))
if self.dry_run:
return True
try:
r = self.session.post(f"{self.api_url}/event", json=feature, timeout=30)
if r.status_code == 201:
logger.info("Événement créé avec succès")
try:
uid = feature.get('properties', {}).get('source:uid')
if uid:
self.cache['sent'][uid] = int(time.time())
self._save_cache()
except Exception:
pass
return True
if r.status_code == 409:
logger.info("Événement déjà existant (409)")
try:
uid = feature.get('properties', {}).get('source:uid')
if uid:
self.cache['sent'][uid] = int(time.time())
self._save_cache()
except Exception:
pass
return True
logger.error(f"Erreur API OEDB {r.status_code}: {r.text}")
return False
except requests.RequestException as e:
logger.error(f"Erreur d'appel OEDB: {e}")
return False
def import_events(self, limit: int, page_size: int, start_page: int = 1, sleep_s: float = 0.5) -> None:
inserted = 0
fetched = 0 # nombre brut d'événements récupérés depuis l'API
page = start_page
pages_fetched = 0
# Parcourir les pages jusqu'à atteindre la limite demandée
while inserted < limit:
# Ne pas parcourir plus de pages que nécessaire (ex: limit=1, page-size=10 => 1 page max)
max_pages = max(1, math.ceil(limit / page_size))
if pages_fetched >= max_pages:
logger.info("Limite de pages atteinte selon --limit et --page-size, arrêt de la pagination")
break
remaining_fetch = max(1, min(page_size, max(1, limit - inserted)))
events, total = self.client.fetch_events_page(page=page, page_size=remaining_fetch)
if not events:
logger.info("Aucun événement supplémentaire retourné par l'API")
# Traiter des événements non envoyés depuis le cache si disponible
if self.cache.get('events'):
logger.info("Utilisation du cache pour traiter les événements non envoyés")
for uid, ev_data in list(self.cache['events'].items()):
if inserted >= limit:
break
if uid in self.cache['sent']:
continue
ev = MobilizonEvent(
uuid=uid,
url=ev_data.get('url'),
title=ev_data.get('title'),
description=ev_data.get('description'),
begins_on=ev_data.get('begins_on'),
ends_on=ev_data.get('ends_on'),
status=ev_data.get('status'),
latitude=ev_data.get('latitude'),
longitude=ev_data.get('longitude'),
address_text=ev_data.get('address_text'),
tags=ev_data.get('tags'),
organizer_name=ev_data.get('organizer_name'),
organizer_url=ev_data.get('organizer_url'),
category=ev_data.get('category'),
website=ev_data.get('website'),
)
# Filtrer les événements de plus d'une semaine
start_dt = self._parse_dt(ev.begins_on)
end_dt = self._parse_dt(ev.ends_on)
if start_dt and end_dt:
duration = end_dt - start_dt
if duration.total_seconds() > 7 * 24 * 3600:
continue
feature = self._oedb_feature(ev)
if feature is None and self.geocode_missing and ev.address_text:
coords = self.geocode_address(ev.address_text)
if coords:
ev.latitude, ev.longitude = coords
# mettre à jour le cache
ev_data['latitude'], ev_data['longitude'] = coords
self.cache['events'][uid] = ev_data
self._save_cache()
feature = self._oedb_feature(ev)
if feature is None:
continue
ok = self.send_to_oedb(feature)
if ok:
inserted += 1
break
break
# marquer fetched et filtrer déjà envoyés/déjà vus
new_in_page = 0
filtered: List[MobilizonEvent] = []
for ev in events:
uid = ev.uuid or ev.url
if uid:
if uid in self.cache['sent']:
logger.info("Ignoré (déjà envoyé) uid=%s" % uid)
continue
if uid not in self.cache['fetched']:
new_in_page += 1
self.cache['fetched'][uid] = int(time.time())
# Sauvegarder l'événement (cache pour dry-run / re-run sans refetch)
self.cache['events'][uid] = {
'url': ev.url,
'title': ev.title,
'description': ev.description,
'begins_on': ev.begins_on,
'ends_on': ev.ends_on,
'status': ev.status,
'latitude': ev.latitude,
'longitude': ev.longitude,
'address_text': ev.address_text,
'tags': ev.tags,
'organizer_name': ev.organizer_name,
'organizer_url': ev.organizer_url,
'category': ev.category,
'website': ev.website,
}
filtered.append(ev)
self._save_cache()
fetched += len(events)
pages_fetched += 1
for ev in filtered:
if inserted >= limit:
break
# Filtrer les événements de plus d'une semaine
start_dt = self._parse_dt(ev.begins_on)
end_dt = self._parse_dt(ev.ends_on)
if start_dt and end_dt:
duration = end_dt - start_dt
if duration.total_seconds() > 7 * 24 * 3600:
logger.info("Ignoré (durée > 7 jours)")
continue
feature = self._oedb_feature(ev)
if feature is None:
# Pas de géométrie -> on saute (évite un géocodage agressif pour rester léger)
# Mais on loggue tout de même les propriétés pour visibilité
properties = {
"label": ev.title or "Événement Mobilizon",
"type": "scheduled",
"what": "culture.meetup",
"start": MobilizonImporter._iso_or_none(ev.begins_on),
"stop": MobilizonImporter._iso_or_none(ev.ends_on),
"where": ev.address_text or "",
"description": ev.description or "",
"source:name": "Mobilizon",
"source:url": ev.url or "",
"source:uid": ev.uuid or "",
"url": ev.url or "",
}
pseudo_feature = {"type": "Feature", "geometry": None, "properties": properties}
logger.info("Ignoré (pas de géométrie) - Événement qui aurait été envoyé:")
logger.info(ev)
logger.info(json.dumps(pseudo_feature, indent=2, ensure_ascii=False))
# Si demandé, essayer un géocodage sur l'adresse
if self.geocode_missing and ev.address_text:
logger.info("Tentative de géocodage pour compléter les coordonnées...")
coords = self.geocode_address(ev.address_text)
if coords:
ev.latitude, ev.longitude = coords
feature = self._oedb_feature(ev)
if feature is None:
continue
else:
continue
ok = self.send_to_oedb(feature)
if ok:
inserted += 1
time.sleep(sleep_s)
page += 1
logger.info(f"Terminé: {inserted} événement(s) traité(s) (limite demandée: {limit})")
def main() -> None:
parser = argparse.ArgumentParser(description='Import Mobilizon -> OEDB (via GraphQL)')
parser.add_argument('--limit', type=int, default=20, help="Nombre maximal d'événements à insérer")
parser.add_argument('--page-size', type=int, default=10, help='Taille des pages GraphQL')
parser.add_argument('--start-page', type=int, default=1, help='Page de départ (1-indexée)')
parser.add_argument('--instance-url', default='https://mobilizon.fr', help="URL de l'instance Mobilizon (ex: https://mobilizon.fr)")
parser.add_argument('--api-url', default='https://api.openeventdatabase.org', help="URL de l'API OEDB")
parser.add_argument('--dry-run', action='store_true', help='Mode test sans envoi vers OEDB')
parser.add_argument('--geocode-missing', action='store_true', help="Tenter un géocodage si pas de géométrie fournie", default=True)
parser.add_argument('--cache-file', default='mobilizon_cache.json', help='Fichier JSON de cache pour éviter les doublons')
parser.add_argument('--verbose', action='store_true', help='Mode verbeux')
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
importer = MobilizonImporter(api_url=args.api_url, instance_url=args.instance_url, dry_run=args.dry_run, geocode_missing=args.geocode_missing, cache_file=args.cache_file)
importer.import_events(limit=args.limit, page_size=args.page_size, start_page=args.start_page)
if __name__ == '__main__':
main()
# extractors/mobilizon.py

View file

@ -0,0 +1,211 @@
#!/usr/bin/env python3
"""
Script de monitoring pour le scraper agenda du libre
Affiche les statistiques et l'état du scraper
"""
import json
import os
import sys
from datetime import datetime, timedelta
from typing import Dict, Any
class AgendaDuLibreMonitor:
def __init__(self, data_file: str = "agendadulibre_events.json"):
self.data_file = data_file
self.events_data = self.load_events_data()
def load_events_data(self) -> Dict[str, Any]:
"""Charge les données d'événements"""
if os.path.exists(self.data_file):
try:
with open(self.data_file, 'r', encoding='utf-8') as f:
return json.load(f)
except Exception as e:
print(f"❌ Erreur lors du chargement de {self.data_file}: {e}")
return {"events": {}, "last_update": None}
return {"events": {}, "last_update": None}
def get_statistics(self) -> Dict[str, Any]:
"""Calcule les statistiques des événements"""
events = self.events_data.get("events", {})
stats = {
"total_events": len(events),
"saved": 0,
"already_exists": 0,
"error": 0,
"unknown": 0,
"recent_errors": 0,
"last_update": self.events_data.get("last_update"),
"events_by_status": {},
"recent_events": []
}
# Analyser les statuts
for event_id, event_data in events.items():
status = event_data.get("status", "unknown")
stats["events_by_status"][status] = stats["events_by_status"].get(status, 0) + 1
if status == "saved":
stats["saved"] += 1
elif status == "already_exists":
stats["already_exists"] += 1
elif status == "error":
stats["error"] += 1
else:
stats["unknown"] += 1
# Vérifier les erreurs récentes (dernières 24h)
last_attempt = event_data.get("last_attempt")
if last_attempt and status == "error":
try:
attempt_time = datetime.fromisoformat(last_attempt.replace('Z', '+00:00'))
if datetime.now() - attempt_time.replace(tzinfo=None) < timedelta(hours=24):
stats["recent_errors"] += 1
except:
pass
# Collecter les événements récents (derniers 10)
if len(stats["recent_events"]) < 10:
event_info = {
"id": event_id,
"label": event_data.get("event", {}).get("properties", {}).get("label", "Sans titre"),
"status": status,
"last_attempt": last_attempt,
"message": event_data.get("message", "")
}
stats["recent_events"].append(event_info)
return stats
def display_statistics(self):
"""Affiche les statistiques de manière formatée"""
stats = self.get_statistics()
print("📊 Statistiques du scraper agenda du libre")
print("=" * 50)
# Informations générales
print(f"📁 Fichier de données: {self.data_file}")
print(f"📅 Dernière mise à jour: {stats['last_update'] or 'Jamais'}")
print(f"📈 Total d'événements traités: {stats['total_events']}")
print()
# Répartition par statut
print("📋 Répartition par statut:")
for status, count in stats["events_by_status"].items():
emoji = {
"saved": "",
"already_exists": "⚠️",
"error": "",
"unknown": ""
}.get(status, "")
print(f" {emoji} {status}: {count}")
print()
# Erreurs récentes
if stats["recent_errors"] > 0:
print(f"🚨 Erreurs récentes (24h): {stats['recent_errors']}")
print()
# Événements récents
if stats["recent_events"]:
print("🕒 Événements récents:")
for event in stats["recent_events"][:5]:
emoji = {
"saved": "",
"already_exists": "⚠️",
"error": "",
"unknown": ""
}.get(event["status"], "")
print(f" {emoji} {event['label'][:50]}{'...' if len(event['label']) > 50 else ''}")
if event["status"] == "error":
print(f" 💬 {event['message']}")
print()
# Recommandations
self.display_recommendations(stats)
def display_recommendations(self, stats: Dict[str, Any]):
"""Affiche des recommandations basées sur les statistiques"""
print("💡 Recommandations:")
if stats["total_events"] == 0:
print(" - Aucun événement traité. Exécutez le scraper pour commencer.")
elif stats["error"] > stats["saved"]:
print(" - Beaucoup d'erreurs détectées. Vérifiez la connectivité API.")
elif stats["recent_errors"] > 5:
print(" - Erreurs récentes nombreuses. Vérifiez les logs.")
elif stats["saved"] > 0:
print(" - Scraper fonctionne correctement.")
if stats["already_exists"] > stats["saved"]:
print(" - Beaucoup d'événements déjà existants. Le système de déduplication fonctionne.")
print()
def check_file_status(self):
"""Vérifie l'état du fichier de données"""
if not os.path.exists(self.data_file):
print(f"❌ Fichier de données non trouvé: {self.data_file}")
return False
try:
stat = os.stat(self.data_file)
size = stat.st_size
mtime = datetime.fromtimestamp(stat.st_mtime)
print(f"📁 État du fichier de données:")
print(f" - Taille: {size:,} octets")
print(f" - Dernière modification: {mtime}")
print(f" - Lisible: {'' if os.access(self.data_file, os.R_OK) else ''}")
print(f" - Écriture: {'' if os.access(self.data_file, os.W_OK) else ''}")
print()
return True
except Exception as e:
print(f"❌ Erreur lors de la vérification du fichier: {e}")
return False
def show_help(self):
"""Affiche l'aide"""
print("🔍 Monitor agenda du libre - Aide")
print("=" * 40)
print("Usage: python3 monitor_agendadulibre.py [options]")
print()
print("Options:")
print(" --stats, -s Afficher les statistiques (défaut)")
print(" --file, -f Vérifier l'état du fichier")
print(" --help, -h Afficher cette aide")
print()
print("Exemples:")
print(" python3 monitor_agendadulibre.py")
print(" python3 monitor_agendadulibre.py --file")
print(" python3 monitor_agendadulibre.py --stats")
def main():
"""Fonction principale"""
import argparse
parser = argparse.ArgumentParser(description="Monitor pour le scraper agenda du libre")
parser.add_argument("--stats", "-s", action="store_true", default=True,
help="Afficher les statistiques")
parser.add_argument("--file", "-f", action="store_true",
help="Vérifier l'état du fichier de données")
parser.add_argument("--data-file", default="agendadulibre_events.json",
help="Fichier de données à analyser")
args = parser.parse_args()
monitor = AgendaDuLibreMonitor(args.data_file)
if args.file:
monitor.check_file_status()
if args.stats:
monitor.display_statistics()
if __name__ == "__main__":
main()

View file

@ -3,18 +3,35 @@
OSM Calendar Extractor for the OpenEventDatabase.
This script fetches events from the OpenStreetMap Calendar RSS feed
and adds them to the OpenEventDatabase if they don't already exist.
and adds them to the OpenEventDatabase via the API.
For events that don't have geographic coordinates in the RSS feed but have a link
to an OSM Calendar event (https://osmcal.org/event/...), the script will fetch
the iCal version of the event and extract the coordinates and location from there.
RSS Feed URL: https://osmcal.org/events.rss
API Endpoint: https://api.openeventdatabase.org/event
Usage:
python osm_cal.py [--max-events MAX_EVENTS] [--offset OFFSET]
Arguments:
--max-events MAX_EVENTS Maximum number of events to insert (default: 1)
--offset OFFSET Number of events to skip from the beginning of the RSS feed (default: 0)
Examples:
# Insert the first event from the RSS feed
python osm_cal.py
# Insert up to 5 events from the RSS feed
python osm_cal.py --max-events 5
# Skip the first 3 events and insert the next 2
python osm_cal.py --offset 3 --max-events 2
Environment Variables:
DB_NAME: The name of the database (default: "oedb")
DB_HOST: The hostname of the database server (default: "localhost")
DB_USER: The username to connect to the database (default: "")
POSTGRES_PASSWORD: The password to connect to the database (default: None)
These environment variables can be set in the system environment or in a .env file
in the project root directory.
These environment variables can be set in the system environment or in a .env file
in the project root directory.
"""
import json
@ -25,6 +42,8 @@ import xml.etree.ElementTree as ET
import re
import html
from datetime import datetime, timedelta
from bs4 import BeautifulSoup
import unicodedata
# Add the parent directory to the path so we can import from oedb
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
@ -34,6 +53,180 @@ from oedb.utils.logging import logger
# RSS Feed URL for OSM Calendar
RSS_URL = "https://osmcal.org/events.rss"
# Base URL for OSM Calendar events
OSMCAL_EVENT_BASE_URL = "https://osmcal.org/event/"
# Main OSM Calendar page
OSMCAL_MAIN_URL = "https://osmcal.org"
# Cache file for processed events
CACHE_FILE = os.path.join(os.path.dirname(__file__), 'osm_cal_cache.json')
def fix_encoding(text):
"""
Corrige les problèmes d'encodage UTF-8 courants.
Args:
text (str): Texte potentiellement mal encodé
Returns:
str: Texte avec l'encodage corrigé
"""
if not text:
return text
try:
# Essayer de détecter et corriger l'encodage double UTF-8
# (UTF-8 interprété comme Latin-1 puis réencodé en UTF-8)
if 'Ã' in text:
# Encoder en latin-1 puis décoder en UTF-8
corrected = text.encode('latin-1').decode('utf-8')
logger.info(f"Encodage corrigé : '{text}' -> '{corrected}'")
return corrected
except (UnicodeEncodeError, UnicodeDecodeError):
# Si la correction échoue, essayer d'autres méthodes
try:
# Normaliser les caractères Unicode
normalized = unicodedata.normalize('NFKD', text)
return normalized
except:
pass
# Si aucune correction ne fonctionne, retourner le texte original
return text
def load_event_cache():
"""
Charge le cache des événements traités depuis le fichier JSON.
Returns:
dict: Dictionnaire des événements avec leur statut de traitement
"""
if os.path.exists(CACHE_FILE):
try:
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
cache = json.load(f)
logger.info(f"Cache chargé : {len(cache)} événements en cache")
return cache
except Exception as e:
logger.error(f"Erreur lors du chargement du cache : {e}")
return {}
else:
logger.info("Aucun cache trouvé, création d'un nouveau cache")
return {}
def save_event_cache(cache):
"""
Sauvegarde le cache des événements dans le fichier JSON.
Args:
cache (dict): Dictionnaire des événements avec leur statut
"""
try:
with open(CACHE_FILE, 'w', encoding='utf-8') as f:
json.dump(cache, f, indent=2, ensure_ascii=False)
logger.info(f"Cache sauvegardé : {len(cache)} événements")
except Exception as e:
logger.error(f"Erreur lors de la sauvegarde du cache : {e}")
def scrape_osmcal_event_links():
"""
Scrape la page principale d'osmcal.org pour extraire tous les liens d'événements.
Returns:
list: Liste des URLs d'événements trouvés
"""
logger.info(f"Scraping de la page principale : {OSMCAL_MAIN_URL}")
try:
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
response = requests.get(OSMCAL_MAIN_URL, headers=headers)
response.raise_for_status()
soup = BeautifulSoup(response.content, 'html.parser')
# Debugging : sauvegarder le HTML pour inspection
debug_file = os.path.join(os.path.dirname(__file__), 'osmcal_debug.html')
with open(debug_file, 'w', encoding='utf-8') as f:
f.write(response.text)
logger.info(f"HTML de débogage sauvegardé dans : {debug_file}")
event_links = []
# Essayer différents sélecteurs basés sur la structure HTML fournie
selectors_to_try = [
'a.event-list-entry-box', # Sélecteur principal basé sur l'exemple HTML
'li.event-list-entry a', # Sélecteur alternatif basé sur la structure
'.event-list-entry a', # Variation sans spécifier le tag li
'a[href*="/event/"]', # Tous les liens contenant "/event/"
'.event-list-entry-box' # Au cas où ce serait juste la classe
]
for selector in selectors_to_try:
logger.info(f"Essai du sélecteur : {selector}")
elements = soup.select(selector)
logger.info(f"Trouvé {len(elements)} éléments avec le sélecteur {selector}")
if elements:
for element in elements:
href = None
# Si l'élément est déjà un lien
if element.name == 'a' and element.get('href'):
href = element.get('href')
# Si l'élément contient un lien
elif element.name != 'a':
link_element = element.find('a')
if link_element and link_element.get('href'):
href = link_element.get('href')
if href:
# Construire l'URL complète si c'est un lien relatif
if href.startswith('/'):
# Enlever les paramètres de requête de l'URL de base
base_url = OSMCAL_MAIN_URL.split('?')[0]
if base_url.endswith('/'):
base_url = base_url[:-1]
full_url = base_url + href
else:
full_url = href
# Vérifier que c'est bien un lien vers un événement
if '/event/' in href and full_url not in event_links:
event_links.append(full_url)
logger.info(f"Lien d'événement trouvé : {full_url}")
# Si on a trouvé des liens avec ce sélecteur, on s'arrête
if event_links:
break
# Si aucun lien trouvé, essayer de lister tous les liens pour débugger
if not event_links:
logger.warning("Aucun lien d'événement trouvé. Listing de tous les liens pour débogage :")
all_links = soup.find_all('a', href=True)
logger.info(f"Total de liens trouvés sur la page : {len(all_links)}")
# Afficher les 10 premiers liens pour débogage
for i, link in enumerate(all_links[:10]):
logger.info(f"Lien {i+1}: {link.get('href')} (classes: {link.get('class', [])})")
# Chercher spécifiquement les liens contenant "event"
event_related_links = [link for link in all_links if 'event' in link.get('href', '').lower()]
logger.info(f"Liens contenant 'event' : {len(event_related_links)}")
for link in event_related_links[:5]:
logger.info(f"Lien event: {link.get('href')}")
logger.success(f"Trouvé {len(event_links)} liens d'événements uniques sur la page principale")
return event_links
except requests.exceptions.RequestException as e:
logger.error(f"Erreur lors du scraping de osmcal.org : {e}")
return []
except Exception as e:
logger.error(f"Erreur inattendue lors du scraping : {e}")
import traceback
logger.error(f"Traceback: {traceback.format_exc()}")
return []
def fetch_osm_calendar_data():
"""
@ -179,6 +372,76 @@ def parse_event_dates(description):
now = datetime.now()
return (now.isoformat(), (now + timedelta(days=1)).isoformat())
def fetch_ical_data(event_url):
"""
Fetch and parse iCal data for an OSM Calendar event.
Args:
event_url (str): The URL of the OSM Calendar event.
Returns:
tuple: A tuple containing (location_name, coordinates).
"""
try:
# Check if the URL is an OSM Calendar event URL
if not event_url.startswith(OSMCAL_EVENT_BASE_URL):
logger.warning(f"Not an OSM Calendar event URL: {event_url}")
return ("Unknown Location", [0, 0])
# Extract the event ID from the URL
event_id_match = re.search(r'event/(\d+)', event_url)
if not event_id_match:
logger.warning(f"Could not extract event ID from URL: {event_url}")
return ("Unknown Location", [0, 0])
event_id = event_id_match.group(1)
# Construct the iCal URL
ical_url = f"{OSMCAL_EVENT_BASE_URL}{event_id}.ics"
# Fetch the iCal content
logger.info(f"Fetching iCal data from: {ical_url}")
response = requests.get(ical_url)
if not response.ok:
logger.warning(f"Failed to fetch iCal data: {response.status_code}")
return ("Unknown Location", [0, 0])
# Parse the iCal content avec l'encodage correct
response.encoding = response.apparent_encoding or 'utf-8'
ical_content = response.text
# Extract GEO information
geo_match = re.search(r'GEO:([-+]?\d+\.\d+);([-+]?\d+\.\d+)', ical_content)
if geo_match:
# GEO format is latitude;longitude
latitude = float(geo_match.group(2))
longitude = float(geo_match.group(1))
coordinates = [longitude, latitude] # GeoJSON uses [longitude, latitude]
logger.info(f"Extracted coordinates from iCal: {coordinates}")
else:
logger.warning(f"No GEO information found in iCal data for event: {event_id}")
coordinates = [0, 0]
# Extract LOCATION information
location_match = re.search(r'LOCATION:(.+?)(?:\r\n|\n|\r)', ical_content)
if location_match:
location_name = location_match.group(1).strip()
# Unescape backslash-escaped characters (e.g., \, becomes ,)
location_name = re.sub(r'\\(.)', r'\1', location_name)
# Corriger l'encodage
location_name = fix_encoding(location_name)
logger.info(f"Extracted location from iCal: {location_name}")
else:
logger.warning(f"No LOCATION information found in iCal data for event: {event_id}")
location_name = "Unknown Location"
return (location_name, coordinates)
except Exception as e:
logger.error(f"Error fetching or parsing iCal data: {e}")
return ("Unknown Location", [0, 0])
def extract_location(description):
"""
Extract location information from the event description.
@ -202,7 +465,7 @@ def extract_location(description):
# The second paragraph often contains the location
location_candidate = location_matches[1].strip()
if location_candidate and "," in location_candidate and not location_candidate.startswith('<'):
location_name = location_candidate
location_name = fix_encoding(location_candidate)
# For now, we don't have exact coordinates, so we'll use a placeholder
# In a real implementation, you might want to geocode the location
@ -236,12 +499,32 @@ def create_event(item):
clean_description = html.unescape(clean_description)
clean_description = re.sub(r'\s+', ' ', clean_description).strip()
# Corriger l'encodage du titre et de la description
title = fix_encoding(title)
clean_description = fix_encoding(clean_description)
# Parse dates from the description
start_date, end_date = parse_event_dates(description)
# Extract location information
# Extract location information from the description
location_name, coordinates = extract_location(description)
# If we don't have coordinates and the link is to an OSM Calendar event,
# try to get coordinates and location from the iCal file
if coordinates == [0, 0] and link and link.startswith(OSMCAL_EVENT_BASE_URL):
logger.info(f"No coordinates found in description, trying to get from iCal: {link}")
ical_location_name, ical_coordinates = fetch_ical_data(link)
# Use iCal coordinates if available
if ical_coordinates != [0, 0]:
coordinates = ical_coordinates
logger.info(f"Using coordinates from iCal: {coordinates}")
# Use iCal location name if available and better than what we have
if ical_location_name != "Unknown Location":
location_name = ical_location_name
logger.info(f"Using location name from iCal: {location_name}")
# Create a descriptive label
label = title
@ -325,138 +608,75 @@ def event_exists(db, properties):
def submit_event(event):
"""
Submit an event to the OpenEventDatabase.
Submit an event to the OpenEventDatabase using the API.
Args:
event: A GeoJSON Feature representing the event.
Returns:
bool: True if the event was successfully submitted, False otherwise.
tuple: A tuple containing (success: bool, event_id: str or None).
success is True if the event was successfully submitted, False otherwise.
event_id is the OEDB event ID if available, None otherwise.
"""
try:
# Connect to the database
db = db_connect()
# Extract event properties
# Extract event properties for logging
properties = event['properties']
# Check if the event already exists
if event_exists(db, properties):
logger.info(f"Skipping event '{properties.get('label')}' as it already exists")
db.close()
return False
# API endpoint for OpenEventDatabase
api_url = "https://api.openeventdatabase.org/event"
cur = db.cursor()
geometry = json.dumps(event['geometry'])
# Make the API request
logger.info(f"Submitting event '{properties.get('label')}' to API")
response = requests.post(
api_url,
headers={"Content-Type": "application/json"},
data=json.dumps(event)
)
print('event: ', event)
# Insert the geometry into the geo table
cur.execute("""
INSERT INTO geo
SELECT geom, md5(st_astext(geom)) as hash, st_centroid(geom) as geom_center FROM
(SELECT st_setsrid(st_geomfromgeojson(%s),4326) as geom) as g
WHERE ST_IsValid(geom)
ON CONFLICT DO NOTHING RETURNING hash;
""", (geometry,))
# Check if the request was successful
if response.status_code == 200 or response.status_code == 201:
# Parse the response to get the event ID
response_data = response.json()
event_id = response_data.get('id')
# Get the geometry hash
hash_result = cur.fetchone()
if hash_result is None:
# If the hash is None, check if the geometry already exists in the database
cur.execute("""
SELECT hash FROM geo
WHERE hash = md5(st_astext(st_setsrid(st_geomfromgeojson(%s),4326)));
""", (geometry,))
existing_hash = cur.fetchone()
if existing_hash:
# Geometry already exists in the database, use its hash
geo_hash = existing_hash[0]
logger.info(f"Using existing geometry with hash: {geo_hash}")
if event_id:
logger.success(f"Event created with ID: {event_id}")
logger.info(f" https://api.openeventdatabase.org/event/{event_id}")
return (True, event_id)
else:
# Geometry doesn't exist, try to insert it directly
cur.execute("""
SELECT md5(st_astext(geom)) as hash,
ST_IsValid(geom),
ST_IsValidReason(geom) from (SELECT st_setsrid(st_geomfromgeojson(%s),4326) as geom) as g;
""", (geometry,))
hash_result = cur.fetchone()
if hash_result is None or not hash_result[1]:
logger.error(f"Invalid geometry for event: {properties.get('label')}")
if hash_result and len(hash_result) > 2:
logger.error(f"Reason: {hash_result[2]}")
db.close()
return False
geo_hash = hash_result[0]
# Now insert the geometry explicitly
cur.execute("""
INSERT INTO geo (geom, hash, geom_center)
VALUES (
st_setsrid(st_geomfromgeojson(%s),4326),
%s,
st_centroid(st_setsrid(st_geomfromgeojson(%s),4326))
)
ON CONFLICT (hash) DO NOTHING;
""", (geometry, geo_hash, geometry))
# Verify the geometry was inserted
cur.execute("SELECT 1 FROM geo WHERE hash = %s", (geo_hash,))
if cur.fetchone() is None:
logger.error(f"Failed to insert geometry with hash: {geo_hash}")
db.close()
return False
logger.info(f"Inserted new geometry with hash: {geo_hash}")
logger.warning(f"Event created but no ID returned in response")
return (True, None)
elif response.status_code == 409:
# 409 Conflict - L'événement existe déjà, considéré comme un succès
logger.success(f"Event already exists in database: {properties.get('label')} (HTTP 409)")
# Essayer d'extraire l'ID de l'événement existant depuis la réponse
try:
response_data = response.json()
existing_event_id = response_data.get('id')
return (True, existing_event_id)
except:
return (True, None)
else:
geo_hash = hash_result[0]
# Determine the bounds for the time range
bounds = '[]' if properties['start'] == properties['stop'] else '[)'
# Insert the event into the database
cur.execute("""
INSERT INTO events (events_type, events_what, events_when, events_tags, events_geo)
VALUES (%s, %s, tstzrange(%s, %s, %s), %s, %s)
ON CONFLICT DO NOTHING RETURNING events_id;
""", (
properties['type'],
properties['what'],
properties['start'],
properties['stop'],
bounds,
json.dumps(properties),
geo_hash
))
# Get the event ID
event_id = cur.fetchone()
if event_id:
logger.success(f"Event created with ID: {event_id[0]}")
db.commit()
db.close()
return True
else:
logger.warning(f"Failed to create event: {properties.get('label')}")
db.close()
return False
logger.warning(f"Failed to create event: {properties.get('label')}. Status code: {response.status_code}")
logger.warning(f"Response: {response.text}")
return (False, None)
except Exception as e:
logger.error(f"Error submitting event: {e}")
return False
return (False, None)
def main():
def main(max_events=1, offset=0):
"""
Main function to fetch OSM Calendar events and add them to the database.
Main function to fetch OSM Calendar events and add them to the OpenEventDatabase API.
Args:
max_events (int): Maximum number of events to insert (default: 1)
offset (int): Number of events to skip from the beginning of the RSS feed (default: 0)
The function will exit if the .env file doesn't exist, as it's required
for database connection parameters.
for environment variables.
"""
logger.info("Starting OSM Calendar extractor")
logger.info(f"Starting OSM Calendar extractor (max_events={max_events}, offset={offset})")
# Load environment variables from .env file and check if it exists
if not load_env_from_file():
@ -465,27 +685,223 @@ def main():
logger.info("Environment variables loaded successfully from .env file")
# Fetch events from the OSM Calendar RSS feed
items = fetch_osm_calendar_data()
# Charger le cache des événements traités
event_cache = load_event_cache()
if not items:
logger.warning("No events found, exiting")
# Scraper la page principale pour obtenir tous les liens d'événements
event_links = scrape_osmcal_event_links()
if not event_links:
logger.warning("Aucun lien d'événement trouvé sur la page principale")
return
# Process each item
# Identifier les nouveaux événements (non présents dans le cache ou non traités avec succès)
new_events = []
success_events = []
for link in event_links:
# Vérifier si l'événement existe dans le cache et a le statut 'success'
if link in event_cache and event_cache[link].get('status') == 'success':
success_events.append(link)
oedb_id = event_cache[link].get('oedb_event_id', 'ID non disponible')
logger.info(f"Événement déjà traité avec succès (ID OEDB: {oedb_id}), ignoré : {link}")
else:
new_events.append(link)
# Initialiser l'événement dans le cache s'il n'existe pas
if link not in event_cache:
event_cache[link] = {
'discovered_at': datetime.now().isoformat(),
'status': 'pending',
'attempts': 0
}
else:
# Log du statut actuel pour les événements déjà en cache
current_status = event_cache[link].get('status', 'unknown')
attempts = event_cache[link].get('attempts', 0)
oedb_id = event_cache[link].get('oedb_event_id', 'non disponible')
logger.info(f"Événement à retraiter (statut: {current_status}, tentatives: {attempts}, ID OEDB: {oedb_id}) : {link}")
logger.info(f"Liens d'événements trouvés : {len(event_links)}")
logger.info(f"Événements déjà traités avec succès : {len(success_events)}")
logger.info(f"Nouveaux événements à traiter : {len(new_events)}")
if len(new_events) == 0:
logger.success("Aucun nouvel événement à traiter. Tous les événements ont déjà été insérés avec succès.")
return
# Appliquer l'offset et la limite aux nouveaux événements
if offset >= len(new_events):
logger.warning(f"Offset {offset} est supérieur ou égal au nombre de nouveaux événements {len(new_events)}")
return
events_to_process = new_events[offset:offset + max_events]
logger.info(f"Traitement de {len(events_to_process)} nouveaux événements")
# Fetch events from the OSM Calendar RSS feed pour obtenir les détails
rss_items = fetch_osm_calendar_data()
if not rss_items:
logger.warning("Aucun événement trouvé dans le flux RSS, mais continuons avec les liens scrapés")
# Créer un mapping des liens RSS vers les items pour un accès rapide
rss_link_to_item = {}
for item in rss_items:
link_element = item.find('link')
if link_element is not None:
rss_link_to_item[link_element.text] = item
# Process each new event
success_count = 0
for item in items:
# Create an event from the item
event = create_event(item)
for event_link in events_to_process:
try:
# Vérifier si l'événement est déjà en succès (sécurité supplémentaire)
if event_cache.get(event_link, {}).get('status') == 'success':
logger.info(f"Événement déjà en succès, passage au suivant : {event_link}")
success_count += 1 # Compter comme succès puisqu'il est déjà traité
continue
if not event:
continue
event_cache[event_link]['attempts'] += 1
event_cache[event_link]['last_attempt'] = datetime.now().isoformat()
# Submit the event to the database
if submit_event(event):
success_count += 1
# Chercher l'item correspondant dans le flux RSS
rss_item = rss_link_to_item.get(event_link)
logger.success(f"Successfully added {success_count} out of {len(items)} events to the database")
if rss_item is not None:
# Créer l'événement depuis l'item RSS
event = create_event(rss_item)
else:
# Si pas trouvé dans le flux RSS, essayer de créer un événement minimal depuis le lien
logger.warning(f"Événement {event_link} non trouvé dans le flux RSS, tentative de création depuis le lien")
event = create_event_from_link(event_link)
if event:
# Tenter de soumettre l'événement à l'API
submit_success, oedb_event_id = submit_event(event)
if submit_success:
success_count += 1
event_cache[event_link]['status'] = 'success'
event_cache[event_link]['inserted_at'] = datetime.now().isoformat()
# Sauvegarder l'ID de l'événement OEDB dans le cache
if oedb_event_id:
event_cache[event_link]['oedb_event_id'] = oedb_event_id
logger.success(f"Événement inséré avec succès (ID OEDB: {oedb_event_id}) : {event_link}")
else:
logger.success(f"Événement inséré avec succès (ID OEDB non disponible) : {event_link}")
else:
event_cache[event_link]['status'] = 'failed'
logger.warning(f"Échec de l'insertion de l'événement : {event_link}")
else:
event_cache[event_link]['status'] = 'failed'
logger.error(f"Impossible de créer l'événement depuis : {event_link}")
except Exception as e:
logger.error(f"Erreur lors du traitement de l'événement {event_link} : {e}")
event_cache[event_link]['status'] = 'error'
event_cache[event_link]['error'] = str(e)
# Sauvegarder le cache mis à jour
save_event_cache(event_cache)
# Calculer les statistiques finales du cache
cache_stats = {
'success': 0,
'pending': 0,
'failed': 0,
'error': 0,
'total': len(event_cache)
}
for link, data in event_cache.items():
status = data.get('status', 'pending')
if status in cache_stats:
cache_stats[status] += 1
# Événements en attente d'insertion (tous sauf success)
events_awaiting_insertion = cache_stats['pending'] + cache_stats['failed'] + cache_stats['error']
logger.success(f"Traitement terminé : {success_count} événements insérés avec succès sur {len(events_to_process)} traités")
logger.info("=== STATISTIQUES GLOBALES DU CACHE ===")
logger.info(f"Total d'événements dans le cache : {cache_stats['total']}")
logger.info(f"Événements traités avec succès : {cache_stats['success']}")
logger.info(f"Événements en attente d'insertion : {events_awaiting_insertion}")
logger.info(f" - Statut 'pending' : {cache_stats['pending']}")
logger.info(f" - Statut 'failed' : {cache_stats['failed']}")
logger.info(f" - Statut 'error' : {cache_stats['error']}")
if events_awaiting_insertion > 0:
logger.info(f"🔄 Il reste {events_awaiting_insertion} événements à traiter lors de la prochaine exécution")
else:
logger.success("✅ Tous les événements découverts ont été traités avec succès")
def create_event_from_link(event_link):
"""
Créer un événement minimal depuis un lien osmcal.org quand il n'est pas disponible dans le flux RSS.
Args:
event_link (str): URL de l'événement osmcal.org
Returns:
dict: Un objet GeoJSON Feature représentant l'événement, ou None en cas d'échec
"""
try:
logger.info(f"Tentative de création d'événement depuis le lien : {event_link}")
# Si c'est un lien vers un événement OSM Calendar, essayer d'obtenir les données iCal
if event_link.startswith(OSMCAL_EVENT_BASE_URL):
location_name, coordinates = fetch_ical_data(event_link)
# Extraire l'ID de l'événement pour créer un GUID
event_id_match = re.search(r'event/(\d+)', event_link)
if event_id_match:
event_id = event_id_match.group(1)
external_id = f"osmcal_{event_id}"
else:
external_id = event_link
# Créer un événement avec les informations minimales disponibles
now = datetime.now()
event = {
"type": "Feature",
"geometry": {
"type": "Point",
"coordinates": coordinates
},
"properties": {
"type": "scheduled",
"what": "community.osm.event",
"what:series": "OpenStreetMap Calendar",
"where": location_name,
"label": f"Événement OSM Calendar {event_id if 'event_id' in locals() else 'inconnu'}",
"description": f"Événement trouvé sur osmcal.org : {event_link}",
"start": now.isoformat(),
"stop": (now + timedelta(days=1)).isoformat(),
"url": event_link,
"external_id": external_id,
"source": "OSM Calendar (scraped)"
}
}
return event
else:
logger.warning(f"Lien non reconnu comme un événement OSM Calendar : {event_link}")
return None
except Exception as e:
logger.error(f"Erreur lors de la création d'événement depuis le lien {event_link} : {e}")
return None
if __name__ == "__main__":
main()
import argparse
# Set up command line argument parsing
parser = argparse.ArgumentParser(description='OSM Calendar Extractor for the OpenEventDatabase')
parser.add_argument('--max-events', type=int, default=1,
help='Maximum number of events to insert (default: 1)')
parser.add_argument('--offset', type=int, default=0,
help='Number of events to skip from the beginning of the RSS feed (default: 0)')
# Parse arguments
args = parser.parse_args()
# Run the main function with the provided arguments
main(max_events=args.max_events, offset=args.offset)

1989
extractors/osmcal_debug.html Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,395 @@
#!/usr/bin/env python3
"""
Import d'expositions depuis https://programmedesexpos.paris/ vers l'API OpenEventDatabase.
Principe:
- Récupère les balises <script type="application/ld+json"> sur une ou plusieurs pages
- Filtre les objets JSON-LD de type Event/Exhibition
- Convertit en GeoJSON Feature avec properties.type="scheduled" et what="culture.exhibition"
- Utilise un cache pour éviter de renvoyer des événements déjà transmis (HTTP 201 ou 409)
Exécution (exemples):
python3 programmedesexpos_paris.py \
--api-url https://api.openeventdatabase.org \
--pages 1 --dry-run --verbose
python3 programmedesexpos_paris.py \
--api-url https://api.openeventdatabase.org \
--pages 5 --geocode-missing
Notes:
- Le site embarque parfois un objet WebPage (à ignorer). On ne garde que Event/Exhibition.
- UID source: on privilégie l'URL de l'événement, à défaut @id, sinon un hash de name+date.
"""
import argparse
import json
import logging
import os
import re
import time
from dataclasses import dataclass
from datetime import datetime, timezone
from hashlib import md5
from typing import Dict, Iterable, List, Optional, Tuple, Union
import requests
from bs4 import BeautifulSoup
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[logging.StreamHandler()],
)
logger = logging.getLogger(__name__)
BASE_URL = "https://programmedesexpos.paris/"
CACHE_FILE = os.path.join(os.path.dirname(__file__), 'programmedesexpos_cache.json')
@dataclass
class ExpoEvent:
uid: str
url: Optional[str]
name: Optional[str]
description: Optional[str]
start: Optional[str]
stop: Optional[str]
latitude: Optional[float]
longitude: Optional[float]
where_text: Optional[str]
def _is_event_type(obj_type: Union[str, List[str], None]) -> bool:
if obj_type is None:
return False
if isinstance(obj_type, str):
t = obj_type.lower()
return 'event' in t or 'exhibition' in t
if isinstance(obj_type, list):
return any(_is_event_type(t) for t in obj_type)
return False
def _to_iso8601(value: Optional[str]) -> Optional[str]:
if not value:
return None
try:
# Supporte valeurs ISO déjà valides
dt = datetime.fromisoformat(value.replace('Z', '+00:00'))
if dt.tzinfo is None:
dt = dt.replace(tzinfo=timezone.utc)
return dt.isoformat()
except Exception:
# Tenter extraction par regex YYYY-MM-DD
m = re.match(r"(\d{4}-\d{2}-\d{2})", value)
if m:
try:
dt = datetime.fromisoformat(m.group(1))
return dt.isoformat()
except Exception:
return None
return None
def load_cache() -> Dict:
if os.path.exists(CACHE_FILE):
try:
with open(CACHE_FILE, 'r', encoding='utf-8') as f:
data = json.load(f)
if isinstance(data, dict):
for k in ["fetched", "sent", "pending", "events"]:
if k not in data:
data[k] = {}
logger.info(f"Cache chargé: fetched={len(data['fetched'])}, sent={len(data['sent'])}, pending={len(data['pending'])}, events={len(data['events'])}")
return data
except Exception as e:
logger.warning(f"Chargement du cache échoué: {e}")
return {"fetched": {}, "sent": {}, "pending": {}, "events": {}}
def save_cache(cache: Dict) -> None:
try:
tmp = CACHE_FILE + ".tmp"
with open(tmp, 'w', encoding='utf-8') as f:
json.dump(cache, f, ensure_ascii=False, indent=2)
os.replace(tmp, CACHE_FILE)
except Exception as e:
logger.warning(f"Écriture du cache échouée: {e}")
def geocode_address(address: str) -> Optional[Tuple[float, float]]:
if not address:
return None
try:
geocode_url = "https://nominatim.openstreetmap.org/search"
params = {
'q': address,
'format': 'json',
'limit': 1,
'addressdetails': 0,
}
s = requests.Session()
s.headers.update({'User-Agent': 'OEDB-ProgrammedesExpos-Importer/1.0 (+https://github.com/cquest/oedb)'})
r = s.get(geocode_url, params=params, timeout=20)
r.raise_for_status()
results = r.json()
if isinstance(results, list) and results:
lat = float(results[0]['lat'])
lon = float(results[0]['lon'])
return (lat, lon)
except Exception as e:
logger.warning(f"Géocodage échoué pour '{address}': {e}")
return None
def parse_jsonld_scripts(html_text: str, page_url: str) -> List[Dict]:
soup = BeautifulSoup(html_text, 'html.parser')
scripts = soup.find_all('script', attrs={'type': 'application/ld+json'})
items: List[Dict] = []
for sc in scripts:
try:
raw = sc.string or sc.get_text("", strip=True)
if not raw:
continue
data = json.loads(raw)
# Parfois un tableau d'objets
if isinstance(data, list):
for obj in data:
if isinstance(obj, dict):
items.append(obj)
elif isinstance(data, dict):
items.append(data)
except Exception:
continue
# Annoter la source de page si absente
for it in items:
it.setdefault('page:url', page_url)
return items
def jsonld_to_expo_event(obj: Dict) -> Optional[ExpoEvent]:
if not _is_event_type(obj.get('@type')):
return None
name = obj.get('name') or obj.get('headline')
description = obj.get('description')
start = _to_iso8601(obj.get('startDate'))
stop = _to_iso8601(obj.get('endDate'))
# URL prioritaire: champ url; sinon @id; sinon page URL
url = obj.get('url') or obj.get('@id') or obj.get('page:url')
# UID: URL sinon hash de name+start
uid = url or md5(f"{name or ''}|{start or ''}".encode('utf-8')).hexdigest()
# Localisation
lat = None
lon = None
where_text_parts: List[str] = []
loc = obj.get('location')
if isinstance(loc, dict):
# Nom du lieu
loc_name = loc.get('name')
if loc_name:
where_text_parts.append(loc_name)
# Adresse postale
addr = loc.get('address')
if isinstance(addr, dict):
for key in ['streetAddress', 'postalCode', 'addressLocality', 'addressRegion', 'addressCountry']:
val = addr.get(key)
if val:
where_text_parts.append(str(val))
# Coordonnées potentiellement dans location.geo
geo = loc.get('geo')
if isinstance(geo, dict):
try:
lat = float(geo.get('latitude')) if geo.get('latitude') is not None else None
lon = float(geo.get('longitude')) if geo.get('longitude') is not None else None
except Exception:
lat = None
lon = None
# Fallback: geo directement sur l'objet
if (lat is None or lon is None) and isinstance(obj.get('geo'), dict):
geo = obj['geo']
try:
lat = float(geo.get('latitude')) if geo.get('latitude') is not None else lat
lon = float(geo.get('longitude')) if geo.get('longitude') is not None else lon
except Exception:
pass
where_text = ", ".join([p for p in where_text_parts if p]) if where_text_parts else None
return ExpoEvent(
uid=uid,
url=url,
name=name,
description=description,
start=start,
stop=stop,
latitude=lat,
longitude=lon,
where_text=where_text,
)
def to_oedb_feature(ev: ExpoEvent) -> Optional[Dict]:
properties = {
"label": ev.name or "Exposition",
"type": "scheduled",
"what": "culture.exhibition.paris",
"start": ev.start,
"stop": ev.stop,
"where": ev.where_text or "",
"description": ev.description or "",
"source:name": "Programme des Expos Paris",
"source:url": ev.url or "",
"source:uid": ev.uid,
"url": ev.url or "",
}
# Géométrie: si coords absentes, retourner un Point avec coordonnées null
geometry: Dict = {"type": "Point", "coordinates": [ev.longitude, ev.latitude]} if ev.longitude is not None and ev.latitude is not None else {"type": "Point", "coordinates": None}
feature = {
"type": "Feature",
"geometry": geometry,
"properties": properties,
}
logger.info(f"feature: {json.dumps(feature, indent=2, ensure_ascii=False)}")
return feature
class Importer:
def __init__(self, api_url: str, dry_run: bool, geocode_missing: bool, pages: int) -> None:
self.api_url = api_url.rstrip('/')
self.dry_run = dry_run
self.geocode_missing = geocode_missing
self.pages = max(1, pages)
self.session = requests.Session()
self.session.headers.update({'User-Agent': 'OEDB-ProgrammedesExpos-Importer/1.0 (+https://github.com/cquest/oedb)'})
self.cache = load_cache()
def _save(self) -> None:
save_cache(self.cache)
def fetch_pages(self) -> List[Tuple[str, str]]:
pages: List[Tuple[str, str]] = []
for i in range(1, self.pages + 1):
url = BASE_URL if i == 1 else (BASE_URL.rstrip('/') + f"/page/{i}/")
try:
logger.info(f"Téléchargement page {i}: {url}")
r = self.session.get(url, timeout=30)
r.raise_for_status()
pages.append((url, r.text))
except requests.RequestException as e:
logger.warning(f"Échec téléchargement {url}: {e}")
break
return pages
def send_to_oedb(self, feature: Dict) -> bool:
if self.dry_run:
logger.info("DRY RUN - Événement qui serait envoyé:")
logger.info(json.dumps(feature, indent=2, ensure_ascii=False))
return True
try:
r = self.session.post(f"{self.api_url}/event", json=feature, timeout=30)
if r.status_code in (200, 201):
logger.info("Événement créé avec succès")
return True
if r.status_code == 409:
logger.info("Événement déjà existant (409)")
return True
logger.error(f"Erreur API OEDB {r.status_code}: {r.text}")
return False
except requests.RequestException as e:
logger.error(f"Erreur d'appel OEDB: {e}")
return False
def run(self, limit: int, sleep_s: float = 0.5) -> None:
inserted = 0
pages = self.fetch_pages()
for page_url, html_text in pages:
if inserted >= limit:
break
jsonld_items = parse_jsonld_scripts(html_text, page_url)
for obj in jsonld_items:
if inserted >= limit:
break
ev = jsonld_to_expo_event(obj)
if not ev:
continue
# Filtrage via cache
if ev.uid in self.cache['sent']:
logger.info(f"Ignoré (déjà envoyé) uid={ev.uid}")
continue
# Déterminer si l'événement était déjà en cache avant ce run
in_cache = ev.uid in self.cache['events']
# Géocoder seulement si pas en cache et coordonnées manquantes mais where_text présent
if (ev.latitude is None or ev.longitude is None) and ev.where_text and not in_cache:
coords = geocode_address(ev.where_text)
if coords:
ev.latitude, ev.longitude = coords
# Marquer fetched et enregistrer/mettre à jour l'événement dans le cache
self.cache['fetched'][ev.uid] = int(time.time())
self.cache['events'][ev.uid] = {
'url': ev.url,
'name': ev.name,
'description': ev.description,
'start': ev.start,
'stop': ev.stop,
'latitude': ev.latitude,
'longitude': ev.longitude,
'where_text': ev.where_text,
}
# Si pas déjà marqué envoyé, on le marque pending (sera déplacé vers sent après envoi effectif)
if ev.uid not in self.cache['sent']:
self.cache['pending'][ev.uid] = int(time.time())
self._save()
feature = to_oedb_feature(ev)
ok = self.send_to_oedb(feature)
if ok:
# En dry-run on conserve en pending, en commit on bascule en sent
if not self.dry_run:
self.cache['sent'][ev.uid] = int(time.time())
if ev.uid in self.cache['pending']:
self.cache['pending'].pop(ev.uid, None)
self._save()
inserted += 1
time.sleep(sleep_s)
logger.info(f"Terminé: {inserted} événement(s) traité(s) (limite demandée: {limit})")
def main() -> None:
parser = argparse.ArgumentParser(description="Import 'Programme des Expositions à Paris' -> OEDB")
parser.add_argument('--api-url', default='https://api.openeventdatabase.org', help="URL de l'API OEDB")
parser.add_argument('--pages', type=int, default=1, help='Nombre de pages à scrapper (1 = accueil)')
parser.add_argument('--limit', type=int, default=50, help="Nombre maximal d'événements à insérer")
# Dry-run par défaut, --commit pour envoyer réellement
parser.add_argument('--commit', action='store_true', help='Envoyer réellement vers OEDB (désactive le dry-run)')
parser.add_argument('--geocode-missing', action='store_true', help='Géocoder si pas de coordonnées en JSON-LD')
parser.add_argument('--verbose', action='store_true', help='Logs verbeux')
args = parser.parse_args()
if args.verbose:
logging.getLogger().setLevel(logging.DEBUG)
dry_run = not args.commit
importer = Importer(api_url=args.api_url, dry_run=dry_run, geocode_missing=args.geocode_missing, pages=args.pages)
importer.run(limit=args.limit)
if __name__ == '__main__':
main()

View file

@ -0,0 +1,2 @@
requests>=2.28.0
icalendar>=5.0.0

View file

@ -0,0 +1,3 @@
requests>=2.25.0
beautifulsoup4>=4.9.0
lxml>=4.6.0

19
extractors/run_daily.sh Normal file
View file

@ -0,0 +1,19 @@
#!/bin/bash
# script a lancer de façon quotidienne
# machine de référence: proxmox
# penser à copier les fichiers json de cache si lancé depuis une autre machine
# crontab -e
# -----------------
# 0 0 * * * cd /home/tykayn/www/oedb-backend/extractors && bash run_daily.sh
# */15 * * * * cd /home/tykayn/www/OEDb_Scrappers && source venv/bin/activate && bash run_jobs.sh # scrappers de traffic
source ../venv/bin/activate
pip install -r ../requirements.txt
LOG_FILE="./log_conjob_daily.log"
/bin/python3 mobilizon.py --limit 2000 --instance-url https://mobilizon.fr --geocode-missing
/bin/python3 agenda_geek.py
/bin/python3 osm_cal.py --max-events 100
/bin/python3 viparis_events.py --max-pages 2 --max-events 200 --no-dry-run
/bin/python3 world_days_extractor.py --limit 800 --no-dry-run

98
extractors/setup_cron.sh Executable file
View file

@ -0,0 +1,98 @@
#!/bin/bash
# Script de configuration du cron pour le scraper agenda du libre
SCRIPT_DIR="/home/poule/encrypted/stockage-syncable/www/development/html/oedb-backend/extractors"
SCRIPT_PATH="$SCRIPT_DIR/agendadulibre.py"
LOG_FILE="$SCRIPT_DIR/cron_agendadulibre.log"
echo "🔧 Configuration du cron pour le scraper agenda du libre"
echo "========================================================"
# Vérifier que le script existe
if [ ! -f "$SCRIPT_PATH" ]; then
echo "❌ Erreur: Le script $SCRIPT_PATH n'existe pas"
exit 1
fi
# Rendre le script exécutable
chmod +x "$SCRIPT_PATH"
echo "📋 Options de planification disponibles:"
echo "1. Toutes les heures (batch de 1)"
echo "2. Toutes les 2 heures (batch de 5)"
echo "3. Tous les jours à 6h (batch de 10)"
echo "4. Tous les jours à 6h et 18h (batch de 5)"
echo "5. Personnalisé"
echo ""
read -p "Choisissez une option (1-5): " choice
case $choice in
1)
CRON_SCHEDULE="0 * * * *"
BATCH_SIZE="1"
;;
2)
CRON_SCHEDULE="0 */2 * * *"
BATCH_SIZE="5"
;;
3)
CRON_SCHEDULE="0 6 * * *"
BATCH_SIZE="10"
;;
4)
CRON_SCHEDULE="0 6,18 * * *"
BATCH_SIZE="5"
;;
5)
read -p "Entrez la planification cron (ex: 0 */3 * * *): " CRON_SCHEDULE
read -p "Entrez la taille des batches (ex: 5): " BATCH_SIZE
;;
*)
echo "❌ Option invalide"
exit 1
;;
esac
# Demander l'URL de l'API
read -p "Entrez l'URL de l'API OEDB (défaut: http://localhost:5000): " API_URL
API_URL=${API_URL:-"http://localhost:5000"}
# Créer la commande cron
CRON_COMMAND="$CRON_SCHEDULE cd $SCRIPT_DIR && python3 $SCRIPT_PATH --api-url $API_URL --batch-size $BATCH_SIZE >> $LOG_FILE 2>&1"
echo ""
echo "📝 Configuration cron proposée:"
echo "Planification: $CRON_SCHEDULE"
echo "Commande: $CRON_COMMAND"
echo ""
read -p "Voulez-vous ajouter cette tâche au cron ? (y/N): " confirm
if [[ $confirm =~ ^[Yy]$ ]]; then
# Ajouter la tâche au cron
(crontab -l 2>/dev/null; echo "$CRON_COMMAND") | crontab -
if [ $? -eq 0 ]; then
echo "✅ Tâche cron ajoutée avec succès"
echo ""
echo "📋 Tâches cron actuelles:"
crontab -l | grep agendadulibre || echo "Aucune tâche trouvée"
echo ""
echo "📁 Logs disponibles dans: $LOG_FILE"
echo "🔍 Pour surveiller les logs: tail -f $LOG_FILE"
else
echo "❌ Erreur lors de l'ajout de la tâche cron"
exit 1
fi
else
echo "❌ Configuration annulée"
exit 0
fi
echo ""
echo "💡 Commandes utiles:"
echo " - Voir les tâches cron: crontab -l"
echo " - Supprimer une tâche: crontab -e"
echo " - Voir les logs: tail -f $LOG_FILE"
echo " - Tester manuellement: python3 $SCRIPT_PATH --api-url $API_URL --batch-size $BATCH_SIZE"

View file

@ -0,0 +1,139 @@
#!/usr/bin/env python3
"""
Script de test pour le scraper de l'agenda du libre
"""
import sys
import os
sys.path.append(os.path.dirname(os.path.abspath(__file__)))
from agendadulibre import AgendaDuLibreScraper, api_oedb
import logging
# Configuration du logging pour les tests
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
def test_ical_fetch():
"""Test de récupération du fichier iCal"""
print("🧪 Test de récupération du fichier iCal...")
scraper = AgendaDuLibreScraper()
calendar = scraper.fetch_ical_data()
if calendar:
print("✅ Fichier iCal récupéré avec succès")
# Compter les événements
event_count = 0
for component in calendar.walk():
if component.name == "VEVENT":
event_count += 1
print(f"📅 Nombre d'événements trouvés: {event_count}")
return True
else:
print("❌ Échec de la récupération du fichier iCal")
return False
def test_event_parsing():
"""Test de parsing d'un événement"""
print("🧪 Test de parsing d'événement...")
scraper = AgendaDuLibreScraper()
calendar = scraper.fetch_ical_data()
if not calendar:
print("❌ Impossible de récupérer le fichier iCal pour le test")
return False
# Trouver le premier événement
for component in calendar.walk():
if component.name == "VEVENT":
parsed_event = scraper.parse_event(component)
if parsed_event:
print("✅ Événement parsé avec succès:")
print(f" ID: {parsed_event['id']}")
print(f" Titre: {parsed_event['event']['properties']['label']}")
print(f" Début: {parsed_event['event']['properties']['start']}")
print(f" Fin: {parsed_event['event']['properties']['stop']}")
print(f" Lieu: {parsed_event['event']['properties']['where']}")
return True
else:
print("❌ Échec du parsing de l'événement")
return False
print("❌ Aucun événement trouvé pour le test")
return False
def test_data_persistence():
"""Test de persistance des données"""
print("🧪 Test de persistance des données...")
scraper = AgendaDuLibreScraper()
# Test de sauvegarde
test_data = {
"events": {
"test_event_123": {
"status": "saved",
"message": "Test event",
"last_attempt": "2024-01-01T00:00:00",
"event": {
"properties": {
"label": "Test Event",
"what": "culture.geek"
}
}
}
},
"last_update": "2024-01-01T00:00:00"
}
scraper.events_data = test_data
scraper.save_events_data()
# Test de chargement
scraper2 = AgendaDuLibreScraper()
if "test_event_123" in scraper2.events_data["events"]:
print("✅ Persistance des données fonctionne correctement")
return True
else:
print("❌ Échec de la persistance des données")
return False
def main():
"""Exécute tous les tests"""
print("🚀 Démarrage des tests du scraper agenda du libre")
print("=" * 50)
tests = [
test_ical_fetch,
test_event_parsing,
test_data_persistence
]
passed = 0
total = len(tests)
for test in tests:
try:
if test():
passed += 1
print()
except Exception as e:
print(f"❌ Erreur lors du test {test.__name__}: {e}")
print()
print("=" * 50)
print(f"📊 Résultats: {passed}/{total} tests réussis")
if passed == total:
print("✅ Tous les tests sont passés!")
return True
else:
print("❌ Certains tests ont échoué")
return False
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)

View 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())

130
extractors/test_api_connection.py Executable file
View file

@ -0,0 +1,130 @@
#!/usr/bin/env python3
"""
Script de test de connexion à l'API OEDB pour le scraper agenda du libre
"""
import requests
import sys
import json
from datetime import datetime
# Configuration par défaut
api_oedb = "https://api.openeventdatabase.org"
def test_api_connection(api_url: str = api_oedb):
"""Test la connexion à l'API OEDB"""
print(f"🔍 Test de connexion à l'API OEDB: {api_url}")
print("=" * 50)
# Test 1: Endpoint de base
print("1⃣ Test de l'endpoint de base...")
try:
response = requests.get(f"{api_url}/", timeout=10)
if response.status_code == 200:
print("✅ Endpoint de base accessible")
else:
print(f"⚠️ Endpoint de base répond avec le code: {response.status_code}")
except requests.exceptions.RequestException as e:
print(f"❌ Erreur de connexion à l'endpoint de base: {e}")
return False
# Test 2: Endpoint des événements
print("\n2⃣ Test de l'endpoint des événements...")
try:
response = requests.get(f"{api_url}/events", timeout=10)
if response.status_code == 200:
print("✅ Endpoint des événements accessible")
try:
data = response.json()
if 'features' in data:
print(f" 📊 {len(data['features'])} événements trouvés dans l'API")
else:
print(" ⚠️ Format de réponse inattendu")
except json.JSONDecodeError:
print(" ⚠️ Réponse non-JSON reçue")
else:
print(f"❌ Endpoint des événements répond avec le code: {response.status_code}")
return False
except requests.exceptions.RequestException as e:
print(f"❌ Erreur de connexion à l'endpoint des événements: {e}")
return False
# Test 3: Test d'envoi d'un événement de test
print("\n3⃣ Test d'envoi d'un événement de test...")
test_event = {
"type": "Feature",
"properties": {
"label": f"Test API Connection - {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}",
"description": "Événement de test pour vérifier la connexion API",
"type": "scheduled",
"what": "community",
"where": "Test Location",
"start": datetime.now().isoformat(),
"stop": (datetime.now().timestamp() + 3600).isoformat(),
"source:name": "Test API Connection",
"last_modified_by": "test_script"
},
"geometry": {
"type": "Point",
"coordinates": [0, 0]
}
}
try:
response = requests.post(
f"{api_url}/event",
json=test_event,
headers={"Content-Type": "application/json"},
timeout=10
)
if response.status_code == 201:
print("✅ Événement de test créé avec succès")
try:
created_event = response.json()
event_id = created_event.get('id', 'inconnu')
print(f" 📝 ID de l'événement créé: {event_id}")
except json.JSONDecodeError:
print(" ⚠️ Réponse de création non-JSON")
elif response.status_code == 409:
print("⚠️ Événement de test déjà existant (conflit)")
else:
print(f"❌ Erreur lors de la création de l'événement de test: {response.status_code}")
print(f" Réponse: {response.text}")
return False
except requests.exceptions.RequestException as e:
print(f"❌ Erreur lors de l'envoi de l'événement de test: {e}")
return False
print("\n✅ Tous les tests de connexion sont passés!")
return True
def main():
"""Fonction principale"""
import argparse
parser = argparse.ArgumentParser(description="Test de connexion à l'API OEDB")
parser.add_argument("--api-url", default=api_oedb,
help="URL de l'API OEDB à tester")
args = parser.parse_args()
success = test_api_connection(args.api_url)
if success:
print("\n🎉 L'API OEDB est prête pour le scraper agenda du libre!")
print("\n💡 Commandes utiles:")
print(" - Test complet: python3 test_agendadulibre.py")
print(" - Démonstration: python3 demo_agendadulibre.py")
print(" - Scraping réel: python3 agendadulibre.py --api-url " + args.api_url)
else:
print("\n❌ L'API OEDB n'est pas accessible ou ne fonctionne pas correctement.")
print("\n🔧 Vérifications à effectuer:")
print(" - L'API OEDB est-elle démarrée?")
print(" - L'URL est-elle correcte?")
print(" - Y a-t-il des erreurs dans les logs de l'API?")
sys.exit(0 if success else 1)
if __name__ == "__main__":
main()

View file

@ -0,0 +1,105 @@
#!/usr/bin/env python3
"""
Script de test pour analyser la structure des données Viparis
"""
import requests
import json
import re
from bs4 import BeautifulSoup
def analyze_viparis_data():
"""Analyse la structure des données Viparis"""
url = "https://www.viparis.com/actualites-evenements/evenements"
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36'
}
try:
print(f"🔍 Analyse de la structure des données Viparis: {url}")
response = requests.get(url, headers=headers, timeout=30)
response.raise_for_status()
soup = BeautifulSoup(response.text, 'html.parser')
# Chercher les scripts avec des données JSON
script_tags = soup.find_all('script')
for i, script in enumerate(script_tags):
if script.string and 'window.__NUXT__' in script.string:
print(f"\n📜 Script {i+1} trouvé avec window.__NUXT__")
script_content = script.string
# Extraire le JSON
match = re.search(r'window\.__NUXT__\s*=\s*({.*?});', script_content, re.DOTALL)
if match:
try:
nuxt_data = json.loads(match.group(1))
print("✅ JSON parsé avec succès")
# Analyser la structure
print(f"\n🔍 Structure de niveau 1:")
for key in nuxt_data.keys():
print(f" - {key}: {type(nuxt_data[key])}")
# Chercher les événements
if 'state' in nuxt_data:
state = nuxt_data['state']
print(f"\n🔍 Structure de state:")
for key in state.keys():
print(f" - {key}: {type(state[key])}")
# Chercher les événements dans différentes clés possibles
possible_event_keys = ['events', 'event', 'data', 'items', 'results']
for key in possible_event_keys:
if key in state:
events_data = state[key]
print(f"\n📅 Données d'événements trouvées dans '{key}':")
print(f" Type: {type(events_data)}")
if isinstance(events_data, list):
print(f" Nombre d'éléments: {len(events_data)}")
if events_data:
print(f" Premier élément: {json.dumps(events_data[0], indent=2)[:500]}...")
elif isinstance(events_data, dict):
print(f" Clés: {list(events_data.keys())}")
if 'data' in events_data:
data = events_data['data']
if isinstance(data, list):
print(f" Nombre d'événements dans data: {len(data)}")
if data:
print(f" Premier événement: {json.dumps(data[0], indent=2)[:500]}...")
# Chercher des patterns d'événements dans tout le JSON
print(f"\n🔍 Recherche de patterns d'événements...")
json_str = json.dumps(nuxt_data)
# Chercher des noms d'événements connus
event_names = ['BattleKart', 'Virtual Room', 'PRODURABLE', 'RÉÉDUCA', 'SALON']
for name in event_names:
if name in json_str:
print(f" ✅ Trouvé '{name}' dans les données")
# Chercher des dates
date_patterns = [r'\d{4}-\d{2}-\d{2}', r'\d{1,2}/\d{1,2}/\d{4}']
for pattern in date_patterns:
matches = re.findall(pattern, json_str)
if matches:
print(f" 📅 Dates trouvées ({pattern}): {matches[:5]}")
break
except json.JSONDecodeError as e:
print(f"❌ Erreur JSON: {e}")
continue
else:
print("❌ Pattern window.__NUXT__ non trouvé")
print("\n✅ Analyse terminée")
except Exception as e:
print(f"❌ Erreur: {e}")
if __name__ == "__main__":
analyze_viparis_data()

View file

@ -0,0 +1,152 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
import json
import os
import time
from dataclasses import dataclass
from typing import Any, Dict, List, Optional, Tuple
import requests
import logging as logger
@dataclass
class CacheConfig:
path: str
ttl_seconds: int = 24 * 3600
def load_cache(cfg: CacheConfig) -> Dict[str, Any]:
if not cfg.path or not os.path.exists(cfg.path):
return {}
try:
with open(cfg.path, 'r', encoding='utf-8') as f:
data = json.load(f)
if not isinstance(data, dict):
return {}
ts = data.get("__fetched_at__")
if cfg.ttl_seconds > 0 and isinstance(ts, (int, float)):
if time.time() - ts > cfg.ttl_seconds:
return {}
return data
except Exception:
return {}
def save_cache(cfg: CacheConfig, data: Dict[str, Any]) -> None:
if not cfg.path:
return
os.makedirs(os.path.dirname(cfg.path), exist_ok=True)
payload = dict(data)
payload["__fetched_at__"] = int(time.time())
with open(cfg.path, 'w', encoding='utf-8') as f:
json.dump(payload, f, ensure_ascii=False, indent=2)
def oedb_feature(label: str, what: str, start: str, stop: Optional[str] = None, description: str = "", where: str = "", online: Optional[bool] = None) -> Dict[str, Any]:
props: Dict[str, Any] = {
"name": label,
"what": what,
"start": start,
"description": description,
}
if stop:
props["stop"] = stop
if where:
props["where"] = where
if online is not None:
props["online"] = "yes" if online else "no"
logger.info(f"props: {json.dumps(props, ensure_ascii=False, indent=2)}")
return {
"type": "Feature",
"properties": props,
# Non localisé par défaut
"geometry": {"type": "Point", "coordinates": [0, 0]},
}
def post_oedb_features(base_url: str, features: List[Dict[str, Any]], dry_run: bool = True, timeout: int = 20, sent_cache_path: str = None) -> Tuple[int, int, int]:
ok = 0
failed = 0
neterr = 0
# Charger le cache des événements déjà envoyés
sent_events = set()
if sent_cache_path and os.path.exists(sent_cache_path):
try:
with open(sent_cache_path, 'r', encoding='utf-8') as f:
sent_events = set(json.load(f))
except:
pass
new_sent_events = set()
for feat in features:
if dry_run:
ok += 1
continue
# Générer un ID unique pour l'événement basé sur ses propriétés
event_id = generate_event_id(feat)
# Vérifier si l'événement a déjà été envoyé
if event_id in sent_events:
print(f"Événement déjà envoyé, ignoré: {feat.get('properties', {}).get('name', 'Sans nom')}")
continue
try:
r = requests.post(f"{base_url.rstrip('/')}/event", json=feat, timeout=timeout)
if 200 <= r.status_code < 300:
ok += 1
new_sent_events.add(event_id)
elif r.status_code == 409:
# Doublon - considérer comme déjà envoyé
print(f"Événement déjà existant (doublon), ignoré: {feat.get('properties', {}).get('name', 'Sans nom')}")
ok += 1
new_sent_events.add(event_id)
else:
print(f"Erreur HTTP {r.status_code}: {r.text}")
failed += 1
except requests.RequestException as e:
print(f"Erreur réseau: {e}")
neterr += 1
# Sauvegarder les nouveaux événements envoyés
if sent_cache_path and new_sent_events:
all_sent_events = sent_events | new_sent_events
try:
os.makedirs(os.path.dirname(sent_cache_path), exist_ok=True)
with open(sent_cache_path, 'w', encoding='utf-8') as f:
json.dump(list(all_sent_events), f, ensure_ascii=False, indent=2)
except Exception as e:
print(f"Erreur lors de la sauvegarde du cache: {e}")
return ok, failed, neterr
def generate_event_id(feature: Dict[str, Any]) -> str:
"""Génère un ID unique pour un événement basé sur ses propriétés."""
props = feature.get('properties', {})
# Utiliser les propriétés clés pour créer un ID unique
key_props = {
'name': props.get('name', ''),
'what': props.get('what', ''),
'start': props.get('start', ''),
'where': props.get('where', '')
}
# Créer un hash des propriétés clés
import hashlib
content = json.dumps(key_props, sort_keys=True, ensure_ascii=False)
return hashlib.md5(content.encode('utf-8')).hexdigest()
def http_get_json(url: str, timeout: int = 20, headers: Optional[Dict[str, str]] = None) -> Any:
r = requests.get(url, timeout=timeout, headers=headers)
r.raise_for_status()
ct = r.headers.get("content-type", "")
if "json" in ct:
return r.json()
return json.loads(r.text)

1760
extractors/viparis_events.py Normal file

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,213 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
Extracteur des journées mondiales/internationales.
Source: https://www.journee-mondiale.com/les-journees-mondiales.htm
Fonctionnalités:
- Cache JSON pour limiter les requêtes
- Paramètres CLI (base_url, dry-run, ttl cache)
- Conversion vers format OEDB (what par défaut: culture.arts)
- Une journée d'événement, positionnée à la prochaine occurrence dans les 365 jours à venir
- Rapport succès/échecs et impression du GeoJSON en dry-run
"""
import argparse
import datetime as dt
import re
import sys
from typing import Any, Dict, List, Tuple
from bs4 import BeautifulSoup
from utils_extractor_common import (
CacheConfig,
load_cache,
save_cache,
oedb_feature,
post_oedb_features,
http_get_json,
)
DEFAULT_CACHE = "extractors_cache/world_days_cache.json"
OEDB_DEFAULT = "https://api.openeventdatabase.org"
SOURCE_URL = "https://www.journee-mondiale.com/les-journees-mondiales.htm"
def build_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="Extracteur journées mondiales/internationales -> OEDB")
p.add_argument("--base-url", help="Base URL OEDB", default=OEDB_DEFAULT)
p.add_argument("--cache", help="Fichier de cache JSON", default=DEFAULT_CACHE)
p.add_argument("--cache-ttl", help="Durée de vie du cache (sec)", type=int, default=24*3600)
p.add_argument("--limit", help="Limiter le nombre d'événements à traiter", type=int, default=None)
# dry-run activé par défaut; passer --no-dry-run pour envoyer
p.add_argument("--no-dry-run", dest="dry_run", help="Désactive le dry-run (envoie à l'API)", action="store_false")
p.set_defaults(dry_run=True)
return p.parse_args()
MONTHS = {
"janvier": 1, "février": 2, "fevrier": 2, "mars": 3, "avril": 4, "mai": 5, "juin": 6,
"juillet": 7, "août": 8, "aout": 8, "septembre": 9, "octobre": 10, "novembre": 11, "décembre": 12, "decembre": 12
}
def parse_days_from_html(html: str) -> List[Tuple[int, int, str, str]]:
"""Parse les journées depuis le HTML en ciblant les ancres dans id=texte et class=content."""
days: List[Tuple[int, int, str, str]] = []
soup = BeautifulSoup(html, 'html.parser')
# Cibler spécifiquement les ancres dans id=texte et class=content
text_section = soup.find('div', id='texte')
if not text_section:
return days
content_section = text_section.find('div', class_='content')
if not content_section:
return days
# Chercher tous les articles (mois) dans cette section
articles = content_section.find_all('article')
for article in articles:
# Chercher toutes les ancres dans chaque article
links = article.find_all('a')
for link in links:
# Extraire le texte du lien et l'URL
text = link.get_text(strip=True)
url = link.get('href', '')
if not text:
continue
# Pattern pour capturer: "1er janvier" ou "15 janvier" + "Journée mondiale..."
pattern = re.compile(r"\b(\d{1,2}|1er)\s+([a-zA-Zéèêëàâîïôöùûüç]+)\s*:\s*(.+)")
match = pattern.search(text)
if match:
day_str = match.group(1).lower()
day = 1 if day_str == "1er" else int(re.sub(r"\D", "", day_str))
month_name = match.group(2).lower()
month = MONTHS.get(month_name)
label = match.group(3).strip()
if month is not None and label:
days.append((month, day, label, url))
return days
def fetch_sources(cache_cfg: CacheConfig) -> Dict[str, Any]:
cache = load_cache(cache_cfg)
if cache:
return cache
# Récupérer la page HTML
import requests
r = requests.get(SOURCE_URL, timeout=30)
r.raise_for_status()
html = r.text
# Parser le HTML pour extraire les journées
items = parse_days_from_html(html)
out: Dict[str, Any] = {"items": items}
save_cache(cache_cfg, out)
return out
def create_event_date(month: int, day: int, today: dt.date) -> dt.date:
"""Crée la date de l'événement pour l'année courante à partir d'aujourd'hui."""
year = today.year
try:
# Essayer de créer la date pour l'année courante
event_date = dt.date(year, month, day)
# Si la date est dans le passé, utiliser l'année suivante
if event_date < today:
event_date = dt.date(year + 1, month, day)
return event_date
except ValueError:
# Gérer les cas comme le 29 février dans une année non-bissextile
# Utiliser l'année suivante
try:
return dt.date(year + 1, month, day)
except ValueError:
# Si toujours impossible, utiliser le 28 février
return dt.date(year + 1, 2, 28)
def convert_to_oedb(data: Dict[str, Any], limit: int | None = None) -> List[Dict[str, Any]]:
features: List[Dict[str, Any]] = []
today = dt.date.today()
for (month, day, label, url) in data.get("items", []) or []:
try:
date_obj = create_event_date(month, day, today)
except Exception:
continue
date_iso = date_obj.isoformat()
# Déterminer la zone selon le titre
label_lower = label.lower()
if "mondial" in label_lower or "international" in label_lower:
zone = "world"
where = "Monde"
else:
zone = "france"
where = "France"
# Créer l'événement avec propriété zone
feature = oedb_feature(
label=label,
what="culture.days",
start=f"{date_iso}T00:00:00Z",
stop=f"{date_iso}T23:59:59Z",
description="Journée mondiale/internationale",
where=where,
online=True,
)
# Ajouter la propriété type requise par l'API OEDB
feature["properties"]["type"] = "scheduled"
# Ajouter la propriété zone
feature["properties"]["zone"] = zone
# Ajouter l'URL si disponible
if url:
feature["properties"]["url"] = url
features.append(feature)
# Appliquer la limite si définie
if limit and len(features) >= limit:
break
return features
def main() -> int:
args = build_args()
cache_cfg = CacheConfig(path=args.cache, ttl_seconds=args.cache_ttl)
src = fetch_sources(cache_cfg)
feats = convert_to_oedb(src, args.limit)
if args.dry_run:
# Imprimer le GeoJSON prêt à envoyer
collection = {"type": "FeatureCollection", "features": feats}
import json
print(json.dumps(collection, ensure_ascii=False, indent=2))
# Utiliser un cache pour éviter de renvoyer les événements déjà traités
sent_cache_path = "extractors_cache/world_days_sent.json"
ok, failed, neterr = post_oedb_features(args.base_url, feats, dry_run=args.dry_run, sent_cache_path=sent_cache_path)
print(json_report(ok, failed, neterr))
return 0
def json_report(ok: int, failed: int, neterr: int) -> str:
import json
return json.dumps({"success": ok, "failed": failed, "networkErrors": neterr}, indent=2)
if __name__ == "__main__":
sys.exit(main())

17
frontend/.editorconfig Normal file
View file

@ -0,0 +1,17 @@
# Editor configuration, see https://editorconfig.org
root = true
[*]
charset = utf-8
indent_style = space
indent_size = 2
insert_final_newline = true
trim_trailing_whitespace = true
[*.ts]
quote_type = single
ij_typescript_use_double_quotes = false
[*.md]
max_line_length = off
trim_trailing_whitespace = false

7
frontend/.env.example Normal file
View file

@ -0,0 +1,7 @@
DB_USER=cipherbliss
POSTGRES_PASSWORD=tralalahihou
CLIENT_ID=ziozioizo-sllkslk
CLIENT_SECRET=spposfdo-msmldflkds
CLIENT_AUTORIZATIONS=read_prefs
CLIENT_REDIRECT=https://oedb.cipherbliss.com/demo/traffic

42
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# See https://docs.github.com/get-started/getting-started-with-git/ignoring-files for more about ignoring files.
# Compiled output
/dist
/tmp
/out-tsc
/bazel-out
# Node
/node_modules
npm-debug.log
yarn-error.log
# IDEs and editors
.idea/
.project
.classpath
.c9/
*.launch
.settings/
*.sublime-workspace
# Visual Studio Code
.vscode/*
!.vscode/settings.json
!.vscode/tasks.json
!.vscode/launch.json
!.vscode/extensions.json
.history/*
# Miscellaneous
/.angular/cache
.sass-cache/
/connect.lock
/coverage
/libpeerconnection.log
testem.log
/typings
# System files
.DS_Store
Thumbs.db

View file

@ -0,0 +1,41 @@
# Configuration OAuth2 OpenStreetMap
## Variables d'environnement requises
Pour utiliser l'authentification OSM, vous devez configurer les variables suivantes :
### Frontend (environments/environment.ts)
```typescript
export const environment = {
production: false,
osmClientId: 'your_osm_client_id_here',
osmClientSecret: 'your_osm_client_secret_here', // Ne pas utiliser côté client
apiBaseUrl: 'http://localhost:5000'
};
```
### Backend (.env)
```bash
OSM_CLIENT_ID=your_osm_client_id_here
OSM_CLIENT_SECRET=your_osm_client_secret_here
API_BASE_URL=http://localhost:5000
```
## Configuration OSM
1. Allez sur https://www.openstreetmap.org/user/your_username/oauth_clients
2. Créez une nouvelle application OAuth
3. Configurez l'URL de redirection : `http://localhost:4200/oauth/callback`
4. Copiez le Client ID et Client Secret
## Fonctionnalités
- Connexion/déconnexion OSM
- Persistance des données utilisateur en localStorage
- Ajout automatique du pseudo OSM dans `last_modified_by` pour les nouveaux événements
- Interface utilisateur pour gérer l'authentification
## Sécurité
⚠️ **Important** : Le Client Secret ne doit jamais être exposé côté client.
L'échange du code d'autorisation contre un token d'accès doit se faire côté serveur.

59
frontend/README.md Normal file
View file

@ -0,0 +1,59 @@
# Frontend
This project was generated using [Angular CLI](https://github.com/angular/angular-cli) version 20.3.2.
## Development server
To start a local development server, run:
```bash
ng serve
```
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
## Code scaffolding
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
```bash
ng generate component component-name
```
For a complete list of available schematics (such as `components`, `directives`, or `pipes`), run:
```bash
ng generate --help
```
## Building
To build the project run:
```bash
ng build
```
This will compile your project and store the build artifacts in the `dist/` directory. By default, the production build optimizes your application for performance and speed.
## Running unit tests
To execute unit tests with the [Karma](https://karma-runner.github.io) test runner, use the following command:
```bash
ng test
```
## Running end-to-end tests
For end-to-end (e2e) testing, run:
```bash
ng e2e
```
Angular CLI does not come with an end-to-end testing framework by default. You can choose one that suits your needs.
## Additional Resources
For more information on using the Angular CLI, including detailed command references, visit the [Angular CLI Overview and Command Reference](https://angular.dev/tools/cli) page.

100
frontend/angular.json Normal file
View file

@ -0,0 +1,100 @@
{
"$schema": "./node_modules/@angular/cli/lib/config/schema.json",
"version": 1,
"newProjectRoot": "projects",
"projects": {
"frontend": {
"projectType": "application",
"schematics": {
"@schematics/angular:component": {
"style": "scss"
}
},
"root": "",
"sourceRoot": "src",
"prefix": "app",
"architect": {
"build": {
"builder": "@angular/build:application",
"options": {
"browser": "src/main.ts",
"polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"node_modules/angular-calendar/css/angular-calendar.css",
"src/styles.scss"
]
},
"configurations": {
"production": {
"budgets": [
{
"type": "initial",
"maximumWarning": "500kB",
"maximumError": "1MB"
},
{
"type": "anyComponentStyle",
"maximumWarning": "4kB",
"maximumError": "150kB"
}
],
"outputHashing": "all"
},
"development": {
"optimization": false,
"extractLicenses": false,
"sourceMap": true
}
},
"defaultConfiguration": "production"
},
"serve": {
"builder": "@angular/build:dev-server",
"configurations": {
"production": {
"buildTarget": "frontend:build:production"
},
"development": {
"buildTarget": "frontend:build:development"
}
},
"defaultConfiguration": "development"
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n"
},
"test": {
"builder": "@angular/build:karma",
"options": {
"polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json",
"inlineStyleLanguage": "scss",
"assets": [
{
"glob": "**/*",
"input": "public"
}
],
"styles": [
"node_modules/angular-calendar/css/angular-calendar.css",
"src/styles.scss"
]
}
}
}
}
}
}

9767
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load diff

53
frontend/package.json Normal file
View file

@ -0,0 +1,53 @@
{
"name": "frontend",
"version": "0.0.0",
"scripts": {
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"watch": "ng build --watch --configuration development",
"test": "ng test"
},
"prettier": {
"printWidth": 100,
"singleQuote": true,
"overrides": [
{
"files": "*.html",
"options": {
"parser": "angular"
}
}
]
},
"private": true,
"dependencies": {
"@angular/common": "^20.3.0",
"@angular/compiler": "^20.3.0",
"@angular/core": "^20.3.0",
"@angular/forms": "^20.3.0",
"@angular/platform-browser": "^20.3.0",
"@angular/router": "^20.3.0",
"angular-calendar": "^0.32.0",
"angular-draggable-droppable": "^9.0.1",
"angular-resizable-element": "^8.0.0",
"date-fns": "^4.1.0",
"moment": "^2.0.0",
"rxjs": "~7.8.0",
"tslib": "^2.3.0",
"zone.js": "~0.15.0"
},
"devDependencies": {
"@angular/build": "^20.3.2",
"@angular/cli": "^20.3.2",
"@angular/compiler-cli": "^20.3.0",
"@types/jasmine": "~5.1.0",
"jasmine-core": "~5.9.0",
"karma": "~6.4.0",
"karma-chrome-launcher": "~3.2.0",
"karma-coverage": "~2.2.0",
"karma-jasmine": "~5.1.0",
"karma-jasmine-html-reporter": "~2.1.0",
"typescript": "~5.9.2"
}
}

367
frontend/public/embed.js Normal file
View file

@ -0,0 +1,367 @@
/**
* OEDB Embed Script
* Script d'intégration pour afficher les événements OEDB sur des sites externes
*/
(function() {
'use strict';
// Configuration par défaut
const defaultConfig = {
apiUrl: 'https://api.openenventdatabase.org',
theme: 'light',
limit: 50,
width: '100%',
height: '400px',
showMap: true,
showList: true,
autoRefresh: false,
refreshInterval: 300000 // 5 minutes
};
// Thèmes CSS
const themes = {
light: {
background: '#ffffff',
text: '#2c3e50',
border: '#ecf0f1',
primary: '#3498db',
secondary: '#95a5a6'
},
dark: {
background: '#2c3e50',
text: '#ecf0f1',
border: '#34495e',
primary: '#3498db',
secondary: '#7f8c8d'
}
};
class OEDBEmbed {
constructor(container, config) {
this.container = typeof container === 'string' ? document.querySelector(container) : container;
this.config = { ...defaultConfig, ...config };
this.events = [];
this.isLoading = false;
this.refreshTimer = null;
if (!this.container) {
console.error('OEDB Embed: Container not found');
return;
}
this.init();
}
init() {
this.injectStyles();
this.render();
this.loadEvents();
if (this.config.autoRefresh) {
this.startAutoRefresh();
}
}
injectStyles() {
if (document.getElementById('oedb-embed-styles')) return;
const theme = themes[this.config.theme] || themes.light;
const styles = `
<style id="oedb-embed-styles">
.oedb-embed {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: ${theme.background};
color: ${theme.text};
border: 1px solid ${theme.border};
border-radius: 8px;
overflow: hidden;
width: ${this.config.width};
height: ${this.config.height};
display: flex;
flex-direction: column;
}
.oedb-embed-header {
background: ${theme.primary};
color: white;
padding: 1rem;
text-align: center;
font-weight: 600;
}
.oedb-embed-content {
flex: 1;
display: flex;
overflow: hidden;
}
.oedb-embed-map {
flex: 1;
min-height: 200px;
background: #f8f9fa;
position: relative;
}
.oedb-embed-list {
width: 300px;
overflow-y: auto;
border-left: 1px solid ${theme.border};
background: ${theme.background};
}
.oedb-embed-list-only {
width: 100%;
}
.oedb-embed-map-only {
width: 100%;
}
.oedb-event-item {
padding: 1rem;
border-bottom: 1px solid ${theme.border};
cursor: pointer;
transition: background-color 0.2s;
}
.oedb-event-item:hover {
background: ${theme.border};
}
.oedb-event-title {
font-weight: 600;
margin-bottom: 0.5rem;
color: ${theme.text};
}
.oedb-event-meta {
font-size: 0.9rem;
color: ${theme.secondary};
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.oedb-loading {
display: flex;
align-items: center;
justify-content: center;
height: 200px;
color: ${theme.secondary};
}
.oedb-error {
padding: 2rem;
text-align: center;
color: #e74c3c;
background: #fdf2f2;
}
.oedb-no-events {
padding: 2rem;
text-align: center;
color: ${theme.secondary};
}
.oedb-map-placeholder {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
background: #f8f9fa;
color: ${theme.secondary};
font-style: italic;
}
</style>
`;
document.head.insertAdjacentHTML('beforeend', styles);
}
render() {
this.container.innerHTML = `
<div class="oedb-embed">
<div class="oedb-embed-header">
Événements OEDB
</div>
<div class="oedb-embed-content">
${this.config.showMap ? '<div class="oedb-embed-map" id="oedb-map"></div>' : ''}
${this.config.showList ? `<div class="oedb-embed-list ${!this.config.showMap ? 'oedb-embed-list-only' : ''}" id="oedb-list"></div>` : ''}
</div>
</div>
`;
}
async loadEvents() {
if (this.isLoading) return;
this.isLoading = true;
this.showLoading();
try {
const params = new URLSearchParams();
if (this.config.what) params.set('what', this.config.what);
if (this.config.start) params.set('start', this.config.start);
if (this.config.end) params.set('end', this.config.end);
if (this.config.limit) params.set('limit', this.config.limit.toString());
if (this.config.bbox) params.set('bbox', this.config.bbox);
const response = await fetch(`${this.config.apiUrl}/events?${params.toString()}`);
const data = await response.json();
this.events = data.features || [];
this.renderEvents();
this.renderMap();
} catch (error) {
console.error('OEDB Embed: Error loading events', error);
this.showError('Erreur lors du chargement des événements');
} finally {
this.isLoading = false;
}
}
showLoading() {
const listContainer = document.getElementById('oedb-list');
if (listContainer) {
listContainer.innerHTML = '<div class="oedb-loading">Chargement des événements...</div>';
}
}
showError(message) {
const listContainer = document.getElementById('oedb-list');
if (listContainer) {
listContainer.innerHTML = `<div class="oedb-error">${message}</div>`;
}
}
renderEvents() {
const listContainer = document.getElementById('oedb-list');
if (!listContainer) return;
if (this.events.length === 0) {
listContainer.innerHTML = '<div class="oedb-no-events">Aucun événement trouvé</div>';
return;
}
const eventsHtml = this.events.map(event => {
const title = event.properties?.label || event.properties?.name || 'Événement sans nom';
const date = event.properties?.start || event.properties?.when || '';
const location = event.properties?.where || '';
const type = event.properties?.what || '';
return `
<div class="oedb-event-item" data-event-id="${event.id || ''}">
<div class="oedb-event-title">${this.escapeHtml(title)}</div>
<div class="oedb-event-meta">
${date ? `<div>📅 ${this.formatDate(date)}</div>` : ''}
${location ? `<div>📍 ${this.escapeHtml(location)}</div>` : ''}
${type ? `<div>🏷️ ${this.escapeHtml(type)}</div>` : ''}
</div>
</div>
`;
}).join('');
listContainer.innerHTML = eventsHtml;
// Ajouter les événements de clic
listContainer.querySelectorAll('.oedb-event-item').forEach(item => {
item.addEventListener('click', () => {
const eventId = item.dataset.eventId;
this.onEventClick(eventId);
});
});
}
renderMap() {
const mapContainer = document.getElementById('oedb-map');
if (!mapContainer) return;
if (this.events.length === 0) {
mapContainer.innerHTML = '<div class="oedb-map-placeholder">Aucun événement à afficher sur la carte</div>';
return;
}
// Pour l'instant, afficher un placeholder
// Dans une vraie implémentation, on utiliserait Leaflet ou une autre librairie de cartes
mapContainer.innerHTML = `
<div class="oedb-map-placeholder">
Carte interactive<br>
${this.events.length} événement(s) trouvé(s)
</div>
`;
}
onEventClick(eventId) {
// Émettre un événement personnalisé
const event = new CustomEvent('oedb-event-click', {
detail: { eventId, event: this.events.find(e => e.id === eventId) }
});
this.container.dispatchEvent(event);
}
startAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
}
this.refreshTimer = setInterval(() => {
this.loadEvents();
}, this.config.refreshInterval);
}
stopAutoRefresh() {
if (this.refreshTimer) {
clearInterval(this.refreshTimer);
this.refreshTimer = null;
}
}
destroy() {
this.stopAutoRefresh();
this.container.innerHTML = '';
}
// Utilitaires
escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
formatDate(dateString) {
try {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateString;
}
}
}
// API publique
window.OEDBEmbed = {
init: function(config) {
return new OEDBEmbed(config.container, config);
}
};
// Auto-initialisation si des éléments avec data-oedb-embed sont présents
document.addEventListener('DOMContentLoaded', function() {
const embedElements = document.querySelectorAll('[data-oedb-embed]');
embedElements.forEach(element => {
const config = {
container: element,
...JSON.parse(element.dataset.oedbEmbed || '{}')
};
new OEDBEmbed(config.container, config);
});
});
})();

BIN
frontend/public/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 171 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 29 KiB

View file

@ -0,0 +1,20 @@
import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideZoneChangeDetection } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideRouter } from '@angular/router';
import * as moment from 'moment';
import 'moment/locale/fr';
import { routes } from './app.routes';
// Configuration du locale français pour moment
moment.locale('fr');
export const appConfig: ApplicationConfig = {
providers: [
provideBrowserGlobalErrorListeners(),
provideZoneChangeDetection({ eventCoalescing: true }),
provideRouter(routes),
provideHttpClient(),
{ provide: 'moment', useValue: moment }
]
};

42
frontend/src/app/app.html Normal file
View file

@ -0,0 +1,42 @@
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * * * The content below * * * * * * * * * * * -->
<!-- * * * * * * * * * * is only a placeholder * * * * * * * * * * -->
<!-- * * * * * * * * * * and can be replaced. * * * * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<!-- * * * * * * * * * Delete the template below * * * * * * * * * -->
<!-- * * * * * * * to get started with your project! * * * * * * * -->
<!-- * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * * -->
<style>
</style>
<main class="main">
<div class="content">
<header>
<nav>
<a [routerLink]="['/']" routerLinkActive="active"><h1>
<img src="/static/oedb.png" alt="OEDB" style="width: 20px; height: 20px;">
OEDB
</h1>
</a>
<!-- <button class="login">login OSM</button> -->
<a routerLink="/agenda" routerLinkActive="active">agenda</a>
<a routerLink="/unlocated-events" routerLinkActive="active">événements non localisés</a>
<a routerLink="/batch-edit" routerLinkActive="active">batch edit</a>
<a routerLink="/events-docs" routerLinkActive="active">events docs</a>
<a routerLink="/research" routerLinkActive="active">research</a>
<a routerLink="/nouvelles-categories" routerLinkActive="active">nouvelles catégories</a>
<a href="/demo/stats" routerLinkActive="active">stats</a>
<a href="https://source.cipherbliss.com/tykayn/oedb-backend" routerLinkActive="active">sources</a>
</nav>
</header>
<main>
<router-outlet/>
</main>
</div>
</main>

View file

@ -0,0 +1,49 @@
import { Routes } from '@angular/router';
import {Home} from './pages/home/home';
import { Agenda } from './pages/agenda/agenda';
import { Research } from './pages/research/research';
import { Embed } from './pages/embed/embed';
import { BatchEdit } from './pages/batch-edit/batch-edit';
import { NouvellesCategories } from './pages/nouvelles-categories/nouvelles-categories';
import { UnlocatedEventsPage } from './pages/unlocated-events/unlocated-events';
import { CommunityUpcoming } from './pages/community-upcoming/community-upcoming';
import { EventsDocs } from './pages/events-docs/events-docs';
export const routes: Routes = [
{
path : '',
component: Home
},
{
path: 'community-upcoming',
component: CommunityUpcoming
},
{
path: 'events-docs',
component: EventsDocs
},
{
path : 'agenda',
component: Agenda
},
{
path: 'research',
component: Research
},
{
path: 'embed',
component: Embed
},
{
path: 'batch-edit',
component: BatchEdit
},
{
path : 'nouvelles-categories',
component: NouvellesCategories
},
{
path : 'unlocated-events',
component: UnlocatedEventsPage
}
];

37
frontend/src/app/app.scss Normal file
View file

@ -0,0 +1,37 @@
:host{
.container{
height: 100%;
overflow-y: auto;
}
}
/* CSS global pour permettre le scroll sur toutes les pages */
html, body {
height: 100%;
overflow-x: hidden;
}
body {
margin: 0;
padding: 0;
}
a{
cursor: pointer;
padding: 10px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
display: inline-block;
margin-bottom: 10px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
text-decoration: none;
color: #000;
&.active{
background-color: #d4ebff;
border-color: #007bff;
color: #007bff;
}
}

View file

@ -0,0 +1,23 @@
import { TestBed } from '@angular/core/testing';
import { App } from './app';
describe('App', () => {
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [App],
}).compileComponents();
});
it('should create the app', () => {
const fixture = TestBed.createComponent(App);
const app = fixture.componentInstance;
expect(app).toBeTruthy();
});
it('should render title', () => {
const fixture = TestBed.createComponent(App);
fixture.detectChanges();
const compiled = fixture.nativeElement as HTMLElement;
expect(compiled.querySelector('h1')?.textContent).toContain('Hello, frontend');
});
});

25
frontend/src/app/app.ts Normal file
View file

@ -0,0 +1,25 @@
import { Component, signal } from '@angular/core';
import { RouterOutlet, RouterLink } from '@angular/router';
import { CalendarPreviousViewDirective, CalendarTodayDirective, CalendarNextViewDirective, CalendarMonthViewComponent, CalendarWeekViewComponent, CalendarDayViewComponent, CalendarDatePipe, DateAdapter, provideCalendar } from 'angular-calendar';
import { adapterFactory } from 'angular-calendar/date-adapters/moment';
import * as moment from 'moment';
export function momentAdapterFactory() {
return adapterFactory(moment);
};
@Component({
selector: 'app-root',
imports: [RouterOutlet, RouterLink, CalendarPreviousViewDirective, CalendarTodayDirective, CalendarNextViewDirective, CalendarMonthViewComponent, CalendarWeekViewComponent, CalendarDayViewComponent, CalendarDatePipe],
templateUrl: './app.html',
styleUrl: './app.scss',
providers: [
provideCalendar({
provide: DateAdapter,
useFactory: momentAdapterFactory,
}),
],
})
export class App {
protected readonly title = signal('frontend');
}

View file

@ -0,0 +1,143 @@
<form (ngSubmit)="onSubmit()" [formGroup]="form">
<div class="row">
<label>What</label>
<input class="input" type="text" formControlName="what" placeholder="ex: traffic.roadwork" (keyup)="onWhatKeyup()" />
<div class="presets under-field">
<h4>
Presets
<span class="muted" style="cursor:pointer;" title="Réinitialiser le filtre"
(click)="resetPresetFilter()">({{filteredPresetCount()}})</span>
</h4>
<div class="preset-groups">
@for (g of filteredGroups(); track g.category) {
<div class="group">
<div class="group-title">{{g.category}}</div>
<div class="preset-list">
@for (p of g.items; track p.key) {
<button type="button" (click)="applyPreset(p.key)" title="{{p.key}}">
<span class="emoji">{{p.emoji}}</span>
<span class="label">{{p.label}}</span>
</button>
}
</div>
</div>
}
</div>
</div>
</div>
<div class="row">
<label>Label</label>
<input class="input" type="text" formControlName="label" />
</div>
<div class="row">
<label>Description</label>
<textarea class="input" formControlName="description"></textarea>
</div>
<div class="row">
<label>Début</label>
<input class="input" type="datetime-local" formControlName="start" />
</div>
<div class="row">
<label>Fin</label>
<input class="input" type="datetime-local" formControlName="stop" />
<div class="muted">Durée: {{durationHuman()}}</div>
</div>
<!-- Propriétés dynamiques selon le preset sélectionné -->
@if (currentPreset(); as cp) {
<div class="row">
<div class="preset-properties">
@for (entry of presetEntries(); track entry.key) {
<div class="prop-row">
<label>{{entry.spec?.label || entry.key}}</label>
@if (entry.spec?.values; as vs) {
<select class="input" [disabled]="entry.spec?.writable === false" [formControlName]="entry.key">
@for (v of vs; track v) {
<option [value]="v">{{v}}</option>
}
</select>
} @else {
<input class="input" type="text" [disabled]="entry.spec?.writable === false" [formControlName]="entry.key" />
}
</div>
}
</div>
</div>
}
<!-- Propriétés supplémentaires détectées dans l'évènement sélectionné -->
@if (extraPropertyKeys().length > 0) {
<div class="row">
<div class="preset-properties">
@for (key of extraPropertyKeys(); track key) {
<div class="prop-row">
<label>{{key}}</label>
<input class="input" type="text" [formControlName]="key" />
</div>
}
</div>
</div>
}
<pre>
{{currentPreset() | json}}
</pre>
<div class="row">
<label>Type</label>
<select class="input" formControlName="type" >
<option value="unscheduled">Unscheduled</option>
<option value="scheduled">Scheduled</option>
<option value="forecast">Forecast</option>
</select>
</div>
<div class="row">
<label>Where</label>
<input class="input" type="text" formControlName="where" />
</div>
<div class="row">
<label>
<input type="checkbox" formControlName="noLocation" /> Évènement en ligne / sans lieu (accepte sans coordonnées)
</label>
</div>
<div class="row">
<label>Wikidata</label>
<input class="input" type="text" formControlName="wikidata" />
</div>
<div class="row">
<label>Latitude</label>
<input class="input" type="number" step="any" formControlName="lat" />
</div>
<div class="row">
<label>Longitude</label>
<input class="input" type="number" step="any" formControlName="lon" />
</div>
<div class="actions">
@if (featureId()) {
<button class="btn btn-ghost btn-danger" type="button" (click)="onDelete()">Supprimer</button>
}
<button class="btn btn-ghost" type="button" (click)="onCancelEdit()">Quitter lédition</button>
<button class="btn btn-primary" type="submit">{{ featureId() ? 'Mettre à jour' : 'Valider' }}</button>
</div>
@if (status().state !== 'idle') {
<div class="toast-container">
<div class="toast" [class.is-info]="status().state==='saving'" [class.is-success]="status().state==='saved'" [class.is-error]="status().state==='error'">
@if (status().state === 'saving') {
<div>{{status().message}}</div>
} @else if (status().state === 'saved') {
<div>
{{status().message}}.
<a [href]="'/demo/by-what?what=' + (status().what || form.value.what)" target="_blank">Voir d'autres évènements de ce type</a>
</div>
} @else if (status().state === 'error') {
<div>{{status().message}}</div>
}
</div>
</div>
}
</form>

View file

@ -0,0 +1,53 @@
form {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.row {
display: grid;
gap: 6px;
}
.presets {
background: rgba(159, 211, 246, 0.2);
border: 1px dashed rgba(0,0,0,0.08);
border-radius: 10px;
padding: 10px;
}
.presets.under-field { margin-top: 8px; }
.preset-list {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 8px;
}
.preset-list button {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 10px;
border: 1px solid rgba(0,0,0,0.08);
background: #fff;
cursor: pointer;
&:hover{
background-color: #7fa8d6;
color: #fff;
}
}
.actions {
display: flex;
gap: 8px;
}
.preset-groups { display: grid; gap: 10px; }
.group-title { font-weight: 700; opacity: 0.8; margin-bottom: 6px; }
.group { background: #fff; border: 1px solid rgba(0,0,0,0.06); border-radius: 10px; padding: 8px; }

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { EditForm } from './edit-form';
describe('EditForm', () => {
let component: EditForm;
let fixture: ComponentFixture<EditForm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [EditForm]
})
.compileComponents();
fixture = TestBed.createComponent(EditForm);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,428 @@
import { Component, EventEmitter, Input, Output, OnChanges, SimpleChanges, computed, effect, signal } from '@angular/core';
import { FormBuilder, FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
import oedb from '../../../oedb-types';
import { OedbApi } from '../../services/oedb-api';
import { JsonPipe } from '@angular/common';
@Component({
selector: 'app-edit-form',
standalone: true,
imports: [ReactiveFormsModule, JsonPipe],
templateUrl: './edit-form.html',
styleUrl: './edit-form.scss'
})
export class EditForm implements OnChanges {
@Input() selected: any | null = null;
@Output() saved = new EventEmitter<any>();
@Output() created = new EventEmitter<any>();
@Output() deleted = new EventEmitter<any>();
@Output() canceled = new EventEmitter<void>();
form: FormGroup;
allPresets: Array<{ key: string, label: string, emoji: string, category: string, description?: string, durationHours?: number, properties?: Record<string, { label?: string, writable?: boolean, values?: any[], default?: any, allow_custom?: boolean, allow_empty?: boolean }> }>;
filteredGroups = computed(() => this.groupPresets(this.form.get('what')?.value || ''));
currentPreset = computed(() => {
const key = this.form.get('what')?.value || '';
return (oedb.presets.what as any)[key] || null;
});
presetEntries = computed(() => {
const p = this.currentPreset();
const props = (p && p.properties) ? p.properties as Record<string, any> : {};
return Object.keys(props).map(k => ({ key: k, spec: props[k] }));
});
presetValues = signal<Record<string, any>>({});
extraPropertyKeys = signal<string[]>([]);
private readonly defaultFormKeys = new Set([
'label','description','what','where','lat','lon','noLocation','wikidata','featureType','type','start','stop'
]);
status = signal<{ state: 'idle' | 'saving' | 'saved' | 'error', message?: string, what?: string }>({ state: 'idle' });
featureId = signal<string | number | null>(null);
durationHuman = signal<string>('');
constructor(private fb: FormBuilder, private api: OedbApi) {
this.form = this.fb.group({
label: ['', Validators.required],
description: [''],
what: ['', Validators.required],
where: [''],
lat: ['', Validators.required],
lon: ['', Validators.required],
noLocation: [false],
wikidata: [''],
featureType: ['point'],
type: ['unscheduled'],
start: [''],
stop: ['']
});
const what = oedb.presets.what as Record<string, any>;
this.allPresets = Object.keys(what).map(k => ({
key: k,
label: what[k].label || k,
emoji: what[k].emoji || '',
category: what[k].category || 'Autres',
description: what[k].description || ''
}));
// initialize default 24h window
const now = new Date();
const in24h = new Date(now.getTime() + 24 * 3600 * 1000);
this.form.patchValue({
start: this.toLocalInputValue(now),
stop: this.toLocalInputValue(in24h)
}, { emitEvent: false });
// initial fill if provided on first render
this.fillFormFromSelected();
// watch start/stop changes to update human duration
this.form.valueChanges.subscribe(v => {
const startIso = this.toIsoFromLocalInput(v.start);
const stopIso = this.toIsoFromLocalInput(v.stop);
if (startIso && stopIso) this.durationHuman.set(this.humanDuration(startIso, stopIso));
});
}
ngOnChanges(changes: SimpleChanges): void {
if (changes['selected']) {
this.fillFormFromSelected();
}
}
private fillFormFromSelected() {
const sel = this.selected;
if (sel && sel.properties) {
const propId = sel?.properties?.id ?? sel?.properties?.uuid;
this.featureId.set((propId ?? sel.id) ?? null);
const p = sel.properties || {};
const coords = sel?.geometry?.coordinates || [];
this.form.patchValue({
label: p.label || p.name || '',
id: p.id || '',
description: p.description || '',
what: p.what || '',
"what:series": p['what:series'] || '',
where: p.where || '',
lat: coords[1] ?? '',
lon: coords[0] ?? '',
wikidata: p.wikidata || '',
featureType: 'point',
type: p.type || this.form.value.type || 'unscheduled',
start: this.toLocalInputValue(p.start || p.when || new Date()),
stop: this.toLocalInputValue(p.stop || new Date(new Date().getTime() + 24 * 3600 * 1000))
}, { emitEvent: false });
// Ajouter des contrôles pour les propriétés du preset courant
const current = this.currentPreset();
const presetKeys = new Set<string>(
current && current.properties ? Object.keys(current.properties) : []
);
presetKeys.forEach(key => this.ensureControl(key, p[key] ?? current?.properties?.[key]?.default ?? ''));
// Ajouter des contrôles pour les autres propriétés présentes dans l'événement sélectionné
const extra: string[] = [];
Object.keys(p).forEach((key) => {
if (this.defaultFormKeys.has(key)) return;
if (presetKeys.has(key)) return;
this.ensureControl(key, p[key]);
extra.push(key);
});
this.extraPropertyKeys.set(extra);
}
}
applyPreset(key: string) {
const what = oedb.presets.what as Record<string, any>;
const preset = what[key];
if (!preset) return;
this.form.patchValue({
what: key,
label: preset.label || this.form.value.label,
description: preset.description || this.form.value.description
});
// Créer/mettre à jour les contrôles dynamiques pour les propriétés du preset
const props = preset.properties || {};
Object.keys(props).forEach(k => {
const initial = Object.prototype.hasOwnProperty.call(props[k], 'default') ? props[k].default : '';
this.ensureControl(k, (this.selected?.properties?.[k] ?? initial));
});
// adjust stop based on preset duration
const startIso = this.toIsoFromLocalInput(this.form.value.start);
if (typeof preset.durationHours === 'number' && startIso) {
const start = new Date(startIso);
const stop = new Date(start.getTime() + preset.durationHours * 3600 * 1000);
this.form.patchValue({ stop: this.toLocalInputValue(stop) }, { emitEvent: true });
}
// Recalculer les extra properties (non définies par le preset)
const presetKeys = new Set<string>(Object.keys(props));
const currentProps = this.selected?.properties || {};
const extra: string[] = [];
Object.keys(currentProps).forEach((k) => {
if (this.defaultFormKeys.has(k)) return;
if (presetKeys.has(k)) return;
this.ensureControl(k, currentProps[k]);
extra.push(k);
});
this.extraPropertyKeys.set(extra);
}
private groupPresets(query: string): Array<{ category: string, items: Array<{ key: string, label: string, emoji: string }> }> {
const q = String(query || '').trim().toLowerCase();
const matches = (p: typeof this.allPresets[number]) => {
if (!q) return true;
return (
p.key.toLowerCase().includes(q) ||
(p.label || '').toLowerCase().includes(q) ||
(p.description || '').toLowerCase().includes(q) ||
(p.category || '').toLowerCase().includes(q)
);
};
const grouped: Record<string, Array<{ key: string, label: string, emoji: string }>> = {};
for (const p of this.allPresets) {
if (!matches(p)) continue;
const cat = p.category || 'Autres';
if (!grouped[cat]) grouped[cat] = [];
grouped[cat].push({ key: p.key, label: p.label, emoji: p.emoji });
}
return Object.keys(grouped)
.sort((a, b) => a.localeCompare(b))
.map(cat => ({ category: cat, items: grouped[cat].sort((a, b) => a.label.localeCompare(b.label)) }));
}
onSubmit() {
const val = this.form.value as any;
// Validation minimale: what obligatoire; coordonnées lat/lon obligatoires si non en ligne/sans lieu
if (!val.what || String(val.what).trim() === '') {
this.status.set({ state: 'error', what: val.what, message: 'Le champ "what" est requis' });
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
return;
}
const isNoLocation = !!val.noLocation;
const numLat = Number(val.lat);
const numLon = Number(val.lon);
const haveCoords = Number.isFinite(numLat) && Number.isFinite(numLon);
if (!isNoLocation && !haveCoords) {
this.status.set({ state: 'error', what: val.what, message: 'Latitude et longitude sont requises' });
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
return;
}
// Construire la géométrie selon le mode
let geometry: any;
if (isNoLocation) {
// Polygone englobant le monde (bbox globale)
geometry = {
type: 'Polygon',
coordinates: [[
[-180, -90],
[-180, 90],
[180, 90],
[180, -90],
[-180, -90]
]]
};
} else {
geometry = {
type: 'Point',
coordinates: [Number(val.lon), Number(val.lat)]
};
}
const feature: any = {
type: 'Feature',
properties: {
label: val.label,
description: val.description,
what: val.what,
where: val.where,
wikidata: val.wikidata,
type: val.type,
start: this.toIsoFromLocalInput(val.start),
stop: this.toIsoFromLocalInput(val.stop),
...(isNoLocation ? { no_location: true } : {})
},
geometry
};
// Apply default duration from preset when creating a new event
const durationPreset = (oedb.presets.what as any)[val.what];
if ((!this.featureId()) && durationPreset && typeof durationPreset.durationHours === 'number') {
// already set from form; ensure consistency if empty
if (!feature.properties.start || !feature.properties.stop) {
const start = new Date();
const stop = new Date(start.getTime() + durationPreset.durationHours * 3600 * 1000);
feature.properties.start = start.toISOString();
feature.properties.stop = stop.toISOString();
}
}
const id = this.featureId();
// Ajouter les propriétés issues du preset (contrôles dynamiques)
const submitPreset = (oedb.presets.what as any)[val.what];
if (submitPreset && submitPreset.properties) {
Object.keys(submitPreset.properties).forEach((k: string) => {
if (this.form.contains(k)) {
feature.properties[k] = this.form.get(k)?.value;
}
});
}
// Ajouter les propriétés extra (non définies par le preset)
for (const k of this.extraPropertyKeys()) {
if (this.form.contains(k)) {
feature.properties[k] = this.form.get(k)?.value;
}
}
this.status.set({ state: 'saving', what: val.what, message: 'Envoi en cours…' });
if (id !== null && id !== undefined && id !== '') {
this.api.updateEvent(id, feature).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: val.what, message: 'Évènement mis à jour' });
this.saved.emit(res);
// Quitter l'édition après succès
this.canceled.emit();
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
},
error: (err) => {
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la mise à jour' });
console.error(err);
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
}
});
} else {
this.api.createEvent(feature).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: val.what, message: 'Évènement créé' });
this.created.emit(res);
// Quitter l'édition après succès
this.canceled.emit();
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
},
error: (err) => {
this.status.set({ state: 'error', what: val.what, message: 'Erreur lors de la création' });
console.error(err);
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
}
});
}
}
onPresetValueChange(key: string, value: any) {
// Conservé pour compat, plus utilisé avec formControlName
if (this.form.contains(key)) this.form.get(key)?.setValue(value);
}
onDelete() {
const id = this.featureId();
if (id === null || id === undefined || id === '') return;
this.status.set({ state: 'saving', what: this.form.value.what, message: 'Suppression…' });
this.api.deleteEvent(id).subscribe({
next: (res) => {
this.status.set({ state: 'saved', what: this.form.value.what, message: 'Évènement supprimé' });
this.deleted.emit(res);
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
},
error: (err) => {
this.status.set({ state: 'error', what: this.form.value.what, message: 'Erreur lors de la suppression' });
console.error(err);
setTimeout(() => this.status.set({ state: 'idle' }), 3000);
}
});
}
onWhatKeyup() {
// Le filtrage se base déjà sur filteredGroups() qui lit form.what,
// donc un keyup déclenche le recalcul via Angular forms.
// On garde cette méthode pour des actions annexes si besoin.
}
filteredPresetCount() {
try {
const groups = this.filteredGroups();
return groups.reduce((acc, g) => acc + (g.items?.length || 0), 0);
} catch {
return 0;
}
}
resetPresetFilter() {
// Réinitialise le champ what pour afficher tous les presets
this.form.patchValue({ what: '' });
}
onCancelEdit() {
this.selected = null;
this.featureId.set(null);
this.form.reset({
label: '',
description: '',
what: '',
where: '',
lat: '',
lon: '',
wikidata: '',
featureType: 'point',
type: 'unscheduled',
start: this.toLocalInputValue(new Date()),
stop: this.toLocalInputValue(new Date(new Date().getTime() + 24 * 3600 * 1000))
});
this.presetValues.set({});
this.status.set({ state: 'idle' });
this.canceled.emit();
}
private toLocalInputValue(d: string | Date): string {
const date = (typeof d === 'string') ? new Date(d) : d;
if (Number.isNaN(date.getTime())) return '';
const pad = (n: number) => n.toString().padStart(2, '0');
const y = date.getFullYear();
const m = pad(date.getMonth() + 1);
const da = pad(date.getDate());
const h = pad(date.getHours());
const mi = pad(date.getMinutes());
return `${y}-${m}-${da}T${h}:${mi}`;
}
private toIsoFromLocalInput(s?: string): string | null {
if (!s) return null;
// Treat input as local time and convert to ISO
const m = /^([0-9]{4})-([0-9]{2})-([0-9]{2})T([0-9]{2}):([0-9]{2})$/.exec(s);
if (!m) return null;
const date = new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]), Number(m[4]), Number(m[5]), 0, 0);
return date.toISOString();
}
private ensureControl(key: string, initial: any) {
if (!this.form.contains(key)) {
this.form.addControl(key, new FormControl(initial));
} else {
// Mettre à jour sans émettre pour éviter des boucles
this.form.get(key)?.setValue(initial, { emitEvent: false });
}
}
private humanDuration(startIso: string, stopIso: string): string {
const a = new Date(startIso).getTime();
const b = new Date(stopIso).getTime();
if (!Number.isFinite(a) || !Number.isFinite(b) || b <= a) return '';
const ms = b - a;
const hours = Math.floor(ms / 3600000);
const days = Math.floor(hours / 24);
const h = hours % 24;
if (days > 0 && h > 0) return `${days} j ${h} h`;
if (days > 0) return `${days} j`;
return `${h} h`;
}
}

View file

@ -0,0 +1,35 @@
<div class="osm-auth">
@if (isAuthenticated) {
<div class="user-info">
<div class="user-avatar">
@if (currentUser?.img?.href) {
<img [src]="currentUser?.img?.href" [alt]="currentUser?.display_name || 'Utilisateur OSM'" class="avatar">
} @else {
<div class="avatar-placeholder">👤</div>
}
</div>
<div class="user-details">
<div class="username">{{getUsername()}}</div>
<div class="user-stats">
<span class="stat">{{currentUser?.changesets?.count || 0}} changesets</span>
</div>
</div>
<button class="btn btn-sm btn-outline" (click)="logout()">
Déconnexion
</button>
</div>
} @else {
<div class="login-prompt">
<div class="login-text">
<p>Connectez-vous à votre compte OpenStreetMap pour :</p>
<ul>
<li>Ajouter automatiquement votre pseudo aux événements créés</li>
<li>Bénéficier de fonctionnalités avancées</li>
</ul>
</div>
<button class="btn btn-primary" (click)="login()">
🗺️ Se connecter à OSM
</button>
</div>
}
</div>

View file

@ -0,0 +1,114 @@
.osm-auth {
padding: 15px;
border: 1px solid #e9ecef;
border-radius: 8px;
background: #f8f9fa;
margin-bottom: 15px;
.user-info {
display: flex;
align-items: center;
gap: 12px;
.user-avatar {
.avatar {
width: 40px;
height: 40px;
border-radius: 50%;
object-fit: cover;
border: 2px solid #007bff;
}
.avatar-placeholder {
width: 40px;
height: 40px;
border-radius: 50%;
background: #007bff;
color: white;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
}
.user-details {
flex: 1;
.username {
font-weight: 600;
color: #333;
margin-bottom: 4px;
}
.user-stats {
font-size: 12px;
color: #666;
.stat {
background: #e9ecef;
padding: 2px 6px;
border-radius: 12px;
margin-right: 8px;
}
}
}
}
.login-prompt {
text-align: center;
.login-text {
margin-bottom: 15px;
p {
margin: 0 0 10px 0;
color: #555;
font-size: 14px;
}
ul {
margin: 0;
padding-left: 20px;
text-align: left;
font-size: 13px;
color: #666;
li {
margin-bottom: 4px;
}
}
}
.btn {
padding: 10px 20px;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
font-weight: 500;
transition: all 0.2s ease;
&.btn-primary {
background: #007bff;
color: white;
&:hover {
background: #0056b3;
transform: translateY(-1px);
}
}
&.btn-outline {
background: transparent;
color: #6c757d;
border: 1px solid #6c757d;
&:hover {
background: #6c757d;
color: white;
}
}
}
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Osm } from './osm';
describe('Osm', () => {
let component: Osm;
let fixture: ComponentFixture<Osm>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Osm]
})
.compileComponents();
fixture = TestBed.createComponent(Osm);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,44 @@
import { Component, inject, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OsmAuth, OsmUser } from '../../services/osm-auth';
import { Subscription } from 'rxjs';
@Component({
selector: 'app-osm',
standalone: true,
imports: [CommonModule],
templateUrl: './osm.html',
styleUrl: './osm.scss'
})
export class Osm implements OnInit, OnDestroy {
private osmAuth = inject(OsmAuth);
private subscription?: Subscription;
currentUser: OsmUser | null = null;
isAuthenticated = false;
ngOnInit() {
this.subscription = this.osmAuth.currentUser$.subscribe(user => {
this.currentUser = user;
this.isAuthenticated = !!user;
});
}
ngOnDestroy() {
if (this.subscription) {
this.subscription.unsubscribe();
}
}
login() {
this.osmAuth.initiateOAuthLogin();
}
logout() {
this.osmAuth.logout();
}
getUsername(): string {
return this.osmAuth.getUsername() || '';
}
}

View file

@ -0,0 +1,13 @@
<div class="map-wrapper" style="position:relative;height:100%;">
<div #mapContainer class="map" style="width:100%;height:100%;border:1px solid #e6eef3;border-radius:10px;"></div>
<div class="search" style="position:absolute;top:10px;left:10px;right:10px;display:flex;gap:8px;">
<input #searchBox type="text" placeholder="Chercher un lieu (Nominatim)" class="input" style="flex:1;">
<button class="btn" type="button" (click)="searchPlace(searchBox.value)">Chercher</button>
</div>
@if (canRestoreOriginal) {
<div style="position:absolute;bottom:10px;left:10px;display:flex;gap:8px;">
<button class="btn btn-ghost" type="button" (click)="restoreOriginalCoords()">Reprendre les coordonnées initiales</button>
</div>
}
</div>

View file

@ -0,0 +1,20 @@
@keyframes pulseGreen {
0% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0.4); }
70% { box-shadow: 0 0 0 12px rgba(76, 175, 80, 0); }
100% { box-shadow: 0 0 0 0 rgba(76, 175, 80, 0); }
}
@keyframes pulseRed {
0% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0.4); }
70% { box-shadow: 0 0 0 12px rgba(244, 67, 54, 0); }
100% { box-shadow: 0 0 0 0 rgba(244, 67, 54, 0); }
}
[data-feature-id].pulse-green {
animation: pulseGreen 1.2s ease-out 1;
border-radius: 50%;
}
[data-feature-id].pulse-red {
animation: pulseRed 1.2s ease-out 1;
border-radius: 50%;
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { AllEvents } from './all-events';
describe('AllEvents', () => {
let component: AllEvents;
let fixture: ComponentFixture<AllEvents>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [AllEvents]
})
.compileComponents();
fixture = TestBed.createComponent(AllEvents);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,654 @@
import { Component, ElementRef, EventEmitter, Input, OnDestroy, OnInit, Output, ViewChild } from '@angular/core';
import { ActivatedRoute, Router } from '@angular/router';
import oedb_what_categories from '../../../oedb-types';
@Component({
selector: 'app-all-events',
standalone: true,
imports: [],
templateUrl: './all-events.html',
styleUrl: './all-events.scss'
})
export class AllEvents implements OnInit, OnDestroy {
@Input() features: Array<any> = [];
@Input() selected: any | null = null;
@Input() highlight: { id: string | number, type: 'saved' | 'deleted' } | null = null;
@Input() selectMode: 'none' | 'rectangle' | 'polygon' = 'none';
@Output() select = new EventEmitter<any>();
@Output() pickCoords = new EventEmitter<[number, number]>();
@Output() selection = new EventEmitter<Array<string | number>>();
@Output() mapMove = new EventEmitter<{ minLng: number, minLat: number, maxLng: number, maxLat: number }>();
@ViewChild('mapContainer', { static: true }) mapContainer!: ElementRef<HTMLDivElement>;
private map: any;
private markers: any[] = [];
private pickedMarker: any | null = null;
private originalCoords: [number, number] | null = null;
private currentPicked: [number, number] | null = null;
private isInitialLoad = true;
private mapInitialized = false;
// selection state
private selectionActive = false;
private rectStartPoint: { x: number, y: number } | null = null;
private rectOverlay: HTMLDivElement | null = null;
private polygonPoints: Array<[number, number]> = [];
constructor(
private route: ActivatedRoute,
private router: Router
) {}
async ngOnInit() {
await this.ensureMapLibre();
this.initMap();
this.renderFeatures();
}
ngOnDestroy(): void {
this.markers.forEach(m => m.remove && m.remove());
this.markers = [];
if (this.map && this.map.remove) this.map.remove();
}
ngOnChanges(): void {
// track original coordinates of the selected feature
if (this.selected && Array.isArray(this.selected?.geometry?.coordinates)) {
const coords = this.selected.geometry.coordinates as [number, number];
this.originalCoords = coords;
// If no picked marker yet, align current picked to original
if (!this.currentPicked) this.currentPicked = coords;
}
this.renderFeatures();
// trigger animation highlight
if (this.highlight && this.highlight.id !== undefined && this.highlight.id !== null) {
const idStr = String(this.highlight.id);
const el = document.querySelector(`[data-feature-id="${CSS.escape(idStr)}"]`);
if (el) {
el.classList.remove('pulse-green', 'pulse-red');
if (this.highlight.type === 'saved') el.classList.add('pulse-green');
if (this.highlight.type === 'deleted') el.classList.add('pulse-red');
setTimeout(() => {
el.classList.remove('pulse-green', 'pulse-red');
}, 1500);
}
}
// handle selectMode changes
if (this.mapInitialized) {
this.setupSelectionHandlers();
}
}
private ensureMapLibre(): Promise<void> {
return new Promise(resolve => {
if ((window as any).maplibregl) return resolve();
const css = document.createElement('link');
css.rel = 'stylesheet';
css.href = 'https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.css';
document.head.appendChild(css);
const s = document.createElement('script');
s.src = 'https://unpkg.com/maplibre-gl@3.6.0/dist/maplibre-gl.js';
s.onload = () => resolve();
document.body.appendChild(s);
});
}
private initMap() {
const maplibregl = (window as any).maplibregl;
// Récupérer les paramètres de l'URL ou utiliser les valeurs par défaut (Île-de-France)
const lat = parseFloat(this.route.snapshot.queryParams['lat']) || 48.8566;
const lon = parseFloat(this.route.snapshot.queryParams['lon']) || 2.3522;
const zoom = parseFloat(this.route.snapshot.queryParams['zoom']) || 8;
const basemap = (this.route.snapshot.queryParams['basemap'] || '').toLowerCase();
// Déterminer le style de fond de carte selon le query param "basemap"
// valeurs supportées: "vector" (défaut), "raster", "waymarked"
const style = (() => {
if (basemap === 'raster') {
return {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
}
},
layers: [
{ id: 'osm-tiles', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 }
]
} as any;
}
if (basemap === 'waymarked') {
return {
version: 8,
sources: {
osm: {
type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© OpenStreetMap contributors'
},
waymarked: {
type: 'raster',
// Waymarked Trails randonnée (hiking)
tiles: ['https://tile.waymarkedtrails.org/hiking/{z}/{x}/{y}.png'],
tileSize: 256,
attribution: '© waymarkedtrails.org'
}
},
layers: [
{ id: 'osm-tiles', type: 'raster', source: 'osm', minzoom: 0, maxzoom: 19 },
{ id: 'waymarked-overlay', type: 'raster', source: 'waymarked', minzoom: 0, maxzoom: 19 }
]
} as any;
}
// vector (défaut)
return 'https://tiles.openfreemap.org/styles/liberty';
})();
this.map = new maplibregl.Map({
container: this.mapContainer.nativeElement,
style,
center: [lon, lat],
zoom: zoom
});
this.map.addControl(new maplibregl.NavigationControl());
this.map.addControl(new maplibregl.GeolocateControl({ positionOptions: { enableHighAccuracy: true }, trackUserLocation: true }));
this.map.on('click', (e: any) => {
const coords: [number, number] = [e.lngLat.lng, e.lngLat.lat];
this.showPickedMarker(coords);
this.pickCoords.emit(coords);
});
// Écouter les changements de vue pour mettre à jour l'URL et émettre la bbox
this.map.on('moveend', () => {
if (this.mapInitialized) {
this.updateUrlFromMap();
this.emitCurrentBbox();
}
});
this.map.on('zoomend', () => {
if (this.mapInitialized) {
this.updateUrlFromMap();
this.emitCurrentBbox();
}
});
this.mapInitialized = true;
this.setupSelectionHandlers();
}
private setupSelectionHandlers() {
const maplibregl = (window as any).maplibregl;
if (!this.map) return;
// Cleanup previous handlers
this.selectionActive = false;
this.removeRectOverlay();
this.polygonPoints = [];
// Disable default dragging while selecting
const enableDrag = () => this.map.dragPan && this.map.dragPan.enable && this.map.dragPan.enable();
const disableDrag = () => this.map.dragPan && this.map.dragPan.disable && this.map.dragPan.disable();
// Remove prior listeners by re-adding map handlers only when needed
this.map.off('mousedown', this as any);
this.map.off('mousemove', this as any);
this.map.off('mouseup', this as any);
this.map.off('click', this as any);
if (this.selectMode === 'rectangle') {
disableDrag();
this.selectionActive = true;
this.map.on('mousedown', (e: any) => {
if (!this.selectionActive) return;
const p = this.map.project(e.lngLat);
this.rectStartPoint = { x: p.x, y: p.y };
this.createRectOverlay();
const onMouseMove = (ev: any) => {
if (!this.rectStartPoint) return;
const pt = this.map.project(ev.lngLat);
this.updateRectOverlay(this.rectStartPoint, { x: pt.x, y: pt.y });
};
const onMouseUp = (ev: any) => {
this.map.off('mousemove', onMouseMove);
this.map.off('mouseup', onMouseUp);
enableDrag();
if (!this.rectStartPoint) { this.removeRectOverlay(); return; }
const endPt = this.map.project(ev.lngLat);
const a = this.map.unproject(this.rectStartPoint);
const b = this.map.unproject(endPt);
const minLng = Math.min(a.lng, b.lng);
const maxLng = Math.max(a.lng, b.lng);
const minLat = Math.min(a.lat, b.lat);
const maxLat = Math.max(a.lat, b.lat);
const ids = this.collectIdsInBbox([minLng, minLat, maxLng, maxLat]);
this.selection.emit(ids);
this.removeRectOverlay();
this.rectStartPoint = null;
this.selectionActive = false;
};
this.map.on('mousemove', onMouseMove);
this.map.on('mouseup', onMouseUp);
});
} else if (this.selectMode === 'polygon') {
disableDrag();
this.selectionActive = true;
this.polygonPoints = [];
const clickHandler = (e: any) => {
if (!this.selectionActive) return;
const pt: [number, number] = [e.lngLat.lng, e.lngLat.lat];
this.polygonPoints.push(pt);
// finish on double click (two close clicks)
};
const dblHandler = () => {
if (!this.selectionActive || this.polygonPoints.length < 3) return;
const ids = this.collectIdsInPolygon(this.polygonPoints);
this.selection.emit(ids);
this.selectionActive = false;
this.polygonPoints = [];
enableDrag();
};
this.map.on('click', clickHandler);
this.map.on('dblclick', dblHandler);
} else {
// none
enableDrag();
this.selectionActive = false;
this.removeRectOverlay();
this.polygonPoints = [];
}
}
private createRectOverlay() {
if (this.rectOverlay) this.removeRectOverlay();
const el = document.createElement('div');
el.style.position = 'absolute';
el.style.border = '2px dashed #1976d2';
el.style.background = 'rgba(25,118,210,0.1)';
el.style.pointerEvents = 'none';
this.rectOverlay = el;
this.mapContainer.nativeElement.appendChild(el);
}
private updateRectOverlay(a: { x: number, y: number }, b: { x: number, y: number }) {
if (!this.rectOverlay) return;
const left = Math.min(a.x, b.x);
const top = Math.min(a.y, b.y);
const width = Math.abs(a.x - b.x);
const height = Math.abs(a.y - b.y);
this.rectOverlay.style.left = `${left}px`;
this.rectOverlay.style.top = `${top}px`;
this.rectOverlay.style.width = `${width}px`;
this.rectOverlay.style.height = `${height}px`;
}
private removeRectOverlay() {
if (this.rectOverlay && this.rectOverlay.parentElement) {
this.rectOverlay.parentElement.removeChild(this.rectOverlay);
}
this.rectOverlay = null;
}
private collectIdsInBbox(bbox: [number, number, number, number]): Array<string | number> {
const [minLng, minLat, maxLng, maxLat] = bbox;
const ids: Array<string | number> = [];
for (const f of this.features) {
const id = (f?.properties?.id ?? f?.id);
const c = f?.geometry?.coordinates;
if (!id || !Array.isArray(c)) continue;
const [lng, lat] = c as [number, number];
if (lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat) ids.push(id);
}
return ids;
}
private collectIdsInPolygon(poly: Array<[number, number]>): Array<string | number> {
const ids: Array<string | number> = [];
const inside = (pt: [number, number], polygon: Array<[number, number]>) => {
// ray casting
let x = pt[0], y = pt[1];
let inside = false;
for (let i = 0, j = polygon.length - 1; i < polygon.length; j = i++) {
const xi = polygon[i][0], yi = polygon[i][1];
const xj = polygon[j][0], yj = polygon[j][1];
const intersect = ((yi > y) !== (yj > y)) && (x < (xj - xi) * (y - yi) / ((yj - yi) || 1e-9) + xi);
if (intersect) inside = !inside;
}
return inside;
};
for (const f of this.features) {
const id = (f?.properties?.id ?? f?.id);
const c = f?.geometry?.coordinates;
if (!id || !Array.isArray(c)) continue;
const pt: [number, number] = [c[0], c[1]];
if (inside(pt, poly)) ids.push(id);
}
return ids;
}
private getEmojiForWhat(what: string): string {
try {
// if what is exact key
const preset: any = (oedb_what_categories as any).presets.what as Record<string, any>;
if (preset && preset[what] && preset[what].emoji) return preset[what].emoji;
const family = what?.split('.')[0] || '';
if (preset && preset[family] && preset[family].emoji) return preset[family].emoji;
} catch {}
return '📍';
}
private getImageForWhat(what: string): string | null {
try {
const preset: any = (oedb_what_categories as any).presets.what as Record<string, any>;
if (preset && preset[what] && preset[what].image) return preset[what].image;
const family = what?.split('.')[0] || '';
if (preset && preset[family] && preset[family].image) return preset[family].image;
} catch {}
return null;
}
private showPickedMarker(coords: [number, number]) {
const maplibregl = (window as any).maplibregl;
const el = document.createElement('div');
el.style.width = '10px';
el.style.height = '10px';
el.style.borderRadius = '50%';
el.style.background = '#2196f3';
el.style.border = '2px solid white';
el.style.boxShadow = '0 0 0 2px rgba(33,150,243,0.3)';
if (this.pickedMarker && this.pickedMarker.remove) {
this.pickedMarker.remove();
}
this.pickedMarker = new maplibregl.Marker({ element: el }).setLngLat(coords).addTo(this.map);
this.currentPicked = coords;
}
async searchPlace(query: string) {
const q = (query || '').trim();
if (!q) return;
try {
const resp = await fetch(`https://nominatim.openstreetmap.org/search?format=geojson&q=${encodeURIComponent(q)}`);
const data = await resp.json();
const f = data?.features?.[0];
const coords = f?.geometry?.type === 'Point' ? f.geometry.coordinates : f?.bbox;
if (Array.isArray(coords)) {
if (coords.length === 2) {
this.map.flyTo({ center: coords, zoom: 14 });
this.showPickedMarker(coords as [number, number]);
this.pickCoords.emit(coords as [number, number]);
} else if (coords.length === 4) {
const maplibregl = (window as any).maplibregl;
const bounds = new maplibregl.LngLatBounds([coords[0], coords[1]], [coords[2], coords[3]]);
// this.map.fitBounds(bounds, { padding: 40 });
}
}
} catch {}
}
get canRestoreOriginal(): boolean {
if (!this.originalCoords || !this.currentPicked) return false;
return this.originalCoords[0] !== this.currentPicked[0] || this.originalCoords[1] !== this.currentPicked[1];
}
restoreOriginalCoords() {
if (!this.originalCoords) return;
this.showPickedMarker(this.originalCoords);
this.pickCoords.emit(this.originalCoords);
if (this.map) this.map.flyTo({ center: this.originalCoords, zoom: Math.max(this.map.getZoom() || 12, 12) });
}
private updateUrlFromMap() {
if (!this.map) return;
const center = this.map.getCenter();
const zoom = this.map.getZoom();
this.router.navigate([], {
relativeTo: this.route,
queryParams: {
lat: center.lat.toFixed(6),
lon: center.lng.toFixed(6),
zoom: Math.round(zoom)
},
queryParamsHandling: 'merge',
replaceUrl: true
});
}
private renderFeatures() {
if (!this.map || !Array.isArray(this.features)) return;
// clear existing markers
this.markers.forEach(m => m.remove && m.remove());
this.markers = [];
const maplibregl = (window as any).maplibregl;
const bounds = new maplibregl.LngLatBounds();
this.features.forEach(f => {
const coords = f?.geometry?.coordinates;
if (!coords || !Array.isArray(coords)) return;
const p = f.properties || {};
const fid = (p && (p.id ?? p.uuid)) ?? f?.id;
const el = this.buildMarkerElement(p);
el.style.cursor = 'pointer';
if (typeof fid !== 'undefined') {
el.setAttribute('data-feature-id', String(fid));
}
// selected styling
const selId = this.selected?.properties?.id ?? this.selected?.properties?.uuid ?? this.selected?.id;
if (selId !== undefined && selId !== null && String(selId) === String(fid)) {
el.style.transform = 'scale(1.2)';
el.style.boxShadow = '0 0 0 4px rgba(25,118,210,0.25)';
el.style.borderRadius = '50%';
}
const popupHtml = this.buildPopupHtml(p, (p && (p.id ?? p.uuid)) ?? f?.id);
const marker = new maplibregl.Marker({ element: el })
.setLngLat(coords)
.setPopup(new maplibregl.Popup({
offset: 12,
closeOnClick: false, // Empêcher la fermeture au clic sur la carte
closeButton: true
}).setHTML(popupHtml))
.addTo(this.map);
el.addEventListener('click', (e) => {
e.stopPropagation(); // Empêcher la propagation du clic vers la carte
// Ouvrir la popup du marqueur
marker.togglePopup();
this.select.emit({
id: fid,
properties: p,
geometry: { type: 'Point', coordinates: coords }
});
});
const popup = marker.getPopup && marker.getPopup();
if (popup && popup.on) {
popup.on('open', () => {
const rawId = (p && (p.id ?? p.uuid)) ?? f?.id;
const targetId = typeof rawId !== 'undefined' ? String(rawId) : `${coords[0]},${coords[1]}`;
const elTitle = document.querySelector(`[data-feature-id="${CSS.escape(targetId)}"]`);
if (elTitle) {
elTitle.addEventListener('click', (ev: Event) => {
ev.preventDefault();
this.select.emit({
id: (p && (p.id ?? p.uuid)) ?? f?.id,
properties: p,
geometry: { type: 'Point', coordinates: coords }
});
}, { once: true });
}
});
}
this.markers.push(marker);
bounds.extend(coords);
});
// Ne faire fitBounds que lors du chargement initial et seulement si pas de paramètres URL
if (!bounds.isEmpty() && this.isInitialLoad) {
const hasUrlParams = this.route.snapshot.queryParams['lat'] || this.route.snapshot.queryParams['lon'] || this.route.snapshot.queryParams['zoom'];
if (!hasUrlParams) {
// this.map.fitBounds(bounds, { padding: 40, maxZoom: 12 });
}
this.isInitialLoad = false;
}
// Supprimer le fitBounds automatique lors des mises à jour pour éviter le dézoom
}
private buildMarkerElement(props: any): HTMLDivElement {
const container = document.createElement('div');
container.style.fontSize = '20px';
container.style.lineHeight = '1';
container.style.display = 'flex';
container.style.alignItems = 'center';
container.style.justifyContent = 'center';
const htmlCandidate = this.findMarkerHtml(props);
if (htmlCandidate) {
const safe = this.sanitizeHtml(htmlCandidate);
container.innerHTML = safe;
return container;
}
const what = props?.what || '';
const image = this.getImageForWhat(what);
if (image) {
const img = document.createElement('img');
img.src = image;
img.alt = what || 'marker';
img.style.width = '24px';
img.style.height = '24px';
img.style.objectFit = 'contain';
container.appendChild(img);
return container;
}
const emoji = this.getEmojiForWhat(what);
container.textContent = emoji;
return container;
}
private findMarkerHtml(props: any): string | null {
const keysToCheck = ['marker_html', 'icon_html', 'html', 'marker', 'icon'];
for (const key of keysToCheck) {
const value = props?.[key];
if (typeof value === 'string' && value.includes('<')) return value;
}
return null;
}
private sanitizeHtml(html: string): string {
const temp = document.createElement('div');
temp.innerHTML = html;
const walk = (node: Element) => {
// Remove script and style tags entirely
if (node.tagName === 'SCRIPT' || node.tagName === 'STYLE') {
node.remove();
return;
}
// Strip event handlers and javascript: URLs
for (const attr of Array.from(node.attributes)) {
const name = attr.name.toLowerCase();
const value = attr.value || '';
if (name.startsWith('on')) {
node.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src') && /^\s*javascript:/i.test(value)) {
node.removeAttribute(attr.name);
continue;
}
if (node.tagName === 'A' && name === 'href' && !/^(https?:|#|\/)/i.test(value)) {
node.removeAttribute(attr.name);
continue;
}
}
// Recurse children
for (const child of Array.from(node.children)) walk(child as Element);
};
for (const child of Array.from(temp.children)) walk(child as Element);
return temp.innerHTML;
}
private buildPopupHtml(props: any, id?: any): string {
const title = this.escapeHtml(String(props?.name || props?.label || props?.what || 'évènement'));
const titleId = typeof id !== 'undefined' ? String(id) : '';
// Informations principales à afficher en priorité
const mainInfo = [];
if (props?.what) mainInfo.push({ key: 'Type', value: props.what });
if (props?.where) mainInfo.push({ key: 'Lieu', value: props.where });
if (props?.start) mainInfo.push({ key: 'Début', value: this.formatDate(props.start) });
if (props?.stop) mainInfo.push({ key: 'Fin', value: this.formatDate(props.stop) });
if (props?.url) mainInfo.push({ key: 'Lien', value: `<a href="${this.escapeHtml(props.url)}" target="_blank">Voir l'événement</a>` });
const mainRows = mainInfo.map(info =>
`<tr><td style="font-weight:bold;vertical-align:top;padding:4px 8px;color:#666;">${this.escapeHtml(info.key)}</td><td style="padding:4px 8px;">${info.value}</td></tr>`
).join('');
// Autres propriétés
const otherProps = Object.keys(props || {})
.filter(k => !['name', 'label', 'what', 'where', 'start', 'stop', 'url', 'id', 'uuid'].includes(k))
.sort();
const otherRows = otherProps.map(k => {
const v = props[k];
const value = typeof v === 'object' ? `<pre style="font-size:11px;margin:0;">${this.escapeHtml(JSON.stringify(v, null, 2))}</pre>` : this.escapeHtml(String(v));
return `<tr><td style="font-weight:bold;vertical-align:top;padding:2px 8px;color:#999;font-size:12px;">${this.escapeHtml(k)}</td><td style="padding:2px 8px;font-size:12px;">${value}</td></tr>`;
}).join('');
const clickable = `<div style="font-weight:700;margin:0 0 8px 0;font-size:16px;color:#1976d2;">
<a href="#" data-feature-id="${this.escapeHtml(titleId)}" style="text-decoration:none;color:inherit;">${title}</a>
</div>`;
return `<div style="max-width:350px;font-family:Arial,sans-serif;">
${clickable}
<table style="border-collapse:collapse;width:100%;margin-bottom:8px;">${mainRows}</table>
${otherRows ? `<details style="margin-top:8px;"><summary style="cursor:pointer;color:#666;font-size:12px;">Plus de détails</summary><table style="border-collapse:collapse;width:100%;margin-top:4px;">${otherRows}</table></details>` : ''}
</div>`;
}
private formatDate(dateStr: string): string {
try {
const date = new Date(dateStr);
return date.toLocaleString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
} catch {
return dateStr;
}
}
private escapeHtml(s: string): string {
return s.replace(/[&<>"]+/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c] as string));
}
private emitCurrentBbox() {
if (!this.map) return;
const bounds = this.map.getBounds();
const bbox = {
minLng: bounds.getWest(),
minLat: bounds.getSouth(),
maxLng: bounds.getEast(),
maxLat: bounds.getNorth()
};
this.mapMove.emit(bbox);
}
}

View file

@ -0,0 +1 @@
<p>unlocated-events works!</p>

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { UnlocatedEvents } from './unlocated-events';
describe('UnlocatedEvents', () => {
let component: UnlocatedEvents;
let fixture: ComponentFixture<UnlocatedEvents>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [UnlocatedEvents]
})
.compileComponents();
fixture = TestBed.createComponent(UnlocatedEvents);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,11 @@
import { Component } from '@angular/core';
@Component({
selector: 'app-unlocated-events',
imports: [],
templateUrl: './unlocated-events.html',
styleUrl: './unlocated-events.scss'
})
export class UnlocatedEvents {
}

View file

@ -0,0 +1,90 @@
<div class="agenda-page">
<div class="layout">
<div class="aside">
@if (isLoading) {
<div class="loading">
<div class="loading-spinner"></div>
<p>Chargement des événements...</p>
</div>
} @else {
<div class="agenda-layout">
<aside class="agenda-sidebar">
<div class="sidebar-header">
<h3>Agenda</h3>
<small>{{filteredCalendarEvents.length}} évènements</small>
</div>
<div class="sidebar-filters">
<app-what-filter
[label]="'Filtrer par type d\'événement'"
[available]="availableWhatTypes"
[selected]="selectedWhatFilter"
(selectedChange)="onWhatFilterChange($event)"></app-what-filter>
@if (selectedDate) {
<div class="date-filter-info">
<small>Filtré par date: {{formatDayHeader(selectedDate)}}</small>
<button class="btn-reset-date" (click)="clearDateFilter()">Afficher tous les jours</button>
</div>
}
</div>
<div class="day-groups">
@for (group of groupedEvents; track group.dateKey) {
<div class="day-group" [attr.data-date-key]="group.dateKey">
<div class="day-title">{{formatDayHeader(group.date)}}</div>
<ul class="event-list">
@for (ev of group.items; track ev.id) {
<li class="event-item" (click)="selectFromSidebar(ev)" [class.active]="selectedEvent?.id === ev.id">
<span class="event-icon">
@if (getImageForWhat(ev.properties.what)) {
<img [src]="getImageForWhat(ev.properties.what)" alt="" />
} @else if (getEmojiForWhat(ev.properties.what)) {
{{getEmojiForWhat(ev.properties.what)}}
} @else {
📌
}
</span>
<div class="event-meta">
<div class="event-title">{{ev.properties.label || ev.properties.name || 'Événement'}}</div>
<div class="event-when">{{(ev.properties.start || ev.properties.when) || '—'}}</div>
</div>
</li>
}
</ul>
</div>
}
</div>
</aside>
@if (selectedEvent) {
<div class="event-edit-panel">
<div class="panel-header">
<h3>Modifier l'événement</h3>
<button class="btn-close" (click)="selectedEvent = null">×</button>
</div>
<div class="panel-content">
<app-edit-form
[selected]="selectedEvent"
(saved)="onEventSaved()"
(created)="onEventCreated()"
(deleted)="onEventDeleted()">
</app-edit-form>
</div>
</div>
}
</div>
}
</div>
<div class="main">
<main class="agenda-main">
<app-calendar
[events]="filteredCalendarEvents"
(eventClick)="onEventClick($event)"
(dateClick)="onDateClick($event)">
</app-calendar>
</main>
</div>
</div>
</div>

View file

@ -0,0 +1,223 @@
.agenda-page {
min-height: 100vh;
background: #f8f9fa;
overflow-y: auto;
}
.layout {
display: grid;
grid-template-columns: 400px 1fr;
grid-template-rows: minmax(100vh, auto);
gap: 0;
// min-height: 100vh;
&.is-small {
grid-template-columns: 100px 1fr;
}
}
.aside {
background: #f8f9fa;
border-right: 1px solid #e9ecef;
overflow-y: auto;
}
.main {
background: white;
overflow-y: auto;
}
.loading {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
// height: 100vh;
background: #f8f9fa;
}
.loading-spinner {
width: 40px;
height: 40px;
border: 4px solid #e9ecef;
border-top: 4px solid #007bff;
border-radius: 50%;
animation: spin 1s linear infinite;
margin-bottom: 20px;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.loading p {
color: #6c757d;
font-size: 1.1rem;
margin: 0;
}
.agenda-layout {
display: grid;
grid-template-columns: 320px 1fr auto;
grid-template-rows: 1fr;
min-height: 500px;
}
.agenda-sidebar {
background: #ffffff;
border-right: 1px solid #e9ecef;
overflow-y: auto;
padding: 12px;
max-height: 80vh;
}
.sidebar-header {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 8px 12px 8px;
border-bottom: 1px solid #f1f3f5;
}
.day-group {
padding: 10px 0;
}
.day-title {
font-weight: 600;
color: #2c3e50;
font-size: 0.95rem;
margin: 8px 0;
}
.event-list {
list-style: none;
margin: 0;
padding: 0;
}
.event-item {
display: flex;
gap: 10px;
padding: 8px;
border-radius: 8px;
cursor: pointer;
transition: background 0.2s ease;
}
.event-item:hover {
background: #f5f7fb;
}
.event-item.active {
background: #eef3ff;
border-left: 3px solid #75a0f6;
}
.event-icon {
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
}
.event-icon img {
width: 20px;
height: 20px;
}
.event-meta {
min-width: 0;
}
.event-title {
font-size: 0.95rem;
color: #243b53;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-when {
font-size: 0.8rem;
color: #6b7280;
}
.agenda-main {
position: relative;
}
.event-edit-panel {
position: fixed;
top: 0;
right: 0;
width: 400px;
height: 100vh;
background: white;
box-shadow: -4px 0 12px rgba(0,0,0,0.15);
z-index: 1000;
display: flex;
flex-direction: column;
animation: slideIn 0.3s ease-out;
}
@keyframes slideIn {
from {
transform: translateX(100%);
}
to {
transform: translateX(0);
}
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 20px;
border-bottom: 1px solid #e9ecef;
background: #f8f9fa;
}
.panel-header h3 {
margin: 0;
color: #2c3e50;
font-size: 1.2rem;
}
.btn-close {
background: none;
border: none;
font-size: 1.5rem;
color: #6c757d;
cursor: pointer;
padding: 5px;
border-radius: 50%;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
&:hover {
background: #e9ecef;
color: #495057;
}
}
.panel-content {
flex: 1;
padding: 20px;
overflow-y: auto;
}
// Responsive
@media (max-width: 768px) {
.event-edit-panel {
width: 100%;
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Agenda } from './agenda';
describe('Agenda', () => {
let component: Agenda;
let fixture: ComponentFixture<Agenda>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Agenda]
})
.compileComponents();
fixture = TestBed.createComponent(Agenda);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,363 @@
import { Component, inject, OnInit } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { OedbApi } from '../../services/oedb-api';
import { EditForm } from '../../forms/edit-form/edit-form';
import { CalendarComponent, CalendarEvent } from './calendar/calendar';
import { Menu } from '../home/menu/menu';
import oedb from '../../../oedb-types';
import { WhatFilterComponent } from '../../shared/what-filter/what-filter';
import { ActivatedRoute } from '@angular/router';
import { subMonths, addMonths, format } from 'date-fns';
interface OedbEvent {
id: string;
properties: {
label?: string;
name?: string;
what?: string;
start?: string;
when?: string;
stop?: string;
description?: string;
where?: string;
};
geometry?: {
type: string;
coordinates: [number, number];
};
}
@Component({
selector: 'app-agenda',
standalone: true,
imports: [CommonModule, FormsModule, EditForm, CalendarComponent, WhatFilterComponent, Menu],
templateUrl: './agenda.html',
styleUrl: './agenda.scss'
})
export class Agenda implements OnInit {
private oedbApi = inject(OedbApi);
private route = inject(ActivatedRoute);
events: OedbEvent[] = [];
filteredEvents: OedbEvent[] = [];
calendarEvents: CalendarEvent[] = [];
filteredCalendarEvents: CalendarEvent[] = [];
selectedEvent: OedbEvent | null = null;
isLoading = false;
groupedEvents: Array<{ dateKey: string; date: Date; items: OedbEvent[] }> = [];
availableWhatTypes: string[] = [];
selectedWhatFilter = 'culture';
selectedDate: Date | null = null;
ngOnInit() {
// Gérer les paramètres de requête ET les paramètres du fragment
this.route.queryParamMap.subscribe(map => {
const id = (map.get('id') || '').trim();
const what = (map.get('what') || '').trim();
const limitParam = map.get('limit');
const limit = limitParam ? Number(limitParam) : null;
// Définir le filtre what avant de charger les événements
if (what) {
this.selectedWhatFilter = what;
}
if (id) {
this.loadSingleEvent(id);
} else {
this.loadEvents({ what: what || undefined, limit: limit || undefined });
}
});
// Gérer aussi les paramètres du fragment (pour les URLs avec #)
this.route.fragment.subscribe(fragment => {
console.log('🔗 Fragment reçu:', fragment);
if (fragment) {
// Nettoyer le fragment en supprimant le & initial s'il existe
const cleanFragment = fragment.startsWith('&') ? fragment.substring(1) : fragment;
console.log('🧹 Fragment nettoyé:', cleanFragment);
const params = new URLSearchParams(cleanFragment);
const what = params.get('what');
console.log('🎯 Paramètre what extrait:', what);
if (what) {
this.selectedWhatFilter = what;
console.log('✅ Filtre what défini:', this.selectedWhatFilter);
this.loadEvents({ what: what, limit: undefined });
}
}
});
}
loadEvents(overrides: { what?: string; limit?: number } = {}) {
this.isLoading = true;
const today = new Date();
// Calculer startDate : 3 mois avant aujourd'hui (plus robuste avec date-fns)
const startDate = subMonths(today, 3);
// Calculer endDate : 3 mois après aujourd'hui (plus robuste avec date-fns)
const endDate = addMonths(today, 3);
const params: any = {
start: format(startDate, 'yyyy-MM-dd'),
end: format(endDate, 'yyyy-MM-dd'),
what: "culture",
limit: overrides.limit ?? 1000
};
if (overrides.what) params.what = overrides.what;
console.log('🔍 Chargement des événements avec paramètres:', params);
console.log('📅 Plage de dates:', {
start: startDate.toISOString(),
end: endDate.toISOString(),
today: today.toISOString(),
what: "culture",
startFormatted: format(startDate, 'yyyy-MM-dd'),
endFormatted: format(endDate, 'yyyy-MM-dd')
});
this.oedbApi.getEvents(params).subscribe((response: any) => {
console.log('📡 Réponse API reçue:', response);
this.events = Array.isArray(response?.features) ? response.features : [];
console.log('📊 Nombre d\'événements chargés:', this.events.length);
this.updateAvailableWhatTypes();
this.applyWhatFilter();
this.isLoading = false;
// Scroller vers le jour actuel après le chargement
this.scrollToToday();
}, (error) => {
console.error('❌ Erreur lors du chargement des événements:', error);
this.isLoading = false;
});
}
loadSingleEvent(id: string | number) {
this.isLoading = true;
this.oedbApi.getEventById(id).subscribe({
next: (feature: any) => {
const f = (feature && (feature as any).type === 'Feature') ? feature : (feature?.feature || null);
this.events = f ? [f] as OedbEvent[] : [];
this.filteredEvents = this.events;
this.updateAvailableWhatTypes();
this.applyWhatFilter();
this.isLoading = false;
},
error: () => {
this.events = [];
this.filteredEvents = [];
this.calendarEvents = [];
this.filteredCalendarEvents = [];
this.groupedEvents = [];
this.isLoading = false;
}
});
}
convertToCalendarEvents() {
const source = this.filteredEvents.length ? this.filteredEvents : this.events;
this.calendarEvents = source.map(event => {
const startDate = this.parseEventDate(event.properties.start || event.properties.when);
const endDate = event.properties.stop ? this.parseEventDate(event.properties.stop) : null;
return {
id: event.id || Math.random().toString(36).substr(2, 9),
title: event.properties.label || event.properties.name || 'Événement sans nom',
start: startDate,
end: endDate || undefined,
description: event.properties.description || '',
location: event.properties.where || '',
type: event.properties.what || 'default',
properties: event.properties
};
});
this.filteredCalendarEvents = this.calendarEvents;
}
parseEventDate(dateString: string | undefined): Date {
if (!dateString) return new Date();
// Essayer différents formats de date
const date = new Date(dateString);
if (isNaN(date.getTime())) {
// Si la date n'est pas valide, essayer de parser manuellement
const parts = dateString.split(/[-T:]/);
if (parts.length >= 3) {
const year = parseInt(parts[0]);
const month = parseInt(parts[1]) - 1; // Les mois commencent à 0
const day = parseInt(parts[2]);
const hour = parts[3] ? parseInt(parts[3]) : 0;
const minute = parts[4] ? parseInt(parts[4]) : 0;
return new Date(year, month, day, hour, minute);
}
}
return date;
}
onEventClick(event: CalendarEvent) {
// Trouver l'événement OEDB correspondant
this.selectedEvent = this.events.find(e =>
(e.id && e.id === event.id) ||
(e.properties.label === event.title)
) || null;
}
onDateClick(date: Date) {
console.log('Date cliquée:', date);
this.selectedDate = date;
this.applyDateFilter();
this.scrollToDateInSidebar(date);
}
onEventSaved() {
this.loadEvents();
}
onEventCreated() {
this.loadEvents();
}
onEventDeleted() {
this.loadEvents();
}
// Sidebar helpers
updateAvailableWhatTypes() {
const set = new Set<string>();
for (const e of this.events) {
const w = e?.properties?.what;
if (w) set.add(w);
}
this.availableWhatTypes = Array.from(set).sort();
}
applyWhatFilter() {
console.log('🔍 Application du filtre what:', this.selectedWhatFilter);
console.log('📊 Événements avant filtrage:', this.events.length);
if (this.selectedWhatFilter) {
const prefix = this.selectedWhatFilter;
this.filteredEvents = this.events.filter(e => String(e?.properties?.what || '').startsWith(prefix));
console.log('✅ Événements après filtrage par', prefix + ':', this.filteredEvents.length);
} else {
this.filteredEvents = [...this.events];
console.log('📋 Aucun filtre appliqué, tous les événements conservés');
}
this.convertToCalendarEvents();
if (this.selectedDate) {
this.applyDateFilter();
} else {
this.buildGroupedEvents();
}
}
onWhatFilterChange(value: string) {
this.selectedWhatFilter = value || '';
this.applyWhatFilter();
}
private getEventStartDate(ev: OedbEvent): Date {
const ds = ev.properties.start || ev.properties.when;
return this.parseEventDate(ds);
}
private toDateKey(d: Date): string {
const y = d.getFullYear();
const m = (d.getMonth() + 1).toString().padStart(2, '0');
const da = d.getDate().toString().padStart(2, '0');
return `${y}-${m}-${da}`;
}
buildGroupedEvents() {
const groups: Record<string, { date: Date; items: OedbEvent[] }> = {};
const source = this.filteredEvents.length ? this.filteredEvents : this.events;
for (const ev of source) {
const d = this.getEventStartDate(ev);
const key = this.toDateKey(d);
if (!groups[key]) groups[key] = { date: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
groups[key].items.push(ev);
}
const result = Object.keys(groups)
.sort((a, b) => groups[a].date.getTime() - groups[b].date.getTime())
.map(k => ({ dateKey: k, date: groups[k].date, items: groups[k].items.sort((a, b) => this.getEventStartDate(a).getTime() - this.getEventStartDate(b).getTime()) }));
this.groupedEvents = result;
}
applyDateFilter() {
if (!this.selectedDate) {
this.buildGroupedEvents();
return;
}
const selectedDateKey = this.toDateKey(this.selectedDate);
const groups: Record<string, { date: Date; items: OedbEvent[] }> = {};
const source = this.filteredEvents.length ? this.filteredEvents : this.events;
for (const ev of source) {
const d = this.getEventStartDate(ev);
const key = this.toDateKey(d);
if (key === selectedDateKey) {
if (!groups[key]) groups[key] = { date: new Date(d.getFullYear(), d.getMonth(), d.getDate()), items: [] };
groups[key].items.push(ev);
}
}
const result = Object.keys(groups)
.sort((a, b) => groups[a].date.getTime() - groups[b].date.getTime())
.map(k => ({ dateKey: k, date: groups[k].date, items: groups[k].items.sort((a, b) => this.getEventStartDate(a).getTime() - this.getEventStartDate(b).getTime()) }));
this.groupedEvents = result;
}
formatDayHeader(d: Date): string {
const days = ['Dimanche','Lundi','Mardi','Mercredi','Jeudi','Vendredi','Samedi'];
const months = ['janvier','février','mars','avril','mai','juin','juillet','août','septembre','octobre','novembre','décembre'];
return `${days[d.getDay()]} ${d.getDate()} ${months[d.getMonth()]} ${d.getFullYear()}`;
}
getEmojiForWhat(what?: string): string | null {
if (!what) return null;
const spec: any = (oedb.presets.what as any)[what];
return spec?.emoji || null;
}
getImageForWhat(what?: string): string | null {
if (!what) return null;
const spec: any = (oedb.presets.what as any)[what];
if (spec?.image) {
const img: string = spec.image;
return img.startsWith('/') ? img : `/${img}`;
}
return null;
}
selectFromSidebar(ev: OedbEvent) {
this.selectedEvent = ev;
}
scrollToDateInSidebar(date: Date) {
setTimeout(() => {
const dateKey = this.toDateKey(date);
const dayGroupElement = document.querySelector(`[data-date-key="${dateKey}"]`);
if (dayGroupElement) {
dayGroupElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
}
scrollToToday() {
setTimeout(() => {
const today = new Date();
const todayKey = this.toDateKey(today);
const todayGroupElement = document.querySelector(`[data-date-key="${todayKey}"]`);
if (todayGroupElement) {
todayGroupElement.scrollIntoView({ behavior: 'smooth', block: 'start' });
}
}, 100);
}
clearDateFilter() {
this.selectedDate = null;
this.buildGroupedEvents();
}
}

View file

@ -0,0 +1,132 @@
<div class="calendar-container">
<!-- En-tête du calendrier -->
<div class="calendar-header">
<div class="calendar-controls">
<button class="btn btn-nav" (click)="previousMonth()"></button>
<h2 class="calendar-title">{{getMonthName()}} {{currentYear}}</h2>
<button class="btn btn-nav" (click)="nextMonth()"></button>
</div>
<button class="btn btn-today" (click)="goToToday()">Aujourd'hui</button>
</div>
<!-- Statistiques -->
<div class="calendar-stats">
<div class="stat-item">
<span class="stat-number">{{getTotalEventsCount()}}</span>
<span class="stat-label">Total événements</span>
</div>
<div class="stat-item">
<span class="stat-number">{{getEventsThisMonth()}}</span>
<span class="stat-label">Ce mois</span>
</div>
</div>
<!-- Grille du calendrier -->
<div class="calendar-grid">
<!-- En-têtes des jours -->
<div class="calendar-weekdays">
@for (day of weekDays; track day) {
<div class="weekday-header">{{day}}</div>
}
</div>
<!-- Jours du calendrier -->
<div class="calendar-days">
@for (day of calendarDays; track day.getTime()) {
<div
class="calendar-day"
[class.today]="isToday(day)"
[class.other-month]="!isCurrentMonth(day)"
[class.weekend]="isWeekend(day)"
[class.selected]="selectedDate?.toDateString() === day.toDateString()"
(click)="onDateClick(day)">
<div class="day-number">{{day.getDate()}}</div>
@if (getEventCountForDate(day) > 0) {
<div class="event-indicator">
<span class="event-count">{{getEventCountForDate(day)}}</span>
</div>
}
<div class="day-events">
@for (event of getEventsForDate(day).slice(0, 3); track event.id) {
<div
class="event-preview"
[class]="'event-type-' + (event.type || 'default')"
(click)="onEventClick(event, $event)"
[title]="event.title">
{{event.title}}
</div>
}
@if (getEventsForDate(day).length > 3) {
<div class="more-events">+{{getEventsForDate(day).length - 3}} autres</div>
}
</div>
</div>
}
</div>
</div>
<!-- Panel de détails de l'événement -->
@if (showEventDetails && selectedEvent) {
<div class="event-details-panel">
<div class="panel-header">
<h3>Détails de l'événement</h3>
<button class="btn-close" (click)="closeEventDetails()">×</button>
</div>
<div class="panel-content">
<div class="event-title">{{selectedEvent.title}}</div>
@if (selectedEvent.description) {
<div class="event-description">
<strong>Description :</strong>
<p>{{selectedEvent.description}}</p>
</div>
}
@if (selectedEvent.location) {
<div class="event-location">
<strong>📍 Lieu :</strong>
<span>{{selectedEvent.location}}</span>
</div>
}
<div class="event-datetime">
<strong>📅 Date :</strong>
<span>{{selectedEvent.start | date:'dd/MM/yyyy à HH:mm'}}</span>
</div>
@if (selectedEvent.end) {
<div class="event-end">
<strong>⏰ Fin :</strong>
<span>{{selectedEvent.end | date:'dd/MM/yyyy à HH:mm'}}</span>
</div>
}
@if (selectedEvent.type) {
<div class="event-type">
<strong>Type :</strong>
<span class="type-badge">{{selectedEvent.type}}</span>
</div>
}
@if (selectedEvent.properties) {
<div class="event-properties">
<strong>Propriétés :</strong>
<div class="properties-list">
@for (prop of getObjectKeys(selectedEvent.properties); track prop) {
<div class="property-item">
<span class="property-key">{{prop}} :</span>
<span class="property-value">{{selectedEvent.properties[prop]}}</span>
</div>
}
</div>
</div>
}
</div>
</div>
}
</div>

View file

@ -0,0 +1,563 @@
:host {
display: block;
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
background: #f8f9fa;
border-radius: 12px;
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
overflow: hidden;
}
.calendar-container {
background: white;
border-radius: 12px;
overflow: hidden;
}
/* En-tête du calendrier */
.calendar-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 15px;
}
.calendar-controls {
display: flex;
align-items: center;
gap: 15px;
}
.btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 8px 12px;
border-radius: 6px;
cursor: pointer;
transition: all 0.3s ease;
font-size: 14px;
font-weight: 500;
}
.btn:hover {
background: rgba(255, 255, 255, 0.3);
transform: translateY(-1px);
}
.btn-nav {
font-size: 18px;
padding: 8px 12px;
min-width: 40px;
}
.btn-today {
background: rgba(255, 255, 255, 0.9);
color: #667eea;
font-weight: 600;
}
.btn-today:hover {
background: white;
transform: translateY(-1px);
}
.calendar-title {
margin: 0;
font-size: 24px;
font-weight: 600;
text-align: center;
min-width: 200px;
}
/* Statistiques */
.calendar-stats {
background: #f8f9fa;
padding: 15px 20px;
display: flex;
gap: 30px;
border-bottom: 1px solid #e9ecef;
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
text-align: center;
}
.stat-number {
font-size: 24px;
font-weight: 700;
color: #667eea;
line-height: 1;
}
.stat-label {
font-size: 12px;
color: #6c757d;
margin-top: 4px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* Grille du calendrier */
.calendar-grid {
background: white;
}
.calendar-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
background: #f8f9fa;
border-bottom: 2px solid #e9ecef;
}
.weekday-header {
padding: 15px 8px;
text-align: center;
font-weight: 600;
color: #495057;
font-size: 14px;
text-transform: uppercase;
letter-spacing: 0.5px;
border-right: 1px solid #e9ecef;
}
.weekday-header:last-child {
border-right: none;
}
.calendar-days {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0;
}
.calendar-day {
min-height: 120px;
padding: 8px;
border-right: 1px solid #e9ecef;
border-bottom: 1px solid #e9ecef;
cursor: pointer;
transition: all 0.2s ease;
position: relative;
background: white;
display: flex;
flex-direction: column;
}
.calendar-day:nth-child(7n) {
border-right: none;
}
.calendar-day:hover {
background: #f8f9fa;
transform: scale(1.02);
z-index: 1;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.calendar-day.today {
background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%);
border: 2px solid #2196f3;
}
.calendar-day.today .day-number {
background: #2196f3;
color: white;
font-weight: 700;
}
.calendar-day.other-month {
background: #f8f9fa;
color: #adb5bd;
}
.calendar-day.other-month .day-number {
color: #adb5bd;
}
.calendar-day.weekend {
background: #f8f9fa;
}
.calendar-day.selected {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.calendar-day.selected .day-number {
background: rgba(255, 255, 255, 0.2);
color: white;
}
.day-number {
font-size: 16px;
font-weight: 600;
color: #495057;
background: #f8f9fa;
border-radius: 50%;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 8px;
transition: all 0.2s ease;
}
/* Indicateurs d'événements */
.event-indicator {
position: absolute;
top: 8px;
right: 8px;
background: #ff4757;
color: white;
border-radius: 50%;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
font-size: 11px;
font-weight: 700;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.day-events {
flex: 1;
display: flex;
flex-direction: column;
gap: 2px;
margin-top: 4px;
}
.event-preview {
background: #667eea;
color: white;
padding: 2px 6px;
border-radius: 4px;
font-size: 11px;
font-weight: 500;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
transition: all 0.2s ease;
border-left: 3px solid #5a67d8;
max-width: 150px;
overflow: auto;
text-overflow: ellipsis;
}
.event-preview:hover {
background: #5a67d8;
transform: translateX(2px);
}
.event-type-conference {
background: #4caf50;
border-left-color: #388e3c;
}
.event-type-conference:hover {
background: #388e3c;
}
.event-type-workshop {
background: #ff9800;
border-left-color: #f57c00;
}
.event-type-workshop:hover {
background: #f57c00;
}
.event-type-meeting {
background: #9c27b0;
border-left-color: #7b1fa2;
}
.event-type-meeting:hover {
background: #7b1fa2;
}
.event-type-default {
background: #6c757d;
border-left-color: #495057;
}
.event-type-default:hover {
background: #495057;
}
.more-events {
background: #e9ecef;
color: #6c757d;
padding: 2px 6px;
border-radius: 4px;
font-size: 10px;
font-weight: 600;
text-align: center;
margin-top: auto;
}
/* Panel de détails d'événement */
.event-details-panel {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: white;
border-radius: 12px;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.3);
z-index: 1000;
max-width: 500px;
width: 90%;
max-height: 80vh;
overflow-y: auto;
animation: slideIn 0.3s ease;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translate(-50%, -60%);
}
to {
opacity: 1;
transform: translate(-50%, -50%);
}
}
.panel-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 20px;
border-radius: 12px 12px 0 0;
display: flex;
justify-content: space-between;
align-items: center;
}
.panel-header h3 {
margin: 0;
font-size: 20px;
font-weight: 600;
}
.btn-close {
background: rgba(255, 255, 255, 0.2);
border: none;
color: white;
font-size: 24px;
width: 32px;
height: 32px;
border-radius: 50%;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.btn-close:hover {
background: rgba(255, 255, 255, 0.3);
transform: scale(1.1);
}
.panel-content {
padding: 20px;
}
.event-title {
font-size: 24px;
font-weight: 700;
color: #2c3e50;
margin-bottom: 20px;
line-height: 1.3;
}
.event-description,
.event-location,
.event-datetime,
.event-end,
.event-type {
margin-bottom: 15px;
padding: 12px;
background: #f8f9fa;
border-radius: 8px;
border-left: 4px solid #667eea;
}
.event-description strong,
.event-location strong,
.event-datetime strong,
.event-end strong,
.event-type strong {
display: block;
color: #495057;
font-size: 14px;
margin-bottom: 8px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.event-description p {
margin: 0;
color: #6c757d;
line-height: 1.5;
}
.event-location span,
.event-datetime span,
.event-end span {
color: #2c3e50;
font-weight: 500;
}
.type-badge {
background: #667eea;
color: white;
padding: 4px 12px;
border-radius: 20px;
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.event-properties {
margin-top: 20px;
padding: 15px;
background: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.event-properties strong {
display: block;
color: #495057;
font-size: 14px;
margin-bottom: 12px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.properties-list {
display: flex;
flex-direction: column;
gap: 8px;
}
.property-item {
display: flex;
justify-content: space-between;
align-items: center;
padding: 8px 12px;
background: white;
border-radius: 6px;
border: 1px solid #e9ecef;
}
.property-key {
font-weight: 600;
color: #495057;
font-size: 13px;
}
.property-value {
color: #6c757d;
font-size: 13px;
text-align: right;
max-width: 200px;
word-break: break-word;
}
/* Responsive Design */
@media (max-width: 768px) {
.calendar-header {
flex-direction: column;
text-align: center;
gap: 10px;
}
.calendar-controls {
order: 2;
}
.calendar-title {
order: 1;
font-size: 20px;
min-width: auto;
}
.btn-today {
order: 3;
}
.calendar-stats {
justify-content: center;
gap: 20px;
}
.calendar-day {
min-height: 80px;
padding: 4px;
}
.day-number {
width: 24px;
height: 24px;
font-size: 14px;
}
.event-preview {
font-size: 10px;
padding: 1px 4px;
}
.event-details-panel {
width: 95%;
max-height: 90vh;
}
.panel-content {
padding: 15px;
}
.event-title {
font-size: 20px;
}
}
@media (max-width: 480px) {
.calendar-day {
min-height: 60px;
padding: 2px;
}
.weekday-header {
padding: 10px 4px;
font-size: 12px;
}
.day-number {
width: 20px;
height: 20px;
font-size: 12px;
}
.event-preview {
font-size: 9px;
padding: 1px 2px;
}
.more-events {
font-size: 8px;
}
}

View file

@ -0,0 +1,164 @@
import { Component, Input, Output, EventEmitter, OnInit, OnDestroy } from '@angular/core';
import { CommonModule } from '@angular/common';
export interface CalendarEvent {
id: string;
title: string;
start: Date;
end?: Date;
description?: string;
location?: string;
type?: string;
properties?: any;
}
@Component({
selector: 'app-calendar',
standalone: true,
imports: [CommonModule],
templateUrl: 'calendar.html',
styleUrl: 'calendar.scss'
})
export class CalendarComponent implements OnInit, OnDestroy {
@Input() events: CalendarEvent[] = [];
@Output() eventClick = new EventEmitter<CalendarEvent>();
@Output() dateClick = new EventEmitter<Date>();
currentDate = new Date();
selectedDate: Date | null = null;
selectedEvent: CalendarEvent | null = null;
showEventDetails = false;
// Configuration du calendrier
weekDays = ['Lun', 'Mar', 'Mer', 'Jeu', 'Ven', 'Sam', 'Dim'];
months = [
'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin',
'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre'
];
calendarDays: Date[] = [];
currentMonth: number;
currentYear: number;
constructor() {
this.currentMonth = this.currentDate.getMonth();
this.currentYear = this.currentDate.getFullYear();
}
ngOnInit() {
this.generateCalendar();
}
ngOnDestroy() {
// Cleanup si nécessaire
}
generateCalendar() {
this.calendarDays = [];
// Premier jour du mois
const firstDay = new Date(this.currentYear, this.currentMonth, 1);
// Dernier jour du mois
const lastDay = new Date(this.currentYear, this.currentMonth + 1, 0);
// Commencer le lundi de la semaine qui contient le premier jour
const startDate = new Date(firstDay);
const dayOfWeek = firstDay.getDay();
const mondayOffset = dayOfWeek === 0 ? -6 : 1 - dayOfWeek; // Lundi = 1
startDate.setDate(firstDay.getDate() + mondayOffset);
// Générer 42 jours (6 semaines)
for (let i = 0; i < 42; i++) {
const date = new Date(startDate);
date.setDate(startDate.getDate() + i);
this.calendarDays.push(date);
}
}
getEventsForDate(date: Date): CalendarEvent[] {
return this.events.filter(event => {
const eventDate = new Date(event.start);
return eventDate.toDateString() === date.toDateString();
});
}
getEventCountForDate(date: Date): number {
return this.getEventsForDate(date).length;
}
isToday(date: Date): boolean {
const today = new Date();
return date.toDateString() === today.toDateString();
}
isCurrentMonth(date: Date): boolean {
return date.getMonth() === this.currentMonth;
}
isWeekend(date: Date): boolean {
const day = date.getDay();
return day === 0 || day === 6; // Dimanche ou Samedi
}
onDateClick(date: Date) {
this.selectedDate = date;
this.dateClick.emit(date);
}
onEventClick(event: CalendarEvent, $event: Event) {
$event.stopPropagation();
this.selectedEvent = event;
this.showEventDetails = true;
this.eventClick.emit(event);
}
closeEventDetails() {
this.showEventDetails = false;
this.selectedEvent = null;
}
previousMonth() {
this.currentMonth--;
if (this.currentMonth < 0) {
this.currentMonth = 11;
this.currentYear--;
}
this.generateCalendar();
}
nextMonth() {
this.currentMonth++;
if (this.currentMonth > 11) {
this.currentMonth = 0;
this.currentYear++;
}
this.generateCalendar();
}
goToToday() {
this.currentDate = new Date();
this.currentMonth = this.currentDate.getMonth();
this.currentYear = this.currentDate.getFullYear();
this.generateCalendar();
}
getMonthName(): string {
return this.months[this.currentMonth];
}
getTotalEventsCount(): number {
return this.events.length;
}
getEventsThisMonth(): number {
return this.events.filter(event => {
const eventDate = new Date(event.start);
return eventDate.getMonth() === this.currentMonth &&
eventDate.getFullYear() === this.currentYear;
}).length;
}
getObjectKeys(obj: any): string[] {
return Object.keys(obj || {});
}
}

View file

@ -0,0 +1,177 @@
<div class="batch-edit-page">
<div class="container">
<header class="page-header">
<h1>Modification en masse</h1>
<p>Modifiez plusieurs événements en une seule opération</p>
</header>
<div class="filters-section">
<div class="filters-card">
<h2>Filtres</h2>
<div class="filter-group">
<label for="search">Recherche textuelle :</label>
<input
id="search"
type="text"
[(ngModel)]="searchText"
placeholder="Rechercher dans les événements..."
class="search-input">
</div>
<div class="filter-group">
<label for="whatFilter">Type d'événement :</label>
<select
id="whatFilter"
[(ngModel)]="selectedWhatFilter">
<option value="">Tous les types</option>
@for (whatType of availableWhatTypes; track whatType) {
<option [value]="whatType">{{whatType}}</option>
}
</select>
</div>
<div class="selection-info">
<span class="selection-count">
{{selectedEvents().size}} événement(s) sélectionné(s)
</span>
<div class="selection-actions">
<button class="btn btn-secondary" (click)="selectAll()">Tout sélectionner</button>
<button class="btn btn-secondary" (click)="clearSelection()">Tout désélectionner</button>
</div>
</div>
</div>
</div>
<div class="batch-operation-section">
<div class="operation-card">
<h2>Opération en masse</h2>
<div class="operation-form">
<div class="form-group">
<label for="operationType">Type d'opération :</label>
<select
id="operationType"
[(ngModel)]="batchOperation().type"
(ngModelChange)="onBatchOperationChange()">
<option value="none">Sélectionner une opération</option>
<option value="changeWhat">Changer le type d'événement</option>
<option value="setField">Définir un champ</option>
<option value="delete">Supprimer</option>
</select>
</div>
@if (batchOperation().type === 'changeWhat') {
<div class="form-group">
<label for="newWhat">Nouveau type :</label>
<select id="newWhat" [(ngModel)]="batchOperation().what">
<option value="">Sélectionner un type</option>
@for (whatType of availableWhatTypes; track whatType) {
<option [value]="whatType">{{whatType}}</option>
}
</select>
</div>
}
@if (batchOperation().type === 'setField') {
<div class="form-row">
<div class="form-group">
<label for="fieldKey">Nom du champ :</label>
<input
id="fieldKey"
type="text"
[(ngModel)]="batchOperation().fieldKey"
placeholder="ex: label, description, where">
</div>
<div class="form-group">
<label for="fieldValue">Valeur :</label>
<input
id="fieldValue"
type="text"
[(ngModel)]="batchOperation().fieldValue"
placeholder="Nouvelle valeur">
</div>
</div>
}
<div class="form-actions">
<button
class="btn btn-primary"
(click)="applyBatchOperation()"
[disabled]="isProcessing() || selectedEvents().size === 0 || batchOperation().type === 'none'">
@if (isProcessing()) {
⏳ Traitement en cours...
} @else {
⚡ Appliquer l'opération
}
</button>
</div>
</div>
@if (batchResult()) {
<div class="result-summary">
<h3>Résultat de l'opération</h3>
<div class="result-stats">
<div class="stat success">
<span class="stat-number">{{batchResult()!.success}}</span>
<span class="stat-label">Succès</span>
</div>
<div class="stat failed">
<span class="stat-number">{{batchResult()!.failed}}</span>
<span class="stat-label">Échecs</span>
</div>
<div class="stat network-error">
<span class="stat-number">{{batchResult()!.networkErrors}}</span>
<span class="stat-label">Erreurs réseau</span>
</div>
<div class="stat total">
<span class="stat-number">{{batchResult()!.total}}</span>
<span class="stat-label">Total</span>
</div>
</div>
</div>
}
</div>
</div>
<div class="events-section">
<div class="events-header">
<h2>Événements ({{filteredEvents.length}})</h2>
<div class="view-options">
<button class="btn btn-secondary">Vue carte</button>
<button class="btn btn-secondary">Vue liste</button>
</div>
</div>
<div class="events-content">
<div class="events-list">
@for (event of filteredEvents; track event.id) {
<div
class="event-card"
[class.selected]="selectedEvents().has(event.id || event.properties?.id)"
(click)="toggleEventSelection(event.id || event.properties?.id)">
<div class="event-checkbox">
<input
type="checkbox"
[checked]="selectedEvents().has(event.id || event.properties?.id)"
(change)="toggleEventSelection(event.id || event.properties?.id)">
</div>
<div class="event-content">
<div class="event-header">
<h4 class="event-title">{{getEventTitle(event)}}</h4>
<span class="event-type">{{getEventType(event)}}</span>
</div>
<div class="event-meta">
@if (getEventDate(event)) {
<div class="event-date">📅 {{formatDate(getEventDate(event))}}</div>
}
</div>
</div>
</div>
}
</div>
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,365 @@
.batch-edit-page {
min-height: 100vh;
background: #f8f9fa;
padding: 2rem 0;
display: flex;
flex-direction: column;
.container {
max-width: 1400px;
margin: 0 auto;
padding: 0 1rem;
}
.page-header {
text-align: center;
margin-bottom: 3rem;
h1 {
color: #2c3e50;
margin-bottom: 0.5rem;
}
p {
color: #6c757d;
font-size: 1.1rem;
}
}
.filters-section {
margin-bottom: 2rem;
.filters-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
border-bottom: 2px solid #3498db;
padding-bottom: 0.5rem;
}
.filter-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #2c3e50;
}
input, select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
}
.search-input {
font-size: 1.1rem;
padding: 1rem;
}
}
.selection-info {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1rem;
background: #f8f9fa;
border-radius: 6px;
margin-top: 1rem;
.selection-count {
font-weight: 600;
color: #3498db;
}
.selection-actions {
display: flex;
gap: 0.5rem;
}
}
}
}
.batch-operation-section {
margin-bottom: 2rem;
.operation-card {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
border-bottom: 2px solid #e74c3c;
padding-bottom: 0.5rem;
}
.operation-form {
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #2c3e50;
}
input, select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.form-actions {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #ecf0f1;
}
}
.result-summary {
margin-top: 2rem;
padding: 1.5rem;
background: #f8f9fa;
border-radius: 6px;
h3 {
color: #2c3e50;
margin-bottom: 1rem;
}
.result-stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 1rem;
.stat {
text-align: center;
padding: 1rem;
border-radius: 6px;
.stat-number {
display: block;
font-size: 2rem;
font-weight: 700;
margin-bottom: 0.25rem;
}
.stat-label {
font-size: 0.9rem;
font-weight: 500;
}
&.success {
background: #d4edda;
color: #155724;
.stat-number {
color: #28a745;
}
}
&.failed {
background: #f8d7da;
color: #721c24;
.stat-number {
color: #dc3545;
}
}
&.network-error {
background: #fff3cd;
color: #856404;
.stat-number {
color: #ffc107;
}
}
&.total {
background: #d1ecf1;
color: #0c5460;
.stat-number {
color: #17a2b8;
}
}
}
}
}
}
}
.events-section {
.events-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #3498db;
h2 {
color: #2c3e50;
margin: 0;
}
.view-options {
display: flex;
gap: 0.5rem;
}
}
.events-content {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
.events-list {
max-height: 70vh;
overflow-y: auto;
.event-card {
display: flex;
align-items: center;
padding: 1rem;
border-bottom: 1px solid #ecf0f1;
cursor: pointer;
transition: all 0.3s;
&:hover {
background: #f8f9fa;
}
&.selected {
background: #e3f2fd;
border-left: 4px solid #3498db;
}
.event-checkbox {
margin-right: 1rem;
input[type="checkbox"] {
width: 18px;
height: 18px;
cursor: pointer;
}
}
.event-content {
flex: 1;
.event-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
margin-bottom: 0.5rem;
.event-title {
color: #2c3e50;
margin: 0;
font-size: 1rem;
font-weight: 600;
flex: 1;
margin-right: 1rem;
}
.event-type {
background: #3498db;
color: white;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-size: 0.8rem;
font-weight: 500;
white-space: nowrap;
}
}
.event-meta {
.event-date {
font-size: 0.9rem;
color: #6c757d;
}
}
}
}
}
}
}
.btn {
padding: 0.75rem 1.5rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 1rem;
font-weight: 600;
transition: all 0.3s;
display: inline-flex;
align-items: center;
gap: 0.5rem;
&:disabled {
opacity: 0.6;
cursor: not-allowed;
}
&.btn-primary {
background: #e74c3c;
color: white;
&:hover:not(:disabled) {
background: #c0392b;
}
}
&.btn-secondary {
background: #95a5a6;
color: white;
&:hover:not(:disabled) {
background: #7f8c8d;
}
}
}
}

View file

@ -0,0 +1,239 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { OedbApi } from '../../services/oedb-api';
import { AllEvents } from '../../maps/all-events/all-events';
import { Menu } from '../home/menu/menu';
interface BatchOperation {
type: 'changeWhat' | 'setField' | 'delete' | 'none';
what?: string;
fieldKey?: string;
fieldValue?: any;
}
interface BatchResult {
success: number;
failed: number;
networkErrors: number;
total: number;
}
@Component({
selector: 'app-batch-edit',
standalone: true,
imports: [CommonModule, FormsModule, AllEvents, Menu],
templateUrl: './batch-edit.html',
styleUrl: './batch-edit.scss'
})
export class BatchEdit {
private oedbApi = inject(OedbApi);
events = signal<any[]>([]);
selectedEvents = signal<Set<string | number>>(new Set());
batchOperation = signal<BatchOperation>({ type: 'none' });
batchResult = signal<BatchResult | null>(null);
isLoading = signal<boolean>(false);
isProcessing = signal<boolean>(false);
// Filtres
searchText = '';
selectedWhatFilter = '';
availableWhatTypes: string[] = [];
constructor() {
this.loadEvents();
}
loadEvents() {
this.isLoading.set(true);
this.oedbApi.getEvents({ limit: 1000 }).subscribe({
next: (response: any) => {
this.events.set(Array.isArray(response?.features) ? response.features : []);
this.updateAvailableWhatTypes();
this.isLoading.set(false);
},
error: (error) => {
console.error('Erreur lors du chargement des événements:', error);
this.isLoading.set(false);
}
});
}
updateAvailableWhatTypes() {
const whatTypes = new Set<string>();
this.events().forEach(event => {
if (event?.properties?.what) {
whatTypes.add(event.properties.what);
}
});
this.availableWhatTypes = Array.from(whatTypes).sort();
}
get filteredEvents() {
let filtered = this.events();
if (this.searchText.trim()) {
const searchLower = this.searchText.toLowerCase();
filtered = filtered.filter(event => {
const label = event?.properties?.label || event?.properties?.name || '';
const description = event?.properties?.description || '';
const what = event?.properties?.what || '';
return label.toLowerCase().includes(searchLower) ||
description.toLowerCase().includes(searchLower) ||
what.toLowerCase().includes(searchLower);
});
}
if (this.selectedWhatFilter) {
filtered = filtered.filter(event => {
const what = event?.properties?.what || '';
return what.startsWith(this.selectedWhatFilter + '.') || what === this.selectedWhatFilter;
});
}
return filtered;
}
toggleEventSelection(eventId: string | number) {
const selected = new Set(this.selectedEvents());
if (selected.has(eventId)) {
selected.delete(eventId);
} else {
selected.add(eventId);
}
this.selectedEvents.set(selected);
}
selectAll() {
const allIds = this.filteredEvents.map(event => event.id || event.properties?.id);
this.selectedEvents.set(new Set(allIds));
}
clearSelection() {
this.selectedEvents.set(new Set());
}
onBatchOperationChange() {
this.batchResult.set(null);
}
async applyBatchOperation() {
const selectedIds = Array.from(this.selectedEvents());
const operation = this.batchOperation();
if (selectedIds.length === 0 || operation.type === 'none') {
return;
}
this.isProcessing.set(true);
this.batchResult.set(null);
let success = 0;
let failed = 0;
let networkErrors = 0;
try {
if (operation.type === 'delete') {
for (const id of selectedIds) {
try {
await this.oedbApi.deleteEvent(id).toPromise();
success++;
} catch (error: any) {
if (error?.status === 0) {
networkErrors++;
} else {
failed++;
}
}
}
} else if (operation.type === 'changeWhat') {
for (const id of selectedIds) {
try {
const event = this.events().find(e => (e.id || e.properties?.id) === id);
if (event) {
const updated = {
...event,
properties: { ...event.properties, what: operation.what }
};
await this.oedbApi.updateEvent(id, updated).toPromise();
success++;
} else {
failed++;
}
} catch (error: any) {
if (error?.status === 0) {
networkErrors++;
} else {
failed++;
}
}
}
} else if (operation.type === 'setField') {
for (const id of selectedIds) {
try {
const event = this.events().find(e => (e.id || e.properties?.id) === id);
if (event && operation.fieldKey) {
const updated = {
...event,
properties: { ...event.properties, [operation.fieldKey]: operation.fieldValue }
};
await this.oedbApi.updateEvent(id, updated).toPromise();
success++;
} else {
failed++;
}
} catch (error: any) {
if (error?.status === 0) {
networkErrors++;
} else {
failed++;
}
}
}
}
this.batchResult.set({
success,
failed,
networkErrors,
total: selectedIds.length
});
// Recharger les événements après l'opération
this.loadEvents();
this.clearSelection();
} catch (error) {
console.error('Erreur lors de l\'opération en masse:', error);
} finally {
this.isProcessing.set(false);
}
}
getEventTitle(event: any): string {
return event?.properties?.label || event?.properties?.name || 'Événement sans nom';
}
getEventType(event: any): string {
return event?.properties?.what || '';
}
getEventDate(event: any): string {
return event?.properties?.start || event?.properties?.when || '';
}
formatDate(dateString: string): string {
if (!dateString) return '';
try {
const date = new Date(dateString);
return date.toLocaleDateString('fr-FR', {
year: 'numeric',
month: 'short',
day: 'numeric'
});
} catch {
return dateString;
}
}
}

View file

@ -0,0 +1,13 @@
<div class="community-upcoming">
<section class="toolbar">
<label>Jours à venir:
<input type="number" min="1" [ngModel]="days()" (ngModelChange)="days.set($event); load()" />
</label>
</section>
<app-all-events [features]="features"></app-all-events>
</div>

View file

@ -0,0 +1,14 @@
.toolbar {
display: flex;
gap: 12px;
align-items: center;
padding: 8px 0;
}
:host{
.community-upcoming{
padding: 1rem;
}
}

View file

@ -0,0 +1,36 @@
import { Component, inject, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { AllEvents } from '../../maps/all-events/all-events';
import { OedbApi } from '../../services/oedb-api';
@Component({
selector: 'app-community-upcoming',
imports: [CommonModule, FormsModule, AllEvents],
templateUrl: './community-upcoming.html',
styleUrl: './community-upcoming.scss'
})
export class CommunityUpcoming {
private api = inject(OedbApi);
days = signal<number>(7);
features: Array<any> = [];
selected: any | null = null;
ngOnInit() {
this.load();
}
load() {
// when: NEXT7DAYS etc. Utilise le param 'when' déjà supporté par l'API
const d = Math.max(1, Number(this.days()) || 7);
const when = `NEXT${d}DAYS`;
this.api.getEvents({ when, what: 'community', limit: 1000 }).subscribe((events: any) => {
const list = Array.isArray(events?.features) ? events.features : [];
// Filtrer côté client pour tout ce qui commence par "community"
this.features = list.filter((f: any) => String(f?.properties?.what || '').startsWith('community'));
});
}
}

View file

@ -0,0 +1,141 @@
<div class="embed-page">
<div class="layout">
<div class="aside">
<header class="page-header">
<h1>Intégration OEDB</h1>
<p>Générez un code d'intégration pour afficher les événements OEDB sur votre site web</p>
</header>
<h2>Comment utiliser</h2>
<ol>
<li>Configurez les paramètres ci-dessus selon vos besoins</li>
<li>Copiez le code généré</li>
<li>Collez-le dans votre page HTML</li>
<li>Le script chargera automatiquement les événements depuis l'API OEDB</li>
</ol>
<div class="features">
<h3>Fonctionnalités</h3>
<ul>
<li>✅ Affichage responsive des événements</li>
<li>✅ Filtrage par type et dates</li>
<li>✅ Thèmes clair et sombre</li>
<li>✅ Mise à jour automatique</li>
<li>✅ Compatible avec tous les navigateurs</li>
</ul>
</div>
</div>
<div class="main">
<div class="container">
<div class="embed-config">
<div class="config-form">
<h2>Configuration</h2>
<div class="form-group">
<label for="apiUrl">URL de l'API :</label>
<input
id="apiUrl"
type="url"
[(ngModel)]="config().apiUrl"
(ngModelChange)="updateConfig()"
placeholder="https://api.openenventdatabase.org">
</div>
<div class="form-group">
<label for="what">Type d'événements :</label>
<select
id="what"
[(ngModel)]="config().what"
(ngModelChange)="updateConfig()">
<option value="culture">Culture</option>
<option value="traffic">Trafic</option>
<option value="sport">Sport</option>
<option value="education">Éducation</option>
<option value="">Tous</option>
</select>
</div>
<div class="form-row">
<div class="form-group">
<label for="start">Date de début :</label>
<input
id="start"
type="date"
[(ngModel)]="config().start"
(ngModelChange)="updateConfig()">
</div>
<div class="form-group">
<label for="end">Date de fin :</label>
<input
id="end"
type="date"
[(ngModel)]="config().end"
(ngModelChange)="updateConfig()">
</div>
</div>
<div class="form-group">
<label for="limit">Nombre d'événements :</label>
<input
id="limit"
type="number"
[(ngModel)]="config().limit"
(ngModelChange)="updateConfig()"
min="1"
max="1000">
</div>
<div class="form-row">
<div class="form-group">
<label for="width">Largeur :</label>
<input
id="width"
type="text"
[(ngModel)]="config().width"
(ngModelChange)="updateConfig()"
placeholder="100%">
</div>
<div class="form-group">
<label for="height">Hauteur :</label>
<input
id="height"
type="text"
[(ngModel)]="config().height"
(ngModelChange)="updateConfig()"
placeholder="400px">
</div>
</div>
<div class="form-group">
<label for="theme">Thème :</label>
<select
id="theme"
[(ngModel)]="config().theme"
(ngModelChange)="updateConfig()">
<option value="light">Clair</option>
<option value="dark">Sombre</option>
</select>
</div>
</div>
<div class="code-output">
<div class="code-header">
<h2>Code d'intégration</h2>
<div class="code-actions">
<button class="btn btn-secondary" (click)="preview()">Aperçu</button>
<button class="btn btn-primary" (click)="copyToClipboard()">Copier</button>
</div>
</div>
<div class="code-container">
<pre><code>{{generatedCode()}}</code></pre>
</div>
</div>
</div>
<div class="usage-info">
</div>
</div>
</div>
</div>

View file

@ -0,0 +1,223 @@
.embed-page {
min-height: 100vh;
background: #f8f9fa;
padding: 2rem 0;
.aside {
padding: 1rem;
}
}
.layout {
display: grid;
grid-template-columns: 400px 1fr;
grid-template-rows: minmax(100vh, auto);
gap: 0;
min-height: 100vh;
&.is-small {
grid-template-columns: 100px 1fr;
}
}
.aside {
background: #f8f9fa;
border-right: 1px solid #e9ecef;
overflow-y: auto;
}
.main {
background: white;
overflow-y: auto;
}
.container {
max-width: 1200px;
margin: 0 auto;
padding: 0 1rem;
}
.page-header {
text-align: center;
margin-bottom: 3rem;
h1 {
color: #2c3e50;
margin-bottom: 0.5rem;
}
p {
color: #6c757d;
font-size: 1.1rem;
}
}
.embed-config {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 2rem;
margin-bottom: 3rem;
@media (max-width: 768px) {
grid-template-columns: 1fr;
}
}
.config-form {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
border-bottom: 2px solid #3498db;
padding-bottom: 0.5rem;
}
.form-group {
margin-bottom: 1.5rem;
label {
display: block;
margin-bottom: 0.5rem;
font-weight: 600;
color: #2c3e50;
}
input, select {
width: 100%;
padding: 0.75rem;
border: 1px solid #ddd;
border-radius: 4px;
font-size: 1rem;
transition: border-color 0.3s;
&:focus {
outline: none;
border-color: #3498db;
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
}
}
}
.form-row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
}
.code-output {
background: white;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
overflow: hidden;
.code-header {
background: #2c3e50;
color: white;
padding: 1rem 1.5rem;
display: flex;
justify-content: space-between;
align-items: center;
h2 {
margin: 0;
font-size: 1.2rem;
}
.code-actions {
display: flex;
gap: 0.5rem;
}
}
.code-container {
padding: 1.5rem;
background: #f8f9fa;
pre {
margin: 0;
background: #2c3e50;
color: #ecf0f1;
padding: 1rem;
border-radius: 4px;
overflow-x: auto;
font-size: 0.9rem;
line-height: 1.4;
code {
font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
}
}
}
}
.usage-info {
background: white;
padding: 2rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
h2 {
color: #2c3e50;
margin-bottom: 1rem;
}
h3 {
color: #34495e;
margin: 1.5rem 0 1rem 0;
}
ol {
padding-left: 1.5rem;
margin-bottom: 2rem;
li {
margin-bottom: 0.5rem;
line-height: 1.6;
}
}
.features {
ul {
list-style: none;
padding: 0;
li {
padding: 0.5rem 0;
border-bottom: 1px solid #ecf0f1;
color: #2c3e50;
}
}
}
}
.btn {
padding: 0.5rem 1rem;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.9rem;
font-weight: 600;
transition: all 0.3s;
&.btn-primary {
background: #3498db;
color: white;
&:hover {
background: #2980b9;
}
}
&.btn-secondary {
background: #95a5a6;
color: white;
&:hover {
background: #7f8c8d;
}
}
}

View file

@ -0,0 +1,23 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { Embed } from './embed';
describe('Embed', () => {
let component: Embed;
let fixture: ComponentFixture<Embed>;
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [Embed]
})
.compileComponents();
fixture = TestBed.createComponent(Embed);
component = fixture.componentInstance;
fixture.detectChanges();
});
it('should create', () => {
expect(component).toBeTruthy();
});
});

View file

@ -0,0 +1,111 @@
import { Component, signal } from '@angular/core';
import { CommonModule } from '@angular/common';
import { FormsModule } from '@angular/forms';
import { Menu } from '../home/menu/menu';
interface EmbedConfig {
apiUrl: string;
what: string;
start: string;
end: string;
limit: number;
width: string;
height: string;
theme: string;
}
@Component({
selector: 'app-embed',
standalone: true,
imports: [CommonModule, FormsModule, Menu],
templateUrl: './embed.html',
styleUrl: './embed.scss'
})
export class Embed {
config = signal<EmbedConfig>({
apiUrl: 'https://api.openenventdatabase.org',
what: 'culture',
start: '',
end: '',
limit: 50,
width: '100%',
height: '400px',
theme: 'light'
});
generatedCode = signal<string>('');
constructor() {
this.updateConfig();
}
updateConfig() {
const config = this.config();
const code = this.generateEmbedCode(config);
this.generatedCode.set(code);
}
private generateEmbedCode(config: EmbedConfig): string {
const params = new URLSearchParams();
if (config.what) params.set('what', config.what);
if (config.start) params.set('start', config.start);
if (config.end) params.set('end', config.end);
if (config.limit) params.set('limit', config.limit.toString());
const queryString = params.toString();
const scriptUrl = `${window.location.origin}/embed.js`;
return `<!-- OpenEventDatabase Embed start-->
<div id="oedb-events" style="width: ${config.width}; height: ${config.height}; border: 1px solid #ddd; border-radius: 8px; overflow: hidden;"></div>
<script src="${scriptUrl}"></script>
<script>
OEDBEmbed.init({
container: '#oedb-events',
apiUrl: '${config.apiUrl}',
params: {
${queryString ? queryString.split('&').map(param => `'${param.split('=')[0]}': '${param.split('=')[1]}'`).join(',\n ') : ''}
},
theme: '${config.theme}'
});
</script>
<!--OpenEventDatabase Embed end-->
`;
}
copyToClipboard() {
const code = this.generatedCode();
navigator.clipboard.writeText(code).then(() => {
// Optionnel : afficher une notification de succès
console.log('Code copié dans le presse-papiers');
});
}
preview() {
// Ouvrir une nouvelle fenêtre avec un aperçu
const previewWindow = window.open('', '_blank', 'width=800,height=600');
if (previewWindow) {
const config = this.config();
const code = this.generateEmbedCode(config);
previewWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Aperçu OEDB Embed</title>
<style>
body { margin: 0; padding: 20px; font-family: Arial, sans-serif; }
.preview-container { max-width: 100%; }
</style>
</head>
<body>
<h2>Aperçu de l'intégration</h2>
<div class="preview-container">
${code}
</div>
</body>
</html>
`);
}
}
}

View file

@ -0,0 +1,75 @@
<section class="docs">
<aside class="sidebar">
<h2>Types d'événements </h2>
<p>(1000 prochains pour les 30 prochains jours)</p>
<ul>
<li *ngFor="let c of counts">
<button (click)="filterByWhat(c.what)" [class.selected]="selectedWhatType === c.what"> {{ c.what }} <span class="badge">{{ c.count }}</span></button>
</li>
</ul>
</aside>
<section class="map-panel">
<app-all-events [features]="filtered"></app-all-events>
</section>
</section>
@if (selectedWhatType && selectedTypeDetails) {
<div class="type-details-panel">
<div class="panel-content">
<div class="panel-header">
<h3>
@if (getEmojiForWhat(selectedWhatType)) {
<span class="emoji">{{ getEmojiForWhat(selectedWhatType) }}</span>
}
@if (getImageForWhat(selectedWhatType)) {
<img [src]="getImageForWhat(selectedWhatType)" alt="" class="type-image">
}
{{ selectedTypeDetails.label || selectedWhatType }}
</h3>
<button class="close-btn" (click)="selectedWhatType = null; selectedTypeDetails = null">×</button>
</div>
<div class="panel-body">
<p class="description">{{ selectedTypeDetails.description || 'Aucune description disponible' }}</p>
@if (selectedTypeDetails.category) {
<div class="detail-item">
<strong>Catégorie:</strong> {{ selectedTypeDetails.category }}
</div>
}
@if (selectedTypeDetails.label && selectedTypeDetails.label !== selectedWhatType) {
<div class="detail-item">
<strong>Nom court:</strong> {{ selectedTypeDetails.label }}
</div>
}
@if (selectedTypeDetails.durationHours) {
<div class="detail-item">
<strong>Durée par défaut:</strong> {{ selectedTypeDetails.durationHours }} heures
</div>
}
@if (getPropertiesForWhat(selectedWhatType)) {
<div class="detail-item">
<strong>Propriétés disponibles:</strong>
<ul class="properties-list">
@for (prop of getObjectKeys(getPropertiesForWhat(selectedWhatType)); track prop) {
<li>
<strong>{{ prop }}:</strong>
{{ getPropertiesForWhat(selectedWhatType)[prop].label || prop }}
@if (getPropertiesForWhat(selectedWhatType)[prop].description) {
<span class="prop-desc">({{ getPropertiesForWhat(selectedWhatType)[prop].description }})</span>
}
</li>
}
</ul>
</div>
}
</div>
</div>
</div>
}

View file

@ -0,0 +1,123 @@
.docs {
display: grid;
grid-template-columns: 350px 1fr;
gap: 12px;
}
.sidebar {
max-height: calc(100vh - 160px);
overflow: auto;
padding: 1rem;
}
.badge {
background: #1976d2;
color: #fff;
border-radius: 10px;
padding: 0 8px;
margin-left: 6px;
float:right;
}
.map-panel {
min-height: 60vh;
}
.type-details-panel {
position: fixed;
bottom: 0;
left: 0;
right: 0;
background: #fff;
border-top: 2px solid #1976d2;
box-shadow: 0 -4px 12px rgba(0,0,0,0.15);
z-index: 1000;
max-height: 50vh;
overflow: hidden;
}
.panel-content {
padding: 16px;
max-height: 50vh;
overflow-y: auto;
}
.panel-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e0e0e0;
}
.panel-header h3 {
margin: 0;
display: flex;
align-items: center;
gap: 8px;
}
.emoji {
font-size: 1.2em;
}
.type-image {
width: 24px;
height: 24px;
object-fit: contain;
}
.close-btn {
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 4px;
width: 32px;
height: 32px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
font-size: 18px;
line-height: 1;
}
.close-btn:hover {
background: #e0e0e0;
}
.panel-body {
.description {
margin: 0 0 12px 0;
color: #555;
line-height: 1.4;
}
}
.detail-item {
margin-bottom: 8px;
strong {
color: #1976d2;
}
}
.properties-list {
margin: 8px 0 0 16px;
padding: 0;
li {
margin-bottom: 4px;
list-style: none;
.prop-desc {
color: #666;
font-style: italic;
}
}
}
button.selected {
background: #e3f2fd;
border-color: #1976d2;
}

View file

@ -0,0 +1,77 @@
import { Component, inject } from '@angular/core';
import { CommonModule } from '@angular/common';
import { OedbApi } from '../../services/oedb-api';
import { AllEvents } from '../../maps/all-events/all-events';
import oedb from '../../../oedb-types';
@Component({
selector: 'app-events-docs',
imports: [CommonModule, AllEvents],
templateUrl: './events-docs.html',
styleUrl: './events-docs.scss'
})
export class EventsDocs {
private api = inject(OedbApi);
features: Array<any> = [];
counts: Array<{ what: string, count: number }> = [];
filtered: Array<any> = [];
selected: any | null = null;
selectedWhatType: string | null = null;
selectedTypeDetails: any = null;
ngOnInit() {
// Charger 1000 events récents
this.api.getEvents({ when: 'NEXT30DAYS', limit: 1000 }).subscribe((events: any) => {
this.features = Array.isArray(events?.features) ? events.features : [];
this.buildCounts();
this.filtered = this.features;
});
}
buildCounts() {
const map = new Map<string, number>();
for (const f of this.features) {
const w = (f?.properties?.what || '').trim();
if (!w) continue;
map.set(w, (map.get(w) || 0) + 1);
}
this.counts = Array.from(map.entries()).sort((a,b) => b[1]-a[1]).map(([what, count]) => ({ what, count }));
}
filterByWhat(what: string) {
this.filtered = this.features.filter(f => String(f?.properties?.what || '').startsWith(what));
this.selectedWhatType = what;
this.loadTypeDetails(what);
}
loadTypeDetails(what: string) {
const presets = oedb.presets.what as any;
this.selectedTypeDetails = presets[what] || null;
}
getEmojiForWhat(what: string): string | null {
const details = this.selectedTypeDetails;
return details?.emoji || null;
}
getImageForWhat(what: string): string | null {
const details = this.selectedTypeDetails;
if (details?.image) {
const img: string = details.image;
return img.startsWith('/') ? img : `/${img}`;
}
return null;
}
getPropertiesForWhat(what: string): any {
const details = this.selectedTypeDetails;
return details?.properties || null;
}
getObjectKeys(obj: any): string[] {
return Object.keys(obj || {});
}
}

Some files were not shown because too many files have changed in this diff Show more