diff --git a/.gitignore b/.gitignore
index daefb8e..75503a1 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,4 +2,6 @@
fb_profile/
prospects.csv
config.json
+backups/
+scrape.log
__pycache__/
diff --git a/README.md b/README.md
index 6bb4248..c918951 100644
--- a/README.md
+++ b/README.md
@@ -31,11 +31,21 @@ Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s
- **Statut de prospection** : chaque prospect a un statut (À contacter, Contacté, En discussion, Concert programmé, Sans réponse, Refusé) affiché en pastille colorée. Un menu déroulant au-dessus de la liste permet de filtrer par statut.
- **Import en masse** : dans la section *Import en masse*, collez plusieurs liens Facebook (un par ligne) et cliquez sur **Importer la liste**. Les lieux sont analysés un par un et ajoutés automatiquement ; l'avancement s'affiche en direct et les doublons (déjà présents) sont ignorés.
-- **Prise de contact** : en dépliant une fiche, des boutons permettent d'**écrire un mail** (sujet et corps pré-remplis depuis le modèle de message), d'**appeler**, d'ouvrir la **page Facebook/Messenger** ou de **copier le message**. Le modèle se règle dans **⚙️ Paramètres** (variables `{nom}`, `{ville}`, `{type}`).
+- **Prise de contact** : en dépliant une fiche, un bouton permet de **copier le message** de prise de contact. Le modèle se règle dans **⚙️ Paramètres** (variables `{nom}`, `{ville}`, `{type}`).
+- **Notes libres** : chaque prospect a un champ **Notes** (visible en dépliant la fiche) pour consigner compte-rendu d'appel, contraintes, disponibilités… Une fiche avec des notes affiche un repère 📝.
+- **Relances** : les prospects au statut *Contacté* ou *En discussion* dont la **date de contact** dépasse le **délai de relance** (réglable dans les Paramètres, 7 jours par défaut) sont signalés par une notification 🔔 en haut de la liste et un badge sur la fiche. Cliquer sur un nom dans la notification ouvre directement la fiche. Aucun mail n'est envoyé : c'est un simple rappel visuel.
- **Distance, carburant et péage** : renseignez votre **adresse de départ**, la **consommation** (L/100 km), le **prix du carburant** et le **tarif péage** (€/km d'autoroute) dans les Paramètres, puis cliquez sur **🚗 Calculer le trajet** dans une fiche. L'application estime la distance routière, la durée et le coût total — **carburant + péage** — pour l'aller et l'aller-retour, via OpenStreetMap (Nominatim + OSRM, gratuit, sans clé). Le péage est estimé à partir des kilomètres d'autoroute du trajet × le tarif configuré (≈ 0,10 €/km ; mettez 0 pour l'ignorer) : c'est une approximation, certaines autoroutes « A » étant gratuites. Une connexion Internet est requise pour ce calcul.
Les paramètres sont stockés dans `config.json` (non versionné, car il contient votre adresse personnelle).
+## Robustesse
+
+- **Sauvegardes automatiques** : avant chaque écriture, l'ancien `prospects.csv` est copié dans le dossier `backups/` (les 30 versions les plus récentes sont conservées). En cas de fausse manip, vous pouvez restaurer une version en recopiant le fichier voulu à la place de `prospects.csv` (app arrêtée).
+- **Export Excel** : le bouton **Excel (.xlsx)** télécharge un vrai classeur (en plus du **CSV**), prêt à ouvrir dans Excel/Numbers/LibreOffice.
+- **Journal de scraping** : le panneau **🩺 Journal de scraping** (en bas) liste les analyses passées avec leur résultat (`ok`, `vide`, `doublon`, ou code d'erreur comme `login_required`, `redirection`…), pour comprendre rapidement pourquoi une page n'a rien donné. Il est stocké dans `scrape.log` (non versionné).
+
+Les dossiers/fichiers `backups/` et `scrape.log` restent locaux (non versionnés).
+
## À savoir
- ⚠️ N'éditez pas `prospects.csv` dans Excel pendant que l'app tourne : toute modification via l'interface réécrit le fichier et écraserait vos changements Excel. Ouvrez-le en lecture seulement, ou éditez via l'interface.
diff --git a/app.py b/app.py
index 6ef5cae..34bdea8 100644
--- a/app.py
+++ b/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})
diff --git a/excel.py b/excel.py
new file mode 100644
index 0000000..4a4dec5
--- /dev/null
+++ b/excel.py
@@ -0,0 +1,78 @@
+"""Génération d'un classeur .xlsx minimal, sans dépendance.
+
+Un fichier .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes
+« inline » (t="inlineStr") pour éviter la table des chaînes partagées : le résultat
+s'ouvre dans Excel, Numbers et LibreOffice. Bibliothèque standard uniquement.
+"""
+
+import io
+import zipfile
+from xml.sax.saxutils import escape
+
+
+def _ref(col, ligne):
+ """Référence de cellule façon tableur : (0, 1) -> « A1 », (27, 3) -> « AB3 »."""
+ lettres, n = "", col
+ while True:
+ n, reste = divmod(n, 26)
+ lettres = chr(65 + reste) + lettres
+ if n == 0:
+ break
+ n -= 1
+ return f"{lettres}{ligne}"
+
+
+def _cellule(col, ligne, valeur):
+ texte = escape("" if valeur is None else str(valeur))
+ return (f'
Le péage est estimé à partir des kilomètres d'autoroute du trajet × ce tarif (≈ 0,10 €/km pour une voiture). Mettez 0 pour ne pas compter de péage.
Historique des analyses : utile pour comprendre pourquoi une page n'a rien donné (mur de connexion, lien non reconnu, redirection…).
+