/** * 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 : * - Classification IA en deux étapes (pertinence + catégorie) avec fallback sur mots-clés * - 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, removeArticleReadRecords } from "./db"; import { rssFeeds, veilleItems, aapItems, processedDedupKeys, type RssFeed, } from "../drizzle/schema"; import { eq, sql } from "drizzle-orm"; import { classifyArticle, classifyAap, generateSummary } from "./aiClassifier"; import { fetchArticleFirstParagraph } from "./articleFetcher"; // ─── Types internes ─────────────────────────────────────────────────────────── interface RssItem { title: string; description?: string; link?: string; pubDate?: string; guid?: string; } interface AutoRule { keyword: string; typeVeille?: "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"; categorieAap?: "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; } interface FetchResult { feedId: number; feedName: string; status: "ok" | "error"; newItems: number; skippedItems: number; mergedItems: number; errorMessage?: string; } // ─── Dictionnaire des départements (AuRA, PACA, Nouvelle-Aquitaine, Occitanie, Bourgogne-FC) ────── // IMPORTANT : les départements composés doivent être AVANT leurs variantes simples // pour éviter les faux positifs (ex. Haute-Loire avant Loire). const AURA_DEPARTMENTS: Array<{ pattern: RegExp; name: string; num: string; region: string }> = [ // ─── AUVERGNE-RHÔNE-ALPES ───────────────────────────────────────────────────────────────── { pattern: /haute-savoie|haute savoie/i, name: "Haute-Savoie", num: "74", region: "Auvergne-Rhône-Alpes" }, { pattern: /haute-loire|haute loire/i, name: "Haute-Loire", num: "43", region: "Auvergne-Rhône-Alpes" }, { pattern: /puy-de-d[oô]me|puy de d[oô]me/i, name: "Puy-de-Dôme", num: "63", region: "Auvergne-Rhône-Alpes" }, { pattern: /\bain\b/i, name: "Ain", num: "01", region: "Auvergne-Rhône-Alpes" }, { pattern: /\ballier\b/i, name: "Allier", num: "03", region: "Auvergne-Rhône-Alpes" }, { pattern: /\bard[eè]che\b/i, name: "Ardèche", num: "07", region: "Auvergne-Rhône-Alpes" }, { pattern: /\bcantal\b/i, name: "Cantal", num: "15", region: "Auvergne-Rhône-Alpes" }, { pattern: /\bdr[oô]me\b/i, name: "Drôme", num: "26", region: "Auvergne-Rhône-Alpes" }, { pattern: /\bis[eè]re\b/i, name: "Isère", num: "38", region: "Auvergne-Rhône-Alpes" }, { pattern: /(? [ d.name.toLowerCase(), d.name.toLowerCase().normalize("NFD").replace(/[\u0300-\u036f]/g, ""), ]).concat([ "métropole de lyon", "metropole de lyon", "lyon", "marseille", "toulouse", "bordeaux", "montpellier", "nice", "dijon", "besançon", "besancon", ]); // Protéger "auvergne-rhône-alpes" et "rhône-alpes" key = key.replace(/auvergne.?rh.?ne.?alpes/gi, "__AURA__"); key = key.replace(/rh.?ne.?alpes/gi, "__RHONEALPES__"); for (const dept of deptNames) { // Supprimer avec prépositions courantes key = key.replace(new RegExp(`(dans\\s+l[ae']?\\s*|en\\s+|du\\s+|de\\s+la\\s+|de\\s+l[ae']?\\s*|et\\s+la\\s+|et\\s+le\\s+|,\\s*)${dept.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, "gi"), " "); key = key.replace(new RegExp(`\\b${dept.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, "gi"), " "); } // Supprimer les numéros de département key = key.replace(/\(\d{2}\)/g, " "); // Restaurer les placeholders key = key.replace(/__AURA__/g, "auvergne-rhone-alpes"); key = key.replace(/__RHONEALPES__/g, "rhone-alpes"); // Supprimer les stop-words et prépositions orphelines const stopWords = ["dans", "l'", "la", "le", "les", "l", "en", "du", "de", "des", "et", "un", "une", "pour", "au", "aux", "par", "sur", "avec", "sans", "ou", "ni"]; for (const sw of stopWords) { key = key.replace(new RegExp(`\\b${sw}\\b`, "gi"), " "); } // Nettoyer et normaliser key = key .replace(/[^a-z0-9à-ÿ\s]/gi, " ") .replace(/\s+/g, " ") .trim(); return key; } /** * Conserve le titre original mais nettoie les artefacts de normalisation * (utilisé uniquement pour l'affichage du titre dans la liste). */ function cleanTitleForDisplay(title: string): string { // Le titre original est conservé tel quel return title.trim(); } // ─── Extraction automatique pour la Veille ─────────────────────────────────── type VeilleCategorie = "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; type VeilleNiveau = "departemental" | "regional" | "national"; /** * Déduit la catégorie d'un article de veille depuis son titre + description. */ export function detectVeilleCategorie(text: string): VeilleCategorie { const t = text.toLowerCase(); if (/\bhandicap\b|im[eé]\b|esat\b|uema\b|ueea\b|autisme\b|polyhandicap\b|mdph\b|rqth\b|inclusion scolaire/i.test(t)) { return "Handicap"; } if (/\bpersonnes?\s+âgées?\b|personnes?\s+agees?\b|ehpad\b|ssiad\b|sad\b|perte\s+d'autonomie\b|autonomie\b|domicile\b|\bcrt\b|centres?\s+de\s+ressources?\s+territoriaux/i.test(t)) { return "PA"; } if (/\benfan[ct]\b|jeune[s]?\b|mineur[s]?\b|maternelle\b|élémentaire\b|scolaire\b|camsp\b|crip\b|protection\s+de\s+l'enfance\b/i.test(t)) { return "Enfance"; } if (/\bprécarité\b|precarite\b|exclusion\b|sans-abri\b|\bsdf\b|emsp\b|équipe\s+mobile\s+santé\s+précarité\b|hébergement\b|pauvreté\b/i.test(t)) { return "Précarité"; } if (/\bsoin[s]?\b|santé\b|sante\b|médical\b|médecin\b|infirmier\b|hôpital\b|hopital\b|clinique\b|vaccination\b|vaccin\b|épidémie\b|virus\b|infection\b|dépistage\b|prévention\b|\bars\b|msp\b|maison\s+de\s+santé\b/i.test(t)) { return "Sanitaire"; } return "Autre"; } /** * Déduit le niveau et le territoire d'un article de veille. * Retourne le niveau le plus fin détecté : départemental > régional > national. */ export function detectVeilleNiveauTerritoire(text: string): { niveau: VeilleNiveau; territoire: string } { const dept = detectDepartment(text); if (dept) { return { niveau: "departemental", territoire: dept.name }; } const region = detectRegion(text); if (region) { return { niveau: "regional", territoire: region }; } return { niveau: "national", territoire: "France" }; } // ─── Extraction automatique pour les AAP ───────────────────────────────────── /** * Détecte le niveau de localisation le plus fin dans le texte : * - Département connu → region = région du département, departement = "Nom (num)" * - Région connue → region = nom de la région, departement = null * - Sinon → region = "National", departement = null */ export function detectAapGeo(text: string): { region: string; departement: string | null } { const dept = detectDepartment(text); if (dept) { return { region: dept.region, departement: `${dept.name} (${dept.num})`, }; } const region = detectRegion(text); if (region) { return { region, departement: null, }; } return { region: "National", departement: null, }; } // ─── Utilitaires ───────────────────────────────────────────────────────────── function dedupHash(text: string): string { return crypto.createHash("sha256").update(text).digest("hex").substring(0, 64); } function parseDate(dateStr?: string): Date | null { if (!dateStr) return null; const d = new Date(dateStr); return isNaN(d.getTime()) ? null : d; } /** * Tente d'extraire une date de clôture depuis le texte d'un AAP. * Patterns reconnus (fr) : * "date limite : 30 septembre 2026" * "clôture le 30/09/2026" * "avant le 30 sept. 2026" * "jusqu'au 30-09-2026" * "dépôt des dossiers : 30 septembre 2026" * "délai de réponse : 30 septembre 2026" * Retourne un objet Date ou null si non détecté. */ const MONTHS_FR: Record = { janvier: 0, février: 1, fevrier: 1, mars: 2, avril: 3, mai: 4, juin: 5, juillet: 6, août: 7, aout: 7, septembre: 8, octobre: 9, novembre: 10, décembre: 11, decembre: 11, janv: 0, févr: 1, fevr: 1, avr: 3, juil: 6, sept: 8, oct: 9, nov: 10, déc: 11, dec: 11, }; function parseFrDate(dateStr: string): Date | null { const s = dateStr.trim(); // Format JJ/MM/AAAA ou JJ-MM-AAAA ou JJ.MM.AAAA const numMatch = s.match(/(\d{1,2})[\/.\-](\d{1,2})[\/.\-](\d{4})/); if (numMatch) { const d = new Date(parseInt(numMatch[3]), parseInt(numMatch[2]) - 1, parseInt(numMatch[1])); return isNaN(d.getTime()) ? null : d; } // Format JJ mois AAAA ou "1er mois AAAA" const textMatch = s.match(/(\d{1,2})(?:er|e)?\s+([a-z\u00e9\u00e8\u00ea\u00e0\u00f9\u00fb]+)\.?\s+(\d{4})/i); if (textMatch) { const month = MONTHS_FR[textMatch[2].toLowerCase().replace(/\.$/, "")]; if (month !== undefined) { const d = new Date(parseInt(textMatch[3]), month, parseInt(textMatch[1])); return isNaN(d.getTime()) ? null : d; } } // Format AAAA-MM-JJ (ISO) const isoMatch = s.match(/(\d{4})-(\d{2})-(\d{2})/); if (isoMatch) { const d = new Date(parseInt(isoMatch[1]), parseInt(isoMatch[2]) - 1, parseInt(isoMatch[3])); return isNaN(d.getTime()) ? null : d; } return null; } // Extrait la première date valide trouvée dans une chaîne function findDateInStr(s: string): Date | null { // Essayer directement const d = parseFrDate(s); if (d) return d; // Chercher un sous-motif date dans la chaîne (utile quand du texte suit la date) const sub = s.match(/(\d{1,2}(?:er|e)?[\s.\/\-][\w\s.]+\d{4}|\d{4}-\d{2}-\d{2}|\d{1,2}[\/\.\-]\d{1,2}[\/\.\-]\d{4})/); if (sub) return parseFrDate(sub[1]); return null; } export function extractDateCloture(text: string): Date | null { // Normaliser : supprimer les retours à la ligne et les espaces multiples const t = text.replace(/[\r\n]+/g, " ").replace(/\s{2,}/g, " "); const patterns: RegExp[] = [ // "date limite (de dépôt|de candidature|d'envoi|de soumission|de remise)? ..." /date\s+limite(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "fin (de dépôt|des candidatures|de soumission|d'inscription|de réception)?" /fin\s+(?:de\s+)?(?:d[eé]p[oô]t|des\s+candidatures|de\s+soumission|d['\u2019]inscription|de\s+r[eé]ception|des\s+dossiers)(?:[^:\d]{0,30})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "clôture (des candidatures|des dossiers|de l'appel|le|au|:)?" /cl[oô]ture(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "clôture le ..." /cl[oô]ture\s+le\s+([\d][\w\s.\/\-]+\d{4})/i, // "avant le ..." /avant\s+le\s+([\d][\w\s.\/\-]+\d{4})/i, // "jusqu'au ..." /jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i, // "dépôt (des dossiers|des candidatures|des projets)? ..." /d[eé]p[oô]t(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "délai (de réponse|de dépôt|de soumission)? ..." /d[eé]lai(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "réception (des dossiers|des candidatures)? ..." /r[eé]ception(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "soumission (des candidatures|des projets)? ..." /soumission(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "remise (des offres|des dossiers|des candidatures)? ..." /remise(?:[^:\d]{0,50})?[:\s]+([\d][\w\s.\/\-]+\d{4})/i, // "candidatures (attendues|acceptées|ouvertes)? jusqu'au ..." /candidatures?(?:[^\d]{0,40})?jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i, // "répondre avant le ..." /r[eé]pondre\s+avant\s+le\s+([\d][\w\s.\/\-]+\d{4})/i, // "ouvert jusqu'au ..." /ouvert(?:e)?\s+jusqu['\u2019]au\s+([\d][\w\s.\/\-]+\d{4})/i, ]; for (const pattern of patterns) { const match = t.match(pattern); if (match?.[1]) { const d = findDateInStr(match[1].trim()); if (d && d.getFullYear() >= 2020 && d.getFullYear() <= 2035) return d; } } return null; } function stripHtml(html: string): string { return html .replace(/<[^>]*>/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; } // ─── Filtre de date minimale : ignorer les articles trop anciens ────────────── const MIN_DATE = new Date('2026-04-01'); if (pubDate && pubDate < MIN_DATE) { 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 ?? "")); // ─── Tombstone : ignorer les articles déjà traités (même supprimés) ───────── const tombstone = await db.select().from(processedDedupKeys) .where(eq(processedDedupKeys.dedupKey, dedupKey)) .limit(1); if (tombstone.length > 0) { result.skippedItems++; continue; } if (feed.feedType === "veille") { // ── 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, () => { const matchedRule = applyAutoRules(title, description, rules); return { typeVeille: (matchedRule?.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle", }; }, contenuPage ); const typeVeille = (aiResult.typeVeille ?? feed.defaultTypeVeille ?? "informationnelle") as "reglementaire" | "concurrentielle" | "technologique" | "informationnelle"; // ── Filtre de pertinence : ne pas insérer les articles non pertinents ────── if (!aiResult.relevant) { console.log(`[RSS] Article rejeté (non pertinent) : "${title.substring(0, 80)}" — ${aiResult.reason}`); result.skippedItems++; continue; } // Générer le résumé IA uniquement pour les articles pertinents const iaResume = await generateSummary(title, description); try { // Essayer d'insérer await db.insert(veilleItems).values({ dedupKey, titre: title, niveau, territoire, territoires: JSON.stringify(territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes" ? [territoire] : []), resume: description || null, source: feed.name, lien: link || null, typeVeille, datePublication: pubDate, iaRelevant: aiResult.relevant, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason, iaResume: iaResume || null, }); // Enregistrer le tombstone pour éviter la réinsertion après purge await db.insert(processedDedupKeys).values({ dedupKey, feedType: "veille" }).onDuplicateKeyUpdate({ set: { dedupKey } }); result.newItems++; } catch (e: any) { if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.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 { console.error(`[RSS DEBUG veille] code=${e?.code} errno=${e?.errno} sqlMsg=${e?.sqlMessage} msg=${String(e?.message).substring(0,200)}`); throw e; } } } else if (feed.feedType === "aap") { // ── 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 classifyAap( title, description, (feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre", contenuPageAap ); const categorie = (aiResult.categorieAap ?? feed.defaultCategorieAap ?? "Autre") as "Handicap" | "PA" | "Enfance" | "Précarité" | "Sanitaire" | "Autre"; // ── Filtre de pertinence : ne pas insérer les AAP non pertinents ───────── if (!aiResult.relevant) { console.log(`[RSS] AAP rejeté (non pertinent) : "${title.substring(0, 80)}" — ${aiResult.reason}`); result.skippedItems++; continue; } // Générer le résumé IA pour les AAP pertinents const iaResume = await generateSummary(title, description); // Extraire la date de clôture depuis le texte const dateCloture = extractDateCloture(fullText); try { await db.insert(aapItems).values({ dedupKey, titre: title, categorie, region, departement, departements: JSON.stringify(departement ? [departement] : []), lien: link || null, datePublication: pubDate, dateCloture: dateCloture || null, iaRelevant: aiResult.relevant, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason, iaResume: iaResume || null, }); // Enregistrer le tombstone pour éviter la réinsertion après purge await db.insert(processedDedupKeys).values({ dedupKey, feedType: "aap" }).onDuplicateKeyUpdate({ set: { dedupKey } }); result.newItems++; } catch (e: any) { if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.message?.includes("Duplicate entry") || e?.cause?.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.errorMessage = e?.message ?? String(e); try { await db.update(rssFeeds) .set({ lastFetchedAt: new Date(), lastFetchStatus: "error", lastFetchError: result.errorMessage }) .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"); try { await db.update(veilleItems) .set({ categorie: newCategorie, niveau: newNiveau, territoire: newTerritoire, territoires: JSON.stringify(newTerritoire !== "France" && newTerritoire !== "Auvergne-Rhône-Alpes" ? [newTerritoire] : []), titre: row.titre, dedupKey: newDedupKey, }) .where(eq(veilleItems.id, row.id)); veilleUpdated++; } catch (e: any) { // Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) { await removeArticleReadRecords("veille", [row.id]); await db.delete(veilleItems).where(eq(veilleItems.id, row.id)); veilleMerged++; } else { throw e; } } } 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 try { await db.update(veilleItems) .set({ categorie: newCategorie, niveau: allTerritoires.length > 1 ? "departemental" : "regional", territoire: allTerritoires.length > 0 ? allTerritoires[0] : "Auvergne-Rhône-Alpes", territoires: JSON.stringify(allTerritoires), titre: primary.titre, dedupKey: newDedupKey, }) .where(eq(veilleItems.id, primary.id)); // Supprimer les doublons for (const dup of duplicates) { await removeArticleReadRecords("veille", [dup.id]); await db.delete(veilleItems).where(eq(veilleItems.id, dup.id)); veilleMerged++; } veilleUpdated++; } catch (e: any) { // Si le newDedupKey existe déjà → supprimer tout le groupe if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) { for (const row of sorted) { await removeArticleReadRecords("veille", [row.id]); await db.delete(veilleItems).where(eq(veilleItems.id, row.id)); veilleMerged++; } } else { throw e; } } } } // ─── 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"); try { await db.update(aapItems) .set({ region: newRegion, departement: newDept, departements: JSON.stringify(newDept ? [newDept] : []), titre: row.titre, dedupKey: newDedupKey, }) .where(eq(aapItems.id, row.id)); aapUpdated++; } catch (e: any) { // Si le nouveau dedupKey existe déjà → cet article est un doublon, le supprimer if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) { await removeArticleReadRecords("aap", [row.id]); await db.delete(aapItems).where(eq(aapItems.id, row.id)); aapMerged++; } else { throw e; } } } 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"); try { await db.update(aapItems) .set({ region: "Auvergne-Rhône-Alpes", departement: allDepts.length > 0 ? allDepts[0] : null, departements: JSON.stringify(allDepts), titre: primary.titre, dedupKey: newDedupKey, }) .where(eq(aapItems.id, primary.id)); for (const dup of duplicates) { await removeArticleReadRecords("aap", [dup.id]); await db.delete(aapItems).where(eq(aapItems.id, dup.id)); aapMerged++; } aapUpdated++; } catch (e: any) { // Si le newDedupKey existe déjà → supprimer tout le groupe if (e?.code === "ER_DUP_ENTRY" || e?.cause?.code === "ER_DUP_ENTRY" || e?.cause?.message?.includes("Duplicate entry")) { for (const row of sorted) { await removeArticleReadRecords("aap", [row.id]); await db.delete(aapItems).where(eq(aapItems.id, row.id)); aapMerged++; } } else { throw e; } } } } 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; }