Robustesse (backup, export Excel, journal) + notes libres et relances
- Sauvegarde automatique du CSV avant chaque écriture (backups/, 30 versions) - Export Excel .xlsx sans dépendance (module excel.py) - Journal de scraping (scrape.log) + panneau et endpoints /api/logs - Note libre par prospect (colonne Notes, zone de texte) - Notification de relance in-app (statut Contacté/En discussion + délai configurable)
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -2,4 +2,6 @@
|
|||||||
fb_profile/
|
fb_profile/
|
||||||
prospects.csv
|
prospects.csv
|
||||||
config.json
|
config.json
|
||||||
|
backups/
|
||||||
|
scrape.log
|
||||||
__pycache__/
|
__pycache__/
|
||||||
|
|||||||
12
README.md
12
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.
|
- **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.
|
- **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.
|
- **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).
|
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
|
## À 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.
|
||||||
|
|||||||
92
app.py
92
app.py
@@ -5,11 +5,12 @@ import json
|
|||||||
import os
|
import os
|
||||||
import threading
|
import threading
|
||||||
import webbrowser
|
import webbrowser
|
||||||
from datetime import date
|
from datetime import date, datetime
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from flask import Flask, Response, jsonify, request, send_file, stream_with_context
|
from flask import Flask, Response, jsonify, request, send_file, stream_with_context
|
||||||
|
|
||||||
|
import excel
|
||||||
import extractor
|
import extractor
|
||||||
import scraper
|
import scraper
|
||||||
import trajet
|
import trajet
|
||||||
@@ -17,10 +18,13 @@ 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"
|
CONFIG_PATH = DOSSIER / "config.json"
|
||||||
|
BACKUP_DIR = DOSSIER / "backups"
|
||||||
|
JOURNAL_PATH = DOSSIER / "scrape.log"
|
||||||
|
MAX_BACKUPS = 30
|
||||||
COLONNES = [
|
COLONNES = [
|
||||||
"Nom du prospect", "Statut", "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", "Notes",
|
||||||
]
|
]
|
||||||
STATUT_DEFAUT = "À contacter"
|
STATUT_DEFAUT = "À contacter"
|
||||||
|
|
||||||
@@ -29,6 +33,7 @@ CONFIG_DEFAUT = {
|
|||||||
"conso_l_100km": 6.5,
|
"conso_l_100km": 6.5,
|
||||||
"prix_carburant": 1.90,
|
"prix_carburant": 1.90,
|
||||||
"cout_peage_km": 0.10,
|
"cout_peage_km": 0.10,
|
||||||
|
"delai_relance_jours": 7,
|
||||||
"modele_message": (
|
"modele_message": (
|
||||||
"Bonjour,\n\n"
|
"Bonjour,\n\n"
|
||||||
"Je me permets de vous contacter au sujet de « {nom} »{ville}. "
|
"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]
|
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):
|
def ecrire_prospects(lignes):
|
||||||
|
_sauvegarder_csv()
|
||||||
tmp = CSV_PATH.with_suffix(".csv.tmp")
|
tmp = CSV_PATH.with_suffix(".csv.tmp")
|
||||||
with open(tmp, "w", encoding="utf-8-sig", newline="") as f:
|
with open(tmp, "w", encoding="utf-8-sig", newline="") as f:
|
||||||
ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore")
|
ecrivain = csv.DictWriter(f, fieldnames=COLONNES, delimiter=";", extrasaction="ignore")
|
||||||
@@ -62,6 +90,17 @@ def ecrire_prospects(lignes):
|
|||||||
os.replace(tmp, CSV_PATH)
|
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():
|
def lire_config():
|
||||||
config = dict(CONFIG_DEFAUT)
|
config = dict(CONFIG_DEFAUT)
|
||||||
if CONFIG_PATH.exists():
|
if CONFIG_PATH.exists():
|
||||||
@@ -112,11 +151,16 @@ def api_scrape():
|
|||||||
try:
|
try:
|
||||||
resultat = scraper.scrape(url)
|
resultat = scraper.scrape(url)
|
||||||
champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["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)
|
return jsonify(champs)
|
||||||
except scraper.ErreurScrape as e:
|
except scraper.ErreurScrape as e:
|
||||||
|
journaliser(url, e.code, str(e))
|
||||||
statuts = {"login_required": 409, "page_introuvable": 404, "url_invalide": 400, "redirection": 422}
|
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)
|
return jsonify({"error": e.code, "message": str(e)}), statuts.get(e.code, 500)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
journaliser(url, "erreur", str(e))
|
||||||
return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500
|
return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500
|
||||||
finally:
|
finally:
|
||||||
verrou_scrape.release()
|
verrou_scrape.release()
|
||||||
@@ -174,6 +218,42 @@ 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")
|
||||||
|
|
||||||
|
|
||||||
|
@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):
|
def _ajouter_resultat_lot(resultat):
|
||||||
"""Transforme un résultat de scrape_lot en prospect ajouté au CSV (avec dédoublonnage).
|
"""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"]
|
url = resultat["url"]
|
||||||
if not resultat["ok"]:
|
if not resultat["ok"]:
|
||||||
|
journaliser(url, resultat.get("code", "erreur"), resultat.get("message", "Échec."))
|
||||||
return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")}
|
return {"url": url, "statut": "erreur", "message": resultat.get("message", "Échec.")}
|
||||||
champs = extractor.extraire(
|
champs = extractor.extraire(
|
||||||
resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"])
|
resultat["resultat"]["titre"], resultat["resultat"]["texte"], resultat["resultat"]["url"])
|
||||||
@@ -188,10 +269,12 @@ def _ajouter_resultat_lot(resultat):
|
|||||||
lignes = lire_prospects()
|
lignes = lire_prospects()
|
||||||
lien = (champs.get("Lien Facebook") or "").strip()
|
lien = (champs.get("Lien Facebook") or "").strip()
|
||||||
if lien and any((l.get("Lien Facebook") or "").strip() == lien for l in lignes):
|
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"]}
|
return {"url": url, "statut": "doublon", "nom": champs["Nom du prospect"]}
|
||||||
champs["Statut"] = STATUT_DEFAUT
|
champs["Statut"] = STATUT_DEFAUT
|
||||||
lignes.append({col: champs.get(col, "") for col in COLONNES})
|
lignes.append({col: champs.get(col, "") for col in COLONNES})
|
||||||
ecrire_prospects(lignes)
|
ecrire_prospects(lignes)
|
||||||
|
journaliser(url, "ajoute", champs["Nom du prospect"])
|
||||||
return {"url": url, "statut": "ajoute", "nom": 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(",", "."))
|
config[cle] = float(str(donnees[cle]).replace(",", "."))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
pass # valeur non numérique : on garde l'ancienne
|
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)
|
ecrire_config(config)
|
||||||
return jsonify({"ok": True, "config": config})
|
return jsonify({"ok": True, "config": config})
|
||||||
|
|
||||||
|
|||||||
78
excel.py
Normal file
78
excel.py
Normal file
@@ -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'<c r="{_ref(col, ligne)}" t="inlineStr">'
|
||||||
|
f'<is><t xml:space="preserve">{texte}</t></is></c>')
|
||||||
|
|
||||||
|
|
||||||
|
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 = ['<row r="1">' + "".join(_cellule(c, 1, colonnes[c]) for c in range(len(colonnes))) + "</row>"]
|
||||||
|
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'<row r="{i}">{cellules}</row>')
|
||||||
|
|
||||||
|
feuille = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">'
|
||||||
|
f'<sheetData>{"".join(rangs)}</sheetData></worksheet>'
|
||||||
|
)
|
||||||
|
content_types = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">'
|
||||||
|
'<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>'
|
||||||
|
'<Default Extension="xml" ContentType="application/xml"/>'
|
||||||
|
'<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>'
|
||||||
|
'<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>'
|
||||||
|
'</Types>'
|
||||||
|
)
|
||||||
|
rels = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>'
|
||||||
|
'</Relationships>'
|
||||||
|
)
|
||||||
|
workbook = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" '
|
||||||
|
'xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">'
|
||||||
|
f'<sheets><sheet name="{escape(nom_feuille[:31])}" sheetId="1" r:id="rId1"/></sheets></workbook>'
|
||||||
|
)
|
||||||
|
workbook_rels = (
|
||||||
|
'<?xml version="1.0" encoding="UTF-8" standalone="yes"?>'
|
||||||
|
'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">'
|
||||||
|
'<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>'
|
||||||
|
'</Relationships>'
|
||||||
|
)
|
||||||
|
|
||||||
|
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()
|
||||||
@@ -28,7 +28,12 @@
|
|||||||
#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, .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; }
|
.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; }
|
||||||
@@ -80,6 +85,10 @@
|
|||||||
<label>Tarif péage (€ / km d'autoroute)</label>
|
<label>Tarif péage (€ / km d'autoroute)</label>
|
||||||
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
|
<input id="cfg-peage" type="number" step="0.01" min="0" placeholder="0.10">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="champ">
|
||||||
|
<label>Délai de relance (jours sans réponse)</label>
|
||||||
|
<input id="cfg-relance" type="number" step="1" min="0" placeholder="7">
|
||||||
|
</div>
|
||||||
</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>
|
<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">
|
<div class="champ" style="margin-top:12px">
|
||||||
@@ -123,9 +132,13 @@
|
|||||||
</section>
|
</section>
|
||||||
|
|
||||||
<section>
|
<section>
|
||||||
|
<div id="relances" class="banniere-relance" style="display:none"></div>
|
||||||
<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>
|
<div style="display:flex;gap:8px">
|
||||||
|
<a href="/api/export"><button type="button" class="secondaire">CSV</button></a>
|
||||||
|
<a href="/api/export.xlsx"><button type="button" class="secondaire">Excel (.xlsx)</button></a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="filtres">
|
<div class="filtres">
|
||||||
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
|
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…">
|
||||||
@@ -134,10 +147,25 @@
|
|||||||
<div id="liste"></div>
|
<div id="liste"></div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<details id="journal-details">
|
||||||
|
<summary style="cursor:pointer;font-weight:600;font-size:16px">🩺 Journal de scraping</summary>
|
||||||
|
<p class="indice">Historique des analyses : utile pour comprendre pourquoi une page n'a rien donné (mur de connexion, lien non reconnu, redirection…).</p>
|
||||||
|
<div class="actions" style="margin-top:0">
|
||||||
|
<button type="button" class="secondaire" id="journal-rafraichir">Rafraîchir</button>
|
||||||
|
<button type="button" class="danger" id="journal-vider">Vider le journal</button>
|
||||||
|
</div>
|
||||||
|
<div id="journal" style="margin-top:12px"></div>
|
||||||
|
</details>
|
||||||
|
</section>
|
||||||
|
|
||||||
</main>
|
</main>
|
||||||
<script>
|
<script>
|
||||||
const COLONNES = ["Nom du prospect","Statut","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","Notes"];
|
||||||
|
|
||||||
|
// Statuts encore « actifs » pour lesquels une relance a du sens.
|
||||||
|
const STATUTS_A_RELANCER = ["Contacté", "En discussion"];
|
||||||
|
|
||||||
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
|
// Statut de prospection -> couleur du badge. L'ordre sert au menu déroulant.
|
||||||
const STATUTS = {
|
const STATUTS = {
|
||||||
@@ -179,7 +207,9 @@ async function chargerConfig() {
|
|||||||
$("cfg-conso").value = config.conso_l_100km ?? "";
|
$("cfg-conso").value = config.conso_l_100km ?? "";
|
||||||
$("cfg-prix").value = config.prix_carburant ?? "";
|
$("cfg-prix").value = config.prix_carburant ?? "";
|
||||||
$("cfg-peage").value = config.cout_peage_km ?? "";
|
$("cfg-peage").value = config.cout_peage_km ?? "";
|
||||||
|
$("cfg-relance").value = config.delai_relance_jours ?? "";
|
||||||
$("cfg-message").value = config.modele_message || "";
|
$("cfg-message").value = config.modele_message || "";
|
||||||
|
afficherRelances();
|
||||||
}
|
}
|
||||||
|
|
||||||
$("cfg-enregistrer").addEventListener("click", async () => {
|
$("cfg-enregistrer").addEventListener("click", async () => {
|
||||||
@@ -188,6 +218,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
|
|||||||
conso_l_100km: $("cfg-conso").value,
|
conso_l_100km: $("cfg-conso").value,
|
||||||
prix_carburant: $("cfg-prix").value,
|
prix_carburant: $("cfg-prix").value,
|
||||||
cout_peage_km: $("cfg-peage").value,
|
cout_peage_km: $("cfg-peage").value,
|
||||||
|
delai_relance_jours: $("cfg-relance").value,
|
||||||
modele_message: $("cfg-message").value,
|
modele_message: $("cfg-message").value,
|
||||||
};
|
};
|
||||||
const rep = await fetch("/api/config", {
|
const rep = await fetch("/api/config", {
|
||||||
@@ -198,6 +229,7 @@ $("cfg-enregistrer").addEventListener("click", async () => {
|
|||||||
if (rep.ok) {
|
if (rep.ok) {
|
||||||
config = (await rep.json()).config;
|
config = (await rep.json()).config;
|
||||||
message("Paramètres enregistrés ✔", "info", "message-config");
|
message("Paramètres enregistrés ✔", "info", "message-config");
|
||||||
|
afficherRelances();
|
||||||
} else {
|
} else {
|
||||||
message("Échec de l'enregistrement.", "erreur", "message-config");
|
message("Échec de l'enregistrement.", "erreur", "message-config");
|
||||||
}
|
}
|
||||||
@@ -228,6 +260,12 @@ function champControle(col, valeur) {
|
|||||||
}
|
}
|
||||||
return sel;
|
return sel;
|
||||||
}
|
}
|
||||||
|
if (col === "Notes") {
|
||||||
|
const ta = document.createElement("textarea");
|
||||||
|
ta.name = col; ta.rows = 3; ta.value = valeur || "";
|
||||||
|
ta.placeholder = "Notes libres : compte-rendu d'appel, contraintes, disponibilités…";
|
||||||
|
return ta;
|
||||||
|
}
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.name = col;
|
input.name = col;
|
||||||
input.value = valeur || "";
|
input.value = valeur || "";
|
||||||
@@ -239,7 +277,7 @@ 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" + (col === "Notes" ? " large" : "");
|
||||||
const label = document.createElement("label");
|
const label = document.createElement("label");
|
||||||
label.textContent = col;
|
label.textContent = col;
|
||||||
div.append(label, champControle(col, valeurs[col]));
|
div.append(label, champControle(col, valeurs[col]));
|
||||||
@@ -293,13 +331,14 @@ $("analyser").addEventListener("click", async () => {
|
|||||||
} finally {
|
} finally {
|
||||||
bouton.disabled = false;
|
bouton.disabled = false;
|
||||||
bouton.textContent = "Analyser";
|
bouton.textContent = "Analyser";
|
||||||
|
rafraichirLogsSiOuvert();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
$("formulaire").addEventListener("submit", async (e) => {
|
$("formulaire").addEventListener("submit", async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const ligne = {};
|
const ligne = {};
|
||||||
for (const ctrl of $("grille-champs").querySelectorAll("input, select")) ligne[ctrl.name] = ctrl.value;
|
for (const ctrl of $("grille-champs").querySelectorAll("input, select, textarea")) 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" },
|
||||||
@@ -358,6 +397,7 @@ $("importer").addEventListener("click", async () => {
|
|||||||
}
|
}
|
||||||
message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot");
|
message(`Terminé : ${compte.ajoute} ajouté(s), ${compte.doublon} doublon(s), ${compte.erreur} erreur(s).`, "info", "message-lot");
|
||||||
chargerListe();
|
chargerListe();
|
||||||
|
rafraichirLogsSiOuvert();
|
||||||
} catch {
|
} catch {
|
||||||
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
|
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur", "message-lot");
|
||||||
} finally {
|
} finally {
|
||||||
@@ -395,6 +435,7 @@ function remplirFiltreStatut() {
|
|||||||
async function chargerListe() {
|
async function chargerListe() {
|
||||||
prospects = await (await fetch("/api/prospects")).json();
|
prospects = await (await fetch("/api/prospects")).json();
|
||||||
afficherListe();
|
afficherListe();
|
||||||
|
afficherRelances();
|
||||||
}
|
}
|
||||||
|
|
||||||
function afficherListe() {
|
function afficherListe() {
|
||||||
@@ -414,15 +455,67 @@ function afficherListe() {
|
|||||||
for (const p of visibles) liste.append(carte(p));
|
for (const p of visibles) liste.append(carte(p));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Relances ---
|
||||||
|
|
||||||
|
function parseDateFr(s) {
|
||||||
|
const m = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec((s || "").trim());
|
||||||
|
return m ? new Date(+m[3], +m[2] - 1, +m[1]) : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Nombre de jours depuis le contact si le prospect est à relancer, sinon null.
|
||||||
|
function joursDeRelance(p) {
|
||||||
|
if (!STATUTS_A_RELANCER.includes(statutDe(p))) return null;
|
||||||
|
const d = parseDateFr(p["Date de contact"]);
|
||||||
|
if (!d) return null;
|
||||||
|
const delai = Number(config.delai_relance_jours);
|
||||||
|
const seuil = Number.isFinite(delai) ? delai : 7;
|
||||||
|
const jours = Math.floor((new Date() - d) / 86400000);
|
||||||
|
return jours >= seuil ? jours : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function afficherRelances() {
|
||||||
|
const banniere = $("relances");
|
||||||
|
if (!banniere) return;
|
||||||
|
const liste = prospects
|
||||||
|
.map(p => ({ p, j: joursDeRelance(p) }))
|
||||||
|
.filter(x => x.j !== null)
|
||||||
|
.sort((a, b) => b.j - a.j);
|
||||||
|
if (!liste.length) { banniere.style.display = "none"; banniere.innerHTML = ""; return; }
|
||||||
|
const seuil = Number(config.delai_relance_jours) || 7;
|
||||||
|
banniere.style.display = "block";
|
||||||
|
banniere.innerHTML =
|
||||||
|
`🔔 <strong>${liste.length} prospect(s) à relancer</strong> ` +
|
||||||
|
`<span class="sous">(contactés il y a plus de ${seuil} jours sans réponse)</span>` +
|
||||||
|
`<div class="relance-chips">${liste.map(({ p, j }) =>
|
||||||
|
`<button class="chip-relance" data-index="${p.index}">${echap(p["Nom du prospect"] || "Sans nom")} · ${j} j</button>`
|
||||||
|
).join("")}</div>`;
|
||||||
|
banniere.querySelectorAll(".chip-relance").forEach(b =>
|
||||||
|
b.addEventListener("click", () => ouvrirFiche(+b.dataset.index)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Réinitialise les filtres, déplie la fiche du prospect et la fait défiler à l'écran.
|
||||||
|
function ouvrirFiche(index) {
|
||||||
|
$("recherche").value = "";
|
||||||
|
$("filtre-statut").value = "";
|
||||||
|
afficherListe();
|
||||||
|
const carteEl = $("liste").querySelector(`.carte[data-index="${index}"]`);
|
||||||
|
if (!carteEl) return;
|
||||||
|
const detail = carteEl.querySelector(".carte-detail");
|
||||||
|
if (detail.style.display === "none") carteEl.querySelector(".carte-tete").click();
|
||||||
|
carteEl.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||||
|
}
|
||||||
|
|
||||||
function carte(p) {
|
function carte(p) {
|
||||||
const div = document.createElement("div");
|
const div = document.createElement("div");
|
||||||
div.className = "carte";
|
div.className = "carte";
|
||||||
|
div.dataset.index = p.index;
|
||||||
|
|
||||||
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 st = statutDe(p);
|
const st = statutDe(p);
|
||||||
const couleur = STATUTS[st] || "#7f8c8d";
|
const couleur = STATUTS[st] || "#7f8c8d";
|
||||||
|
const relance = joursDeRelance(p);
|
||||||
const contacte = (p["Date de contact"] || "").trim()
|
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>` : "";
|
? `<span class="sous">contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}</span>` : "";
|
||||||
tete.innerHTML = `
|
tete.innerHTML = `
|
||||||
@@ -432,9 +525,11 @@ function carte(p) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="carte-infos">
|
<div class="carte-infos">
|
||||||
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
|
<span class="badge-statut" style="background:${couleur}">${echap(st)}</span>
|
||||||
|
${relance !== null ? `<span class="badge-relance">🔔 à relancer (${relance} j)</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>` : ""}
|
||||||
|
${(p["Notes"] || "").trim() ? `<span class="sous" title="${echap(p["Notes"])}">📝 note</span>` : ""}
|
||||||
${contacte}
|
${contacte}
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
@@ -486,7 +581,7 @@ function construireDetail(detail, p) {
|
|||||||
enregistrer.textContent = "Enregistrer";
|
enregistrer.textContent = "Enregistrer";
|
||||||
enregistrer.addEventListener("click", async () => {
|
enregistrer.addEventListener("click", async () => {
|
||||||
const donnees = {};
|
const donnees = {};
|
||||||
for (const ctrl of grille.querySelectorAll("input, select")) donnees[ctrl.name] = ctrl.value;
|
for (const ctrl of grille.querySelectorAll("input, select, textarea")) 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" },
|
||||||
@@ -548,6 +643,41 @@ function construireDetail(detail, p) {
|
|||||||
$("recherche").addEventListener("input", afficherListe);
|
$("recherche").addEventListener("input", afficherListe);
|
||||||
$("filtre-statut").addEventListener("change", afficherListe);
|
$("filtre-statut").addEventListener("change", afficherListe);
|
||||||
|
|
||||||
|
// --- Journal de scraping ---
|
||||||
|
|
||||||
|
const COULEURS_LOG = { ok: "#27ae60", ajoute: "#27ae60", vide: "#f39c12", doublon: "#7f8c8d" };
|
||||||
|
|
||||||
|
async function chargerLogs() {
|
||||||
|
let entrees = [];
|
||||||
|
try { entrees = await (await fetch("/api/logs")).json(); } catch { return; }
|
||||||
|
const div = $("journal");
|
||||||
|
if (!entrees.length) {
|
||||||
|
div.innerHTML = '<p class="vide">Aucune analyse enregistrée pour le moment.</p>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
div.innerHTML = entrees.map(e => {
|
||||||
|
const couleur = COULEURS_LOG[e.statut] || "#c0392b";
|
||||||
|
return `<div class="ligne-progres">
|
||||||
|
<span class="sous">${echap(e.date)}</span>
|
||||||
|
<span class="badge-statut" style="background:${couleur}">${echap(e.statut)}</span>
|
||||||
|
${echap(e.url)} <span class="sous">${echap(e.message)}</span>
|
||||||
|
</div>`;
|
||||||
|
}).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
$("journal-details").addEventListener("toggle", (e) => { if (e.target.open) chargerLogs(); });
|
||||||
|
$("journal-rafraichir").addEventListener("click", chargerLogs);
|
||||||
|
$("journal-vider").addEventListener("click", async () => {
|
||||||
|
if (!confirm("Vider tout le journal de scraping ?")) return;
|
||||||
|
await fetch("/api/logs", { method: "DELETE" });
|
||||||
|
chargerLogs();
|
||||||
|
});
|
||||||
|
|
||||||
|
// Rafraîchit le journal s'il est ouvert (après une analyse ou un import).
|
||||||
|
function rafraichirLogsSiOuvert() {
|
||||||
|
if ($("journal-details").open) chargerLogs();
|
||||||
|
}
|
||||||
|
|
||||||
remplirFiltreStatut();
|
remplirFiltreStatut();
|
||||||
chargerConfig();
|
chargerConfig();
|
||||||
chargerListe();
|
chargerListe();
|
||||||
|
|||||||
Reference in New Issue
Block a user