Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
43 changes: 43 additions & 0 deletions .github/workflows/tests.yml
Original file line number Diff line number Diff line change
@@ -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
9 changes: 5 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
Expand All @@ -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)

Expand Down
12 changes: 6 additions & 6 deletions src/pdf_generator.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
66 changes: 51 additions & 15 deletions src/rss_aggregator.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import time
import re
import html
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit

@dataclass
class Article:
Expand Down Expand Up @@ -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"""
Expand Down Expand Up @@ -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)

Expand All @@ -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 = ""
Expand Down
52 changes: 50 additions & 2 deletions test_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 = ('<rss version="2.0"><channel><title>Test</title><item><title>Shared</title>'
'<link>https://example.invalid/post?utm_source=feed</link>'
f'<pubDate>{date}</pubDate><description>Text</description></item>'
'</channel></rss>').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'<item><title>Pinned {index}</title><link>https://example.invalid/old{index}</link>'
f'<pubDate>{old}</pubDate><description>Old</description></item>' for index in range(3))
feed = ('<rss version="2.0"><channel><title>Test</title>' + items +
'<item><title>Recent</title><link>https://example.invalid/new</link>'
f'<pubDate>{new}</pubDate><description>Fresh</description></item>'
'</channel></rss>').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',
Expand Down
Loading