Initial commit

This commit is contained in:
jerem
2026-06-13 13:32:38 +02:00
commit 528d994ea8
9 changed files with 1003 additions and 0 deletions

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
.venv/
fb_profile/
prospects.csv
__pycache__/

26
README.md Normal file
View 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
View 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
View 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
View 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
View File

@@ -0,0 +1,2 @@
flask>=3.0
playwright>=1.45

20
run.sh Executable file
View 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
View 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
View 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 => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[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>