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:
92
app.py
92
app.py
@@ -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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user