Ajout suivi prospection : statut, import en masse, message type, trajet+péage

- Statut de prospection (colonne CSV) avec badge coloré et filtre
- Import en masse de liens Facebook (streaming, dédoublonnage)
- Modèle de message de contact configurable + copie en un clic
- Estimation distance/carburant/péage via OpenStreetMap (Nominatim + OSRM)
- Section Paramètres + config.json (non versionné)
This commit is contained in:
jerem
2026-06-13 15:28:25 +02:00
parent 1e57e56643
commit 1cf427a0f2
6 changed files with 671 additions and 120 deletions

141
app.py
View File

@@ -1,24 +1,43 @@
"""Serveur local AutoMood : interface web + API + stockage CSV."""
import csv
import json
import os
import threading
import webbrowser
from datetime import date
from pathlib import Path
from flask import Flask, jsonify, request, send_file
from flask import Flask, Response, jsonify, request, send_file, stream_with_context
import extractor
import scraper
import trajet
DOSSIER = Path(__file__).parent
CSV_PATH = DOSSIER / "prospects.csv"
CONFIG_PATH = DOSSIER / "config.json"
COLONNES = [
"Nom du prospect", "Département", "Ville", "Code Postal", "Adresse",
"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",
]
STATUT_DEFAUT = "À contacter"
CONFIG_DEFAUT = {
"adresse_depart": "",
"conso_l_100km": 6.5,
"prix_carburant": 1.90,
"cout_peage_km": 0.10,
"modele_message": (
"Bonjour,\n\n"
"Je me permets de vous contacter au sujet de « {nom} »{ville}. "
"Nous organisons des concerts et serions ravis d'étudier la possibilité "
"d'un événement musical dans votre établissement.\n\n"
"Seriez-vous disponible pour en échanger ?\n\n"
"Bien cordialement,"
),
}
app = Flask(__name__, static_folder="static")
verrou_scrape = threading.Lock()
@@ -43,6 +62,24 @@ def ecrire_prospects(lignes):
os.replace(tmp, CSV_PATH)
def lire_config():
config = dict(CONFIG_DEFAUT)
if CONFIG_PATH.exists():
try:
with open(CONFIG_PATH, encoding="utf-8") as f:
config.update({k: v for k, v in json.load(f).items() if k in CONFIG_DEFAUT})
except Exception:
pass # config illisible : on retombe sur les valeurs par défaut
return config
def ecrire_config(config):
tmp = CONFIG_PATH.with_suffix(".json.tmp")
with open(tmp, "w", encoding="utf-8", newline="") as f:
json.dump(config, f, ensure_ascii=False, indent=2)
os.replace(tmp, CONFIG_PATH)
@app.get("/")
def accueil():
return app.send_static_file("index.html")
@@ -97,6 +134,8 @@ def api_ajouter():
ligne = {col: str(donnees.get(col, "")).strip() for col in COLONNES}
if not ligne["Date d'ajout"]:
ligne["Date d'ajout"] = date.today().strftime("%d/%m/%Y")
if not ligne["Statut"]:
ligne["Statut"] = STATUT_DEFAUT
with verrou_csv:
lignes = lire_prospects()
lignes.append(ligne)
@@ -135,6 +174,104 @@ def api_export():
return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv")
def _ajouter_resultat_lot(resultat):
"""Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage).
Retourne le compte-rendu (dict) à renvoyer au navigateur pour la ligne traitée.
"""
url = resultat["url"]
if not resultat["ok"]:
return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")}
champs = extractor.extraire(
resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"])
with verrou_csv:
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):
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)
return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]}
@app.post("/api/scrape-lot")
def api_scrape_lot():
donnees = request.get_json(silent=True) or {}
urls = [u.strip() for u in (donnees.get("urls") or []) if u and u.strip()]
if not urls:
return jsonify({"error": "vide", "message": "Collez au moins un lien Facebook."}), 400
if not verrou_scrape.acquire(blocking=False):
return jsonify({"error": "occupe", "message": "Une analyse est déjà en cours, patientez."}), 429
def generer():
# Flux NDJSON : une ligne JSON par lien, envoyée dès qu'il est traité.
try:
for resultat in scraper.scrape_lot(urls):
yield json.dumps(_ajouter_resultat_lot(resultat), ensure_ascii=False) + "\n"
except Exception as e:
yield json.dumps({"statut": "erreur", "message": f"Échec global : {e}"}, ensure_ascii=False) + "\n"
finally:
verrou_scrape.release()
return Response(stream_with_context(generer()), mimetype="application/x-ndjson")
@app.get("/api/config")
def api_config_lire():
return jsonify(lire_config())
@app.put("/api/config")
def api_config_ecrire():
donnees = request.get_json(silent=True) or {}
config = lire_config()
for cle in ("adresse_depart", "modele_message"):
if cle in donnees:
config[cle] = str(donnees[cle])
for cle in ("conso_l_100km", "prix_carburant", "cout_peage_km"):
if cle in donnees:
try:
config[cle] = float(str(donnees[cle]).replace(",", "."))
except (TypeError, ValueError):
pass # valeur non numérique : on garde l'ancienne
ecrire_config(config)
return jsonify({"ok": True, "config": config})
@app.get("/api/distance/<int:idx>")
def api_distance(idx):
lignes = lire_prospects()
if not 0 <= idx < len(lignes):
return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404
p = lignes[idx]
config = lire_config()
depart = (config.get("adresse_depart") or "").strip()
if not depart:
return jsonify({
"error": "pas_de_depart",
"message": "Renseignez d'abord votre adresse de départ dans les Paramètres.",
}), 400
arrivee = ", ".join(x.strip() for x in (p.get("Adresse"), p.get("Code Postal"), p.get("Ville")) if x and x.strip())
if not arrivee:
return jsonify({
"error": "pas_d_adresse",
"message": "Ce prospect n'a pas d'adresse ou de ville exploitable.",
}), 400
try:
resultat = trajet.calculer(
depart, arrivee,
float(config.get("conso_l_100km") or 0) or CONFIG_DEFAUT["conso_l_100km"],
float(config.get("prix_carburant") or 0) or CONFIG_DEFAUT["prix_carburant"],
float(config.get("cout_peage_km") or 0), # 0 = pas de péage compté
)
return jsonify(resultat)
except trajet.ErreurTrajet as e:
return jsonify({"error": "trajet", "message": str(e)}), 502
except Exception as e:
return jsonify({"error": "erreur", "message": f"Échec du calcul : {e}"}), 500
if __name__ == "__main__":
lire_prospects()
threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start()