/** * Moteur de lecture RSS * Récupère les flux actifs, parse les articles, applique les règles d'automatisme, * et insère les nouveaux articles dans veille_items ou aap_items. * * Enrichissement automatique : * - AAP : région (toujours Auvergne-Rhône-Alpes) + département extrait du titre/description * - Veille : territoire, catégorie (Handicap/PA/Enfance/Précarité/Sanitaire/Autre), niveau * * Fusion multi-département : * - Les articles avec le même titre normalisé (sans nom de département) sont fusionnés * en un seul enregistrement avec une liste JSON de territoires/départements. */ import { XMLParser } from "fast-xml-parser"; import * as crypto from "crypto"; import { getDb } from "./db"; import { rssFeeds, veilleItems, aapItems, type RssFeed, } from "../drizzle/schema"; import { eq, sql } from "drizzle-orm"; // ─── Types internes ─────────────────────────────────────────────────────────── interface RssItem { title: string; description?: string; link?: string; pubDate?: string; guid?: string; } interface AutoRule { keyword: string; typeVeille?: "reglementaire" | "concurrentielle" | "technologique" | "generale"; categorieAap?: "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; } interface FetchResult { feedId: number; feedName: string; status: "ok" | "error"; newItems: number; skippedItems: number; mergedItems: number; error?: string; } // ─── Dictionnaire des départements d'Auvergne-Rhône-Alpes ──────────────────── // IMPORTANT : les départements composés (Haute-Loire, Haute-Savoie, Puy-de-Dôme) // doivent être AVANT leurs variantes simples (Loire, Savoie) pour éviter les faux positifs. const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string }> = [ // Composés en premier { pattern: /haute-savoie|haute savoie|(?]*>/g, "") .replace(/&/g, "&") .replace(/</g, "<") .replace(/>/g, ">") .replace(/"/g, '"') .replace(/'/g, "'") .trim(); } function applyAutoRules(title: string, description: string, rules: AutoRule[]): AutoRule | null { const text = (title + " " + description).toLowerCase(); for (const rule of rules) { if (text.includes(rule.keyword.toLowerCase())) { return rule; } } return null; } /** * Ajoute un territoire à la liste JSON existante (sans doublon). * Retourne la nouvelle liste sérialisée. */ function addToTerritoiresList(existing: string | null, newTerritoire: string): string { let list: string[] = []; if (existing) { try { list = JSON.parse(existing); } catch { list = [existing]; } } if (!list.includes(newTerritoire)) { list.push(newTerritoire); } return JSON.stringify(list); } // ─── Parsing RSS/Atom ───────────────────────────────────────────────────────── async function fetchAndParseRss(url: string): Promise { const response = await fetch(url, { headers: { "User-Agent": "Mozilla/5.0 (compatible; VeilleBot/1.0; +https://itinova.fr)", "Accept": "application/rss+xml, application/xml, text/xml, */*", }, signal: AbortSignal.timeout(15000), }); if (!response.ok) { throw new Error(`HTTP ${response.status} ${response.statusText}`); } const xml = await response.text(); const parser = new XMLParser({ ignoreAttributes: false, attributeNamePrefix: "@_", textNodeName: "#text", parseAttributeValue: true, trimValues: true, }); const parsed = parser.parse(xml); const channel = parsed?.rss?.channel; if (channel) { const items = Array.isArray(channel.item) ? channel.item : channel.item ? [channel.item] : []; return items.map((item: any) => ({ title: String(item.title?.["#text"] ?? item.title ?? ""), description: String(item.description?.["#text"] ?? item.description ?? ""), link: String(item.link?.["#text"] ?? item.link ?? item.guid?.["#text"] ?? item.guid ?? ""), pubDate: String(item.pubDate ?? item["dc:date"] ?? ""), guid: String(item.guid?.["#text"] ?? item.guid ?? item.link ?? ""), })); } const feed = parsed?.feed; if (feed) { const entries = Array.isArray(feed.entry) ? feed.entry : feed.entry ? [feed.entry] : []; return entries.map((entry: any) => { const links = Array.isArray(entry.link) ? entry.link : entry.link ? [entry.link] : []; const altLink = links.find((l: any) => l["@_rel"] === "alternate") ?? links[0]; return { title: String(entry.title?.["#text"] ?? entry.title ?? ""), description: String(entry.summary?.["#text"] ?? entry.summary ?? entry.content?.["#text"] ?? ""), link: String(altLink?.["@_href"] ?? ""), pubDate: String(entry.published ?? entry.updated ?? ""), guid: String(entry.id ?? altLink?.["@_href"] ?? ""), }; }); } throw new Error("Format RSS/Atom non reconnu"); } // ─── Traitement d'un flux ───────────────────────────────────────────────────── async function processFeed(feed: RssFeed): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const result: FetchResult = { feedId: feed.id, feedName: feed.name, status: "ok", newItems: 0, skippedItems: 0, mergedItems: 0, }; try { const items = await fetchAndParseRss(feed.url); const rules: AutoRule[] = Array.isArray(feed.autoRules) ? feed.autoRules as AutoRule[] : []; for (const item of items) { const title = stripHtml(item.title || ""); const description = stripHtml(item.description || ""); const link = item.link || item.guid || ""; const pubDate = parseDate(item.pubDate); const fullText = title + " " + description; if (!title) { result.skippedItems++; continue; } // ─── Clé de déduplication : basée sur le titre NORMALISÉ (sans département) ─── const normalizedTitle = buildMergeKey(title); const dedupKey = dedupHash(normalizedTitle + "|" + (feed.feedType ?? "")); if (feed.feedType === "veille") { const matchedRule = applyAutoRules(title, description, rules); const typeVeille = (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "generale") as "reglementaire" | "concurrentielle" | "technologique" | "generale"; const categorie = detectVeilleCategorie(fullText); const { niveau, territoire } = detectVeilleNiveauTerritoire(fullText); try { // Essayer d'insérer await db.insert(veilleItems).values({ dedupKey, titre: title, categorie, niveau, territoire, territoires: territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes" ? [territoire] : [], resume: description || null, source: feed.name, lien: link || null, typeVeille, datePublication: pubDate, }); result.newItems++; } catch (e: any) { if (e?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry")) { // Article existant → ajouter le territoire à la liste si c'est un nouveau département if (territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes") { await db.execute( sql`UPDATE veille_items SET territoires = JSON_ARRAY_APPEND( COALESCE(territoires, JSON_ARRAY()), '$', ${territoire} ) WHERE dedupKey = ${dedupKey} AND NOT JSON_CONTAINS(COALESCE(territoires, JSON_ARRAY()), ${JSON.stringify(territoire)})` ); result.mergedItems++; } else { result.skippedItems++; } } else { throw e; } } } else if (feed.feedType === "aap") { const matchedRule = applyAutoRules(title, description, rules); const categorie = (matchedRule?.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; const { region, departement } = detectAapGeo(fullText); try { await db.insert(aapItems).values({ dedupKey, titre: title, categorie, region, departement, departements: departement ? [departement] : [], lien: link || null, datePublication: pubDate, }); result.newItems++; } catch (e: any) { if (e?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry")) { // Article existant → ajouter le département à la liste if (departement) { await db.execute( sql`UPDATE aap_items SET departements = JSON_ARRAY_APPEND( COALESCE(departements, JSON_ARRAY()), '$', ${departement} ) WHERE dedupKey = ${dedupKey} AND NOT JSON_CONTAINS(COALESCE(departements, JSON_ARRAY()), ${JSON.stringify(departement)})` ); result.mergedItems++; } else { result.skippedItems++; } } else { throw e; } } } } await db.update(rssFeeds) .set({ lastFetchedAt: new Date(), lastFetchStatus: "ok", lastFetchError: null }) .where(eq(rssFeeds.id, feed.id)); } catch (e: any) { result.status = "error"; result.error = e?.message ?? String(e); try { await db.update(rssFeeds) .set({ lastFetchedAt: new Date(), lastFetchStatus: "error", lastFetchError: result.error }) .where(eq(rssFeeds.id, feed.id)); } catch (_) { /* ignore */ } } return result; } // ─── Migration des articles existants ───────────────────────────────────────── export interface MigrationSummary { veilleUpdated: number; veilleMerged: number; aapUpdated: number; aapMerged: number; executedAt: string; } /** * Met à jour et fusionne les articles déjà importés. * - Recalcule catégorie, niveau, territoire pour veille_items * - Recalcule région, département pour aap_items * - Fusionne les articles avec le même titre normalisé */ export async function migrateExistingItems(): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); let veilleUpdated = 0; let veilleMerged = 0; let aapUpdated = 0; let aapMerged = 0; // ─── 1. Recalculer les champs enrichis pour veille_items ────────────────── const veilleRows = await db.select().from(veilleItems); // Grouper par titre normalisé const veilleGroups = new Map(); for (const row of veilleRows) { const normalized = buildMergeKey(row.titre || ""); const key = dedupHash(normalized + "|veille"); if (!veilleGroups.has(key)) veilleGroups.set(key, []); veilleGroups.get(key)!.push(row); } for (const [, group] of Array.from(veilleGroups)) { if (group.length === 1) { // Article unique : mettre à jour les champs enrichis const row = group[0]; const fullText = (row.titre || "") + " " + (row.resume || ""); const newCategorie = detectVeilleCategorie(fullText); const { niveau: newNiveau, territoire: newTerritoire } = detectVeilleNiveauTerritoire(fullText); const normalizedTitle = buildMergeKey(row.titre || ""); const newDedupKey = dedupHash(normalizedTitle + "|veille"); await db.update(veilleItems) .set({ categorie: newCategorie, niveau: newNiveau, territoire: newTerritoire, territoires: newTerritoire !== "France" && newTerritoire !== "Auvergne-Rhône-Alpes" ? [newTerritoire] : [], titre: row.titre, dedupKey: newDedupKey, }) .where(eq(veilleItems.id, row.id)); veilleUpdated++; } else { // Groupe : fusionner en gardant le premier, supprimer les autres const sorted = group.sort((a: (typeof veilleRows)[number], b: (typeof veilleRows)[number]) => a.id - b.id); const primary = sorted[0]; const duplicates = sorted.slice(1); // Collecter tous les territoires const allTerritoires: string[] = []; for (const row of sorted) { const fullText = (row.titre || "") + " " + (row.resume || ""); const { territoire } = detectVeilleNiveauTerritoire(fullText); if (territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes" && !allTerritoires.includes(territoire)) { allTerritoires.push(territoire); } } const fullText = (primary.titre || "") + " " + (primary.resume || ""); const newCategorie = detectVeilleCategorie(fullText); const normalizedTitle = buildMergeKey(primary.titre || ""); const newDedupKey = dedupHash(normalizedTitle + "|veille"); // Mettre à jour le principal await db.update(veilleItems) .set({ categorie: newCategorie, niveau: allTerritoires.length > 1 ? "departemental" : "regional", territoire: allTerritoires.length > 0 ? allTerritoires[0] : "Auvergne-Rhône-Alpes", territoires: allTerritoires, titre: primary.titre, dedupKey: newDedupKey, }) .where(eq(veilleItems.id, primary.id)); // Supprimer les doublons for (const dup of duplicates) { await db.delete(veilleItems).where(eq(veilleItems.id, dup.id)); veilleMerged++; } veilleUpdated++; } } // ─── 2. Recalculer les champs enrichis pour aap_items ──────────────────── const aapRows = await db.select().from(aapItems); // Grouper par titre normalisé const aapGroups = new Map(); for (const row of aapRows) { const normalized = buildMergeKey(row.titre || ""); const key = dedupHash(normalized + "|aap"); if (!aapGroups.has(key)) aapGroups.set(key, []); aapGroups.get(key)!.push(row); } for (const [, group] of Array.from(aapGroups)) { if (group.length === 1) { const row = group[0]; const { region: newRegion, departement: newDept } = detectAapGeo(row.titre || ""); const normalizedTitle = buildMergeKey(row.titre || ""); const newDedupKey = dedupHash(normalizedTitle + "|aap"); await db.update(aapItems) .set({ region: newRegion, departement: newDept, departements: newDept ? [newDept] : [], titre: row.titre, dedupKey: newDedupKey, }) .where(eq(aapItems.id, row.id)); aapUpdated++; } else { // Fusionner const sorted = group.sort((a: (typeof aapRows)[number], b: (typeof aapRows)[number]) => a.id - b.id); const primary = sorted[0]; const duplicates = sorted.slice(1); const allDepts: string[] = []; for (const row of sorted) { const { departement } = detectAapGeo(row.titre || ""); if (departement && !allDepts.includes(departement)) { allDepts.push(departement); } } const normalizedTitle = buildMergeKey(primary.titre || ""); const newDedupKey = dedupHash(normalizedTitle + "|aap"); await db.update(aapItems) .set({ region: "Auvergne-Rhône-Alpes", departement: allDepts.length > 0 ? allDepts[0] : null, departements: allDepts, titre: primary.titre, dedupKey: newDedupKey, }) .where(eq(aapItems.id, primary.id)); for (const dup of duplicates) { await db.delete(aapItems).where(eq(aapItems.id, dup.id)); aapMerged++; } aapUpdated++; } } console.log(`[Migration] Veille: ${veilleUpdated} mis à jour, ${veilleMerged} fusionnés. AAP: ${aapUpdated} mis à jour, ${aapMerged} fusionnés.`); return { veilleUpdated, veilleMerged, aapUpdated, aapMerged, executedAt: new Date().toISOString(), }; } // ─── Point d'entrée principal ───────────────────────────────────────────────── export interface RssFetchSummary { totalFeeds: number; successFeeds: number; errorFeeds: number; totalNewItems: number; totalSkippedItems: number; totalMergedItems: number; results: FetchResult[]; executedAt: string; } export async function runRssFetch(): Promise { const db = await getDb(); if (!db) throw new Error("Database not available"); const feeds = await db.select().from(rssFeeds).where(eq(rssFeeds.isActive, true)); const results: FetchResult[] = []; for (const feed of feeds) { console.log(`[RSS] Lecture du flux: ${feed.name} (${feed.url})`); const result = await processFeed(feed); results.push(result); console.log(`[RSS] ${feed.name}: ${result.newItems} nouveaux, ${result.mergedItems} fusionnés, ${result.skippedItems} doublons, statut: ${result.status}`); } const summary: RssFetchSummary = { totalFeeds: feeds.length, successFeeds: results.filter(r => r.status === "ok").length, errorFeeds: results.filter(r => r.status === "error").length, totalNewItems: results.reduce((acc, r) => acc + r.newItems, 0), totalSkippedItems: results.reduce((acc, r) => acc + r.skippedItems, 0), totalMergedItems: results.reduce((acc, r) => acc + r.mergedItems, 0), results, executedAt: new Date().toISOString(), }; console.log(`[RSS] Terminé: ${summary.totalNewItems} nouveaux, ${summary.totalMergedItems} fusionnés, ${summary.errorFeeds} erreurs`); return summary; }