Initial commit: Reorganiser le projet FFA Calendar Scraper
- 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 <crush@charm.land>
This commit is contained in:
137
.gitignore
vendored
Normal file
137
.gitignore
vendored
Normal file
@@ -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
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
@@ -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.
|
||||
423
README.md
Normal file
423
README.md
Normal file
@@ -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.
|
||||
17
config/config.env
Normal file
17
config/config.env
Normal file
@@ -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
|
||||
8
requirements.txt
Normal file
8
requirements.txt
Normal file
@@ -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
|
||||
360
scripts/athlete_summary.py
Executable file
360
scripts/athlete_summary.py
Executable file
@@ -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()
|
||||
349
scripts/extract_races.py
Executable file
349
scripts/extract_races.py
Executable file
@@ -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()
|
||||
283
scripts/ffa_cli.py
Normal file
283
scripts/ffa_cli.py
Normal file
@@ -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()
|
||||
181
scripts/list_clubs.py
Executable file
181
scripts/list_clubs.py
Executable file
@@ -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()
|
||||
72
scripts/monitor_scraping.py
Executable file
72
scripts/monitor_scraping.py
Executable file
@@ -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()
|
||||
298
scripts/post_process.py
Executable file
298
scripts/post_process.py
Executable file
@@ -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()
|
||||
312
scripts/scrape_all_periods.py
Executable file
312
scripts/scrape_all_periods.py
Executable file
@@ -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()
|
||||
215
scripts/search_athlete.py
Executable file
215
scripts/search_athlete.py
Executable file
@@ -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()
|
||||
269
scripts/search_race.py
Executable file
269
scripts/search_race.py
Executable file
@@ -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()
|
||||
365
src/ffa_analyzer.py
Normal file
365
src/ffa_analyzer.py
Normal file
@@ -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")
|
||||
610
src/ffa_scraper.py
Normal file
610
src/ffa_scraper.py
Normal file
@@ -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']}")
|
||||
Reference in New Issue
Block a user