- trajet.py : fonction couts() pure (calcul des coûts sans appel réseau) - app.py : mémorisation distance/durée/péage + adresses dans le CSV ; auto-calcul best-effort après ajout (scrape simple et en lot) ; api_distance recalcule en local si adresses inchangées, complet sinon - index.html : affichage auto du trajet en cache à l'ouverture du prospect
479 lines
18 KiB
Python
479 lines
18 KiB
Python
"""Serveur local AutoMood : interface web + API + stockage CSV."""
|
|
|
|
import csv
|
|
import json
|
|
import os
|
|
import threading
|
|
import webbrowser
|
|
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 ia
|
|
import scraper
|
|
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", "Notes",
|
|
"Trajet distance km", "Trajet durée min", "Trajet km péage",
|
|
"Trajet adresse départ", "Trajet adresse arrivée",
|
|
]
|
|
STATUT_DEFAUT = "À contacter"
|
|
|
|
CONFIG_DEFAUT = {
|
|
"adresse_depart": "",
|
|
"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}. "
|
|
"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,"
|
|
),
|
|
"ia_modele": ia.MODELE_DEFAUT,
|
|
}
|
|
|
|
app = Flask(__name__, static_folder="static")
|
|
verrou_scrape = threading.Lock()
|
|
verrou_csv = threading.Lock()
|
|
# Sérialise les calculs de trajet (un appel Nominatim/OSRM à la fois, ~1 req/s).
|
|
verrou_trajet = threading.Lock()
|
|
|
|
|
|
def lire_prospects():
|
|
if not CSV_PATH.exists():
|
|
ecrire_prospects([])
|
|
return []
|
|
with open(CSV_PATH, encoding="utf-8-sig", newline="") as f:
|
|
lecteur = csv.DictReader(f, delimiter=";")
|
|
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")
|
|
ecrivain.writeheader()
|
|
ecrivain.writerows(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():
|
|
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)
|
|
|
|
|
|
def _adresse_arrivee(p):
|
|
"""Reconstruit l'adresse d'arrivée d'un prospect (Adresse, Code Postal, Ville)."""
|
|
return ", ".join(
|
|
x.strip() for x in (p.get("Adresse"), p.get("Code Postal"), p.get("Ville")) if x and x.strip())
|
|
|
|
|
|
def _tarifs(config):
|
|
"""(conso L/100km, prix €/L, péage €/km) depuis la config, avec replis."""
|
|
return (
|
|
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é
|
|
)
|
|
|
|
|
|
def _ecrire_colonnes_trajet(ligne, depart, arrivee, res):
|
|
"""Mémorise dans une ligne les données géo + adresses utilisées pour le calcul."""
|
|
ligne["Trajet distance km"] = str(res["distance_km"])
|
|
ligne["Trajet durée min"] = str(res["duree_min"])
|
|
ligne["Trajet km péage"] = str(res["km_peage"])
|
|
ligne["Trajet adresse départ"] = depart
|
|
ligne["Trajet adresse arrivée"] = arrivee
|
|
|
|
|
|
def _auto_trajet(ligne, config):
|
|
"""Calcule et mémorise le trajet d'un prospect fraîchement ajouté (best-effort).
|
|
|
|
Conçu pour tourner dans un thread daemon : un échec réseau ne doit jamais
|
|
affecter le scrape. `verrou_trajet` sérialise les appels Nominatim/OSRM.
|
|
"""
|
|
try:
|
|
depart = (config.get("adresse_depart") or "").strip()
|
|
arrivee = _adresse_arrivee(ligne)
|
|
lien = (ligne.get("Lien Facebook") or "").strip()
|
|
if not depart or not arrivee:
|
|
return
|
|
with verrou_trajet:
|
|
res = trajet.calculer(depart, arrivee, *_tarifs(config))
|
|
with verrou_csv:
|
|
lignes = lire_prospects()
|
|
cible = next(
|
|
(l for l in lignes if (l.get("Lien Facebook") or "").strip() == lien), None
|
|
) if lien else None
|
|
if cible is not None:
|
|
_ecrire_colonnes_trajet(cible, depart, arrivee, res)
|
|
ecrire_prospects(lignes)
|
|
except Exception:
|
|
pass # best-effort : le trajet sera calculé au clic si besoin
|
|
|
|
|
|
@app.get("/")
|
|
def accueil():
|
|
return app.send_static_file("index.html")
|
|
|
|
|
|
@app.post("/api/login")
|
|
def api_login():
|
|
if not verrou_scrape.acquire(blocking=False):
|
|
return jsonify({"error": "occupe", "message": "Une autre opération est en cours, patientez."}), 429
|
|
try:
|
|
if scraper.connexion():
|
|
return jsonify({"ok": True, "message": "Connexion Facebook enregistrée."})
|
|
return jsonify({
|
|
"error": "login_timeout",
|
|
"message": "Connexion non détectée dans le temps imparti. Réessayez.",
|
|
}), 408
|
|
except Exception as e:
|
|
return jsonify({"error": "erreur", "message": f"Échec de la connexion : {e}"}), 500
|
|
finally:
|
|
verrou_scrape.release()
|
|
|
|
|
|
@app.post("/api/scrape")
|
|
def api_scrape():
|
|
url = (request.get_json(silent=True) or {}).get("url", "").strip()
|
|
if not url:
|
|
return jsonify({"error": "url_manquante", "message": "Collez 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
|
|
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()
|
|
|
|
|
|
@app.get("/api/prospects")
|
|
def api_lister():
|
|
lignes = lire_prospects()
|
|
return jsonify([{"index": i, **ligne} for i, ligne in enumerate(lignes)])
|
|
|
|
|
|
@app.post("/api/prospects")
|
|
def api_ajouter():
|
|
donnees = request.get_json(silent=True) or {}
|
|
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)
|
|
ecrire_prospects(lignes)
|
|
threading.Thread(target=_auto_trajet, args=(ligne, lire_config()), daemon=True).start()
|
|
return jsonify({"ok": True, "index": len(lignes) - 1})
|
|
|
|
|
|
@app.put("/api/prospects/<int:idx>")
|
|
def api_modifier(idx):
|
|
donnees = request.get_json(silent=True) or {}
|
|
with verrou_csv:
|
|
lignes = lire_prospects()
|
|
if not 0 <= idx < len(lignes):
|
|
return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404
|
|
for col in COLONNES:
|
|
if col in donnees:
|
|
lignes[idx][col] = str(donnees[col]).strip()
|
|
ecrire_prospects(lignes)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.delete("/api/prospects/<int:idx>")
|
|
def api_supprimer(idx):
|
|
with verrou_csv:
|
|
lignes = lire_prospects()
|
|
if not 0 <= idx < len(lignes):
|
|
return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404
|
|
lignes.pop(idx)
|
|
ecrire_prospects(lignes)
|
|
return jsonify({"ok": True})
|
|
|
|
|
|
@app.get("/api/export")
|
|
def api_export():
|
|
lire_prospects() # crée le fichier si absent
|
|
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).
|
|
|
|
Retourne le compte-rendu (dict) à renvoyer au navigateur pour la ligne traitée.
|
|
"""
|
|
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"])
|
|
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):
|
|
journaliser(url, "doublon", champs["Nom du prospect"])
|
|
return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]}
|
|
champs["Statut"] = STATUT_DEFAUT
|
|
ligne = {col: champs.get(col, "") for col in COLONNES}
|
|
lignes.append(ligne)
|
|
ecrire_prospects(lignes)
|
|
journaliser(url, "ajoute", champs["Nom du prospect"])
|
|
threading.Thread(target=_auto_trajet, args=(ligne, lire_config()), daemon=True).start()
|
|
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", "ia_modele"):
|
|
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
|
|
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})
|
|
|
|
|
|
@app.get("/api/ia/status")
|
|
def api_ia_status():
|
|
return jsonify(ia.statut_login())
|
|
|
|
|
|
@app.post("/api/ia/login")
|
|
def api_ia_login():
|
|
try:
|
|
return jsonify(ia.lancer_login())
|
|
except Exception as e:
|
|
return jsonify({"error": "login", "message": f"Échec du démarrage de la connexion : {e}"}), 502
|
|
|
|
|
|
@app.post("/api/message")
|
|
def api_message():
|
|
donnees = request.get_json(silent=True) or {}
|
|
prospect = donnees.get("prospect") or {}
|
|
mode = donnees.get("mode") or "generer"
|
|
if not prospect.get("Nom du prospect"):
|
|
return jsonify({"error": "prospect_vide", "message": "Prospect sans nom."}), 400
|
|
config = lire_config()
|
|
try:
|
|
message = ia.generer_message(
|
|
prospect, config.get("modele_message", ""), mode,
|
|
nom_modele=config.get("ia_modele") or ia.MODELE_DEFAUT,
|
|
)
|
|
return jsonify({"message": message})
|
|
except ia.IANonConnecte as e:
|
|
return jsonify({"error": "non_connecte", "message": str(e)}), 401
|
|
except ia.IAErreur as e:
|
|
return jsonify({"error": "ia", "message": str(e)}), 502
|
|
except Exception as e:
|
|
return jsonify({"error": "erreur", "message": f"Échec de la génération : {e}"}), 500
|
|
|
|
|
|
@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 = _adresse_arrivee(p)
|
|
if not arrivee:
|
|
return jsonify({
|
|
"error": "pas_d_adresse",
|
|
"message": "Ce prospect n'a pas d'adresse ou de ville exploitable.",
|
|
}), 400
|
|
conso, prix, peage = _tarifs(config)
|
|
|
|
# Recalcul local : les données géo en cache restent valides tant que les deux
|
|
# adresses n'ont pas changé. On réapplique simplement les prix courants.
|
|
if (p.get("Trajet distance km")
|
|
and p.get("Trajet adresse départ") == depart
|
|
and p.get("Trajet adresse arrivée") == arrivee):
|
|
try:
|
|
return jsonify({**trajet.couts(
|
|
float(p["Trajet distance km"]),
|
|
float(p.get("Trajet durée min") or 0),
|
|
float(p.get("Trajet km péage") or 0),
|
|
conso, prix, peage,
|
|
), "source": "local"})
|
|
except (TypeError, ValueError):
|
|
pass # données en cache illisibles : on bascule sur un recalcul complet
|
|
|
|
# Recalcul complet : appels Nominatim/OSRM, puis mémorisation des données géo.
|
|
try:
|
|
resultat = trajet.calculer(depart, arrivee, conso, prix, peage)
|
|
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
|
|
with verrou_csv:
|
|
lignes = lire_prospects()
|
|
if 0 <= idx < len(lignes):
|
|
_ecrire_colonnes_trajet(lignes[idx], depart, arrivee, resultat)
|
|
ecrire_prospects(lignes)
|
|
return jsonify({**resultat, "source": "api"})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
lire_prospects()
|
|
threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start()
|
|
app.run(host="127.0.0.1", port=5000)
|