diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
new file mode 100644
index 0000000..e013845
--- /dev/null
+++ b/.github/workflows/tests.yml
@@ -0,0 +1,43 @@
+name: tests
+
+# Les régressions sont hors ligne: aucun secret, aucun appel RSS, OpenAI ou SMTP.
+on:
+ push:
+ branches: [main]
+ pull_request:
+ workflow_dispatch:
+
+permissions:
+ contents: read
+
+jobs:
+ test:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v4
+
+ - uses: actions/setup-python@v5
+ with:
+ python-version: '3.12'
+ cache: pip
+ cache-dependency-path: requirements.lock
+
+ - name: Installer les dépendances verrouillées
+ run: python -m pip install -r requirements.lock
+
+ - name: Vérifier la structure et les imports
+ run: python verify.py
+
+ - name: Régressions hors ligne
+ run: python test_system.py
+
+ - name: Générer le PDF de démonstration
+ run: python demo.py
+
+ - name: Conserver le PDF de démonstration
+ uses: actions/upload-artifact@v4
+ with:
+ name: demo-journal
+ path: output/demo_journal_*.pdf
+ if-no-files-found: error
+ retention-days: 7
diff --git a/README.md b/README.md
index 3ed25d6..c762bae 100644
--- a/README.md
+++ b/README.md
@@ -12,7 +12,7 @@ restent dans l'environnement; les valeurs par défaut sauvegardées ne les copie
pas dans `config.yaml`.
```bash
-venv/bin/python test_system.py # 12 régressions hors ligne
+venv/bin/python test_system.py # 16 régressions hors ligne
venv/bin/python verify.py # présence des fichiers et imports uniquement
venv/bin/python demo.py # PDF fictif séparé du vrai journal quotidien
```
@@ -26,12 +26,13 @@ Les notes YouTube utilisent seulement le **titre et la description RSS**, pas la
transcription ou le contenu vidéo. Vérifiez les faits avant utilisation. Les
articles RSS sont des extraits, pas une analyse complète des sources.
-Validation locale : 12 tests, contrôle des dépendances, scripts shell et démo
+Validation locale : 16 tests, contrôle des dépendances, scripts shell et démo
PDF de six pages inspectée. Aucun appel réel OpenAI/SMTP ni livraison Kindle
vérifié. La mise en page reste A4 avec une page par élément; elle n'est pas validée
sur Kindle. Helvetica couvre le texte français de la démo, mais pas tous les
-caractères Unicode possibles dans des flux tiers. Aucun workflow CI n'est encore
-configuré.
+caractères Unicode possibles dans des flux tiers. GitHub Actions rejoue ces
+vérifications hors ligne sur chaque push et pull request; aucun envoi réel n'y
+est testé.
[Capture historique de la démo (juin 2025)](demo/screenshots/daily-learning-plan-script-generation.png)
diff --git a/src/pdf_generator.py b/src/pdf_generator.py
index 66f2ef2..155628b 100644
--- a/src/pdf_generator.py
+++ b/src/pdf_generator.py
@@ -183,12 +183,12 @@ def add_content_item(self, story: List, item, index: int, styles):
story.append(Paragraph(source_info, styles['Metadata']))
- # Contenu principal
- content_text = ""
- if hasattr(item, 'summary') and item.summary:
- content_text = item.summary
- elif hasattr(item, 'content') and item.content:
- content_text = item.content
+ # Contenu principal: préférer le texte le plus complet. Le résumé d'un
+ # article n'est qu'une troncature de son contenu, alors que celui d'une
+ # vidéo est rédigé par l'IA et constitue la seule source disponible.
+ summary_text = getattr(item, 'summary', '') or ''
+ full_text = getattr(item, 'content', '') or ''
+ content_text = full_text if len(full_text) > len(summary_text) else summary_text
if content_text:
# Diviser le contenu en paragraphes si nécessaire
diff --git a/src/rss_aggregator.py b/src/rss_aggregator.py
index 1b03cce..b8789b5 100644
--- a/src/rss_aggregator.py
+++ b/src/rss_aggregator.py
@@ -11,6 +11,7 @@
import time
import re
import html
+from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
@dataclass
class Article:
@@ -50,9 +51,19 @@ def collect_articles(self) -> List[Article]:
# Trier par date de publication (plus récent en premier)
all_articles.sort(key=lambda x: x.published, reverse=True)
- # Limiter le nombre total d'articles
- max_total = self.config.max_articles_per_feed * len(self.config.rss_feeds)
- return all_articles[:max_total]
+ # Écarter les doublons: un même lien revient souvent via plusieurs flux
+ # (agrégateurs). Le tri ci-dessus garde la version la plus récente.
+ unique_articles = []
+ seen_urls = set()
+ for article in all_articles:
+ key = self.normalise_url(article.url)
+ if key in seen_urls:
+ print(f"↔️ Doublon ignoré: {article.title}")
+ continue
+ seen_urls.add(key)
+ unique_articles.append(article)
+
+ return unique_articles
def process_feed(self, feed_url: str) -> List[Article]:
"""Traiter un seul flux RSS"""
@@ -80,19 +91,16 @@ def process_feed(self, feed_url: str) -> List[Article]:
# Date limite (articles récents uniquement)
cutoff_date = datetime.now(timezone.utc) - timedelta(days=self.config.days_lookback)
- for entry in feed.entries[:self.config.max_articles_per_feed]:
+ # Filtrer sur la date avant de limiter: certains flux ne sont pas triés
+ # par date et épinglent d'anciennes entrées en tête.
+ recent_entries = []
+ for entry in feed.entries:
+ published = self.parse_published(entry)
+ if published >= cutoff_date:
+ recent_entries.append((entry, published))
+
+ for entry, published in recent_entries[:self.config.max_articles_per_feed]:
try:
- # Parser la date de publication
- published = datetime.now(timezone.utc)
- if hasattr(entry, 'published_parsed') and entry.published_parsed:
- published = datetime(*entry.published_parsed[:6], tzinfo=timezone.utc)
- elif hasattr(entry, 'updated_parsed') and entry.updated_parsed:
- published = datetime(*entry.updated_parsed[:6], tzinfo=timezone.utc)
-
- # Ignorer les articles trop anciens
- if published < cutoff_date:
- continue
-
# Obtenir le contenu
content = self.extract_content(entry)
@@ -116,6 +124,34 @@ def process_feed(self, feed_url: str) -> List[Article]:
return articles
+ def normalise_url(self, url: str) -> str:
+ """Normaliser une URL pour comparer deux liens équivalents"""
+ if not url:
+ return ""
+
+ cleaned = url.strip()
+
+ # Ignorer la casse du schéma et du domaine, ainsi qu'un slash final
+ parts = urlsplit(cleaned)
+ path = parts.path.rstrip('/') or '/'
+
+ # Retirer les paramètres de suivi, qui diffèrent d'un flux à l'autre
+ query = urlencode([(name, value) for name, value in parse_qsl(parts.query)
+ if not name.lower().startswith('utm_')
+ and name.lower() not in {'fbclid', 'gclid', 'ref', 'source'}])
+
+ return urlunsplit((parts.scheme.lower(), parts.netloc.lower(), path, query, ''))
+
+ def parse_published(self, entry) -> datetime:
+ """Lire la date de publication d'une entrée, en UTC"""
+ for attribute in ('published_parsed', 'updated_parsed'):
+ value = getattr(entry, attribute, None)
+ if value:
+ return datetime(*value[:6], tzinfo=timezone.utc)
+
+ # Sans date exploitable, considérer l'entrée comme actuelle
+ return datetime.now(timezone.utc)
+
def extract_content(self, entry) -> str:
"""Extraire le contenu de l'entrée RSS"""
content = ""
diff --git a/test_system.py b/test_system.py
index a084fee..ca79e2b 100644
--- a/test_system.py
+++ b/test_system.py
@@ -6,15 +6,17 @@
import tempfile
import unittest
from contextlib import redirect_stdout
-from datetime import datetime, timezone
+from datetime import datetime, timedelta, timezone
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
+from reportlab.platypus import Paragraph
+
from src.config import Config
from src.pdf_generator import PDFGenerator
from src.rss_aggregator import Article, RSSAggregator
-from src.youtube_summarizer import YouTubeSummarizer
+from src.youtube_summarizer import VideoSummary, YouTubeSummarizer
from src.kindle_sender import KindleSender
import main as application
@@ -100,6 +102,52 @@ def test_rss_dates_are_utc(self):
self.assertEqual(len(articles), 1)
self.assertEqual(articles[0].published.tzinfo, timezone.utc)
+ def test_duplicate_links_across_feeds_appear_once(self):
+ self.config_path.write_text(
+ 'rss_feeds:\n - https://example.invalid/a\n - https://example.invalid/b\n')
+ date = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT')
+ feed = ('TestShared'
+ 'https://example.invalid/post?utm_source=feed'
+ f'{date}Text'
+ '').encode()
+ with patch('src.rss_aggregator.requests.get', return_value=MagicMock(content=feed)), \
+ patch('src.rss_aggregator.time.sleep'), redirect_stdout(io.StringIO()):
+ articles = RSSAggregator(self.config()).collect_articles()
+ self.assertEqual(len(articles), 1)
+
+ def test_recent_entries_survive_a_pinned_older_entry(self):
+ old = (datetime.now(timezone.utc) - timedelta(days=400)).strftime('%a, %d %b %Y %H:%M:%S GMT')
+ new = datetime.now(timezone.utc).strftime('%a, %d %b %Y %H:%M:%S GMT')
+ items = ''.join(
+ f'Pinned {index}https://example.invalid/old{index}'
+ f'{old}Old' for index in range(3))
+ feed = ('Test' + items +
+ 'Recenthttps://example.invalid/new'
+ f'{new}Fresh'
+ '').encode()
+ with patch('src.rss_aggregator.requests.get', return_value=MagicMock(content=feed)):
+ articles = RSSAggregator(self.config()).process_feed('https://example.invalid/rss')
+ self.assertEqual([article.title for article in articles], ['Recent'])
+
+ def test_pdf_renders_article_content_rather_than_its_truncation(self):
+ body = 'Phrase unique. ' + 'corps ' * 200
+ article = Article('Titre', body, 'https://example.invalid/a',
+ datetime.now(), 'Source', summary=body[:200] + '...')
+ generator = PDFGenerator(self.config())
+ story, styles = [], generator.create_custom_styles()
+ generator.add_content_item(story, article, 1, styles)
+ rendered = ' '.join(item.text for item in story if isinstance(item, Paragraph))
+ self.assertIn(body.strip(), rendered)
+
+ def test_pdf_still_uses_the_ai_summary_for_videos(self):
+ video = VideoSummary('Titre', 'Résumé rédigé par le modèle.', 'https://example.invalid/v',
+ datetime.now(), 'YouTube', 'Chaîne')
+ generator = PDFGenerator(self.config())
+ story, styles = [], generator.create_custom_styles()
+ generator.add_content_item(story, video, 1, styles)
+ rendered = ' '.join(item.text for item in story if isinstance(item, Paragraph))
+ self.assertIn('Résumé rédigé par le modèle.', rendered)
+
def test_smtp_uses_verified_tls_with_a_timeout(self):
pdf = self.root / 'test.pdf'; pdf.write_bytes(b'%PDF-test')
config = SimpleNamespace(kindle_email='reader@example.invalid', sender_email='sender@example.invalid',