From 1cf427a0f2366672662c55212d2c4baaa990f2db Mon Sep 17 00:00:00 2001 From: jerem Date: Sat, 13 Jun 2026 15:28:25 +0200 Subject: [PATCH] =?UTF-8?q?Ajout=20suivi=20prospection=20:=20statut,=20imp?= =?UTF-8?q?ort=20en=20masse,=20message=20type,=20trajet+p=C3=A9age?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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é) --- .gitignore | 1 + README.md | 11 +- app.py | 141 ++++++++++++++++++- scraper.py | 190 ++++++++++++++++---------- static/index.html | 340 ++++++++++++++++++++++++++++++++++++++++------ trajet.py | 108 +++++++++++++++ 6 files changed, 671 insertions(+), 120 deletions(-) create mode 100644 trajet.py diff --git a/.gitignore b/.gitignore index 39cda27..daefb8e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ .venv/ fb_profile/ prospects.csv +config.json __pycache__/ diff --git a/README.md b/README.md index 08d30d2..6bb4248 100644 --- a/README.md +++ b/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/`). 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**. -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. +## 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 - ⚠️ 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. diff --git a/app.py b/app.py index 8b39a19..6ef5cae 100644 --- a/app.py +++ b/app.py @@ -1,24 +1,43 @@ """Serveur local AutoMood : interface web + API + stockage CSV.""" import csv +import json import os import threading import webbrowser from datetime import date from pathlib import Path -from flask import Flask, jsonify, request, send_file +from flask import Flask, Response, jsonify, request, send_file, stream_with_context import extractor import scraper +import trajet DOSSIER = Path(__file__).parent CSV_PATH = DOSSIER / "prospects.csv" +CONFIG_PATH = DOSSIER / "config.json" COLONNES = [ - "Nom du prospect", "Département", "Ville", "Code Postal", "Adresse", + "Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse", "Date d'ajout", "Date de contact", "Nom de contact", "Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", ] +STATUT_DEFAUT = "À contacter" + +CONFIG_DEFAUT = { + "adresse_depart": "", + "conso_l_100km": 6.5, + "prix_carburant": 1.90, + "cout_peage_km": 0.10, + "modele_message": ( + "Bonjour,\n\n" + "Je me permets de vous contacter au sujet de « {nom} »{ville}. " + "Nous organisons des concerts et serions ravis d'étudier la possibilité " + "d'un événement musical dans votre établissement.\n\n" + "Seriez-vous disponible pour en échanger ?\n\n" + "Bien cordialement," + ), +} app = Flask(__name__, static_folder="static") verrou_scrape = threading.Lock() @@ -43,6 +62,24 @@ def ecrire_prospects(lignes): os.replace(tmp, CSV_PATH) +def lire_config(): + config = dict(CONFIG_DEFAUT) + if CONFIG_PATH.exists(): + try: + with open(CONFIG_PATH, encoding="utf-8") as f: + config.update({k: v for k, v in json.load(f).items() if k in CONFIG_DEFAUT}) + except Exception: + pass # config illisible : on retombe sur les valeurs par défaut + return config + + +def ecrire_config(config): + tmp = CONFIG_PATH.with_suffix(".json.tmp") + with open(tmp, "w", encoding="utf-8", newline="") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + os.replace(tmp, CONFIG_PATH) + + @app.get("/") def accueil(): return app.send_static_file("index.html") @@ -97,6 +134,8 @@ def api_ajouter(): ligne = {col: str(donnees.get(col, "")).strip() for col in COLONNES} if not ligne["Date d'ajout"]: ligne["Date d'ajout"] = date.today().strftime("%d/%m/%Y") + if not ligne["Statut"]: + ligne["Statut"] = STATUT_DEFAUT with verrou_csv: lignes = lire_prospects() lignes.append(ligne) @@ -135,6 +174,104 @@ def api_export(): return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv") +def _ajouter_resultat_lot(resultat): + """Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage). + + Retourne le compte-rendu (dict) à renvoyer au navigateur pour la ligne traitée. + """ + url = resultat["url"] + if not resultat["ok"]: + return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")} + champs = extractor.extraire( + resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"]) + with verrou_csv: + lignes = lire_prospects() + lien = (champs.get("Lien Facebook") or "").strip() + if lien and any((l.get("Lien Facebook") or "").strip() == lien for l in lignes): + return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]} + champs["Statut"] = STATUT_DEFAUT + lignes.append({col: champs.get(col, "") for col in COLONNES}) + ecrire_prospects(lignes) + return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]} + + +@app.post("/api/scrape-lot") +def api_scrape_lot(): + donnees = request.get_json(silent=True) or {} + urls = [u.strip() for u in (donnees.get("urls") or []) if u and u.strip()] + if not urls: + return jsonify({"error": "vide", "message": "Collez au moins un lien Facebook."}), 400 + if not verrou_scrape.acquire(blocking=False): + return jsonify({"error": "occupe", "message": "Une analyse est déjà en cours, patientez."}), 429 + + def generer(): + # Flux NDJSON : une ligne JSON par lien, envoyée dès qu'il est traité. + try: + for resultat in scraper.scrape_lot(urls): + yield json.dumps(_ajouter_resultat_lot(resultat), ensure_ascii=False) + "\n" + except Exception as e: + yield json.dumps({"statut": "erreur", "message": f"Échec global : {e}"}, ensure_ascii=False) + "\n" + finally: + verrou_scrape.release() + + return Response(stream_with_context(generer()), mimetype="application/x-ndjson") + + +@app.get("/api/config") +def api_config_lire(): + return jsonify(lire_config()) + + +@app.put("/api/config") +def api_config_ecrire(): + donnees = request.get_json(silent=True) or {} + config = lire_config() + for cle in ("adresse_depart", "modele_message"): + if cle in donnees: + config[cle] = str(donnees[cle]) + for cle in ("conso_l_100km", "prix_carburant", "cout_peage_km"): + if cle in donnees: + try: + config[cle] = float(str(donnees[cle]).replace(",", ".")) + except (TypeError, ValueError): + pass # valeur non numérique : on garde l'ancienne + ecrire_config(config) + return jsonify({"ok": True, "config": config}) + + +@app.get("/api/distance/") +def api_distance(idx): + lignes = lire_prospects() + if not 0 <= idx < len(lignes): + return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404 + p = lignes[idx] + config = lire_config() + depart = (config.get("adresse_depart") or "").strip() + if not depart: + return jsonify({ + "error": "pas_de_depart", + "message": "Renseignez d'abord votre adresse de départ dans les Paramètres.", + }), 400 + arrivee = ", ".join(x.strip() for x in (p.get("Adresse"), p.get("Code Postal"), p.get("Ville")) if x and x.strip()) + if not arrivee: + return jsonify({ + "error": "pas_d_adresse", + "message": "Ce prospect n'a pas d'adresse ou de ville exploitable.", + }), 400 + try: + resultat = trajet.calculer( + depart, arrivee, + float(config.get("conso_l_100km") or 0) or CONFIG_DEFAUT["conso_l_100km"], + float(config.get("prix_carburant") or 0) or CONFIG_DEFAUT["prix_carburant"], + float(config.get("cout_peage_km") or 0), # 0 = pas de péage compté + ) + return jsonify(resultat) + except trajet.ErreurTrajet as e: + return jsonify({"error": "trajet", "message": str(e)}), 502 + except Exception as e: + return jsonify({"error": "erreur", "message": f"Échec du calcul : {e}"}), 500 + + if __name__ == "__main__": lire_prospects() threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start() diff --git a/scraper.py b/scraper.py index 2e9e18b..eb98680 100644 --- a/scraper.py +++ b/scraper.py @@ -151,9 +151,94 @@ def _texte_page(page): return "" -def scrape(url): - """Visite les pages « À propos » et retourne {"titre", "texte", "url"}.""" +def _scrape_avec_page(page, 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) + + # Lien de partage (/share/, /reel/...) : le visiter pour découvrir la vraie page + if info.get("resoudre"): + page.goto(info["resoudre"], wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(3000) + _refuser_cookies(page) + info = normaliser_url(page.url) + if info.get("resoudre"): + raise ErreurScrape( + "url_invalide", + "Ce lien de partage ne mène pas à une page de lieu : ouvrez la page du lieu " + "dans Facebook et copiez son adresse directe.", + ) + + # Page d'accueil du lieu : la carte « Intro » contient souvent l'adresse, + # qui n'apparaît dans aucune sous-section « À propos » + page.goto(info["canonique"], wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(2500) + _refuser_cookies(page) + if _mur_de_connexion(page): + raise ErreurScrape( + "login_required", + "Session Facebook expirée : cliquez sur « Connexion Facebook » pour vous reconnecter.", + ) + if not _sur_la_bonne_page(page, info["marqueur"]): + # profile.php?id=N est parfois réécrit vers l'adresse « vanity » de la + # page : si on a atterri sur un profil/une page plausible, adopter ce slug. + nouveau = urlparse(page.url).path.strip("/").split("/")[0].lower() + if (info["marqueur"].isdigit() and nouveau + and nouveau not in SLUGS_A_RESOUDRE | {"p", "people", "pages", "profile.php"}): + info["marqueur"] = nouveau + else: + # Redirigé ailleurs (fil d'actualité, votre profil...) : + # surtout ne pas extraire ce texte, ce serait VOS infos. + raise ErreurScrape( + "redirection", + f"Facebook a redirigé vers {page.url} au lieu de la page demandée. " + "Ouvrez la page du lieu dans Facebook et copiez son adresse directe.", + ) + titre = "" + for _ in range(6): # le titre met parfois quelques secondes à refléter la page + titre = re.sub(r"\s*[|\-–]\s*(Facebook|À propos|About).*$", "", page.title()).strip() + titre = re.sub(r"^\(\d+\+?\)\s*", "", titre) # compteur de notifications « (20+) » + if titre and titre.lower() != "facebook": + break + page.wait_for_timeout(500) + corps = _texte_page(page) + if re.search(r"ce contenu n'est pas disponible|this content isn'?t available|page introuvable", corps, re.I): + raise ErreurScrape("page_introuvable", "Cette page Facebook est introuvable ou inaccessible.") + if not titre or titre.lower() == "facebook": + # repli : la première ligne du contenu est le nom du lieu + titre = next((l.strip() for l in corps.split("\n") if l.strip()), "") + textes = [corps] + + # Page « À propos » + page.goto(info["about"], wait_until="domcontentloaded", timeout=30000) + page.wait_for_timeout(2500) + if _sur_la_bonne_page(page, info["marqueur"]): + textes.append(_texte_page(page)) + + # Sous-section « Coordonnées » : on CLIQUE l'élément dans la page plutôt que + # de deviner l'URL — l'URL directe n'existe pas pour les nouvelles pages et + # Facebook redirige alors vers VOTRE propre profil. Selon le style de page, + # c'est un onglet (nouvelles pages) ou un lien (pages classiques). + for role in ("tab", "link"): + try: + page.locator('div[role="main"]').get_by_role( + role, name=re.compile(r"coordonnées|contact and basic info", re.I) + ).first.click(timeout=4000) + page.wait_for_timeout(2500) + if _sur_la_bonne_page(page, info["marqueur"]): + textes.append(_texte_page(page)) + break + except Exception: + continue # pas trouvé sous ce rôle ; sinon on garde le texte À propos + + 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", @@ -166,83 +251,38 @@ def scrape(url): "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() - # Lien de partage (/share/, /reel/...) : le visiter pour découvrir la vraie page - if info.get("resoudre"): - page.goto(info["resoudre"], wait_until="domcontentloaded", timeout=30000) - page.wait_for_timeout(3000) - _refuser_cookies(page) - info = normaliser_url(page.url) - if info.get("resoudre"): - raise ErreurScrape( - "url_invalide", - "Ce lien de partage ne mène pas à une page de lieu : ouvrez la page du lieu " - "dans Facebook et copiez son adresse directe.", - ) - # Page d'accueil du lieu : la carte « Intro » contient souvent l'adresse, - # qui n'apparaît dans aucune sous-section « À propos » - page.goto(info["canonique"], wait_until="domcontentloaded", timeout=30000) - page.wait_for_timeout(2500) - _refuser_cookies(page) - if _mur_de_connexion(page): - raise ErreurScrape( - "login_required", - "Session Facebook expirée : cliquez sur « Connexion Facebook » pour vous reconnecter.", - ) - if not _sur_la_bonne_page(page, info["marqueur"]): - # profile.php?id=N est parfois réécrit vers l'adresse « vanity » de la - # page : si on a atterri sur un profil/une page plausible, adopter ce slug. - nouveau = urlparse(page.url).path.strip("/").split("/")[0].lower() - if (info["marqueur"].isdigit() and nouveau - and nouveau not in SLUGS_A_RESOUDRE | {"p", "people", "pages", "profile.php"}): - info["marqueur"] = nouveau - else: - # Redirigé ailleurs (fil d'actualité, votre profil...) : - # surtout ne pas extraire ce texte, ce serait VOS infos. - raise ErreurScrape( - "redirection", - f"Facebook a redirigé vers {page.url} au lieu de la page demandée. " - "Ouvrez la page du lieu dans Facebook et copiez son adresse directe.", - ) - titre = "" - for _ in range(6): # le titre met parfois quelques secondes à refléter la page - titre = re.sub(r"\s*[|\-–]\s*(Facebook|À propos|About).*$", "", page.title()).strip() - titre = re.sub(r"^\(\d+\+?\)\s*", "", titre) # compteur de notifications « (20+) » - if titre and titre.lower() != "facebook": - break - page.wait_for_timeout(500) - corps = _texte_page(page) - if re.search(r"ce contenu n'est pas disponible|this content isn'?t available|page introuvable", corps, re.I): - raise ErreurScrape("page_introuvable", "Cette page Facebook est introuvable ou inaccessible.") - if not titre or titre.lower() == "facebook": - # repli : la première ligne du contenu est le nom du lieu - titre = next((l.strip() for l in corps.split("\n") if l.strip()), "") - textes = [corps] +def scrape_lot(urls): + """Analyse une liste de liens en réutilisant une seule fenêtre Chromium. - # Page « À propos » - page.goto(info["about"], wait_until="domcontentloaded", timeout=30000) - page.wait_for_timeout(2500) - if _sur_la_bonne_page(page, info["marqueur"]): - textes.append(_texte_page(page)) - - # Sous-section « Coordonnées » : on CLIQUE l'élément dans la page plutôt que - # de deviner l'URL — l'URL directe n'existe pas pour les nouvelles pages et - # Facebook redirige alors vers VOTRE propre profil. Selon le style de page, - # c'est un onglet (nouvelles pages) ou un lien (pages classiques). - for role in ("tab", "link"): + 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: - page.locator('div[role="main"]').get_by_role( - role, name=re.compile(r"coordonnées|contact and basic info", re.I) - ).first.click(timeout=4000) - page.wait_for_timeout(2500) - if _sur_la_bonne_page(page, info["marqueur"]): - textes.append(_texte_page(page)) - break - except Exception: - continue # pas trouvé sous ce rôle ; sinon on garde le texte À propos - - return {"titre": titre, "texte": "\n".join(textes), "url": info["canonique"]} + 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: contexte.close() diff --git a/static/index.html b/static/index.html index 212037e..9c10825 100644 --- a/static/index.html +++ b/static/index.html @@ -15,34 +15,44 @@ h2 { margin-top: 0; font-size: 16px; } .ligne-url { display: flex; gap: 8px; } .ligne-url input { flex: 1; } - input, button { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); } - input:focus { outline: 2px solid var(--accent); border-color: transparent; } + input, button, select, textarea { font: inherit; padding: 8px 10px; border-radius: 6px; border: 1px solid var(--bordure); } + 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:disabled { opacity: .5; cursor: wait; } button.secondaire { background: #eee; color: #222; } button.danger { background: #c0392b; } - #message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; } - #message.erreur { display: block; background: #fdecea; color: #b03a2e; } - #message.info { display: block; background: #eaf2fd; color: #1a5276; } + .message { margin: 10px 0 0; padding: 10px; border-radius: 6px; display: none; } + .message.erreur { display: block; background: #fdecea; color: #b03a2e; } + .message.info { display: block; background: #eaf2fd; color: #1a5276; } #formulaire { display: none; margin-top: 16px; } .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 input { width: 100%; } - .actions { margin-top: 14px; display: flex; gap: 8px; } + .champ input, .champ select { width: 100%; } + .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-tete { display: flex; flex-wrap: wrap; gap: 6px 14px; align-items: center; padding: 10px 12px; cursor: pointer; } .carte-tete:hover { background: #faf9fd; } .carte-titre { display: flex; align-items: center; gap: 8px; min-width: 260px; flex: 1; } .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-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; } .sous { color: #666; font-size: 12px; } .carte-boutons { display: flex; gap: 6px; margin-left: auto; } .carte-boutons button { padding: 4px 9px; font-size: 12px; } .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; } - .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.sombre { border-color: var(--accent); border-top-color: transparent; } @keyframes tourne { to { transform: rotate(360deg); } } @@ -50,6 +60,40 @@

🎸 AutoMood — Prospection de lieux de concert

+
+
+ ⚙️ Paramètres +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+

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.

+
+ + +

Variables disponibles : {nom} (nom du lieu), {ville} (devient « à Ville », ou rien), {type}.

+
+
+ +
+
+
+
+

Nouveau prospect

@@ -57,7 +101,7 @@
-
+
@@ -67,45 +111,144 @@
+
+

Import en masse

+

Collez plusieurs liens Facebook (un par ligne). Chaque lieu est analysé puis ajouté automatiquement ; les doublons déjà présents sont ignorés.

+ +
+ +
+
+
+
+

Liste des prospects

- +
+ + +
diff --git a/trajet.py b/trajet.py new file mode 100644 index 0000000..0c7301a --- /dev/null +++ b/trajet.py @@ -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 ") + sys.exit(1) + print(json.dumps(calculer(sys.argv[1], sys.argv[2], 6.5, 1.90), ensure_ascii=False, indent=2))