Initial commit
This commit is contained in:
+18
@@ -0,0 +1,18 @@
|
||||
# On part d'une version légère de Python
|
||||
FROM python:3.9-slim
|
||||
|
||||
# On se met dans un dossier de travail
|
||||
WORKDIR /app
|
||||
|
||||
# On copie les requirements et on installe
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# On copie le code
|
||||
COPY webapp.py .
|
||||
|
||||
# On expose le port 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# La commande de démarrage
|
||||
CMD ["python", "webapp.py"]
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
# On part d'une version légère de Python
|
||||
FROM python:3.9-slim
|
||||
|
||||
# On se met dans un dossier de travail
|
||||
WORKDIR /app
|
||||
|
||||
# On installe les dépendances système (nécessaire parfois pour pdfplumber/images)
|
||||
RUN apt-get update && apt-get install -y libgl1-mesa-glx && rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# On copie les requirements et on installe
|
||||
COPY requirements.txt .
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# On copie le code
|
||||
COPY webapp.py .
|
||||
|
||||
# On expose le port 5000
|
||||
EXPOSE 5000
|
||||
|
||||
# La commande de démarrage
|
||||
CMD ["python", "webapp.py"]
|
||||
@@ -0,0 +1,7 @@
|
||||
services:
|
||||
extracteur-pdf:
|
||||
build: .
|
||||
container_name: extracteur-pdf
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "5050:5000" # J'ai mis 5050 pour éviter un conflit si le 5000 est pris
|
||||
@@ -0,0 +1,5 @@
|
||||
flask
|
||||
pdfplumber
|
||||
pandas
|
||||
openpyxl
|
||||
xlsxwriter
|
||||
@@ -0,0 +1,277 @@
|
||||
import os
|
||||
import io
|
||||
import pandas as pd
|
||||
import pdfplumber
|
||||
from flask import Flask, request, render_template_string, Response, send_from_directory
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Dossier temporaire pour stocker le fichier généré à l'intérieur du conteneur
|
||||
TEMP_DIR = "/app/downloads"
|
||||
if not os.path.exists(TEMP_DIR):
|
||||
os.makedirs(TEMP_DIR)
|
||||
|
||||
# --- DESIGN HTML & JAVASCRIPT ---
|
||||
# Ce bloc est envoyé en premier au navigateur pour préparer l'interface de log
|
||||
HTML_LAYOUT = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Extracteur de formations</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f0f2f5; padding: 20px; color: #2d3748; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
|
||||
.log-window { background: #1a202c; color: #cbd5e0; padding: 15px; border-radius: 8px; height: 400px; overflow-y: auto; font-family: 'Courier New', Courier, monospace; font-size: 12px; margin-top: 20px; border: 1px solid #2d3748; line-height: 1.5; }
|
||||
.progress-bar { width: 100%; background: #e2e8f0; height: 12px; border-radius: 6px; margin-top: 20px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #4299e1; width: 0%; transition: width 0.3s ease; }
|
||||
.status-ok { color: #68d391; font-weight: bold; }
|
||||
.status-skip { color: #718096; }
|
||||
.status-err { color: #f56565; font-weight: bold; }
|
||||
.btn-download { display: inline-block; background: #48bb78; color: white; padding: 15px 30px; border-radius: 8px; text-decoration: none; font-weight: bold; margin-top: 25px; transition: 0.2s; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.btn-download:hover { background: #38a169; transform: translateY(-2px); }
|
||||
h1 { margin-top: 0; color: #2d3748; font-size: 1.5rem; }
|
||||
p { color: #4a5568; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Analyse du catalogue en cours...</h1>
|
||||
<p>Chaque page du fichier est analysée pour extraire les fiches de formation.</p>
|
||||
|
||||
<div class="progress-bar"><div id="progress" class="progress-fill"></div></div>
|
||||
|
||||
<div id="logs" class="log-window">
|
||||
> Initialisation du moteur d'extraction...<br>
|
||||
</div>
|
||||
|
||||
<div id="final-link" style="display:none; text-align: center;">
|
||||
<hr style="margin: 30px 0; border: 0; border-top: 1px solid #e2e8f0;">
|
||||
<p>✅ <b>Analyse terminée avec succès !</b></p>
|
||||
<a href="/download" class="btn-download">📥 Télécharger le résultat (Excel)</a>
|
||||
<br><br><a href="/" style="color: #a0aec0; font-size: 0.9rem;">Lancer une nouvelle analyse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const logs = document.getElementById('logs');
|
||||
const progress = document.getElementById('progress');
|
||||
|
||||
function updateLog(msg, percent) {
|
||||
const line = document.createElement('div');
|
||||
line.innerHTML = msg;
|
||||
logs.appendChild(line);
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
progress.style.width = percent + '%';
|
||||
}
|
||||
|
||||
function showFinal() {
|
||||
document.getElementById('final-link').style.display = 'block';
|
||||
document.querySelector('h1').innerText = "Extraction terminée !";
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
|
||||
# --- PAGE D'ACCUEIL ---
|
||||
@app.route("/")
|
||||
def home():
|
||||
return render_template_string("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Extracteur de formations</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f0f2f5; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||
.card { background: white; padding: 2.5rem; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); text-align: center; width: 100%; max-width: 420px; }
|
||||
|
||||
h1 { color: #1a202c; margin-bottom: 0.5rem; font-size: 1.8rem; }
|
||||
.subtitle { color: #718096; margin-bottom: 2rem; font-size: 0.95rem; line-height: 1.4; }
|
||||
|
||||
/* Style du bouton Parcourir personnalisé */
|
||||
.file-input-container { margin-bottom: 1.5rem; position: relative; }
|
||||
|
||||
#pdf_file { display: none; } /* On cache l'input moche par défaut */
|
||||
|
||||
.custom-file-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border: 2px dashed #cbd5e0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #f8fafc;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.custom-file-upload:hover {
|
||||
border-color: #3182ce;
|
||||
background: #ebf8ff;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.file-icon { font-size: 1.5rem; }
|
||||
#file-name { font-weight: 500; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 250px; }
|
||||
|
||||
/* Style du bouton Valider */
|
||||
.btn-submit {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 6px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #2b6cb0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-submit:active { transform: translateY(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📄</div>
|
||||
<h1>Extracteur de formations</h1>
|
||||
<p class="subtitle">Sélectionnez le catalogue de formations (PDF) pour extraire les données vers Excel.</p>
|
||||
|
||||
<form action="/process" method="post" enctype="multipart/form-data" onsubmit="return validateFile()">
|
||||
<div class="file-input-container">
|
||||
<input type="file" name="pdf_file" id="pdf_file" accept=".pdf" required onchange="updateFileName()">
|
||||
<label for="pdf_file" class="custom-file-upload">
|
||||
<span class="file-icon">📂</span>
|
||||
<span id="file-name">Choisir le fichier PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Lancer l'extraction</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Affiche le nom du fichier une fois sélectionné
|
||||
function updateFileName() {
|
||||
const input = document.getElementById('pdf_file');
|
||||
const fileNameDisplay = document.getElementById('file-name');
|
||||
if (input.files.length > 0) {
|
||||
fileNameDisplay.innerText = input.files[0].name;
|
||||
document.querySelector('.custom-file-upload').style.borderColor = '#48bb78';
|
||||
document.querySelector('.custom-file-upload').style.background = '#f0fff4';
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifie que c'est bien un PDF avant d'envoyer
|
||||
function validateFile() {
|
||||
const input = document.getElementById('pdf_file');
|
||||
const file = input.files[0];
|
||||
if (file && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
alert("Erreur : Veuillez sélectionner un fichier au format PDF uniquement.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
# --- MOTEUR D'EXTRACTION ---
|
||||
@app.route("/process", methods=["POST"])
|
||||
def process():
|
||||
file = request.files['pdf_file']
|
||||
if not file: return "Fichier manquant"
|
||||
|
||||
file_content = file.read() # On garde le fichier en mémoire vive
|
||||
|
||||
def generate():
|
||||
yield HTML_LAYOUT # Envoie l'interface au navigateur
|
||||
|
||||
all_formations = []
|
||||
COLONNES_ADMISES = ["Référence", "Durée", "Type", "Publics éligibles", "Niveau", "Sessions", "Tarifs"]
|
||||
|
||||
try:
|
||||
with pdfplumber.open(io.BytesIO(file_content)) as pdf:
|
||||
total_pages = len(pdf.pages)
|
||||
yield f"<script>updateLog('<b>Document chargé : {total_pages} pages identifiées.</b>', 0)</script>"
|
||||
|
||||
for i, page in enumerate(pdf.pages):
|
||||
page_num = i + 1
|
||||
percent = int((page_num / total_pages) * 100)
|
||||
|
||||
try:
|
||||
# Zone de détection (haut de page)
|
||||
check_zone = page.crop((page.width * 0.5, 0, page.width, 400))
|
||||
text = check_zone.extract_text() or ""
|
||||
|
||||
if "Référence" in text:
|
||||
# --- EXTRACTION DE LA FICHE ---
|
||||
header = page.crop((0, 0, page.width, 100))
|
||||
titre_brut = header.extract_text().split('\n')[0].strip()
|
||||
# Sécurisation du titre pour JS (on enlève les quotes)
|
||||
titre_clean = titre_brut.replace("'", "").replace('"', '').strip()[:60]
|
||||
|
||||
data = {'Titre': titre_brut, 'Page_Source': page_num}
|
||||
|
||||
# Extraction du tableau à droite
|
||||
right_side = page.crop((page.width * 0.6, 180, page.width, 600))
|
||||
table = right_side.extract_table()
|
||||
if table:
|
||||
for row in table:
|
||||
if len(row) >= 2 and row[0]:
|
||||
cle = row[0].replace('\n', ' ').strip()
|
||||
for col in COLONNES_ADMISES:
|
||||
if col.lower() in cle.lower():
|
||||
data[col] = row[1].replace('\n', ' ').strip()
|
||||
break
|
||||
|
||||
all_formations.append(data)
|
||||
yield f"<script>updateLog('<span class=\"status-ok\">[OK] Page {page_num} : formation extraite ({titre_clean}...)</span>', {percent})</script>"
|
||||
|
||||
else:
|
||||
# --- PAGE IGNORÉE ---
|
||||
yield f"<script>updateLog('<span class=\"status-skip\">[SKIP] Page {page_num} : pas une fiche de formation</span>', {percent})</script>"
|
||||
|
||||
except Exception as page_err:
|
||||
yield f"<script>updateLog('<span class=\"status-err\">[ERREUR] Page {page_num} : {str(page_err)}</span>', {percent})</script>"
|
||||
|
||||
# --- FIN DE BOUCLE : SAUVEGARDE EXCEL ---
|
||||
yield f"<script>updateLog('<b>Génération du fichier Excel final...</b>', 99)</script>"
|
||||
|
||||
if all_formations:
|
||||
df = pd.DataFrame(all_formations)
|
||||
output_path = os.path.join(TEMP_DIR, "resultat.xlsx")
|
||||
# On utilise xlsxwriter pour la stabilité
|
||||
df.to_excel(output_path, index=False, engine='xlsxwriter')
|
||||
yield f"<script>updateLog('<b>Extraction terminée ! {len(all_formations)} formations trouvées.</b>', 100)</script>"
|
||||
yield "<script>showFinal()</script>"
|
||||
else:
|
||||
yield f"<script>updateLog('<span class=\"status-err\">Aucune formation trouvée dans le document.</span>', 100)</script>"
|
||||
|
||||
except Exception as global_err:
|
||||
yield f"<script>updateLog('<span class=\"status-err\">ERREUR GLOBALE : {str(global_err)}</span>', 0)</script>"
|
||||
|
||||
yield "</body></html>"
|
||||
|
||||
return Response(generate(), mimetype='text/html')
|
||||
|
||||
# --- TÉLÉCHARGEMENT ---
|
||||
@app.route("/download")
|
||||
def download():
|
||||
return send_from_directory(TEMP_DIR, "resultat.xlsx", as_attachment=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Écoute sur 0.0.0.0 pour Docker
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
+277
@@ -0,0 +1,277 @@
|
||||
import os
|
||||
import io
|
||||
import pandas as pd
|
||||
import pdfplumber
|
||||
from flask import Flask, request, render_template_string, Response, send_from_directory
|
||||
|
||||
app = Flask(__name__)
|
||||
|
||||
# Dossier temporaire pour stocker le fichier généré à l'intérieur du conteneur
|
||||
TEMP_DIR = "/app/downloads"
|
||||
if not os.path.exists(TEMP_DIR):
|
||||
os.makedirs(TEMP_DIR)
|
||||
|
||||
# --- DESIGN HTML & JAVASCRIPT ---
|
||||
# Ce bloc est envoyé en premier au navigateur pour préparer l'interface de log
|
||||
HTML_LAYOUT = """
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Extracteur PDF - Progression en direct</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; background: #f0f2f5; padding: 20px; color: #2d3748; }
|
||||
.container { max-width: 900px; margin: 0 auto; background: white; padding: 30px; border-radius: 12px; box-shadow: 0 4px 20px rgba(0,0,0,0.08); }
|
||||
.log-window { background: #1a202c; color: #cbd5e0; padding: 15px; border-radius: 8px; height: 400px; overflow-y: auto; font-family: 'Courier New', Courier, monospace; font-size: 12px; margin-top: 20px; border: 1px solid #2d3748; line-height: 1.5; }
|
||||
.progress-bar { width: 100%; background: #e2e8f0; height: 12px; border-radius: 6px; margin-top: 20px; overflow: hidden; }
|
||||
.progress-fill { height: 100%; background: #4299e1; width: 0%; transition: width 0.3s ease; }
|
||||
.status-ok { color: #68d391; font-weight: bold; }
|
||||
.status-skip { color: #718096; }
|
||||
.status-err { color: #f56565; font-weight: bold; }
|
||||
.btn-download { display: inline-block; background: #48bb78; color: white; padding: 15px 30px; border-radius: 8px; text-decoration: none; font-weight: bold; margin-top: 25px; transition: 0.2s; box-shadow: 0 4px 6px rgba(0,0,0,0.1); }
|
||||
.btn-download:hover { background: #38a169; transform: translateY(-2px); }
|
||||
h1 { margin-top: 0; color: #2d3748; font-size: 1.5rem; }
|
||||
p { color: #4a5568; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<h1>Analyse du catalogue en cours...</h1>
|
||||
<p>Chaque page du fichier est analysée pour extraire les fiches de formation.</p>
|
||||
|
||||
<div class="progress-bar"><div id="progress" class="progress-fill"></div></div>
|
||||
|
||||
<div id="logs" class="log-window">
|
||||
> Initialisation du moteur d'extraction...<br>
|
||||
</div>
|
||||
|
||||
<div id="final-link" style="display:none; text-align: center;">
|
||||
<hr style="margin: 30px 0; border: 0; border-top: 1px solid #e2e8f0;">
|
||||
<p>✅ <b>Analyse terminée avec succès !</b></p>
|
||||
<a href="/download" class="btn-download">📥 Télécharger le résultat (Excel)</a>
|
||||
<br><br><a href="/" style="color: #a0aec0; font-size: 0.9rem;">Lancer une nouvelle analyse</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
const logs = document.getElementById('logs');
|
||||
const progress = document.getElementById('progress');
|
||||
|
||||
function updateLog(msg, percent) {
|
||||
const line = document.createElement('div');
|
||||
line.innerHTML = msg;
|
||||
logs.appendChild(line);
|
||||
logs.scrollTop = logs.scrollHeight;
|
||||
progress.style.width = percent + '%';
|
||||
}
|
||||
|
||||
function showFinal() {
|
||||
document.getElementById('final-link').style.display = 'block';
|
||||
document.querySelector('h1').innerText = "Extraction terminée !";
|
||||
}
|
||||
</script>
|
||||
"""
|
||||
|
||||
# --- PAGE D'ACCUEIL ---
|
||||
@app.route("/")
|
||||
def home():
|
||||
return render_template_string("""
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Extracteur de formations</title>
|
||||
<style>
|
||||
body { font-family: 'Segoe UI', system-ui, sans-serif; background: #f0f2f5; display: flex; justify-content: center; align-items: center; height: 100vh; margin: 0; }
|
||||
.card { background: white; padding: 2.5rem; border-radius: 20px; box-shadow: 0 10px 30px rgba(0,0,0,0.08); text-align: center; width: 100%; max-width: 420px; }
|
||||
|
||||
h1 { color: #1a202c; margin-bottom: 0.5rem; font-size: 1.8rem; }
|
||||
.subtitle { color: #718096; margin-bottom: 2rem; font-size: 0.95rem; line-height: 1.4; }
|
||||
|
||||
/* Style du bouton Parcourir personnalisé */
|
||||
.file-input-container { margin-bottom: 1.5rem; position: relative; }
|
||||
|
||||
#pdf_file { display: none; } /* On cache l'input moche par défaut */
|
||||
|
||||
.custom-file-upload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 12px;
|
||||
border: 2px dashed #cbd5e0;
|
||||
padding: 1.5rem;
|
||||
border-radius: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
background: #f8fafc;
|
||||
color: #4a5568;
|
||||
}
|
||||
|
||||
.custom-file-upload:hover {
|
||||
border-color: #3182ce;
|
||||
background: #ebf8ff;
|
||||
color: #2b6cb0;
|
||||
}
|
||||
|
||||
.file-icon { font-size: 1.5rem; }
|
||||
#file-name { font-weight: 500; font-size: 0.9rem; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 250px; }
|
||||
|
||||
/* Style du bouton Valider */
|
||||
.btn-submit {
|
||||
background: #3182ce;
|
||||
color: white;
|
||||
border: none;
|
||||
padding: 14px 24px;
|
||||
border-radius: 12px;
|
||||
font-size: 1rem;
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
font-weight: 600;
|
||||
transition: all 0.2s;
|
||||
box-shadow: 0 4px 6px rgba(49, 130, 206, 0.2);
|
||||
}
|
||||
|
||||
.btn-submit:hover {
|
||||
background: #2b6cb0;
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 6px 12px rgba(49, 130, 206, 0.3);
|
||||
}
|
||||
|
||||
.btn-submit:active { transform: translateY(0); }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="card">
|
||||
<div style="font-size: 3rem; margin-bottom: 1rem;">📄</div>
|
||||
<h1>Extracteur PDF</h1>
|
||||
<p class="subtitle">Sélectionnez le catalogue de formations (PDF) pour extraire les données vers Excel.</p>
|
||||
|
||||
<form action="/process" method="post" enctype="multipart/form-data" onsubmit="return validateFile()">
|
||||
<div class="file-input-container">
|
||||
<input type="file" name="pdf_file" id="pdf_file" accept=".pdf" required onchange="updateFileName()">
|
||||
<label for="pdf_file" class="custom-file-upload">
|
||||
<span class="file-icon">📂</span>
|
||||
<span id="file-name">Choisir le fichier PDF</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn-submit">Lancer l'extraction</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Affiche le nom du fichier une fois sélectionné
|
||||
function updateFileName() {
|
||||
const input = document.getElementById('pdf_file');
|
||||
const fileNameDisplay = document.getElementById('file-name');
|
||||
if (input.files.length > 0) {
|
||||
fileNameDisplay.innerText = input.files[0].name;
|
||||
document.querySelector('.custom-file-upload').style.borderColor = '#48bb78';
|
||||
document.querySelector('.custom-file-upload').style.background = '#f0fff4';
|
||||
}
|
||||
}
|
||||
|
||||
// Vérifie que c'est bien un PDF avant d'envoyer
|
||||
function validateFile() {
|
||||
const input = document.getElementById('pdf_file');
|
||||
const file = input.files[0];
|
||||
if (file && !file.name.toLowerCase().endsWith('.pdf')) {
|
||||
alert("Erreur : Veuillez sélectionner un fichier au format PDF uniquement.");
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
""")
|
||||
|
||||
# --- MOTEUR D'EXTRACTION ---
|
||||
@app.route("/process", methods=["POST"])
|
||||
def process():
|
||||
file = request.files['pdf_file']
|
||||
if not file: return "Fichier manquant"
|
||||
|
||||
file_content = file.read() # On garde le fichier en mémoire vive
|
||||
|
||||
def generate():
|
||||
yield HTML_LAYOUT # Envoie l'interface au navigateur
|
||||
|
||||
all_formations = []
|
||||
COLONNES_ADMISES = ["Référence", "Durée", "Type", "Publics éligibles", "Niveau", "Sessions", "Tarifs"]
|
||||
|
||||
try:
|
||||
with pdfplumber.open(io.BytesIO(file_content)) as pdf:
|
||||
total_pages = len(pdf.pages)
|
||||
yield f"<script>updateLog('<b>Document chargé : {total_pages} pages identifiées.</b>', 0)</script>"
|
||||
|
||||
for i, page in enumerate(pdf.pages):
|
||||
page_num = i + 1
|
||||
percent = int((page_num / total_pages) * 100)
|
||||
|
||||
try:
|
||||
# Zone de détection (haut de page)
|
||||
check_zone = page.crop((page.width * 0.5, 0, page.width, 400))
|
||||
text = check_zone.extract_text() or ""
|
||||
|
||||
if "Référence" in text:
|
||||
# --- EXTRACTION DE LA FICHE ---
|
||||
header = page.crop((0, 0, page.width, 100))
|
||||
titre_brut = header.extract_text().split('\n')[0].strip()
|
||||
# Sécurisation du titre pour JS (on enlève les quotes)
|
||||
titre_clean = titre_brut.replace("'", "").replace('"', '').strip()[:60]
|
||||
|
||||
data = {'Titre': titre_brut, 'Page_Source': page_num}
|
||||
|
||||
# Extraction du tableau à droite
|
||||
right_side = page.crop((page.width * 0.6, 180, page.width, 600))
|
||||
table = right_side.extract_table()
|
||||
if table:
|
||||
for row in table:
|
||||
if len(row) >= 2 and row[0]:
|
||||
cle = row[0].replace('\n', ' ').strip()
|
||||
for col in COLONNES_ADMISES:
|
||||
if col.lower() in cle.lower():
|
||||
data[col] = row[1].replace('\n', ' ').strip()
|
||||
break
|
||||
|
||||
all_formations.append(data)
|
||||
yield f"<script>updateLog('<span class=\"status-ok\">[OK] Page {page_num} : formation extraite ({titre_clean}...)</span>', {percent})</script>"
|
||||
|
||||
else:
|
||||
# --- PAGE IGNORÉE ---
|
||||
yield f"<script>updateLog('<span class=\"status-skip\">[SKIP] Page {page_num} : pas une fiche de formation</span>', {percent})</script>"
|
||||
|
||||
except Exception as page_err:
|
||||
yield f"<script>updateLog('<span class=\"status-err\">[ERREUR] Page {page_num} : {str(page_err)}</span>', {percent})</script>"
|
||||
|
||||
# --- FIN DE BOUCLE : SAUVEGARDE EXCEL ---
|
||||
yield f"<script>updateLog('<b>Génération du fichier Excel final...</b>', 99)</script>"
|
||||
|
||||
if all_formations:
|
||||
df = pd.DataFrame(all_formations)
|
||||
output_path = os.path.join(TEMP_DIR, "resultat.xlsx")
|
||||
# On utilise xlsxwriter pour la stabilité
|
||||
df.to_excel(output_path, index=False, engine='xlsxwriter')
|
||||
yield f"<script>updateLog('<b>Extraction terminée ! {len(all_formations)} formations trouvées.</b>', 100)</script>"
|
||||
yield "<script>showFinal()</script>"
|
||||
else:
|
||||
yield f"<script>updateLog('<span class=\"status-err\">Aucune formation trouvée dans le document.</span>', 100)</script>"
|
||||
|
||||
except Exception as global_err:
|
||||
yield f"<script>updateLog('<span class=\"status-err\">ERREUR GLOBALE : {str(global_err)}</span>', 0)</script>"
|
||||
|
||||
yield "</body></html>"
|
||||
|
||||
return Response(generate(), mimetype='text/html')
|
||||
|
||||
# --- TÉLÉCHARGEMENT ---
|
||||
@app.route("/download")
|
||||
def download():
|
||||
return send_from_directory(TEMP_DIR, "resultat.xlsx", as_attachment=True)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Écoute sur 0.0.0.0 pour Docker
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
Reference in New Issue
Block a user