From 6de44556f4d5463027672b70762c2f9a1da9f530 Mon Sep 17 00:00:00 2001 From: Muyue Date: Sat, 3 Jan 2026 11:03:35 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Feature:=20Add=20club=20results=20e?= =?UTF-8?q?xtractor=20and=20course=20details=20scraper?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- README.md | 114 ++++++- scripts/get_club_results.py | 218 ++++++++++++++ scripts/scrape_course_details.py | 493 +++++++++++++++++++++++++++++++ 3 files changed, 823 insertions(+), 2 deletions(-) create mode 100755 scripts/get_club_results.py create mode 100755 scripts/scrape_course_details.py diff --git a/README.md b/README.md index 134c03b..d86c969 100644 --- a/README.md +++ b/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 diff --git a/scripts/get_club_results.py b/scripts/get_club_results.py new file mode 100755 index 0000000..76dee1d --- /dev/null +++ b/scripts/get_club_results.py @@ -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() diff --git a/scripts/scrape_course_details.py b/scripts/scrape_course_details.py new file mode 100755 index 0000000..76c7039 --- /dev/null +++ b/scripts/scrape_course_details.py @@ -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()