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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,4 +1,5 @@
|
|||||||
.venv/
|
.venv/
|
||||||
fb_profile/
|
fb_profile/
|
||||||
prospects.csv
|
prospects.csv
|
||||||
|
config.json
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
11
README.md
11
README.md
@@ -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
141
app.py
@@ -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()
|
||||||
|
|||||||
68
scraper.py
68
scraper.py
@@ -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()
|
||||||
|
|
||||||
|
|||||||
@@ -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 https://www.facebook.com/lieu-2 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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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
108
trajet.py
Normal 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))
|
||||||
Reference in New Issue
Block a user