Files
college-infos/pronote_sync/sources/pronote/ical.py
T

572 lines
22 KiB
Python

"""Récupération et parsing du flux iCal Pronote.
Ce module fournit le téléchargement du flux iCal Pronote (via HTTP ou
``file://`` pour les tests) ainsi que son parsing en modèles : cours
(:class:`~pronote_sync.models.agenda.Lesson`), événements scolaires
(:class:`~pronote_sync.models.agenda.SchoolEvent`) et devoirs
(:class:`~pronote_sync.models.homework.Homework`).
La collecte finale des devoirs est réalisée à part, une fois la date
cible connue, via :func:`collect_homeworks`.
"""
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 NamedTuple, TypedDict
import requests
from bs4 import BeautifulSoup
from icalendar import Calendar
from ...models.agenda import (
HomeworkBlock,
Lesson,
LessonStatus,
SchoolEvent,
SchoolEventKind,
)
from ...models.homework import Homework
from ...utils.redaction import redact_exception, redact_url
from ...utils.uid import generate_deterministic_uid, normalize_pronote_uid
from .lessons import collapse_replaced_lessons
_HEADER_LABEL_PATTERN = re.compile(
r"(?P<label>Mati(?:ère|ere)|Professeur(?:s|\(s\))?|Salle(?:s|\(s\))?"
r"|Groupe|Partie(?:s|\(s\))?\s+de\s+classe)\s*:\s*",
re.IGNORECASE,
)
_CALNAME_PATTERN = re.compile(r"^X-WR-CALNAME(?:;[^:]*)?:([^\r\n]*)", re.MULTILINE)
_STRONG_PATTERN = re.compile(r"<strong\b[^>]*>(?P<label>.*?)</strong>", re.IGNORECASE | re.DOTALL)
_HEADERS = {
"accept": "text/calendar",
"user-agent": "pronote-sync",
}
class HeaderInfo(TypedDict):
"""Métadonnées du cours extraites de l'en-tête de la DESCRIPTION."""
subject: str
teachers: list[str]
rooms: list[str]
group: str | None
class_part: str | None
class ParsedHomeworkBlock(NamedTuple):
"""Bloc de devoir parsé avec son texte nettoyé et son HTML sûr."""
date: date
text: str
html: str
def fetch_ical(url: str, timeout: int = 20) -> str:
"""Récupère le contenu brut d'un flux iCal Pronote.
Gère les URLs ``file://`` pour les tests locaux (le chemin est
décodé de l'échappement URI, ex. ``%20`` → espace) et valide que
le flux commence bien par ``BEGIN:VCALENDAR``. Toutes les erreurs
sont relancées avec un message dont les secrets (token
``icalsecurise``) sont masqués.
:param url: URL du flux iCal (avec token ``icalsecurise``) ou chemin ``file://``.
:param timeout: Timeout HTTP en secondes (défaut : 20).
:return: Contenu brut du flux iCal.
:rtype: str
:raises OSError: Si le fichier local ``file://`` est illisible.
:raises requests.RequestException: Si la récupération HTTP échoue.
:raises ValueError: Si le flux ne commence pas par ``BEGIN:VCALENDAR``.
"""
if url.startswith("file://"):
parsed_url = urllib.parse.urlparse(url)
path = Path(urllib.parse.unquote(parsed_url.path))
try:
content = path.read_text(encoding="utf-8")
except OSError as exc:
raise OSError(
f"Impossible de lire le fichier iCal {redact_url(url)} : {redact_exception(exc)}"
) from exc
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
raise ValueError(f"Fichier iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
return content
try:
response = requests.get(url, headers=_HEADERS, timeout=timeout)
response.raise_for_status()
content = response.text
except Exception as exc:
raise requests.RequestException(
f"Échec de la récupération du flux iCal {redact_url(url)} : {redact_exception(exc)}"
) from exc
if not content.lstrip().startswith("BEGIN:VCALENDAR"):
raise ValueError(f"Flux iCal invalide (pas de BEGIN:VCALENDAR) : {redact_url(url)}")
return str(content)
def _unfold_ical(raw_ical: str) -> str:
"""Déplie les lignes de continuation iCalendar.
Une ligne commençant par un espace ou une tabulation prolonge la
ligne précédente : le caractère d'espacement initial est retiré et
la suite est jointe à la ligne précédente.
:param raw_ical: Contenu brut du flux iCal.
:return: Contenu avec les lignes de continuation dépliées.
:rtype: str
"""
unfolded: list[str] = []
for line in raw_ical.splitlines():
if line.startswith((" ", "\t")) and unfolded:
unfolded[-1] += line[1:]
else:
unfolded.append(line)
return "\n".join(unfolded)
def get_calendar_name(raw_ical: str) -> str | None:
"""Extrait le nom du calendrier depuis la propriété ``X-WR-CALNAME``.
La propriété peut comporter des paramètres (par exemple
``X-WR-CALNAME;LANGUAGE=fr:Nom``) et les lignes de continuation
iCalendar sont dépliées avant la recherche.
:param raw_ical: Contenu brut du flux iCal.
:return: Nom du calendrier ou ``None`` si la propriété est absente.
:rtype: str | None
"""
match = _CALNAME_PATTERN.search(_unfold_ical(raw_ical))
if match is None:
return None
return match.group(1).strip()
def split_header_and_body(description: str) -> tuple[str, str]:
"""Sépare l'en-tête texte du corps HTML dans la DESCRIPTION.
L'en-tête est la partie avant le premier ``<strong>``, le corps
HTML commence à partir du premier ``<strong>``.
:param description: Contenu brut de la DESCRIPTION d'un VEVENT.
:return: Tuple ``(en-tête, corps)`` ; le corps est vide si aucun ``<strong>``.
:rtype: tuple[str, str]
"""
strong_match = _STRONG_PATTERN.search(description)
if strong_match is None:
return description.strip(), ""
header = description[: strong_match.start()].strip()
body = description[strong_match.start() :]
return header, body
def parse_header(header: str) -> HeaderInfo:
"""Parse l'en-tête texte pour extraire les métadonnées du cours.
Les labels reconnus sont : ``Matière :``, ``Professeur(s) :``,
``Salle(s) :``, ``Groupe :`` et ``Partie(s) de classe :``. La recherche se fait par position
des labels, ce qui supporte aussi bien un en-tête multi-lignes
qu'un en-tête dont les lignes sont jointes sur une seule ligne.
:param header: En-tête texte (avant le premier ``<strong>``).
:return: Dictionnaire typé avec les champs subject, teachers, rooms, group.
:rtype: HeaderInfo
"""
info: HeaderInfo = {
"subject": "",
"teachers": [],
"rooms": [],
"group": None,
"class_part": None,
}
header_text = _strip_html(header)
matches = list(_HEADER_LABEL_PATTERN.finditer(header_text))
for index, match in enumerate(matches):
value_start = match.end()
value_end = matches[index + 1].start() if index + 1 < len(matches) else len(header_text)
value = unescape(header_text[value_start:value_end].strip())
label = _normalize_label(match.group("label")).replace("(s)", "s")
if label == "matiere":
info["subject"] = value
elif label in ("professeur", "professeurs"):
info["teachers"] = [part.strip() for part in value.split(",") if part.strip()]
elif label in ("salle", "salles"):
info["rooms"] = [part.strip() for part in value.split(",") if part.strip()]
elif label == "groupe":
info["group"] = value
elif label in ("partie de classe", "parties de classe"):
info["class_part"] = value
return info
def _normalize_label(value: str) -> str:
"""Normalise un libellé iCal pour comparer des variantes contrôlées.
:param value: Libellé à normaliser.
:return: Libellé minuscule sans accents et avec des espaces unifiés.
:rtype: str
"""
decomposed = unicodedata.normalize("NFKD", value)
without_accents = "".join(char for char in decomposed if not unicodedata.combining(char))
return re.sub(r"\s+", " ", without_accents).strip().lower()
def _sanitize_html(fragment: str) -> str:
"""Nettoie un fragment HTML de description sans exécuter de contenu.
:param fragment: Fragment HTML extrait d'une section de devoir.
:return: HTML conservé sans scripts, styles ni attributs exécutables.
:rtype: str
"""
soup = BeautifulSoup(fragment, "html.parser")
for tag in soup.find_all(("script", "style")):
tag.decompose()
for tag in soup.find_all(True):
for attribute in list(tag.attrs):
lowered = attribute.lower()
value = tag.attrs[attribute]
if lowered.startswith("on") or (
lowered in ("href", "src") and str(value).lower().strip().startswith("javascript:")
):
del tag.attrs[attribute]
return soup.decode_contents().strip()
def _strip_html(text: str) -> str:
"""Retire les balises HTML d'un texte et nettoie les espaces.
:param text: Texte pouvant contenir des balises HTML.
:return: Texte brut sans balises, entités HTML décodées.
:rtype: str
"""
soup = BeautifulSoup(text, "html.parser")
for tag in soup.find_all(("script", "style")):
tag.decompose()
return " ".join(unescape(soup.get_text(" ", strip=True)).split())
def _parse_french_date(value: str) -> date | None:
"""Convertit une date au format ``JJ/MM/AAAA`` en :class:`date`.
:param value: Date au format ``JJ/MM/AAAA``.
:return: Date parsée, ou ``None`` si le format est invalide.
:rtype: date | None
"""
try:
return datetime.strptime(value, "%d/%m/%Y").date()
except ValueError:
return None
def parse_body(
body: str,
) -> tuple[str | None, list[ParsedHomeworkBlock], list[ParsedHomeworkBlock]]:
"""Parse le corps HTML pour extraire contenu pédagogique et devoirs.
Le contenu est extrait de la section ``<strong>Contenu pédagogique :</strong>``.
Les devoirs à faire sont extraits des sections ``<strong>Pour le JJ/MM/AAAA :</strong>``
et les devoirs donnés des sections ``<strong>Donné le JJ/MM/AAAA :</strong>``.
Les listes préservent tous les blocs, même lorsque plusieurs sections partagent
la même date, avec le texte nettoyé et le HTML sûr de chaque bloc.
:param body: Corps HTML (à partir du premier ``<strong>``).
:return: Tuple ``(contenu pédagogique, devoirs dus, devoirs donnés)``.
:rtype: tuple[str | None, list[ParsedHomeworkBlock], list[ParsedHomeworkBlock]]
"""
content: str | None = None
due_blocks: list[ParsedHomeworkBlock] = []
assigned_blocks: list[ParsedHomeworkBlock] = []
matches = list(_STRONG_PATTERN.finditer(body))
for index, match in enumerate(matches):
next_start = matches[index + 1].start() if index + 1 < len(matches) else len(body)
heading = _strip_html(match.group("label")).rstrip(":").strip()
fragment = body[match.end() : next_start]
safe_html = _sanitize_html(fragment)
text = _strip_html(fragment)
normalized_heading = _normalize_label(heading)
if normalized_heading == "contenu pedagogique":
content = text
continue
due_match = re.fullmatch(r"Pour\s+le\s+(\d{2}/\d{2}/\d{4})", heading, re.IGNORECASE)
assigned_match = re.fullmatch(
r"Donne\s+le\s+(\d{2}/\d{2}/\d{4})", normalized_heading, re.IGNORECASE
)
if due_match is not None:
due_date = _parse_french_date(due_match.group(1))
if due_date is not None:
due_blocks.append(ParsedHomeworkBlock(due_date, text, safe_html))
elif assigned_match is not None:
assigned_date = _parse_french_date(assigned_match.group(1))
if assigned_date is not None:
assigned_blocks.append(ParsedHomeworkBlock(assigned_date, text, safe_html))
return content, due_blocks, assigned_blocks
def parse_homework_blocks(
due_blocks: list[ParsedHomeworkBlock],
assigned_blocks: list[ParsedHomeworkBlock],
) -> tuple[HomeworkBlock, ...]:
"""Construit les :class:`HomeworkBlock` depuis les listes de devoirs.
Les blocs dus (``kind="due"``) précèdent les blocs donnés
(``kind="assigned"``), dans l'ordre des listes. Tous les blocs
sont préservés, y compris lorsque plusieurs partagent la même date.
:param due_blocks: Liste de tuples ``(date, texte)`` des devoirs à faire.
:param assigned_blocks: Liste de tuples ``(date, texte)`` des devoirs donnés.
:return: Tuple de blocs de devoirs pour le cours.
:rtype: tuple[HomeworkBlock, ...]
"""
blocks: list[HomeworkBlock] = []
for block in due_blocks:
blocks.append(HomeworkBlock(kind="due", date=block.date, text=block.text, html=block.html))
for block in assigned_blocks:
blocks.append(
HomeworkBlock(kind="assigned", date=block.date, text=block.text, html=block.html)
)
return tuple(blocks)
def normalize_homework_text(text: str) -> str:
"""Normalise le texte d'un devoir pour la déduplication.
Unifie les espaces multiples, supprime les balises HTML, puis
applique trim et minuscules.
:param text: Texte brut du devoir.
:return: Texte normalisé.
:rtype: str
"""
return _strip_html(text).casefold()
def generate_homework_id(
due_on: date,
normalized_text: str,
subject: str = "",
teachers: tuple[str, ...] = (),
) -> str:
"""Génère un ID stable pour un devoir.
L'ID est la clé ``AAAA-MM-JJ|matière|enseignants|texte_normalisé`` hachée en SHA-1 dont
on garde les 12 premiers caractères hexadécimaux. Le hachage n'est
pas utilisé à des fins de sécurité (``usedforsecurity=False``).
:param due_on: Date d'échéance du devoir.
:param normalized_text: Texte normalisé du devoir.
:param subject: Matière du devoir, utile pour distinguer les homonymes.
:param teachers: Enseignants du devoir, triés pour garantir la stabilité.
:return: ID stable (12 caractères hexadécimaux).
:rtype: str
"""
payload = (
f"{due_on.isoformat()}|{subject}|{','.join(sorted(teachers))}|{normalized_text}".encode()
)
return hashlib.sha1(payload, usedforsecurity=False).hexdigest()[:12]
def collect_homeworks(lessons: list[Lesson], target_date: date) -> list[Homework]:
"""Collecte et déduplique les devoirs en deux passes globales.
Passe 1 : les blocs ``due`` (devoirs à faire pour ``target_date``)
de tous les cours. Passe 2 : les blocs ``assigned`` (devoirs donnés
le jour cible) des cours du jour ``target_date``. La déduplication
se fait par texte, matière et enseignants normalisés (premier venu, premier servi) et le
résultat est trié par matière puis texte.
:param lessons: Liste de tous les cours (VEVENT) parsés.
:param target_date: Date cible pour laquelle collecter les devoirs.
:return: Liste unique de devoirs, triée par matière puis texte.
:rtype: list[Homework]
"""
by_context: dict[tuple[str, tuple[str, ...], str], Homework] = {}
for lesson in lessons:
for block in lesson.homework_blocks:
if block.kind == "due" and block.date == target_date:
normalized_text = normalize_homework_text(block.text)
key = (
lesson.subject.casefold(),
tuple(teacher.casefold() for teacher in lesson.teachers),
normalized_text,
)
if key not in by_context:
by_context[key] = Homework(
id=generate_homework_id(
target_date, normalized_text, lesson.subject, lesson.teachers
),
subject=lesson.subject,
teachers=lesson.teachers,
assigned_on=lesson.start.date(),
due_on=block.date,
text=block.text,
html=block.html,
)
for lesson in lessons:
if lesson.start.date() != target_date:
continue
for block in lesson.homework_blocks:
if block.kind == "assigned":
normalized_text = normalize_homework_text(block.text)
key = (
lesson.subject.casefold(),
tuple(teacher.casefold() for teacher in lesson.teachers),
normalized_text,
)
if key not in by_context:
by_context[key] = Homework(
id=generate_homework_id(
target_date, normalized_text, lesson.subject, lesson.teachers
),
subject=lesson.subject,
teachers=lesson.teachers,
assigned_on=block.date,
due_on=target_date,
text=block.text,
html=block.html,
)
return sorted(by_context.values(), key=lambda hw: (hw.subject.lower(), hw.text.lower()))
def parse_ical(raw_ical: str) -> tuple[list[Lesson], list[Homework], list[SchoolEvent]]:
"""Parse un flux iCal Pronote en événements typés.
Les VEVENT de vacances/congés (tout le jour) deviennent des
:class:`SchoolEvent` de type ``holiday``. Les VEVENT horodatés
deviennent des :class:`Lesson` dont le statut dérive de la propriété
``STATUS`` (``CANCELLED`` → ``CANCELLED``) et de la catégorie
(``Cours - Cours annulé`` → ``CANCELLED``, ``Cours - Cours déplacé`` →
``MOVED``). Les UID sont normalisés ;
un événement sans UID reçoit un UID déterministe généré à partir
de ses champs clés (début, fin, matière, enseignants, salles, groupe).
:param raw_ical: Contenu brut du flux iCal.
:return: Tuple ``(lessons, homeworks, school_events)`` où ``homeworks``
est **toujours vide** : la collecte/déduplication se fait plus tard
via :func:`collect_homeworks` une fois la date cible connue.
:rtype: tuple[list[Lesson], list[Homework], list[SchoolEvent]]
"""
calendar = Calendar.from_ical(raw_ical)
lessons: list[Lesson] = []
homeworks: list[Homework] = []
school_events: list[SchoolEvent] = []
for component in calendar.walk():
if component.name != "VEVENT":
continue
dtstart = component.get("dtstart")
dtend = component.get("dtend")
if dtstart is None or dtend is None:
continue
start = dtstart.dt
end = dtend.dt
categories_obj = component.get("categories")
if categories_obj is None:
categories: list[str] = []
else:
categories = [str(category) for category in categories_obj.cats]
normalized_categories = [_normalize_label(category) for category in categories]
holiday_kind: SchoolEventKind | None = None
if any(
category in ("conges", "vacances", "vacances scolaires")
for category in normalized_categories
):
holiday_kind = SchoolEventKind.HOLIDAY
elif any(
category in ("jour ferie", "jours feries", "ferie", "feries")
for category in normalized_categories
):
holiday_kind = SchoolEventKind.PUBLIC_HOLIDAY
# Événements de type vacances/congés/jours fériés (tout le jour).
if holiday_kind is not None:
from_date = start.date() if isinstance(start, datetime) else start
to_date = end.date() if isinstance(end, datetime) else end
summary = component.get("summary")
label = str(summary) if summary is not None else ""
school_events.append(
SchoolEvent(
kind=holiday_kind,
label=label,
from_date=from_date,
to_date=to_date,
)
)
continue
# Les cours sont des événements horodatés ; les événements tout
# le jour non scolaires sont ignorés.
if not isinstance(start, datetime) or not isinstance(end, datetime):
continue
status_obj = component.get("status")
status_value = str(status_obj).strip().upper() if status_obj is not None else ""
normalized_status_categories = set(normalized_categories)
if status_value == "CANCELLED" or any(
"annul" in category for category in normalized_status_categories
):
status = LessonStatus.CANCELLED
elif any(
any(token in category for token in ("deplac", "changement de salle", "modifi"))
for category in normalized_status_categories
):
status = LessonStatus.MOVED
else:
status = LessonStatus.NORMAL
description = component.get("description")
description_str = str(description) if description is not None else ""
header, body = split_header_and_body(description_str)
lesson_data = parse_header(header)
content, due_blocks, assigned_blocks = parse_body(body)
homework_blocks = parse_homework_blocks(due_blocks, assigned_blocks)
uid_value = component.get("uid")
if uid_value is None or str(uid_value).strip() == "":
uid = generate_deterministic_uid(
start=start,
end=end,
subject=lesson_data["subject"],
teachers=lesson_data["teachers"],
rooms=lesson_data["rooms"],
group=lesson_data["group"],
)
else:
uid = normalize_pronote_uid(str(uid_value))
lessons.append(
Lesson(
id=uid,
start=start,
end=end,
subject=lesson_data["subject"],
teachers=tuple(lesson_data["teachers"]),
rooms=tuple(lesson_data["rooms"]),
group=lesson_data["group"],
class_part=lesson_data["class_part"],
status=status,
content=content,
homework_blocks=homework_blocks,
)
)
return collapse_replaced_lessons(lessons), homeworks, school_events