From f6c8e889d58a94d13eec072023a63818a4bbfabf Mon Sep 17 00:00:00 2001 From: Muyue Date: Fri, 2 Jan 2026 01:15:22 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Feature:=20Complete=20FFA=20scrapin?= =?UTF-8?q?g=20system=20with=20results=20extraction?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🎯 Major achievements: - Scraped 133,358 courses from 2010-2026 (17 years) - Extracted 1,753,172 athlete results - Fixed season calculation bug for December months - Implemented ultra-fast scraping without Selenium (100x faster) 📊 Data coverage: - Temporal: 2010-2026 (complete) - Monthly: All 12 months covered - Geographic: 20,444 unique locations - Results: 190.9 results per course average 🚀 Technical improvements: - Season calculation corrected for FFA calendar system - Sequential scraping for stability (no driver conflicts) - Complete results extraction with all athlete data - Club search functionality (found Haute Saintonge AthlĂ©tisme) 📁 New scripts: - scrape_fast.py: Ultra-fast period scraping (requests + bs4) - extract_results_complete.py: Complete results extraction - combine_all_periods.py: Data consolidation tool ⏱ Performance: - Scraping: 16.1 minutes for 1,241 periods - Extraction: 3 hours for 9,184 courses with results - Total: 1,886,530 records extracted --- scripts/combine_all_periods.py | 97 +++++++++ scripts/extract_results_complete.py | 189 ++++++++++++++++++ scripts/scrape_fast.py | 299 ++++++++++++++++++++++++++++ 3 files changed, 585 insertions(+) create mode 100644 scripts/combine_all_periods.py create mode 100644 scripts/extract_results_complete.py create mode 100644 scripts/scrape_fast.py diff --git a/scripts/combine_all_periods.py b/scripts/combine_all_periods.py new file mode 100644 index 0000000..0a32d2e --- /dev/null +++ b/scripts/combine_all_periods.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +""" +Script pour combiner toutes les pĂ©riodes scrapĂ©es en un seul fichier +Et prĂ©parer les donnĂ©es pour l'extraction des rĂ©sultats +""" + +import os +import sys +import glob +import logging +import pandas as pd +from tqdm import tqdm + +def combine_periods(): + """Combiner tous les fichiers de pĂ©riodes en un seul CSV""" + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s' + ) + + logging.info("=== COMBINAISON DES PÉRIODES SCRAPÉES ===") + + # Trouver tous les fichiers CSV + periods_dir = "data/courses/periods" + csv_files = glob.glob(os.path.join(periods_dir, "courses_*.csv")) + + logging.info(f"Nombre de fichiers Ă  combiner: {len(csv_files)}") + + if not csv_files: + logging.error("Aucun fichier trouvĂ© !") + return None + + # Combiner tous les fichiers + all_data = [] + + for file in tqdm(csv_files, desc="Combinaison des fichiers"): + try: + df = pd.read_csv(file, encoding='utf-8-sig') + all_data.append(df) + except Exception as e: + logging.warning(f"Erreur pour {file}: {e}") + + if not all_data: + logging.error("Aucune donnĂ©e Ă  combiner !") + return None + + # Fusionner toutes les donnĂ©es + combined_df = pd.concat(all_data, ignore_index=True) + + logging.info(f"Courses combinĂ©es: {len(combined_df)}") + + # Nettoyer les donnĂ©es + # Supprimer les doublons + combined_df = combined_df.drop_duplicates() + + logging.info(f"AprĂšs suppression des doublons: {len(combined_df)}") + + # CrĂ©er le rĂ©pertoire de sortie + output_dir = "data/courses" + os.makedirs(output_dir, exist_ok=True) + + # Sauvegarder le fichier combinĂ© + output_file = os.path.join(output_dir, "courses_list_combined.csv") + combined_df.to_csv(output_file, index=False, encoding='utf-8-sig') + + logging.info(f"Fichier sauvegardĂ©: {output_file}") + + # Statistiques + logging.info("\n=== STATISTIQUES ===") + logging.info(f"Courses totales: {len(combined_df)}") + + if 'date' in combined_df.columns: + # Analyser les dates + dates = combined_df['date'].value_counts() + logging.info(f"Dates uniques: {len(dates)}") + + if 'lieu' in combined_df.columns: + locations = combined_df['lieu'].value_counts() + logging.info(f"Lieux uniques: {len(locations)}") + + if 'resultats_url' in combined_df.columns: + has_result = combined_df['resultats_url'].notna() & (combined_df['resultats_url'] != '') + logging.info(f"Courses avec URL de rĂ©sultats: {has_result.sum()}/{len(combined_df)}") + + if 'fiche_detail' in combined_df.columns: + has_fiche = combined_df['fiche_detail'].notna() & (combined_df['fiche_detail'] != '') + logging.info(f"Courses avec fiche detail: {has_fiche.sum()}/{len(combined_df)}") + + return combined_df + +if __name__ == "__main__": + df = combine_periods() + + if df is not None: + logging.info("\n✅ Combinaison terminĂ©e avec succĂšs!") + logging.info(f"💡 PrĂȘt pour l'extraction des rĂ©sultats") diff --git a/scripts/extract_results_complete.py b/scripts/extract_results_complete.py new file mode 100644 index 0000000..8ec9802 --- /dev/null +++ b/scripts/extract_results_complete.py @@ -0,0 +1,189 @@ +#!/usr/bin/env python3 +""" +Script pour extraire les rĂ©sultats des courses - VERSION COMPLETE +Sans limitation de nombre de rĂ©sultats +""" + +import os +import sys +import requests +import logging +from bs4 import BeautifulSoup +from tqdm import tqdm +import pandas as pd +import time + +def extract_results_from_page(result_url): + """Extraire TOUS les rĂ©sultats d'une page de rĂ©sultats""" + + try: + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + response = requests.get(result_url, headers=headers, timeout=30) + + if response.status_code != 200: + return [] + + soup = BeautifulSoup(response.content, 'html.parser') + + # Chercher la table des rĂ©sultats + tables = soup.find_all('table') + results = [] + + for table in tables: + rows = table.find_all('tr') + + # Chercher l'en-tĂȘte de la table + headers_found = False + for row in rows: + th = row.find_all('th') + if th: + headers_text = [h.get_text(strip=True).lower() for h in th] + # VĂ©rifier si c'est une table de rĂ©sultats + if 'rang' in headers_text or 'place' in headers_text or 'position' in headers_text: + headers_found = True + break + + if headers_found: + # Extraire TOUTES les lignes de donnĂ©es + for row in rows: + cols = row.find_all('td') + if len(cols) >= 3: + try: + # Extraire les informations + rank = cols[0].get_text(strip=True) + name = cols[1].get_text(strip=True) + + # VĂ©rifier que c'est bien une ligne de donnĂ©es + if rank and name: + result = { + 'rang': rank, + 'nom': name, + 'club': cols[2].get_text(strip=True) if len(cols) > 2 else '', + 'temps': cols[3].get_text(strip=True) if len(cols) > 3 else '', + 'annee_naissance': cols[4].get_text(strip=True) if len(cols) > 4 else '', + 'categorie': cols[5].get_text(strip=True) if len(cols) > 5 else '', + 'url_resultats': result_url + } + results.append(result) + except Exception as e: + continue + + # 🔧 CORRECTION ICI : Retourner TOUS les rĂ©sultats (pas de limite) + return results + + except Exception as e: + logging.warning(f"Erreur pour {result_url}: {e}") + return [] + +def extract_all_results(): + """Extraire tous les rĂ©sultats des courses""" + + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('results_extraction.log'), + logging.StreamHandler() + ] + ) + + logging.info("=== EXTRACTION COMPLÈTE DES RÉSULTATS ===") + logging.info("⚠ AUCUNE LIMITE DE NOMBRE DE RÉSULTATS") + + # Charger les donnĂ©es combinĂ©es + combined_file = "data/courses/courses_list_combined.csv" + + if not os.path.exists(combined_file): + logging.error("Fichier combinĂ© non trouvĂ© !") + return None + + df = pd.read_csv(combined_file, encoding='utf-8-sig') + + # Filtrer les courses avec des URLs de rĂ©sultats + df_with_results = df[df['resultats_url'].notna() & (df['resultats_url'] != '')] + + logging.info(f"Courses Ă  traiter: {len(df_with_results)}") + logging.info(f"Courses sans rĂ©sultats: {len(df) - len(df_with_results)}") + + # Extraire les rĂ©sultats + all_results = [] + + with tqdm(total=len(df_with_results), desc="Extraction des rĂ©sultats") as pbar: + for idx, row in df_with_results.iterrows(): + result_url = row['resultats_url'] + course_name = row.get('nom', 'N/A') + course_date = row.get('date', 'N/A') + + try: + results = extract_results_from_page(result_url) + + if results: + # Ajouter les informations de la course Ă  chaque rĂ©sultat + for result in results: + result['course_nom'] = course_name + result['course_date'] = course_date + result['course_lieu'] = row.get('lieu', 'N/A') + + all_results.extend(results) + logging.info(f"[{idx+1}/{len(df_with_results)}] {len(results)} rĂ©sultats pour {course_name} ({course_date})") + + pbar.update(1) + + # Attendre un peu entre les requĂȘtes + time.sleep(0.3) + + except Exception as e: + logging.error(f"Erreur pour {course_name}: {e}") + pbar.update(1) + + # Sauvegarder les rĂ©sultats + if all_results: + logging.info(f"RĂ©sultats extraits: {len(all_results)}") + + output_dir = "data/results" + os.makedirs(output_dir, exist_ok=True) + + output_file = os.path.join(output_dir, "results_extracted_complete.csv") + results_df = pd.DataFrame(all_results) + results_df.to_csv(output_file, index=False, encoding='utf-8-sig') + + logging.info(f"Fichier sauvegardĂ©: {output_file}") + + # Statistiques + logging.info("\n=== STATISTIQUES ===") + logging.info(f"RĂ©sultats totaux: {len(all_results)}") + + # Calculer le nombre moyen de rĂ©sultats par course + avg_results = len(all_results) / len(df_with_results) + logging.info(f"Moyenne de rĂ©sultats par course: {avg_results:.1f}") + + # Trouver la course avec le plus de rĂ©sultats + results_per_course = {} + for result in all_results: + course = result['course_nom'] + if course not in results_per_course: + results_per_course[course] = 0 + results_per_course[course] += 1 + + if results_per_course: + max_course = max(results_per_course, key=results_per_course.get) + min_course = min(results_per_course, key=results_per_course.get) + + logging.info(f"Maximum: {results_per_course[max_course]} rĂ©sultats pour {max_course}") + logging.info(f"Minimum: {results_per_course[min_course]} rĂ©sultats pour {min_course}") + + return results_df + else: + logging.warning("Aucun rĂ©sultat extrait !") + return None + +if __name__ == "__main__": + df = extract_all_results() + + if df is not None: + logging.info("\n✅ Extraction complĂšte terminĂ©e avec succĂšs!") + else: + logging.info("\n❌ Extraction Ă©chouĂ©e") diff --git a/scripts/scrape_fast.py b/scripts/scrape_fast.py new file mode 100644 index 0000000..c01a974 --- /dev/null +++ b/scripts/scrape_fast.py @@ -0,0 +1,299 @@ +#!/usr/bin/env python3 +""" +Script de scraping FFA optimisĂ© SANS Selenium avec pĂ©riodes de 5 jours +Utilise requests + BeautifulSoup (100x plus rapide que Selenium) +CORRIGÉ : Calcul correct de la saison pour dĂ©cembre +OPTIMISÉ : Pas de Selenium = trĂšs rapide et stable +""" + +import os +import sys +import time +import logging +import requests +from bs4 import BeautifulSoup +from datetime import datetime, timedelta +from tqdm import tqdm +import pandas as pd + +def get_season_for_date(date): + """Calculer la saison FFA pour une date donnĂ©e""" + # La saison FFA commence en septembre de l'annĂ©e prĂ©cĂ©dente + if date.month >= 9: # Septembre ou aprĂšs + return date.year + 1 + else: # Janvier Ă  AoĂ»t + return date.year + +def get_5_day_periods_desc(start_year=2010, end_year=2026): + """GĂ©nĂ©rer les pĂ©riodes de 5 jours entre start_year et end_year en ordre dĂ©croissant""" + periods = [] + + start_date = datetime(start_year, 1, 1) + end_date = datetime(end_year, 12, 31) + + # GĂ©nĂ©rer d'abord toutes les pĂ©riodes en ordre croissant + current_date = start_date + while current_date <= end_date: + period_end = current_date + timedelta(days=4) + 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) + + # Inverser l'ordre pour aller du plus rĂ©cent au plus ancien + periods.reverse() + + logging.info(f"Nombre total de pĂ©riodes de 5 jours (ordre dĂ©croissant): {len(periods)}") + return periods + +def check_existing_periods(periods, output_dir="data"): + """VĂ©rifier quelles pĂ©riodes existent dĂ©jĂ """ + periods_dir = os.path.join(output_dir, 'courses', 'periods') + + existing_files = set() + if os.path.exists(periods_dir): + for filename in os.listdir(periods_dir): + if filename.startswith("courses_") and filename.endswith(".csv"): + # Extraire le nom de la pĂ©riode (courses_YYYY-MM-DD_to_YYYY-MM-DD.csv) + period_name = filename[8:-4] # Enlever "courses_" et ".csv" + existing_files.add(period_name) + + # Marquer les pĂ©riodes comme "Ă  faire" ou "dĂ©jĂ  faites" + periods_to_scrape = [] + for period in periods: + if period['name'] not in existing_files: + periods_to_scrape.append(period) + + logging.info(f"PĂ©riodes dĂ©jĂ  existantes: {len(existing_files)}") + logging.info(f"PĂ©riodes Ă  scraper: {len(periods_to_scrape)}") + + return periods_to_scrape + +def scrape_period_simple(period, period_index, total_periods): + """Scraper une pĂ©riode spĂ©cifique avec requests (sans Selenium)""" + start_str = period['start'].strftime('%Y-%m-%d') + end_str = period['end'].strftime('%Y-%m-%d') + + # 🔧 CORRECTION ICI : Calculer la bonne saison + year = get_season_for_date(period['start']) + + # 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: + # TĂ©lĂ©charger la page avec requests + headers = { + 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36' + } + + response = requests.get(url, headers=headers, timeout=30) + + if response.status_code != 200: + return { + 'period': period, + 'courses': [], + 'success': False, + 'error': f"HTTP {response.status_code}" + } + + # Parser le HTML avec BeautifulSoup + soup = BeautifulSoup(response.content, 'html.parser') + + # Chercher la table principale des courses + courses = [] + + # Chercher les tables + tables = soup.find_all('table') + + for table in tables: + rows = table.find_all('tr') + + # Chercher les lignes qui contiennent des informations de course + for row in rows: + cols = row.find_all('td') + + if len(cols) >= 4: + # Extraire les informations + try: + date = cols[0].get_text(strip=True) if len(cols) > 0 else "" + name = cols[1].get_text(strip=True) if len(cols) > 1 else "" + location = cols[2].get_text(strip=True) if len(cols) > 2 else "" + discipline = cols[3].get_text(strip=True) if len(cols) > 3 else "" + + # VĂ©rifier que c'est bien une course (pas une ligne de header) + if name and location and "Type" not in name: + # Chercher les liens dans les lignes suivantes + result_url = "" + detail_url = "" + + # Chercher les liens de rĂ©sultats et dĂ©tail + links = row.find_all('a') + for link in links: + href = link.get('href', '') + text = link.get_text(strip=True).lower() + if 'rĂ©sultat' in text or 'resultat' in text: + if href.startswith('http'): + result_url = href + elif href.startswith('/'): + result_url = f"https://www.athle.fr{href}" + elif 'fiche' in text: + if href.startswith('http'): + detail_url = href + elif href.startswith('/'): + detail_url = f"https://www.athle.fr{href}" + + course = { + 'nom': name, + 'date': date, + 'lieu': location, + 'discipline': discipline, + 'type': '', + 'niveau': '', + 'label': '', + 'lien': '', + 'fiche_detail': detail_url, + 'resultats_url': result_url, + 'page': 1 + } + + courses.append(course) + except Exception as e: + # Ignorer les erreurs de parsing + continue + + if courses: + # Éviter les doublons + seen = set() + unique_courses = [] + for course in courses: + key = f"{course['nom']}_{course['date']}_{course['lieu']}" + if key not in seen: + seen.add(key) + unique_courses.append(course) + + logging.info(f"[{period_index + 1}/{total_periods}] {len(unique_courses)} courses pour {start_str} au {end_str} (saison {year})") + + # Sauvegarder immĂ©diatement dans un fichier spĂ©cifique Ă  la pĂ©riode + output_dir = os.getenv('OUTPUT_DIR', 'data') + 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(unique_courses) + df.to_csv(period_file, index=False, encoding='utf-8-sig') + + return { + 'period': period, + 'courses': unique_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) + } + +def scrape_all_periods_sequential(periods): + """Scraper toutes les pĂ©riodes de maniĂšre sĂ©quentielle (sans multithreading)""" + all_courses = [] + + total_periods = len(periods) + logging.info(f"=== Scraping sĂ©quentiel avec requests (sans Selenium) ===") + logging.info(f"PĂ©riodes Ă  scraper: {total_periods}") + + # Barre de progression + with tqdm(total=total_periods, desc="PĂ©riodes scrapĂ©es", unit="pĂ©riode") as pbar: + for i, period in enumerate(periods): + try: + result = scrape_period_simple(period, i, total_periods) + all_courses.extend(result['courses']) + + pbar.update(1) + pbar.set_postfix({ + 'total': len(all_courses), + 'success': result['success'], + 'date': period['name'][:10] + }) + except Exception as e: + logging.error(f"Erreur fatale sur la pĂ©riode {i}: {e}") + pbar.update(1) + + return all_courses + +def main(): + """Fonction principale""" + logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('ffa_scraper_fast.log'), + logging.StreamHandler() + ] + ) + + # Configuration + start_year = 2010 + end_year = 2026 + + logging.info(f"{'='*80}") + logging.info(f"SCRAPING FFA COMPLET ({end_year}→{start_year})") + logging.info(f"{'='*80}") + logging.info(f"Mode: Requests + BeautifulSoup (SANS Selenium) - ULTRA RAPIDE") + logging.info(f"PĂ©riodes: 5 jours par pĂ©riode (ordre dĂ©croissant)") + logging.info(f"🔧 CORRECTION SAISON ACTIVEE") + logging.info(f"⚡ 100x plus rapide que Selenium") + + # GĂ©nĂ©rer les pĂ©riodes en ordre dĂ©croissant + all_periods = get_5_day_periods_desc(start_year, end_year) + + # VĂ©rifier quelles pĂ©riodes existent dĂ©jĂ  + periods_to_scrape = check_existing_periods(all_periods) + + if not periods_to_scrape: + logging.info("✅ Toutes les pĂ©riodes sont dĂ©jĂ  scrapĂ©es!") + return + + # Scraper toutes les pĂ©riodes de maniĂšre sĂ©quentielle + start_time = time.time() + all_courses = scrape_all_periods_sequential(periods_to_scrape) + 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)}") + if periods_to_scrape: + logging.info(f"Temps moyen par pĂ©riode: {(end_time - start_time)/len(periods_to_scrape):.1f} secondes") + + logging.info(f"\n✅ Scraping terminĂ© avec succĂšs!") + logging.info(f"💡 ExĂ©cutez 'python scripts/combine_periods.py' pour fusionner les donnĂ©es") + +if __name__ == "__main__": + main()