feat(M5): source blog RSS — fetch, parsing, déduplication et état persistant

Implémentation complète de la source blog RSS du collège :
- BlogRSSClient (sources/blog/rss.py) : client sans état récupérant et
  parsant le flux via feedparser, avec déduplication par ensemble de
  GUIDs connus, cache HTTP conditionnel (ETag/Last-Modified), conversion
  HTML→texte (BeautifulSoup), tri déterministe (date desc puis id asc),
  et mode dégradé (flux invalide/erreur → warning expurgé + liste vide).
- BlogRSSFetchResult (sources/blog/result.py) : résultat immuable
  contenant articles, en-têtes de cache et indicateur not_modified.
- BlogRSSState (sources/blog/state.py) : persistance JSON tolérante
  (GUIDs triés, version, ETag, Last-Modified) avec redaction des chemins
  dans les logs.
- Fixture tests/fixtures/blog_rss.xml : flux RSS 2.0 anonymisé, 3
  articles, dates fixes, ordre non chronologique.
- 38 tests unitaires (22 client + 16 state) couvrant parsing nominal,
  déduplication intra-flux, 304, bozo, erreurs réseau, non-fuite de
  secrets, tri secondaire, persistance d'état et tolérance aux fichiers
  corrompus.
- Documentation : TODO.md M5 coché, GUIDE_DEV_PYTHON.md §5 bis aligné
  avec l'API livrée (known_guids, BlogRSSFetchResult, BlogRSSState).
- Configuration : feedparser ajouté aux additional_dependencies du hook
  mypy pre-commit pour aligner l'environnement isolé avec le .venv.

Co-authored-by: opencode/coder <coder@agents.invalid>
Co-authored-by: opencode/test-engineer <test-engineer@agents.invalid>
This commit is contained in:
2026-09-06 20:23:49 +02:00
parent 2c935ef648
commit 6d1a7a649f
11 changed files with 1895 additions and 161 deletions

View File

@@ -791,15 +791,70 @@ class PronoteData(BaseModel):
#### 5 bis.7.1 Client RSS (`sources/blog/rss.py`)
Le résultat d'une récupération est un modèle Pydantic figé, `BlogRSSFetchResult`
(module `sources/blog/result.py`) :
```python
import feedparser
from datetime import datetime, timezone
from typing import List, Optional
from html import unescape
from bs4 import BeautifulSoup
from pydantic import BaseModel, ConfigDict, Field
from ..models.blog import BlogArticle
from ..utils.redaction import redact_url
class BlogRSSFetchResult(BaseModel):
"""
Résultat d'une récupération du flux RSS du blog du collège.
Modèle figé (``frozen``) : les instances sont immuables après création.
"""
model_config = ConfigDict(frozen=True)
articles: tuple[BlogArticle, ...] = Field(
default=(),
description=(
"Nouveaux articles absents de known_guids, triés par date de "
"publication décroissante puis par identifiant croissant"
),
)
etag: str | None = Field(
default=None,
description="Valeur de l'en-tête ETag de la réponse RSS, si disponible",
)
last_modified: str | None = Field(
default=None,
description="Valeur de l'en-tête Last-Modified de la réponse RSS, si disponible",
)
not_modified: bool = Field(
default=False,
description="Vaut True si le serveur a répondu 304 Not Modified",
)
```
**Attributs** :
- `articles` : nouveaux articles absents de `known_guids`, triés par date de
publication décroissante puis par identifiant croissant. Tuple vide si aucun
nouvel article (ou en cas de réponse `304 Not Modified`).
- `etag` : valeur de l'en-tête `ETag` de la réponse RSS, ou `None` si
indisponible.
- `last_modified` : valeur de l'en-tête `Last-Modified` de la réponse RSS, ou
`None` si indisponible.
- `not_modified` : vaut `True` si le serveur a répondu `304 Not Modified`,
`False` sinon.
Client de récupération et de parsing du flux (`sources/blog/rss.py`) :
```python
import logging
import re
from datetime import UTC, datetime
from html import unescape
import feedparser
from bs4 import BeautifulSoup
from ..models.blog import BlogArticle
from ..sources.blog.result import BlogRSSFetchResult
from ..utils.redaction import redact_url
logger = logging.getLogger(__name__)
@@ -809,119 +864,163 @@ class BlogRSSClient:
Client pour récupérer et parser le flux RSS du blog du collège.
"""
def __init__(
self,
rss_url: str = "https://blogpeda.ac-bordeaux.fr/cjeliote/?feed=rss2",
timeout: int = 20,
):
def __init__(self, rss_url: str, timeout: int = 20):
self.rss_url = rss_url
self.timeout = timeout
def fetch_and_parse(self, known_guids: Optional[set] = None) -> List[BlogArticle]:
def fetch_and_parse(
self,
*,
known_guids: frozenset[str] | None = None,
etag: str | None = None,
last_modified: str | None = None,
) -> BlogRSSFetchResult:
"""
Récupère le flux RSS et parse les nouveaux articles.
Args:
known_guids: Ensemble des GUID déjà connus (pour la déduplication). Si None, retourne tous les articles.
known_guids: Ensemble des GUID d'articles déjà traités (pour la déduplication).
Si None, retourne tous les articles.
etag: Valeur de l'en-tête ``ETag`` mémorisée pour la requête conditionnelle, ou None.
last_modified: Valeur de l'en-tête ``Last-Modified`` mémorisée pour la requête
conditionnelle, ou None.
Returns:
Liste des nouveaux articles (triés par date de publication décroissante).
Résultat de la récupération : nouveaux articles (triés par date de publication
décroissante puis par identifiant croissant), en-têtes de cache reçus et
indicateur ``304 Not Modified``.
"""
try:
# Récupération du flux avec cache HTTP (géré par feedparser)
# Récupération du flux avec requête conditionnelle (ETag / Last-Modified)
feed = feedparser.parse(
self.rss_url,
etag=etag,
modified=last_modified,
request_timeout=self.timeout,
etag=None, # Géré automatiquement par feedparser
modified=None,
)
# Vérifier que le flux est valide
if feed.bozo:
raise ValueError(f"Flux RSS invalide: {feed.bozo_exception}")
# Réponse 304 Not Modified : rien n'a changé, on restitue les en-têtes mémorisés
if getattr(feed, "status", None) == 304:
return BlogRSSFetchResult(
articles=(),
etag=etag,
last_modified=last_modified,
not_modified=True,
)
articles = []
for entry in feed.entries:
# Extraire le GUID (utiliser link si guid est vide)
guid = getattr(entry, "guid", None) or entry.link
response_etag: str | None = getattr(feed, "etag", None)
response_last_modified: str | None = getattr(feed, "modified", None)
# Ignorer les articles déjà connus
if known_guids and guid in known_guids:
# Flux invalide (erreur HTTP, XML malformé, etc.) : résultat vide, sans erreur
if getattr(feed, "bozo", None):
logger.warning(
"Flux RSS du blog invalide, ignoré : %s",
redact_url(self.rss_url),
)
return BlogRSSFetchResult(
articles=(),
etag=response_etag,
last_modified=response_last_modified,
not_modified=False,
)
articles: list[BlogArticle] = []
seen_guids: set[str] = set(known_guids) if known_guids is not None else set()
for entry in getattr(feed, "entries", []):
# Extraire le GUID (utiliser link si le GUID est vide)
guid = str(entry.get("id") or entry.get("link") or "")
# Ignorer les articles déjà connus ou en double dans le flux
if not guid or guid in seen_guids:
continue
# Parser la date de publication (RFC 822 ou ISO 8601)
published_at = self._parse_date(
getattr(entry, "published_parsed", None)
or getattr(entry, "pubdate_parsed", None)
entry.get("published_parsed") or entry.get("pubdate_parsed")
)
if published_at is None:
continue
# Parser la date de mise à jour (si disponible)
updated_at = self._parse_date(
getattr(entry, "updated_parsed", None)
)
updated_at = self._parse_date(entry.get("updated_parsed"))
# Extraire le contenu HTML (content:encoded ou description)
content_html = ""
if hasattr(entry, "content") and entry.content:
content_html = entry.content[0].value
elif hasattr(entry, "description"):
content_html = entry.description
raw_content = entry.get("content")
if raw_content:
content_html = str(raw_content[0].get("value") or "")
else:
content_html = str(entry.get("description") or "")
# Convertir le HTML en texte brut
content_text = self._html_to_text(content_html)
# Extraire la catégorie (tags ou champ category)
tags = entry.get("tags")
category_value = tags[0].get("term") if tags else None
if not category_value:
category_value = entry.get("category")
category = str(category_value) if category_value else None
# Créer l'article
article = BlogArticle(
id=guid,
title=entry.title,
url=entry.link,
published_at=published_at,
updated_at=updated_at,
category=getattr(entry, "category", None),
author=getattr(entry, "author", None),
content_html=content_html,
content_text=content_text,
articles.append(
BlogArticle(
id=guid,
title=str(entry.get("title") or guid),
url=str(entry.get("link") or guid),
published_at=published_at,
updated_at=updated_at,
category=category,
author=str(entry.get("author")) if entry.get("author") else None,
content_html=content_html,
content_text=self._html_to_text(content_html),
)
)
articles.append(article)
seen_guids.add(guid)
# Trier par date de publication décroissante
articles.sort(key=lambda a: a.published_at, reverse=True)
return articles
# Tri stable : d'abord par date de publication décroissante, puis par identifiant croissant
articles.sort(key=lambda article: article.id)
articles.sort(key=lambda article: article.published_at, reverse=True)
return BlogRSSFetchResult(
articles=tuple(articles),
etag=response_etag,
last_modified=response_last_modified,
not_modified=False,
)
except Exception as e:
safe_url = redact_url(self.rss_url)
logger.error(f"Échec de la récupération du flux RSS {safe_url}: {e}")
return []
return BlogRSSFetchResult(articles=(), not_modified=False)
def _parse_date(self, date_tuple: Optional[tuple]) -> datetime:
@staticmethod
def _parse_date(date_tuple: tuple[int, ...] | None) -> datetime | None:
"""
Convertit un tuple de date (RFC 822 ou ISO 8601) en datetime UTC.
Args:
date_tuple: Tuple retourné par feedparser (ex: (2026, 8, 10, 9, 0, 11, 0, 1, -1)).
date_tuple: Tuple retourné par feedparser (ex: (2026, 8, 10, 9, 0, 11, 0, 1, -1)),
ou None si absent.
Returns:
datetime en UTC.
datetime en UTC, ou None si le tuple est absent, vide ou invalide.
"""
if not date_tuple:
return datetime.now(timezone.utc)
return None
# feedparser retourne un tuple struct_time (année, mois, jour, heure, minute, seconde, jour_semaine, jour_année, DST)
try:
dt = datetime(
return datetime(
date_tuple[0], # année
date_tuple[1], # mois
date_tuple[2], # jour
date_tuple[3], # heure
date_tuple[4], # minute
date_tuple[5], # seconde
tzinfo=timezone.utc,
tzinfo=UTC,
)
return dt
except (ValueError, IndexError):
return datetime.now(timezone.utc)
return None
def _html_to_text(self, html: str) -> str:
@staticmethod
def _html_to_text(html: str) -> str:
"""
Convertit du HTML en texte brut (supprime les balises, décode les entités).
@@ -942,32 +1041,51 @@ class BlogRSSClient:
text = unescape(text)
# Nettoyer les espaces multiples
import re
text = re.sub(r"\s+", " ", text).strip()
return text
```
#### 5 bis.7.2 Déduplication et état local
La déduplication des articles du blog repose sur leur **GUID** (ou leur URL si le GUID est vide).
**Stratégie** :
1. Stocker le **dernier GUID traité** dans un fichier d'état local (ex: `.blog_rss_state.json`).
2. À chaque récupération, ignorer les articles dont le GUID est **antérieur ou égal** au dernier GUID connu.
3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour éviter les requêtes inutiles.
1. Stocker un **fichier d'état local** (ex: `.blog_rss_state.json`) contenant la version du
format, l'**ensemble des GUID déjà traités** et les en-têtes de cache HTTP (`ETag` /
`Last-Modified`) de la dernière réponse.
2. À chaque récupération, ignorer les articles dont le GUID est **déjà présent** dans
l'ensemble des GUID connus.
3. Utiliser le **cache HTTP** (`If-Modified-Since` / `If-None-Match`) via `feedparser` pour
éviter les requêtes inutiles.
**Exemple de fichier d'état** (`sync/blog_state.py`) :
**Exemple de fichier d'état** :
```json
{
"version": 1,
"known_guids": [
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625",
"https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
],
"etag": "abc123",
"last_modified": "Wed, 01 Sep 2026 00:00:00 GMT"
}
```
Les GUID sont triés alphabétiquement pour une sortie JSON déterministe.
**Gestionnaire d'état** (`sources/blog/state.py`) :
```python
import json
from pathlib import Path
from typing import Optional
from ..models.blog import BlogArticle
import logging
from collections.abc import Iterable
from pathlib import Path
logger = logging.getLogger(__name__)
_STATE_VERSION = 1
class BlogRSSState:
"""
@@ -975,64 +1093,77 @@ class BlogRSSState:
"""
def __init__(self, state_file: str = ".blog_rss_state.json"):
self.state_file = Path(state_file)
self._known_guids: set = set()
self._etag: Optional[str] = None
self._last_modified: Optional[str] = None
self._state_file = Path(state_file)
self._known_guids: set[str] = set()
self._etag: str | None = None
self._last_modified: str | None = None
self._load()
def _load(self) -> None:
"""Charge l'état depuis le fichier."""
if self.state_file.exists():
try:
with open(self.state_file, "r", encoding="utf-8") as f:
state = json.load(f)
self._known_guids = set(state.get("known_guids", []))
self._etag = state.get("etag")
self._last_modified = state.get("last_modified")
except Exception as e:
logger.warning(f"Échec du chargement de l'état du blog: {e}")
self._known_guids = set()
self._etag = None
self._last_modified = None
if not self._state_file.exists():
return
try:
data = json.loads(self._state_file.read_text(encoding="utf-8"))
if not isinstance(data, dict) or data.get("version") != _STATE_VERSION:
logger.warning(
"Fichier d'état blog RSS : version absente ou non supportée, "
"démarrage avec un état vide."
)
return
guids_data = data.get("known_guids", [])
if isinstance(guids_data, list):
self._known_guids = {guid for guid in guids_data if isinstance(guid, str)}
etag_data = data.get("etag")
if isinstance(etag_data, str):
self._etag = etag_data
last_modified_data = data.get("last_modified")
if isinstance(last_modified_data, str):
self._last_modified = last_modified_data
except Exception as e:
logger.warning(f"Échec du chargement de l'état du blog: {e}")
self._known_guids = set()
self._etag = None
self._last_modified = None
def _save(self) -> None:
"""Sauvegarde l'état dans le fichier."""
payload = {
"version": _STATE_VERSION,
"known_guids": sorted(self._known_guids),
"etag": self._etag,
"last_modified": self._last_modified,
}
try:
with open(self.state_file, "w", encoding="utf-8") as f:
json.dump({
"known_guids": list(self._known_guids),
"etag": self._etag,
"last_modified": self._last_modified
}, f, indent=2)
with self._state_file.open("w", encoding="utf-8") as f:
json.dump(payload, f, indent=2)
except Exception as e:
logger.error(f"Échec de la sauvegarde de l'état du blog: {e}")
def get_known_guids(self) -> set:
"""Retourne l'ensemble des GUID connus."""
return self._known_guids.copy()
def get_known_guids(self) -> frozenset[str]:
"""Retourne une copie immuable des GUID connus."""
return frozenset(self._known_guids)
def add_guid(self, guid: str) -> None:
"""Ajoute un GUID à l'ensemble des GUID connus."""
self._known_guids.add(guid)
def add_guids(self, guids: Iterable[str]) -> None:
"""Ajoute des GUID à l'ensemble des GUID connus et sauvegarde."""
new_guids = set(guids)
if not new_guids:
return
self._known_guids.update(new_guids)
self._save()
def get_etag(self) -> Optional[str]:
"""Retourne l'ETag du dernier flux RSS."""
return self._etag
def get_cache_headers(self) -> tuple[str | None, str | None]:
"""Retourne les en-têtes de cache HTTP mémorisés (etag, last_modified)."""
return self._etag, self._last_modified
def get_last_modified(self) -> Optional[str]:
"""Retourne la date de dernière modification du flux RSS."""
return self._last_modified
def update_cache_headers(self, etag: Optional[str], last_modified: Optional[str]) -> None:
"""Met à jour les en-têtes de cache HTTP."""
def update_cache_headers(self, etag: str | None, last_modified: str | None) -> None:
"""Met à jour les en-têtes de cache HTTP et sauvegarde."""
self._etag = etag
self._last_modified = last_modified
self._save()
def clear(self) -> None:
"""Efface l'état."""
"""Efface l'état (GUID et en-têtes de cache) et sauvegarde."""
self._known_guids = set()
self._etag = None
self._last_modified = None
@@ -1047,11 +1178,16 @@ blog_state = BlogRSSState()
# Récupération des nouveaux articles
known_guids = blog_state.get_known_guids()
new_articles = rss_client.fetch_and_parse(known_guids=known_guids)
etag, last_modified = blog_state.get_cache_headers()
result = rss_client.fetch_and_parse(
known_guids=known_guids,
etag=etag,
last_modified=last_modified,
)
# Mise à jour de l'état avec les nouveaux GUID
for article in new_articles:
blog_state.add_guid(article.id)
# Mise à jour de l'état avec les nouveaux GUID et les en-têtes de cache
blog_state.add_guids(article.id for article in result.articles)
blog_state.update_cache_headers(result.etag, result.last_modified)
```
---
@@ -1063,9 +1199,8 @@ for article in new_articles:
```python
from typing import List
from ..models.blog import BlogArticle
from ..models.external import ExternalInfo
from ..sources.blog.rss import BlogRSSClient
from ..sync.blog_state import BlogRSSState
from ..sources.blog.state import BlogRSSState
def fetch_blog_step(
@@ -1078,7 +1213,7 @@ def fetch_blog_step(
Args:
rss_client: Client RSS configuré.
blog_state: État local pour la déduplication.
blog_state: État local pour la déduplication et le cache HTTP.
enabled: Si False, retourne une liste vide.
Returns:
@@ -1087,14 +1222,19 @@ def fetch_blog_step(
if not enabled:
return []
last_guid = blog_state.get_last_guid()
articles = rss_client.fetch_and_parse(last_guid=last_guid)
known_guids = blog_state.get_known_guids()
etag, last_modified = blog_state.get_cache_headers()
result = rss_client.fetch_and_parse(
known_guids=known_guids,
etag=etag,
last_modified=last_modified,
)
# Mettre à jour l'état si des articles sont trouvés
if articles:
blog_state.update_last_guid(articles[0].id)
# Mettre à jour l'état avec les nouveaux GUID et les en-têtes de cache
blog_state.add_guids(article.id for article in result.articles)
blog_state.update_cache_headers(result.etag, result.last_modified)
return articles
return list(result.articles)
```
#### 5 bis.8.2 Intégration dans le pipeline principal
@@ -1240,12 +1380,12 @@ def mock_blog_rss_client():
@pytest.mark.unittest
def test_parse_blog_rss(mock_blog_rss_client):
"""Test le parsing d'un flux RSS du blog."""
articles = mock_blog_rss_client.fetch_and_parse()
result = mock_blog_rss_client.fetch_and_parse()
assert len(articles) == 2
assert len(result.articles) == 2
# Vérifier le premier article
article1 = articles[0]
article1 = result.articles[0]
assert article1.title == "Sortie pédagogique"
assert article1.url == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
assert article1.category == "Pédagogie"
@@ -1254,7 +1394,7 @@ def test_parse_blog_rss(mock_blog_rss_client):
assert article1.published_at == datetime(2026, 8, 11, 14, 30, 0, tzinfo=timezone.utc)
# Vérifier le deuxième article
article2 = articles[1]
article2 = result.articles[1]
assert article2.title == "Réunion de rentrée"
assert article2.url == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625"
assert article2.category == "Administration"
@@ -1266,26 +1406,26 @@ def test_parse_blog_rss(mock_blog_rss_client):
@pytest.mark.unittest
def test_blog_deduplication(tmp_path):
"""Test la déduplication des articles du blog."""
from pronote_sync.sync.blog_state import BlogRSSState
from pronote_sync.sources.blog.state import BlogRSSState
# Créer un fichier d'état temporaire
state_file = tmp_path / "blog_state.json"
state = BlogRSSState(state_file=str(state_file))
# Initialement, aucun article connu
assert state.get_last_guid() is None
assert state.get_known_guids() == frozenset()
# Simuler la récupération de 2 articles
state.update_last_guid("https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625")
assert state.get_last_guid() == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625"
state.add_guids(["https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625"])
assert "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1625" in state.get_known_guids()
# Simuler une nouvelle récupération : seul le nouvel article doit être retourné
client = BlogRSSClient(rss_url="file://tests/fixtures/blog_rss.xml")
new_articles = client.fetch_and_parse(last_guid=state.get_last_guid())
result = client.fetch_and_parse(known_guids=state.get_known_guids())
# Seul l'article avec p=1626 doit être retourné (car p=1625 est déjà connu)
assert len(new_articles) == 1
assert new_articles[0].id == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
assert len(result.articles) == 1
assert result.articles[0].id == "https://blogpeda.ac-bordeaux.fr/cjeliote/?p=1626"
```
---
@@ -4670,10 +4810,10 @@ exception externe brute susceptible de contenir un secret.
#### 11.4.1 bis `fetch_blog_step.py`
```python
from typing import List, Optional
from ..models.blog import BlogArticle
from ..sources.blog.rss import BlogRSSClient
from ..sync.blog_state import BlogRSSState
from pronote_sync.models.blog import BlogArticle
from pronote_sync.sources.blog.result import BlogRSSFetchResult
from pronote_sync.sources.blog.rss import BlogRSSClient
from pronote_sync.sources.blog.state import BlogRSSState
from .errors import PipelineError, ErrorSeverity
@@ -4681,33 +4821,43 @@ def fetch_blog_step(
rss_client: BlogRSSClient,
blog_state: BlogRSSState,
enabled: bool = True,
) -> List[BlogArticle]:
) -> list[BlogArticle]:
"""
Étape de récupération des articles du blog du collège.
Args:
rss_client: Client RSS configuré.
blog_state: État local pour la déduplication.
enabled: Si False, retourne une liste vide.
Lit les GUID déjà connus et les en-têtes de cache HTTP depuis l'état,
puis appelle le client RSS avec ces valeurs pour une requête
conditionnelle. Si la réponse n'est pas ``304 Not Modified`` et que de
nouveaux articles sont présents, les GUID et les en-têtes de cache sont
enregistrés dans l'état. Retourne les nouveaux articles sous forme de
liste.
Returns:
Liste des nouveaux articles.
Raises:
PipelineError: Si la récupération échoue (non bloquante pour le pipeline).
:param rss_client: Client RSS configuré.
:param blog_state: État local pour la déduplication et le cache HTTP.
:param enabled: Si False, retourne une liste vide.
:return: Liste des nouveaux articles.
:rtype: list[BlogArticle]
:raises PipelineError: Si la récupération échoue (non bloquante pour le
pipeline).
"""
if not enabled:
return []
try:
last_guid = blog_state.get_last_guid()
articles = rss_client.fetch_and_parse(last_guid=last_guid)
known_guids = blog_state.get_known_guids()
etag, last_modified = blog_state.get_cache_headers()
result = rss_client.fetch_and_parse(
known_guids=known_guids,
etag=etag,
last_modified=last_modified,
)
# Mettre à jour l'état si des articles sont trouvés
if articles:
blog_state.update_last_guid(articles[0].id)
# Mettre à jour l'état si de nouveaux articles sont trouvés
if not result.not_modified and result.articles:
blog_state.add_guids(article.id for article in result.articles)
blog_state.update_cache_headers(result.etag, result.last_modified)
return articles
return list(result.articles)
except Exception as e:
raise PipelineError(