Files
veille-reglementaire/server/rssEngine.ts

677 lines
25 KiB
TypeScript

/**
* 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|(?<!\d)74(?!\d)|\(74\)/i, name: "Haute-Savoie", num: "74" },
{ pattern: /haute-loire|haute loire|(?<!\d)43(?!\d)|\(43\)/i, name: "Haute-Loire", num: "43" },
{ pattern: /puy-de-d[oô]me|puy de d[oô]me|(?<!\d)63(?!\d)|\(63\)/i, name: "Puy-de-Dôme", num: "63" },
// Simples ensuite
{ pattern: /\bain\b|(?<!\d)01(?!\d)|\(01\)/i, name: "Ain", num: "01" },
{ pattern: /\ballier\b|(?<!\d)03(?!\d)|\(03\)/i, name: "Allier", num: "03" },
{ pattern: /\bard[eè]che\b|(?<!\d)07(?!\d)|\(07\)/i, name: "Ardèche", num: "07" },
{ pattern: /\bcantal\b|(?<!\d)15(?!\d)|\(15\)/i, name: "Cantal", num: "15" },
{ pattern: /\bdr[oô]me\b|(?<!\d)26(?!\d)|\(26\)/i, name: "Drôme", num: "26" },
{ pattern: /\bis[eè]re\b|(?<!\d)38(?!\d)|\(38\)/i, name: "Isère", num: "38" },
// Loire : exclure "Haute-Loire" déjà traité
{ pattern: /(?<!haute-)\bloire\b|(?<!\d)42(?!\d)|\(42\)/i, name: "Loire", num: "42" },
{ pattern: /\brhone\b|\brhône\b|(?<!\d)69(?!\d)|\(69\)|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69" },
// Savoie : exclure "Haute-Savoie" déjà traité
{ pattern: /\bsavoie\b(?!.*haute)|(?<!\d)73(?!\d)|\(73\)/i, name: "Savoie", num: "73" },
];
/**
* Détecte le département dans un texte.
* Retourne { name, num } ou null si non trouvé.
*/
export function detectDepartment(text: string): { name: string; num: string } | null {
for (const dept of AURA_DEPARTMENTS) {
if (dept.pattern.test(text)) {
return { name: dept.name, num: dept.num };
}
}
return null;
}
/**
* Détecte si le texte mentionne la région Auvergne-Rhône-Alpes.
*/
function isRegional(text: string): boolean {
return /auvergne.?rh.?ne.?alpes|aura\b|a\.r\.a\.|ars\s+auvergne/i.test(text);
}
// ─── Normalisation du titre pour la fusion ────────────────────────────────────
/**
* Crée une clé de fusion en extrayant uniquement les mots significatifs du titre
* (sans noms de départements, sans stop-words, en minuscules).
* Cette clé est utilisée pour grouper les articles similaires.
* Le titre original est conservé tel quel pour l'affichage.
*/
function buildMergeKey(title: string): string {
let key = title.toLowerCase();
// Supprimer les noms de départements AuRA (composites d'abord)
const deptNames = [
"haute-savoie", "haute savoie", "haute-loire", "haute loire",
"puy-de-dôme", "puy-de-dome", "puy de dôme", "puy de dome",
"ain", "allier", "ardèche", "ardeche", "cantal",
"drôme", "drome", "isère", "isere", "loire",
"rhône", "rhone", "savoie",
"métropole de lyon", "metropole de lyon", "lyon",
"allier",
];
// 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.
*/
export function detectVeilleNiveauTerritoire(text: string): { niveau: VeilleNiveau; territoire: string } {
const dept = detectDepartment(text);
if (dept) {
return { niveau: "departemental", territoire: dept.name };
}
if (isRegional(text)) {
return { niveau: "regional", territoire: "Auvergne-Rhône-Alpes" };
}
return { niveau: "national", territoire: "France" };
}
// ─── Extraction automatique pour les AAP ─────────────────────────────────────
export function detectAapGeo(text: string): { region: string; departement: string | null } {
const dept = detectDepartment(text);
return {
region: "Auvergne-Rhône-Alpes",
departement: dept ? `${dept.name} (${dept.num})` : 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;
}
function stripHtml(html: string): string {
return html
.replace(/<[^>]*>/g, "")
.replace(/&amp;/g, "&")
.replace(/&lt;/g, "<")
.replace(/&gt;/g, ">")
.replace(/&quot;/g, '"')
.replace(/&#39;/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<RssItem[]> {
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<FetchResult> {
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: JSON.stringify(territoire !== "France" && territoire !== "Auvergne-Rhône-Alpes"
? [territoire]
: []) as any,
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: JSON.stringify(departement ? [departement] : []) as any,
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<MigrationSummary> {
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<string, (typeof veilleRows)[number][]>();
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: JSON.stringify(newTerritoire !== "France" && newTerritoire !== "Auvergne-Rhône-Alpes"
? [newTerritoire] : []) as any,
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: JSON.stringify(allTerritoires) as any,
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<string, (typeof aapRows)[number][]>();
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: JSON.stringify(newDept ? [newDept] : []) as any,
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: JSON.stringify(allDepts) as any,
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<RssFetchSummary> {
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;
}