Robustesse (backup, export Excel, journal) + notes libres et relances

- Sauvegarde automatique du CSV avant chaque écriture (backups/, 30 versions)
- Export Excel .xlsx sans dépendance (module excel.py)
- Journal de scraping (scrape.log) + panneau et endpoints /api/logs
- Note libre par prospect (colonne Notes, zone de texte)
- Notification de relance in-app (statut Contacté/En discussion + délai configurable)
This commit is contained in:
jerem
2026-06-13 15:47:49 +02:00
parent 1cf427a0f2
commit 02180f1c7b
5 changed files with 317 additions and 9 deletions

92
app.py
View File

@@ -5,11 +5,12 @@ import json
import os
import threading
import webbrowser
from datetime import date
from datetime import date, datetime
from pathlib import Path
from flask import Flask, Response, jsonify, request, send_file, stream_with_context
import excel
import extractor
import scraper
import trajet
@@ -17,10 +18,13 @@ import trajet
DOSSIER = Path(__file__).parent
CSV_PATH = DOSSIER / "prospects.csv"
CONFIG_PATH = DOSSIER / "config.json"
BACKUP_DIR = DOSSIER / "backups"
JOURNAL_PATH = DOSSIER / "scrape.log"
MAX_BACKUPS = 30
COLONNES = [
"Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse",
"Date d'ajout", "Date de contact", "Nom de contact",
"Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook",
"Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Notes",
]
STATUT_DEFAUT = "À contacter"
@@ -29,6 +33,7 @@ CONFIG_DEFAUT = {
"conso_l_100km": 6.5,
"prix_carburant": 1.90,
"cout_peage_km": 0.10,
"delai_relance_jours": 7,
"modele_message": (
"Bonjour,\n\n"
"Je me permets de vous contacter au sujet de « {nom} »{ville}. "
@@ -53,7 +58,30 @@ def lire_prospects():
return [{col: (ligne.get(col) or "") for col in COLONNES} for ligne in lecteur]
def _sauvegarder_csv():
"""Copie le CSV actuel dans backups/ avant qu'il ne soit écrasé.
Ne garde que les MAX_BACKUPS plus récents et saute la copie si rien n'a
changé depuis la dernière sauvegarde (évite les doublons inutiles).
"""
if not CSV_PATH.exists():
return
try:
contenu = CSV_PATH.read_bytes()
BACKUP_DIR.mkdir(exist_ok=True)
sauvegardes = sorted(BACKUP_DIR.glob("prospects-*.csv"))
if sauvegardes and sauvegardes[-1].read_bytes() == contenu:
return # identique à la dernière sauvegarde
horodatage = datetime.now().strftime("%Y%m%d-%H%M%S-%f")
(BACKUP_DIR / f"prospects-{horodatage}.csv").write_bytes(contenu)
for vieux in sauvegardes[:-(MAX_BACKUPS - 1)]:
vieux.unlink(missing_ok=True)
except Exception:
pass # une sauvegarde ratée ne doit jamais bloquer l'écriture
def ecrire_prospects(lignes):
_sauvegarder_csv()
tmp = CSV_PATH.with_suffix(".csv.tmp")
with open(tmp, "w", encoding="utf-8-sig", newline="") as f:
ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore")
@@ -62,6 +90,17 @@ def ecrire_prospects(lignes):
os.replace(tmp, CSV_PATH)
def journaliser(url, statut, message=""):
"""Ajoute une ligne au journal de scraping (date, statut, url, message)."""
horodatage = datetime.now().strftime("%d/%m/%Y %H:%M:%S")
ligne = "\t".join((horodatage, statut, url or "", message or "")).rstrip() + "\n"
try:
with open(JOURNAL_PATH, "a", encoding="utf-8") as f:
f.write(ligne)
except Exception:
pass
def lire_config():
config = dict(CONFIG_DEFAUT)
if CONFIG_PATH.exists():
@@ -112,11 +151,16 @@ def api_scrape():
try:
resultat = scraper.scrape(url)
champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["url"])
trouves = [c for c in ("Nom du prospect", "Téléphone", "Email", "Ville", "Adresse") if champs.get(c)]
journaliser(url, "ok" if champs.get("Nom du prospect") else "vide",
("trouvé : " + ", ".join(trouves)) if trouves else "aucun champ extrait")
return jsonify(champs)
except scraper.ErreurScrape as e:
journaliser(url, e.code, str(e))
statuts = {"login_required": 409, "page_introuvable": 404, "url_invalide": 400, "redirection": 422}
return jsonify({"error": e.code, "message": str(e)}), statuts.get(e.code, 500)
except Exception as e:
journaliser(url, "erreur", str(e))
return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500
finally:
verrou_scrape.release()
@@ -174,6 +218,42 @@ def api_export():
return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv")
@app.get("/api/export.xlsx")
def api_export_xlsx():
contenu = excel.construire_xlsx(COLONNES, lire_prospects())
return Response(
contenu,
mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
headers={"Content-Disposition": "attachment; filename=prospects.xlsx"},
)
@app.get("/api/logs")
def api_logs():
if not JOURNAL_PATH.exists():
return jsonify([])
with open(JOURNAL_PATH, encoding="utf-8") as f:
lignes = f.readlines()[-100:]
entrees = []
for ligne in reversed(lignes): # le plus récent en premier
parts = ligne.rstrip("\n").split("\t")
if len(parts) >= 3:
entrees.append({
"date": parts[0], "statut": parts[1], "url": parts[2],
"message": parts[3] if len(parts) > 3 else "",
})
return jsonify(entrees)
@app.delete("/api/logs")
def api_logs_vider():
try:
JOURNAL_PATH.unlink(missing_ok=True)
except Exception:
pass
return jsonify({"ok": True})
def _ajouter_resultat_lot(resultat):
"""Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage).
@@ -181,6 +261,7 @@ def _ajouter_resultat_lot(resultat):
"""
url = resultat["url"]
if not resultat["ok"]:
journaliser(url, resultat.get("code", "erreur"), resultat.get("message", "Échec."))
return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")}
champs = extractor.extraire(
resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"])
@@ -188,10 +269,12 @@ def _ajouter_resultat_lot(resultat):
lignes = lire_prospects()
lien = (champs.get("Lien Facebook") or "").strip()
if lien and any((l.get("Lien Facebook") or "").strip() == lien for l in lignes):
journaliser(url, "doublon", champs["Nom du prospect"])
return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]}
champs["Statut"] = STATUT_DEFAUT
lignes.append({col: champs.get(col, "") for col in COLONNES})
ecrire_prospects(lignes)
journaliser(url, "ajoute", champs["Nom du prospect"])
return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]}
@@ -235,6 +318,11 @@ def api_config_ecrire():
config[cle] = float(str(donnees[cle]).replace(",", "."))
except (TypeError, ValueError):
pass # valeur non numérique : on garde l'ancienne
if "delai_relance_jours" in donnees:
try:
config["delai_relance_jours"] = max(0, int(float(str(donnees["delai_relance_jours"]).replace(",", "."))))
except (TypeError, ValueError):
pass
ecrire_config(config)
return jsonify({"ok": True, "config": config})