Feature: Complete FFA scraping system with results extraction

🎯 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
This commit is contained in:
Muyue
2026-01-02 01:15:22 +01:00
parent 3620975e50
commit f6c8e889d5
3 changed files with 585 additions and 0 deletions

View File

@@ -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")

View File

@@ -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")

299
scripts/scrape_fast.py Normal file
View File

@@ -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()