- Modification de listFolderResults pour inclure les fichiers en uploads - Création de résultats minimaux pour les fichiers non traités - Fonction getMimeTypeFromExtension pour détecter le type MIME - Les fichiers non traités apparaissent maintenant avec status 'Non traité' - Résolution du problème où les documents ne remontaient pas après vidage Fixes: Fichiers dans uploads non visibles après vidage du cache Fixes: listFolderResults ne regardait que le cache, pas les uploads
1606 lines
51 KiB
JavaScript
1606 lines
51 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'))
|
|
|
|
// Middleware de logging pour debug
|
|
app.use((req, res, next) => {
|
|
console.log(`[DEBUG] ${new Date().toISOString()} - ${req.method} ${req.url}`)
|
|
console.log(`[DEBUG] Headers:`, req.headers)
|
|
next()
|
|
})
|
|
|
|
// 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 obtenir le type MIME à partir de l'extension
|
|
function getMimeType(ext) {
|
|
const mimeTypes = {
|
|
'.pdf': 'application/pdf',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.tiff': 'image/tiff'
|
|
}
|
|
return mimeTypes[ext.toLowerCase()] || 'application/octet-stream'
|
|
}
|
|
|
|
// Fonction pour créer la structure de dossiers
|
|
function createFolderStructure(folderHash) {
|
|
console.log(`[FOLDER] Création de la structure pour le hash: ${folderHash}`)
|
|
console.log(`[FOLDER] Répertoire de travail: ${process.cwd()}`)
|
|
|
|
// Créer les dossiers racines s'ils n'existent pas
|
|
const uploadsDir = 'uploads'
|
|
const cacheDir = 'cache'
|
|
|
|
console.log(`[FOLDER] Vérification de l'existence de ${uploadsDir}: ${fs.existsSync(uploadsDir)}`)
|
|
console.log(`[FOLDER] Vérification de l'existence de ${cacheDir}: ${fs.existsSync(cacheDir)}`)
|
|
|
|
if (!fs.existsSync(uploadsDir)) {
|
|
fs.mkdirSync(uploadsDir, { recursive: true })
|
|
console.log(`[FOLDER] Dossier racine créé: ${uploadsDir}`)
|
|
}
|
|
if (!fs.existsSync(cacheDir)) {
|
|
fs.mkdirSync(cacheDir, { recursive: true })
|
|
console.log(`[FOLDER] Dossier racine créé: ${cacheDir}`)
|
|
}
|
|
|
|
const folderPath = path.join(uploadsDir, folderHash)
|
|
const cachePath = path.join(cacheDir, folderHash)
|
|
|
|
console.log(`[FOLDER] Chemin du dossier uploads: ${folderPath}`)
|
|
console.log(`[FOLDER] Chemin du dossier cache: ${cachePath}`)
|
|
|
|
if (!fs.existsSync(folderPath)) {
|
|
fs.mkdirSync(folderPath, { recursive: true })
|
|
console.log(`[FOLDER] Dossier uploads créé: ${folderPath}`)
|
|
}
|
|
if (!fs.existsSync(cachePath)) {
|
|
fs.mkdirSync(cachePath, { recursive: true })
|
|
console.log(`[FOLDER] Dossier cache créé: ${cachePath}`)
|
|
}
|
|
|
|
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}`)
|
|
|
|
// Supprimer le flag pending si il existe
|
|
const pendingFile = path.join(cachePath, `${fileHash}.pending`)
|
|
if (fs.existsSync(pendingFile)) {
|
|
fs.unlinkSync(pendingFile)
|
|
console.log(`[CACHE] Flag pending supprimé pour ${fileHash}`)
|
|
}
|
|
|
|
return true
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la sauvegarde dans le dossier ${folderHash}:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Fonction pour créer un flag pending
|
|
function createPendingFlag(folderHash, fileHash) {
|
|
const { cachePath } = createFolderStructure(folderHash)
|
|
const pendingFile = path.join(cachePath, `${fileHash}.pending`)
|
|
|
|
try {
|
|
const pendingData = {
|
|
fileHash,
|
|
folderHash,
|
|
timestamp: new Date().toISOString(),
|
|
status: 'processing'
|
|
}
|
|
fs.writeFileSync(pendingFile, JSON.stringify(pendingData, null, 2))
|
|
console.log(`[CACHE] Flag pending créé pour ${fileHash} dans le dossier ${folderHash}`)
|
|
return true
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la création du flag pending:`, error)
|
|
return false
|
|
}
|
|
}
|
|
|
|
// Fonction pour vérifier si un fichier est en cours de traitement
|
|
function isFilePending(folderHash, fileHash) {
|
|
const cachePath = path.join('cache', folderHash)
|
|
const pendingFile = path.join(cachePath, `${fileHash}.pending`)
|
|
return fs.existsSync(pendingFile)
|
|
}
|
|
|
|
// Fonction pour nettoyer les flags pending orphelins (plus de 1 heure)
|
|
function cleanupOrphanedPendingFlags() {
|
|
console.log('[CLEANUP] Nettoyage des flags pending orphelins...')
|
|
|
|
const cacheDir = 'cache'
|
|
if (!fs.existsSync(cacheDir)) {
|
|
return
|
|
}
|
|
|
|
const folders = fs.readdirSync(cacheDir)
|
|
let cleanedCount = 0
|
|
|
|
for (const folder of folders) {
|
|
const folderPath = path.join(cacheDir, folder)
|
|
if (!fs.statSync(folderPath).isDirectory()) continue
|
|
|
|
const files = fs.readdirSync(folderPath)
|
|
for (const file of files) {
|
|
if (file.endsWith('.pending')) {
|
|
const pendingFile = path.join(folderPath, file)
|
|
try {
|
|
const stats = fs.statSync(pendingFile)
|
|
const age = Date.now() - stats.mtime.getTime()
|
|
const oneHour = 60 * 60 * 1000 // 1 heure en millisecondes
|
|
|
|
if (age > oneHour) {
|
|
fs.unlinkSync(pendingFile)
|
|
cleanedCount++
|
|
console.log(`[CLEANUP] Flag pending orphelin supprimé: ${file}`)
|
|
}
|
|
} catch (error) {
|
|
console.error(`[CLEANUP] Erreur lors du nettoyage de ${file}:`, error.message)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
if (cleanedCount > 0) {
|
|
console.log(`[CLEANUP] ${cleanedCount} flags pending orphelins supprimés`)
|
|
} else {
|
|
console.log('[CLEANUP] Aucun flag pending orphelin trouvé')
|
|
}
|
|
}
|
|
|
|
// 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 obtenir le type MIME à partir de l'extension
|
|
function getMimeTypeFromExtension(extension) {
|
|
const mimeTypes = {
|
|
'.pdf': 'application/pdf',
|
|
'.jpg': 'image/jpeg',
|
|
'.jpeg': 'image/jpeg',
|
|
'.png': 'image/png',
|
|
'.gif': 'image/gif',
|
|
'.bmp': 'image/bmp',
|
|
'.tiff': 'image/tiff',
|
|
'.webp': 'image/webp'
|
|
}
|
|
return mimeTypes[extension.toLowerCase()] || 'application/octet-stream'
|
|
}
|
|
|
|
// Fonction pour lister tous les résultats d'un dossier
|
|
function listFolderResults(folderHash) {
|
|
const cachePath = path.join('cache', folderHash)
|
|
const uploadsPath = path.join('uploads', folderHash)
|
|
|
|
const results = []
|
|
const pending = []
|
|
let hasPending = false
|
|
|
|
// Traiter les fichiers en cache (avec résultats d'extraction)
|
|
if (fs.existsSync(cachePath)) {
|
|
const cacheFiles = fs.readdirSync(cachePath)
|
|
|
|
for (const file of cacheFiles) {
|
|
if (file.endsWith('.json')) {
|
|
const fileHash = path.basename(file, '.json')
|
|
const result = getJsonCacheFromFolder(folderHash, fileHash)
|
|
if (result) {
|
|
results.push({
|
|
fileHash,
|
|
...result
|
|
})
|
|
}
|
|
} else if (file.endsWith('.pending')) {
|
|
const fileHash = path.basename(file, '.pending')
|
|
try {
|
|
const pendingData = JSON.parse(fs.readFileSync(path.join(cachePath, file), 'utf8'))
|
|
pending.push(pendingData)
|
|
hasPending = true
|
|
console.log(`[CACHE] Fichier en cours de traitement détecté: ${fileHash}`)
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la lecture du flag pending ${file}:`, error)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Traiter les fichiers en uploads (sans résultats d'extraction)
|
|
if (fs.existsSync(uploadsPath)) {
|
|
const uploadFiles = fs.readdirSync(uploadsPath)
|
|
|
|
for (const file of uploadFiles) {
|
|
// Extraire le hash du nom de fichier (format: hash.extension)
|
|
const fileHash = path.basename(file, path.extname(file))
|
|
|
|
// Vérifier si ce fichier n'a pas déjà un résultat en cache
|
|
const hasCacheResult = results.some(result => result.fileHash === fileHash)
|
|
|
|
if (!hasCacheResult) {
|
|
// Créer un résultat minimal pour les fichiers non traités
|
|
const filePath = path.join(uploadsPath, file)
|
|
const stats = fs.statSync(filePath)
|
|
|
|
results.push({
|
|
fileHash,
|
|
document: {
|
|
id: `doc-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`,
|
|
fileName: file,
|
|
mimeType: getMimeTypeFromExtension(path.extname(file)),
|
|
fileSize: stats.size,
|
|
uploadTimestamp: stats.mtime.toISOString()
|
|
},
|
|
classification: {
|
|
documentType: 'Document',
|
|
confidence: 0.0,
|
|
subType: 'Non traité',
|
|
language: 'fr',
|
|
pageCount: 1
|
|
},
|
|
extraction: {
|
|
text: {
|
|
raw: '',
|
|
processed: '',
|
|
wordCount: 0,
|
|
characterCount: 0,
|
|
confidence: 0.0
|
|
},
|
|
entities: {
|
|
persons: [],
|
|
companies: [],
|
|
addresses: [],
|
|
financial: {
|
|
amounts: [],
|
|
totals: {},
|
|
payment: {}
|
|
},
|
|
dates: [],
|
|
contractual: {
|
|
clauses: [],
|
|
signatures: []
|
|
},
|
|
references: []
|
|
}
|
|
},
|
|
metadata: {
|
|
processing: {
|
|
engine: '4NK_IA_Backend',
|
|
version: '1.0.0',
|
|
processingTime: '0ms',
|
|
ocrEngine: 'none',
|
|
nerEngine: 'none',
|
|
preprocessing: {
|
|
applied: false,
|
|
reason: 'Fichier non traité'
|
|
}
|
|
},
|
|
quality: {
|
|
globalConfidence: 0.0,
|
|
textExtractionConfidence: 0.0,
|
|
entityExtractionConfidence: 0.0,
|
|
classificationConfidence: 0.0
|
|
}
|
|
},
|
|
status: {
|
|
success: false,
|
|
errors: ['Fichier non traité'],
|
|
warnings: ['Aucune extraction effectuée'],
|
|
timestamp: stats.mtime.toISOString()
|
|
}
|
|
})
|
|
|
|
console.log(`[FOLDER] Fichier non traité ajouté: ${file}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
return { results, pending, hasPending }
|
|
}
|
|
|
|
// 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 le fichier est déjà en cours de traitement
|
|
if (isFilePending(folderHash, fileHash)) {
|
|
console.log(`[CACHE] Fichier déjà en cours de traitement: ${fileHash}`)
|
|
fs.unlinkSync(req.file.path)
|
|
return res.status(202).json({
|
|
success: false,
|
|
status: 'pending',
|
|
message: 'Fichier en cours de traitement',
|
|
fileHash
|
|
})
|
|
}
|
|
|
|
// Créer un flag pending pour ce fichier
|
|
createPendingFlag(folderHash, fileHash)
|
|
|
|
// 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
|
|
let result
|
|
|
|
try {
|
|
// 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)
|
|
throw new Error(`Erreur lors de l'extraction PDF: ${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
|
|
result = generateStandardJSON(req.file, ocrResult, entities, processingTime)
|
|
|
|
// Sauvegarder le résultat dans le cache du dossier
|
|
saveJsonCacheInFolder(folderHash, fileHash, result)
|
|
|
|
console.log(`[API] Traitement terminé en ${Date.now() - startTime}ms`)
|
|
|
|
} catch (error) {
|
|
console.error(`[API] Erreur lors du traitement du fichier ${fileHash}:`, error)
|
|
|
|
// Supprimer le flag pending en cas d'erreur
|
|
const { cachePath } = createFolderStructure(folderHash)
|
|
const pendingFile = path.join(cachePath, `${fileHash}.pending`)
|
|
if (fs.existsSync(pendingFile)) {
|
|
fs.unlinkSync(pendingFile)
|
|
console.log(`[CACHE] Flag pending supprimé après erreur pour ${fileHash}`)
|
|
}
|
|
|
|
return res.status(500).json({
|
|
success: false,
|
|
error: 'Erreur lors du traitement du document',
|
|
details: error.message
|
|
})
|
|
} finally {
|
|
// Nettoyage du fichier temporaire
|
|
if (isDuplicate) {
|
|
// Supprimer le doublon uploadé
|
|
fs.unlinkSync(duplicatePath)
|
|
}
|
|
// Note: Ne pas supprimer req.file.path car c'est le fichier final dans le dossier
|
|
}
|
|
|
|
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 {
|
|
console.log('[FOLDER] Début de la création d\'un nouveau dossier')
|
|
const folderHash = generateFolderHash()
|
|
console.log(`[FOLDER] Hash généré: ${folderHash}`)
|
|
|
|
const result = createFolderStructure(folderHash)
|
|
console.log(`[FOLDER] Structure créée:`, result)
|
|
|
|
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 folderData = listFolderResults(folderHash)
|
|
|
|
console.log(`[FOLDER] Résultats récupérés pour le dossier ${folderHash}: ${folderData.results.length} fichiers, ${folderData.pending.length} en cours`)
|
|
|
|
res.json({
|
|
success: true,
|
|
folderHash,
|
|
results: folderData.results,
|
|
pending: folderData.pending,
|
|
hasPending: folderData.hasPending,
|
|
count: folderData.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 {
|
|
console.log(`[FOLDER] Traitement de ${testFile}...`)
|
|
|
|
// Simuler un objet req.file pour la logique existante
|
|
const mockFile = {
|
|
path: destPath,
|
|
originalname: testFile,
|
|
mimetype: getMimeType(ext)
|
|
}
|
|
|
|
// Extraction de texte selon le type de fichier
|
|
let ocrResult = null
|
|
if (ext.toLowerCase() === '.pdf') {
|
|
ocrResult = await extractTextFromPdf(destPath)
|
|
} else if (['.jpg', '.jpeg', '.png', '.tiff'].includes(ext.toLowerCase())) {
|
|
ocrResult = await extractTextFromImage(destPath)
|
|
}
|
|
|
|
if (ocrResult && ocrResult.text) {
|
|
// Extraction des entités
|
|
const entities = extractEntitiesFromText(ocrResult.text)
|
|
|
|
// Génération du résultat au format standard
|
|
const result = generateStandardJSON(mockFile, ocrResult, entities, 0)
|
|
|
|
// Sauvegarde dans le cache du dossier
|
|
saveJsonCacheInFolder(folderHash, fileHash, result)
|
|
console.log(`[FOLDER] Fichier de test traité: ${testFile} -> ${fileHash}`)
|
|
} else {
|
|
console.warn(`[FOLDER] Aucun texte extrait de ${testFile}`)
|
|
}
|
|
} 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'
|
|
})
|
|
})
|
|
|
|
// Vider le cache d'un dossier
|
|
app.delete('/api/folders/:folderHash/cache', (req, res) => {
|
|
const { folderHash } = req.params
|
|
|
|
console.log(`[CACHE] Demande de suppression du cache pour le dossier: ${folderHash}`)
|
|
|
|
try {
|
|
const { cachePath, uploadsPath } = createFolderStructure(folderHash)
|
|
|
|
let deletedFiles = 0
|
|
let deletedDirs = 0
|
|
|
|
// Supprimer le dossier cache s'il existe
|
|
if (fs.existsSync(cachePath)) {
|
|
const files = fs.readdirSync(cachePath)
|
|
for (const file of files) {
|
|
const filePath = path.join(cachePath, file)
|
|
try {
|
|
fs.unlinkSync(filePath)
|
|
deletedFiles++
|
|
console.log(`[CACHE] Fichier supprimé: ${file}`)
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la suppression de ${file}:`, error.message)
|
|
}
|
|
}
|
|
|
|
// Supprimer le dossier cache vide
|
|
try {
|
|
fs.rmdirSync(cachePath)
|
|
deletedDirs++
|
|
console.log(`[CACHE] Dossier cache supprimé: ${cachePath}`)
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la suppression du dossier cache:`, error.message)
|
|
}
|
|
}
|
|
|
|
// Supprimer le dossier uploads s'il existe
|
|
if (fs.existsSync(uploadsPath)) {
|
|
const files = fs.readdirSync(uploadsPath)
|
|
for (const file of files) {
|
|
const filePath = path.join(uploadsPath, file)
|
|
try {
|
|
fs.unlinkSync(filePath)
|
|
deletedFiles++
|
|
console.log(`[CACHE] Fichier upload supprimé: ${file}`)
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la suppression de ${file}:`, error.message)
|
|
}
|
|
}
|
|
|
|
// Supprimer le dossier uploads vide
|
|
try {
|
|
fs.rmdirSync(uploadsPath)
|
|
deletedDirs++
|
|
console.log(`[CACHE] Dossier uploads supprimé: ${uploadsPath}`)
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors de la suppression du dossier uploads:`, error.message)
|
|
}
|
|
}
|
|
|
|
console.log(`[CACHE] Cache vidé pour le dossier ${folderHash}: ${deletedFiles} fichiers, ${deletedDirs} dossiers supprimés`)
|
|
|
|
res.json({
|
|
success: true,
|
|
message: `Cache vidé pour le dossier ${folderHash}`,
|
|
deletedFiles,
|
|
deletedDirs,
|
|
folderHash
|
|
})
|
|
|
|
} catch (error) {
|
|
console.error(`[CACHE] Erreur lors du vidage du cache:`, error)
|
|
res.status(500).json({
|
|
success: false,
|
|
error: 'Erreur lors du vidage du cache',
|
|
details: error.message
|
|
})
|
|
}
|
|
})
|
|
|
|
// 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`)
|
|
console.log(`📂 Répertoire de travail: ${process.cwd()}`)
|
|
|
|
// Nettoyer les flags pending orphelins au démarrage
|
|
cleanupOrphanedPendingFlags()
|
|
})
|
|
|
|
module.exports = app
|