Initial commit
This commit is contained in:
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
.venv/
|
||||
fb_profile/
|
||||
prospects.csv
|
||||
__pycache__/
|
||||
26
README.md
Normal file
26
README.md
Normal file
@@ -0,0 +1,26 @@
|
||||
# AutoMood — Prospection de lieux de concert
|
||||
|
||||
Petit outil local pour constituer un fichier CSV de prospects (bars, restaurants, campings…) à partir de liens de pages Facebook.
|
||||
|
||||
## Lancement
|
||||
|
||||
```bash
|
||||
./run.sh
|
||||
```
|
||||
|
||||
La première fois, l'installation prend quelques minutes. Ensuite le serveur démarre et l'interface s'ouvre dans votre navigateur sur http://127.0.0.1:5000. Arrêt : `Ctrl-C` dans le terminal.
|
||||
|
||||
## Utilisation
|
||||
|
||||
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).
|
||||
|
||||
Les données sont dans `prospects.csv` (UTF-8, séparateur point-virgule) : il s'ouvre directement dans Excel ou Numbers par double-clic.
|
||||
|
||||
## À 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.
|
||||
- Les champs introuvables sur la page Facebook restent vides : complétez-les à la main dans le formulaire.
|
||||
- Si Facebook affiche une vérification de sécurité, résolvez-la dans la fenêtre Chromium puis relancez l'analyse.
|
||||
141
app.py
Normal file
141
app.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Serveur local AutoMood : interface web + API + stockage CSV."""
|
||||
|
||||
import csv
|
||||
import os
|
||||
import threading
|
||||
import webbrowser
|
||||
from datetime import date
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, jsonify, request, send_file
|
||||
|
||||
import extractor
|
||||
import scraper
|
||||
|
||||
DOSSIER = Path(__file__).parent
|
||||
CSV_PATH = DOSSIER / "prospects.csv"
|
||||
COLONNES = [
|
||||
"Nom du prospect", "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",
|
||||
]
|
||||
|
||||
app = Flask(__name__, static_folder="static")
|
||||
verrou_scrape = threading.Lock()
|
||||
verrou_csv = threading.Lock()
|
||||
|
||||
|
||||
def lire_prospects():
|
||||
if not CSV_PATH.exists():
|
||||
ecrire_prospects([])
|
||||
return []
|
||||
with open(CSV_PATH, encoding="utf-8-sig", newline="") as f:
|
||||
lecteur = csv.DictReader(f, delimiter=";")
|
||||
return [{col: (ligne.get(col) or "") for col in COLONNES} for ligne in lecteur]
|
||||
|
||||
|
||||
def ecrire_prospects(lignes):
|
||||
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")
|
||||
ecrivain.writeheader()
|
||||
ecrivain.writerows(lignes)
|
||||
os.replace(tmp, CSV_PATH)
|
||||
|
||||
|
||||
@app.get("/")
|
||||
def accueil():
|
||||
return app.send_static_file("index.html")
|
||||
|
||||
|
||||
@app.post("/api/login")
|
||||
def api_login():
|
||||
if not verrou_scrape.acquire(blocking=False):
|
||||
return jsonify({"error": "occupe", "message": "Une autre opération est en cours, patientez."}), 429
|
||||
try:
|
||||
if scraper.connexion():
|
||||
return jsonify({"ok": True, "message": "Connexion Facebook enregistrée."})
|
||||
return jsonify({
|
||||
"error": "login_timeout",
|
||||
"message": "Connexion non détectée dans le temps imparti. Réessayez.",
|
||||
}), 408
|
||||
except Exception as e:
|
||||
return jsonify({"error": "erreur", "message": f"Échec de la connexion : {e}"}), 500
|
||||
finally:
|
||||
verrou_scrape.release()
|
||||
|
||||
|
||||
@app.post("/api/scrape")
|
||||
def api_scrape():
|
||||
url = (request.get_json(silent=True) or {}).get("url", "").strip()
|
||||
if not url:
|
||||
return jsonify({"error": "url_manquante", "message": "Collez 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
|
||||
try:
|
||||
resultat = scraper.scrape(url)
|
||||
champs = extractor.extraire(resultat["titre"], resultat["texte"], resultat["url"])
|
||||
return jsonify(champs)
|
||||
except scraper.ErreurScrape as 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:
|
||||
return jsonify({"error": "erreur", "message": f"Échec de l'analyse : {e}"}), 500
|
||||
finally:
|
||||
verrou_scrape.release()
|
||||
|
||||
|
||||
@app.get("/api/prospects")
|
||||
def api_lister():
|
||||
lignes = lire_prospects()
|
||||
return jsonify([{"index": i, **ligne} for i, ligne in enumerate(lignes)])
|
||||
|
||||
|
||||
@app.post("/api/prospects")
|
||||
def api_ajouter():
|
||||
donnees = request.get_json(silent=True) or {}
|
||||
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")
|
||||
with verrou_csv:
|
||||
lignes = lire_prospects()
|
||||
lignes.append(ligne)
|
||||
ecrire_prospects(lignes)
|
||||
return jsonify({"ok": True, "index": len(lignes) - 1})
|
||||
|
||||
|
||||
@app.put("/api/prospects/<int:idx>")
|
||||
def api_modifier(idx):
|
||||
donnees = request.get_json(silent=True) or {}
|
||||
with verrou_csv:
|
||||
lignes = lire_prospects()
|
||||
if not 0 <= idx < len(lignes):
|
||||
return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404
|
||||
for col in COLONNES:
|
||||
if col in donnees:
|
||||
lignes[idx][col] = str(donnees[col]).strip()
|
||||
ecrire_prospects(lignes)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.delete("/api/prospects/<int:idx>")
|
||||
def api_supprimer(idx):
|
||||
with verrou_csv:
|
||||
lignes = lire_prospects()
|
||||
if not 0 <= idx < len(lignes):
|
||||
return jsonify({"error": "introuvable", "message": "Ligne inexistante."}), 404
|
||||
lignes.pop(idx)
|
||||
ecrire_prospects(lignes)
|
||||
return jsonify({"ok": True})
|
||||
|
||||
|
||||
@app.get("/api/export")
|
||||
def api_export():
|
||||
lire_prospects() # crée le fichier si absent
|
||||
return send_file(CSV_PATH, as_attachment=True, download_name="prospects.csv")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
lire_prospects()
|
||||
threading.Timer(1.0, webbrowser.open, args=["http://127.0.0.1:5000"]).start()
|
||||
app.run(host="127.0.0.1", port=5000)
|
||||
57
departements.py
Normal file
57
departements.py
Normal file
@@ -0,0 +1,57 @@
|
||||
"""Table des départements français et résolution depuis un code postal."""
|
||||
|
||||
DEPARTEMENTS = {
|
||||
"01": "Ain", "02": "Aisne", "03": "Allier", "04": "Alpes-de-Haute-Provence",
|
||||
"05": "Hautes-Alpes", "06": "Alpes-Maritimes", "07": "Ardèche", "08": "Ardennes",
|
||||
"09": "Ariège", "10": "Aube", "11": "Aude", "12": "Aveyron",
|
||||
"13": "Bouches-du-Rhône", "14": "Calvados", "15": "Cantal", "16": "Charente",
|
||||
"17": "Charente-Maritime", "18": "Cher", "19": "Corrèze",
|
||||
"2A": "Corse-du-Sud", "2B": "Haute-Corse",
|
||||
"21": "Côte-d'Or", "22": "Côtes-d'Armor", "23": "Creuse", "24": "Dordogne",
|
||||
"25": "Doubs", "26": "Drôme", "27": "Eure", "28": "Eure-et-Loir",
|
||||
"29": "Finistère", "30": "Gard", "31": "Haute-Garonne", "32": "Gers",
|
||||
"33": "Gironde", "34": "Hérault", "35": "Ille-et-Vilaine", "36": "Indre",
|
||||
"37": "Indre-et-Loire", "38": "Isère", "39": "Jura", "40": "Landes",
|
||||
"41": "Loir-et-Cher", "42": "Loire", "43": "Haute-Loire", "44": "Loire-Atlantique",
|
||||
"45": "Loiret", "46": "Lot", "47": "Lot-et-Garonne", "48": "Lozère",
|
||||
"49": "Maine-et-Loire", "50": "Manche", "51": "Marne", "52": "Haute-Marne",
|
||||
"53": "Mayenne", "54": "Meurthe-et-Moselle", "55": "Meuse", "56": "Morbihan",
|
||||
"57": "Moselle", "58": "Nièvre", "59": "Nord", "60": "Oise",
|
||||
"61": "Orne", "62": "Pas-de-Calais", "63": "Puy-de-Dôme",
|
||||
"64": "Pyrénées-Atlantiques", "65": "Hautes-Pyrénées", "66": "Pyrénées-Orientales",
|
||||
"67": "Bas-Rhin", "68": "Haut-Rhin", "69": "Rhône", "70": "Haute-Saône",
|
||||
"71": "Saône-et-Loire", "72": "Sarthe", "73": "Savoie", "74": "Haute-Savoie",
|
||||
"75": "Paris", "76": "Seine-Maritime", "77": "Seine-et-Marne", "78": "Yvelines",
|
||||
"79": "Deux-Sèvres", "80": "Somme", "81": "Tarn", "82": "Tarn-et-Garonne",
|
||||
"83": "Var", "84": "Vaucluse", "85": "Vendée", "86": "Vienne",
|
||||
"87": "Haute-Vienne", "88": "Vosges", "89": "Yonne", "90": "Territoire de Belfort",
|
||||
"91": "Essonne", "92": "Hauts-de-Seine", "93": "Seine-Saint-Denis",
|
||||
"94": "Val-de-Marne", "95": "Val-d'Oise",
|
||||
"971": "Guadeloupe", "972": "Martinique", "973": "Guyane",
|
||||
"974": "La Réunion", "976": "Mayotte",
|
||||
}
|
||||
|
||||
|
||||
def departement_depuis_cp(cp):
|
||||
"""Retourne « NN - Nom » depuis un code postal à 5 chiffres, ou ""."""
|
||||
cp = (cp or "").strip()
|
||||
if len(cp) != 5 or not cp.isdigit():
|
||||
return ""
|
||||
if cp.startswith("97"):
|
||||
cle = cp[:3]
|
||||
elif cp.startswith("20"):
|
||||
# Corse : 20000-20199 → Corse-du-Sud, 20200-20699 → Haute-Corse (approximation usuelle)
|
||||
cle = "2A" if int(cp) < 20200 else "2B"
|
||||
else:
|
||||
cle = cp[:2]
|
||||
nom = DEPARTEMENTS.get(cle)
|
||||
return f"{cle} - {nom}" if nom else ""
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
for cp, attendu in [("29200", "29 - Finistère"), ("20090", "2A - Corse-du-Sud"),
|
||||
("20600", "2B - Haute-Corse"), ("97400", "974 - La Réunion"),
|
||||
("75011", "75 - Paris"), ("00000", ""), ("abc", "")]:
|
||||
resultat = departement_depuis_cp(cp)
|
||||
statut = "OK" if resultat == attendu else f"ECHEC (attendu {attendu!r})"
|
||||
print(f"{cp} -> {resultat!r} {statut}")
|
||||
198
extractor.py
Normal file
198
extractor.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""Extraction par règles : texte brut d'une page Facebook → champs du prospect."""
|
||||
|
||||
import re
|
||||
import unicodedata
|
||||
from datetime import date
|
||||
|
||||
from departements import DEPARTEMENTS, departement_depuis_cp
|
||||
|
||||
RE_TELEPHONE = re.compile(r"(?:\+33\s?[1-9]|0[1-9])(?:[\s.\-]?\d{2}){4}")
|
||||
RE_EMAIL = re.compile(r"[A-Za-z0-9._%+\-]+@[A-Za-z0-9.\-]+\.[A-Za-z]{2,}")
|
||||
RE_CP_VILLE = re.compile(r"(?<!\d)(\d{5})(?!\d)[ \t,]+([A-Za-zÀ-ÖØ-öø-ÿ'\- ]{2,40})")
|
||||
RE_CP_SEUL = re.compile(r"(?<!\d)(\d{5})(?!\d)")
|
||||
# « à Saint-Omer-de-Blain (44) » : ville + numéro de département entre parenthèses
|
||||
RE_VILLE_DEPT = re.compile(r"([A-Za-zÀ-ÖØ-öø-ÿ'’\-]{3,40})?\s*\(\s*(\d{2,3}|2[AB])\s*\)", re.I)
|
||||
# « Blain, France, 44130 » ou « Blain, 44130 » : ville avant le code postal
|
||||
RE_VILLE_CP = re.compile(
|
||||
r"([A-Za-zÀ-ÖØ-öø-ÿ'’\- ]{2,40})\s*,\s*(?:France\s*,?\s*)?(\d{5})(?!\d)", re.I)
|
||||
|
||||
VOIES = (r"rue|avenue|av\.|boulevard|bd\.?|chemin|route|impasse|place|quai|allée|cours"
|
||||
r"|esplanade|promenade|lieu-dit|hameau|zone|za|zac|zi")
|
||||
# Ligne ressemblant à une voie : « 12 Quai du Port », « 6, rue du moulin »...
|
||||
RE_ADRESSE = re.compile(
|
||||
rf"(?:^|\n)\s*((?:\d{{1,4}}\s?(?:bis|ter)?\s*,?\s+)?(?:{VOIES})\s[^\n;]{{2,60}})", re.I)
|
||||
|
||||
# Mots d'interface Facebook à ne pas prendre pour une ville devant « (NN) »
|
||||
MOTS_INTERFACE = {
|
||||
"photos", "avis", "reels", "followers", "publications", "posts",
|
||||
"mentions", "tout", "plus", "voir", "vidéos", "amis",
|
||||
}
|
||||
|
||||
# (mot-clé, type) — ordre = priorité, le premier trouvé gagne
|
||||
TYPES = [
|
||||
("camping", "Camping"),
|
||||
("guinguette", "Guinguette"),
|
||||
("festival", "Festival"),
|
||||
("restaurant", "Restaurant"),
|
||||
("pizzeria", "Restaurant"),
|
||||
("creperie", "Restaurant"),
|
||||
("brasserie", "Restaurant"),
|
||||
("bistrot", "Restaurant"),
|
||||
("bistro", "Restaurant"),
|
||||
("pub", "Bar"),
|
||||
("bar", "Bar"),
|
||||
("cafe", "Bar"),
|
||||
("cave", "Bar"),
|
||||
("salle", "Salle"),
|
||||
("mjc", "Salle"),
|
||||
("hotel", "Hôtel"),
|
||||
("domaine", "Domaine"),
|
||||
]
|
||||
|
||||
INFOS_LIEU = [
|
||||
"jardin", "terrasse", "plage", "bord de mer", "port", "piscine",
|
||||
"lac", "riviere", "rooftop", "patio", "plein air", "scene", "guinguette",
|
||||
]
|
||||
|
||||
|
||||
def _sans_accents(texte):
|
||||
return unicodedata.normalize("NFKD", texte).encode("ascii", "ignore").decode()
|
||||
|
||||
|
||||
def _chercher_mot(mot, texte_normalise):
|
||||
return re.search(rf"(?<![a-z]){re.escape(mot)}(?![a-z])", texte_normalise) is not None
|
||||
|
||||
|
||||
def _telephone(texte):
|
||||
m = RE_TELEPHONE.search(texte)
|
||||
if not m:
|
||||
return ""
|
||||
chiffres = re.sub(r"\D", "", m.group(0))
|
||||
if chiffres.startswith("33"):
|
||||
chiffres = "0" + chiffres[2:]
|
||||
if len(chiffres) != 10:
|
||||
return ""
|
||||
return " ".join(chiffres[i:i + 2] for i in range(0, 10, 2))
|
||||
|
||||
|
||||
def _email(texte):
|
||||
for m in RE_EMAIL.finditer(texte):
|
||||
adresse = m.group(0)
|
||||
if not adresse.lower().endswith(("@facebook.com", "@fb.com")):
|
||||
return adresse
|
||||
return ""
|
||||
|
||||
|
||||
def _cp_ville(texte):
|
||||
for m in RE_CP_VILLE.finditer(texte):
|
||||
cp, ville = m.group(1), m.group(2)
|
||||
if not ("01" <= cp[:2] <= "98"):
|
||||
continue
|
||||
ville = ville.split("\n")[0].strip(" -'")
|
||||
ville = re.sub(r"\bfrance\b", "", ville, flags=re.IGNORECASE).strip(" ,-")
|
||||
return cp, ville.title()
|
||||
# « Blain, France, 44130 » : ville avant le code postal
|
||||
for m in RE_VILLE_CP.finditer(texte):
|
||||
cp, ville = m.group(2), m.group(1).split("\n")[-1].strip(" -'’")
|
||||
if not ("01" <= cp[:2] <= "98") or ville.lower() == "france":
|
||||
continue
|
||||
return cp, ville.title() if ville.islower() else ville
|
||||
m = RE_CP_SEUL.search(texte)
|
||||
if m and "01" <= m.group(1)[:2] <= "98":
|
||||
return m.group(1), ""
|
||||
return "", ""
|
||||
|
||||
|
||||
def _nettoyer_adresse(brut):
|
||||
# couper avant le code postal s'il suit sur la même ligne
|
||||
brut = re.split(r",?\s*\d{5}\b", brut)[0]
|
||||
# « 6, rue du moulin , Blain, France » : ne garder que jusqu'au segment de voie,
|
||||
# la ville et le pays ont leurs propres colonnes
|
||||
segments = [s.strip() for s in brut.split(",") if s.strip()]
|
||||
dernier_voie = -1
|
||||
for i, seg in enumerate(segments):
|
||||
if re.search(rf"\b(?:{VOIES})\b", seg, re.I):
|
||||
dernier_voie = i
|
||||
if dernier_voie >= 0:
|
||||
segments = segments[:dernier_voie + 1]
|
||||
else:
|
||||
segments = [s for s in segments if s.lower() != "france"]
|
||||
return ", ".join(segments).strip(" ,-–")
|
||||
|
||||
|
||||
def _adresse(texte):
|
||||
lignes = [l.strip() for l in texte.split("\n")]
|
||||
# libellé « Adresse » de la section Coordonnées (mais pas « Adresse e-mail »)
|
||||
for i, ligne in enumerate(lignes):
|
||||
if ligne.lower() in ("adresse", "address"):
|
||||
for suite in lignes[i + 1:i + 3]:
|
||||
if suite and not re.match(r"^(adresse|address|e-?mail|téléphone|phone|site|web|messenger)", suite, re.I):
|
||||
return _nettoyer_adresse(suite)
|
||||
m = RE_ADRESSE.search(texte)
|
||||
return _nettoyer_adresse(m.group(1)) if m else ""
|
||||
|
||||
|
||||
def _ville_dept_parentheses(texte):
|
||||
"""Repli quand il n'y a pas d'adresse postale : « Saint-Omer-de-Blain (44) »."""
|
||||
for m in RE_VILLE_DEPT.finditer(texte):
|
||||
cle = m.group(2).upper()
|
||||
nom = DEPARTEMENTS.get(cle)
|
||||
if not nom:
|
||||
continue
|
||||
ville = (m.group(1) or "").strip("-'’ ")
|
||||
if len(ville) < 3 or ville.lower() in MOTS_INTERFACE or ville.lower() == "france":
|
||||
ville = ""
|
||||
return ville, f"{cle} - {nom}"
|
||||
return "", ""
|
||||
|
||||
|
||||
def _type_lieu(titre, texte):
|
||||
# La catégorie de la page apparaît en général dans les premiers caractères du texte
|
||||
for zone in (titre, texte[:500], texte):
|
||||
zone_norm = _sans_accents(zone.lower())
|
||||
for mot, type_lieu in TYPES:
|
||||
if _chercher_mot(mot, zone_norm):
|
||||
return type_lieu
|
||||
return ""
|
||||
|
||||
|
||||
def _infos_lieu(texte):
|
||||
texte_norm = _sans_accents(texte.lower())
|
||||
trouves = [mot for mot in INFOS_LIEU if _chercher_mot(mot, texte_norm)]
|
||||
return ", ".join(trouves[:5])
|
||||
|
||||
|
||||
def extraire(titre, texte, url):
|
||||
cp, ville = _cp_ville(texte)
|
||||
departement = departement_depuis_cp(cp)
|
||||
if not cp:
|
||||
ville_repli, departement = _ville_dept_parentheses(texte)
|
||||
ville = ville or ville_repli
|
||||
return {
|
||||
"Nom du prospect": titre.strip(),
|
||||
"Département": departement,
|
||||
"Ville": ville,
|
||||
"Code Postal": cp,
|
||||
"Adresse": _adresse(texte),
|
||||
"Date d'ajout": date.today().strftime("%d/%m/%Y"),
|
||||
"Date de contact": "",
|
||||
"Nom de contact": "",
|
||||
"Téléphone": _telephone(texte),
|
||||
"Email": _email(texte),
|
||||
"Infos du lieu": _infos_lieu(texte),
|
||||
"Type": _type_lieu(titre, texte),
|
||||
"Lien Facebook": url,
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
exemple = """Le Vieux Gréement
|
||||
Bar · Restaurant
|
||||
12 Quai du Port, 29200 Brest, France
|
||||
06 12 34 56 78
|
||||
contact@vieuxgreement.fr
|
||||
Grande terrasse avec vue sur le port, concerts en plein air l'été dans le jardin.
|
||||
"""
|
||||
import json
|
||||
champs = extraire("Le Vieux Gréement", exemple, "https://www.facebook.com/vieuxgreement")
|
||||
print(json.dumps(champs, ensure_ascii=False, indent=2))
|
||||
2
requirements.txt
Normal file
2
requirements.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
flask>=3.0
|
||||
playwright>=1.45
|
||||
20
run.sh
Executable file
20
run.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
set -e
|
||||
cd "$(dirname "$0")"
|
||||
|
||||
# Python Homebrew en priorité (le python système 3.9 est trop vieux pour Playwright récent)
|
||||
if [ -x /opt/homebrew/bin/python3 ]; then
|
||||
PY=/opt/homebrew/bin/python3
|
||||
else
|
||||
PY=$(command -v python3)
|
||||
fi
|
||||
|
||||
if [ ! -d .venv ]; then
|
||||
echo "Première installation (environnement Python + Chromium), patientez quelques minutes..."
|
||||
"$PY" -m venv .venv
|
||||
.venv/bin/pip install --upgrade pip
|
||||
.venv/bin/pip install -r requirements.txt
|
||||
.venv/bin/playwright install chromium
|
||||
fi
|
||||
|
||||
exec .venv/bin/python app.py
|
||||
255
scraper.py
Normal file
255
scraper.py
Normal file
@@ -0,0 +1,255 @@
|
||||
"""Scraping des pages « À propos » Facebook avec une session Chromium persistante."""
|
||||
|
||||
import re
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse, parse_qs
|
||||
|
||||
from playwright.sync_api import sync_playwright
|
||||
|
||||
PROFIL = str(Path(__file__).parent / "fb_profile")
|
||||
ATTENTE_LOGIN_S = 300
|
||||
|
||||
|
||||
class ErreurScrape(Exception):
|
||||
def __init__(self, code, message):
|
||||
super().__init__(message)
|
||||
self.code = code
|
||||
|
||||
|
||||
# Liens de partage/contenu qui ne désignent pas directement une page : à résoudre en les visitant
|
||||
SLUGS_A_RESOUDRE = {"share", "reel", "reels", "story.php", "photo", "photo.php", "video.php", "watch", "posts", "l.php"}
|
||||
|
||||
|
||||
def _urls_pour_id(page_id):
|
||||
# marqueur = les chiffres seuls : Facebook réécrit souvent profile.php?id=N
|
||||
# en /people/Nom-De-Page/N/, où « id= » disparaît mais pas le numéro
|
||||
canonique = f"https://www.facebook.com/profile.php?id={page_id}"
|
||||
return {"canonique": canonique, "about": f"{canonique}&sk=about", "marqueur": str(page_id)}
|
||||
|
||||
|
||||
def normaliser_url(url):
|
||||
"""Retourne {canonique, abouts, marqueur} ou {resoudre: url} ; lève ErreurScrape sinon.
|
||||
|
||||
« marqueur » est un fragment qui doit rester présent dans l'URL après navigation :
|
||||
il sert à détecter les redirections de Facebook (vers le fil, votre profil...).
|
||||
"""
|
||||
url = url.strip()
|
||||
if not url.startswith("http"):
|
||||
url = "https://" + url
|
||||
p = urlparse(url)
|
||||
if not re.search(r"(?:^|\.)(facebook|fb)\.com$", p.netloc.lower().split(":")[0]):
|
||||
raise ErreurScrape("url_invalide", "Ce n'est pas un lien Facebook.")
|
||||
|
||||
segments = [s for s in p.path.split("/") if s]
|
||||
|
||||
if segments and segments[0] == "profile.php" or p.path.rstrip("/").endswith("profile.php"):
|
||||
page_id = parse_qs(p.query).get("id", [""])[0]
|
||||
if not page_id:
|
||||
raise ErreurScrape("url_invalide", "Lien profile.php sans identifiant.")
|
||||
return _urls_pour_id(page_id)
|
||||
|
||||
if not segments:
|
||||
raise ErreurScrape("url_invalide", "Lien Facebook sans nom de page.")
|
||||
|
||||
premier = segments[0].lower()
|
||||
|
||||
if premier in SLUGS_A_RESOUDRE:
|
||||
return {"resoudre": url}
|
||||
|
||||
# facebook.com/p/Nom-De-Page-100063123456789 (nouvelles pages)
|
||||
if premier == "p" and len(segments) >= 2:
|
||||
m = re.search(r"(\d{5,})/?$", segments[1])
|
||||
if m:
|
||||
return _urls_pour_id(m.group(1))
|
||||
raise ErreurScrape("url_invalide", "Lien /p/ sans identifiant numérique reconnaissable.")
|
||||
|
||||
# facebook.com/people/Nom/123456789 et facebook.com/pages/Nom/123456789 (anciens formats)
|
||||
if premier in ("people", "pages"):
|
||||
for seg in reversed(segments):
|
||||
if re.fullmatch(r"\d{5,}", seg):
|
||||
return _urls_pour_id(seg)
|
||||
raise ErreurScrape("url_invalide", f"Lien /{premier}/ sans identifiant numérique.")
|
||||
|
||||
slug = segments[0]
|
||||
canonique = f"https://www.facebook.com/{slug}"
|
||||
return {"canonique": canonique, "about": f"{canonique}/about", "marqueur": slug.lower()}
|
||||
|
||||
|
||||
def _sur_la_bonne_page(page, marqueur):
|
||||
return marqueur in page.url.lower()
|
||||
|
||||
|
||||
def _refuser_cookies(page):
|
||||
try:
|
||||
bouton = page.get_by_role(
|
||||
"button",
|
||||
name=re.compile(r"refuser les cookies optionnels|decline optional cookies|autoriser tous les cookies", re.I),
|
||||
).first
|
||||
bouton.click(timeout=3000)
|
||||
page.wait_for_timeout(1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _mur_de_connexion(page):
|
||||
if "/login" in page.url:
|
||||
return True
|
||||
try:
|
||||
corps = page.inner_text("body", timeout=5000)
|
||||
except Exception:
|
||||
return False
|
||||
return bool(re.search(r"connectez-vous pour continuer|log into facebook|vous devez vous connecter", corps, re.I))
|
||||
|
||||
|
||||
def _est_connecte(contexte):
|
||||
"""Le cookie de session « c_user » n'existe que si un compte est connecté."""
|
||||
try:
|
||||
return any(c["name"] == "c_user" for c in contexte.cookies("https://www.facebook.com"))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
def connexion():
|
||||
"""Ouvre Facebook dans la fenêtre persistante et attend que l'utilisateur se connecte.
|
||||
|
||||
Aucune autre action ne tourne pendant ce temps : l'utilisateur a jusqu'à
|
||||
ATTENTE_LOGIN_S secondes pour se connecter tranquillement (captcha, 2FA...).
|
||||
Retourne True si la session est établie.
|
||||
"""
|
||||
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 _est_connecte(contexte):
|
||||
return True
|
||||
page = contexte.pages[0] if contexte.pages else contexte.new_page()
|
||||
page.goto("https://www.facebook.com/login", wait_until="domcontentloaded", timeout=30000)
|
||||
page.wait_for_timeout(2000)
|
||||
_refuser_cookies(page)
|
||||
debut = time.time()
|
||||
while time.time() - debut < ATTENTE_LOGIN_S:
|
||||
if _est_connecte(contexte):
|
||||
page.wait_for_timeout(2000) # laisser FB finir d'écrire la session
|
||||
return True
|
||||
page.wait_for_timeout(2000)
|
||||
return False
|
||||
finally:
|
||||
contexte.close()
|
||||
|
||||
|
||||
def _texte_page(page):
|
||||
try:
|
||||
return page.locator('div[role="main"]').first.inner_text(timeout=5000)
|
||||
except Exception:
|
||||
try:
|
||||
return page.inner_text("body", timeout=5000)
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def scrape(url):
|
||||
"""Visite les pages « À propos » et retourne {"titre", "texte", "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
|
||||
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"]}
|
||||
finally:
|
||||
contexte.close()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
if len(sys.argv) != 2:
|
||||
print("Usage : python scraper.py <url-facebook>")
|
||||
sys.exit(1)
|
||||
resultat = scrape(sys.argv[1])
|
||||
print(f"=== TITRE ===\n{resultat['titre']}\n=== TEXTE ===\n{resultat['texte']}")
|
||||
300
static/index.html
Normal file
300
static/index.html
Normal file
@@ -0,0 +1,300 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="fr">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>AutoMood — Prospection</title>
|
||||
<style>
|
||||
:root { --accent: #5b4dbf; --fond: #f5f4fa; --bordure: #ddd; }
|
||||
* { box-sizing: border-box; }
|
||||
body { font-family: -apple-system, "Segoe UI", sans-serif; margin: 0; background: var(--fond); color: #222; }
|
||||
header { background: var(--accent); color: #fff; padding: 14px 24px; }
|
||||
header h1 { margin: 0; font-size: 20px; }
|
||||
main { max-width: 1200px; margin: 24px auto; padding: 0 16px; }
|
||||
section { background: #fff; border-radius: 10px; padding: 20px; margin-bottom: 24px; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
||||
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; }
|
||||
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; }
|
||||
#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; }
|
||||
.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; }
|
||||
.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; }
|
||||
.vide { color: #999; font-style: italic; }
|
||||
.barre-liste { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
|
||||
.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; }
|
||||
@keyframes tourne { to { transform: rotate(360deg); } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<header><h1>🎸 AutoMood — Prospection de lieux de concert</h1></header>
|
||||
<main>
|
||||
|
||||
<section>
|
||||
<h2>Nouveau prospect</h2>
|
||||
<div class="ligne-url">
|
||||
<input id="url" type="url" placeholder="https://www.facebook.com/nom-du-lieu" autofocus>
|
||||
<button id="analyser">Analyser</button>
|
||||
<button id="connexion" class="secondaire" title="À faire une seule fois : la session est mémorisée">🔑 Connexion Facebook</button>
|
||||
</div>
|
||||
<div id="message"></div>
|
||||
<form id="formulaire">
|
||||
<div class="grille" id="grille-champs"></div>
|
||||
<div class="actions">
|
||||
<button type="submit">Ajouter au fichier</button>
|
||||
<button type="button" class="secondaire" id="annuler">Annuler</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<div class="barre-liste">
|
||||
<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>
|
||||
<input id="recherche" type="search" placeholder="Filtrer par nom, ville, type, département…" style="width:100%;margin-bottom:12px">
|
||||
<div id="liste"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
<script>
|
||||
const COLONNES = ["Nom du prospect","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"];
|
||||
|
||||
const $ = id => document.getElementById(id);
|
||||
|
||||
function message(texte, classe) {
|
||||
const m = $("message");
|
||||
m.textContent = texte;
|
||||
m.className = classe || "";
|
||||
}
|
||||
|
||||
// --- Formulaire de nouveau prospect ---
|
||||
|
||||
function construireFormulaire(valeurs) {
|
||||
const grille = $("grille-champs");
|
||||
grille.innerHTML = "";
|
||||
for (const col of COLONNES) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "champ";
|
||||
const label = document.createElement("label");
|
||||
label.textContent = col;
|
||||
const input = document.createElement("input");
|
||||
input.name = col;
|
||||
input.value = valeurs[col] || "";
|
||||
if (col === "Date de contact") input.placeholder = "à remplir plus tard";
|
||||
div.append(label, input);
|
||||
grille.append(div);
|
||||
}
|
||||
$("formulaire").style.display = "block";
|
||||
}
|
||||
|
||||
$("connexion").addEventListener("click", async () => {
|
||||
const bouton = $("connexion");
|
||||
bouton.disabled = true;
|
||||
$("analyser").disabled = true;
|
||||
bouton.innerHTML = '<span class="spinner"></span>En attente de votre connexion…';
|
||||
message("Une fenêtre Chromium s'ouvre sur la page de connexion Facebook. Connectez-vous tranquillement (vous avez 5 minutes), la fenêtre se fermera toute seule.", "info");
|
||||
try {
|
||||
const rep = await fetch("/api/login", { method: "POST" });
|
||||
const donnees = await rep.json();
|
||||
message(rep.ok ? "Connexion Facebook enregistrée ✔ Vous pouvez analyser des pages." : (donnees.message || "Échec de la connexion."), rep.ok ? "info" : "erreur");
|
||||
} catch {
|
||||
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur");
|
||||
} finally {
|
||||
bouton.disabled = false;
|
||||
$("analyser").disabled = false;
|
||||
bouton.textContent = "🔑 Connexion Facebook";
|
||||
}
|
||||
});
|
||||
|
||||
$("analyser").addEventListener("click", async () => {
|
||||
const url = $("url").value.trim();
|
||||
if (!url) { message("Collez d'abord un lien Facebook.", "erreur"); return; }
|
||||
const bouton = $("analyser");
|
||||
bouton.disabled = true;
|
||||
bouton.innerHTML = '<span class="spinner"></span>Analyse…';
|
||||
message("Analyse en cours, une fenêtre Chromium s'ouvre quelques secondes…", "info");
|
||||
try {
|
||||
const rep = await fetch("/api/scrape", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ url }),
|
||||
});
|
||||
const donnees = await rep.json();
|
||||
if (!rep.ok) { message(donnees.message || "Erreur lors de l'analyse.", "erreur"); return; }
|
||||
message("Champs pré-remplis : vérifiez et corrigez avant d'ajouter.", "info");
|
||||
construireFormulaire(donnees);
|
||||
} catch {
|
||||
message("Le serveur ne répond pas. Relancez ./run.sh.", "erreur");
|
||||
} finally {
|
||||
bouton.disabled = false;
|
||||
bouton.textContent = "Analyser";
|
||||
}
|
||||
});
|
||||
|
||||
$("formulaire").addEventListener("submit", async (e) => {
|
||||
e.preventDefault();
|
||||
const ligne = {};
|
||||
for (const input of $("grille-champs").querySelectorAll("input")) ligne[input.name] = input.value;
|
||||
const rep = await fetch("/api/prospects", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(ligne),
|
||||
});
|
||||
if (rep.ok) {
|
||||
$("formulaire").style.display = "none";
|
||||
$("url").value = "";
|
||||
message("Prospect ajouté ✔", "info");
|
||||
chargerListe();
|
||||
} else {
|
||||
message("Échec de l'ajout.", "erreur");
|
||||
}
|
||||
});
|
||||
|
||||
$("annuler").addEventListener("click", () => {
|
||||
$("formulaire").style.display = "none";
|
||||
message("", "");
|
||||
});
|
||||
|
||||
// --- Liste des prospects en fiches ---
|
||||
|
||||
let prospects = [];
|
||||
|
||||
const echap = (t) => (t || "").replace(/[&<>"]/g, c => ({ "&": "&", "<": "<", ">": ">", '"': """ }[c]));
|
||||
|
||||
async function chargerListe() {
|
||||
prospects = await (await fetch("/api/prospects")).json();
|
||||
afficherListe();
|
||||
}
|
||||
|
||||
function afficherListe() {
|
||||
const filtre = $("recherche").value.trim().toLowerCase();
|
||||
const visibles = prospects.filter(p =>
|
||||
!filtre || COLONNES.some(c => (p[c] || "").toLowerCase().includes(filtre)));
|
||||
$("compteur").textContent = filtre ? `(${visibles.length}/${prospects.length})` : `(${prospects.length})`;
|
||||
const liste = $("liste");
|
||||
liste.innerHTML = "";
|
||||
if (!visibles.length) {
|
||||
liste.innerHTML = `<p class="vide">${prospects.length ? "Aucun prospect ne correspond au filtre." : "Aucun prospect pour le moment."}</p>`;
|
||||
return;
|
||||
}
|
||||
for (const p of visibles) liste.append(carte(p));
|
||||
}
|
||||
|
||||
function carte(p) {
|
||||
const div = document.createElement("div");
|
||||
div.className = "carte";
|
||||
|
||||
const tete = document.createElement("div");
|
||||
tete.className = "carte-tete";
|
||||
const lieu = [p["Ville"], p["Département"]].filter(Boolean).join(" · ");
|
||||
const contact = p["Date de contact"]
|
||||
? `✅ Contacté le ${echap(p["Date de contact"])}${p["Nom de contact"] ? ` (${echap(p["Nom de contact"])})` : ""}`
|
||||
: "⏳ À contacter";
|
||||
tete.innerHTML = `
|
||||
<div class="carte-titre">
|
||||
<strong>${echap(p["Nom du prospect"]) || "<i>Sans nom</i>"}</strong>
|
||||
${p["Type"] ? `<span class="badge">${echap(p["Type"])}</span>` : ""}
|
||||
</div>
|
||||
<div class="carte-infos">
|
||||
${lieu ? `<span class="sous">📍 ${echap(lieu)}</span>` : ""}
|
||||
${p["Téléphone"] ? `<span>📞 ${echap(p["Téléphone"])}</span>` : ""}
|
||||
${p["Email"] ? `<span>✉️ ${echap(p["Email"])}</span>` : ""}
|
||||
<span class="sous">${contact}</span>
|
||||
</div>`;
|
||||
|
||||
const boutons = document.createElement("div");
|
||||
boutons.className = "carte-boutons";
|
||||
if (p["Lien Facebook"]) {
|
||||
const fb = document.createElement("button");
|
||||
fb.className = "secondaire";
|
||||
fb.textContent = "Facebook ↗";
|
||||
fb.addEventListener("click", (e) => { e.stopPropagation(); window.open(p["Lien Facebook"], "_blank"); });
|
||||
boutons.append(fb);
|
||||
}
|
||||
const suppr = document.createElement("button");
|
||||
suppr.className = "danger";
|
||||
suppr.textContent = "✕";
|
||||
suppr.title = "Supprimer";
|
||||
suppr.addEventListener("click", async (e) => {
|
||||
e.stopPropagation();
|
||||
if (!confirm(`Supprimer « ${p["Nom du prospect"] || "cette ligne"} » ?`)) return;
|
||||
await fetch(`/api/prospects/${p.index}`, { method: "DELETE" });
|
||||
chargerListe();
|
||||
});
|
||||
boutons.append(suppr);
|
||||
tete.append(boutons);
|
||||
|
||||
const detail = document.createElement("div");
|
||||
detail.className = "carte-detail";
|
||||
detail.style.display = "none";
|
||||
|
||||
tete.addEventListener("click", () => {
|
||||
const ouvert = detail.style.display !== "none";
|
||||
if (!ouvert && !detail.hasChildNodes()) construireDetail(detail, p);
|
||||
detail.style.display = ouvert ? "none" : "block";
|
||||
});
|
||||
|
||||
div.append(tete, detail);
|
||||
return div;
|
||||
}
|
||||
|
||||
function construireDetail(detail, p) {
|
||||
const grille = document.createElement("div");
|
||||
grille.className = "grille";
|
||||
for (const col of COLONNES) {
|
||||
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");
|
||||
actions.className = "actions";
|
||||
const enregistrer = document.createElement("button");
|
||||
enregistrer.textContent = "Enregistrer";
|
||||
enregistrer.addEventListener("click", async () => {
|
||||
const donnees = {};
|
||||
for (const input of grille.querySelectorAll("input")) donnees[input.name] = input.value;
|
||||
const rep = await fetch(`/api/prospects/${p.index}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(donnees),
|
||||
});
|
||||
if (rep.ok) chargerListe();
|
||||
});
|
||||
actions.append(enregistrer);
|
||||
detail.append(grille, actions);
|
||||
}
|
||||
|
||||
$("recherche").addEventListener("input", afficherListe);
|
||||
|
||||
chargerListe();
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user