From dde3d8b6b874ca6ea9abd69e27df9a051104af29 Mon Sep 17 00:00:00 2001 From: Manus Date: Fri, 19 Jun 2026 09:27:59 -0400 Subject: [PATCH] =?UTF-8?q?Checkpoint:=20Ajout=20de=20server/articleFetche?= =?UTF-8?q?r.ts=20(fetch=20HTTP=20+=20extraction=20500=20mots,=20timeout?= =?UTF-8?q?=205s,=20fallback=20silencieux).=20Int=C3=A9gration=20dans=20rs?= =?UTF-8?q?sEngine.ts=20:=20si=20le=20r=C3=A9sum=C3=A9=20RSS=20est=20court?= =?UTF-8?q?=20(<150=20car.),=20le=201er=20paragraphe=20de=20l'article=20es?= =?UTF-8?q?t=20r=C3=A9cup=C3=A9r=C3=A9=20et=20transmis=20=C3=A0=20classify?= =?UTF-8?q?Article.=20Signature=20de=20classifyArticle=20mise=20=C3=A0=20j?= =?UTF-8?q?our=20avec=20param=C3=A8tre=20contenuPage=20optionnel.=200=20er?= =?UTF-8?q?reur=20TypeScript,=2021=20tests=20pass=C3=A9s.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- client/public/__manus__/version.json | 4 +- server/aiClassifier.ts | 16 +++-- server/articleFetcher.ts | 94 ++++++++++++++++++++++++++++ server/rssEngine.ts | 19 +++++- todo.md | 7 +++ 5 files changed, 130 insertions(+), 10 deletions(-) create mode 100644 server/articleFetcher.ts diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index 1ce6406..bb38b37 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "version": "c4f5048d", - "timestamp": 1781875470326 + "version": "6a49fe67", + "timestamp": 1781875679562 } \ No newline at end of file diff --git a/server/aiClassifier.ts b/server/aiClassifier.ts index 6dd0114..b2133e3 100644 --- a/server/aiClassifier.ts +++ b/server/aiClassifier.ts @@ -99,16 +99,22 @@ function parseJsonSafe(text: string): T | null { * Détermine simultanément la pertinence et le type de veille. * Retourne toujours un résultat (fallback sur rules en cas d'erreur). * - * @param titre Titre de l'article - * @param resume Résumé / description de l'article - * @param fallbackFn Fonction de fallback appelée si l'IA échoue + * @param titre Titre de l'article + * @param resume Résumé / description de l'article + * @param fallbackFn Fonction de fallback appelée si l'IA échoue + * @param contenuPage Premiers paragraphes de l'article complet (optionnel, améliore la précision) */ export async function classifyArticle( titre: string, resume: string, - fallbackFn: () => { typeVeille: AiTypeVeille } + fallbackFn: () => { typeVeille: AiTypeVeille }, + contenuPage?: string | null ): Promise { - const userContent = `Titre : ${titre}\n\nRésumé : ${resume}`; + // Construire le contenu utilisateur en enrichissant avec le contenu de la page si disponible + const contenuSection = contenuPage + ? `\n\nExtrait de l'article (premiers paragraphes) : ${contenuPage}` + : ""; + const userContent = `Titre : ${titre}\n\nRésumé RSS : ${resume}${contenuSection}`; try { const response = await invokeLLM({ diff --git a/server/articleFetcher.ts b/server/articleFetcher.ts new file mode 100644 index 0000000..8ad8e06 --- /dev/null +++ b/server/articleFetcher.ts @@ -0,0 +1,94 @@ +/** + * articleFetcher.ts + * + * Récupère le contenu textuel des 500 premiers mots d'un article web + * pour enrichir la classification IA quand le résumé RSS est trop court. + * + * Stratégie : + * - Fetch HTTP avec timeout 5 secondes + * - Extraction des balises

,

du HTML brut (sans dépendance lourde) + * - Limite à 500 mots pour rester dans le contexte LLM + * - Retourne null en cas d'erreur (timeout, 403, paywall, JS-only…) — silencieux + */ + +const FETCH_TIMEOUT_MS = 5_000; +const MAX_WORDS = 500; + +/** + * Extrait le texte brut des balises de contenu d'un HTML. + * Approche légère sans parser DOM complet. + */ +function extractTextFromHtml(html: string): string { + // Supprimer les balises script, style, nav, header, footer, aside + let cleaned = html + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " ") + .replace(//gi, " "); + + // Extraire le contenu des balises de texte pertinentes + const contentTags = cleaned.match(/<(p|h[1-3]|li|blockquote)[^>]*>([\s\S]*?)<\/\1>/gi) ?? []; + + const texts = contentTags.map((tag) => + tag + .replace(/<[^>]+>/g, " ") // supprimer les balises HTML restantes + .replace(/&/g, "&") + .replace(/</g, "<") + .replace(/>/g, ">") + .replace(/ /g, " ") + .replace(/"/g, '"') + .replace(/&#\d+;/g, " ") + .replace(/\s+/g, " ") + .trim() + ); + + return texts.filter((t) => t.length > 20).join(" "); +} + +/** + * Récupère les premiers ~500 mots du contenu d'un article web. + * Retourne null si l'article est inaccessible ou si le contenu est trop pauvre. + * + * @param url URL de l'article + * @returns Texte extrait (500 mots max) ou null + */ +export async function fetchArticleFirstParagraph(url: string): Promise { + try { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS); + + const response = await fetch(url, { + signal: controller.signal, + headers: { + "User-Agent": + "Mozilla/5.0 (compatible; VeilleBot/1.0; +https://itinova.fr)", + Accept: "text/html,application/xhtml+xml", + "Accept-Language": "fr-FR,fr;q=0.9", + }, + }); + + clearTimeout(timer); + + if (!response.ok) return null; + + const contentType = response.headers.get("content-type") ?? ""; + if (!contentType.includes("text/html")) return null; + + const html = await response.text(); + const text = extractTextFromHtml(html); + + if (!text || text.length < 50) return null; + + // Limiter à MAX_WORDS mots + const words = text.split(/\s+/); + const truncated = words.slice(0, MAX_WORDS).join(" "); + + return truncated.length > 50 ? truncated : null; + } catch { + // Timeout, réseau, CORS, paywall, etc. — silencieux + return null; + } +} diff --git a/server/rssEngine.ts b/server/rssEngine.ts index 6c44010..3c9857c 100644 --- a/server/rssEngine.ts +++ b/server/rssEngine.ts @@ -23,6 +23,7 @@ import { } from "../drizzle/schema"; import { eq, sql } from "drizzle-orm"; import { classifyArticle, generateSummary } from "./aiClassifier"; +import { fetchArticleFirstParagraph } from "./articleFetcher"; // ─── Types internes ─────────────────────────────────────────────────────────── @@ -351,9 +352,14 @@ async function processFeed(feed: RssFeed): Promise { const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? "")); if (feed.feedType === "veille") { - // ── Classification IA (avec fallback sur mots-clés) ────────────────── + // ── Classification IA (avec fallback sur mots-clés) ────────────────────────────────────── const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText); + // Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.) + const contenuPage = description.length < 150 && link + ? await fetchArticleFirstParagraph(link) + : null; + const aiResult = await classifyArticle( title, description, @@ -363,7 +369,8 @@ async function processFeed(feed: RssFeed): Promise { typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle", }; - } + }, + contenuPage ); const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as @@ -420,12 +427,18 @@ async function processFeed(feed: RssFeed): Promise { // ── Classification IA pour les AAP ─────────────────────────────────── const { region, departement } = detectAapGeo(fullText); + // Enrichissement : récupérer le 1er paragraphe si le résumé RSS est court (<150 car.) + const contenuPageAap = description.length < 150 && link + ? await fetchArticleFirstParagraph(link) + : null; + const aiResult = await classifyArticle( title, description, () => ({ typeVeille: "informationnelle" as const, - }) + }), + contenuPageAap ); const categorie = (feed.defaultCategorieAap ?? "Autre") as diff --git a/todo.md b/todo.md index ee807e8..43c1071 100644 --- a/todo.md +++ b/todo.md @@ -155,3 +155,10 @@ - [x] aiClassifier.ts : remplacer le prompt de pertinence générique ESMS par une définition explicite des 5 secteurs (handicap, précarité/logement, protection de l'enfance, personnes âgées, sanitaire SMR) - [x] 0 erreur TypeScript, 21 tests passés - [x] Checkpoint + +## Récupération du 1er paragraphe pour la classification IA +- [x] Créer server/articleFetcher.ts : fetch HTTP avec timeout 5s, extraction des 500 premiers mots du contenu textuel (balises p, h1-h3), fallback silencieux si erreur +- [x] Intégrer articleFetcher dans rssEngine.ts : enrichir le contenu envoyé à classifyArticle avec le 1er paragraphe récupéré +- [x] Mettre à jour la signature de classifyArticle pour accepter un paramètre optionnel contenuPage +- [x] 0 erreur TypeScript, tests passés +- [x] Checkpoint