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