Comment j'ai développé une solution complète d'extraction de données PDF pour automatiser la saisie des propales dans un CRM. Retour d'expérience technique avec code source et techniques d'OCR.
Client : Entreprise de services (Marseille)
Problème : Les commerciaux passaient 2h par jour à saisir manuellement les données des propales PDF dans le CRM
Objectif : Automatiser l'extraction et permettre la validation avant intégration
La solution AutoProposal V2 se compose de 3 modules principaux :
Cet article contient du code source complet, des techniques avancées d'OCR et des exemples d'intégration CRM.
# requirements.txt
Flask==2.3.3
PyPDF2==3.0.1
pytesseract==0.3.10
Pillow==10.0.0
pandas==2.0.3
openpyxl==3.1.2
python-dotenv==1.0.0
Voici le cœur de la solution : l'extraction intelligente des données PDF.
import PyPDF2
import pytesseract
from PIL import Image
import re
import json
class PDFExtractor:
def __init__(self):
self.patterns = {
'montant': r'(\d{1,3}(?:\s?\d{3})*(?:[.,]\d{2})?)\s*€',
'date': r'(\d{1,2}[./-]\d{1,2}[./-]\d{2,4})',
'client': r'Client[:\s]+([^\n\r]+)',
'reference': r'Réf[:\s]+([A-Z0-9-]+)'
}
def extract_text_from_pdf(self, pdf_path):
"""Extraction du texte depuis un PDF"""
text = ""
try:
with open(pdf_path, 'rb') as file:
pdf_reader = PyPDF2.PdfReader(file)
for page in pdf_reader.pages:
text += page.extract_text() + "\n"
except Exception as e:
print(f"Erreur extraction PDF: {e}")
return None
return text
def extract_with_ocr(self, pdf_path):
"""Extraction via OCR si le PDF est scanné"""
try:
# Conversion PDF vers images
images = convert_from_path(pdf_path)
text = ""
for image in images:
text += pytesseract.image_to_string(image, lang='fra') + "\n"
return text
except Exception as e:
print(f"Erreur OCR: {e}")
return None
def parse_data(self, text):
"""Parsing des données extraites"""
data = {}
for key, pattern in self.patterns.items():
matches = re.findall(pattern, text, re.IGNORECASE)
if matches:
data[key] = matches[0] if len(matches) == 1 else matches
# Nettoyage des données
if 'montant' in data:
data['montant'] = self.clean_amount(data['montant'])
return data
def clean_amount(self, amount_str):
"""Nettoyage du montant"""
# Supprimer les espaces et normaliser les séparateurs
amount_str = amount_str.replace(' ', '').replace(',', '.')
try:
return float(amount_str)
except ValueError:
return None
Interface simple et intuitive pour les commerciaux :
from flask import Flask, render_template, request, jsonify
import os
from werkzeug.utils import secure_filename
app = Flask(__name__)
app.config['UPLOAD_FOLDER'] = 'uploads/'
app.config['MAX_CONTENT_LENGTH'] = 16 * 1024 * 1024 # 16MB max
@app.route('/')
def index():
return render_template('upload.html')
@app.route('/upload', methods=['POST'])
def upload_file():
if 'file' not in request.files:
return jsonify({'error': 'Aucun fichier sélectionné'})
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'Aucun fichier sélectionné'})
if file and allowed_file(file.filename):
filename = secure_filename(file.filename)
filepath = os.path.join(app.config['UPLOAD_FOLDER'], filename)
file.save(filepath)
# Extraction des données
extractor = PDFExtractor()
text = extractor.extract_text_from_pdf(filepath)
if not text or len(text.strip()) < 50:
# Essayer avec OCR
text = extractor.extract_with_ocr(filepath)
if text:
data = extractor.parse_data(text)
return jsonify({
'success': True,
'data': data,
'raw_text': text[:500] + '...' if len(text) > 500 else text
})
else:
return jsonify({'error': 'Impossible d\'extraire le texte du PDF'})
return jsonify({'error': 'Format de fichier non supporté'})
def allowed_file(filename):
return '.' in filename and filename.rsplit('.', 1)[1].lower() == 'pdf'
if __name__ == '__main__':
app.run(debug=True)
<!-- upload.html -->
<!DOCTYPE html>
<html>
<head>
<title>AutoProposal V2</title>
<style>
.upload-area {
border: 2px dashed #ccc;
border-radius: 10px;
padding: 40px;
text-align: center;
margin: 20px 0;
}
.upload-area.dragover {
border-color: #007bff;
background-color: #f8f9fa;
}
.data-preview {
background: #f8f9fa;
padding: 20px;
border-radius: 8px;
margin: 20px 0;
}
.form-group {
margin: 10px 0;
}
.form-group label {
display: block;
margin-bottom: 5px;
font-weight: bold;
}
.form-group input {
width: 100%;
padding: 8px;
border: 1px solid #ddd;
border-radius: 4px;
}
</style>
</head>
<body>
<h1>AutoProposal V2</h1>
<div class="upload-area" id="uploadArea">
<p>Glissez-déposez votre PDF ici ou cliquez pour sélectionner</p>
<input type="file" id="fileInput" accept=".pdf" style="display: none;">
</div>
<div id="dataPreview" style="display: none;">
<h3>Données extraites :</h3>
<div class="data-preview">
<div class="form-group">
<label>Client :</label>
<input type="text" id="client">
</div>
<div class="form-group">
<label>Montant :</label>
<input type="number" id="montant" step="0.01">
</div>
<div class="form-group">
<label>Date :</label>
<input type="date" id="date">
</div>
<div class="form-group">
<label>Référence :</label>
<input type="text" id="reference">
</div>
<button onclick="saveToCRM()">Sauvegarder dans le CRM</button>
</div>
</div>
<script>
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const dataPreview = document.getElementById('dataPreview');
uploadArea.addEventListener('click', () => fileInput.click());
uploadArea.addEventListener('dragover', handleDragOver);
uploadArea.addEventListener('drop', handleDrop);
function handleDragOver(e) {
e.preventDefault();
uploadArea.classList.add('dragover');
}
function handleDrop(e) {
e.preventDefault();
uploadArea.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFile(files[0]);
}
}
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFile(e.target.files[0]);
}
});
function handleFile(file) {
const formData = new FormData();
formData.append('file', file);
fetch('/upload', {
method: 'POST',
body: formData
})
.then(response => response.json())
.then(data => {
if (data.success) {
displayData(data.data);
} else {
alert('Erreur: ' + data.error);
}
})
.catch(error => {
console.error('Error:', error);
alert('Erreur lors du traitement du fichier');
});
}
function displayData(data) {
document.getElementById('client').value = data.client || '';
document.getElementById('montant').value = data.montant || '';
document.getElementById('date').value = data.date || '';
document.getElementById('reference').value = data.reference || '';
dataPreview.style.display = 'block';
}
function saveToCRM() {
// Logique de sauvegarde dans le CRM
alert('Données sauvegardées dans le CRM !');
}
</script>
</body>
</html>
Pour l'intégration avec le CRM, j'ai créé un module d'export :
import pandas as pd
import requests
import json
class CRMExporter:
def __init__(self, crm_api_url, api_key):
self.crm_api_url = crm_api_url
self.api_key = api_key
self.headers = {
'Authorization': f'Bearer {api_key}',
'Content-Type': 'application/json'
}
def export_to_crm(self, data):
"""Export des données vers le CRM"""
crm_data = {
'client_name': data.get('client', ''),
'amount': data.get('montant', 0),
'date': data.get('date', ''),
'reference': data.get('reference', ''),
'status': 'pending_validation'
}
try:
response = requests.post(
f"{self.crm_api_url}/proposals",
headers=self.headers,
json=crm_data
)
if response.status_code == 201:
return {
'success': True,
'crm_id': response.json().get('id'),
'message': 'Propal ajoutée au CRM avec succès'
}
else:
return {
'success': False,
'error': f'Erreur CRM: {response.status_code}'
}
except Exception as e:
return {
'success': False,
'error': f'Erreur de connexion: {str(e)}'
}
def export_to_excel(self, data_list, filename):
"""Export vers Excel pour validation manuelle"""
df = pd.DataFrame(data_list)
df.to_excel(filename, index=False)
return f'Données exportées vers {filename}'
La solution AutoProposal V2 a transformé le processus de saisie :
La version 2 apporte des améliorations significatives :
Chaque entreprise a ses spécificités. Contactez-moi pour une solution d'extraction PDF sur-mesure adaptée à vos besoins.
Discuter de votre projet