Matching séries→site par dates ciblées (CSS section-spectacle-dates-date)

- _get_performance_text_blocks : cible les éléments CSS spécifiques au site
  (section-spectacle-dates-date) plutôt que le texte brut de la page,
  évitant la contamination par le calendrier de navigation du site
- match_series_to_catalog : remplace le matching LLM (trop imprécis avec
  les titres poétiques) par un recoupement de dates entre PDF et site
- cluster_notes_into_series : passe les événements complets (avec dates)
  au lieu des notes seules → le LLM identifie correctement les répétitions
  partielles (ex: STRAUSS/PREVIN = même série que BEETHOVEN/STRAUSS/PREVIN)

Résultat : Beethoven/Strauss/Previn→"Là où bat le cœur",
Chostakovitch/Salonen/Prokofiev→"Virtuosité et destin", etc.
Scraping réduit de 143 à 9 requêtes HTTP pour mars+avril.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sylvain
2026-03-08 17:43:49 +01:00
parent 71bdc9f6ea
commit 325eff5ede
+127 -64
View File
@@ -267,18 +267,37 @@ def _extract_description(soup: BeautifulSoup) -> str:
return soup.get_text('\n', strip=True)[:2000] 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 <time>
times = soup.find_all('time')
if times:
return [t.get('datetime', '') + ' ' + t.get_text(' ', strip=True) for t in times]
# Fallback final : texte complet (moins précis)
return [soup.get_text(' ')]
def _extract_dates_from_page(soup: BeautifulSoup) -> list[str]: def _extract_dates_from_page(soup: BeautifulSoup) -> list[str]:
"""Extrait les dates de représentation depuis une page événement du site.""" """Extrait les dates de représentation depuis une page événement du site."""
text = soup.get_text(' ')
months = '|'.join(FRENCH_MONTHS.keys()) months = '|'.join(FRENCH_MONTHS.keys())
pattern = rf'\b(\d{{1,2}})\s+({months})\s*(?:(\d{{4}}))?\b' pattern = rf'\b(\d{{1,2}})\s+({months})\s*(?:(\d{{4}}))?\b'
found = [] blocks = _get_performance_text_blocks(soup)
for m in re.finditer(pattern, text, re.IGNORECASE): seen, unique = set(), []
found.append(m.group(0).strip()) for block in blocks:
# Déduplique en conservant l'ordre for m in re.finditer(pattern, block, re.IGNORECASE):
seen = set() d = m.group(0).strip()
unique = []
for d in found:
if d not in seen: if d not in seen:
seen.add(d) seen.add(d)
unique.append(d) unique.append(d)
@@ -292,9 +311,10 @@ def _parse_french_dates_from_page(soup: BeautifulSoup, reference_year: int) -> s
""" """
months = '|'.join(FRENCH_MONTHS.keys()) months = '|'.join(FRENCH_MONTHS.keys())
pattern = rf'\b(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?\b' pattern = rf'\b(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?\b'
text = soup.get_text(' ') blocks = _get_performance_text_blocks(soup)
result = set() result = set()
for m in re.finditer(pattern, text, re.IGNORECASE): for block in blocks:
for m in re.finditer(pattern, block, re.IGNORECASE):
day = int(m.group(1)) day = int(m.group(1))
month_name = m.group(2).lower() month_name = m.group(2).lower()
year = int(m.group(3)) if m.group(3) else reference_year year = int(m.group(3)) if m.group(3) else reference_year
@@ -351,10 +371,11 @@ def _parse_json_response(content: str, key: str) -> dict:
# ── Étape 1 : clustering PDF-first ─────────────────────────────────────────── # ── Étape 1 : clustering PDF-first ───────────────────────────────────────────
def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path, def cluster_notes_into_series(all_events: list, config: dict, cache_dir: Path,
log: Callable = None, force: bool = False) -> dict: log: Callable = None, force: bool = False) -> dict:
""" """
Groupe les notes du PDF en séries canoniques via LLM. Groupe les notes du PDF en séries canoniques via LLM.
Utilise les dates des événements pour identifier les répétitions partielles.
Ne nécessite PAS le catalogue du site. Ne nécessite PAS le catalogue du site.
Retourne {note: nom_canonique_série}. Retourne {note: nom_canonique_série}.
""" """
@@ -367,7 +388,19 @@ def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path,
with open(cache_file) as f: with open(cache_file) as f:
return json.load(f) return json.load(f)
non_empty = sorted(n for n in unique_notes if n.strip()) # Construire un index note → dates et types d'événements
note_context: dict[str, dict] = {}
for e in all_events:
n = e['note']
if not n.strip():
continue
if n not in note_context:
note_context[n] = {'dates': [], 'titres': []}
note_context[n]['dates'].append(e['date'].isoformat())
if e['titre'] not in note_context[n]['titres']:
note_context[n]['titres'].append(e['titre'])
non_empty = sorted(note_context.keys())
if not non_empty: if not non_empty:
return {} return {}
@@ -378,24 +411,35 @@ def cluster_notes_into_series(unique_notes: set, config: dict, cache_dir: Path,
if m: if m:
heuristic[note] = m.group(2).strip().strip('"\'') heuristic[note] = m.group(2).strip().strip('"\'')
notes_list = '\n'.join(f'- {repr(n)}' for n in non_empty) # Construire la liste avec contexte de dates
lines = []
for n in non_empty:
ctx = note_context[n]
dates = sorted(set(ctx['dates']))
titres = ', '.join(ctx['titres'])
date_range = f"{dates[0]}{dates[-1]}" if len(dates) > 1 else dates[0]
lines.append(f'- {repr(n)}\n dates: {date_range} | types: {titres}')
notes_block = '\n'.join(lines)
prompt = f"""Tu analyses le planning interne d'un orchestre (Opéra Orchestre National Montpellier). 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. Voici des notes de programme extraites du PDF, avec leurs dates et types d'événements.
Ta tâche : regrouper ces notes en SÉRIES (même programme = même série). Ta tâche : regrouper ces notes en SÉRIES (même programme de concert = même série).
Pour chaque note, donne un nom canonique court et propre de la série. Pour chaque note, donne un nom canonique court et propre de la série.
Règles : Règles IMPORTANTES :
1. Mêmes œuvres/compositeurs avec formulations différentes → MÊME série 1. Mêmes compositeurs avec formulations ou ordres différents → MÊME série
2. Préfixes (A), (B), (A'), (B') → SÉRIES PARALLÈLES DIFFÉRENTES (noms distincts) 2. Une note avec seulement CERTAINS compositeurs d'un programme plus complet,
3. Mentions entre parenthèses (captation, présence de...) ne changent PAS la série dont les dates se chevauchent avec ce programme → c'est une répétition PARTIELLE
4. Répétitions partielles (Cordes, Vents...) = même série que le Tutti complet de la MÊME série (ex: "STRAUSS / PREVIN" est une répétition partielle de
5. Les séries avec seulement des répétitions (pas de concert) sont valides "BEETHOVEN / STRAUSS / PREVIN" si leurs dates sont proches)
6. Nom canonique = titre principal de l'œuvre ou des compositeurs (ex: "Pelléas et Mélisande", "Concert Beethoven / Brahms") 3. Préfixes (A), (B), (A'), (B') → SÉRIES PARALLÈLES DIFFÉRENTES (noms distincts)
4. Mentions entre parenthèses (captation, présence de..., ordre différent...) → MÊME série
5. Les séries avec seulement des répétitions (pas de concert public) sont valides
6. Nom canonique = programme COMPLET de la série (inclure tous les compositeurs du concert)
Notes à analyser : Notes à analyser (avec contexte de dates) :
{notes_list} {notes_block}
Réponds UNIQUEMENT avec un JSON valide, sans texte autour : Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
{{ {{
@@ -431,15 +475,34 @@ Réponds UNIQUEMENT avec un JSON valide, sans texte autour :
return result return result
# ── Étape 2 : correspondance séries PDF ↔ site (enrichissement) ────────────── # ── Étape 2 : correspondance séries PDF ↔ site par dates ─────────────────────
def match_series_to_catalog(canonical_series: set, catalog: dict, config: dict, def _parse_catalog_dates(date_strings: list, reference_year: int) -> set:
"""Parse les dates texte du catalogue site en objets date."""
months = '|'.join(FRENCH_MONTHS.keys())
pattern = rf'(\d{{1,2}})\s+({months})(?:\s+(\d{{4}}))?'
result = set()
for s in date_strings:
m = re.search(pattern, s, re.IGNORECASE)
if m:
day = int(m.group(1))
month = FRENCH_MONTHS.get(m.group(2).lower())
year = int(m.group(3)) if m.group(3) else reference_year
if month:
try:
result.add(date(year, month, day))
except ValueError:
pass
return result
def match_series_to_catalog(series_concert_dates: dict, catalog: dict,
cache_dir: Path, log: Callable = None, cache_dir: Path, log: Callable = None,
force: bool = False) -> dict: force: bool = False) -> dict:
""" """
Fait correspondre les noms canoniques (PDF) aux titres officiels du site. Fait correspondre les séries PDF aux événements du site par recoupement de dates.
series_concert_dates : {nom_canonique: set[date]} — dates de concerts du PDF par série.
Retourne {nom_canonique: titre_site_ou_None}. 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_file = cache_dir / "series_site_match.json"
cache_dir.mkdir(parents=True, exist_ok=True) cache_dir.mkdir(parents=True, exist_ok=True)
@@ -450,44 +513,39 @@ def match_series_to_catalog(canonical_series: set, catalog: dict, config: dict,
with open(cache_file) as f: with open(cache_file) as f:
return json.load(f) return json.load(f)
if not canonical_series or not catalog: if not series_concert_dates or not catalog:
return {s: None for s in canonical_series} return {s: None for s in series_concert_dates}
catalog_titles = sorted(catalog.keys()) # Année de référence : la plus fréquente dans toutes les dates
titles_list = '\n'.join(f'- "{t}"' for t in catalog_titles) all_dates = [d for dates in series_concert_dates.values() for d in dates]
series_list = '\n'.join(f'- "{s}"' for s in sorted(canonical_series)) reference_year = max(set(d.year for d in all_dates), key=lambda y: sum(1 for d in all_dates if d.year == y)) if all_dates else datetime.now().year
prompt = f"""Tu dois faire correspondre des noms de séries (extraits d'un planning interne) # Pré-calculer les dates parsées pour chaque événement du site
aux titres officiels du site web de l'Opéra Orchestre National Montpellier. site_dates: dict[str, set] = {}
for title, info in catalog.items():
site_dates[title] = _parse_catalog_dates(info.get('dates', []), reference_year)
Titres officiels du site : result = {}
{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: if log:
log("Correspondance séries PDF ↔ site web...") log("Correspondance séries PDF ↔ site par recoupement de dates...")
content = _llm_call(prompt, config['ollama']['url'], model) for canonical, pdf_concert_dates in series_concert_dates.items():
result = _parse_json_response(content, 'matches') if not pdf_concert_dates:
result[canonical] = None
continue
# S'assurer que toutes les séries ont une entrée (null si absente) best_title = None
for s in canonical_series: best_overlap = 0
if s not in result: for site_title, s_dates in site_dates.items():
result[s] = None overlap = len(pdf_concert_dates & s_dates)
if overlap > best_overlap:
best_overlap = overlap
best_title = site_title
result[canonical] = best_title if best_overlap > 0 else None
if log:
status = f"{best_title!r} ({best_overlap} date(s) communes)" if best_title else "→ aucune correspondance site"
log(f" {canonical!r} {status}")
with open(cache_file, 'w') as f: with open(cache_file, 'w') as f:
json.dump(result, f, ensure_ascii=False, indent=2) json.dump(result, f, ensure_ascii=False, indent=2)
@@ -597,7 +655,7 @@ def process_pdfs(pdf_paths: list, config: dict, data_dir: Path,
unique_notes = {e['note'] for e in all_events} unique_notes = {e['note'] for e in all_events}
if log: if log:
log(f"{len(unique_notes)} notes uniques à analyser...") log(f"{len(unique_notes)} notes uniques à analyser...")
note_to_canonical = cluster_notes_into_series(unique_notes, config, cache_dir, log) note_to_canonical = cluster_notes_into_series(all_events, config, cache_dir, log)
# ── 3. Scraping site ciblé sur les dates de concerts du PDF ─────────────── # ── 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'])} concert_dates = {e['date'] for e in all_events if is_public_event(e['titre'])}
@@ -605,9 +663,14 @@ def process_pdfs(pdf_paths: list, config: dict, data_dir: Path,
log(f"{len(concert_dates)} dates de concerts identifiées dans le PDF") 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) catalog = scrape_catalog(config, cache_dir, log, target_dates=concert_dates or None)
# ── 4. Correspondance canonique → site (optionnel) ───────────────────────── # ── 4. Correspondance canonique → site par dates ───────────────────────────
canonical_series = set(v for v in note_to_canonical.values() if v) # Dates de concerts du PDF par série canonique
canonical_to_site = match_series_to_catalog(canonical_series, catalog, config, cache_dir, log) series_concert_dates: dict[str, set] = {}
for evt in all_events:
canonical = note_to_canonical.get(evt['note'])
if canonical and is_public_event(evt['titre']):
series_concert_dates.setdefault(canonical, set()).add(evt['date'])
canonical_to_site = match_series_to_catalog(series_concert_dates, catalog, cache_dir, log)
# ── 5. Groupement des événements par série ───────────────────────────────── # ── 5. Groupement des événements par série ─────────────────────────────────
series_events: dict[str, list] = {} series_events: dict[str, list] = {}