From e63000230b949578b03f4ac12e40a79afecb1798 Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 12 Sep 2026 13:33:58 +0200 Subject: [PATCH 1/2] fix(ical): fiabiliser le parsing Pronote 2026 --- .secrets.baseline | 4 +- GUIDE_DEV_PYTHON.md | 14 + docs/fixtures-pronote.md | 16 ++ pronote_sync/models/agenda.py | 1 + pronote_sync/sources/pronote/ical.py | 246 +++++++++++++----- pronote_sync/sync/serialization.py | 4 + tests/fixtures/pronote-variants.ics | 53 ++++ tests/fixtures/pronote-variants.manifest.json | 19 ++ tests/unit/test_ical.py | 90 ++++++- 9 files changed, 368 insertions(+), 79 deletions(-) create mode 100644 docs/fixtures-pronote.md create mode 100644 tests/fixtures/pronote-variants.ics create mode 100644 tests/fixtures/pronote-variants.manifest.json diff --git a/.secrets.baseline b/.secrets.baseline index 24b06df..dfc147d 100644 --- a/.secrets.baseline +++ b/.secrets.baseline @@ -140,7 +140,7 @@ "filename": "GUIDE_DEV_PYTHON.md", "hashed_secret": "90bd1b48e958257948487b90bee080ba5ed00caa", "is_verified": false, - "line_number": 5103 + "line_number": 5117 } ], "tests/unit/test_caldav_gateway.py": [ @@ -185,5 +185,5 @@ } ] }, - "generated_at": "2026-09-11T14:59:15Z" + "generated_at": "2026-09-12T11:33:28Z" } diff --git a/GUIDE_DEV_PYTHON.md b/GUIDE_DEV_PYTHON.md index 37f41f5..5ffb434 100644 --- a/GUIDE_DEV_PYTHON.md +++ b/GUIDE_DEV_PYTHON.md @@ -1893,6 +1893,20 @@ def generate_deterministic_uid( #### 5.1.6 Parsing complet du flux iCal (`sources/pronote/ical.py`) +Le parseur de production accepte les variantes contrôlées des libellés et des +balises `strong` générées par Pronote. Il distingue les catégories `Congés` / +`Vacances` (`HOLIDAY`) des catégories `Jour(s) férié(s)` (`PUBLIC_HOLIDAY`), +conserve la borne `DTEND` comme date exclusive et reconnaît les statuts +annulé, déplacé ou modifié. Les sections de devoirs conservent un HTML +nettoyé (scripts, styles et attributs exécutables supprimés) ainsi qu'un texte +lisible. La déduplication utilise la date, la matière, les enseignants et le +texte normalisé : une copie du même devoir est fusionnée, tandis qu'un devoir +homonyme d'une autre matière ou d'un autre enseignant est conservé. + +Le champ `Partie(s) de classe` est conservé dans `Lesson.class_part` et dans +la description iCalendar sérialisée. Les catégories ou sections inconnues ne +doivent pas être interprétées comme des vacances, une annulation ou un devoir. + ```python List, Optional, Tuple from datetime import datetime, date diff --git a/docs/fixtures-pronote.md b/docs/fixtures-pronote.md new file mode 100644 index 0000000..e544594 --- /dev/null +++ b/docs/fixtures-pronote.md @@ -0,0 +1,16 @@ +# Corpus de fixtures Pronote + +Le corpus versionné de `tests/fixtures/` est limité à des exemples iCalendar +minimaux et anonymisés. Il est rejouable sans connexion Pronote et sert à +vérifier les modèles métier finaux, pas seulement l'absence d'exception. + +Chaque fichier `*.ics` de variante possède un manifeste JSON homonyme. Le +manifeste indique la source, la version connue (ou `unknown`), les scénarios +couverts et les invariants à préserver. Les réponses API Pronote ne sont pas +stockées tant qu'elles ne peuvent pas être réduites sans conserver de données +personnelles ou de secret. + +Avant d'ajouter un export : supprimer les URL, jetons, identifiants, noms, +établissement et contenu personnel ; réduire le document aux propriétés +nécessaires ; exécuter `scripts/check_secrets.py` ; puis ajouter un test qui +vérifie les modèles `Lesson`, `Homework` et `SchoolEvent` obtenus. diff --git a/pronote_sync/models/agenda.py b/pronote_sync/models/agenda.py index 0584d56..7c0028c 100644 --- a/pronote_sync/models/agenda.py +++ b/pronote_sync/models/agenda.py @@ -52,6 +52,7 @@ class Lesson(BaseModel): teachers: tuple[str, ...] = Field(default=(), description="Liste des professeurs") rooms: tuple[str, ...] = Field(default=(), description="Liste des salles") group: str | None = Field(None, description="Groupe (ex: Classe entière)") + class_part: str | None = None status: LessonStatus = Field(default=LessonStatus.NORMAL, description="Statut du cours") content: str | None = Field(None, description="Contenu pédagogique") homework_blocks: tuple[HomeworkBlock, ...] = Field( diff --git a/pronote_sync/sources/pronote/ical.py b/pronote_sync/sources/pronote/ical.py index 3a7e7d5..4e7ecc3 100644 --- a/pronote_sync/sources/pronote/ical.py +++ b/pronote_sync/sources/pronote/ical.py @@ -14,13 +14,15 @@ from __future__ import annotations import hashlib import re +import unicodedata import urllib.parse from datetime import date, datetime from html import unescape from pathlib import Path -from typing import TypedDict +from typing import NamedTuple, TypedDict import requests +from bs4 import BeautifulSoup from icalendar import Calendar from ...models.agenda import ( @@ -34,21 +36,13 @@ from ...models.homework import Homework from ...utils.redaction import redact_exception, redact_url from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid -_HEADER_LABEL_PATTERN = re.compile(r"\b(Matière|Professeurs?|Salles?|Groupe)\s*:\s*") +_HEADER_LABEL_PATTERN = re.compile( + r"(?P