✨ Feature: Add club results extractor and course details scraper
- Add get_club_results.py: Extract all results for a club from results CSV - Fuzzy search for club names - List clubs functionality - Export results to CSV - Add scrape_course_details.py: Scrape detailed info from course pages - Extract competition ID (frmcompetition) - Extract event details (distance, category, sex) - Extract course website link - Extract location and organizer info - Update README.md with new scripts and usage examples - Update version to v1.3.0
This commit is contained in:
114
README.md
114
README.md
@@ -52,7 +52,9 @@ ffa-calendar/
|
||||
│ ├── 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
|
||||
│ ├── athlete_summary.py # Résumé par athlète
|
||||
│ ├── get_club_results.py # Extraction des résultats par club
|
||||
│ └── scrape_course_details.py # Scraping des détails des courses
|
||||
├── config/ # Fichiers de configuration
|
||||
│ └── config.env # Configuration du scraper
|
||||
├── data/ # Données générées
|
||||
@@ -277,6 +279,61 @@ python scripts/list_clubs.py
|
||||
python scripts/athlete_summary.py --data-dir data --output athlete_summary.csv
|
||||
```
|
||||
|
||||
#### Extraction des résultats par club
|
||||
|
||||
**Récupérer tous les résultats d'un club:**
|
||||
```bash
|
||||
python scripts/get_club_results.py --club "Haute Saintonge Athletisme"
|
||||
```
|
||||
|
||||
**Recherche floue (contient le nom):**
|
||||
```bash
|
||||
python scripts/get_club_results.py --club "Saintonge" --fuzzy-match
|
||||
```
|
||||
|
||||
**Lister tous les clubs contenant un terme:**
|
||||
```bash
|
||||
python scripts/get_club_results.py --list-clubs --search-term "Saintonge"
|
||||
```
|
||||
|
||||
Options:
|
||||
- `--club`: Nom du club à rechercher
|
||||
- `--fuzzy-match`: Recherche floue (défaut: True)
|
||||
- `--exact-match`: Recherche exacte du nom
|
||||
- `--list-clubs`: Lister tous les clubs disponibles
|
||||
- `--search-term`: Terme de recherche pour filtrer les clubs
|
||||
- `--limit`: Limiter le nombre de clubs affichés
|
||||
- `--output-dir`: Répertoire de sortie pour les exports
|
||||
|
||||
Les résultats sont exportés dans `data/exports/` avec un fichier CSV contenant tous les résultats du club.
|
||||
|
||||
#### Scraping des détails et épreuves des courses
|
||||
|
||||
**Scraper les détails de toutes les courses:**
|
||||
```bash
|
||||
python scripts/scrape_course_details.py
|
||||
```
|
||||
|
||||
**Scraper un nombre limité de courses:**
|
||||
```bash
|
||||
python scripts/scrape_course_details.py --limit 100
|
||||
```
|
||||
|
||||
**Reprendre le scraping à partir d'un index:**
|
||||
```bash
|
||||
python scripts/scrape_course_details.py --start-from 500
|
||||
```
|
||||
|
||||
Ce script génère deux fichiers CSV:
|
||||
- `data/course_details.csv`: Détails de chaque course (ID, lieu, site web, etc.)
|
||||
- `data/course_epreuves.csv`: Épreuves de chaque course (distance, catégorie, sexe, participants)
|
||||
|
||||
Options:
|
||||
- `--courses-file`: Fichier CSV des courses (défaut: data/courses_daily.csv)
|
||||
- `--output-dir`: Répertoire de sortie (défaut: data)
|
||||
- `--limit`: Limiter le nombre de courses à scraper
|
||||
- `--start-from`: Index de départ pour reprendre le scraping
|
||||
|
||||
### Via les modules Python
|
||||
|
||||
```python
|
||||
@@ -323,6 +380,49 @@ data/
|
||||
- lien: URL vers la page de la course
|
||||
- type: Type de course (Cross, Stade, Route, etc.)
|
||||
- categorie: Catégorie de la compétition
|
||||
- resultats_url: URL vers la page des résultats
|
||||
|
||||
#### 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
|
||||
|
||||
#### course_details.csv (généré par scrape_course_details.py)
|
||||
- course_id: ID unique de la course
|
||||
- course_url: URL de la page des résultats
|
||||
- course_nom: Nom de la course
|
||||
- course_date: Date de la course
|
||||
- course_lieu: Lieu de la course
|
||||
- course_lieu_complet: Lieu détaillé
|
||||
- course_site_web: Lien vers le site web de la course
|
||||
- competition_id: Numéro de compétition FFA (frmcompetition)
|
||||
- organisateur: Organisateur de la course
|
||||
- contact: Informations de contact
|
||||
- date_heure: Date et heure de la course
|
||||
- lieu_details: Détails du lieu
|
||||
- nb_epreuves: Nombre d'épreuves
|
||||
|
||||
#### course_epreuves.csv (généré par scrape_course_details.py)
|
||||
- course_id: ID unique de la course
|
||||
- epreuve_id: ID unique de l'épreuve
|
||||
- epreuve_nom: Nom complet de l'épreuve
|
||||
- epreuve_numero: Numéro de l'épreuve dans la course
|
||||
- epreuve_distance: Distance (ex: 100m, 10km)
|
||||
- epreuve_categorie: Catégorie (ex: Seniors, Vétérans)
|
||||
- epreuve_sexe: Sexe (M/F)
|
||||
- epreuve_type: Type d'épreuve (Piste, Cross, Trail, Route, Relais)
|
||||
- participants: Nombre de participants
|
||||
- url_resultats: URL des résultats
|
||||
- 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
|
||||
@@ -353,6 +453,8 @@ data/
|
||||
| `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 |
|
||||
| `get_club_results.py` | Extraction des résultats d'un club |
|
||||
| `scrape_course_details.py` | Scraping des détails et épreuves des courses |
|
||||
|
||||
## 🚀 Performance
|
||||
|
||||
@@ -374,7 +476,15 @@ Note: Chaque driver Chrome consomme ~200-300 Mo de RAM.
|
||||
|
||||
## 📝 Version
|
||||
|
||||
### v1.2.0 (Dernière mise à jour)
|
||||
### v1.3.0 (Dernière mise à jour)
|
||||
- **Nouveau**: Script `get_club_results.py` pour extraire les résultats d'un club depuis le CSV
|
||||
- **Nouveau**: Script `scrape_course_details.py` pour récupérer les détails et épreuves des courses
|
||||
- **Nouveau**: Extraction des numéros de compétition (frmcompetition)
|
||||
- **Nouveau**: Extraction des épreuves avec distance, catégorie et sexe
|
||||
- **Nouveau**: Extraction des liens vers les sites web des courses
|
||||
- **Amélioré**: Recherche flexible de clubs avec correspondance floue
|
||||
|
||||
### v1.2.0
|
||||
- **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
|
||||
|
||||
218
scripts/get_club_results.py
Executable file
218
scripts/get_club_results.py
Executable file
@@ -0,0 +1,218 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour récupérer tous les résultats d'un club depuis le CSV des résultats
|
||||
Filtre simple sur le nom du club dans results_daily.csv
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import pandas as pd
|
||||
import csv
|
||||
import argparse
|
||||
from datetime import datetime
|
||||
|
||||
def get_club_results(club_name, results_file="data/results_daily.csv", output_dir="data/exports", fuzzy_match=True):
|
||||
"""
|
||||
Récupérer tous les résultats d'un club
|
||||
|
||||
Args:
|
||||
club_name: Nom du club à rechercher
|
||||
results_file: Fichier CSV des résultats
|
||||
output_dir: Répertoire de sortie pour l'export
|
||||
fuzzy_match: Utiliser la recherche floue (contient) ou exacte
|
||||
|
||||
Returns:
|
||||
DataFrame pandas avec les résultats du club
|
||||
"""
|
||||
|
||||
print(f"Recherche du club: {club_name}")
|
||||
print(f"Fichier source: {results_file}")
|
||||
|
||||
# Lire le fichier CSV
|
||||
try:
|
||||
# Lire par chunks si le fichier est très gros
|
||||
df = pd.read_csv(results_file, encoding='utf-8-sig')
|
||||
print(f"✓ {len(df)} résultats chargés")
|
||||
except Exception as e:
|
||||
print(f"✗ Erreur lecture CSV: {e}")
|
||||
return None
|
||||
|
||||
# Filtrer par club
|
||||
if fuzzy_match:
|
||||
# Recherche floue: contient le nom
|
||||
mask = df['club'].str.contains(club_name, case=False, na=False)
|
||||
else:
|
||||
# Recherche exacte
|
||||
mask = df['club'].str.lower() == club_name.lower()
|
||||
|
||||
club_results = df[mask]
|
||||
|
||||
if len(club_results) == 0:
|
||||
print(f"\n✗ Aucun résultat trouvé pour '{club_name}'")
|
||||
print(f"\n💡 Suggestions:")
|
||||
print(" - Vérifiez l'orthographe exacte du club")
|
||||
print(" - Essayez une recherche partielle avec --fuzzy-match")
|
||||
print(" - Utilisez --list-clubs pour voir tous les clubs disponibles")
|
||||
return None
|
||||
|
||||
print(f"✓ {len(club_results)} résultats trouvés")
|
||||
|
||||
# Afficher les variantes de noms de club trouvées
|
||||
club_variants = club_results['club'].unique()
|
||||
if len(club_variants) > 1:
|
||||
print(f"\n📋 Variantes du nom trouvées ({len(club_variants)}):")
|
||||
for variant in sorted(club_variants):
|
||||
count = len(club_results[club_results['club'] == variant])
|
||||
print(f" - {variant} ({count} résultats)")
|
||||
|
||||
# Statistiques
|
||||
athletes = club_results[['nom', 'prenom']].drop_duplicates()
|
||||
print(f"\n📊 Statistiques:")
|
||||
print(f" - Athlètes différents: {len(athletes)}")
|
||||
print(f" - Courses différentes: {len(club_results['course_nom'].unique())}")
|
||||
print(f" - Dates couvertes: {club_results['course_date'].min()} à {club_results['course_date'].max()}")
|
||||
|
||||
return club_results
|
||||
|
||||
def export_club_results(club_results, club_name, output_dir="data/exports"):
|
||||
"""Exporter les résultats d'un club en CSV"""
|
||||
|
||||
os.makedirs(output_dir, exist_ok=True)
|
||||
|
||||
# Nom du fichier de sortie
|
||||
safe_name = club_name.replace('/', '-').replace('\\', '-').replace(' ', '_')
|
||||
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"club_{safe_name}_{timestamp}.csv"
|
||||
filepath = os.path.join(output_dir, filename)
|
||||
|
||||
# Sauvegarder
|
||||
club_results.to_csv(filepath, index=False, encoding='utf-8-sig')
|
||||
print(f"\n💾 Exporté: {filepath}")
|
||||
print(f" {len(club_results)} lignes")
|
||||
|
||||
return filepath
|
||||
|
||||
def list_unique_clubs(results_file="data/results_daily.csv", search_term=None, limit=50):
|
||||
"""Lister tous les clubs uniques dans les résultats"""
|
||||
|
||||
print(f"Lecture des clubs depuis {results_file}...")
|
||||
|
||||
try:
|
||||
df = pd.read_csv(results_file, encoding='utf-8-sig')
|
||||
except Exception as e:
|
||||
print(f"✗ Erreur lecture CSV: {e}")
|
||||
return
|
||||
|
||||
if search_term:
|
||||
clubs = df[df['club'].str.contains(search_term, case=False, na=False)]['club'].unique()
|
||||
print(f"\n📋 Clubs contenant '{search_term}' ({len(clubs)} trouvés):")
|
||||
else:
|
||||
clubs = df['club'].unique()
|
||||
print(f"\n📋 Liste des clubs ({len(clubs)} clubs):")
|
||||
|
||||
# Trier et limiter l'affichage
|
||||
clubs_sorted = sorted(clubs)[:limit] if limit else sorted(clubs)
|
||||
|
||||
for i, club in enumerate(clubs_sorted, 1):
|
||||
count = len(df[df['club'] == club])
|
||||
print(f" {i:3d}. {club} ({count} résultats)")
|
||||
|
||||
if len(clubs) > limit:
|
||||
print(f"\n... et {len(clubs) - limit} autres clubs")
|
||||
print(f"Utilisez --search-term pour filtrer")
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Récupérer tous les résultats d\'un club FFA',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Exemples:
|
||||
# Résultats du club Haute Saintonge Athletisme
|
||||
python get_club_results.py --club "Haute Saintonge Athletisme"
|
||||
|
||||
# Recherche floue
|
||||
python get_club_results.py --club "Saintonge" --fuzzy-match
|
||||
|
||||
# Liste tous les clubs contenant "Saintonge"
|
||||
python get_club_results.py --list-clubs --search-term "Saintonge"
|
||||
|
||||
# Sauvegarder dans un fichier spécifique
|
||||
python get_club_results.py --club "Paris Athletic" --output custom_output.csv
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--club', type=str,
|
||||
help='Nom du club à rechercher')
|
||||
|
||||
parser.add_argument('--fuzzy-match', action='store_true', default=True,
|
||||
help='Recherche floue (contient le nom, défaut: True)')
|
||||
|
||||
parser.add_argument('--exact-match', action='store_true',
|
||||
help='Recherche exacte (désactive fuzzy-match)')
|
||||
|
||||
parser.add_argument('--export', action='store_true', default=True,
|
||||
help='Exporter en CSV (défaut: True)')
|
||||
|
||||
parser.add_argument('--list-clubs', action='store_true',
|
||||
help='Lister tous les clubs disponibles')
|
||||
|
||||
parser.add_argument('--search-term', type=str,
|
||||
help='Terme de recherche pour lister les clubs')
|
||||
|
||||
parser.add_argument('--limit', type=int, default=50,
|
||||
help='Limiter le nombre de clubs affichés (défaut: 50)')
|
||||
|
||||
parser.add_argument('--output-dir', default='data/exports',
|
||||
help='Répertoire de sortie pour les exports')
|
||||
|
||||
parser.add_argument('--results-file', default='data/results_daily.csv',
|
||||
help='Fichier CSV des résultats')
|
||||
|
||||
parser.add_argument('--output', type=str,
|
||||
help='Nom du fichier de sortie (si --export)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Si recherche exacte, désactiver fuzzy match
|
||||
if args.exact_match:
|
||||
args.fuzzy_match = False
|
||||
|
||||
# Lister les clubs
|
||||
if args.list_clubs:
|
||||
list_unique_clubs(args.results_file, args.search_term, args.limit)
|
||||
return
|
||||
|
||||
# Vérifier qu'un nom de club est fourni
|
||||
if not args.club:
|
||||
parser.error("--club est requis (sauf si --list-clubs est utilisé)")
|
||||
|
||||
print("=" * 70)
|
||||
print("🏃 Récupération des résultats par club FFA")
|
||||
print("=" * 70)
|
||||
|
||||
# Récupérer les résultats
|
||||
club_results = get_club_results(
|
||||
args.club,
|
||||
args.results_file,
|
||||
args.output_dir,
|
||||
args.fuzzy_match
|
||||
)
|
||||
|
||||
if club_results is None or len(club_results) == 0:
|
||||
return
|
||||
|
||||
# Exporter si demandé
|
||||
if args.export:
|
||||
if args.output:
|
||||
# Utiliser le nom de fichier spécifié
|
||||
os.makedirs(args.output_dir, exist_ok=True)
|
||||
filepath = os.path.join(args.output_dir, args.output)
|
||||
club_results.to_csv(filepath, index=False, encoding='utf-8-sig')
|
||||
print(f"\n💾 Exporté: {filepath}")
|
||||
else:
|
||||
export_club_results(club_results, args.club, args.output_dir)
|
||||
|
||||
print("\n✅ Terminé!")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
493
scripts/scrape_course_details.py
Executable file
493
scripts/scrape_course_details.py
Executable file
@@ -0,0 +1,493 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script pour récupérer les détails des pages de courses FFA
|
||||
Récupère:
|
||||
- Le numéro de course (frmcompetition)
|
||||
- Les différentes épreuves
|
||||
- Le lieu
|
||||
- Le lien vers le site de la course
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import time
|
||||
import csv
|
||||
import logging
|
||||
import re
|
||||
import argparse
|
||||
from tqdm import tqdm
|
||||
from selenium import webdriver
|
||||
from selenium.webdriver.chrome.service import Service
|
||||
from webdriver_manager.chrome import ChromeDriverManager
|
||||
from bs4 import BeautifulSoup
|
||||
import pandas as pd
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '../src'))
|
||||
|
||||
# Configuration du logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('scrape_course_details.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class CourseDetailsScraper:
|
||||
"""Scraper pour récupérer les détails des pages de courses"""
|
||||
|
||||
def __init__(self, output_dir="data"):
|
||||
self.output_dir = output_dir
|
||||
self.base_url = "https://www.athle.fr"
|
||||
self.request_delay = 2.0
|
||||
self.max_retries = 3
|
||||
|
||||
# Fichiers de sortie
|
||||
self.details_file = os.path.join(output_dir, "course_details.csv")
|
||||
self.epreuves_file = os.path.join(output_dir, "course_epreuves.csv")
|
||||
|
||||
# Initialiser le driver Selenium
|
||||
self.driver = None
|
||||
|
||||
# En-têtes CSV
|
||||
self.details_headers = [
|
||||
'course_id', 'course_url', 'course_nom', 'course_date', 'course_lieu',
|
||||
'course_lieu_complet', 'course_site_web', 'competition_id',
|
||||
'organisateur', 'contact', 'date_heure', 'lieu_details',
|
||||
'nb_epreuves', 'url_source'
|
||||
]
|
||||
|
||||
self.epreuves_headers = [
|
||||
'course_id', 'epreuve_id', 'epreuve_nom', 'epreuve_numero',
|
||||
'epreuve_distance', 'epreuve_categorie', 'epreuve_sexe',
|
||||
'epreuve_type', 'participants', 'url_resultats'
|
||||
]
|
||||
|
||||
# Initialiser les fichiers CSV
|
||||
self._init_csv_files()
|
||||
|
||||
def _init_csv_file(self, filename, headers):
|
||||
"""Initialiser un fichier CSV avec les en-têtes"""
|
||||
if not os.path.exists(filename):
|
||||
with open(filename, 'w', newline='', encoding='utf-8-sig') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=headers)
|
||||
writer.writeheader()
|
||||
logger.info(f"Fichier CSV initialisé: {filename}")
|
||||
|
||||
def _init_csv_files(self):
|
||||
"""Initialiser tous les fichiers CSV"""
|
||||
self._init_csv_file(self.details_file, self.details_headers)
|
||||
self._init_csv_file(self.epreuves_file, self.epreuves_headers)
|
||||
|
||||
def _init_driver(self):
|
||||
"""Initialiser le driver Selenium"""
|
||||
if self.driver is None:
|
||||
options = webdriver.ChromeOptions()
|
||||
options.add_argument('--headless')
|
||||
options.add_argument('--no-sandbox')
|
||||
options.add_argument('--disable-dev-shm-usage')
|
||||
options.add_argument('--disable-gpu')
|
||||
options.add_argument('--window-size=1920,1080')
|
||||
options.add_argument('user-agent=Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36')
|
||||
|
||||
service = Service(ChromeDriverManager().install())
|
||||
self.driver = webdriver.Chrome(service=service, options=options)
|
||||
logger.info("Driver Chrome initialisé")
|
||||
|
||||
def extract_competition_id(self, url):
|
||||
"""Extraire l'ID de competition depuis l'URL (frmcompetition=XXX)"""
|
||||
match = re.search(r'frmcompetition=(\d+)', url)
|
||||
if match:
|
||||
return match.group(1)
|
||||
return ""
|
||||
|
||||
def get_courses_from_csv(self, courses_file):
|
||||
"""Lire les courses depuis le fichier CSV"""
|
||||
courses = []
|
||||
try:
|
||||
df = pd.read_csv(courses_file, encoding='utf-8-sig')
|
||||
for idx, row in df.iterrows():
|
||||
if pd.notna(row.get('resultats_url')) and row['resultats_url']:
|
||||
courses.append({
|
||||
'id': idx,
|
||||
'nom': row.get('nom', ''),
|
||||
'date': row.get('date', ''),
|
||||
'lieu': row.get('lieu', ''),
|
||||
'resultats_url': row['resultats_url'],
|
||||
'lien': row.get('lien', '')
|
||||
})
|
||||
logger.info(f"{len(courses)} courses trouvées dans {courses_file}")
|
||||
return courses
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur lecture du fichier CSV: {e}")
|
||||
return []
|
||||
|
||||
def parse_course_page(self, url, course_info):
|
||||
"""Parser la page d'une course pour extraire les détails et épreuves"""
|
||||
course_details = None
|
||||
epreuves = []
|
||||
|
||||
for attempt in range(self.max_retries):
|
||||
try:
|
||||
self.driver.get(url)
|
||||
time.sleep(self.request_delay)
|
||||
|
||||
html = self.driver.page_source
|
||||
soup = BeautifulSoup(html, 'html.parser')
|
||||
|
||||
# Extraire l'ID de compétition
|
||||
competition_id = self.extract_competition_id(url)
|
||||
|
||||
# ===== EXTRAIRE LES DÉTAILS DE LA COURSE =====
|
||||
course_details = {
|
||||
'course_id': course_info['id'],
|
||||
'course_url': url,
|
||||
'course_nom': course_info['nom'],
|
||||
'course_date': course_info['date'],
|
||||
'course_lieu': course_info['lieu'],
|
||||
'course_lieu_complet': '',
|
||||
'course_site_web': '',
|
||||
'competition_id': competition_id,
|
||||
'organisateur': '',
|
||||
'contact': '',
|
||||
'date_heure': '',
|
||||
'lieu_details': '',
|
||||
'nb_epreuves': 0,
|
||||
'url_source': url
|
||||
}
|
||||
|
||||
# Chercher les informations générales de la course
|
||||
# Ces informations peuvent être dans des divs spécifiques ou le titre de la page
|
||||
title = soup.find('h1')
|
||||
if title:
|
||||
course_details['course_nom'] = title.get_text(strip=True)
|
||||
|
||||
# Chercher les blocs d'information (lieu, date, etc.)
|
||||
info_divs = soup.find_all('div', class_=re.compile(r'info|detail|location|lieu', re.IGNORECASE))
|
||||
for div in info_divs:
|
||||
text = div.get_text(strip=True)
|
||||
if 'site web' in text.lower() or 'http' in text:
|
||||
# Extraire le lien vers le site
|
||||
links = div.find_all('a', href=True)
|
||||
for link in links:
|
||||
href = link['href']
|
||||
if href.startswith('http'):
|
||||
course_details['course_site_web'] = href
|
||||
break
|
||||
elif 'contact' in text.lower():
|
||||
course_details['contact'] = text
|
||||
elif 'organisateur' in text.lower():
|
||||
course_details['organisateur'] = text
|
||||
|
||||
# Chercher le site web dans les liens
|
||||
all_links = soup.find_all('a', href=True)
|
||||
for link in all_links:
|
||||
href = link['href']
|
||||
link_text = link.get_text(strip=True).lower()
|
||||
if (href.startswith('http') and
|
||||
'athle.fr' not in href and
|
||||
any(keyword in link_text for keyword in ['site', 'web', 'inscription', 'organisateur'])):
|
||||
course_details['course_site_web'] = href
|
||||
break
|
||||
|
||||
# ===== EXTRAIRE LES ÉPREUVES =====
|
||||
main_table = soup.find('table', class_='reveal-table')
|
||||
if not main_table:
|
||||
logger.warning(f"Pas de table trouvée pour {url}")
|
||||
return course_details, epreuves
|
||||
|
||||
rows = main_table.find_all('tr')
|
||||
current_epreuve = None
|
||||
epreuve_numero = 0
|
||||
|
||||
for row in rows:
|
||||
cells = row.find_all(['td', 'th'])
|
||||
|
||||
# Ignorer les tables imbriquées
|
||||
if row.find('table', class_='detail-inner-table'):
|
||||
continue
|
||||
|
||||
# Détecter une nouvelle épreuve (en-tête de section)
|
||||
if cells and len(cells) == 1:
|
||||
cell = cells[0]
|
||||
cell_text = cell.get_text(strip=True)
|
||||
|
||||
# Vérifier si c'est un en-tête d'épreuve
|
||||
# Les épreuves FFA ont souvent: distance, catégorie, sexe
|
||||
if cell_text and self._is_epreuve_header(cell_text):
|
||||
epreuve_numero += 1
|
||||
current_epreuve = {
|
||||
'course_id': course_info['id'],
|
||||
'epreuve_id': f"{course_info['id']}_E{epreuve_numero}",
|
||||
'epreuve_nom': cell_text,
|
||||
'epreuve_numero': epreuve_numero,
|
||||
'epreuve_distance': self._extract_distance(cell_text),
|
||||
'epreuve_categorie': self._extract_categorie(cell_text),
|
||||
'epreuve_sexe': self._extract_sexe(cell_text),
|
||||
'epreuve_type': self._extract_type(cell_text),
|
||||
'participants': 0,
|
||||
'url_resultats': url
|
||||
}
|
||||
|
||||
# Compter les participants si c'est une ligne de résultats
|
||||
if current_epreuve and cells and len(cells) >= 9:
|
||||
try:
|
||||
place = cells[0].get_text(strip=True)
|
||||
int(place) # Vérifier que c'est un nombre
|
||||
current_epreuve['participants'] += 1
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# Ajouter l'épreuve si elle a des participants
|
||||
if current_epreuve and current_epreuve['participants'] > 0:
|
||||
epreuves.append(current_epreuve)
|
||||
current_epreuve = None
|
||||
|
||||
course_details['nb_epreuves'] = len(epreuves)
|
||||
logger.info(f"Course: {course_info['nom']} - {len(epreuves)} épreuves")
|
||||
return course_details, epreuves
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur tentative {attempt + 1}/{self.max_retries} pour {url}: {e}")
|
||||
if attempt < self.max_retries - 1:
|
||||
time.sleep(self.request_delay * (attempt + 1))
|
||||
else:
|
||||
logger.warning(f"Impossible de récupérer les détails pour {url}")
|
||||
return course_details, epreuves
|
||||
|
||||
return course_details, epreuves
|
||||
|
||||
def _is_epreuve_header(self, text):
|
||||
"""Vérifier si le texte est un en-tête d'épreuve"""
|
||||
# Les épreuves contiennent généralement:
|
||||
# - Une distance (ex: 100m, 5km, marathon)
|
||||
# - Un sexe (F/H, Hommes/Femmes, Masculin/Féminin)
|
||||
# - Une catégorie (Seniors, Vétérans, etc.)
|
||||
|
||||
keywords_distance = ['m ', 'm$', 'km', 'marathon', 'semi']
|
||||
keywords_sexe = ['femmes', 'hommes', 'masculin', 'féminin', 'féminines', 'masculines', 'F ', 'H ', 'SEF', 'SEM']
|
||||
keywords_categorie = ['seniors', 'vétérans', 'juniors', 'cadets', 'minimes', 'benjamins', 'poussins',
|
||||
'espoirs', 'u20', 'u18', 'u16', 'u14', 'u23', 'm0', 'm1', 'm2', 'm3', 'm4']
|
||||
|
||||
# Vérifier la présence de mots-clés
|
||||
has_distance = any(re.search(kw, text, re.IGNORECASE) for kw in keywords_distance)
|
||||
has_sexe = any(re.search(kw, text, re.IGNORECASE) for kw in keywords_sexe)
|
||||
has_categorie = any(re.search(kw, text, re.IGNORECASE) for kw in keywords_categorie)
|
||||
|
||||
# C'est probablement une épreuve si elle a au moins un de ces éléments
|
||||
# et que le texte n'est pas trop court
|
||||
return len(text) > 10 and (has_distance or has_sexe or has_categorie)
|
||||
|
||||
def _extract_distance(self, text):
|
||||
"""Extraire la distance depuis le texte de l'épreuve"""
|
||||
# Distance en mètres
|
||||
match = re.search(r'(\d+)\s*m\b', text, re.IGNORECASE)
|
||||
if match:
|
||||
return f"{match.group(1)}m"
|
||||
|
||||
# Distance en km
|
||||
match = re.search(r'(\d+(?:\.\d+)?)\s*km\b', text, re.IGNORECASE)
|
||||
if match:
|
||||
return f"{match.group(1)}km"
|
||||
|
||||
# Marathon ou semi-marathon
|
||||
if 'marathon' in text.lower() and 'semi' not in text.lower():
|
||||
return "42.195km"
|
||||
elif 'semi' in text.lower():
|
||||
return "21.1km"
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_categorie(self, text):
|
||||
"""Extraire la catégorie depuis le texte de l'épreuve"""
|
||||
categories = ['Vétérans', 'Seniors', 'Juniors', 'Cadets', 'Minimes', 'Benjamins',
|
||||
'Poussins', 'Espoirs', 'U20', 'U18', 'U16', 'U14', 'U23']
|
||||
|
||||
for cat in categories:
|
||||
if cat in text:
|
||||
return cat
|
||||
|
||||
return ""
|
||||
|
||||
def _extract_sexe(self, text):
|
||||
"""Extraire le sexe depuis le texte de l'épreuve"""
|
||||
text_upper = text.upper()
|
||||
|
||||
if any(kw in text_upper for kw in ['FEMMES', 'FÉMININ', 'FÉMININES', ' F ', 'SEF']):
|
||||
return 'F'
|
||||
elif any(kw in text_upper for kw in ['HOMMES', 'MASCULIN', 'MASCULINES', ' H ', 'SEM']):
|
||||
return 'M'
|
||||
else:
|
||||
return ""
|
||||
|
||||
def _extract_type(self, text):
|
||||
"""Extraire le type d'épreuve"""
|
||||
if any(kw in text.lower() for kw in ['relais', 'relay']):
|
||||
return 'Relais'
|
||||
elif any(kw in text.lower() for kw in ['cross']):
|
||||
return 'Cross'
|
||||
elif any(kw in text.lower() for kw in ['trail']):
|
||||
return 'Trail'
|
||||
elif any(kw in text.lower() for kw in ['marathon', 'semi']):
|
||||
return 'Route'
|
||||
elif any(kw in text.lower() for kw in ['saut', 'lancer', 'lancé']):
|
||||
return 'Épreuves combinées'
|
||||
else:
|
||||
return 'Piste'
|
||||
|
||||
def append_to_csv(self, data, filename, headers):
|
||||
"""Ajouter des données au fichier CSV"""
|
||||
if not data:
|
||||
return
|
||||
|
||||
with open(filename, 'a', newline='', encoding='utf-8-sig') as f:
|
||||
writer = csv.DictWriter(f, fieldnames=headers)
|
||||
# data peut être un dict ou une liste
|
||||
if isinstance(data, dict):
|
||||
writer.writerow(data)
|
||||
else:
|
||||
writer.writerows(data)
|
||||
|
||||
logger.info(f"{len(data) if isinstance(data, list) else 1} enregistrement(s) ajouté(s) à {filename}")
|
||||
|
||||
def run(self, courses_file, limit=None, start_from=0):
|
||||
"""Exécuter le scraping des détails pour toutes les courses"""
|
||||
logger.info(f"Début du scraping des détails depuis {courses_file}")
|
||||
|
||||
# Charger les courses
|
||||
courses = self.get_courses_from_csv(courses_file)
|
||||
|
||||
if not courses:
|
||||
logger.error("Aucune course trouvée")
|
||||
return
|
||||
|
||||
# Limiter ou démarrer à un index spécifique
|
||||
if limit:
|
||||
courses = courses[:limit]
|
||||
logger.info(f"Limitation à {limit} courses")
|
||||
|
||||
if start_from > 0:
|
||||
courses = courses[start_from:]
|
||||
logger.info(f"Démarrage à l'index {start_from}")
|
||||
|
||||
# Initialiser le driver
|
||||
self._init_driver()
|
||||
|
||||
total_details = 0
|
||||
total_epreuves = 0
|
||||
skipped = 0
|
||||
|
||||
# Scraper les détails pour chaque course
|
||||
with tqdm(courses, desc="Courses scrapées", unit="course") as pbar:
|
||||
for course in pbar:
|
||||
try:
|
||||
details, epreuves = self.parse_course_page(course['resultats_url'], course)
|
||||
|
||||
if details:
|
||||
self.append_to_csv(details, self.details_file, self.details_headers)
|
||||
total_details += 1
|
||||
|
||||
if epreuves:
|
||||
self.append_to_csv(epreuves, self.epreuves_file, self.epreuves_headers)
|
||||
total_epreuves += len(epreuves)
|
||||
else:
|
||||
skipped += 1
|
||||
|
||||
pbar.set_postfix({
|
||||
'details': total_details,
|
||||
'épreuves': total_epreuves,
|
||||
'skipped': skipped
|
||||
})
|
||||
|
||||
# Pause entre les requêtes
|
||||
time.sleep(1)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Erreur pour la course {course['nom']}: {e}")
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# Fermer le driver
|
||||
if self.driver:
|
||||
self.driver.quit()
|
||||
|
||||
logger.info(f"Scraping terminé!")
|
||||
logger.info(f"Details récupérés: {total_details}")
|
||||
logger.info(f"Epreuves récupérées: {total_epreuves}")
|
||||
logger.info(f"Courses sans épreuves: {skipped}")
|
||||
|
||||
return {
|
||||
'total_courses': len(courses),
|
||||
'total_details': total_details,
|
||||
'total_epreuves': total_epreuves,
|
||||
'skipped': skipped
|
||||
}
|
||||
|
||||
|
||||
def main():
|
||||
"""Point d'entrée principal"""
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Récupérer les détails des pages de courses FFA',
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Exemples:
|
||||
# Scraper toutes les courses
|
||||
python scrape_course_details.py
|
||||
|
||||
# Scraper seulement les 100 premières courses
|
||||
python scrape_course_details.py --limit 100
|
||||
|
||||
# Reprendre à partir de l'index 500
|
||||
python scrape_course_details.py --start-from 500
|
||||
|
||||
# Utiliser un autre fichier de courses
|
||||
python scrape_course_details.py --courses-file data/courses_daily.csv
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument('--courses-file', default='data/courses_daily.csv',
|
||||
help='Fichier CSV des courses (défaut: data/courses_daily.csv)')
|
||||
parser.add_argument('--output-dir', default='data',
|
||||
help='Répertoire de sortie (défaut: data)')
|
||||
parser.add_argument('--limit', type=int,
|
||||
help='Limiter le nombre de courses à scraper')
|
||||
parser.add_argument('--start-from', type=int, default=0,
|
||||
help='Index de départ pour reprendre le scraping')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("=" * 70)
|
||||
print("🔄 Scraper des détails de courses FFA")
|
||||
print("=" * 70)
|
||||
print(f"Fichier des courses: {args.courses_file}")
|
||||
print(f"Répertoire de sortie: {args.output_dir}")
|
||||
if args.limit:
|
||||
print(f"Limitation: {args.limit} courses")
|
||||
if args.start_from > 0:
|
||||
print(f"Démarrage à l'index: {args.start_from}")
|
||||
print("=" * 70)
|
||||
|
||||
scraper = CourseDetailsScraper(output_dir=args.output_dir)
|
||||
|
||||
# Lancer le scraping
|
||||
stats = scraper.run(
|
||||
args.courses_file,
|
||||
limit=args.limit,
|
||||
start_from=args.start_from
|
||||
)
|
||||
|
||||
print("\n" + "=" * 70)
|
||||
print("✅ Scraping terminé!")
|
||||
print("=" * 70)
|
||||
print(f"Courses traitées: {stats['total_courses']}")
|
||||
print(f"Détails récupérés: {stats['total_details']}")
|
||||
print(f"Epreuves récupérées: {stats['total_epreuves']}")
|
||||
print(f"Courses sans épreuves: {stats['skipped']}")
|
||||
print("=" * 70)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user