From 0bd65b1d3fe88c02df01a809df1c20e318f25678 Mon Sep 17 00:00:00 2001 From: Muyue Date: Fri, 2 Jan 2026 11:54:56 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8=20Feature:=20Add=20new=20daily=20scra?= =?UTF-8?q?per=20approach=20for=20FFA=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New scraper_jour_par_jour.py: Day-by-day scraping approach * Fixes 403/404 errors from previous method * Uses frmsaisonffa= (empty) parameter to avoid season filtering * Scrapes courses and results for each day from 01/01/2024 to 01/08/2026 * Progressive CSV saving with 'jour_recupere' column for traceability - New scraper_jour_par_jour_cli.py: CLI version with customizable dates * --start-date: Custom start date (default: 2024-01-01) * --end-date: Custom end date (default: 2026-08-01) * --no-results: Skip result fetching for faster scraping * --output-dir: Custom output directory - Documentation in docs/NOUVEAU_SCRAPER.md * Explains problems with old approach * Details new day-by-day methodology * Usage instructions and examples - Cleaned up: Removed temporary test scripts and debug files --- docs/NOUVEAU_SCRAPER.md | 102 ++++++ scripts/scraper_jour_par_jour.py | 464 +++++++++++++++++++++++++++ scripts/scraper_jour_par_jour_cli.py | 63 ++++ 3 files changed, 629 insertions(+) create mode 100644 docs/NOUVEAU_SCRAPER.md create mode 100644 scripts/scraper_jour_par_jour.py create mode 100644 scripts/scraper_jour_par_jour_cli.py diff --git a/docs/NOUVEAU_SCRAPER.md b/docs/NOUVEAU_SCRAPER.md new file mode 100644 index 0000000..8abba73 --- /dev/null +++ b/docs/NOUVEAU_SCRAPER.md @@ -0,0 +1,102 @@ +# Nouveau Scraper FFA - Approche Jour par Jour + +Ce script remplace l'ancienne méthode de scraping qui avait des problèmes d'erreurs 403/404. + +## Ce qui a changé + +### Problèmes de l'ancienne approche +- Utilisait des périodes de 15 jours +- Paramètre `frmsaisonffa=2026` fixe qui causait des erreurs +- Erreurs 403/404 fréquentes +- Difficile de relancer sur une date spécifique + +### Nouvelle approche +- Scraping **jour par jour** (plus robuste) +- Utilise `frmsaisonffa=` (vide) pour éviter les filtres par saison +- Récupère les courses et leurs résultats pour chaque jour +- Sauvegarde progressive dans des CSV +- **Colonne `jour_recupere`** pour savoir quel jour on a scrapé +- Facile de relancer à partir d'une date spécifique + +## Fichiers générés + +### data/courses_daily.csv +Contient toutes les courses récupérées avec les colonnes: +- `jour_recupere`: Date pour laquelle on a récupéré la course (format YYYY-MM-DD) +- `nom`: Nom de la course +- `date`: Date de la course (format affiché par le site) +- `lieu`: Lieu de la course +- `discipline`: Discipline +- `type`: Type de compétition +- `niveau`: Niveau (Départemental, Régional, etc.) +- `label`: Label +- `lien`: URL principale de la course +- `fiche_detail`: URL vers la fiche détaillée +- `resultats_url`: URL vers la page des résultats + +### data/results_daily.csv +Contient tous les résultats des courses avec les colonnes: +- `jour_recupere`: Date pour laquelle on a récupéré les résultats +- `course_nom`: Nom de la course +- `course_date`: Date de la course +- `course_lieu`: Lieu de la course +- `course_url`: URL de la page des résultats +- `place`: Place obtenue +- `resultat`: Résultat (temps/points) +- `nom`: Nom de l'athlète +- `prenom`: Prénom de l'athlète +- `club`: Club de l'athlète +- `dept`: Département +- `ligue`: Ligue +- `categorie`: Catégorie +- `niveau`: Niveau +- `points`: Points +- `temps`: Temps réalisé + +## Utilisation + +### Scraping complet (du 01/01/2024 au 01/08/2026) +```bash +python scripts/scraper_jour_par_jour.py +``` + +### Estimation du temps +- Environ 5-6 secondes par jour (avec récupération des résultats) +- 944 jours à scraper = ~1.3 à 1.5 heures + +### Reprise après interruption +Si le script est interrompu, il continue d'ajouter aux fichiers CSV existants. +Pour recommencer à zéro, supprimez les fichiers: +```bash +rm data/courses_daily.csv data/results_daily.csv +``` + +### Logs +Le script génère des logs dans `scraper_jour_par_jour.log` et affiche la progression en temps réel. + +## Modifications pour tests + +Si vous voulez tester sur une période plus courte, modifiez les dates dans `scripts/scraper_jour_par_jour.py`: + +```python +# Ligne 276-277 dans la fonction main() +start_date = "2024-01-01" # Modifier ici +end_date = "2024-01-31" # Modifier ici +``` + +## Avantages de cette approche + +1. **Robustesse**: Scraping jour par jour évite les problèmes de pagination +2. **Transparence**: La colonne `jour_recupere` permet de savoir exactement ce qui a été récupéré +3. **Reprise facile**: On peut relancer à n'importe quel moment +4. **Progressive sauvegarde**: Les données sont sauvegardées au fur et à mesure +5. **Pas de duplication**: Les courses sont clairement identifiées par leur jour de récupération + +## URL utilisée + +Le script utilise cette URL pour chaque jour: +``` +https://www.athle.fr/bases/liste.aspx?frmpostback=true&frmbase=calendrier&frmmode=1&frmespace=0&frmsaisonffa=&frmdate1=YYYY-MM-DD&frmdate2=YYYY-MM-DD&frmtype1=&frmniveau=&frmligue=&frmdepartement=&frmniveaulab=&frmepreuve=&frmtype2=&frmtype3=&frmtype4= +``` + +Le paramètre clé est `frmsaisonffa=` (vide) qui permet de récupérer les résultats sans filtrer par saison. diff --git a/scripts/scraper_jour_par_jour.py b/scripts/scraper_jour_par_jour.py new file mode 100644 index 0000000..9bd21e1 --- /dev/null +++ b/scripts/scraper_jour_par_jour.py @@ -0,0 +1,464 @@ +#!/usr/bin/env python3 +""" +Scraper FFA - Nouvelle approche jour par jour + +Ce script scrape les données de la FFA du 01/01/2024 au 01/08/2026, +jour par jour, en récupérant les courses et leurs résultats. +""" + +import sys +import os +import time +from datetime import datetime, timedelta +import csv +import logging +from tqdm import tqdm +from selenium import webdriver +from selenium.webdriver.chrome.service import Service +from selenium.webdriver.common.by import By +from selenium.webdriver.support.ui import WebDriverWait +from selenium.webdriver.support import expected_conditions as EC +from webdriver_manager.chrome import ChromeDriverManager +from bs4 import BeautifulSoup +import pandas as pd + +# Configuration du logging +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(levelname)s - %(message)s', + handlers=[ + logging.FileHandler('scraper_jour_par_jour.log'), + logging.StreamHandler() + ] +) +logger = logging.getLogger(__name__) + +class FFAScraperDaily: + """Scraper FFA - Approche jour par jour""" + + def __init__(self, output_dir="data"): + self.output_dir = output_dir + self.base_url = "https://www.athle.fr" + self.request_delay = 3.0 # Délai plus long pour éviter les erreurs 403/404 + self.max_retries = 5 + self.timeout = 45 + + # Créer les répertoires + os.makedirs(output_dir, exist_ok=True) + + # Fichiers de sortie + self.courses_file = os.path.join(output_dir, "courses_daily.csv") + self.results_file = os.path.join(output_dir, "results_daily.csv") + + # Initialiser les en-têtes CSV si les fichiers n'existent pas + self._init_csv_files() + + # Driver Selenium (initialisé plus tard) + self.driver = None + + def _init_csv_files(self): + """Initialiser les fichiers CSV avec les en-têtes""" + + # En-têtes pour les courses + courses_headers = [ + 'jour_recupere', # Jour pour lequel on a récupéré les courses + 'nom', 'date', 'lieu', 'discipline', 'type', 'niveau', + 'label', 'lien', 'fiche_detail', 'resultats_url' + ] + + if not os.path.exists(self.courses_file): + with open(self.courses_file, 'w', newline='', encoding='utf-8-sig') as f: + writer = csv.DictWriter(f, fieldnames=courses_headers) + writer.writeheader() + + # En-têtes pour les résultats + results_headers = [ + 'jour_recupere', # Jour pour lequel on a récupéré les résultats + 'course_nom', 'course_date', 'course_lieu', 'course_url', + 'place', 'resultat', 'nom', 'prenom', 'club', + 'dept', 'ligue', 'categorie', 'niveau', 'points', 'temps' + ] + + if not os.path.exists(self.results_file): + with open(self.results_file, 'w', newline='', encoding='utf-8-sig') as f: + writer = csv.DictWriter(f, fieldnames=results_headers) + writer.writeheader() + + 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 _build_calendar_url(self, date_str): + """Construire l'URL du calendrier pour une date spécifique""" + base_url = "https://www.athle.fr/bases/liste.aspx" + params = { + 'frmpostback': 'true', + 'frmbase': 'calendrier', + 'frmmode': '1', + 'frmespace': '0', + 'frmsaisonffa': '', # Important: laisser vide pour ne pas filtrer par saison + 'frmdate1': date_str, + 'frmdate2': date_str, + 'frmtype1': '', + 'frmniveau': '', + 'frmligue': '', + 'frmdepartement': '', + 'frmniveaulab': '', + 'frmepreuve': '', + 'frmtype2': '', + 'frmtype3': '', + 'frmtype4': '' + } + + param_str = '&'.join([f"{k}={v}" for k, v in params.items()]) + return f"{base_url}?{param_str}" + + def get_courses_for_day(self, date_str): + """Récupérer les courses pour un jour spécifique""" + self._init_driver() + + url = self._build_calendar_url(date_str) + logger.info(f"Récupération des courses pour {date_str}") + + courses = [] + + 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') + + # Trouver la table principale + main_table = soup.find('table', class_='reveal-table') + if not main_table: + logger.warning(f"Pas de table trouvée pour {date_str}") + return courses + + rows = main_table.find_all('tr') + + for row in rows: + cells = row.find_all('td') + if len(cells) < 5 or row.find('table', class_='detail-inner-table'): + continue + + try: + # Récupérer la date (colonne 0) + date = cells[0].get_text(strip=True) + + # Récupérer le nom de la course (colonne 1) + libelle_cell = cells[1] + nom = libelle_cell.get_text(strip=True) + + # Récupérer le lien de la course (colonne 1) + course_url = '' + link_element = libelle_cell.find('a', href=True) + if link_element: + href = link_element.get('href', '') + if href.startswith('http'): + course_url = href + else: + course_url = f"{self.base_url}{href}" + + # Récupérer le lieu (colonne 2) + lieu = cells[2].get_text(strip=True) if len(cells) > 2 else '' + + # Récupérer le type (colonne 3) + type_competition = cells[3].get_text(strip=True) if len(cells) > 3 else '' + + # Récupérer le niveau (colonne 4) + niveau = cells[4].get_text(strip=True) if len(cells) > 4 else '' + + # Récupérer le label (colonne 5) + label = cells[5].get_text(strip=True) if len(cells) > 5 else '' + + # Récupérer le lien vers la fiche détail (colonne 6 "Fiche") + detail_url = '' + if len(cells) > 6: + fiche_cell = cells[6] + fiche_link = fiche_cell.find('a', href=True) + if fiche_link: + href = fiche_link.get('href', '') + if href.startswith('http'): + detail_url = href + else: + detail_url = f"{self.base_url}{href}" + + # Récupérer le lien vers les résultats (colonne 7 "Résultats") + result_url = '' + if len(cells) > 7: + result_cell = cells[7] + result_link = result_cell.find('a', href=True) + if result_link: + href = result_link.get('href', '') + if href.startswith('http'): + result_url = href + else: + result_url = f"{self.base_url}{href}" + + course = { + 'jour_recupere': date_str, + 'nom': nom, + 'date': date, + 'lieu': lieu, + 'discipline': '', + 'type': type_competition, + 'niveau': niveau, + 'label': label, + 'lien': course_url, + 'fiche_detail': detail_url, + 'resultats_url': result_url + } + courses.append(course) + + except Exception as e: + logger.warning(f"Erreur parsing course: {e}") + continue + + logger.info(f"{len(courses)} courses trouvées pour {date_str}") + return courses + + except Exception as e: + logger.error(f"Erreur tentative {attempt + 1}/{self.max_retries} pour {date_str}: {e}") + if attempt < self.max_retries - 1: + time.sleep(self.request_delay * (attempt + 1)) + else: + raise + + return courses + + def get_results_for_course(self, course): + """Récupérer les résultats d'une course""" + result_url = course.get('resultats_url', '') + if not result_url: + return [] + + logger.info(f"Récupération des résultats pour: {course['nom']}") + + results = [] + + for attempt in range(self.max_retries): + try: + self.driver.get(result_url) + time.sleep(self.request_delay) + + html = self.driver.page_source + soup = BeautifulSoup(html, 'html.parser') + + main_table = soup.find('table', class_='reveal-table') + if not main_table: + logger.warning(f"Pas de table de résultats pour {course['nom']}") + return results + + rows = main_table.find_all('tr') + header_found = False + + for row in rows: + cells = row.find_all(['td', 'th']) + + if row.find('table', class_='detail-inner-table'): + continue + + if not header_found and cells: + first_cell_text = cells[0].get_text(strip=True) + if first_cell_text == 'Place' and len(cells) >= 9: + header_found = True + continue + + if header_found and cells and len(cells) >= 9: + try: + place = cells[0].get_text(strip=True) + resultat = cells[1].get_text(strip=True) + + nom_cell = cells[2].get_text(strip=True) if len(cells) > 2 else '' + if nom_cell: + parts = nom_cell.split(' ') + if len(parts) >= 2: + nom = parts[0] + prenom = ' '.join(parts[1:]) + else: + nom = nom_cell + prenom = '' + else: + nom = '' + prenom = '' + + club = cells[3].get_text(strip=True) if len(cells) > 3 else '' + dept = cells[4].get_text(strip=True) if len(cells) > 4 else '' + ligue = cells[5].get_text(strip=True) if len(cells) > 5 else '' + infos = cells[6].get_text(strip=True) if len(cells) > 6 else '' + niveau = cells[7].get_text(strip=True) if len(cells) > 7 else '' + points = cells[8].get_text(strip=True) if len(cells) > 8 else '' + + try: + int(place) + except (ValueError, TypeError): + continue + + result = { + 'jour_recupere': course['jour_recupere'], + 'course_nom': course['nom'], + 'course_date': course['date'], + 'course_lieu': course['lieu'], + 'course_url': result_url, + 'place': place, + 'resultat': resultat, + 'nom': nom, + 'prenom': prenom, + 'club': club, + 'dept': dept, + 'ligue': ligue, + 'categorie': infos, + 'niveau': niveau, + 'points': points, + 'temps': resultat + } + results.append(result) + + except Exception as e: + logger.warning(f"Erreur parsing résultat: {e}") + continue + + logger.info(f"{len(results)} résultats trouvés pour {course['nom']}") + return results + + except Exception as e: + logger.error(f"Erreur tentative {attempt + 1}/{self.max_retries}: {e}") + if attempt < self.max_retries - 1: + time.sleep(self.request_delay * (attempt + 1)) + else: + logger.warning(f"Impossible de récupérer les résultats pour {course['nom']}") + return results + + return results + + def append_to_csv(self, data, filename): + """Ajouter des données à un fichier CSV""" + if not data: + return + + # Lire les en-têtes existants + with open(filename, 'r', newline='', encoding='utf-8-sig') as f: + reader = csv.DictReader(f) + fieldnames = reader.fieldnames + + # Ajouter les nouvelles données + with open(filename, 'a', newline='', encoding='utf-8-sig') as f: + writer = csv.DictWriter(f, fieldnames=fieldnames) + writer.writerows(data) + + logger.info(f"{len(data)} enregistrements ajoutés à {filename}") + + def run(self, start_date, end_date, fetch_results=True): + """Exécuter le scraping jour par jour""" + + logger.info(f"Début du scraping du {start_date} au {end_date}") + + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + + total_days = (end_dt - start_dt).days + 1 + logger.info(f"Nombre total de jours à traiter: {total_days}") + + total_courses = 0 + total_results = 0 + + # Barre de progression pour les jours + day_bar = tqdm(range(total_days), desc="Jours traités", unit="jour") + + for day_offset in day_bar: + current_date = start_dt + timedelta(days=day_offset) + date_str = current_date.strftime('%Y-%m-%d') + + try: + # Récupérer les courses du jour + courses = self.get_courses_for_day(date_str) + + if courses: + # Sauvegarder les courses + self.append_to_csv(courses, self.courses_file) + total_courses += len(courses) + + # Récupérer les résultats de chaque course + if fetch_results: + course_bar = tqdm(courses, desc=f"Résultats {date_str}", leave=False) + for course in course_bar: + try: + results = self.get_results_for_course(course) + if results: + self.append_to_csv(results, self.results_file) + total_results += len(results) + except Exception as e: + logger.error(f"Erreur récupération résultats pour {course['nom']}: {e}") + continue + else: + logger.info(f"Aucune course trouvée pour {date_str}") + + # Mettre à jour la barre de progression + day_bar.set_postfix({ + 'courses': total_courses, + 'résultats': total_results + }) + + # Pause entre les jours pour éviter les erreurs 403/404 + time.sleep(1) + + except Exception as e: + logger.error(f"Erreur pour le jour {date_str}: {e}") + continue + + # Fermer le driver + if self.driver: + self.driver.quit() + + logger.info(f"Scraping terminé!") + logger.info(f"Total courses: {total_courses}") + logger.info(f"Total résultats: {total_results}") + + return { + 'total_days': total_days, + 'total_courses': total_courses, + 'total_results': total_results + } + + +def main(): + """Point d'entrée principal""" + # Période à scraper: du 01/01/2024 au 01/08/2026 + start_date = "2024-01-01" + end_date = "2026-08-01" + + print("=" * 60) + print("🔄 Scraper FFA - Approche jour par jour") + print("=" * 60) + print(f"Période: {start_date} au {end_date}") + print(f"Résultats: data/courses_daily.csv") + print(f"Résultats: data/results_daily.csv") + print("=" * 60) + + scraper = FFAScraperDaily(output_dir="data") + stats = scraper.run(start_date, end_date, fetch_results=True) + + print("\n" + "=" * 60) + print("✅ Scraping terminé!") + print("=" * 60) + print(f"Jours traités: {stats['total_days']}") + print(f"Courses récupérées: {stats['total_courses']}") + print(f"Résultats récupérés: {stats['total_results']}") + print("=" * 60) + + +if __name__ == "__main__": + main() diff --git a/scripts/scraper_jour_par_jour_cli.py b/scripts/scraper_jour_par_jour_cli.py new file mode 100644 index 0000000..d6f24b6 --- /dev/null +++ b/scripts/scraper_jour_par_jour_cli.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +""" +Scraper FFA - Version avec arguments en ligne de commande + +Ce script scrape les données de la FFA jour par jour. +Les dates peuvent être spécifiées en ligne de commande. +""" + +import sys +import os +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + +import argparse +from datetime import datetime +from scripts.scraper_jour_par_jour import FFAScraperDaily + +def main(): + """Point d'entrée principal avec arguments""" + parser = argparse.ArgumentParser(description='Scraper FFA - Approche jour par jour') + parser.add_argument('--start-date', type=str, default='2024-01-01', + help='Date de début (format YYYY-MM-DD). Défaut: 2024-01-01') + parser.add_argument('--end-date', type=str, default='2026-08-01', + help='Date de fin (format YYYY-MM-DD). Défaut: 2026-08-01') + parser.add_argument('--output-dir', type=str, default='data', + help='Répertoire de sortie. Défaut: data') + parser.add_argument('--no-results', action='store_true', + help='Ne pas récupérer les résultats (uniquement les courses)') + + args = parser.parse_args() + + # Valider les dates + try: + datetime.strptime(args.start_date, '%Y-%m-%d') + datetime.strptime(args.end_date, '%Y-%m-%d') + except ValueError: + print("❌ Erreur: Les dates doivent être au format YYYY-MM-DD") + sys.exit(1) + + print("=" * 60) + print("🔄 Scraper FFA - Approche jour par jour") + print("=" * 60) + print(f"Période: {args.start_date} au {args.end_date}") + print(f"Récupération des résultats: {'Non' if args.no_results else 'Oui'}") + print(f"Répertoire de sortie: {args.output_dir}") + print(f"Fichiers de sortie:") + print(f" - {args.output_dir}/courses_daily.csv") + print(f" - {args.output_dir}/results_daily.csv") + print("=" * 60) + + scraper = FFAScraperDaily(output_dir=args.output_dir) + stats = scraper.run(args.start_date, args.end_date, fetch_results=not args.no_results) + + print("\n" + "=" * 60) + print("✅ Scraping terminé!") + print("=" * 60) + print(f"Jours traités: {stats['total_days']}") + print(f"Courses récupérées: {stats['total_courses']}") + print(f"Résultats récupérés: {stats['total_results']}") + print("=" * 60) + + +if __name__ == "__main__": + main()