958 lines
43 KiB
TypeScript
958 lines
43 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 :
|
|
* - 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 } from "./db";
|
|
import {
|
|
rssFeeds,
|
|
veilleItems,
|
|
aapItems,
|
|
type RssFeed,
|
|
} from "../drizzle/schema";
|
|
import { eq, sql } from "drizzle-orm";
|
|
import { classifyArticle, 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|(?<!\d)74(?!\d)|\(74\)/i, name: "Haute-Savoie", num: "74", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /haute-loire|haute loire|(?<!\d)43(?!\d)|\(43\)/i, name: "Haute-Loire", num: "43", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /puy-de-d[oô]me|puy de d[oô]me|(?<!\d)63(?!\d)|\(63\)/i, name: "Puy-de-Dôme", num: "63", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\bain\b|(?<!\d)01(?!\d)|\(01\)/i, name: "Ain", num: "01", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\ballier\b|(?<!\d)03(?!\d)|\(03\)/i, name: "Allier", num: "03", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\bard[eè]che\b|(?<!\d)07(?!\d)|\(07\)/i, name: "Ardèche", num: "07", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\bcantal\b|(?<!\d)15(?!\d)|\(15\)/i, name: "Cantal", num: "15", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\bdr[oô]me\b|(?<!\d)26(?!\d)|\(26\)/i, name: "Drôme", num: "26", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\bis[eè]re\b|(?<!\d)38(?!\d)|\(38\)/i, name: "Isère", num: "38", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /(?<!haute-)\bloire\b|(?<!\d)42(?!\d)|\(42\)/i, name: "Loire", num: "42", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\brhone\b|\brhône\b|(?<!\d)69(?!\d)|\(69\)|m[eé]tropole\s+de\s+lyon/i, name: "Rhône", num: "69", region: "Auvergne-Rhône-Alpes" },
|
|
{ pattern: /\bsavoie\b(?!.*haute)|(?<!\d)73(?!\d)|\(73\)/i, name: "Savoie", num: "73", region: "Auvergne-Rhône-Alpes" },
|
|
|
|
// ─── PROVENCE-ALPES-CÔTE D'AZUR (PACA) ────────────────────────────────────────────────────
|
|
{ pattern: /alpes-de-haute-provence|alpes de haute provence|(?<!\d)04(?!\d)|\(04\)/i, name: "Alpes-de-Haute-Provence", num: "04", region: "Provence-Alpes-Côte d'Azur" },
|
|
{ pattern: /hautes-alpes|hautes alpes|(?<!\d)05(?!\d)|\(05\)/i, name: "Hautes-Alpes", num: "05", region: "Provence-Alpes-Côte d'Azur" },
|
|
{ pattern: /alpes-maritimes|alpes maritimes|(?<!\d)06(?!\d)|\(06\)/i, name: "Alpes-Maritimes", num: "06", region: "Provence-Alpes-Côte d'Azur" },
|
|
{ pattern: /bouches-du-rh[oô]ne|bouches du rh[oô]ne|(?<!\d)13(?!\d)|\(13\)|marseille/i, name: "Bouches-du-Rhône", num: "13", region: "Provence-Alpes-Côte d'Azur" },
|
|
{ pattern: /\bvar\b|(?<!\d)83(?!\d)|\(83\)/i, name: "Var", num: "83", region: "Provence-Alpes-Côte d'Azur" },
|
|
{ pattern: /\bvaucluse\b|(?<!\d)84(?!\d)|\(84\)/i, name: "Vaucluse", num: "84", region: "Provence-Alpes-Côte d'Azur" },
|
|
|
|
// ─── NOUVELLE-AQUITAINE ───────────────────────────────────────────────────────────────────────────
|
|
{ pattern: /charente-maritime|charente maritime|(?<!\d)17(?!\d)|\(17\)/i, name: "Charente-Maritime", num: "17", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\bcharente\b|(?<!\d)16(?!\d)|\(16\)/i, name: "Charente", num: "16", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\bcorr[eè]ze\b|(?<!\d)19(?!\d)|\(19\)/i, name: "Corrèze", num: "19", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\bcreuse\b|(?<!\d)23(?!\d)|\(23\)/i, name: "Creuse", num: "23", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\bdordogne\b|(?<!\d)24(?!\d)|\(24\)/i, name: "Dordogne", num: "24", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\bgironde\b|(?<!\d)33(?!\d)|\(33\)|bordeaux/i, name: "Gironde", num: "33", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\blandes\b|(?<!\d)40(?!\d)|\(40\)/i, name: "Landes", num: "40", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /lot-et-garonne|lot et garonne|(?<!\d)47(?!\d)|\(47\)/i, name: "Lot-et-Garonne", num: "47", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /pyr[eé]n[eé]es-atlantiques|pyr[eé]n[eé]es atlantiques|(?<!\d)64(?!\d)|\(64\)/i, name: "Pyrénées-Atlantiques", num: "64", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /deux-s[eè]vres|deux s[eè]vres|(?<!\d)79(?!\d)|\(79\)/i, name: "Deux-Sèvres", num: "79", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /haute-vienne|haute vienne|(?<!\d)87(?!\d)|\(87\)/i, name: "Haute-Vienne", num: "87", region: "Nouvelle-Aquitaine" },
|
|
{ pattern: /\bvienne\b|(?<!\d)86(?!\d)|\(86\)/i, name: "Vienne", num: "86", region: "Nouvelle-Aquitaine" },
|
|
|
|
// ─── OCCITANIE ───────────────────────────────────────────────────────────────────────────────────
|
|
{ pattern: /\bari[eè]ge\b|(?<!\d)09(?!\d)|\(09\)/i, name: "Ariège", num: "09", region: "Occitanie" },
|
|
{ pattern: /\baude\b|(?<!\d)11(?!\d)|\(11\)/i, name: "Aude", num: "11", region: "Occitanie" },
|
|
{ pattern: /\baveyron\b|(?<!\d)12(?!\d)|\(12\)/i, name: "Aveyron", num: "12", region: "Occitanie" },
|
|
{ pattern: /\bgard\b|(?<!\d)30(?!\d)|\(30\)/i, name: "Gard", num: "30", region: "Occitanie" },
|
|
{ pattern: /haute-garonne|haute garonne|(?<!\d)31(?!\d)|\(31\)|toulouse/i, name: "Haute-Garonne", num: "31", region: "Occitanie" },
|
|
{ pattern: /\bgers\b|(?<!\d)32(?!\d)|\(32\)/i, name: "Gers", num: "32", region: "Occitanie" },
|
|
{ pattern: /\bh[eé]rault\b|(?<!\d)34(?!\d)|\(34\)|montpellier/i, name: "Hérault", num: "34", region: "Occitanie" },
|
|
{ pattern: /\blot\b|(?<!\d)46(?!\d)|\(46\)/i, name: "Lot", num: "46", region: "Occitanie" },
|
|
{ pattern: /\bloz[eè]re\b|(?<!\d)48(?!\d)|\(48\)/i, name: "Lozère", num: "48", region: "Occitanie" },
|
|
{ pattern: /hautes-pyr[eé]n[eé]es|hautes pyr[eé]n[eé]es|(?<!\d)65(?!\d)|\(65\)/i, name: "Hautes-Pyrénées", num: "65", region: "Occitanie" },
|
|
{ pattern: /pyr[eé]n[eé]es-orientales|pyr[eé]n[eé]es orientales|(?<!\d)66(?!\d)|\(66\)/i, name: "Pyrénées-Orientales", num: "66", region: "Occitanie" },
|
|
{ pattern: /tarn-et-garonne|tarn et garonne|(?<!\d)82(?!\d)|\(82\)/i, name: "Tarn-et-Garonne", num: "82", region: "Occitanie" },
|
|
{ pattern: /\btarn\b|(?<!\d)81(?!\d)|\(81\)/i, name: "Tarn", num: "81", region: "Occitanie" },
|
|
|
|
// ─── BOURGOGNE-FRANCHE-COMTÉ ────────────────────────────────────────────────────────────────────
|
|
{ pattern: /c[oô]te-d.or|c[oô]te d.or|(?<!\d)21(?!\d)|\(21\)|dijon/i, name: "Côte-d'Or", num: "21", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /\bdoubs\b|(?<!\d)25(?!\d)|\(25\)|besan[cç]on/i, name: "Doubs", num: "25", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /\bjura\b|(?<!\d)39(?!\d)|\(39\)/i, name: "Jura", num: "39", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /\bni[eè]vre\b|(?<!\d)58(?!\d)|\(58\)/i, name: "Nièvre", num: "58", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /haute-sa[oô]ne|haute sa[oô]ne|(?<!\d)70(?!\d)|\(70\)/i, name: "Haute-Saône", num: "70", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /sa[oô]ne-et-loire|sa[oô]ne et loire|(?<!\d)71(?!\d)|\(71\)/i, name: "Saône-et-Loire", num: "71", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /\byonne\b|(?<!\d)89(?!\d)|\(89\)/i, name: "Yonne", num: "89", region: "Bourgogne-Franche-Comté" },
|
|
{ pattern: /territoire-de-belfort|territoire de belfort|belfort|(?<!\d)90(?!\d)|\(90\)/i, name: "Territoire de Belfort", num: "90", region: "Bourgogne-Franche-Comté" },
|
|
];
|
|
|
|
/**
|
|
* Détecte le département dans un texte.
|
|
* Retourne { name, num, region } ou null si non trouvé.
|
|
*/
|
|
export function detectDepartment(text: string): { name: string; num: string; region: string } | null {
|
|
for (const dept of AURA_DEPARTMENTS) {
|
|
if (dept.pattern.test(text)) {
|
|
return { name: dept.name, num: dept.num, region: dept.region };
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
/**
|
|
* Détecte si le texte mentionne une des régions couvertes.
|
|
* Retourne le nom de la région ou null.
|
|
*/
|
|
function detectRegion(text: string): string | null {
|
|
if (/auvergne.?rh.?ne.?alpes|\baura\b|a\.r\.a\.|ars\s+auvergne|rh.?ne.?alpes/i.test(text)) return "Auvergne-Rhône-Alpes";
|
|
if (/provence.?alpes.?c.?te.?d.?azur|\bpaca\b|ars\s+paca|ars\s+provence/i.test(text)) return "Provence-Alpes-Côte d'Azur";
|
|
if (/nouvelle.?aquitaine|ars\s+nouvelle.?aquitaine/i.test(text)) return "Nouvelle-Aquitaine";
|
|
if (/\boccitanie\b|ars\s+occitanie/i.test(text)) return "Occitanie";
|
|
if (/bourgogne.?franche.?comt[eé]|ars\s+bourgogne/i.test(text)) return "Bourgogne-Franche-Comté";
|
|
return null;
|
|
}
|
|
|
|
/** @deprecated Utiliser detectRegion à la place */
|
|
function isRegional(text: string): boolean {
|
|
return detectRegion(text) !== null;
|
|
}
|
|
|
|
// ─── 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 (composites d'abord)
|
|
const deptNames = AURA_DEPARTMENTS.flatMap(d => [
|
|
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<string, number> = {
|
|
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<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") {
|
|
// ── 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,
|
|
});
|
|
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 classifyArticle(
|
|
title,
|
|
description,
|
|
() => ({
|
|
typeVeille: "informationnelle" as const,
|
|
}),
|
|
contenuPageAap
|
|
);
|
|
|
|
const categorie = (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,
|
|
});
|
|
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<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");
|
|
|
|
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 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 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 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<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");
|
|
|
|
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 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 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 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<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;
|
|
}
|