Feature: Add new daily scraper approach for FFA data

- 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
This commit is contained in:
Muyue
2026-01-02 11:54:56 +01:00
parent f6c8e889d5
commit 0bd65b1d3f
3 changed files with 629 additions and 0 deletions

102
docs/NOUVEAU_SCRAPER.md Normal file
View File

@@ -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.

View File

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

View File

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