4NK_IA_front/backend/server.js
Nicolas Cantu 1fb8a56cf0 backend
2025-09-16 04:27:07 +02:00

1217 lines
38 KiB
JavaScript

#!/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