diff --git a/client/public/__manus__/version.json b/client/public/__manus__/version.json index b76fc12..38c241b 100644 --- a/client/public/__manus__/version.json +++ b/client/public/__manus__/version.json @@ -1,4 +1,4 @@ { - "version": "598c445e", - "timestamp": 1782826461485 + "version": "4a018fb6", + "timestamp": 1782828810514 } \ No newline at end of file diff --git a/server/rssEngine.ts b/server/rssEngine.ts index 90f8289..136243a 100644 --- a/server/rssEngine.ts +++ b/server/rssEngine.ts @@ -298,6 +298,74 @@ function parseDate(dateStr?: string): Date | null { 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 { + // Format JJ/MM/AAAA ou JJ-MM-AAAA + const numMatch = dateStr.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 (ex: "30 septembre 2026") + const textMatch = dateStr.match(/(\d{1,2})\s+([a-zéèêàù]+)\.?\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; + } + } + return null; +} + +export function extractDateCloture(text: string): Date | null { + const patterns = [ + // "date limite (de dépôt|de candidature|d'envoi|de soumission)? : ..." + /date\s+limite(?:[^:]{0,40})?:\s*([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "clôture (des candidatures|des dossiers|le|au)? : ..." + /cl[oô]ture(?:[^:]{0,40})?:\s*([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "clôture le ..." + /cl[oô]ture\s+le\s+([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "avant le ..." + /avant\s+le\s+([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "jusqu'au ..." + /jusqu['\u2019]au\s+([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "dépôt des dossiers : ..." + /d[eé]p[oô]t(?:[^:]{0,40})?:\s*([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "délai de réponse : ..." + /d[eé]lai(?:[^:]{0,40})?:\s*([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "réception des dossiers : ..." + /r[eé]ception(?:[^:]{0,40})?:\s*([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + // "soumission (des candidatures)? : ..." + /soumission(?:[^:]{0,40})?:\s*([\d]{1,2}[\s\/\-][\w\s.]+\d{4})/i, + ]; + + for (const pattern of patterns) { + const match = text.match(pattern); + if (match?.[1]) { + const d = parseFrDate(match[1].trim()); + if (d && d.getFullYear() >= 2020 && d.getFullYear() <= 2035) return d; + } + } + return null; +} + function stripHtml(html: string): string { return html .replace(/<[^>]*>/g, "") @@ -534,6 +602,9 @@ async function processFeed(feed: RssFeed): Promise { // 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, @@ -544,6 +615,7 @@ async function processFeed(feed: RssFeed): Promise { departements: JSON.stringify(departement ? [departement] : []), lien: link || null, datePublication: pubDate, + dateCloture: dateCloture || null, iaRelevant: aiResult.relevant, iaClassifiedBy: aiResult.classifiedBy, iaReason: aiResult.reason,