From 7bcc6d3c1f96f17bd6809d8924a7a487314ea896 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 16 Jul 2026 17:06:44 +0000 Subject: [PATCH] =?UTF-8?q?fix(synthese):=20date=20cibl=C3=A9e=20stricte?= =?UTF-8?q?=20+=20contenu=20r=C3=A9cup=C3=A9r=C3=A9=20depuis=20l'URL=20r?= =?UTF-8?q?=C3=A9elle=20de=20l'article?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - La génération manuelle pour une date filtre désormais le corpus sur les articles collectés ce jour-là exactement (bornes début ET fin de journée), au lieu d'une simple borne supérieure qui produisait une fenêtre glissante des 500 derniers articles — d'où les mêmes articles traités à chaque fois. - Les liens de tracking TLDR (tracking.tldrnewsletter.com/CL0/...) sont déballés avant le téléchargement du contenu intégral : le texte envoyé au LLM provient de la vraie source de l'article, sans dépendre du redirecteur. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01X3j8zF5W8ru4o83KsATVHb --- backend/app/routers/admin.py | 4 +-- collector/processors/synthesis.py | 51 ++++++++++++++++++++++++------- tests/test_synthesis.py | 46 ++++++++++++++++++++++++++-- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/backend/app/routers/admin.py b/backend/app/routers/admin.py index 9ef2801..bfc65b1 100644 --- a/backend/app/routers/admin.py +++ b/backend/app/routers/admin.py @@ -255,8 +255,8 @@ def generate_synthesis_now(date_: str | None = Query(None, alias="date"), Le collector est lancé avec COLLECTOR_SYNTHESIS_ONLY=1 : aucune collecte, régénération forcée (le skip « rien de nouveau » est contourné). - `date` (YYYY-MM-DD, optionnelle) : applique la logique comme si le run - avait eu lieu ce jour-là et écrit dans syntheses/{date}. + `date` (YYYY-MM-DD, optionnelle) : restreint le corpus aux articles + collectés ce jour-là et écrit dans syntheses/{date}. """ synthesis_date = None if date_: diff --git a/collector/processors/synthesis.py b/collector/processors/synthesis.py index 605618f..e7801e1 100644 --- a/collector/processors/synthesis.py +++ b/collector/processors/synthesis.py @@ -5,14 +5,17 @@ 1. lit le périmètre saisi dans l'IHM admin (`synthesis_source_ids`, `synthesis_categories` dans `settings/global`) et filtre les articles récents en conséquence ; -2. télécharge le contenu intégral de chaque article du corpus et le réduit - à du texte brut (suppression HTML, CSS, scripts, images) ; +2. télécharge le contenu intégral de chaque article du corpus depuis sa + vraie source (les liens de tracking newsletter sont déballés au préalable) + et le réduit à du texte brut (suppression HTML, CSS, scripts, images) ; 3. envoie le tout au LLM avec le prompt de synthèse et le centre d'intérêt, puis écrit le résultat dans `syntheses/{date}`. """ import logging +import re from concurrent.futures import ThreadPoolExecutor from datetime import datetime, date +from urllib.parse import unquote import httpx from bs4 import BeautifulSoup, Comment @@ -34,6 +37,23 @@ _STRIPPED_TAGS = ["script", "style", "img", "svg", "picture", "video", "audio", "iframe", "noscript", "nav", "header", "footer", "form", "aside"] +# Liens de tracking TLDR : l'URL réelle de l'article est encodée dans le chemin +# (https://tracking.tldrnewsletter.com/CL0//1//) +_TLDR_TRACKING_RE = re.compile( + r"^https?://tracking\.tldrnewsletter\.com/CL0/(?P[^/]+)", re.IGNORECASE +) + + +def resolve_article_url(url: str) -> str: + """Retourne l'URL réelle de l'article quand l'URL stockée est un lien de + tracking de newsletter — le contenu est ainsi téléchargé directement + depuis la vraie source, sans dépendre du redirecteur.""" + m = _TLDR_TRACKING_RE.match(url) + if not m: + return url + real = unquote(m.group("encoded")) + return real if real.startswith(("http://", "https://")) else url + def extract_text(html: str) -> str: """Réduit une page HTML à son texte brut : ni balise, ni CSS, ni script, ni image.""" @@ -47,7 +67,14 @@ def extract_text(html: str) -> str: def fetch_article_text(url: str) -> str | None: - """Télécharge un article et retourne son texte nettoyé, ou None si inexploitable.""" + """Télécharge un article et retourne son texte nettoyé, ou None si inexploitable. + + Les liens de tracking sont d'abord déballés pour interroger la vraie source. + """ + real_url = resolve_article_url(url) + if real_url != url: + logger.info(f" Lien de tracking déballé → {real_url}") + url = real_url try: response = httpx.get( url, @@ -124,10 +151,9 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str], jour existe déjà pour le même périmètre et qu'aucun nouvel article n'y entre, la génération est sautée (aucun token consommé). - `target_date` (YYYY-MM-DD, génération manuelle) : applique la logique - comme si le run avait eu lieu ce jour-là — corpus limité aux articles - collectés jusqu'à la fin du jour choisi, document écrit dans - `syntheses/{target_date}`. + `target_date` (YYYY-MM-DD, génération manuelle) : corpus strictement + limité aux articles collectés ce jour-là (00:00 → 23:59), document + écrit dans `syntheses/{target_date}`. """ interest = global_settings.get("interest", "").strip() if not interest: @@ -163,8 +189,9 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str], query = db.collection("articles") if target_date: - # Corpus tel qu'il était à la fin du jour ciblé - query = query.where("collected_at", "<=", f"{target_date}T23:59:59.999999") + # Corpus strictement limité aux articles collectés le jour ciblé + query = (query.where("collected_at", ">=", f"{target_date}T00:00:00") + .where("collected_at", "<=", f"{target_date}T23:59:59.999999")) recent = query.order_by( "collected_at", direction="DESCENDING" ).limit(RECENT_ARTICLES_POOL).stream() @@ -176,10 +203,12 @@ def run_synthesis(db, global_settings: dict, model_priority: list[str], result = None if not articles: - logger.warning("Aucun article dans le périmètre sélectionné — pas d'appel LLM.") + quand = f"collecté le {target_date} " if target_date else "" + logger.warning(f"Aucun article {quand}dans le périmètre sélectionné — pas d'appel LLM.") corpus = [] result = { - "synthesis": "⚠️ Aucun article dans le périmètre sélectionné (sources/thèmes) — synthèse non générée.", + "synthesis": f"⚠️ Aucun article {quand}dans le périmètre sélectionné (sources/thèmes) " + "— synthèse non générée.", "cited_ids": [], } else: diff --git a/tests/test_synthesis.py b/tests/test_synthesis.py index 05d1c75..1896074 100644 --- a/tests/test_synthesis.py +++ b/tests/test_synthesis.py @@ -80,6 +80,46 @@ def test_extract_text_strips_html_css_scripts_images(): assert interdit not in text +def test_resolve_article_url_unwraps_tldr_tracking(): + """Un lien de tracking TLDR est déballé vers l'URL réelle de l'article.""" + from processors.synthesis import resolve_article_url + tracking = ("https://tracking.tldrnewsletter.com/CL0/" + "https:%2F%2Fexample.com%2Fpost%3Futm_source%3Dtldrnewsletter" + "/1/010001/abcdef=123") + assert resolve_article_url(tracking) == "https://example.com/post?utm_source=tldrnewsletter" + + +def test_resolve_article_url_passthrough(): + """Une URL directe (source web) ou un lien de tracking illisible restent inchangés.""" + from processors.synthesis import resolve_article_url + assert resolve_article_url("https://example.com/article") == "https://example.com/article" + assert (resolve_article_url("https://tracking.tldrnewsletter.com/CL0/pas-une-url/1/x") + == "https://tracking.tldrnewsletter.com/CL0/pas-une-url/1/x") + + +def test_fetch_article_text_uses_real_url(monkeypatch): + """Le téléchargement du contenu interroge l'URL réelle, pas le redirecteur.""" + import processors.synthesis as synthesis + + requested = {} + + def fake_get(url, **kwargs): + requested["url"] = url + class R: + headers = {"content-type": "text/html"} + text = "

" + "Contenu réel de l'article. " * 20 + "

" + def raise_for_status(self): + pass + return R() + + monkeypatch.setattr(synthesis.httpx, "get", fake_get) + tracking = "https://tracking.tldrnewsletter.com/CL0/https:%2F%2Fexample.com%2Fpost/1/010001/abc=1" + text = synthesis.fetch_article_text(tracking) + + assert requested["url"] == "https://example.com/post" + assert text and "Contenu réel de l'article." in text + + def test_fetch_article_text_fallback_on_error(monkeypatch): """URL injoignable → None (l'article retombera sur son résumé stocké).""" import processors.synthesis as synthesis @@ -377,8 +417,9 @@ def test_run_synthesis_manual_trigger_bypasses_skip(monkeypatch): def test_run_synthesis_target_date(monkeypatch): - """Génération pour une date choisie : corpus ancré à la fin de ce jour, - document écrit dans syntheses/{date choisie}.""" + """Génération pour une date choisie : corpus limité aux articles collectés + ce jour-là (bornes début ET fin de journée), document écrit dans + syntheses/{date choisie}.""" import processors.synthesis as synthesis _stub_synthesis(monkeypatch, synthesis) @@ -390,6 +431,7 @@ def test_run_synthesis_target_date(monkeypatch): assert list(db.written.keys()) == ["2026-07-10"] doc = db.written["2026-07-10"] assert doc["target_date"] == "2026-07-10" + assert ("collected_at", ">=", "2026-07-10T00:00:00") in db.where_calls assert ("collected_at", "<=", "2026-07-10T23:59:59.999999") in db.where_calls