95 lines
3.0 KiB
TypeScript
95 lines
3.0 KiB
TypeScript
/**
|
||
* 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;
|
||
}
|
||
}
|