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