#!/usr/bin/env node /** * Serveur backend pour le traitement des documents * Gère l'OCR, l'extraction NER et renvoie du JSON au frontend */ const express = require('express') const multer = require('multer') const cors = require('cors') const path = require('path') const fs = require('fs') const crypto = require('crypto') const { createWorker } = require('tesseract.js') const { preprocessImageForOCR, analyzeImageMetadata } = require('./imagePreprocessing') const pdf = require('pdf-parse') const app = express() const PORT = process.env.PORT || 3001 // Middleware app.use(cors()) app.use(express.json()) app.use(express.static('public')) // Fonction pour calculer le hash d'un fichier function calculateFileHash(buffer) { return crypto.createHash('sha256').update(buffer).digest('hex') } // Fonction pour générer un hash de dossier function generateFolderHash() { return crypto.randomBytes(16).toString('hex') } // Fonction pour créer la structure de dossiers function createFolderStructure(folderHash) { const folderPath = path.join('uploads', folderHash) const cachePath = path.join('cache', folderHash) if (!fs.existsSync(folderPath)) { fs.mkdirSync(folderPath, { recursive: true }) } if (!fs.existsSync(cachePath)) { fs.mkdirSync(cachePath, { recursive: true }) } return { folderPath, cachePath } } // Fonction pour sauvegarder le cache JSON dans un dossier spécifique function saveJsonCacheInFolder(folderHash, fileHash, result) { const { cachePath } = createFolderStructure(folderHash) const cacheFile = path.join(cachePath, `${fileHash}.json`) try { fs.writeFileSync(cacheFile, JSON.stringify(result, null, 2)) console.log(`[CACHE] Résultat sauvegardé dans le dossier ${folderHash}: ${fileHash}`) return true } catch (error) { console.error(`[CACHE] Erreur lors de la sauvegarde dans le dossier ${folderHash}:`, error) return false } } // Fonction pour récupérer le cache JSON depuis un dossier spécifique function getJsonCacheFromFolder(folderHash, fileHash) { const cachePath = path.join('cache', folderHash) const cacheFile = path.join(cachePath, `${fileHash}.json`) if (fs.existsSync(cacheFile)) { try { const data = fs.readFileSync(cacheFile, 'utf8') const result = JSON.parse(data) console.log(`[CACHE] Résultat récupéré depuis le dossier ${folderHash}: ${fileHash}`) return result } catch (error) { console.error(`[CACHE] Erreur lors de la lecture depuis le dossier ${folderHash}:`, error) return null } } return null } // Fonction pour lister tous les résultats d'un dossier function listFolderResults(folderHash) { const cachePath = path.join('cache', folderHash) if (!fs.existsSync(cachePath)) { return [] } const files = fs.readdirSync(cachePath) const results = [] for (const file of files) { if (file.endsWith('.json')) { const fileHash = path.basename(file, '.json') const result = getJsonCacheFromFolder(folderHash, fileHash) if (result) { results.push({ fileHash, ...result }) } } } return results } // Fonction pour vérifier si un fichier existe déjà par hash dans un dossier function findExistingFileByHash(hash, folderHash) { const folderPath = path.join('uploads', folderHash) if (!fs.existsSync(folderPath)) return null const files = fs.readdirSync(folderPath) for (const file of files) { // Vérifier si le nom de fichier commence par le hash if (file.startsWith(hash)) { const filePath = path.join(folderPath, file) return { path: filePath, name: file } } } return null } // Fonction pour sauvegarder le cache JSON function saveJsonCache(hash, result) { const cacheDir = 'cache/' if (!fs.existsSync(cacheDir)) { fs.mkdirSync(cacheDir, { recursive: true }) } const cacheFile = path.join(cacheDir, `${hash}.json`) try { fs.writeFileSync(cacheFile, JSON.stringify(result, null, 2)) console.log(`[CACHE] Résultat sauvegardé: ${hash.substring(0, 16)}...`) return true } catch (error) { console.error(`[CACHE] Erreur lors de la sauvegarde:`, error.message) return false } } // Fonction pour récupérer le cache JSON function getJsonCache(hash) { const cacheFile = path.join('cache/', `${hash}.json`) try { if (fs.existsSync(cacheFile)) { const cachedData = fs.readFileSync(cacheFile, 'utf8') const result = JSON.parse(cachedData) console.log(`[CACHE] Résultat récupéré: ${hash.substring(0, 16)}...`) return result } } catch (error) { console.warn(`[CACHE] Erreur lors de la lecture du cache:`, error.message) } return null } // Fonction pour lister les fichiers de cache function listCacheFiles() { const cacheDir = 'cache/' if (!fs.existsSync(cacheDir)) return [] const files = fs.readdirSync(cacheDir) return files.map(file => { const filePath = path.join(cacheDir, file) try { const stats = fs.statSync(filePath) const hash = path.basename(file, '.json') return { hash: hash, fileName: file, size: stats.size, createdDate: stats.birthtime, modifiedDate: stats.mtime } } catch (error) { console.warn(`[CACHE] Erreur lors de la lecture de ${file}:`, error.message) return null } }).filter(file => file !== null) } // Configuration multer pour l'upload de fichiers avec hash comme nom const storage = multer.diskStorage({ destination: (req, file, cb) => { const uploadDir = 'uploads/' if (!fs.existsSync(uploadDir)) { fs.mkdirSync(uploadDir, { recursive: true }) } cb(null, uploadDir) }, filename: (req, file, cb) => { // Utiliser un nom temporaire, le hash sera calculé après const timestamp = Date.now() const ext = path.extname(file.originalname) cb(null, `temp-${timestamp}${ext}`) } }) const upload = multer({ storage, limits: { fileSize: 10 * 1024 * 1024 }, // 10MB max fileFilter: (req, file, cb) => { const allowedTypes = ['image/jpeg', 'image/png', 'image/tiff', 'application/pdf'] if (allowedTypes.includes(file.mimetype)) { cb(null, true) } else { cb(new Error('Type de fichier non supporté'), false) } } }) // Fonction d'extraction de texte depuis un PDF async function extractTextFromPdf(pdfPath) { console.log(`[PDF] Début de l'extraction de texte pour: ${path.basename(pdfPath)}`) try { const dataBuffer = fs.readFileSync(pdfPath) const data = await pdf(dataBuffer) console.log(`[PDF] Texte extrait: ${data.text.length} caractères`) console.log(`[PDF] Nombre de pages: ${data.numpages}`) return { text: data.text, confidence: 95, // PDF text extraction est très fiable words: data.text.split(/\s+/).filter(word => word.length > 0) } } catch (error) { console.error(`[PDF] Erreur lors de l'extraction:`, error.message) throw error } } // Fonction d'extraction OCR optimisée avec préprocessing async function extractTextFromImage(imagePath) { console.log(`[OCR] Début de l'extraction pour: ${imagePath}`) // Analyse des métadonnées de l'image const metadata = await analyzeImageMetadata(imagePath) // Préprocessing de l'image pour améliorer l'OCR console.log(`[OCR] Préprocessing de l'image...`) const preprocessedBuffer = await preprocessImageForOCR(imagePath, null, { width: 2000, contrast: 1.5, brightness: 1.1, grayscale: true, sharpen: true, denoise: true }) // Sauvegarde temporaire de l'image préprocessée const tempPath = imagePath.replace(/\.[^/.]+$/, '_preprocessed.png') await fs.promises.writeFile(tempPath, preprocessedBuffer) console.log(`[OCR] Image préprocessée sauvegardée: ${tempPath}`) const worker = await createWorker('fra+eng') try { // Stratégie multi-modes pour améliorer la détection const strategies = [ { name: 'Mode Standard', params: { tessedit_pageseg_mode: '6', tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ', tessedit_ocr_engine_mode: '1', preserve_interword_spaces: '1', textord_min_linesize: '2.0', textord_min_xheight: '6' } }, { name: 'Mode Fine', params: { tessedit_pageseg_mode: '8', // Mot unique tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ', tessedit_ocr_engine_mode: '1', textord_min_linesize: '1.0', textord_min_xheight: '4', textord_heavy_nr: '0' } }, { name: 'Mode Ligne', params: { tessedit_pageseg_mode: '13', // Ligne brute de texte tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789ÀÁÂÃÄÅÆÇÈÉÊËÌÍÎÏÐÑÒÓÔÕÖØÙÚÛÜÝÞßàáâãäåæçèéêëìíîïðñòóôõöøùúûüýþÿ.,/-:() ', tessedit_ocr_engine_mode: '1', textord_min_linesize: '1.5', textord_min_xheight: '5' } } ] let bestResult = { text: '', confidence: 0, words: [], strategy: 'none' } for (const strategy of strategies) { try { console.log(`[OCR] Test de la stratégie: ${strategy.name}`) await worker.setParameters(strategy.params) const { data } = await worker.recognize(tempPath) console.log(`[OCR] ${strategy.name} - Confiance: ${data.confidence}%`) if (data.confidence > bestResult.confidence) { bestResult = { text: data.text, confidence: data.confidence, words: data.words || [], strategy: strategy.name } } } catch (error) { console.log(`[OCR] Erreur avec ${strategy.name}: ${error.message}`) } } console.log(`[OCR] Meilleur résultat (${bestResult.strategy}) - Confiance: ${bestResult.confidence}%`) console.log(`[OCR] Texte extrait (${bestResult.text.length} caractères): ${bestResult.text.substring(0, 200)}...`) return { text: bestResult.text, confidence: bestResult.confidence, words: bestResult.words } } finally { await worker.terminate() // Nettoyage du fichier temporaire try { if (fs.existsSync(tempPath)) { await fs.promises.unlink(tempPath) console.log(`[OCR] Fichier temporaire supprimé: ${tempPath}`) } } catch (error) { console.warn(`[OCR] Erreur lors de la suppression du fichier temporaire: ${error.message}`) } } } // Fonction de correction de texte pour améliorer la détection function correctOCRText(text) { // Corrections courantes pour les erreurs OCR const corrections = { // Corrections générales courantes seulement '0': 'o', '1': 'l', '5': 's', '@': 'a', '3': 'e' } let correctedText = text for (const [wrong, correct] of Object.entries(corrections)) { correctedText = correctedText.replace(new RegExp(wrong, 'gi'), correct) } return correctedText } // Fonction pour générer le format JSON standard function generateStandardJSON(documentInfo, ocrResult, entities, processingTime) { const timestamp = new Date().toISOString() const documentId = `doc-${Date.now()}` // Classification du document const documentType = entities.documentType || 'Document' const subType = getDocumentSubType(documentType, ocrResult.text) // Extraction des informations financières pour les factures const financial = extractFinancialInfo(ocrResult.text, documentType) // Extraction des références const references = extractReferences(ocrResult.text, documentType) // Calcul de la confiance globale const globalConfidence = Math.min(95, Math.max(60, ocrResult.confidence * 0.8 + (entities.identities.length > 0 ? 10 : 0) + (entities.cniNumbers.length > 0 ? 15 : 0))) return { document: { id: documentId, fileName: documentInfo.originalname, fileSize: documentInfo.size, mimeType: documentInfo.mimetype, uploadTimestamp: timestamp }, classification: { documentType: documentType, confidence: globalConfidence / 100, subType: subType, language: 'fr', pageCount: 1 }, extraction: { text: { raw: ocrResult.text, processed: correctOCRText(ocrResult.text), wordCount: ocrResult.words.length, characterCount: ocrResult.text.length, confidence: ocrResult.confidence / 100 }, entities: { persons: entities.identities.map(identity => ({ id: identity.id, type: 'person', firstName: identity.firstName, lastName: identity.lastName, role: identity.role || null, email: identity.email || null, phone: identity.phone || null, confidence: identity.confidence, source: identity.source })), companies: entities.companies.map(company => ({ id: company.id, name: company.name, legalForm: company.legalForm || null, siret: company.siret || null, rcs: company.rcs || null, tva: company.tva || null, capital: company.capital || null, role: company.role || null, confidence: company.confidence, source: company.source })), addresses: entities.addresses.map(address => ({ id: address.id, type: address.type || 'general', street: address.street, city: address.city, postalCode: address.postalCode, country: address.country, company: address.company || null, confidence: address.confidence, source: address.source })), financial: financial, dates: entities.dates.map(date => ({ id: date.id, type: date.type || 'general', value: date.date || date.value, formatted: formatDate(date.date || date.value), confidence: date.confidence, source: date.source })), contractual: { clauses: entities.contractClauses.map(clause => ({ id: clause.id, type: clause.type, content: clause.text, confidence: clause.confidence })), signatures: entities.signatures.map(signature => ({ id: signature.id, type: signature.type || 'électronique', present: signature.present || false, signatory: signature.signatory || null, date: signature.date || null, confidence: signature.confidence })) }, references: references } }, metadata: { processing: { engine: '4NK_IA_Backend', version: '1.0.0', processingTime: `${processingTime}ms`, ocrEngine: documentInfo.mimetype === 'application/pdf' ? 'pdf-parse' : 'tesseract.js', nerEngine: 'rule-based', preprocessing: { applied: documentInfo.mimetype !== 'application/pdf', reason: documentInfo.mimetype === 'application/pdf' ? 'PDF direct text extraction' : 'Image preprocessing applied' } }, quality: { globalConfidence: globalConfidence / 100, textExtractionConfidence: ocrResult.confidence / 100, entityExtractionConfidence: 0.90, classificationConfidence: globalConfidence / 100 } }, status: { success: true, errors: [], warnings: entities.signatures.length === 0 ? ['Aucune signature détectée'] : [], timestamp: timestamp } } } // Fonction pour déterminer le sous-type de document function getDocumentSubType(documentType, text) { if (documentType === 'Facture') { if (/prestation|service/i.test(text)) return 'Facture de prestation' if (/vente|achat/i.test(text)) return 'Facture de vente' return 'Facture' } if (documentType === 'CNI') return 'Carte Nationale d\'Identité' if (documentType === 'Contrat') { if (/vente|achat/i.test(text)) return 'Contrat de vente' if (/location|bail/i.test(text)) return 'Contrat de location' return 'Contrat' } return documentType } // Fonction pour extraire les informations financières function extractFinancialInfo(text, documentType) { if (documentType !== 'Facture') { return { amounts: [], totals: {}, payment: {} } } const amounts = [] const totals = {} const payment = {} // Extraction des montants const amountPatterns = [ /(\d+(?:[.,]\d{2})?)\s*€/g, /Total\s+H\.T\.\s*[:\-]?\s*(\d+(?:[.,]\d{2})?)\s*€/gi, /Total\s+T\.T\.C\.\s*[:\-]?\s*(\d+(?:[.,]\d{2})?)\s*€/gi, /T\.V\.A\.\s*[:\-]?\s*(\d+(?:[.,]\d{2})?)\s*€/gi ] amountPatterns.forEach(pattern => { for (const match of text.matchAll(pattern)) { const amount = parseFloat(match[1].replace(',', '.')) if (amount > 0) { amounts.push({ id: `amount-${amounts.length}`, type: 'montant', value: amount, currency: 'EUR', confidence: 0.9 }) } } }) // Extraction des conditions de paiement const paymentPattern = /paiement\s+se\s+fera\s+\(maximum\)\s+(\d+)\s+jours/gi const paymentMatch = paymentPattern.exec(text) if (paymentMatch) { payment.terms = `${paymentMatch[1]} jours après émission` } return { amounts, totals, payment } } // Fonction pour extraire les références function extractReferences(text, documentType) { const references = [] if (documentType === 'Facture') { const facturePattern = /Facture\s+N°\s*[:\-]?\s*([A-Z0-9_-]+)/gi for (const match of text.matchAll(facturePattern)) { references.push({ id: `ref-${references.length}`, type: 'facture', number: match[1], confidence: 0.95 }) } } return references } // Fonction pour formater les dates function formatDate(dateStr) { if (!dateStr) return null // Format DD-MM-YY vers YYYY-MM-DD const match = dateStr.match(/(\d{2})-(\w+)-(\d{2})/) if (match) { const months = { 'janvier': '01', 'février': '02', 'mars': '03', 'avril': '04', 'mai': '05', 'juin': '06', 'juillet': '07', 'août': '08', 'septembre': '09', 'octobre': '10', 'novembre': '11', 'décembre': '12' } const month = months[match[2].toLowerCase()] if (month) { const year = '20' + match[3] return `${year}-${month}-${match[1].padStart(2, '0')}` } } return dateStr } // Fonction d'extraction NER par règles function extractEntitiesFromText(text) { console.log(`[NER] Début de l'extraction d'entités pour ${text.length} caractères`) // Correction du texte OCR const correctedText = correctOCRText(text) if (correctedText !== text) { console.log(`[NER] Texte corrigé: ${correctedText.substring(0, 100)}...`) } const entities = { identities: [], companies: [], addresses: [], cniNumbers: [], dates: [], contractClauses: [], signatures: [], documentType: 'Document' } // Extraction des noms avec patterns généraux const namePatterns = [ // Patterns pour documents officiels /(Vendeur|Acheteur|Vendeuse|Acheteuse|Propriétaire|Locataire|Bailleur|Preneur)\s*:\s*([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, // Lignes en MAJUSCULES (noms complets) /^([A-Z][A-ZÀ-ÖØ-öø-ÿ\s\-']{2,30})$/gm, // Noms avec prénom + nom /([A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/g ] namePatterns.forEach(pattern => { for (const match of correctedText.matchAll(pattern)) { const fullName = match[2] || match[1] || match[0] if (fullName && fullName.length > 3) { const nameParts = fullName.trim().split(/\s+/) if (nameParts.length >= 2) { entities.identities.push({ id: `identity-${entities.identities.length}`, type: 'person', firstName: nameParts[0], lastName: nameParts.slice(1).join(' '), confidence: 0.9, source: 'rule-based' }) } } } }) // Extraction des sociétés const companyPatterns = [ /(S\.A\.R\.L\.|SAS|SASU|EURL|SNC|SCI|SARL|SA|SAS|SASU|EURL|SNC|SCI|S\.A\.|S\.A\.R\.L\.|S\.A\.S\.|S\.A\.S\.U\.|E\.U\.R\.L\.|S\.N\.C\.|S\.C\.I\.)/gi, /([A-Z][A-Za-zÀ-ÖØ-öø-ÿ\s\-']{3,50})\s+(S\.A\.R\.L\.|SAS|SASU|EURL|SNC|SCI|SARL|SA)/gi, /(Entreprise|Société|Compagnie|Groupe|Corporation|Corp\.|Inc\.|Ltd\.|LLC)/gi ] companyPatterns.forEach(pattern => { for (const match of text.matchAll(pattern)) { const companyName = match[1] || match[0] if (companyName && companyName.length > 3) { entities.companies.push({ id: `company-${entities.companies.length}`, name: companyName.trim(), type: 'company', confidence: 0.8, source: 'rule-based' }) } } }) // Extraction des adresses const addressPatterns = [ /(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+)/gi, /demeurant\s+(\d{1,4})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+?),\s*(\d{5})\s+([A-Za-zÀ-ÖØ-öø-ÿ\s\-']+)/gi, /(Adresse|Siège|Adresse de facturation)\s*:\s*(\d{1,4}\s+[A-Za-zÀ-ÖØ-öø-ÿ\s\-']+,\s*\d{5}\s+[A-Za-zÀ-ÖØ-öø-ÿ\s\-']+)/gi ] addressPatterns.forEach(pattern => { for (const match of text.matchAll(pattern)) { const street = match[2] || match[1] const city = match[4] || match[3] const postalCode = match[3] || match[2] entities.addresses.push({ id: `address-${entities.addresses.length}`, street: street ? `${street}`.trim() : '', city: city ? city.trim() : '', postalCode: postalCode ? postalCode.trim() : '', country: 'France', confidence: 0.9, source: 'rule-based' }) } }) // Extraction des numéros de carte d'identité const cniPattern = /([A-Z]{2}\d{6})/g for (const match of text.matchAll(cniPattern)) { entities.cniNumbers.push({ id: `cni-${entities.cniNumbers.length}`, number: match[1], confidence: 0.95, source: 'rule-based' }) } // Extraction des dates const datePatterns = [ /(\d{2}\/\d{2}\/\d{4})/g, /(né|née)\s+le\s+(\d{2}\/\d{2}\/\d{4})/gi ] datePatterns.forEach(pattern => { for (const match of text.matchAll(pattern)) { const date = match[2] || match[1] entities.dates.push({ id: `date-${entities.dates.length}`, date: date, type: match[1]?.toLowerCase().includes('né') ? 'birth' : 'general', confidence: 0.9, source: 'rule-based' }) } }) // Extraction des clauses contractuelles const clausePatterns = [ /(Article\s+\d+[:\-]?\s*[^\.]+\.)/gi, /(Clause\s+\d+[:\-]?\s*[^\.]+\.)/gi, /(Conditions\s+générales[^\.]+\.)/gi, /(Modalités\s+de\s+[^\.]+\.)/gi, /(Obligations\s+du\s+[^\.]+\.)/gi, /(Responsabilités[^\.]+\.)/gi ] clausePatterns.forEach(pattern => { for (const match of text.matchAll(pattern)) { const clause = match[1] || match[0] if (clause && clause.length > 10) { entities.contractClauses.push({ id: `clause-${entities.contractClauses.length}`, text: clause.trim(), type: 'contractual', confidence: 0.8, source: 'rule-based' }) } } }) // Extraction des signatures const signaturePatterns = [ /(Signé\s+le\s+\d{1,2}[\/\-]\d{1,2}[\/\-]\d{2,4})/gi, /(Signature\s+de\s+[A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, /(Par\s+[A-Z][a-zà-öø-ÿ'\-]+\s+[A-Z][a-zà-öø-ÿ'\-]+)/gi, /(Fait\s+et\s+signé\s+[^\.]+\.)/gi ] signaturePatterns.forEach(pattern => { for (const match of text.matchAll(pattern)) { const signature = match[1] || match[0] if (signature && signature.length > 5) { entities.signatures.push({ id: `signature-${entities.signatures.length}`, text: signature.trim(), type: 'signature', confidence: 0.8, source: 'rule-based' }) } } }) // Classification du type de document if (/carte\s+nationale\s+d'identité|cni|mrz|identite/i.test(text)) { entities.documentType = 'CNI' } else if (/facture|tva|siren|montant|facturation/i.test(text)) { entities.documentType = 'Facture' } else if (/attestation|certificat/i.test(text)) { entities.documentType = 'Attestation' } else if (/contrat|vente|achat|acte/i.test(text)) { entities.documentType = 'Contrat' } console.log(`[NER] Extraction terminée:`) console.log(` - Identités: ${entities.identities.length}`) console.log(` - Sociétés: ${entities.companies.length}`) console.log(` - Adresses: ${entities.addresses.length}`) console.log(` - Numéros CNI: ${entities.cniNumbers.length}`) console.log(` - Dates: ${entities.dates.length}`) console.log(` - Clauses contractuelles: ${entities.contractClauses.length}`) console.log(` - Signatures: ${entities.signatures.length}`) console.log(` - Type: ${entities.documentType}`) return entities } // Route pour l'extraction de documents app.post('/api/extract', upload.single('document'), async (req, res) => { const startTime = Date.now() try { if (!req.file) { return res.status(400).json({ error: 'Aucun fichier fourni' }) } // Récupérer le hash du dossier depuis les paramètres de requête const folderHash = req.body.folderHash || req.query.folderHash if (!folderHash) { return res.status(400).json({ error: 'Hash du dossier requis' }) } console.log(`[API] Traitement du fichier: ${req.file.originalname} dans le dossier: ${folderHash}`) // Calculer le hash du fichier uploadé const fileBuffer = fs.readFileSync(req.file.path) const fileHash = calculateFileHash(fileBuffer) console.log(`[HASH] Hash du fichier: ${fileHash.substring(0, 16)}...`) // Vérifier d'abord le cache JSON dans le dossier const cachedResult = getJsonCacheFromFolder(folderHash, fileHash) if (cachedResult) { console.log(`[CACHE] Utilisation du résultat en cache du dossier ${folderHash}`) // Supprimer le fichier temporaire fs.unlinkSync(req.file.path) // Retourner le résultat en cache return res.json(cachedResult) } // Vérifier si un fichier avec le même hash existe déjà dans le dossier const existingFile = findExistingFileByHash(fileHash, folderHash) let isDuplicate = false let duplicatePath = null if (existingFile) { console.log(`[HASH] Fichier déjà existant trouvé dans le dossier ${folderHash}: ${existingFile.name}`) isDuplicate = true // Sauvegarder le chemin du doublon pour suppression ultérieure duplicatePath = req.file.path // Utiliser le fichier existant pour le traitement req.file.path = existingFile.path req.file.originalname = existingFile.name } else { console.log(`[HASH] Nouveau fichier, renommage avec hash dans le dossier ${folderHash}`) // Créer la structure du dossier si elle n'existe pas const { folderPath } = createFolderStructure(folderHash) // Renommer le fichier avec son hash + extension dans le dossier const ext = path.extname(req.file.originalname) const newFileName = `${fileHash}${ext}` const newFilePath = path.join(folderPath, newFileName) // Renommer le fichier fs.renameSync(req.file.path, newFilePath) // Mettre à jour les informations du fichier req.file.path = newFilePath req.file.filename = newFileName console.log(`[HASH] Fichier renommé: ${newFileName}`) } let ocrResult // Si c'est un PDF, extraire le texte directement if (req.file.mimetype === 'application/pdf') { console.log(`[API] Extraction de texte depuis PDF...`) try { ocrResult = await extractTextFromPdf(req.file.path) console.log(`[API] Texte extrait du PDF: ${ocrResult.text.length} caractères`) } catch (error) { console.error(`[API] Erreur lors de l'extraction PDF:`, error.message) return res.status(500).json({ success: false, error: 'Erreur lors de l\'extraction PDF', details: error.message }) } } else { // Pour les images, utiliser l'OCR avec préprocessing ocrResult = await extractTextFromImage(req.file.path) } // Extraction NER const entities = extractEntitiesFromText(ocrResult.text) // Mesure du temps de traitement const processingTime = Date.now() - startTime // Génération du format JSON standard const result = generateStandardJSON(req.file, ocrResult, entities, processingTime) // Sauvegarder le résultat dans le cache du dossier saveJsonCacheInFolder(folderHash, fileHash, result) // Nettoyage du fichier temporaire if (isDuplicate) { // Supprimer le doublon uploadé fs.unlinkSync(duplicatePath) } else { // Supprimer le fichier temporaire normal fs.unlinkSync(req.file.path) } console.log(`[API] Traitement terminé avec succès - Confiance: ${Math.round(result.metadata.quality.globalConfidence * 100)}%`) res.json(result) } catch (error) { console.error('[API] Erreur lors du traitement:', error) // Nettoyage en cas d'erreur if (req.file && fs.existsSync(req.file.path)) { fs.unlinkSync(req.file.path) } res.status(500).json({ success: false, error: 'Erreur lors du traitement du document', details: error.message }) } }) // Route pour lister les fichiers de test app.get('/api/test-files', (req, res) => { try { const testFilesDir = path.join(__dirname, '..', 'test-files') const files = fs.readdirSync(testFilesDir) .filter(file => { const ext = path.extname(file).toLowerCase() return ['.jpg', '.jpeg', '.png', '.pdf', '.tiff'].includes(ext) }) .map(file => { const filePath = path.join(testFilesDir, file) const stats = fs.statSync(filePath) return { name: file, size: stats.size, type: path.extname(file).toLowerCase(), lastModified: stats.mtime } }) res.json({ success: true, files }) } catch (error) { res.status(500).json({ success: false, error: error.message }) } }) // Route pour servir un fichier de test individuel app.get('/api/test-files/:filename', (req, res) => { try { const filename = req.params.filename const testFilesDir = path.join(__dirname, '..', 'test-files') const filePath = path.join(testFilesDir, filename) // Vérifier que le fichier existe et est dans le bon répertoire if (!fs.existsSync(filePath)) { return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) } // Vérifier que le fichier est bien dans le répertoire test-files (sécurité) const resolvedPath = path.resolve(filePath) const resolvedTestDir = path.resolve(testFilesDir) if (!resolvedPath.startsWith(resolvedTestDir)) { return res.status(403).json({ success: false, error: 'Accès non autorisé' }) } // Servir le fichier res.sendFile(filePath) } catch (error) { res.status(500).json({ success: false, error: error.message }) } }) // Route de santé // Route pour lister les fichiers uploadés avec leurs hash app.get('/api/uploads', (req, res) => { try { const uploadDir = 'uploads/' if (!fs.existsSync(uploadDir)) { return res.json({ files: [] }) } const files = fs.readdirSync(uploadDir) const fileList = files.map(file => { const filePath = path.join(uploadDir, file) try { const stats = fs.statSync(filePath) // Extraire le hash du nom de fichier (format: hash.extension) const ext = path.extname(file) const hash = path.basename(file, ext) return { name: file, size: stats.size, hash: hash, uploadDate: stats.birthtime, modifiedDate: stats.mtime } } catch (error) { console.warn(`[API] Erreur lors de la lecture de ${file}:`, error.message) return null } }).filter(file => file !== null) res.json({ files: fileList, count: fileList.length, totalSize: fileList.reduce((sum, file) => sum + file.size, 0) }) } catch (error) { console.error('[API] Erreur lors de la liste des fichiers:', error) res.status(500).json({ error: 'Erreur lors de la récupération des fichiers' }) } }) // Route pour lister les fichiers de cache JSON app.get('/api/cache', (req, res) => { try { const cacheFiles = listCacheFiles() res.json({ files: cacheFiles, count: cacheFiles.length, totalSize: cacheFiles.reduce((sum, file) => sum + file.size, 0) }) } catch (error) { console.error('[API] Erreur lors de la liste du cache:', error) res.status(500).json({ error: 'Erreur lors de la récupération du cache' }) } }) // Route pour récupérer un résultat de cache spécifique app.get('/api/cache/:hash', (req, res) => { try { const { hash } = req.params const cachedResult = getJsonCache(hash) if (cachedResult) { res.json(cachedResult) } else { res.status(404).json({ error: 'Résultat non trouvé dans le cache' }) } } catch (error) { console.error('[API] Erreur lors de la récupération du cache:', error) res.status(500).json({ error: 'Erreur lors de la récupération du cache' }) } }) // Route pour supprimer un fichier de cache app.delete('/api/cache/:hash', (req, res) => { try { const { hash } = req.params const cacheFile = path.join('cache/', `${hash}.json`) if (fs.existsSync(cacheFile)) { fs.unlinkSync(cacheFile) console.log(`[CACHE] Fichier supprimé: ${hash.substring(0, 16)}...`) res.json({ message: 'Fichier de cache supprimé avec succès' }) } else { res.status(404).json({ error: 'Fichier de cache non trouvé' }) } } catch (error) { console.error('[API] Erreur lors de la suppression du cache:', error) res.status(500).json({ error: 'Erreur lors de la suppression du cache' }) } }) // Route pour créer un nouveau dossier app.post('/api/folders', (req, res) => { try { const folderHash = generateFolderHash() createFolderStructure(folderHash) console.log(`[FOLDER] Nouveau dossier créé: ${folderHash}`) res.json({ success: true, folderHash, message: 'Dossier créé avec succès' }) } catch (error) { console.error('[FOLDER] Erreur lors de la création du dossier:', error) res.status(500).json({ success: false, error: error.message }) } }) // Route pour récupérer les résultats d'un dossier app.get('/api/folders/:folderHash/results', (req, res) => { try { const { folderHash } = req.params const results = listFolderResults(folderHash) console.log(`[FOLDER] Résultats récupérés pour le dossier ${folderHash}: ${results.length} fichiers`) res.json({ success: true, folderHash, results, count: results.length }) } catch (error) { console.error('[FOLDER] Erreur lors de la récupération des résultats:', error) res.status(500).json({ success: false, error: error.message }) } }) // Route pour récupérer un fichier original depuis un dossier app.get('/api/folders/:folderHash/files/:fileHash', (req, res) => { try { const { folderHash, fileHash } = req.params const folderPath = path.join('uploads', folderHash) if (!fs.existsSync(folderPath)) { return res.status(404).json({ success: false, error: 'Dossier non trouvé' }) } const files = fs.readdirSync(folderPath) const targetFile = files.find(file => file.startsWith(fileHash)) if (!targetFile) { return res.status(404).json({ success: false, error: 'Fichier non trouvé' }) } const filePath = path.join(folderPath, targetFile) res.sendFile(path.resolve(filePath)) } catch (error) { console.error('[FOLDER] Erreur lors de la récupération du fichier:', error) res.status(500).json({ success: false, error: error.message }) } }) // Route pour créer le dossier par défaut avec les fichiers de test app.post('/api/folders/default', async (req, res) => { try { const folderHash = generateFolderHash() const { folderPath, cachePath } = createFolderStructure(folderHash) console.log(`[FOLDER] Création du dossier par défaut: ${folderHash}`) // Charger les fichiers de test dans le dossier const testFilesDir = path.join(__dirname, '..', 'test-files') if (fs.existsSync(testFilesDir)) { const testFiles = fs.readdirSync(testFilesDir) const supportedFiles = testFiles.filter(file => ['.pdf', '.jpg', '.jpeg', '.png', '.tiff'].includes(path.extname(file).toLowerCase()) ) for (const testFile of supportedFiles) { const sourcePath = path.join(testFilesDir, testFile) const fileBuffer = fs.readFileSync(sourcePath) const fileHash = calculateFileHash(fileBuffer) const ext = path.extname(testFile) const newFileName = `${fileHash}${ext}` const destPath = path.join(folderPath, newFileName) // Copier le fichier fs.copyFileSync(sourcePath, destPath) // Traiter le fichier et sauvegarder le résultat try { const result = await processDocument(fileBuffer, newFileName, testFile) saveJsonCacheInFolder(folderHash, fileHash, result) console.log(`[FOLDER] Fichier de test traité: ${testFile} -> ${fileHash}`) } catch (error) { console.warn(`[FOLDER] Erreur lors du traitement de ${testFile}:`, error.message) } } } res.json({ success: true, folderHash, message: 'Dossier par défaut créé avec succès' }) } catch (error) { console.error('[FOLDER] Erreur lors de la création du dossier par défaut:', error) res.status(500).json({ success: false, error: error.message }) } }) app.get('/api/health', (req, res) => { res.json({ status: 'OK', timestamp: new Date().toISOString(), version: '1.0.0' }) }) // Démarrage du serveur app.listen(PORT, () => { console.log(`🚀 Serveur backend démarré sur le port ${PORT}`) console.log(`📡 API disponible sur: http://localhost:${PORT}/api`) console.log(`🏥 Health check: http://localhost:${PORT}/api/health`) console.log(`📁 Test files: http://localhost:${PORT}/api/test-files`) }) module.exports = app