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

1
.gitignore vendored
View File

@@ -1,4 +1,5 @@
.venv/ .venv/
fb_profile/ fb_profile/
prospects.csv prospects.csv
config.json
__pycache__/ __pycache__/

View File

@@ -23,10 +23,19 @@ La première fois, l'installation prend quelques minutes (environnement Python +
1. **Une seule fois** : cliquez sur **🔑 Connexion Facebook**. Une fenêtre Chromium s'ouvre sur la page de connexion : connectez-vous tranquillement (vous avez 5 minutes, 2FA compris), la fenêtre se ferme toute seule et la session est mémorisée (dossier `fb_profile/`). 1. **Une seule fois** : cliquez sur **🔑 Connexion Facebook**. Une fenêtre Chromium s'ouvre sur la page de connexion : connectez-vous tranquillement (vous avez 5 minutes, 2FA compris), la fenêtre se ferme toute seule et la session est mémorisée (dossier `fb_profile/`).
2. Collez le lien Facebook d'un lieu et cliquez sur **Analyser** : une fenêtre Chromium s'ouvre quelques secondes et visite la page « À propos » du lieu. 2. Collez le lien Facebook d'un lieu et cliquez sur **Analyser** : une fenêtre Chromium s'ouvre quelques secondes et visite la page « À propos » du lieu.
3. Vérifiez/corrigez les champs pré-remplis, puis **Ajouter au fichier**. 3. Vérifiez/corrigez les champs pré-remplis, puis **Ajouter au fichier**.
4. Dans le tableau, cliquez sur une cellule pour la modifier (utile pour « Date de contact » et « Nom de contact » plus tard). 4. Dans la liste, cliquez sur une fiche pour la déplier et modifier ses champs (utile pour « Date de contact » et « Nom de contact » plus tard).
Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s'ouvre directement dans Excel ou Numbers par double-clic. Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s'ouvre directement dans Excel ou Numbers par double-clic.
## Fonctionnalités de suivi
- **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}`).
- **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).
## À savoir ## À 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. - ⚠️ 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.

141
app.py
View File

@@ -1,24 +1,43 @@
"""Serveur local AutoMood : interface web + API + stockage CSV.""" """Serveur local AutoMood : interface web + API + stockage CSV."""
import csv import csv
import json
import os import os
import threading import threading
import webbrowser import webbrowser
from datetime import date from datetime import date
from pathlib import Path 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 extractor
import scraper import scraper
import trajet
DOSSIER = Path(__file__).parent DOSSIER = Path(__file__).parent
CSV_PATH = DOSSIER / "prospects.csv" CSV_PATH = DOSSIER / "prospects.csv"
CONFIG_PATH = DOSSIER / "config.json"
COLONNES = [ 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", "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",
] ]
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") app = Flask(__name__, static_folder="static")
verrou_scrape = threading.Lock() verrou_scrape = threading.Lock()
@@ -43,6 +62,24 @@ def ecrire_prospects(lignes):
os.replace(tmp, CSV_PATH) 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("/") @app.get("/")
def accueil(): def accueil():
return app.send_static_file("index.html") 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} ligne = {col: str(donnees.get(col, "")).strip() for col in COLONNES}
if not ligne["Date d'ajout"]: if not ligne["Date d'ajout"]:
ligne["Date d'ajout"] = date.today().strftime("%d/%m/%Y") ligne["Date d'ajout"] = date.today().strftime("%d/%m/%Y")
if not ligne["Statut"]:
ligne["Statut"] = STATUT_DEFAUT
with verrou_csv: with verrou_csv:
lignes = lire_prospects() lignes = lire_prospects()
lignes.append(ligne) lignes.append(ligne)
@@ -135,6 +174,104 @@ def api_export():
return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv") 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__": if __name__ == "__main__":
lire_prospects() lire_prospects()
threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start() threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start()

View File

@@ -151,21 +151,13 @@ def _texte_page(page):
return "" return ""
def scrape(url): def _scrape_avec_page(page, url):
"""Visite les pages « À propos » et retourne {"titre", "texte", "url"}.""" """Visite les pages « À propos » d'un lieu via une page déjà connectée.
Retourne {"titre", "texte", "url"}. Suppose la session Facebook déjà active :
c'est le cœur partagé par `scrape` (un lien) et `scrape_lot` (plusieurs liens).
"""
info = normaliser_url(url) info = normaliser_url(url)
with sync_playwright() as pw:
contexte = pw.chromium.launch_persistent_context(
user_data_dir=PROFIL, headless=False, locale="fr-FR",
viewport={"width": 1280, "height": 900},
)
try:
if not _est_connecte(contexte):
raise ErreurScrape(
"login_required",
"Aucune session Facebook : cliquez d'abord sur « Connexion Facebook ».",
)
page = contexte.pages[0] if contexte.pages else contexte.new_page()
# Lien de partage (/share/, /reel/...) : le visiter pour découvrir la vraie page # Lien de partage (/share/, /reel/...) : le visiter pour découvrir la vraie page
if info.get("resoudre"): if info.get("resoudre"):
@@ -243,6 +235,54 @@ def scrape(url):
continue # pas trouvé sous ce rôle ; sinon on garde le texte À propos continue # pas trouvé sous ce rôle ; sinon on garde le texte À propos
return {"titre": titre, "texte": "\n".join(textes), "url": info["canonique"]} return {"titre": titre, "texte": "\n".join(textes), "url": info["canonique"]}
def scrape(url):
"""Visite les pages « À propos » d'un lieu et retourne {"titre", "texte", "url"}."""
with sync_playwright() as pw:
contexte = pw.chromium.launch_persistent_context(
user_data_dir=PROFIL, headless=False, locale="fr-FR",
viewport={"width": 1280, "height": 900},
)
try:
if not _est_connecte(contexte):
raise ErreurScrape(
"login_required",
"Aucune session Facebook : cliquez d'abord sur « Connexion Facebook ».",
)
page = contexte.pages[0] if contexte.pages else contexte.new_page()
return _scrape_avec_page(page, url)
finally:
contexte.close()
def scrape_lot(urls):
"""Analyse une liste de liens en réutilisant une seule fenêtre Chromium.
Générateur : produit, pour chaque lien, un dict
{"url", "ok": True, "resultat": {...}} en cas de succès,
{"url", "ok": False, "code", "message"} en cas d'échec — un lien raté
n'interrompt pas le reste du lot.
"""
with sync_playwright() as pw:
contexte = pw.chromium.launch_persistent_context(
user_data_dir=PROFIL, headless=False, locale="fr-FR",
viewport={"width": 1280, "height": 900},
)
try:
if not _est_connecte(contexte):
for url in urls:
yield {"url": url, "ok": False, "code": "login_required",
"message": "Aucune session Facebook : cliquez d'abord sur « Connexion Facebook »."}
return
page = contexte.pages[0] if contexte.pages else contexte.new_page()
for url in urls:
try:
yield {"url": url, "ok": True, "resultat": _scrape_avec_page(page, url)}
except ErreurScrape as e:
yield {"url": url, "ok": False, "code": e.code, "message": str(e)}
except Exception as e:
yield {"url": url, "ok": False, "code": "erreur", "message": str(e)}
finally: finally:
contexte.close() contexte.close()

View File

@@ -15,34 +15,44 @@
h2 { margin-top: 0; font-size: 16px; } h2 { margin-top: 0; font-size: 16px; }
.ligne-url { display: flex; gap: 8px; } .ligne-url { display: flex; gap: 8px; }
.ligne-url input { flex: 1; } .ligne-url input { flex: 1; }
input, button { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); } input, button, select, textarea { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); }
input:focus { outline: 2px solid var(--accent); border-color: transparent; } textarea { width: 100%; resize: vertical; }
input:focus, select:focus, textarea:focus { outline: 2px solid var(--accent); border-color: transparent; }
button { background: var(--accent); color: #fff; border: none; cursor: pointer; } button { background: var(--accent); color: #fff; border: none; cursor: pointer; }
button:disabled { opacity: .5; cursor: wait; } button:disabled { opacity: .5; cursor: wait; }
button.secondaire { background: #eee; color: #222; } button.secondaire { background: #eee; color: #222; }
button.danger { background: #c0392b; } button.danger { background: #c0392b; }
#message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; } .message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; }
#message.erreur { display: block; background: #fdecea; color: #b03a2e; } .message.erreur { display: block; background: #fdecea; color: #b03a2e; }
#message.info { display: block; background: #eaf2fd; color: #1a5276; } .message.info { display: block; background: #eaf2fd; color: #1a5276; }
#formulaire { display: none; margin-top: 16px; } #formulaire { display: none; margin-top: 16px; }
.grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; } .grille { display: grid; grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); gap: 12px; }
.champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; } .champ label { display: block; font-size: 12px; color: #666; margin-bottom: 3px; }
.champ input { width: 100%; } .champ input, .champ select { width: 100%; }
.actions { margin-top: 14px; display: flex; gap: 8px; } .actions { margin-top: 14px; display: flex; gap: 8px; flex-wrap: wrap; }
.carte { border: 1px solid var(--bordure); border-radius: 8px; margin-bottom: 10px; background: #fff; } .carte { border: 1px solid var(--bordure); border-radius: 8px; margin-bottom: 10px; background: #fff; }
.carte-tete { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 10px 12px; cursor: pointer; } .carte-tete { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 10px 12px; cursor: pointer; }
.carte-tete:hover { background: #faf9fd; } .carte-tete:hover { background: #faf9fd; }
.carte-titre { display: flex; align-items: center; gap: 8px; min-width: 260px; flex: 1; } .carte-titre { display: flex; align-items: center; gap: 8px; min-width: 260px; flex: 1; }
.carte-titre strong { font-size: 14px; } .carte-titre strong { font-size: 14px; }
.badge { background: var(--accent); color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; } .badge { background: var(--accent); color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; }
.badge-statut { color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; }
.carte-infos { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 13px; align-items: center; } .carte-infos { display: flex; flex-wrap: wrap; gap: 4px 16px; font-size: 13px; align-items: center; }
.sous { color: #666; font-size: 12px; } .sous { color: #666; font-size: 12px; }
.carte-boutons { display: flex; gap: 6px; margin-left: auto; } .carte-boutons { display: flex; gap: 6px; margin-left: auto; }
.carte-boutons button { padding: 4px 9px; font-size: 12px; } .carte-boutons button { padding: 4px 9px; font-size: 12px; }
.carte-detail { padding: 12px; border-top: 1px solid var(--bordure); background: #fbfaff; } .carte-detail { padding: 12px; border-top: 1px solid var(--bordure); background: #fbfaff; }
.lien-bouton { display: inline-block; text-decoration: none; padding: 8px 10px; border-radius: 6px; background: #eee; color: #222; font-size: 13px; }
.trajet-resu { margin-top: 8px; font-size: 14px; }
.vide { color: #999; font-style: italic; } .vide { color: #999; font-style: italic; }
.barre-liste { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; } .barre-liste { display: flex; justify-content: space-between; align-items: center; gap: 12px; flex-wrap: wrap; margin-bottom: 12px; }
.filtres { display: flex; gap: 8px; margin-bottom: 12px; }
.filtres input { flex: 1; }
details.params summary { cursor: pointer; font-weight: 600; font-size: 16px; }
.indice { color: #888; font-size: 12px; margin: 2px 0 10px; }
.ligne-progres { padding: 4px 0; font-size: 13px; border-bottom: 1px solid #f0eef8; }
.spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #fff; border-top-color: transparent; border-radius: 50%; animation: tourne .8s linear infinite; vertical-align: -2px; margin-right: 6px; } .spinner { display: inline-block; width: 14px; height: 14px; border: 2px solid #fff; border-top-color: transparent; border-radius: 50%; animation: tourne .8s linear infinite; vertical-align: -2px; margin-right: 6px; }
.spinner.sombre { border-color: var(--accent); border-top-color: transparent; }
@keyframes tourne { to { transform: rotate(360deg); } } @keyframes tourne { to { transform: rotate(360deg); } }
</style> </style>
</head> </head>
@@ -50,6 +60,40 @@
<header><h1>🎸 AutoMood — Prospection de lieux de concert</h1></header> <header><h1>🎸 AutoMood — Prospection de lieux de concert</h1></header>
<main> <main>
<section>
<details class="params">
<summary>⚙️ Paramètres</summary>
<div class="grille" style="margin-top:14px">
<div class="champ">
<label>Adresse de départ (pour le calcul des trajets)</label>
<input id="cfg-adresse" placeholder="12 rue de la Paix, 44000 Nantes">
</div>
<div class="champ">
<label>Consommation (L / 100 km)</label>
<input id="cfg-conso" type="number" step="0.1" min="0" placeholder="6.5">
</div>
<div class="champ">
<label>Prix du carburant (€ / L)</label>
<input id="cfg-prix" type="number" step="0.01" min="0" placeholder="1.90">
</div>
<div class="champ">
<label>Tarif péage (€ / km d'autoroute)</label>
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
</div>
</div>
<p class="indice">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.</p>
<div class="champ" style="margin-top:12px">
<label>Modèle de message de prise de contact</label>
<textarea id="cfg-message" rows="6"></textarea>
<p class="indice">Variables disponibles : <code>{nom}</code> (nom du lieu), <code>{ville}</code> (devient « à Ville », ou rien), <code>{type}</code>.</p>
</div>
<div class="actions">
<button id="cfg-enregistrer">Enregistrer les paramètres</button>
</div>
<div id="message-config" class="message"></div>
</details>
</section>
<section> <section>
<h2>Nouveau prospect</h2> <h2>Nouveau prospect</h2>
<div class="ligne-url"> <div class="ligne-url">
@@ -57,7 +101,7 @@
<button id="analyser">Analyser</button> <button id="analyser">Analyser</button>
<button id="connexion" class="secondaire" title="À faire une seule fois : la session est mémorisée">🔑 Connexion Facebook</button> <button id="connexion" class="secondaire" title="À faire une seule fois : la session est mémorisée">🔑 Connexion Facebook</button>
</div> </div>
<div id="message"></div> <div id="message" class="message"></div>
<form id="formulaire"> <form id="formulaire">
<div class="grille" id="grille-champs"></div> <div class="grille" id="grille-champs"></div>
<div class="actions"> <div class="actions">
@@ -67,45 +111,144 @@
</form> </form>
</section> </section>
<section>
<h2>Import en masse</h2>
<p class="indice">Collez plusieurs liens Facebook (un par ligne). Chaque lieu est analysé puis ajouté automatiquement ; les doublons déjà présents sont ignorés.</p>
<textarea id="urls-lot" rows="5" placeholder="https://www.facebook.com/lieu-1&#10;https://www.facebook.com/lieu-2&#10;https://www.facebook.com/lieu-3"></textarea>
<div class="actions">
<button id="importer">Importer la liste</button>
</div>
<div id="message-lot" class="message"></div>
<div id="progres-lot"></div>
</section>
<section> <section>
<div class="barre-liste"> <div class="barre-liste">
<h2 style="margin:0">Liste des prospects <span id="compteur"></span></h2> <h2 style="margin:0">Liste des prospects <span id="compteur"></span></h2>
<a href="/api/export"><button type="button" class="secondaire">Télécharger le CSV</button></a> <a href="/api/export"><button type="button" class="secondaire">Télécharger le CSV</button></a>
</div> </div>
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…" style="width:100%;margin-bottom:12px"> <div class="filtres">
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
<select id="filtre-statut"><option value="">Tous les statuts</option></select>
</div>
<div id="liste"></div> <div id="liste"></div>
</section> </section>
</main> </main>
<script> <script>
const COLONNES = ["Nom du prospect","Département","Ville","Code Postal","Adresse","Date d'ajout", const 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"]; "Date de contact","Nom de contact","Téléphone","Email","Infos du lieu","Type","Lien Facebook"];
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
const STATUTS = {
"À contacter": "#7f8c8d",
"Contacté": "#2980b9",
"En discussion": "#f39c12",
"Concert programmé": "#27ae60",
"Sans réponse": "#95a5a6",
"Refusé": "#c0392b",
};
const STATUT_DEFAUT = "À contacter";
const $ = id => document.getElementById(id); const $ = id => document.getElementById(id);
function message(texte, classe) { function message(texte, classe, cible = "message") {
const m = $("message"); const m = $(cible);
m.textContent = texte; m.textContent = texte;
m.className = classe || ""; m.className = "message " + (classe || "");
}
// Statut effectif : valeur enregistrée, ou déduit pour les anciennes lignes.
function statutDe(p) {
const s = (p["Statut"] || "").trim();
if (s) return s;
return (p["Date de contact"] || "").trim() ? "Contacté" : STATUT_DEFAUT;
}
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
// --- Paramètres ---
let config = {};
async function chargerConfig() {
try {
config = await (await fetch("/api/config")).json();
} catch { config = {}; }
$("cfg-adresse").value = config.adresse_depart || "";
$("cfg-conso").value = config.conso_l_100km ?? "";
$("cfg-prix").value = config.prix_carburant ?? "";
$("cfg-peage").value = config.cout_peage_km ?? "";
$("cfg-message").value = config.modele_message || "";
}
$("cfg-enregistrer").addEventListener("click", async () => {
const corps = {
adresse_depart: $("cfg-adresse").value.trim(),
conso_l_100km: $("cfg-conso").value,
prix_carburant: $("cfg-prix").value,
cout_peage_km: $("cfg-peage").value,
modele_message: $("cfg-message").value,
};
const rep = await fetch("/api/config", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps),
});
if (rep.ok) {
config = (await rep.json()).config;
message("Paramètres enregistrés ✔", "info", "message-config");
} else {
message("Échec de l'enregistrement.", "erreur", "message-config");
}
});
// Construit le message de contact à partir du modèle et du prospect.
function messagePour(p) {
const ville = (p["Ville"] || "").trim();
return (config.modele_message || "")
.replaceAll("{nom}", p["Nom du prospect"] || "")
.replaceAll("{ville}", ville ? (" à " + ville) : "")
.replaceAll("{type}", (p["Type"] || "").toLowerCase());
} }
// --- Formulaire de nouveau prospect --- // --- Formulaire de nouveau prospect ---
function construireFormulaire(valeurs) { // Champ texte, ou menu déroulant pour le statut.
const grille = $("grille-champs"); function champControle(col, valeur) {
if (col === "Statut") {
const sel = document.createElement("select");
sel.name = col;
const courant = (valeur || "").trim() || STATUT_DEFAUT;
for (const s of Object.keys(STATUTS)) {
const opt = document.createElement("option");
opt.value = s; opt.textContent = s;
if (s === courant) opt.selected = true;
sel.append(opt);
}
return sel;
}
const input = document.createElement("input");
input.name = col;
input.value = valeur || "";
if (col === "Date de contact") input.placeholder = "à remplir plus tard";
return input;
}
function construireGrilleChamps(grille, valeurs) {
grille.innerHTML = ""; grille.innerHTML = "";
for (const col of COLONNES) { for (const col of COLONNES) {
const div = document.createElement("div"); const div = document.createElement("div");
div.className = "champ"; div.className = "champ";
const label = document.createElement("label"); const label = document.createElement("label");
label.textContent = col; label.textContent = col;
const input = document.createElement("input"); div.append(label, champControle(col, valeurs[col]));
input.name = col;
input.value = valeurs[col] || "";
if (col === "Date de contact") input.placeholder = "à remplir plus tard";
div.append(label, input);
grille.append(div); grille.append(div);
} }
}
function construireFormulaire(valeurs) {
construireGrilleChamps($("grille-champs"), valeurs);
$("formulaire").style.display = "block"; $("formulaire").style.display = "block";
} }
@@ -156,7 +299,7 @@ $("analyser").addEventListener("click", async () => {
$("formulaire").addEventListener("submit", async (e) => { $("formulaire").addEventListener("submit", async (e) => {
e.preventDefault(); e.preventDefault();
const ligne = {}; const ligne = {};
for (const input of $("grille-champs").querySelectorAll("input")) ligne[input.name] = input.value; for (const ctrl of $("grille-champs").querySelectorAll("input, select")) ligne[ctrl.name] = ctrl.value;
const rep = await fetch("/api/prospects", { const rep = await fetch("/api/prospects", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -177,11 +320,77 @@ $("annuler").addEventListener("click", () => {
message("", ""); message("", "");
}); });
// --- Import en masse ---
$("importer").addEventListener("click", async () => {
const urls = $("urls-lot").value.split("\n").map(s => s.trim()).filter(Boolean);
if (!urls.length) { message("Collez au moins un lien.", "erreur", "message-lot"); return; }
const bouton = $("importer");
bouton.disabled = true;
bouton.innerHTML = '<span class="spinner"></span>Import en cours…';
message(`Analyse de ${urls.length} lien(s)… une fenêtre Chromium reste ouverte pendant l'opération.`, "info", "message-lot");
$("progres-lot").innerHTML = "";
const compte = { ajoute: 0, doublon: 0, erreur: 0 };
try {
const rep = await fetch("/api/scrape-lot", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ urls }),
});
if (!rep.ok) {
const d = await rep.json().catch(() => ({}));
message(d.message || "Échec de l'import.", "erreur", "message-lot");
return;
}
const lecteur = rep.body.getReader();
const decodeur = new TextDecoder();
let tampon = "";
while (true) {
const { value, done } = await lecteur.read();
if (done) break;
tampon += decodeur.decode(value, { stream: true });
let coupure;
while ((coupure = tampon.indexOf("\n")) >= 0) {
const ligne = tampon.slice(0, coupure).trim();
tampon = tampon.slice(coupure + 1);
if (ligne) afficherProgresLot(JSON.parse(ligne), compte);
}
}
message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot");
chargerListe();
} catch {
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
} finally {
bouton.disabled = false;
bouton.textContent = "Importer la liste";
}
});
function afficherProgresLot(res, compte) {
if (compte[res.statut] !== undefined) compte[res.statut]++;
const icone = { ajoute: "✅", doublon: "⏭️", erreur: "❌" }[res.statut] || "•";
const intitule = res.nom ? echap(res.nom) : echap(res.url || "");
const detail = res.statut === "doublon" ? " — déjà présent"
: res.statut === "erreur" ? `${echap(res.message || "échec")}` : "";
const div = document.createElement("div");
div.className = "ligne-progres";
div.innerHTML = `${icone} <strong>${intitule}</strong><span class="sous">${detail}</span>`;
$("progres-lot").append(div);
}
// --- Liste des prospects en fiches --- // --- Liste des prospects en fiches ---
let prospects = []; let prospects = [];
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c])); function remplirFiltreStatut() {
const sel = $("filtre-statut");
if (sel.options.length > 1) return; // déjà rempli
for (const s of Object.keys(STATUTS)) {
const opt = document.createElement("option");
opt.value = s; opt.textContent = s;
sel.append(opt);
}
}
async function chargerListe() { async function chargerListe() {
prospects = await (await fetch("/api/prospects")).json(); prospects = await (await fetch("/api/prospects")).json();
@@ -190,9 +399,12 @@ async function chargerListe() {
function afficherListe() { function afficherListe() {
const filtre = $("recherche").value.trim().toLowerCase(); const filtre = $("recherche").value.trim().toLowerCase();
const statutVoulu = $("filtre-statut").value;
const visibles = prospects.filter(p => const visibles = prospects.filter(p =>
!filtre || COLONNES.some(c => (p[c] || "").toLowerCase().includes(filtre))); (!filtre || COLONNES.some(c => (p[c] || "").toLowerCase().includes(filtre))) &&
$("compteur").textContent = filtre ? `(${visibles.length}/${prospects.length})` : `(${prospects.length})`; (!statutVoulu || statutDe(p) === statutVoulu));
const actif = filtre || statutVoulu;
$("compteur").textContent = actif ? `(${visibles.length}/${prospects.length})` : `(${prospects.length})`;
const liste = $("liste"); const liste = $("liste");
liste.innerHTML = ""; liste.innerHTML = "";
if (!visibles.length) { if (!visibles.length) {
@@ -209,19 +421,21 @@ function carte(p) {
const tete = document.createElement("div"); const tete = document.createElement("div");
tete.className = "carte-tete"; tete.className = "carte-tete";
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · "); const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
const contact = p["Date de contact"] const st = statutDe(p);
? `✅ Contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}` const couleur = STATUTS[st] || "#7f8c8d";
: "⏳ À contacter"; const contacte = (p["Date de contact"] || "").trim()
? `<span class="sous">contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}</span>` : "";
tete.innerHTML = ` tete.innerHTML = `
<div class="carte-titre"> <div class="carte-titre">
<strong>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</strong> <strong>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</strong>
${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""} ${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""}
</div> </div>
<div class="carte-infos"> <div class="carte-infos">
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
${lieu ? `<span class="sous">📍 ${echap(lieu)}</span>` : ""} ${lieu ? `<span class="sous">📍 ${echap(lieu)}</span>` : ""}
${p["Téléphone"] ? `<span>📞 ${echap(p["Téléphone"])}</span>` : ""} ${p["Téléphone"] ? `<span>📞 ${echap(p["Téléphone"])}</span>` : ""}
${p["Email"] ? `<span>✉️ ${echap(p["Email"])}</span>` : ""} ${p["Email"] ? `<span>✉️ ${echap(p["Email"])}</span>` : ""}
<span class="sous">${contact}</span> ${contacte}
</div>`; </div>`;
const boutons = document.createElement("div"); const boutons = document.createElement("div");
@@ -263,24 +477,16 @@ function carte(p) {
function construireDetail(detail, p) { function construireDetail(detail, p) {
const grille = document.createElement("div"); const grille = document.createElement("div");
grille.className = "grille"; grille.className = "grille";
for (const col of COLONNES) { construireGrilleChamps(grille, p);
const champ = document.createElement("div");
champ.className = "champ";
const label = document.createElement("label");
label.textContent = col;
const input = document.createElement("input");
input.name = col;
input.value = p[col] || "";
champ.append(label, input);
grille.append(champ);
}
const actions = document.createElement("div"); const actions = document.createElement("div");
actions.className = "actions"; actions.className = "actions";
const enregistrer = document.createElement("button"); const enregistrer = document.createElement("button");
enregistrer.textContent = "Enregistrer"; enregistrer.textContent = "Enregistrer";
enregistrer.addEventListener("click", async () => { enregistrer.addEventListener("click", async () => {
const donnees = {}; const donnees = {};
for (const input of grille.querySelectorAll("input")) donnees[input.name] = input.value; for (const ctrl of grille.querySelectorAll("input, select")) donnees[ctrl.name] = ctrl.value;
const rep = await fetch(`/api/prospects/${p.index}`, { const rep = await fetch(`/api/prospects/${p.index}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
@@ -289,11 +495,61 @@ function construireDetail(detail, p) {
if (rep.ok) chargerListe(); if (rep.ok) chargerListe();
}); });
actions.append(enregistrer); actions.append(enregistrer);
detail.append(grille, actions);
// Prise de contact
const copier = document.createElement("button");
copier.className = "secondaire";
copier.textContent = "📋 Copier le message";
copier.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(messagePour(p));
copier.textContent = "✔ Copié";
setTimeout(() => { copier.textContent = "📋 Copier le message"; }, 1500);
} catch {
copier.textContent = "Échec de la copie";
}
});
actions.append(copier);
// Trajet + carburant
const trajetBtn = document.createElement("button");
trajetBtn.className = "secondaire";
trajetBtn.textContent = "🚗 Calculer le trajet";
const resu = document.createElement("div");
resu.className = "trajet-resu";
trajetBtn.addEventListener("click", async () => {
trajetBtn.disabled = true;
resu.innerHTML = '<span class="spinner sombre"></span> Calcul en cours…';
try {
const rep = await fetch(`/api/distance/${p.index}`);
const d = await rep.json();
if (!rep.ok) { resu.innerHTML = `<span style="color:#b03a2e">${echap(d.message || "Échec du calcul.")}</span>`; return; }
const detailAller = d.peage_aller > 0
? ` <span class="sous">(carburant ${d.carburant_aller} € + péage ${d.peage_aller} €)</span>` : "";
const detailAR = d.peage_aller_retour > 0
? ` <span class="sous">(carburant ${d.carburant_aller_retour} € + péage ${d.peage_aller_retour} €)</span>` : "";
resu.innerHTML =
`🚗 <strong>${d.distance_km} km</strong> (~${d.duree_min} min` +
(d.km_peage > 0 ? `, dont ${d.km_peage} km d'autoroute` : "") + `)<br>` +
`Aller : <strong>${d.cout_aller} €</strong>${detailAller}<br>` +
`Aller-retour : <strong>${d.cout_aller_retour} €</strong>${detailAR} ` +
`<span class="sous">(${d.distance_aller_retour_km} km)</span>`;
} catch {
resu.innerHTML = '<span style="color:#b03a2e">Le serveur ne répond pas.</span>';
} finally {
trajetBtn.disabled = false;
}
});
actions.append(trajetBtn);
detail.append(grille, actions, resu);
} }
$("recherche").addEventListener("input", afficherListe); $("recherche").addEventListener("input", afficherListe);
$("filtre-statut").addEventListener("change", afficherListe);
remplirFiltreStatut();
chargerConfig();
chargerListe(); chargerListe();
</script> </script>
</body> </body>

108
trajet.py Normal file
View File

@@ -0,0 +1,108 @@
"""Distance routière et coût carburant entre deux adresses (OpenStreetMap : Nominatim + OSRM).
Aucune dépendance ni clé d'API : on utilise les services publics gratuits d'OpenStreetMap
via la bibliothèque standard. Usage personnel et ponctuel (un prospect à la fois).
"""
import json
import re
import urllib.parse
import urllib.request
NOMINATIM = "https://nominatim.openstreetmap.org/search"
OSRM = "https://router.project-osrm.org/route/v1/driving"
# Nominatim impose un User-Agent identifiant l'application
ENTETES = {"User-Agent": "AutoMood/1.0 (outil local de prospection)"}
# Les coordonnées d'une adresse ne changent pas : on évite de re-géocoder (et on
# reste poli envers Nominatim, dont l'usage est limité à ~1 requête/seconde).
_cache_geo = {}
class ErreurTrajet(Exception):
pass
def _http_json(url):
requete = urllib.request.Request(url, headers=ENTETES)
try:
with urllib.request.urlopen(requete, timeout=20) as reponse:
return json.load(reponse)
except Exception as e:
raise ErreurTrajet(f"Service de cartographie injoignable : {e}")
def geocoder(adresse):
"""Adresse texte -> (longitude, latitude). Lève ErreurTrajet si introuvable."""
adresse = (adresse or "").strip()
if not adresse:
raise ErreurTrajet("Adresse vide.")
if adresse in _cache_geo:
return _cache_geo[adresse]
params = urllib.parse.urlencode({
"q": adresse, "format": "json", "limit": 1, "countrycodes": "fr",
})
donnees = _http_json(f"{NOMINATIM}?{params}")
if not donnees:
raise ErreurTrajet(f"Adresse introuvable : « {adresse} ».")
coord = (float(donnees[0]["lon"]), float(donnees[0]["lat"]))
_cache_geo[adresse] = coord
return coord
def _est_autoroute(ref):
"""Vrai si le numéro de route est une autoroute française (« A 11 », « A8;E60 »...).
Heuristique : la plupart des autoroutes « A » sont à péage. Certaines sont
gratuites (rocades, sections urbaines) : l'estimation reste donc approximative.
"""
return any(re.match(r"A\s?\d", t.strip(), re.I) for t in re.split(r"[;,]", ref or ""))
def itineraire(depart, arrivee):
"""(lon, lat) x2 -> (distance_km, duree_min, km_autoroute) du trajet le plus court."""
(lon1, lat1), (lon2, lat2) = depart, arrivee
donnees = _http_json(f"{OSRM}/{lon1},{lat1};{lon2},{lat2}?overview=false&steps=true")
if donnees.get("code") != "Ok" or not donnees.get("routes"):
raise ErreurTrajet("Aucun itinéraire routier trouvé entre ces deux adresses.")
route = donnees["routes"][0]
metres_autoroute = sum(
etape.get("distance", 0)
for jambe in route.get("legs", [])
for etape in jambe.get("steps", [])
if _est_autoroute(etape.get("ref"))
)
return route["distance"] / 1000.0, route["duration"] / 60.0, metres_autoroute / 1000.0
def calculer(adresse_depart, adresse_arrivee, conso_l_100km, prix_carburant, cout_peage_km=0.0):
"""Estime distance, durée, coût carburant et péage (aller simple et aller-retour).
Le péage est estimé : km d'autoroute du trajet × `cout_peage_km` (tarif moyen
paramétrable, en €/km). Mettre 0 pour ne pas compter de péage.
"""
distance_km, duree_min, km_peage = itineraire(
geocoder(adresse_depart), geocoder(adresse_arrivee))
carburant = distance_km / 100.0 * conso_l_100km * prix_carburant
peage = km_peage * cout_peage_km
cout_aller = carburant + peage
return {
"distance_km": round(distance_km, 1),
"distance_aller_retour_km": round(distance_km * 2, 1),
"duree_min": round(duree_min),
"km_peage": round(km_peage, 1),
"carburant_aller": round(carburant, 2),
"carburant_aller_retour": round(carburant * 2, 2),
"peage_aller": round(peage, 2),
"peage_aller_retour": round(peage * 2, 2),
"cout_aller": round(cout_aller, 2),
"cout_aller_retour": round(cout_aller * 2, 2),
}
if __name__ == "__main__":
import sys
if len(sys.argv) != 3:
print("Usage : python trajet.py <adresse-depart> <adresse-arrivee>")
sys.exit(1)
print(json.dumps(calculer(sys.argv[1], sys.argv[2], 6.5, 1.90), ensure_ascii=False, indent=2))