diff --git a/post_rss_to_ghost.py b/post_rss_to_ghost.py index 6504fd4..5ef12e9 100644 --- a/post_rss_to_ghost.py +++ b/post_rss_to_ghost.py @@ -141,7 +141,7 @@ class MistralClient: self.model = model self.base_url = "https://api.mistral.ai/v1/chat/completions" - def _call_api(self, messages: List[Dict], temperature: float = 0.3) -> Optional[str]: + def _call_api(self, messages: List[Dict], temperature: float = 0.3, json_mode: bool = True) -> Optional[str]: """Make a call to the Mistral API.""" headers = { "Authorization": f"Bearer {self.api_key}", @@ -151,8 +151,9 @@ class MistralClient: "model": self.model, "messages": messages, "temperature": temperature, - "response_format": {"type": "json_object"}, } + if json_mode: + payload["response_format"] = {"type": "json_object"} try: resp = requests.post(self.base_url, headers=headers, json=payload, timeout=120) resp.raise_for_status() @@ -363,6 +364,124 @@ IMPORTANT: Chaque article dans UN SEUL sous-groupe. Titre = nom de franchise/sé "subgroups": [{"title": "Toutes les news", "items": items}] }] + def select_top_story_ids(self, items: List[dict], count: int = 5) -> List[int]: + """ + Sélectionne UNIQUEMENT les ids des actualités les plus importantes de la semaine + (pas de réécriture : le texte affiché reste celui de l'article original). + Retourne une liste d'ids, du plus important au moins important. + """ + if not items: + return [] + + items_for_analysis = [] + for i, item in enumerate(items): + raw_summary = html.unescape(item.get("summary", "") or "") + clean_summary = re.sub("<[^<]+?>", "", raw_summary).strip() + items_for_analysis.append({ + "id": i, + "title": item.get("title", ""), + "summary": clean_summary[:400], + }) + + prompt = f"""Tu es rédacteur pour une newsletter jeux vidéo en français. + +Parmi ces articles de la semaine, sélectionne les {count} actualités les PLUS IMPORTANTES +(grosses annonces, sorties majeures, gros trailers, actu qui va marquer la semaine). +Ignore les tests/critiques individuels sauf s'ils concernent un jeu très attendu. + +Articles à analyser: +{json.dumps(items_for_analysis, ensure_ascii=False, indent=2)} + +Retourne ce JSON, du plus important au moins important, avec au maximum {count} ids: +{{"top_ids": [3, 0, 7]}}""" + + response = self._call_api([{"role": "user", "content": prompt}], temperature=0.2) + if response: + try: + result = json.loads(response) + ids: List[int] = [] + for idx in (result.get("top_ids") or [])[:count]: + if isinstance(idx, int) and 0 <= idx < len(items) and idx not in ids: + ids.append(idx) + if ids: + return ids + except json.JSONDecodeError: + LOG.warning("Failed to parse Mistral response for top stories") + + return [] + + def write_intro(self, top_titles: List[str]) -> Optional[str]: + """ + Rédige une courte introduction (façon "Cette semaine, ...") qui cite + naturellement les titres les plus importants de la semaine. + """ + if not top_titles: + return None + + prompt = f"""Tu rédiges l'introduction d'une newsletter jeux vidéo hebdomadaire en français. + +Voici les titres des actualités les plus importantes de la semaine, du plus au moins important: +{json.dumps(top_titles, ensure_ascii=False)} + +Rédige UNE introduction FACTUELLE (2 à 3 phrases maximum, un seul paragraphe) qui énumère ces +actus. Reformule légèrement les titres pour qu'ils s'enchaînent naturellement, avec des verbes +simples et neutres (dévoile, présente, revient avec, sort, annonce, c'est aussi/également...). +Reste fidèle au contenu, n'invente aucune information. + +INTERDIT: adjectifs et formules d'accroche marketing ("particulièrement riche", "frappe fort", +"en force", "sans oublier"), questions rhétoriques ou phrases d'accroche finales ("prêts à...", +"de quoi...", "à ne pas manquer"). Pas de ton commercial, pas de superlatifs. Un simple constat +factuel de ce qui s'est passé, comme un résumé de dépêches. + +Exemple de ton attendu: +"Cette semaine, Marvel’s Wolverine nous dévoile ses personnages clés, Microsoft présente This is +an Xbox, Battlefield 6 revient avec une saison 4 centrée sur la mer. C'est également la sortie de +la version remastérisée d’Arma: Cold War Assault et le lancement mondial de DragonSword : +Awakening, après le rejet de l’injonction de Webzen." + +Commence par "Cette semaine, ...". + +Retourne UNIQUEMENT le texte brut de l'introduction, sans guillemets, sans markdown, sans JSON.""" + + response = self._call_api([{"role": "user", "content": prompt}], temperature=0.5, json_mode=False) + if response: + text = response.strip().strip('"').strip() + text = re.sub(r'[*_`]+', '', text) # strip stray markdown emphasis (raw HTML post, not markdown) + if text: + return text + return None + + def write_excerpt(self, top_titles: List[str], max_len: int = 300) -> Optional[str]: + """ + Version courte de l'intro (une seule phrase) qui tient dans la limite de + caractères de Ghost, plutôt que de tronquer l'intro complète du corps du post. + """ + if not top_titles: + return None + + prompt = f"""Tu rédiges le résumé (excerpt) d'une newsletter jeux vidéo hebdomadaire en français. + +Voici les titres des actualités les plus importantes de la semaine, du plus au moins important: +{json.dumps(top_titles, ensure_ascii=False)} + +Rédige UNE SEULE phrase factuelle qui tient en moins de {max_len} caractères, résumant les 2 ou 3 +actus les plus importantes de cette liste. Verbes simples et neutres (dévoile, présente, sort, +annonce...). Reste fidèle au contenu, n'invente rien. + +INTERDIT: adjectifs et formules d'accroche marketing, questions rhétoriques, phrases d'accroche +finales. Pas de ton commercial. Commence par "Cette semaine, ...". + +Retourne UNIQUEMENT le texte brut, sans guillemets, sans markdown, sans JSON. La phrase DOIT +tenir en moins de {max_len} caractères.""" + + response = self._call_api([{"role": "user", "content": prompt}], temperature=0.5, json_mode=False) + if response: + text = response.strip().strip('"').strip() + text = re.sub(r'[*_`]+', '', text) + if text: + return text + return None + # ------------- Ghost Admin API client ------------- @@ -420,11 +539,14 @@ class GhostAdmin: return n.get("slug") return (actives or nls)[0].get("slug") - def create_post_html(self, title: str, html_content: str, status: str = "draft", feature_image: Optional[str] = None): + def create_post_html(self, title: str, html_content: str, status: str = "draft", + feature_image: Optional[str] = None, custom_excerpt: Optional[str] = None): url = self.base + "posts/?source=html" post = {"title": title, "html": html_content, "status": status} if feature_image: post["feature_image"] = feature_image + if custom_excerpt: + post["custom_excerpt"] = custom_excerpt[:300] # Ghost limit resp = requests.post(url, headers=self._headers(), json={"posts": [post]}, timeout=30) if resp.status_code >= 400: raise RuntimeError(f"Ghost create error {resp.status_code}: {resp.text}") @@ -541,41 +663,95 @@ class GhostTask: pass return None + def _select_top_stories(self, items: List[dict], count: int = 5) -> List[Dict]: + """ + Choisit les news les plus importantes (via Mistral si dispo, sinon les + premières de la liste). Sert de base à l'introduction rédigée. + """ + if not items: + return [] + ids: List[int] = self.mistral.select_top_story_ids(items, count=count) if self.mistral else [] + if not ids: + ids = list(range(min(count, len(items)))) + return [items[idx] for idx in ids] + + @staticmethod + def _deterministic_summary(titles: List[str], max_len: Optional[int] = None) -> str: + """No-AI fallback sentence, optionally shortened (by dropping items, not mid-word) to fit max_len.""" + def render(ts: List[str]) -> str: + if len(ts) == 1: + return f"Cette semaine : {ts[0]}." + return "Cette semaine : " + ", ".join(ts[:-1]) + " et " + ts[-1] + "." + + text = render(titles) + if not max_len or len(text) <= max_len: + return text + + for n in range(len(titles) - 1, 0, -1): + candidate = render(titles[:n]) + if len(candidate) <= max_len: + return candidate + + truncated = text[:max_len - 1] + last_space = truncated.rfind(" ") + if last_space > 20: + truncated = truncated[:last_space] + return truncated.rstrip(",.;: ") + "…" + + def _build_intro(self, top_stories: List[dict]) -> Optional[str]: + """Rédige le paragraphe d'intro ('Cette semaine, ...') citant les news les plus importantes.""" + titles = [(t.get("title") or "").strip() for t in top_stories] + titles = [t for t in titles if t] + if not titles: + return None + + if self.mistral: + intro = self.mistral.write_intro(titles) + if intro: + return intro + + return self._deterministic_summary(titles) + + def _build_excerpt_text(self, top_stories: List[dict], max_len: int = 300) -> Optional[str]: + """ + Résumé COURT dédié au champ excerpt de Ghost : rédigé pour tenir dans max_len, + plutôt que de tronquer l'intro complète du corps du post. + """ + titles = [(t.get("title") or "").strip() for t in top_stories] + titles = [t for t in titles if t] + if not titles: + return None + + if self.mistral: + excerpt = self.mistral.write_excerpt(titles, max_len=max_len) + if excerpt: + if len(excerpt) <= max_len: + return excerpt + truncated = excerpt[:max_len - 1] + last_space = truncated.rfind(" ") + if last_space > 20: + truncated = truncated[:last_space] + return truncated.rstrip(",.;: ") + "…" + + return self._deterministic_summary(titles, max_len=max_len) + # --- HTML builder for grouped content - def _build_html_roundup_grouped(self, categories: List[Dict], feeds: List[RSSfeed]): + def _build_html_roundup_grouped(self, categories: List[Dict], feeds: List[RSSfeed], + intro: Optional[str] = None): """ - Construit le HTML avec des catégories et sous-groupes thématiques. - Inclut un résumé et une table des matières en haut. + Construit le HTML avec un paragraphe d'introduction, puis des + catégories et sous-groupes thématiques. Retourne (html, feature_image_url_ou_None). """ parts: List[str] = [] first_image: Optional[str] = None - - # --- Build Summary Section --- - parts.append('

✨ En bref cette semaine

') - parts.append('') - parts.append('
') - + + # --- Intro paragraph --- + if intro: + parts.append(f'

{html.escape(intro)}

') + parts.append('
') + # --- Build Table of Contents --- # parts.append('

📋 Sommaire

') # parts.append('