diff --git a/.gitignore b/.gitignore index daefb8e..75503a1 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,6 @@ fb_profile/ prospects.csv config.json +backups/ +scrape.log __pycache__/ diff --git a/README.md b/README.md index 6bb4248..c918951 100644 --- a/README.md +++ b/README.md @@ -31,11 +31,21 @@ Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s - **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}`). +- **Prise de contact** : en dépliant une fiche, un bouton permet de **copier le message** de prise de contact. Le modèle se règle dans **⚙️ Paramètres** (variables `{nom}`, `{ville}`, `{type}`). +- **Notes libres** : chaque prospect a un champ **Notes** (visible en dépliant la fiche) pour consigner compte-rendu d'appel, contraintes, disponibilités… Une fiche avec des notes affiche un repère 📝. +- **Relances** : les prospects au statut *Contacté* ou *En discussion* dont la **date de contact** dépasse le **délai de relance** (réglable dans les Paramètres, 7 jours par défaut) sont signalés par une notification 🔔 en haut de la liste et un badge sur la fiche. Cliquer sur un nom dans la notification ouvre directement la fiche. Aucun mail n'est envoyé : c'est un simple rappel visuel. - **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). +## Robustesse + +- **Sauvegardes automatiques** : avant chaque écriture, l'ancien `prospects.csv` est copié dans le dossier `backups/` (les 30 versions les plus récentes sont conservées). En cas de fausse manip, vous pouvez restaurer une version en recopiant le fichier voulu à la place de `prospects.csv` (app arrêtée). +- **Export Excel** : le bouton **Excel (.xlsx)** télécharge un vrai classeur (en plus du **CSV**), prêt à ouvrir dans Excel/Numbers/LibreOffice. +- **Journal de scraping** : le panneau **🩺 Journal de scraping** (en bas) liste les analyses passées avec leur résultat (`ok`, `vide`, `doublon`, ou code d'erreur comme `login_required`, `redirection`…), pour comprendre rapidement pourquoi une page n'a rien donné. Il est stocké dans `scrape.log` (non versionné). + +Les dossiers/fichiers `backups/` et `scrape.log` restent locaux (non versionnés). + ## À 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 6ef5cae..34bdea8 100644 --- a/app.py +++ b/app.py @@ -5,11 +5,12 @@ import json import os import threading import webbrowser -from datetime import date +from datetime import date, datetime from pathlib import Path from flask import Flask, Response, jsonify, request, send_file, stream_with_context +import excel import extractor import scraper import trajet @@ -17,10 +18,13 @@ import trajet DOSSIER = Path(__file__).parent CSV_PATH = DOSSIER / "prospects.csv" CONFIG_PATH = DOSSIER / "config.json" +BACKUP_DIR = DOSSIER / "backups" +JOURNAL_PATH = DOSSIER / "scrape.log" +MAX_BACKUPS = 30 COLONNES = [ "Nom du prospect", "Statut", "Département", "Ville", "Code Postal", "Adresse", "Date d'ajout", "Date de contact", "Nom de contact", - "Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", + "Téléphone", "Email", "Infos du lieu", "Type", "Lien Facebook", "Notes", ] STATUT_DEFAUT = "À contacter" @@ -29,6 +33,7 @@ CONFIG_DEFAUT = { "conso_l_100km": 6.5, "prix_carburant": 1.90, "cout_peage_km": 0.10, + "delai_relance_jours": 7, "modele_message": ( "Bonjour,\n\n" "Je me permets de vous contacter au sujet de « {nom} »{ville}. " @@ -53,7 +58,30 @@ def lire_prospects(): return [{col: (ligne.get(col) or "") for col in COLONNES} for ligne in lecteur] +def _sauvegarder_csv(): + """Copie le CSV actuel dans backups/ avant qu'il ne soit écrasé. + + Ne garde que les MAX_BACKUPS plus récents et saute la copie si rien n'a + changé depuis la dernière sauvegarde (évite les doublons inutiles). + """ + if not CSV_PATH.exists(): + return + try: + contenu = CSV_PATH.read_bytes() + BACKUP_DIR.mkdir(exist_ok=True) + sauvegardes = sorted(BACKUP_DIR.glob("prospects-*.csv")) + if sauvegardes and sauvegardes[-1].read_bytes() == contenu: + return # identique à la dernière sauvegarde + horodatage = datetime.now().strftime("%Y%m%d-%H%M%S-%f") + (BACKUP_DIR / f"prospects-{horodatage}.csv").write_bytes(contenu) + for vieux in sauvegardes[:-(MAX_BACKUPS - 1)]: + vieux.unlink(missing_ok=True) + except Exception: + pass # une sauvegarde ratée ne doit jamais bloquer l'écriture + + def ecrire_prospects(lignes): + _sauvegarder_csv() tmp = CSV_PATH.with_suffix(".csv.tmp") with open(tmp, "w", encoding="utf-8-sig", newline="") as f: ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore") @@ -62,6 +90,17 @@ def ecrire_prospects(lignes): os.replace(tmp, CSV_PATH) +def journaliser(url, statut, message=""): + """Ajoute une ligne au journal de scraping (date, statut, url, message).""" + horodatage = datetime.now().strftime("%d/%m/%Y %H:%M:%S") + ligne = "\t".join((horodatage, statut, url or "", message or "")).rstrip() + "\n" + try: + with open(JOURNAL_PATH, "a", encoding="utf-8") as f: + f.write(ligne) + except Exception: + pass + + def lire_config(): config = dict(CONFIG_DEFAUT) if CONFIG_PATH.exists(): @@ -112,11 +151,16 @@ def api_scrape(): try: resultat = scraper.scrape(url) champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["url"]) + trouves = [c for c in ("Nom du prospect", "Téléphone", "Email", "Ville", "Adresse") if champs.get(c)] + journaliser(url, "ok" if champs.get("Nom du prospect") else "vide", + ("trouvé : " + ", ".join(trouves)) if trouves else "aucun champ extrait") return jsonify(champs) except scraper.ErreurScrape as e: + journaliser(url, e.code, str(e)) statuts = {"login_required": 409, "page_introuvable": 404, "url_invalide": 400, "redirection": 422} return jsonify({"error": e.code, "message": str(e)}), statuts.get(e.code, 500) except Exception as e: + journaliser(url, "erreur", str(e)) return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500 finally: verrou_scrape.release() @@ -174,6 +218,42 @@ def api_export(): return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv") +@app.get("/api/export.xlsx") +def api_export_xlsx(): + contenu = excel.construire_xlsx(COLONNES, lire_prospects()) + return Response( + contenu, + mimetype="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", + headers={"Content-Disposition": "attachment; filename=prospects.xlsx"}, + ) + + +@app.get("/api/logs") +def api_logs(): + if not JOURNAL_PATH.exists(): + return jsonify([]) + with open(JOURNAL_PATH, encoding="utf-8") as f: + lignes = f.readlines()[-100:] + entrees = [] + for ligne in reversed(lignes): # le plus récent en premier + parts = ligne.rstrip("\n").split("\t") + if len(parts) >= 3: + entrees.append({ + "date": parts[0], "statut": parts[1], "url": parts[2], + "message": parts[3] if len(parts) > 3 else "", + }) + return jsonify(entrees) + + +@app.delete("/api/logs") +def api_logs_vider(): + try: + JOURNAL_PATH.unlink(missing_ok=True) + except Exception: + pass + return jsonify({"ok": True}) + + def _ajouter_resultat_lot(resultat): """Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage). @@ -181,6 +261,7 @@ def _ajouter_resultat_lot(resultat): """ url = resultat["url"] if not resultat["ok"]: + journaliser(url, resultat.get("code", "erreur"), resultat.get("message", "Échec.")) return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")} champs = extractor.extraire( resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"]) @@ -188,10 +269,12 @@ def _ajouter_resultat_lot(resultat): lignes = lire_prospects() lien = (champs.get("Lien Facebook") or "").strip() if lien and any((l.get("Lien Facebook") or "").strip() == lien for l in lignes): + journaliser(url, "doublon", champs["Nom du prospect"]) return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]} champs["Statut"] = STATUT_DEFAUT lignes.append({col: champs.get(col, "") for col in COLONNES}) ecrire_prospects(lignes) + journaliser(url, "ajoute", champs["Nom du prospect"]) return {"url": url, "statut": "ajoute", "nom": champs["Nom du prospect"]} @@ -235,6 +318,11 @@ def api_config_ecrire(): config[cle] = float(str(donnees[cle]).replace(",", ".")) except (TypeError, ValueError): pass # valeur non numérique : on garde l'ancienne + if "delai_relance_jours" in donnees: + try: + config["delai_relance_jours"] = max(0, int(float(str(donnees["delai_relance_jours"]).replace(",", ".")))) + except (TypeError, ValueError): + pass ecrire_config(config) return jsonify({"ok": True, "config": config}) diff --git a/excel.py b/excel.py new file mode 100644 index 0000000..4a4dec5 --- /dev/null +++ b/excel.py @@ -0,0 +1,78 @@ +"""Génération d'un classeur .xlsx minimal, sans dépendance. + +Un fichier .xlsx est une archive ZIP de fichiers XML. On utilise des chaînes +« inline » (t="inlineStr") pour éviter la table des chaînes partagées : le résultat +s'ouvre dans Excel, Numbers et LibreOffice. Bibliothèque standard uniquement. +""" + +import io +import zipfile +from xml.sax.saxutils import escape + + +def _ref(col, ligne): + """Référence de cellule façon tableur : (0, 1) -> « A1 », (27, 3) -> « AB3 ».""" + lettres, n = "", col + while True: + n, reste = divmod(n, 26) + lettres = chr(65 + reste) + lettres + if n == 0: + break + n -= 1 + return f"{lettres}{ligne}" + + +def _cellule(col, ligne, valeur): + texte = escape("" if valeur is None else str(valeur)) + return (f'' + f'{texte}') + + +def construire_xlsx(colonnes, lignes, nom_feuille="Prospects"): + """Octets d'un classeur .xlsx : une feuille avec en-tête puis une ligne par dict.""" + rangs = ['' + "".join(_cellule(c, 1, colonnes[c]) for c in range(len(colonnes))) + ""] + for i, ligne in enumerate(lignes, start=2): + cellules = "".join(_cellule(c, i, ligne.get(colonnes[c], "")) for c in range(len(colonnes))) + rangs.append(f'{cellules}') + + feuille = ( + '' + '' + f'{"".join(rangs)}' + ) + content_types = ( + '' + '' + '' + '' + '' + '' + '' + ) + rels = ( + '' + '' + '' + '' + ) + workbook = ( + '' + '' + f'' + ) + workbook_rels = ( + '' + '' + '' + '' + ) + + tampon = io.BytesIO() + with zipfile.ZipFile(tampon, "w", zipfile.ZIP_DEFLATED) as z: + z.writestr("[Content_Types].xml", content_types) + z.writestr("_rels/.rels", rels) + z.writestr("xl/workbook.xml", workbook) + z.writestr("xl/_rels/workbook.xml.rels", workbook_rels) + z.writestr("xl/worksheets/sheet1.xml", feuille) + return tampon.getvalue() diff --git a/static/index.html b/static/index.html index 9c10825..a622ca9 100644 --- a/static/index.html +++ b/static/index.html @@ -28,7 +28,12 @@ #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, .champ select { width: 100%; } + .champ input, .champ select, .champ textarea { width: 100%; } + .grille .champ.large { grid-column: 1 / -1; } + .banniere-relance { background: #fff4e2; border: 1px solid #f0c987; color: #8a5a00; border-radius: 8px; padding: 12px 14px; margin-bottom: 16px; } + .relance-chips { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; } + .chip-relance { background: #e67e22; color: #fff; border: none; border-radius: 99px; padding: 4px 11px; font-size: 12px; cursor: pointer; } + .badge-relance { background: #e67e22; color: #fff; border-radius: 99px; padding: 2px 9px; font-size: 11px; white-space: nowrap; } .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; } @@ -80,6 +85,10 @@ +
+ + +

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.

@@ -123,9 +132,13 @@
+

Liste des prospects

- +
+ + +
@@ -134,10 +147,25 @@
+
+
+ 🩺 Journal de scraping +

Historique des analyses : utile pour comprendre pourquoi une page n'a rien donné (mur de connexion, lien non reconnu, redirection…).

+
+ + +
+
+
+
+