""" core.py - Logique métier pour planning2ics web app. Adapté de planning2ics.py pour usage web (config injectable, callback de progression). Pipeline : 1. Extraction PDF → événements bruts 2. Clustering PDF-first → notes groupées en séries canoniques (LLM, sans site) 3. Scraping site → catalogue officiel (enrichissement optionnel) 4. Match canonique→site → correspondance pour titre officiel + description (LLM) 5. Génération ICS → une série = un fichier, même sans correspondance site """ import re import json import time as time_module from pathlib import Path from datetime import datetime, date, time, timedelta from typing import Callable, Optional import pdfplumber import requests from bs4 import BeautifulSoup from icalendar import Calendar, Event from uuid import uuid4 MONTH_MAP = { "JANV": 1, "JAN": 1, "JANVIER": 1, "FEV": 2, "FEVR": 2, "FEVRIER": 2, "MARS": 3, "MAR": 3, "AVRIL": 4, "AVR": 4, "MAI": 5, "JUIN": 6, "JUIL": 7, "JUILLET": 7, "AOUT": 8, "AOÛT": 8, "SEPT": 9, "SEP": 9, "SEPTEMBRE": 9, "OCT": 10, "OCTOBRE": 10, "NOV": 11, "NOVEMBRE": 11, "DEC": 12, "DÉC": 12, "DECEMBRE": 12, "DÉCEMBRE": 12, } CONCERT_KEYWORDS = { 'concert', 'représentation', 'générale publique', 'raccord', 'italienne', 'scène orch' } FRENCH_MONTHS = { 'janvier': 1, 'février': 2, 'mars': 3, 'avril': 4, 'mai': 5, 'juin': 6, 'juillet': 7, 'août': 8, 'septembre': 9, 'octobre': 10, 'novembre': 11, 'décembre': 12, } # ── Utilitaires ─────────────────────────────────────────────────────────────── def normalize_note(note: str) -> str: return re.sub(r'\s+', ' ', note).strip() def is_public_event(titre: str) -> bool: t = titre.lower() return any(k in t for k in CONCERT_KEYWORDS) def sanitize_filename(name: str) -> str: clean = re.sub(r'[^\w\s\-éèêàùûîôç]', '', name, flags=re.UNICODE) return clean.strip().replace(' ', '_')[:80] or 'SERIE_INCONNUE' def extract_year_month_from_filename(filename: str): year_match = re.search(r'(\d{4})', filename) year = int(year_match.group(1)) if year_match else 2026 stem = Path(filename).stem.upper() main_month = 1 for key, val in MONTH_MAP.items(): if key in stem: main_month = val break return year, main_month def parse_date(date_str: str, main_year: int, main_month: int) -> Optional[date]: try: day, month = map(int, date_str.strip().split('/')) if month > main_month + 3: year = main_year - 1 elif month < main_month - 3: year = main_year + 1 else: year = main_year return date(year, month, day) except Exception: return None def parse_time(s: str) -> Optional[time]: m = re.match(r'(\d{1,2}):(\d{2})', s.strip()) return time(int(m.group(1)), int(m.group(2))) if m else None def parse_horaires(s: str): s = s.strip() m = re.match(r'(\d{1,2}:\d{2})\s*[-–]\s*(\d{1,2}:\d{2})', s) if m: return parse_time(m.group(1)), parse_time(m.group(2)) m = re.match(r'(\d{1,2}:\d{2})', s) if m: return parse_time(m.group(1)), None return None, None # ── Extraction PDF ──────────────────────────────────────────────────────────── def extract_events_from_pdf(pdf_path: Path) -> list: events = [] main_year, main_month = extract_year_month_from_filename(pdf_path.name) current_date = None with pdfplumber.open(pdf_path) as pdf: for page in pdf.pages: for table in (page.extract_tables() or []): for row in table: if not row: continue cells = [str(c).strip() if c else '' for c in row] if cells[0].lower() == 'jour' or len(cells) < 5: continue date_str = cells[1] horaires = cells[2] titre = cells[3] lieu = cells[4] note = cells[5] if len(cells) > 5 else '' dec = cells[6] if len(cells) > 6 else '' voy = cells[7] if len(cells) > 7 else '' if date_str and re.match(r'\d{1,2}/\d{2}', date_str): parsed = parse_date(date_str, main_year, main_month) if parsed: current_date = parsed if not current_date: continue if 'repos' in horaires.lower(): continue if not re.search(r'\d{1,2}:\d{2}', horaires): continue start_time, end_time = parse_horaires(horaires) if not start_time: continue events.append({ 'date': current_date, 'horaires': horaires, 'start_time': start_time, 'end_time': end_time, 'titre': titre, 'lieu': lieu, 'note': normalize_note(note), 'dec': dec, 'voy': voy, 'source_file': pdf_path.name, }) return events # ── Scraping site web ───────────────────────────────────────────────────────── def scrape_catalog(config: dict, cache_dir: Path, log: Callable = None, force: bool = False, target_dates: set = None) -> dict: """ Scrape les événements du site en se limitant aux dates de concerts du PDF. target_dates : set de date objects issus du PDF (concerts/représentations). Seules les pages site dont au moins une date correspond sont conservées. Si target_dates est None ou vide, toutes les pages sont récupérées (mode exhaustif). """ cache_file = cache_dir / "website_catalog.json" cache_dir.mkdir(parents=True, exist_ok=True) if not force and cache_file.exists(): if log: log("Catalogue site web chargé depuis le cache") with open(cache_file) as f: return json.load(f) headers = {'User-Agent': 'Mozilla/5.0 (compatible; planning2ics/1.0)'} calendar_url = config['site']['calendar_url'] site_base = config['site']['base_url'] # Année de référence : la plus représentée dans les dates PDF if target_dates: years = [d.year for d in target_dates] reference_year = max(set(years), key=years.count) else: reference_year = datetime.now().year if log: if target_dates: log(f"Scraping ciblé : {len(target_dates)} dates de concerts à croiser avec le site...") else: log("Scraping du site web de l'opéra (mode exhaustif)...") resp = requests.get(calendar_url, headers=headers, timeout=30) resp.raise_for_status() soup = BeautifulSoup(resp.text, 'html.parser') event_links = {} for a in soup.find_all('a', href=True): href = a['href'] if '/evenements/' in href and href.rstrip('/') != f'{site_base}/evenements': full_url = href if href.startswith('http') else site_base + href h3 = a.find('h3') cat_tag = a.find('p') title = h3.get_text(strip=True) if h3 else a.get_text(strip=True) category = cat_tag.get_text(strip=True) if cat_tag else '' if title and len(title) > 3: event_links[title] = {'url': full_url, 'category': category} total = len(event_links) if log: log(f"{total} événements trouvés sur le site, filtrage par dates...") catalog = {} fetched = 0 skipped = 0 for title, info in event_links.items(): try: r = requests.get(info['url'], headers=headers, timeout=20) r.raise_for_status() page_soup = BeautifulSoup(r.text, 'html.parser') page_dates = _parse_french_dates_from_page(page_soup, reference_year) # Filtrage par correspondance de dates if target_dates and not (page_dates & target_dates): skipped += 1 time_module.sleep(0.1) continue catalog[title] = { 'url': info['url'], 'description': _extract_description(page_soup), 'category': info['category'], 'dates': _extract_dates_from_page(page_soup), } fetched += 1 time_module.sleep(0.2) except Exception: pass # On ignore silencieusement les pages inaccessibles with open(cache_file, 'w') as f: json.dump(catalog, f, ensure_ascii=False, indent=2) if log: log(f"Site : {fetched} événements retenus, {skipped} ignorés (dates hors planning)") return catalog def _extract_description(soup: BeautifulSoup) -> str: for selector in ['div.wp-block-group', 'div.entry-content', 'article', 'main']: container = soup.select_one(selector) if container: for tag in container.find_all(['nav', 'header', 'footer', 'button', 'form']): tag.decompose() lines = [ l.strip() for l in container.get_text('\n', strip=True).splitlines() if l.strip() and len(l.strip()) > 15 ][:40] if lines: return '\n'.join(lines) return soup.get_text('\n', strip=True)[:2000] def _get_performance_text_blocks(soup: BeautifulSoup) -> list[str]: """ Retourne les blocs de texte contenant les vraies dates de représentation. Cible d'abord les éléments CSS spécifiques au site de l'opéra, puis utilise le texte complet en fallback. """ # Balises ciblées sur le site opera-orchestre-montpellier.fr for css_class in ['section-spectacle-dates-date', 'section-spectacle-dates', 'event-dates', 'dates-seances', 'programme-dates']: elements = soup.find_all(class_=css_class) if elements: return [el.get_text(' ', strip=True) for el in elements] # Fallback : balises