""" 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 _extract_dates_from_page(soup: BeautifulSoup) -> list[str]: """Extrait les dates de représentation depuis une page événement du site.""" text = soup.get_text(' ') months = '|'.join(FRENCH_MONTHS.keys()) pattern = rf'\b(\d{{1,2}})\s+({months})\s*(?:(\d{{4}}))?\b' found = [] for m in re.finditer(pattern, text, re.IGNORECASE): found.append(m.group(0).strip()) # Déduplique en conservant l'ordre seen = set() unique = [] for d in found: if d not in seen: seen.add(d) unique.append(d) return unique[:15] def _parse_french_dates_from_page(soup: BeautifulSoup, reference_year: int) -> set: """ Extrait et convertit les dates de représentation en objets date. Utilisé pour filtrer les pages site par correspondance avec les dates PDF. """ months = '|'.join(FRENCH_MONTHS.keys()) pattern = rf'\b(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?\b' text = soup.get_text(' ') result = set() for m in re.finditer(pattern, text, re.IGNORECASE): day = int(m.group(1)) month_name = m.group(2).lower() year = int(m.group(3)) if m.group(3) else reference_year month = FRENCH_MONTHS.get(month_name) if not month: continue try: result.add(date(year, month, day)) except ValueError: pass return result # ── LLM ─────────────────────────────────────────────────────────────────────── def _llm_call(prompt: str, ollama_url: str, model: str) -> str: resp = requests.post( f"{ollama_url}/api/chat", json={ "model": model, "messages": [{"role": "user", "content": prompt}], "stream": True, "options": {"temperature": 0.05, "num_predict": 16384}, "think": False, }, stream=True, timeout=600, ) resp.raise_for_status() content = "" for line in resp.iter_lines(): if line: chunk = json.loads(line) content += chunk.get('message', {}).get('content', '') if chunk.get('done'): break return content def _parse_json_response(content: str, key: str) -> dict: """Extrait un dict depuis une réponse LLM JSON (robuste aux erreurs de parsing).""" json_match = re.search(r'\{[\s\S]*\}', content) if not json_match: return {} raw = json_match.group() try: return json.loads(raw).get(key, {}) except json.JSONDecodeError: result = {} for m in re.finditer(r'"((?:[^"\\]|\\.)*)"\s*:\s*(?:"((?:[^"\\]|\\.)*)"|null)', raw): result[m.group(1)] = m.group(2) if m.group(2) is not None else None return result # ── Étape 1 : clustering PDF-first ─────────────────────────────────────────── def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path, log: Callable = None, force: bool = False) -> dict: """ Groupe les notes du PDF en séries canoniques via LLM. Ne nécessite PAS le catalogue du site. Retourne {note: nom_canonique_série}. """ cache_file = cache_dir / "series_clusters.json" cache_dir.mkdir(parents=True, exist_ok=True) if not force and cache_file.exists(): if log: log("Clusters de séries chargés depuis le cache") with open(cache_file) as f: return json.load(f) non_empty = sorted(n for n in unique_notes if n.strip()) if not non_empty: return {} # Heuristique (A)/(B) : pré-traitement avant LLM heuristic = {} for note in non_empty: m = re.match(r"^\(([AB]'?)\)\s*:\s*[\"']?(.+?)[\"']?\s*$", note, re.IGNORECASE) if m: heuristic[note] = m.group(2).strip().strip('"\'') notes_list = '\n'.join(f'- {repr(n)}' for n in non_empty) prompt = f"""Tu analyses le planning interne d'un orchestre (Opéra Orchestre National Montpellier). Voici des notes de programme extraites du PDF de planning interne. Ta tâche : regrouper ces notes en SÉRIES (même programme = même série). Pour chaque note, donne un nom canonique court et propre de la série. Règles : 1. Mêmes œuvres/compositeurs avec formulations différentes → MÊME série 2. Préfixes (A), (B), (A'), (B') → SÉRIES PARALLÈLES DIFFÉRENTES (noms distincts) 3. Mentions entre parenthèses (captation, présence de...) ne changent PAS la série 4. Répétitions partielles (Cordes, Vents...) = même série que le Tutti complet 5. Les séries avec seulement des répétitions (pas de concert) sont valides 6. Nom canonique = titre principal de l'œuvre ou des compositeurs (ex: "Pelléas et Mélisande", "Concert Beethoven / Brahms") Notes à analyser : {notes_list} Réponds UNIQUEMENT avec un JSON valide, sans texte autour : {{ "clusters": {{ "note exacte telle quelle": "Nom Canonique de la Série", ... }} }}""" model = config['ollama']['cluster_model'] if log: log(f"Identification des séries depuis le PDF ({model})...") content = _llm_call(prompt, config['ollama']['url'], model) result = _parse_json_response(content, 'clusters') # Appliquer les heuristiques pour les notes non assignées for note, canonical in heuristic.items(): if note not in result: result[note] = canonical # Fallback : note non assignée → elle-même (tronquée) for note in non_empty: if note not in result or not result[note]: result[note] = note[:80] with open(cache_file, 'w') as f: json.dump(result, f, ensure_ascii=False, indent=2) nb_series = len(set(v for v in result.values() if v)) if log: log(f"{len(result)} notes → {nb_series} séries identifiées depuis le PDF") return result # ── Étape 2 : correspondance séries PDF ↔ site (enrichissement) ────────────── def match_series_to_catalog(canonical_series: set, catalog: dict, config: dict, cache_dir: Path, log: Callable = None, force: bool = False) -> dict: """ Fait correspondre les noms canoniques (PDF) aux titres officiels du site. Retourne {nom_canonique: titre_site_ou_None}. Les séries sans correspondance reçoivent None (pas perdues). """ cache_file = cache_dir / "series_site_match.json" cache_dir.mkdir(parents=True, exist_ok=True) if not force and cache_file.exists(): if log: log("Correspondances séries↔site chargées depuis le cache") with open(cache_file) as f: return json.load(f) if not canonical_series or not catalog: return {s: None for s in canonical_series} catalog_titles = sorted(catalog.keys()) titles_list = '\n'.join(f'- "{t}"' for t in catalog_titles) series_list = '\n'.join(f'- "{s}"' for s in sorted(canonical_series)) prompt = f"""Tu dois faire correspondre des noms de séries (extraits d'un planning interne) aux titres officiels du site web de l'Opéra Orchestre National Montpellier. Titres officiels du site : {titles_list} Séries du planning PDF : {series_list} Pour chaque série PDF, donne le titre officiel le plus proche, ou null si aucun ne correspond (ex: série de répétitions sans concert public). Réponds UNIQUEMENT avec un JSON valide : {{ "matches": {{ "Nom série PDF": "Titre officiel du site", "Série répétitions seules": null }} }}""" model = config['ollama']['local_model'] if log: log("Correspondance séries PDF ↔ site web...") content = _llm_call(prompt, config['ollama']['url'], model) result = _parse_json_response(content, 'matches') # S'assurer que toutes les séries ont une entrée (null si absente) for s in canonical_series: if s not in result: result[s] = None with open(cache_file, 'w') as f: json.dump(result, f, ensure_ascii=False, indent=2) matched = sum(1 for v in result.values() if v) if log: log(f"{matched}/{len(result)} séries PDF trouvées sur le site") return result # ── Génération ICS ──────────────────────────────────────────────────────────── def _build_description(evt: dict, catalog_entry: Optional[dict]) -> str: lines = [] if is_public_event(evt['titre']) and catalog_entry: desc = catalog_entry.get('description', '') if desc: lines.append(desc[:1500]) site_dates = catalog_entry.get('dates', []) if site_dates: lines.append(f"Dates sur le site : {', '.join(site_dates)}") url = catalog_entry.get('url', '') if url: lines.append(f"Site : {url}") else: if evt['note']: lines.append(f"Œuvres : {evt['note']}") lines.append(f"Type : {evt['titre']}") if evt['dec']: lines.append(f"Durée déclarée : {evt['dec']}") if evt['voy']: lines.append(f"Déplacement : {evt['voy']}h de trajet") lines.append(f"Source : {evt['source_file']}") return '\n'.join(lines) def _create_ics_bytes(series_title: str, events: list, catalog_entry: Optional[dict]) -> bytes: cal = Calendar() cal.add('prodid', '-//Opéra Orchestre National Montpellier//planning2ics//FR') cal.add('version', '2.0') cal.add('x-wr-calname', series_title) cal.add('x-wr-timezone', 'Europe/Paris') for evt in sorted(events, key=lambda e: (e['date'], e['start_time'])): vevent = Event() start_dt = datetime.combine(evt['date'], evt['start_time']) vevent.add('dtstart', start_dt) if evt['end_time']: end_dt = datetime.combine(evt['date'], evt['end_time']) else: dec_m = re.match(r'(\d{1,2}):(\d{2})', evt['dec']) duration = ( timedelta(hours=int(dec_m.group(1)), minutes=int(dec_m.group(2))) if dec_m else timedelta(hours=2) ) end_dt = start_dt + duration vevent.add('dtend', end_dt) vevent.add('summary', f"{evt['titre']} – {series_title}") if evt['lieu']: vevent.add('location', evt['lieu']) vevent.add('description', _build_description(evt, catalog_entry)) vevent.add('uid', str(uuid4()) + '@planning-orchestre') cal.add_component(vevent) return cal.to_ical() # ── Point d'entrée principal ────────────────────────────────────────────────── def process_pdfs(pdf_paths: list, config: dict, data_dir: Path, log: Callable = None) -> dict: """ Traite une liste de PDFs. Retourne {series_title: {filename, bytes, event_count}}. Pipeline : PDF → extraction → clustering PDF-first → scraping site → match optionnel → ICS Toutes les séries du PDF sont générées, même sans correspondance site. """ cache_dir = data_dir / "cache" # ── 1. Extraction PDF ────────────────────────────────────────────────────── if log: log(f"Extraction de {len(pdf_paths)} PDF(s)...") all_events = [] seen = set() for i, pdf_path in enumerate(pdf_paths): if log: log(f"Extraction {i+1}/{len(pdf_paths)} : {pdf_path.name}") for evt in extract_events_from_pdf(pdf_path): key = (evt['date'], evt['start_time'], evt['titre'], evt['note']) if key not in seen: seen.add(key) all_events.append(evt) if log: log(f"{len(all_events)} événements extraits au total") if not all_events: return {} # ── 2. Clustering PDF-first (sans site) ──────────────────────────────────── unique_notes = {e['note'] for e in all_events} if log: log(f"{len(unique_notes)} notes uniques à analyser...") note_to_canonical = cluster_notes_into_series(unique_notes, config, cache_dir, log) # ── 3. Scraping site ciblé sur les dates de concerts du PDF ─────────────── concert_dates = {e['date'] for e in all_events if is_public_event(e['titre'])} if log and concert_dates: log(f"{len(concert_dates)} dates de concerts identifiées dans le PDF") catalog = scrape_catalog(config, cache_dir, log, target_dates=concert_dates or None) # ── 4. Correspondance canonique → site (optionnel) ───────────────────────── canonical_series = set(v for v in note_to_canonical.values() if v) canonical_to_site = match_series_to_catalog(canonical_series, catalog, config, cache_dir, log) # ── 5. Groupement des événements par série ───────────────────────────────── series_events: dict[str, list] = {} for evt in all_events: canonical = note_to_canonical.get(evt['note']) if not canonical: # Note vide : regrouper par type de titre canonical = evt['titre'] or 'SANS PROGRAMME' # Titre final = titre site si trouvé, sinon nom canonique PDF site_title = canonical_to_site.get(canonical) series_key = site_title if site_title else canonical evt['_canonical'] = canonical evt['_site_title'] = site_title series_events.setdefault(series_key, []).append(evt) if log: log(f"{len(series_events)} séries à générer...") # ── 6. Génération ICS ────────────────────────────────────────────────────── result = {} for series_key, events in series_events.items(): catalog_entry = catalog.get(series_key) # None si pas de correspondance site result[series_key] = { 'filename': sanitize_filename(series_key) + '.ics', 'bytes': _create_ics_bytes(series_key, events, catalog_entry), 'event_count': len(events), } if log: log(f"Terminé : {len(result)} séries générées") return result