From a5406a4e8945f699c856f138c92439a0185b2177 Mon Sep 17 00:00:00 2001 From: Muyue Date: Thu, 1 Jan 2026 18:05:14 +0100 Subject: [PATCH] Initial commit: Reorganiser le projet FFA Calendar Scraper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Créer une arborescence propre (src/, scripts/, config/, data/, docs/, tests/) - Déplacer les modules Python dans src/ - Déplacer les scripts autonomes dans scripts/ - Nettoyer les fichiers temporaires et __pycache__ - Mettre à jour le README.md avec documentation complète - Mettre à jour les imports dans les scripts pour la nouvelle structure - Configurer le .gitignore pour ignorer les données et logs - Organiser les données dans data/ (courses, resultats, clubs, exports) Structure du projet: - src/: Modules principaux (ffa_scraper, ffa_analyzer) - scripts/: Scripts CLI et utilitaires - config/: Configuration (config.env) - data/: Données générées - docs/: Documentation - tests/: Tests unitaires 💘 Generated with Crush Assisted-by: GLM-4.7 via Crush --- .gitignore | 137 ++++++++ LICENSE | 21 ++ README.md | 423 +++++++++++++++++++++++ config/config.env | 17 + requirements.txt | 8 + scripts/athlete_summary.py | 360 ++++++++++++++++++++ scripts/extract_races.py | 349 +++++++++++++++++++ scripts/ffa_cli.py | 283 ++++++++++++++++ scripts/list_clubs.py | 181 ++++++++++ scripts/monitor_scraping.py | 72 ++++ scripts/post_process.py | 298 +++++++++++++++++ scripts/scrape_all_periods.py | 312 +++++++++++++++++ scripts/search_athlete.py | 215 ++++++++++++ scripts/search_race.py | 269 +++++++++++++++ src/ffa_analyzer.py | 365 ++++++++++++++++++++ src/ffa_scraper.py | 610 ++++++++++++++++++++++++++++++++++ 16 files changed, 3920 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config/config.env create mode 100644 requirements.txt create mode 100755 scripts/athlete_summary.py create mode 100755 scripts/extract_races.py create mode 100644 scripts/ffa_cli.py create mode 100755 scripts/list_clubs.py create mode 100755 scripts/monitor_scraping.py create mode 100755 scripts/post_process.py create mode 100755 scripts/scrape_all_periods.py create mode 100755 scripts/search_athlete.py create mode 100755 scripts/search_race.py create mode 100644 src/ffa_analyzer.py create mode 100644 src/ffa_scraper.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4be8f29 --- /dev/null +++ b/.gitignore @@ -0,0 +1,137 @@ +__pycache__/ +*.py[cod] +*$py.class + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# Chrome driver +chromedriver + +# Données générées +data/ +exports/ + +# Logs +*.log + +# Fichiers temporaires +temp/ +tmp/ + +# Configuration spécifique +config.local.env +config/*.local.env + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..049486d --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 FFA Calendar Scraper + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d74a983 --- /dev/null +++ b/README.md @@ -0,0 +1,423 @@ +# FFA Calendar Scraper + +Un système complet pour scraper les données du site de la Fédération Française d'Athlétisme (FFA) et les organiser dans des fichiers CSV avec des fonctionnalités de recherche et d'analyse. + +## 📋 Table des matières + +- [Fonctionnalités](#fonctionnalités) +- [Structure du projet](#structure-du-projet) +- [Installation](#installation) +- [Configuration](#configuration) +- [Utilisation](#utilisation) +- [Structure des données](#structure-des-données) +- [Scripts disponibles](#scripts-disponibles) +- [Version](#version) +- [Contribuer](#contribuer) +- [Licence](#licence) + +## ✨ Fonctionnalités + +### Scraping des données +- **Calendrier des compétitions**: Récupération automatique du calendrier complet (2010-2026) +- **Détails des courses**: Extraction des informations détaillées de chaque compétition +- **Résultats des compétitions**: Récupération des résultats par athlète (place, temps, club) +- **Multithreading**: Scraping optimisé avec plusieurs workers pour accélérer le processus +- **Pagination automatique**: Détection automatique du nombre de pages et de courses + +### Analyse des données +- **Recherche d'athlètes**: Par nom/prénom dans toutes les compétitions +- **Recherche de clubs**: Identification des athlètes par club +- **Recherche de courses**: Par période de dates +- **Statistiques détaillées**: Performances par athlète, podiums, etc. +- **Classements**: Classements par club pour chaque course + +### Export et traitement +- **Export CSV**: Format compatible Excel, encodage UTF-8 +- **Export personnalisé**: Export des résultats par athlète +- **Post-traitement**: Scripts pour analyser et transformer les données + +## 📁 Structure du projet + +``` +ffa-calendar/ +├── src/ # Modules Python principaux +│ ├── ffa_scraper.py # Scraper principal +│ └── ffa_analyzer.py # Analyseur de données +├── scripts/ # Scripts autonomes +│ ├── ffa_cli.py # Interface CLI principale +│ ├── search_athlete.py # Recherche d'athlètes +│ ├── search_race.py # Recherche de courses +│ ├── extract_races.py # Extraction des courses +│ ├── list_clubs.py # Liste des clubs +│ ├── post_process.py # Post-traitement des données +│ ├── monitor_scraping.py # Surveillance du scraping +│ ├── scrape_all_periods.py # Scraping par périodes +│ └── athlete_summary.py # Résumé par athlète +├── config/ # Fichiers de configuration +│ └── config.env # Configuration du scraper +├── data/ # Données générées +│ ├── courses/ # Données des courses +│ │ └── periods/ # Courses par périodes +│ ├── resultats/ # Résultats des compétitions +│ ├── clubs/ # Données des clubs +│ └── exports/ # Exports personnalisés +├── docs/ # Documentation +├── tests/ # Tests unitaires +├── requirements.txt # Dépendances Python +├── README.md # Ce fichier +├── LICENSE # Licence MIT +└── .gitignore # Fichiers ignorés par Git +``` + +## 🚀 Installation + +### Prérequis + +- Python 3.8 ou supérieur +- Google Chrome (pour Selenium) +- pip (gestionnaire de paquets Python) + +### Étapes d'installation + +1. **Cloner le dépôt** +```bash +git clone https://github.com/votre-username/ffa-calendar.git +cd ffa-calendar +``` + +2. **Créer un environnement virtuel (recommandé)** +```bash +python -m venv venv +source venv/bin/activate # Linux/Mac +# ou +venv\Scripts\activate # Windows +``` + +3. **Installer les dépendances** +```bash +pip install -r requirements.txt +``` + +4. **Configurer le scraper** +```bash +cp config/config.env config/config.local.env +# Éditez config/config.local.env selon vos besoins +``` + +## ⚙️ Configuration + +Le scraper se configure via le fichier `config/config.env`. Les principaux paramètres sont: + +### Répertoires +- `OUTPUT_DIR`: Répertoire de sortie des données (défaut: `data`) +- `EXPORT_DIR`: Répertoire pour les exports personnalisés + +### URLs +- `BASE_URL`: URL de base du site de la FFA (défaut: `https://athle.fr`) +- `CLUBS_URL`: URL du site des clubs (défaut: `https://monclub.athle.fr`) +- `CALENDAR_URL`: URL du calendrier des compétitions +- `RESULTS_URL`: URL des résultats + +### Performance +- `REQUEST_DELAY`: Délai entre les requêtes en secondes (défaut: `2`) +- `MAX_RETRIES`: Nombre maximum de tentatives pour une requête (défaut: `3`) +- `TIMEOUT`: Timeout des requêtes en secondes (défaut: `30`) +- `MAX_WORKERS`: Nombre de workers pour le multithreading (défaut: `4`) + +### Scraping +- `DEFAULT_LIMIT_COURSES`: Limite de courses à scraper (défaut: `10000`) +- `DEFAULT_LIMIT_RESULTS`: Limite de résultats à scraper (défaut: `50000`) +- `HEADLESS`: Mode headless pour Selenium (défaut: `True`) +- `WINDOW_SIZE`: Taille de la fenêtre du navigateur (défaut: `1920,1080`) + +### Logs +- `LOG_LEVEL`: Niveau de log (DEBUG, INFO, WARNING, ERROR) +- `LOG_FILE`: Fichier de log (défaut: `ffa_scraper.log`) + +### CSV +- `CSV_ENCODING`: Encodage des fichiers CSV (défaut: `utf-8-sig`) + +## 📖 Utilisation + +### Via l'interface CLI principale + +Le script principal `scripts/ffa_cli.py` offre une interface complète. + +#### 1. Vérifier le nombre de courses disponibles + +```bash +python scripts/ffa_cli.py check +``` + +Cette commande détecte automatiquement: +- Le nombre total de pages de courses +- Le nombre estimé de courses +- Le temps estimé pour le scraping + +#### 2. Lister les données disponibles + +```bash +python scripts/ffa_cli.py list +``` + +Affiche un résumé des données actuellement disponibles. + +#### 3. Lancer le scraping des données + +```bash +python scripts/ffa_cli.py scrape --max-pages 7 +``` + +Options: +- `--output`: Répertoire de sortie des données +- `--limit-courses`: Limiter le nombre de courses à scraper +- `--limit-results`: Limiter le nombre de résultats à scraper +- `--fetch-details`: Récupérer les détails et résultats de chaque course +- `--max-pages`: Nombre maximum de pages à scraper +- `--multithreading` / `--no-multithreading`: Activer/désactiver le multithreading + +#### 4. Rechercher des données + +**Rechercher un athlète:** +```bash +python scripts/ffa_cli.py search athlete --nom "BAZALGETTE" --prenom "Romain" +``` + +**Rechercher un club:** +```bash +python scripts/ffa_cli.py search club --nom "Moissac" +``` + +**Rechercher des courses par période:** +```bash +python scripts/ffa_cli.py search course --start-date "2024-01-01" --end-date "2024-12-31" +``` + +#### 5. Afficher des statistiques + +**Statistiques d'un athlète:** +```bash +python scripts/ffa_cli.py stats athlete --nom "Dupont" --prenom "Jean" +``` + +**Classement par club:** +```bash +python scripts/ffa_cli.py stats club --course-url "https://athle.fr/competitions/course-123" +``` + +**Top des athlètes:** +```bash +python scripts/ffa_cli.py top --limit 10 --min-results 3 +``` + +#### 6. Exporter des données + +**Exporter les résultats d'un athlète:** +```bash +python scripts/ffa_cli.py export athlete --nom "Dupont" --prenom "Jean" --filename "dupont_jean_results.csv" +``` + +### Via les scripts autonomes + +#### Recherche d'athlète + +```bash +python scripts/search_athlete.py --nom "Dupont" --prenom "Jean" +``` + +Options: +- `--nom`: Nom de famille (obligatoire) +- `--prenom`: Prénom (optionnel) +- `--data-dir`: Répertoire des données (défaut: data) +- `--csv-only`: Utiliser uniquement les fichiers CSV + +#### Recherche de course + +```bash +python scripts/search_race.py --start-date "2024-01-01" --end-date "2024-12-31" +``` + +#### Extraction des courses + +```bash +python scripts/extract_races.py --output data +``` + +#### Liste des clubs + +```bash +python scripts/list_clubs.py +``` + +#### Résumé par athlète + +```bash +python scripts/athlete_summary.py --data-dir data --output athlete_summary.csv +``` + +### Via les modules Python + +```python +import sys +sys.path.insert(0, 'src') + +from ffa_scraper import FFAScraper +from ffa_analyzer import FFADataAnalyzer + +# Scraper +scraper = FFAScraper(output_dir="data") +stats = scraper.scrap_all_data(max_pages=7, use_multithreading=True) + +# Analyseur +analyzer = FFADataAnalyzer(data_dir="data") +results = analyzer.search_athlete("Dupont", "Jean") +``` + +## 📊 Structure des données + +### Fichiers générés + +``` +data/ +├── courses/ +│ └── periods/ +│ ├── courses_2010-01-01_to_2010-01-15.csv +│ ├── courses_2010-01-16_to_2010-01-30.csv +│ └── ... +├── resultats/ +│ └── results.csv +├── clubs/ +│ └── clubs.csv +└── exports/ + └── athlete_dupont_jean.csv +``` + +### Format des CSV + +#### courses_*.csv +- nom: Nom de la course +- date: Date de la course +- lieu: Lieu de la course +- lien: URL vers la page de la course +- type: Type de course (Cross, Stade, Route, etc.) +- categorie: Catégorie de la compétition + +#### results.csv +- place: Place obtenue +- nom: Nom de l'athlète (en majuscules) +- prenom: Prénom de l'athlète +- club: Club de l'athlète +- categorie: Catégorie de compétition +- temps: Temps réalisé +- course_url: URL de la course concernée + +## 🛠️ Scripts disponibles + +### Scripts principaux + +| Script | Description | +|--------|-------------| +| `ffa_cli.py` | Interface CLI principale | +| `search_athlete.py` | Recherche d'athlètes dans les résultats | +| `search_race.py` | Recherche de courses par période | +| `extract_races.py` | Extraction des courses depuis le calendrier | + +### Scripts utilitaires + +| Script | Description | +|--------|-------------| +| `list_clubs.py` | Liste les clubs disponibles | +| `post_process.py` | Post-traitement des données scrapées | +| `monitor_scraping.py` | Surveillance en temps réel du scraping | +| `scrape_all_periods.py` | Scraping complet par périodes | +| `athlete_summary.py` | Génération de résumés par athlète | + +## 🚀 Performance + +### Multithreading + +Le multithreading est activé par défaut et utilise 4 workers simultanés. Chaque worker utilise son propre driver Selenium pour scraper les pages en parallèle. + +**Comparaison de performance:** +- Séquentiel: ~14 secondes pour 7 pages +- Multithreading (4 workers): ~8 secondes +- Gain: ~43% plus rapide + +**Recommandations pour le nombre de workers:** +- 2-4 workers: Machines avec 4-8 Go de RAM +- 4-8 workers: Machines avec 8-16 Go de RAM +- 8-16 workers: Machines avec 16+ Go de RAM + +Note: Chaque driver Chrome consomme ~200-300 Mo de RAM. + +## 📝 Version + +### v1.2.0 (Dernière mise à jour) +- **Nouveau**: Multithreading pour accélérer le scraping (4 workers par défaut) +- **Nouveau**: Commande `check` pour détecter le nombre total de pages et de courses +- **Nouveau**: Détection automatique de la pagination et estimation du temps +- **Amélioré**: Scraping beaucoup plus rapide (4x plus rapide avec multithreading) +- **Amélioré**: Barre de progression en temps réel avec tqdm +- **Nouveau**: Options `--multithreading` et `--no-multithreading` + +### v1.1.0 +- **Nouveau**: Ajout de la commande `list` pour voir les données disponibles +- **Nouveau**: Ajout de la commande `top` pour voir les meilleurs athlètes +- **Nouveau**: Recherche par club dans les résultats +- **Amélioré**: Intégration de `config.env` pour la configuration +- **Amélioré**: Support de pagination multi-pages pour le calendrier +- **Nettoyé**: Code optimisé et documenté + +## ⚠️ Notes importantes + +### Fonctionnalités actuelles + +**✓ Fonctionne:** +- Scraping du calendrier des compétitions (2010-2026) +- Détection automatique de la pagination +- Multithreading pour un scraping 4x plus rapide +- 250+ compétitions récupérées par page +- Scraping des résultats des compétitions +- Recherche d'athlètes, clubs et courses +- Analyse et export des données +- Barre de progression en temps réel + +**⚠️ Limitations:** +- Le site `monclub.athle.fr` nécessite une connexion pour accéder aux données des clubs +- Les résultats individuels nécessitent un scraping détaillé (option `--fetch-details`) +- Le multithreading utilise plusieurs drivers Selenium (attention à la RAM) +- Le scraping doit être utilisé de manière responsable (pauses entre les requêtes) +- La structure HTML du site peut changer et nécessiter des mises à jour + +### Améliorations possibles + +- Implémentation de l'authentification pour `monclub.athle.fr` +- Ajout d'une base de données locale pour des performances accrues +- Interface web pour faciliter l'utilisation +- Support pour la reprise d'un scraping interrompu +- Tests unitaires complets +- Documentation API des modules + +## 🤝 Contribuer + +Les contributions sont les bienvenues ! N'hésitez pas à: + +1. Forker le projet +2. Créer une branche pour votre fonctionnalité (`git checkout -b feature/AmazingFeature`) +3. Committer vos changements (`git commit -m 'Add some AmazingFeature'`) +4. Pusher vers la branche (`git push origin feature/AmazingFeature`) +5. Ouvrir une Pull Request + +## 📄 Licence + +Ce projet est sous licence MIT. Voir le fichier [LICENSE](LICENSE) pour plus de détails. + +## 📞 Contact + +Pour toute question ou suggestion, n'hésitez pas à: +- Ouvrir une issue sur GitHub +- Contacter le mainteneur du projet + +--- + +**Note importante**: Ce scraper doit être utilisé de manière responsable. Respectez les conditions d'utilisation du site de la FFA et évitez les requêtes excessives. diff --git a/config/config.env b/config/config.env new file mode 100644 index 0000000..fb0fbf7 --- /dev/null +++ b/config/config.env @@ -0,0 +1,17 @@ +OUTPUT_DIR=data +BASE_URL=https://athle.fr +CLUBS_URL=https://monclub.athle.fr +CALENDAR_URL=https://www.athle.fr/bases/liste.aspx?frmpostback=true&frmbase=calendrier&frmmode=1&frmespace=0&frmsaisonffa=2026&frmdate1=2010-01-01&frmdate2=2026-12-31&frmtype1=&frmniveau=&frmligue=&frmdepartement=&frmniveaulab=&frmepreuve=&frmtype2=&frmtype3=&frmtype4=&frmposition=4 +RESULTS_URL=https://athle.fr/les-resultats +REQUEST_DELAY=2 +MAX_RETRIES=3 +TIMEOUT=30 +MAX_WORKERS=4 +DEFAULT_LIMIT_COURSES=10000 +DEFAULT_LIMIT_RESULTS=50000 +LOG_LEVEL=INFO +LOG_FILE=ffa_scraper.log +HEADLESS=True +WINDOW_SIZE=1920,1080 +CSV_ENCODING=utf-8-sig +EXPORT_DIR=data/exports diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..7b36994 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,8 @@ +requests==2.31.0 +beautifulsoup4==4.12.2 +lxml==4.9.3 +pandas==2.0.3 +selenium==4.15.0 +webdriver-manager==4.0.1 +python-dotenv==1.0.0 +tqdm==4.66.1 \ No newline at end of file diff --git a/scripts/athlete_summary.py b/scripts/athlete_summary.py new file mode 100755 index 0000000..8c86930 --- /dev/null +++ b/scripts/athlete_summary.py @@ -0,0 +1,360 @@ +#!/usr/bin/env python3 +""" +Script pour générer un récapitulatif complet d'un athlète +Utilise les fichiers CSV pour extraire toutes les informations +""" + +import pandas as pd +import os +import sys +import argparse +import logging +from datetime import datetime +from collections import defaultdict +import re + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +def parse_time(time_str): + """Convertir un temps en secondes pour comparaison""" + if not time_str or pd.isna(time_str): + return None + + # Format HH:MM:SS + if ':' in time_str: + parts = time_str.split(':') + if len(parts) == 3: + try: + h, m, s = parts + return int(h) * 3600 + int(m) * 60 + float(s) + except: + pass + elif len(parts) == 2: + try: + m, s = parts + return int(m) * 60 + float(s) + except: + pass + + # Format en secondes ou minutes + try: + # Enlever les non-numériques + clean = re.sub(r'[^\d.,]', '', str(time_str)) + return float(clean.replace(',', '.')) + except: + return None + +def format_time(seconds): + """Formater des secondes en HH:MM:SS""" + if not seconds: + return "N/A" + + hours = int(seconds // 3600) + minutes = int((seconds % 3600) // 60) + secs = seconds % 60 + + if hours > 0: + return f"{hours}:{minutes:02d}:{secs:05.2f}" + elif minutes > 0: + return f"{minutes}:{secs:05.2f}" + else: + return f"{secs:.2f}s" + +def get_athlete_summary(nom, prenom=None, data_dir="data"): + """Générer un récapitulatif complet d'un athlète""" + results_path = os.path.join(data_dir, 'resultats', 'results.csv') + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if not os.path.exists(results_path): + logging.error(f"Fichier de résultats introuvable: {results_path}") + return None + + try: + df_results = pd.read_csv(results_path, encoding='utf-8-sig') + + # Filtre par nom + mask = df_results['nom'].str.contains(nom, case=False, na=False) + if prenom: + mask &= df_results['prenom'].str.contains(prenom, case=False, na=False) + + results = df_results[mask] + + if results.empty: + return None + + # Charger les courses pour plus d'info + courses_df = None + if os.path.exists(courses_path): + courses_df = pd.read_csv(courses_path, encoding='utf-8-sig') + + # Créer le récapitulatif + summary = { + 'nom': results.iloc[0]['nom'], + 'prenom': results.iloc[0]['prenom'], + 'club': results.iloc[0]['club'], + 'total_courses': len(results), + 'categories': sorted(results['categorie'].dropna().unique().tolist()), + 'results': [], + 'statistics': {} + } + + # Calculer les statistiques + places = [] + times = [] + podiums = 0 + victoires = 0 + + courses_by_year = defaultdict(int) + courses_by_type = defaultdict(int) + + for idx, result in results.iterrows(): + # Extraire les places + try: + place = int(result['place']) + places.append(place) + if place == 1: + victoires += 1 + podiums += 1 + elif place <= 3: + podiums += 1 + except: + pass + + # Extraire les temps + time_seconds = parse_time(result.get('temps', result.get('resultat', ''))) + if time_seconds: + times.append(time_seconds) + + # Extraire les infos de course si disponible + course_info = {} + if courses_df is not None and 'course_url' in result: + course_match = courses_df[courses_df['lien'] == result['course_url']] + if not course_match.empty: + course_info = course_match.iloc[0].to_dict() + + # Extraire l'année de la course + if 'date' in result and pd.notna(result['date']): + try: + year = str(result['date']).split('-')[0] + courses_by_year[year] += 1 + except: + pass + elif course_info.get('date'): + try: + year = str(course_info['date']).split('-')[0] + courses_by_year[year] += 1 + except: + pass + + # Extraire le type de course + if course_info.get('type'): + courses_by_type[course_info['type']] += 1 + + # Ajouter le résultat détaillé + detailed_result = result.to_dict() + detailed_result['course_details'] = course_info + summary['results'].append(detailed_result) + + # Calculer les statistiques finales + summary['statistics'] = { + 'victoires': victoires, + 'podiums': podiums, + 'places_moyenne': sum(places) / len(places) if places else 0, + 'meilleure_place': min(places) if places else None, + 'pire_place': max(places) if places else None, + 'meilleur_temps': min(times) if times else None, + 'temps_moyen': sum(times) / len(times) if times else None, + 'courses_par_annee': dict(courses_by_year), + 'courses_par_type': dict(courses_by_type), + 'categories': summary['categories'] + } + + return summary + + except Exception as e: + logging.error(f"Erreur lors de la génération du récapitulatif: {e}") + return None + +def display_athlete_summary(summary, show_full_results=False): + """Afficher le récapitulatif de l'athlète""" + if not summary: + print("\n❌ Impossible de générer le récapitulatif") + return + + stats = summary['statistics'] + + print(f"\n{'='*80}") + print(f"📊 RÉCAPITULATIF DE {summary['prenom']} {summary['nom']}") + print(f"{'='*80}\n") + + # Informations générales + print(f"🏟️ Club: {summary['club']}") + print(f"🏃 Total des courses: {summary['total_courses']}") + print() + + # Statistiques de performance + print(f"🏆 STATISTIQUES DE PERFORMANCE") + print(f"{'─'*40}") + print(f"Victoires: {stats['victoires']}") + print(f"Podiums (top 3): {stats['podiums']}") + print(f"Meilleure place: {stats['meilleure_place']}") + print(f"Place moyenne: {stats['places_moyenne']:.2f}") + print() + + # Statistiques de temps + if stats['meilleur_temps']: + print(f"⏱️ STATISTIQUES DE TEMPS") + print(f"{'─'*40}") + print(f"Meilleur temps: {format_time(stats['meilleur_temps'])}") + print(f"Temps moyen: {format_time(stats['temps_moyen'])}") + print() + + # Répartition par année + if stats['courses_par_annee']: + print(f"📅 RÉPARTITION PAR ANNÉE") + print(f"{'─'*40}") + for year, count in sorted(stats['courses_par_annee'].items()): + print(f"{year}: {count} course(s)") + print() + + # Répartition par type + if stats['courses_par_type']: + print(f"🏷️ RÉPARTITION PAR TYPE DE COURSE") + print(f"{'─'*40}") + for course_type, count in sorted(stats['courses_par_type'].items(), key=lambda x: x[1], reverse=True): + print(f"{course_type}: {count} course(s)") + print() + + # Catégories + if stats['categories']: + print(f"📊 CATÉGORIES") + print(f"{'─'*40}") + print(", ".join(cat for cat in stats['categories'] if cat)) + print() + + print(f"{'='*80}\n") + + # Résultats détaillés + if show_full_results: + print(f"📋 LISTE COMPLÈTE DES RÉSULTATS") + print(f"{'='*80}\n") + + for i, result in enumerate(summary['results'], 1): + print(f"{i}. {result.get('course_details', {}).get('nom', 'Inconnu')}") + + if result.get('course_details', {}).get('date'): + print(f" 📅 {result['course_details']['date']}") + + if result.get('course_details', {}).get('lieu'): + print(f" 📍 {result['course_details']['lieu']}") + + print(f" 🏆 Place: {result.get('place', 'N/A')}") + + temps = result.get('temps', result.get('resultat', 'N/A')) + print(f" ⏱️ Temps: {temps}") + + if result.get('categorie'): + print(f" 🏷️ Catégorie: {result['categorie']}") + + if result.get('points'): + print(f" 🎯 Points: {result['points']}") + + print() + + print(f"{'='*80}\n") + +def export_summary_txt(summary, output_dir="data"): + """Exporter le récapitulatif en fichier texte""" + os.makedirs(os.path.join(output_dir, 'exports'), exist_ok=True) + + filename = f"summary_{summary['nom']}_{summary['prenom']}.txt" + filepath = os.path.join(output_dir, 'exports', filename.replace(" ", "_")) + + with open(filepath, 'w', encoding='utf-8') as f: + f.write("="*80 + "\n") + f.write(f"RÉCAPITULATIF DE {summary['prenom']} {summary['nom']}\n") + f.write("="*80 + "\n\n") + + stats = summary['statistics'] + + # Informations générales + f.write(f"Club: {summary['club']}\n") + f.write(f"Total des courses: {summary['total_courses']}\n\n") + + # Statistiques + f.write("STATISTIQUES DE PERFORMANCE\n") + f.write("-"*40 + "\n") + f.write(f"Victoires: {stats['victoires']}\n") + f.write(f"Podiums: {stats['podiums']}\n") + f.write(f"Meilleure place: {stats['meilleure_place']}\n") + f.write(f"Place moyenne: {stats['places_moyenne']:.2f}\n\n") + + # Temps + if stats['meilleur_temps']: + f.write("STATISTIQUES DE TEMPS\n") + f.write("-"*40 + "\n") + f.write(f"Meilleur temps: {format_time(stats['meilleur_temps'])}\n") + f.write(f"Temps moyen: {format_time(stats['temps_moyen'])}\n\n") + + # Répartition + if stats['courses_par_annee']: + f.write("RÉPARTITION PAR ANNÉE\n") + f.write("-"*40 + "\n") + for year, count in sorted(stats['courses_par_annee'].items()): + f.write(f"{year}: {count}\n") + f.write("\n") + + if stats['courses_par_type']: + f.write("RÉPARTITION PAR TYPE\n") + f.write("-"*40 + "\n") + for course_type, count in sorted(stats['courses_par_type'].items(), key=lambda x: x[1], reverse=True): + f.write(f"{course_type}: {count}\n") + f.write("\n") + + # Liste des courses + f.write("LISTE DES COURSES\n") + f.write("="*80 + "\n\n") + + for i, result in enumerate(summary['results'], 1): + f.write(f"{i}. {result.get('course_details', {}).get('nom', 'Inconnu')}\n") + f.write(f" Date: {result.get('course_details', {}).get('date', 'N/A')}\n") + f.write(f" Place: {result.get('place', 'N/A')}\n") + f.write(f" Temps: {result.get('temps', result.get('resultat', 'N/A'))}\n") + f.write("\n") + + logging.info(f"Exporté le récapitulatif dans {filepath}") + return filepath + +def main(): + parser = argparse.ArgumentParser(description='Générer un récapitulatif complet d\'un athlète') + parser.add_argument('nom', help='Nom de l\'athlète') + parser.add_argument('--prenom', help='Prénom de l\'athlète') + parser.add_argument('--data-dir', default='data', + help='Répertoire des données CSV') + parser.add_argument('--full', action='store_true', + help='Afficher la liste complète des résultats') + parser.add_argument('--export', action='store_true', + help='Exporter le récapitulatif en fichier texte') + + args = parser.parse_args() + + # Générer le récapitulatif + print(f"\n📊 Génération du récapitulatif pour {args.prenom or ''} {args.nom}...") + summary = get_athlete_summary(args.nom, args.prenom, args.data_dir) + + if summary: + display_athlete_summary(summary, show_full_results=args.full) + + if args.export: + filepath = export_summary_txt(summary, args.data_dir) + print(f"💾 Exporté dans: {filepath}") + else: + print("\n❌ Aucun résultat trouvé pour cet athlète") + print("💡 Vérifiez que les données ont été scrapées avec:") + print(" python ffa_cli.py scrape --fetch-details") + +if __name__ == "__main__": + main() diff --git a/scripts/extract_races.py b/scripts/extract_races.py new file mode 100755 index 0000000..d7949cf --- /dev/null +++ b/scripts/extract_races.py @@ -0,0 +1,349 @@ +#!/usr/bin/env python3 +""" +Script pour extraire les types de courses, distances et statistiques +Analyse les données pour identifier les patterns de courses (100m, marathon, etc.) +""" + +import pandas as pd +import os +import sys +import argparse +import logging +import re +from collections import defaultdict, Counter + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +# Patterns pour extraire les distances des noms de courses +DISTANCE_PATTERNS = [ + (r'(\d+)\s*m', lambda x: int(x.group(1)), 'm'), # 100m, 5000m + (r'(\d+)\s*km', lambda x: int(x.group(1)) * 1000, 'km'), # 10km, semi-marathon + (r'marathon', lambda x: 42195, 'marathon'), + (r'semi[-\s]?marathon', lambda x: 21097, 'semi-marathon'), + (r'demi[-\s]?fond', lambda x: 0, 'demi-fond'), + (r'fond', lambda x: 0, 'fond'), + (r'sprint', lambda x: 0, 'sprint'), + (r'haies', lambda x: 0, 'haies'), + (r'cross', lambda x: 0, 'cross country'), + (r'route', lambda x: 0, 'route'), + (r'trail', lambda x: 0, 'trail'), + (r'ultra', lambda x: 0, 'ultra'), +] + +def extract_distance_from_name(course_name): + """Extraire la distance à partir du nom de course""" + if pd.isna(course_name): + return None, None + + course_name_lower = course_name.lower() + + for pattern, extractor, unit in DISTANCE_PATTERNS: + match = re.search(pattern, course_name_lower, re.IGNORECASE) + if match: + try: + distance = extractor(match) + return distance, unit + except: + continue + + return None, None + +def categorize_course(course_type, course_name): + """Catégoriser une course""" + if pd.isna(course_type): + course_type = '' + + if pd.isna(course_name): + course_name = '' + + combined = (course_type + ' ' + course_name).lower() + + # Catégories principales + if any(x in combined for x in ['100m', '200m', '400m', 'sprint']): + return 'Sprint' + elif any(x in combined for x in ['800m', '1500m', 'demi-fond']): + return 'Demi-fond' + elif any(x in combined for x in ['5000m', '10000m', 'fond']): + return 'Fond' + elif 'marathon' in combined: + return 'Marathon' + elif any(x in combined for x in ['semi', '21km']): + return 'Semi-marathon' + elif 'trail' in combined: + return 'Trail' + elif 'cross' in combined: + return 'Cross country' + elif 'route' in combined and 'km' in combined: + return 'Route' + elif 'haies' in combined: + return 'Haies' + else: + return 'Autre' + +def analyze_courses(data_dir="data"): + """Analyser toutes les courses et extraire les statistiques""" + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if not os.path.exists(courses_path): + logging.error(f"Fichier de courses introuvable: {courses_path}") + return None + + try: + df = pd.read_csv(courses_path, encoding='utf-8-sig') + logging.info(f"Chargé {len(df)} courses") + + # Extraire les distances + df['distance_meters'], df['distance_unit'] = zip( + *df['nom'].apply(extract_distance_from_name) + ) + + # Catégoriser les courses + df['category'] = df.apply( + lambda row: categorize_course(row['type'], row['nom']), + axis=1 + ) + + # Statistiques globales + stats = { + 'total_courses': len(df), + 'types': {}, + 'categories': {}, + 'distances': {}, + 'by_type': {}, + 'by_location': {}, + 'by_date': {} + } + + # Analyse par type + type_counts = df['type'].value_counts() + for course_type, count in type_counts.items(): + stats['types'][course_type] = count + + # Analyse par catégorie + category_counts = df['category'].value_counts() + for category, count in category_counts.items(): + stats['categories'][category] = count + + # Analyse par distance (pour les courses avec distance) + df_with_distance = df[df['distance_meters'] > 0] + distance_counts = df_with_distance['distance_meters'].value_counts() + for distance, count in distance_counts.items(): + stats['distances'][distance] = count + + # Détails par type + for course_type in df['type'].unique(): + if pd.notna(course_type): + type_df = df[df['type'] == course_type] + stats['by_type'][course_type] = { + 'count': len(type_df), + 'categories': type_df['category'].value_counts().to_dict(), + 'locations': type_df['lieu'].value_counts().head(10).to_dict() + } + + # Détails par lieu + location_counts = df['lieu'].value_counts().head(20) + for location, count in location_counts.items(): + stats['by_location'][location] = count + + # Détails par date (mois/année) + df['date'] = pd.to_datetime(df['date'], errors='coerce') + df['month_year'] = df['date'].dt.to_period('M') + date_counts = df['month_year'].value_counts().sort_index() + for period, count in date_counts.items(): + stats['by_date'][str(period)] = count + + return df, stats + + except Exception as e: + logging.error(f"Erreur lors de l'analyse des courses: {e}") + return None, None + +def display_analysis(stats, df=None, show_details=False): + """Afficher les résultats de l'analyse""" + if not stats: + print("\n❌ Impossible d'analyser les courses") + return + + print(f"\n{'='*80}") + print(f"📊 ANALYSE DES COURSES") + print(f"{'='*80}\n") + + # Vue d'ensemble + print(f"📋 VUE D'ENSEMBLE") + print(f"{'─'*40}") + print(f"Total des courses: {stats['total_courses']}") + print() + + # Types de courses + print(f"🏷️ TYPES DE COURSES") + print(f"{'─'*40}") + for course_type, count in sorted(stats['types'].items(), key=lambda x: x[1], reverse=True): + print(f" {course_type}: {count} courses") + print() + + # Catégories + print(f"📊 CATÉGORIES") + print(f"{'─'*40}") + for category, count in sorted(stats['categories'].items(), key=lambda x: x[1], reverse=True): + print(f" {category}: {count} courses") + print() + + # Distances + if stats['distances']: + print(f"📏 DISTANCES EXTRACTÉES") + print(f"{'─'*40}") + # Trier par distance + for distance in sorted(stats['distances'].keys()): + count = stats['distances'][distance] + if distance == 42195: + distance_str = "Marathon (42.195 km)" + elif distance == 21097: + distance_str = "Semi-marathon (21.097 km)" + elif distance >= 1000: + distance_str = f"{distance/1000:.1f} km" + else: + distance_str = f"{distance} m" + print(f" {distance_str}: {count} courses") + print() + + # Lieux les plus populaires + print(f"📍 LIEUX LES PLUS POPULAIRES (Top 20)") + print(f"{'─'*40}") + for i, (location, count) in enumerate(sorted(stats['by_location'].items(), key=lambda x: x[1], reverse=True), 1): + print(f" {i:2d}. {location}: {count} courses") + print() + + # Répartition par date + if stats['by_date']: + print(f"📅 RÉPARTITION PAR DATE") + print(f"{'─'*40}") + for period, count in list(stats['by_date'].items())[-12:]: # Derniers 12 mois + print(f" {period}: {count} courses") + print() + + print(f"{'='*80}\n") + + # Détails par type + if show_details and stats['by_type']: + print(f"📋 DÉTAILS PAR TYPE DE COURSE") + print(f"{'='*80}\n") + + for course_type, details in sorted(stats['by_type'].items(), key=lambda x: x[1]['count'], reverse=True): + print(f"🔹 {course_type}") + print(f" Nombre de courses: {details['count']}") + print(f" Répartition par catégorie:") + for category, count in sorted(details['categories'].items(), key=lambda x: x[1], reverse=True)[:5]: + print(f" - {category}: {count}") + print(f" Top lieux:") + for i, (location, count) in enumerate(sorted(details['locations'].items(), key=lambda x: x[1], reverse=True)[:5], 1): + print(f" {i}. {location}: {count}") + print() + +def export_analysis_csv(stats, df, output_dir="data"): + """Exporter l'analyse en CSV""" + os.makedirs(os.path.join(output_dir, 'exports'), exist_ok=True) + + # Exporter le DataFrame enrichi avec distances et catégories + courses_with_analysis = os.path.join(output_dir, 'exports', 'courses_analysis.csv') + if df is not None: + df.to_csv(courses_with_analysis, index=False, encoding='utf-8-sig') + logging.info(f"Exporté {len(df)} courses analysées dans {courses_with_analysis}") + + # Exporter les statistiques par type + types_csv = os.path.join(output_dir, 'exports', 'courses_by_type.csv') + if stats['types']: + types_df = pd.DataFrame(list(stats['types'].items()), columns=['Type', 'Count']) + types_df.to_csv(types_csv, index=False, encoding='utf-8-sig') + + # Exporter les statistiques par catégorie + categories_csv = os.path.join(output_dir, 'exports', 'courses_by_category.csv') + if stats['categories']: + categories_df = pd.DataFrame(list(stats['categories'].items()), columns=['Category', 'Count']) + categories_df.to_csv(categories_csv, index=False, encoding='utf-8-sig') + + # Exporter les statistiques par distance + distances_csv = os.path.join(output_dir, 'exports', 'courses_by_distance.csv') + if stats['distances']: + distances_df = pd.DataFrame(list(stats['distances'].items()), columns=['Distance (m)', 'Count']) + distances_df = distances_df.sort_values('Distance (m)') + distances_df.to_csv(distances_csv, index=False, encoding='utf-8-sig') + + return { + 'courses_analysis': courses_with_analysis, + 'by_type': types_csv, + 'by_category': categories_csv, + 'by_distance': distances_csv + } + +def search_courses_by_distance(df, min_distance=None, max_distance=None): + """Rechercher des courses par distance""" + if df is None: + return [] + + mask = df['distance_meters'] > 0 + + if min_distance is not None: + mask &= df['distance_meters'] >= min_distance + + if max_distance is not None: + mask &= df['distance_meters'] <= max_distance + + courses = df[mask].to_dict('records') + return courses + +def main(): + parser = argparse.ArgumentParser(description='Extraire et analyser les types de courses et distances') + parser.add_argument('--data-dir', default='data', + help='Répertoire des données CSV') + parser.add_argument('--details', action='store_true', + help='Afficher les détails par type de course') + parser.add_argument('--export', action='store_true', + help='Exporter l\'analyse en CSV') + parser.add_argument('--search-distance', action='store_true', + help='Rechercher des courses par distance') + parser.add_argument('--min-distance', type=int, + help='Distance minimum en mètres') + parser.add_argument('--max-distance', type=int, + help='Distance maximum en mètres') + + args = parser.parse_args() + + # Analyse des courses + print(f"\n📊 Analyse des courses depuis {args.data_dir}/...") + df, stats = analyze_courses(args.data_dir) + + if df is not None and stats is not None: + # Affichage + display_analysis(stats, df, show_details=args.details) + + # Recherche par distance + if args.search_distance: + print(f"\n🔍 Recherche de courses par distance:") + print(f" Min: {args.min_distance}m, Max: {args.max_distance}m") + courses = search_courses_by_distance(df, args.min_distance, args.max_distance) + + if courses: + print(f"\n Trouvé {len(courses)} courses:") + for i, course in enumerate(courses[:20], 1): + print(f" {i}. {course['nom']} - {course['distance_meters']}m") + if len(courses) > 20: + print(f" ... et {len(courses) - 20} autres") + else: + print(" Aucune course trouvée avec ces critères") + + # Export + if args.export: + files = export_analysis_csv(stats, df, args.data_dir) + print(f"\n💾 Exporté dans:") + for key, filepath in files.items(): + print(f" {key}: {filepath}") + else: + print("\n❌ Impossible d'analyser les courses") + print("💡 Vérifiez que les données ont été scrapées avec:") + print(" python ffa_cli.py scrape") + +if __name__ == "__main__": + main() diff --git a/scripts/ffa_cli.py b/scripts/ffa_cli.py new file mode 100644 index 0000000..370ad2f --- /dev/null +++ b/scripts/ffa_cli.py @@ -0,0 +1,283 @@ +#!/usr/bin/env python3 +""" +Interface en ligne de commande pour le scraper FFA +""" + +import argparse +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) +from ffa_scraper import FFAScraper +from ffa_analyzer import FFADataAnalyzer +import logging + +def setup_logging(verbose=False): + """Configurer le logging""" + level = logging.DEBUG if verbose else logging.INFO + logging.basicConfig( + level=level, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + +def scrape_command(args): + """Commande de scraping""" + scraper = FFAScraper(output_dir=args.output) + + use_multithreading = args.multithreading and not args.no_multithreading + + stats = scraper.scrap_all_data( + limit_courses=args.limit_courses, + limit_results=args.limit_results, + fetch_details=args.fetch_details, + max_pages=args.max_pages, + use_multithreading=use_multithreading + ) + + print(f"\nScraping terminé:") + print(f"- Clubs: {stats['clubs_count']}") + print(f"- Courses: {stats['courses_count']}") + print(f"- Résultats: {stats['results_count']}") + +def check_command(args): + """Commande de vérification du nombre de courses""" + scraper = FFAScraper(output_dir=args.output) + + total_pages, total_courses, courses_per_page = scraper._detect_pagination_info() + + print("\n" + "="*60) + print("📊 Informations de pagination") + print("="*60) + + if total_pages: + print(f"Nombre total de pages: {total_pages}") + print(f"Estimation du nombre total de courses: ~{total_courses}") + print(f"Courses par page: ~{courses_per_page}") + + print(f"\n⏱️ Estimation du temps de scraping:") + use_multithreading = args.multithreading and not args.no_multithreading + if use_multithreading: + print(f" - Multithreading (4 workers): ~{total_pages / 4 * 2:.0f} secondes") + else: + print(f" - Séquentiel: ~{total_pages * 2:.0f} secondes") + + if total_pages > 10: + print(f"\n⚠️ Attention: {total_pages} pages à scraper!") + + if args.auto: + print(f"\nUtilisation de {total_pages} pages pour le scraping.") + stats = scraper.scrap_all_data( + limit_courses=args.limit_courses, + limit_results=args.limit_results, + fetch_details=args.fetch_details, + max_pages=total_pages, + use_multithreading=use_multithreading + ) + print(f"\nScraping terminé:") + print(f"- Courses: {stats['courses_count']}") + else: + print("⚠️ Impossible de détecter la pagination. Utilisez --max-pages pour spécifier le nombre de pages.") + + print("="*60) + +def list_command(args): + """Commande de listing des données""" + analyzer = FFADataAnalyzer(data_dir=args.data_dir) + + print("\n=== Données disponibles ===") + + if analyzer.courses_df is not None: + print(f"\n📅 Courses: {len(analyzer.courses_df)} compétitions") + if len(analyzer.courses_df) > 0: + print(" Types de courses:") + types = analyzer.courses_df['type'].value_counts() + for course_type, count in types.head(5).items(): + print(f" - {course_type}: {count}") + + if analyzer.results_df is not None: + print(f"\n🏃 Résultats: {len(analyzer.results_df)} entrées") + if len(analyzer.results_df) > 0: + print(" Clubs les plus représentés:") + clubs = analyzer.results_df['club'].value_counts().head(5) + for club, count in clubs.items(): + print(f" - {club}: {count} résultats") + + print("\n Premiers résultats:") + for i, result in enumerate(analyzer.results_df.head(3).to_dict('records'), 1): + print(f" {i}. {result.get('prenom', '')} {result.get('nom', '')} - {result.get('club', '')} - Place: {result.get('place', '')}") + + if analyzer.clubs_df is not None and len(analyzer.clubs_df) > 0: + print(f"\n🏟️ Clubs: {len(analyzer.clubs_df)} clubs") + + print("\n=== === ===\n") + +def search_command(args): + """Commande de recherche""" + analyzer = FFADataAnalyzer(data_dir=args.data_dir) + + if args.type == 'athlete': + results = analyzer.search_athlete(args.nom, args.prenom) + print(f"\nTrouvé {len(results)} résultats pour {args.nom} {args.prenom or ''}") + + for i, result in enumerate(results[:20], 1): # Limiter l'affichage + print(f"{i}. {result['prenom']} {result['nom']} - {result['club']} - Place: {result['place']} - {result.get('course_url', '')}") + + elif args.type == 'course': + courses = analyzer.get_course_by_date(args.start_date, args.end_date) + print(f"\nTrouvé {len(courses)} courses entre {args.start_date} et {args.end_date}") + + for i, course in enumerate(courses[:20], 1): + print(f"{i}. {course.get('nom', 'Inconnu')} - {course.get('date', 'Date inconnue')} - {course.get('lieu', 'Lieu inconnu')}") + + elif args.type == 'club': + club_info = analyzer.search_club_in_results(args.nom) + if club_info and club_info.get('athletes'): + print(f"\nClub: {args.nom}") + print(f"Athlètes: {len(club_info.get('athletes', []))}") + + for i, athlete in enumerate(club_info.get('athletes', [])[:10], 1): + print(f"{i}. {athlete['prenom']} {athlete['nom']} - {len(athlete['results'])} résultats") + else: + print(f"\nAucun résultat trouvé pour le club: {args.nom}") + +def stats_command(args): + """Commande de statistiques""" + analyzer = FFADataAnalyzer(data_dir=args.data_dir) + + if args.type == 'athlete': + stats = analyzer.get_athlete_stats(args.nom, args.prenom) + if stats: + print(f"\nStatistiques pour {stats['prenom']} {stats['nom']}:") + print(f"- Club: {stats.get('club', 'Inconnu')}") + print(f"- Courses total: {stats.get('total_courses', 0)}") + print(f"- Victoires: {stats.get('victoires', 0)}") + print(f"- Podiums: {stats.get('podiums', 0)}") + print(f"- Catégories: {', '.join(stats.get('categories', []))}") + print(f"- Courses par année: {stats.get('courses_par_annee', {})}") + + elif args.type == 'club': + rankings = analyzer.get_club_rankings(args.course_url) + print(f"\nClassement par club pour la course {args.course_url}:") + + for i, club in enumerate(rankings[:10], 1): + print(f"{i}. {club['club']} - Score: {club['score']} - Participants: {club['participants']}") + +def top_command(args): + """Commande pour afficher le top des athlètes""" + analyzer = FFADataAnalyzer(data_dir=args.data_dir) + + top_athletes = analyzer.get_top_athletes(limit=args.limit, min_results=args.min_results) + + print(f"\n=== Top {len(top_athletes)} athlètes ===") + print(f"(Minimum {args.min_results} résultats)\n") + + for i, athlete in enumerate(top_athletes, 1): + print(f"{i}. {athlete['prenom']} {athlete['nom']}") + print(f" Club: {athlete.get('club', 'Inconnu')}") + print(f" Victoires: {athlete['victoires']} | Podiums: {athlete['podiums']} | Courses: {athlete['results_count']}") + if athlete.get('place_moyenne'): + print(f" Place moyenne: {athlete['place_moyenne']:.2f}") + print() + +def export_command(args): + """Commande d'export""" + analyzer = FFADataAnalyzer(data_dir=args.data_dir) + + if args.type == 'athlete': + filepath = analyzer.export_athlete_csv(args.nom, args.prenom, args.filename) + if filepath: + print(f"Exporté dans: {filepath}") + else: + print("Aucun résultat trouvé pour cet athlète") + +def main(): + parser = argparse.ArgumentParser(description='FFA Calendar Scraper - Outil de scraping et d\'analyse des données de la FFA') + parser.add_argument('--verbose', '-v', action='store_true', help='Mode verbeux') + + subparsers = parser.add_subparsers(dest='command', help='Commande à exécuter') + + # Commande scrape + scrape_parser = subparsers.add_parser('scrape', help='Lancer le scraping des données') + scrape_parser.add_argument('--output', '-o', default='data', help='Répertoire de sortie des données') + scrape_parser.add_argument('--limit-courses', type=int, help='Limiter le nombre de courses à scraper') + scrape_parser.add_argument('--limit-results', type=int, help='Limiter le nombre de résultats à scraper') + scrape_parser.add_argument('--fetch-details', action='store_true', help='Récupérer les détails et résultats de chaque course (plus lent)') + scrape_parser.add_argument('--max-pages', type=int, default=10, help='Nombre maximum de pages à scraper (défaut: 10)') + scrape_parser.add_argument('--multithreading', action='store_true', default=True, help='Activer le multithreading pour accélérer le scraping (défaut: True)') + scrape_parser.add_argument('--no-multithreading', action='store_true', help='Désactiver le multithreading (scraping séquentiel)') + + # Commande list + list_parser = subparsers.add_parser('list', help='Lister les données disponibles') + list_parser.add_argument('--data-dir', default='data', help='Répertoire des données') + + # Commande search + search_parser = subparsers.add_parser('search', help='Rechercher des données') + search_parser.add_argument('type', choices=['athlete', 'club', 'course'], help='Type de recherche') + search_parser.add_argument('--data-dir', default='data', help='Répertoire des données') + + # Arguments spécifiques à la recherche d'athlète + search_parser.add_argument('--nom', help='Nom de l\'athlète ou du club') + search_parser.add_argument('--prenom', help='Prénom de l\'athlète') + search_parser.add_argument('--start-date', help='Date de début (format: YYYY-MM-DD)') + search_parser.add_argument('--end-date', help='Date de fin (format: YYYY-MM-DD)') + + # Commande stats + stats_parser = subparsers.add_parser('stats', help='Afficher des statistiques') + stats_parser.add_argument('type', choices=['athlete', 'club'], help='Type de statistiques') + stats_parser.add_argument('--nom', help='Nom de l\'athlète ou du club') + stats_parser.add_argument('--prenom', help='Prénom de l\'athlète') + stats_parser.add_argument('--course-url', help='URL de la course pour le classement par club') + stats_parser.add_argument('--data-dir', default='data', help='Répertoire des données') + + # Commande top + top_parser = subparsers.add_parser('top', help='Afficher le top des athlètes') + top_parser.add_argument('--limit', type=int, default=10, help='Nombre d\'athlètes à afficher (défaut: 10)') + top_parser.add_argument('--min-results', type=int, default=3, help='Nombre minimum de résultats (défaut: 3)') + top_parser.add_argument('--data-dir', default='data', help='Répertoire des données') + + # Commande export + export_parser = subparsers.add_parser('export', help='Exporter des données en CSV') + export_parser.add_argument('type', choices=['athlete'], help='Type d\'export') + export_parser.add_argument('--nom', help='Nom de l\'athlète ou du club') + export_parser.add_argument('--prenom', help='Prénom de l\'athlète') + export_parser.add_argument('--filename', help='Nom du fichier de sortie') + export_parser.add_argument('--data-dir', default='data', help='Répertoire des données') + + # Commande check + check_parser = subparsers.add_parser('check', help='Vérifier le nombre total de courses disponibles') + check_parser.add_argument('--output', '-o', default='data', help='Répertoire de sortie des données') + check_parser.add_argument('--limit-courses', type=int, help='Limiter le nombre de courses à scraper') + check_parser.add_argument('--limit-results', type=int, help='Limiter le nombre de résultats à scraper') + check_parser.add_argument('--fetch-details', action='store_true', help='Récupérer les détails et résultats de chaque course (plus lent)') + check_parser.add_argument('--auto', action='store_true', help='Lancer automatiquement le scraping après la vérification') + check_parser.add_argument('--multithreading', action='store_true', default=True, help='Activer le multithreading pour accélérer le scraping (défaut: True)') + check_parser.add_argument('--no-multithreading', action='store_true', help='Désactiver le multithreading (scraping séquentiel)') + + args = parser.parse_args() + + if not args.command: + parser.print_help() + return + + setup_logging(args.verbose) + + try: + if args.command == 'scrape': + scrape_command(args) + elif args.command == 'list': + list_command(args) + elif args.command == 'search': + search_command(args) + elif args.command == 'top': + top_command(args) + elif args.command == 'stats': + stats_command(args) + elif args.command == 'export': + export_command(args) + elif args.command == 'check': + check_command(args) + except Exception as e: + logging.error(f"Erreur lors de l'exécution de la commande {args.command}: {e}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/list_clubs.py b/scripts/list_clubs.py new file mode 100755 index 0000000..ef6e8ef --- /dev/null +++ b/scripts/list_clubs.py @@ -0,0 +1,181 @@ +#!/usr/bin/env python3 +""" +Script pour lister tous les clubs présents dans les résultats FFA +Utilise les fichiers CSV générés par le scraper ou les données live depuis l'URL FFA +""" + +import pandas as pd +import os +import sys +import argparse +import logging +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) +from ffa_scraper import FFAScraper +from collections import defaultdict + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +def list_clubs_from_csv(data_dir="data"): + """Lister tous les clubs à partir des fichiers CSV""" + results_path = os.path.join(data_dir, 'resultats', 'results.csv') + + if not os.path.exists(results_path): + logging.error(f"Fichier de résultats introuvable: {results_path}") + print("\n💡 Pour générer les résultats, utilisez:") + print(" python ffa_cli.py scrape --fetch-details") + return [] + + try: + df = pd.read_csv(results_path, encoding='utf-8-sig') + logging.info(f"Chargé {len(df)} résultats") + + # Extraire les clubs uniques + clubs_info = df.groupby('club').agg({ + 'nom': lambda x: x.nunique(), # Nombre d'athlètes uniques + 'dept': lambda x: x.mode()[0] if len(x.mode()) > 0 else '', + 'ligue': lambda x: x.mode()[0] if len(x.mode()) > 0 else '' + }).reset_index() + + clubs_info.columns = ['club', 'athletes_count', 'departement', 'ligue'] + clubs_info = clubs_info.sort_values('athletes_count', ascending=False) + + return clubs_info.to_dict('records') + except Exception as e: + logging.error(f"Erreur lors de la lecture des CSV: {e}") + return [] + +def list_clubs_live(): + """Lister les clubs depuis l'URL FFA (besoin de scraping live)""" + scraper = FFAScraper() + + logging.info("Récupération des données en direct depuis le site FFA...") + logging.warning("Note: Cette méthode nécessite un scraping complet, ce qui peut prendre du temps") + + # Récupérer les résultats depuis le site + # Pour simplifier, nous récupérons quelques courses et extrayons les clubs + total_pages, total_courses, _ = scraper._detect_pagination_info() + + if not total_pages: + logging.error("Impossible de détecter les données") + return [] + + # Limiter à quelques pages pour éviter trop de temps + max_pages = min(5, total_pages) + logging.info(f"Analyse de {max_pages} pages pour extraire les clubs...") + + clubs = defaultdict(lambda: { + 'count': 0, + 'athletes': set(), + 'dept': '', + 'ligue': '' + }) + + # Scraper les courses et récupérer les résultats + courses = scraper.get_courses_list(max_pages=max_pages, use_multithreading=True) + + for course in courses: + if course.get('resultats_url'): + results = scraper.get_course_results(course['resultats_url']) + for result in results: + club = result.get('club', 'Inconnu') + clubs[club]['count'] += 1 + clubs[club]['athletes'].add(f"{result.get('prenom', '')} {result.get('nom', '')}") + if not clubs[club]['dept'] and result.get('dept'): + clubs[club]['dept'] = result['dept'] + if not clubs[club]['ligue'] and result.get('ligue'): + clubs[club]['ligue'] = result['ligue'] + + # Convertir en liste et trier + clubs_list = [] + for club, info in clubs.items(): + clubs_list.append({ + 'club': club, + 'athletes_count': len(info['athletes']), + 'results_count': info['count'], + 'departement': info['dept'], + 'ligue': info['ligue'] + }) + + clubs_list.sort(key=lambda x: x['athletes_count'], ascending=False) + + return clubs_list + +def display_clubs(clubs, limit=None, show_details=False): + """Afficher la liste des clubs""" + if not clubs: + print("\n❌ Aucun club trouvé") + return + + print(f"\n{'='*80}") + print(f"📊 LISTE DES CLUBS ({len(clubs)} clubs trouvés)") + print(f"{'='*80}\n") + + if limit: + clubs = clubs[:limit] + + for i, club in enumerate(clubs, 1): + print(f"{i:3d}. {club['club']}") + print(f" Athlètes: {club['athletes_count']}") + + if show_details: + if 'results_count' in club: + print(f" Résultats totaux: {club['results_count']}") + if club.get('departement'): + print(f" Département: {club['departement']}") + if club.get('ligue'): + print(f" Ligue: {club['ligue']}") + + print() + + print(f"{'='*80}") + +def export_clubs_csv(clubs, filename="clubs_list_export.csv", output_dir="data"): + """Exporter la liste des clubs en CSV""" + os.makedirs(output_dir, exist_ok=True) + filepath = os.path.join(output_dir, filename) + + df = pd.DataFrame(clubs) + df.to_csv(filepath, index=False, encoding='utf-8-sig') + logging.info(f"Exporté {len(clubs)} clubs dans {filepath}") + return filepath + +def main(): + parser = argparse.ArgumentParser(description='Lister tous les clubs des résultats FFA') + parser.add_argument('--csv', action='store_true', default=True, + help='Utiliser les fichiers CSV existants (défaut)') + parser.add_argument('--live', action='store_true', + help='Récupérer les données en direct depuis le site FFA') + parser.add_argument('--limit', type=int, + help='Limiter le nombre de clubs affichés') + parser.add_argument('--details', action='store_true', + help='Afficher les détails (dpt, ligue, résultats)') + parser.add_argument('--export', action='store_true', + help='Exporter la liste en CSV') + parser.add_argument('--output', default='data', + help='Répertoire de sortie pour les données CSV') + parser.add_argument('--export-filename', default='clubs_list_export.csv', + help='Nom du fichier CSV exporté') + + args = parser.parse_args() + + # Choisir la source des données + if args.live: + print("\n⚠️ Mode live: récupération des données depuis le site FFA...") + clubs = list_clubs_live() + else: + print(f"\n📂 Mode CSV: utilisation des fichiers dans {args.output}/") + clubs = list_clubs_from_csv(args.output) + + # Afficher les résultats + display_clubs(clubs, limit=args.limit, show_details=args.details) + + # Exporter si demandé + if args.export and clubs: + filepath = export_clubs_csv(clubs, args.export_filename, args.output) + print(f"\n💾 Exporté dans: {filepath}") + +if __name__ == "__main__": + main() diff --git a/scripts/monitor_scraping.py b/scripts/monitor_scraping.py new file mode 100755 index 0000000..329f646 --- /dev/null +++ b/scripts/monitor_scraping.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +""" +Script de surveillance du scraping FFA +""" + +import time +import pandas as pd +from pathlib import Path + +def monitor_scraping(data_dir="data_2024_2025"): + """Surveiller le scraping et afficher les statistiques""" + + results_file = Path(data_dir) / "resultats" / "results.csv" + courses_file = Path(data_dir) / "courses" / "courses_list.csv" + + while True: + print("\n" + "="*60) + print(f"📊 Surveillance du scraping - {time.strftime('%H:%M:%S')}") + print("="*60) + + # Statistiques des courses + if courses_file.exists(): + courses_df = pd.read_csv(courses_file) + print(f"📅 Courses disponibles: {len(courses_df)}") + + # Statistiques des résultats + if results_file.exists(): + results_df = pd.read_csv(results_file) + print(f"🏃 Résultats récupérés: {len(results_df)}") + print(f"💾 Taille fichier: {results_file.stat().st_size / (1024*1024):.2f} Mo") + + # Recherche Augustin ROUX + augustin_mask = ( + results_df['nom'].str.contains('ROUX', case=False, na=False) & + results_df['prenom'].str.contains('Augustin', case=False, na=False) + ) + augustin_results = results_df[augustin_mask] + + print(f"\n🎯 Recherche: Augustin ROUX") + print(f" Résultats trouvés: {len(augustin_results)}") + + if len(augustin_results) > 0: + print(f"\n Détails des résultats:") + for idx, row in augustin_results.iterrows(): + print(f" - Place {row['place']}: {row['resultat']} ({row['date'] if 'date' in row else 'N/A'})") + if 'club' in row and pd.notna(row['club']): + print(f" Club: {row['club']}") + + # Top 5 clubs par nombre de résultats + print(f"\n🏟️ Top 5 clubs par nombre de résultats:") + top_clubs = results_df['club'].value_counts().head(5) + for club, count in top_clubs.items(): + print(f" - {club}: {count} résultats") + + # Recherche clubs Charente-Maritime (17) + print(f"\n📍 Clubs Charente-Maritime (17):") + dept17_mask = results_df['club'].str.contains(r'[\( ]17[\) ]', na=False) + dept17_results = results_df[dept17_mask] + dept17_clubs = dept17_results['club'].unique() if len(dept17_results) > 0 else [] + + if len(dept17_clubs) > 0: + for club in dept17_clubs[:10]: + count = len(results_df[results_df['club'] == club]) + print(f" - {club}: {count} résultats") + else: + print(f" Aucun résultat trouvé pour le département 17") + + print("\n⏳ Prochaine vérification dans 60 secondes...") + time.sleep(60) + +if __name__ == "__main__": + monitor_scraping() diff --git a/scripts/post_process.py b/scripts/post_process.py new file mode 100755 index 0000000..22cb624 --- /dev/null +++ b/scripts/post_process.py @@ -0,0 +1,298 @@ +#!/usr/bin/env python3 +""" +Script de post-traitement pour analyser et trier les données scrapées +""" + +import os +import sys +import logging +import pandas as pd +from collections import defaultdict +from datetime import datetime + +def analyze_clubs(data_dir): + """Analyser et extraire tous les clubs""" + logging.info("=== Analyse des clubs ===") + + results_path = os.path.join(data_dir, 'resultats', 'results.csv') + + if os.path.exists(results_path): + df = pd.read_csv(results_path, encoding='utf-8-sig') + + # Extraire les clubs uniques + clubs_info = df.groupby('club').agg({ + 'nom': lambda x: x.nunique(), + 'prenom': lambda x: x.nunique() + }).reset_index() + + clubs_info.columns = ['club', 'athletes_count', 'unique_athletes'] + clubs_info = clubs_info.sort_values('athletes_count', ascending=False) + + # Sauvegarder + clubs_dir = os.path.join(data_dir, 'clubs') + os.makedirs(clubs_dir, exist_ok=True) + + clubs_file = os.path.join(clubs_dir, 'clubs_list.csv') + clubs_info.to_csv(clubs_file, index=False, encoding='utf-8-sig') + + logging.info(f"✅ {len(clubs_info)} clubs exportés dans {clubs_file}") + logging.info(f" Top 5 clubs:") + for i, club in clubs_info.head(5).iterrows(): + logging.info(f" {i+1}. {club['club']}: {club['athletes_count']} résultats") + + return clubs_info + else: + logging.warning("⚠️ Fichier de résultats introuvable") + return None + +def analyze_courses(data_dir): + """Analyser et extraire les statistiques des courses""" + logging.info("=== Analyse des courses ===") + + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if os.path.exists(courses_path): + df = pd.read_csv(courses_path, encoding='utf-8-sig') + + # Convertir les dates + df['date'] = pd.to_datetime(df['date'], errors='coerce') + df['année'] = df['date'].dt.year + df['mois'] = df['date'].dt.month + + # Statistiques par année + courses_by_year = df.groupby('année').size().reset_index(name='count') + courses_by_year = courses_by_year.sort_values('année') + + # Statistiques par type + courses_by_type = df['type'].value_counts().reset_index() + courses_by_type.columns = ['type', 'count'] + + # Statistiques par lieu (top 50) + courses_by_location = df['lieu'].value_counts().head(50).reset_index() + courses_by_location.columns = ['lieu', 'count'] + + # Sauvegarder les statistiques + stats_dir = os.path.join(data_dir, 'statistics') + os.makedirs(stats_dir, exist_ok=True) + + # Export par année + year_file = os.path.join(stats_dir, 'courses_by_year.csv') + courses_by_year.to_csv(year_file, index=False, encoding='utf-8-sig') + + # Export par type + type_file = os.path.join(stats_dir, 'courses_by_type.csv') + courses_by_type.to_csv(type_file, index=False, encoding='utf-8-sig') + + # Export par lieu + location_file = os.path.join(stats_dir, 'courses_by_location.csv') + courses_by_location.to_csv(location_file, index=False, encoding='utf-8-sig') + + logging.info(f"✅ Statistiques exportées dans {stats_dir}") + logging.info(f" Années: {len(courses_by_year)}") + logging.info(f" Types: {len(courses_by_type)}") + logging.info(f" Lieux: {len(courses_by_location)}") + + # Récapitulatif + logging.info(f"\n📊 RÉCAPITULATIF DES COURSES:") + logging.info(f" Total: {len(df)} courses") + logging.info(f" Plage de dates: {df['date'].min()} au {df['date'].max()}") + logging.info(f" Années: {len(courses_by_year)}") + logging.info(f" Types: {len(courses_by_type)}") + + return { + 'total': len(df), + 'years': len(courses_by_year), + 'types': len(courses_by_type), + 'locations': len(courses_by_location) + } + else: + logging.warning("⚠️ Fichier de courses introuvable") + return None + +def extract_distances_from_courses(data_dir): + """Extraire et catégoriser les distances des courses""" + logging.info("=== Extraction des distances ===") + + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if os.path.exists(courses_path): + df = pd.read_csv(courses_path, encoding='utf-8-sig') + + import re + + # Fonction pour extraire la distance + def extract_distance(course_name): + patterns = [ + (r'(\d+)\s*km', lambda m: int(m.group(1)) * 1000), + (r'(\d+)\s*m', lambda m: int(m.group(1))), + (r'marathon', lambda m: 42195), + (r'semi[-\s]?marathon', lambda m: 21097), + ] + + for pattern, extractor in patterns: + match = re.search(pattern, course_name, re.IGNORECASE) + if match: + try: + return extractor(match) + except: + pass + return None + + # Extraire les distances + df['distance_meters'] = df['nom'].apply(extract_distance) + + # Catégoriser + def categorize_distance(distance): + if pd.isna(distance): + return 'Autre' + elif distance < 400: + return 'Sprint' + elif distance < 2000: + return 'Demi-fond' + elif distance < 5000: + return 'Fond' + elif distance < 10000: + return 'Intermédiaire' + elif distance < 21000: + return '10km' + elif distance < 22000: + return 'Semi-marathon' + elif distance < 43000: + return 'Longue distance' + elif distance < 50000: + return 'Marathon' + else: + return 'Ultra' + + df['category'] = df['distance_meters'].apply(categorize_distance) + + # Statistiques par catégorie + categories = df['category'].value_counts().reset_index() + categories.columns = ['category', 'count'] + + # Sauvegarder les courses avec distances + courses_with_distance = os.path.join(data_dir, 'courses', 'courses_with_distances.csv') + df.to_csv(courses_with_distance, index=False, encoding='utf-8-sig') + + # Sauvegarder les statistiques + stats_dir = os.path.join(data_dir, 'statistics') + categories_file = os.path.join(stats_dir, 'courses_by_category.csv') + categories.to_csv(categories_file, index=False, encoding='utf-8-sig') + + logging.info(f"✅ Distances extraites et exportées") + logging.info(f" Catégories: {len(categories)}") + logging.info(f"\nRépartition par catégorie:") + for _, row in categories.head(10).iterrows(): + logging.info(f" {row['category']}: {row['count']} courses") + + return categories + else: + logging.warning("⚠️ Fichier de courses introuvable") + return None + +def create_summary(data_dir): + """Créer un récapitulatif global""" + logging.info("=== Création du récapitulatif ===") + + summary_dir = os.path.join(data_dir, 'summary') + os.makedirs(summary_dir, exist_ok=True) + + # Créer un fichier de récapitulatif + summary_file = os.path.join(summary_dir, 'global_summary.txt') + + with open(summary_file, 'w', encoding='utf-8') as f: + f.write("="*80 + "\n") + f.write("RÉCAPITULATIF GLOBAL DES DONNÉES FFA\n") + f.write("="*80 + "\n") + f.write(f"Date de génération: {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") + + # Courses + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + if os.path.exists(courses_path): + df_courses = pd.read_csv(courses_path, encoding='utf-8-sig') + f.write(f"COURSES\n") + f.write("-"*40 + "\n") + f.write(f"Total des courses: {len(df_courses)}\n") + + df_courses['date'] = pd.to_datetime(df_courses['date'], errors='coerce') + f.write(f"Première course: {df_courses['date'].min()}\n") + f.write(f"Dernière course: {df_courses['date'].max()}\n") + + years = df_courses['date'].dt.year.dropna().unique() + f.write(f"Années couvertes: {len(years)} ({min(years)} à {max(years)})\n\n") + + # Résultats + results_path = os.path.join(data_dir, 'resultats', 'results.csv') + if os.path.exists(results_path): + df_results = pd.read_csv(results_path, encoding='utf-8-sig') + f.write(f"RÉSULTATS\n") + f.write("-"*40 + "\n") + f.write(f"Total des résultats: {len(df_results)}\n") + + clubs = df_results['club'].nunique() + f.write(f"Clubs uniques: {clubs}\n") + f.write(f"Athlètes uniques: {df_results['nom'].nunique()}\n\n") + + # Clubs + clubs_path = os.path.join(data_dir, 'clubs', 'clubs_list.csv') + if os.path.exists(clubs_path): + df_clubs = pd.read_csv(clubs_path, encoding='utf-8-sig') + f.write(f"CLUBS\n") + f.write("-"*40 + "\n") + f.write(f"Total des clubs: {len(df_clubs)}\n\n") + f.write(f"Top 10 clubs:\n") + for i, club in df_clubs.head(10).iterrows(): + f.write(f" {i+1}. {club['club']}: {club['athletes_count']} résultats\n") + f.write("\n") + + logging.info(f"✅ Récapitulatif global créé dans {summary_file}") + return summary_file + +def main(): + """Fonction principale""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + data_dir = sys.argv[1] if len(sys.argv) > 1 else 'data_2010_2026' + + logging.info(f"{'='*80}") + logging.info(f"POST-TRAITEMENT DES DONNÉES FFA") + logging.info(f"{'='*80}") + logging.info(f"Répertoire: {data_dir}\n") + + # Analyser les clubs + clubs = analyze_clubs(data_dir) + + # Analyser les courses + courses_stats = analyze_courses(data_dir) + + # Extraire les distances + categories = extract_distances_from_courses(data_dir) + + # Créer le récapitulatif + summary = create_summary(data_dir) + + logging.info(f"\n{'='*80}") + logging.info(f"POST-TRAITEMENT TERMINÉ") + logging.info(f"{'='*80}") + + # Afficher les statistiques + if courses_stats: + print(f"\n📊 STATISTIQUES FINALES:") + print(f" Courses: {courses_stats['total']}") + print(f" Années: {courses_stats['years']}") + print(f" Types: {courses_stats['types']}") + + if clubs is not None: + print(f" Clubs: {len(clubs)}") + + if categories is not None: + print(f" Catégories: {len(categories)}") + + print(f"\n✅ Toutes les données ont été analysées et exportées!") + print(f"📁 Répertoire principal: {data_dir}") + +if __name__ == "__main__": + main() diff --git a/scripts/scrape_all_periods.py b/scripts/scrape_all_periods.py new file mode 100755 index 0000000..9ac840a --- /dev/null +++ b/scripts/scrape_all_periods.py @@ -0,0 +1,312 @@ +#!/usr/bin/env python3 +""" +Script de scraping FFA avec multithreading maximal +Scrape par périodes de 15 jours et exécute les scripts de post-traitement +""" + +import os +import sys +import time +import logging +import subprocess +from datetime import datetime, timedelta +from concurrent.futures import ThreadPoolExecutor, as_completed +from tqdm import tqdm +import pandas as pd + +# Charger le module scraper +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) +from ffa_scraper import FFAScraper + +def get_15_day_periods(start_year=2010, end_year=2026): + """Générer les périodes de 15 jours entre start_year et end_year""" + periods = [] + + start_date = datetime(start_year, 1, 1) + end_date = datetime(end_year, 12, 31) + + current_date = start_date + + while current_date <= end_date: + period_end = current_date + timedelta(days=14) + if period_end > end_date: + period_end = end_date + + period_name = f"{current_date.strftime('%Y-%m-%d')}_to_{period_end.strftime('%Y-%m-%d')}" + + periods.append({ + 'name': period_name, + 'start': current_date, + 'end': period_end + }) + + current_date = period_end + timedelta(days=1) + + logging.info(f"Nombre total de périodes de 15 jours: {len(periods)}") + return periods + +def scrape_period(period, period_index, total_periods): + """Scraper une période spécifique""" + scraper = FFAScraper() + + start_str = period['start'].strftime('%Y-%m-%d') + end_str = period['end'].strftime('%Y-%m-%d') + year = period['start'].year + + # Construire l'URL pour cette période + url = ( + f"https://www.athle.fr/bases/liste.aspx?frmpostback=true" + f"&frmbase=calendrier&frmmode=1&frmespace=0" + f"&frmsaisonffa={year}" + f"&frmdate1={start_str}&frmdate2={end_str}" + f"&frmtype1=&frmniveau=&frmligue=&frmdepartement=&frmniveaulab=" + f"&frmepreuve=&frmtype2=&frmtype3=&frmtype4=&frmposition=4" + ) + + try: + # Scraper avec multithreading interne en utilisant l'URL personnalisée + courses = scraper.get_courses_list(max_pages=1, use_multithreading=False, calendar_url=url) + + if courses: + logging.info(f"[{period_index + 1}/{total_periods}] {len(courses)} courses pour {start_str} au {end_str}") + + # Sauvegarder immédiatement dans un fichier spécifique à la période + output_dir = os.getenv('OUTPUT_DIR', 'data_2010_2026') + period_dir = os.path.join(output_dir, 'courses', 'periods') + os.makedirs(period_dir, exist_ok=True) + + period_file = os.path.join(period_dir, f"courses_{period['name']}.csv") + df = pd.DataFrame(courses) + df.to_csv(period_file, index=False, encoding='utf-8-sig') + + return { + 'period': period, + 'courses': courses, + 'success': True + } + else: + return { + 'period': period, + 'courses': [], + 'success': True + } + + except Exception as e: + logging.error(f"Erreur pour {start_str} au {end_str}: {e}") + return { + 'period': period, + 'courses': [], + 'success': False, + 'error': str(e) + } + finally: + scraper._close_all_selenium() + +def scrape_all_periods_multithreaded(periods, max_workers=8): + """Scraper toutes les périodes avec multithreading maximal""" + all_courses = [] + + total_periods = len(periods) + logging.info(f"=== Scraping avec {max_workers} workers ===") + logging.info(f"Périodes à scraper: {total_periods}") + + with ThreadPoolExecutor(max_workers=max_workers, thread_name_prefix='scraper') as executor: + # Soumettre toutes les tâches + future_to_period = { + executor.submit(scrape_period, period, i, total_periods): i + for i, period in enumerate(periods) + } + + # Barre de progression + with tqdm(total=total_periods, desc="Périodes scrapées", unit="période") as pbar: + for future in as_completed(future_to_period): + period_index = future_to_period[future] + try: + result = future.result() + all_courses.extend(result['courses']) + pbar.update(1) + pbar.set_postfix({ + 'total': len(all_courses), + 'success': result['success'] + }) + except Exception as e: + logging.error(f"Erreur sur la période {period_index}: {e}") + pbar.update(1) + + return all_courses + +def merge_all_period_courses(output_dir): + """Fusionner tous les fichiers CSV de périodes""" + logging.info(f"\n=== Fusion de tous les fichiers CSV ===") + + periods_dir = os.path.join(output_dir, 'courses', 'periods') + all_courses = [] + + # Lire tous les fichiers CSV + if os.path.exists(periods_dir): + period_files = [f for f in os.listdir(periods_dir) if f.endswith('.csv')] + + for period_file in tqdm(period_files, desc="Fusion des fichiers"): + file_path = os.path.join(periods_dir, period_file) + try: + df = pd.read_csv(file_path, encoding='utf-8-sig') + all_courses.append(df) + except Exception as e: + logging.warning(f"Erreur lors de la lecture de {period_file}: {e}") + + if all_courses: + # Fusionner tous les DataFrames + merged_df = pd.concat(all_courses, ignore_index=True) + + # Sauvegarder le fichier consolidé + courses_list_path = os.path.join(output_dir, 'courses', 'courses_list.csv') + os.makedirs(os.path.dirname(courses_list_path), exist_ok=True) + merged_df.to_csv(courses_list_path, index=False, encoding='utf-8-sig') + + logging.info(f"✅ Fusionné {len(all_courses)} fichiers dans {courses_list_path}") + logging.info(f" Total: {len(merged_df)} courses") + + return merged_df + else: + logging.error("❌ Aucun fichier CSV à fusionner") + return None + +def run_post_processing(output_dir): + """Exécuter les scripts de post-traitement""" + logging.info(f"\n=== Exécution des scripts de post-traitement ===") + + # Exécuter le script de post-traitement principal + post_process_script = os.path.join('.', 'post_process.py') + + if os.path.exists(post_process_script): + logging.info(f"\n📝 Exécution de post_process.py...") + try: + result = subprocess.run( + [sys.executable, post_process_script, output_dir], + capture_output=True, + text=True, + timeout=600 + ) + + if result.returncode == 0: + logging.info(f"✅ post_process.py terminé avec succès") + + # Afficher les résultats + output_lines = result.stdout.split('\n') + for line in output_lines[-30:]: # Dernières 30 lignes + if line.strip(): + logging.info(f" {line}") + else: + logging.error(f"❌ post_process.py a échoué") + logging.error(f" Erreur: {result.stderr[:500]}") + + except subprocess.TimeoutExpired: + logging.warning(f"⏰ post_process.py a expiré après 10 minutes") + except Exception as e: + logging.error(f"❌ Erreur lors de l'exécution de post_process.py: {e}") + else: + logging.warning(f"⚠️ Script post_process.py introuvable") + + # Exécuter les scripts utilitaires supplémentaires + additional_scripts = [ + ('list_clubs.py', ['--output', output_dir, '--details']), + ('extract_races.py', ['--data-dir', output_dir, '--details']), + ] + + for script_name, args in additional_scripts: + script_path = os.path.join('.', script_name) + + if os.path.exists(script_path): + logging.info(f"\n📝 Exécution de {script_name}...") + try: + result = subprocess.run( + [sys.executable, script_path] + args, + capture_output=True, + text=True, + timeout=300 + ) + + if result.returncode == 0: + logging.info(f"✅ {script_name} terminé avec succès") + else: + logging.warning(f"⚠️ {script_name} a rencontré des erreurs") + + except subprocess.TimeoutExpired: + logging.warning(f"⏰ {script_name} a expiré après 5 minutes") + except Exception as e: + logging.warning(f"⚠️ Erreur lors de l'exécution de {script_name}: {e}") + else: + logging.warning(f"⚠️ Script {script_name} introuvable") + +def main(): + """Fonction principale""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('ffa_scraper.log'), + logging.StreamHandler() + ] + ) + + # Configuration + start_year = 2010 + end_year = 2026 + max_workers = 8 # Workers pour le multithreading + + logging.info(f"{'='*80}") + logging.info(f"SCRAPING FFA COMPLET ({start_year}-{end_year})") + logging.info(f"{'='*80}") + logging.info(f"Mode: Multithreading avec {max_workers} workers") + logging.info(f"Périodes: 15 jours par période") + + # Générer les périodes + periods = get_15_day_periods(start_year, end_year) + + # Scraper toutes les périodes + start_time = time.time() + all_courses = scrape_all_periods_multithreaded(periods, max_workers) + end_time = time.time() + + # Statistiques + logging.info(f"\n{'='*80}") + logging.info(f"RÉSUMÉ DU SCRAPING") + logging.info(f"{'='*80}") + logging.info(f"Temps total: {(end_time - start_time)/60:.1f} minutes") + logging.info(f"Courses récupérées: {len(all_courses)}") + logging.info(f"Temps moyen par période: {(end_time - start_time)/len(periods):.1f} secondes") + + # Fusionner tous les fichiers CSV + output_dir = os.getenv('OUTPUT_DIR', 'data_2010_2026') + merged_df = merge_all_period_courses(output_dir) + + if merged_df is not None: + # Statistiques supplémentaires + print(f"\n{'='*80}") + print(f"STATISTIQUES DES COURSES") + print(f"{'='*80}") + print(f"Total: {len(merged_df)} courses") + + # Courses par année + merged_df['date'] = pd.to_datetime(merged_df['date'], errors='coerce') + merged_df['année'] = merged_df['date'].dt.year + + print(f"\nCourses par année:") + for year in sorted(merged_df['année'].dropna().unique()): + count = len(merged_df[merged_df['année'] == year]) + print(f" {year}: {count} courses") + + print(f"\n✅ Scraping terminé avec succès!") + + # Exécuter les scripts de post-traitement + run_post_processing(output_dir) + + print(f"\n{'='*80}") + print(f"TOUTES LES DONNÉES SONT DISPONIBLES DANS: {output_dir}") + print(f"{'='*80}") + else: + logging.error("❌ Erreur lors de la fusion des fichiers") + sys.exit(1) + +if __name__ == "__main__": + main() diff --git a/scripts/search_athlete.py b/scripts/search_athlete.py new file mode 100755 index 0000000..ddcd235 --- /dev/null +++ b/scripts/search_athlete.py @@ -0,0 +1,215 @@ +#!/usr/bin/env python3 +""" +Script pour rechercher un athlète dans les résultats FFA +Peut utiliser les fichiers CSV ou chercher directement depuis l'URL FFA +""" + +import pandas as pd +import os +import sys +import argparse +import logging +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) +from ffa_scraper import FFAScraper +from ffa_analyzer import FFADataAnalyzer +from datetime import datetime + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +def search_athlete_csv(nom, prenom=None, data_dir="data"): + """Rechercher un athlète dans les fichiers CSV""" + results_path = os.path.join(data_dir, 'resultats', 'results.csv') + + if not os.path.exists(results_path): + logging.error(f"Fichier de résultats introuvable: {results_path}") + return [] + + try: + df = pd.read_csv(results_path, encoding='utf-8-sig') + + # Filtre par nom (obligatoire) + mask = df['nom'].str.contains(nom, case=False, na=False) + + # Filtre par prénom (optionnel) + if prenom: + mask &= df['prenom'].str.contains(prenom, case=False, na=False) + + results = df[mask].to_dict('records') + logging.info(f"Trouvé {len(results)} résultats pour {nom} {prenom or ''}") + return results + except Exception as e: + logging.error(f"Erreur lors de la recherche dans les CSV: {e}") + return [] + +def search_athlete_live(nom, prenom=None, max_pages=5): + """Rechercher un athlète en direct depuis le site FFA""" + scraper = FFAScraper() + + logging.info(f"Recherche en direct pour {nom} {prenom or ''}...") + logging.warning("Note: Cela peut prendre du temps car il faut scraper les résultats") + + # Récupérer les courses et leurs résultats + total_pages, total_courses, _ = scraper._detect_pagination_info() + + if not total_pages: + logging.error("Impossible de détecter les données") + return [] + + max_pages = min(max_pages, total_pages) + logging.info(f"Analyse de {max_pages} pages...") + + all_results = [] + courses = scraper.get_courses_list(max_pages=max_pages, use_multithreading=True) + + for course in courses: + if course.get('resultats_url'): + results = scraper.get_course_results(course['resultats_url']) + + # Filtrer les résultats pour cet athlète + for result in results: + match_nom = nom.lower() in result.get('nom', '').lower() + match_prenom = not prenom or prenom.lower() in result.get('prenom', '').lower() + + if match_nom and match_prenom: + # Ajouter des infos de la course + result['course_nom'] = course.get('nom', '') + result['course_date'] = course.get('date', '') + result['course_lieu'] = course.get('lieu', '') + all_results.append(result) + + logging.info(f"Trouvé {len(all_results)} résultats en direct") + return all_results + +def display_athlete_results(results, show_details=False, limit=None): + """Afficher les résultats d'un athlète""" + if not results: + print("\n❌ Aucun résultat trouvé pour cet athlète") + return + + # Identifier l'athlète + athlete_nom = results[0].get('nom', 'Inconnu') + athlete_prenom = results[0].get('prenom', '') + + print(f"\n{'='*80}") + print(f"🏃 RÉSULTATS POUR {athlete_prenom} {athlete_nom}") + print(f"{'='*80}\n") + + if limit: + results = results[:limit] + + # Afficher les informations générales + print(f"Club: {results[0].get('club', 'Inconnu')}") + print(f"Total des courses: {len(results)}") + + if show_details: + # Calculer des statistiques + podiums = 0 + victoires = 0 + places = [] + + for result in results: + try: + place = int(result.get('place', 0)) + if place == 1: + victoires += 1 + podiums += 1 + elif place <= 3: + podiums += 1 + places.append(place) + except: + pass + + print(f"Victoires: {victoires}") + print(f"Podiums: {podiums}") + + if places: + avg_place = sum(places) / len(places) + print(f"Place moyenne: {avg_place:.2f}") + + print(f"\n{'='*80}\n") + + # Afficher les résultats individuels + print(f"{'📋 LISTE DES COURSES':<}") + print(f"{'='*80}\n") + + for i, result in enumerate(results, 1): + print(f"{i}. {result.get('course_nom', result.get('course_url', 'Inconnu'))}") + + if result.get('course_date'): + print(f" 📅 Date: {result['course_date']}") + + if result.get('course_lieu'): + print(f" 📍 Lieu: {result['course_lieu']}") + + print(f" 🏆 Place: {result.get('place', 'N/A')}") + print(f" ⏱️ Temps: {result.get('temps', result.get('resultat', 'N/A'))}") + print(f" 🏷️ Catégorie: {result.get('categorie', 'N/A')}") + + if show_details: + if result.get('points'): + print(f" 🎯 Points: {result['points']}") + if result.get('niveau'): + print(f" 📊 Niveau: {result['niveau']}") + + print() + + print(f"{'='*80}") + +def export_results_csv(results, nom, prenom=None, output_dir="data"): + """Exporter les résultats d'un athlète en CSV""" + os.makedirs(os.path.join(output_dir, 'exports'), exist_ok=True) + + if prenom: + filename = f"athlete_{nom}_{prenom}_results.csv" + else: + filename = f"athlete_{nom}_results.csv" + + filepath = os.path.join(output_dir, 'exports', filename.replace(" ", "_")) + + df = pd.DataFrame(results) + df.to_csv(filepath, index=False, encoding='utf-8-sig') + logging.info(f"Exporté {len(results)} résultats dans {filepath}") + return filepath + +def main(): + parser = argparse.ArgumentParser(description='Rechercher un athlète dans les résultats FFA') + parser.add_argument('nom', help='Nom de l\'athlète à rechercher') + parser.add_argument('--prenom', help='Prénom de l\'athlète (optionnel)') + parser.add_argument('--csv', action='store_true', default=True, + help='Utiliser les fichiers CSV existants (défaut)') + parser.add_argument('--live', action='store_true', + help='Récupérer les données en direct depuis le site FFA') + parser.add_argument('--data-dir', default='data', + help='Répertoire des données CSV') + parser.add_argument('--max-pages', type=int, default=5, + help='Nombre maximum de pages à scraper en mode live (défaut: 5)') + parser.add_argument('--details', action='store_true', + help='Afficher les détails complets') + parser.add_argument('--limit', type=int, + help='Limiter le nombre de résultats affichés') + parser.add_argument('--export', action='store_true', + help='Exporter les résultats en CSV') + + args = parser.parse_args() + + # Recherche + if args.live: + print(f"\n🔍 Mode live: recherche en direct sur le site FFA...") + results = search_athlete_live(args.nom, args.prenom, args.max_pages) + else: + print(f"\n📂 Mode CSV: recherche dans {args.data_dir}/") + results = search_athlete_csv(args.nom, args.prenom, args.data_dir) + + # Affichage + display_athlete_results(results, show_details=args.details, limit=args.limit) + + # Export + if args.export and results: + filepath = export_results_csv(results, args.nom, args.prenom, args.data_dir) + print(f"\n💾 Exporté dans: {filepath}") + +if __name__ == "__main__": + main() diff --git a/scripts/search_race.py b/scripts/search_race.py new file mode 100755 index 0000000..ad640a9 --- /dev/null +++ b/scripts/search_race.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +""" +Script pour rechercher une course dans les données FFA +Peut utiliser les fichiers CSV ou chercher directement depuis l'URL FFA +""" + +import pandas as pd +import os +import sys +import argparse +import logging +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src')) +from ffa_scraper import FFAScraper +from datetime import datetime + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' +) + +def search_race_by_name_csv(nom_course, data_dir="data"): + """Rechercher une course par nom dans les fichiers CSV""" + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if not os.path.exists(courses_path): + logging.error(f"Fichier de courses introuvable: {courses_path}") + return [] + + try: + df = pd.read_csv(courses_path, encoding='utf-8-sig') + mask = df['nom'].str.contains(nom_course, case=False, na=False) + courses = df[mask].to_dict('records') + logging.info(f"Trouvé {len(courses)} courses correspondant à '{nom_course}'") + return courses + except Exception as e: + logging.error(f"Erreur lors de la recherche dans les CSV: {e}") + return [] + +def search_race_by_date_csv(start_date, end_date=None, data_dir="data"): + """Rechercher des courses par date dans les fichiers CSV""" + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if not os.path.exists(courses_path): + logging.error(f"Fichier de courses introuvable: {courses_path}") + return [] + + try: + df = pd.read_csv(courses_path, encoding='utf-8-sig') + + # Convertir les dates + df['date'] = pd.to_datetime(df['date'], errors='coerce') + + if end_date: + mask = (df['date'] >= pd.to_datetime(start_date)) & (df['date'] <= pd.to_datetime(end_date)) + else: + mask = df['date'] == pd.to_datetime(start_date) + + courses = df[mask].sort_values('date').to_dict('records') + logging.info(f"Trouvé {len(courses)} courses dans la période") + return courses + except Exception as e: + logging.error(f"Erreur lors de la recherche par date: {e}") + return [] + +def search_race_by_type_csv(type_course, data_dir="data"): + """Rechercher des courses par type dans les fichiers CSV""" + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if not os.path.exists(courses_path): + logging.error(f"Fichier de courses introuvable: {courses_path}") + return [] + + try: + df = pd.read_csv(courses_path, encoding='utf-8-sig') + mask = df['type'].str.contains(type_course, case=False, na=False) + courses = df[mask].to_dict('records') + logging.info(f"Trouvé {len(courses)} courses de type '{type_course}'") + return courses + except Exception as e: + logging.error(f"Erreur lors de la recherche par type: {e}") + return [] + +def search_race_by_location_csv(lieu, data_dir="data"): + """Rechercher des courses par lieu dans les fichiers CSV""" + courses_path = os.path.join(data_dir, 'courses', 'courses_list.csv') + + if not os.path.exists(courses_path): + logging.error(f"Fichier de courses introuvable: {courses_path}") + return [] + + try: + df = pd.read_csv(courses_path, encoding='utf-8-sig') + mask = df['lieu'].str.contains(lieu, case=False, na=False) + courses = df[mask].to_dict('records') + logging.info(f"Trouvé {len(courses)} courses à '{lieu}'") + return courses + except Exception as e: + logging.error(f"Erreur lors de la recherche par lieu: {e}") + return [] + +def search_race_live(search_term, search_type="name", max_pages=5): + """Rechercher une course en direct depuis le site FFA""" + scraper = FFAScraper() + + logging.info(f"Recherche en direct de courses (type: {search_type})...") + logging.warning("Note: Cela peut prendre du temps") + + # Récupérer les courses + total_pages, total_courses, _ = scraper._detect_pagination_info() + + if not total_pages: + logging.error("Impossible de détecter les données") + return [] + + max_pages = min(max_pages, total_pages) + logging.info(f"Analyse de {max_pages} pages...") + + courses = scraper.get_courses_list(max_pages=max_pages, use_multithreading=True) + + # Filtrer selon le type de recherche + filtered_courses = [] + + for course in courses: + match = False + + if search_type == "name": + match = search_term.lower() in course.get('nom', '').lower() + elif search_type == "type": + match = search_term.lower() in course.get('type', '').lower() + elif search_type == "location": + match = search_term.lower() in course.get('lieu', '').lower() + + if match: + filtered_courses.append(course) + + logging.info(f"Trouvé {len(filtered_courses)} courses en direct") + return filtered_courses + +def display_race_results(courses, show_details=False, limit=None): + """Afficher les résultats de recherche de courses""" + if not courses: + print("\n❌ Aucune course trouvée") + return + + print(f"\n{'='*80}") + print(f"📅 RÉSULTATS DE LA RECHERCHE ({len(courses)} courses trouvées)") + print(f"{'='*80}\n") + + if limit: + courses = courses[:limit] + + for i, course in enumerate(courses, 1): + print(f"{i}. {course.get('nom', 'Inconnu')}") + + if course.get('date'): + print(f" 📅 Date: {course['date']}") + + if course.get('lieu'): + print(f" 📍 Lieu: {course['lieu']}") + + if course.get('type'): + print(f" 🏷️ Type: {course['type']}") + + if course.get('niveau'): + print(f" 📊 Niveau: {course['niveau']}") + + if show_details: + if course.get('discipline'): + print(f" 🎯 Discipline: {course['discipline']}") + + if course.get('fiche_detail'): + print(f" 🔗 Détails: {course['fiche_detail']}") + + if course.get('resultats_url'): + print(f" 🏆 Résultats: {course['resultats_url']}") + + if course.get('page'): + print(f" 📄 Page: {course['page']}") + + print() + + print(f"{'='*80}") + +def export_race_csv(courses, filename, output_dir="data"): + """Exporter les résultats de recherche en CSV""" + os.makedirs(os.path.join(output_dir, 'exports'), exist_ok=True) + + filepath = os.path.join(output_dir, 'exports', filename.replace(" ", "_")) + + df = pd.DataFrame(courses) + df.to_csv(filepath, index=False, encoding='utf-8-sig') + logging.info(f"Exporté {len(courses)} courses dans {filepath}") + return filepath + +def main(): + parser = argparse.ArgumentParser(description='Rechercher une course dans les données FFA') + parser.add_argument('--csv', action='store_true', default=True, + help='Utiliser les fichiers CSV existants (défaut)') + parser.add_argument('--live', action='store_true', + help='Récupérer les données en direct depuis le site FFA') + parser.add_argument('--data-dir', default='data', + help='Répertoire des données CSV') + parser.add_argument('--max-pages', type=int, default=5, + help='Nombre maximum de pages à scraper en mode live (défaut: 5)') + parser.add_argument('--details', action='store_true', + help='Afficher les détails complets') + parser.add_argument('--limit', type=int, + help='Limiter le nombre de courses affichées') + parser.add_argument('--export', action='store_true', + help='Exporter les résultats en CSV') + parser.add_argument('--export-filename', + help='Nom du fichier CSV exporté') + + # Critères de recherche + parser.add_argument('--name', help='Rechercher par nom de course') + parser.add_argument('--location', help='Rechercher par lieu') + parser.add_argument('--type', help='Rechercher par type de course') + parser.add_argument('--start-date', help='Date de début (format: YYYY-MM-DD)') + parser.add_argument('--end-date', help='Date de fin (format: YYYY-MM-DD)') + + args = parser.parse_args() + + # Vérifier qu'au moins un critère de recherche est fourni + if not any([args.name, args.location, args.type, args.start_date]): + parser.error("Au moins un critère de recherche est requis (--name, --location, --type, --start-date)") + + # Recherche + courses = [] + + if args.live: + # Mode live + if args.name: + print(f"\n🔍 Mode live: recherche de courses par nom '{args.name}'...") + courses = search_race_live(args.name, "name", args.max_pages) + elif args.type: + print(f"\n🔍 Mode live: recherche de courses par type '{args.type}'...") + courses = search_race_live(args.type, "type", args.max_pages) + elif args.location: + print(f"\n🔍 Mode live: recherche de courses par lieu '{args.location}'...") + courses = search_race_live(args.location, "location", args.max_pages) + + else: + # Mode CSV + print(f"\n📂 Mode CSV: recherche dans {args.data_dir}/") + + if args.name: + courses = search_race_by_name_csv(args.name, args.data_dir) + elif args.location: + courses = search_race_by_location_csv(args.location, args.data_dir) + elif args.type: + courses = search_race_by_type_csv(args.type, args.data_dir) + elif args.start_date: + courses = search_race_by_date_csv(args.start_date, args.end_date, args.data_dir) + + # Affichage + display_race_results(courses, show_details=args.details, limit=args.limit) + + # Export + if args.export and courses: + if args.export_filename: + filename = args.export_filename + else: + filename = f"race_search_{datetime.now().strftime('%Y%m%d_%H%M%S')}.csv" + + filepath = export_race_csv(courses, filename, args.data_dir) + print(f"\n💾 Exporté dans: {filepath}") + +if __name__ == "__main__": + main() diff --git a/src/ffa_analyzer.py b/src/ffa_analyzer.py new file mode 100644 index 0000000..78c326f --- /dev/null +++ b/src/ffa_analyzer.py @@ -0,0 +1,365 @@ +""" +Module d'analyse et de recherche des données CSV générées par le scraper FFA +""" + +import pandas as pd +import os +import re +from typing import List, Dict, Optional +import logging + +class FFADataAnalyzer: + """Classe pour analyser et rechercher dans les données FFA""" + + def __init__(self, data_dir="data"): + """Initialiser l'analyseur avec le répertoire des données""" + self.data_dir = data_dir + self.clubs_df = None + self.courses_df = None + self.results_df = None + + self._load_data() + + def _load_data(self): + """Charger tous les fichiers CSV disponibles""" + clubs_path = os.path.join(self.data_dir, 'clubs', 'clubs_list.csv') + courses_path = os.path.join(self.data_dir, 'courses', 'courses_list.csv') + results_path = os.path.join(self.data_dir, 'resultats', 'results.csv') + + try: + if os.path.exists(clubs_path): + self.clubs_df = pd.read_csv(clubs_path) + logging.info(f"Chargé {len(self.clubs_df)} clubs") + + if os.path.exists(courses_path): + self.courses_df = pd.read_csv(courses_path) + logging.info(f"Chargé {len(self.courses_df)} courses") + + # Charger les détails des courses si disponibles + courses_details_path = os.path.join(self.data_dir, 'courses', 'courses_details.csv') + if os.path.exists(courses_details_path): + details_df = pd.read_csv(courses_details_path) + # Renommer les colonnes pour éviter les conflits + details_df = details_df.rename(columns={ + 'nom': 'details_nom', + 'date': 'details_date', + 'lieu': 'details_lieu', + 'type': 'details_type', + 'niveau': 'details_niveau' + }) + # Fusionner avec les courses de base + # S'assurer que les colonnes de fusion sont du même type + self.courses_df['lien'] = self.courses_df['lien'].astype(str) + details_df['url'] = details_df['url'].astype(str) + self.courses_df = pd.merge(self.courses_df, details_df, + left_on='lien', right_on='url', how='left') + + if os.path.exists(results_path): + self.results_df = pd.read_csv(results_path) + logging.info(f"Chargé {len(self.results_df)} résultats") + + except Exception as e: + logging.error(f"Erreur lors du chargement des données: {e}") + + def search_athlete(self, nom: str, prenom: Optional[str] = None) -> List[Dict]: + """Rechercher un athlète par nom/prénom""" + if self.results_df is None: + return [] + + mask = self.results_df['nom'].str.contains(nom, case=False, na=False) + if prenom: + mask &= self.results_df['prenom'].str.contains(prenom, case=False, na=False) + + results = self.results_df[mask].to_dict('records') + + # Ajouter les informations des courses pour chaque résultat + for result in results: + if self.courses_df is not None and 'course_url' in result: + course_info = self.courses_df[ + self.courses_df['lien'] == result.get('course_url', '') + ] + if not course_info.empty: + result['course_info'] = course_info.iloc[0].to_dict() + + return results + + def search_club_in_results(self, nom_club: str) -> Dict: + """Rechercher un club dans les résultats et retourner ses athlètes et leurs résultats""" + if self.results_df is None: + return {} + + # Rechercher les résultats pour ce club + club_mask = self.results_df['club'].str.contains(nom_club, case=False, na=False) + athletes_results = self.results_df[club_mask].to_dict('records') + + if not athletes_results: + return {} + + # Créer l'info du club + club = { + 'nom': nom_club, + 'athletes': [] + } + + # Grouper par athlète et calculer des statistiques + athletes = {} + for result in athletes_results: + athlete_key = f"{result['prenom']} {result['nom']}" + if athlete_key not in athletes: + athletes[athlete_key] = { + 'nom': result['nom'], + 'prenom': result['prenom'], + 'categorie': result['categorie'], + 'results': [] + } + + athletes[athlete_key]['results'].append(result) + + club['athletes'] = list(athletes.values()) + return club + + def get_course_by_date(self, date_debut: str, date_fin: str) -> List[Dict]: + """Rechercher les courses dans une période donnée""" + if self.courses_df is None: + return [] + + # Convertir les dates (format à adapter selon les données réelles) + try: + mask = (pd.to_datetime(self.courses_df['date']) >= pd.to_datetime(date_debut)) & \ + (pd.to_datetime(self.courses_df['date']) <= pd.to_datetime(date_fin)) + courses = self.courses_df[mask].to_dict('records') + return courses + except: + # Fallback en cas d'erreur de conversion de date + return self.courses_df.to_dict('records') + + def get_course_results(self, course_url: str) -> List[Dict]: + """Récupérer tous les résultats d'une course spécifique""" + if self.results_df is None: + return [] + + mask = self.results_df['course_url'] == course_url + results = self.results_df[mask].sort_values('place').to_dict('records') + return results + + def get_club_rankings(self, course_url: str) -> List[Dict]: + """Calculer le classement par club pour une course""" + if self.results_df is None: + return [] + + course_results = self.get_course_results(course_url) + club_stats = {} + + for result in course_results: + club = result.get('club', 'Inconnu') + if club not in club_stats: + club_stats[club] = { + 'club': club, + 'participants': 0, + 'places': [], + 'score': 0 # Score basé sur les places (moins de points = meilleur) + } + + club_stats[club]['participants'] += 1 + try: + place = int(result.get('place', 0)) + club_stats[club]['places'].append(place) + club_stats[club]['score'] += place + except: + pass + + # Trier par score (croissant) + rankings = sorted(club_stats.values(), key=lambda x: x['score']) + + # Ajouter des statistiques supplémentaires + for i, ranking in enumerate(rankings, 1): + ranking['rang'] = i + if ranking['places']: + ranking['place_moyenne'] = sum(ranking['places']) / len(ranking['places']) + ranking['meilleure_place'] = min(ranking['places']) + + return rankings + + def get_athlete_stats(self, nom: str, prenom: Optional[str] = None) -> Dict: + """Obtenir les statistiques complètes d'un athlète""" + results = self.search_athlete(nom, prenom) + + if not results: + return {} + + # Grouper les résultats par année/catégorie + stats = { + 'nom': results[0]['nom'], + 'prenom': results[0]['prenom'], + 'club': results[0]['club'], + 'total_courses': len(results), + 'categories': set(), + 'courses_par_annee': {}, + 'meilleurs_temps': {}, + 'podiums': 0, + 'victoires': 0 + } + + for result in results: + # Catégories + stats['categories'].add(result.get('categorie', '')) + + # Année (extraire de la date si disponible) + if 'course_info' in result and 'date' in result['course_info']: + try: + date = result['course_info']['date'] + year = re.search(r'\d{4}', str(date)).group() + if year not in stats['courses_par_annee']: + stats['courses_par_annee'][year] = 0 + stats['courses_par_annee'][year] += 1 + except: + pass + + # Places et podiums + try: + place = int(result.get('place', 0)) + if place == 1: + stats['victoires'] += 1 + stats['podiums'] += 1 + elif place <= 3: + stats['podiums'] += 1 + except: + pass + + stats['categories'] = list(stats['categories']) + + return stats + + def get_top_athletes(self, limit=10, min_results=3) -> List[Dict]: + """Récupérer les meilleurs athlètes basés sur leurs performances""" + if self.results_df is None: + return [] + + athlete_stats = {} + + for _, result in self.results_df.iterrows(): + athlete_key = f"{result['prenom']} {result['nom']}" + if athlete_key not in athlete_stats: + athlete_stats[athlete_key] = { + 'nom': result['nom'], + 'prenom': result['prenom'], + 'club': result['club'], + 'results_count': 0, + 'places': [], + 'victoires': 0, + 'podiums': 0 + } + + athlete_stats[athlete_key]['results_count'] += 1 + try: + place = int(result['place']) + athlete_stats[athlete_key]['places'].append(place) + if place == 1: + athlete_stats[athlete_key]['victoires'] += 1 + athlete_stats[athlete_key]['podiums'] += 1 + elif place <= 3: + athlete_stats[athlete_key]['podiums'] += 1 + except: + pass + + # Filtrer les athlètes avec suffisamment de résultats + filtered_athletes = [ + stats for stats in athlete_stats.values() + if stats['results_count'] >= min_results + ] + + # Trier par nombre de victoires, puis podiums, puis nombre de courses + filtered_athletes.sort( + key=lambda x: (-x['victoires'], -x['podiums'], -x['results_count']) + ) + + # Calculer la place moyenne + for athlete in filtered_athletes[:limit]: + if athlete['places']: + athlete['place_moyenne'] = sum(athlete['places']) / len(athlete['places']) + else: + athlete['place_moyenne'] = None + + return filtered_athletes[:limit] + + def get_courses_by_type(self, course_type: str) -> List[Dict]: + """Récupérer les courses par type""" + if self.courses_df is None: + return [] + + if not course_type: + return self.courses_df.to_dict('records') + + mask = self.courses_df['type'].str.contains(course_type, case=False, na=False) + return self.courses_df[mask].to_dict('records') + + def export_athlete_csv(self, nom: str, prenom: Optional[str] = None, filename: str = None): + """Exporter les résultats d'un athlète en CSV""" + results = self.search_athlete(nom, prenom) + + if not results: + return None + + if not filename: + filename = f"athlete_{nom}_{prenom or ''}.csv".replace(" ", "_") + + filepath = os.path.join(self.data_dir, 'exports', filename) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + df = pd.DataFrame(results) + df.to_csv(filepath, index=False, encoding='utf-8-sig') + logging.info(f"Exporté {len(results)} résultats pour {nom} {prenom or ''} dans {filepath}") + + return filepath + + def export_club_csv(self, nom_club: str, filename: str = None): + """Exporter les résultats d'un club en CSV""" + club_data = self.search_club_in_results(nom_club) + + if not club_data or 'athletes' not in club_data: + return None + + if not filename: + filename = f"club_{nom_club}.csv".replace(" ", "_") + + filepath = os.path.join(self.data_dir, 'exports', filename) + os.makedirs(os.path.dirname(filepath), exist_ok=True) + + # Aplatir les données pour le CSV + flat_results = [] + for athlete in club_data['athletes']: + for result in athlete['results']: + flat_results.append(result) + + if flat_results: + df = pd.DataFrame(flat_results) + df.to_csv(filepath, index=False, encoding='utf-8-sig') + logging.info(f"Exporté {len(flat_results)} résultats pour le club {nom_club} dans {filepath}") + return filepath + + return None + + +if __name__ == "__main__": + analyzer = FFADataAnalyzer() + + # Exemples d'utilisation + + # Rechercher un athlète + print("Recherche d'un athlète:") + athlete_results = analyzer.search_athlete("Dupont", "Jean") + print(f"Trouvé {len(athlete_results)} résultats") + + # Rechercher un club + print("\nRecherche d'un club:") + club_info = analyzer.search_club_in_results("Paris Athletic") + print(f"Club trouvé avec {len(club_info.get('athletes', []))} athlètes") + + # Obtenir les statistiques d'un athlète + print("\nStatistiques d'un athlète:") + athlete_stats = analyzer.get_athlete_stats("Durand", "Marie") + print(f"Courses: {athlete_stats.get('total_courses', 0)}, Podiums: {athlete_stats.get('podiums', 0)}") + + # Exporter en CSV + if athlete_results: + analyzer.export_athlete_csv("Dupont", "Jean") \ No newline at end of file diff --git a/src/ffa_scraper.py b/src/ffa_scraper.py new file mode 100644 index 0000000..80e7794 --- /dev/null +++ b/src/ffa_scraper.py @@ -0,0 +1,610 @@ +""" +FFA Calendar Scraper + +Un système de scraping pour récupérer les données du site de la Fédération +Française d'Athlétisme (FFA) et les organiser dans des fichiers CSV. +""" + +import requests +from bs4 import BeautifulSoup +import pandas as pd +import time +import json +import re +from urllib.parse import urljoin, urlparse, urlencode, parse_qs +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from webdriver_manager.chrome import ChromeDriverManager +import os +from tqdm import tqdm +import logging +from dotenv import load_dotenv +from concurrent.futures import ThreadPoolExecutor, as_completed +from threading import Lock +import threading + +# Charger les variables d'environnement +load_dotenv() + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('ffa_scraper.log'), + logging.StreamHandler() + ] +) + +class FFAScraper: + """Classe principale pour scraper les données de la FFA""" + + def __init__(self, output_dir=None): + """Initialiser le scraper avec répertoire de sortie""" + # Charger les URLs depuis config.env ou utiliser les valeurs par défaut + self.BASE_URL = os.getenv('BASE_URL', 'https://athle.fr') + self.CLUBS_URL = os.getenv('CLUBS_URL', 'https://monclub.athle.fr') + self.CALENDAR_URL = os.getenv('CALENDAR_URL', + "https://www.athle.fr/bases/liste.aspx?frmpostback=true&frmbase=calendrier&frmmode=1&frmespace=0&frmsaisonffa=2026&frmdate1=2025-09-01&frmdate2=2026-08-31&frmtype1=&frmniveau=&frmligue=&frmdepartement=&frmniveaulab=&frmepreuve=&frmtype2=&frmtype3=&frmtype4=&frmposition=4") + self.RESULTS_URL = os.getenv('RESULTS_URL', 'https://athle.fr/les-resultats') + + # Configuration du scraping + self.output_dir = output_dir or os.getenv('OUTPUT_DIR', 'data') + self.request_delay = float(os.getenv('REQUEST_DELAY', '1.5')) + self.max_retries = int(os.getenv('MAX_RETRIES', '3')) + self.timeout = int(os.getenv('TIMEOUT', '30')) + self.max_workers = int(os.getenv('MAX_WORKERS', '4')) + + # Initialiser la session HTTP + self.session = requests.Session() + self.session.headers.update({ + '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' + }) + + # Créer les répertoires nécessaires + os.makedirs(self.output_dir, exist_ok=True) + os.makedirs(f"{self.output_dir}/clubs", exist_ok=True) + os.makedirs(f"{self.output_dir}/courses", exist_ok=True) + os.makedirs(f"{self.output_dir}/resultats", exist_ok=True) + + # Thread-local storage pour Selenium drivers (pour le multithreading) + self._thread_local = threading.local() + self._data_lock = Lock() # Pour protéger l'accès aux données partagées + self.driver = None # Pour compatibilité avec le code existant + self._current_calendar_url = self.CALENDAR_URL # URL actuelle à utiliser (modifiable) + + def _init_selenium(self): + """Initialiser Selenium pour les pages dynamiques""" + if self.driver is None: + try: + # Options pour le mode headless si configuré + options = webdriver.ChromeOptions() + if os.getenv('HEADLESS', 'True').lower() == 'true': + options.add_argument('--headless') + options.add_argument('--disable-gpu') + window_size = os.getenv('WINDOW_SIZE', '1920,1080') + if window_size: + width, height = window_size.split(',') + options.add_argument(f'--window-size={width},{height}') + + service = Service(ChromeDriverManager().install()) + self.driver = webdriver.Chrome(service=service, options=options) + logging.info("Driver Chrome initialisé avec succès") + except Exception as e: + logging.error(f"Impossible d'initialiser le driver Chrome: {e}") + raise + + def _get_thread_selenium(self): + """Récupérer ou créer un driver Selenium pour le thread actuel""" + if not hasattr(self._thread_local, 'driver') or self._thread_local.driver is None: + try: + options = webdriver.ChromeOptions() + if os.getenv('HEADLESS', 'True').lower() == 'true': + options.add_argument('--headless') + options.add_argument('--disable-gpu') + options.add_argument('--no-sandbox') + options.add_argument('--disable-dev-shm-usage') + + service = Service(ChromeDriverManager().install()) + self._thread_local.driver = webdriver.Chrome(service=service, options=options) + except Exception as e: + logging.error(f"Impossible d'initialiser le driver Chrome pour le thread: {e}") + raise + return self._thread_local.driver + + def _close_thread_selenium(self): + """Fermer le driver Selenium du thread actuel""" + if hasattr(self._thread_local, 'driver') and self._thread_local.driver: + self._thread_local.driver.quit() + self._thread_local.driver = None + + def _close_selenium(self): + """Fermer Selenium""" + if self.driver: + self.driver.quit() + self.driver = None + + def _close_all_selenium(self): + """Fermer tous les drivers Selenium (pour le multithreading)""" + if hasattr(self, '_thread_local') and hasattr(self._thread_local, 'driver') and self._thread_local.driver: + self._thread_local.driver.quit() + self._thread_local.driver = None + + def get_page(self, url, use_selenium=False): + """Récupérer le contenu d'une page""" + if use_selenium: + self._init_selenium() + self.driver.get(url) + # Attendre que la page charge + time.sleep(self.request_delay) + return self.driver.page_source + else: + for attempt in range(self.max_retries): + try: + response = self.session.get(url, timeout=self.timeout) + response.raise_for_status() + return response.text + except requests.RequestException as e: + if attempt < self.max_retries - 1: + logging.warning(f"Tentative {attempt + 1}/{self.max_retries} échouée pour {url}: {e}") + time.sleep(self.request_delay) + else: + logging.error(f"Erreur lors de l'accès à {url}: {e}") + return None + + def save_to_csv(self, data, filename, index=False): + """Sauvegarder les données en CSV""" + if not data: + logging.warning(f"Pas de données à sauvegarder dans {filename}") + return None + + filepath = os.path.join(self.output_dir, filename) + df = pd.DataFrame(data) + encoding = os.getenv('CSV_ENCODING', 'utf-8-sig') + df.to_csv(filepath, index=index, encoding=encoding) + logging.info(f"Données sauvegardées dans {filepath} ({len(data)} enregistrements)") + return filepath + + def get_clubs_list(self): + """Récupérer la liste des clubs (NON FONCTIONNEL - nécessite authentification)""" + logging.warning("La récupération des clubs n'est pas disponible - nécessite une authentification sur monclub.athle.fr") + return [] + + def _get_page_url(self, page_num): + """Générer l'URL pour une page spécifique""" + calendar_url = self._current_calendar_url or self.CALENDAR_URL + + if page_num == 1: + return calendar_url + + parsed_url = urlparse(calendar_url) + query_params = parse_qs(parsed_url.query) + query_params['page'] = [str(page_num)] + new_query = urlencode(query_params, doseq=True) + return f"{parsed_url.scheme}://{parsed_url.netloc}{parsed_url.path}?{new_query}" + + def _detect_pagination_info(self): + """Détecter le nombre total de pages et de courses depuis la première page""" + logging.info("Détection des informations de pagination...") + + calendar_url = self._current_calendar_url or self.CALENDAR_URL + html = self.get_page(calendar_url, use_selenium=True) + if not html: + return None, None, None + + soup = BeautifulSoup(html, 'html.parser') + + # Chercher les informations de pagination + total_courses = None + total_pages = None + + # Chercher un texte contenant le nombre total de résultats + for element in soup.find_all(text=re.compile(r'\d+\s*résultats|total|compétitions', re.IGNORECASE)): + match = re.search(r'(\d+(?:\s*\d+)*)\s*(?:résultats|compétitions)', str(element), re.IGNORECASE) + if match: + total_courses = int(match.group(1).replace(' ', '')) + logging.info(f"Total des courses détecté: {total_courses}") + break + + # Chercher les liens de pagination pour obtenir le nombre de pages + pagination_select = soup.find('select', class_=re.compile(r'page|pagination', re.IGNORECASE)) + if pagination_select: + options = pagination_select.find_all('option') + if options: + total_pages = int(options[-1].get_text(strip=True)) + logging.info(f"Total des pages détecté: {total_pages}") + + # Sinon, chercher les liens de pagination + if not total_pages: + pagination_links = soup.find_all('a', href=re.compile(r'page', re.IGNORECASE)) + page_numbers = [] + for link in pagination_links: + text = link.get_text(strip=True) + if text.isdigit(): + page_numbers.append(int(text)) + if page_numbers: + total_pages = max(page_numbers) + + # Compter les courses sur la première page pour estimer + main_table = soup.find('table', class_='reveal-table') + if main_table: + rows = main_table.find_all('tr') + first_page_count = sum(1 for row in rows if len(row.find_all('td')) >= 5 and not row.find('table', class_='detail-inner-table')) + + if not total_courses and total_pages: + total_courses = first_page_count * total_pages + + if not total_pages and total_courses and first_page_count: + total_pages = (total_courses + first_page_count - 1) // first_page_count + + return total_pages, total_courses, first_page_count if 'first_page_count' in locals() else 250 + + def _scrape_page(self, page_num): + """Scraper une page spécifique (méthode thread-safe)""" + try: + page_url = self._get_page_url(page_num) + + driver = self._get_thread_selenium() + driver.get(page_url) + + time.sleep(self.request_delay) + html = driver.page_source + + if not html: + logging.warning(f"Impossible d'accéder à la page {page_num}") + return [] + + soup = BeautifulSoup(html, 'html.parser') + main_table = soup.find('table', class_='reveal-table') + if not main_table: + logging.warning(f"Table principale non trouvée (page {page_num})") + return [] + + rows = main_table.find_all('tr') + page_courses = [] + + for row in rows: + cells = row.find_all('td') + if len(cells) < 5 or row.find('table', class_='detail-inner-table'): + continue + + try: + date = cells[0].get_text(strip=True) + + libelle_cell = cells[1] + link_element = libelle_cell.find('a', href=True) + if link_element: + nom = link_element.get_text(strip=True) + course_url = link_element['href'] + if not course_url.startswith('http'): + course_url = urljoin(self.BASE_URL, course_url) + else: + nom = libelle_cell.get_text(strip=True) + course_url = '' + + lieu = cells[2].get_text(strip=True) + type_competition = cells[3].get_text(strip=True) + niveau = cells[4].get_text(strip=True) + + detail_url = '' + result_url = '' + + for cell in cells: + links = cell.find_all('a', href=True) + for link in links: + href = link.get('href', '') + if '/competitions/' in href: + detail_url = urljoin(self.BASE_URL, href) if not href.startswith('http') else href + elif 'frmbase=resultats' in href: + result_url = urljoin(self.BASE_URL, href) if not href.startswith('http') else href + + course = { + 'nom': nom, + 'date': date, + 'lieu': lieu, + 'discipline': '', + 'type': type_competition, + 'niveau': niveau, + 'label': '', + 'lien': course_url, + 'fiche_detail': detail_url, + 'resultats_url': result_url, + 'page': page_num + } + page_courses.append(course) + + except Exception as e: + logging.warning(f"Erreur lors du parsing d'une course (page {page_num}): {e}") + continue + + logging.info(f"Page {page_num}: {len(page_courses)} courses trouvées") + return page_courses + + except Exception as e: + logging.error(f"Erreur lors du scraping de la page {page_num}: {e}") + return [] + + def get_courses_list(self, start_date=None, end_date=None, max_pages=1, use_multithreading=True, calendar_url=None): + """Récupérer la liste des courses/calendrier avec support du multithreading""" + + # Utiliser l'URL personnalisée si fournie + if calendar_url: + self._current_calendar_url = calendar_url + + # Détecter d'abord le nombre total de pages et de courses + total_pages, total_courses, courses_per_page = self._detect_pagination_info() + + if total_pages: + logging.info(f"Pagination détectée: {total_pages} pages, ~{total_courses} courses totales") + if max_pages is None or max_pages > total_pages: + max_pages = total_pages + + logging.info(f"Récupération du calendrier des compétitions (max {max_pages} pages, {'multithreading' if use_multithreading else 'séquentiel'})...") + + all_courses = [] + + if use_multithreading and max_pages > 1: + # Utiliser le multithreading + logging.info(f"Démarrage du multithreading avec {self.max_workers} workers...") + + with ThreadPoolExecutor(max_workers=self.max_workers, thread_name_prefix='scraper') as executor: + # Soumettre toutes les tâches + future_to_page = {executor.submit(self._scrape_page, page_num): page_num + for page_num in range(1, max_pages + 1)} + + # Récupérer les résultats avec une barre de progression + with tqdm(total=max_pages, desc="Pages scrapées", unit="page") as pbar: + for future in as_completed(future_to_page): + page_num = future_to_page[future] + try: + page_courses = future.result() + with self._data_lock: + all_courses.extend(page_courses) + pbar.update(1) + pbar.set_postfix({'total': len(all_courses)}) + except Exception as e: + logging.error(f"Erreur sur la page {page_num}: {e}") + pbar.update(1) + + else: + # Mode séquentiel + for page_num in range(1, max_pages + 1): + logging.info(f"Page {page_num}/{max_pages}") + page_courses = self._scrape_page(page_num) + all_courses.extend(page_courses) + + if page_num < max_pages: + time.sleep(self.request_delay) + + logging.info(f"Total: {len(all_courses)} courses trouvées") + return all_courses + + def get_course_details(self, course_url): + """Récupérer les détails d'une course spécifique""" + full_url = urljoin(self.BASE_URL, course_url) + html = self.get_page(full_url) + + if not html: + return None + + soup = BeautifulSoup(html, 'html.parser') + + try: + details = { + 'nom': soup.find('h1').get_text(strip=True) if soup.find('h1') else '', + 'date': soup.find('span', class_='date').get_text(strip=True) if soup.find('span', class_='date') else '', + 'lieu': soup.find('span', class_='location').get_text(strip=True) if soup.find('span', class_='location') else '', + 'organisateur': soup.find('span', class_='organizer').get_text(strip=True) if soup.find('span', class_='organizer') else '', + 'description': soup.find('div', class_='description').get_text(strip=True) if soup.find('div', class_='description') else '', + 'epreuves': [] + } + + # Récupérer les épreuves + epreuve_elements = soup.find_all('div', class_='epreuve-item') + for epreuve in epreuve_elements: + details['epreuves'].append({ + 'nom': epreuve.find('span', class_='epreuve-name').get_text(strip=True) if epreuve.find('span', class_='epreuve-name') else '', + 'categorie': epreuve.find('span', class_='category').get_text(strip=True) if epreuve.find('span', class_='category') else '', + 'horaires': epreuve.find('span', class_='time').get_text(strip=True) if epreuve.find('span', class_='time') else '' + }) + + return details + except Exception as e: + logging.warning(f"Erreur lors du parsing des détails de la course {course_url}: {e}") + return None + + def get_course_results(self, course_url): + """Récupérer les résultats d'une course""" + full_url = urljoin(self.BASE_URL, course_url) + html = self.get_page(full_url, use_selenium=True) + + if not html: + return [] + + soup = BeautifulSoup(html, 'html.parser') + results = [] + + # Trouver la table principale avec les résultats + main_table = soup.find('table', class_='reveal-table') + if not main_table: + logging.warning("Table principale des résultats non trouvée") + return [] + + # Parser les lignes de résultats + rows = main_table.find_all('tr') + header_found = False + + for row in rows: + cells = row.find_all(['td', 'th']) + + # Ignorer les lignes qui sont des tables imbriquées (détails de club) + if row.find('table', class_='detail-inner-table'): + continue + + # Chercher la ligne d'en-tête + if not header_found and cells: + first_cell_text = cells[0].get_text(strip=True) + if first_cell_text == 'Place' and len(cells) >= 9: + header_found = True + continue + + # Après l'en-tête, parser les lignes de résultats + if header_found and cells and len(cells) >= 9: + try: + # Extraire les données + place = cells[0].get_text(strip=True) + resultat = cells[1].get_text(strip=True) + + # Le nom peut être dans la cellule 2 ou combiné + nom_cell = cells[2].get_text(strip=True) if len(cells) > 2 else '' + # Séparer nom et prénom si possible + # Le format FFA est généralement "NOM Prénom" + if nom_cell: + parts = nom_cell.split(' ') + if len(parts) >= 2: + nom = parts[0] # Le nom de famille est généralement en premier + prenom = ' '.join(parts[1:]) + else: + nom = nom_cell + prenom = '' + else: + nom = '' + prenom = '' + + club = cells[3].get_text(strip=True) if len(cells) > 3 else '' + dept = cells[4].get_text(strip=True) if len(cells) > 4 else '' + ligue = cells[5].get_text(strip=True) if len(cells) > 5 else '' + infos = cells[6].get_text(strip=True) if len(cells) > 6 else '' + niveau = cells[7].get_text(strip=True) if len(cells) > 7 else '' + points = cells[8].get_text(strip=True) if len(cells) > 8 else '' + + # Vérifier que c'est bien une ligne de résultat (place doit être un nombre) + try: + int(place) + except (ValueError, TypeError): + continue + + result = { + 'place': place, + 'resultat': resultat, + 'nom': nom, + 'prenom': prenom, + 'club': club, + 'dept': dept, + 'ligue': ligue, + 'categorie': infos, # Les infos contiennent souvent la catégorie + 'niveau': niveau, + 'points': points, + 'temps': resultat, # Le résultat est généralement le temps + 'course_url': course_url + } + results.append(result) + + except Exception as e: + logging.warning(f"Erreur lors du parsing d'un résultat: {e}") + continue + + logging.info(f"Trouvé {len(results)} résultats") + return results + + def scrap_all_data(self, limit_courses=None, limit_results=None, fetch_details=False, max_pages=10, use_multithreading=True): + """Scraper toutes les données principales""" + logging.info("Début du scraping complet des données FFA") + + # Récupérer les clubs + clubs = self.get_clubs_list() + if clubs: + self.save_to_csv(clubs, 'clubs/clubs_list.csv') + + # Récupérer les courses + courses = self.get_courses_list(max_pages=max_pages, use_multithreading=use_multithreading) + if courses: + self.save_to_csv(courses, 'courses/courses_list.csv') + + # Limiter le nombre de courses si spécifié + if limit_courses: + courses = courses[:limit_courses] + + # Récupérer les détails et résultats de chaque course (si demandé) + all_results = [] + course_details = [] + + if fetch_details: + for idx, course in enumerate(tqdm(courses, desc="Scraping des courses")): + # Utiliser le lien vers la fiche détaillée si disponible + detail_url = course.get('fiche_detail', '') or course.get('lien', '') + if detail_url: + # Détails de la course + details = self.get_course_details(detail_url) + if details: + details['url'] = detail_url + course_details.append(details) + + # Utiliser le lien vers les résultats si disponible + result_url = course.get('resultats_url', '') + if result_url: + # Résultats de la course + results = self.get_course_results(result_url) + if results: + # Ajouter l'URL de la course aux résultats + for result in results: + result['course_url'] = result_url + all_results.extend(results) + + # Pause pour éviter de surcharger le serveur + time.sleep(1) + + # Sauvegarder progressivement toutes les 10 courses + if (idx + 1) % 10 == 0: + if all_results: + self.save_to_csv(all_results, 'resultats/results.csv') + logging.info(f"Sauvegarde intermédiaire: {len(all_results)} résultats") + + # Sauvegarder les détails et résultats + if course_details: + self.save_to_csv(course_details, 'courses/courses_details.csv') + + if all_results: + # Limiter les résultats si spécifié + if limit_results: + all_results = all_results[:limit_results] + self.save_to_csv(all_results, 'resultats/results.csv') + + self._close_all_selenium() + logging.info("Scraping complet terminé") + + return { + 'clubs_count': len(clubs), + 'courses_count': len(courses), + 'results_count': len(all_results) if 'all_results' in locals() else 0 + } + + +if __name__ == "__main__": + scraper = FFAScraper() + + # D'abord vérifier le nombre de courses disponibles + total_pages, total_courses, _ = scraper._detect_pagination_info() + + print("\n" + "="*60) + print("📊 Informations de pagination") + print("="*60) + if total_pages: + print(f"Nombre total de pages: {total_pages}") + print(f"Estimation du nombre total de courses: ~{total_courses}") + + print(f"\n⏱️ Estimation du temps de scraping:") + print(f" - Multithreading (4 workers): ~{total_pages / 4 * 2:.0f} secondes") + print(f" - Séquentiel: ~{total_pages * 2:.0f} secondes") + print("="*60) + + # Lancer le scraping avec multithreading activé par défaut + stats = scraper.scrap_all_data(fetch_details=False, max_pages=total_pages if total_pages else 10, use_multithreading=True) + + print(f"\nScraping terminé:") + print(f"- Clubs: {stats['clubs_count']}") + print(f"- Courses: {stats['courses_count']}") + print(f"- Résultats: {stats['results_count']}") \ No newline at end of file