Checkpoint: Ajout de server/articleFetcher.ts (fetch HTTP + extraction 500 mots, timeout 5s, fallback silencieux). Intégration dans rssEngine.ts : si le résumé RSS est court (<150 car.), le 1er paragraphe de l'article est récupéré et transmis à classifyArticle. Signature de classifyArticle mise à jour avec paramètre contenuPage optionnel. 0 erreur TypeScript, 21 tests passés.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
{
|
||||
"version": "c4f5048d",
|
||||
"timestamp": 1781875470326
|
||||
"version": "6a49fe67",
|
||||
"timestamp": 1781875679562
|
||||
}
|
||||
@@ -99,16 +99,22 @@ function parseJsonSafe<T>(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<AiClassificationResult> {
|
||||
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({
|
||||
|
||||
94
server/articleFetcher.ts
Normal file
94
server/articleFetcher.ts
Normal file
@@ -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 <p>, <h1>–<h3> 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(/<script[\s\S]*?<\/script>/gi, " ")
|
||||
.replace(/<style[\s\S]*?<\/style>/gi, " ")
|
||||
.replace(/<nav[\s\S]*?<\/nav>/gi, " ")
|
||||
.replace(/<header[\s\S]*?<\/header>/gi, " ")
|
||||
.replace(/<footer[\s\S]*?<\/footer>/gi, " ")
|
||||
.replace(/<aside[\s\S]*?<\/aside>/gi, " ")
|
||||
.replace(/<noscript[\s\S]*?<\/noscript>/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<string | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<FetchResult> {
|
||||
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<FetchResult> {
|
||||
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<FetchResult> {
|
||||
// ── 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
|
||||
|
||||
7
todo.md
7
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
|
||||
|
||||
Reference in New Issue
Block a user